Compare commits

..

3 Commits

8 changed files with 300 additions and 8 deletions

View File

@ -10,5 +10,6 @@
- [项目 Python 虚拟环境](reference_python_venv.md):运行 Django、pytest 和脚本时使用。
- [项目测试数据库](reference_test_database.md):测试与可切换的生产查询连接解耦,始终使用固定的 `test_ehs_develop`
- [两个 WebView 套壳 App](reference_wrapper_apps.md):修改 h5x 与扫码、返回键交互时使用。
- [前端验证时机](feedback_frontend_validation.md):日常修改先跑 check完整 build 留到 push 前执行。
这些文件记录的是长期约定或历史上下文。执行任务前应结合当前代码和数据重新验证,尤其不要把历史缺陷结论直接当成当前故障原因。

View File

@ -0,0 +1,5 @@
# 前端验证时机
- 修改配套前端 `../ehs_web` 时,日常开发和中间验证优先运行项目已有的 `check`,不要每次修改后都运行完整 `build`
- 准备 push 前运行一次完整 `build`,用于发现生产构建阶段的问题。
- 若当前前端尚未配置 `check` 脚本,应先说明现状,不得把其他命令擅自当作 `check`

View File

@ -877,7 +877,7 @@ class Handoverb(BaseModel):
@property
def handoverbw(self):
return Handoverbw.objects.filter(handoverb=self)
return self.w_handoverb.all()
class Handoverbw(BaseModel):
"""TN: 单个产品交接记录

View File

@ -31,6 +31,28 @@ from apps.em.models import Equipment
from django.db.models import Q
mylogger = logging.getLogger("log")
WM_STATE_NAMES = {
WMaterial.WM_OK: "合格",
WMaterial.WM_NOTOK: "不合格",
WMaterial.WM_REPAIR: "返修",
WMaterial.WM_REPAIRED: "返修完成",
WMaterial.WM_TEST: "检验",
WMaterial.WM_SCRAP: "报废",
}
class MlogEquipmentOptionSerializer(serializers.ModelSerializer):
mgroup_name = serializers.CharField(source="mgroup.name", read_only=True)
full_name = serializers.SerializerMethodField()
def get_full_name(self, obj):
return f"{obj.number}|{obj.name}|{obj.model}"
class Meta:
model = Equipment
fields = ["id", "name", "number", "model", "mgroup_name", "full_name"]
class OtherLogSerializer(CustomModelSerializer):
class Meta:
model = OtherLog
@ -1286,8 +1308,35 @@ class HandoverbSerializer(CustomModelSerializer):
)
)
notok_sign = serializers.CharField(source='wm.notok_sign', read_only=True)
notok_sign_name = serializers.SerializerMethodField()
defect_name = serializers.CharField(source="wm.defect.name", read_only=True)
defect_grade = serializers.SerializerMethodField()
defect_grade_name = serializers.SerializerMethodField()
material_name = serializers.StringRelatedField(source="wm.material", read_only=True)
state_name = serializers.SerializerMethodField()
mgroup_name = serializers.CharField(source="wm.mgroup.name", read_only=True)
belong_dept_name = serializers.CharField(source="wm.belong_dept.name", read_only=True)
count_available = serializers.SerializerMethodField()
handoverbw = Handoverbwserializer(many=True, required=False)
def get_notok_sign_name(self, obj):
return getattr(NotOkOption, obj.wm.notok_sign, NotOkOption.qt).label if obj.wm.notok_sign else None
def get_defect_grade(self, obj):
return effective_defect_grade(obj.wm, "notok_sign")
def get_defect_grade_name(self, obj):
return DEFECT_GRADE_NAMES[self.get_defect_grade(obj)]
def get_state_name(self, obj):
return WM_STATE_NAMES.get(obj.wm.state, str(obj.wm.state))
def get_count_available(self, obj):
# 编辑未提交交接时,当前明细占用的数量仍应允许重新填写。
if obj.handover.submit_time is not None:
return obj.count
return obj.wm.count - obj.wm.count_handovering + obj.count
class Meta:
model = Handoverb
fields = "__all__"
@ -1310,9 +1359,14 @@ class HandoverSerializer(CustomModelSerializer):
recive_user_name = serializers.CharField(
source='recive_user.name', read_only=True)
recive_dept_name = serializers.CharField(
source='recive_dept', read_only=True)
source='recive_dept.name', read_only=True)
send_dept_name = serializers.CharField(source='send_dept.name', read_only=True)
send_mgroup_name = serializers.CharField(source='send_mgroup.name', read_only=True)
recive_mgroup_name = serializers.CharField(source='recive_mgroup.name', read_only=True)
submit_user_name = serializers.CharField(source='submit_user.name', read_only=True)
type_name = serializers.CharField(source='get_type_display', read_only=True)
mtype_name = serializers.CharField(source='get_mtype_display', read_only=True)
state_changed_name = serializers.SerializerMethodField()
material_ = MaterialSimpleSerializer(source='material', read_only=True)
material_name = serializers.StringRelatedField(
source='material', read_only=True)
@ -1321,6 +1375,9 @@ class HandoverSerializer(CustomModelSerializer):
handoverb = HandoverbSerializer(many=True, required=False)
ticket_ = TicketSimpleSerializer(source='ticket', read_only=True)
def get_state_changed_name(self, obj):
return WM_STATE_NAMES.get(obj.state_changed) if obj.state_changed is not None else None
def to_internal_value(self, data):
handoverb = data.get("handoverb", []) if hasattr(data, "get") else []
wm_ids = {

View File

@ -1,4 +1,5 @@
import datetime
from collections import defaultdict
from django.core.cache import cache
from django.db.models import Sum
@ -27,6 +28,44 @@ from django.db.models import F
myLogger = logging.getLogger('log')
RECENT_EQUIPMENT_LOG_LIMIT = 50
def get_recent_mgroup_equipment_ids(
mgroup_id, log_limit=RECENT_EQUIPMENT_LOG_LIMIT
):
"""按日志时间倒序返回工段最近使用过的设备 ID空值和重复值忽略。"""
recent_logs = list(
Mlog.objects.filter(mgroup_id=mgroup_id)
.order_by("-create_time", "-id")
.values_list("id", "equipment_id", "equipment_2_id")[:log_limit]
)
if not recent_logs:
return []
log_ids = [log_id for log_id, _, _ in recent_logs]
multiple_equipment_ids = defaultdict(list)
for log_id, equipment_id in (
Mlog.equipments.through.objects.filter(mlog_id__in=log_ids)
.order_by("id")
.values_list("mlog_id", "equipment_id")
):
multiple_equipment_ids[log_id].append(equipment_id)
result = []
seen = set()
for log_id, equipment_id, equipment_2_id in recent_logs:
candidate_ids = [
equipment_id,
equipment_2_id,
*multiple_equipment_ids[log_id],
]
for candidate_id in candidate_ids:
if candidate_id and candidate_id not in seen:
seen.add(candidate_id)
result.append(candidate_id)
return result
def inherit_zt_batch(source: BatchSt, target: BatchSt):
"""拆批/报工改号时目标批继承来源批的直通统计大批归属(纯继承, 不做判定)

