From 074e759c2908dfbadd40d98bcdc0e10999dbc0a3 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 30 Jul 2026 14:52:43 +0800 Subject: [PATCH 1/6] Allow clearing same-grade batch defects --- apps/wpm/serializers.py | 15 +++++++----- apps/wpm/tests.py | 52 ++++++++++++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/apps/wpm/serializers.py b/apps/wpm/serializers.py index c01dae8c..cae7fe3e 100644 --- a/apps/wpm/serializers.py +++ b/apps/wpm/serializers.py @@ -1398,6 +1398,7 @@ class HandoverSerializer(CustomModelSerializer): next_mat = None next_state = None next_defect = None + next_defect_grade = None if new_wm and attrs["type"] != Handover.H_CHANGE: next_mat = new_wm.material next_state = new_wm.state @@ -1418,13 +1419,15 @@ class HandoverSerializer(CustomModelSerializer): if clear_defect and new_wm is not None and new_wm.defect is not None: raise ParseError('清除批次缺陷时目标批次不能带缺陷') if clear_defect and tracking == Material.MA_TRACKING_BATCH: - if ( - wm.state != WMaterial.WM_OK - or wm.defect is None - or wm.defect.okcate != Defect.DEFECT_OK_B - ): + if wm.defect is None: raise ParseError( - f'第{ind+1}行-批次追踪物料仅合格B类可清除批次缺陷' + f'第{ind+1}行-批次追踪物料仅同缺陷等级可清除批次缺陷' + ) + if next_defect_grade is None: + next_defect_grade = wm.defect.okcate + elif next_defect_grade != wm.defect.okcate: + raise ParseError( + f'第{ind+1}行-批次追踪物料仅同缺陷等级可清除批次缺陷' ) if next_mat is None: next_mat = wm.material diff --git a/apps/wpm/tests.py b/apps/wpm/tests.py index 189faf9e..0ce84e2c 100644 --- a/apps/wpm/tests.py +++ b/apps/wpm/tests.py @@ -462,22 +462,58 @@ class WMaterialScopeTests(SimpleTestCase): self.assertTrue(validated["clear_defect"]) self.assertEqual(validated["count"], 2) - def test_batch_tracking_merge_cannot_clear_notok_defect(self): + def test_batch_tracking_merge_can_clear_same_grade_notok_defects(self): material = Material(tracking=Material.MA_TRACKING_BATCH) - defect = Defect(id="1", okcate=Defect.DEFECT_NOTOK) - wm = WMaterial( + defect_a = Defect(id="1", okcate=Defect.DEFECT_NOTOK) + defect_b = Defect(id="2", okcate=Defect.DEFECT_NOTOK) + wm_a = WMaterial( id="10", material=material, batch="N-001", count=1, - state=WMaterial.WM_NOTOK, defect=defect, + state=WMaterial.WM_NOTOK, defect=defect_a, + ) + wm_b = WMaterial( + id="20", material=material, batch="N-002", count=1, + state=WMaterial.WM_NOTOK, defect=defect_b, + ) + + validated = HandoverSerializer().validate({ + "wm": wm_a, + "handoverb": [ + {"wm": wm_a, "count": 1}, + {"wm": wm_b, "count": 1}, + ], + "new_batch": "N-MERGED", + "clear_defect": True, + "type": Handover.H_NORMAL, + "mtype": Handover.H_MERGE, + }) + + self.assertTrue(validated["clear_defect"]) + self.assertEqual(validated["count"], 2) + + def test_batch_tracking_merge_cannot_clear_mixed_defect_grades(self): + material = Material(tracking=Material.MA_TRACKING_BATCH) + defect_b = Defect(id="1", okcate=Defect.DEFECT_OK_B) + defect_notok = Defect(id="2", okcate=Defect.DEFECT_NOTOK) + wm_a = WMaterial( + id="10", material=material, batch="B-001", count=1, + state=WMaterial.WM_OK, defect=defect_b, + ) + wm_b = WMaterial( + id="20", material=material, batch="N-001", count=1, + state=WMaterial.WM_OK, defect=defect_notok, ) with self.assertRaisesMessage( ParseError, - "批次追踪物料仅合格B类可清除批次缺陷", + "批次追踪物料仅同缺陷等级可清除批次缺陷", ): HandoverSerializer().validate({ - "wm": wm, - "handoverb": [{"wm": wm, "count": 1}], - "new_batch": "N-MERGED", + "wm": wm_a, + "handoverb": [ + {"wm": wm_a, "count": 1}, + {"wm": wm_b, "count": 1}, + ], + "new_batch": "MIXED-MERGED", "clear_defect": True, "type": Handover.H_NORMAL, "mtype": Handover.H_MERGE, From 1f98b81ce3e03f706a90daea18e61a90d5d64eaf Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 31 Jul 2026 11:00:01 +0800 Subject: [PATCH 2/6] Fix bulk-created individual defect IDs --- apps/wpm/services.py | 2 ++ apps/wpm/tests.py | 31 +++++++++++++++++++++++++++++++ apps/wpmw/models.py | 2 ++ 3 files changed, 35 insertions(+) diff --git a/apps/wpm/services.py b/apps/wpm/services.py index da0b96cf..b86b55fe 100644 --- a/apps/wpm/services.py +++ b/apps/wpm/services.py @@ -21,6 +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.utils.snowflake import idWorker from apps.mtm.models import Process, WmScope from django.db.models import F @@ -214,6 +215,7 @@ def restore_mlog_wpr_defects(mlog: Mlog): continue restored.extend( WprDefect( + id=idWorker.get_id(), wpr=wpr, defect_id=item["defect_id"], is_main=item.get("is_main", False), diff --git a/apps/wpm/tests.py b/apps/wpm/tests.py index 0ce84e2c..6954cd04 100644 --- a/apps/wpm/tests.py +++ b/apps/wpm/tests.py @@ -548,6 +548,30 @@ class WMaterialScopeTests(SimpleTestCase): class WprDefectLifecycleTests(SimpleTestCase): + @patch("apps.wpmw.models.WprDefect.objects.bulk_create") + @patch("apps.wpmw.models.WprDefect.objects.filter") + @patch( + "apps.wpmw.models.idWorker.get_id", + side_effect=["100", "101"], + ) + def test_replace_defects_assigns_unique_ids_before_bulk_create( + self, + get_id, + defect_filter, + bulk_create, + ): + wpr = Wpr(id="1") + + wpr.replace_defects([ + {"defect_id": "10", "is_main": True}, + {"defect_id": "11", "is_main": False}, + ]) + + defect_filter.return_value.delete.assert_called_once_with() + created = bulk_create.call_args.args[0] + self.assertEqual([str(item.id) for item in created], ["100", "101"]) + self.assertEqual(get_id.call_count, 2) + @patch("apps.wpmw.models.WprDefect.objects.filter") def test_change_inventory_never_changes_individual_defects(self, defect_filter): material = Material(id="1", tracking=Material.MA_TRACKING_SINGLE) @@ -671,8 +695,13 @@ class MlogWprDefectSnapshotTests(SimpleTestCase): @patch("apps.wpm.services.WprDefect.objects.bulk_create") @patch("apps.wpm.services.WprDefect.objects.filter") @patch("apps.wpm.services.Wpr.objects.select_for_update") + @patch( + "apps.wpm.services.idWorker.get_id", + side_effect=["100", "101"], + ) def test_restore_replaces_current_defects_exactly( self, + get_id, select_for_update, defect_filter, bulk_create, @@ -698,6 +727,8 @@ class MlogWprDefectSnapshotTests(SimpleTestCase): [(str(item.defect_id), item.is_main) for item in created], [("10", True), ("11", False)], ) + self.assertEqual([str(item.id) for item in created], ["100", "101"]) + self.assertEqual(get_id.call_count, 2) @skipUnless( diff --git a/apps/wpmw/models.py b/apps/wpmw/models.py index f45bc23c..a655e2e0 100644 --- a/apps/wpmw/models.py +++ b/apps/wpmw/models.py @@ -5,6 +5,7 @@ from apps.utils.models import BaseModel from apps.mtm.models import Material from rest_framework.exceptions import ParseError from apps.wpm.models import WmStateOption, Mlogbw, Handoverbw +from apps.utils.snowflake import idWorker from apps.utils.tools import update_dict from apps.inm.models import MIOItemw from django.db.models import F, Value @@ -130,6 +131,7 @@ class Wpr(BaseModel): WprDefect.objects.filter(wpr=self).delete() WprDefect.objects.bulk_create([ WprDefect( + id=idWorker.get_id(), wpr=self, defect_id=item["defect_id"], is_main=item.get("is_main", False), From cee94f9f4a5f6d9914a6e1486813923b54f065ea Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 31 Jul 2026 11:03:26 +0800 Subject: [PATCH 3/6] Centralize snowflake IDs for bulk create --- apps/utils/models.py | 24 ++++++++- apps/utils/tests.py | 116 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 apps/utils/tests.py diff --git a/apps/utils/models.py b/apps/utils/models.py index 7c08da61..30ae9101 100755 --- a/apps/utils/models.py +++ b/apps/utils/models.py @@ -31,7 +31,27 @@ class SoftDeletableQuerySetMixin(object): return super(SoftDeletableQuerySetMixin, self).delete() -class SoftDeletableQuerySet(SoftDeletableQuerySetMixin, QuerySet): +class SnowflakeQuerySet(QuerySet): + """为 BaseModel 的批量创建统一准备雪花主键。""" + + def bulk_create(self, objs, *args, **kwargs): + objs = list(objs) + for obj in objs: + if not obj.pk: + obj.pk = str(idWorker.get_id()) + else: + obj.pk = str(obj.pk) + return super().bulk_create(objs, *args, **kwargs) + + +class SnowflakeManager(models.Manager.from_queryset(SnowflakeQuerySet)): + pass + + +class SoftDeletableQuerySet( + SoftDeletableQuerySetMixin, + SnowflakeQuerySet, +): pass @@ -111,6 +131,8 @@ class BaseModel(models.Model): is_deleted = models.BooleanField( default=False, verbose_name='删除标记', help_text='删除标记') + objects = SnowflakeManager() + class Meta: abstract = True diff --git a/apps/utils/tests.py b/apps/utils/tests.py new file mode 100644 index 00000000..6301e97c --- /dev/null +++ b/apps/utils/tests.py @@ -0,0 +1,116 @@ +from unittest.mock import patch + +from django.db.models.query import QuerySet +from django.test import SimpleTestCase, TestCase + +from apps.develop.models import Project +from apps.mtm.models import Material +from apps.system.models import User +from apps.wpmw.models import WprDefect + + +class SnowflakeBulkCreateTests(SimpleTestCase): + @patch.object(QuerySet, "bulk_create") + @patch( + "apps.utils.models.idWorker.get_id", + side_effect=[100, 101], + ) + def test_base_model_bulk_create_assigns_missing_ids( + self, + get_id, + bulk_create, + ): + objs = [ + WprDefect(defect_id="10", wpr_id="20"), + WprDefect(defect_id="11", wpr_id="20"), + ] + + WprDefect.objects.bulk_create(objs) + + self.assertEqual([obj.id for obj in objs], ["100", "101"]) + self.assertEqual(get_id.call_count, 2) + bulk_create.assert_called_once() + + @patch.object(QuerySet, "bulk_create") + @patch("apps.utils.models.idWorker.get_id") + def test_base_model_bulk_create_preserves_existing_ids( + self, + get_id, + bulk_create, + ): + objs = [ + WprDefect(id=100, defect_id="10", wpr_id="20"), + WprDefect(id="101", defect_id="11", wpr_id="20"), + ] + + WprDefect.objects.bulk_create(objs) + + self.assertEqual([obj.id for obj in objs], ["100", "101"]) + get_id.assert_not_called() + bulk_create.assert_called_once() + + @patch.object(QuerySet, "bulk_create") + @patch( + "apps.utils.models.idWorker.get_id", + side_effect=[200, 201], + ) + def test_soft_delete_manager_uses_snowflake_bulk_create( + self, + get_id, + bulk_create, + ): + objs = [ + Material(name="物料1"), + Material(name="物料2"), + ] + + Material.objects.bulk_create(objs) + + self.assertEqual([obj.id for obj in objs], ["200", "201"]) + self.assertEqual(get_id.call_count, 2) + bulk_create.assert_called_once() + + @patch.object(QuerySet, "bulk_create") + @patch( + "apps.utils.models.idWorker.get_id", + side_effect=[300, 301], + ) + def test_custom_user_manager_uses_snowflake_bulk_create( + self, + get_id, + bulk_create, + ): + objs = [ + User(username="bulk-user-1"), + User(username="bulk-user-2"), + ] + + User.objects.bulk_create(objs) + + self.assertEqual([obj.id for obj in objs], ["300", "301"]) + self.assertEqual(get_id.call_count, 2) + bulk_create.assert_called_once() + + +class SnowflakeBulkCreateDatabaseTests(TestCase): + @patch( + "apps.utils.models.idWorker.get_id", + side_effect=[900000000000000001, 900000000000000002], + ) + def test_bulk_create_persists_generated_ids(self, get_id): + objs = [ + Project(name="批量项目1", code="bulk-project-1"), + Project(name="批量项目2", code="bulk-project-2"), + ] + + Project.objects.bulk_create(objs) + + self.assertEqual( + [obj.id for obj in objs], + ["900000000000000001", "900000000000000002"], + ) + self.assertEqual( + set(Project.objects.values_list("id", flat=True)), + {"900000000000000001", "900000000000000002"}, + ) + self.assertEqual(get_id.call_count, 2) From cf2800daa0fc3bea2accdda29a982c3cc78a2366 Mon Sep 17 00:00:00 2001 From: zty Date: Tue, 21 Jul 2026 04:20:22 -0400 Subject: [PATCH 4/6] =?UTF-8?q?feat(enm):=20=E6=96=B0=E5=A2=9E=E7=93=AE?= =?UTF-8?q?=E7=A6=8F=E6=95=B0=E6=8D=AEAPI=EF=BC=88=E9=87=87=E9=9B=86?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=9F=A5=E8=AF=A2+=E6=B5=8B=E7=82=B9?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=88=86=E9=A1=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- apps/enm/urls.py | 7 ++- apps/enm/views.py | 128 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/apps/enm/urls.py b/apps/enm/urls.py index f5777809..6d12af13 100644 --- a/apps/enm/urls.py +++ b/apps/enm/urls.py @@ -1,7 +1,8 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from apps.enm.views import (MpointViewSet, MpointStatViewSet, - EnStatViewSet, EnStat2ViewSet, XscriptViewSet, MpLogxAPIView) +from apps.enm.views import (MpointViewSet, MpointStatViewSet, + EnStatViewSet, EnStat2ViewSet, XscriptViewSet, MpLogxAPIView, + WengfuMpLogxAPIView, WengfuMpointListAPIView) API_BASE_URL = 'api/enm/' HTML_BASE_URL = 'dhtml/enm/' @@ -16,4 +17,6 @@ router.register('xscript', XscriptViewSet, basename='xscript') urlpatterns = [ path(API_BASE_URL, include(router.urls)), path(f'{API_BASE_URL}mplogx/', MpLogxAPIView.as_view(), name='mplogx_list'), + path(f'{API_BASE_URL}wengfu_mplogx/', WengfuMpLogxAPIView.as_view(), name='wengfu_mplogx'), + path(f'{API_BASE_URL}wengfu_mpoints/', WengfuMpointListAPIView.as_view(), name='wengfu_mpoints'), ] \ No newline at end of file diff --git a/apps/enm/views.py b/apps/enm/views.py index 11a74a1b..04542e74 100644 --- a/apps/enm/views.py +++ b/apps/enm/views.py @@ -265,6 +265,134 @@ class MpLogxAPIView(APIView): +class WengfuMpLogxAPIView(APIView): + perms_map = {"get": "*", "post": "*"} + + def _get_conn(self): + from apps.utils.sql import DbConnection + return DbConnection( + host='10.0.11.52', user='postgres', + password='zcDsj@2024', database='hfnf', dbtype='pg' + ) + + def get(self, request, *args, **kwargs): + mpoint_ids = request.query_params.get("mpoint_ids", None) + timex_gte = request.query_params.get("timex__gte", None) + timex_lte = request.query_params.get("timex__lte", None) + page = int(request.query_params.get("page", 1)) + page_size = int(request.query_params.get("page_size", 20)) + + conditions = [] + params = [] + + if mpoint_ids: + ids = [x.strip() for x in mpoint_ids.split(",") if x.strip()] + placeholders = ",".join(["%s"] * len(ids)) + conditions.append(f"m.mpoint_id IN ({placeholders})") + params.extend(ids) + if timex_gte: + conditions.append("m.timex >= %s") + params.append(timex_gte) + if timex_lte: + conditions.append("m.timex <= %s") + params.append(timex_lte) + + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + + with self._get_conn() as cur: + count_sql = f"""SELECT COUNT(*) FROM mplogx_wengfu m {where}""" + cur.execute(count_sql, params) + total = cur.fetchone()[0] + + data_sql = f""" + SELECT m.mpoint_id, n.point_name AS mpoint_name, m.timex, + m.val_float AS val, m.val_bool + FROM mplogx_wengfu m + LEFT JOIN cj_mpoint_name n ON n.mpoint_id = m.mpoint_id AND n.company = '瓮福' + {where} + ORDER BY m.timex DESC + LIMIT %s OFFSET %s + """ + cur.execute(data_sql, params + [page_size, (page - 1) * page_size]) + columns = [desc[0] for desc in cur.description] + results = [] + for row in cur.fetchall(): + d = dict(zip(columns, row)) + if d.get("timex"): + d["timex"] = d["timex"].strftime("%Y-%m-%d %H:%M:%S") + results.append(d) + + return Response({"count": total, "results": results}) + + def post(self, request, *args, **kwargs): + """导出或图表数据(不分页)""" + mpoint_ids = request.data.get("mpoint_ids", []) + timex_gte = request.data.get("timex__gte") + timex_lte = request.data.get("timex__lte") + + if not mpoint_ids or not timex_gte or not timex_lte: + raise ParseError("mpoint_ids, timex__gte, timex__lte are required") + + conditions = [] + params = [] + placeholders = ",".join(["%s"] * len(mpoint_ids)) + conditions.append(f"m.mpoint_id IN ({placeholders})") + params.extend(mpoint_ids) + conditions.append("m.timex >= %s") + params.append(timex_gte) + conditions.append("m.timex <= %s") + params.append(timex_lte) + + where = "WHERE " + " AND ".join(conditions) + + with self._get_conn() as cur: + sql = f""" + SELECT m.mpoint_id, n.point_name AS mpoint_name, m.timex, m.val_float AS val, m.val_bool + FROM mplogx_wengfu m + LEFT JOIN cj_mpoint_name n ON n.mpoint_id = m.mpoint_id AND n.company = '瓮福' + {where} + ORDER BY m.timex ASC + """ + cur.execute(sql, params) + columns = [desc[0] for desc in cur.description] + results = [] + for row in cur.fetchall(): + d = dict(zip(columns, row)) + if d.get("timex"): + d["timex"] = d["timex"].strftime("%Y-%m-%d %H:%M:%S") + results.append(d) + + return Response(results) + + +class WengfuMpointListAPIView(APIView): + """瓮福测点列表(供下拉选择)""" + perms_map = {"get": "*"} + + def get(self, request, *args, **kwargs): + keyword = request.query_params.get("keyword", "") + page = int(request.query_params.get("page", 1)) + page_size = int(request.query_params.get("page_size", 50)) + from apps.utils.sql import DbConnection + with DbConnection(host='10.0.11.52', user='postgres', + password='zcDsj@2024', database='hfnf', dbtype='pg') as cur: + conditions = ["company = '瓮福'"] + params = [] + if keyword: + conditions.append("(mpoint_id ILIKE %s OR point_name ILIKE %s)") + params.extend([f"%{keyword}%", f"%{keyword}%"]) + where = "WHERE " + " AND ".join(conditions) + cur.execute(f"SELECT COUNT(*) FROM cj_mpoint_name {where}", params) + total = cur.fetchone()[0] + cur.execute(f""" + SELECT mpoint_id, point_name FROM cj_mpoint_name + {where} ORDER BY mpoint_id LIMIT %s OFFSET %s + """, params + [page_size, (page - 1) * page_size]) + columns = [desc[0] for desc in cur.description] + results = [dict(zip(columns, row)) for row in cur.fetchall()] + return Response({"count": total, "results": results}) + + class MpLogxViewSet(CustomListModelMixin, CustomGenericViewSet): """ list: 测点采集数据 From e23db9dc6c6de6b0525f8df646bcce1a240b3c4e Mon Sep 17 00:00:00 2001 From: zty Date: Fri, 31 Jul 2026 03:47:29 -0400 Subject: [PATCH 5/6] =?UTF-8?q?feat(hrm):=20=E5=91=98=E5=B7=A5Excel?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=94=AF=E6=8C=81=E5=85=A8=E9=87=8F=E8=A6=86?= =?UTF-8?q?=E7=9B=96(=E7=A9=BA=E5=8D=95=E5=85=83=E6=A0=BC=E6=B8=85?= =?UTF-8?q?=E7=A9=BA,=E4=B8=8D=E5=8F=AF=E7=A9=BA=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=8E=9F=E5=80=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- apps/hrm/views.py | 58 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/apps/hrm/views.py b/apps/hrm/views.py index 9b9cc201..b0558c44 100755 --- a/apps/hrm/views.py +++ b/apps/hrm/views.py @@ -390,7 +390,24 @@ class EmployeeViewSet(CustomModelViewSet): # 获取模型字段 model_fields = {f.name: f for f in Employee._meta.fields} - + + from django.db.models import CharField, TextField + _ABSENT = object() # 区分“列没出现”与“列为空” + _KEEP = object() # 该字段不可清空时,保留原值 + + def empty_value(field_name): + """全量覆盖时,空单元格应写入的“清空值”;不可清空的字段返回 _KEEP。""" + if field_name == 'belong_dept_id': + return None # 部门 FK 可空 + mf = model_fields.get(field_name) + if mf is None: + return _KEEP + if getattr(mf, 'null', False): + return None + if isinstance(mf, (CharField, TextField)) and getattr(mf, 'blank', False): + return '' + return _KEEP # 非空且不可为空(如布尔/性别/类别)-> 保留原值 + for row_num, row in enumerate(rows[1:], start=2): try: data = {} @@ -419,12 +436,15 @@ class EmployeeViewSet(CustomModelViewSet): data['type'] = TYPE_MAPPING[excel_type] else: raise ParseError(f'第{row_num}行,人员类型"{excel_type}"无效,有效类型:{", ".join(TYPE_MAPPING.keys())}') - # 处理部门外键:填了就校验是否存在并赋值;为空时不动(新增场景的必填在下方创建处校验) - dept_name = data.pop('belong_dept', None) - if dept_name: - if dept_name not in dept_map: - raise ParseError(f'第{row_num}行,部门"{dept_name}"不存在') - data['belong_dept_id'] = dept_map[dept_name] + # 处理部门外键:填了校验并赋值;列存在但为空 -> 清空(新增场景的必填在创建处校验) + dept_raw = data.pop('belong_dept', _ABSENT) + if dept_raw is not _ABSENT: + if dept_raw: + if dept_raw not in dept_map: + raise ParseError(f'第{row_num}行,部门"{dept_raw}"不存在') + data['belong_dept_id'] = dept_map[dept_raw] + else: + data['belong_dept_id'] = None # 数据验证 if data.get('phone'): @@ -454,11 +474,14 @@ class EmployeeViewSet(CustomModelViewSet): (e for e in name_matches if e.id_number == id_number), None) if existing: - # 用 Excel 中填写了值的列覆盖数据库已有数据;空单元格保持原值不变 + # 全量覆盖:以本次 Excel 为准。填了值的列覆盖原值; + # 空单元格清空原值(不可为空的字段如布尔/性别/类别则保留原值)。 updated_fields = [] for field_name, value in data.items(): if value in [None, '']: - continue + value = empty_value(field_name) + if value is _KEEP: + continue if getattr(existing, field_name, None) != value: setattr(existing, field_name, value) updated_fields.append(field_name) @@ -470,9 +493,11 @@ class EmployeeViewSet(CustomModelViewSet): created = False else: # 新增人员时所属部门必填 - if 'belong_dept_id' not in data: + if not data.get('belong_dept_id'): raise ParseError(f'第{row_num}行,新增人员时所属部门不能为空') - Employee.objects.create(**data) + # 新增时丢弃空值,交给模型默认值/允许为空 + create_data = {k: v for k, v in data.items() if v is not None} + Employee.objects.create(**create_data) created = True except Exception as e: raise @@ -501,18 +526,23 @@ def convert_field_value(model_field, value, row_num): # ===== 日期字段 ===== if isinstance(model_field, CharField): + sval = str(value) if isinstance(value, str): if 'e' in value.lower(): try: - return str(int(float(value))) + sval = str(int(float(value))) except ValueError: pass if '.' in value: try: - return str(int(float(value))) + sval = str(int(float(value))) except ValueError: pass - return str(value) + # 超长字段给出明确提示,避免数据库报 value too long + if model_field.max_length and len(sval) > model_field.max_length: + raise ParseError( + f'第{row_num}行,字段 {model_field.verbose_name} 长度超过 {model_field.max_length} 个字符(当前 {len(sval)})') + return sval if isinstance(model_field, DateField): From d86d70641d939ad75aefb63df9cd4c5d622a7231 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 3 Aug 2026 10:22:01 +0800 Subject: [PATCH 6/6] feat(wpm): add files to individual outputs --- apps/wpm/migrations/0138_mlogbw_files.py | 24 ++++++++++++++++ apps/wpm/models.py | 6 ++++ apps/wpm/serializers.py | 16 +++++++++-- apps/wpm/tests/__init__.py | 1 + apps/wpm/tests/test_mlogbw_files.py | 36 ++++++++++++++++++++++++ apps/wpm/views.py | 17 +++++++++++ 6 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 apps/wpm/migrations/0138_mlogbw_files.py create mode 100644 apps/wpm/tests/__init__.py create mode 100644 apps/wpm/tests/test_mlogbw_files.py diff --git a/apps/wpm/migrations/0138_mlogbw_files.py b/apps/wpm/migrations/0138_mlogbw_files.py new file mode 100644 index 00000000..4a48ed69 --- /dev/null +++ b/apps/wpm/migrations/0138_mlogbw_files.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.27 on 2026-08-03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("system", "0007_alter_dept_create_by_alter_dept_third_info_and_more"), + ("wpm", "0137_mlog_wpr_defect_snapshot"), + ] + + operations = [ + migrations.AddField( + model_name="mlogbw", + name="files", + field=models.ManyToManyField( + blank=True, + related_name="mlogbw_outputs", + to="system.file", + verbose_name="产出附件", + ), + ), + ] diff --git a/apps/wpm/models.py b/apps/wpm/models.py index c9b49681..9a3c0e51 100644 --- a/apps/wpm/models.py +++ b/apps/wpm/models.py @@ -740,6 +740,12 @@ class Mlogbw(BaseModel): """TN: 单个产品生产/检验日志 """ number = models.TextField('单个编号', db_index=True) + files = models.ManyToManyField( + "system.File", + verbose_name="产出附件", + blank=True, + related_name="mlogbw_outputs", + ) mlogb = models.ForeignKey(Mlogb, verbose_name='生产记录', on_delete=models.CASCADE, related_name="w_mlogb") mlogbw_from = models.ForeignKey("self", verbose_name='来源个', on_delete=models.CASCADE, null=True, blank=True, related_name="w_mlogbw_from") wpr = models.ForeignKey("wpmw.wpr", verbose_name='关联产品', on_delete=models.SET_NULL diff --git a/apps/wpm/serializers.py b/apps/wpm/serializers.py index cae7fe3e..fc7fa8e9 100644 --- a/apps/wpm/serializers.py +++ b/apps/wpm/serializers.py @@ -8,7 +8,7 @@ from .models import (SfLog, StLog, SfLogExp, WMaterial, Mlog, Handover, Handoverb, Mlogb, AttLog, OtherLog, Fmlog, BatchSt, Mlogbw, Handoverbw, MlogbDefect, MlogUser, BatchLog) from apps.system.models import Dept, User -from apps.system.serializers import UserSimpleSerializer +from apps.system.serializers import FileSerializer, 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 @@ -923,6 +923,8 @@ class MlogbInUpdateSerializer(CustomModelSerializer): return ins class MlogbwCreateUpdateSerializer(CustomModelSerializer): + files = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + files_ = FileSerializer(source="files", many=True, read_only=True) ftest = FtestProcessSerializer(required=False, allow_null=True) equip_name = serializers.CharField(source='equip.name', read_only=True) equip_number = serializers.CharField(source='equip.number', read_only=True) @@ -936,7 +938,8 @@ class MlogbwCreateUpdateSerializer(CustomModelSerializer): fields = ["id", "number", "wpr", "note", "mlogb", "ftest", "equip", "tooling", "work_start_time", "work_end_time", "mlogbw_from", "equip_name", "equip_number", - "tooling_name", "tooling_number", "wpr_number_out", "wpr_pre_info", "mlogb__batch"] + "tooling_name", "tooling_number", "wpr_number_out", "wpr_pre_info", "mlogb__batch", + "files", "files_"] read_only_fields = ["mlogbw_from"] def validate(self, attrs): @@ -998,6 +1001,15 @@ class MlogbwCreateUpdateSerializer(CustomModelSerializer): class MlogbwListSerializer(MlogbwCreateUpdateSerializer): ftest = FtestProcessListSerializer(read_only=True) + +class MlogbwFilesSerializer(CustomModelSerializer): + files_ = FileSerializer(source="files", many=True, read_only=True) + + class Meta: + model = Mlogbw + fields = ["id", "files", "files_"] + read_only_fields = ["id", "files_"] + class MlogbwStartTestSerializer(serializers.Serializer): mlogbw_ids = serializers.ListField(child=serializers.CharField(), label="mlogbwId列表") test_equip = serializers.CharField(label="测试设备", allow_null=True, required=False, allow_blank=True) diff --git a/apps/wpm/tests/__init__.py b/apps/wpm/tests/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/apps/wpm/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/wpm/tests/test_mlogbw_files.py b/apps/wpm/tests/test_mlogbw_files.py new file mode 100644 index 00000000..8d2f39c1 --- /dev/null +++ b/apps/wpm/tests/test_mlogbw_files.py @@ -0,0 +1,36 @@ +from django.test import SimpleTestCase +from django.urls import reverse + +from apps.system.models import File +from apps.wpm.models import Mlogbw +from apps.wpm.serializers import MlogbwFilesSerializer, MlogbwListSerializer +from apps.wpm.views import MlogbwViewSet + + +class MlogbwFilesDefinitionTests(SimpleTestCase): + def test_files_field_links_system_file(self): + field = Mlogbw._meta.get_field("files") + + self.assertTrue(field.blank) + self.assertIs(field.remote_field.model, File) + self.assertEqual(field.remote_field.related_name, "mlogbw_outputs") + + def test_list_serializer_exposes_file_ids_and_details(self): + fields = MlogbwListSerializer().fields + + self.assertTrue(fields["files"].read_only) + self.assertTrue(fields["files_"].read_only) + + def test_files_serializer_only_writes_file_relations(self): + fields = MlogbwFilesSerializer().fields + + self.assertEqual(set(fields), {"id", "files", "files_"}) + self.assertFalse(fields["files"].read_only) + + def test_files_action_supports_read_and_update(self): + self.assertEqual(MlogbwViewSet.files.mapping["get"], "files") + self.assertEqual(MlogbwViewSet.files.mapping["put"], "files") + self.assertEqual( + reverse("mlogbw-files", kwargs={"pk": "single-id"}), + "/api/wpm/mlogbw/single-id/files/", + ) diff --git a/apps/wpm/views.py b/apps/wpm/views.py index f2b4bb0c..d7f01582 100644 --- a/apps/wpm/views.py +++ b/apps/wpm/views.py @@ -46,6 +46,7 @@ from .serializers import ( BatchStSerializer, MlogbwCreateUpdateSerializer, MlogbwListSerializer, + MlogbwFilesSerializer, HandoverMgroupSerializer, MlogListSerializer, MlogbSerializer, @@ -1147,6 +1148,7 @@ class MlogbwViewSet(CustomModelViewSet): filterset_class = MlogbwFilter select_related_fields = ["ftest", "equip", "tooling", "wpr", "mlogb"] prefetch_related_fields = [ + "files", Prefetch( "ftest__items_ftest", queryset=FtestItem.objects.select_related( @@ -1166,6 +1168,21 @@ class MlogbwViewSet(CustomModelViewSet): ordering = ["mlogb", "number", "create_time"] ordering_fields = ["number", "create_time", "mlogb"] + @action(detail=True, methods=["get", "put"], url_path="files") + @transaction.atomic + def files(self, request, *args, **kwargs): + instance = self.get_object() + if instance.mlogb.material_out_id is None: + raise ParseError("只能给输出单件上传附件") + if request.method == "PUT": + MlogViewSet.lock_and_check_can_update(instance.mlogb.mlog) + serializer = MlogbwFilesSerializer(instance, data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + else: + serializer = MlogbwFilesSerializer(instance) + return Response(serializer.data) + def perform_create(self, serializer): ins: Mlogbw = serializer.save() mlog: Mlog = None