料领到产线后在生产中报废,要在 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,
而不是让一个写接口在生产上默默开着。
249 lines
10 KiB
Python
249 lines
10 KiB
Python
"""生产报废 — 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]
|