feat(scrap): 报废来源适配层(纯新增,零行为变更)

把「报废来源差异」从审批服务里抽出来,为后续把借出未还、在管不良品
接入审批流铺路。

背景
----
报废审批流原先只认三张库存表(ScrapApprovalService._stock_models 硬编码),
导致另外两类来源只能各走直报接口绕过审批 —— 与系统自陈的
「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL)冲突,构成职责分离
漏洞:同一个库管可自行宣告实物销毁而无人复核。

三类来源的语义差异
------------------
  维度        库存行            在管不良品        借出未还
  执行模式    scan              scan              auto(免扫码)
  可报废量    available_qty     remaining_qty     quantity-returned
  扣减        available 与      只动在管台账,    只扣 stock_quantity
              stock 同扣       不碰任何库存表    (可用量已在借出时冻结)
  成本        沿用现口径        defective_unit_cost  0/0

★ 为什么在管不良品保留扫码、借出未还免扫码:
  坏件实物就在仓库里、有 SKU,扫码是有效的「申请报 A、实际毁 B」防护;
  借出物在借用人手上,物理上不可能扫到,且执行只改台账与总库存、
  不产生任何可被挪用的可用库存,风险等级不同量级。

其他要点
--------
- defective_unit_cost() 从 api/v1/inbound/stock.py 迁入本模块:服务层不得
  反向 import API 层。复用 inventory_reservation.stock_model_map(),
  避免第四份库存表字典。
- 提交期新增 submit_guard 钩子,用于修复「提交只校验 available、执行却校验
  both」的校验不对称(会出现申请通过、执行必失败的单据)。
- is_scan_source() 对未知来源返回 False(Fail-Closed):扫码通道只接纳明确
  声明为 scan 的来源,杜绝 trans_repair 那类「扫得到、执行却拒绝」的错配。

本步不触碰任何现有路由,现网仍走老路径,可独立评审与验证。
This commit is contained in:
yueli
2026-09-16 17:14:05 +08:00
parent 511e822d12
commit e4d2b2ec68

View File

