"""对内接口(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