View File

@ -0,0 +1,52 @@
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
from apps.wpm.services import get_recent_mgroup_equipment_ids
class RecentMgroupEquipmentTests(SimpleTestCase):
@patch("apps.wpm.services.Mlog")
def test_collects_all_equipment_fields_in_log_order_without_duplicates(
self, mlog
):
log_queryset = MagicMock()
mlog.objects.filter.return_value = log_queryset
log_queryset.order_by.return_value.values_list.return_value.__getitem__.return_value = [
("log-new", "equipment-a", None),
("log-middle", "equipment-b", "equipment-a"),
("log-empty", None, None),
]
through_queryset = MagicMock()
mlog.equipments.through.objects.filter.return_value = through_queryset
through_queryset.order_by.return_value.values_list.return_value = [
("log-new", "equipment-c"),
("log-middle", "equipment-c"),
("log-middle", "equipment-d"),
]
result = get_recent_mgroup_equipment_ids("mgroup-1", log_limit=50)
self.assertEqual(
result,
["equipment-a", "equipment-c", "equipment-b", "equipment-d"],
)
log_queryset.order_by.return_value.values_list.return_value.__getitem__.assert_called_once_with(
slice(None, 50, None)
)
@patch("apps.wpm.services.Mlog")
def test_returns_empty_when_recent_logs_have_no_equipment(self, mlog):
log_queryset = MagicMock()
mlog.objects.filter.return_value = log_queryset
log_queryset.order_by.return_value.values_list.return_value.__getitem__.return_value = [
("log-1", None, None),
("log-2", None, None),
]
through_queryset = MagicMock()
mlog.equipments.through.objects.filter.return_value = through_queryset
through_queryset.order_by.return_value.values_list.return_value = []
result = get_recent_mgroup_equipment_ids("mgroup-1")
self.assertEqual(result, [])

