Files
KCGL/inventory-backend/app/api/v1/internal.py
yueli c7f85880a9 feat(scrap): 新增对内接口,让 Track 能提交生产报废
料一经出库领用,那条库存行的可用量就扣掉了,走不了标准库存行报废。
本接口内部做两件事:① 走逆向物流「从出库单退回(不良品)」→ 在管不良品;
② 对这笔在管量提交报废申请。两者在**同一个事务**里,要么都成要么都不成。

- 鉴权用 X-API-Key(config.MOM_INTERNAL_API_KEY),与 TRACK_WEBHOOK_KEY
  刻意分离:方向相反、权限不同,独立轮换不连坐。未配置一律 503(Fail-Closed),
  不静默放行 —— 一个默认开着的写接口比没配好的更危险。
- 刻意收紧:is_defective 恒为 true、need_reissue 恒为 false,都不由请求体
  控制。良品分支会往库存行加数量,一个泄漏的密钥就能凭空造库存。
- track_ref 必填:Redis 未部署,唯一索引是唯一的并发防线。
- 退回逻辑从 inbound/stock.py 抽到 services/return_service.py:内部接口没有
  JWT,而视图里夹着 get_current_company_filter/_normalize_user_id,不抽没法复用。
  API 层保留薄包装,restock 等既有调用方一行不用改。
2026-09-23 15:17:44 +08:00

