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,
  而不是让一个写接口在生产上默默开着。
This commit is contained in:
2026-09-23 15:18:06 +08:00
parent e45c97bd1f
commit 551819e0e3
7 changed files with 839 additions and 0 deletions

View File

@ -0,0 +1,159 @@
"""MOM 报废单只读查询 — 直连 MOM 库(跨库,无 ORM)
Track 侧只存**回执号**(`product_scraps.scrap_request_no`),状态与金额一律实时
回查 MOM。为什么不落一份到 Track:
· 状态会变。MOM 里审批、执行之后 Track 不会收到通知(报废没有回调),
本地存的那份立刻就过期,而「到底批没批、执行没执行」正是用户要看的。
· 金额**取决于执行时的实际扫码量**。MOM 允许少扫(合法子集),
所以「受理量」≠「执行量」,金额必须在 MOM 执行那一刻才算得准。
本地另算一份就是第二份口径,迟早对不上。
写法照 mom_outbound_service(同步 psycopg2 + text() + MomSessionLocal),
调用方一律 `run_in_threadpool` 包出去,别阻塞事件循环。
"""
import logging
from sqlalchemy import text
from app.core.mom_database import MomSessionLocal
logger = logging.getLogger(__name__)
# MOM scrap_approval.status → 中文。与 MOM 的 scrap_approval_service 逐字对齐,
# 不要自创说法(用户在 MOM 界面看到的和 Track 上看的不一致会让人怀疑数据错了)。
MOM_SCRAP_STATUS_LABELS = {
0: '待审批',
1: '已通过(待执行)',
2: '已驳回',
3: '已执行',
4: '已撤回',
}
def describe_status(code) -> str:
"""状态码 → 中文。未知/空值返回空串(前端兜底显示 '-')。"""
if code is None:
return ''
try:
return MOM_SCRAP_STATUS_LABELS.get(int(code), '')
except (TypeError, ValueError):
return ''
def fetch_scrap_status(request_nos: list[str]) -> dict[str, dict]:
"""按报废申请单号批量查 MOM 的审批状态与执行金额。
返回 `{request_no: {...}}`;查不到的**不出现在结果里**(调用方按「缺失 = MOM
侧还没有/已清理」处理,不要伪造成一个空状态)。
金额字段说明(都是 Decimal → float):
· `cost_at_scrap` 报废成本(单价)
· `total_loss` **报废损失 = 单价 × 实报废数量**,这是「统计生产报废金额」要的数
· `scrapped_quantity` 实际报废数量(执行时扫码量,可能小于受理量)
⚠️ 单号可能一张都没执行(还在待审批),此时 trans_scrap 里**毫无痕迹**,
金额是 0 而不是「缺失」—— 用 `executed` 标志区分这两种情况,
别让前端把「还没批」显示成「损失 0 元」。
"""
nos = [str(n).strip() for n in (request_nos or []) if str(n or '').strip()]
if not nos:
return {}
db = MomSessionLocal()
try:
# ---- 1. 申请单头:状态 / 审批人 / 执行人 ----
head_rows = db.execute(
text("""
SELECT request_no, status, applicant_id,
actual_approver_id, approved_at,
executor_name, executed_at, reject_reason,
remark, reason_category
FROM scrap_approval
WHERE request_no = ANY(:nos)
"""),
{"nos": nos},
).fetchall()
out: dict[str, dict] = {}
for r in head_rows:
status = r.status
out[r.request_no] = {
'request_no': r.request_no,
'status': status,
'status_label': describe_status(status),
'applicant_id': r.applicant_id,
'approver_name': _user_name(db, r.actual_approver_id),
'approved_at': _iso(r.approved_at),
'executor_name': r.executor_name or '',
'executed_at': _iso(r.executed_at),
'reject_reason': r.reject_reason or '',
'reason_category': r.reason_category or '',
# 只有 status==3 才真的执行过、台账里才有金额
'executed': status == 3,
'cost_at_scrap': None,
'total_loss': None,
'scrapped_quantity': None,
}
# ---- 2. 报废流水:实际报废量与损失金额(只有执行过才有行) ----
if out:
ledger_rows = db.execute(
text("""
SELECT scrap_request_no,
count(*) AS line_count,
sum(quantity) AS scrapped_quantity,
max(cost_at_scrap) AS cost_at_scrap,
sum(total_loss) AS total_loss
FROM trans_scrap
WHERE scrap_request_no = ANY(:nos)
GROUP BY scrap_request_no
"""),
{"nos": list(out.keys())},
).fetchall()
for r in ledger_rows:
item = out.get(r.scrap_request_no)
if item is None:
continue
item['scrapped_quantity'] = _f(r.scrapped_quantity)
item['cost_at_scrap'] = _f(r.cost_at_scrap)
item['total_loss'] = _f(r.total_loss)
return out
finally:
db.close()
def _f(v):
"""Decimal/None → float/None(JSON 友好)"""
return float(v) if v is not None else None
def _iso(dt):
"""MOM 的时间列是 naive 北京时间,补 +08:00 再交给上层。
⚠️ 少这一步,前端会按**本地时区**解释这个 naive 串,非北京时区的人看到的
时间就是错的。与 mom_outbound_service._as_beijing 同一处理。
"""
if dt is None:
return None
from datetime import timedelta, timezone
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone(timedelta(hours=8)))
return dt.isoformat()
def _user_name(db, user_id):
"""MOM sys_user.id → 中文姓名('张三/zhangsan01' 取 '/' 前那段)。"""
if not user_id:
return ''
try:
row = db.execute(
text("SELECT username FROM sys_user WHERE id = :uid"),
{"uid": int(user_id)},
).fetchone()
except Exception:
return ''
if not row or not row.username:
return ''
return row.username.split('/')[0] if '/' in row.username else row.username