Files
KCGL/inventory-backend/app/models/scrap_approval.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

194 lines
8.9 KiB
Python
Raw 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.

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 一致。
★ 原先返回 tz-aware 时间,配合 timestamptz 列才正确;现已将该表列类型统一为
timestamp without time zone(见 db_migrations/unify_approval_timezone.sql),
故此处必须返回 naive 值,否则存库会被当 UTC 处理而早 8 小时。
"""
return beijing_time()
class ScrapApproval(db.Model):
"""
报废申请/审批单(镜像 BorrowApproval / OutboundApproval)
与出/借库的关键差异:items_json 里每条明细必须携带
source_table + stock_id(精准定位到某条实物库存记录),
因为报废从“申请那一刻”就要求指明具体要报废的东西。
"""
__tablename__ = 'scrap_approval'
id = db.Column(db.Integer, primary_key=True)
request_no = db.Column(db.String(100), unique=True, nullable=False, index=True)
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)
allowed_approvers = db.Column(db.Text) # JSON: [{"type":"user","value":123}, ...]
actual_approver_id = db.Column(db.Integer, index=True)
approved_at = db.Column(db.DateTime)
reject_reason = db.Column(db.Text)
# 明细快照: [{"source_table","stock_id","sku","name","spec_model","location","batch_number","scrap_qty"}]
items_json = db.Column(db.Text, nullable=False)
# 执行信息
executed_at = db.Column(db.DateTime)
executor_name = db.Column(db.String(100))
created_at = db.Column(db.DateTime, default=_beijing_now, nullable=False)
updated_at = db.Column(db.DateTime, default=_beijing_now, onupdate=_beijing_now, nullable=False)
def set_items(self, items):
self.items_json = json.dumps(items or [], ensure_ascii=False)
def get_items(self):
try:
return json.loads(self.items_json) if self.items_json else []
except Exception:
return []
def set_allowed_approvers(self, approvers):
self.allowed_approvers = json.dumps(approvers or [], ensure_ascii=False)
def get_allowed_approvers(self):
try:
return json.loads(self.allowed_approvers) if self.allowed_approvers else []
except Exception:
return []
@staticmethod
def _user_name(user_id):
if not user_id:
return ''
try:
from app.models.system import SysUser
u = SysUser.query.get(user_id)
return u.username if u else ''
except Exception:
return ''
def to_dict(self):
return {
'id': self.id,
'request_no': self.request_no,
'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,
'actual_approver_name': self._user_name(self.actual_approver_id),
'approved_at': self.approved_at.strftime('%Y-%m-%d %H:%M:%S') if self.approved_at else None,
'reject_reason': self.reject_reason or '',
'items': self.get_items(),
'executed_at': self.executed_at.strftime('%Y-%m-%d %H:%M:%S') if self.executed_at else None,
'executor_name': self.executor_name or '',
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
}