"""生命周期阶段(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