Files
track/backend/app/core/lifecycle.py
duxingchen f22eae315f 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: 售后工序标签由红色改紫色 —— 红色在本系统是「驳回/危险」
  语义色,售后只是另一条流转支线,用红色会让操作员误以为设备报错
2026-09-15 10:57:29 +08:00

180 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""生命周期阶段Lifecycle Phase词汇表与选项隔离 — 单一事实来源
生产制造PRODUCTION与售后回流AFTER_SALES各自拥有一套合法的
宏观状态 / 工序名。回流设备绝不允许被重新排产回「备货 / 生产」等前期环节,
本模块集中定义两套词表并提供校验,前端各端复制同一份口径即可。
设计要点
--------
1. 售后阶段使用**独立工序名**(发货测试 / 售后维修),不复用生产阶段的
「测试 / 维修」。这样报表、看板、导出无需 JOIN lifecycle_phase 就能区分,
也不会出现"同一个词两种含义"
2. 选定售后专属工序 = 设备进入售后生命周期,单向不可回退。
这条规则同时覆盖两类设备:
- 已出库后被重新派发任务(由 task_service 的 outbound 判定捕获)
- 无任何历史记录、直接走售后流程的老设备(首次选定售后工序即判定)
3. 「待确认」与仓库虚拟节点是建单占位符,不参与阶段校验,否则会卡死建单流程。
"""
from __future__ import annotations
# ============================================================
# 生命周期阶段
# ============================================================
LIFECYCLE_PRODUCTION = "PRODUCTION" # 生产制造阶段(发货前)
LIFECYCLE_AFTER_SALES = "AFTER_SALES" # 已出库后再次回流返厂(售后)
PHASE_LABELS = {
LIFECYCLE_PRODUCTION: "生产制造阶段",
LIFECYCLE_AFTER_SALES: "售后回流阶段",
}
# ============================================================
# 售后专属工序名 — 一旦选中即代表设备进入售后生命周期
# ============================================================
STEP_SHIP_TEST = "发货测试" # 售后返修后的出货测试
STEP_AFTER_SALES_REPAIR = "售后维修" # 售后返修本体
AFTER_SALES_ONLY_STEPS = frozenset({STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR})
# ============================================================
# 各阶段合法的工序名 / 宏观状态名
# ============================================================
# ── 生产制造阶段(原有词表,一字未改,保证存量流程不受影响)──
PRODUCTION_TASK_STEPS = ("备货", "生产", "测试", "维修", "在库")
PRODUCTION_OVERALL_STEPS = (
"备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库",
)
# ── 售后回流阶段:只保留「发货测试 / 售后维修 / 入库出库」──
AFTER_SALES_TASK_STEPS = (STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库")
AFTER_SALES_OVERALL_STEPS = (
STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库", "待仓库收货", "已入库", "已出库",
)
# 仅属于生产制造阶段的工序名 — 售后设备出现即视为"跨阶段误排"。
# 注意:「在库 / 已入库 / 已出库」是两阶段通用的,不在此列。
PRODUCTION_ONLY_STEPS = frozenset({"备货", "生产", "测试", "维修"})
# 建单占位工序名 — 建单时为「待确认」,接收时才由操作员选定真实工序
PLACEHOLDER_STEPS = frozenset({"待确认"})
# 仓库虚拟节点标记(转交入库时作为 assignee 传递)
VIRTUAL_WAREHOUSE = "virtual_warehouse"
# 两阶段并集 — 用于"完全非法取值"的第一道粗筛
ALL_OVERALL_STEPS = frozenset(PRODUCTION_OVERALL_STEPS) | frozenset(AFTER_SALES_OVERALL_STEPS)
_ALLOWED_BY_PHASE = {
LIFECYCLE_PRODUCTION: frozenset(PRODUCTION_OVERALL_STEPS),
LIFECYCLE_AFTER_SALES: frozenset(AFTER_SALES_OVERALL_STEPS),
}
# ============================================================
# 查询 / 校验
# ============================================================
def phase_label(phase: str | None) -> str:
"""阶段的中文名(用于报错文案)"""
return PHASE_LABELS.get(phase or "", PHASE_LABELS[LIFECYCLE_PRODUCTION])
def allowed_steps(phase: str | None) -> tuple[str, ...]:
"""该阶段合法的全部工序/状态名"""
if (phase or "") == LIFECYCLE_AFTER_SALES:
return AFTER_SALES_OVERALL_STEPS
return PRODUCTION_OVERALL_STEPS
def is_placeholder_step(step: str | None) -> bool:
"""建单占位符(待确认)/ 仓库虚拟节点 — 不做阶段校验
否则 create_task 会被「待确认」卡住,转交入库会被
「🏭 入库 (virtual_warehouse)」卡住,整条流程直接断掉。
"""
if not step:
return True
return step in PLACEHOLDER_STEPS or VIRTUAL_WAREHOUSE in step
def resolve_phase_for_step(current_phase: str | None, step: str | None) -> str:
"""选定售后专属工序 → 设备进入售后生命周期(单向,不可回退)
这是"无历史记录的老设备"进入售后阶段的唯一入口:它首次选定
「发货测试 / 售后维修」时即被判定为售后回流设备。
"""
if step and step.strip() in AFTER_SALES_ONLY_STEPS:
return LIFECYCLE_AFTER_SALES
return current_phase or LIFECYCLE_PRODUCTION
def is_step_allowed(phase: str | None, step: str | None) -> bool:
"""工序名在该生命周期阶段下是否合法"""
if is_placeholder_step(step):
return True
allowed = _ALLOWED_BY_PHASE.get(
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