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:
155
db_migrations/phase12_production_scrap.sql
Normal file
155
db_migrations/phase12_production_scrap.sql
Normal file
@ -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 是 '<company>:<track_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
|
||||
'外部系统唯一引用,格式 <company>:<外部单号>。用于幂等与追溯';
|
||||
|
||||
-- 唯一索引:同一外部单据不得重复受理(部分索引,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
|
||||
'外部系统唯一引用,格式 <company>:<外部单号>。外部接口据此判重(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;
|
||||
78
db_migrations/phase13_scrap_category_stock_default.sql
Normal file
78
db_migrations/phase13_scrap_category_stock_default.sql
Normal file
@ -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(<公司>:<Track单据号>)。有 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;
|
||||
Reference in New Issue
Block a user