diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index 020dbcd..05d99ae 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -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] = [] diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index f9f0289..475fcb2 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -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, diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py index 2d89719..4c92188 100644 --- a/backend/app/services/product_service.py +++ b/backend/app/services/product_service.py @@ -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],