fix: 售后回流口径统一 — 状态双字段同步、工序名归一、标签配色

产品存在 overall_status 与 status 两个状态字段,此前各写入点各写一份映射、
甚至只改 overall_status 不改 status,导致回流设备(product.status 停留在
OUTBOUND)污染看板统计口径。

- lifecycle.py: 把映射表收敛为单一来源 overall_to_product_status /
  sync_product_status;新增 normalize_after_sales_step,将售后设备沿用生产
  阶段写法的历史工序名(测试/维修)折算到售后区独立工序名
- product_service.py: 删除本地 _OVERALL_TO_STATUS 副本,改用共享函数
- dashboard_service.py: WIP 矩阵补出 lifecycle_phase 列,活跃任务判定
  (is_active) 提前到所有终结态判定之前,避免残留 OUTBOUND 被误判为完结
- scripts/fix_product_status.py: 历史数据修复脚本(一次性)
- constants/task.ts: 售后工序标签由红色改紫色 —— 红色在本系统是「驳回/危险」
  语义色,售后只是另一条流转支线,用红色会让操作员误以为设备报错
This commit is contained in:
2026-09-15 10:57:29 +08:00
parent 31d085881b
commit f22eae315f
6 changed files with 216 additions and 28 deletions

View File

@ -117,3 +117,63 @@ def is_step_allowed(phase: str | None, step: str | None) -> bool:
phase or LIFECYCLE_PRODUCTION, _ALLOWED_BY_PHASE[LIFECYCLE_PRODUCTION],
)
return step.strip() in allowed
# 老数据兼容:售后设备的工序曾沿用生产阶段的「测试 / 维修」写法
# (售后独立工序名是后加的)。统计/看板展示时折算到售后区的独立工序名,
# 否则售后设备的历史工序会混进生产区的同名工序,导致统计失真。
AFTER_SALES_STEP_ALIAS = {
"测试": STEP_SHIP_TEST,
"维修": STEP_AFTER_SALES_REPAIR,
}
def normalize_after_sales_step(phase: str | None, step: str | None) -> str | None:
"""售后回流设备的历史工序名归一 —— 折算到售后区的独立工序名。
仅对 AFTER_SALES 生效;生产设备的「测试 / 维修」原样保留。
统计口径WIP 矩阵 / 下钻)必须与展示口径一致,故集中在模块内。
"""
if (phase or "") != LIFECYCLE_AFTER_SALES or not step:
return step
return AFTER_SALES_STEP_ALIAS.get(step.strip(), step)
# ============================================================
# overall_status中文宏观状态 ⇄ product.status英文状态码
# ============================================================
# 这两个字段必须始终同步。历史缺陷receive_task / transfer_task 只改
# overall_status 不改 status导致设备出库回流后 status 永远停在 OUTBOUND
# 统计口径被污染WIP 矩阵曾因此把正在做「发货测试」的设备吞进「已出库」列)。
# 所有改写 overall_status 的地方一律调 sync_product_status()。
# 终态映射;不在表内的(备货/生产/测试/维修/发货测试/售后维修…)都是活跃工序 → WIP
OVERALL_TO_PRODUCT_STATUS = {
"已入库": "ARCHIVED",
"在库": "ARCHIVED",
"已出库": "OUTBOUND",
"待仓库收货": "COMPLETED",
}
DEFAULT_PRODUCT_STATUS = "WIP"
def overall_to_product_status(overall: str | None) -> str:
"""中文宏观状态 → product.status 状态码。
活跃工序(含售后「发货测试 / 售后维修」)不在映射表里,统一落到 WIP ——
这正是消除「status 残留在 OUTBOUND」的关键。
"""
if not overall:
return DEFAULT_PRODUCT_STATUS
return OVERALL_TO_PRODUCT_STATUS.get(overall.strip(), DEFAULT_PRODUCT_STATUS)
def sync_product_status(product) -> str:
"""把 product.status 与 overall_status 对齐,返回落定的状态码。
刻意用 duck-typing 而非导入 ORM 模型,避免 core 层反向依赖 models。
调用点:任何改写 product.overall_status 之后都必须调一次。
"""
product.status = overall_to_product_status(getattr(product, "overall_status", None))
return product.status

View File

