Files
KCGL/db_migrations/phase12_production_scrap.sql
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

156 lines
8.2 KiB
PL/PgSQL
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.

-- =============================================================================
-- 生产导致报废: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;