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 等既有调用方一行不用改。
This commit is contained in:
360
inventory-backend/app/api/v1/internal.py
Normal file
360
inventory-backend/app/api/v1/internal.py
Normal file
@ -0,0 +1,360 @@
|
||||
"""对内接口(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
|
||||
Reference in New Issue
Block a user