把「报废来源差异」从审批服务里抽出来,为后续把借出未还、在管不良品
接入审批流铺路。
背景
----
报废审批流原先只认三张库存表(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 那类「扫得到、执行却拒绝」的错配。
本步不触碰任何现有路由,现网仍走老路径,可独立评审与验证。
471 lines
19 KiB
Python
471 lines
19 KiB
Python
"""
|
||
报废来源适配层 —— 统一三类报废来源的解析、校验、快照与扣减。
|
||
|
||
背景
|
||
----
|
||
报废审批流(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_loss(best-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)
|