Files
KCGL/inventory-backend/app/services/scrap_sources.py
yueli e4d2b2ec68 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 那类「扫得到、执行却拒绝」的错配。

本步不触碰任何现有路由,现网仍走老路径,可独立评审与验证。
2026-09-16 17:14:05 +08:00

471 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
报废来源适配层 —— 统一三类报废来源的解析、校验、快照与扣减。
背景
----
报废审批流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)