361 lines
18 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 → MOM)— 共享密钥鉴权,不走 JWT
═══════════════════════════════════════════════════════════════════════════
为什么需要这个模块
═══════════════════════════════════════════════════════════════════════════
生产领用的料已经出库到产线,之后在生产中报废 —— 要把它提交进 MOM,复用 MOM
现有的报废流程(申请 → 审批 → 执行),并标记「生产导致」。
但料已出库,那条库存行的可用量在出库时就扣掉了,走不了标准库存行报废。
MOM 自己的答案是逆向物流的「从出库单退回(不良品)」:坏件转进
`trans_defective_goods` 在管台账,原库存表分毫不动。所以本接口内部做两件事:
① 退回(is_defective=true)→ 在管不良品
② 对这笔在管量提交报废申请(待审批,角色级审批人)
两件事在**同一个事务**里,要么都成要么都不成 —— 见 return_service 的编排函数。
═══════════════════════════════════════════════════════════════════════════
鉴权
═══════════════════════════════════════════════════════════════════════════
MOM 此前**没有任何在用的非 JWT 接口**(ai_proxy 那个 DIFY_INTERNAL_SECRET 是
从未 register_blueprint 的死代码,且密钥硬编码 —— 反面教材,不要照抄)。
本模块新建共享密钥校验:
· 密钥走 config.MOM_INTERNAL_API_KEY(环境变量),**不硬编码**;
· 请求头 `X-API-Key`,与 Track 侧接收端同一字段名(track_query_service 注释里
明确要求「鉴权字段名必须为 X-API-Key,与 Track 接收端严格对齐」);
· 未配置密钥 → 503(Fail-Closed),不是放行;
· 用 hmac.compare_digest 做常量时间比较,避免时序侧信道。
═══════════════════════════════════════════════════════════════════════════
刻意收紧的地方(实现时不要「顺手放开」)
═══════════════════════════════════════════════════════════════════════════
· `is_defective` **恒为 True**,请求体不暴露该字段 —— 良品分支会往库存行加数量,
一个泄漏的密钥就能凭空造库存。内部接口只开放不良品分支,这是爆炸半径控制。
· `need_reissue` **恒为 False**,请求体不暴露 —— 补发会生成出库单,
与「报废」无关,不该由外部系统触发。
· `track_ref` 必填 —— prevent_double_submit 依赖 Redis,而 compose 里没有 redis
服务(redis_client 恒为 None),该装饰器**全程 fail-open**。唯一索引是唯一的
并发防线,没有稳定的外部单据号就无从判重。
· 只注册 `/api/v1/internal` 一条路径,不做 legacy 双注册。
"""
import hmac
import logging
import traceback
from functools import wraps
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy.exc import IntegrityError
from app.extensions import db
from app.models.system import SysUser
from app.models.transaction import TransReturn
from app.services import return_service
logger = logging.getLogger(__name__)
internal_bp = Blueprint('internal', __name__)
# =============================================================================
# 鉴权
# =============================================================================
def require_internal_key(fn):
"""共享密钥校验。Fail-Closed:未配置密钥 → 503,不是放行。"""
@wraps(fn)
def decorator(*args, **kwargs):
configured = (current_app.config.get('MOM_INTERNAL_API_KEY') or '').strip()
if not configured:
logger.warning('[Internal] 请求被拒:服务端未配置 MOM_INTERNAL_API_KEY')
return jsonify({
'code': 503,
'msg': '内部接口未启用:服务端未配置访问密钥',
}), 503
provided = (request.headers.get('X-API-Key') or '').strip()
if not provided:
return jsonify({'code': 401, 'msg': '缺少 X-API-Key 请求头'}), 401
# ★ 必须先 encode:compare_digest 传 str 且含非 ASCII 会抛 TypeError
if not hmac.compare_digest(provided.encode('utf-8'), configured.encode('utf-8')):
logger.warning('[Internal] 请求被拒:X-API-Key 不匹配')
return jsonify({'code': 403, 'msg': 'X-API-Key 无效'}), 403
return fn(*args, **kwargs)
return decorator
# =============================================================================
# 入参解析
# =============================================================================
def _parse_bool(raw, field):
"""严格布尔解析:只认真正的 bool。缺省(None)报错,字符串一律报错。
不做 'true'/'1'/'yes' 之类的宽松转换 —— 该字段决定「要不要提交报废申请」,
解析歧义会导致静默的半截行为(只退回、不提申请)。
"""
if isinstance(raw, bool):
return raw
if raw is None:
raise ValueError(
f'{field} 为必填(true=退回并提交报废申请;false=仅登记为在管不良品)'
)
raise ValueError(f'{field} 必须为布尔值 true / false')
def _parse_int(raw, field, required=True, max_len=None):
if raw is None or (isinstance(raw, str) and not raw.strip()):
if required:
raise ValueError(f'{field} 为必填')
return None
try:
value = int(raw)
except (TypeError, ValueError):
raise ValueError(f'{field} 无效')
if value <= 0:
raise ValueError(f'{field} 必须为正整数')
return value
def _parse_text(raw, field, max_len, required=True):
text = str(raw or '').strip()
if not text:
if required:
raise ValueError(f'{field} 为必填({max_len} 字符以内)')
return None
if len(text) > max_len:
raise ValueError(f'{field} 超长(最多 {max_len} 字符)')
return text
def _resolve_applicant(data):
"""解析申请人 → (applicant_id, company_name)。
★ 为什么**必须**由调用方提供,不能从出库行推导:
trans_outbound.applicant_id 大部分为 NULL(实测 1364 行中 1127 行为 NULL,
PRODUCTION 类型 457/656)—— 该列是后来才加的,存量历史无从回填。
回退到「原出库申请人」在八成场景下会失败。
优先 applicant_id;其次 applicant_account(sys_user.username 里 '/' 后的账号)。
"""
applicant_id = data.get('applicant_id')
account = (data.get('applicant_account') or '').strip()
if applicant_id is not None and str(applicant_id).strip() != '':
try:
applicant_id = int(applicant_id)
except (TypeError, ValueError):
raise ValueError('applicant_id 无效')
user = SysUser.query.get(applicant_id)
if not user:
raise ValueError(f'申请人不存在(ID: {applicant_id})')
return user.id, (user.department or '').strip() or None
if not account:
raise ValueError('applicant_id 与 applicant_account 至少提供一个')
# username 约定为「真实姓名/登录账号」,账号是 '/' 之后那段
users = SysUser.query.filter(SysUser.username.like(f'%/{account}')).all()
if not users:
raise ValueError(f'申请人账号不存在:{account}')
if len(users) > 1:
# Fail-Closed:同名账号跨公司时不能猜
raise ValueError(f'申请人账号 {account} 在多公司重复,请改用 applicant_id 指定')
return users[0].id, (users[0].department or '').strip() or None
# =============================================================================
# 生产报废受理
# =============================================================================
@internal_bp.route('/production-scrap', methods=['POST'])
@require_internal_key
def create_production_scrap():
"""受理生产报废:退回(不良品) + 提交报废申请(待审批)。
Body(JSON):
{
"company_name": "IRIS", # 必填,调用方所属公司/实例
"outbound_id": 1364, # 必填,trans_outbound.id(出库明细行)
"return_qty": 2, # 必填,>0
"submit_scrap": true, # 必填,false=仅登记为在管不良品
"track_ref": "WO-2026-001234", # 必填,Track 侧唯一单据号(幂等锚点)
"scrap_qty": 2, # 可选,默认 = return_qty
"reason_category": "PRODUCTION", # 可选,默认 PRODUCTION
"reason": "生产装配时压坏", # 可选
"applicant_id": 8, # 与 applicant_account 至少一个
"applicant_account": "zhangsan01", # 同上
"operator": "Track系统" # 可选,写入台账的操作人名
}
"""
data = request.get_json(silent=True) or {}
try:
company_name = _parse_text(data.get('company_name'), 'company_name', 255)
outbound_id = _parse_int(data.get('outbound_id'), 'outbound_id')
track_ref = _parse_text(data.get('track_ref'), 'track_ref', 100)
submit_scrap = _parse_bool(data.get('submit_scrap'), 'submit_scrap')
reason = _parse_text(data.get('reason'), 'reason', 500, required=False)
operator_name = _parse_text(data.get('operator'), 'operator', 100,
required=False) or 'Track系统'
reason_category = _parse_text(data.get('reason_category'), 'reason_category',
50, required=False) or 'PRODUCTION'
# 幂等锚点带公司前缀:IRIS 与 LICA 各自独立跑一套 Track,工单号可能重号,
# 裸用 track_ref 做唯一索引会让两家互相挡住对方的首次受理。
source_ref = f'{company_name}:{track_ref}'
if not submit_scrap and data.get('scrap_qty') not in (None, ''):
raise ValueError('submit_scrap=false 时不应传 scrap_qty(本次不生成报废申请)')
# ---- 入参数量 ----
try:
return_qty = float(data.get('return_qty') or 0)
except (TypeError, ValueError):
raise ValueError('return_qty 无效')
if return_qty <= 0:
raise ValueError('return_qty 为必填且必须大于 0')
scrap_qty = None
if submit_scrap:
raw_qty = data.get('scrap_qty')
if raw_qty is None or raw_qty == '':
scrap_qty = return_qty
else:
try:
scrap_qty = float(raw_qty)
except (TypeError, ValueError):
raise ValueError('scrap_qty 无效')
if scrap_qty <= 0:
raise ValueError('scrap_qty 必须大于 0')
if scrap_qty > return_qty:
raise ValueError(
f'scrap_qty({scrap_qty})不能大于本次退回数量({return_qty})'
)
applicant_id, applicant_company = _resolve_applicant(data)
# 申请人公司必须与调用方声明的公司一致 —— 否则可以借别人公司的身份提交
if applicant_company and applicant_company != company_name:
raise ValueError(
f'申请人不属于 {company_name}(实际 {applicant_company}),禁止跨公司提交'
)
# ---- 幂等:先按 source_ref 查重 ----
existing = TransReturn.query.filter_by(source_ref=source_ref).first()
if existing is not None:
return _duplicate_response(existing, data, company_name, track_ref,
return_qty, source_ref, submit_scrap)
try:
ret, scrap = return_service.return_and_submit_production_scrap(
outbound_id=outbound_id,
return_qty=return_qty,
applicant_id=applicant_id,
operator_name=operator_name,
# 公司隔离:内部接口没有 JWT,显式传公司限制。
# 但业务上要允许「本实例处理本公司物料」,故直接用 company_name。
company_limit=company_name,
reason=reason,
reason_category=reason_category,
source_ref=source_ref,
submit_scrap=submit_scrap,
scrap_qty=scrap_qty,
)
except IntegrityError:
# 并发穿透了上面的预检 —— 唯一索引兜底
db.session.rollback()
existing = TransReturn.query.filter_by(source_ref=source_ref).first()
if existing is not None:
return _duplicate_response(existing, data, company_name, track_ref,
return_qty, source_ref, submit_scrap)
raise
# ⚠️ 跨公司校验**不在这里做**:company_limit=company_name 已经传进
# return_service,由 assert_company_owns 在**写之前**拦下
# (文案:「无权操作其他公司的库存」)。放到这里就成了「先提交再检查」——
# 事务已经 commit,rollback 撤不回来,等于没检查。
return jsonify({
'code': 200,
'msg': ('生产报废已受理:已退回登记为在管不良品,并提交报废申请(待审批)'
if submit_scrap else
'已受理:料已退回登记为在管不良品(本次未提交报废申请)'),
'data': _build_data(ret, scrap, company_name, track_ref, source_ref),
}), 200
except PermissionError as e:
# ★ 必须显式捕获:PermissionError 是 OSError 的子类,不是 ValueError,
# 而 app/__init__.py 的全局 errorhandler(Exception) 会把未捕获异常
# 变成一条没有信息的 500,把 403 吞掉。
db.session.rollback()
return jsonify({'code': 403, 'msg': str(e)}), 403
except ValueError as e:
db.session.rollback()
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
db.session.rollback()
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'生产报废受理失败: {str(e)}'}), 500
def _build_data(ret, scrap, company_name, track_ref, source_ref, duplicate=False):
"""响应体。submit_scrap=false 时 scrap 为 {'submitted': false, ...}。"""
return {
'duplicate': duplicate,
'company_name': company_name,
'track_ref': track_ref,
'source_ref': source_ref,
'outbound_id': ret.get('outbound_id'),
'outbound_no': ret.get('outbound_no') or '',
'return_id': ret.get('return_id'),
'defective_goods_id': ret.get('defective_goods_id'),
'return_qty': ret.get('return_qty'),
'returned_quantity': ret.get('returned_quantity'),
'returnable_quantity': ret.get('returnable_quantity'),
'scrap': ({
'submitted': True,
'request_id': scrap.id,
'request_no': scrap.request_no,
'status': scrap.status,
'status_text': '待审批',
'reason_category': scrap.reason_category or '',
'reason_category_label': scrap.to_dict().get('reason_category_label', ''),
'allowed_approvers': scrap.get_allowed_approvers(),
} if scrap is not None else {'submitted': False}),
}
def _duplicate_response(existing, data, company_name, track_ref, return_qty,
source_ref, submit_scrap):
"""重复受理:回显首次结果,不写任何数据。
★ 已知语义边界(明确告知,不掩盖):若首次 submit_scrap=false,
之后用同一 track_ref 重试 submit_scrap=true,会走这里短路、
**不会补提报废申请**。要报废需在 MOM 不良品台账另发申请。
"""
# 请求内容与原单不一致 → 409,避免把它当成「幂等重试」而掩盖真实冲突
if abs(float(existing.return_qty or 0) - float(return_qty)) > 1e-9:
return jsonify({
'code': 409,
'msg': (f'track_ref 已受理但请求内容不一致'
f'(原 outbound_id={existing.outbound_id}, '
f'原 return_qty={float(existing.return_qty or 0)})'),
}), 409
from app.models.transaction import TransDefectiveGoods
goods = TransDefectiveGoods.query.filter_by(return_id=existing.id).first()
ret = {
'outbound_id': existing.outbound_id,
'outbound_no': '',
'return_id': existing.id,
'defective_goods_id': goods.id if goods else None,
'return_qty': float(existing.return_qty or 0),
'returned_quantity': None,
'returnable_quantity': None,
}
return jsonify({
'code': 200,
'msg': '该 track_ref 已受理,本次为重复请求,未重复受理',
'data': _build_data(ret, None, company_name, track_ref, source_ref,
duplicate=True),
}), 200