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