Files
track/backend/app/services/product_scrap_service.py
duxingchen 551819e0e3 feat(scrap): Track 侧生产报废 —— 提交、回查、金额
料领到产线后在生产中报废,要在 MOM 里走报废流程并能统计金额。

- mom_scrap_client:Track **唯一**一处主动写 MOM 的通道。读仍走直连只读库
  (MOM 查询接口有权限与行级隔离),写必须走接口(跨库直写会绕过 MOM 的
  全部业务校验、权限与审批)。
- product_scrap_service:归属校验是关键 —— 可见范围是整台设备、不是「谁领的」,
  不能靠隐藏来防,必须在写入前确认这条 mom_line_id 就挂在这台设备上。
  申请人直接用当前登录人(Track 的 sub 就是 MOM sys_user.id),
  MOM 里显示的就是本人,不需要服务账号也不会串人。
- 幂等:track_ref 由前端在打开弹层时生成一次、重试复用;网络超时后重试
  不该在 MOM 里多报一张单。
- 状态与金额**实时回查 MOM**,不在本地存副本:报废没有回调,本地那份立刻
  就过期;且金额取决于执行时的实际扫码量(MOM 允许少扫),受理量 ≠ 执行量。
  ⚠️ 未执行时 total_loss 是 null 不是 0 —— 0 会让人以为「这东西不值钱」。
- MOM_INTERNAL_API_KEY 走环境变量且不给默认值:未配置时报废提交 503,
  而不是让一个写接口在生产上默默开着。
2026-09-23 15:18:06 +08:00

