Compare commits

...

2 Commits

Author SHA1 Message Date
caoqianming 6068f2315d Expose independent material defect grades 2026-07-30 11:15:29 +08:00
caoqianming 298a32f744 Refine individual defect lifecycle 2026-07-30 09:40:29 +08:00
11 changed files with 644 additions and 63 deletions

View File

@ -436,11 +436,18 @@ class InmService:
raise ParseError("出入库与明细数量不一致,操作失败")
for mioitemw in mioitemws:
if mioitemw.wpr:
Wpr.change_or_new(wpr=mioitemw.wpr, mb=mb, ftest=mioitemw.ftest)
wpr = Wpr.change_or_new(
wpr=mioitemw.wpr,
mb=mb,
)
else:
wpr = Wpr.change_or_new(number=mioitemw.number, mb=mb, ftest=mioitemw.ftest)
wpr = Wpr.change_or_new(
number=mioitemw.number,
mb=mb,
)
mioitemw.wpr = wpr
mioitemw.save()
wpr.apply_ftest(mioitemw.ftest)
elif in_or_out == -1:
mb.count = mb.count - change_count

View File

@ -221,7 +221,9 @@ class RouteSerializer(CustomModelSerializer):
def validate(self, attrs):
if attrs.get('material_out') and attrs.get('material_out_create'):
raise ParseError('选择已有输出物料和新建半成品不可同时提交')
raise serializers.ValidationError(
'选择已有输出物料和新建半成品不可同时提交'
)
if attrs.get('routepack', None):
attrs['material'] = attrs['routepack'].material
if 'mgroup' in attrs and attrs['mgroup']:

View File

@ -189,7 +189,10 @@ class BaseModel(models.Model):
is_create = False
if not self.id:
is_create = True
self.id = idWorker.get_id()
self.id = str(idWorker.get_id())
else:
# BaseModel 的主键是 CharField保证新建后内存值与数据库回读值类型一致。
self.id = str(self.id)
old_parent = None
need_handle_parent = False
@ -209,7 +212,7 @@ class BaseModel(models.Model):
except IntegrityError as e:
if is_create:
time.sleep(0.01)
self.id = idWorker.get_id()
self.id = str(idWorker.get_id())
ins = super().save(*args, **kwargs)
raise e
# 处理父级

View File

@ -1,14 +1,38 @@
from datetime import timedelta
from unittest.mock import patch
from django.test import TestCase
from django.utils import timezone
from apps.hrm.models import Employee
from apps.hrm.services import HrmService
from apps.vm.models import Visit
# Create your tests here.
class VisitTest(TestCase):
def test_audit_end(self):
v = Visit.objects.get(id='1547401054033940480')
ep = Employee.objects.get(id='1547406632055607296')
HrmService.sync_dahua_employee(ep, '', v.visit_time, v.leave_time)
@patch.object(HrmService, 'sync_dahua_employee')
def test_sync_employee_uses_visit_time_window(self, sync_employee):
visit_time = timezone.now()
leave_time = visit_time + timedelta(hours=2)
visit = Visit.objects.create(
purpose=Visit.V_PURPOSE_CHOICES[0][0],
level=Visit.V_LEVEL_CHOICES[0][0],
name='访客同步测试',
visit_time=visit_time,
leave_time=leave_time,
)
employee = Employee.objects.create(name='测试访客', type='visitor')
HrmService.sync_dahua_employee(
employee,
'',
visit.visit_time,
visit.leave_time,
)
sync_employee.assert_called_once_with(
employee,
'',
visit_time,
leave_time,
)

View File

@ -132,7 +132,8 @@ class WMaterialFilter(filters.FilterSet):
"notok_sign": ["exact", "in", "isnull"],
"count_xtest": ["gte", "isnull"],
"supplier": ["exact"],
"defect": ["exact", "isnull"]
"defect": ["exact", "isnull"],
"defect__okcate": ["exact", "in"],
}
class MlogFilter(filters.FilterSet):

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.27 on 2026-07-30 01:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wpm', '0136_alter_handover_global_scope_fields'),
]
operations = [
migrations.AddField(
model_name='mlog',
name='wpr_defect_snapshot',
field=models.JSONField(blank=True, editable=False, null=True, verbose_name='单件缺陷提交前快照'),
),
]

View File

