Files
KCGL/inventory-backend/app/services/scrap_sources.py
yueli bfd0db791c feat(scrap): 报废原因分类 + 角色级审批
报废要回答「这笔损失出在哪个环节」,并让主管审批不再依赖逐个指定人。

- 新增「报废原因分类」字段(scrap_approval + trans_scrap 各一列),
  只有两个互斥口径:生产报废(走 Track 的)/ 库存报废(MOM 自身流程的)。
  不传即库存报废 —— 这条二分法在写入那一刻就成立,不依赖任何推导。
  ⚠️ 不能从 source_table 推导:Track 的生产报废与手工的不良品退回共用
     同一张 trans_defective_goods 表,推导会把生产损失算成库存损失。
- scrap_approval 加 company_name / source_ref:前者是公司隔离快照,
  后者是外部单据的幂等锚点(Redis 未部署,prevent_double_submit 全程
  fail-open,唯一索引是唯一防线)。
- 审批从「只认 type=user」放宽到「user 或 role」——主管角色都能审,
  谁审就记谁。★ 空名单依然拒绝所有人(Fail-Closed),这是历史
  「名单为空则人人可审」漏洞的修复点,不得改回 fail-open。
- 角色级放行必须配公司隔离:6 个主管里 IRIS 5 个、LICA 1 个,
  不隔离就是跨公司审批通道。
- trans_return 加 source_ref(幂等锚点)。

迁移:db_migrations/phase12(建列)+ phase13(存量空分类回填为库存报废)。
2026-09-23 15:17:44 +08:00

511 lines
21 KiB
Python
Raw Permalink 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, beijing_time
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):
"""写 TransScrap 的公共字段 —— **三种来源适配器全部经过这里**。
★ 这是报废原因分类落到台账的**唯一出口**,分类只在这里带一次,
三个 adapter(库存行 / 在管不良品 / 借出未还)就都通了。
「统计生产报废金额」按 trans_scrap.reason_category 分组,不要靠
reason 自由文本去匹配。
"""
from app.models.scrap_approval import ScrapApproval
return {
'reason': req.remark or '',
'reason_category': getattr(req, 'reason_category', None),
'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)
@staticmethod
def _resolve_location(goods):
"""
取「原库位 / 批次(序列号)」:台账快照优先,回查源库存行兜底。
★ 口径 = **原库位**(这批货最初在哪),与三张库存表来源一致。
★ 原实现此处硬编码空串(注释「台账无库位字段」),导致申请单/审批单
明细里凡是不良品来源的行,库位与批次恒显示 "-"。但台账自带
source_table + stock_id 溯源指针,源行还在时本来取得到。
★ 两级都落空(源行已被入库模块物理删除,且台账也无快照)时返回空串
—— 不中断报废:实物已销毁,台账必须先记上,位置缺失是可接受的
降级,记录丢失不是。
★ 取值口径与 scrap.py 的 _from_stock 一致:成品表无 batch_number 列,
回退到 serial_number。
"""
loc = getattr(goods, 'warehouse_location', '') or ''
batch = getattr(goods, 'batch_number', '') or ''
if loc and batch:
return loc, batch
model = _stock_model_map().get(getattr(goods, 'source_table', ''))
if model is not None and getattr(goods, 'stock_id', None):
row = model.query.get(goods.stock_id)
if row:
loc = loc or getattr(row, 'warehouse_location', '') or ''
batch = batch or (getattr(row, 'batch_number', '')
or getattr(row, 'serial_number', '') or '')
return loc, batch
def snapshot(self, row, qty, raw):
# 物料名/规格取自台账自身的冗余快照,**不联表 MaterialBase** ——
# 原库存行可能已被物理删除,联表会取到空值。
location, batch_number = self._resolve_location(row)
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': location,
'batch_number': 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 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'
# ★ 时间口径:与 borrow_time / 归还流水统一取北京时间。
# 原用 datetime.now()(容器 UTC),会让报废时间在台账上比实际早 8 小时,
# 与同一行的 borrow_time 自相矛盾,也让流转时间线出现倒序假象。
record.return_time = beijing_time()
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)