Merge branch 'master' of http://gitea.xxhhcty.xyz:8080/zcdsj/factory
This commit is contained in:
commit
cfcd6c2ba3
|
|
@ -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'),
|
||||
]
|
||||
|
|
@ -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: 测点采集数据
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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="产出附件",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1398,6 +1410,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 +1431,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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -512,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)
|
||||
|
|
@ -635,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,
|
||||
|
|
@ -662,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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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/",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue