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

@ -2,6 +2,85 @@ from app.extensions import db, beijing_time
import json
# =============================================================================
# 报废原因分类 —— 用来回答「这笔损失出在哪个环节」
#
# 业务口径(2026-09 与业务确认):
# PRODUCTION 生产环节 —— **好料领到产线之后**,在生产中变坏了。
# STOCK 库存/采购 —— 东西还在仓库里就有问题、买来就坏、或售后从客户
# 退回后发现的不良。凡「不是生产过程中弄坏的」都归这里。
# OTHER 其他 —— 兜底,给以后说不清的情况留个位置。
#
# 大前提:这几类都发生在**发货之前**,都属于「生产未发货前的损失」,
# 与「已发货后客户退货产生的损失」不是一回事。
#
# ★★★ 为什么分类**必须显式记录**,绝不能从 source_table 推导 ★★★
# Track 提交的生产报废走的是「出库领用 → 退回(不良品) → 在管不良品 → 报废」,
# 而 MOM 界面手工报的不良品退回**走的是同一条路、落进同一张
# trans_defective_goods 表**。两条路在 source_table 上长得一模一样:
# · Track 提交的 → 料是好的,生产时弄坏的 → PRODUCTION
# · MOM 手工报的 → 料本来就有问题 → STOCK
# 一旦写成「trans_defective_goods 就推成 STOCK」,Track 报的生产损失会被
# 静默算成库存损失,「统计生产报废金额」这个目标当场失效。
# 所以:**提交时谁传什么就是什么,推导是错的。**
#
# ★ 单一事实来源:模型定义码表,service / API / 前端文案都从这里取,
# 不要再抄一份 —— 抄出去就开始漂移。
# ★ 加新分类只需扩这个 dict,**不需要 DDL**(列是 varchar(50))。
# 刻意不预置「质量/运输/存储」等更多分类:那是给 UI 的承诺,没有业务确认不要先造。
# =============================================================================
SCRAP_CATEGORY_PRODUCTION = 'PRODUCTION' # 生产报废:**所有走 Track 的**
SCRAP_CATEGORY_STOCK = 'STOCK' # 库存报废:MOM 自身流程走下来的
SCRAP_CATEGORY_OTHER = 'OTHER' # 其他(兜底,暂未使用)
SCRAP_CATEGORY_LABELS = {
SCRAP_CATEGORY_PRODUCTION: '生产报废',
SCRAP_CATEGORY_STOCK: '库存报废',
SCRAP_CATEGORY_OTHER: '其他',
}
# 中文别名 → 码。外部系统/前端传中文时用得上(码本身大小写不敏感,此处只列中文)
# 老叫法(生产损耗 / 库存采购)保留兼容 —— Track 侧可能已经在传旧名字
_SCRAP_CATEGORY_ALIASES = {
'生产报废': SCRAP_CATEGORY_PRODUCTION,
'生产损耗': SCRAP_CATEGORY_PRODUCTION, # 旧标签
'生产导致': SCRAP_CATEGORY_PRODUCTION, # 更早的叫法
'生产': SCRAP_CATEGORY_PRODUCTION,
'库存报废': SCRAP_CATEGORY_STOCK,
'库存/采购': SCRAP_CATEGORY_STOCK, # 旧标签
'库存': SCRAP_CATEGORY_STOCK,
'采购': SCRAP_CATEGORY_STOCK,
'其他': SCRAP_CATEGORY_OTHER,
}
def normalize_scrap_category(raw, default=None):
"""报废原因分类入参归一化 → 分类码。
接受分类码(大小写不敏感)或中文别名。空值返回 `default`。
⚠️ 未知值**抛 ValueError** 而不是静默回落 —— 白名单外的东西一律挡在入口,
否则会写进库里一个谁也看不懂的码,统计时才发现分类是脏的。
"""
text = str(raw or '').strip()
if not text:
return default
upper = text.upper()
if upper in SCRAP_CATEGORY_LABELS:
return upper
if text in _SCRAP_CATEGORY_ALIASES:
return _SCRAP_CATEGORY_ALIASES[text]
raise ValueError(
'报废原因分类不支持:{},仅支持 {}'.format(
text, '、'.join(sorted(SCRAP_CATEGORY_LABELS)),
)
)
def scrap_category_label(code):
"""分类码 → 中文名。未知/空值返回空串(前端自行兜底 '-')。"""
return SCRAP_CATEGORY_LABELS.get(str(code or '').strip().upper(), '')
def _beijing_now():
"""
统一时间口径:naive 北京时间,与 created_at / OutboundApproval / BorrowApproval 一致。
@ -29,6 +108,20 @@ class ScrapApproval(db.Model):
applicant_id = db.Column(db.Integer, nullable=False, index=True)
remark = db.Column(db.Text) # 报废原因
# 申请所属公司(= 申请人 sys_user.department)。
# ★ 这是**角色级审批**能做公司隔离的前提:主管共 6 人(IRIS 5 / LICA 1),
# 只按角色放行而不比公司,LICA 主管就能审 IRIS 的报废单。
# 存快照而不联表查 sys_user:申请人被删后仍要能定性这张单属于谁。
company_name = db.Column(db.String(255), index=True)
# 报废原因分类码(SCRAP_CATEGORY_*)。执行时经 _ledger_kwargs 带到 trans_scrap。
reason_category = db.Column(db.String(50))
# 外部系统唯一引用,格式 <company>:<外部单号>。
# 幂等与追溯的锚点(Redis 未部署,prevent_double_submit 全程 fail-open,
# 唯一索引是唯一防线;DDL 见 db_migrations/phase12_production_scrap.sql)。
source_ref = db.Column(db.String(100), index=True)
# 状态: 0-待审批, 1-已通过(待执行), 2-已驳回, 3-已执行(已报废)
status = db.Column(db.Integer, default=0, nullable=False, index=True)
@ -83,6 +176,10 @@ class ScrapApproval(db.Model):
'applicant_id': self.applicant_id,
'applicant_name': self._user_name(self.applicant_id),
'remark': self.remark or '',
'company_name': self.company_name or '',
'reason_category': self.reason_category or '',
'reason_category_label': scrap_category_label(self.reason_category),
'source_ref': self.source_ref or '',
'status': self.status,
'allowed_approvers': self.get_allowed_approvers(),
'actual_approver_id': self.actual_approver_id,

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 由