@ -4,6 +4,8 @@ from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.core.lifecycle import normalize_after_sales_step
# ============================================================
# Schemas
@ -680,6 +682,7 @@ async def get_wip_matrix(
Product.current_location_id,
Product.overall_status,
Product.status,
Product.lifecycle_phase,
)
.outerjoin(Task, Task.product_id == Product.id)
.where(
@ -696,7 +699,7 @@ async def get_wip_matrix(
# 每台设备 → (spec, 当前工序/负责人, 当前负责人ID)
device_cur: dict[str, tuple] = {}
seen: set[str] = set()
for pid, spec, product_name, task_name, assignee, tstatus, created, completed, loc, overall_status, product_status in rows:
for pid, spec, product_name, task_name, assignee, tstatus, created, completed, loc, overall_status, product_status, lifecycle in rows:
if pid in seen:
continue
seen.add(pid)
@ -706,8 +709,15 @@ async def get_wip_matrix(
# completed_at or created_at若后续新增 updated_at 可并入。
anchor = completed or created
# ── 活跃任务判定:决定"按当前工序归列"还是"降级判完结态"的唯一依据 ──
# 必须在所有终结态判定之前。否则「已出库」的残留状态(设备回流后
# product.status 仍是 OUTBOUND因为 receive_task/transfer_task 只同步
# overall_status 不同步 status会把正在做售后工序的设备误判为终结态。
is_active = tstatus in ("WIP", "PENDING")
# ── 动静分离:仅终结(收口/在库/出库)设备按 since~until 过滤;在制设备无条件全量 ──
is_terminal = (
# 有活跃任务的一律视为在制,不做时间过滤(与"在制无条件全量"口径一致)
is_terminal = (not is_active) and (
loc == "virtual_warehouse"
or overall_status in ("待仓库收货", "已入库", "在库", "已出库")
or str(product_status).upper() in ("ARCHIVED", "OUTBOUND")
@ -723,9 +733,21 @@ async def get_wip_matrix(
# else: 在制设备无视时间参数,保证与人员看板“实时在制”总数一致
if dimension == "task_name":
# ── 终结三列判定(多维防御,不依赖单一 loc / overall──
# ══ ① 第一优先级:有活跃任务(WIP/PENDING) → 一律按当前工序归列 ══
# 绝不看 overall_status / product.status 的终结态字段。这两个字段
# 在售后回流场景下会残留旧值status 停在 OUTBOUND若先判终结态
# 正在做「发货测试」的设备会被吞进「已出库」列。
if is_active:
if tstatus == "PENDING":
# 转交后待接收(工序名还是占位符「待确认」)→ 统一归「待接收」
key = "待接收"
else:
key = task_name if (task_name and task_name != "") else "待接收"
# 售后回流设备按折算后的工序名落列:发货测试 / 售后维修
key = normalize_after_sales_step(lifecycle, key)
# ══ ② 无活跃任务,才降级判完结态(多维防御,不依赖单一 loc / overall══
# 已入库: 整体实收(已入库/在库) 或 status==ARCHIVED 或 收口节点「扫码入库」
if overall_status in ("已入库", "在库") or str(product_status).upper() == "ARCHIVED" or task_name == "扫码入库":
elif overall_status in ("已入库", "在库") or str(product_status).upper() == "ARCHIVED" or task_name == "扫码入库":
key = "已入库"
# 已出库: 整体已出库 或 status==OUTBOUND 或 收口节点「扫码出库」
elif overall_status == "已出库" or str(product_status).upper() == "OUTBOUND" or task_name == "扫码出库":
@ -734,11 +756,15 @@ async def get_wip_matrix(
elif overall_status == "待仓库收货" or loc == "virtual_warehouse":
key = "已完成"
else:
# 🔧 对齐全景/产品管理:PENDING(含 task_name=待确认) 与 无任务新品 统一归「待接收」
if tstatus == "PENDING" or (tstatus is None and task_name is None):
# 🔧 对齐全景/产品管理:无任何任务的新品统一归「待接收」
if tstatus is None and task_name is None:
key = "待接收"
else:
# 已完工但未收口的工序(如「测试」完成),按原工序名显示
key = task_name if (task_name and task_name != "") else "待接收"
# 🔧 售后回流设备的历史工序归一:老数据里售后设备的工序可能仍叫
# 「测试 / 维修」,折算进售后区的独立列,避免混入生产区同名工序
key = normalize_after_sales_step(lifecycle, key)
else:
key = assignee or "未分配"
device_cur[pid] = (spec or "未知型号", key, assignee or "", product_name or "")
@ -869,8 +895,13 @@ async def get_wip_matrix_detail(
# ── 真实产出时间锚点:完工/变动时间优先创建时间仅兜底schema 无 updated_at──
anchor = completed or created
# ── 活跃任务判定:与 get_wip_matrix 同口径,必须先于一切终结态判定 ──
# 否则「已出库」残留状态(回流后 pstatus 仍是 OUTBOUND会把正在做
# 售后工序的设备误判为终结态,既错配时间过滤又错列。
is_active = tstatus in ("WIP", "PENDING")
# ── 动静分离:仅终结(收口/在库/出库)设备按 since~until 过滤;在制设备无条件全量 ──
is_terminal = (
is_terminal = (not is_active) and (
loc == "virtual_warehouse"
or overall in ("待仓库收货", "已入库", "在库", "已出库")
or str(pstatus).upper() in ("ARCHIVED", "OUTBOUND")
@ -885,19 +916,30 @@ async def get_wip_matrix_detail(
continue
# else: 在制设备无视时间参数,保证与人员看板“实时在制”总数一致
# ── 终结三列判定(与 get_wip_matrix 一字不差同步,多维防御)──
if overall in ("已入库", "在库") or str(pstatus).upper() == "ARCHIVED" or task_name == "扫码入库":
# ══ 列归类(与 get_wip_matrix 一字不差同步)══
# ① 第一优先级:有活跃任务 → 一律按当前工序归列,不看终结态字段
if is_active:
if tstatus == "PENDING":
key = "待接收"
else:
key = task_name if (task_name and task_name != "") else "待接收"
key = normalize_after_sales_step(lifecycle, key)
# ② 无活跃任务,才降级判完结态
elif overall in ("已入库", "在库") or str(pstatus).upper() == "ARCHIVED" or task_name == "扫码入库":
key = "已入库"
elif overall == "已出库" or str(pstatus).upper() == "OUTBOUND" or task_name == "扫码出库":
key = "已出库"
elif overall == "待仓库收货" or loc == "virtual_warehouse":
key = "已完成"
else:
# 🔧 对齐全景/产品管理:PENDING(含 task_name=待确认) 与 无任务新品 统一归「待接收」
if tstatus == "PENDING" or (tstatus is None and task_name is None):
# 🔧 对齐全景/产品管理:无任何任务的新品统一归「待接收」
if tstatus is None and task_name is None:
key = "待接收"
else:
key = task_name if (task_name and task_name != "") else "待接收"
# 🔧 必须与 get_wip_matrix 同口径归一:否则矩阵格子里有数、
# 点进来下钻却是空的(售后设备的历史「测试/维修」工序)
key = normalize_after_sales_step(lifecycle, key)
if process and key != process:
continue

View File

@ -12,6 +12,7 @@ from app.core.lifecycle import (
is_step_allowed,
phase_label,
resolve_phase_for_step,
sync_product_status,
)
from app.models.product import Product
from app.models.production_order import ProductionOrder
@ -506,14 +507,9 @@ async def update_overall_status(
)
product.lifecycle_phase = phase
product.overall_status = status_value
# 同步 status 字段,保证与整体状态口径一致(修复"只改整体状态不改 status"的旧缺陷)
_OVERALL_TO_STATUS = {
"已入库": "ARCHIVED",
"在库": "ARCHIVED",
"已出库": "OUTBOUND",
"待仓库收货": "COMPLETED",
}
product.status = _OVERALL_TO_STATUS.get(status_value, "WIP")
# 同步 status 字段(映射表已上提到 app.core.lifecycle 单一来源,
# 供 task_service 的各写入点共用,避免各处各写一份)
sync_product_status(product)
await db.commit()
await db.refresh(product)

View File

@ -0,0 +1 @@
"""一次性运维脚本集合按模块方式运行python -m scripts.<name>"""

View File

@ -0,0 +1,84 @@
"""一次性数据清洗 — 修复 Product.status 与 overall_status 脱节的历史存量
背景
----
app/core/lifecycle.py 记录了历史缺陷:早期 receive_task / transfer_task 只改
overall_status、不改 status导致部分设备的 status 残留为 'pending' / 'OUTBOUND'
等旧值。
现状(重要)
----------
所有 overall_status 的写入点**都已补上 sync_product_status()**
task_service.py:387 / :691 / :1007、product_service.py:512、
webhooks.py:84 / :256、product_finalize_service.py:107
因此新数据不会再脱节,本脚本只处理历史存量。
影响面
------
前端列表筛选走的是 macro_status由 overall_status 实时派生,见
product_service._resolve_macro_status所以这批脏数据**基本不影响页面展示**。
但 _mark_after_sales_if_reactivated 等逻辑会读 product.status 判断"是否出库回流"
脏值可能引发误判 —— 故仍需清洗。
用法
----
python -m scripts.fix_product_status # 干跑:只打印待修复清单
python -m scripts.fix_product_status --apply # 确认后真正写库
"""
from __future__ import annotations
import argparse
import asyncio
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.core.lifecycle import overall_to_product_status
from app.models.product import Product
async def main(apply: bool) -> None:
async with AsyncSessionLocal() as db:
products = (
await db.execute(select(Product).order_by(Product.serial_number))
).scalars().all()
fixes: list[tuple[Product, str, str]] = []
for p in products:
expected = overall_to_product_status(p.overall_status)
current = (p.status or "").strip()
# 大小写不敏感比较:历史数据里存在小写 'pending'
if current.upper() == expected:
continue
fixes.append((p, current or "(空)", expected))
print(f"扫描 {len(products)} 台设备,需修复 {len(fixes)}\n")
if fixes:
header = f"{'序列号':<18}{'overall_status':<16}{'当前 status':<14}→ 目标 status"
print(header)
print("-" * len(header) * 2)
for p, cur, exp in fixes:
overall = p.overall_status or "(NULL)"
phase = p.lifecycle_phase or "-"
print(f"{p.serial_number:<18}{overall:<16}{cur:<14}{exp} [{phase}]")
else:
print("所有设备的 status 均已与 overall_status 对齐。")
if not apply:
print("\n[干跑] 未写库。确认清单无误后,加 --apply 执行。")
return
if not fixes:
return
for p, _cur, exp in fixes:
p.status = exp
await db.commit()
print(f"\n[已提交] {len(fixes)} 台设备的 status 已对齐到 overall_status。")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="对齐 Product.status 与 overall_status 的历史脏数据")
parser.add_argument("--apply", action="store_true", help="真正写库(默认仅干跑)")
asyncio.run(main(parser.parse_args().apply))

View File

@ -112,17 +112,20 @@ export const STATUS_CONFIG: Record<string, StatusStyle> = {
ring: "ring-blue-400",
label: "维修",
},
// ---- 售后回流阶段专属工序(红系,与生产阶段一眼区分----
// ---- 售后回流阶段专属工序(紫系----
// 选紫色而非红色:红色在本系统里是「驳回 / 危险」的语义色,售后只是另一条
// 流转支线而非异常,用红色会让操作员误以为设备报错。也不选靛蓝——那已被
// 「已出库 OUTBOUND」占用两者相邻显示会混淆。
: {
bg: "bg-red-50",
text: "text-red-700",
ring: "ring-red-400",
bg: "bg-purple-50",
text: "text-purple-700",
ring: "ring-purple-300",
label: "发货测试",
},
: {
bg: "bg-red-50",
text: "text-red-700",
ring: "ring-red-400",
bg: "bg-purple-50",
text: "text-purple-700",
ring: "ring-purple-300",
label: "售后维修",
},
};
@ -230,9 +233,11 @@ export interface LifecycleBadge {
/**
* 生命周期标签 — 只在设备确实处于**售后回流环节**时返回:
* 生命周期为 AFTER_SALES 且当前工序是「发货测试」或「售后维修」
* → 底白字,文案即工序名。
* → 底白字,文案即工序名。
*
* 生产制造阶段一律返回 null不打标避免和售后设备混淆。
* 配色用紫色:红色在本系统是「驳回 / 危险」语义,售后只是另一条流转支线,
* 用红色会让操作员误以为设备报错。
*/
export function lifecycleBadge(
overallStatus: string | null | undefined,
@ -243,7 +248,7 @@ export function lifecycleBadge(
if (!AFTER_SALES_TAG_STEPS.has(step)) return null;
return {
label: step,
className: "bg-red-600 text-white ring-1 ring-red-600",
className: "bg-purple-600 text-white ring-1 ring-purple-600",
phase: LIFECYCLE_PHASE.AFTER_SALES,
};
}