feat: 产品接口暴露lifecycle_phase并做阶段感知状态校验
This commit is contained in:
@ -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] = []
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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],
|
||||
|
||||
Reference in New Issue
Block a user