chore: fork from IRIS track 供 LICA 部门独立运行

- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update)
- 组织隔离目标: LICA
- 端口规划: 前端 8030 / 后端 8031 / 数据库 8032
- 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本)
- 已排除工作区未提交改动,取干净的 192c8ee 状态
This commit is contained in:
2026-09-21 15:56:52 +08:00
commit 3286a11bc7
212 changed files with 44060 additions and 0 deletions

View File

@ -0,0 +1,196 @@
"""生命周期阶段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})
# 🔧 工序语义细分2026-09-17——「售后专属」不等于「回流返厂」
# · 出厂质检(发货测试):设备可能压根没回厂,只是补做发货前测试。
# 它【不】构成回流信号,选中它不应把设备永久烙进售后生命周期。
# · 明确修机指令(售后维修):设备确实是回厂返修 → 判定回流。
# 历史缺陷:两者混为一谈,导致「已出库 + 发货测试」被打上不可回退的
# AFTER_SALES 烙印(售后死锁),而「已出库」的物理终态也被抹掉。
OUTBOUND_QC_STEPS = frozenset({STEP_SHIP_TEST})
AFTER_SALES_REPAIR_STEPS = frozenset({STEP_AFTER_SALES_REPAIR})
# ============================================================
# 各阶段合法的工序名 / 宏观状态名
# ============================================================
# ── 生产制造阶段 ──
# 🔧 2026-09-17 补入「发货测试」:出厂质检现在被允许在生产阶段执行
# (设备的 lifecycle_phase 不再因选中它而翻成售后)。若不补,
# _enforce_step_isolation 会把「已出库设备的发货测试」直接 400 掉。
PRODUCTION_TASK_STEPS = ("备货", "生产", "测试", "维修", "在库")
PRODUCTION_OVERALL_STEPS = (
"备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库",
STEP_SHIP_TEST,
)
# ── 售后回流阶段:只保留「发货测试 / 售后维修 / 入库出库」──
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_REPAIR_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