fix: 售后回流口径统一 — 状态双字段同步、工序名归一、标签配色
产品存在 overall_status 与 status 两个状态字段,此前各写入点各写一份映射、 甚至只改 overall_status 不改 status,导致回流设备(product.status 停留在 OUTBOUND)污染看板统计口径。 - lifecycle.py: 把映射表收敛为单一来源 overall_to_product_status / sync_product_status;新增 normalize_after_sales_step,将售后设备沿用生产 阶段写法的历史工序名(测试/维修)折算到售后区独立工序名 - product_service.py: 删除本地 _OVERALL_TO_STATUS 副本,改用共享函数 - dashboard_service.py: WIP 矩阵补出 lifecycle_phase 列,活跃任务判定 (is_active) 提前到所有终结态判定之前,避免残留 OUTBOUND 被误判为完结 - scripts/fix_product_status.py: 历史数据修复脚本(一次性) - constants/task.ts: 售后工序标签由红色改紫色 —— 红色在本系统是「驳回/危险」 语义色,售后只是另一条流转支线,用红色会让操作员误以为设备报错
This commit is contained in:
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user