diff --git a/backend/alembic/versions/i1j2k3l4m5n6_add_product_lifecycle_phase.py b/backend/alembic/versions/i1j2k3l4m5n6_add_product_lifecycle_phase.py new file mode 100644 index 0000000..8ba1dc0 --- /dev/null +++ b/backend/alembic/versions/i1j2k3l4m5n6_add_product_lifecycle_phase.py @@ -0,0 +1,45 @@ +"""add_product_lifecycle_phase + +Revision ID: i1j2k3l4m5n6 +Revises: h1h2h3h4h5h6 +Create Date: 2026-09-14 + +产品生命周期阶段(lifecycle_phase) +-------------------------------- +用于区分「生产阶段的测试(发货测试)」与「出库后再次返厂的售后维修」。 + +存量数据一律回填 'PRODUCTION'(历史产品视为从未出库回流); +新数据由 SQLAlchemy 端 default 提供,双保险。 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "i1j2k3l4m5n6" +down_revision: Union[str, None] = "h1h2h3h4h5h6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "products", + sa.Column( + "lifecycle_phase", + sa.String(20), + nullable=False, + server_default="PRODUCTION", + comment="生命周期阶段: PRODUCTION(生产制造) | AFTER_SALES(出库后返厂售后)", + ), + ) + # 存量数据回填:历史产品从未出库回流,统一视为生产阶段 + op.execute("UPDATE products SET lifecycle_phase = 'PRODUCTION' WHERE lifecycle_phase IS NULL") + # 加索引:售后设备排查(按阶段筛选)会高频用到 + op.create_index( + "ix_products_lifecycle_phase", "products", ["lifecycle_phase"], unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_products_lifecycle_phase", table_name="products") + op.drop_column("products", "lifecycle_phase") diff --git a/backend/app/core/lifecycle.py b/backend/app/core/lifecycle.py new file mode 100644 index 0000000..50f47c9 --- /dev/null +++ b/backend/app/core/lifecycle.py @@ -0,0 +1,119 @@ +"""生命周期阶段(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 diff --git a/backend/app/models/product.py b/backend/app/models/product.py index 13d8f90..b558a00 100644 --- a/backend/app/models/product.py +++ b/backend/app/models/product.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base from app.core.time_utils import get_beijing_time +from app.core.lifecycle import LIFECYCLE_PRODUCTION class Product(Base): @@ -64,6 +65,13 @@ class Product(Base): overall_status: Mapped[str | None] = mapped_column( String(20), nullable=True, comment="宏观状态: 备货/生产/测试/维修/待仓库收货/已入库/已出库", ) + # 生命周期阶段 — 生产制造 vs 出库后返厂售后。 + # 设备出库后再次被派发任务 = 回流返厂,此处切为 AFTER_SALES 且不再回退。 + lifecycle_phase: Mapped[str] = mapped_column( + String(20), nullable=False, default=LIFECYCLE_PRODUCTION, + server_default=LIFECYCLE_PRODUCTION, index=True, + comment="生命周期阶段: PRODUCTION(生产制造) | AFTER_SALES(出库后返厂售后)", + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=get_beijing_time, comment="创建时间", )