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:
yueli
2026-09-23 15:17:44 +08:00
parent bfd0db791c
commit c7f85880a9
6 changed files with 856 additions and 246 deletions

View File

@ -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

View File

@ -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 注册采购管理模块
# -----------------------------------------------------

View File

@ -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,
)
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,
}],
# ★ 业务逻辑全部在 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(),
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

View 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

View File

@ -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: 外部系统唯一引用(`<company>:<外部单号>`),供判重
: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: `<company>:<外部单号>`,幂等锚点
★ 本函数是**唯一提交点**。退回段与报废申请段共用同一个 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

View File

@ -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', '')