From 3019653dfa41dfb7f4d74c7f73aa659995261e55 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Wed, 2 Sep 2026 10:38:49 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=AE=8F=E8=A7=82=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=BB=9F=E4=B8=80(=E5=BE=85=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E6=94=B6=E8=B4=A7/=E5=B7=B2=E5=85=A5=E5=BA=93)=E5=BA=9F?= =?UTF-8?q?=E5=BC=83"=E5=9C=A8=E5=BA=93";=20WIP=E7=9F=A9=E9=98=B5=E8=BF=94?= =?UTF-8?q?=E5=9B=9Eproduct=5Fname=E5=B9=B6=E6=96=B0=E5=A2=9E=E4=B8=8B?= =?UTF-8?q?=E9=92=BB=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/endpoints/dashboard.py | 17 +- backend/app/api/v1/endpoints/products.py | 4 +- backend/app/models/product.py | 4 +- backend/app/services/dashboard_service.py | 179 +++++++++++++++++++++- backend/app/services/product_service.py | 12 +- backend/app/services/task_service.py | 2 +- 6 files changed, 199 insertions(+), 19 deletions(-) diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index 38fd52c..dab9358 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -13,6 +13,7 @@ from app.services.dashboard_service import ( get_user_operations, UserOperation, get_user_operation_detail, OperationDetail, get_wip_matrix, WipMatrixRow, + get_wip_matrix_detail, WipMatrixDetailRow, get_people_workload, PersonWorkload, get_people_history, PersonHistoryRecord, search_product_messages, ProductMessageList, @@ -108,12 +109,26 @@ async def wip_matrix( until: str | None = Query(None, description="截止日期 ISO"), db: AsyncSession = Depends(get_db), ): - """生产分布透视表 — 规格型号 × 人员/工序 的设备数量交叉聚合(含在库/完成)""" + """生产分布透视表 — 规格型号 × 人员/工序 的设备数量交叉聚合(含已完成/已入库/已出库)""" since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None return await get_wip_matrix(db, dimension=dimension, since=since_dt, until=until_dt) +@router.get("/wip-matrix/detail", response_model=list[WipMatrixDetailRow]) +async def wip_matrix_detail( + spec: str = Query(..., description="规格型号"), + process: str = Query(..., description="当前工序(dimension_key)"), + since: str | None = Query(None, description="起始日期 ISO"), + until: str | None = Query(None, description="截止日期 ISO"), + db: AsyncSession = Depends(get_db), +): + """WIP 矩阵单元格下钻 — 返回某 规格型号×工序 交叉点下的设备明细""" + since_dt = datetime.fromisoformat(since) if since else None + until_dt = datetime.fromisoformat(until) if until else None + return await get_wip_matrix_detail(db, spec_model=spec, process=process, since=since_dt, until=until_dt) + + @router.get("/people-workload", response_model=list[PersonWorkload]) async def people_workload( db: AsyncSession = Depends(get_db), diff --git a/backend/app/api/v1/endpoints/products.py b/backend/app/api/v1/endpoints/products.py index 0921c25..6ae6fb2 100644 --- a/backend/app/api/v1/endpoints/products.py +++ b/backend/app/api/v1/endpoints/products.py @@ -125,7 +125,7 @@ async def delete_product_endpoint( # ============================================================ class OverallStatusUpdate(BaseModel): - status: str = Field(..., min_length=1, max_length=20, description="宏观状态: 备货/生产/测试/维修/在库") + status: str = Field(..., min_length=1, max_length=20, description="宏观状态: 备货/生产/测试/维修/待仓库收货/已入库/已出库") @router.patch("/scan/{serial_number}/status", response_model=ProductScanResponse) @@ -138,7 +138,7 @@ async def update_product_overall_status( """ 更新产品宏观流转状态。 移动端首次扫码或手动切换时调用。 - 合法值: 备货 | 生产 | 测试 | 维修 | 在库 + 合法值: 备货 | 生产 | 测试 | 维修 | 待仓库收货 | 已入库 | 已出库 权限:仅 SUPER_ADMIN 或当前操作该产品主线任务的人可以修改。 """ diff --git a/backend/app/models/product.py b/backend/app/models/product.py index a290099..13d8f90 100644 --- a/backend/app/models/product.py +++ b/backend/app/models/product.py @@ -60,9 +60,9 @@ class Product(Base): status: Mapped[str] = mapped_column( String(50), nullable=False, default="pending", comment="产品状态", ) - # 宏观流转状态 — 首次扫码时强制设定:备货/生产/测试/维修/在库 + # 宏观流转状态 — 首次扫码时强制设定:备货/生产/测试/维修/待仓库收货/已入库/已出库 overall_status: Mapped[str | None] = mapped_column( - String(20), nullable=True, comment="宏观状态: 备货/生产/测试/维修/在库", + String(20), nullable=True, comment="宏观状态: 备货/生产/测试/维修/待仓库收货/已入库/已出库", ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=get_beijing_time, comment="创建时间", diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 49832bd..cccf33d 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -82,11 +82,28 @@ class OperationDetail(BaseModel): class WipMatrixRow(BaseModel): spec_model: str # 规格型号(Y 轴) + product_name: str = "" # 产品名称(物料名称,用于首列复合显示) dimension_key: str # 人员姓名 或 工序名称(X 轴) count: int = 0 # 该交叉点的设备数量 assignees: list[str] = [] # 该交叉点涉及的主负责人(中文名,去重) +class WipMatrixDetailRow(BaseModel): + """WIP 矩阵单元格下钻 — 某规格型号 × 某工序下的设备明细""" + product_id: str + serial_number: str # 16位HEX身份证 + external_serial: str | None = None # 业务序列号 + material_name: str = "" # 产品名称 + spec_model: str = "" + task_status: str = "" # 当前状态 WIP/PENDING/COMPLETED/ARCHIVED/OUTBOUND + assignee_id: str = "" + assignee: str = "" # 负责人中文名 + duration_hours: float = 0.0 # 已在该工序滞留时长(小时) + total_hours: float = 0.0 # 整个项目总时长(自然小时,最早介入→最晚结束) + work_hours: float = 0.0 # 仅工作日有效时长(小时,排除周末/节假日) + received_at: str | None = None # 接手时间 MM-DD HH:mm + + class PersonDevice(BaseModel): product_id: str serial_number: str # 16位HEX身份证 @@ -631,8 +648,8 @@ async def get_wip_matrix( 1. 取该设备最新的一条主分支任务(parent_task_id IS NULL 或 TRANSFER/RECOVERY) 2. 设备当前工序 = 该最新主任务的工序名(task_name),不区分状态 - 「待确认」(PENDING) = 别人转给我但未接收 - - 「在库」(COMPLETED) = 真正入库/生产完成 - - 其他已完成工序(如「测试」完成)按原工序显示,不强制归「在库」 + - 「已入库」= MOM 已扫码实收;「已完成」= 车间完工待实收 + - 其他已完成工序(如「测试」完成)按原工序显示,不强制归仓库态 这样一台设备在「上一步已完成 + 下一步待确认」时只算一次(待确认),不会重复计数。 since/until 按设备最新主任务的创建时间过滤。 @@ -650,6 +667,7 @@ async def get_wip_matrix( select( Product.id, Product.spec_model, + Product.material_name, Task.task_name, Task.assignee_id, Task.created_at, @@ -671,7 +689,7 @@ async def get_wip_matrix( # 每台设备 → (spec, 当前工序/负责人, 当前负责人ID) device_cur: dict[str, tuple] = {} seen: set[str] = set() - for pid, spec, task_name, assignee, created, loc, overall_status, product_status in rows: + for pid, spec, product_name, task_name, assignee, created, loc, overall_status, product_status in rows: if pid in seen: continue seen.add(pid) @@ -691,24 +709,27 @@ async def get_wip_matrix( # 待仓库收货 = 完工已转交仓库、MOM 尚未扫码实收 # 否则按最新主任务工序名(活跃/完成态都归该工序) if loc == "virtual_warehouse": + # 统一术语:待仓库收货(车间完工) → "已完成";扫码实收 → "已入库";已出库保持 if overall_status in ("已入库", "在库") or str(product_status).upper() == "ARCHIVED": key = "已入库" elif overall_status == "已出库" or str(product_status).upper() == "OUTBOUND": key = "已出库" else: - key = "待仓库收货" + key = "已完成" else: key = task_name or "—" else: key = assignee or "未分配" - device_cur[pid] = (spec or "未知型号", key, assignee or "") + device_cur[pid] = (spec or "未知型号", key, assignee or "", product_name or "") - # 聚合:规格 × 当前工序 → 设备数;同时收集负责人 + # 聚合:规格 × 当前工序 → 设备数;同时收集负责人 与 产品名称 agg: dict[tuple, int] = {} assignee_map: dict[tuple, set] = {} - for spec, key, assignee in device_cur.values(): + product_name_map: dict[tuple, str] = {} + for spec, key, assignee, product_name in device_cur.values(): k = (spec, key) agg[k] = agg.get(k, 0) + 1 + product_name_map.setdefault(k, product_name) if assignee: assignee_map.setdefault(k, set()).add(assignee) @@ -727,6 +748,7 @@ async def get_wip_matrix( assignees = [name_map.get(a, a) for a in assignee_map.get((spec, key), set())] or [] items.append(WipMatrixRow( spec_model=spec, + product_name=product_name_map.get((spec, key), ""), dimension_key=dim_display, count=cnt, assignees=assignees, @@ -735,6 +757,149 @@ async def get_wip_matrix( return items +# ============================================================ +# WIP 矩阵单元格下钻 — 某规格型号 × 某工序下的设备明细 +# ============================================================ + +async def get_wip_matrix_detail( + db: AsyncSession, + spec_model: str, + process: str, + since: datetime | None = None, + until: datetime | None = None, +) -> list[WipMatrixDetailRow]: + """WIP 矩阵单元格下钻:返回该 规格型号×工序 交叉点下的设备明细。 + + 口径与 get_wip_matrix 完全一致:每台设备取最新一条主分支任务, + 按其在库三态(已完成/已入库/已出库)或工序名归类到 process。 + """ + from datetime import timezone as dt_timezone + from app.models.task import Task + from app.models.product import Product + from app.models.holiday import Holiday + from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours + + hres = await db.execute(select(Holiday.day)) + holidays = {r[0] for r in hres} + + result = await db.execute( + select( + Product.id, + Product.serial_number, + Product.external_serial, + Product.material_name, + Product.spec_model, + Product.current_location_id, + Product.overall_status, + Product.status, + Task.task_name, + Task.assignee_id, + Task.status.label("task_status"), + Task.created_at, + Task.received_at, + ) + .join(Task, Task.product_id == Product.id) + .where( + or_( + Task.parent_task_id.is_(None), + Task.task_type.in_(["TRANSFER", "RECOVERY"]), + ) + ) + .order_by(Product.id, Task.created_at.desc()) + ) + rows = result.all() + + now = get_beijing_time() + seen: set[str] = set() + matched: list[WipMatrixDetailRow] = [] + raw_names: set[str] = set() + + for pid, serial, ext, mat_name, spec, loc, overall, pstatus, task_name, assignee, tstatus, created, received in rows: + if pid in seen: + continue + seen.add(pid) + + if spec_model and (spec or "") != spec_model: + continue + + # 时间筛选(设备最新主任务创建时间) + if created is not None and created.tzinfo is None: + created = created.replace(tzinfo=dt_timezone.utc) + if since and created is not None and created < since: + continue + if until and created is not None and created > until: + continue + + # 当前工序归类(与 get_wip_matrix 一致) + if loc == "virtual_warehouse": + if overall in ("已入库", "在库") or str(pstatus).upper() == "ARCHIVED": + key = "已入库" + elif overall == "已出库" or str(pstatus).upper() == "OUTBOUND": + key = "已出库" + else: + key = "已完成" + else: + key = task_name or "—" + + if process and key != process: + continue + + # 滞留时长(当前工序接手时间 → 现在) + start = received or created + duration = 0.0 + received_str = None + if start: + start_bj = to_beijing(start) + duration = working_duration_hours(start_bj, now, holidays) + received_str = start_bj.strftime("%m-%d %H:%M") + + matched.append(WipMatrixDetailRow( + product_id=str(pid), + serial_number=serial or "", + external_serial=ext, + material_name=mat_name or "", + spec_model=spec or "", + task_status=tstatus or "", + assignee_id=assignee or "", + duration_hours=duration, + received_at=received_str, + )) + if assignee: + raw_names.add(assignee) + + # 负责人中文名映射 + name_map: dict[str, str] = {} + if raw_names: + from app.services.mom_cache import get_display_names + name_map = get_display_names(list(raw_names)) + for r in matched: + r.assignee = name_map.get(r.assignee_id, r.assignee_id) + + # ── 整个项目总时长:设备最早介入 → 最晚结束(自然 + 工作日) ── + if matched: + import uuid as uuid_mod + from sqlalchemy import func as sa_func + uuid_list = [uuid_mod.UUID(m.product_id) for m in matched] + range_rows = (await db.execute( + select( + Task.product_id, + sa_func.min(sa_func.coalesce(Task.received_at, Task.created_at)).label("t0"), + sa_func.max(sa_func.coalesce(Task.completed_at, now)).label("tmax"), + ) + .where(Task.product_id.in_(uuid_list)) + .group_by(Task.product_id) + )).all() + row_by_pid = {m.product_id: m for m in matched} + for pid, t0, tmax in range_rows: + row = row_by_pid.get(str(pid)) + if row is None or t0 is None or tmax is None: + continue + row.total_hours = round((tmax - t0).total_seconds() / 3600, 1) + row.work_hours = round(working_duration_hours(to_beijing(t0), to_beijing(tmax), holidays), 1) + + return matched + + # ============================================================ # 人员操作明细(点击数字下钻 — 接收/转交/上传备注) # ============================================================ diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py index 30186df..a04a874 100644 --- a/backend/app/services/product_service.py +++ b/backend/app/services/product_service.py @@ -220,16 +220,16 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product node_assignee = (outbound_log.operator_id if outbound_log else None) or last_transfer_operator or last_main.assignee_id node_created_by = last_transfer_operator or last_main.assignee_id elif inbound_log is not None: - # 情况 B:MOM 已扫码实收 → "在库" 已完成,负责人为仓库接收人 - node_name = "在库" - node_status = "COMPLETED" + # 情况 B:MOM 已扫码实收 → "已入库",负责人为仓库接收人 + node_name = "已入库" + node_status = "ARCHIVED" node_assignee = inbound_log.operator_id or "仓库" # "转入在库"始终显示操作转入库的人(车间),而不是 MOM 接收人 node_created_by = last_transfer_operator or last_main.assignee_id else: - # 情况 A:MOM 还没扫码 → "待收货" 进行中,负责人为占位"待仓库扫码" - node_name = "待收货" - node_status = "IN_PROGRESS" + # 情况 A:MOM 还没扫码 → "已完成"(车间完工待实收),负责人为占位"待仓库扫码" + node_name = "已完成" + node_status = "COMPLETED" node_assignee = "待仓库扫码" node_created_by = last_transfer_operator or last_main.assignee_id diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 2597800..8da022c 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -278,7 +278,7 @@ async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse: product = product_result.scalar_one_or_none() if product: if data.task_name and (not data.parent_task_id or data.task_type in ("TRANSFER", "RECOVERY")): - product.overall_status = "在库" if "virtual_warehouse" in data.task_name else data.task_name + product.overall_status = "已入库" if "virtual_warehouse" in data.task_name else data.task_name # 派发给人 → 产品离开仓库 if data.assignee_id and data.assignee_id != VIRTUAL_WAREHOUSE: product.current_location_id = data.assignee_id