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:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user