统一车间库存归属策略

This commit is contained in:
caoqianming 2026-07-28 09:26:25 +08:00
parent dde5b0a6e8
commit 1700a58409
9 changed files with 270 additions and 55 deletions

View File

@ -5,7 +5,7 @@ from apps.mtm.models import Material, Shift, Mgroup, Process
@admin.register(Process)
class ProcessAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'cate', 'sort', 'into_wm_mgroup', 'store_notok', 'batch_append_equip', 'mlog_need_ticket')
list_display = ('id', 'name', 'cate', 'sort', 'into_wm_scope', 'store_notok', 'batch_append_equip', 'mlog_need_ticket')
@admin.register(Material)

View File

@ -0,0 +1,36 @@
from django.db import migrations, models
def migrate_scope_forward(apps, schema_editor):
Process = apps.get_model('mtm', 'Process')
Process.objects.filter(into_wm_mgroup=True).update(into_wm_scope=10)
def migrate_scope_backward(apps, schema_editor):
Process = apps.get_model('mtm', 'Process')
Process.objects.filter(into_wm_scope=10).update(into_wm_mgroup=True)
Process.objects.exclude(into_wm_scope=10).update(into_wm_mgroup=False)
class Migration(migrations.Migration):
dependencies = [
('mtm', '0067_material_test_mode_in'),
]
operations = [
migrations.AddField(
model_name='process',
name='into_wm_scope',
field=models.PositiveSmallIntegerField(
choices=[(10, '工段'), (20, '部门'), (30, '全局')],
default=20,
verbose_name='产出库存归属',
),
),
migrations.RunPython(migrate_scope_forward, migrate_scope_backward),
migrations.RemoveField(
model_name='process',
name='into_wm_mgroup',
),
]

View File