View File

@ -5,7 +5,7 @@ from django.test.utils import CaptureQueriesContext
from apps.mtm.models import Material
from apps.qm.models import Defect
from apps.system.models import Dept, User
from apps.wpm.models import Handover, WMaterial
from apps.wpm.models import Handover, Handoverb, WMaterial
from apps.wpm.serializers import HandoverSerializer
@ -56,3 +56,32 @@ class HandoverSerializerQueryTests(TestCase):
if 'FROM "wpm_wmaterial"' in query["sql"]
]
self.assertEqual(len(inventory_queries), 1, inventory_queries)
def test_detail_contains_display_fields_for_handover_and_items(self):
handover = Handover.objects.create(
send_date="2026-08-07",
send_user=self.user,
send_dept=self.dept,
recive_dept=self.dept,
material=self.material,
wm=self.inventories[0],
count=1,
type=Handover.H_NORMAL,
mtype=Handover.H_NORMAL,
)
Handoverb.objects.create(
handover=handover,
wm=self.inventories[0],
batch=self.inventories[0].batch,
count=1,
)
data = HandoverSerializer(handover).data
self.assertEqual(data["send_dept_name"], self.dept.name)
self.assertEqual(data["recive_dept_name"], self.dept.name)
self.assertEqual(data["type_name"], "正常交接")
self.assertEqual(data["mtype_name"], "正常")
self.assertEqual(data["handoverb"][0]["material_name"], str(self.material))
self.assertEqual(data["handoverb"][0]["state_name"], "合格")
self.assertEqual(data["handoverb"][0]["count_available"], 1)

View File

