Compare commits
7 Commits
b088d25df9
...
31d085881b
| Author | SHA1 | Date | |
|---|---|---|---|
| 31d085881b | |||
| 3238e4819a | |||
| db3bafcc66 | |||
| 2de95799c6 | |||
| b40340ea55 | |||
| b9f9b897a1 | |||
| 3568d17865 |
@ -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")
|
||||
119
backend/app/core/lifecycle.py
Normal file
119
backend/app/core/lifecycle.py
Normal 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
|
||||
@ -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="创建时间",
|
||||
)
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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)
|
||||
# 🚀 智能父节点继承算法
|
||||
# 主线任务转交 → 保持平级继承(主分支永远在一维主干上)
|
||||
# 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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: "身份证号",
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 || "—"} />
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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[];
|
||||
/** 完整递归任务树 — 供十字矩阵树状图渲染 */
|
||||
|
||||
@ -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; }
|
||||
|
||||
68
track-uniapp/src/utils/lifecycle.js
Normal file
68
track-uniapp/src/utils/lifecycle.js
Normal 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" };
|
||||
}
|
||||
Reference in New Issue
Block a user