@ -9,6 +9,28 @@ from django.utils import timezone
from django.db.models import Sum, Q, Value, F, ExpressionWrapper, DecimalField
from django.db.models.functions import Coalesce
class WmScope(models.IntegerChoices):
MGROUP = 10, '工段'
DEPT = 20, '部门'
GLOBAL = 30, '全局'
@classmethod
def resolve_location(cls, scope, mgroup):
"""将库存归属策略统一解析为 WMaterial 的位置字段。"""
scope = cls(scope)
if scope == cls.GLOBAL:
return {'mgroup': None, 'belong_dept': None}
if mgroup is None:
raise ParseError('工段或部门库存必须提供工段')
if scope == cls.MGROUP:
return {'mgroup': mgroup, 'belong_dept': mgroup.belong_dept}
return {'mgroup': None, 'belong_dept': mgroup.belong_dept}
@classmethod
def requires_mgroup(cls, scope):
return cls(scope) == cls.MGROUP
class Process(CommonBModel):
"""
TN:工序
@ -29,7 +51,8 @@ class Process(CommonBModel):
instruction = models.ForeignKey(
File, verbose_name='指导书', on_delete=models.SET_NULL, null=True, blank=True)
instruction_content = models.TextField('指导书内容', null=True, blank=True)
into_wm_mgroup = models.BooleanField('交接到工段', default=False)
into_wm_scope = models.PositiveSmallIntegerField(
'产出库存归属', choices=WmScope.choices, default=WmScope.DEPT)
store_notok = models.BooleanField('不合格品是否入库', default=False)
batch_append_equip = models.BooleanField('批号追加设备', default=False)
mlog_need_ticket = models.BooleanField('日志提交是否需要审批', default=False)
@ -600,4 +623,4 @@ class Route(CommonADModel):
class RouteMat(BaseModel):
"""TN:工艺路线辅助物料"""
route = models.ForeignKey(Route, verbose_name='关联路线', on_delete=models.CASCADE, related_name="routemat_route")
material = models.ForeignKey(Material, verbose_name='辅助物料', on_delete=models.CASCADE)
material = models.ForeignKey(Material, verbose_name='辅助物料', on_delete=models.CASCADE)

View File

@ -123,7 +123,7 @@ class WMaterialFilter(filters.FilterSet):
"material__name": ["exact", "in", "contains"],
"material__process": ["exact", "in"],
"material__process__name": ["exact", "contains", "in"],
"belong_dept": ["exact"],
"belong_dept": ["exact", "isnull"],
"belong_dept__name": ["exact", "in"],
"batch": ["exact", "contains"],
"mgroup": ["exact", "in", "isnull"],
@ -296,4 +296,4 @@ class MlogbwFilter(filters.FilterSet):
elif value == 'out':
return queryset.filter(mlogb__material_out__isnull=False)
return queryset
return queryset.none()
return queryset.none()

View File

@ -0,0 +1,37 @@
from django.db import migrations, models
def migrate_scope_forward(apps, schema_editor):
Mlog = apps.get_model('wpm', 'Mlog')
Mlog.objects.filter(stored_mgroup=True).update(stored_scope=10)
def migrate_scope_backward(apps, schema_editor):
Mlog = apps.get_model('wpm', 'Mlog')
Mlog.objects.filter(stored_scope=10).update(stored_mgroup=True)
Mlog.objects.exclude(stored_scope=10).update(stored_mgroup=False)
class Migration(migrations.Migration):
dependencies = [
('mtm', '0068_process_into_wm_scope'),
('wpm', '0134_alter_mlog_clear_defect'),
]
operations = [
migrations.AddField(
model_name='mlog',
name='stored_scope',
field=models.PositiveSmallIntegerField(
choices=[(10, '工段'), (20, '部门'), (30, '全局')],
default=20,
verbose_name='产出库存归属',
),
),
migrations.RunPython(migrate_scope_forward, migrate_scope_backward),
migrations.RemoveField(
model_name='mlog',
name='stored_mgroup',
),
]

View File

@ -1,6 +1,6 @@
from django.db import models
from apps.utils.models import CommonADModel, CommonBDModel, BaseModel
from apps.mtm.models import Mgroup, Team, Shift, Material, Route, Process
from apps.mtm.models import Mgroup, Team, Shift, Material, Route, Process, WmScope
from apps.pm.models import Mtask, Mtaskb
from apps.system.models import User
from django.utils.timezone import localtime
@ -103,6 +103,9 @@ class WmStateOption(models.IntegerChoices):
class WMaterial(CommonBDModel):
"""
TN: 车间库存
归属范围分三级mgroup 指定工段只有 belong_dept 指定部门
两者都为空表示全局库存
"""
WM_OK = 10
WM_NOTOK = 20
@ -144,7 +147,11 @@ class WMaterial(CommonBDModel):
@property
def belong_dept_or_mgroup_id(self):
return self.mgroup.id if self.mgroup else self.belong_dept.id
if self.mgroup_id:
return ("mgroup", self.mgroup_id)
if self.belong_dept_id:
return ("belong_dept", self.belong_dept_id)
return ("global", None)
@property
def count_working(self):
@ -171,7 +178,6 @@ class WMaterial(CommonBDModel):
if qs is None:
qs = cls.objects
return WMaterial.ava_qs(mgroup=mtask.mgroup, qs=qs).filter(
mgroup=mtask.mgroup,
material=mtask.material_in,
batch__in=Subquery(
Mlogb.objects.filter(
@ -240,7 +246,8 @@ class Mlog(CommonADModel):
work_end_time = models.DateTimeField('生产结束时间', null=True, blank=True)
hour_work = models.FloatField('预计工时', null=True, blank=True)
reminder_interval_list = models.JSONField('提醒间隔', default=list, blank=True)
stored_mgroup = models.BooleanField('入库到工段', default=False)
stored_scope = models.PositiveSmallIntegerField(
'产出库存归属', choices=WmScope.choices, default=WmScope.DEPT)
stored_notok = models.BooleanField('不合格品是否已入库', default=False)
clear_defect = models.BooleanField('合格B类记为合格', default=False)
route = models.ForeignKey(Route, verbose_name='生产路线', on_delete=models.SET_NULL, null=True, blank=True)

View File

@ -12,7 +12,7 @@ from apps.system.serializers import UserSimpleSerializer
from apps.pm.models import Mtask, Mtaskb
from apps.wpm.tasks import cal_enstat_when_pcoal_heat_change, cal_enstat_when_team_change, cal_exp_duration_sec
from apps.wpm.services import get_sflog, find_material_can_change, generate_new_batch
from apps.mtm.models import Mgroup, TeamMember, Shift, Material, Route, Process
from apps.mtm.models import Mgroup, TeamMember, Shift, Material, Route, Process, WmScope
from apps.mtm.serializers import MaterialSimpleSerializer
from django.db import transaction
from django.utils import timezone
@ -232,16 +232,19 @@ class WMaterialSerializer(CustomModelSerializer):
class WMaterialCreateSerializer(CustomModelSerializer):
class Meta:
model = WMaterial
fields = ['material', 'count', 'batch', 'mgroup']
fields = ['material', 'count', 'batch', 'mgroup', 'belong_dept']
extra_kwargs = {
'material': {'required': True},
'count': {'required': True},
'batch': {'required': True},
'mgroup': {'required': True, 'allow_null': False},
'mgroup': {'required': False, 'allow_null': True},
'belong_dept': {'required': False, 'allow_null': True},
}
def validate(self, attrs):
attrs['belong_dept'] = attrs['mgroup'].belong_dept
mgroup = attrs.get('mgroup')
if mgroup:
attrs['belong_dept'] = mgroup.belong_dept
return attrs
@ -1300,7 +1303,12 @@ class HandoverSerializer(CustomModelSerializer):
attrs['send_dept'] = wm.belong_dept
if wm.mgroup:
attrs['send_mgroup'] = wm.mgroup
if attrs['material'].process and attrs['material'].process.into_wm_mgroup and 'recive_mgroup' not in attrs:
material_process = attrs['material'].process
if (
material_process
and WmScope.requires_mgroup(material_process.into_wm_scope)
and not attrs.get('recive_mgroup')
):
raise ParseError('必须指定交接工段')
if 'recive_mgroup' in attrs and attrs['recive_mgroup']:
attrs['recive_dept'] = attrs['recive_mgroup'].belong_dept

View File

@ -21,7 +21,7 @@ from apps.wpmw.models import Wpr, WprDefect
from ..qm.models import Defect, Ftest
from django.db.models import Count, Q
from apps.utils.tasks import ctask_run
from apps.mtm.models import Process
from apps.mtm.models import Process, WmScope
from django.db.models import F
myLogger = logging.getLogger('log')
@ -187,7 +187,7 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
mgroup = mlog.mgroup
process = mgroup.process
stored_mgroup = process.into_wm_mgroup
stored_scope = process.into_wm_scope
stored_notok = process.store_notok
belong_dept = mgroup.belong_dept
material_out: Material = mlog.material_out
@ -195,7 +195,8 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
supplier = mlog.supplier # 外协
is_fix = mlog.is_fix
if is_fix: # 如果是返工,直接放到工段下
stored_mgroup = True
stored_scope = WmScope.MGROUP
stored_location = WmScope.resolve_location(stored_scope, mgroup)
m_ins_list = []
m_ins_bl_list = []
@ -237,15 +238,23 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
continue
if mi_count <= 0:
raise ParseError('存在非正数!')
# 需要判断领用数是否合理
# 优先使用工段库存
if isinstance(mlog_or_b, Mlogb) and mlog_or_b.wm_in:
wm = WMaterial.objects.select_for_update().get(id=mlog_or_b.wm_in.id)
# 优先使用日志已选库存;未选择时按工段、部门、全局依次匹配并记录实际来源
wm_in = getattr(mlog_or_b, 'wm_in', None)
if wm_in:
wm = WMaterial.objects.select_for_update().get(id=wm_in.id)
else:
wm_qs = WMaterial.objects.filter(batch=mi_batch, material=mi_ma, mgroup=mgroup, state=WMaterial.WM_OK)
if not wm_qs.exists():
wm_qs = WMaterial.objects.filter(batch=mi_batch, material=mi_ma,
belong_dept=belong_dept, mgroup=None, state=WMaterial.WM_OK)
if not wm_qs.exists():
wm_qs = WMaterial.objects.filter(
batch=mi_batch,
material=mi_ma,
belong_dept=None,
mgroup=None,
state=WMaterial.WM_OK,
)
count_x = wm_qs.count()
if count_x == 1:
wm = WMaterial.objects.select_for_update().get(id=wm_qs.first().id)
@ -255,6 +264,8 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
else:
raise ParseError(
f'{str(mi_ma)}-{mi_batch}-存在多个相同批次!')
mlog_or_b.wm_in = wm
mlog_or_b.save(update_fields=['wm_in'])
if mi_count > wm.count:
raise ParseError(
@ -280,8 +291,14 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
c_state = WMaterial.WM_NOTOK
if isinstance(defect, Defect) and defect.okcate == Defect.DEFECT_OK_B:
c_state = WMaterial.WM_OK
lookup = {'batch': batch, 'material': material, 'mgroup': mgroup, 'defect': defect, 'state': c_state}
wm, is_create = WMaterial.locked_get_or_create(**lookup, defaults={"belong_dept": belong_dept})
lookup = {
'batch': batch,
'material': material,
'defect': defect,
'state': c_state,
**stored_location,
}
wm, is_create = WMaterial.locked_get_or_create(**lookup, defaults={})
wm.count = wm.count + count
if is_create:
wm.create_by = user
@ -359,18 +376,19 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
wm_state = WMaterial.WM_OK if notok_sign_or_defect is None or (
isinstance(notok_sign_or_defect, Defect) and notok_sign_or_defect.okcate in [Defect.DEFECT_OK, Defect.DEFECT_OK_B]
) else WMaterial.WM_NOTOK
lookup = {'batch': mo_batch, 'material': mo_ma, 'mgroup': None,
'notok_sign': None, 'defect': None, 'state': wm_state}
lookup = {
'batch': mo_batch,
'material': mo_ma,
'notok_sign': None,
'defect': None,
'state': wm_state,
**stored_location,
}
if isinstance(notok_sign_or_defect, Defect):
lookup['defect'] = notok_sign_or_defect
elif notok_sign_or_defect is not None:
lookup['notok_sign'] = notok_sign_or_defect
if stored_mgroup:
lookup['mgroup'] = mgroup
else:
lookup['belong_dept'] = belong_dept
wm, is_create2 = WMaterial.locked_get_or_create(**lookup, defaults={"belong_dept": belong_dept})
wm, is_create2 = WMaterial.locked_get_or_create(**lookup, defaults={})
wm.count = wm.count + mo_count
wm.count_eweight = mo_count_eweight
wm.update_by = user
@ -415,7 +433,7 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
mlog.submit_time = now
mlog.submit_user = user
mlog.stored_notok = stored_notok
mlog.stored_mgroup = stored_mgroup
mlog.stored_scope = stored_scope
if mlog.work_end_time is None and mlog.work_start_time is not None:
mlog.work_end_time = now
mlog.save()
@ -448,7 +466,8 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
material_out:Material = mlog.material_out
material_in:Material = mlog.material_in
stored_notok = mlog.stored_notok
stored_mgroup = mlog.stored_mgroup
stored_scope = mlog.stored_scope
stored_location = WmScope.resolve_location(stored_scope, mgroup)
is_fix = mlog.is_fix
if is_fix:
@ -523,15 +542,18 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
wm_state = WMaterial.WM_OK if notok_sign_or_defect is None or (
isinstance(notok_sign_or_defect, Defect) and notok_sign_or_defect.okcate in [Defect.DEFECT_OK, Defect.DEFECT_OK_B]
) else WMaterial.WM_NOTOK
lookup = {'batch': mo_batch, 'material': mo_ma, 'mgroup': None, 'notok_sign': None, 'defect': None, 'state': wm_state}
lookup = {
'batch': mo_batch,
'material': mo_ma,
'notok_sign': None,
'defect': None,
'state': wm_state,
**stored_location,
}
if isinstance(notok_sign_or_defect, Defect):
lookup['defect'] = notok_sign_or_defect
else:
lookup['notok_sign'] = notok_sign_or_defect
if stored_mgroup:
lookup['mgroup'] = mgroup
else:
lookup['belong_dept'] = belong_dept
wm_qs = WMaterial.objects.filter(**lookup)
count_x = wm_qs.count()
if count_x == 1:
@ -584,18 +606,18 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
continue
if mi_count <= 0:
raise ParseError('存在非正数!')
if isinstance(mlog_or_b, Mlogb) and mlog_or_b.wm_in:
wm = WMaterial.objects.select_for_update().get(id=mlog_or_b.wm_in.id)
wm_in = getattr(mlog_or_b, 'wm_in', None)
if wm_in:
wm = WMaterial.objects.select_for_update().get(id=wm_in.id)
else:
# 针对光子的情况实际上必须需要wm_in
lookup = {'batch': mi_batch, 'material': mi_ma, 'mgroup': None, 'state': WMaterial.WM_OK}
if stored_mgroup:
# 退回到本工段
lookup['mgroup'] = mgroup
else:
lookup['belong_dept'] = belong_dept
wm, _ = WMaterial.locked_get_or_create(**lookup, defaults={"belong_dept": belong_dept})
lookup = {
'batch': mi_batch,
'material': mi_ma,
'state': WMaterial.WM_OK,
**stored_location,
}
wm, _ = WMaterial.locked_get_or_create(**lookup, defaults={})
wm.count = wm.count + mi_count
wm.update_by = user
wm.save()
@ -615,12 +637,14 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
c_state = WMaterial.WM_NOTOK
if isinstance(defect, Defect) and defect.okcate == Defect.DEFECT_OK_B:
c_state = WMaterial.WM_OK
lookup = {'batch': batch, 'material': material, 'mgroup': None, 'defect': defect, 'state': c_state}
if stored_mgroup:
lookup['mgroup'] = mgroup
else:
lookup['belong_dept'] = belong_dept
wm, is_create = WMaterial.locked_get_or_create(**lookup, defaults={"belong_dept": belong_dept})
lookup = {
'batch': batch,
'material': material,
'defect': defect,
'state': c_state,
**stored_location,
}
wm, is_create = WMaterial.locked_get_or_create(**lookup, defaults={})
wm.count = wm.count - count
if wm.count < 0:
raise ParseError('加工前不良数量大于库存量')

View File

@ -3,7 +3,10 @@ from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
from apps.mtm.models import Material
from apps.mtm.models import Material, WmScope
from apps.wpm.filters import WMaterialFilter
from apps.wpm.models import WMaterial
from apps.wpm.serializers import WMaterialCreateSerializer
from apps.wpm.views import MlogbwViewSet
@ -59,3 +62,80 @@ class MlogbwViewSetTests(SimpleTestCase):
)
self.assertEqual(cal_count_notok.call_count, 2)
mlog.cal_mlog_count_from_mlogb.assert_called_once_with()
class WMaterialScopeTests(SimpleTestCase):
def test_scope_resolver_returns_consistent_location_fields(self):
dept = object()
mgroup = SimpleNamespace(belong_dept=dept)
self.assertEqual(
WmScope.resolve_location(WmScope.MGROUP, mgroup),
{"mgroup": mgroup, "belong_dept": dept},
)
self.assertEqual(
WmScope.resolve_location(WmScope.DEPT, mgroup),
{"mgroup": None, "belong_dept": dept},
)
self.assertEqual(
WmScope.resolve_location(WmScope.GLOBAL, mgroup),
{"mgroup": None, "belong_dept": None},
)
def test_only_mgroup_scope_requires_handover_mgroup(self):
self.assertTrue(WmScope.requires_mgroup(WmScope.MGROUP))
self.assertFalse(WmScope.requires_mgroup(WmScope.DEPT))
self.assertFalse(WmScope.requires_mgroup(WmScope.GLOBAL))
def test_global_scope_key_does_not_access_missing_relations(self):
wm = WMaterial()
self.assertEqual(wm.belong_dept_or_mgroup_id, ("global", None))
def test_department_and_mgroup_scope_keys_cannot_collide(self):
dept_wm = WMaterial(belong_dept_id=10)
mgroup_wm = WMaterial(mgroup_id=10)
self.assertNotEqual(
dept_wm.belong_dept_or_mgroup_id,
mgroup_wm.belong_dept_or_mgroup_id,
)
def test_manual_create_allows_global_scope(self):
serializer = WMaterialCreateSerializer()
attrs = {
"material": Material(),
"count": 1,
"batch": "GLOBAL-001",
}
validated = serializer.validate(attrs)
self.assertFalse(serializer.fields["mgroup"].required)
self.assertFalse(serializer.fields["belong_dept"].required)
self.assertNotIn("mgroup", validated)
self.assertNotIn("belong_dept", validated)
def test_global_scope_can_be_filtered_explicitly(self):
self.assertIn(
"isnull",
WMaterialFilter.Meta.fields["belong_dept"],
)
self.assertIn(
"isnull",
WMaterialFilter.Meta.fields["mgroup"],
)
def test_manual_create_derives_department_from_mgroup(self):
dept = SimpleNamespace(id=20)
mgroup = SimpleNamespace(id=10, belong_dept=dept)
attrs = {
"material": Material(),
"count": 1,
"batch": "MGROUP-001",
"mgroup": mgroup,
}
validated = WMaterialCreateSerializer().validate(attrs)
self.assertIs(validated["belong_dept"], dept)