8 Commits

Author SHA1 Message Date
d729a19b86 feat: MOM 撤回出库强制回滚通道
MOM 把误点出库的设备物理回滚到仓库时,Track 被动跟随 MOM 的权威物理状态。

【撤回信号识别】
- action / event 里的 revoke / rollback / revert / cancel 子串匹配
  (MOM 侧字段命名尚未冻结,刻意宽松,避免对方改词就整条链路失联)
- 无显式标记但产品正处于「已出库」时,按"已发货设备收到入库回调 = 货回来了"
  隐式判定

【匹配放宽】
- 常规入库仍严格要求 current_location_id == virtual_warehouse
- 撤回信号、或 payload 带 serial 时才放行「已出库」产品 —— 出库回调已把
  location 置为 None,不放宽则撤回必然失配、静默返回 matched=False
- 刻意不给 sku 兜底也无条件放宽:同型号可能多台,放宽会误标到别的设备

【特权通道】
- 强制覆写 overall_status=已入库 + 位置回滚 virtual_warehouse,优先级高于
  task_service 的【绝对物理终态保护】。两者方向刻意相反:那套约束的是
  "车间内部流转不许用工序名抹掉物理终态",而本接口是物理事实的权威来源。
  代码内已留醒目注释,防止后续维护者误加状态互斥校验
- 改用 sync_product_status 统一双字段同步(原先硬编码 status="ARCHIVED"
  绕过了 lifecycle.py 的约定),并把 status 变化一并计入 changed,
  避免"状态与位置本就正确时纠偏不提交"

【撤回留痕】
- 追加「撤回出库(重新入库)」主线节点。名称里的「入库」二字是必须保留的契约:
  product_service._has_warehouse_task 用子串判定仓库节点,若只有"出库"会让
  location==virtual_warehouse 的产品被注入假的「待仓库收货」虚拟节点,
  出现"已入库却在等收货"的自相矛盾

【重构】
- 抽出 _pick_warehouse_log_task / _match_inbound_product 复用,出库回调同步简化
2026-09-17 17:42:06 +08:00
42c883fe7d feat: 移动端直接完结入口、权限与派发解绑
【直接完结入口(双重门槛)】
- 转交弹窗新增「🏁 直接完结」选项,与「入库」互斥
- 角色门槛:仅超管 / 主管可见可操作(与后端 ADMIN_ROLES 对齐,两个角色都判 ——
  本文件既有的 canEditOverallStatus 只判了 SUPER_ADMIN 漏了 SUPERVISOR)
- 场景门槛:新增 showFinishDirect,要求产品确实是「已出库」。普通生产设备若被
  直接完结,既无下游任务、又不在仓库池,会变成卡在工人名下的孤儿数据
- transferMode 的 direct 分支同步校验双门槛,防残留选中态把 finish_directly 发出去

【修复售后"鸡生蛋"死锁】
- 已出库设备的下拉工序改走售后词表:taskOptionsFor 增加 overall_status 入参,
  已出库与 AFTER_SALES 同等待遇。此前只认 lifecycle_phase,而上一轮已禁止后端
  因"正常已出库做质检"翻转该阶段 → 工人选不到工序 → 进不了售后 → 更选不到

【派发入口与仓库位置解绑】
- 原条件硬性要求 current_location_id === 'virtual_warehouse',直接完结后的设备
  位置停在最后经手人名下,派发入口彻底消失,产品再也无法流转
- 新增豁免:产品空闲(无活跃主线任务)+ 超管/主管即可派发;新增 hasActiveMainTask
  只算主干,协助分支(SPAWN)不阻塞派发
- 文案与图标按场景动态化(在仓库 / 任务已完结)

【顺带修复】
- isWarehouseTransfer 死标志位:openWarehouseTransfer 先置 true,紧接着调用的
  openCreateFirstTask 又立刻重置为 false,导致弹窗标题永远显示「发起首道工序」,
  仓库转出文案从未生效。改为在 openCreateFirstTask 内按产品实际位置推导
2026-09-17 17:06:00 +08:00
ee25b8fd23 feat: PC 端直接完结入口与当前工序口径(前端)
【直接完结入口】
- 共享 TransferModal 新增「🏁 直接完结」开关,与「入库」互斥;选中后下游字段
  禁用(保留输入便于反悔)、标题/预览/按钮切换为独立视觉
- 入口仅超管/主管可见(useAuth + isAdminRole),并加防御性 useEffect 清掉
  非管理员的残留选中态,避免提交出 finish_directly
- 表单重置改为「打开时重置」:TransferModal 被 memo 后常驻挂载会跨次残留状态,
  且原实现于提交时清空会让等待期主题从「直接完结」闪回普通转交

【角色判断收口】
- 新增 ADMIN_ROLES / isAdminRole(与后端 task_service.ADMIN_ROLES 对齐)
  此前该判断散落在 TaskFlowView / AdminProductsPage 各处,移动端那份还只判了
  SUPER_ADMIN 漏了 SUPERVISOR,导致主管被前端误挡

【当前工序口径】
- 「当前工序」列改用后端派生的 current_step,不再用 overall_status ——
  后者会被"最新主干任务名"覆盖,让已 COMPLETED 的历史工序(扫码出库/测试…)
  冒充当前工序。显示与筛选值一并切换以保持一致
- ProductResponse 类型补齐 current_step
2026-09-17 17:05:51 +08:00
d666bbd4ed feat: 直接完结通道与报表口径收口(后端)
【直接完结:保留动作,剥离状态】
- transfer_task 权限硬拦截:仅 SUPER_ADMIN / SUPERVISOR。判据取签名保护的
  operator_role,刻意不用客户端可通过 ?operator_id= 伪造的 operator_id
- 直接完结【不再改写】overall_status —— 它只是任务闭环动作,不改变物理状态。
  已出库设备完结后依然是「已出库」,回归 WIP 矩阵的已出库列
- 物理终态保护:create_task / receive_task / transfer_task 三处写入点统一加
  _is_physical_terminal 守卫,禁止用任务名覆写 已入库/在库/已出库。
  历史缺陷:「发货测试」会把设备的「已出库」标识静默抹掉,跨报表口径随之打架

【位置与工序口径】
- _recalc_product_location:已出库且闲置 → 位置清空(货发走就离场)。
  只清「已出库」;已入库/在库的设备确实还在仓库里,位置必须保留
- MOM 出库回调同步清空 current_location_id
- 新增 ProductResponse.current_step:只认活跃主干任务,无活跃任务时返回
  宏观终态或空。不再让已 COMPLETED 的历史工序(扫码出库/测试…)冒充"当前工序"

【售后烙印去死锁】
- 拆分 OUTBOUND_QC_STEPS(发货测试) / AFTER_SALES_REPAIR_STEPS(售后维修)
- resolve_phase_for_step 与 _mark_after_sales_if_reactivated 双双改为仅
  「售后维修」触发 AFTER_SALES,解除「已出库 + 发货测试」被永久烙印的死锁
- PRODUCTION_OVERALL_STEPS 补入「发货测试」,使出厂质检在生产阶段合法
  (否则 _enforce_step_isolation 会把已出库设备的发货测试直接 400)

【游魂收口】
- get_wip_matrix / get_wip_matrix_detail 的 is_terminal 增加"名下再无任何活跃
  任务则视同终结态",让被直接完结的生产设备接受时间筛选,不再恒挂在看板上
  冒充在制。两处必须一字不差同步,否则会出现"矩阵有数、下钻为空"
2026-09-17 17:05:42 +08:00
e707f3673d feat: PC 端补齐直接完结通道
- taskApi.transferTask 增 finishDirectly 参数,按新 Schema 发送
  { next_tasks: [], finish_directly: true, note }
- TaskTransferPayload 支持 next_tasks / finish_directly,旧版单线字段降为可选
  (后端仍兼容 next_assignees / next_task_name,存量调用不受影响)
- 共享 TransferModal 新增「🏁 直接完结(无下游,不入库)」开关:
  与入库互斥,选中后下游字段禁用(保留输入便于反悔切回)、
  切换独立标题/提示/预览/提交按钮,不再套用「将创建 N 个任务」模板
- 表单重置改为「展开时重置」:TransferModal 被 memo 后常驻挂载会跨次残留状态,
  且原实现于提交时清空会让等待期主题从「直接完结」闪回普通转交
- TaskTreeViewer 与 AdminTasksPage 两个入口的 handleTransfer 签名对齐
2026-09-17 14:08:30 +08:00
1e6c006360 feat: MES/MOM 出入库状态解绑与直接完结通道(后端契约 + 移动端)
- TaskTransferRequest 新增 finish_directly 开关:闭环当前任务但不产生任何下游任务,
  产品 overall_status 保持原样,工人无需再借道「入库(virtual_warehouse)」来关闭任务,
  从根源上避免产品被误标「待仓库收货」而卡住 MOM 对账
- 空 assignees 分支加防呆校验:想直接完结必须显式传 finish_directly=true,
  杜绝漏填导致「转交」静默退化成「直接完结」的丢件级隐患
- 直接完结跳过 _mark_after_sales_if_reactivated:不产生新任务即不构成「回流返厂」信号,
  否则已出库设备的正常收官会把产品误翻成售后机(该标志单向不可回退)
- 补齐直接完结的独立响应文案,不再拼出「已创建 0 个下一道工序任务「None」」
- 移动端转交弹窗改为「转交个人 / 入库 / 直接完结」三选一互斥
2026-09-17 14:07:02 +08:00
2539bede3c chore: 看板页头副标题改用车间口语措辞
原标题「产品 = 物理实体(身份证)| 任务 = 工序节点」偏学术抽象,车间管理人员
不易一眼建立对应关系。改为按「产品盯物、任务盯人」的直觉表述:

    产品 = 车间里的物理设备 | 任务 = 派发给工人的生产指令(派工单)

配合卡片上已加的「待流转 / 待接收」口径 Tooltip,共同消除「产品数 > 任务数」
带来的误判。
2026-09-16 14:41:33 +08:00
dcc92b223e feat: 生产看板多维度重构(全量在制品、时间维度联动、口径提示)
本次只含后端看板接口与 PC 端页面。移动端的体验优化已在之前的 12 个提交中
入库,不在本提交范围内。

后端 · 服务层 dashboard_service.py
- 在制品列表不再按 20 条静默截断:此前 `.limit(limit*2)` 预取再排序切片,
  前端又拿数组长度当总数,表现为「卡片 23、列表却写共 20 个」的数据被吞。
  现默认返回全量,limit 降级为防御性上限
- 产品流转「已完结」接入右上角时间筛选(since/until),废弃硬编码「本月」。
  此前选「今天」也会看到整月产出,业务语义分裂;待流转/流转中仍为实时快照。
  Product 表无 completed_at,以名下工序的完成时间锚定「完结时刻」
- 返工任务可见性:未完结卡点永远展示,已了结的仅限本月,且与卡片数字共用
  同一份条件,避免「按钮显示 2、点开共 0 条」
- 驳回明细的「返工给」改为真实溯源:直接查派生出的返工任务当前 assignee_id,
  不再复刻 reject_task 的推算逻辑(返工任务事后被转交时会显示旧名字)

后端 · 接口层 dashboard.py
- /wip-tasks 的 limit 默认 20→500、上限 100→1000
- /my-stats 支持 since/until,新增接收/转交/上传备注口径(与 PC 人员操作统计
  逐字段交叉验证一致)

PC 端
- AdminDashboard: 在制品计数绑定真实总数并加截断提示、列表改为容器内滚动;
  驳回明细只展示「已驳回」行(返工行不再独立成行,其信息已由「返工给」承载);
  「待流转」「待接收」图例新增口径 Tooltip,消除「产品数 > 任务数」的误解;
  移除已失真的时间筛选提示条
- App.tsx: dayjs 中文 locale 上提到应用根。此前散落在 AdminPeoplePage /
  AnalyticsDashboard 两个页面里,而路由是 lazy 分割的 —— 直接打开概览页时
  那两个模块未加载,日期面板就露出 Sep/Su/Mo
- AdminPeoplePage / AnalyticsDashboard: 移除重复的页面级 locale 设置
- dashboardApi.ts: fetchWipTasks 默认值同步为 500
2026-09-16 14:29:56 +08:00
21 changed files with 1117 additions and 313 deletions

View File

