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(存量空分类回填为库存报废)。
This commit is contained in:
yueli
2026-09-23 15:17:44 +08:00
parent 822f8976a9
commit bfd0db791c
6 changed files with 648 additions and 21 deletions

View File

@ -115,9 +115,17 @@ class ScrapSourceAdapter:
# --- 台账公共字段 ---
@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',
@ -238,9 +246,39 @@ class DefectiveScrapAdapter(ScrapSourceAdapter):
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,
@ -248,8 +286,8 @@ class DefectiveScrapAdapter(ScrapSourceAdapter):
'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': '',
'location': location,
'batch_number': batch_number,
'scrap_qty': qty,
'available_at_apply': self.cap(row),
'scrap_mode': self.scrap_mode,