@ -0,0 +1,470 @@
"""
报废来源适配层 —— 统一三类报废来源的解析、校验、快照与扣减。
背景
----
报废审批流ScrapApprovalService原先只认三张库存表导致「借出未还」与
「在管不良品」两类来源只能各自走直报接口绕过审批 —— 与系统自陈的
「报废一律需审批」SCRAP_ALWAYS_REQUIRES_APPROVAL规则冲突构成职责分离
漏洞:同一个库管可自行宣告实物销毁而无人复核。
本模块把「来源差异」收敛到适配器,审批服务不必再关心来源细节。
执行模式
--------
scan —— 需扫码执行。实物在仓库内、有可扫标识,扫码能防「申请报 A、实际毁 B」。
auto —— 按批准量执行。实物不在库,物理上无法扫码。
★ 为什么「在管不良品」保留扫码:坏件就在仓库里、有 SKU扫码是有效的
实物在场核验,且成本极低。
★ 为什么「借出未还」免扫码:东西在借用人手上,不可能扫到;且该来源执行
只改台账与总库存,**不产生任何可被挪用的可用库存**,风险等级不同量级。
残余风险(执行人可能在未实际销毁时点「执行」)由 scrap_execute 权限与
audit_logs 留痕兜底。
多租户
------
库存行与借出记录经查询层隔离;在管不良品用**台账自带的 company_name 快照**
刻意不联表 MaterialBase —— 坏件的原库存行可能已被入库模块物理删除。
"""
import logging
from app.extensions import db
logger = logging.getLogger(__name__)
# 执行模式
SCRAP_MODE_SCAN = 'scan' # 需扫码执行
SCRAP_MODE_AUTO = 'auto' # 按批准量执行
def _stock_model_map():
"""复用 inventory_reservation 的库存表映射,避免第四份重复定义。"""
from app.services.inventory_reservation import stock_model_map
return stock_model_map()
def defective_unit_cost(goods):
"""
取坏件单价,用于报废台账的 cost_at_scrap / total_lossbest-effort
取价口径与既有报废模块一致:成品取 sale_price采购件取 pre_tax_unit_price
半成品无价(返回 0
★ 原库存行可能已被删除(入库模块会物理删除库存行),故取不到时返回 0。
刻意**不**因缺行而中断报废:实物已经销毁,台账必须先记上,
成本缺失是可接受的降级,记录丢失不是。
"""
model = _stock_model_map().get(goods.source_table)
if model is None or not goods.stock_id:
return 0.0
row = model.query.get(goods.stock_id)
if not row:
return 0.0
if goods.source_table == 'stock_product':
return float(getattr(row, 'sale_price', 0) or 0)
if goods.source_table == 'stock_buy':
return float(getattr(row, 'pre_tax_unit_price', 0) or 0)
return 0.0
# =============================================================================
# 基类
# =============================================================================
class ScrapSourceAdapter:
"""
报废来源适配器基类。
子类必须提供 source_table / scrap_mode / label / cap_label 四个类属性,
并实现 load / cap / snapshot / deduct。
"""
source_table = ''
scrap_mode = SCRAP_MODE_SCAN
label = '' # 中文来源名,用于报表与错误文案
cap_label = '可报废量' # 上限的中文说法,用于错误文案
# --- 提交阶段(不加锁,乐观读)---
def load(self, sid):
"""按主键取来源行;不存在返回 None。"""
raise NotImplementedError
def cap(self, row):
"""该来源当前的可报废上限。"""
raise NotImplementedError
def submit_guard(self, row, qty):
"""
提交期的额外校验symmetry guard
★ 存在的意义:提交与执行的校验必须对称,否则会出现「申请能过、
执行必失败」的单据。默认无额外约束,子类按需覆盖。
"""
return None
def snapshot(self, row, qty, raw):
"""产出写入 items_json 的明细快照。"""
raise NotImplementedError
# --- 执行阶段(加锁 + 扣减 + 写台账)---
def deduct(self, row_id, qty, req, operator_name):
"""加锁重取 → 二次校验 → 扣减 → 写 TransScrap。不满足即抛 ValueError。"""
raise NotImplementedError
# --- 台账公共字段 ---
@staticmethod
def _ledger_kwargs(req, operator_name):
from app.models.scrap_approval import ScrapApproval
return {
'reason': req.remark or '',
'operator_name': operator_name,
'approver_name': ScrapApproval._user_name(req.actual_approver_id),
'approval_status': 'executed',
'scrap_request_no': req.request_no,
}
# =============================================================================
# 一类:库存行(三张库存表)
# =============================================================================
class StockRowAdapter(ScrapSourceAdapter):
"""
常规库存行来源。语义与改造前的 execute() 逐字一致。
扣减available_quantity 与 stock_quantity **同时扣**(报废 = 实物销毁)。
扫码:必需 —— 库存行是同质可替换物,在库内处于执行人物理控制之下,
存在以次充好、批次腾挪的空间,扫码把「批准批次」与「实毁批次」钉死。
"""
scrap_mode = SCRAP_MODE_SCAN
cap_label = '可用库存'
_LABELS = {
'stock_buy': '采购件',
'stock_semi': '半成品',
'stock_product': '成品',
}
def __init__(self, source_table, model):
self.source_table = source_table
self.model = model
self.label = self._LABELS.get(source_table, source_table)
def load(self, sid):
return self.model.query.get(sid)
def cap(self, row):
return float(getattr(row, 'available_quantity', 0) or 0)
def submit_guard(self, row, qty):
# ★ 对称性修复:旧代码提交只校验 available_quantity而执行同时校验
# available 与 stock_quantity —— 会出现「申请通过、执行必然失败」。
# 此处提前拦下。
stock = float(getattr(row, 'stock_quantity', 0) or 0)
if qty > stock:
raise ValueError(f"报废数量({qty})超过实物库存({stock})")
def snapshot(self, row, qty, raw):
base = getattr(row, 'base', None)
return {
'source_table': self.source_table,
'stock_id': row.id,
'base_id': getattr(row, 'base_id', None),
'sku': getattr(row, 'sku', '') or '',
'name': (base.name if base else '') or raw.get('name') or '',
'spec_model': (base.spec_model if base else '') or raw.get('spec_model') or '',
'location': getattr(row, 'warehouse_location', '') or '',
'batch_number': (getattr(row, 'batch_number', '')
or getattr(row, 'serial_number', '') or ''),
'scrap_qty': qty,
'available_at_apply': self.cap(row),
'scrap_mode': self.scrap_mode,
}
def deduct(self, row_id, qty, req, operator_name):
from app.models.transaction import TransScrap
row = self.model.query.with_for_update().get(row_id)
if not row:
raise ValueError(f"库存记录已不存在({self.source_table}#{row_id}")
avail = float(getattr(row, 'available_quantity', 0) or 0)
stock = float(getattr(row, 'stock_quantity', 0) or 0)
label = getattr(row, 'sku', '') or f"{self.source_table}#{row_id}"
if qty > avail:
raise ValueError(f"库存 SKU【{label}】可用不足(剩 {avail}),无法报废 {qty}")
if qty > stock:
raise ValueError(f"库存 SKU【{label}】实物不足(剩 {stock}),无法报废 {qty}")
# 报废 = 实物销毁:实物数与可用数同时扣减
row.available_quantity = avail - qty
row.stock_quantity = stock - qty
db.session.flush()
db.session.add(TransScrap(
sku=getattr(row, 'sku', '') or label,
source_table=self.source_table,
stock_id=row_id,
quantity=qty,
**self._ledger_kwargs(req, operator_name),
))
# =============================================================================
# 二类:在管不良品台账
# =============================================================================
class DefectiveScrapAdapter(ScrapSourceAdapter):
"""
在管不良品来源(逆向物流)。
扣减remaining_qty -=、scrapped_qty +=、推进状态机;**完全不动任何库存表**
—— 坏件从未进入库存表,这是本次逆向物流的核心架构决策。
扫码:必需(业务方决策)—— 坏件实物在仓、有 SKU扫码是有效核验。
成本:按 defective_unit_cost 取价,保持改造前直报接口的口径。
"""
source_table = 'trans_defective_goods'
scrap_mode = SCRAP_MODE_SCAN
label = '在管不良品'
cap_label = '在管数量'
def load(self, sid):
from app.models.transaction import TransDefectiveGoods
return TransDefectiveGoods.query.get(sid)
def cap(self, row):
return float(getattr(row, 'remaining_qty', 0) or 0)
def snapshot(self, row, qty, raw):
# 物料名/规格取自台账自身的冗余快照,**不联表 MaterialBase** ——
# 原库存行可能已被物理删除,联表会取到空值。
return {
'source_table': self.source_table,
'stock_id': row.id,
'base_id': getattr(row, 'base_id', None),
'sku': getattr(row, 'sku', '') or '',
'name': getattr(row, 'material_name', '') or raw.get('name') or '',
'spec_model': getattr(row, 'spec_model', '') or raw.get('spec_model') or '',
'location': '', # 台账无库位字段
'batch_number': '',
'scrap_qty': qty,
'available_at_apply': self.cap(row),
'scrap_mode': self.scrap_mode,
}
def deduct(self, row_id, qty, req, operator_name):
from app.models.transaction import (
TransScrap, TransDefectiveGoods, OPEN_DEFECTIVE_STATUSES,
DEFECTIVE_STATUS_IN_PROGRESS, defective_close_status,
)
goods = TransDefectiveGoods.query.with_for_update().get(row_id)
if not goods:
raise ValueError(f"不良品在管记录已不存在(#{row_id}")
# 状态守门Fail-Closed终态不得再处置
if goods.status not in OPEN_DEFECTIVE_STATUSES:
raise ValueError(
f"在管不良品【{goods.sku or row_id}】当前状态为「{goods.status}」,不可报废"
)
remaining = float(goods.remaining_qty or 0)
if qty > remaining:
# ★ Fail-Closed批准后可能被另一张单先报废、或已部分回库
# 此时静默夹到剩余量会让单据状态(已执行)与实际扣减不符,
# 审计上不可接受。整单报错,由申请人撤回后重提。
raise ValueError(
f"在管不良品【{goods.sku or row_id}】在管量不足"
f"(剩 {remaining}),无法报废 {qty}"
)
new_remaining = remaining - qty
goods.remaining_qty = new_remaining
goods.scrapped_qty = float(goods.scrapped_qty or 0) + qty
# 终态由「累计去向」推导而非「最后一次动作」—— 一批坏件可能既回库过
# 又报废过,按最后动作定状态会产生误导(见 defective_close_status
goods.status = (
defective_close_status(goods.restocked_qty, goods.scrapped_qty)
if new_remaining <= 0 else DEFECTIVE_STATUS_IN_PROGRESS
)
db.session.flush()
unit_cost = defective_unit_cost(goods)
db.session.add(TransScrap(
sku=goods.sku or '',
source_table=self.source_table,
stock_id=goods.id,
quantity=qty,
cost_at_scrap=unit_cost,
total_loss=round(unit_cost * qty, 2),
**self._ledger_kwargs(req, operator_name),
))
# =============================================================================
# 三类:借出未还(借库转报废)
# =============================================================================
class BorrowScrapAdapter(ScrapSourceAdapter):
"""
借出未还来源(借库转报废)。
扣减:标记借用记录为已报废;内层库存行**只扣 stock_quantity**,不动
available_quantity —— 可用量已在借出时冻结(见
trans_service.execute_dispatch 的 deduct_stock=False 及其注释)。
扫码:免扫码(业务方决策,理由见模块头)。
成本0/0 —— 与改造前 scrap_borrow 口径一致。
"""
source_table = 'trans_borrow'
scrap_mode = SCRAP_MODE_AUTO
label = '借出未还'
cap_label = '待还数量'
def load(self, sid):
from app.models.transaction import TransBorrow
return TransBorrow.query.get(sid)
def cap(self, row):
return (float(getattr(row, 'quantity', 0) or 0)
- float(getattr(row, 'returned_quantity', 0) or 0))
def submit_guard(self, row, qty):
# 已归还/已报废的借用记录不得再报废。旧实现是静默 continue 并返回
# count=0缺陷用户以为成功这里改为明确报错。
if getattr(row, 'is_returned', False):
raise ValueError(
f"借用记录【{getattr(row, 'borrow_no', '')}】已归还或已报废,不可再报废"
)
def snapshot(self, row, qty, raw):
name, spec = self._resolve_material(row)
return {
'source_table': self.source_table,
'stock_id': row.id, # ★ 存 TransBorrow.id与既有约定一致
'base_id': None,
'sku': getattr(row, 'sku', '') or '',
'name': name or raw.get('name') or '',
'spec_model': spec or raw.get('spec_model') or '',
'location': getattr(row, 'location', '') or '',
'batch_number': getattr(row, 'barcode', '') or '',
'scrap_qty': qty,
'available_at_apply': self.cap(row),
'scrap_mode': self.scrap_mode,
}
@staticmethod
def _resolve_material(record):
"""经借用记录回查其源库存行取物料名/规格;行已删除时返回空串。"""
model = _stock_model_map().get(getattr(record, 'source_table', ''))
if model is None or not getattr(record, 'stock_id', None):
return '', ''
row = model.query.get(record.stock_id)
base = getattr(row, 'base', None) if row else None
return ((base.name if base else '') or '',
(base.spec_model if base else '') or '')
def deduct(self, row_id, qty, req, operator_name):
from datetime import datetime
from app.models.transaction import TransScrap, TransBorrow
record = TransBorrow.query.with_for_update().get(row_id)
if not record:
raise ValueError(f"借用记录已不存在(#{row_id}")
if record.is_returned:
raise ValueError(
f"借用记录【{record.borrow_no}】已归还或已报废,不可再报废"
)
pending = (float(record.quantity or 0)
- float(record.returned_quantity or 0))
if qty > pending:
# Fail-Closed批准后可能已被部分归还同不良品来源的理由
raise ValueError(
f"借用记录【{record.borrow_no}】待还量不足(剩 {pending}),无法报废 {qty}"
)
# 1) 标记借用记录:不再追讨归还
record.is_returned = True
record.status = 'scrapped'
record.return_time = datetime.now()
record.return_operator = operator_name
# 2) 扣总库存(该物品确认损失);可用量已在借出时冻结,不重复扣
model = _stock_model_map().get(record.source_table)
if model is not None and record.stock_id:
stock = model.query.with_for_update().get(record.stock_id)
if stock:
stock_qty = float(stock.stock_quantity or 0)
if qty > stock_qty:
raise ValueError(
f"SKU {record.sku} 实物库存不足(剩 {stock_qty}),无法报废 {qty}"
)
stock.stock_quantity = stock_qty - qty
else:
# 源库存行已被物理删除:损失无法落在库存账上。
# 刻意不中断 —— 实物已确认无法归还,台账必须先记上;
# 但显式告警,避免这种「账上无痕」的损失悄无声息。
logger.warning(
"[报废] 借出记录 #%s 的源库存行已不存在(%s#%s"
"本次报废未在库存账上体现",
row_id, record.source_table, record.stock_id,
)
db.session.flush()
# 3) 写报废台账。成本口径保持 0/0与改造前 scrap_borrow 一致)
db.session.add(TransScrap(
sku=record.sku or '',
source_table=self.source_table,
stock_id=record.id,
quantity=qty,
cost_at_scrap=0,
total_loss=0,
**self._ledger_kwargs(req, operator_name),
))
# =============================================================================
# 注册表
# =============================================================================
def _build_registry():
models = _stock_model_map()
registry = {
st: StockRowAdapter(st, model) for st, model in models.items()
}
registry[DefectiveScrapAdapter.source_table] = DefectiveScrapAdapter()
registry[BorrowScrapAdapter.source_table] = BorrowScrapAdapter()
return registry
_REGISTRY = None
def get_adapter(source_table):
"""按来源表名取适配器;不支持则返回 None。"""
global _REGISTRY
if _REGISTRY is None:
_REGISTRY = _build_registry()
return _REGISTRY.get((source_table or '').strip())
def all_source_tables():
global _REGISTRY
if _REGISTRY is None:
_REGISTRY = _build_registry()
return tuple(_REGISTRY.keys())
def is_scan_source(source_table):
"""
该来源是否走扫码执行。
★ 未知来源一律返回 False —— execute 的扫码索引只接纳明确声明的 scan 来源,
避免历史上 trans_repair 那类「扫码能扫到、执行却拒绝」的错配重演。
"""
adapter = get_adapter(source_table)
return bool(adapter and adapter.scrap_mode == SCRAP_MODE_SCAN)