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

@ -385,6 +385,10 @@ class TransScrap(db.Model):
total_loss = db.Column(db.Numeric(19, 4))
# ★ 关联报废申请单号(审批流写台账用;DDL 见 db_migrations/add_scrap_approval.sql)
scrap_request_no = db.Column(db.String(100), index=True)
# 报废原因分类码,执行时从 scrap_approval.reason_category 带出(见 scrap_sources._ledger_kwargs)。
# 「统计生产报废金额」按此列分组,不要靠 reason 自由文本去匹配。
# DDL 见 db_migrations/phase12_production_scrap.sql
reason_category = db.Column(db.String(50), index=True)
def to_dict(self):
return {
@ -394,6 +398,7 @@ class TransScrap(db.Model):
'stock_id': self.stock_id,
'quantity': float(self.quantity) if self.quantity is not None else None,
'reason': self.reason,
'reason_category': self.reason_category,
'operator_name': self.operator_name,
'operation_time': self.operation_time.strftime('%Y-%m-%d %H:%M:%S') if self.operation_time else None,
'approver_name': self.approver_name,
@ -461,6 +466,13 @@ class TransReturn(db.Model):
# DDL 见 db_migrations/add_return_view_support.sql
company_name = db.Column(db.String(255), index=True)
# ★ 外部系统唯一引用,格式 <company>:<外部单号>。
# 外部接口(Track → MOM 生产报废)据此判重。之所以不靠 prevent_double_submit:
# 它依赖 Redis,而 compose 里没有 redis 服务 → redis_client 恒为 None →
# 装饰器全程 fail-open。唯一索引是唯一的并发防线。
# DDL 见 db_migrations/phase12_production_scrap.sql
source_ref = db.Column(db.String(100), index=True)
def to_dict(self):
return {
'id': self.id,
@ -474,6 +486,7 @@ class TransReturn(db.Model):
'operator': self.operator,
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None,
'company_name': self.company_name,
'source_ref': self.source_ref or '',
}
@ -557,6 +570,17 @@ class TransDefectiveGoods(db.Model):
sku = db.Column(db.String(100))
material_name = db.Column(db.String(200))
spec_model = db.Column(db.String(255))
# 原库位 / 批次(序列号) 快照 —— 退回时取自源库存行
# (DDL 见 db_migrations/phase14_defective_goods_location_snapshot.sql)
#
# ★ 口径是「**原库位**」:这批货最初在哪,而不是坏件当前所在的不良品区
# (不良品区位置本系统未记录)。与三张库存表来源的报废记录口径一致。
# ★ batch_number 对 stock_product 来源存的是 serial_number(成品表无
# batch_number 列),与 scrap.py 的 _from_stock 取值口径一致。
# ★ 加这两列是为了让报表**不必**依赖源库存行存活 —— 源行会被入库模块
# 物理删除,届时回查落空,只能靠这里的快照。存量行由上述迁移回填。
warehouse_location = db.Column(db.String(100))
batch_number = db.Column(db.String(100))
# --- 数量 ---
# ★ 不变式:restocked_qty + scrapped_qty + remaining_qty = quantity
@ -592,6 +616,8 @@ class TransDefectiveGoods(db.Model):
'sku': self.sku,
'material_name': self.material_name,
'spec_model': self.spec_model,
'warehouse_location': self.warehouse_location or '',
'batch_number': self.batch_number or '',
'quantity': qty,
'remaining_qty': remain,
# ★ 三个去向列各自独立取值。改造前 restocked_qty 由