Compare commits
8 Commits
6bc6531e6a
...
1.0应用
| Author | SHA1 | Date | |
|---|---|---|---|
| d729a19b86 | |||
| 42c883fe7d | |||
| ee25b8fd23 | |||
| d666bbd4ed | |||
| e707f3673d | |||
| 1e6c006360 | |||
| 2539bede3c | |||
| dcc92b223e |
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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 "—"
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
)
|
||||||
# 🚀 智能父节点继承算法
|
# 🚀 智能父节点继承算法
|
||||||
# 主线任务转交 → 保持平级继承(主分支永远在一维主干上)
|
# 主线任务转交 → 保持平级继承(主分支永远在一维主干上)
|
||||||
# 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝)
|
# 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝)
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 || "";
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
// ─── 状态中文映射 ─────────────────────────────────────────
|
// ─── 状态中文映射 ─────────────────────────────────────────
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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; } },
|
||||||
// 💬 留言板
|
// 💬 留言板
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user