feat: 产品接口暴露lifecycle_phase并做阶段感知状态校验
This commit is contained in:
@ -54,6 +54,8 @@ class ProductResponse(BaseModel):
|
|||||||
macro_status: str | None = None # 🔧 后端预计算的任务树状态(免前端逐条展开)
|
macro_status: str | None = None # 🔧 后端预计算的任务树状态(免前端逐条展开)
|
||||||
overall_status: str | None = None
|
overall_status: str | None = None
|
||||||
status: str
|
status: str
|
||||||
|
# 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
|
||||||
|
lifecycle_phase: str = "PRODUCTION"
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
# 🔧 最新动态 — 该产品活跃任务的最新记录
|
# 🔧 最新动态 — 该产品活跃任务的最新记录
|
||||||
latest_record_time: datetime | None = None
|
latest_record_time: datetime | None = None
|
||||||
@ -86,6 +88,8 @@ class ProductScanResponse(BaseModel):
|
|||||||
current_location_id: str | None = None
|
current_location_id: str | None = None
|
||||||
overall_status: str | None = None
|
overall_status: str | None = None
|
||||||
status: str
|
status: str
|
||||||
|
# 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
|
||||||
|
lifecycle_phase: str = "PRODUCTION"
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
top_level_tasks: list[TaskSummaryResponse] = []
|
top_level_tasks: list[TaskSummaryResponse] = []
|
||||||
task_tree: list[TaskResponse] = []
|
task_tree: list[TaskResponse] = []
|
||||||
|
|||||||
@ -96,6 +96,8 @@ class WipMatrixDetailRow(BaseModel):
|
|||||||
material_name: str = "" # 产品名称
|
material_name: str = "" # 产品名称
|
||||||
spec_model: str = ""
|
spec_model: str = ""
|
||||||
task_status: str = "" # 当前状态 WIP/PENDING/COMPLETED/ARCHIVED/OUTBOUND
|
task_status: str = "" # 当前状态 WIP/PENDING/COMPLETED/ARCHIVED/OUTBOUND
|
||||||
|
# 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
|
||||||
|
lifecycle_phase: str = "PRODUCTION"
|
||||||
assignee_id: str = ""
|
assignee_id: str = ""
|
||||||
assignee: str = "" # 负责人中文名
|
assignee: str = "" # 负责人中文名
|
||||||
duration_hours: float = 0.0 # 已在该工序滞留时长(小时)
|
duration_hours: float = 0.0 # 已在该工序滞留时长(小时)
|
||||||
@ -831,6 +833,7 @@ async def get_wip_matrix_detail(
|
|||||||
Product.current_location_id,
|
Product.current_location_id,
|
||||||
Product.overall_status,
|
Product.overall_status,
|
||||||
Product.status,
|
Product.status,
|
||||||
|
Product.lifecycle_phase,
|
||||||
Task.task_name,
|
Task.task_name,
|
||||||
Task.assignee_id,
|
Task.assignee_id,
|
||||||
Task.status.label("task_status"),
|
Task.status.label("task_status"),
|
||||||
@ -855,7 +858,7 @@ async def get_wip_matrix_detail(
|
|||||||
matched: list[WipMatrixDetailRow] = []
|
matched: list[WipMatrixDetailRow] = []
|
||||||
raw_names: set[str] = set()
|
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:
|
if pid in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(pid)
|
seen.add(pid)
|
||||||
@ -915,6 +918,7 @@ async def get_wip_matrix_detail(
|
|||||||
material_name=mat_name or "",
|
material_name=mat_name or "",
|
||||||
spec_model=spec or "",
|
spec_model=spec or "",
|
||||||
task_status=tstatus or "",
|
task_status=tstatus or "",
|
||||||
|
lifecycle_phase=lifecycle or "PRODUCTION",
|
||||||
assignee_id=assignee or "",
|
assignee_id=assignee or "",
|
||||||
duration_hours=duration,
|
duration_hours=duration,
|
||||||
received_at=received_str,
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
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.product import Product
|
||||||
from app.models.production_order import ProductionOrder
|
from app.models.production_order import ProductionOrder
|
||||||
from app.models.task import Task
|
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,
|
current_location_id=product.current_location_id,
|
||||||
overall_status=product.overall_status,
|
overall_status=product.overall_status,
|
||||||
status=product.status,
|
status=product.status,
|
||||||
|
lifecycle_phase=product.lifecycle_phase,
|
||||||
created_at=product.created_at,
|
created_at=product.created_at,
|
||||||
top_level_tasks=[
|
top_level_tasks=[
|
||||||
TaskSummaryResponse.model_validate(t) for t in top_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,
|
current_location_name=creator_display_name or None,
|
||||||
overall_status=product.overall_status,
|
overall_status=product.overall_status,
|
||||||
status=product.status,
|
status=product.status,
|
||||||
|
lifecycle_phase=product.lifecycle_phase,
|
||||||
created_at=product.created_at,
|
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,
|
current_location_id=product.current_location_id,
|
||||||
overall_status=product.overall_status,
|
overall_status=product.overall_status,
|
||||||
status=product.status,
|
status=product.status,
|
||||||
|
lifecycle_phase=product.lifecycle_phase,
|
||||||
created_at=product.created_at,
|
created_at=product.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
VALID_OVERALL_STATUS = {"备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库"}
|
# 全部合法宏观状态(两阶段并集)— 仅作"完全非法取值"的第一道粗筛;
|
||||||
|
# 阶段内的细分校验(售后回流设备禁止改回「备货 / 生产」)见下方 is_step_allowed
|
||||||
|
VALID_OVERALL_STATUS = ALL_OVERALL_STEPS
|
||||||
|
|
||||||
|
|
||||||
async def update_overall_status(
|
async def update_overall_status(
|
||||||
@ -434,7 +446,7 @@ async def update_overall_status(
|
|||||||
if status_value not in VALID_OVERALL_STATUS:
|
if status_value not in VALID_OVERALL_STATUS:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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(
|
result = await db.execute(
|
||||||
@ -480,6 +492,19 @@ async def update_overall_status(
|
|||||||
detail="只有 SUPER_ADMIN 或当前操作该产品主线任务的人才能修改宏观状态",
|
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
|
product.overall_status = status_value
|
||||||
# 同步 status 字段,保证与整体状态口径一致(修复"只改整体状态不改 status"的旧缺陷)
|
# 同步 status 字段,保证与整体状态口径一致(修复"只改整体状态不改 status"的旧缺陷)
|
||||||
_OVERALL_TO_STATUS = {
|
_OVERALL_TO_STATUS = {
|
||||||
@ -829,6 +854,7 @@ async def get_all_products(
|
|||||||
macro_status=_resolve_macro_status(p),
|
macro_status=_resolve_macro_status(p),
|
||||||
overall_status=overall_names.get(p.id) or p.overall_status,
|
overall_status=overall_names.get(p.id) or p.overall_status,
|
||||||
status=p.status,
|
status=p.status,
|
||||||
|
lifecycle_phase=p.lifecycle_phase,
|
||||||
created_at=p.created_at,
|
created_at=p.created_at,
|
||||||
latest_record_time=latest_record_map.get(p.id, (None, None, False, None))[0],
|
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],
|
latest_record_content=latest_record_map.get(p.id, (None, None, False, None))[1],
|
||||||
|
|||||||
Reference in New Issue
Block a user