@ -75,10 +75,10 @@ async def my_stats(
@router.get("/wip-tasks", response_model=list[WipTask]) @router.get("/wip-tasks", response_model=list[WipTask])
async def wip_tasks( async def wip_tasks(
limit: int = Query(20, ge=1, le=100), limit: int = Query(500, ge=1, le=1000, description="防御性安全上限;默认足以覆盖全部在制品"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""在制品看板 — 永远实时的 PENDING/WIP 任务""" """在制品看板 — 永远实时的 PENDING/WIP 任务(默认全量,不再按 20 条静默截断)"""
return await get_wip_tasks(db, limit) return await get_wip_tasks(db, limit)

View File

@ -2,6 +2,10 @@
MOM 仓储系统确认接收产品入库后,回调本接口,将 Track 中该产品的状态 MOM 仓储系统确认接收产品入库后,回调本接口,将 Track 中该产品的状态
真正标记为"已入库闭环"(更新宏观状态 + 记录 task_logs 证明仓库已接收)。 真正标记为"已入库闭环"(更新宏观状态 + 记录 task_logs 证明仓库已接收)。
同一条入站通道还承担【撤回出库】的强制回滚:MOM 把误点出库的设备物理
回滚到仓库时,Track 必须被动跟随 MOM 的权威物理状态(详见
_mom_inbound_revoke 上方的特权通道说明)。
""" """
from __future__ import annotations from __future__ import annotations
@ -14,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.core.lifecycle import sync_product_status
from app.models.product import Product from app.models.product import Product
from app.models.task import Task, TaskRecord from app.models.task import Task, TaskRecord
from app.models.task_log import TaskLog from app.models.task_log import TaskLog
@ -22,11 +27,101 @@ router = APIRouter(prefix="/external/webhooks", tags=["外部回调"])
class MomInboundPayload(BaseModel): class MomInboundPayload(BaseModel):
"""MOM 仓储系统确认接收入库的回调载荷""" """MOM 仓储系统确认接收入库 / 撤回出库的回调载荷"""
serial_number: str | None = None # 产品 16 位身份证(可空,优先匹配) serial_number: str | None = None # 产品 16 位身份证(可空,优先匹配)
sku: str | None = None # 规格型号 spec_model(serial 缺失时的兜底匹配) sku: str | None = None # 规格型号 spec_model(serial 缺失时的兜底匹配)
operator: str | None = None # 入库操作人(写入 task_logs.operator_id) operator: str | None = None # 入库操作人(写入 task_logs.operator_id)
inbound_time: datetime | None = None # 入库确认时间 inbound_time: datetime | None = None # 入库确认时间
# ↓ MOM 侧一直在发、此前被 Pydantic 静默丢弃的字段。撤回信号靠它们识别。
event: str | None = None # 事件名,如 inbound.created / outbound.revoked
action: str | None = None # 显式动作指令,如 revoke_outbound
source_table: str | None = None # stock_product / stock_semi
# 「撤回出库」信号词 —— 只在 action / event 里做子串匹配。
# MOM 侧的字段命名尚未冻结,故刻意宽松:revoke_outbound / outbound.revoked /
# rollback_outbound 都能命中,避免因对方改个词就整条链路失联。
_OUTBOUND_REVOKE_TOKENS = ("revoke", "rollback", "revert", "cancel")
def _is_outbound_revoke(payload: MomInboundPayload) -> bool:
"""payload 是否携带**显式**的撤回出库信号。
注意:返回 False 不代表「不是撤回」——MOM 也可能不加任何标记、直接以
常规 inbound.created 重推。那种隐式信号由调用方用「产品此刻是否处于
已出库」兜底判定(见 mom_inbound_webhook 里的 was_outbound)。
"""
for raw in (payload.action, payload.event):
token = (raw or "").strip().lower()
if token and any(word in token for word in _OUTBOUND_REVOKE_TOKENS):
return True
return False
async def _pick_warehouse_log_task(db: AsyncSession, product: Product) -> Task | None:
"""挑一条挂日志的任务:优先「在库」任务,其次该产品最新任务,都没有则 None。"""
task = (
await db.execute(
select(Task)
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
.order_by(Task.created_at.desc())
.limit(1)
)
).scalars().first()
if task is None:
task = (
await db.execute(
select(Task)
.where(Task.product_id == product.id)
.order_by(Task.created_at.desc())
.limit(1)
)
).scalars().first()
return task
async def _match_inbound_product(
db: AsyncSession, payload: MomInboundPayload, *, allow_outbound: bool,
) -> Product | None:
"""按 serial_number(优先)或 sku 匹配产品。
allow_outbound=False:只认「当前挂在虚拟仓库池」的产品(常规入库的既有语义)。
allow_outbound=True :额外放行「已出库」产品 —— 出库回调会把 current_location_id
置为 None,若仍用原条件,撤回信号必然失配并静默 return matched=False,
造成 MOM 认为货已回库、Track 却永远停在「已出库」的数据脑裂。
"""
location_cond = Product.current_location_id == "virtual_warehouse"
where_cond = (
or_(
location_cond,
Product.overall_status == "已出库",
Product.status == "OUTBOUND",
)
if allow_outbound
else location_cond
)
if payload.serial_number:
return (
await db.execute(
select(Product).where(
or_(
Product.serial_number == payload.serial_number,
Product.external_serial == payload.serial_number,
),
where_cond,
)
)
).scalar_one_or_none()
if payload.sku:
return (
await db.execute(
select(Product)
.where(Product.spec_model == payload.sku, where_cond)
.order_by(Product.created_at.desc())
)
).scalars().first()
return None
@router.post("/mom-inbound") @router.post("/mom-inbound")
@ -35,92 +130,127 @@ async def mom_inbound_webhook(
x_api_key: str | None = Header(default=None, alias="X-API-Key"), x_api_key: str | None = Header(default=None, alias="X-API-Key"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> dict: ) -> dict:
"""MOM 仓储系统确认接收产品入库后回调本接口。 """MOM 确认接收入库 / 撤回出库后回调本接口。
- 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。 - 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
- 用 serial_number(优先)或 sku 查询当前位于 virtual_warehouse 的产品; - 常规入库:用 serial_number(优先)或 sku 匹配「当前位于 virtual_warehouse」
命中则标记"已实收"(overall_status=已入库 + 记录 task_logs)。 的产品,命中则标记"已实收"(overall_status=已入库 + 记录 task_logs)。
- 未命中返回 200(MOM 可能入库了非 Track 生产的物料,直接忽略)。 - 撤回出库:MOM 把误出库的设备物理回滚到仓库 → 本接口强制执行特权回滚。
- 未命中返回 200(MOM 可能操作了非 Track 生产的物料,直接忽略)。
""" """
# ── 鉴权 ── # ── 鉴权 ──
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY: if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key") raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
# ── 按 serial_number / external_serial(双字段联合)或 sku 匹配"当前位于仓库"的产品 ── explicit_revoke = _is_outbound_revoke(payload)
product = None
if payload.serial_number: # ── 匹配产品 ──
product = ( # 常规入库保持严格匹配;撤回(显式标记,或带 serial 可精确定位)才放宽到已出库产品。
await db.execute( # 刻意不给 sku 兜底也无条件放宽:同型号可能有多台,放宽后可能误标到别的设备。
select(Product).where( product = await _match_inbound_product(
or_( db, payload, allow_outbound=explicit_revoke or bool(payload.serial_number),
Product.serial_number == payload.serial_number,
Product.external_serial == payload.serial_number,
),
Product.current_location_id == "virtual_warehouse",
) )
)
).scalar_one_or_none()
elif payload.sku:
product = (
await db.execute(
select(Product)
.where(
Product.spec_model == payload.sku,
Product.current_location_id == "virtual_warehouse",
)
.order_by(Product.created_at.desc())
)
).scalars().first()
# ── 未命中:可能是非 Track 生产的物料,直接忽略 ── # ── 未命中:可能是非 Track 生产的物料,直接忽略 ──
if product is None: if product is None:
return {"ok": True, "matched": False} return {"ok": True, "matched": False}
# ── 标记"已实收"闭环 ── # 隐式撤回:payload 没带任何标记,但产品此刻正处于「已出库」。
# 对一台已发货的设备来说,任何入库回调都只能意味着「货回来了」。
was_outbound = (
(product.overall_status or "").strip() == "已出库"
or (product.status or "").strip().upper() == "OUTBOUND"
)
is_revoke = explicit_revoke or was_outbound
# ══════════════════════════════════════════════════════════════════════
# ★ 特权通道 — MOM 的物理状态同步优先级最高,强制覆写、不受任何内部守卫约束
#
# 与 task_service.py 的【绝对物理终态保护】(PHYSICAL_TERMINAL_OVERALL,
# task_service.py:115-127) 方向刻意相反:那套保护约束的是「车间内部流转
# 不许用工序名抹掉物理终态」;而本接口是物理事实的**权威来源**——MOM 说
# 货已回到仓库,Track 必须无条件跟随。
#
# ⚠️ 后续维护者:不要在此处添加 _is_physical_terminal / 状态互斥 / 仅当
# 状态为 X 才允许覆写 之类的校验。那会让设备永远卡在「已出库」,
# 与 MOM 账面对不上——正是本次要消灭的数据脑裂。
# ══════════════════════════════════════════════════════════════════════
changed = False changed = False
# 1) 宏观状态强制覆写为「已入库」(撤回时从「已出库」拉回)
if product.overall_status != "已入库": if product.overall_status != "已入库":
product.overall_status = "已入库" product.overall_status = "已入库"
# 双字段同步:整体状态与产品状态保持一致(前端徽标依赖 status)
product.status = "ARCHIVED"
changed = True changed = True
# 记录仓库接收日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态) # 2) 物理位置强制回滚到虚拟仓库池(出库回调曾把它置为 None)
inbound_task = ( if product.current_location_id != "virtual_warehouse":
await db.execute( product.current_location_id = "virtual_warehouse"
select(Task) changed = True
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
.order_by(Task.created_at.desc())
.limit(1)
)
).scalars().first()
if inbound_task is None:
inbound_task = (
await db.execute(
select(Task)
.where(Task.product_id == product.id)
.order_by(Task.created_at.desc())
.limit(1)
)
).scalars().first()
if inbound_task is not None: # 3) 双字段同步:lifecycle.py 约定凡改写 overall_status 必调一次。
# (原实现在这里硬编码 product.status="ARCHIVED",绕过了约定,一并纠正)
# ⚠️ 必须把 status 的变化也计入 changed:否则当 overall_status / location
# 本来就已经正确时,这一处纠偏会因为 changed 保持 False 而永远不提交。
prev_status = product.status
sync_product_status(product)
if product.status != prev_status:
changed = True
# ── 记录日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态) ──
log_task = await _pick_warehouse_log_task(db, product)
if log_task is not None:
if is_revoke:
signal = payload.action or payload.event or "inbound.created(隐式)"
remark = (
f"MOM 撤回出库 → 强制回滚:宏观状态已入库、"
f"位置已回到 virtual_warehouse(信号: {signal})"
)
action_type = "warehouse_outbound_revoked"
else:
time_str = payload.inbound_time.isoformat() if payload.inbound_time else "—" time_str = payload.inbound_time.isoformat() if payload.inbound_time else "—"
remark = f"MOM 仓储系统确认接收入库(inbound_time: {time_str})"
action_type = "warehouse_inbound"
db.add(TaskLog( db.add(TaskLog(
task_id=inbound_task.id, task_id=log_task.id,
operator_id=(payload.operator or "virtual_warehouse")[:64], operator_id=(payload.operator or "virtual_warehouse")[:64],
action_type="warehouse_inbound", action_type=action_type,
remark=f"MOM 仓储系统确认接收入库(inbound_time: {time_str})", remark=remark,
)) ))
changed = True changed = True
# ── 动态生成"扫码入库"主线任务节点 + 操作日志(流转树最底部长出入库节点) ── # ── 动态生成主线任务节点 + 操作日志(流转树最底部长出节点) ──
if await _append_warehouse_task(db, product, "扫码入库", "通过 MOM 系统扫码入库完成"): if is_revoke:
# 撤回必须留痕:否则流转树末节点仍是「扫码出库」,而产品徽标已是
# 「已入库」,这种可见的自相矛盾会让车间不敢信这套数据。
#
# ⚠️ 节点名里的「(重新入库)」不是装饰,是 [必须保留] 的契约:
# product_service.py:166-174 的 _has_warehouse_task() 用**子串**判定
# 仓库节点("在库" in task_name or "入库" in task_name)。而
# 「撤回出库」四个字里只有"出库"、不含"入库",会让它判定为"无仓库任务",
# 进而给 location==virtual_warehouse 的产品注入一个假的「已完成 /
# 待仓库扫码」虚拟节点(product_service.py:237-242 的情况 A)——
# 该设备明明已入库且在仓库里,树尾却显示待收货。
# 补上「重新入库」后关键字命中,虚拟节点不再注入。
appended = await _append_warehouse_task(
db, product, "撤回出库(重新入库)", "MOM 撤回出库,设备已物理回滚至仓库",
)
else:
appended = await _append_warehouse_task(
db, product, "扫码入库", "通过 MOM 系统扫码入库完成",
)
if appended:
changed = True changed = True
if changed: if changed:
await db.commit() await db.commit()
return {"ok": True, "matched": True, "serial_number": product.serial_number} return {
"ok": True,
"matched": True,
"serial_number": product.serial_number,
"revoked": is_revoke,
}
async def _append_warehouse_task( async def _append_warehouse_task(
@ -256,24 +386,16 @@ async def mom_outbound_webhook(
product.status = "OUTBOUND" product.status = "OUTBOUND"
changed = True changed = True
# 🚚 同步出清厂内位置(与 _recalc_product_location 的「货发走就离场」对齐):
# 本回调的匹配条件之一就是 current_location_id == "virtual_warehouse",
# 即设备此刻还挂在仓库池里。既然 MOM 已确认发货,就不该再显示为厂内仓库/工位。
# 置空后产品列表的「当前位置」显示为「—」。
if product.current_location_id is not None:
product.current_location_id = None
changed = True
# 记录出库日志(优先"在库"任务,其次该产品最新任务) # 记录出库日志(优先"在库"任务,其次该产品最新任务)
outbound_task = ( outbound_task = await _pick_warehouse_log_task(db, product)
await db.execute(
select(Task)
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
.order_by(Task.created_at.desc())
.limit(1)
)
).scalars().first()
if outbound_task is None:
outbound_task = (
await db.execute(
select(Task)
.where(Task.product_id == product.id)
.order_by(Task.created_at.desc())
.limit(1)
)
).scalars().first()
if outbound_task is not None: if outbound_task is not None:
time_str = payload.outbound_time.isoformat() if payload.outbound_time else "—" time_str = payload.outbound_time.isoformat() if payload.outbound_time else "—"

View File

@ -29,21 +29,35 @@ PHASE_LABELS = {
} }
# ============================================================ # ============================================================
# 售后专属工序名 — 一旦选中即代表设备进入售后生命周期 # 售后专属工序名
# ============================================================ # ============================================================
STEP_SHIP_TEST = "发货测试" # 售后返修后的出货测试 STEP_SHIP_TEST = "发货测试" # 出厂 / 发货前测试
STEP_AFTER_SALES_REPAIR = "售后维修" # 售后返修本体 STEP_AFTER_SALES_REPAIR = "售后维修" # 售后返修本体
# 售后区独立成列所用的两个工序名(前端分列 / 大屏返厂统计仍以它为准)
AFTER_SALES_ONLY_STEPS = frozenset({STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR}) AFTER_SALES_ONLY_STEPS = frozenset({STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR})
# 🔧 工序语义细分(2026-09-17)——「售后专属」不等于「回流返厂」:
# · 出厂质检(发货测试):设备可能压根没回厂,只是补做发货前测试。
# 它【不】构成回流信号,选中它不应把设备永久烙进售后生命周期。
# · 明确修机指令(售后维修):设备确实是回厂返修 → 判定回流。
# 历史缺陷:两者混为一谈,导致「已出库 + 发货测试」被打上不可回退的
# AFTER_SALES 烙印(售后死锁),而「已出库」的物理终态也被抹掉。
OUTBOUND_QC_STEPS = frozenset({STEP_SHIP_TEST})
AFTER_SALES_REPAIR_STEPS = frozenset({STEP_AFTER_SALES_REPAIR})
# ============================================================ # ============================================================
# 各阶段合法的工序名 / 宏观状态名 # 各阶段合法的工序名 / 宏观状态名
# ============================================================ # ============================================================
# ── 生产制造阶段(原有词表,一字未改,保证存量流程不受影响)── # ── 生产制造阶段 ──
# 🔧 2026-09-17 补入「发货测试」:出厂质检现在被允许在生产阶段执行
# (设备的 lifecycle_phase 不再因选中它而翻成售后)。若不补,
# _enforce_step_isolation 会把「已出库设备的发货测试」直接 400 掉。
PRODUCTION_TASK_STEPS = ("备货", "生产", "测试", "维修", "在库") PRODUCTION_TASK_STEPS = ("备货", "生产", "测试", "维修", "在库")
PRODUCTION_OVERALL_STEPS = ( PRODUCTION_OVERALL_STEPS = (
"备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库", "备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库",
STEP_SHIP_TEST,
) )
# ── 售后回流阶段:只保留「发货测试 / 售后维修 / 入库出库」── # ── 售后回流阶段:只保留「发货测试 / 售后维修 / 入库出库」──
@ -99,12 +113,15 @@ def is_placeholder_step(step: str | None) -> bool:
def resolve_phase_for_step(current_phase: str | None, step: str | None) -> str: def resolve_phase_for_step(current_phase: str | None, step: str | None) -> str:
"""选定售后专属工序 → 设备进入售后生命周期(单向,不可回退) """选定【明确修机指令】→ 设备进入售后生命周期(单向,不可回退)
这是"无历史记录的老设备"进入售后阶段的唯一入口:它首次选定 ⚠️ 只有「售后维修」触发,出厂质检(发货测试)【不】触发。
「发货测试 / 售后维修」时即被判定为售后回流设备。 「已出库的设备补做发货测试」不代表它回厂返修;若在此烙印,
设备会被永久打成售后(该标志单向不可回退),这就是售后死锁的第二个入口。
这是"无历史记录的老设备"进入售后阶段的入口:首次选定「售后维修」即判定回流。
""" """
if step and step.strip() in AFTER_SALES_ONLY_STEPS: if step and step.strip() in AFTER_SALES_REPAIR_STEPS:
return LIFECYCLE_AFTER_SALES return LIFECYCLE_AFTER_SALES
return current_phase or LIFECYCLE_PRODUCTION return current_phase or LIFECYCLE_PRODUCTION

View File

@ -53,6 +53,14 @@ class ProductResponse(BaseModel):
current_location_name: str | None = None current_location_name: str | None = None
macro_status: str | None = None # 🔧 后端预计算的任务树状态(免前端逐条展开) macro_status: str | None = None # 🔧 后端预计算的任务树状态(免前端逐条展开)
overall_status: str | None = None overall_status: str | None = None
# 【当前工序】—— 只反映"此刻在做什么",绝不拿历史工序冒充。
# · 有活跃主干任务(WIP/PENDING) → 该任务工序名
# · 否则产品处于宏观终态(待仓库收货/已入库/在库/已出库) → 该终态(表达"货在哪")
# · 否则(活已干完、只剩工序名残留)→ ""(前端显示「—」)
# ⚠️ 必须与 overall_status 分开:ProductResponse.overall_status 会被"最新主干任务名"
# 覆盖(见 product_service 的 overall_names),于是已 COMPLETED 的历史工序
# (扫码出库 / 测试 / 发货测试…)会被当成"当前工序"长期展示。
current_step: str = ""
status: str status: str
# 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) # 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
lifecycle_phase: str = "PRODUCTION" lifecycle_phase: str = "PRODUCTION"

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator, model_validator
# ============================================================ # ============================================================
@ -79,7 +79,13 @@ class TaskRejectRequest(BaseModel):
class TaskTransferBranch(BaseModel): class TaskTransferBranch(BaseModel):
"""裂变分支""" """裂变分支"""
task_name: str = Field(..., max_length=200, description="工序名称") task_name: str = Field(..., max_length=200, description="工序名称")
assignees: list[str] = Field(..., min_length=1, description="接收人列表") assignees: list[str] = Field(
default_factory=list, min_length=0,
description=(
"接收人列表。允许为空数组,但仅当 finish_directly=True 时合法——"
"空分支不产生任何下游任务(见 TaskTransferRequest 的校验)。"
),
)
class TaskTransferRequest(BaseModel): class TaskTransferRequest(BaseModel):
@ -88,6 +94,29 @@ class TaskTransferRequest(BaseModel):
next_task_name: str | None = Field(None, max_length=200, description="[旧版] 下一道工序名称") next_task_name: str | None = Field(None, max_length=200, description="[旧版] 下一道工序名称")
next_tasks: list[TaskTransferBranch] | None = Field(None, description="[新版] 多分支任务列表") next_tasks: list[TaskTransferBranch] | None = Field(None, description="[新版] 多分支任务列表")
note: str | None = Field(None, description="交接备注") note: str | None = Field(None, description="交接备注")
finish_directly: bool = Field(
False,
description=(
"直接完结:闭环当前任务但【不产生任何下游任务】。"
"它只是一个任务闭环动作,【不改动】产品的 overall_status —— "
"已出库的设备完结后依然是已出库(不入库,也不会变成「待仓库收货」)。"
"权限:仅 SUPER_ADMIN / SUPERVISOR 可调用,其余角色 403。"
"置 True 时忽略 next_tasks / next_assignees。"
),
)
@model_validator(mode="after")
def _reject_silent_empty_branch(self):
"""空 assignees 分支会让「转交」静默退化成「直接完结」——任务闭环了却没人接手,
是个丢件级隐患。想直接完结必须显式传 finish_directly=true,不能靠漏填凑合。"""
if self.finish_directly:
return self
if any(not b.assignees for b in (self.next_tasks or [])):
raise ValueError(
"分支 assignees 不能为空;若意图是「直接完结该任务」,"
"请改为传 finish_directly=true"
)
return self
class TaskRecordCreate(BaseModel): class TaskRecordCreate(BaseModel):

View File

@ -182,6 +182,59 @@ class ProductMessageList(BaseModel):
total: int total: int
# ============================================================
# 看板「当下性」闸门 —— 返工任务可见性
# ============================================================
def _rework_visible_condition(
month_start: datetime,
since: datetime | None = None,
until: datetime | None = None,
):
"""构造返工任务在看板上的可见条件。
⚠️ 卡片数字(get_dashboard_stats.tasks_rework)与明细抽屉
(get_rejected_tasks 的 kind="rework")**必须共用这一份**,否则会出现
「按钮显示 2、点开却是共 0 条」的自相矛盾。
背景(2026-09 整改):原实现里返工查询是 `is_rework=True AND status != REJECTED`
的纯实时快照,没有任何状态/时间边界,导致上季度甚至更早的「已返工完成」记录
无限期挂在品质异常区持续报警 —— 报警永远不消,等于没有报警。
规则:
- 未完结卡点(PENDING / WIP):无论发生在何时都展示。没解决的异常必须
一直可见,这正是看板的价值所在。
- 已了结的(COMPLETED / ARCHIVED / CANCELED …):只保留「本月 1 日
(北京时间)之后」的,并进一步尊重调用方所选时段。
用 status.notin_(开放态) 而不是枚举已了结态:将来新增状态时会自动落进
「已了结」一侧(受时间约束),而不是凭空消失或无限期留存。
"""
from datetime import timezone
from sqlalchemy import and_
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
open_states = [TASK_STATUS_PENDING, TASK_STATUS_WIP]
open_cond = Task.status.in_(open_states)
# 已了结项的时间下限 = max(本月 1 日, 调用方给的 since):
# 既保证「绝不早于本月」,也不会越过用户选定的时段。
# since 可能是裸时间(无时区),先补成 UTC 再比较,否则 max() 会抛 TypeError。
floor = month_start
if since is not None:
floor = max(floor, since if since.tzinfo else since.replace(tzinfo=timezone.utc))
closed_cond = and_(
Task.status.notin_(open_states),
Task.completed_at.isnot(None),
Task.completed_at >= floor,
)
if until is not None:
closed_cond = and_(closed_cond, Task.completed_at <= until)
return or_(open_cond, closed_cond)
# ============================================================ # ============================================================
# 看板统计(时间快照语义) # 看板统计(时间快照语义)
# ============================================================ # ============================================================
@ -205,32 +258,78 @@ async def get_dashboard_stats(
) )
from app.models.notification import Notification from app.models.notification import Notification
from app.models.message import ProductMessage from app.models.message import ProductMessage
# ── 产品(实时快照,不过滤) ── # ── 产品流转三段 ──
# 状态口径(用户确认):废弃 Product.status 恒值判断, # 状态口径(用户确认):废弃 Product.status 恒值判断,
# COMPLETED=待仓库收货,ARCHIVED=已入库('在库' 旧命名等价),OUTBOUND=已出库。 # COMPLETED=待仓库收货,ARCHIVED=已入库('在库' 旧命名等价),OUTBOUND=已出库。
# 产品流转卡片第三段"已完结"= 待收货 + 已实收 + 旧在库 + 已出库,保证三段和 = 总数。 #
p_total = await db.scalar(select(func.count(Product.id))) # ⚠️「已完结」= 产出,必须响应用户在右上角选的时间范围(since / until),
# 不能钉死在某个固定窗口 —— 否则选「今天」却看到「本月」的产出,业务语义就裂了。
# 历史上这里先是无边界(历史全量无限累加),后又改成硬编码「本月」,两次都错。
# Product 表没有 completed_at / updated_at(只有 created_at),故用它名下工序的
# 完成时间来锚定「完结时刻」:只要有一条 Task.completed_at 落在所选时段内即计入。
#
# 「待流转 / 流转中」保持实时快照:只要还没完结,不论多久以前建的单都要统计出来 ——
# 它们才是车间真实的积压物料,正是看板要盯的东西。
from sqlalchemy import and_
# 时间下限优先取调用方所选时段的起点;未给时段时退回「本月」作为**防御性下限**,
# 避免退化成历史全量累加(注意用独立变量,不要覆盖 since —— 下方 t_done / t_rej
# 与返工可见性还要用它,改了会连带改变那些口径)。
if since is not None:
done_since = since
else:
from app.services.screen_service import _month_bounds
done_since = _month_bounds()[0]
finished_cond = Product.overall_status.in_(["待仓库收货", "已入库", "在库", "已出库"]) finished_cond = Product.overall_status.in_(["待仓库收货", "已入库", "在库", "已出库"])
p_done = await db.scalar(select(func.count(Product.id)).where(finished_cond)) not_finished = or_(Product.overall_status.is_(None), ~finished_cond)
# 在制 WIP = 未完结 且 存在活跃任务(PENDING/WIP) 的产品数 # 在制 WIP = 未完结 且 存在活跃任务(PENDING/WIP) 的产品数
not_finished = or_(Product.overall_status.is_(None), ~finished_cond)
has_active_task = select(Task.id).where( has_active_task = select(Task.id).where(
Task.product_id == Product.id, Task.product_id == Product.id,
Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]), Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]),
).exists() ).exists()
p_progress = await db.scalar(
select(func.count(Product.id)).where(not_finished, has_active_task) # 所选时段内完结 = 已完结 且 名下有工序在该时段内完成
done_time_conds = [
Task.product_id == Product.id,
Task.completed_at.isnot(None),
Task.completed_at >= done_since,
]
if until is not None:
done_time_conds.append(Task.completed_at <= until)
done_in_range = and_(
finished_cond,
select(Task.id).where(*done_time_conds).exists(),
) )
# 待流转 = 总数 - 在制 - 完结(三段互斥,保证进度条总和=总数) p_unfinished = await db.scalar(select(func.count(Product.id)).where(not_finished)) or 0
p_pending = max((p_total or 0) - (p_progress or 0) - (p_done or 0), 0) p_progress = await db.scalar(
select(func.count(Product.id)).where(not_finished, has_active_task)
) or 0
p_done = await db.scalar(select(func.count(Product.id)).where(done_in_range)) or 0
# ── 任务实时快照(PENDING/WIP/返工 — 永远不过滤) ── # 待流转 = 未完结 - 在制;展示总数 = 未完结 + 所选时段已完结
# (三段互斥,保证进度条总和 = 展示总数)
p_pending = max(p_unfinished - p_progress, 0)
p_total = p_unfinished + p_done
# ── 任务实时快照(PENDING/WIP — 永远不过滤) ──
t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_PENDING)) t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_PENDING))
t_progress = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_WIP)) t_progress = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_WIP))
t_rework = await db.scalar(select(func.count(Task.id)).where(Task.is_rework.is_(True)))
# 返工数不再是无边界的全时段快照:未完结卡点永远计入,已了结的只算本月。
# 与 get_rejected_tasks 的 kind="rework" 共用同一份可见性条件,
# 保证「品质异常」卡片数字与点开后的明细条数对得上。
from app.services.screen_service import _month_bounds
month_start_utc, _, _ = _month_bounds()
t_rework = await db.scalar(
select(func.count(Task.id)).where(
Task.is_rework.is_(True),
_rework_visible_condition(month_start_utc, since, until),
)
)
# ── 任务已完成/驳回(时间可过滤) ── # ── 任务已完成/驳回(时间可过滤) ──
t_done_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED) t_done_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED)
@ -382,7 +481,16 @@ async def get_my_stats(
# 在制品看板(永远实时) # 在制品看板(永远实时)
# ============================================================ # ============================================================
async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]: async def get_wip_tasks(db: AsyncSession, limit: int = 500) -> list[WipTask]:
"""在制品看板 — 永远实时的 PENDING/WIP 任务。
默认返回**全部**活跃任务,limit 只是防御性安全上限。
⚠️ 这里曾默认 limit=20,且先 `.limit(limit * 2)` 预取 40 条、按滞留时长排序后
再 `[:limit]` 截断 —— 列表被静默砍到 20 条,而卡片数字来自
tasks_pending + tasks_in_progress 的真实计数,于是看板上出现
「卡片 23、列表却写共 20 个」的数据被吞现象。
"""
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
from app.models.product import Product from app.models.product import Product
from app.models.holiday import Holiday from app.models.holiday import Holiday
@ -392,11 +500,11 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
hres = await db.execute(select(Holiday.day)) hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres} holidays = {r[0] for r in hres}
# 不在 SQL 层做预取限制:排序在内存里按滞留时长进行,截断只作为上限保护
stmt = ( stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model) select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
.join(Product, Task.product_id == Product.id) .join(Product, Task.product_id == Product.id)
.where(Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP])) .where(Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]))
.limit(limit * 2)
) )
result = await db.execute(stmt) result = await db.execute(stmt)
rows = result.all() rows = result.all()
@ -433,7 +541,7 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
)) ))
# 统一按滞留时间降序排列(无视 status,纯数值排序) # 统一按滞留时间降序排列(无视 status,纯数值排序)
wip_list.sort(key=lambda t: t.duration_hours, reverse=True) wip_list.sort(key=lambda t: t.duration_hours, reverse=True)
return wip_list[:limit] return wip_list[:limit] # limit 仅作上限保护,默认 500 实际不会截断
# ============================================================ # ============================================================
@ -507,10 +615,14 @@ async def get_rejected_tasks(
返回两类(与卡片数字 tasks_rejected + tasks_rework 口径一致): 返回两类(与卡片数字 tasks_rejected + tasks_rework 口径一致):
- kind="rejected":被驳回任务,按 completed_at 时间过滤 - kind="rejected":被驳回任务,按 completed_at 时间过滤
- kind="rework":返工任务(is_rework=True 且非驳回状态),实时快照不过滤时间 - kind="rework":返工任务(is_rework=True 且非驳回状态)。
未完结(PENDING/WIP)永远展示;已了结的仅限本月 —— 详见
_rework_visible_condition。该条件与 get_dashboard_stats 的 t_rework
共用,保证卡片数字与明细条数一致。
返工负责人追溯逻辑与 task_service.reject_task 一致: 返工负责人(rework_assignee)不再复刻 reject_task 的推算逻辑,而是直接查
优先最早 create log 的 operator → 兜底父任务负责人 → 兜底自身。 本次驳回派生出的那条返工任务、读其**当前** assignee_id —— 这样返工任务事后
被转交/撤回时,看板显示的仍是真实责任人。详见 _derived_rework_assignee。
""" """
from app.models.task import Task, TASK_STATUS_REJECTED from app.models.task import Task, TASK_STATUS_REJECTED
from app.models.task_log import TaskLog from app.models.task_log import TaskLog
@ -540,11 +652,20 @@ async def get_rejected_tasks(
stmt = stmt.order_by(Task.completed_at.desc()).limit(limit) stmt = stmt.order_by(Task.completed_at.desc()).limit(limit)
rejected_rows = (await db.execute(stmt)).all() rejected_rows = (await db.execute(stmt)).all()
# ── 2. 返工任务(is_rework=True 且当前非驳回状态,实时不过滤时间)── # ── 2. 返工任务(is_rework=True 且当前非驳回状态)──
# 原实现此处无任何状态/时间边界,是本页「数据发霉」的根源:上季度甚至更早的
# 「已返工完成」记录会无限期留在品质异常区。改由 _rework_visible_condition
# 统一约束 —— 未完结卡点永远展示,已了结的仅限本月(详见该函数说明)。
from app.services.screen_service import _month_bounds
month_start_utc, _, _ = _month_bounds()
rework_stmt = ( rework_stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model) select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
.join(Product, Task.product_id == Product.id) .join(Product, Task.product_id == Product.id)
.where(Task.is_rework.is_(True), Task.status != TASK_STATUS_REJECTED) .where(
Task.is_rework.is_(True),
Task.status != TASK_STATUS_REJECTED,
_rework_visible_condition(month_start_utc, since, until),
)
.order_by(Task.created_at.desc()) .order_by(Task.created_at.desc())
.limit(limit) .limit(limit)
) )
@ -575,41 +696,44 @@ async def get_rejected_tasks(
if row[1]: if row[1]:
reject_op[row[0]] = row[1] reject_op[row[0]] = row[1]
create_op: dict = {} # ── 派生返工任务的真实负责人 ──
if rejected_ids: # 不再复刻 reject_task 的推算逻辑去「猜」当时派给了谁 —— 那只是把当时的推导
sub = ( # 重跑一遍,返工任务事后一旦被转交/撤回,看板就会继续显示当初那个旧名字。
select( # 改为去库里定位真正由本次驳回派生出的那条返工任务,读它**当前**的 assignee_id。
TaskLog.task_id, TaskLog.operator_id, #
func.row_number().over( # 定位方式:reject_task 建返工任务时只继承了 product_id 与 parent_task_id
partition_by=TaskLog.task_id, # (数据模型里没有「派生自哪条任务」的外键),故按「同产品 + 同父节点 +
order_by=TaskLog.created_at.asc(), # is_rework」筛出候选,再用「创建时间在驳回之后的最早一条」消歧 —— 若返工任务
).label("rn"), # 又被驳回,会再派生一条更晚的,取最早即命中本次那条。
# 实测两者时间戳完全相等:reject_task 里用的是同一个 now。
rework_candidates: dict = {}
product_ids = {t.product_id for t, *_ in rejected_rows if t.product_id}
if product_ids:
cand_rows = await db.execute(
select(Task.parent_task_id, Task.product_id, Task.created_at, Task.assignee_id)
.where(Task.is_rework.is_(True), Task.product_id.in_(product_ids))
.order_by(Task.created_at.asc())
) )
.where(TaskLog.task_id.in_(rejected_ids), TaskLog.action_type == "create") for parent_id, prod_id, created_at, assignee_id in cand_rows.all():
).subquery() rework_candidates.setdefault((parent_id, prod_id), []).append((created_at, assignee_id))
r = await db.execute(select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1))
for row in r:
if row[1]:
create_op[row[0]] = row[1]
parent_assignee: dict = {} def _derived_rework_assignee(task) -> str | None:
parent_ids = [t.parent_task_id for t, *_ in rejected_rows if t.parent_task_id and t.id not in create_op] """取本次驳回派生出的返工任务的**当前**负责人。"""
if parent_ids: cands = rework_candidates.get((task.parent_task_id, task.product_id)) or []
r = await db.execute( if not cands:
select(Task.id, Task.assignee_id).where(Task.id.in_(parent_ids)) return None # 找不到就如实返回空,不拿推算值冒充
) for created_at, assignee_id in cands: # 已按 created_at 升序
for row in r: if task.completed_at and created_at and created_at >= task.completed_at:
if row[1]: return assignee_id
parent_assignee[row[0]] = row[1] return cands[0][1] # 缺 completed_at 的历史数据:退回最早一条
# ── 中文名映射(驳回人 + 返工负责人 一次批量查)── # ── 中文名映射(驳回人 + 返工负责人 一次批量查)──
derived_map = {t.id: _derived_rework_assignee(t) for t, *_ in rejected_rows}
raw_ids: set[str] = set() raw_ids: set[str] = set()
for kind, (t, *_row) in all_rows: for kind, (t, *_row) in all_rows:
if kind == "rejected": if kind == "rejected":
raw_ids.add(reject_op.get(t.id) or "") raw_ids.add(reject_op.get(t.id) or "")
raw_ids.add(create_op.get(t.id) or "") raw_ids.add(derived_map.get(t.id) or "")
if t.id not in create_op and t.parent_task_id:
raw_ids.add(parent_assignee.get(t.parent_task_id) or "")
raw_ids.add(t.assignee_id or "") raw_ids.add(t.assignee_id or "")
raw_ids.discard("") raw_ids.discard("")
name_map: dict[str, str] = {} name_map: dict[str, str] = {}
@ -620,12 +744,8 @@ async def get_rejected_tasks(
items: list[RejectedTask] = [] items: list[RejectedTask] = []
for kind, (task, sn, ext, mat, spec) in all_rows: for kind, (task, sn, ext, mat, spec) in all_rows:
if kind == "rejected": if kind == "rejected":
# 复刻 reject_task 追溯逻辑:create op → 父任务负责人 → 自身 # 直接取派生返工任务的真实负责人(见 _derived_rework_assignee)
rework_id = create_op.get(task.id) rework_id = derived_map.get(task.id)
if not rework_id and task.parent_task_id:
rework_id = parent_assignee.get(task.parent_task_id)
if not rework_id:
rework_id = task.assignee_id
rejected_by_id = reject_op.get(task.id) or task.assignee_id rejected_by_id = reject_op.get(task.id) or task.assignee_id
items.append(RejectedTask( items.append(RejectedTask(
task_id=str(task.id), task_id=str(task.id),
@ -798,9 +918,13 @@ async def get_wip_matrix(
- task_name: 按当前工序聚合(dimension_key 为工序名,附主负责人) - task_name: 按当前工序聚合(dimension_key 为工序名,附主负责人)
""" """
from datetime import timezone as dt_timezone from datetime import timezone as dt_timezone
from sqlalchemy.orm import aliased
from app.models.task import Task from app.models.task import Task
from app.models.product import Product from app.models.product import Product
# 外层已 join 了 tasks,内层判活跃任务必须换别名,否则语义无歧义
_ActiveTask = aliased(Task, name="active_task")
# 每台设备按主任务创建时间倒序,取第一条即「最新主任务」 # 每台设备按主任务创建时间倒序,取第一条即「最新主任务」
result = await db.execute( result = await db.execute(
select( select(
@ -816,6 +940,15 @@ async def get_wip_matrix(
Product.overall_status, Product.overall_status,
Product.status, Product.status,
Product.lifecycle_phase, Product.lifecycle_phase,
# 🔧 该产品名下是否还有 ANY 活跃任务(含协助分支)——
# 注意:这比下面的 is_active 更宽。is_active 只看"最新一条主干任务",
# 而这里扫描该产品的全部任务,用于 is_terminal 兜底判定。
# ⚠️ 必须用别名:外层 FROM 里已有一个 tasks,同名会让 SQLAlchemy
# 自动关联掉内层 FROM 而报 "no FROM clauses"。别名后语义才无歧义。
select(_ActiveTask.id).where(
_ActiveTask.product_id == Product.id,
_ActiveTask.status.in_(["WIP", "PENDING"]),
).exists().label("has_any_active"),
) )
.outerjoin(Task, Task.product_id == Product.id) .outerjoin(Task, Task.product_id == Product.id)
.where( .where(
@ -832,7 +965,7 @@ async def get_wip_matrix(
# 每台设备 → (spec, 当前工序/负责人, 当前负责人ID) # 每台设备 → (spec, 当前工序/负责人, 当前负责人ID)
device_cur: dict[str, tuple] = {} device_cur: dict[str, tuple] = {}
seen: set[str] = set() seen: set[str] = set()
for pid, spec, product_name, task_name, assignee, tstatus, created, completed, loc, overall_status, product_status, lifecycle in rows: for pid, spec, product_name, task_name, assignee, tstatus, created, completed, loc, overall_status, product_status, lifecycle, has_any_active in rows:
if pid in seen: if pid in seen:
continue continue
seen.add(pid) seen.add(pid)
@ -855,6 +988,13 @@ async def get_wip_matrix(
or overall_status in ("待仓库收货", "已入库", "在库", "已出库") or overall_status in ("待仓库收货", "已入库", "在库", "已出库")
or str(product_status).upper() in ("ARCHIVED", "OUTBOUND") or str(product_status).upper() in ("ARCHIVED", "OUTBOUND")
or task_name in ("扫码入库", "扫码出库") or task_name in ("扫码入库", "扫码出库")
# 🔧 兜底(2026-09-17):设备名下【再无任何活跃任务】= 真的没活了 → 视同终结态。
# 历史缺陷:生产设备被【直接完结】后 overall_status 停在工序名(如「测试」),
# 既不满足上面任何一条,又因 is_terminal=False 对时间筛选完全免疫 ——
# 于是它永远挂在看板上冒充"在制"(报表游魂)。
# 补上这一条后,它照常接受 since~until 过滤,不再污染在制口径。
# 仍保留原有的终结态判定(本条件是「或」,只会让更多设备被正确归类为终结)。
or not has_any_active
) )
if is_terminal: if is_terminal:
if anchor is not None and anchor.tzinfo is None: if anchor is not None and anchor.tzinfo is None:
@ -974,11 +1114,15 @@ async def get_wip_matrix_detail(
按其在库三态(已完成/已入库/已出库)或工序名归类到 process。 按其在库三态(已完成/已入库/已出库)或工序名归类到 process。
""" """
from datetime import timezone as dt_timezone from datetime import timezone as dt_timezone
from sqlalchemy.orm import aliased
from app.models.task import Task from app.models.task import Task
from app.models.product import Product from app.models.product import Product
from app.models.holiday import Holiday from app.models.holiday import Holiday
from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours
# 与外层 join 的 tasks 区分开(理由见 get_wip_matrix)
_ActiveTask = aliased(Task, name="active_task")
hres = await db.execute(select(Holiday.day)) hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres} holidays = {r[0] for r in hres}
@ -999,6 +1143,12 @@ async def get_wip_matrix_detail(
Task.created_at, Task.created_at,
Task.completed_at, Task.completed_at,
Task.received_at, Task.received_at,
# 🔧 与 get_wip_matrix 同口径:该产品名下是否还有 ANY 活跃任务
# (同样必须用别名,理由见 get_wip_matrix 处注释)
select(_ActiveTask.id).where(
_ActiveTask.product_id == Product.id,
_ActiveTask.status.in_(["WIP", "PENDING"]),
).exists().label("has_any_active"),
) )
.outerjoin(Task, Task.product_id == Product.id) .outerjoin(Task, Task.product_id == Product.id)
.where( .where(
@ -1017,7 +1167,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, lifecycle, 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, has_any_active in rows:
if pid in seen: if pid in seen:
continue continue
seen.add(pid) seen.add(pid)
@ -1034,11 +1184,15 @@ async def get_wip_matrix_detail(
is_active = tstatus in ("WIP", "PENDING") is_active = tstatus in ("WIP", "PENDING")
# ── 动静分离:仅终结(收口/在库/出库)设备按 since~until 过滤;在制设备无条件全量 ── # ── 动静分离:仅终结(收口/在库/出库)设备按 since~until 过滤;在制设备无条件全量 ──
# ⚠️ 必须与 get_wip_matrix 一字不差同步,否则会出现
# 「矩阵格子里有数、点进来下钻却是空的」。
is_terminal = (not is_active) and ( is_terminal = (not is_active) and (
loc == "virtual_warehouse" loc == "virtual_warehouse"
or overall in ("待仓库收货", "已入库", "在库", "已出库") or overall in ("待仓库收货", "已入库", "在库", "已出库")
or str(pstatus).upper() in ("ARCHIVED", "OUTBOUND") or str(pstatus).upper() in ("ARCHIVED", "OUTBOUND")
or task_name in ("扫码入库", "扫码出库") or task_name in ("扫码入库", "扫码出库")
# 🔧 兜底:名下再无任何活跃任务 → 视同终结态,接受时间筛选(防报表游魂)
or not has_any_active
) )
if is_terminal: if is_terminal:
if anchor is not None and anchor.tzinfo is None: if anchor is not None and anchor.tzinfo is None:

View File

@ -673,6 +673,8 @@ async def get_all_products(
# 🔧 动态主干状态名:只从主干任务中获取最高优先级任务的 task_name(宏观状态名) # 🔧 动态主干状态名:只从主干任务中获取最高优先级任务的 task_name(宏观状态名)
overall_names: dict[uuid.UUID, str] = {} overall_names: dict[uuid.UUID, str] = {}
# 🔧 【当前工序】专用:仅「活跃主干任务」的工序名(见下方 main_stmt 循环填充)
active_step_map: dict[uuid.UUID, str] = {}
if product_ids: if product_ids:
from sqlalchemy import and_, func as sa_func, case as sa_case from sqlalchemy import and_, func as sa_func, case as sa_case
main_where = and_( main_where = and_(
@ -694,7 +696,7 @@ async def get_all_products(
.group_by(Task.product_id) .group_by(Task.product_id)
).subquery("mp") ).subquery("mp")
main_stmt = ( main_stmt = (
select(Task.product_id, Task.task_name) select(Task.product_id, Task.task_name, Task.status)
.join(max_prio, and_( .join(max_prio, and_(
Task.product_id == max_prio.c.product_id, Task.product_id == max_prio.c.product_id,
prio_expr == max_prio.c.prio, prio_expr == max_prio.c.prio,
@ -706,6 +708,11 @@ async def get_all_products(
main_result = await db.execute(main_stmt) main_result = await db.execute(main_stmt)
for row in main_result: for row in main_result:
overall_names[row[0]] = row[1] overall_names[row[0]] = row[1]
# 🔧 供【当前工序】专用:只收【活跃】主干任务的工序名。
# overall_names 保持原语义不动(它连 COMPLETED 的任务也收,
# 是"最新主干任务名",被 overall_status 复用,改动影响面太大)。
if row[2] in ("WIP", "PENDING"):
active_step_map[row[0]] = row[1]
# 🔧 当前位置:汇总所有活跃任务(WIP/PENDING,不分主线/分支)的负责人,去重保序 # 🔧 当前位置:汇总所有活跃任务(WIP/PENDING,不分主线/分支)的负责人,去重保序
active_assignees_map: dict[uuid.UUID, list[str]] = {} active_assignees_map: dict[uuid.UUID, list[str]] = {}
@ -825,6 +832,28 @@ async def get_all_products(
return "COMPLETED" return "COMPLETED"
return macro_map.get(p.id) or "PENDING" return macro_map.get(p.id) or "PENDING"
# 宏观终态 —— 表达的是"货在哪",不是"在做什么"。
# 这类值即使当前没有活跃任务也必须照常展示(业务要求保留物理标识)。
_TERMINAL_OVERALL = ("待仓库收货", "已入库", "在库", "已出库")
def _resolve_current_step(p: Product) -> str:
"""【当前工序】—— 只反映"此刻在做什么",绝不拿已完结的历史工序冒充。
· 有活跃主干任务(WIP/PENDING) → 该任务工序名
· 否则产品处于宏观终态 → 该终态("货在哪",保留)
· 否则(活已干完、只剩工序名残留)→ ""(前端显示「—」)
历史缺陷:ProductResponse.overall_status 会被"最新主干任务名"覆盖
(见上方 overall_names),于是已 COMPLETED 的「扫码出库 / 测试 / 发货测试」
会长期冒充"当前工序",误导管理者以为活还在干。
"""
raw = (p.overall_status or "").strip()
if not raw:
return ""
if raw in _TERMINAL_OVERALL:
return raw
return active_step_map.get(p.id, "")
return [ return [
ProductResponse( ProductResponse(
id=p.id, id=p.id,
@ -849,6 +878,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,
current_step=_resolve_current_step(p),
status=p.status, status=p.status,
lifecycle_phase=p.lifecycle_phase, lifecycle_phase=p.lifecycle_phase,
created_at=p.created_at, created_at=p.created_at,

View File

@ -11,6 +11,7 @@ from app.models.task import Task, TaskRecord, TASK_STATUS_PENDING, TASK_STATUS_W
from app.models.notification import Notification, NOTIFY_TRANSFER, NOTIFY_REJECT from app.models.notification import Notification, NOTIFY_TRANSFER, NOTIFY_REJECT
from app.core.time_utils import get_beijing_time from app.core.time_utils import get_beijing_time
from app.core.lifecycle import ( from app.core.lifecycle import (
AFTER_SALES_REPAIR_STEPS,
LIFECYCLE_AFTER_SALES, LIFECYCLE_AFTER_SALES,
PRODUCTION_ONLY_STEPS, PRODUCTION_ONLY_STEPS,
allowed_steps, allowed_steps,
@ -91,26 +92,61 @@ async def _recalc_product_location(
result = await db.execute(stmt) result = await db.execute(stmt)
main_task = result.scalar_one_or_none() main_task = result.scalar_one_or_none()
# 上面的排序把 WIP(3) > PENDING(2) 放最前,所以取到的这条即最高优先级:
# 它是 WIP/PENDING 说明产品仍有活跃主线任务,否则视为闲置。
has_active_main = main_task is not None and main_task.status in (
TASK_STATUS_WIP, TASK_STATUS_PENDING,
)
new_location = main_task.assignee_id if main_task else None new_location = main_task.assignee_id if main_task else None
# 🚚 「货发走就离场」——已出库且闲置的设备,厂内不再有它的位置。
# 直接完结后若继续停在最后经手人名下(或残留 virtual_warehouse),
# 列表里会与「已出库」自相矛盾。置空后前端统一显示「—」。
# ⚠️ 只清「已出库」:已入库 / 在库 的设备确实还在仓库里,位置必须保留。
if product.overall_status == "已出库" and not has_active_main:
new_location = None
if product.current_location_id != new_location: if product.current_location_id != new_location:
product.current_location_id = new_location product.current_location_id = new_location
await db.flush() # 唯一的落盘点 await db.flush() # 唯一的落盘点
# 绝对物理终态 — 描述「设备此刻物理上在哪」的宏观状态,由 MOM 仓储/发货回调驱动。
# 接收 / 派发 / 转交任务时【禁止】用任务名覆写它们:否则「已出库」会被
# 「发货测试」这类工序名静默抹掉,设备在按终态口径统计的报表里就不再是已出库
# (WIP 矩阵因为优先看活跃任务而看不出问题,但全局概览 / 大屏会把它移出已完结)。
#
# ⚠️ 刻意不含「待仓库收货」:那是「车间完工待实收」的过渡态,设备随后仍会被
# 重新派活,宏观状态理应随工序更新。
PHYSICAL_TERMINAL_OVERALL = ("已入库", "在库", "已出库")
def _is_physical_terminal(overall: str | None) -> bool:
"""该宏观状态是否为「绝对物理终态」(不应被任务名覆写)"""
return (overall or "").strip() in PHYSICAL_TERMINAL_OVERALL
async def _mark_after_sales_if_reactivated( async def _mark_after_sales_if_reactivated(
db: AsyncSession, product_id: uuid.UUID, db: AsyncSession, product_id: uuid.UUID,
steps: str | list[str | None] | None = None,
) -> bool: ) -> bool:
"""出库后又被派发新任务 = 设备回流返厂 → 生命周期切到 AFTER_SALES。 """出库后又接到【明确修机指令】= 设备回流返厂 → 生命周期切到 AFTER_SALES。
判定依据:产品当前处于「已出库」终态(overall_status == '已出库' 或 判定依据(必须同时满足):
status == 'OUTBOUND'),却又产生了新的在制任务。 1. 产品当前处于「已出库」终态(overall_status == '已出库' 或 status == 'OUTBOUND')
2. 本次接到的工序是明确修机指令(售后维修)
⚠️ 出厂质检(发货测试)【不】触发。设备可能压根没回厂,只是补做发货前测试。
历史缺陷:两者混为一谈,导致「已出库 + 发货测试」被打上不可回退的
AFTER_SALES 烙印(售后死锁),且设备的物理终态「已出库」被工序名抹掉。
该标志单向:一旦进入 AFTER_SALES 不再回退,这样前端就能把 该标志单向:一旦进入 AFTER_SALES 不再回退,这样前端就能把
生产阶段的「发货测试」与回流后的「售后维修」区分开。 生产阶段的「发货测试」与回流后的「售后维修」区分开。
调用时机必须早于调用方改写 overall_status,否则会漏判。 调用时机必须早于调用方改写 overall_status,否则会漏判。
:param steps: 本次接到的工序名;转交场景可能多分支,故接受列表。
返回是否发生了翻转。product 已在本 session 加载时 db.get 直接命中 返回是否发生了翻转。product 已在本 session 加载时 db.get 直接命中
identity map,不产生额外查询。 identity map,不产生额外查询。
""" """
@ -118,6 +154,10 @@ async def _mark_after_sales_if_reactivated(
if product is None or product.lifecycle_phase == LIFECYCLE_AFTER_SALES: if product is None or product.lifecycle_phase == LIFECYCLE_AFTER_SALES:
return False return False
names = [steps] if isinstance(steps, str) else [s for s in (steps or []) if s]
if not any(n.strip() in AFTER_SALES_REPAIR_STEPS for n in names):
return False
is_outbound = ( is_outbound = (
product.overall_status == "已出库" product.overall_status == "已出库"
or (product.status or "").upper() == "OUTBOUND" or (product.status or "").upper() == "OUTBOUND"
@ -376,11 +416,18 @@ async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse:
product_result = await db.execute(select(Product).where(Product.id == data.product_id)) product_result = await db.execute(select(Product).where(Product.id == data.product_id))
product = product_result.scalar_one_or_none() product = product_result.scalar_one_or_none()
if product: if product:
# 🔧 出库后再次派发任务 = 设备回流返厂 → 切到售后生命周期(须早于下方改写 overall_status) # 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期
await _mark_after_sales_if_reactivated(db, data.product_id) # (须早于下方改写 overall_status)。出厂质检(发货测试)不触发。
await _mark_after_sales_if_reactivated(db, data.product_id, data.task_name)
# 🔧 选项隔离:售后回流设备禁止被排回「备货 / 生产」等前期工序(防伪造传参) # 🔧 选项隔离:售后回流设备禁止被排回「备货 / 生产」等前期工序(防伪造传参)
_enforce_step_isolation(product, data.task_name, action="创建任务") _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")): # 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】—— 设备已是
# 已入库/在库/已出库 时禁止用任务名覆写,否则「已出库」会被工序名抹掉。
if (
data.task_name
and (not data.parent_task_id or data.task_type in ("TRANSFER", "RECOVERY"))
and not _is_physical_terminal(product.overall_status)
):
product.overall_status = "已入库" if "virtual_warehouse" in data.task_name else data.task_name product.overall_status = "已入库" if "virtual_warehouse" in data.task_name else data.task_name
# 🔧 双字段同步:overall_status 改动后必须对齐 status, # 🔧 双字段同步:overall_status 改动后必须对齐 status,
# 否则出库回流设备的 status 会永远停在 OUTBOUND,污染统计口径 # 否则出库回流设备的 status 会永远停在 OUTBOUND,污染统计口径
@ -675,17 +722,27 @@ async def receive_task(
product_result = await db.execute(select(Product).where(Product.id == task.product_id)) product_result = await db.execute(select(Product).where(Product.id == task.product_id))
product = product_result.scalar_one_or_none() product = product_result.scalar_one_or_none()
if product: if product:
# 🔧 出库后任务被重新接收 = 设备回流返厂 → 切到售后生命周期(须早于下方改写 overall_status) # 🔧 出库后任务被重新接收 = 设备回流返厂 → 切到售后生命周期
await _mark_after_sales_if_reactivated(db, task.product_id) # (须早于下方改写 overall_status)。出厂质检(发货测试)不触发。
await _mark_after_sales_if_reactivated(
db, task.product_id, task_name or task.task_name,
)
# 🔧 选项隔离:接收时选定的工序必须落在该产品当前生命周期阶段的合法集合内 # 🔧 选项隔离:接收时选定的工序必须落在该产品当前生命周期阶段的合法集合内
#(task_name 为 None 时表示本次未指定工序,跳过校验,不阻断 PC 端「直接接收」) #(task_name 为 None 时表示本次未指定工序,跳过校验,不阻断 PC 端「直接接收」)
_enforce_step_isolation(product, task_name, action="接收任务") _enforce_step_isolation(product, task_name, action="接收任务")
if task.assignee_id: if task.assignee_id:
product.current_location_id = task.assignee_id product.current_location_id = task.assignee_id
if task_name and ( # 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】——
not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY") # 设备已是 已入库/在库/已出库 时禁止用任务名覆写。
# 这是「发货测试 抹掉 已出库」的关键拦截点:若不拦,设备会在
# 概览/大屏等按终态口径统计的报表里不再是已出库,而矩阵因优先看
# 活跃任务看不出问题(跨报表口径就此打架)。
if (
task_name
and (not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"))
and not _is_physical_terminal(product.overall_status)
): ):
product.overall_status = task_name # 只主线任务同步宏观状态 product.overall_status = task_name
# 🔧 双字段同步:无条件对齐一次,顺带治愈历史残留 # 🔧 双字段同步:无条件对齐一次,顺带治愈历史残留
# (如整体已是「发货测试」而 status 还停在 OUTBOUND) # (如整体已是「发货测试」而 status 还停在 OUTBOUND)
sync_product_status(product) sync_product_status(product)
@ -844,6 +901,12 @@ async def transfer_task(
- 如果包含 'virtual_warehouse',则将 Product 的 current_location_id 设为 'virtual_warehouse'。 - 如果包含 'virtual_warehouse',则将 Product 的 current_location_id 设为 'virtual_warehouse'。
- 为每一个 assignee_id(非 virtual_warehouse)新建一条 Task 记录(状态 PENDING)。 - 为每一个 assignee_id(非 virtual_warehouse)新建一条 Task 记录(状态 PENDING)。
动作 2'(直接完结 finish_directly=True):
- 不解析任何下家,分支强制为空,直接落入下方"无下家"兜底分支。
- 用于「售后返厂直接发走 / 半成品被提走」这类**无需入库**的收官场景:
工人不必再借道「入库(virtual_warehouse)」来关闭任务,
从而避免把产品误标成「待仓库收货」并卡住 MOM 对账。
裂变逻辑: 裂变逻辑:
- 如果 len(next_assignees) > 1:多路裂变 → 所有新任务挂到当前任务下(parent_task_id = 当前任务ID)。 - 如果 len(next_assignees) > 1:多路裂变 → 所有新任务挂到当前任务下(parent_task_id = 当前任务ID)。
- 如果当前任务本身就是子任务(有 parent_task_id):单路转交也挂到同一父任务下。 - 如果当前任务本身就是子任务(有 parent_task_id):单路转交也挂到同一父任务下。
@ -855,6 +918,18 @@ async def transfer_task(
# 权限校验:本人 或 管理员/主管 可转交 # 权限校验:本人 或 管理员/主管 可转交
_check_permission(task.assignee_id, operator_id, operator_role) _check_permission(task.assignee_id, operator_id, operator_role)
# ── 🏁 直接完结:额外收紧为「仅管理员/主管」──
# 这是【正向】拦截(非管理员一律拒绝),刻意不复用 _check_permission 的反向写法:
# 后者在 operator_id 为空时会静默放行,而直接完结是不可逆的收官动作
# (不产生下游任务、产品落终态),必须默认拒绝。
# ⚠️ 判据只能是 operator_role —— 它来自签名保护的 JWT;
# operator_id 是客户端可通过 ?operator_id= 自行填写的,不可作为权限依据。
if request.finish_directly and not (operator_role and operator_role in ADMIN_ROLES):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅超管和主管有权限进行直接完结操作",
)
# 校验:不能重复完成 # 校验:不能重复完成
if task.status == TASK_STATUS_COMPLETED: if task.status == TASK_STATUS_COMPLETED:
raise HTTPException( raise HTTPException(
@ -879,6 +954,16 @@ async def transfer_task(
now = get_beijing_time() now = get_beijing_time()
# --- 动作 1:闭环当前节点 --- # --- 动作 1:闭环当前节点 ---
# 🔧 直接完结走独立文案:action_type 沿用 "complete"(不新增取值,避免扰动现有
# 看板的完成数口径),仅在 remark 里区分,审计时可按文本筛出「直接完结」。
finish_directly = bool(request.finish_directly)
if finish_directly:
log_remark = request.note or f"直接完结任务「{task.task_name}」(无下游,不入库)"
record_remark = request.note or "[直接完结] 无下游任务,产品宏观状态保持不变"
else:
log_remark = request.note or f"完成任务「{task.task_name}」,转交至下一道工序"
record_remark = request.note or "[完工转交] 移交下一工序"
task.status = TASK_STATUS_COMPLETED task.status = TASK_STATUS_COMPLETED
task.completed_at = now task.completed_at = now
@ -886,11 +971,11 @@ async def transfer_task(
db, task_id, db, task_id,
action_type="complete", action_type="complete",
operator_id=operator_id, operator_id=operator_id,
remark=request.note or f"完成任务「{task.task_name}」,转交至下一道工序", remark=log_remark,
task_assignee_id=task.assignee_id, task_assignee_id=task.assignee_id,
) )
db.add(TaskRecord(task_id=task.id, db.add(TaskRecord(task_id=task.id,
remark=(request.note or f"[完工转交] 移交下一工序") + _admin_proxy_note(operator_id, task.assignee_id), remark=record_remark + _admin_proxy_note(operator_id, task.assignee_id),
images="[]")) images="[]"))
# --- 动作 2:解析下家 & 裂变 --- # --- 动作 2:解析下家 & 裂变 ---
@ -901,13 +986,11 @@ async def transfer_task(
) )
product = product_result.scalar_one_or_none() product = product_result.scalar_one_or_none()
# 🔧 出库后又被转交出新任务 = 设备回流返厂 → 切到售后生命周期。
# 必须在阶段校验之前执行,否则"已出库设备被转交到生产工序"会被误放行。
if product:
await _mark_after_sales_if_reactivated(db, task.product_id)
# 兼容新旧格式 # 兼容新旧格式
if request.next_tasks: # 🔧 直接完结:不解析任何下家,分支强制为空,直接命中下方"无下家"兜底分支。
if finish_directly:
branches: list[tuple[str | None, str]] = []
elif request.next_tasks:
branches = [ branches = [
(b.task_name, a) (b.task_name, a)
for b in request.next_tasks for b in request.next_tasks
@ -922,6 +1005,17 @@ async def transfer_task(
has_warehouse = any(a == VIRTUAL_WAREHOUSE for _, a in branches) has_warehouse = any(a == VIRTUAL_WAREHOUSE for _, a in branches)
real_branches = [(tn, a) for tn, a in branches if a != VIRTUAL_WAREHOUSE] real_branches = [(tn, a) for tn, a in branches if a != VIRTUAL_WAREHOUSE]
# 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期。
# 必须在下方阶段校验之前执行,否则"已出库设备被转交到生产工序"会被误放行。
# (故本调用安排在分支解析之后、_reject_cross_phase_steps 之前。)
# ⚠️ 直接完结【不产生新任务】,不构成"回流返厂"信号,必须跳过:
# 否则一台已出库设备的正常收官,会把产品误翻成售后机(该标志单向不可回退)。
# ⚠️ 出厂质检(发货测试)同样不触发 —— 详见 _mark_after_sales_if_reactivated。
if product and not finish_directly:
await _mark_after_sales_if_reactivated(
db, task.product_id, [tn for tn, _ in real_branches],
)
# 🔧 选项隔离:售后回流设备不允许被转交到「备货 / 生产 / 测试 / 维修」。 # 🔧 选项隔离:售后回流设备不允许被转交到「备货 / 生产 / 测试 / 维修」。
# 转交允许自由填写工序名(喷漆/老化…),故只精准拦跨阶段词,不用白名单。 # 转交允许自由填写工序名(喷漆/老化…),故只精准拦跨阶段词,不用白名单。
if product: if product:
@ -993,20 +1087,44 @@ async def transfer_task(
task_id=nt.id, task_id=nt.id,
)) ))
# --- 更新 Product 的 current_location_id --- # --- 更新 Product 的 current_location_id / overall_status ---
# 🏁 直接完结【不改动 overall_status】:它只是一个「任务闭环动作」,
# 不改变产品的物理状态。已出库的设备完结后依然是已出库,
# 从而回归 WIP 矩阵的【已出库】列(无活跃任务 → 落终结态判定)。
# 下方 has_warehouse / real_branches 两个分支对直接完结均为假,
# overall_status 自然原样保留。
if product: if product:
if has_warehouse and not real_branches: if has_warehouse and not real_branches:
# 转入虚拟仓库池:位置一定改(下一手才能在这里看到并接手),
# 但宏观状态要分情况 ——
# · 绝对物理终态(已入库 / 在库 / 已出库):禁止改写成「待仓库收货」。
# 已出库设备售后返修时选择【入库】是为了「放回池子让下一个人继续转」,
# 改成「待仓库收货」会抹掉它的物理终态,且与 MOM 侧「已发货」的记录冲突
# (MOM 认为货已出门,Track 却说「等仓库收货」,双方对不上账)。
# 已入库 / 在库设备本就在仓库,再入库更不该回退成「待收货」。
# · 普通生产设备(备货/生产/测试/维修…):维持原行为,正常置为「待仓库收货」。
product.current_location_id = VIRTUAL_WAREHOUSE product.current_location_id = VIRTUAL_WAREHOUSE
if not _is_physical_terminal(product.overall_status):
product.overall_status = "待仓库收货" product.overall_status = "待仓库收货"
elif real_branches: elif real_branches:
product.current_location_id = real_branches[0][1] product.current_location_id = real_branches[0][1]
if not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"): # 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】——
# 设备已是 已入库/在库/已出库 时禁止用任务名覆写,
# 否则「已出库」会被「发货测试」这类工序名抹掉。
if (
(not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"))
and not _is_physical_terminal(product.overall_status)
):
product.overall_status = real_branches[0][0] product.overall_status = real_branches[0][0]
# 🔧 双字段同步:两个分支统一在这里对齐一次(原先只有入库分支硬编码 # 🔧 双字段同步:各分支统一在这里对齐一次(原先只有入库分支硬编码
# product.status="COMPLETED",普通转交分支完全没同步 → 残留 OUTBOUND) # product.status="COMPLETED",普通转交分支完全没同步 → 残留 OUTBOUND)
# 注意顺序:必须在上面的 overall_status 赋值【之后】,否则 status 同步到旧值。
sync_product_status(product) sync_product_status(product)
# 🔧 位置回溯:如果有新任务创建,优先新任务负责人;否则回溯到父任务 # 🔧 位置回溯:如果有新任务创建,优先新任务负责人;否则回溯到父任务
# 「直接完结」(finish_directly) 与旧版空分支都落到这里:
# current_location_id 回溯到最后一手经手人(业务确认:位置停在经手人合理)。
# 注意它只改 location,不碰 overall_status,故上面的终态不会被覆盖。
if not real_branches and not has_warehouse: if not real_branches and not has_warehouse:
await _recalc_product_location(db, task.product_id, task_id) await _recalc_product_location(db, task.product_id, task_id)
@ -1019,19 +1137,25 @@ async def transfer_task(
await db.refresh(nt) await db.refresh(nt)
created_task_responses.append(_to_response(nt)) created_task_responses.append(_to_response(nt))
# 🔧 直接完结没有下家,不能套用"已创建 N 个任务(接收人: 仓库)"的模板
# (否则会拼出「已创建 0 个下一道工序任务「None」(接收人: 仓库)」这种误导文案)
if finish_directly:
message = f"任务「{task.task_name}」已直接完结(无下游任务,产品状态保持不变)"
else:
assignee_list = ", ".join(a for _, a in real_branches) if real_branches else "仓库" assignee_list = ", ".join(a for _, a in real_branches) if real_branches else "仓库"
location_info = "" location_info = ""
if has_warehouse: if has_warehouse:
location_info = ",产品已入库(virtual_warehouse)" location_info = ",产品已入库(virtual_warehouse)"
return TaskTransferResponse(
completed_task=_to_response(refreshed_task),
created_tasks=created_task_responses,
message = ( message = (
f"任务「{task.task_name}」已完成," f"任务「{task.task_name}」已完成,"
f"已创建 {len(created_tasks)} 个下一道工序任务「{request.next_task_name}」" f"已创建 {len(created_tasks)} 个下一道工序任务「{request.next_task_name}」"
f"(接收人: {assignee_list}){location_info}" f"(接收人: {assignee_list}){location_info}"
), )
return TaskTransferResponse(
completed_task=_to_response(refreshed_task),
created_tasks=created_task_responses,
message=message,
) )
@ -1101,8 +1225,11 @@ async def complete_task(
# --- 4. 可选:创建下一步任务(转交) --- # --- 4. 可选:创建下一步任务(转交) ---
next_task = None next_task = None
if request.next_task_name and request.next_assignee_id: if request.next_task_name and request.next_assignee_id:
# 🔧 出库后又被转交出新任务 = 设备回流返厂 → 切到售后生命周期 # 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期
await _mark_after_sales_if_reactivated(db, task.product_id) # (出厂质检「发货测试」不触发)
await _mark_after_sales_if_reactivated(
db, task.product_id, request.next_task_name,
)
# 🚀 智能父节点继承算法 # 🚀 智能父节点继承算法
# 主线任务转交 → 保持平级继承(主分支永远在一维主干上) # 主线任务转交 → 保持平级继承(主分支永远在一维主干上)
# 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝) # 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝)

View File

@ -2,6 +2,24 @@ import { Suspense, lazy } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { App as AntApp, ConfigProvider } from "antd"; import { App as AntApp, ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN"; import zhCN from "antd/locale/zh_CN";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
// ── dayjs 中文 locale ────────────────────────────────────────────────
// 必须设在应用根模块,不能下放到各页面。
//
// antd 的 ConfigProvider locale 只负责「请选择日期 / 今天 / 此刻」这类**文案**;
// 日期面板上的**月份与星期名**是 rc-picker 交给 dayjs 取的 ——
// @rc-component/picker/es/generate/dayjs.js 里:
// getShortWeekDays: locale => dayjs().locale(parseLocale(locale)).localeData().weekdaysMin()
// parseLocale 把 antd 传来的 "zh_CN"(下划线)在 localeMap 未命中后按 '_' 切分,
// 得到 "zh",最终查的是 dayjs 的 locale 表 —— 不注册就永远是默认的 en。
//
// 此前这两行被分别写在 AdminPeoplePage / AnalyticsDashboard 两个页面里,而路由是
// lazy() 代码分割:直接打开 /admin/dashboard 或 /admin/matrix 时那两个模块根本没
// 被加载,dayjs 仍是 en,面板就露出 Sep / Su / Mo —— 表现为「有的页面中文、有的
// 页面英文」。放到根模块可保证任何路由下都先生效。
dayjs.locale("zh-cn");
import { ToastProvider } from "./components/ui/Toast"; import { ToastProvider } from "./components/ui/Toast";
import { AuthProvider } from "./contexts/AuthContext"; import { AuthProvider } from "./contexts/AuthContext";

View File

@ -19,7 +19,8 @@ import {
} from "../../services/taskApi"; } from "../../services/taskApi";
import { useToast } from "../ui/Toast"; import { useToast } from "../ui/Toast";
import type { ProductScanResponse, TaskResponse } from "../../types/api"; import type { ProductScanResponse, TaskResponse } from "../../types/api";
import { getStatusConfig } from "../../constants/task"; import { getStatusConfig, isAdminRole } from "../../constants/task";
import { useAuth } from "../../contexts/AuthContext";
import ImageUploader from "../ui/ImageUploader"; import ImageUploader from "../ui/ImageUploader";
import { extractErrorMessage } from "../../utils/errorMessage"; import { extractErrorMessage } from "../../utils/errorMessage";
@ -237,14 +238,45 @@ export const TransferModal = memo(function TransferModal({
task: TaskResponse | null; task: TaskResponse | null;
submitting: boolean; submitting: boolean;
onClose: () => void; onClose: () => void;
onSubmit: (nextTaskName: string, assignees: string[], note: string) => void; onSubmit: (
nextTaskName: string,
assignees: string[],
note: string,
finishDirectly: boolean
) => void;
}) { }) {
const [nextTaskName, setNextTaskName] = useState(""); const [nextTaskName, setNextTaskName] = useState("");
const [assigneeInput, setAssigneeInput] = useState(""); const [assigneeInput, setAssigneeInput] = useState("");
const [assignees, setAssignees] = useState<string[]>([]); const [assignees, setAssignees] = useState<string[]>([]);
const [useWarehouse, setUseWarehouse] = useState(false); const [useWarehouse, setUseWarehouse] = useState(false);
// 🏁 直接完结:无下游、不入库。与「转交个人 / 入库」互斥
const [finishDirectly, setFinishDirectly] = useState(false);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
// 🔄 每次打开都重置:TransferModal 被 memo 后常驻挂载(task 为 null 时只是 return null),
// 状态会跨次打开残留,必须显式清零。
// ⚠️ 必须放在 `if (!task) return null` 之前 —— 否则是条件提前返回后再调 Hook,
// 违反 Rules of Hooks,task 从 null 变非 null 时 Hook 数量错位会直接崩。
useEffect(() => {
if (!open) return;
setNextTaskName("");
setAssigneeInput("");
setAssignees([]);
setUseWarehouse(false);
setFinishDirectly(false);
setNote("");
}, [open]);
// 🔒 直接完结入口仅超管/主管【可见】:普通人看不到这个选项,而不是点了才被后端 403。
// 后端那道硬拦截依然保留 —— 这里是体验层,角色来自客户端存储、可被伪造。
const { user } = useAuth();
const canFinishDirectly = isAdminRole(user?.role);
// 防御:非管理员若因任何原因残留了选中态,强制清掉,避免提交出 finish_directly
useEffect(() => {
if (!canFinishDirectly) setFinishDirectly(false);
}, [canFinishDirectly]);
if (!task) return null; if (!task) return null;
function handleAddAssignee() { function handleAddAssignee() {
@ -271,6 +303,9 @@ export const TransferModal = memo(function TransferModal({
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
// 🏁 直接完结:不需要工序名与接收人,只闭环当前任务
if (!finishDirectly) {
const name = nextTaskName.trim(); const name = nextTaskName.trim();
if (!name) return; if (!name) return;
@ -280,35 +315,68 @@ export const TransferModal = memo(function TransferModal({
} }
if (finalAssignees.length === 0) return; if (finalAssignees.length === 0) return;
onSubmit(name, finalAssignees, note.trim()); onSubmit(name, finalAssignees, note.trim(), false);
// 重置表单 } else {
onSubmit("", [], note.trim(), true);
}
// ⚠️ 此处刻意不重置表单:请求是异步的,父组件要等 await 结束才关闭弹窗。
// 若在此清空,提交等待期间标题/预览会从「直接完结」闪回普通转交。
// 统一改由下方的「打开时重置」负责。
}
function resetForm() {
setNextTaskName(""); setNextTaskName("");
setAssignees([]); setAssignees([]);
setAssigneeInput(""); setAssigneeInput("");
setUseWarehouse(false); setUseWarehouse(false);
setFinishDirectly(false);
setNote(""); setNote("");
} }
function handleClose() { function handleClose() {
setNextTaskName(""); resetForm();
setAssignees([]);
setAssigneeInput("");
setUseWarehouse(false);
setNote("");
onClose(); onClose();
} }
// 🏁 直接完结时,下游相关字段全部失效(禁用而非清空,便于用户反悔切回)
const downstreamDisabled = finishDirectly;
// 互斥:勾选直接完结即撤掉入库,避免两个开关同时生效
function handleToggleFinishDirectly(checked: boolean) {
setFinishDirectly(checked);
if (checked) setUseWarehouse(false);
}
const allAssignees = [ const allAssignees = [
...assignees, ...assignees,
...(useWarehouse ? ["virtual_warehouse"] : []), ...(useWarehouse ? ["virtual_warehouse"] : []),
]; ];
return ( return (
<Modal open={open} onClose={handleClose} title="完工转交 — 裂变到下道工序"> <Modal
<div className="mb-4 rounded-lg bg-green-50 px-3 py-2.5 text-sm text-green-700"> open={open}
onClose={handleClose}
title={
finishDirectly
? "完工转交 — 🏁 直接完结(无下游,不入库)"
: "完工转交 — 裂变到下道工序"
}
>
<div
className={`mb-4 rounded-lg px-3 py-2.5 text-sm ${
finishDirectly
? "bg-amber-50 text-amber-700"
: "bg-green-50 text-green-700"
}`}
>
<p className="font-medium">{task.task_name}</p> <p className="font-medium">{task.task_name}</p>
<p className="mt-0.5 text-xs text-green-500"> <p
完成任务并批量创建下一道工序任务,支持多路裂变分支 className={`mt-0.5 text-xs ${
finishDirectly ? "text-amber-500" : "text-green-500"
}`}
>
{finishDirectly
? "结束本任务且不创建下游任务,产品状态保持原样"
: "完成任务并批量创建下一道工序任务,支持多路裂变分支"}
</p> </p>
</div> </div>
@ -324,7 +392,8 @@ export const TransferModal = memo(function TransferModal({
onChange={(e) => setNextTaskName(e.target.value)} onChange={(e) => setNextTaskName(e.target.value)}
placeholder="如:组装、质检、包装" placeholder="如:组装、质检、包装"
maxLength={200} maxLength={200}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100" disabled={downstreamDisabled}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-400"
autoFocus autoFocus
/> />
</div> </div>
@ -375,12 +444,13 @@ export const TransferModal = memo(function TransferModal({
onKeyDown={handleAssigneeKeyDown} onKeyDown={handleAssigneeKeyDown}
placeholder="输入接收人 ID,按 Enter 添加" placeholder="输入接收人 ID,按 Enter 添加"
maxLength={64} maxLength={64}
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100" disabled={downstreamDisabled}
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-400"
/> />
<button <button
type="button" type="button"
onClick={handleAddAssignee} onClick={handleAddAssignee}
disabled={!assigneeInput.trim()} disabled={!assigneeInput.trim() || downstreamDisabled}
className="flex items-center gap-1 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-40" className="flex items-center gap-1 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-40"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
@ -390,11 +460,16 @@ export const TransferModal = memo(function TransferModal({
</div> </div>
{/* 虚拟入库 */} {/* 虚拟入库 */}
<label className="flex items-center gap-2 cursor-pointer select-none"> <label
className={`flex items-center gap-2 select-none ${
downstreamDisabled ? "cursor-not-allowed opacity-40" : "cursor-pointer"
}`}
>
<input <input
type="checkbox" type="checkbox"
checked={useWarehouse} checked={useWarehouse}
onChange={(e) => setUseWarehouse(e.target.checked)} onChange={(e) => setUseWarehouse(e.target.checked)}
disabled={downstreamDisabled}
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/> />
<Warehouse className="h-4 w-4 text-purple-500" /> <Warehouse className="h-4 w-4 text-purple-500" />
@ -403,6 +478,32 @@ export const TransferModal = memo(function TransferModal({
</span> </span>
</label> </label>
{/* 🏁 直接完结 — 与转交/入库互斥。仅超管/主管可见 */}
{canFinishDirectly && (
<div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={finishDirectly}
onChange={(e) => handleToggleFinishDirectly(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
/>
<span className="text-sm font-medium text-gray-700">
🏁 直接完结(无下游,不入库)
</span>
</label>
{finishDirectly && (
<p className="mt-1.5 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
任务将直接完结,不创建任何下游任务。
<span className="font-semibold">它只是一个任务闭环动作,不改动产品状态</span>
:已出库的设备完结后依然是<span className="font-semibold">「已出库」</span>
,回归 WIP 矩阵的已出库列;不会变成「待仓库收货」,也无需仓库扫码实收。
适用于售后返厂直接发走、半成品被直接提走等无需入库的收官场景。
</p>
)}
</div>
)}
{/* 备注 */} {/* 备注 */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700">
@ -417,8 +518,21 @@ export const TransferModal = memo(function TransferModal({
/> />
</div> </div>
{/* 预览 — 🏁 直接完结走独立文案,不能套用「将创建 N 个任务」模板 */}
{finishDirectly && (
<div className="rounded-lg bg-amber-50 px-3 py-2.5 text-xs text-amber-700">
<p className="font-medium">🏁 直接完结预览:</p>
<p className="mt-1">
任务「<span className="font-semibold">{task.task_name}</span>
」将直接结束,
<span className="font-semibold">不创建任何下游任务</span>
;产品宏观状态与当前位置保持不变。
</p>
</div>
)}
{/* 预览 */} {/* 预览 */}
{allAssignees.length > 0 && nextTaskName.trim() && ( {!finishDirectly && allAssignees.length > 0 && nextTaskName.trim() && (
<div className="rounded-lg bg-gray-50 px-3 py-2.5 text-xs text-gray-500"> <div className="rounded-lg bg-gray-50 px-3 py-2.5 text-xs text-gray-500">
<p className="font-medium text-gray-600">裂变预览:</p> <p className="font-medium text-gray-600">裂变预览:</p>
<p className="mt-1"> <p className="mt-1">
@ -460,13 +574,17 @@ export const TransferModal = memo(function TransferModal({
type="submit" type="submit"
disabled={ disabled={
submitting || submitting ||
!nextTaskName.trim() || (!finishDirectly &&
allAssignees.length === 0 (!nextTaskName.trim() || allAssignees.length === 0))
} }
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50" className={`flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white disabled:opacity-50 ${
finishDirectly
? "bg-amber-600 hover:bg-amber-700"
: "bg-green-600 hover:bg-green-700"
}`}
> >
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />} {submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
确认转交 {finishDirectly ? "🏁 确认直接完结" : "确认转交"}
</button> </button>
</div> </div>
</form> </form>
@ -573,7 +691,8 @@ export default function TaskTreeViewer() {
async function handleTransfer( async function handleTransfer(
nextTaskName: string, nextTaskName: string,
assignees: string[], assignees: string[],
note: string note: string,
finishDirectly: boolean
) { ) {
if (!modalTarget) return; if (!modalTarget) return;
setSubmitting(true); setSubmitting(true);
@ -582,7 +701,8 @@ export default function TaskTreeViewer() {
modalTarget.task.id, modalTarget.task.id,
assignees, assignees,
nextTaskName, nextTaskName,
note || undefined note || undefined,
finishDirectly
); );
toast(result.message, "success"); toast(result.message, "success");
setModalTarget(null); setModalTarget(null);

View File

@ -200,8 +200,17 @@ export const AFTER_SALES_OVERALL_OPTIONS = [
export function taskOptionsFor( export function taskOptionsFor(
phase: string | null | undefined, phase: string | null | undefined,
hasHistory = true, hasHistory = true,
overallStatus?: string | null,
): string[] { ): string[] {
if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_TASK_OPTIONS; // 🔧 出库设备与售后生命周期同等待遇(与 track-uniapp/src/utils/lifecycle.js 同步):
// 后端为消除不可回退的售后烙印,已不再把「正常已出库做质检」的设备翻成
// AFTER_SALES。若这里仍只认 phase,工人永远选不到「发货测试 / 售后维修」。
if (
phase === LIFECYCLE_PHASE.AFTER_SALES ||
String(overallStatus || "").trim() === "已出库"
) {
return AFTER_SALES_TASK_OPTIONS;
}
if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS]; if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
return PRODUCTION_TASK_OPTIONS; return PRODUCTION_TASK_OPTIONS;
} }
@ -217,6 +226,22 @@ export function overallOptionsFor(
} }
/** 列表筛选枚举 — 两阶段并集(用于筛选,不是录入项) */ /** 列表筛选枚举 — 两阶段并集(用于筛选,不是录入项) */
/**
* 管理角色 — 必须与后端 task_service.ADMIN_ROLES 保持一致。
*
* ⚠️ 收敛到这里的理由:此前这段判断散落在多处(TaskFlowView / AdminProductsPage),
* 而移动端那份只判了 SUPER_ADMIN、漏了 SUPERVISOR,导致主管被前端误挡。
* 新增判断一律用 isAdminRole(),不要再手写 === 比较。
*
* 注意:这是【体验层】判断,角色来自客户端存储、可被伪造。
* 真正的拦截在后端(transfer_task 对 finish_directly 有 403 硬校验)。
*/
export const ADMIN_ROLES = ["SUPER_ADMIN", "SUPERVISOR"] as const;
export function isAdminRole(role?: string | null): boolean {
return !!role && (ADMIN_ROLES as readonly string[]).includes(role);
}
export const ALL_OVERALL_OPTIONS = [ export const ALL_OVERALL_OPTIONS = [
...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS, ...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS,
]; ];

View File

@ -2,9 +2,9 @@ import { useEffect, useState, useCallback, useRef } from "react";
import { import {
Package, ClipboardList, Bell, TrendingUp, AlertTriangle, Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
RefreshCw, Loader2, AlertCircle, ArrowRight, ArrowUp, ArrowDown, ArrowUpDown, RefreshCw, Loader2, AlertCircle, ArrowRight, ArrowUp, ArrowDown, ArrowUpDown,
Clock, MessageCircle, CheckCircle2, Users, Clock, MessageCircle, CheckCircle2, Users, Info,
} from "lucide-react"; } from "lucide-react";
import { Radio, DatePicker, Drawer, Input } from "antd"; import { Radio, DatePicker, Drawer, Input, Tooltip } from "antd";
import dayjs, { type Dayjs } from "dayjs"; import dayjs, { type Dayjs } from "dayjs";
import { import {
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations, fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations,
@ -34,10 +34,16 @@ function rangeToParams(key: DateRangeKey, customRange: [Dayjs, Dayjs] | null) {
} }
// ─── 进度条(支持3或4段) ───────────────────────────────── // ─── 进度条(支持3或4段) ─────────────────────────────────
function ProgressBar({ a, b, c, total, labels, d }: { function ProgressBar({ a, b, c, total, labels, d, hints }: {
a: number; b: number; c: number; total: number; a: number; b: number; c: number; total: number;
labels: [string, string, string]; labels: [string, string, string];
d?: number; d?: number;
/**
* 图例口径说明:label → 悬浮提示文案。
* 用于消除「产品数 > 任务数」这类口径差异带来的误判 —— 底层逻辑没问题,
* 但不写清楚就极像算错数据。
*/
hints?: Record<string, string>;
}) { }) {
if (total === 0) return <div className="py-4 text-center text-xs text-gray-400">暂无数据</div>; if (total === 0) return <div className="py-4 text-center text-xs text-gray-400">暂无数据</div>;
const pct = (n: number) => Math.round((n / total) * 100); const pct = (n: number) => Math.round((n / total) * 100);
@ -60,6 +66,11 @@ function ProgressBar({ a, b, c, total, labels, d }: {
<span key={i} className="flex items-center gap-1"> <span key={i} className="flex items-center gap-1">
<span className={`inline-block h-2 w-2 rounded-full ${s.color}`} /> <span className={`inline-block h-2 w-2 rounded-full ${s.color}`} />
{s.label} {s.n}({pct(s.n)}%) {s.label} {s.n}({pct(s.n)}%)
{hints?.[s.label] && (
<Tooltip title={hints[s.label]}>
<Info className="h-3 w-3 shrink-0 cursor-help text-gray-400 transition-colors hover:text-gray-600" />
</Tooltip>
)}
</span> </span>
))} ))}
</div> </div>
@ -190,43 +201,34 @@ function CompletedRow({ t }: { t: CompletedTask }) {
} }
// ─── 被驳回明细项(卡片式) ────────────────────────────── // ─── 被驳回明细项(卡片式) ──────────────────────────────
/**
* 驳回明细行。
*
* 只渲染「已驳回」这一种形态:一次驳回必然派生一条返工任务,两条同时成行会
* 让看板看起来「有两条异常」。而本行已经带了「返工给: xxx」,一条就足以说清
* 「这道工序被驳回了,现在由谁在返工」的完整语义,返工任务无需再独立成行。
*/
function RejectedRow({ t }: { t: RejectedTask }) { function RejectedRow({ t }: { t: RejectedTask }) {
const timeStr = t.rejected_at ? dayjs(t.rejected_at).format("YYYY-MM-DD HH:mm") : ""; const timeStr = t.rejected_at ? dayjs(t.rejected_at).format("YYYY-MM-DD HH:mm") : "";
const isRework = t.kind === "rework";
return ( return (
<div <div
onClick={() => t.product_sn && window.open(`/admin/tasks?sn=${t.product_sn}`, "_blank")} onClick={() => t.product_sn && window.open(`/admin/tasks?sn=${t.product_sn}`, "_blank")}
className="cursor-pointer rounded-lg border border-gray-100 bg-white px-4 py-3 transition-shadow hover:border-red-200 hover:shadow-md" className="cursor-pointer rounded-lg border border-gray-100 bg-white px-4 py-3 transition-shadow hover:border-red-200 hover:shadow-md"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isRework ? <RefreshCw className="h-4 w-4 shrink-0 text-orange-500" /> : <AlertTriangle className="h-4 w-4 shrink-0 text-red-500" />} <AlertTriangle className="h-4 w-4 shrink-0 text-red-500" />
<span className="text-sm font-semibold text-gray-800 truncate">{t.task_name}</span> <span className="text-sm font-semibold text-gray-800 truncate">{t.task_name}</span>
<span className="text-xs text-gray-500">|</span> <span className="text-xs text-gray-500">|</span>
{isRework ? (
<span className="shrink-0 rounded-full bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600">
{t.status === "WIP" ? "返工中" : t.status === "PENDING" ? "待返工" : "已返工"}
</span>
) : (
<span className="shrink-0 rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-600">已驳回</span> <span className="shrink-0 rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-600">已驳回</span>
)} <span className="text-xs text-gray-500 shrink-0">驳回人: {t.rejected_by}</span>
<span className="text-xs text-gray-500 shrink-0">
{isRework ? `返工人: ${t.rework_assignee}` : `驳回人: ${t.rejected_by}`}
</span>
<span className="ml-auto shrink-0 text-xs text-gray-400">{timeStr}</span> <span className="ml-auto shrink-0 text-xs text-gray-400">{timeStr}</span>
</div> </div>
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400"> <div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
{!isRework && (
<>
<span className="inline-flex items-center gap-1 rounded bg-orange-50 px-1.5 py-0.5 font-medium text-orange-600"> <span className="inline-flex items-center gap-1 rounded bg-orange-50 px-1.5 py-0.5 font-medium text-orange-600">
🔁 返工给: {t.rework_assignee} 🔁 返工给: {t.rework_assignee}
</span> </span>
<span className="text-gray-300">|</span> <span className="text-gray-300">|</span>
<span className="truncate text-red-500/80" title={t.reject_reason || ""}>原因: {t.reject_reason || "—"}</span> <span className="truncate text-red-500/80" title={t.reject_reason || ""}>原因: {t.reject_reason || "—"}</span>
</>
)}
{isRework && (
<span className="truncate text-orange-600/80">🔁 返工任务 · {t.rework_assignee} 负责</span>
)}
</div> </div>
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400"> <div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
<span className="font-medium text-gray-500">{t.material_name || "未知设备"}</span> <span className="font-medium text-gray-500">{t.material_name || "未知设备"}</span>
@ -245,6 +247,9 @@ function RejectedRow({ t }: { t: RejectedTask }) {
export default function AdminDashboard() { export default function AdminDashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null); const [stats, setStats] = useState<DashboardStats | null>(null);
const [wipTasks, setWipTasks] = useState<WipTask[]>([]); const [wipTasks, setWipTasks] = useState<WipTask[]>([]);
// 在制品列表的真实总数,与「任务状态」卡片的活跃任务同源(待接收 + 进行中)。
// 用它而不是 wipTasks.length 做计数,避免接口截断时列表谎报总数。
const activeTaskCount = (stats?.tasks_pending ?? 0) + (stats?.tasks_in_progress ?? 0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -288,7 +293,7 @@ export default function AdminDashboard() {
const { since, until } = rangeToParams(key, range); const { since, until } = rangeToParams(key, range);
Promise.all([ Promise.all([
fetchDashboardStats(since, until), fetchDashboardStats(since, until),
fetchWipTasks(20), fetchWipTasks(), // 不传 limit:取全量在制品,避免被截断后与卡片数字对不上
]) ])
.then(([s, w]) => { setStats(s); setWipTasks(w); setError(null); }) .then(([s, w]) => { setStats(s); setWipTasks(w); setError(null); })
.catch(() => setError("加载失败,请确认后端已启动")) .catch(() => setError("加载失败,请确认后端已启动"))
@ -436,7 +441,7 @@ export default function AdminDashboard() {
<div> <div>
<h2 className="text-xl font-bold text-gray-800">📊 生产管理看板</h2> <h2 className="text-xl font-bold text-gray-800">📊 生产管理看板</h2>
<p className="mt-0.5 text-sm text-gray-400"> <p className="mt-0.5 text-sm text-gray-400">
产品 = 物理实体(身份证)| 任务 = 工序节点 产品 = 车间里的物理设备 | 任务 = 派发给工人的生产指令(派工单)
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -469,12 +474,6 @@ export default function AdminDashboard() {
</div> </div>
</div> </div>
{/* 提示:已完结受时间筛选 */}
{dateKey !== "today" && (
<div className="rounded-lg bg-blue-50 px-3 py-1.5 text-[11px] text-blue-600">
📐 当前时间筛选仅影响<strong>已完成/已驳回</strong>计数,在制品和总数始终为实时快照
</div>
)}
{/* ═══ 4 卡片 ═══ */} {/* ═══ 4 卡片 ═══ */}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
@ -485,9 +484,12 @@ export default function AdminDashboard() {
<h3 className="text-sm font-semibold text-gray-700">📦 产品流转</h3> <h3 className="text-sm font-semibold text-gray-700">📦 产品流转</h3>
</div> </div>
<p className="text-3xl font-bold text-gray-800">{stats.products_total}<span className="text-sm font-normal text-gray-400"> 个</span></p> <p className="text-3xl font-bold text-gray-800">{stats.products_total}<span className="text-sm font-normal text-gray-400"> 个</span></p>
<p className="mb-3 text-[11px] text-gray-400">实时快照(不受时间筛选影响)</p> <p className="mb-3 text-[11px] text-gray-400">在制品为实时快照;「已完结」受右上角时间筛选影响</p>
<ProgressBar a={stats.products_pending} b={stats.products_in_progress} c={stats.products_completed} <ProgressBar a={stats.products_pending} b={stats.products_in_progress} c={stats.products_completed}
total={stats.products_total} labels={["待流转", "流转中", "已完结"]} /> total={stats.products_total} labels={["待流转", "流转中", "已完结"]}
hints={{
"待流转": "指已建档,但尚未派发首道工序,或当前无任何活跃工序挂载的物理产品。因此待流转数可能会大于待接收任务数。",
}} />
</div> </div>
{/* 任务状态 */} {/* 任务状态 */}
@ -499,7 +501,10 @@ export default function AdminDashboard() {
<p className="text-3xl font-bold text-gray-800">{stats.tasks_total}<span className="text-sm font-normal text-gray-400"> 个</span></p> <p className="text-3xl font-bold text-gray-800">{stats.tasks_total}<span className="text-sm font-normal text-gray-400"> 个</span></p>
<p className="mb-3 text-[11px] text-gray-400">PENDING/WIP 实时 | COMPLETED 按时段</p> <p className="mb-3 text-[11px] text-gray-400">PENDING/WIP 实时 | COMPLETED 按时段</p>
<ProgressBar a={stats.tasks_pending} b={stats.tasks_in_progress} c={stats.tasks_completed} <ProgressBar a={stats.tasks_pending} b={stats.tasks_in_progress} c={stats.tasks_completed}
total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} d={stats.tasks_rejected} /> total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} d={stats.tasks_rejected}
hints={{
"待接收": "指系统已生成具体工序单并指派给工人,但工人尚未在手机端点击接单的任务。",
}} />
</div> </div>
{/* 品质 & 留言 */} {/* 品质 & 留言 */}
@ -571,12 +576,18 @@ export default function AdminDashboard() {
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-700"> <h3 className="flex items-center gap-2 text-sm font-semibold text-gray-700">
<Clock className="h-4 w-4 text-orange-500" /> 当前在制品(实时) <Clock className="h-4 w-4 text-orange-500" /> 当前在制品(实时)
</h3> </h3>
<span className="text-[11px] text-gray-400">共 {wipTasks.length} 个</span> {/* 计数取「任务状态」卡片同源的真实活跃数,而不是 wipTasks.length ——
后者一旦被接口截断就会比实际少,出现「卡片 23、列表共 20」的假象 */}
<span className="text-[11px] text-gray-400">
共 {activeTaskCount} 个
{wipTasks.length < activeTaskCount && `(仅显示前 ${wipTasks.length} 条)`}
</span>
</div> </div>
{wipTasks.length === 0 ? ( {wipTasks.length === 0 ? (
<div className="py-10 text-center text-sm text-gray-400">🎉 暂无滞留任务</div> <div className="py-10 text-center text-sm text-gray-400">🎉 暂无滞留任务</div>
) : ( ) : (
<div> // 容器内滚动:列表可能很长(全量在制品),不让它把整页撑开
<div className="max-h-[420px] overflow-y-auto pr-1">
<div className="mb-1 flex items-center gap-3 text-[11px] font-medium text-gray-400"> <div className="mb-1 flex items-center gap-3 text-[11px] font-medium text-gray-400">
<span className="w-14 shrink-0">状态</span> <span className="w-14 shrink-0">状态</span>
<span className="flex-1">任务 · 负责人</span> <span className="flex-1">任务 · 负责人</span>
@ -692,7 +703,7 @@ export default function AdminDashboard() {
{/* ═══ 驳回/返工明细抽屉 ═══ */} {/* ═══ 驳回/返工明细抽屉 ═══ */}
<Drawer <Drawer
title={<span className="text-base font-bold">🔴 驳回/返工明细 <span className="font-normal text-gray-400">共 {rejectedTasks.length} 条</span></span>} title={<span className="text-base font-bold">🔴 驳回明细 <span className="font-normal text-gray-400">共 {rejectedTasks.filter(t => t.kind === "rejected").length} 条</span></span>}
open={rejectedDrawerOpen} open={rejectedDrawerOpen}
onClose={() => setRejectedDrawerOpen(false)} onClose={() => setRejectedDrawerOpen(false)}
size="large" size="large"
@ -702,37 +713,29 @@ export default function AdminDashboard() {
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-red-500" /> <Loader2 className="h-6 w-6 animate-spin text-red-500" />
</div> </div>
) : rejectedTasks.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400">
该时段暂无驳回/返工记录
</div>
) : ( ) : (
<div className="space-y-4"> (() => {
{(() => { // 只展示「已驳回」行:返工任务是驳回自动派生的,独立成行会让同一次
// 异常看起来像两条(详见 RejectedRow 顶部说明)。
const rejected = rejectedTasks.filter(t => t.kind === "rejected"); const rejected = rejectedTasks.filter(t => t.kind === "rejected");
const rework = rejectedTasks.filter(t => t.kind === "rework"); if (rejected.length === 0) {
return ( return (
<> <div className="py-16 text-center text-sm text-gray-400">
{rejected.length > 0 && ( 该时段暂无驳回记录
</div>
);
}
return (
<div className="space-y-4">
<div> <div>
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-red-600"> <h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-red-600">
<AlertTriangle className="h-3.5 w-3.5" />已驳回 · {rejected.length} 条 <AlertTriangle className="h-3.5 w-3.5" />已驳回 · {rejected.length} 条
</h4> </h4>
{rejected.map(t => <RejectedRow key={t.task_id} t={t} />)} {rejected.map(t => <RejectedRow key={t.task_id} t={t} />)}
</div> </div>
)}
{rework.length > 0 && (
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-orange-600">
<RefreshCw className="h-3.5 w-3.5" />返工任务 · {rework.length} 条
</h4>
{rework.map(t => <RejectedRow key={t.task_id} t={t} />)}
</div> </div>
)}
</>
); );
})()} })()
</div>
)} )}
</Drawer> </Drawer>

View File

@ -6,8 +6,7 @@ import {
import { Radio, DatePicker, Table, Button, AutoComplete, Modal, Timeline, Image, Select } from "antd"; import { Radio, DatePicker, Table, Button, AutoComplete, Modal, Timeline, Image, Select } from "antd";
import type { ColumnsType } from "antd/es/table"; import type { ColumnsType } from "antd/es/table";
import dayjs, { type Dayjs } from "dayjs"; import dayjs, { type Dayjs } from "dayjs";
import "dayjs/locale/zh-cn"; // dayjs 的中文 locale 已在应用根(App.tsx)统一设置,此处不再重复
dayjs.locale("zh-cn");
import { import {
fetchPeopleWorkload, fetchPeopleHistory, downloadPeopleHistory, fetchPeopleWorkload, fetchPeopleHistory, downloadPeopleHistory,
type PersonWorkload, type PersonHistoryRecord, type PeopleHistoryQuery, type PersonWorkload, type PersonHistoryRecord, type PeopleHistoryQuery,

View File

@ -114,15 +114,19 @@ export default function AdminTasksPage() {
render: (p) => <div className="text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>, render: (p) => <div className="text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>,
}, },
{ {
key: "overall_status", label: "当前工序", colSpan: 1, key: "current_step", label: "当前工序", colSpan: 1,
filterType: "enum", getFilterValue: (p) => p.overall_status || "—", // 🔧 改用后端派生的 current_step,而不是 overall_status ——
// 后者的取值被"最新主干任务名"覆盖(含已 COMPLETED 的历史工序),
// 会让「扫码出库 / 测试 / 发货测试」等已完结工序冒充"当前工序"。
// current_step 只认活跃主干任务;无活跃任务时返回终态或空。
filterType: "enum", getFilterValue: (p) => p.current_step || "—",
// 🔧 筛选用两阶段并集:售后设备的「发货测试 / 售后维修」也要能被筛出来 // 🔧 筛选用两阶段并集:售后设备的「发货测试 / 售后维修」也要能被筛出来
enumOptions: ALL_OVERALL_OPTIONS.map((v) => ({ value: v, label: v })), enumOptions: ALL_OVERALL_OPTIONS.map((v) => ({ value: v, label: v })),
render: (p) => { render: (p) => {
const lb = lifecycleBadge(p.overall_status, p.lifecycle_phase); const lb = lifecycleBadge(p.current_step, p.lifecycle_phase);
return ( return (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span> <span className="text-xs font-medium text-gray-700">{p.current_step || "—"}</span>
{lb && ( {lb && (
<span className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none ${lb.className}`}> <span className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none ${lb.className}`}>
{lb.label} {lb.label}
@ -528,12 +532,13 @@ export default function AdminTasksPage() {
nextTaskName: string, nextTaskName: string,
assignees: string[], assignees: string[],
note: string, note: string,
finishDirectly: boolean,
) { ) {
if (!modalTarget) return; if (!modalTarget) return;
setSubmitting(true); setSubmitting(true);
try { try {
const result = await transferTask( const result = await transferTask(
modalTarget.task.id, assignees, nextTaskName, note || undefined, modalTarget.task.id, assignees, nextTaskName, note || undefined, finishDirectly,
); );
toast(result.message, "success"); toast(result.message, "success");
const sn = modalTarget.task.product_sn || ""; const sn = modalTarget.task.product_sn || "";

View File

@ -6,7 +6,7 @@ import { DatePicker, Tabs, Select, Button, Empty, Modal, Timeline, Image, Radio
import { Users, GitBranch, RefreshCw, Loader2, AlertCircle, CalendarDays } from "lucide-react"; import { Users, GitBranch, RefreshCw, Loader2, AlertCircle, CalendarDays } from "lucide-react";
import dayjs, { type Dayjs } from "dayjs"; import dayjs, { type Dayjs } from "dayjs";
import type { EChartsCoreOption } from "echarts/core"; import type { EChartsCoreOption } from "echarts/core";
import "dayjs/locale/zh-cn"; // dayjs 的中文 locale 已在应用根(App.tsx)统一设置,此处不再重复
import BaseEChart from "../../components/BaseEChart"; import BaseEChart from "../../components/BaseEChart";
import { import {
@ -15,8 +15,6 @@ import {
type CapabilityQuery, type FlowQuery, type DeviceRecord, type FlowDevice, type FlowInterval, type CapabilityQuery, type FlowQuery, type DeviceRecord, type FlowDevice, type FlowInterval,
} from "../../services/analyticsApi"; } from "../../services/analyticsApi";
dayjs.locale("zh-cn");
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
// ─── 状态中文映射 ───────────────────────────────────────── // ─── 状态中文映射 ─────────────────────────────────────────

View File

@ -164,7 +164,8 @@ export async function fetchDashboardStats(since?: string, until?: string): Promi
return data; return data;
} }
export async function fetchWipTasks(limit = 20): Promise<WipTask[]> { /** 在制品任务。limit 只是防御性上限,默认值足以覆盖全量 —— 不要拿它做分页截断。 */
export async function fetchWipTasks(limit = 500): Promise<WipTask[]> {
const { data } = await api.get<WipTask[]>("/dashboard/wip-tasks", { params: { limit } }); const { data } = await api.get<WipTask[]>("/dashboard/wip-tasks", { params: { limit } });
return data; return data;
} }

View File

@ -114,20 +114,25 @@ export async function rejectTask(
/** /**
* 完工裂变转交 — → COMPLETED,批量创建下家任务。 * 完工裂变转交 — → COMPLETED,批量创建下家任务。
* 对应后端 POST /api/v1/tasks/{taskId}/transfer * 对应后端 POST /api/v1/tasks/{taskId}/transfer
*
* finishDirectly=true 时走「直接完结」通道:next_tasks 留空 + finish_directly=true,
* 后端据此跳过分支解析,只闭环任务【不产生下游任务】、且不改产品宏观状态
* (不会把产品误标成「待仓库收货」)。用于售后返厂直接发走 / 半成品被提走等无需入库的收官场景。
*/ */
export async function transferTask( export async function transferTask(
taskId: string, taskId: string,
nextAssignees: string[], nextAssignees: string[],
nextTaskName: string, nextTaskName: string,
note?: string note?: string,
finishDirectly = false
): Promise<TaskTransferResponse> { ): Promise<TaskTransferResponse> {
const payload: TaskTransferPayload = finishDirectly
? { next_tasks: [], finish_directly: true, note }
: { next_assignees: nextAssignees, next_task_name: nextTaskName, note };
const { data } = await api.post<TaskTransferResponse>( const { data } = await api.post<TaskTransferResponse>(
`/tasks/${taskId}/transfer`, `/tasks/${taskId}/transfer`,
{ payload
next_assignees: nextAssignees,
next_task_name: nextTaskName,
note,
} satisfies TaskTransferPayload
); );
return data; return data;
} }

View File

@ -17,6 +17,14 @@ export interface ProductResponse {
current_location_name: string | null; current_location_name: string | null;
macro_status: string | null; macro_status: string | null;
overall_status: string | null; overall_status: string | null;
/**
* 【当前工序】— 只反映"此刻在做什么",绝不拿已完结的历史工序冒充。
* · 有活跃主干任务(WIP/PENDING) → 该工序名
* · 否则处于宏观终态(待仓库收货/已入库/在库/已出库) → 该终态
* · 否则(活已干完)→ ""(列表显示「—」)
* ⚠️ 不要用 overall_status 渲染该列:它会被"最新主干任务名"覆盖,含已 COMPLETED 的。
*/
current_step?: string;
status: string; status: string;
/** 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) */ /** 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) */
lifecycle_phase?: LifecyclePhase; lifecycle_phase?: LifecyclePhase;

View File

@ -86,9 +86,23 @@ export interface TaskRejectPayload {
images: string[]; images: string[];
} }
/** 裂变分支 — assignees 允许为空数组,但【仅当】finish_directly=true 时合法;
* 否则后端 TaskTransferRequest 校验会直接 422 拒绝(防「转交」静默退化成「直接完结」的丢件隐患)。 */
export interface TaskTransferBranch {
task_name: string;
assignees: string[];
}
export interface TaskTransferPayload { export interface TaskTransferPayload {
next_assignees: string[]; /** [旧版] 下一道工序接收人列表 — 与 next_tasks 二选一 */
next_task_name: string; next_assignees?: string[];
/** [旧版] 下一道工序名称 — 与 next_tasks 二选一 */
next_task_name?: string;
/** [新版] 多分支任务列表 */
next_tasks?: TaskTransferBranch[];
/** 🏁 直接完结:闭环当前任务但【不产生任何下游任务】,产品 overall_status 保持原样
* (不入库 → 不会变成「待仓库收货」)。置 true 时后端忽略 next_tasks / next_assignees。 */
finish_directly?: boolean;
note?: string; note?: string;
} }

View File

@ -38,10 +38,10 @@
</view> </view>
</view> </view>
<view v-if="product && product.current_location_id === 'virtual_warehouse' && !hasMyActiveTask" <view v-if="showDispatchBanner"
class="warehouse-transfer-banner" @tap="openWarehouseTransfer"> class="warehouse-transfer-banner" @tap="openCreateFirstTask">
<text class="wt-icon">📤</text> <text class="wt-icon">{{ dispatchBannerIcon }}</text>
<text class="wt-text">该产品在仓库中 — 点击此处转出并派发给指定人员</text> <text class="wt-text">{{ dispatchBannerText }}</text>
</view> </view>
</template> </template>
@ -148,7 +148,7 @@
</template> </template>
<template v-if="actionPopup.type === 'transfer'"> <template v-if="actionPopup.type === 'transfer'">
<text class="popup-title">完工转交</text> <text class="popup-title">完工转交</text>
<view class="field-label">接收人 <text class="required">*</text></view> <view class="field-label">接收人 / 处理方式 <text class="required">*</text></view>
<view class="user-grid"> <view class="user-grid">
<view v-for="u in userGridOptions" :key="u.id" <view v-for="u in userGridOptions" :key="u.id"
:class="['user-grid-item', transferForm.selectedUserId === u.id ? 'user-grid-active' : '']" :class="['user-grid-item', transferForm.selectedUserId === u.id ? 'user-grid-active' : '']"
@ -156,10 +156,14 @@
</view> </view>
<view class="field-label" style="margin-top:12px;">或</view> <view class="field-label" style="margin-top:12px;">或</view>
<view :class="['user-grid-item', transferForm.isWarehouse ? 'user-grid-active' : '']" style="width:100%;" @tap="toggleWarehouse">📦 入库 (virtual_warehouse)</view> <view :class="['user-grid-item', transferForm.isWarehouse ? 'user-grid-active' : '']" style="width:100%;" @tap="toggleWarehouse">📦 入库 (virtual_warehouse)</view>
<template v-if="showFinishDirect">
<view :class="['user-grid-item', transferForm.isFinishDirect ? 'user-grid-active' : '']" style="width:100%;margin-top:8px;" @tap="toggleFinishDirect">🏁 直接完结 (不入库)</view>
<text class="popup-hint">🏁 直接完结:结束本任务且不创建下游任务,不改动产品状态(已出库的设备完结后依然是「已出库」)。用于售后返厂直接发走、半成品被直接提走等无需入库的场景</text>
</template>
<view class="field-label" style="margin-top:12px;">交接备注 <text class="required">*</text></view> <view class="field-label" style="margin-top:12px;">交接备注 <text class="required">*</text></view>
<textarea v-model="transferForm.note" class="popup-textarea" placeholder="请填写交接备注(必填)" :maxlength="500" /> <textarea v-model="transferForm.note" class="popup-textarea" placeholder="请填写交接备注(必填)" :maxlength="500" />
<view v-if="transferForm.isWarehouse || transferForm.selectedUserId" class="preview-hint">{{ transferForm.isWarehouse ? '产品将入库并从个人待办中移除' : '将创建新任务指派给 ' + (transferUserName || '—') }}</view> <view v-if="transferMode" class="preview-hint">{{ transferPreview }}</view>
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary" :disabled="actionLoading || (!transferForm.isWarehouse && !transferForm.selectedUserId) || !transferForm.note.trim()" @tap="doTransfer">{{ actionLoading ? '提交中...' : (transferForm.isWarehouse ? '📦 确认入库' : '确认转交') }}</button></view> <view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary" :disabled="actionLoading || !transferMode || !transferForm.note.trim()" @tap="doTransfer">{{ actionLoading ? '提交中...' : transferSubmitLabel }}</button></view>
</template> </template>
<template v-if="actionPopup.type === 'spawn'"> <template v-if="actionPopup.type === 'spawn'">
<text class="popup-title">➕ 派发协助分支</text> <text class="popup-title">➕ 派发协助分支</text>
@ -250,7 +254,7 @@ export default {
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "", actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
// 📷 驳回异常图片(选填):与追加记录共用同一套选图/上传流程 // 📷 驳回异常图片(选填):与追加记录共用同一套选图/上传流程
rejectForm: { images: [], pendingCount: 0 }, rejectForm: { images: [], pendingCount: 0 },
transferForm: { selectedUserId: "", isWarehouse: false, note: "" }, transferForm: { selectedUserId: "", isWarehouse: false, isFinishDirect: false, note: "" },
spawnForm: { assignee_id: "", remark: "" }, spawnForm: { assignee_id: "", remark: "" },
// 🛡️ 双重确认倒计时:避免误触 // 🛡️ 双重确认倒计时:避免误触
confirming: "", // 当前倒计时中的操作 key('' = 无) confirming: "", // 当前倒计时中的操作 key('' = 无)
@ -271,6 +275,64 @@ export default {
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); }, userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; const assignee = this.recordPopup.task.assignee_id; return assignee == this.currentUserId || assignee == this.currentUsername || (this.currentUser && this.currentUser.id == assignee) || (this.currentUser && this.currentUser.username == assignee); }, canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; const assignee = this.recordPopup.task.assignee_id; return assignee == this.currentUserId || assignee == this.currentUsername || (this.currentUser && this.currentUser.id == assignee) || (this.currentUser && this.currentUser.username == assignee); },
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; }, transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
// 🔒 直接完结入口仅超管/主管【可见】——普通人看不到,而不是点了才被后端 403。
// ⚠️ 必须两个角色都判:本文件既有的 canEditOverallStatus 只判了 SUPER_ADMIN、
// 漏了 SUPERVISOR,导致主管被前端误挡。此处与后端 ADMIN_ROLES 对齐。
// 🔒 管理角色判定(超管 / 主管)—— 与后端 ADMIN_ROLES 对齐,作为各处权限判断的唯一入口
isAdminUser() { const r = (this.currentUser && this.currentUser.role) || this.currentUserRole || ''; return r === 'SUPER_ADMIN' || r === 'SUPERVISOR'; },
canFinishDirectly() { return this.isAdminUser; },
// 📤 产品是否空闲:没有任何活跃的【主线】任务(WIP/PENDING)。
// 只算主干(无父任务 或 TRANSFER/RECOVERY):协助分支(SPAWN)不阻塞派发,
// 否则一个挂着的协助分支会把产品永久锁死。
hasActiveMainTask() {
const walk = (tasks) => {
if (!tasks) return false;
for (const t of tasks) {
const isMain = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
if (isMain && (t.status === 'WIP' || t.status === 'PENDING')) return true;
if (walk(t.child_tasks)) return true;
}
return false;
};
return this.product ? walk(this.product.task_tree) : false;
},
// 📤 派发新任务入口的显示条件 —— 2026-09-17 解绑「派发权限」与「仓库位置」。
//
// 原实现硬性要求 current_location_id === 'virtual_warehouse',于是
// 【直接完结】后的设备(位置停在最后经手人名下、又不在仓库池)派发入口
// 彻底消失,产品变成再也无法流转的孤儿数据。
// 但物理上除非设备报废,永远存在派发新任务的需求,故增加豁免:
// · 在仓库且我本人没有待办 → 转出派发(原有行为,保持不动)
// · 产品空闲(无活跃主线任务)且我是超管/主管 → 直接派发新任务
// (已出库设备尤其如此:位置在工人名下也不该挡住派发)
showDispatchBanner() {
if (!this.product) return false;
if (this.product.current_location_id === 'virtual_warehouse' && !this.hasMyActiveTask) return true;
return this.isAdminUser && !this.hasActiveMainTask;
},
dispatchBannerText() {
return this.product && this.product.current_location_id === 'virtual_warehouse'
? '该产品在仓库中 — 点击此处转出并派发给指定人员'
: '当前任务已完结 — 点击此处直接派发新任务';
},
dispatchBannerIcon() {
return this.product && this.product.current_location_id === 'virtual_warehouse' ? '📤' : '🚀';
},
// 🛡️ 直接完结的【场景】门槛:角色够 + 产品确实是「已出库」。
// 为什么必须卡状态:普通生产中的设备若被直接完结,产品既无下游任务、
// 又不在仓库池中,会变成卡在工人名下的**孤儿数据**;而且售后往往要多步
// 流转(发货测试 → 维修 → 入库 → …),提前完结会把任务流彻底切断。
// 生产中的设备只能走「入库」或「转交个人」,回归正常流转。
showFinishDirect() {
return this.canFinishDirectly
&& !!this.product
&& this.product.overall_status === '已出库';
},
// 🏁 转交弹窗的三选一模式:'' = 未选 | 'user' 转交个人 | 'warehouse' 入库 | 'direct' 直接完结
// direct 分支额外校验 角色 + 场景 双门槛,防御残留选中态把 finish_directly 发出去
transferMode() { const f = this.transferForm; if (f.isFinishDirect && this.showFinishDirect) return 'direct'; if (f.isWarehouse) return 'warehouse'; return f.selectedUserId ? 'user' : ''; },
transferSubmitLabel() { return { direct: '🏁 确认直接完结', warehouse: '📦 确认入库', user: '确认转交' }[this.transferMode] || '确认转交'; },
transferPreview() { return { direct: '任务将直接完结,不创建下游任务;不改动产品状态(已出库的设备完结后依然是「已出库」)', warehouse: '产品将入库并从个人待办中移除', user: '将创建新任务指派给 ' + (this.transferUserName || '—') }[this.transferMode] || ''; },
spawnUserName() { const u = this.userOptions.find(u => u.id === this.spawnForm.assignee_id); return u ? u.name : ""; }, spawnUserName() { const u = this.userOptions.find(u => u.id === this.spawnForm.assignee_id); return u ? u.name : ""; },
userGridOptions() { return (this.userOptions || []).map(u => ({ id: u.id, name: u.name })); }, userGridOptions() { return (this.userOptions || []).map(u => ({ id: u.id, name: u.name })); },
modeToggleLabel() { if (this.currentMode === 'workspace') return '📇 流转卡片'; if (this.currentMode === 'swipe') return '🌳 流转树'; return '🛠️ 工作区'; }, modeToggleLabel() { if (this.currentMode === 'workspace') return '📇 流转卡片'; if (this.currentMode === 'swipe') return '🌳 流转树'; return '🛠️ 工作区'; },
@ -334,7 +396,13 @@ export default {
return overallOptionsFor(this.product && this.product.lifecycle_phase, this.hasHistory); return overallOptionsFor(this.product && this.product.lifecycle_phase, this.hasHistory);
}, },
availableTaskOptions() { availableTaskOptions() {
return taskOptionsFor(this.product && this.product.lifecycle_phase, this.hasHistory); // 🔧 第三个参数 overall_status:已出库设备同样要走售后词表(发货测试 / 售后维修),
// 否则出库后补做质检的工人永远选不到对应工序(鸡生蛋死锁)。
return taskOptionsFor(
this.product && this.product.lifecycle_phase,
this.hasHistory,
this.product && this.product.overall_status,
);
}, },
}, },
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); }, 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); },
@ -392,9 +460,20 @@ export default {
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = this.availableTaskOptions[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})`; } }, 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; }, // 🚀 打开「派发新任务」弹窗。标题由 isWarehouseTransfer 决定,
// 而该标志【必须在此处按产品实际位置重新推导】——
// 原先 openWarehouseTransfer 先置 true、再调用本方法被立刻重置为 false,
// 导致弹窗标题永远显示「发起首道工序」,仓库转出场景的文案从未生效。
openCreateFirstTask() {
this.isWarehouseTransfer = !!(this.product && this.product.current_location_id === 'virtual_warehouse');
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 }); } }, 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 }); } },
openWarehouseTransfer() { this.isWarehouseTransfer = true; this.openCreateFirstTask(); }, // 注:原 openWarehouseTransfer() 已移除 —— 它的 isWarehouseTransfer=true 会被
// openCreateFirstTask 立刻覆盖(死代码)。现在 banner 直接调用 openCreateFirstTask,
// 由后者按产品实际位置推导标题。
async doCreateFirstTask() { this.firstSaving = true; try { await post("/tasks/", { product_id: this.product.id, task_name: "待确认", assignee_id: this.firstForm.assignee_id, notify_parent_on_complete: false, remark: this.firstForm.note.trim() || undefined }); if (this.product.current_location_id === 'virtual_warehouse') { try { await patch(`/products/${this.product.id}`, { current_location_id: this.firstForm.assignee_id }); } catch {} } uni.showToast({ title: "任务已派发,待接收", icon: "success" }); this.createFirstVisible = false; this.doQuery(this.product.serial_number); } catch {} finally { this.firstSaving = false; } }, async doCreateFirstTask() { this.firstSaving = true; try { await post("/tasks/", { product_id: this.product.id, task_name: "待确认", assignee_id: this.firstForm.assignee_id, notify_parent_on_complete: false, remark: this.firstForm.note.trim() || undefined }); if (this.product.current_location_id === 'virtual_warehouse') { try { await patch(`/products/${this.product.id}`, { current_location_id: this.firstForm.assignee_id }); } catch {} } uni.showToast({ title: "任务已派发,待接收", icon: "success" }); this.createFirstVisible = false; this.doQuery(this.product.serial_number); } catch {} finally { this.firstSaving = false; } },
// savedCount = 打开弹窗时已落库的图片张数。列表里 [0, savedCount) 是服务端已有的, // savedCount = 打开弹窗时已落库的图片张数。列表里 [0, savedCount) 是服务端已有的,
@ -488,7 +567,7 @@ export default {
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(); } } 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.actionPopup = { visible: true, type, task }; this.rejectReason = ""; this.receiveRemark = ""; this.receiveTaskName = "";
this.rejectForm = { images: [], pendingCount: 0 }; this.isUploading = false; this.rejectForm = { images: [], pendingCount: 0 }; this.isUploading = false;
this.transferForm = { selectedUserId: "", isWarehouse: false, note: "" }; this.transferForm = { selectedUserId: "", isWarehouse: false, isFinishDirect: false, note: "" };
this.spawnForm = { assignee_id: "", remark: "" }; this.spawnForm = { assignee_id: "", remark: "" };
}, },
handleViewRecords(task) { uni.navigateTo({ url: `/pages/scan/records?taskId=${task.id}` }); }, handleViewRecords(task) { uni.navigateTo({ url: `/pages/scan/records?taskId=${task.id}` }); },
@ -598,9 +677,28 @@ export default {
// 驳回:reason 必填,images 选填(编号错误等场景允许空数组) // 驳回:reason 必填,images 选填(编号错误等场景允许空数组)
async doReject() { if (this.isUploading) return; this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/reject?operator_id=${encodeURIComponent(opId)}`, { reason: this.rejectReason.trim(), images: this.rejectForm.images }); uni.showToast({ title: "已驳回", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "驳回任务"); } finally { this.actionLoading = false; } }, async doReject() { if (this.isUploading) return; this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/reject?operator_id=${encodeURIComponent(opId)}`, { reason: this.rejectReason.trim(), images: this.rejectForm.images }); uni.showToast({ title: "已驳回", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "驳回任务"); } finally { this.actionLoading = false; } },
// 转交 — 互斥选择 // 转交 — 互斥选择
selectTransferUser(userId) { this.transferForm.selectedUserId = userId; this.transferForm.isWarehouse = false; }, selectTransferUser(userId) { this.transferForm.selectedUserId = userId; this.transferForm.isWarehouse = false; this.transferForm.isFinishDirect = false; },
toggleWarehouse() { this.transferForm.isWarehouse = !this.transferForm.isWarehouse; if (this.transferForm.isWarehouse) this.transferForm.selectedUserId = ""; }, toggleWarehouse() { this.transferForm.isWarehouse = !this.transferForm.isWarehouse; if (this.transferForm.isWarehouse) { this.transferForm.selectedUserId = ""; this.transferForm.isFinishDirect = false; } },
async doTransfer() { this.actionLoading = true; try { const assignees = this.transferForm.isWarehouse ? ["virtual_warehouse"] : [this.transferForm.selectedUserId]; const taskName = this.transferForm.isWarehouse ? "🏭 入库 (virtual_warehouse)" : "待确认"; await post(`/tasks/${this.actionPopup.task.id}/transfer`, { next_tasks: [{ task_name: taskName, assignees }], note: this.transferForm.note.trim() || undefined }); uni.showToast({ title: this.transferForm.isWarehouse ? "已入库" : "转交成功", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, this.transferForm.isWarehouse ? "入库" : "转交"); } finally { this.actionLoading = false; } }, // 🏁 直接完结:与前两者互斥。不发往仓库 → 后端落入"无下家"分支,overall_status 原样保留
toggleFinishDirect() { this.transferForm.isFinishDirect = !this.transferForm.isFinishDirect; if (this.transferForm.isFinishDirect) { this.transferForm.selectedUserId = ""; this.transferForm.isWarehouse = false; } },
async doTransfer() {
this.actionLoading = true;
const { isWarehouse, isFinishDirect } = this.transferForm;
try {
const note = this.transferForm.note.trim() || undefined;
// 🏁 直接完结:next_tasks 留空 + finish_directly=true,
// 后端据此跳过分支解析,只闭环任务、不改产品宏观状态(不会变成"待仓库收货")
const payload = isFinishDirect
? { next_tasks: [], finish_directly: true, note }
: { next_tasks: [{ task_name: isWarehouse ? "🏭 入库 (virtual_warehouse)" : "待确认", assignees: isWarehouse ? ["virtual_warehouse"] : [this.transferForm.selectedUserId] }], note };
await post(`/tasks/${this.actionPopup.task.id}/transfer`, payload);
uni.showToast({ title: isFinishDirect ? "已直接完结" : (isWarehouse ? "已入库" : "转交成功"), icon: "success" });
this.closeActionPopup();
this.doQuery(this.product.serial_number);
} catch (e) {
this.handleNetworkFailure(e, isFinishDirect ? "直接完结" : (isWarehouse ? "入库" : "转交"));
} finally { this.actionLoading = false; }
},
// 派发协助分支 // 派发协助分支
async doSpawn() { this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/spawn?operator_id=${encodeURIComponent(opId)}`, { task_name: "待确认", assignee_id: this.spawnForm.assignee_id, remark: this.spawnForm.remark.trim() || undefined }); uni.showToast({ title: "协助分支已派发", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "派发协助分支"); } finally { this.actionLoading = false; } }, async doSpawn() { this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/spawn?operator_id=${encodeURIComponent(opId)}`, { task_name: "待确认", assignee_id: this.spawnForm.assignee_id, remark: this.spawnForm.remark.trim() || undefined }); uni.showToast({ title: "协助分支已派发", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "派发协助分支"); } finally { this.actionLoading = false; } },
// 💬 留言板 // 💬 留言板

View File

@ -20,6 +20,16 @@ export const LIFECYCLE_PHASE = {
export const STEP_SHIP_TEST = "发货测试"; export const STEP_SHIP_TEST = "发货测试";
export const STEP_AFTER_SALES_REPAIR = "售后维修"; export const STEP_AFTER_SALES_REPAIR = "售后维修";
/**
* 绝对物理终态之一:设备已发货出库。
*
* 🔧 出库设备也要按售后环节给选项:设备出库后仍可能被叫回来补做
* 「发货测试 / 售后维修」,但后端已不再因「正常已出库做质检」而把
* lifecycle_phase 翻成 AFTER_SALES(避免不可回退的售后烙印)。
* 若选项渲染仍只认 phase,工人就永远选不到那两个工序 —— 鸡生蛋死锁。
*/
export const OUTBOUND_OVERALL = "已出库";
const AFTER_SALES_ONLY_STEPS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR]; const AFTER_SALES_ONLY_STEPS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR];
// ── 生产制造阶段合法工序(原有词表,一字未改)── // ── 生产制造阶段合法工序(原有词表,一字未改)──
@ -40,9 +50,22 @@ export const AFTER_SALES_OVERALL_OPTIONS = [
* hasHistory = false(无任何历史任务的首次激活 / 老设备)时返回**并集**: * hasHistory = false(无任何历史任务的首次激活 / 老设备)时返回**并集**:
* 这类设备既可能是新投产,也可能是直接返厂售后的老设备,让操作员自己声明。 * 这类设备既可能是新投产,也可能是直接返厂售后的老设备,让操作员自己声明。
* 一旦选定售后专属工序,后端即把设备判为 AFTER_SALES,之后下拉自动收窄。 * 一旦选定售后专属工序,后端即把设备判为 AFTER_SALES,之后下拉自动收窄。
*
* @param phase lifecycle_phase
* @param hasHistory 是否有历史任务
* @param overallStatus 产品当前宏观状态 — 【已出库】同样走售后词表,见下方说明
*/ */
export function taskOptionsFor(phase, hasHistory = true) { export function taskOptionsFor(phase, hasHistory = true, overallStatus = null) {
if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_TASK_OPTIONS; // 🔧 出库设备与售后生命周期同等待遇(2026-09-17 修复「鸡生蛋」死锁):
// 后端为消除不可回退的售后烙印,已不再把「正常已出库做质检」的设备
// 翻成 AFTER_SALES。若这里仍只认 phase,工人就永远选不到
// 「发货测试 / 售后维修」—— 而选不到就无法进入售后,phase 也就永远不变。
if (
phase === LIFECYCLE_PHASE.AFTER_SALES ||
String(overallStatus || "").trim() === OUTBOUND_OVERALL
) {
return AFTER_SALES_TASK_OPTIONS;
}
if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS]; if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
return PRODUCTION_TASK_OPTIONS; return PRODUCTION_TASK_OPTIONS;
} }