@ -7,7 +7,7 @@ from rest_framework.decorators import action
from rest_framework.exceptions import ParseError
from rest_framework.response import Response
from rest_framework.serializers import Serializer
from django.db.models import Sum
from django.db.models import Case, IntegerField, Sum, When
from django.utils import timezone
from apps.system.models import User
@ -54,13 +54,23 @@ from .serializers import (
MlogUserSerializer,
BatchLogSerializer,
MlogQuickSerializer,
MlogEquipmentOptionSerializer,
MlogbwStartTestSerializer,
HandoverListSerializer,
BatchChangeSerializer,
MlogbOutPatchUpdateSerializer
)
from .services import mlog_submit, handover_submit, mlog_revert, get_batch_dag, handover_revert
from apps.wpm.services import mlog_submit_validate, generate_new_batch
from .services import (
RECENT_EQUIPMENT_LOG_LIMIT,
generate_new_batch,
get_batch_dag,
get_recent_mgroup_equipment_ids,
handover_revert,
handover_submit,
mlog_revert,
mlog_submit,
mlog_submit_validate,
)
from apps.wf.models import State, Ticket
from apps.wpmw.models import Wpr
from apps.qm.models import Qct, Ftest, TestItem
@ -332,6 +342,92 @@ class MlogViewSet(CustomModelViewSet):
]
ordering_fields = ["create_time", "update_time"]
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(
name="mgroup",
in_=openapi.IN_QUERY,
description="日志所属工段",
type=openapi.TYPE_STRING,
required=True,
),
openapi.Parameter(
name="search",
in_=openapi.IN_QUERY,
description="按设备名称或编号搜索全部生产设备",
type=openapi.TYPE_STRING,
required=False,
),
]
)
@action(
methods=["get"],
detail=False,
perms_map={"get": "*"},
serializer_class=MlogEquipmentOptionSerializer,
)
def equipment_options(self, request, *args, **kwargs):
"""返回本工段设备、最近 50 条日志用过的设备或搜索结果。"""
mgroup_id = request.query_params.get("mgroup")
if not mgroup_id:
raise ParseError("请传入mgroup参数")
search = request.query_params.get("search", "").strip()
owned_ids = list(
Equipment.objects.filter(
type=Equipment.EQUIP_TYPE_PRO,
mgroup_id=mgroup_id,
)
.order_by("name", "number")
.values_list("id", flat=True)
)
owned_id_set = set(owned_ids)
if search:
queryset = (
Equipment.objects.filter(type=Equipment.EQUIP_TYPE_PRO)
.filter(Q(name__icontains=search) | Q(number__icontains=search))
.order_by("name", "number")
)
option_group = "搜索结果"
else:
recent_ids = get_recent_mgroup_equipment_ids(
mgroup_id, RECENT_EQUIPMENT_LOG_LIMIT
)
option_ids = list(dict.fromkeys([*owned_ids, *recent_ids]))
if option_ids:
order = Case(
*[
When(id=equipment_id, then=position)
for position, equipment_id in enumerate(option_ids)
],
output_field=IntegerField(),
)
queryset = Equipment.objects.filter(
id__in=option_ids,
type=Equipment.EQUIP_TYPE_PRO,
).order_by(order)
else:
queryset = Equipment.objects.none()
option_group = None
queryset = queryset.select_related("mgroup")
page = self.paginate_queryset(queryset)
equipment_list = page if page is not None else queryset
data = MlogEquipmentOptionSerializer(
equipment_list,
many=True,
context=self.get_serializer_context(),
).data
for item in data:
item["option_group"] = option_group or (
"本工段设备" if item["id"] in owned_id_set else "近期使用"
)
if page is not None:
return self.get_paginated_response(data)
return Response(data)
def add_info_for_item(self, data):
if data.get("oinfo_json", {}):
czx_dict = dict(TestItem.objects.filter(id__in=data.get("oinfo_json", {}).keys()).values_list("id", "name"))
@ -592,7 +688,20 @@ class HandoverViewSet(CustomModelViewSet):
select_related_fields = ["send_user", "send_mgroup", "send_dept", "recive_user", "recive_mgroup", "recive_dept", "wm", "material_changed", "material", "material__process"]
filterset_class = HandoverFilter
search_fields = ["material__name", "material__number", "material__specification", "batch", "material__model", "b_handover__batch", "new_batch", "wm__batch"]
prefetch_related_fields = [Prefetch("b_handover", queryset=Handoverb.objects.select_related("wm__defect")), "ticket__state"]
prefetch_related_fields = ["ticket__state"]
def get_queryset_custom(self, queryset):
if self.action not in ["list", "retrieve"]:
return queryset
detail_queryset = Handoverb.objects.select_related(
"handover", "wm__defect", "wm__material", "wm__mgroup", "wm__belong_dept"
)
if self.action == "retrieve":
detail_queryset = detail_queryset.prefetch_related("w_handoverb")
return queryset.prefetch_related(
Prefetch("b_handover", queryset=detail_queryset)
)
def perform_destroy(self, instance: Handover):
user = self.request.user
@ -680,7 +789,7 @@ class HandoverViewSet(CustomModelViewSet):
m_qs = m_qs.filter(process__route_p__material_in__id=materialInId) | m_qs.filter(process__route_p__routemat_route__material__id=materialInId)
elif type in [Handover.H_SCRAP]:
m_qs = m_qs.filter(process=None)
return Response(list(m_qs.values("id", "name").distinct()))
return Response(list(m_qs.values("id", "name", "belong_dept").distinct()))
@action(methods=["post"], detail=False, perms_map={"post": "handover.create"}, serializer_class=GenHandoverWmSerializer)
@transaction.atomic