diff --git a/backend/app/core/lifecycle.py b/backend/app/core/lifecycle.py index 50f47c9..bb44b6d 100644 --- a/backend/app/core/lifecycle.py +++ b/backend/app/core/lifecycle.py @@ -117,3 +117,63 @@ def is_step_allowed(phase: str | None, step: str | None) -> bool: phase or LIFECYCLE_PRODUCTION, _ALLOWED_BY_PHASE[LIFECYCLE_PRODUCTION], ) return step.strip() in allowed + + +# 老数据兼容:售后设备的工序曾沿用生产阶段的「测试 / 维修」写法 +# (售后独立工序名是后加的)。统计/看板展示时折算到售后区的独立工序名, +# 否则售后设备的历史工序会混进生产区的同名工序,导致统计失真。 +AFTER_SALES_STEP_ALIAS = { + "测试": STEP_SHIP_TEST, + "维修": STEP_AFTER_SALES_REPAIR, +} + + +def normalize_after_sales_step(phase: str | None, step: str | None) -> str | None: + """售后回流设备的历史工序名归一 —— 折算到售后区的独立工序名。 + + 仅对 AFTER_SALES 生效;生产设备的「测试 / 维修」原样保留。 + 统计口径(WIP 矩阵 / 下钻)必须与展示口径一致,故集中在模块内。 + """ + if (phase or "") != LIFECYCLE_AFTER_SALES or not step: + return step + return AFTER_SALES_STEP_ALIAS.get(step.strip(), step) + + +# ============================================================ +# overall_status(中文宏观状态) ⇄ product.status(英文状态码) +# ============================================================ +# 这两个字段必须始终同步。历史缺陷:receive_task / transfer_task 只改 +# overall_status 不改 status,导致设备出库回流后 status 永远停在 OUTBOUND, +# 统计口径被污染(WIP 矩阵曾因此把正在做「发货测试」的设备吞进「已出库」列)。 +# 所有改写 overall_status 的地方一律调 sync_product_status()。 + +# 终态映射;不在表内的(备货/生产/测试/维修/发货测试/售后维修…)都是活跃工序 → WIP +OVERALL_TO_PRODUCT_STATUS = { + "已入库": "ARCHIVED", + "在库": "ARCHIVED", + "已出库": "OUTBOUND", + "待仓库收货": "COMPLETED", +} + +DEFAULT_PRODUCT_STATUS = "WIP" + + +def overall_to_product_status(overall: str | None) -> str: + """中文宏观状态 → product.status 状态码。 + + 活跃工序(含售后「发货测试 / 售后维修」)不在映射表里,统一落到 WIP —— + 这正是消除「status 残留在 OUTBOUND」的关键。 + """ + if not overall: + return DEFAULT_PRODUCT_STATUS + return OVERALL_TO_PRODUCT_STATUS.get(overall.strip(), DEFAULT_PRODUCT_STATUS) + + +def sync_product_status(product) -> str: + """把 product.status 与 overall_status 对齐,返回落定的状态码。 + + 刻意用 duck-typing 而非导入 ORM 模型,避免 core 层反向依赖 models。 + 调用点:任何改写 product.overall_status 之后都必须调一次。 + """ + product.status = overall_to_product_status(getattr(product, "overall_status", None)) + return product.status diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 475fcb2..332811a 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -4,6 +4,8 @@ from sqlalchemy import select, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel +from app.core.lifecycle import normalize_after_sales_step + # ============================================================ # Schemas @@ -680,6 +682,7 @@ async def get_wip_matrix( Product.current_location_id, Product.overall_status, Product.status, + Product.lifecycle_phase, ) .outerjoin(Task, Task.product_id == Product.id) .where( @@ -696,7 +699,7 @@ async def get_wip_matrix( # 每台设备 → (spec, 当前工序/负责人, 当前负责人ID) device_cur: dict[str, tuple] = {} seen: set[str] = set() - for pid, spec, product_name, task_name, assignee, tstatus, created, completed, loc, overall_status, product_status in rows: + for pid, spec, product_name, task_name, assignee, tstatus, created, completed, loc, overall_status, product_status, lifecycle in rows: if pid in seen: continue seen.add(pid) @@ -706,8 +709,15 @@ async def get_wip_matrix( # completed_at or created_at;若后续新增 updated_at 可并入。 anchor = completed or created + # ── 活跃任务判定:决定"按当前工序归列"还是"降级判完结态"的唯一依据 ── + # 必须在所有终结态判定之前。否则「已出库」的残留状态(设备回流后 + # product.status 仍是 OUTBOUND,因为 receive_task/transfer_task 只同步 + # overall_status 不同步 status)会把正在做售后工序的设备误判为终结态。 + is_active = tstatus in ("WIP", "PENDING") + # ── 动静分离:仅终结(收口/在库/出库)设备按 since~until 过滤;在制设备无条件全量 ── - is_terminal = ( + # 有活跃任务的一律视为在制,不做时间过滤(与"在制无条件全量"口径一致) + is_terminal = (not is_active) and ( loc == "virtual_warehouse" or overall_status in ("待仓库收货", "已入库", "在库", "已出库") or str(product_status).upper() in ("ARCHIVED", "OUTBOUND") @@ -723,9 +733,21 @@ async def get_wip_matrix( # else: 在制设备无视时间参数,保证与人员看板“实时在制”总数一致 if dimension == "task_name": - # ── 终结三列判定(多维防御,不依赖单一 loc / overall)── + # ══ ① 第一优先级:有活跃任务(WIP/PENDING) → 一律按当前工序归列 ══ + # 绝不看 overall_status / product.status 的终结态字段。这两个字段 + # 在售后回流场景下会残留旧值(status 停在 OUTBOUND),若先判终结态, + # 正在做「发货测试」的设备会被吞进「已出库」列。 + if is_active: + if tstatus == "PENDING": + # 转交后待接收(工序名还是占位符「待确认」)→ 统一归「待接收」 + key = "待接收" + else: + key = task_name if (task_name and task_name != "—") else "待接收" + # 售后回流设备按折算后的工序名落列:发货测试 / 售后维修 + key = normalize_after_sales_step(lifecycle, key) + # ══ ② 无活跃任务,才降级判完结态(多维防御,不依赖单一 loc / overall)══ # 已入库: 整体实收(已入库/在库) 或 status==ARCHIVED 或 收口节点「扫码入库」 - if overall_status in ("已入库", "在库") or str(product_status).upper() == "ARCHIVED" or task_name == "扫码入库": + elif overall_status in ("已入库", "在库") or str(product_status).upper() == "ARCHIVED" or task_name == "扫码入库": key = "已入库" # 已出库: 整体已出库 或 status==OUTBOUND 或 收口节点「扫码出库」 elif overall_status == "已出库" or str(product_status).upper() == "OUTBOUND" or task_name == "扫码出库": @@ -734,11 +756,15 @@ async def get_wip_matrix( elif overall_status == "待仓库收货" or loc == "virtual_warehouse": key = "已完成" else: - # 🔧 对齐全景/产品管理:PENDING(含 task_name=待确认) 与 无任务新品 统一归「待接收」 - if tstatus == "PENDING" or (tstatus is None and task_name is None): + # 🔧 对齐全景/产品管理:无任何任务的新品统一归「待接收」 + if tstatus is None and task_name is None: key = "待接收" else: + # 已完工但未收口的工序(如「测试」完成),按原工序名显示 key = task_name if (task_name and task_name != "—") else "待接收" + # 🔧 售后回流设备的历史工序归一:老数据里售后设备的工序可能仍叫 + # 「测试 / 维修」,折算进售后区的独立列,避免混入生产区同名工序 + key = normalize_after_sales_step(lifecycle, key) else: key = assignee or "未分配" device_cur[pid] = (spec or "未知型号", key, assignee or "", product_name or "") @@ -869,8 +895,13 @@ async def get_wip_matrix_detail( # ── 真实产出时间锚点:完工/变动时间优先,创建时间仅兜底(schema 无 updated_at)── anchor = completed or created + # ── 活跃任务判定:与 get_wip_matrix 同口径,必须先于一切终结态判定 ── + # 否则「已出库」残留状态(回流后 pstatus 仍是 OUTBOUND)会把正在做 + # 售后工序的设备误判为终结态,既错配时间过滤又错列。 + is_active = tstatus in ("WIP", "PENDING") + # ── 动静分离:仅终结(收口/在库/出库)设备按 since~until 过滤;在制设备无条件全量 ── - is_terminal = ( + is_terminal = (not is_active) and ( loc == "virtual_warehouse" or overall in ("待仓库收货", "已入库", "在库", "已出库") or str(pstatus).upper() in ("ARCHIVED", "OUTBOUND") @@ -885,19 +916,30 @@ async def get_wip_matrix_detail( continue # else: 在制设备无视时间参数,保证与人员看板“实时在制”总数一致 - # ── 终结三列判定(与 get_wip_matrix 一字不差同步,多维防御)── - if overall in ("已入库", "在库") or str(pstatus).upper() == "ARCHIVED" or task_name == "扫码入库": + # ══ 列归类(与 get_wip_matrix 一字不差同步)══ + # ① 第一优先级:有活跃任务 → 一律按当前工序归列,不看终结态字段 + if is_active: + if tstatus == "PENDING": + key = "待接收" + else: + key = task_name if (task_name and task_name != "—") else "待接收" + key = normalize_after_sales_step(lifecycle, key) + # ② 无活跃任务,才降级判完结态 + elif overall in ("已入库", "在库") or str(pstatus).upper() == "ARCHIVED" or task_name == "扫码入库": key = "已入库" elif overall == "已出库" or str(pstatus).upper() == "OUTBOUND" or task_name == "扫码出库": key = "已出库" elif overall == "待仓库收货" or loc == "virtual_warehouse": key = "已完成" else: - # 🔧 对齐全景/产品管理:PENDING(含 task_name=待确认) 与 无任务新品 统一归「待接收」 - if tstatus == "PENDING" or (tstatus is None and task_name is None): + # 🔧 对齐全景/产品管理:无任何任务的新品统一归「待接收」 + if tstatus is None and task_name is None: key = "待接收" else: key = task_name if (task_name and task_name != "—") else "待接收" + # 🔧 必须与 get_wip_matrix 同口径归一:否则矩阵格子里有数、 + # 点进来下钻却是空的(售后设备的历史「测试/维修」工序) + key = normalize_after_sales_step(lifecycle, key) if process and key != process: continue diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py index 4c92188..2881657 100644 --- a/backend/app/services/product_service.py +++ b/backend/app/services/product_service.py @@ -12,6 +12,7 @@ from app.core.lifecycle import ( is_step_allowed, phase_label, resolve_phase_for_step, + sync_product_status, ) from app.models.product import Product from app.models.production_order import ProductionOrder @@ -506,14 +507,9 @@ async def update_overall_status( ) product.lifecycle_phase = phase product.overall_status = status_value - # 同步 status 字段,保证与整体状态口径一致(修复"只改整体状态不改 status"的旧缺陷) - _OVERALL_TO_STATUS = { - "已入库": "ARCHIVED", - "在库": "ARCHIVED", - "已出库": "OUTBOUND", - "待仓库收货": "COMPLETED", - } - product.status = _OVERALL_TO_STATUS.get(status_value, "WIP") + # 同步 status 字段(映射表已上提到 app.core.lifecycle 单一来源, + # 供 task_service 的各写入点共用,避免各处各写一份) + sync_product_status(product) await db.commit() await db.refresh(product) diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..db201f2 --- /dev/null +++ b/backend/scripts/__init__.py @@ -0,0 +1 @@ +"""一次性运维脚本集合(按模块方式运行:python -m scripts.)""" diff --git a/backend/scripts/fix_product_status.py b/backend/scripts/fix_product_status.py new file mode 100644 index 0000000..5516ae9 --- /dev/null +++ b/backend/scripts/fix_product_status.py @@ -0,0 +1,84 @@ +"""一次性数据清洗 — 修复 Product.status 与 overall_status 脱节的历史存量 + +背景 +---- +app/core/lifecycle.py 记录了历史缺陷:早期 receive_task / transfer_task 只改 +overall_status、不改 status,导致部分设备的 status 残留为 'pending' / 'OUTBOUND' +等旧值。 + +现状(重要) +---------- +所有 overall_status 的写入点**都已补上 sync_product_status()**: + task_service.py:387 / :691 / :1007、product_service.py:512、 + webhooks.py:84 / :256、product_finalize_service.py:107 +因此新数据不会再脱节,本脚本只处理历史存量。 + +影响面 +------ +前端列表筛选走的是 macro_status(由 overall_status 实时派生,见 +product_service._resolve_macro_status),所以这批脏数据**基本不影响页面展示**。 +但 _mark_after_sales_if_reactivated 等逻辑会读 product.status 判断"是否出库回流", +脏值可能引发误判 —— 故仍需清洗。 + +用法 +---- + python -m scripts.fix_product_status # 干跑:只打印待修复清单 + python -m scripts.fix_product_status --apply # 确认后真正写库 +""" +from __future__ import annotations + +import argparse +import asyncio + +from sqlalchemy import select + +from app.core.database import AsyncSessionLocal +from app.core.lifecycle import overall_to_product_status +from app.models.product import Product + + +async def main(apply: bool) -> None: + async with AsyncSessionLocal() as db: + products = ( + await db.execute(select(Product).order_by(Product.serial_number)) + ).scalars().all() + + fixes: list[tuple[Product, str, str]] = [] + for p in products: + expected = overall_to_product_status(p.overall_status) + current = (p.status or "").strip() + # 大小写不敏感比较:历史数据里存在小写 'pending' + if current.upper() == expected: + continue + fixes.append((p, current or "(空)", expected)) + + print(f"扫描 {len(products)} 台设备,需修复 {len(fixes)} 台\n") + + if fixes: + header = f"{'序列号':<18}{'overall_status':<16}{'当前 status':<14}→ 目标 status" + print(header) + print("-" * len(header) * 2) + for p, cur, exp in fixes: + overall = p.overall_status or "(NULL)" + phase = p.lifecycle_phase or "-" + print(f"{p.serial_number:<18}{overall:<16}{cur:<14}→ {exp} [{phase}]") + else: + print("所有设备的 status 均已与 overall_status 对齐。") + + if not apply: + print("\n[干跑] 未写库。确认清单无误后,加 --apply 执行。") + return + + if not fixes: + return + + for p, _cur, exp in fixes: + p.status = exp + await db.commit() + print(f"\n[已提交] {len(fixes)} 台设备的 status 已对齐到 overall_status。") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="对齐 Product.status 与 overall_status 的历史脏数据") + parser.add_argument("--apply", action="store_true", help="真正写库(默认仅干跑)") + asyncio.run(main(parser.parse_args().apply)) diff --git a/frontend/src/constants/task.ts b/frontend/src/constants/task.ts index c778a04..bdd1030 100644 --- a/frontend/src/constants/task.ts +++ b/frontend/src/constants/task.ts @@ -112,17 +112,20 @@ export const STATUS_CONFIG: Record = { ring: "ring-blue-400", label: "维修", }, - // ---- 售后回流阶段专属工序(红系,与生产阶段一眼区分)---- + // ---- 售后回流阶段专属工序(紫系)---- + // 选紫色而非红色:红色在本系统里是「驳回 / 危险」的语义色,售后只是另一条 + // 流转支线而非异常,用红色会让操作员误以为设备报错。也不选靛蓝——那已被 + // 「已出库 OUTBOUND」占用,两者相邻显示会混淆。 发货测试: { - bg: "bg-red-50", - text: "text-red-700", - ring: "ring-red-400", + bg: "bg-purple-50", + text: "text-purple-700", + ring: "ring-purple-300", label: "发货测试", }, 售后维修: { - bg: "bg-red-50", - text: "text-red-700", - ring: "ring-red-400", + bg: "bg-purple-50", + text: "text-purple-700", + ring: "ring-purple-300", label: "售后维修", }, }; @@ -230,9 +233,11 @@ export interface LifecycleBadge { /** * 生命周期标签 — 只在设备确实处于**售后回流环节**时返回: * 生命周期为 AFTER_SALES 且当前工序是「发货测试」或「售后维修」 - * → 红底白字,文案即工序名。 + * → 紫底白字,文案即工序名。 * * 生产制造阶段一律返回 null(不打标),避免和售后设备混淆。 + * 配色用紫色:红色在本系统是「驳回 / 危险」语义,售后只是另一条流转支线, + * 用红色会让操作员误以为设备报错。 */ export function lifecycleBadge( overallStatus: string | null | undefined, @@ -243,7 +248,7 @@ export function lifecycleBadge( if (!AFTER_SALES_TAG_STEPS.has(step)) return null; return { label: step, - className: "bg-red-600 text-white ring-1 ring-red-600", + className: "bg-purple-600 text-white ring-1 ring-purple-600", phase: LIFECYCLE_PHASE.AFTER_SALES, }; }