diff --git a/db_migrations/phase12_production_scrap.sql b/db_migrations/phase12_production_scrap.sql new file mode 100644 index 0000000..374a651 --- /dev/null +++ b/db_migrations/phase12_production_scrap.sql @@ -0,0 +1,155 @@ +-- ============================================================================= +-- 生产导致报废:Track → MOM 复用逆向物流 + 报废审批 +-- +-- 问题 +-- Track 要把「生产领用的物料已经领到产线、之后在生产中报废」提交进 MOM, +-- 并复用 MOM 现有的报废流程(申请→审批→执行),最终能统计报废金额。 +-- 但料**已经出库**,那条库存行的 available_quantity / stock_quantity 在出库 +-- 时就已扣掉,走不了标准库存行报废(cap=可用库存)。MOM 自己的答案是走 +-- 「从出库单退回(不良品)」→ trans_defective_goods 在管台账(库存表分毫不动)。 +-- +-- 本次改动(全部为可空列,纯增量) +-- 1) scrap_approval.company_name —— 公司隔离快照。角色级审批的前提: +-- 6 个主管里 IRIS 5 个、LICA 1 个,不隔离公司就是跨公司审批通道。 +-- 2) scrap_approval.reason_category —— 报废原因分类,标记「生产导致」。 +-- 3) scrap_approval.source_ref —— 外部系统唯一引用(幂等 + 追溯)。 +-- 4) trans_scrap.reason_category —— 台账侧同名字段,供报废金额分组统计。 +-- 5) trans_return.source_ref —— 幂等锚点。 +-- +-- --------------------------------------------------------------------------- +-- ★ 为什么 source_ref 是 ':' 而不是裸的单号 +-- IRIS 与 LICA 各自独立跑一套 Track,工单号可能重号。唯一索引必须带公司维度, +-- 否则两家会互相挡住对方的首次受理。 +-- +-- ★ 为什么幂等要靠唯一索引,而不是靠 prevent_double_submit +-- 该装饰器依赖 Redis,而 docker-compose 里**没有 redis 服务** —— +-- app/extensions.py 的 redis_client 恒为 None,装饰器全程 fail-open、 +-- 实际不生效(已实测)。所以 DB 唯一索引是唯一的并发防线。 +-- +-- ★ 为什么不回填 reason_category +-- 本库 scrap_approval / trans_scrap 均为 0 行,无存量;生产库若有历史行, +-- 留 NULL 表示「产生于本列上线之前」,不猜。company_name 可安全回填 +-- (申请人所属部门即公司,是确定性映射)。 +-- +-- ★ 时区提醒 +-- 本文件不新增时间列。注意 scrap_approval 的时间列在**本库**仍是 timestamptz +-- (unify_approval_timezone.sql 尚未在本库执行,实测库 TimeZone=Etc/UTC), +-- 与该脚本的预期不一致。日后再加时间列时请先确认该脚本已执行, +-- 不要照抄 add_scrap_approval.sql 的 timestamptz 写法。 +-- +-- 幂等:带 IF NOT EXISTS,可重复执行。不含 psql 元命令,DataGrip 可直接执行。 +-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件 +-- ============================================================================= + +BEGIN; + +-- --------------------------------------------------------------------------- +-- 1) 报废申请单:公司快照 + 原因分类 + 外部引用 +-- --------------------------------------------------------------------------- +ALTER TABLE scrap_approval + ADD COLUMN IF NOT EXISTS company_name varchar(255); + +COMMENT ON COLUMN scrap_approval.company_name IS + '申请所属公司(= sys_user.department)。角色级审批据此做公司隔离,防止跨公司审批'; + +CREATE INDEX IF NOT EXISTS ix_scrap_approval_company + ON scrap_approval (company_name); + +ALTER TABLE scrap_approval + ADD COLUMN IF NOT EXISTS reason_category varchar(50); + +COMMENT ON COLUMN scrap_approval.reason_category IS + '报废原因分类码:PRODUCTION(生产损耗=好料领用后变坏) / STOCK(库存/采购=在库或买来就有问题) / OTHER(其他)。' + '★必须显式记录,不能从 source_table 推导:Track 的生产报废与 MOM 手工报的不良品退回共用 trans_defective_goods,' + '推导会把生产损失静默算成库存损失。中文名见 scrap_approval.SCRAP_CATEGORY_LABELS'; + +ALTER TABLE scrap_approval + ADD COLUMN IF NOT EXISTS source_ref varchar(100); + +COMMENT ON COLUMN scrap_approval.source_ref IS + '外部系统唯一引用,格式 :<外部单号>。用于幂等与追溯'; + +-- 唯一索引:同一外部单据不得重复受理(部分索引,NULL 不参与唯一性) +CREATE UNIQUE INDEX IF NOT EXISTS uk_scrap_approval_source_ref + ON scrap_approval (source_ref) WHERE source_ref IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- 2) 报废流水台账:原因分类(统计按它分组) +-- --------------------------------------------------------------------------- +ALTER TABLE trans_scrap + ADD COLUMN IF NOT EXISTS reason_category varchar(50); + +COMMENT ON COLUMN trans_scrap.reason_category IS + '报废原因分类码,执行报废时从 scrap_approval.reason_category 带出。统计「生产报废金额」按此分组'; + +CREATE INDEX IF NOT EXISTS ix_trans_scrap_category + ON trans_scrap (reason_category); + +-- --------------------------------------------------------------------------- +-- 3) 退回流水:幂等锚点 +-- --------------------------------------------------------------------------- +ALTER TABLE trans_return + ADD COLUMN IF NOT EXISTS source_ref varchar(100); + +COMMENT ON COLUMN trans_return.source_ref IS + '外部系统唯一引用,格式 :<外部单号>。外部接口据此判重(Redis 未部署,唯一索引是唯一防线)'; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_trans_return_source_ref + ON trans_return (source_ref) WHERE source_ref IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- 4) 存量回填:申请人所属部门即公司(确定性映射) +-- 查不到(申请人已删)保持 NULL —— 应用层对 company_name 为空的旧单 +-- **跳过**公司校验而非拒绝,避免把历史在途单卡死。 +-- --------------------------------------------------------------------------- +UPDATE scrap_approval sa + SET company_name = u.department + FROM sys_user u + WHERE u.id = sa.applicant_id + AND sa.company_name IS NULL + AND COALESCE(u.department, '') <> ''; + +COMMIT; + + +-- ============================================================================= +-- 执行后核对 +-- ============================================================================= +SELECT '=== 1) scrap_approval 三个新列已就位 ===' AS "核对项"; +SELECT column_name, data_type FROM information_schema.columns + WHERE table_name = 'scrap_approval' + AND column_name IN ('company_name', 'reason_category', 'source_ref') + ORDER BY column_name; + +SELECT '=== 2) trans_scrap / trans_return 新列已就位 ===' AS "核对项"; +SELECT table_name, column_name, data_type FROM information_schema.columns + WHERE (table_name = 'trans_scrap' AND column_name = 'reason_category') + OR (table_name = 'trans_return' AND column_name = 'source_ref') + ORDER BY table_name; + +SELECT '=== 3) 索引已就位(三个普通 + 两个唯一)===' AS "核对项"; +SELECT tablename, indexname FROM pg_indexes + WHERE indexname IN ('ix_scrap_approval_company', 'ix_trans_scrap_category', + 'uk_scrap_approval_source_ref', 'uk_trans_return_source_ref') + ORDER BY indexname; + +SELECT '=== 4) 存量:本库应均为 0 行 ===' AS "核对项"; +SELECT (SELECT count(*) FROM scrap_approval) AS 报废申请单数, + (SELECT count(*) FROM trans_scrap) AS 报废流水数, + (SELECT count(*) FROM scrap_approval WHERE company_name IS NULL) AS 公司为空的申请单; + + +-- ============================================================================= +-- 回滚段 +-- ============================================================================= +-- BEGIN; +-- DROP INDEX IF EXISTS uk_trans_return_source_ref; +-- ALTER TABLE trans_return DROP COLUMN IF EXISTS source_ref; +-- DROP INDEX IF EXISTS ix_trans_scrap_category; +-- ALTER TABLE trans_scrap DROP COLUMN IF EXISTS reason_category; +-- DROP INDEX IF EXISTS uk_scrap_approval_source_ref; +-- DROP INDEX IF EXISTS ix_scrap_approval_company; +-- ALTER TABLE scrap_approval DROP COLUMN IF EXISTS source_ref; +-- ALTER TABLE scrap_approval DROP COLUMN IF EXISTS reason_category; +-- ALTER TABLE scrap_approval DROP COLUMN IF EXISTS company_name; +-- COMMIT; diff --git a/db_migrations/phase13_scrap_category_stock_default.sql b/db_migrations/phase13_scrap_category_stock_default.sql new file mode 100644 index 0000000..7bbd8f5 --- /dev/null +++ b/db_migrations/phase13_scrap_category_stock_default.sql @@ -0,0 +1,78 @@ +-- ============================================================================= +-- 报废原因分类:存量归位 + 口径定型 +-- +-- 背景(2026-09-23 与业务确认) +-- 分类只有两种,是**互斥二分**: +-- · 生产报废(PRODUCTION)—— **所有走 Track 的**。料领到产线之后在生产中 +-- 变坏,经 Track 提交、退回不良品、走 MOM 报废审批。 +-- · 库存报废(STOCK) —— **MOM 自身流程走下来的**。不看 Track 的、 +-- 直接在 MOM 界面选库存行报废、不良品看板、借还记录那几条路。 +-- +-- 中文标签同时由「生产损耗 / 库存采购」改为「生产报废 / 库存报废」—— +-- 旧名字里的「损耗」「采购」让现场以为分了四个类,实际只有两个。 +-- +-- 本次改动 +-- 1) 存量 scrap_approval.reason_category 为空的 → 一律归 STOCK(库存报废)。 +-- 理由:本列上线时只有 Track 那条链会写值(显式 PRODUCTION), +-- 其余全是 MOM 自身流程产生的 —— 空值就是「不是 Track 报的」。 +-- 2) trans_scrap.reason_category 为空的 → 同样归 STOCK,口径保持一致 +-- (台账与申请单对不上会让金额统计出现两个数)。 +-- 3) 写入侧的默认值已同步改到应用层(submit_approval 不传分类即 STOCK), +-- 本文件只负责把上线前的存量补齐。 +-- +-- ⚠️ 这是**回填**不是推导:值是按「这单是不是 Track 报的」定的,而 Track 报的 +-- 一定有 source_ref(<公司>:)。有 source_ref 却分类为空的 +-- 属于异常数据,本脚本**不动**它们,留给人工看 —— 宁可留着空, +-- 也不要把它错归成库存报废把生产损失算没了。 +-- +-- 幂等:带 WHERE ... IS NULL,可重复执行。不含 psql 元命令,DataGrip 可直接执行。 +-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件 +-- ============================================================================= + +BEGIN; + +-- 1) 报废申请单:空分类且**没有** Track 来源引用 → 库存报废 +UPDATE scrap_approval + SET reason_category = 'STOCK' + WHERE reason_category IS NULL + AND (source_ref IS NULL OR source_ref = ''); + +-- 2) 报废流水台账:同上,口径与申请单保持一致 +UPDATE trans_scrap + SET reason_category = 'STOCK' + WHERE reason_category IS NULL + AND (scrap_request_no IS NULL + OR scrap_request_no NOT IN ( + SELECT request_no FROM scrap_approval WHERE source_ref IS NOT NULL + )); + +COMMIT; + + +-- ============================================================================= +-- 执行后核对 +-- ============================================================================= +SELECT '=== 1) 申请单分类分布(应只剩生产报废/库存报废两类)===' AS "核对项"; +SELECT COALESCE(reason_category, '(空)') AS 分类, count(*) AS 单数 + FROM scrap_approval GROUP BY reason_category ORDER BY 2 DESC; + +SELECT '=== 2) ★ 异常数据:有 Track 来源引用却没有分类(应为 0,需人工看)===' AS "核对项"; +SELECT count(*) AS 异常单数 + FROM scrap_approval + WHERE (source_ref IS NOT NULL AND source_ref <> '') + AND reason_category IS NULL; + +SELECT '=== 3) 台账分类分布 ===' AS "核对项"; +SELECT COALESCE(reason_category, '(空)') AS 分类, count(*) AS 行数 + FROM trans_scrap GROUP BY reason_category ORDER BY 2 DESC; + + +-- ============================================================================= +-- 回滚段(把回填过的空值还原 —— 只能还原成「空」,值本身无法区分) +-- ============================================================================= +-- BEGIN; +-- UPDATE scrap_approval SET reason_category = NULL +-- WHERE reason_category = 'STOCK' AND (source_ref IS NULL OR source_ref = ''); +-- UPDATE trans_scrap SET reason_category = NULL +-- WHERE reason_category = 'STOCK'; +-- COMMIT; diff --git a/inventory-backend/app/models/scrap_approval.py b/inventory-backend/app/models/scrap_approval.py index c379c69..ed39c2b 100644 --- a/inventory-backend/app/models/scrap_approval.py +++ b/inventory-backend/app/models/scrap_approval.py @@ -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)) + + # 外部系统唯一引用,格式 :<外部单号>。 + # 幂等与追溯的锚点(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, diff --git a/inventory-backend/app/models/transaction.py b/inventory-backend/app/models/transaction.py index 741e925..067020b 100644 --- a/inventory-backend/app/models/transaction.py +++ b/inventory-backend/app/models/transaction.py @@ -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) + # ★ 外部系统唯一引用,格式 :<外部单号>。 + # 外部接口(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 由 diff --git a/inventory-backend/app/services/scrap_approval_service.py b/inventory-backend/app/services/scrap_approval_service.py index b6c7f71..52d8da4 100644 --- a/inventory-backend/app/services/scrap_approval_service.py +++ b/inventory-backend/app/services/scrap_approval_service.py @@ -29,6 +29,22 @@ def _beijing(): # ============================================================================= SCRAP_ALWAYS_REQUIRES_APPROVAL = True +# ============================================================================= +# ★ 默认审批角色(角色级审批) +# +# 提交时若不指定具体审批人,名单回落成这两个角色 —— 「有主管权限的都能看到、 +# 都能审批」,谁审就记谁(actual_approver_id)。 +# +# ★ 刻意**不含 WAREHOUSE_MGR**:库管是报废的执行人(scrap_execute), +# 让他同时能审批,就把「申请 → 审批 → 执行」的职责分离塌缩成一个人, +# 本模块反复强调的那道关卡就没了。与出库/借库的先例一致 +# (outbound.py 的 _default_approvers = SUPERVISOR + SUPER_ADMIN)。 +# ============================================================================= +DEFAULT_SCRAP_APPROVER_ROLES = ('SUPERVISOR', 'SUPER_ADMIN') + +# allowed_approvers 条目里允许出现的 type 值 +_APPROVER_TYPES = ('user', 'role') + # 注:原先此处有 _stock_models() 硬编码三张库存表。来源差异已全部收敛到 # app/services/scrap_sources.py 的来源适配层(它复用 # inventory_reservation.stock_model_map(),避免第四份重复定义), @@ -37,6 +53,139 @@ SCRAP_ALWAYS_REQUIRES_APPROVAL = True class ScrapApprovalService: + # ------------------------------------------------------------------ + # 审批人判定(★ 单一事实来源:approve / 详情查看 / 列表 scope=pending 共用) + # ------------------------------------------------------------------ + @staticmethod + def _approver_entries(allowed): + """allowed_approvers JSON → (user_ids:set, roles:set)。 + + ★ 四处判定(approve / 详情查看 / 列表 pending / 提交)都走这一个解析, + 避免四份逻辑各自漂移。脏条目(非 dict、无 value)直接跳过。 + """ + users, roles = set(), set() + for a in (allowed or []): + if not isinstance(a, dict): + continue + atype = str(a.get('type') or '').strip().lower() + value = a.get('value') + if value is None or str(value).strip() == '': + continue + if atype == 'user': + users.add(str(value).strip()) + elif atype == 'role': + roles.add(str(value).strip().upper()) + return users, roles + + @staticmethod + def resolve_operator_role(operator_id): + """按 id 反查操作人角色(SysUser.role,大写)。查不到返回 ''。 + + ★ 以**数据库**为准,不用 JWT 里的 role claim —— 角色刚被改过时两者会不一致, + 而审批放行必须以「此刻这个人是什么角色」为准。 + """ + try: + from app.models.system import SysUser + u = SysUser.query.get(int(operator_id)) + return (u.role or '').upper() if u else '' + except Exception: + return '' + + @staticmethod + def can_operator_approve(req, operator_id, operator_role=None): + """操作人是否有权审批这张单(名单匹配 user 或 role)。 + + ★ Fail-Closed:名单为空(或全是无 value 的脏条目)→ **拒绝所有人**。 + 这是历史上「名单为空则人人可审」漏洞的修复点,不得改回 + `if entries and x not in entries` 那种短路写法 —— 那种写法在名单为空时 + 条件恒假,等于没审批。 + + ★ 刻意**不做 SUPER_ADMIN 无条件旁路**(出库的 can_approve 有这条)。 + 理由:新单的默认名单已含 SUPER_ADMIN 角色,超管本来就能审; + 再加一条无条件旁路,等于在「必须被列名」这条唯一规则上开洞。 + """ + users, roles = ScrapApprovalService._approver_entries(req.get_allowed_approvers()) + if not users and not roles: + return False + if str(operator_id) in users: + return True + role = operator_role + if role is None: + role = ScrapApprovalService.resolve_operator_role(operator_id) + return bool(role) and str(role).upper() in roles + + @staticmethod + def assert_same_company(req, operator_id): + """角色级审批的**必要配套**:公司隔离。 + + 主管共 6 人(IRIS 5 / LICA 1)。只按角色放行而不比公司, + LICA 的主管就能审 IRIS 的报废单 —— 这是真实的跨公司越权通道。 + + 口径:申请人所属公司(req.company_name,提交时的快照)vs 操作人公司 + (SysUser.department,「部门」在 MOM 里就是公司名)。 + + · req.company_name 为空(本列上线前的旧单 / 申请人已被删)→ **放行**。 + 这是刻意的 fail-open:只对存量生效,而且单据仍受名单约束, + 不会把历史在途单永久卡死。 + · 操作人是超管 / 跨域(无公司归属)→ 放行。 + · 两侧都有值且不等 → PermissionError。 + """ + req_company = (req.company_name or '').strip() + if not req_company: + return + try: + from app.models.system import SysUser + op = SysUser.query.get(int(operator_id)) + except Exception: + return + if not op: + return + op_company = (op.department or '').strip() + role = (op.role or '').upper() + # 超管跨域放行(与 get_current_company_filter 对超管返回 None 同一口径) + if role == 'SUPER_ADMIN' or not op_company: + return + if op_company != req_company: + raise PermissionError("无权审批其他公司的报废申请") + + @staticmethod + def _sanitize_approvers(raw): + """校验并清洗外部传入的 allowed_approvers,返回规范化列表。 + + Fail-Closed 规则: + · 只接受 type ∈ {user, role} 且 value 非空的 dict,其余条目**丢弃并告警**; + · type=user 的值必须能转 int 且**用户在库中存在** —— 否则丢弃。 + 不校验会造出一张「指定的审批人根本不存在」的死单,谁审不了、只能找管理员; + · type=role 的值 strip().upper(),未知角色码只告警不拒绝 + (库里有 PURCHASE 这类 UserRole 常量里没有的历史角色码,硬校验会误伤)。 + """ + cleaned = [] + for a in (raw or []): + if not isinstance(a, dict): + logger.warning(f"[ScrapApproval] 忽略非法审批人条目(非对象): {a!r}") + continue + atype = str(a.get('type') or '').strip().lower() + value = a.get('value') + if atype not in _APPROVER_TYPES or value is None or str(value).strip() == '': + logger.warning(f"[ScrapApproval] 忽略非法审批人条目: {a!r}") + continue + if atype == 'user': + try: + uid = int(value) + except (TypeError, ValueError): + logger.warning(f"[ScrapApproval] 忽略无效的审批人ID: {value!r}") + continue + from app.models.system import SysUser + if not SysUser.query.get(uid): + logger.warning(f"[ScrapApproval] 忽略不存在的审批人ID: {uid}") + continue + cleaned.append({'type': 'user', 'value': uid}) + else: + role_code = str(value).strip().upper()[:50] + logger.warning(f"[ScrapApproval] 使用角色级审批人: {role_code}(未校验角色码是否存在)") + cleaned.append({'type': 'role', 'value': role_code}) + return cleaned + @staticmethod def generate_request_no(): now = _beijing() @@ -50,15 +199,39 @@ class ScrapApprovalService: # ------------------------------------------------------------------ @staticmethod def submit_approval(applicant_id, items, allowed_approvers=None, remark=None, - approver_id=None, force_approval=False): + approver_id=None, force_approval=False, + reason_category=None, company_name=None, source_ref=None, + commit=True): """ 提交报废申请(仅锁定“意向”,不扣库存;扣减在库管执行时进行) items 每项必须包含 source_table + stock_id(精准实物),可带 scrap_qty / 快照字段。 + + 审批人有两条路,优先看 approver_id: + · approver_id 有值 → 名单 = [{"type":"user","value":N}](旧路径,行为不变); + · approver_id 为空 → 用 allowed_approvers;它也为空则回落 + DEFAULT_SCRAP_APPROVER_ROLES(角色级审批:有主管权限的都能审)。 + + reason_category / company_name / source_ref: + 生产报废(Track → MOM)用。company_name 是角色级审批做公司隔离的前提。 + + commit=False:只 flush,把提交权交给调用方 —— 供「退回 + 提报废申请」 + 组合成一个原子操作(见 app/services/return_service.py)。 + ⚠️ 调用方拿到的是**未提交**的对象,必须在自己的事务里 commit/rollback。 """ + from app.models.scrap_approval import normalize_scrap_category + if not items: raise ValueError("报废明细不能为空") + # 分类码在入口就归一化:未知值直接抛,别把脏码写进库里等统计时才发现。 + # ★ 不传 = **库存报废**:MOM 自身流程(界面选库存行报废、不良品看板、 + # 借还记录)走下来的都算库存报废;只有 Track 那条链会显式传 PRODUCTION + # (生产报废)。这样「走 Track 的 = 生产报废、其余的 = 库存报废」这条 + # 二分法在写入那一刻就成立,不依赖任何推导。 + from app.models.scrap_approval import SCRAP_CATEGORY_STOCK + category = normalize_scrap_category(reason_category, default=SCRAP_CATEGORY_STOCK) + # ★ 来源适配:三类来源(库存行 / 在管不良品 / 借出未还)各有不同的 # 可报废上限、扣减行为与快照字段,差异全部收敛在 scrap_sources 里。 # 原先此处硬编码「只认三张库存表」,导致借出未还与在管不良品只能 @@ -114,42 +287,96 @@ class ScrapApprovalService: from app.services.approval_control import resolve_approval_control _, flagged_materials = resolve_approval_control(normalized) - if not approver_id: - if flagged_materials: - _names = ";".join(f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials) - raise ValueError(f"以下物料需审批报废:{_names}。请选择审批人后再提交") - raise ValueError("报废申请必须选择审批人后再提交") - - allowed_approvers = [{"type": "user", "value": int(approver_id)}] + if approver_id: + # 旧路径:指定了具体审批人,名单钉死为这一人。行为与改造前逐字一致。 + final_approvers = [{"type": "user", "value": int(approver_id)}] + else: + final_approvers = ScrapApprovalService._sanitize_approvers(allowed_approvers) + if not final_approvers: + # ★ 角色级审批下**永远有合法审批人**,不再报「必须选择审批人」。 + # Fail-Closed 由「必须持 scrap_approval 权限码 且 命中角色」两道门保证 + # (见 can_operator_approve 与 app/api/v1/scrap.py 的装饰器)。 + final_approvers = [ + {"type": "role", "value": r} for r in DEFAULT_SCRAP_APPROVER_ROLES + ] + # flagged_materials 此时只剩「提示文案」价值,记日志便于回溯 + if flagged_materials: + _names = ";".join( + f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials + ) + logger.info(f"[ScrapApproval] 命中需审批物料(角色级审批):{_names}") req = ScrapApproval( request_no=ScrapApprovalService.generate_request_no(), applicant_id=applicant_id, remark=remark, + company_name=(company_name or '').strip() or None, + reason_category=category, + source_ref=(source_ref or '').strip() or None, ) req.set_items(normalized) - req.set_allowed_approvers(allowed_approvers) + req.set_allowed_approvers(final_approvers) # ★ 恒为「待审批」,不再走免审批自动通过分支 req.status = 0 db.session.add(req) - db.session.commit() - logger.info(f"[ScrapApproval] 提交成功 {req.request_no} approver={approver_id}") + if commit: + db.session.commit() + else: + # ★ 只 flush:把 req 交给调用方的事务,让「退回 + 提报废申请」原子化。 + # flush 后 req.id / req.request_no 均可用(request_no 是 SQL 计数生成, + # 事务内可见自己的写入)。 + db.session.flush() + logger.info( + f"[ScrapApproval] 提交成功 {req.request_no} " + f"approvers={final_approvers} category={category} commit={commit}" + ) return req # ------------------------------------------------------------------ # 列表 # ------------------------------------------------------------------ @staticmethod - def get_list(page=1, limit=10, status=None, applicant_id=None, approver_id=None): + def get_list(page=1, limit=10, status=None, applicant_id=None, + approver_id=None, approver_role=None, company_name=None): + """申请表分页。 + + approver_id / approver_role:只看「指定给我的」或「属于我这个角色的」 + 待办单(scope=pending)。两者是 **OR** 关系 —— 一张单可能既指名某人、 + 又对某角色开放,任一命中就该出现。 + + ⚠️ SQL 层只能用**宽松 LIKE** 匹配 JSON 文本(items_json 是 text 不是 jsonb, + allowed_approvers 同样是 text,没有 JSON 包含查询可用)。 + 已知会误命中:`"value": 12` 会匹配到 `"value": 123`。 + 本轮**不收紧** —— 误命中只让某人多看到一条待办,点审批会被 approve() 的 + 精确集合判定拦下,不构成越权;而收紧成 `12,`/`12}` 一旦遇到分隔符或空格 + 变体就会**漏命、待办静默消失**。审批待办场景:漏 > 多。 + """ query = ScrapApproval.query if status is not None: query = query.filter(ScrapApproval.status == status) if applicant_id is not None: query = query.filter(ScrapApproval.applicant_id == applicant_id) + + approver_conds = [] if approver_id is not None: - query = query.filter(ScrapApproval.allowed_approvers.like(f'%"value": {approver_id}%')) + approver_conds.append( + ScrapApproval.allowed_approvers.like(f'%"value": {int(approver_id)}%') + ) + if approver_role: + role_code = str(approver_role).strip().upper() + if role_code: + approver_conds.append( + ScrapApproval.allowed_approvers.like(f'%"value": "{role_code}"%') + ) + if approver_conds: + query = query.filter(db.or_(*approver_conds)) + + # 公司隔离:只对带了公司的单生效,company_name 为空的旧单不受限 + if company_name: + query = query.filter(ScrapApproval.company_name == company_name) + query = query.order_by(ScrapApproval.created_at.desc()) pg = query.paginate(page=page, per_page=limit, error_out=False) return { @@ -170,7 +397,7 @@ class ScrapApprovalService: if req.status != 0: raise ValueError("当前状态不允许审批(仅待审批可操作)") - # 仅被指定的审批人可操作。 + # 仅被指定的审批人(或审批角色)可操作。 # # ★ Fail-Closed:原实现是 `if user_entries and str(operator_id) not in ...` # —— 当 allowed_approvers 为空(或条目里没有 type='user')时 user_entries @@ -178,12 +405,18 @@ class ScrapApprovalService: # 「报废一律需审批」直接矛盾:留一扇「无审批人则人人可审」的门, # 等于没有审批。现改为无名单即拒绝。 # 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 + # + # ★ 角色级审批(2026-09):名单里现在还可以是 {"type":"role","value":"SUPERVISOR"}。 + # 放宽的只是「条目类型」,**空名单依然拒绝所有人** —— + # 这条不变量在 can_operator_approve 里,任何改动都不得把它改回 fail-open。 allowed = req.get_allowed_approvers() or [] - user_entries = [str(a.get('value')) for a in allowed if a.get('type') == 'user'] - if not user_entries: - raise ValueError("该申请单未指定审批人,无法审批,请联系管理员处理") - if str(operator_id) not in user_entries: - raise ValueError("只有被指定的审批人可以审批该申请") + user_entries, role_entries = ScrapApprovalService._approver_entries(allowed) + if not user_entries and not role_entries: + raise ValueError("该申请单未指定审批人或审批角色,无法审批,请联系管理员处理") + if not ScrapApprovalService.can_operator_approve(req, operator_id): + raise ValueError("只有被指定的审批人或审批角色可以审批该申请") + # ★ 角色级审批的必要配套:不隔离公司,LICA 主管就能审 IRIS 的单 + ScrapApprovalService.assert_same_company(req, operator_id) if action == 'approve': req.status = 1 diff --git a/inventory-backend/app/services/scrap_sources.py b/inventory-backend/app/services/scrap_sources.py index 76151df..ea33773 100644 --- a/inventory-backend/app/services/scrap_sources.py +++ b/inventory-backend/app/services/scrap_sources.py @@ -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,