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:
422
inventory-backend/app/services/return_service.py
Normal file
422
inventory-backend/app/services/return_service.py
Normal 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
|
||||
Reference in New Issue
Block a user