Files
track/backend/app/services/mom_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

160 lines
6.1 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.

"""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