@ -367,6 +367,12 @@ class Mlog(CommonADModel):
'产出库存归属', choices=WmScope.choices, default=WmScope.DEPT)
stored_notok = models.BooleanField('不合格品是否已入库', default=False)
clear_defect = models.BooleanField('合格B类记为合格', default=False)
wpr_defect_snapshot = models.JSONField(
'单件缺陷提交前快照',
null=True,
blank=True,
editable=False,
)
route = models.ForeignKey(Route, verbose_name='生产路线', on_delete=models.SET_NULL, null=True, blank=True)
mtask = models.ForeignKey(
Mtask, verbose_name='关联任务', on_delete=models.CASCADE, null=True, blank=True, related_name='mlog_mtask')

View File

@ -189,12 +189,21 @@ class WMaterialSerializer(CustomModelSerializer):
material_origin_name = serializers.StringRelatedField(source='material_origin', read_only=True)
notok_sign_name = serializers.SerializerMethodField()
defect_name = serializers.CharField(source="defect.name", read_only=True)
defect_okcate = serializers.IntegerField(source="defect.okcate", read_only=True)
defect_grade = serializers.SerializerMethodField()
defect_grade_name = serializers.SerializerMethodField()
count_working = serializers.SerializerMethodField(label='在制数量')
count_handovering = serializers.SerializerMethodField(label='正在交送的数量')
def get_notok_sign_name(self, obj):
return getattr(NotOkOption, obj.notok_sign, NotOkOption.qt).label if obj.notok_sign else None
def get_defect_grade(self, obj):
return obj.defect.okcate if obj.defect else None
def get_defect_grade_name(self, obj):
return obj.defect.get_okcate_display() if obj.defect else None
def get_count_working(self, obj):
# 列表接口 queryset 已注解(单次聚合); 嵌套等无注解场景回退模型属性
# 保留 decimal 精度(原 IntegerField 会截断在途量, 导致可用/可交接量偏大)
@ -347,8 +356,7 @@ class MlogListSerializer(CustomModelSerializer):
class Meta:
model = Mlog
fields = '__all__'
# exclude = ["equipments","handle_users", "material_outs"]
exclude = ["wpr_defect_snapshot"]
class MlogbDetailSerializer(CustomModelSerializer):
@ -419,7 +427,7 @@ class MlogSerializer(CustomModelSerializer):
wm_in_state = serializers.IntegerField(source='wm_in.state', read_only=True)
class Meta:
model = Mlog
fields = '__all__'
exclude = ["wpr_defect_snapshot"]
read_only_fields = EXCLUDE_FIELDS + \
['submit_time', 'submit_user', 'material_outs']
extra_kwargs = {
@ -1280,6 +1288,9 @@ class HandoverSerializer(CustomModelSerializer):
attrs['type'] = Handover.H_NORMAL
mtype = attrs["mtype"]
clear_defect = bool(attrs.get('clear_defect', False))
if clear_defect and mtype != Handover.H_MERGE:
raise ParseError('仅合批时可清除批次缺陷')
new_wm:WMaterial = attrs.get("new_wm", None)
if new_wm:
@ -1404,11 +1415,17 @@ class HandoverSerializer(CustomModelSerializer):
elif deptOrmgroupId != current_mdept_id:
raise ParseError(f'{ind+1}行-交接物料所属工段/车间不一致')
if mtype == Handover.H_MERGE:
clear_defect = attrs.get('clear_defect', False)
if clear_defect and wm.state != WMaterial.WM_OK:
raise ParseError(f'{ind+1}行-清除缺陷合批仅支持合格品')
if clear_defect and new_wm is not None and new_wm.defect is not None:
raise ParseError('清除缺陷合批的目标批次不能带缺陷')
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
):
raise ParseError(
f'{ind+1}行-批次追踪物料仅合格B类可清除批次缺陷'
)
if next_mat is None:
next_mat = wm.material
next_state = wm.state
@ -1533,7 +1550,7 @@ class HandoverSerializer(CustomModelSerializer):
elif count == wm.count:
wpr_qs = Wpr.get_qs_by_wm(wm)
for item in wpr_qs:
Handoverbw.objects.get_or_create(wpr=item, handoverb=handoverb,
Handoverbw.objects.get_or_create(wpr=item, handoverb=hb,
defaults={"number": item.number})
else:
raise ParseError(f'{ind+1}行-请提供交接物料明细')

View File

@ -165,6 +165,73 @@ def get_pcoal_heat(year_s: int, month_s: int, day_s: int):
return 25000
def snapshot_mlog_wpr_defects(mlog: Mlog):
"""锁定并保存日志提交前已有单件的缺陷状态。"""
wpr_ids = list(Mlogbw.objects.filter(
mlogb__mlog=mlog,
wpr__isnull=False,
).values_list("wpr_id", flat=True).distinct())
list(Wpr.objects.select_for_update().filter(
id__in=wpr_ids,
).values_list("id", flat=True))
snapshot = {str(wpr_id): [] for wpr_id in wpr_ids}
for defect in WprDefect.objects.filter(wpr_id__in=wpr_ids).values(
"wpr_id",
"defect_id",
"is_main",
):
snapshot[str(defect["wpr_id"])].append({
"defect_id": str(defect["defect_id"]),
"is_main": defect["is_main"],
})
mlog.wpr_defect_snapshot = snapshot
mlog.save(update_fields=["wpr_defect_snapshot", "update_time"])
return snapshot
def restore_mlog_wpr_defects(mlog: Mlog):
"""按快照精确恢复日志提交前的单件缺陷。"""
snapshot = mlog.wpr_defect_snapshot
if snapshot is None:
return False
wpr_ids = list(snapshot)
wprs = {
str(wpr.id): wpr
for wpr in Wpr.objects.select_for_update().filter(id__in=wpr_ids)
}
WprDefect.objects.filter(wpr_id__in=wpr_ids).delete()
restored = []
for wpr_id, defects in snapshot.items():
wpr = wprs.get(str(wpr_id))
if wpr is None:
myLogger.warning(
"生产日志%s测回时未找到快照中的单件%s",
mlog.id,
wpr_id,
)
continue
restored.extend(
WprDefect(
wpr=wpr,
defect_id=item["defect_id"],
is_main=item.get("is_main", False),
)
for item in defects
)
WprDefect.objects.bulk_create(restored)
return True
def restore_legacy_wpr_defects_from_wm(wpr: Wpr, wm: WMaterial):
"""兼容无快照历史日志的旧测回行为。"""
defects = []
if wm.defect_id:
defects.append({"defect_id": wm.defect_id, "is_main": True})
wpr.replace_defects(defects)
def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
"""
生产日志提交后需要执行的操作
@ -200,6 +267,8 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
m_ins_list = []
m_ins_bl_list = []
wpr_defect_snapshot = snapshot_mlog_wpr_defects(mlog)
if is_fix:
can_matoutIds = process.get_canout_mat_ids()
# 建立关系链
@ -279,7 +348,8 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
if mlogbws.count() != mi_count:
raise ParseError("日志与实际使用明细数量不一致,操作失败")
for item in mlogbws:
Wpr.change_or_new(wpr=item.wpr, old_wm=wm, ftest=item.ftest)
wpr = Wpr.change_or_new(wpr=item.wpr, old_wm=wm)
wpr.apply_ftest(item.ftest)
# 针对加工前不良的暂时额外处理
# 合格B类的需要当合格品
@ -312,7 +382,12 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
if mlogbws.count() != count:
raise ParseError("日志与加工前不良明细数量不一致,操作失败")
for item in mlogbws:
Wpr.change_or_new(wpr=item.wpr, wm=wm, old_wm=wm, ftest=item.ftest)
wpr = Wpr.change_or_new(
wpr=item.wpr,
wm=wm,
old_wm=wm,
)
wpr.apply_ftest(item.ftest)
mlogb_out_qs = Mlogb.objects.filter(mlog=mlog, material_out__isnull=False)
@ -419,18 +494,35 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
raise ParseError("日志与明细数量不一致,操作失败")
for item in mlogbws:
if item.wpr:
Wpr.change_or_new(wpr=item.wpr, wm=wm, ftest=item.ftest)
wpr = Wpr.change_or_new(
wpr=item.wpr,
wm=wm,
)
else:
if not check_wpr_number(item.number):
raise ParseError(f'{item.number} 该编号格式有误')
wpr_from = None
if item.mlogbw_from:
wpr_from = item.mlogbw_from.wpr
wpr = Wpr.change_or_new(number=item.number,
wm=wm, ftest=item.ftest,
wpr_from=wpr_from)
wpr = Wpr.change_or_new(
number=item.number,
wm=wm,
wpr_from=wpr_from,
)
item.wpr = wpr
item.save()
wpr_id = str(wpr.id)
if wpr_id not in wpr_defect_snapshot:
wpr_defect_snapshot[wpr_id] = [
{
"defect_id": str(defect["defect_id"]),
"is_main": defect["is_main"],
}
for defect in WprDefect.objects.filter(
wpr=wpr,
).values("defect_id", "is_main")
]
wpr.apply_ftest(item.ftest)
mlog.submit_time = now
mlog.submit_user = user
@ -438,6 +530,7 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
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.wpr_defect_snapshot = wpr_defect_snapshot
mlog.save()
# 更新任务进度
@ -471,6 +564,7 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
stored_scope = mlog.stored_scope
stored_location = WmScope.resolve_location(stored_scope, mgroup)
is_fix = mlog.is_fix
has_wpr_defect_snapshot = mlog.wpr_defect_snapshot is not None
if is_fix:
can_matoutIds = process.get_canout_mat_ids()
@ -585,7 +679,8 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
if mlogbws.count() != mo_count:
raise ParseError("日志与明细数量不一致,操作失败")
for item in mlogbws:
Wpr.change_or_new(wpr=item.wpr, old_wm=wm)
wpr = Wpr.change_or_new(wpr=item.wpr, old_wm=wm)
wpr.revert_ftest_info(item.ftest)
# 再生成消耗
m_ins_list = []
@ -629,7 +724,9 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
if mlogbws.count() != mi_count:
raise ParseError("日志与实际使用明细数量不一致,操作失败")
for item in mlogbws:
Wpr.change_or_new(wpr=item.wpr, wm=wm)
wpr = Wpr.change_or_new(wpr=item.wpr, wm=wm)
if not has_wpr_defect_snapshot:
restore_legacy_wpr_defects_from_wm(wpr, wm)
# 针对加工前不良的暂时额外处理
if stored_notok:
@ -662,7 +759,18 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
if mlogbws.count() != count:
raise ParseError("日志与加工前不良明细数量不一致,操作失败")
for item in mlogbws:
Wpr.change_or_new(wpr=item.wpr, wm=wm)
wpr = Wpr.change_or_new(wpr=item.wpr, wm=wm)
if not has_wpr_defect_snapshot:
restore_legacy_wpr_defects_from_wm(wpr, wm)
if has_wpr_defect_snapshot:
restore_mlog_wpr_defects(mlog)
mlog.wpr_defect_snapshot = None
else:
myLogger.warning(
"生产日志%s无单件缺陷快照,测回按历史兼容逻辑处理,无法精确恢复提交前缺陷",
mlog.id,
)
mlog.submit_time = None
mlog.submit_user = None
@ -873,14 +981,20 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
WMaterial.decrease(wm_id=wm_from.id, user=user, count=xcount)
if need_add:
target_defect = (
merge_target_defect
if mtype == Handover.H_MERGE
else wm_from.defect
)
# 开始变动
if handover.type == Handover.H_NORMAL:
if mtype == Handover.H_MERGE and handover.new_wm:
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
defect_ok = wm_to.defect == wm_from.defect or (
merge_clear_defect and wm_to.defect is None and wm_from.state == WMaterial.WM_OK
)
if wm_to.state != wm_from.state or wm_to.material != wm_from.material or not defect_ok:
if (
wm_to.state != wm_from.state
or wm_to.material != wm_from.material
or wm_to.defect != target_defect
):
raise ParseError("正常合并到的车间库存状态或物料异常")
else:
wm_to, _ = WMaterial.locked_get_or_create_inventory(
@ -890,7 +1004,7 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
belong_dept=recive_dept,
state=wm_from.state,
notok_sign=wm_from.notok_sign,
defect=merge_target_defect if mtype == Handover.H_MERGE else wm_from.defect,
defect=target_defect,
defaults={
"batch_ofrom": wm_from.batch_ofrom,
"material_ofrom": wm_from.material_ofrom,
@ -904,7 +1018,11 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
wm_state = WMaterial.WM_REPAIR
if mtype == Handover.H_MERGE and handover.new_wm:
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
if wm_to.state != WMaterial.WM_REPAIR or wm_to.material != wm_from.material or wm_to.defect != wm_from.defect:
if (
wm_to.state != WMaterial.WM_REPAIR
or wm_to.material != wm_from.material
or wm_to.defect != target_defect
):
raise ParseError("返修合并到的车间库存状态或物料异常")
elif recive_mgroup:
wm_to, _ = WMaterial.locked_get_or_create_inventory(
@ -913,7 +1031,7 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
mgroup=recive_mgroup,
belong_dept=recive_dept,
notok_sign=wm_from.notok_sign,
defect=wm_from.defect,
defect=target_defect,
material_origin=material,
state=wm_state,
defaults={
@ -928,7 +1046,11 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
elif handover.type == Handover.H_SCRAP:
if mtype == Handover.H_MERGE and handover.new_wm:
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
if wm_to.state != WMaterial.WM_SCRAP or wm_to.material != wm_from.material or wm_to.defect != wm_from.defect:
if (
wm_to.state != WMaterial.WM_SCRAP
or wm_to.material != wm_from.material
or wm_to.defect != target_defect
):
raise ParseError("报废合并到的车间库存状态或物料异常")
elif recive_mgroup:
wm_to, _ = WMaterial.locked_get_or_create_inventory(
@ -937,7 +1059,7 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
mgroup=recive_mgroup,
belong_dept=recive_dept,
notok_sign=wm_from.notok_sign,
defect=wm_from.defect,
defect=target_defect,
state=WMaterial.WM_SCRAP,
defaults={
"batch_ofrom": wm_from.batch_ofrom,
@ -951,7 +1073,11 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
elif handover.type == Handover.H_CHANGE:
if mtype == Handover.H_MERGE and handover.new_wm:
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
if wm_to.material != handover.material_changed or wm_to.state != handover.state_changed:
if (
wm_to.material != handover.material_changed
or wm_to.state != handover.state_changed
or wm_to.defect != target_defect
):
raise ParseError("改版合并到的车间库存状态或物料异常")
elif handover.recive_mgroup:
wm_to, _ = WMaterial.locked_get_or_create_inventory(
@ -961,7 +1087,7 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
mgroup=recive_mgroup,
belong_dept=recive_dept,
notok_sign=wm_from.notok_sign,
defect=wm_from.defect,
defect=target_defect,
material_origin=None if handover.material_changed == material else material,
state_origin=None if handover.state_changed == wm_from.state else wm_from.state,
defaults={
@ -978,6 +1104,12 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
raise ParseError("仅合格品支持退回")
if mtype == Handover.H_MERGE and handover.new_wm:
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
if (
wm_to.state != wm_from.state
or wm_to.material != wm_from.material
or wm_to.defect != target_defect
):
raise ParseError("退料合并到的车间库存状态或物料异常")
else:
wm_to, _ = WMaterial.locked_get_or_create_inventory(
batch=batch,
@ -986,7 +1118,7 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
belong_dept=recive_dept,
state=wm_from.state,
notok_sign=wm_from.notok_sign,
defect=merge_target_defect if mtype == Handover.H_MERGE else wm_from.defect,
defect=target_defect,
defaults={
"batch_ofrom": wm_from.batch_ofrom,
"material_ofrom": wm_from.material_ofrom,
@ -1011,7 +1143,12 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
raise ParseError(f'{batch}物料明细中存在{len(wm_ids)}个不同物料批次')
for item in handoverbws:
wpr:Wpr = item.wpr
Wpr.change_or_new(wpr=wpr, wm=wm_to, old_wm=wpr.wm, old_mb=wpr.mb)
Wpr.change_or_new(
wpr=wpr,
wm=wm_to,
old_wm=wpr.wm,
old_mb=wpr.mb,
)
db_count = WMaterial.objects.filter(id=wm_to.id).values_list("count", flat=True).get()
if db_count != Wpr.objects.filter(wm=wm_to).count():
raise ParseError("交接与明细数量不一致2,操作失败")
@ -1066,7 +1203,13 @@ def handover_revert(handover:Handover, handler:User=None):
wm = WMaterial.objects.get(id=wm.id)
for item in handoverbws:
wpr:Wpr = item.wpr
Wpr.change_or_new(wpr=wpr, wm=wm, old_wm=wpr.wm, old_mb=wpr.mb, add_version=False)
Wpr.change_or_new(
wpr=wpr,
wm=wm,
old_wm=wpr.wm,
old_mb=wpr.mb,
add_version=False,
)
handover.submit_time = None
handover.submit_user = None

View File

@ -9,11 +9,18 @@ from django.test import SimpleTestCase, TestCase, TransactionTestCase
from unittest import skipUnless
from apps.mtm.models import Material, Mgroup, WmScope
from apps.qm.models import Defect
from apps.system.models import Dept, User
from apps.wpm.filters import HandoverFilter, WMaterialFilter
from apps.wpm.models import Handover, WMaterial
from apps.wpm.serializers import HandoverSerializer, WMaterialCreateSerializer
from apps.wpm.serializers import (
HandoverSerializer,
WMaterialCreateSerializer,
WMaterialSerializer,
)
from apps.wpm.services import restore_mlog_wpr_defects, snapshot_mlog_wpr_defects
from apps.wpm.views import MlogbwViewSet
from apps.wpmw.models import Wpr, WprDefect
from rest_framework.exceptions import ParseError
@ -97,6 +104,87 @@ class HandoverFilterTests(TestCase):
])
class WMaterialDefectGradeTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.material = Material.objects.create(name="缺陷等级测试物料")
cls.defect_b = Defect.objects.create(
name="B类缺陷",
cate=Defect.cate_list[0],
okcate=Defect.DEFECT_OK_B,
)
cls.defect_notok = Defect.objects.create(
name="不合格缺陷",
cate=Defect.cate_list[0],
okcate=Defect.DEFECT_NOTOK,
)
cls.normal_with_notok_defect = WMaterial.objects.create(
material=cls.material,
batch="NORMAL-NOTOK",
count=1,
state=WMaterial.WM_OK,
defect=cls.defect_notok,
)
cls.notok_with_b_defect = WMaterial.objects.create(
material=cls.material,
batch="NOTOK-B",
count=1,
state=WMaterial.WM_NOTOK,
defect=cls.defect_b,
)
cls.normal_without_defect = WMaterial.objects.create(
material=cls.material,
batch="NORMAL-NONE",
count=1,
state=WMaterial.WM_OK,
)
def test_serializer_exposes_nullable_defect_grade_without_using_state(self):
normal_notok_data = WMaterialSerializer(
self.normal_with_notok_defect
).data
notok_b_data = WMaterialSerializer(self.notok_with_b_defect).data
no_defect_data = WMaterialSerializer(self.normal_without_defect).data
self.assertEqual(
normal_notok_data["defect_grade"], Defect.DEFECT_NOTOK
)
self.assertEqual(normal_notok_data["defect_grade_name"], "不合格")
self.assertEqual(
notok_b_data["defect_grade"], Defect.DEFECT_OK_B
)
self.assertEqual(notok_b_data["defect_grade_name"], "合格B类")
self.assertIsNone(no_defect_data["defect_grade"])
self.assertIsNone(no_defect_data["defect_grade_name"])
def test_filtering_state_and_defect_grade_are_independent(self):
normal_notok = WMaterialFilter(
{
"state": WMaterial.WM_OK,
"defect__okcate": Defect.DEFECT_NOTOK,
},
queryset=WMaterial.objects.all(),
).qs
notok_b = WMaterialFilter(
{
"state": WMaterial.WM_NOTOK,
"defect__okcate": Defect.DEFECT_OK_B,
},
queryset=WMaterial.objects.all(),
).qs
self.assertQuerySetEqual(
normal_notok,
[self.normal_with_notok_defect],
transform=lambda item: item,
)
self.assertQuerySetEqual(
notok_b,
[self.notok_with_b_defect],
transform=lambda item: item,
)
class MlogbwViewSetTests(SimpleTestCase):
@patch("apps.wpm.views.MlogViewSet.lock_and_check_can_update")
@patch("apps.wpm.views.Mlogbw.cal_count_notok")
@ -346,6 +434,235 @@ class WMaterialScopeTests(SimpleTestCase):
self.assertIs(validated["send_mgroup"], mgroup)
self.assertIs(validated["send_dept"], sending_dept)
def test_batch_tracking_merge_can_clear_ok_b_batch_defects(self):
material = Material(tracking=Material.MA_TRACKING_BATCH)
defect_a = Defect(id="1", okcate=Defect.DEFECT_OK_B)
defect_b = Defect(id="2", okcate=Defect.DEFECT_OK_B)
wm_a = WMaterial(
id="10", material=material, batch="B-001", count=1,
state=WMaterial.WM_OK, defect=defect_a,
)
wm_b = WMaterial(
id="20", material=material, batch="B-002", count=1,
state=WMaterial.WM_OK, defect=defect_b,
)
validated = HandoverSerializer().validate({
"wm": wm_a,
"handoverb": [
{"wm": wm_a, "count": 1},
{"wm": wm_b, "count": 1},
],
"new_batch": "B-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_notok_defect(self):
material = Material(tracking=Material.MA_TRACKING_BATCH)
defect = Defect(id="1", okcate=Defect.DEFECT_NOTOK)
wm = WMaterial(
id="10", material=material, batch="N-001", count=1,
state=WMaterial.WM_NOTOK, defect=defect,
)
with self.assertRaisesMessage(
ParseError,
"批次追踪物料仅合格B类可清除批次缺陷",
):
HandoverSerializer().validate({
"wm": wm,
"handoverb": [{"wm": wm, "count": 1}],
"new_batch": "N-MERGED",
"clear_defect": True,
"type": Handover.H_NORMAL,
"mtype": Handover.H_MERGE,
})
def test_single_tracking_merge_can_clear_notok_batch_defect(self):
material = Material(tracking=Material.MA_TRACKING_SINGLE)
defect = Defect(id="1", okcate=Defect.DEFECT_NOTOK)
wm = WMaterial(
id="10", material=material, batch="S-001", count=1,
state=WMaterial.WM_NOTOK, defect=defect,
)
wm_b = WMaterial(
id="20", material=material, batch="S-002", count=1,
state=WMaterial.WM_NOTOK, defect=defect,
)
validated = HandoverSerializer().validate({
"wm": wm,
"handoverb": [
{"wm": wm, "count": 1},
{"wm": wm_b, "count": 1},
],
"new_batch": "S-MERGED",
"clear_defect": True,
"type": Handover.H_NORMAL,
"mtype": Handover.H_MERGE,
})
self.assertTrue(validated["clear_defect"])
self.assertEqual(validated["count"], 2)
class WprDefectLifecycleTests(SimpleTestCase):
@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)
old_wm = WMaterial(
id="10", material=material, state=WMaterial.WM_OK,
)
target_wm = WMaterial(
id="20", material=material, state=WMaterial.WM_OK,
defect=None,
)
wpr = SimpleNamespace(
wm=old_wm,
mb=None,
material=material,
state=WMaterial.WM_OK,
version=1,
save=MagicMock(),
)
Wpr.change_or_new(
wpr=wpr,
wm=target_wm,
old_wm=old_wm,
)
defect_filter.assert_not_called()
self.assertIs(wpr.wm, target_wm)
@patch.object(Wpr, "replace_defects")
@patch("apps.wpmw.models.FtestDefect.objects.filter")
@patch("apps.wpmw.models.FtestItem.objects.filter")
def test_apply_ftest_replaces_defects_and_carries_info(
self,
ftest_item_filter,
ftest_defect_filter,
replace_defects,
):
ftest = object()
ftest_defect_filter.return_value.values.return_value = [
{"defect_id": "30", "is_main": False},
{"defect_id": "31", "is_main": True},
]
ftest_item_filter.return_value.select_related.return_value = [
SimpleNamespace(
id="40",
test_val_json={"value": 1},
testitem=SimpleNamespace(name="尺寸"),
),
]
wpr = Wpr(
oinfo={"existing": {"value": 2}},
)
wpr.save = MagicMock()
wpr.apply_ftest(ftest)
ftest_defect_filter.assert_called_once_with(ftest=ftest, has=True)
replace_defects.assert_called_once_with([
{"defect_id": "30", "is_main": False},
{"defect_id": "31", "is_main": True},
])
self.assertEqual(
wpr.oinfo["40"],
{"name": "尺寸", "val": {"value": 1}},
)
@patch.object(Wpr, "replace_defects")
def test_apply_no_ftest_clears_defects(self, replace_defects):
wpr = Wpr(oinfo={"existing": True})
wpr.apply_ftest(None)
replace_defects.assert_called_once_with([])
self.assertEqual(wpr.oinfo, {"existing": True})
@patch("apps.wpmw.models.FtestItem.objects.filter")
def test_revert_ftest_info_only_removes_matching_keys(self, item_filter):
item_filter.return_value.values_list.return_value = ["10", "11"]
wpr = Wpr(oinfo={"10": {"v": 1}, "other": {"v": 2}})
wpr.save = MagicMock()
wpr.revert_ftest_info(object())
self.assertEqual(wpr.oinfo, {"other": {"v": 2}})
wpr.save.assert_called_once()
class MlogWprDefectSnapshotTests(SimpleTestCase):
@patch("apps.wpm.services.WprDefect.objects.filter")
@patch("apps.wpm.services.Wpr.objects.select_for_update")
@patch("apps.wpm.services.Mlogbw.objects.filter")
def test_snapshot_keeps_multiple_defects_and_empty_wpr(
self,
mlogbw_filter,
select_for_update,
defect_filter,
):
mlogbw_filter.return_value.values_list.return_value.distinct.return_value = [
"1",
"2",
]
select_for_update.return_value.filter.return_value.values_list.return_value = [
"1",
"2",
]
defect_filter.return_value.values.return_value = [
{"wpr_id": "1", "defect_id": "10", "is_main": True},
{"wpr_id": "1", "defect_id": "11", "is_main": False},
]
mlog = SimpleNamespace(id="100", save=MagicMock())
snapshot = snapshot_mlog_wpr_defects(mlog)
self.assertEqual(snapshot["2"], [])
self.assertEqual(len(snapshot["1"]), 2)
select_for_update.return_value.filter.return_value.values_list.assert_called_once_with(
"id",
flat=True,
)
@patch("apps.wpm.services.WprDefect.objects.bulk_create")
@patch("apps.wpm.services.WprDefect.objects.filter")
@patch("apps.wpm.services.Wpr.objects.select_for_update")
def test_restore_replaces_current_defects_exactly(
self,
select_for_update,
defect_filter,
bulk_create,
):
wpr = Wpr(id="1")
select_for_update.return_value.filter.return_value = [wpr]
mlog = SimpleNamespace(
id="100",
wpr_defect_snapshot={
"1": [
{"defect_id": "10", "is_main": True},
{"defect_id": "11", "is_main": False},
],
},
)
restored = restore_mlog_wpr_defects(mlog)
self.assertTrue(restored)
defect_filter.return_value.delete.assert_called_once_with()
created = bulk_create.call_args.args[0]
self.assertEqual(
[(str(item.defect_id), item.is_main) for item in created],
[("10", True), ("11", False)],
)
@skipUnless(
connection.vendor == "postgresql",

View File

@ -33,7 +33,10 @@ class Wpr(BaseModel):
pre_info = models.JSONField(verbose_name="预处理信息", default=dict, blank=True, null=True)
@classmethod
def change_or_new(cls, wpr=None, number=None, mb=None, wm=None, old_mb=None, old_wm=None, ftest=None, wpr_from=None, add_version=True, number_out=None):
def change_or_new(
cls, wpr=None, number=None, mb=None, wm=None, old_mb=None,
old_wm=None, wpr_from=None, add_version=True, number_out=None,
):
is_create = False
if wpr is None and number is None:
raise ParseError("id和number不能同时为空")
@ -109,25 +112,9 @@ class Wpr(BaseModel):
ins.state = wm.state
if is_create:
ins.material_start = wm.material
WprDefect.objects.filter(wpr=ins).delete()
if add_version:
ins.version = ins.version + 1
ins.save()
if wm:
if wm.defect:
WprDefect.objects.create(wpr=ins, defect=wm.defect, is_main=True)
if ftest:
# 通过检验变更其缺陷项
WprDefect.objects.filter(wpr=ins).delete()
for ftestdefect in FtestDefect.objects.filter(ftest=ftest, has=True):
WprDefect.objects.create(wpr=ins, defect=ftestdefect.defect, is_main=ftestdefect.is_main)
# 携带某些检测信息
oinfo = {}
for ftestitem in FtestItem.objects.filter(ftest=ftest, addto_wpr=True):
if ftestitem.test_val_json:
oinfo[ftestitem.id] = {"name": ftestitem.testitem.name, "val": ftestitem.test_val_json}
ins.oinfo = update_dict(ins.oinfo, oinfo)
ins.save()
# if ins.mb and ins.wm:
# raise ParseError("所属仓库批次和车间批次不可同时存在2")
if number_out:
@ -138,6 +125,62 @@ class Wpr(BaseModel):
raise ParseError(f"{ins.number}-出库编号-{number_out}-已存在")
return ins
def replace_defects(self, defects):
"""用给定缺陷列表完整替换单件缺陷。"""
WprDefect.objects.filter(wpr=self).delete()
WprDefect.objects.bulk_create([
WprDefect(
wpr=self,
defect_id=item["defect_id"],
is_main=item.get("is_main", False),
)
for item in defects
])
def apply_ftest(self, ftest):
"""应用一次明确的单件检验;无检验结果时缺陷为空。"""
defects = []
if ftest is not None:
defects = list(FtestDefect.objects.filter(
ftest=ftest,
has=True,
).values("defect_id", "is_main"))
self.replace_defects(defects)
if ftest is None:
return
oinfo = {}
for ftestitem in FtestItem.objects.filter(
ftest=ftest,
addto_wpr=True,
).select_related("testitem"):
if ftestitem.test_val_json:
oinfo[str(ftestitem.id)] = {
"name": ftestitem.testitem.name,
"val": ftestitem.test_val_json,
}
if oinfo:
self.oinfo = update_dict(self.oinfo, oinfo)
self.save(update_fields=["oinfo", "update_time"])
def revert_ftest_info(self, ftest):
"""仅撤销指定检验写入单件携带信息的字段。"""
if ftest is None or not self.oinfo:
return
item_ids = FtestItem.objects.filter(
ftest=ftest,
addto_wpr=True,
).values_list("id", flat=True)
changed = False
oinfo = dict(self.oinfo)
for item_id in item_ids:
if oinfo.pop(str(item_id), None) is not None:
changed = True
if changed:
self.oinfo = oinfo
self.save(update_fields=["oinfo", "update_time"])
@classmethod
def clear(cls, number_list):
cls.objects.filter(number__in=number_list).update(mb=None, wm=None)
@ -180,4 +223,4 @@ class WprDefect(BaseModel):
"""TN:产物缺陷项关联表"""
wpr = models.ForeignKey(Wpr, verbose_name="关联产物", on_delete=models.CASCADE)
defect = models.ForeignKey("qm.defect", verbose_name="关联缺陷项", on_delete=models.CASCADE)
is_main = models.BooleanField("是否主要缺陷", default=False)
is_main = models.BooleanField("是否主要缺陷", default=False)