From c7f85880a9a38db174d92580e4bc553532581e16 Mon Sep 17 00:00:00 2001 From: yueli Date: Wed, 23 Sep 2026 15:17:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(scrap):=20=E6=96=B0=E5=A2=9E=E5=AF=B9?= =?UTF-8?q?=E5=86=85=E6=8E=A5=E5=8F=A3=EF=BC=8C=E8=AE=A9=20Track=20?= =?UTF-8?q?=E8=83=BD=E6=8F=90=E4=BA=A4=E7=94=9F=E4=BA=A7=E6=8A=A5=E5=BA=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 料一经出库领用,那条库存行的可用量就扣掉了,走不了标准库存行报废。 本接口内部做两件事:① 走逆向物流「从出库单退回(不良品)」→ 在管不良品; ② 对这笔在管量提交报废申请。两者在**同一个事务**里,要么都成要么都不成。 - 鉴权用 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 等既有调用方一行不用改。 --- docker-compose.yml | 4 + inventory-backend/app/__init__.py | 14 + inventory-backend/app/api/v1/inbound/stock.py | 288 ++---------- inventory-backend/app/api/v1/internal.py | 360 +++++++++++++++ .../app/services/return_service.py | 422 ++++++++++++++++++ inventory-backend/config.py | 14 + 6 files changed, 856 insertions(+), 246 deletions(-) create mode 100644 inventory-backend/app/api/v1/internal.py create mode 100644 inventory-backend/app/services/return_service.py diff --git a/docker-compose.yml b/docker-compose.yml index c981bbe..07f17c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,6 +51,10 @@ services: "LICA":{"api":"http://lica_backend:8000", "inbound":"http://lica_backend:8000/api/v1/external/webhooks/mom-inbound", "outbound":"http://lica_backend:8000/api/v1/external/webhooks/mom-outbound"}} + # ★ 对内接口(Track → MOM)共享密钥,请求头 X-API-Key。 + # 与 TRACK_WEBHOOK_KEY 刻意分离:方向相反、权限不同(能发起报废审批), + # 独立轮换不连坐。未配置则内部接口一律 503(Fail-Closed),不静默放行。 + MOM_INTERNAL_API_KEY: ${MOM_INTERNAL_API_KEY:-} depends_on: - db diff --git a/inventory-backend/app/__init__.py b/inventory-backend/app/__init__.py index 0674fa1..3763228 100644 --- a/inventory-backend/app/__init__.py +++ b/inventory-backend/app/__init__.py @@ -135,6 +135,20 @@ def create_app(): except ImportError as e: print(f"❌ 错误: Scrap 模块导入失败: {e}") + # ----------------------------------------------------- + # 2.6b 注册对内接口模块(Track → MOM) + # + # ★ 鉴权走 X-API-Key(config.MOM_INTERNAL_API_KEY),**不走 JWT** —— + # Track 没有 MOM 账号,也不该有:申请人身份由请求体显式携带。 + # ★ 只注册 /api/v1 一条路径,**不做 legacy 双注册**:内部写接口不该多一个入口。 + # ----------------------------------------------------- + try: + from app.api.v1.internal import internal_bp + app.register_blueprint(internal_bp, url_prefix='/api/v1/internal') + print("✅ Internal 模块注册成功") + except ImportError as e: + print(f"❌ 错误: Internal 模块导入失败: {e}") + # ----------------------------------------------------- # 2.8 注册采购管理模块 # ----------------------------------------------------- diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index ec8a844..4a8a716 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -24,11 +24,9 @@ from app.models.inbound.stocktake import ( ) from app.models.transaction import ( TransBorrow, - TransReturn, + # 注:TransReturn / RETURN_TYPE_* / DEFECTIVE_STATUS_PENDING 曾在此使用, + # 退回逻辑抽到 return_service 后本模块不再直接碰它们,故已移出导入。 TransDefectiveGoods, - RETURN_TYPE_GOOD, - RETURN_TYPE_DEFECTIVE, - DEFECTIVE_STATUS_PENDING, DEFECTIVE_STATUS_IN_PROGRESS, RESTOCKABLE_DEFECTIVE_STATUSES, SCRAPPABLE_DEFECTIVE_STATUSES, @@ -37,6 +35,13 @@ from app.models.transaction import ( ) from app.models.outbound import TransOutbound from app.models.base import MaterialBase +# 退回业务逻辑已抽到服务层(内部接口 Track → MOM 要复用同一段)。 +# 别名带 _service 后缀,避免与本模块的视图函数 return_from_outbound 撞名。 +from app.services.return_service import ( + lock_source_stock_row, + assert_company_owns, + return_from_outbound as return_from_outbound_service, +) # 库存状态语义的单一事实来源(与分配器共用同一套常量,避免两处定义漂移) from app.services.inventory_reservation import ( @@ -2606,46 +2611,21 @@ def _lock_source_stock_row(source_table, stock_id): """ 解析并锁定退回目标的**原库存行**。业务不满足即抛 ValueError。 - 三条 Fail-Closed 规则: - 1. source_table 必须是三张库存表之一 —— 维修单等非库存来源没有可退回的行; - 2. 库存行必须仍然存在 —— 入库模块会物理删除库存行(见 - buy/semi/product_service 的 db.session.delete(stock)),实测 1077 条 - 出库记录中已有 7 条指向不存在的行; - 3. 调用方拿到行后还需自行做公司隔离与状态校验(见 _assert_company_owns)。 - - ★ 为什么必须加锁:本行随后会被加减数量,且与出库/报废/状态变更并发。 - 不加锁会出现「读-改-写」丢失更新(lost update)。 + ★ 实现已搬到 `app/services/return_service.py`(内部接口要复用,而服务层 + 不得反向 import API 层)。这里保留薄包装:调用方(restock 等)一行不用改。 """ - model = get_stock_model(source_table) - if model is None: - raise ValueError( - f'来源「{source_table or "(空)"}」不支持退回,' - f'仅支持 stock_buy / stock_semi / stock_product' - ) - - row = model.query.with_for_update().get(stock_id) if stock_id else None - if not row: - raise ValueError( - f'原库存行已不存在({source_table}#{stock_id}),无法自动退回,' - f'请改走入库流程手工登记这批实物' - ) - return row + return lock_source_stock_row(source_table, stock_id) def _assert_company_owns(row): """ 行级多租户隔离:非跨域用户只能操作本公司库存。不满足即抛 PermissionError。 - 口径与扫码出库(OutboundService.get_stock_by_barcode)、状态变更接口完全一致 - —— 都走 MaterialBase.company_name,避免三处隔离逻辑分叉。 + ★ 实现已搬到 `app/services/return_service.py`,那里收显式 company_limit + (内部接口没有 JWT,不能在里面读 get_current_company_filter)。 + 这里保留薄包装,把 JWT 依赖收在 API 层。 """ - company_limit = get_current_company_filter() - if company_limit is None: - return - base = getattr(row, 'base', None) - if (company_limit == '__NO_COMPANY__' or base is None - or (base.company_name or '') != company_limit): - raise PermissionError('无权操作其他公司的库存') + return assert_company_owns(row, get_current_company_filter()) @bp.route('/defective', methods=['GET']) @@ -2786,226 +2766,42 @@ def return_from_outbound(): operator_name = _normalize_user_id() outbound_id = data.get('outbound_id') - is_defective = data.get('is_defective') - reason = (data.get('reason') or '').strip() or None - # 补发(可选):退回后申请人往往仍需这件东西。勾选则自动生成一张免审批出库单。 - need_reissue = bool(data.get('need_reissue')) - reissue_qty = data.get('reissue_qty') - # 补发给谁:不传则回退为当前操作人(见下方补发块) - reissue_applicant_id = data.get('reissue_applicant_id') - - # ---- 1. 入参校验(脏值一律挡在入口)---- + # ---- 入参校验(脏值一律挡在入口)---- + # 只留这一条在视图层:其余校验的文案由 service 原样抛出,见其注释 if not outbound_id: return jsonify({'code': 400, 'msg': 'outbound_id 为必填'}), 400 - if is_defective is None: - return jsonify({ - 'code': 400, - 'msg': 'is_defective 为必填(true=不良品退回,false=良品退回)', - }), 400 - is_defective = bool(is_defective) try: - return_qty = float(data.get('return_qty') or 0) - except (TypeError, ValueError): - return jsonify({'code': 400, 'msg': 'return_qty 无效'}), 400 - if return_qty <= 0: - return jsonify({'code': 400, 'msg': '退回数量必须大于 0'}), 400 - - try: - # ---- 2. 锁定原出库明细并校验退回额度 ---- - # ★ 行锁不可省:并发两笔退回若各自读到相同的 returned_quantity,会双双 - # 通过额度校验,合计退回量超过出库量 —— 凭空多出库存。 - outbound = TransOutbound.query.with_for_update().get(outbound_id) - if not outbound: - raise ValueError(f'出库记录不存在(ID: {outbound_id})') - - shipped = float(outbound.quantity or 0) - returned = float(outbound.returned_quantity or 0) - returnable = shipped - returned - if return_qty > returnable: - raise ValueError( - f'退回数量({return_qty})超出可退额度({returnable}):' - f'原出库 {shipped},已退回 {returned}' - ) - - # ---- 3. 锁定原库存行 + 多租户隔离 ---- - stock_row = _lock_source_stock_row(outbound.source_table, outbound.stock_id) - _assert_company_owns(stock_row) - - # 公司快照:退回看板的隔离判定不能依赖 join 链 —— 源库存行会被入库模块 - # 物理删除,届时链路断裂会让记录对普通用户静默消失。见 TransReturn 注释。 - _base = getattr(stock_row, 'base', None) - snapshot_company = ((_base.company_name if _base else '') or '').strip() or None - - goods = None - if is_defective: - # ================= 不良品分支 ================= - # ★ 原库存表**分毫不动**:坏件全程存放于独立在管台账,既不占用库存 - # 数量、也不改库存行 status,从根上杜绝「坏件混进可分配池」。 - base = getattr(stock_row, 'base', None) - goods = TransDefectiveGoods( - outbound_id=outbound.id, - source_table=outbound.source_table, - stock_id=outbound.stock_id, - base_id=getattr(stock_row, 'base_id', None), - sku=getattr(stock_row, 'sku', '') or '', - material_name=(base.name if base else '') or '', - spec_model=(base.spec_model if base else '') or '', - quantity=return_qty, - remaining_qty=return_qty, - status=DEFECTIVE_STATUS_PENDING, - company_name=(base.company_name if base else '') or '', - reason=reason, - operator=operator_name, - ) - db.session.add(goods) - outcome = '不良品已转入在管台账' - else: - # ================= 良品分支 ================= - # ★ 状态防呆:把良品加回一个已冻结/不良品的行,会让良品被该行的状态 - # 连带隔离(status 是行级属性)—— 静默造成良品不可用。宁可报错让 - # 人先决定该行的归属。 - current = (stock_row.status or '').strip() - if current != STOCK_STATUS_IN_STOCK: - raise ValueError( - f'原库存行当前状态为「{current or "未设置"}」,' - f'良品退回要求该行处于「{STOCK_STATUS_IN_STOCK}」状态' - ) - stock_row.stock_quantity = float(stock_row.stock_quantity or 0) + return_qty - stock_row.available_quantity = float(stock_row.available_quantity or 0) + return_qty - outcome = '良品已加回原库存' - - # ---- 4. 累加退回额度 + 写退回流水 ---- - outbound.returned_quantity = returned + return_qty - - ledger = TransReturn( - outbound_id=outbound.id, - stock_id=outbound.stock_id, - source_table=outbound.source_table, - sku=outbound.sku, - return_qty=return_qty, - return_type=RETURN_TYPE_DEFECTIVE if is_defective else RETURN_TYPE_GOOD, - reason=reason, - operator=operator_name, - company_name=snapshot_company, + # ★ 业务逻辑全部在 app/services/return_service.py —— 内部接口 + # (Track → MOM 生产报废)要复用同一段逻辑,而视图里夹着 JWT 依赖, + # 服务层不能反向依赖它。本视图只负责取请求、转响应。 + result = return_from_outbound_service( + outbound_id=outbound_id, + return_qty=data.get('return_qty'), + is_defective=data.get('is_defective'), + reason=(data.get('reason') or '').strip() or None, + need_reissue=bool(data.get('need_reissue')), + reissue_qty=data.get('reissue_qty'), + reissue_applicant_id=data.get('reissue_applicant_id'), + operator_name=operator_name, + company_limit=get_current_company_filter(), ) - db.session.add(ledger) - db.session.flush() # 先拿到 ledger.id,供在管台账回填 - - # 在管台账回填来源流水 id,形成「出库 → 退回流水 → 在管台账」的追溯闭环 - if goods is not None: - goods.return_id = ledger.id - - # ================================================================== - # ---- 5. 补发(可选)---- - # 退回后申请人往往**仍然需要这件东西**(尤其是坏件 —— 原需求并未 - # 被满足)。勾选即自动生成一张**免审批**的出库单并关联回本笔退回, - # 使「退回 → 补发」形成闭环;否则现场只能靠人记住再手建一张单, - # 而那张单与原单看不出任何关系。 - # - # ★ 库存不足时**整笔回滚**(下面的 reserve_for_items 会抛错)。 - # 若只让补发静默失败,「需要补发」的意图就丢了 —— 那正是本功能 - # 要解决的问题。回滚后库管会看到明确提示,可取消勾选重试。 - # ================================================================== - reissue = None - if need_reissue: - # 单号生成器在 OutboundApprovalService 上(不在 OutboundService) - from app.services.outbound_service import OutboundApprovalService - from app.services.inventory_reservation import reserve_for_items - from app.models.outbound import OutboundApproval - - if reissue_qty is None: - reissue_qty = return_qty # 默认与本次退回量一致 - try: - reissue_qty = float(reissue_qty) - except (TypeError, ValueError): - raise ValueError('补发数量格式无效') - if reissue_qty <= 0: - raise ValueError('补发数量必须大于 0') - if reissue_qty > return_qty: - raise ValueError( - f'补发数量({reissue_qty})不能大于本次退回数量({return_qty})' - ) - - base = getattr(stock_row, 'base', None) - if base is None: - raise ValueError('原库存行的物料主数据已不存在,无法生成补发单') - - # 提交即预占,strict=True —— 与出库申请同一口径,不足即整单失败 - reserved_items, _shortages = reserve_for_items( - [{ - 'base_id': base.id, - 'name': base.name or '', - 'spec_model': base.spec_model or '', - 'quantity': reissue_qty, - }], - company_limit=get_current_company_filter(), - strict=True, - ) - - # ★ 申请人(补发给谁)优先级: - # ① 前端显式指定 reissue_applicant_id —— 现场最清楚该给谁; - # ② 回退到原出库明细记录的 applicant_id(创建出库时从审批单带出的 - # 真实原申请人); - # ③ 两者都没有 → **报错要求指定**。 - # - # ★ 绝不回退为「当前操作人」:补发是**原申请人的需求**,挂到办理 - # 退回的库管名下逻辑不通 —— 那张单会出现在库管的「我的申请」里, - # 而真正该拿东西的人什么也看不到。 - # ⚠ 存量出库明细的 applicant_id 为 NULL(历史无从回填),此时必须由 - # 库管在选择器里明确指定 —— 宁可多一步,也不猜错人。 - if reissue_applicant_id: - try: - _applicant = int(reissue_applicant_id) - except (TypeError, ValueError): - raise ValueError('补发申请人ID格式无效') - from app.models.system import SysUser - if not SysUser.query.get(_applicant): - raise ValueError(f'补发申请人不存在(ID:{reissue_applicant_id})') - elif outbound.applicant_id: - _applicant = int(outbound.applicant_id) - else: - raise ValueError( - '无法确定补发单申请人:这张出库单产生于「申请人」字段上线之前,' - '请在上方选择「补发给谁」' - ) - - reissue = OutboundApproval( - request_no=OutboundApprovalService.generate_request_no(), - applicant_id=_applicant, - outbound_type=outbound.outbound_type, - # 免审批:原需求已经批过一次,补发只是兑现它,重复审批是负担 - status=1, - approved_at=beijing_time(), - source_return_id=ledger.id, - remark=(f'原单退回补发(原出库单 {outbound.outbound_no or outbound.id}' - f',原领用人 {outbound.consumer_name or "未知"})'), - ) - reissue.set_items(reserved_items) - reissue.allowed_approvers = '[]' - db.session.add(reissue) - db.session.flush() - - db.session.commit() + reissue = result['reissue'] return jsonify({ 'code': 200, - 'msg': f'退回成功,{outcome}' - + (f';已生成补发单 {reissue.request_no}' if reissue else ''), + 'msg': f"退回成功,{result['outcome']}" + + (f";已生成补发单 {reissue['request_no']}" if reissue else ''), 'data': { - 'outbound_id': outbound.id, - 'return_id': ledger.id, - 'return_type': ledger.return_type, - 'return_qty': return_qty, - 'returned_quantity': float(outbound.returned_quantity), - 'returnable_quantity': shipped - float(outbound.returned_quantity), - 'defective_goods_id': goods.id if goods is not None else None, + 'outbound_id': result['outbound_id'], + 'return_id': result['return_id'], + 'return_type': result['return_type'], + 'return_qty': result['return_qty'], + 'returned_quantity': result['returned_quantity'], + 'returnable_quantity': result['returnable_quantity'], + 'defective_goods_id': result['defective_goods_id'], # 补发单(未勾选时为 null) - 'reissue': ({ - 'id': reissue.id, - 'request_no': reissue.request_no, - 'quantity': reissue_qty, - } if reissue else None), + 'reissue': reissue, }, }), 200 diff --git a/inventory-backend/app/api/v1/internal.py b/inventory-backend/app/api/v1/internal.py new file mode 100644 index 0000000..0e3a105 --- /dev/null +++ b/inventory-backend/app/api/v1/internal.py @@ -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 diff --git a/inventory-backend/app/services/return_service.py b/inventory-backend/app/services/return_service.py new file mode 100644 index 0000000..669df74 --- /dev/null +++ b/inventory-backend/app/services/return_service.py @@ -0,0 +1,422 @@ +"""原单退回(逆向物流)— 业务逻辑层 + +从 `app/api/v1/inbound/stock.py` 抽出来的。**抽出来的唯一动机**是给内部接口 +(Track → MOM 生产报废)复用:那条链要「退回(不良品) + 提报废申请」在一个事务里 +完成,而视图函数夹着 JWT 依赖(`get_current_company_filter()` / `_normalize_user_id()`), +内部接口没有 JWT,直接调会抛错。 + +抽的时候刻意**不改行为**,只做两件事: + · 把 JWT 依赖变成**显式入参**(`company_limit` / `operator_name`); + · 把 `commit` 变成开关,让调用方能接管提交点。 + +═══════════════════════════════════════════════════════════════════════════ +为什么「已出库的料要报废」必须走退回 +═══════════════════════════════════════════════════════════════════════════ +料一经出库,那条库存行的 available_quantity / stock_quantity 在出库时就已扣掉 +(见 outbound_service 的 restore_then_deduct)。而报废的上限正是可用库存, +所以对同一批料**发起不了**标准库存行报废。 + +本模块的 `is_defective=True` 分支就是这个矛盾的解:坏件转进独立的 +`trans_defective_goods` 在管台账,**原库存表分毫不动** —— 既不重复扣减, +也不把坏件混回可分配池(status 是行级属性,加回原行会让整行良品被连坐隔离)。 + +⚠️ 因此:**不要**为了「让报废更直接」而在这里给库存行加数量。那是重复扣减。 +""" + +import logging + +from app.extensions import db, beijing_time +from app.models.outbound import TransOutbound +from app.models.transaction import ( + TransReturn, + TransDefectiveGoods, + RETURN_TYPE_GOOD, + RETURN_TYPE_DEFECTIVE, + DEFECTIVE_STATUS_PENDING, +) +from app.services.inventory_reservation import ( + STOCK_STATUS_IN_STOCK, + stock_model_map, +) + +logger = logging.getLogger(__name__) + + +def lock_source_stock_row(source_table, stock_id): + """解析并锁定退回目标的**原库存行**。业务不满足即抛 ValueError。 + + 三条 Fail-Closed 规则: + 1. source_table 必须是三张库存表之一 —— 维修单等非库存来源没有可退回的行; + 2. 库存行必须仍然存在 —— 入库模块会物理删除库存行(见 + buy/semi/product_service 的 db.session.delete(stock)),实测 1077 条 + 出库记录中已有 7 条指向不存在的行; + 3. 调用方拿到行后还需自行做公司隔离与状态校验(见 assert_company_owns)。 + + ★ 为什么必须加锁:本行随后会被加减数量,且与出库/报废/状态变更并发。 + 不加锁会出现「读-改-写」丢失更新(lost update)。 + + ★ 与原 API 层 `get_stock_model` 的差异:这里用 `inventory_reservation.stock_model_map()`。 + 服务层不得反向 import API 层(会成循环依赖)。 + """ + model = stock_model_map().get(source_table) + if model is None: + raise ValueError( + f'来源「{source_table or "(空)"}」不支持退回,' + f'仅支持 stock_buy / stock_semi / stock_product' + ) + + row = model.query.with_for_update().get(stock_id) if stock_id else None + if not row: + raise ValueError( + f'原库存行已不存在({source_table}#{stock_id}),无法自动退回,' + f'请改走入库流程手工登记这批实物' + ) + return row + + +def assert_company_owns(row, company_limit): + """行级多租户隔离:非跨域用户只能操作本公司库存。不满足即抛 PermissionError。 + + 口径与扫码出库(OutboundService.get_stock_by_barcode)、状态变更接口完全一致 + —— 都走 MaterialBase.company_name,避免多处隔离逻辑分叉。 + + ★ `company_limit` 是**显式入参**,不在函数内读 JWT:内部接口(Track → MOM) + 没有 JWT,`get_current_company_filter()` 里的 `get_jwt()` 会抛 RuntimeError, + 被全局 errorhandler 吞成一条没有信息的 500,排查极难。 + """ + if company_limit is None: + return + base = getattr(row, 'base', None) + if (company_limit == '__NO_COMPANY__' or base is None + or (base.company_name or '') != company_limit): + raise PermissionError('无权操作其他公司的库存') + + +def return_from_outbound(*, outbound_id, return_qty, is_defective, reason=None, + need_reissue=False, reissue_qty=None, + reissue_applicant_id=None, operator_name='System', + company_limit=None, source_ref=None, commit=True): + """原单退回。返回 dict(形状与 API 响应的 data 字段一致)。 + + :param company_limit: 调用方所在公司(超管/内部接口传 None = 不限) + :param source_ref: 外部系统唯一引用(`:<外部单号>`),供判重 + :param commit: False 时只 flush,提交权交给调用方(组合操作用) + + ⚠️ 调用方负责处理异常与 rollback;本函数不吞异常。 + """ + try: + return_qty = float(return_qty or 0) + except (TypeError, ValueError): + raise ValueError('return_qty 无效') + if return_qty <= 0: + raise ValueError('退回数量必须大于 0') + if is_defective is None: + raise ValueError('is_defective 为必填(true=不良品退回,false=良品退回)') + is_defective = bool(is_defective) + + # ---- 1. 锁定原出库明细并校验退回额度 ---- + # ★ 行锁不可省:并发两笔退回若各自读到相同的 returned_quantity,会双双 + # 通过额度校验,合计退回量超过出库量 —— 凭空多出库存。 + outbound = TransOutbound.query.with_for_update().get(outbound_id) + if not outbound: + raise ValueError(f'出库记录不存在(ID: {outbound_id})') + + shipped = float(outbound.quantity or 0) + returned = float(outbound.returned_quantity or 0) + returnable = shipped - returned + if return_qty > returnable: + raise ValueError( + f'退回数量({return_qty})超出可退额度({returnable}):' + f'原出库 {shipped},已退回 {returned}' + ) + + # ---- 2. 锁定原库存行 + 多租户隔离 ---- + stock_row = lock_source_stock_row(outbound.source_table, outbound.stock_id) + assert_company_owns(stock_row, company_limit) + + # 公司快照:退回看板的隔离判定不能依赖 join 链 —— 源库存行会被入库模块 + # 物理删除,届时链路断裂会让记录对普通用户静默消失。见 TransReturn 注释。 + _base = getattr(stock_row, 'base', None) + snapshot_company = ((_base.company_name if _base else '') or '').strip() or None + + goods = None + if is_defective: + # ================= 不良品分支 ================= + # ★ 原库存表**分毫不动**:坏件全程存放于独立在管台账,既不占用库存 + # 数量、也不改库存行 status,从根上杜绝「坏件混进可分配池」。 + base = getattr(stock_row, 'base', None) + goods = TransDefectiveGoods( + outbound_id=outbound.id, + source_table=outbound.source_table, + stock_id=outbound.stock_id, + base_id=getattr(stock_row, 'base_id', None), + sku=getattr(stock_row, 'sku', '') or '', + material_name=(base.name if base else '') or '', + spec_model=(base.spec_model if base else '') or '', + # ★ 原库位/批次快照:此刻 stock_row 已 with_for_update() 锁在手里, + # 是**唯一**能可靠取到这两个值的时机 —— 源库存行日后会被入库模块 + # 物理删除,届时回查落空,报表上就只剩 "-"。 + # 取值口径与 scrap.py 的 _from_stock 一致(成品表无 batch_number, + # 回退到 serial_number)。 + warehouse_location=getattr(stock_row, 'warehouse_location', '') or '', + batch_number=(getattr(stock_row, 'batch_number', '') + or getattr(stock_row, 'serial_number', '') or ''), + quantity=return_qty, + remaining_qty=return_qty, + status=DEFECTIVE_STATUS_PENDING, + company_name=(base.company_name if base else '') or '', + reason=reason, + operator=operator_name, + ) + db.session.add(goods) + outcome = '不良品已转入在管台账' + else: + # ================= 良品分支 ================= + # ★ 状态防呆:把良品加回一个已冻结/不良品的行,会让良品被该行的状态 + # 连带隔离(status 是行级属性)—— 静默造成良品不可用。宁可报错让 + # 人先决定该行的归属。 + current = (stock_row.status or '').strip() + if current != STOCK_STATUS_IN_STOCK: + raise ValueError( + f'原库存行当前状态为「{current or "未设置"}」,' + f'良品退回要求该行处于「{STOCK_STATUS_IN_STOCK}」状态' + ) + stock_row.stock_quantity = float(stock_row.stock_quantity or 0) + return_qty + stock_row.available_quantity = float(stock_row.available_quantity or 0) + return_qty + outcome = '良品已加回原库存' + + # ---- 3. 累加退回额度 + 写退回流水 ---- + outbound.returned_quantity = returned + return_qty + + ledger = TransReturn( + outbound_id=outbound.id, + stock_id=outbound.stock_id, + source_table=outbound.source_table, + sku=outbound.sku, + return_qty=return_qty, + return_type=RETURN_TYPE_DEFECTIVE if is_defective else RETURN_TYPE_GOOD, + reason=reason, + operator=operator_name, + company_name=snapshot_company, + source_ref=(source_ref or '').strip() or None, + ) + db.session.add(ledger) + # ★ 这个 flush 不能省:要拿 ledger.id 回填在管台账,补发单也要 source_return_id + db.session.flush() + + # 在管台账回填来源流水 id,形成「出库 → 退回流水 → 在管台账」的追溯闭环 + if goods is not None: + goods.return_id = ledger.id + + # ================================================================== + # ---- 4. 补发(可选)---- + # 退回后申请人往往**仍然需要这件东西**(尤其是坏件 —— 原需求并未 + # 被满足)。勾选即自动生成一张**免审批**的出库单并关联回本笔退回, + # 使「退回 → 补发」形成闭环;否则现场只能靠人记住再手建一张单, + # 而那张单与原单看不出任何关系。 + # + # ★ 库存不足时**整笔回滚**(下面的 reserve_for_items 会抛错)。 + # 若只让补发静默失败,「需要补发」的意图就丢了 —— 那正是本功能 + # 要解决的问题。回滚后库管会看到明确提示,可取消勾选重试。 + # ================================================================== + reissue = None + if need_reissue: + reissue, reissue_qty = _create_reissue( + outbound=outbound, stock_row=stock_row, ledger=ledger, + return_qty=return_qty, reissue_qty=reissue_qty, + reissue_applicant_id=reissue_applicant_id, + company_limit=company_limit, + ) + + if commit: + db.session.commit() + else: + db.session.flush() + + return { + 'outbound_id': outbound.id, + 'outbound_no': outbound.outbound_no or '', + 'return_id': ledger.id, + 'return_type': ledger.return_type, + 'return_qty': return_qty, + 'returned_quantity': float(outbound.returned_quantity), + 'returnable_quantity': shipped - float(outbound.returned_quantity), + 'defective_goods_id': goods.id if goods is not None else None, + 'company_name': snapshot_company or '', + 'product_id': getattr(stock_row, 'base_id', None), + 'outcome': outcome, + 'reissue': ({ + 'id': reissue.id, + 'request_no': reissue.request_no, + 'quantity': reissue_qty, + } if reissue else None), + } + + +def _create_reissue(*, outbound, stock_row, ledger, return_qty, reissue_qty, + reissue_applicant_id, company_limit): + """生成免审批补发单。返回 (reissue, reissue_qty)。失败即抛错(整笔回滚)。""" + # 单号生成器在 OutboundApprovalService 上(不在 OutboundService) + from app.services.outbound_service import OutboundApprovalService + from app.services.inventory_reservation import reserve_for_items + from app.models.outbound import OutboundApproval + + if reissue_qty is None: + reissue_qty = return_qty # 默认与本次退回量一致 + try: + reissue_qty = float(reissue_qty) + except (TypeError, ValueError): + raise ValueError('补发数量格式无效') + if reissue_qty <= 0: + raise ValueError('补发数量必须大于 0') + if reissue_qty > return_qty: + raise ValueError( + f'补发数量({reissue_qty})不能大于本次退回数量({return_qty})' + ) + + base = getattr(stock_row, 'base', None) + if base is None: + raise ValueError('原库存行的物料主数据已不存在,无法生成补发单') + + # 提交即预占,strict=True —— 与出库申请同一口径,不足即整单失败 + reserved_items, _shortages = reserve_for_items( + [{ + 'base_id': base.id, + 'name': base.name or '', + 'spec_model': base.spec_model or '', + 'quantity': reissue_qty, + }], + company_limit=company_limit, + strict=True, + ) + + # ★ 申请人(补发给谁)优先级: + # ① 前端显式指定 reissue_applicant_id —— 现场最清楚该给谁; + # ② 回退到原出库明细记录的 applicant_id(创建出库时从审批单带出的 + # 真实原申请人); + # ③ 两者都没有 → **报错要求指定**。 + # + # ★ 绝不回退为「当前操作人」:补发是**原申请人的需求**,挂到办理 + # 退回的库管名下逻辑不通 —— 那张单会出现在库管的「我的申请」里, + # 而真正该拿东西的人什么也看不到。 + # ⚠ 存量出库明细的 applicant_id 为 NULL(实测 1364 行中 1127 行为 NULL, + # 历史无从回填),此时必须由调用方明确指定 —— 宁可多一步,也不猜错人。 + if reissue_applicant_id: + try: + _applicant = int(reissue_applicant_id) + except (TypeError, ValueError): + raise ValueError('补发申请人ID格式无效') + from app.models.system import SysUser + if not SysUser.query.get(_applicant): + raise ValueError(f'补发申请人不存在(ID:{reissue_applicant_id})') + elif outbound.applicant_id: + _applicant = int(outbound.applicant_id) + else: + raise ValueError( + '无法确定补发单申请人:这张出库单产生于「申请人」字段上线之前,' + '请指定「补发给谁」' + ) + + reissue = OutboundApproval( + request_no=OutboundApprovalService.generate_request_no(), + applicant_id=_applicant, + outbound_type=outbound.outbound_type, + # 免审批:原需求已经批过一次,补发只是兑现它,重复审批是负担 + status=1, + approved_at=beijing_time(), + source_return_id=ledger.id, + remark=(f'原单退回补发(原出库单 {outbound.outbound_no or outbound.id}' + f',原领用人 {outbound.consumer_name or "未知"})'), + ) + reissue.set_items(reserved_items) + reissue.allowed_approvers = '[]' + db.session.add(reissue) + db.session.flush() + return reissue, reissue_qty + + +# ============================================================================= +# 生产报废:退回(不良品) + 提交报废申请 —— **一个原子操作** +# ============================================================================= +def return_and_submit_production_scrap(*, outbound_id, return_qty, applicant_id, + operator_name='Track系统', + company_limit=None, reason=None, + reason_category='PRODUCTION', + source_ref=None, submit_scrap=True, + scrap_qty=None): + """生产领用的料报废:先退回登记为在管不良品,(可选)再提交报废申请。 + + :param submit_scrap: True = 退回并提报废申请(一步到位); + False = 只退回登记为在管不良品(以后可能修好回库) + :param source_ref: `:<外部单号>`,幂等锚点 + + ★ 本函数是**唯一提交点**。退回段与报废申请段共用同一个 db.session, + 任一步抛错 → 调用方 rollback() → 整笔回滚。绝不会出现 + 「料已退回成在管不良品、但报废申请没提交」这种半截状态。 + + ★ 为什么 scrap_qty 允许小于 return_qty:本次未必全废(部分修好、 + 部分继续用)。但**不能大于** —— 那必然生成一张执行不了的申请单 + (在管台账的 cap 就是本次退回量)。 + + ⚠️ 只走不良品分支(is_defective 恒 True)。良品分支会往库存行加数量, + 对外部接口来说那等于凭空造库存,爆炸半径太大,不开放。 + """ + from app.services.scrap_approval_service import ScrapApprovalService + + if submit_scrap and scrap_qty is None: + scrap_qty = return_qty + if scrap_qty is not None: + try: + scrap_qty = float(scrap_qty) + except (TypeError, ValueError): + raise ValueError('scrap_qty 无效') + if scrap_qty <= 0: + raise ValueError('scrap_qty 必须大于 0') + if scrap_qty > float(return_qty): + raise ValueError( + f'scrap_qty({scrap_qty})不能大于本次退回数量({return_qty})' + ) + + # ---- 第 1 段:退回(只 flush,不 commit)---- + ret = return_from_outbound( + outbound_id=outbound_id, + return_qty=return_qty, + is_defective=True, # ★ 恒为不良品,不开放良品分支 + reason=reason, + need_reissue=False, # ★ 补发不开放给外部接口 + operator_name=operator_name, + company_limit=company_limit, + source_ref=source_ref, + commit=False, + ) + + if not ret.get('defective_goods_id'): + # 走到这里说明撤回分支出了问题(is_defective=True 必然产出在管行) + raise ValueError('内部错误:未生成在管不良品记录') + + # ---- 第 2 段:报废申请(只 flush,不 commit)---- + scrap = None + if submit_scrap: + scrap = ScrapApprovalService.submit_approval( + applicant_id=applicant_id, + items=[{ + 'source_table': 'trans_defective_goods', + 'stock_id': ret['defective_goods_id'], + 'scrap_qty': scrap_qty, + }], + remark=reason, + reason_category=reason_category, + company_name=ret.get('company_name') or None, + source_ref=source_ref, + commit=False, + ) + + # ---- 唯一提交点 ---- + db.session.commit() + logger.info( + f"[ProductionScrap] 受理完成 source_ref={source_ref} " + f"outbound={outbound_id} qty={return_qty} submit_scrap={submit_scrap} " + f"defective_goods={ret.get('defective_goods_id')} " + f"request_no={getattr(scrap, 'request_no', None)}" + ) + return ret, scrap diff --git a/inventory-backend/config.py b/inventory-backend/config.py index 8f6d5cd..7c4e5c5 100644 --- a/inventory-backend/config.py +++ b/inventory-backend/config.py @@ -100,3 +100,17 @@ class Config: TRACK_ROUTES = json.loads(os.getenv('TRACK_ROUTES', '{}') or '{}') except ValueError as e: raise RuntimeError(f'TRACK_ROUTES 不是合法 JSON,请检查环境变量: {e}') + + # ========================================================= + # 10. 对内接口配置 (接收侧:Track → MOM) + # ========================================================= + # Track 调 MOM 内部接口(如生产报废受理)的共享密钥,请求头 X-API-Key。 + # + # ★ 与 TRACK_WEBHOOK_KEY 刻意分离,不复用: + # · 方向相反 —— 那个是 MOM 发给 Track 的凭证,这个是 Track 发给 MOM 的; + # · 权限不同 —— 本密钥能发起报废审批(间接影响台账与成本), + # 万一泄漏,两边各自轮换即可,不会连坐。 + # + # ★ 未配置时内部接口一律返回 503(Fail-Closed),**不做静默放行** —— + # 一个默认开着的写接口,比一个没配好的接口危险得多。 + MOM_INTERNAL_API_KEY = os.getenv('MOM_INTERNAL_API_KEY', '')