diff --git a/apps/mtm/admin.py b/apps/mtm/admin.py index 5de10265..d4adbaf3 100644 --- a/apps/mtm/admin.py +++ b/apps/mtm/admin.py @@ -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) diff --git a/apps/mtm/migrations/0068_process_into_wm_scope.py b/apps/mtm/migrations/0068_process_into_wm_scope.py new file mode 100644 index 00000000..fa761421 --- /dev/null +++ b/apps/mtm/migrations/0068_process_into_wm_scope.py @@ -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', + ), + ] diff --git a/apps/mtm/models.py b/apps/mtm/models.py index 71875b4a..234aa8ef 100644 --- a/apps/mtm/models.py +++ b/apps/mtm/models.py @@ -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) \ No newline at end of file + material = models.ForeignKey(Material, verbose_name='辅助物料', on_delete=models.CASCADE) diff --git a/apps/wpm/filters.py b/apps/wpm/filters.py index f4423459..fd0c496e 100644 --- a/apps/wpm/filters.py +++ b/apps/wpm/filters.py @@ -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() \ No newline at end of file + return queryset.none() diff --git a/apps/wpm/migrations/0135_mlog_stored_scope.py b/apps/wpm/migrations/0135_mlog_stored_scope.py new file mode 100644 index 00000000..17dd42f3 --- /dev/null +++ b/apps/wpm/migrations/0135_mlog_stored_scope.py @@ -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', + ), + ] diff --git a/apps/wpm/models.py b/apps/wpm/models.py index 5b73a7c6..af1ae43d 100644 --- a/apps/wpm/models.py +++ b/apps/wpm/models.py @@ -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) diff --git a/apps/wpm/serializers.py b/apps/wpm/serializers.py index f3c354ae..73330725 100644 --- a/apps/wpm/serializers.py +++ b/apps/wpm/serializers.py @@ -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 diff --git a/apps/wpm/services.py b/apps/wpm/services.py index 796cbd85..35010d0f 100644 --- a/apps/wpm/services.py +++ b/apps/wpm/services.py @@ -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('加工前不良数量大于库存量') diff --git a/apps/wpm/tests.py b/apps/wpm/tests.py index 2cacf716..5a60d0e2 100644 --- a/apps/wpm/tests.py +++ b/apps/wpm/tests.py @@ -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)