249 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""生产报废 — Track 侧业务逻辑
用户在产品详情页看到这台设备领用的料对某一条发起报废Track 转调 MOM 的
内部接口完成「退回(不良品) → 在管不良品 → 提交报废申请」,再把回执存下来。
═══ 授权模型(刻意的,不是漏掉的)═══
**可见范围跟设备走,责任归属跟实际发生走。**
料是领给这台设备的,不是领给某个人的。一台设备会经历多个任务、多个人的手
(生产领料 → 装配 → 测试)。测试时摔坏的外壳是生产的人领的、挂在生产任务下 ——
如果只允许「原领用人」报废,测试得回头找生产的人来提单,而生产的人压根不知道
这事,流程上讲不通。
所以:**任何能看到这台设备的人,都能报它上面任何一条料**。
跨设备的防护不靠隐藏,靠 `_load_mounted_material` 的归属校验 ——
`mom_line_id` 必须确实挂在这台设备上,报不了别的设备的料。
滥报由 MOM 侧的主管审批兜底(谁报的、报了谁的料,审批页全看得到)。
前端对「报别人的料」加一道确认(判据是 consumer_name ≠ 当前用户),
那是**防误操作的提示**,不是权限 —— 后端不会因为这条拒绝。
"""
import logging
import uuid
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.product import Product
from app.models.product_scrap import ProductScrap
from app.models.product_outbound_material import ProductOutboundMaterial
from app.schemas.product import ProductScrapResponse
logger = logging.getLogger(__name__)
# 生产报废恒用这个分类码(与 MOM 侧 scrap_approval.SCRAP_CATEGORY_LABELS 对齐)。
# ★ 必须显式传、不能由 MOM 从来源推导:生产报废与 MOM 手工报的不良品退回共用
# 同一张 trans_defective_goods 表,一推导就会把生产损失静默算成库存损失。
SCRAP_CATEGORY_PRODUCTION = "PRODUCTION"
def _source_ref(track_ref: str) -> str:
"""幂等锚点:`<公司>:<Track单据号>`,与发给 MOM 的值同一口径。
带公司前缀是因为 IRIS 与 LICA 各自独立跑一套 Track工单号可能重号。
"""
return f"{settings.ORG_DEPARTMENT}:{track_ref.strip()}"
async def _load_mounted_material(
db: AsyncSession, product_id: uuid.UUID, mom_line_id: int,
) -> ProductOutboundMaterial:
"""取出该设备上挂载的这条出库明细,顺带完成**归属校验**。
这是跨设备乱报的唯一防线:可见范围是整台设备,不能靠「查不到」来防,
必须显式确认这条 `mom_line_id` 就挂在这台设备上。
⚠️ 不校验的话,前端随便改个数就能报废任意一台设备的料。
"""
row = (
await db.execute(
select(ProductOutboundMaterial)
.where(
ProductOutboundMaterial.product_id == product_id,
ProductOutboundMaterial.mom_line_id == mom_line_id,
)
.limit(1)
)
).scalars().first()
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="这条出库物料没有挂在这台设备上,无法报废",
)
return row
async def list_product_scraps(
db: AsyncSession, product_id: uuid.UUID,
) -> list[ProductScrapResponse]:
"""列出该产品的生产报废记录(按提交时间倒序),并**实时回查 MOM** 补状态与金额。"""
rows = (
await db.execute(
select(ProductScrap)
.where(ProductScrap.product_id == product_id)
.order_by(ProductScrap.created_at.desc())
)
).scalars().all()
return await _enrich_with_mom(rows)
async def _enrich_with_mom(rows: list[ProductScrap]) -> list[ProductScrapResponse]:
"""把 MOM 的实时状态/金额贴到本地记录上。
★ 回查失败**不能让整个列表挂掉**MOM 短暂不可用时,用户至少要能看到
「我报过什么」,只是状态暂时显示不出来。所以这里 catch 住、降级成本地快照。
"""
from fastapi.concurrency import run_in_threadpool
from app.services import mom_scrap_service
result: list[ProductScrapResponse] = []
live: dict[str, dict] = {}
if rows:
try:
live = await run_in_threadpool(
mom_scrap_service.fetch_scrap_status,
[r.scrap_request_no for r in rows],
)
except Exception as e:
# 降级:用本地快照,并在日志里留痕(静默降级会让人以为 MOM 没执行)
logger.warning(f"[ProductScrap] 回查 MOM 状态失败,降级用本地快照: {e}")
for r in rows:
item = ProductScrapResponse.model_validate(r)
info = live.get(r.scrap_request_no)
if info:
item.mom_status = info.get('status', r.mom_status)
item.mom_status_label = info.get('status_label') or ''
item.mom_approved_at = info.get('approved_at')
item.mom_executor_name = info.get('executor_name') or ''
item.mom_executed = bool(info.get('executed'))
item.total_loss = info.get('total_loss')
item.scrapped_quantity = info.get('scrapped_quantity')
else:
# MOM 里查不到这张单(被清理 / 回查失败降级)→ 用本地快照,
# 但**不伪造金额**total_loss 保持 None前端显示「—」而不是 0
item.mom_status = r.mom_status
item.mom_status_label = mom_scrap_service.describe_status(r.mom_status)
item.mom_executed = False
result.append(item)
return result
async def submit_product_scrap(
db: AsyncSession, product_id: uuid.UUID, *, mom_line_id: int, quantity: float,
track_ref: str, reason: str | None, current_user: dict,
) -> ProductScrapResponse:
"""提交一条生产报废。
幂等:同一个 `track_ref` 重发**不会**产生第二条 MOM 报废单,
命中已有记录直接返回(网络超时后重试是常态,用户不该为这付两次代价)。
"""
track_ref = (track_ref or '').strip()
if not track_ref:
raise HTTPException(status_code=400, detail="track_ref 为必填(幂等锚点)")
if not quantity or float(quantity) <= 0:
raise HTTPException(status_code=400, detail="报废数量必须大于 0")
product = (
await db.execute(select(Product).where(Product.id == product_id))
).scalars().first()
if product is None:
raise HTTPException(status_code=404, detail="产品不存在")
source_ref = _source_ref(track_ref)
# ---- 1. 幂等:这个单据号已经受理过 → 直接回已有的那条 ----
existing = (
await db.execute(
select(ProductScrap).where(ProductScrap.source_ref == source_ref)
)
).scalars().first()
if existing is not None:
logger.info(f"[ProductScrap] track_ref 重复提交,返回已有记录 {source_ref}")
return (await _enrich_with_mom([existing]))[0]
# ---- 2. 归属校验 + 取快照(快照只信后端自己查到的,不信前端传的) ----
material = await _load_mounted_material(db, product_id, int(mom_line_id))
# ---- 3. 申请人当前登录人。Track 的 sub 就是 MOM sys_user.id
# 所以 MOM 里显示的申请人就是本人,不需要服务账号、也不会串人 ----
try:
applicant_id = int(current_user.get("sub"))
except (TypeError, ValueError):
raise HTTPException(status_code=401, detail="登录状态异常,请重新登录")
operator = current_user.get("display_name") or current_user.get("username") or "Track系统"
# ---- 4. 调 MOM唯一的写通道失败直接抛不静默吞 ----
from app.services.mom_scrap_client import MomScrapError, submit_production_scrap
try:
data = await submit_production_scrap(
outbound_id=int(mom_line_id),
return_qty=float(quantity),
track_ref=track_ref,
applicant_id=applicant_id,
reason=reason,
operator=operator,
)
except MomScrapError as e:
# MOM 的文案已经是中文且具体,直接转给用户
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=e.message)
scrap_info = data.get('scrap') or {}
request_no = scrap_info.get('request_no')
if not request_no:
# MOM 回 200 却没给单号 = 契约被破坏,必须炸出来而不是存一条空记录
logger.error(f"[ProductScrap] MOM 返回缺少 scrap.request_no: {data}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="MOM 已受理但未返回报废单号,请到 MOM 报废审批页确认",
)
# ---- 5. 落库 ----
row = ProductScrap(
product_id=product_id,
serial_number=product.serial_number,
task_id=material.task_id,
mom_line_id=int(mom_line_id),
# 快照取自 Track 已挂的出库物料(当初由后端查 MOM 写入),不是前端传的
outbound_no=material.outbound_no,
material_name=material.material_name,
spec_model=material.spec_model,
sku=material.sku,
consumer_name=material.consumer_name,
quantity=quantity,
reason_category=SCRAP_CATEGORY_PRODUCTION,
reason=(reason or '').strip() or None,
scrap_request_no=request_no,
defective_goods_id=data.get('defective_goods_id'),
mom_status=int(scrap_info.get('status') or 0),
source_ref=source_ref,
submitted_by=current_user.get("username"),
)
db.add(row)
try:
await db.commit()
except IntegrityError:
# 并发穿透了第 1 步的预检 —— 唯一约束兜底,回滚后返回已有那条
await db.rollback()
existing = (
await db.execute(
select(ProductScrap).where(ProductScrap.source_ref == source_ref)
)
).scalars().first()
if existing is not None:
return (await _enrich_with_mom([existing]))[0]
raise
await db.refresh(row)
logger.info(
f"[ProductScrap] 提交成功 {request_no} product={product.serial_number} "
f"line={mom_line_id} qty={quantity} by={row.submitted_by}"
)
return (await _enrich_with_mom([row]))[0]