Compare commits

...

7 Commits

18 changed files with 651 additions and 31 deletions

View File

@ -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")

View File

@ -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

View File

@ -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="创建时间",
)

View File

@ -54,6 +54,8 @@ class ProductResponse(BaseModel):
macro_status: str | None = None # 🔧 后端预计算的任务树状态(免前端逐条展开)
overall_status: str | None = None
status: str
# 🔧 生命周期阶段PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
lifecycle_phase: str = "PRODUCTION"
created_at: datetime
# 🔧 最新动态 — 该产品活跃任务的最新记录
latest_record_time: datetime | None = None
@ -86,6 +88,8 @@ class ProductScanResponse(BaseModel):
current_location_id: str | None = None
overall_status: str | None = None
status: str
# 🔧 生命周期阶段PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
lifecycle_phase: str = "PRODUCTION"
created_at: datetime
top_level_tasks: list[TaskSummaryResponse] = []
task_tree: list[TaskResponse] = []

View File

@ -96,6 +96,8 @@ class WipMatrixDetailRow(BaseModel):
material_name: str = "" # 产品名称
spec_model: str = ""
task_status: str = "" # 当前状态 WIP/PENDING/COMPLETED/ARCHIVED/OUTBOUND
# 🔧 生命周期阶段PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
lifecycle_phase: str = "PRODUCTION"
assignee_id: str = ""
assignee: str = "" # 负责人中文名
duration_hours: float = 0.0 # 已在该工序滞留时长(小时)
@ -831,6 +833,7 @@ async def get_wip_matrix_detail(
Product.current_location_id,
Product.overall_status,
Product.status,
Product.lifecycle_phase,
Task.task_name,
Task.assignee_id,
Task.status.label("task_status"),
@ -855,7 +858,7 @@ async def get_wip_matrix_detail(
matched: list[WipMatrixDetailRow] = []
raw_names: set[str] = set()
for pid, serial, ext, mat_name, spec, loc, overall, pstatus, task_name, assignee, tstatus, created, completed, received in rows:
for pid, serial, ext, mat_name, spec, loc, overall, pstatus, lifecycle, task_name, assignee, tstatus, created, completed, received in rows:
if pid in seen:
continue
seen.add(pid)
@ -915,6 +918,7 @@ async def get_wip_matrix_detail(
material_name=mat_name or "",
spec_model=spec or "",
task_status=tstatus or "",
lifecycle_phase=lifecycle or "PRODUCTION",
assignee_id=assignee or "",
duration_hours=duration,
received_at=received_str,

View File

@ -6,6 +6,13 @@ from sqlalchemy import select, or_, cast, String, delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.lifecycle import (
ALL_OVERALL_STEPS,
allowed_steps,
is_step_allowed,
phase_label,
resolve_phase_for_step,
)
from app.models.product import Product
from app.models.production_order import ProductionOrder
from app.models.task import Task
@ -279,6 +286,7 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
current_location_id=product.current_location_id,
overall_status=product.overall_status,
status=product.status,
lifecycle_phase=product.lifecycle_phase,
created_at=product.created_at,
top_level_tasks=[
TaskSummaryResponse.model_validate(t) for t in top_tasks
@ -365,6 +373,7 @@ async def create_product(db: AsyncSession, data: ProductCreate, creator_username
current_location_name=creator_display_name or None,
overall_status=product.overall_status,
status=product.status,
lifecycle_phase=product.lifecycle_phase,
created_at=product.created_at,
)
@ -413,11 +422,14 @@ async def update_product(db: AsyncSession, product_id: uuid.UUID, data: ProductU
current_location_id=product.current_location_id,
overall_status=product.overall_status,
status=product.status,
lifecycle_phase=product.lifecycle_phase,
created_at=product.created_at,
)
VALID_OVERALL_STATUS = {"备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库"}
# 全部合法宏观状态(两阶段并集)— 仅作"完全非法取值"的第一道粗筛;
# 阶段内的细分校验(售后回流设备禁止改回「备货 / 生产」)见下方 is_step_allowed
VALID_OVERALL_STATUS = ALL_OVERALL_STEPS
async def update_overall_status(
@ -434,7 +446,7 @@ async def update_overall_status(
if status_value not in VALID_OVERALL_STATUS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效状态: {status_value},合法值: {', '.join(sorted(VALID_OVERALL_STATUS))}",
detail=f"无效状态: {status_value},合法值: {''.join(sorted(ALL_OVERALL_STEPS))}",
)
result = await db.execute(
@ -480,6 +492,19 @@ async def update_overall_status(
detail="只有 SUPER_ADMIN 或当前操作该产品主线任务的人才能修改宏观状态",
)
# ── 选项隔离:阶段感知校验 ──
# 售后回流设备禁止被改回「备货 / 生产」;选定售后专属工序(发货测试 / 售后维修)
# 则设备随即进入售后生命周期(无历史记录的老设备由此进入售后阶段)。
phase = resolve_phase_for_step(product.lifecycle_phase, status_value)
if not is_step_allowed(phase, status_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"产品当前处于{phase_label(product.lifecycle_phase)},不允许改为「{status_value}」。"
f"该阶段可选:{''.join(allowed_steps(product.lifecycle_phase))}"
),
)
product.lifecycle_phase = phase
product.overall_status = status_value
# 同步 status 字段,保证与整体状态口径一致(修复"只改整体状态不改 status"的旧缺陷)
_OVERALL_TO_STATUS = {
@ -829,6 +854,7 @@ async def get_all_products(
macro_status=_resolve_macro_status(p),
overall_status=overall_names.get(p.id) or p.overall_status,
status=p.status,
lifecycle_phase=p.lifecycle_phase,
created_at=p.created_at,
latest_record_time=latest_record_map.get(p.id, (None, None, False, None))[0],
latest_record_content=latest_record_map.get(p.id, (None, None, False, None))[1],

View File

@ -9,6 +9,14 @@ from sqlalchemy.orm import selectinload
from app.models.task import Task, TaskRecord, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, TASK_STATUS_CANCELED, TASK_STATUS_ARCHIVED
from app.models.notification import Notification, NOTIFY_TRANSFER, NOTIFY_REJECT
from app.core.time_utils import get_beijing_time
from app.core.lifecycle import (
LIFECYCLE_AFTER_SALES,
PRODUCTION_ONLY_STEPS,
allowed_steps,
is_step_allowed,
phase_label,
resolve_phase_for_step,
)
from app.models.product import Product
from app.models.task_log import TaskLog
from app.schemas.task import (
@ -88,6 +96,95 @@ async def _recalc_product_location(
await db.flush() # 唯一的落盘点
async def _mark_after_sales_if_reactivated(
db: AsyncSession, product_id: uuid.UUID,
) -> bool:
"""出库后又被派发新任务 = 设备回流返厂 → 生命周期切到 AFTER_SALES。
判定依据产品当前处于「已出库」终态overall_status == '已出库'
status == 'OUTBOUND'),却又产生了新的在制任务。
该标志单向:一旦进入 AFTER_SALES 不再回退,这样前端就能把
生产阶段的「发货测试」与回流后的「售后维修」区分开。
调用时机必须早于调用方改写 overall_status否则会漏判。
返回是否发生了翻转。product 已在本 session 加载时 db.get 直接命中
identity map不产生额外查询。
"""
product = await db.get(Product, product_id)
if product is None or product.lifecycle_phase == LIFECYCLE_AFTER_SALES:
return False
is_outbound = (
product.overall_status == "已出库"
or (product.status or "").upper() == "OUTBOUND"
)
if not is_outbound:
return False
product.lifecycle_phase = LIFECYCLE_AFTER_SALES
await db.flush()
return True
def _enforce_step_isolation(
product: Product, step: str | None, *, action: str,
) -> None:
"""选项隔离守卫 — 售后回流设备禁止被重新排产回「备货 / 生产」等前期工序。
行为两步:
1. 选定售后专属工序(发货测试 / 售后维修)→ 设备随即进入售后生命周期。
这是「无历史记录的老设备」进入售后阶段的入口。
2. 再校验该工序在当前阶段是否合法,非法直接 400 —— 前端下拉被绕过、
请求被伪造时,仍在此拦下。
必须在 _mark_after_sales_if_reactivated 之后调用,这样出库回流的设备
已处于 AFTER_SALES自然排不回前期工序。
注意:「待确认」与仓库虚拟节点属建单占位符,由 is_step_allowed 直接放行,
否则建单 / 转交入库整条链路会被卡死。
"""
phase = resolve_phase_for_step(product.lifecycle_phase, step)
if not is_step_allowed(phase, step):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"产品当前处于{phase_label(phase)}{action}不允许使用工序「{step}」。"
f"该阶段可选:{''.join(allowed_steps(phase))}"
),
)
if phase != product.lifecycle_phase:
product.lifecycle_phase = phase
def _reject_cross_phase_steps(
phase: str | None, steps: list[str | None], *, action: str,
) -> None:
"""跨阶段工序拦截 — 用于「转交」这类自由文本工序名路径。
转交对话框允许用户手打工序名(喷漆 / 老化 / 待确认…),所以不能用
白名单,否则会误伤合法命名;但"售后回流设备被转交到【生产】"必须挡住。
因此这里只拒绝**生产阶段专属**词(备货 / 生产 / 测试 / 维修)。
「在库 / 已入库 / 已出库」两阶段通用,占位符同样放行。
"""
if (phase or "") != LIFECYCLE_AFTER_SALES:
return
bad = sorted({
s.strip() for s in steps
if s and s.strip() in PRODUCTION_ONLY_STEPS
})
if bad:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"产品当前处于{phase_label(LIFECYCLE_AFTER_SALES)}{action}不允许使用前期工序:"
f"{''.join(bad)}。该阶段可选:{''.join(allowed_steps(LIFECYCLE_AFTER_SALES))}"
),
)
def _check_permission(task_assignee_id: str | None, operator_id: str | None, operator_role: str | None = None) -> None:
"""权限校验:本人 或 管理员/主管 可操作"""
if operator_role and operator_role in ADMIN_ROLES:
@ -277,6 +374,10 @@ async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse:
product_result = await db.execute(select(Product).where(Product.id == data.product_id))
product = product_result.scalar_one_or_none()
if product:
# 🔧 出库后再次派发任务 = 设备回流返厂 → 切到售后生命周期(须早于下方改写 overall_status
await _mark_after_sales_if_reactivated(db, data.product_id)
# 🔧 选项隔离:售后回流设备禁止被排回「备货 / 生产」等前期工序(防伪造传参)
_enforce_step_isolation(product, data.task_name, action="创建任务")
if data.task_name and (not data.parent_task_id or data.task_type in ("TRANSFER", "RECOVERY")):
product.overall_status = "已入库" if "virtual_warehouse" in data.task_name else data.task_name
# 派发给人 → 产品离开仓库
@ -569,6 +670,11 @@ async def receive_task(
product_result = await db.execute(select(Product).where(Product.id == task.product_id))
product = product_result.scalar_one_or_none()
if product:
# 🔧 出库后任务被重新接收 = 设备回流返厂 → 切到售后生命周期(须早于下方改写 overall_status
await _mark_after_sales_if_reactivated(db, task.product_id)
# 🔧 选项隔离:接收时选定的工序必须落在该产品当前生命周期阶段的合法集合内
#task_name 为 None 时表示本次未指定工序,跳过校验,不阻断 PC 端「直接接收」)
_enforce_step_isolation(product, task_name, action="接收任务")
if task.assignee_id:
product.current_location_id = task.assignee_id
if task_name and (
@ -770,6 +876,18 @@ async def transfer_task(
images="[]"))
# --- 动作 2解析下家 & 裂变 ---
# 🔧 提前查产品:下面的阶段隔离校验依赖它的生命周期阶段。
# (通知也需要 product_sn原本在创建任务之后才查提前不增加查询次数
product_result = await db.execute(
select(Product).where(Product.id == task.product_id)
)
product = product_result.scalar_one_or_none()
# 🔧 出库后又被转交出新任务 = 设备回流返厂 → 切到售后生命周期。
# 必须在阶段校验之前执行,否则"已出库设备被转交到生产工序"会被误放行。
if product:
await _mark_after_sales_if_reactivated(db, task.product_id)
# 兼容新旧格式
if request.next_tasks:
branches = [
@ -786,18 +904,29 @@ async def transfer_task(
has_warehouse = any(a == VIRTUAL_WAREHOUSE for _, a in branches)
real_branches = [(tn, a) for tn, a in branches if a != VIRTUAL_WAREHOUSE]
# 🔧 选项隔离:售后回流设备不允许被转交到「备货 / 生产 / 测试 / 维修」。
# 转交允许自由填写工序名(喷漆/老化…),故只精准拦跨阶段词,不用白名单。
if product:
_reject_cross_phase_steps(
product.lifecycle_phase, [tn for tn, _ in real_branches], action="转交",
)
is_fission = len(real_branches) > 1 or (request.next_tasks and len(request.next_tasks) > 1)
is_child_task = task.parent_task_id is not None
created_tasks: list[Task] = []
for task_name, assignee_id in real_branches:
# 🔧 动态推导 task_type禁止分支任务转交被硬编码为 TRANSFER否则 is_main 判定误升为主线)
if is_fission:
new_parent_task_id = task.id
new_task_type = "SPAWN" # 裂变产生新分支
elif is_child_task:
new_parent_task_id = task.parent_task_id
new_task_type = task.task_type or "SPAWN" # 继承父任务的分支血统
else:
new_parent_task_id = task.id
new_task_type = "TRANSFER" # 主干常规流转
new_task = Task(
product_id=task.product_id,
@ -805,7 +934,7 @@ async def transfer_task(
task_name=task_name,
assignee_id=assignee_id,
status=TASK_STATUS_PENDING,
task_type="TRANSFER",
task_type=new_task_type, # ← 使用动态推导的类型
notify_parent_on_complete=False,
is_rework=False,
remark=request.note or None,
@ -816,11 +945,6 @@ async def transfer_task(
# 批量 flush 以生成 ID
await db.flush()
# 提前查询产品(通知需要 product_sn
product_result = await db.execute(
select(Product).where(Product.id == task.product_id)
)
product = product_result.scalar_one_or_none()
product_sn = product.serial_number if product else ""
for nt in created_tasks:
@ -937,6 +1061,8 @@ async def complete_task(
# --- 4. 可选:创建下一步任务(转交) ---
next_task = None
if request.next_task_name and request.next_assignee_id:
# 🔧 出库后又被转交出新任务 = 设备回流返厂 → 切到售后生命周期
await _mark_after_sales_if_reactivated(db, task.product_id)
# 🚀 智能父节点继承算法
# 主线任务转交 → 保持平级继承(主分支永远在一维主干上)
# 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝)

View File

@ -1,7 +1,7 @@
/** 产品信息卡片 */
import { Package } from "lucide-react";
import type { ProductScanResponse } from "../../types/api";
import { statusColor, statusLabel } from "../../constants/task";
import { lifecycleBadge, statusColor, statusLabel } from "../../constants/task";
interface ProductCardProps {
product: ProductScanResponse;
@ -10,6 +10,8 @@ interface ProductCardProps {
export default function ProductCard({ product }: ProductCardProps) {
// 优先显示宏观状态(整体流转),回退到产品状态字段
const displayStatus = product.overall_status || product.status;
// 🔧 发货测试 / 售后维修 — 仅「测试 / 维修」工序下显示
const lifeBadge = lifecycleBadge(product.overall_status, product.lifecycle_phase);
return (
<div className="rounded-xl bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center gap-2">
@ -18,6 +20,11 @@ export default function ProductCard({ product }: ProductCardProps) {
<span className={`ml-auto rounded-full px-2.5 py-0.5 text-xs font-medium ${statusColor(displayStatus)}`}>
{statusLabel(displayStatus)}
</span>
{lifeBadge && (
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-bold ${lifeBadge.className}`}>
{lifeBadge.label}
</span>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>

View File

@ -4,7 +4,7 @@
* 供 TaskTreeViewer / TaskListCard / ProductCard 等组件共用,
* 避免在多处重复维护相同的映射逻辑。
*/
import { TASK_STATUS } from "../types/api";
import { TASK_STATUS, type LifecyclePhase } from "../types/api";
export interface StatusStyle {
bg: string;
@ -112,6 +112,19 @@ export const STATUS_CONFIG: Record<string, StatusStyle> = {
ring: "ring-blue-400",
label: "维修",
},
// ---- 售后回流阶段专属工序(红系,与生产阶段一眼区分)----
: {
bg: "bg-red-50",
text: "text-red-700",
ring: "ring-red-400",
label: "发货测试",
},
: {
bg: "bg-red-50",
text: "text-red-700",
ring: "ring-red-400",
label: "售后维修",
},
};
const FALLBACK: StatusStyle = {
@ -137,3 +150,100 @@ export function statusColor(status: string): string {
export function statusLabel(status: string): string {
return getStatusConfig(status).label;
}
// ============================================================
// 🔧 生命周期阶段lifecycle_phase与工序选项隔离
//
// 生产制造PRODUCTION与售后回流AFTER_SALES各有一套合法工序名。
// ⚠️ 本段与后端 app/core/lifecycle.py 是同一份口径,改动请两边同步。
//
// 售后阶段使用**独立工序名**(发货测试 / 售后维修),不复用生产阶段的
// 「测试 / 维修」—— 这样看板、下钻、导出无需 JOIN lifecycle_phase 即可区分。
// ============================================================
export const LIFECYCLE_PHASE = {
PRODUCTION: "PRODUCTION",
AFTER_SALES: "AFTER_SALES",
} as const satisfies Record<string, LifecyclePhase>;
/** 售后专属工序名 — 选中即代表设备进入售后生命周期 */
export const STEP_SHIP_TEST = "发货测试";
export const STEP_AFTER_SALES_REPAIR = "售后维修";
const AFTER_SALES_ONLY_STEPS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR];
// ── 生产制造阶段合法工序(原有词表,一字未改)──
export const PRODUCTION_TASK_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
export const PRODUCTION_OVERALL_OPTIONS = [
"备货", "生产", "测试", "维修", "在库", "已入库", "已出库",
];
// ── 售后回流阶段合法工序:只保留「发货测试 / 售后维修 / 入库出库」──
export const AFTER_SALES_TASK_OPTIONS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库"];
export const AFTER_SALES_OVERALL_OPTIONS = [
STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库", "已入库", "已出库",
];
/**
* 按生命周期阶段取「可选工序」——选项隔离的 UI 侧实现。
*
* hasHistory = false无任何历史任务的首次激活 / 老设备)时返回**并集**
* 让操作员自行声明这是生产设备还是售后回流设备:一旦选中售后专属工序,
* 后端即把设备判为 AFTER_SALES之后下拉就只剩售后选项。
*
* 注意:这只是体验层的第一道防线,真正的拦截在后端
* task_service._enforce_step_isolation防伪造传参
*/
export function taskOptionsFor(
phase: string | null | undefined,
hasHistory = true,
): string[] {
if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_TASK_OPTIONS;
if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
return PRODUCTION_TASK_OPTIONS;
}
/** 按生命周期阶段取「可选宏观状态」— 同上,含已入库/已出库等终态 */
export function overallOptionsFor(
phase: string | null | undefined,
hasHistory = true,
): string[] {
if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_OVERALL_OPTIONS;
if (!hasHistory) return [...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
return PRODUCTION_OVERALL_OPTIONS;
}
/** 列表筛选枚举 — 两阶段并集(用于筛选,不是录入项) */
export const ALL_OVERALL_OPTIONS = [
...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS,
];
/** 售后服务相关环节 — 只有落到这些工序才打红色标签 */
const AFTER_SALES_TAG_STEPS = new Set<string>(AFTER_SALES_ONLY_STEPS);
export interface LifecycleBadge {
label: string;
className: string;
phase: LifecyclePhase;
}
/**
* 生命周期标签 — 只在设备确实处于**售后回流环节**时返回:
* 生命周期为 AFTER_SALES 且当前工序是「发货测试」或「售后维修」
* → 红底白字,文案即工序名。
*
* 生产制造阶段一律返回 null不打标避免和售后设备混淆。
*/
export function lifecycleBadge(
overallStatus: string | null | undefined,
phase: string | null | undefined,
): LifecycleBadge | null {
if (phase !== LIFECYCLE_PHASE.AFTER_SALES) return null;
const step = (overallStatus || "").trim();
if (!AFTER_SALES_TAG_STEPS.has(step)) return null;
return {
label: step,
className: "bg-red-600 text-white ring-1 ring-red-600",
phase: LIFECYCLE_PHASE.AFTER_SALES,
};
}

View File

@ -8,6 +8,7 @@ import { Loader2 } from "lucide-react";
import dayjs, { type Dayjs } from "dayjs";
import { fetchWipMatrix, fetchWipMatrixDetail, type WipMatrixRow, type WipMatrixDetailRow } from "../services/dashboardApi";
import { fetchDeviceRecords, type DeviceRecord } from "../services/analyticsApi";
import { lifecycleBadge } from "../constants/task";
const { RangePicker } = DatePicker;
@ -285,8 +286,21 @@ export default function MatrixBoard() {
{
title: "产品名称",
dataIndex: "material_name",
width: 160,
render: (v) => <span className="font-semibold text-blue-600">{v || "—"}</span>,
width: 200,
render: (v: string, record: WipMatrixDetailRow) => {
// 🔧 发货测试 / 售后维修 — 工序取当前下钻的列名(点产品名时为全部工序,不显示)
const lb = lifecycleBadge(drill?.process, record.lifecycle_phase);
return (
<span className="flex items-center gap-1.5">
<span className="font-semibold text-blue-600">{v || "—"}</span>
{lb && (
<span className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none ${lb.className}`}>
{lb.label}
</span>
)}
</span>
);
},
},
{
title: "身份证号",

View File

@ -6,6 +6,7 @@ import { scanProduct } from "../services/productApi";
import type { ProductScanResponse } from "../types/api";
import ManualInput from "../components/scan/ManualInput";
import QueryResult from "../components/scan/QueryResult";
import { lifecycleBadge } from "../constants/task";
// 🚀 CameraScanner → QrScanner → html5-qrcode (~200KB),仅在首次点开摄像头时加载
const CameraScanner = lazy(() => import("../components/scan/CameraScanner"));
@ -74,6 +75,11 @@ export default function ScanPage() {
<span className={`flex-1 text-sm font-bold ${product.overall_status === "已入库" ? "text-gray-500" : product.overall_status === "待仓库收货" ? "text-orange-600" : product.overall_status ? "text-blue-600" : "text-red-500"}`}>
{product.overall_status || "未设定"}
</span>
{/* 🔧 售后回流标识:处于发货测试/售后维修环节时红底白字提示 */}
{(() => {
const lb = lifecycleBadge(product.overall_status, product.lifecycle_phase);
return lb ? <span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${lb.className}`}>{lb.label}</span> : null;
})()}
</div>
</div>
)}
@ -126,6 +132,11 @@ export default function ScanPage() {
<span className={`flex-1 text-sm font-bold ${product.overall_status === "已入库" ? "text-gray-500" : product.overall_status === "待仓库收货" ? "text-orange-600" : product.overall_status ? "text-blue-600" : "text-red-500"}`}>
{product.overall_status || "未设定"}
</span>
{/* 🔧 售后回流标识:处于发货测试/售后维修环节时红底白字提示 */}
{(() => {
const lb = lifecycleBadge(product.overall_status, product.lifecycle_phase);
return lb ? <span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${lb.className}`}>{lb.label}</span> : null;
})()}
</div>
</div>
)}

View File

@ -13,7 +13,7 @@ import {
} from "../../services/printApi";
import { useToast } from "../../components/ui/Toast";
import { useAuth } from "../../contexts/AuthContext";
import { getStatusConfig } from "../../constants/task";
import { getStatusConfig, lifecycleBadge } from "../../constants/task";
const QR_BASE = "/api/v1/products/qrcode";
@ -275,12 +275,21 @@ export default function AdminProductsPage() {
<div className="flex flex-col items-center px-4 pt-5 pb-3">
<img src={`${QR_BASE}/${p.serial_number}`} alt={`QR-${p.serial_number}`} className="h-32 w-32 rounded-lg border border-gray-100" loading="lazy" />
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-800">{p.serial_number}</p>
{(() => {
const cfg = getStatusConfig(p.macro_status || p.status);
return cfg.label ? (
<span className={`mt-1 inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
) : null;
})()}
<div className="mt-1 flex flex-wrap items-center justify-center gap-1.5">
{(() => {
const cfg = getStatusConfig(p.macro_status || p.status);
return cfg.label ? (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
) : null;
})()}
{/* 🔧 发货测试 / 售后维修 — 仅「测试 / 维修」工序下显示 */}
{(() => {
const lb = lifecycleBadge(p.overall_status, p.lifecycle_phase);
return lb ? (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ${lb.className}`}>{lb.label}</span>
) : null;
})()}
</div>
</div>
<div className="flex-1 space-y-2 border-t border-gray-50 px-4 py-3">
<InfoRow icon={Package} label="物料名称" value={p.material_name || p.material_id || "—"} />

View File

@ -17,7 +17,7 @@ import { Modal, ReceiveConfirmModal, RejectModal, TransferModal } from "../../co
import type { ProductResponse } from "../../types/admin";
import type { ProductScanResponse } from "../../types/api";
import { useToast } from "../../components/ui/Toast";
import { getStatusConfig } from "../../constants/task";
import { getStatusConfig, lifecycleBadge, ALL_OVERALL_OPTIONS } from "../../constants/task";
import { useAuth } from "../../contexts/AuthContext";
const STATUS_TABS = [
@ -107,8 +107,21 @@ export default function AdminTasksPage() {
{
key: "overall_status", label: "当前工序", colSpan: 1,
filterType: "enum", getFilterValue: (p) => p.overall_status || "—",
enumOptions: ["备货", "生产", "测试", "维修", "待仓库收货", "已入库", "已出库"].map((v) => ({ value: v, label: v })),
render: (p) => <span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span>,
// 🔧 筛选用两阶段并集:售后设备的「发货测试 / 售后维修」也要能被筛出来
enumOptions: ALL_OVERALL_OPTIONS.map((v) => ({ value: v, label: v })),
render: (p) => {
const lb = lifecycleBadge(p.overall_status, p.lifecycle_phase);
return (
<span className="flex items-center gap-1.5">
<span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span>
{lb && (
<span className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none ${lb.className}`}>
{lb.label}
</span>
)}
</span>
);
},
},
{
key: "status", label: "产品状态", colSpan: 1,

View File

@ -1,4 +1,5 @@
import api from "./api";
import type { LifecyclePhase } from "../types/api";
export interface DashboardStats {
products_total: number;
@ -86,6 +87,8 @@ export interface WipMatrixDetailRow {
material_name: string;
spec_model: string;
task_status: string;
/** 🔧 生命周期阶段PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) */
lifecycle_phase?: LifecyclePhase;
assignee_id: string;
assignee: string;
duration_hours: number;

View File

@ -1,4 +1,5 @@
/** 管理端 API 响应类型 */
import type { LifecyclePhase } from "./api";
export interface ProductResponse {
id: string;
@ -17,6 +18,8 @@ export interface ProductResponse {
macro_status: string | null;
overall_status: string | null;
status: string;
/** 🔧 生命周期阶段PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) */
lifecycle_phase?: LifecyclePhase;
created_at: string;
latest_record_time: string | null;
latest_record_content: string | null;

View File

@ -1,5 +1,11 @@
/** 后端 API 响应类型 */
// ============================================================
// 生命周期阶段 — 区分「生产阶段的测试(发货测试)」与「出库后返厂(售后维修)」
// ============================================================
export type LifecyclePhase = "PRODUCTION" | "AFTER_SALES";
// ============================================================
// 任务状态常量
// ============================================================
@ -97,6 +103,8 @@ export interface ProductScanResponse {
current_location_id: string | null;
status: string;
overall_status: string | null;
/** 🔧 生命周期阶段PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) */
lifecycle_phase?: LifecyclePhase;
created_at: string;
top_level_tasks: TaskSummary[];
/** 完整递归任务树 — 供十字矩阵树状图渲染 */

View File

@ -10,6 +10,8 @@
<view class="overall-bar" @tap="handleOverallBarClick">
<text class="overall-label">宏观状态</text>
<text :class="['overall-val', overallStatusClass(product.overall_status)]">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
<!-- 🔧 生命周期标识生产阶段发货测试 vs 出库回流后售后维修 -->
<text v-if="lifeBadge" :class="['life-badge', lifeBadge.cls]">{{ lifeBadge.label }}</text>
<text v-if="product.task_tree && product.task_tree.length && canEditOverallStatus" class="overall-arrow"></text>
</view>
@ -70,7 +72,7 @@
<text class="sheet-title">{{ product && product.overall_status ? '修改宏观状态' : '🔔 请设定产品宏观状态' }}</text>
<text class="sheet-hint">首次扫码请选择一个状态以开启流转</text>
<view class="sheet-options">
<view v-for="opt in OVERALL_OPTIONS" :key="opt" :class="['sheet-opt', product && product.overall_status === opt ? 'sheet-opt-active' : '']" @tap="handleSetOverallStatus(opt)"><text>{{ opt }}</text></view>
<view v-for="opt in availableOverallOptions" :key="opt" :class="['sheet-opt', product && product.overall_status === opt ? 'sheet-opt-active' : '']" @tap="handleSetOverallStatus(opt)"><text>{{ opt }}</text></view>
</view>
<button v-if="product && product.overall_status" class="sheet-close" @tap="showStatusPicker = false">关闭</button>
</view>
@ -124,7 +126,7 @@
<text class="popup-hint">状态: {{ statusLabel(actionPopup.task && actionPopup.task.status) }} 进行中</text>
<view class="field-label">选择工序 <text class="required">*</text></view>
<view class="user-grid">
<view v-for="opt in TASK_NAME_OPTIONS" :key="opt"
<view v-for="opt in availableTaskOptions" :key="opt"
:class="['user-grid-item', receiveTaskName === opt ? 'user-grid-active' : '']"
@tap="receiveTaskName = opt">{{ opt }}</view>
</view>
@ -217,21 +219,22 @@
<script>
import request, { get, post, patch, put, getBaseUrl } from "../../utils/request";
import { setUserNameMap, formatUserName, formatUserAvatar } from "../../utils/format";
import { taskOptionsFor, overallOptionsFor, lifecycleBadge } from "../../utils/lifecycle";
import WorkspaceArea from "./components/WorkspaceArea.vue";
import TreeCanvas from "./components/TreeCanvas.vue";
import TaskSwipeCards from "./components/TaskSwipeCards.vue";
const OVERALL_OPTIONS = ["备货", "生产", "测试", "维修", "在库", "已入库", "已出库"];
const TASK_NAME_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
// 🔧 工序可选项不再写死 —— 由 computed availableOverallOptions / availableTaskOptions
// 按生命周期阶段(生产制造 / 售后回流)动态收窄,词表见 utils/lifecycle.js
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", OUTBOUND: "已出库", CANCELED: "已撤回" };
export default {
components: { WorkspaceArea, TreeCanvas, TaskSwipeCards },
data() {
return {
OVERALL_OPTIONS, loading: true, error: "", product: null,
loading: true, error: "", product: null,
showStatusPicker: false, editProductVisible: false, editForm: { order_no: "", external_serial: "" }, editSaving: false,
users: [], TASK_NAME_OPTIONS,
users: [],
createFirstVisible: false, isWarehouseTransfer: false, firstForm: { task_name: "", taskNameIdx: 0, assignee_id: "", assigneeLabel: "", assigneeIdx: 0, note: "" }, firstSaving: false,
recordPopup: { visible: false, task: null }, recordForm: { recordId: null, remark: "", images: [], pendingCount: 0 }, recordSaving: false, isUploading: false,
currentUser: null, currentUserId: "", currentUsername: "", currentUserRole: "", currentMode: "workspace", autoLockTaskId: "",
@ -287,6 +290,42 @@ export default {
checkTask(this.product.task_tree);
return hasPermission;
},
// 🔧 生命周期标签:只在售后回流环节(发货测试 / 售后维修)打红色标签
lifeBadge() {
return lifecycleBadge(
this.product && this.product.overall_status,
this.product && this.product.lifecycle_phase,
);
},
// ── 🔧 选项隔离:按生命周期阶段收窄可选工序 ──
// 生产制造设备只能排「备货/生产/测试/维修/在库」;
// 售后回流设备只能选「发货测试/售后维修/在库」,排不回前期环节。
isAfterSales() {
return !!(this.product && this.product.lifecycle_phase === "AFTER_SALES");
},
// 是否已有"真实工序"历史 —— 仅判断 task_tree 非空是不够的:
// 老设备首次「发起首道工序」建出来的任务名是占位符「待确认」,
// 此时产品已有一条任务,但接收人还没机会声明工序。若按 task_tree 非空
// 就判定"有历史",接收下拉将只给生产工序,老设备永远选不到「售后维修」,
// 售后通路直接断掉。故这里必须排除占位符。
hasHistory() {
const walk = (tasks) => {
if (!tasks) return false;
for (const t of tasks) {
const name = String(t.task_name || "").trim();
if (name && name !== "待确认" && !name.includes("virtual_warehouse")) return true;
if (walk(t.child_tasks)) return true;
}
return false;
};
return walk(this.product && this.product.task_tree);
},
availableOverallOptions() {
return overallOptionsFor(this.product && this.product.lifecycle_phase, this.hasHistory);
},
availableTaskOptions() {
return taskOptionsFor(this.product && this.product.lifecycle_phase, this.hasHistory);
},
},
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) { this.doQuery(sn); return; } /* 🚀 兜底: 从 taskId 反查 product */ const tid = options.taskId || ""; if (tid) this.doQueryByTask(tid); },
// 🚀 onShow 生命周期:每次页面显示时刷新留言板(解决从聊天室退回不更新问题)
@ -335,7 +374,7 @@ export default {
async loadUsers() { try { const res = await get("/users/", { limit: 200, dept: "IRIS" }); this.users = (res || []).filter(u => u.department === "IRIS"); this.userOptions = this.users.map(u => ({ id: u.username || u.id, name: u.full_name || u.username })); setUserNameMap(this.users); this.dictVersion += 1; } catch (e) { console.error('[loadUsers] 获取用户列表失败:', e); } },
loadCurrentUser() { try { let user = uni.getStorageSync("user"); if (typeof user === "string" && user) { try { user = JSON.parse(user); } catch (e) { user = null; } } if (user && typeof user === "object") { this.currentUser = user; this.currentUserId = String(user.id || ""); this.currentUsername = user.username || ""; this.currentUserRole = user.role || ""; } } catch {} },
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = TASK_NAME_OPTIONS[idx]; },
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = this.availableTaskOptions[idx]; },
onAssigneeChange(e) { const idx = e.detail.value; const u = this.users[idx]; if (u) { this.firstForm.assigneeIdx = idx; this.firstForm.assignee_id = u.username; this.firstForm.assigneeLabel = `${u.full_name} (${u.username})`; } },
openCreateFirstTask() { this.isWarehouseTransfer = false; if (this.currentMode === 'tree') this.currentMode = 'workspace'; this.firstForm = { assignee_id: "", assigneeLabel: "", note: "" }; this.createFirstVisible = true; },
handleOverallBarClick() { if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } else if (this.canEditOverallStatus) { this.showStatusPicker = true; } else { uni.showToast({ title: '仅超级管理员或当前主线负责人可修改状态', icon: 'none', duration: 2500 }); } },
@ -356,7 +395,7 @@ export default {
if (type === "deleteRecord") { this.doDeleteRecord(record); return; }
if (type === "end") { this.confirmEndBranch(task); return; }
if (type === "recall") { this.confirmRecall(task); return; }
if (type === "transfer" || type === "spawn") { uni.showLoading({ title: "加载数据..." }); try { if (!this.userOptions.length) await this.loadUsers(); if (!this.processOptions.length) this.processOptions = ["🏭 入库 (virtual_warehouse)", ...TASK_NAME_OPTIONS]; } finally { uni.hideLoading(); } }
if (type === "transfer" || type === "spawn") { uni.showLoading({ title: "加载数据..." }); try { if (!this.userOptions.length) await this.loadUsers(); this.processOptions = ["🏭 入库 (virtual_warehouse)", ...this.availableTaskOptions]; } finally { uni.hideLoading(); } }
this.actionPopup = { visible: true, type, task }; this.rejectReason = ""; this.receiveRemark = ""; this.receiveTaskName = "";
this.transferForm = { selectedUserId: "", isWarehouse: false, note: "" };
this.spawnForm = { assignee_id: "", remark: "" };
@ -490,6 +529,9 @@ export default {
.overall-outbound { color: #4f46e5; } /* 已出库:靛蓝 */
.overall-warehouse { color: #ea580c; } /* 待仓库收货:橙 */
.overall-arrow { font-size: 12px; color: #9ca3af; }
/* 🔧 售后回流标识:红底白字,回流设备一眼可辨(生产阶段不打标) */
.life-badge { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 20px; flex-shrink: 0; }
.life-badge-after { background: #dc2626; color: #ffffff; }
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); flex-shrink: 0; min-height: 120px; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; gap: 6px; }
.card-header-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }

View File

@ -0,0 +1,68 @@
/**
* 生命周期阶段lifecycle_phase与工序选项隔离 — 移动端口径
*
* ⚠️ 与后端 backend/app/core/lifecycle.py、PC 端 frontend/src/constants/task.ts
* 是同一份词表,改动请三处同步。
*
* 售后回流阶段使用**独立工序名**(发货测试 / 售后维修),不复用生产阶段的
* 「测试 / 维修」,选中售后专属工序即代表设备进入售后生命周期。
*
* 本模块只负责"该给操作员看哪些选项"(体验层);
* 真正的拦截在后端 task_service._enforce_step_isolation防伪造传参
*/
export const LIFECYCLE_PHASE = {
PRODUCTION: "PRODUCTION",
AFTER_SALES: "AFTER_SALES",
};
/** 售后专属工序名 — 选中即代表设备进入售后生命周期 */
export const STEP_SHIP_TEST = "发货测试";
export const STEP_AFTER_SALES_REPAIR = "售后维修";
const AFTER_SALES_ONLY_STEPS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR];
// ── 生产制造阶段合法工序(原有词表,一字未改)──
export const PRODUCTION_TASK_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
export const PRODUCTION_OVERALL_OPTIONS = [
"备货", "生产", "测试", "维修", "在库", "已入库", "已出库",
];
// ── 售后回流阶段:只保留「发货测试 / 售后维修 / 入库出库」──
export const AFTER_SALES_TASK_OPTIONS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库"];
export const AFTER_SALES_OVERALL_OPTIONS = [
STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库", "已入库", "已出库",
];
/**
* 接收任务时可选工序。
*
* hasHistory = false无任何历史任务的首次激活 / 老设备)时返回**并集**
* 这类设备既可能是新投产,也可能是直接返厂售后的老设备,让操作员自己声明。
* 一旦选定售后专属工序,后端即把设备判为 AFTER_SALES之后下拉自动收窄。
*/
export function taskOptionsFor(phase, hasHistory = true) {
if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_TASK_OPTIONS;
if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
return PRODUCTION_TASK_OPTIONS;
}
/** 宏观状态可选值 — 同上,含已入库/已出库等终态 */
export function overallOptionsFor(phase, hasHistory = true) {
if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_OVERALL_OPTIONS;
if (!hasHistory) return [...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
return PRODUCTION_OVERALL_OPTIONS;
}
/**
* 生命周期标签 — 只在设备确实处于售后回流环节时返回:
* 生命周期为 AFTER_SALES 且当前工序是「发货测试」或「售后维修」
* → 红底白字,文案即工序名。
* 生产制造阶段一律返回 null不打标避免与售后设备混淆。
*/
export function lifecycleBadge(overallStatus, phase) {
if (phase !== LIFECYCLE_PHASE.AFTER_SALES) return null;
const step = String(overallStatus || "").trim();
if (step !== STEP_SHIP_TEST && step !== STEP_AFTER_SALES_REPAIR) return null;
return { label: step, phase: LIFECYCLE_PHASE.AFTER_SALES, cls: "life-badge-after" };
}