Compare commits
34 Commits
3b777af87d
...
3.0AI添加
| Author | SHA1 | Date | |
|---|---|---|---|
| 50232bb58f | |||
| 9c1a9886a5 | |||
| d12435a801 | |||
| 52a3c4079c | |||
| 0b87a72a41 | |||
| 89db1d14d3 | |||
| 956d4acb5f | |||
| 91672214c8 | |||
| 46aec0b954 | |||
| 6a9e41f53c | |||
| 7d203b20c1 | |||
| 1d6beaa376 | |||
| 0614597dbe | |||
| b2a2f513ae | |||
| 4ed8abcc6d | |||
| 446c064836 | |||
| ded5dfd7b3 | |||
| ad2d27cd72 | |||
| 69d9f010a9 | |||
| c5356a27ed | |||
| 3e311048ef | |||
| 4c7f0ae47c | |||
| c7f85880a9 | |||
| bfd0db791c | |||
| 822f8976a9 | |||
| c272c01388 | |||
| 7c3b67bf53 | |||
| 0b4a2b29d6 | |||
| 3851ebd7bc | |||
| d99396ecb1 | |||
| 91db54982c | |||
| 0f929468a8 | |||
| b85be2dfdf | |||
| de34a939d0 |
23
.gitignore
vendored
23
.gitignore
vendored
@ -20,3 +20,26 @@ inventory-web/*.local
|
|||||||
pgdata_docker/
|
pgdata_docker/
|
||||||
inventory-backend/uploads/
|
inventory-backend/uploads/
|
||||||
.aider*
|
.aider*
|
||||||
|
|
||||||
|
# --- 大体积二进制与数据产物 ---
|
||||||
|
# 这些一旦误提交,仓库会瞬间膨胀且**无法通过后续提交瘦身**(历史里永久保留),
|
||||||
|
# 只能靠 filter-repo 重写历史。故在此显式拦住,宁可有遗漏再补。
|
||||||
|
base_images.tar # 465MB 的 docker 镜像导出包
|
||||||
|
pgdata_docker_backup_alpine/ # Postgres 数据目录副本(pgdata_docker/ 已有同样规则)
|
||||||
|
inventory_backup.dump # 数据库转储
|
||||||
|
*.dump
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
Odoo_Archive/ # Odoo 侧归档
|
||||||
|
*.pptx # 培训材料(二进制,且体积大)
|
||||||
|
*.xlsx
|
||||||
|
|
||||||
|
# --- 上传与本地环境 ---
|
||||||
|
uploads/ # 根目录上传物(inventory-backend/uploads/ 已有规则)
|
||||||
|
.backup_migration/
|
||||||
|
.qwen/
|
||||||
|
.gitconfig # 本机 git 配置,随机器不同而不同
|
||||||
|
inventory-backend/nul # Windows `> NUL` 重定向误产生的文件
|
||||||
|
|
||||||
|
# --- 构建产物 ---
|
||||||
|
inventory-web/tsc-out.txt
|
||||||
|
|||||||
72
db_migrations/phase11_trans_outbound_request_link.sql
Normal file
72
db_migrations/phase11_trans_outbound_request_link.sql
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 出库明细补记「来源申请单」,让出库流水能反查回它属于哪张单
|
||||||
|
--
|
||||||
|
-- 问题
|
||||||
|
-- trans_outbound 只有 applicant_id(申请人,见 phase8),没有任何指回
|
||||||
|
-- outbound_approval 的外键。出库时 request_id 是**强制必填**的,approval
|
||||||
|
-- 对象在服务层也一直在手上,但只把 applicant_id 复制过来就丢弃了 —— 于是
|
||||||
|
-- 从一条出库明细无法回答「这是哪张申请单出的库」:
|
||||||
|
-- · 申请单号 request_no 查不到
|
||||||
|
-- · 申请说明 / 明细快照 items_json 查不到
|
||||||
|
-- · 同一张申请单分几次扫码出库时,这几条明细也串不起来
|
||||||
|
--
|
||||||
|
-- 本次改动
|
||||||
|
-- trans_outbound 新增 request_id,创建出库时从关联审批单带出。
|
||||||
|
-- 创建出库时 approval 恒非 None(request_id 已强制必填),故新单据必然有值。
|
||||||
|
--
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- ★ 为什么是 request_id(主键)而不是 request_no(单号)
|
||||||
|
-- 与 phase8 同一个理由:主键没有歧义,单号是业务字段、将来格式可能变。
|
||||||
|
-- 要展示单号时 join 一次 outbound_approval 即可。
|
||||||
|
--
|
||||||
|
-- ★ 为什么不回填存量行
|
||||||
|
-- 与 phase8 完全一样:存量出库明细与其来源审批单之间**没有任何可用的关联**
|
||||||
|
-- (创建时只把审批单状态置为 3,没落任何外键),无从回填。刻意留 NULL 而不是
|
||||||
|
-- 按单号/时间猜 —— NULL 表示「这张单产生于本列上线之前」。
|
||||||
|
--
|
||||||
|
-- ★ 与 applicant_id 的关系
|
||||||
|
-- 两者都由 approval 带出,但语义正交:applicant_id 是「人」,request_id 是
|
||||||
|
-- 「那张单」。退回补发用前者(挂回真正该拿东西的人),单据追溯用后者。
|
||||||
|
--
|
||||||
|
-- 幂等:带 IF NOT EXISTS,可重复执行。不含 psql 元命令,DataGrip 可直接执行。
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE trans_outbound
|
||||||
|
ADD COLUMN IF NOT EXISTS request_id integer;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN trans_outbound.request_id IS
|
||||||
|
'来源出库申请单ID(outbound_approval.id),创建出库时从关联审批单带出。该列上线前的历史行为 NULL(无从回填)';
|
||||||
|
|
||||||
|
-- 支撑「某张申请单出过哪些明细」的反查,以及按单聚合对账
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_trans_outbound_request
|
||||||
|
ON trans_outbound (request_id);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 执行后核对
|
||||||
|
-- =============================================================================
|
||||||
|
SELECT '=== 1) 新列已就位 ===' AS "核对项";
|
||||||
|
SELECT column_name, data_type FROM information_schema.columns
|
||||||
|
WHERE table_name = 'trans_outbound' AND column_name = 'request_id';
|
||||||
|
|
||||||
|
SELECT '=== 2) 索引已就位 ===' AS "核对项";
|
||||||
|
SELECT indexname FROM pg_indexes
|
||||||
|
WHERE tablename = 'trans_outbound' AND indexname = 'ix_trans_outbound_request';
|
||||||
|
|
||||||
|
SELECT '=== 3) 存量行应全部为 NULL(历史无从回填)===' AS "核对项";
|
||||||
|
SELECT count(*) AS 出库明细总数,
|
||||||
|
count(request_id) AS 已记录来源单
|
||||||
|
FROM trans_outbound;
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 回滚段
|
||||||
|
-- =============================================================================
|
||||||
|
-- BEGIN;
|
||||||
|
-- DROP INDEX IF EXISTS ix_trans_outbound_request;
|
||||||
|
-- ALTER TABLE trans_outbound DROP COLUMN IF EXISTS request_id;
|
||||||
|
-- COMMIT;
|
||||||
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;
|
||||||
108
db_migrations/phase14_defective_goods_location_snapshot.sql
Normal file
108
db_migrations/phase14_defective_goods_location_snapshot.sql
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 不良品在管台账:补「原库位 / 批次(序列号)」快照列
|
||||||
|
--
|
||||||
|
-- 背景
|
||||||
|
-- trans_defective_goods 只冗余了 material_name / spec_model,却没有存库位与
|
||||||
|
-- 批次。于是报废台账页、报废申请单明细上,凡是不良品来源的行,库位与
|
||||||
|
-- 批次一律显示 "-" —— 而它们的源库存行往往**还在**,本该取得到。
|
||||||
|
--
|
||||||
|
-- 应用层原先的处置是在 _resolve_materials 与 DefectiveScrapAdapter.snapshot
|
||||||
|
-- 里直接硬编码空串,注释理由是「源库存行可能已被物理删除」。这个担忧对
|
||||||
|
-- material_name/spec_model 成立(所以它们有冗余快照),但库位/批次**没有**
|
||||||
|
-- 快照列,一律置空等于放弃了「源行还在」这个绝大多数情形。
|
||||||
|
--
|
||||||
|
-- 本次改动(A+B)
|
||||||
|
-- A. 应用层改为「快照优先 + 回查源库存行兜底」(本次只加列,逻辑在代码侧)
|
||||||
|
-- B. 本文件补两列,把「源行可能被删」这个担忧真正解决掉:退回那一刻
|
||||||
|
-- stock_row 已 with_for_update() 锁在手里,库位/批次随手可取,
|
||||||
|
-- 落库后即使源行日后被物理删除,报表依然有值。
|
||||||
|
--
|
||||||
|
-- 口径(2026-09-23 与业务确认)
|
||||||
|
-- 存的是「**原库位**」—— 这批货最初在哪,而不是坏件当前所在的不良品区。
|
||||||
|
-- 与三张库存表来源的报废记录口径一致。不良品区的位置本系统未记录。
|
||||||
|
--
|
||||||
|
-- ★ batch_number 列对 stock_product 来源存的是 serial_number:
|
||||||
|
-- 成品表没有 batch_number 列(见 information_schema),取值口径沿用
|
||||||
|
-- 应用层的 COALESCE(batch_number, serial_number),与 _from_stock 一致。
|
||||||
|
-- 故本列语义为「批次或序列号」,与前端列名「批次/序列号」对齐。
|
||||||
|
--
|
||||||
|
-- 安全性:纯追加式 DDL,无 NOT NULL、无 DEFAULT,既有行取值 NULL。
|
||||||
|
-- 幂等:IF NOT EXISTS + 带 WHERE 的 UPDATE,可重复执行。
|
||||||
|
--
|
||||||
|
-- 执行
|
||||||
|
-- docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 1) 快照列(长度与三张库存表对齐,均为 varchar(100))
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
ALTER TABLE trans_defective_goods
|
||||||
|
ADD COLUMN IF NOT EXISTS warehouse_location varchar(100),
|
||||||
|
ADD COLUMN IF NOT EXISTS batch_number varchar(100);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN trans_defective_goods.warehouse_location IS
|
||||||
|
'原库位快照(退回时取自源库存行)。源行被物理删除时此处仍有值,故报表优先用它';
|
||||||
|
COMMENT ON COLUMN trans_defective_goods.batch_number IS
|
||||||
|
'批次/序列号快照(退回时取自源库存行的 batch_number,成品表缺失时取 serial_number)';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 2) 存量回填:按 source_table + stock_id 回查源库存行
|
||||||
|
-- 三张表分别 UPDATE(多态关联,无法用一条语句表达)。
|
||||||
|
-- WHERE warehouse_location IS NULL 保证幂等,且不覆盖已回填过的值。
|
||||||
|
--
|
||||||
|
-- 回填不到的(源行已被物理删除)保持 NULL —— 刻意不写成空串,
|
||||||
|
-- 这样下面的核对能数出「真正取不到」的行数,与「已确认无库位」区分开。
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
UPDATE trans_defective_goods g
|
||||||
|
SET warehouse_location = COALESCE(b.warehouse_location, ''),
|
||||||
|
batch_number = COALESCE(NULLIF(b.batch_number, ''), b.serial_number, '')
|
||||||
|
FROM stock_buy b
|
||||||
|
WHERE g.source_table = 'stock_buy'
|
||||||
|
AND g.stock_id = b.id
|
||||||
|
AND g.warehouse_location IS NULL;
|
||||||
|
|
||||||
|
UPDATE trans_defective_goods g
|
||||||
|
SET warehouse_location = COALESCE(s.warehouse_location, ''),
|
||||||
|
batch_number = COALESCE(NULLIF(s.batch_number, ''), s.serial_number, '')
|
||||||
|
FROM stock_semi s
|
||||||
|
WHERE g.source_table = 'stock_semi'
|
||||||
|
AND g.stock_id = s.id
|
||||||
|
AND g.warehouse_location IS NULL;
|
||||||
|
|
||||||
|
-- ★ 成品表无 batch_number 列,只取 serial_number
|
||||||
|
UPDATE trans_defective_goods g
|
||||||
|
SET warehouse_location = COALESCE(p.warehouse_location, ''),
|
||||||
|
batch_number = COALESCE(p.serial_number, '')
|
||||||
|
FROM stock_product p
|
||||||
|
WHERE g.source_table = 'stock_product'
|
||||||
|
AND g.stock_id = p.id
|
||||||
|
AND g.warehouse_location IS NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 执行后核对
|
||||||
|
-- =============================================================================
|
||||||
|
SELECT '=== 1) 回填结果:已回填 / 源行已删取不到 / 无需回填 ===' AS "核对项";
|
||||||
|
SELECT count(*) FILTER (WHERE warehouse_location IS NOT NULL) AS 已回填,
|
||||||
|
count(*) FILTER (WHERE warehouse_location IS NULL) AS 源行已删_取不到,
|
||||||
|
count(*) AS 台账总行数
|
||||||
|
FROM trans_defective_goods;
|
||||||
|
|
||||||
|
SELECT '=== 2) ★ 源行已删的明细(取不到库位属预期,应逐条确认是否为真)===' AS "核对项";
|
||||||
|
SELECT id, source_table, stock_id, sku, status
|
||||||
|
FROM trans_defective_goods
|
||||||
|
WHERE warehouse_location IS NULL
|
||||||
|
ORDER BY id;
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 回滚段(仅撤销本迁移;回填值与「原本就是空」无法区分)
|
||||||
|
-- =============================================================================
|
||||||
|
-- BEGIN;
|
||||||
|
-- ALTER TABLE trans_defective_goods DROP COLUMN IF EXISTS warehouse_location;
|
||||||
|
-- ALTER TABLE trans_defective_goods DROP COLUMN IF EXISTS batch_number;
|
||||||
|
-- COMMIT;
|
||||||
@ -44,6 +44,12 @@ services:
|
|||||||
TRACK_WEBHOOK_KEY: ${TRACK_WEBHOOK_KEY:-2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768}
|
TRACK_WEBHOOK_KEY: ${TRACK_WEBHOOK_KEY:-2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768}
|
||||||
TRACK_API_URL: http://track_backend_prod:8000
|
TRACK_API_URL: http://track_backend_prod:8000
|
||||||
TRACK_OUTBOUND_WEBHOOK_URL: http://track_backend_prod:8000/api/v1/external/webhooks/mom-outbound
|
TRACK_OUTBOUND_WEBHOOK_URL: http://track_backend_prod:8000/api/v1/external/webhooks/mom-outbound
|
||||||
|
# ★ 对内接口(Track → MOM 生产报废受理)共享密钥,请求头 X-API-Key。
|
||||||
|
# 与 TRACK_WEBHOOK_KEY 刻意分离(方向相反、权限不同),独立轮换。
|
||||||
|
# ★ 刻意**不给默认值** —— 未配置时内部接口 503(Fail-Closed),
|
||||||
|
# 而不是让一个写接口用着众所周知的密钥在生产上悄悄打开。
|
||||||
|
# 部署时必须在服务器 .env 里显式配置,且与 Track 侧配同一个值。
|
||||||
|
MOM_INTERNAL_API_KEY: ${MOM_INTERNAL_API_KEY:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
# 加入 mom_net 外部网络,与 Track 生产容器互通
|
# 加入 mom_net 外部网络,与 Track 生产容器互通
|
||||||
|
|||||||
@ -34,6 +34,8 @@ services:
|
|||||||
MAIL_DEFAULT_SENDER: wms@iris-rs.cn
|
MAIL_DEFAULT_SENDER: wms@iris-rs.cn
|
||||||
MAIL_USE_SSL: "true"
|
MAIL_USE_SSL: "true"
|
||||||
MAIL_USE_TLS: "false"
|
MAIL_USE_TLS: "false"
|
||||||
|
# MOM 系统日报收件人(逗号分隔可配多个)。每天 17:30 北京时间发送。
|
||||||
|
MAIL_DAILY_REPORT_RECIPIENTS: ${MAIL_DAILY_REPORT_RECIPIENTS:-duxingchen@iris-rs.cn}
|
||||||
# Track 系统 Webhook 通知(未配置则后端静默跳过,不通知)
|
# Track 系统 Webhook 通知(未配置则后端静默跳过,不通知)
|
||||||
TRACK_WEBHOOK_URL: ${TRACK_WEBHOOK_URL:-}
|
TRACK_WEBHOOK_URL: ${TRACK_WEBHOOK_URL:-}
|
||||||
TRACK_WEBHOOK_KEY: ${TRACK_WEBHOOK_KEY:-}
|
TRACK_WEBHOOK_KEY: ${TRACK_WEBHOOK_KEY:-}
|
||||||
@ -41,6 +43,20 @@ services:
|
|||||||
TRACK_API_URL: ${TRACK_API_URL:-}
|
TRACK_API_URL: ${TRACK_API_URL:-}
|
||||||
# Track 出库 webhook(发货出库时通知 Track 标记"已出库")
|
# Track 出库 webhook(发货出库时通知 Track 标记"已出库")
|
||||||
TRACK_OUTBOUND_WEBHOOK_URL: ${TRACK_OUTBOUND_WEBHOOK_URL:-}
|
TRACK_OUTBOUND_WEBHOOK_URL: ${TRACK_OUTBOUND_WEBHOOK_URL:-}
|
||||||
|
# ★ 多 Track 实例路由表:按 material_base.company_name 决定走哪个实例。
|
||||||
|
# 上面三个扁平变量保留为「未命中路由表」时的回落默认值(向后兼容)。
|
||||||
|
# ★ 用容器名互访(同属 projects_default 网络),不走宿主机端口。
|
||||||
|
TRACK_ROUTES: >-
|
||||||
|
{"IRIS":{"api":"http://track_backend:8000",
|
||||||
|
"inbound":"http://track_backend:8000/api/v1/external/webhooks/mom-inbound",
|
||||||
|
"outbound":"http://track_backend:8000/api/v1/external/webhooks/mom-outbound"},
|
||||||
|
"LICA":{"api":"http://lica_backend:8000",
|
||||||
|
"inbound":"http://lica_backend:8000/api/v1/external/webhooks/mom-inbound",
|
||||||
|
"outbound":"http://lica_backend:8000/api/v1/external/webhooks/mom-outbound"}}
|
||||||
|
# ★ 对内接口(Track → MOM)共享密钥,请求头 X-API-Key。
|
||||||
|
# 与 TRACK_WEBHOOK_KEY 刻意分离:方向相反、权限不同(能发起报废审批),
|
||||||
|
# 独立轮换不连坐。未配置则内部接口一律 503(Fail-Closed),不静默放行。
|
||||||
|
MOM_INTERNAL_API_KEY: ${MOM_INTERNAL_API_KEY:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|
||||||
|
|||||||
@ -135,6 +135,20 @@ def create_app():
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"❌ 错误: Scrap 模块导入失败: {e}")
|
print(f"❌ 错误: Scrap 模块导入失败: {e}")
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 2.6b 注册对内接口模块(Track → MOM)
|
||||||
|
#
|
||||||
|
# ★ 鉴权走 X-API-Key(config.MOM_INTERNAL_API_KEY),**不走 JWT** ——
|
||||||
|
# Track 没有 MOM 账号,也不该有:申请人身份由请求体显式携带。
|
||||||
|
# ★ 只注册 /api/v1 一条路径,**不做 legacy 双注册**:内部写接口不该多一个入口。
|
||||||
|
# -----------------------------------------------------
|
||||||
|
try:
|
||||||
|
from app.api.v1.internal import internal_bp
|
||||||
|
app.register_blueprint(internal_bp, url_prefix='/api/v1/internal')
|
||||||
|
print("✅ Internal 模块注册成功")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"❌ 错误: Internal 模块导入失败: {e}")
|
||||||
|
|
||||||
# -----------------------------------------------------
|
# -----------------------------------------------------
|
||||||
# 2.8 注册采购管理模块
|
# 2.8 注册采购管理模块
|
||||||
# -----------------------------------------------------
|
# -----------------------------------------------------
|
||||||
|
|||||||
@ -1,47 +1,312 @@
|
|||||||
# inventory-backend/app/api/v1/audit.py
|
# inventory-backend/app/api/v1/audit.py
|
||||||
from flask import Blueprint, request, jsonify, current_app
|
import io
|
||||||
from flask_jwt_extended import jwt_required, get_jwt
|
from datetime import datetime, timedelta
|
||||||
from app.utils.decorators import permission_required
|
|
||||||
from app.models.audit import AuditLog
|
from flask import Blueprint, current_app, jsonify, request, send_file
|
||||||
|
from flask_jwt_extended import jwt_required
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from sqlalchemy import or_
|
from app.models.audit import AuditLog
|
||||||
from datetime import datetime
|
from app.models.system import SysUser
|
||||||
import json
|
from app.services.audit_export_service import (
|
||||||
|
SYSTEM_USERNAME,
|
||||||
|
build_audit_workbook,
|
||||||
|
changes_summary,
|
||||||
|
format_target_display,
|
||||||
|
load_child_lookup,
|
||||||
|
load_material_context,
|
||||||
|
load_ref_maps,
|
||||||
|
sanitize_details,
|
||||||
|
)
|
||||||
|
from app.utils.decorators import get_current_company_filter, permission_required
|
||||||
|
|
||||||
audit_bp = Blueprint('audit', __name__)
|
audit_bp = Blueprint('audit', __name__)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 操作类型归一化
|
# 操作类型归一化 / 中文化标签
|
||||||
#
|
#
|
||||||
# 问题背景:历史数据里 action 有两套写法 —— 早期装饰器(已废弃)写入中文
|
# ★ 映射表已统一收敛到 app/utils/audit_labels.py(**唯一来源**)。
|
||||||
# (新增/修改/删除/批量删除…),现行监听器写入大写英文(CREATE/UPDATE/DELETE)。
|
# 本模块与日报服务共用同一份,前端经 GET /audit/labels 拉取同一份 ——
|
||||||
# 前端下拉框直接取 DISTINCT action,于是同时出现「CREATE」和「新增」两个选项,
|
# 改一处即可,不会再出现"两边手工同步、改一边漏一边"的漂移。
|
||||||
# 而表格里二者又都显示为「新增」(actionMap 做了映射),用户无法分辨。
|
|
||||||
#
|
#
|
||||||
# 后果:用户选了看得懂的中文项,只能搜到 3-4 月的历史数据,误以为"没有最近的内容"。
|
# 历史问题(迁移前):前端 AuditLog.vue 自带一份 fieldMap,后端这里自带一份
|
||||||
#
|
# ACTION_ALIASES,两份副本各自演化。
|
||||||
# 处理:对外只暴露规范值(CREATE/UPDATE/DELETE),筛选时自动展开到全部别名,
|
|
||||||
# 历史数据无需迁移即可被正确检索。
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
ACTION_ALIASES = {
|
from app.utils.audit_labels import ( # noqa: E402
|
||||||
'CREATE': ('CREATE', 'create', 'INSERT', 'insert', '新增', '批量生成'),
|
ACTION_ALIASES,
|
||||||
'UPDATE': ('UPDATE', 'update', '修改', '分配', '归还'),
|
canon_action,
|
||||||
'DELETE': ('DELETE', 'delete', '删除', '批量删除'),
|
expand_modules,
|
||||||
}
|
labels_payload,
|
||||||
|
module_options,
|
||||||
|
)
|
||||||
|
|
||||||
# 反向索引:任意别名 → 规范值
|
# 列表页「变更摘要」列的截断长度。太长会把表格撑变形且扫读性差 ——
|
||||||
_ALIAS_TO_CANON = {
|
# 完整逐字段对比在详情抽屉里,摘要只负责"一眼看出改了啥"。
|
||||||
alias: canon
|
SUMMARY_LIMIT = 120
|
||||||
for canon, aliases in ACTION_ALIASES.items()
|
|
||||||
for alias in aliases
|
# 单次导出的记录数上限。
|
||||||
}
|
#
|
||||||
|
# ★ 这是**熔断**,不是"为了让报表好看"的截断:
|
||||||
|
# · Excel 单表硬上限 1,048,576 行,长表展开后会成倍放大;
|
||||||
|
# · 同步请求跑太久会超时,用户只看到"失败",不知道是数据量的问题。
|
||||||
|
# 实测当前全库共 5.8 万条、全量导出 3.3 秒 / 2MB,正常情况下永远碰不到这个值。
|
||||||
|
# 触顶时会**显式告知**(写进文件名和汇总工作表),不静默丢弃 ——
|
||||||
|
# 报表的读法就是"看到的就是全部",偷偷砍掉比报错更危险。
|
||||||
|
EXPORT_LIMIT = 100000
|
||||||
|
|
||||||
|
|
||||||
def canon_action(action):
|
# =============================================================================
|
||||||
"""把任意写法的 action 归一化为规范值;无法识别时原样返回"""
|
# 查询构造 —— /logs 与 /logs/export 的**唯一**入口
|
||||||
return _ALIAS_TO_CANON.get((action or '').strip(), (action or '').strip())
|
# =============================================================================
|
||||||
|
|
||||||
|
def _build_audit_query(args):
|
||||||
|
"""
|
||||||
|
按请求参数构造审计日志查询,返回 (query, company_limit)。
|
||||||
|
|
||||||
|
★ 列表与导出共用本函数。两处各写一份筛选逻辑,迟早会出现
|
||||||
|
"页面上看到 300 条、导出来 280 条"这种对不上的情况 ——
|
||||||
|
而报表对不上,比没有报表更糟(人会照着错的数字做决定)。
|
||||||
|
这正是 audit_labels.py 开头警告过的"两份手工同步的副本必然漂移"。
|
||||||
|
|
||||||
|
★ 可多选的参数用逗号分隔(与仓库既有惯例一致:semi.vue 的 statuses
|
||||||
|
用 join(',') 传参、inbound/product.py 用 split(',') 接收)。
|
||||||
|
"""
|
||||||
|
query = AuditLog.query
|
||||||
|
|
||||||
|
# ---------- 操作来源:all(默认) / user(仅真实用户) / system(仅系统) ----------
|
||||||
|
# 背景:改造前的全局监听器没有请求上下文守卫,系统初始化与后台定时任务
|
||||||
|
# 产生了大量 username='system' 的日志(历史存量约 1.8 万条),会把列表刷屏。
|
||||||
|
operator_type = (args.get('operator_type') or 'all').strip().lower()
|
||||||
|
if operator_type not in ('all', 'user', 'system'):
|
||||||
|
operator_type = 'all'
|
||||||
|
if operator_type == 'user':
|
||||||
|
query = query.filter(AuditLog.username != SYSTEM_USERNAME)
|
||||||
|
elif operator_type == 'system':
|
||||||
|
query = query.filter(AuditLog.username == SYSTEM_USERNAME)
|
||||||
|
|
||||||
|
# ---------- 操作人(精确匹配)----------
|
||||||
|
# ★ 由模糊(LIKE %值%)改为精确:前端的操作人已从自由输入改为**下拉选择**,
|
||||||
|
# 选出来的是完整账号,再模糊匹配就是错的 —— 将来出现 `gaoxue` 与
|
||||||
|
# `gaoxue2` 两个账号时,选前者会连带把后者的记录一起查出来。
|
||||||
|
# 部分匹配的需求由下拉的 filterable(在选项里搜)承担,不需要落到 SQL。
|
||||||
|
username = (args.get('username') or '').strip()
|
||||||
|
if username:
|
||||||
|
query = query.filter(AuditLog.username == username)
|
||||||
|
|
||||||
|
# ---------- 模块(支持多选 + 聚合别名)----------
|
||||||
|
# ★ 经 expand_modules 展开:「入库(全部)」会变成它名下的全部成员值。
|
||||||
|
# 这一步是必需的 —— 审计的 module 在 2026-09-10 换过一次口径
|
||||||
|
# (旧的「入库管理」→ 新的「库存管理」),不展开就会在换口径那天
|
||||||
|
# 断掉,用户看到"入库记录突然没了"。
|
||||||
|
# 详见 audit_labels.MODULE_GROUPS 的说明。
|
||||||
|
modules = [m for m in (args.get('module') or '').split(',') if m.strip()]
|
||||||
|
if modules:
|
||||||
|
query = query.filter(AuditLog.module.in_(expand_modules(modules)))
|
||||||
|
|
||||||
|
# ---------- 操作类型(支持多选)----------
|
||||||
|
actions = [a for a in (args.get('action') or '').split(',') if a.strip()]
|
||||||
|
if actions:
|
||||||
|
# ★ 逐个归一化再合并别名集合,不能只处理第一个:
|
||||||
|
# 历史数据里 CREATE 与「新增」混用,多选时若只对首个值展开别名,
|
||||||
|
# 其余选项就搜不到早期数据(4 月前的中文 action)。
|
||||||
|
aliases = set()
|
||||||
|
for a in actions:
|
||||||
|
canon = canon_action(a)
|
||||||
|
aliases.update(ACTION_ALIASES.get(canon, (a,)))
|
||||||
|
query = query.filter(AuditLog.action.in_(aliases))
|
||||||
|
|
||||||
|
target_id = (args.get('target_id') or '').strip()
|
||||||
|
if target_id:
|
||||||
|
query = query.filter(AuditLog.target_id == target_id)
|
||||||
|
|
||||||
|
# ---------- 操作对象模糊搜索 ----------
|
||||||
|
# 与 target_id 的分工:target_id 是**精确**匹配(程序化调用、追单条记录用),
|
||||||
|
# target_keyword 是给人在搜索框里用的模糊匹配,同时命中单号/编码/名称。
|
||||||
|
#
|
||||||
|
# ★ 用 ILIKE '%kw%' 走不了 btree 索引,会顺序扫描。当前全库 5.8 万条实测
|
||||||
|
# 毫秒级,可接受;若将来量级上去了,再加 pg_trgm 的 GIN 索引即可
|
||||||
|
# (不需要改这里的写法)。
|
||||||
|
target_keyword = (args.get('target_keyword') or '').strip()
|
||||||
|
if target_keyword:
|
||||||
|
query = query.filter(or_(
|
||||||
|
AuditLog.target_id.ilike(f'%{target_keyword}%'),
|
||||||
|
AuditLog.target_name.ilike(f'%{target_keyword}%'),
|
||||||
|
))
|
||||||
|
|
||||||
|
# ---------- 日期区间 ----------
|
||||||
|
# 语义是**闭区间**:end_date 当天 23:59:59 的数据也要包含进来,
|
||||||
|
# 故上界取次日 00:00 的严格小于。写成 <= end_date 会静默丢掉当天。
|
||||||
|
start_date = (args.get('start_date') or '').strip()
|
||||||
|
if start_date:
|
||||||
|
try:
|
||||||
|
query = query.filter(
|
||||||
|
AuditLog.created_at >= datetime.strptime(start_date, '%Y-%m-%d'))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
end_date = (args.get('end_date') or '').strip()
|
||||||
|
if end_date:
|
||||||
|
try:
|
||||||
|
query = query.filter(
|
||||||
|
AuditLog.created_at < datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ---------- 【行级数据隔离】----------
|
||||||
|
# AuditLog 表**没有** company_name 字段,公司信息只能经 SysUser.department 反查。
|
||||||
|
# 审计日志的 username 存的是账号('gaoxue'),而 sys_user.username 存的是
|
||||||
|
# '高雪/gaoxue',故取 '/' 之后的账号部分去 join。
|
||||||
|
#
|
||||||
|
# ★ 导出接口必须带同样的隔离,否则"导出"就成了绕过数据隔离的后门 ——
|
||||||
|
# 页面按公司过滤、导出却给全量,这个洞比页面越权更隐蔽。
|
||||||
|
company_limit = get_current_company_filter()
|
||||||
|
if company_limit is not None:
|
||||||
|
query = query.join(
|
||||||
|
SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username
|
||||||
|
).filter(SysUser.department == company_limit)
|
||||||
|
|
||||||
|
return query, company_limit
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_summary_rows(args):
|
||||||
|
"""
|
||||||
|
把本次生效的筛选条件写成汇总表的几行。
|
||||||
|
|
||||||
|
★ 收到 Excel 的人看不到页面上的筛选框,没有这段说明就无从判断
|
||||||
|
"这份表是全量还是某个子集"。
|
||||||
|
"""
|
||||||
|
rows = []
|
||||||
|
operator_type = (args.get('operator_type') or '').strip().lower()
|
||||||
|
if operator_type:
|
||||||
|
rows.append(('筛选条件', '操作来源',
|
||||||
|
{'user': '仅真实用户', 'system': '仅系统操作'}.get(operator_type, '全部')))
|
||||||
|
|
||||||
|
labels = [
|
||||||
|
('username', '操作人(模糊匹配)'),
|
||||||
|
('module', '模块'),
|
||||||
|
('action', '操作类型'),
|
||||||
|
('target_id', '目标ID'),
|
||||||
|
('target_keyword', '操作对象(模糊匹配)'),
|
||||||
|
]
|
||||||
|
for key, label in labels:
|
||||||
|
val = (args.get(key) or '').strip()
|
||||||
|
if val:
|
||||||
|
rows.append(('筛选条件', label, val))
|
||||||
|
|
||||||
|
start_date = (args.get('start_date') or '').strip()
|
||||||
|
end_date = (args.get('end_date') or '').strip()
|
||||||
|
if start_date or end_date:
|
||||||
|
rows.append(('筛选条件', '日期区间',
|
||||||
|
f"{start_date or '不限'} ~ {end_date or '不限'}"))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_logs(rows):
|
||||||
|
"""
|
||||||
|
审计记录 → 响应 dict(列表与详情共用)。
|
||||||
|
|
||||||
|
在 to_dict() 之上补两件事:
|
||||||
|
|
||||||
|
★ **action 归一化**。历史数据里 CREATE 与「新增」混用(早期装饰器写中文,
|
||||||
|
现行监听器写英文)。归一后前端不必再为每种历史写法兜底 —— 否则每个
|
||||||
|
新消费者都要记得再兜一次,漏一个就静默显示英文/中文原值。
|
||||||
|
归一用 audit_labels.canon_action(**唯一来源**,覆盖全部历史别名),
|
||||||
|
不在模型上另建一份映射:那份只认识 3 个中文词,`批量删除`/`分配`/`归还`
|
||||||
|
这类别名会漏掉。
|
||||||
|
|
||||||
|
★ **变更摘要**。这里算而不是做成模型 @property:摘要要把 user_id / base_id
|
||||||
|
翻成人名 / 物料名,需要查库;模型属性里发查询就是 N+1。
|
||||||
|
改为整页一次性批量解析(load_ref_maps 按 ref 类型各查一次),
|
||||||
|
50 条的页面对应 2~3 次查询,与逐行查库是两回事。
|
||||||
|
"""
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
ref_maps = load_ref_maps(rows)
|
||||||
|
# ★ 操作对象补全:库里 target_name 大多只存了 SKU('0000002270')甚至内部
|
||||||
|
# 标识('stock_buy ID:1667'),业务人员看不懂。补成
|
||||||
|
# 「SKU - 物料名称 (规格型号)」;解不出物料的行不补,前端回落原始值。
|
||||||
|
# 见 load_material_context —— 撞号的 target_id 一律放弃,宁可留空不补错。
|
||||||
|
materials = load_material_context(rows)
|
||||||
|
# ★ 父子关系记录(BOM)的子件:批量解析,无 N+1。见 load_child_lookup。
|
||||||
|
children = load_child_lookup(rows)
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = r.to_dict()
|
||||||
|
# ★ 在**接口出口**剥掉凭据类字段。只靠前端隐藏不够 ——
|
||||||
|
# 原始响应里照样有明文密码,打开 devtools 就读得到。
|
||||||
|
# 见 audit_export_service.sanitize_details。
|
||||||
|
d['details'] = sanitize_details(d.get('details'))
|
||||||
|
d['action'] = canon_action(d.get('action'))
|
||||||
|
mat = materials.get(r.id)
|
||||||
|
# 保留原始 target_name(搜索仍按它匹配),展示用 target_display
|
||||||
|
d['target_display'] = format_target_display(mat)
|
||||||
|
d['summary'] = changes_summary(
|
||||||
|
r, ref_maps, limit=SUMMARY_LIMIT, material=mat,
|
||||||
|
child=children.get(r.id))
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _operator_options(company_limit):
|
||||||
|
"""
|
||||||
|
操作人下拉选项:[{'value': 账号, 'label': '中文名(账号)'}],按记录数降序。
|
||||||
|
|
||||||
|
★ 从 **audit_logs** 取而不是从 sys_user:审计里记的是操作发生时的账号,
|
||||||
|
用户被删/改名后 sys_user 就查不到了,而历史审计仍需要能按他筛选。
|
||||||
|
实测 32 个操作人里有 1 个在 sys_user 中已不存在。
|
||||||
|
|
||||||
|
★ display_name 本来就在审计行上('杜邢宸(duxingchen)'),不必 join。
|
||||||
|
|
||||||
|
★ 按记录数降序:最活跃的人排最前,下拉一打开就是常用的那几个。
|
||||||
|
|
||||||
|
★ 排除 system:它不是人,且「操作来源」那组单选已经专门管它
|
||||||
|
(真实用户 / 系统操作 / 全部)。混进来只会让两个控件语义打架。
|
||||||
|
"""
|
||||||
|
q = db.session.query(
|
||||||
|
AuditLog.username,
|
||||||
|
func.max(AuditLog.display_name),
|
||||||
|
func.count().label('n'),
|
||||||
|
).filter(AuditLog.username != SYSTEM_USERNAME)
|
||||||
|
if company_limit is not None:
|
||||||
|
q = q.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
|
||||||
|
.filter(SysUser.department == company_limit)
|
||||||
|
q = q.group_by(AuditLog.username).order_by(func.count().desc())
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for name, display, _n in q.all():
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
dn = (display or '').strip()
|
||||||
|
# display_name 库里存法不统一('高雪(gaoxue)' / '高闯/gaochuang' / 空),
|
||||||
|
# 统一成「名(账号)」;拿不到名字就只显示账号。
|
||||||
|
label = name
|
||||||
|
for sep in ('(', '/'):
|
||||||
|
if sep in dn:
|
||||||
|
dn = dn.split(sep)[0].strip()
|
||||||
|
if dn and dn != name:
|
||||||
|
label = f"{dn}({name})"
|
||||||
|
out.append({'value': name, 'label': label})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _distinct_with_company(column, company_limit):
|
||||||
|
"""
|
||||||
|
取某列的去重值(带公司隔离)。
|
||||||
|
|
||||||
|
★ 下拉选项也要隔离:不带的话,A 公司的用户在筛选框里能看到 B 公司
|
||||||
|
才有的模块名,属于低危但确实存在的信息泄露。
|
||||||
|
"""
|
||||||
|
q = db.session.query(column).distinct()
|
||||||
|
if company_limit is not None:
|
||||||
|
q = q.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
|
||||||
|
.filter(SysUser.department == company_limit)
|
||||||
|
return [v[0] for v in q.all() if v[0]]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 接口
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
@audit_bp.route('/logs', methods=['GET'])
|
@audit_bp.route('/logs', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@ -49,100 +314,25 @@ def canon_action(action):
|
|||||||
def get_audit_logs():
|
def get_audit_logs():
|
||||||
"""获取审计日志列表(分页)"""
|
"""获取审计日志列表(分页)"""
|
||||||
try:
|
try:
|
||||||
# 分页参数
|
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
page_size = request.args.get('pageSize', 50, type=int)
|
page_size = request.args.get('pageSize', 50, type=int)
|
||||||
|
|
||||||
# 筛选参数
|
query, company_limit = _build_audit_query(request.args)
|
||||||
username = request.args.get('username', '').strip()
|
|
||||||
module = request.args.get('module', '').strip()
|
|
||||||
action = request.args.get('action', '').strip()
|
|
||||||
target_id = request.args.get('target_id', '').strip()
|
|
||||||
start_date = request.args.get('start_date', '').strip()
|
|
||||||
end_date = request.args.get('end_date', '').strip()
|
|
||||||
|
|
||||||
# ★ 操作人类型:all(默认) / user(仅真实用户) / system(仅系统)
|
|
||||||
#
|
|
||||||
# 背景:改造前的全局监听器没有请求上下文守卫,系统初始化与后台定时任务
|
|
||||||
# 产生了大量 username='system' 的日志(历史存量约 1.8 万条),会把列表刷屏。
|
|
||||||
# 新监听器已加守卫不再产生此类记录,但存量数据仍需要能筛掉。
|
|
||||||
operator_type = request.args.get('operator_type', 'all').strip().lower()
|
|
||||||
if operator_type not in ('all', 'user', 'system'):
|
|
||||||
operator_type = 'all'
|
|
||||||
|
|
||||||
# 构建查询
|
|
||||||
query = AuditLog.query
|
|
||||||
|
|
||||||
if operator_type == 'user':
|
|
||||||
# 真实用户:排除 system 占位账号
|
|
||||||
query = query.filter(AuditLog.username != 'system')
|
|
||||||
elif operator_type == 'system':
|
|
||||||
query = query.filter(AuditLog.username == 'system')
|
|
||||||
|
|
||||||
if username:
|
|
||||||
query = query.filter(AuditLog.username.like(f'%{username}%'))
|
|
||||||
if module:
|
|
||||||
query = query.filter(AuditLog.module == module)
|
|
||||||
if action:
|
|
||||||
# ★ 兼容历史别名:先把任意写法(中文/小写)归一化为规范值,
|
|
||||||
# 再展开为该值的全部等价写法一起匹配。
|
|
||||||
# 否则选「新增」只能搜到早期中文 action 的数据(约 3-4 月),
|
|
||||||
# 会让用户误以为"没有最近的内容"。
|
|
||||||
canon = canon_action(action)
|
|
||||||
if canon in ACTION_ALIASES:
|
|
||||||
query = query.filter(AuditLog.action.in_(ACTION_ALIASES[canon]))
|
|
||||||
else:
|
|
||||||
query = query.filter(AuditLog.action == action)
|
|
||||||
if target_id:
|
|
||||||
query = query.filter(AuditLog.target_id == target_id)
|
|
||||||
if start_date:
|
|
||||||
try:
|
|
||||||
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
|
||||||
query = query.filter(AuditLog.created_at >= start_dt)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if end_date:
|
|
||||||
try:
|
|
||||||
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
|
||||||
# 包含当天结束时间
|
|
||||||
from datetime import timedelta
|
|
||||||
end_dt = end_dt + timedelta(days=1)
|
|
||||||
query = query.filter(AuditLog.created_at < end_dt)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 【行级数据隔离】通过操作人 username 关联用户表过滤公司
|
|
||||||
from app.utils.decorators import get_current_company_filter
|
|
||||||
from app.models.system import SysUser
|
|
||||||
from sqlalchemy import func
|
|
||||||
company_limit = get_current_company_filter()
|
|
||||||
if company_limit is not None:
|
|
||||||
query = query.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
|
|
||||||
.filter(SysUser.department == company_limit)
|
|
||||||
|
|
||||||
# 排序
|
# 排序
|
||||||
query = query.order_by(AuditLog.created_at.desc())
|
query = query.order_by(AuditLog.created_at.desc())
|
||||||
|
|
||||||
# 分页
|
# 分页
|
||||||
pagination = query.paginate(page=page, per_page=page_size, error_out=False)
|
pagination = query.paginate(page=page, per_page=page_size, error_out=False)
|
||||||
logs = pagination.items
|
data = _serialize_logs(pagination.items)
|
||||||
|
|
||||||
# 序列化
|
|
||||||
data = [log.to_dict() for log in logs]
|
|
||||||
|
|
||||||
# 获取可用的模块和操作类型(同公司范围内)
|
# 获取可用的模块和操作类型(同公司范围内)
|
||||||
modules_query = db.session.query(AuditLog.module).distinct()
|
raw_modules = _distinct_with_company(AuditLog.module, company_limit)
|
||||||
actions_query = db.session.query(AuditLog.action).distinct()
|
actions_raw = _distinct_with_company(AuditLog.action, company_limit)
|
||||||
if company_limit is not None:
|
|
||||||
modules_query = modules_query.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
|
|
||||||
.filter(SysUser.department == company_limit)
|
|
||||||
actions_query = actions_query.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
|
|
||||||
.filter(SysUser.department == company_limit)
|
|
||||||
modules = [m[0] for m in modules_query.all() if m[0]]
|
|
||||||
|
|
||||||
# ★ action 下拉项归一化:把 CREATE/create/新增 等别名合并为一个规范值,
|
# ★ action 下拉项归一化:把 CREATE/create/新增 等别名合并为一个规范值,
|
||||||
# 避免下拉框出现「CREATE」与「新增」两个语义重复的选项。
|
# 避免下拉框出现「CREATE」与「新增」两个语义重复的选项。
|
||||||
actions = sorted({canon_action(a[0]) for a in actions_query.all() if a[0]})
|
actions = sorted({canon_action(a) for a in actions_raw})
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'code': 200,
|
'code': 200,
|
||||||
@ -152,8 +342,12 @@ def get_audit_logs():
|
|||||||
'total': pagination.total,
|
'total': pagination.total,
|
||||||
'page': page,
|
'page': page,
|
||||||
'pageSize': page_size,
|
'pageSize': page_size,
|
||||||
'modules': modules,
|
# ★ [{value,label}],历史命名已折叠进聚合项且不再单独列出 ——
|
||||||
'actions': actions
|
# 规则由后端 module_options() 统一决定,前端零硬编。
|
||||||
|
'modules': module_options(raw_modules),
|
||||||
|
'actions': actions,
|
||||||
|
# 操作人下拉选项(与 /operators 同源,顺带回一份省一次请求)
|
||||||
|
'operators': _operator_options(company_limit),
|
||||||
}
|
}
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
@ -162,19 +356,94 @@ def get_audit_logs():
|
|||||||
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@audit_bp.route('/logs/export', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@permission_required('system_audit')
|
||||||
|
def export_audit_logs():
|
||||||
|
"""
|
||||||
|
按筛选条件导出审计日志为 Excel(同步返回文件流)。
|
||||||
|
|
||||||
|
★ 筛选条件与 GET /logs **完全一致**(共用 _build_audit_query),
|
||||||
|
且**忽略分页参数** —— 导的是符合条件的全量,不是当前页。
|
||||||
|
「只导当前页 50 条」是导出功能最常见的误解,故这里明确不支持。
|
||||||
|
|
||||||
|
★ 为什么同步返回而不是走 export_service 那套异步任务:
|
||||||
|
实测全库 5.8 万条导出耗时 3.3 秒、2MB,同步完全够用;
|
||||||
|
而异步那套要落盘(uploads/exports/)且**没有清理机制**,
|
||||||
|
日积月累会把磁盘吃掉 —— 见 export_service/excel_task.py。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query, _ = _build_audit_query(request.args)
|
||||||
|
# 台账按时间倒序:导出的人多半想看"最近的"(与列表页一致)
|
||||||
|
query = query.order_by(AuditLog.created_at.desc())
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
truncated = total > EXPORT_LIMIT
|
||||||
|
rows = query.limit(EXPORT_LIMIT).all() if truncated else query.all()
|
||||||
|
|
||||||
|
note = None
|
||||||
|
if truncated:
|
||||||
|
note = (f"匹配记录共 {total} 条,超过单次导出上限 {EXPORT_LIMIT} 条,"
|
||||||
|
f"本次仅导出前 {EXPORT_LIMIT} 条。请缩小日期范围后分批导出。")
|
||||||
|
|
||||||
|
data = build_audit_workbook(
|
||||||
|
rows,
|
||||||
|
summary_rows=_filter_summary_rows(request.args),
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
|
||||||
|
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
# 文件名里带上「部分」—— 用户不解压也能看出这不是全量
|
||||||
|
name = (f"审计日志_部分{EXPORT_LIMIT}条_{stamp}.xlsx" if truncated
|
||||||
|
else f"审计日志_{stamp}.xlsx")
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
io.BytesIO(data),
|
||||||
|
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"导出审计日志失败: {str(e)}")
|
||||||
|
return jsonify({'code': 500, 'msg': f'导出失败: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
@audit_bp.route('/logs/<int:log_id>', methods=['GET'])
|
@audit_bp.route('/logs/<int:log_id>', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@permission_required('system_audit')
|
||||||
def get_audit_log_detail(log_id):
|
def get_audit_log_detail(log_id):
|
||||||
"""获取单条审计日志详情"""
|
"""
|
||||||
|
获取单条审计日志详情。
|
||||||
|
|
||||||
|
★ 补权限码与公司隔离。此前这里**只有 @jwt_required()**,任何登录用户
|
||||||
|
改一下 URL 里的 id 就能读到全部审计明细(含 details 里的完整快照,
|
||||||
|
那是被操作记录的原始字段值)。列表接口有 system_audit 把关,
|
||||||
|
详情接口提供的信息是列表的超集,没道理比列表更宽松。
|
||||||
|
|
||||||
|
★ 越权与不存在**统一返回 404**,不区分二者 —— 区分开来就等于告诉
|
||||||
|
探测者"这个 id 是存在的,只是你没权限",反而泄露了数据规模。
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
log = AuditLog.query.get(log_id)
|
query = AuditLog.query.filter(AuditLog.id == log_id)
|
||||||
|
|
||||||
|
# 与列表用同一个公司判据(SysUser.department 反查),保证两处一致:
|
||||||
|
# 列表里看不到的记录,详情也不该看得到。
|
||||||
|
company_limit = get_current_company_filter()
|
||||||
|
if company_limit is not None:
|
||||||
|
query = query.join(
|
||||||
|
SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username
|
||||||
|
).filter(SysUser.department == company_limit)
|
||||||
|
|
||||||
|
log = query.first()
|
||||||
if not log:
|
if not log:
|
||||||
return jsonify({'code': 404, 'msg': '日志不存在'}), 404
|
return jsonify({'code': 404, 'msg': '日志不存在'}), 404
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'code': 200,
|
'code': 200,
|
||||||
'msg': '获取成功',
|
'msg': '获取成功',
|
||||||
'data': log.to_dict()
|
# 与列表同一套序列化:action 归一 + 摘要(详情页也用得上同一条摘要)
|
||||||
|
'data': _serialize_logs([log])[0]
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -185,11 +454,57 @@ def get_audit_log_detail(log_id):
|
|||||||
@audit_bp.route('/modules', methods=['GET'])
|
@audit_bp.route('/modules', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def get_modules():
|
def get_modules():
|
||||||
"""获取所有模块列表(用于筛选)"""
|
"""
|
||||||
|
获取模块下拉选项(用于筛选)。
|
||||||
|
|
||||||
|
返回 [{value, label}] 而不是裸的 module 字符串 —— 历史命名已被折叠进
|
||||||
|
「入库」聚合项,规则由 audit_labels.module_options() 统一决定。
|
||||||
|
|
||||||
|
★ 只挂 @jwt_required()、不加权限码:这里返回的是模块**名称**(不含任何
|
||||||
|
业务记录),且已做公司隔离;审计页本身由 /logs 的权限码把关,
|
||||||
|
与 /labels 的处理一致。
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
modules = db.session.query(AuditLog.module).distinct().all()
|
company_limit = get_current_company_filter()
|
||||||
modules = [m[0] for m in modules if m[0]]
|
raw_modules = _distinct_with_company(AuditLog.module, company_limit)
|
||||||
return jsonify({'code': 200, 'data': modules}), 200
|
return jsonify({'code': 200, 'data': module_options(raw_modules)}), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error(f"获取模块列表失败: {str(e)}")
|
current_app.logger.error(f"获取模块列表失败: {str(e)}")
|
||||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@audit_bp.route('/operators', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@permission_required('system_audit')
|
||||||
|
def get_operators():
|
||||||
|
"""
|
||||||
|
获取操作人下拉选项(用于筛选)。
|
||||||
|
|
||||||
|
返回 [{value: 账号, label: '名(账号)'}],按记录数降序,已排除 system。
|
||||||
|
|
||||||
|
★ 加权限码,与 /modules(仅 JWT)**故意不同**:/modules 给的是模块名,
|
||||||
|
这个给的是**人员账号清单**。它的唯一消费者就是审计页,而审计页本身
|
||||||
|
要 system_audit —— 没道理让人绕开页面直接拉全员名单。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
company_limit = get_current_company_filter()
|
||||||
|
return jsonify({'code': 200, 'data': _operator_options(company_limit)}), 200
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"获取操作人列表失败: {str(e)}")
|
||||||
|
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@audit_bp.route('/labels', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_labels():
|
||||||
|
"""
|
||||||
|
下发操作类型与字段名的中文映射。
|
||||||
|
|
||||||
|
★ 后端是**唯一来源**,前端不再自带副本 —— 否则两份手工同步的映射必然漂移。
|
||||||
|
前端拉到之前(或拉取失败时)对未命中字段原样显示字段名,
|
||||||
|
是可控的降级,不会让页面崩掉。
|
||||||
|
|
||||||
|
免权限码:纯静态标签,不含任何业务数据;审计页本身已由
|
||||||
|
@permission_required('system_audit') 把关(见 /logs)。
|
||||||
|
"""
|
||||||
|
return jsonify({'code': 200, 'data': labels_payload()}), 200
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from . import common_bp
|
|||||||
|
|
||||||
def active_user_options():
|
def active_user_options():
|
||||||
"""
|
"""
|
||||||
在职人员名单(id + 姓名)——「选择某人」类下拉框的**共用实现**。
|
在职人员名单(id + 姓名 + 账号)——「选择某人」类下拉框的**共用实现**。
|
||||||
|
|
||||||
★ 为什么单独开一条中性路径,而不是复用 /transactions/borrow/users:
|
★ 为什么单独开一条中性路径,而不是复用 /transactions/borrow/users:
|
||||||
那条路径在语义上属于借库模块,出库补发、报废执行等处若直接复用,
|
那条路径在语义上属于借库模块,出库补发、报废执行等处若直接复用,
|
||||||
@ -17,7 +17,13 @@ def active_user_options():
|
|||||||
★ 公司隔离与业务台账同口径(get_current_company_filter):
|
★ 公司隔离与业务台账同口径(get_current_company_filter):
|
||||||
否则 A 公司的人能在选择器里看到 B 公司人员。
|
否则 A 公司的人能在选择器里看到 B 公司人员。
|
||||||
|
|
||||||
★ 只返回 id 与姓名:不含邮箱 / 角色 / 部门,最小披露。
|
★ 只返回 id / 姓名 / 账号:不含邮箱、角色、部门 —— 仍是最小披露。
|
||||||
|
账号(username 里 '/' 之后那一段,如 'gaoxue')是**拼音**,
|
||||||
|
选择器靠它支持「输拼音找人」(输 gaoxue 匹配到高雪)——
|
||||||
|
这是加它的唯一原因,不是要把登录名铺开。
|
||||||
|
|
||||||
|
★ 姓名口径用 user_display_name('高雪/gaoxue' → '高雪'),
|
||||||
|
与出库/借库台账里存姓名的写法一致;否则同一人在台账里会出现两种写法。
|
||||||
"""
|
"""
|
||||||
from app.utils.decorators import get_current_company_filter
|
from app.utils.decorators import get_current_company_filter
|
||||||
from app.models.system import SysUser
|
from app.models.system import SysUser
|
||||||
@ -29,8 +35,15 @@ def active_user_options():
|
|||||||
# 与 borrow_service.get_request_list 一致:SysUser.department 即公司维度
|
# 与 borrow_service.get_request_list 一致:SysUser.department 即公司维度
|
||||||
query = query.filter(SysUser.department == company_limit)
|
query = query.filter(SysUser.department == company_limit)
|
||||||
|
|
||||||
return [{'id': u.id, 'name': user_display_name(u)}
|
out = []
|
||||||
for u in query.order_by(SysUser.username).all()]
|
for u in query.order_by(SysUser.username).all():
|
||||||
|
raw = str(u.username or '')
|
||||||
|
out.append({
|
||||||
|
'id': u.id,
|
||||||
|
'name': user_display_name(u),
|
||||||
|
'account': raw.split('/')[-1] if '/' in raw else '',
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@common_bp.route('/active-users', methods=['GET'])
|
@common_bp.route('/active-users', methods=['GET'])
|
||||||
|
|||||||
@ -24,11 +24,9 @@ from app.models.inbound.stocktake import (
|
|||||||
)
|
)
|
||||||
from app.models.transaction import (
|
from app.models.transaction import (
|
||||||
TransBorrow,
|
TransBorrow,
|
||||||
TransReturn,
|
# 注:TransReturn / RETURN_TYPE_* / DEFECTIVE_STATUS_PENDING 曾在此使用,
|
||||||
|
# 退回逻辑抽到 return_service 后本模块不再直接碰它们,故已移出导入。
|
||||||
TransDefectiveGoods,
|
TransDefectiveGoods,
|
||||||
RETURN_TYPE_GOOD,
|
|
||||||
RETURN_TYPE_DEFECTIVE,
|
|
||||||
DEFECTIVE_STATUS_PENDING,
|
|
||||||
DEFECTIVE_STATUS_IN_PROGRESS,
|
DEFECTIVE_STATUS_IN_PROGRESS,
|
||||||
RESTOCKABLE_DEFECTIVE_STATUSES,
|
RESTOCKABLE_DEFECTIVE_STATUSES,
|
||||||
SCRAPPABLE_DEFECTIVE_STATUSES,
|
SCRAPPABLE_DEFECTIVE_STATUSES,
|
||||||
@ -37,6 +35,13 @@ from app.models.transaction import (
|
|||||||
)
|
)
|
||||||
from app.models.outbound import TransOutbound
|
from app.models.outbound import TransOutbound
|
||||||
from app.models.base import MaterialBase
|
from app.models.base import MaterialBase
|
||||||
|
# 退回业务逻辑已抽到服务层(内部接口 Track → MOM 要复用同一段)。
|
||||||
|
# 别名带 _service 后缀,避免与本模块的视图函数 return_from_outbound 撞名。
|
||||||
|
from app.services.return_service import (
|
||||||
|
lock_source_stock_row,
|
||||||
|
assert_company_owns,
|
||||||
|
return_from_outbound as return_from_outbound_service,
|
||||||
|
)
|
||||||
|
|
||||||
# 库存状态语义的单一事实来源(与分配器共用同一套常量,避免两处定义漂移)
|
# 库存状态语义的单一事实来源(与分配器共用同一套常量,避免两处定义漂移)
|
||||||
from app.services.inventory_reservation import (
|
from app.services.inventory_reservation import (
|
||||||
@ -2606,46 +2611,21 @@ def _lock_source_stock_row(source_table, stock_id):
|
|||||||
"""
|
"""
|
||||||
解析并锁定退回目标的**原库存行**。业务不满足即抛 ValueError。
|
解析并锁定退回目标的**原库存行**。业务不满足即抛 ValueError。
|
||||||
|
|
||||||
三条 Fail-Closed 规则:
|
★ 实现已搬到 `app/services/return_service.py`(内部接口要复用,而服务层
|
||||||
1. source_table 必须是三张库存表之一 —— 维修单等非库存来源没有可退回的行;
|
不得反向 import API 层)。这里保留薄包装:调用方(restock 等)一行不用改。
|
||||||
2. 库存行必须仍然存在 —— 入库模块会物理删除库存行(见
|
|
||||||
buy/semi/product_service 的 db.session.delete(stock)),实测 1077 条
|
|
||||||
出库记录中已有 7 条指向不存在的行;
|
|
||||||
3. 调用方拿到行后还需自行做公司隔离与状态校验(见 _assert_company_owns)。
|
|
||||||
|
|
||||||
★ 为什么必须加锁:本行随后会被加减数量,且与出库/报废/状态变更并发。
|
|
||||||
不加锁会出现「读-改-写」丢失更新(lost update)。
|
|
||||||
"""
|
"""
|
||||||
model = get_stock_model(source_table)
|
return lock_source_stock_row(source_table, stock_id)
|
||||||
if model is None:
|
|
||||||
raise ValueError(
|
|
||||||
f'来源「{source_table or "(空)"}」不支持退回,'
|
|
||||||
f'仅支持 stock_buy / stock_semi / stock_product'
|
|
||||||
)
|
|
||||||
|
|
||||||
row = model.query.with_for_update().get(stock_id) if stock_id else None
|
|
||||||
if not row:
|
|
||||||
raise ValueError(
|
|
||||||
f'原库存行已不存在({source_table}#{stock_id}),无法自动退回,'
|
|
||||||
f'请改走入库流程手工登记这批实物'
|
|
||||||
)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_company_owns(row):
|
def _assert_company_owns(row):
|
||||||
"""
|
"""
|
||||||
行级多租户隔离:非跨域用户只能操作本公司库存。不满足即抛 PermissionError。
|
行级多租户隔离:非跨域用户只能操作本公司库存。不满足即抛 PermissionError。
|
||||||
|
|
||||||
口径与扫码出库(OutboundService.get_stock_by_barcode)、状态变更接口完全一致
|
★ 实现已搬到 `app/services/return_service.py`,那里收显式 company_limit
|
||||||
—— 都走 MaterialBase.company_name,避免三处隔离逻辑分叉。
|
(内部接口没有 JWT,不能在里面读 get_current_company_filter)。
|
||||||
|
这里保留薄包装,把 JWT 依赖收在 API 层。
|
||||||
"""
|
"""
|
||||||
company_limit = get_current_company_filter()
|
return assert_company_owns(row, get_current_company_filter())
|
||||||
if company_limit is None:
|
|
||||||
return
|
|
||||||
base = getattr(row, 'base', None)
|
|
||||||
if (company_limit == '__NO_COMPANY__' or base is None
|
|
||||||
or (base.company_name or '') != company_limit):
|
|
||||||
raise PermissionError('无权操作其他公司的库存')
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/defective', methods=['GET'])
|
@bp.route('/defective', methods=['GET'])
|
||||||
@ -2786,226 +2766,42 @@ def return_from_outbound():
|
|||||||
operator_name = _normalize_user_id()
|
operator_name = _normalize_user_id()
|
||||||
|
|
||||||
outbound_id = data.get('outbound_id')
|
outbound_id = data.get('outbound_id')
|
||||||
is_defective = data.get('is_defective')
|
# ---- 入参校验(脏值一律挡在入口)----
|
||||||
reason = (data.get('reason') or '').strip() or None
|
# 只留这一条在视图层:其余校验的文案由 service 原样抛出,见其注释
|
||||||
# 补发(可选):退回后申请人往往仍需这件东西。勾选则自动生成一张免审批出库单。
|
|
||||||
need_reissue = bool(data.get('need_reissue'))
|
|
||||||
reissue_qty = data.get('reissue_qty')
|
|
||||||
# 补发给谁:不传则回退为当前操作人(见下方补发块)
|
|
||||||
reissue_applicant_id = data.get('reissue_applicant_id')
|
|
||||||
|
|
||||||
# ---- 1. 入参校验(脏值一律挡在入口)----
|
|
||||||
if not outbound_id:
|
if not outbound_id:
|
||||||
return jsonify({'code': 400, 'msg': 'outbound_id 为必填'}), 400
|
return jsonify({'code': 400, 'msg': 'outbound_id 为必填'}), 400
|
||||||
if is_defective is None:
|
|
||||||
return jsonify({
|
|
||||||
'code': 400,
|
|
||||||
'msg': 'is_defective 为必填(true=不良品退回,false=良品退回)',
|
|
||||||
}), 400
|
|
||||||
is_defective = bool(is_defective)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return_qty = float(data.get('return_qty') or 0)
|
# ★ 业务逻辑全部在 app/services/return_service.py —— 内部接口
|
||||||
except (TypeError, ValueError):
|
# (Track → MOM 生产报废)要复用同一段逻辑,而视图里夹着 JWT 依赖,
|
||||||
return jsonify({'code': 400, 'msg': 'return_qty 无效'}), 400
|
# 服务层不能反向依赖它。本视图只负责取请求、转响应。
|
||||||
if return_qty <= 0:
|
result = return_from_outbound_service(
|
||||||
return jsonify({'code': 400, 'msg': '退回数量必须大于 0'}), 400
|
outbound_id=outbound_id,
|
||||||
|
return_qty=data.get('return_qty'),
|
||||||
try:
|
is_defective=data.get('is_defective'),
|
||||||
# ---- 2. 锁定原出库明细并校验退回额度 ----
|
reason=(data.get('reason') or '').strip() or None,
|
||||||
# ★ 行锁不可省:并发两笔退回若各自读到相同的 returned_quantity,会双双
|
need_reissue=bool(data.get('need_reissue')),
|
||||||
# 通过额度校验,合计退回量超过出库量 —— 凭空多出库存。
|
reissue_qty=data.get('reissue_qty'),
|
||||||
outbound = TransOutbound.query.with_for_update().get(outbound_id)
|
reissue_applicant_id=data.get('reissue_applicant_id'),
|
||||||
if not outbound:
|
operator_name=operator_name,
|
||||||
raise ValueError(f'出库记录不存在(ID: {outbound_id})')
|
|
||||||
|
|
||||||
shipped = float(outbound.quantity or 0)
|
|
||||||
returned = float(outbound.returned_quantity or 0)
|
|
||||||
returnable = shipped - returned
|
|
||||||
if return_qty > returnable:
|
|
||||||
raise ValueError(
|
|
||||||
f'退回数量({return_qty})超出可退额度({returnable}):'
|
|
||||||
f'原出库 {shipped},已退回 {returned}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---- 3. 锁定原库存行 + 多租户隔离 ----
|
|
||||||
stock_row = _lock_source_stock_row(outbound.source_table, outbound.stock_id)
|
|
||||||
_assert_company_owns(stock_row)
|
|
||||||
|
|
||||||
# 公司快照:退回看板的隔离判定不能依赖 join 链 —— 源库存行会被入库模块
|
|
||||||
# 物理删除,届时链路断裂会让记录对普通用户静默消失。见 TransReturn 注释。
|
|
||||||
_base = getattr(stock_row, 'base', None)
|
|
||||||
snapshot_company = ((_base.company_name if _base else '') or '').strip() or None
|
|
||||||
|
|
||||||
goods = None
|
|
||||||
if is_defective:
|
|
||||||
# ================= 不良品分支 =================
|
|
||||||
# ★ 原库存表**分毫不动**:坏件全程存放于独立在管台账,既不占用库存
|
|
||||||
# 数量、也不改库存行 status,从根上杜绝「坏件混进可分配池」。
|
|
||||||
base = getattr(stock_row, 'base', None)
|
|
||||||
goods = TransDefectiveGoods(
|
|
||||||
outbound_id=outbound.id,
|
|
||||||
source_table=outbound.source_table,
|
|
||||||
stock_id=outbound.stock_id,
|
|
||||||
base_id=getattr(stock_row, 'base_id', None),
|
|
||||||
sku=getattr(stock_row, 'sku', '') or '',
|
|
||||||
material_name=(base.name if base else '') or '',
|
|
||||||
spec_model=(base.spec_model if base else '') or '',
|
|
||||||
quantity=return_qty,
|
|
||||||
remaining_qty=return_qty,
|
|
||||||
status=DEFECTIVE_STATUS_PENDING,
|
|
||||||
company_name=(base.company_name if base else '') or '',
|
|
||||||
reason=reason,
|
|
||||||
operator=operator_name,
|
|
||||||
)
|
|
||||||
db.session.add(goods)
|
|
||||||
outcome = '不良品已转入在管台账'
|
|
||||||
else:
|
|
||||||
# ================= 良品分支 =================
|
|
||||||
# ★ 状态防呆:把良品加回一个已冻结/不良品的行,会让良品被该行的状态
|
|
||||||
# 连带隔离(status 是行级属性)—— 静默造成良品不可用。宁可报错让
|
|
||||||
# 人先决定该行的归属。
|
|
||||||
current = (stock_row.status or '').strip()
|
|
||||||
if current != STOCK_STATUS_IN_STOCK:
|
|
||||||
raise ValueError(
|
|
||||||
f'原库存行当前状态为「{current or "未设置"}」,'
|
|
||||||
f'良品退回要求该行处于「{STOCK_STATUS_IN_STOCK}」状态'
|
|
||||||
)
|
|
||||||
stock_row.stock_quantity = float(stock_row.stock_quantity or 0) + return_qty
|
|
||||||
stock_row.available_quantity = float(stock_row.available_quantity or 0) + return_qty
|
|
||||||
outcome = '良品已加回原库存'
|
|
||||||
|
|
||||||
# ---- 4. 累加退回额度 + 写退回流水 ----
|
|
||||||
outbound.returned_quantity = returned + return_qty
|
|
||||||
|
|
||||||
ledger = TransReturn(
|
|
||||||
outbound_id=outbound.id,
|
|
||||||
stock_id=outbound.stock_id,
|
|
||||||
source_table=outbound.source_table,
|
|
||||||
sku=outbound.sku,
|
|
||||||
return_qty=return_qty,
|
|
||||||
return_type=RETURN_TYPE_DEFECTIVE if is_defective else RETURN_TYPE_GOOD,
|
|
||||||
reason=reason,
|
|
||||||
operator=operator_name,
|
|
||||||
company_name=snapshot_company,
|
|
||||||
)
|
|
||||||
db.session.add(ledger)
|
|
||||||
db.session.flush() # 先拿到 ledger.id,供在管台账回填
|
|
||||||
|
|
||||||
# 在管台账回填来源流水 id,形成「出库 → 退回流水 → 在管台账」的追溯闭环
|
|
||||||
if goods is not None:
|
|
||||||
goods.return_id = ledger.id
|
|
||||||
|
|
||||||
# ==================================================================
|
|
||||||
# ---- 5. 补发(可选)----
|
|
||||||
# 退回后申请人往往**仍然需要这件东西**(尤其是坏件 —— 原需求并未
|
|
||||||
# 被满足)。勾选即自动生成一张**免审批**的出库单并关联回本笔退回,
|
|
||||||
# 使「退回 → 补发」形成闭环;否则现场只能靠人记住再手建一张单,
|
|
||||||
# 而那张单与原单看不出任何关系。
|
|
||||||
#
|
|
||||||
# ★ 库存不足时**整笔回滚**(下面的 reserve_for_items 会抛错)。
|
|
||||||
# 若只让补发静默失败,「需要补发」的意图就丢了 —— 那正是本功能
|
|
||||||
# 要解决的问题。回滚后库管会看到明确提示,可取消勾选重试。
|
|
||||||
# ==================================================================
|
|
||||||
reissue = None
|
|
||||||
if need_reissue:
|
|
||||||
# 单号生成器在 OutboundApprovalService 上(不在 OutboundService)
|
|
||||||
from app.services.outbound_service import OutboundApprovalService
|
|
||||||
from app.services.inventory_reservation import reserve_for_items
|
|
||||||
from app.models.outbound import OutboundApproval
|
|
||||||
|
|
||||||
if reissue_qty is None:
|
|
||||||
reissue_qty = return_qty # 默认与本次退回量一致
|
|
||||||
try:
|
|
||||||
reissue_qty = float(reissue_qty)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise ValueError('补发数量格式无效')
|
|
||||||
if reissue_qty <= 0:
|
|
||||||
raise ValueError('补发数量必须大于 0')
|
|
||||||
if reissue_qty > return_qty:
|
|
||||||
raise ValueError(
|
|
||||||
f'补发数量({reissue_qty})不能大于本次退回数量({return_qty})'
|
|
||||||
)
|
|
||||||
|
|
||||||
base = getattr(stock_row, 'base', None)
|
|
||||||
if base is None:
|
|
||||||
raise ValueError('原库存行的物料主数据已不存在,无法生成补发单')
|
|
||||||
|
|
||||||
# 提交即预占,strict=True —— 与出库申请同一口径,不足即整单失败
|
|
||||||
reserved_items, _shortages = reserve_for_items(
|
|
||||||
[{
|
|
||||||
'base_id': base.id,
|
|
||||||
'name': base.name or '',
|
|
||||||
'spec_model': base.spec_model or '',
|
|
||||||
'quantity': reissue_qty,
|
|
||||||
}],
|
|
||||||
company_limit=get_current_company_filter(),
|
company_limit=get_current_company_filter(),
|
||||||
strict=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ★ 申请人(补发给谁)优先级:
|
reissue = result['reissue']
|
||||||
# ① 前端显式指定 reissue_applicant_id —— 现场最清楚该给谁;
|
|
||||||
# ② 回退到原出库明细记录的 applicant_id(创建出库时从审批单带出的
|
|
||||||
# 真实原申请人);
|
|
||||||
# ③ 两者都没有 → **报错要求指定**。
|
|
||||||
#
|
|
||||||
# ★ 绝不回退为「当前操作人」:补发是**原申请人的需求**,挂到办理
|
|
||||||
# 退回的库管名下逻辑不通 —— 那张单会出现在库管的「我的申请」里,
|
|
||||||
# 而真正该拿东西的人什么也看不到。
|
|
||||||
# ⚠ 存量出库明细的 applicant_id 为 NULL(历史无从回填),此时必须由
|
|
||||||
# 库管在选择器里明确指定 —— 宁可多一步,也不猜错人。
|
|
||||||
if reissue_applicant_id:
|
|
||||||
try:
|
|
||||||
_applicant = int(reissue_applicant_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise ValueError('补发申请人ID格式无效')
|
|
||||||
from app.models.system import SysUser
|
|
||||||
if not SysUser.query.get(_applicant):
|
|
||||||
raise ValueError(f'补发申请人不存在(ID:{reissue_applicant_id})')
|
|
||||||
elif outbound.applicant_id:
|
|
||||||
_applicant = int(outbound.applicant_id)
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
'无法确定补发单申请人:这张出库单产生于「申请人」字段上线之前,'
|
|
||||||
'请在上方选择「补发给谁」'
|
|
||||||
)
|
|
||||||
|
|
||||||
reissue = OutboundApproval(
|
|
||||||
request_no=OutboundApprovalService.generate_request_no(),
|
|
||||||
applicant_id=_applicant,
|
|
||||||
outbound_type=outbound.outbound_type,
|
|
||||||
# 免审批:原需求已经批过一次,补发只是兑现它,重复审批是负担
|
|
||||||
status=1,
|
|
||||||
approved_at=beijing_time(),
|
|
||||||
source_return_id=ledger.id,
|
|
||||||
remark=(f'原单退回补发(原出库单 {outbound.outbound_no or outbound.id}'
|
|
||||||
f',原领用人 {outbound.consumer_name or "未知"})'),
|
|
||||||
)
|
|
||||||
reissue.set_items(reserved_items)
|
|
||||||
reissue.allowed_approvers = '[]'
|
|
||||||
db.session.add(reissue)
|
|
||||||
db.session.flush()
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'code': 200,
|
'code': 200,
|
||||||
'msg': f'退回成功,{outcome}'
|
'msg': f"退回成功,{result['outcome']}"
|
||||||
+ (f';已生成补发单 {reissue.request_no}' if reissue else ''),
|
+ (f";已生成补发单 {reissue['request_no']}" if reissue else ''),
|
||||||
'data': {
|
'data': {
|
||||||
'outbound_id': outbound.id,
|
'outbound_id': result['outbound_id'],
|
||||||
'return_id': ledger.id,
|
'return_id': result['return_id'],
|
||||||
'return_type': ledger.return_type,
|
'return_type': result['return_type'],
|
||||||
'return_qty': return_qty,
|
'return_qty': result['return_qty'],
|
||||||
'returned_quantity': float(outbound.returned_quantity),
|
'returned_quantity': result['returned_quantity'],
|
||||||
'returnable_quantity': shipped - float(outbound.returned_quantity),
|
'returnable_quantity': result['returnable_quantity'],
|
||||||
'defective_goods_id': goods.id if goods is not None else None,
|
'defective_goods_id': result['defective_goods_id'],
|
||||||
# 补发单(未勾选时为 null)
|
# 补发单(未勾选时为 null)
|
||||||
'reissue': ({
|
'reissue': reissue,
|
||||||
'id': reissue.id,
|
|
||||||
'request_no': reissue.request_no,
|
|
||||||
'quantity': reissue_qty,
|
|
||||||
} if reissue else None),
|
|
||||||
},
|
},
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
|||||||
360
inventory-backend/app/api/v1/internal.py
Normal file
360
inventory-backend/app/api/v1/internal.py
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
"""对内接口(Track → MOM)— 共享密钥鉴权,不走 JWT
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════════════════════
|
||||||
|
为什么需要这个模块
|
||||||
|
═══════════════════════════════════════════════════════════════════════════
|
||||||
|
生产领用的料已经出库到产线,之后在生产中报废 —— 要把它提交进 MOM,复用 MOM
|
||||||
|
现有的报废流程(申请 → 审批 → 执行),并标记「生产导致」。
|
||||||
|
|
||||||
|
但料已出库,那条库存行的可用量在出库时就扣掉了,走不了标准库存行报废。
|
||||||
|
MOM 自己的答案是逆向物流的「从出库单退回(不良品)」:坏件转进
|
||||||
|
`trans_defective_goods` 在管台账,原库存表分毫不动。所以本接口内部做两件事:
|
||||||
|
|
||||||
|
① 退回(is_defective=true)→ 在管不良品
|
||||||
|
② 对这笔在管量提交报废申请(待审批,角色级审批人)
|
||||||
|
|
||||||
|
两件事在**同一个事务**里,要么都成要么都不成 —— 见 return_service 的编排函数。
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════════════════════
|
||||||
|
鉴权
|
||||||
|
═══════════════════════════════════════════════════════════════════════════
|
||||||
|
MOM 此前**没有任何在用的非 JWT 接口**(ai_proxy 那个 DIFY_INTERNAL_SECRET 是
|
||||||
|
从未 register_blueprint 的死代码,且密钥硬编码 —— 反面教材,不要照抄)。
|
||||||
|
本模块新建共享密钥校验:
|
||||||
|
|
||||||
|
· 密钥走 config.MOM_INTERNAL_API_KEY(环境变量),**不硬编码**;
|
||||||
|
· 请求头 `X-API-Key`,与 Track 侧接收端同一字段名(track_query_service 注释里
|
||||||
|
明确要求「鉴权字段名必须为 X-API-Key,与 Track 接收端严格对齐」);
|
||||||
|
· 未配置密钥 → 503(Fail-Closed),不是放行;
|
||||||
|
· 用 hmac.compare_digest 做常量时间比较,避免时序侧信道。
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════════════════════
|
||||||
|
刻意收紧的地方(实现时不要「顺手放开」)
|
||||||
|
═══════════════════════════════════════════════════════════════════════════
|
||||||
|
· `is_defective` **恒为 True**,请求体不暴露该字段 —— 良品分支会往库存行加数量,
|
||||||
|
一个泄漏的密钥就能凭空造库存。内部接口只开放不良品分支,这是爆炸半径控制。
|
||||||
|
· `need_reissue` **恒为 False**,请求体不暴露 —— 补发会生成出库单,
|
||||||
|
与「报废」无关,不该由外部系统触发。
|
||||||
|
· `track_ref` 必填 —— prevent_double_submit 依赖 Redis,而 compose 里没有 redis
|
||||||
|
服务(redis_client 恒为 None),该装饰器**全程 fail-open**。唯一索引是唯一的
|
||||||
|
并发防线,没有稳定的外部单据号就无从判重。
|
||||||
|
· 只注册 `/api/v1/internal` 一条路径,不做 legacy 双注册。
|
||||||
|
"""
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, jsonify, request
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.system import SysUser
|
||||||
|
from app.models.transaction import TransReturn
|
||||||
|
from app.services import return_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
internal_bp = Blueprint('internal', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 鉴权
|
||||||
|
# =============================================================================
|
||||||
|
def require_internal_key(fn):
|
||||||
|
"""共享密钥校验。Fail-Closed:未配置密钥 → 503,不是放行。"""
|
||||||
|
@wraps(fn)
|
||||||
|
def decorator(*args, **kwargs):
|
||||||
|
configured = (current_app.config.get('MOM_INTERNAL_API_KEY') or '').strip()
|
||||||
|
if not configured:
|
||||||
|
logger.warning('[Internal] 请求被拒:服务端未配置 MOM_INTERNAL_API_KEY')
|
||||||
|
return jsonify({
|
||||||
|
'code': 503,
|
||||||
|
'msg': '内部接口未启用:服务端未配置访问密钥',
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
provided = (request.headers.get('X-API-Key') or '').strip()
|
||||||
|
if not provided:
|
||||||
|
return jsonify({'code': 401, 'msg': '缺少 X-API-Key 请求头'}), 401
|
||||||
|
# ★ 必须先 encode:compare_digest 传 str 且含非 ASCII 会抛 TypeError
|
||||||
|
if not hmac.compare_digest(provided.encode('utf-8'), configured.encode('utf-8')):
|
||||||
|
logger.warning('[Internal] 请求被拒:X-API-Key 不匹配')
|
||||||
|
return jsonify({'code': 403, 'msg': 'X-API-Key 无效'}), 403
|
||||||
|
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 入参解析
|
||||||
|
# =============================================================================
|
||||||
|
def _parse_bool(raw, field):
|
||||||
|
"""严格布尔解析:只认真正的 bool。缺省(None)报错,字符串一律报错。
|
||||||
|
|
||||||
|
不做 'true'/'1'/'yes' 之类的宽松转换 —— 该字段决定「要不要提交报废申请」,
|
||||||
|
解析歧义会导致静默的半截行为(只退回、不提申请)。
|
||||||
|
"""
|
||||||
|
if isinstance(raw, bool):
|
||||||
|
return raw
|
||||||
|
if raw is None:
|
||||||
|
raise ValueError(
|
||||||
|
f'{field} 为必填(true=退回并提交报废申请;false=仅登记为在管不良品)'
|
||||||
|
)
|
||||||
|
raise ValueError(f'{field} 必须为布尔值 true / false')
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int(raw, field, required=True, max_len=None):
|
||||||
|
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
||||||
|
if required:
|
||||||
|
raise ValueError(f'{field} 为必填')
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError(f'{field} 无效')
|
||||||
|
if value <= 0:
|
||||||
|
raise ValueError(f'{field} 必须为正整数')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_text(raw, field, max_len, required=True):
|
||||||
|
text = str(raw or '').strip()
|
||||||
|
if not text:
|
||||||
|
if required:
|
||||||
|
raise ValueError(f'{field} 为必填({max_len} 字符以内)')
|
||||||
|
return None
|
||||||
|
if len(text) > max_len:
|
||||||
|
raise ValueError(f'{field} 超长(最多 {max_len} 字符)')
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_applicant(data):
|
||||||
|
"""解析申请人 → (applicant_id, company_name)。
|
||||||
|
|
||||||
|
★ 为什么**必须**由调用方提供,不能从出库行推导:
|
||||||
|
trans_outbound.applicant_id 大部分为 NULL(实测 1364 行中 1127 行为 NULL,
|
||||||
|
PRODUCTION 类型 457/656)—— 该列是后来才加的,存量历史无从回填。
|
||||||
|
回退到「原出库申请人」在八成场景下会失败。
|
||||||
|
|
||||||
|
优先 applicant_id;其次 applicant_account(sys_user.username 里 '/' 后的账号)。
|
||||||
|
"""
|
||||||
|
applicant_id = data.get('applicant_id')
|
||||||
|
account = (data.get('applicant_account') or '').strip()
|
||||||
|
|
||||||
|
if applicant_id is not None and str(applicant_id).strip() != '':
|
||||||
|
try:
|
||||||
|
applicant_id = int(applicant_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError('applicant_id 无效')
|
||||||
|
user = SysUser.query.get(applicant_id)
|
||||||
|
if not user:
|
||||||
|
raise ValueError(f'申请人不存在(ID: {applicant_id})')
|
||||||
|
return user.id, (user.department or '').strip() or None
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
raise ValueError('applicant_id 与 applicant_account 至少提供一个')
|
||||||
|
|
||||||
|
# username 约定为「真实姓名/登录账号」,账号是 '/' 之后那段
|
||||||
|
users = SysUser.query.filter(SysUser.username.like(f'%/{account}')).all()
|
||||||
|
if not users:
|
||||||
|
raise ValueError(f'申请人账号不存在:{account}')
|
||||||
|
if len(users) > 1:
|
||||||
|
# Fail-Closed:同名账号跨公司时不能猜
|
||||||
|
raise ValueError(f'申请人账号 {account} 在多公司重复,请改用 applicant_id 指定')
|
||||||
|
return users[0].id, (users[0].department or '').strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 生产报废受理
|
||||||
|
# =============================================================================
|
||||||
|
@internal_bp.route('/production-scrap', methods=['POST'])
|
||||||
|
@require_internal_key
|
||||||
|
def create_production_scrap():
|
||||||
|
"""受理生产报废:退回(不良品) + 提交报废申请(待审批)。
|
||||||
|
|
||||||
|
Body(JSON):
|
||||||
|
{
|
||||||
|
"company_name": "IRIS", # 必填,调用方所属公司/实例
|
||||||
|
"outbound_id": 1364, # 必填,trans_outbound.id(出库明细行)
|
||||||
|
"return_qty": 2, # 必填,>0
|
||||||
|
"submit_scrap": true, # 必填,false=仅登记为在管不良品
|
||||||
|
"track_ref": "WO-2026-001234", # 必填,Track 侧唯一单据号(幂等锚点)
|
||||||
|
"scrap_qty": 2, # 可选,默认 = return_qty
|
||||||
|
"reason_category": "PRODUCTION", # 可选,默认 PRODUCTION
|
||||||
|
"reason": "生产装配时压坏", # 可选
|
||||||
|
"applicant_id": 8, # 与 applicant_account 至少一个
|
||||||
|
"applicant_account": "zhangsan01", # 同上
|
||||||
|
"operator": "Track系统" # 可选,写入台账的操作人名
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
company_name = _parse_text(data.get('company_name'), 'company_name', 255)
|
||||||
|
outbound_id = _parse_int(data.get('outbound_id'), 'outbound_id')
|
||||||
|
track_ref = _parse_text(data.get('track_ref'), 'track_ref', 100)
|
||||||
|
submit_scrap = _parse_bool(data.get('submit_scrap'), 'submit_scrap')
|
||||||
|
reason = _parse_text(data.get('reason'), 'reason', 500, required=False)
|
||||||
|
operator_name = _parse_text(data.get('operator'), 'operator', 100,
|
||||||
|
required=False) or 'Track系统'
|
||||||
|
reason_category = _parse_text(data.get('reason_category'), 'reason_category',
|
||||||
|
50, required=False) or 'PRODUCTION'
|
||||||
|
|
||||||
|
# 幂等锚点带公司前缀:IRIS 与 LICA 各自独立跑一套 Track,工单号可能重号,
|
||||||
|
# 裸用 track_ref 做唯一索引会让两家互相挡住对方的首次受理。
|
||||||
|
source_ref = f'{company_name}:{track_ref}'
|
||||||
|
|
||||||
|
if not submit_scrap and data.get('scrap_qty') not in (None, ''):
|
||||||
|
raise ValueError('submit_scrap=false 时不应传 scrap_qty(本次不生成报废申请)')
|
||||||
|
|
||||||
|
# ---- 入参数量 ----
|
||||||
|
try:
|
||||||
|
return_qty = float(data.get('return_qty') or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError('return_qty 无效')
|
||||||
|
if return_qty <= 0:
|
||||||
|
raise ValueError('return_qty 为必填且必须大于 0')
|
||||||
|
|
||||||
|
scrap_qty = None
|
||||||
|
if submit_scrap:
|
||||||
|
raw_qty = data.get('scrap_qty')
|
||||||
|
if raw_qty is None or raw_qty == '':
|
||||||
|
scrap_qty = return_qty
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
scrap_qty = float(raw_qty)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError('scrap_qty 无效')
|
||||||
|
if scrap_qty <= 0:
|
||||||
|
raise ValueError('scrap_qty 必须大于 0')
|
||||||
|
if scrap_qty > return_qty:
|
||||||
|
raise ValueError(
|
||||||
|
f'scrap_qty({scrap_qty})不能大于本次退回数量({return_qty})'
|
||||||
|
)
|
||||||
|
|
||||||
|
applicant_id, applicant_company = _resolve_applicant(data)
|
||||||
|
# 申请人公司必须与调用方声明的公司一致 —— 否则可以借别人公司的身份提交
|
||||||
|
if applicant_company and applicant_company != company_name:
|
||||||
|
raise ValueError(
|
||||||
|
f'申请人不属于 {company_name}(实际 {applicant_company}),禁止跨公司提交'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 幂等:先按 source_ref 查重 ----
|
||||||
|
existing = TransReturn.query.filter_by(source_ref=source_ref).first()
|
||||||
|
if existing is not None:
|
||||||
|
return _duplicate_response(existing, data, company_name, track_ref,
|
||||||
|
return_qty, source_ref, submit_scrap)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ret, scrap = return_service.return_and_submit_production_scrap(
|
||||||
|
outbound_id=outbound_id,
|
||||||
|
return_qty=return_qty,
|
||||||
|
applicant_id=applicant_id,
|
||||||
|
operator_name=operator_name,
|
||||||
|
# 公司隔离:内部接口没有 JWT,显式传公司限制。
|
||||||
|
# 但业务上要允许「本实例处理本公司物料」,故直接用 company_name。
|
||||||
|
company_limit=company_name,
|
||||||
|
reason=reason,
|
||||||
|
reason_category=reason_category,
|
||||||
|
source_ref=source_ref,
|
||||||
|
submit_scrap=submit_scrap,
|
||||||
|
scrap_qty=scrap_qty,
|
||||||
|
)
|
||||||
|
except IntegrityError:
|
||||||
|
# 并发穿透了上面的预检 —— 唯一索引兜底
|
||||||
|
db.session.rollback()
|
||||||
|
existing = TransReturn.query.filter_by(source_ref=source_ref).first()
|
||||||
|
if existing is not None:
|
||||||
|
return _duplicate_response(existing, data, company_name, track_ref,
|
||||||
|
return_qty, source_ref, submit_scrap)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# ⚠️ 跨公司校验**不在这里做**:company_limit=company_name 已经传进
|
||||||
|
# return_service,由 assert_company_owns 在**写之前**拦下
|
||||||
|
# (文案:「无权操作其他公司的库存」)。放到这里就成了「先提交再检查」——
|
||||||
|
# 事务已经 commit,rollback 撤不回来,等于没检查。
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'msg': ('生产报废已受理:已退回登记为在管不良品,并提交报废申请(待审批)'
|
||||||
|
if submit_scrap else
|
||||||
|
'已受理:料已退回登记为在管不良品(本次未提交报废申请)'),
|
||||||
|
'data': _build_data(ret, scrap, company_name, track_ref, source_ref),
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except PermissionError as e:
|
||||||
|
# ★ 必须显式捕获:PermissionError 是 OSError 的子类,不是 ValueError,
|
||||||
|
# 而 app/__init__.py 的全局 errorhandler(Exception) 会把未捕获异常
|
||||||
|
# 变成一条没有信息的 500,把 403 吞掉。
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'code': 403, 'msg': str(e)}), 403
|
||||||
|
except ValueError as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'code': 500, 'msg': f'生产报废受理失败: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def _build_data(ret, scrap, company_name, track_ref, source_ref, duplicate=False):
|
||||||
|
"""响应体。submit_scrap=false 时 scrap 为 {'submitted': false, ...}。"""
|
||||||
|
return {
|
||||||
|
'duplicate': duplicate,
|
||||||
|
'company_name': company_name,
|
||||||
|
'track_ref': track_ref,
|
||||||
|
'source_ref': source_ref,
|
||||||
|
'outbound_id': ret.get('outbound_id'),
|
||||||
|
'outbound_no': ret.get('outbound_no') or '',
|
||||||
|
'return_id': ret.get('return_id'),
|
||||||
|
'defective_goods_id': ret.get('defective_goods_id'),
|
||||||
|
'return_qty': ret.get('return_qty'),
|
||||||
|
'returned_quantity': ret.get('returned_quantity'),
|
||||||
|
'returnable_quantity': ret.get('returnable_quantity'),
|
||||||
|
'scrap': ({
|
||||||
|
'submitted': True,
|
||||||
|
'request_id': scrap.id,
|
||||||
|
'request_no': scrap.request_no,
|
||||||
|
'status': scrap.status,
|
||||||
|
'status_text': '待审批',
|
||||||
|
'reason_category': scrap.reason_category or '',
|
||||||
|
'reason_category_label': scrap.to_dict().get('reason_category_label', ''),
|
||||||
|
'allowed_approvers': scrap.get_allowed_approvers(),
|
||||||
|
} if scrap is not None else {'submitted': False}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_response(existing, data, company_name, track_ref, return_qty,
|
||||||
|
source_ref, submit_scrap):
|
||||||
|
"""重复受理:回显首次结果,不写任何数据。
|
||||||
|
|
||||||
|
★ 已知语义边界(明确告知,不掩盖):若首次 submit_scrap=false,
|
||||||
|
之后用同一 track_ref 重试 submit_scrap=true,会走这里短路、
|
||||||
|
**不会补提报废申请**。要报废需在 MOM 不良品台账另发申请。
|
||||||
|
"""
|
||||||
|
# 请求内容与原单不一致 → 409,避免把它当成「幂等重试」而掩盖真实冲突
|
||||||
|
if abs(float(existing.return_qty or 0) - float(return_qty)) > 1e-9:
|
||||||
|
return jsonify({
|
||||||
|
'code': 409,
|
||||||
|
'msg': (f'track_ref 已受理但请求内容不一致'
|
||||||
|
f'(原 outbound_id={existing.outbound_id}, '
|
||||||
|
f'原 return_qty={float(existing.return_qty or 0)})'),
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
from app.models.transaction import TransDefectiveGoods
|
||||||
|
goods = TransDefectiveGoods.query.filter_by(return_id=existing.id).first()
|
||||||
|
ret = {
|
||||||
|
'outbound_id': existing.outbound_id,
|
||||||
|
'outbound_no': '',
|
||||||
|
'return_id': existing.id,
|
||||||
|
'defective_goods_id': goods.id if goods else None,
|
||||||
|
'return_qty': float(existing.return_qty or 0),
|
||||||
|
'returned_quantity': None,
|
||||||
|
'returnable_quantity': None,
|
||||||
|
}
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'msg': '该 track_ref 已受理,本次为重复请求,未重复受理',
|
||||||
|
'data': _build_data(ret, None, company_name, track_ref, source_ref,
|
||||||
|
duplicate=True),
|
||||||
|
}), 200
|
||||||
@ -1,9 +1,10 @@
|
|||||||
# inventory-backend/app/api/v1/scrap.py
|
# inventory-backend/app/api/v1/scrap.py
|
||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify, current_app, send_file
|
||||||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||||
from app.utils.decorators import permission_required, get_current_company_filter
|
from app.utils.decorators import permission_required, get_current_company_filter
|
||||||
from app.services.auth_service import AuthService
|
from app.services.auth_service import AuthService
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.models.scrap_approval import scrap_category_label
|
||||||
from app.models.transaction import (
|
from app.models.transaction import (
|
||||||
TransScrap, TransRepair, TransDefectiveGoods, OPEN_DEFECTIVE_STATUSES,
|
TransScrap, TransRepair, TransDefectiveGoods, OPEN_DEFECTIVE_STATUSES,
|
||||||
)
|
)
|
||||||
@ -13,6 +14,8 @@ from app.models.inbound.product import StockProduct
|
|||||||
from app.models.base import MaterialBase
|
from app.models.base import MaterialBase
|
||||||
from app.models.system import SysUser
|
from app.models.system import SysUser
|
||||||
import traceback
|
import traceback
|
||||||
|
import io
|
||||||
|
from datetime import datetime
|
||||||
import math
|
import math
|
||||||
|
|
||||||
scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap')
|
scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap')
|
||||||
@ -166,6 +169,8 @@ def get_scrap_records():
|
|||||||
end_date = request.args.get('end_date', '')
|
end_date = request.args.get('end_date', '')
|
||||||
keyword = request.args.get('keyword', '')
|
keyword = request.args.get('keyword', '')
|
||||||
search_type = request.args.get('search_type', 'all')
|
search_type = request.args.get('search_type', 'all')
|
||||||
|
# 原因分类筛选:'' / PRODUCTION(生产报废) / STOCK(库存报废)
|
||||||
|
reason_category = request.args.get('reason_category', '')
|
||||||
|
|
||||||
# ★ 高级筛选:JSON 字符串 → 条件列表
|
# ★ 高级筛选:JSON 字符串 → 条件列表
|
||||||
from app.utils.advanced_filter import parse_advanced_filters
|
from app.utils.advanced_filter import parse_advanced_filters
|
||||||
@ -181,6 +186,7 @@ def get_scrap_records():
|
|||||||
keyword=keyword,
|
keyword=keyword,
|
||||||
search_type=search_type,
|
search_type=search_type,
|
||||||
advanced_filters=advanced_filters,
|
advanced_filters=advanced_filters,
|
||||||
|
reason_category=reason_category,
|
||||||
)
|
)
|
||||||
# 损失金额按 scrap_list:loss_amount 权限决定可见性(原为无条件剥离,
|
# 损失金额按 scrap_list:loss_amount 权限决定可见性(原为无条件剥离,
|
||||||
# 会让持有该权限的角色也看不到金额,与权限元素的存在相矛盾)
|
# 会让持有该权限的角色也看不到金额,与权限元素的存在相矛盾)
|
||||||
@ -194,6 +200,110 @@ def get_scrap_records():
|
|||||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@scrap_bp.route('/records/export', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@permission_required('scrap_list')
|
||||||
|
def export_scrap_records():
|
||||||
|
"""导出报废记录为 Excel(.xlsx)。
|
||||||
|
|
||||||
|
与列表页**同一套筛选条件**(关键词/日期/分类/高级筛选),但导出的是
|
||||||
|
**全部匹配结果**而不是当前页 —— 用户点「导出」要的就是全量,
|
||||||
|
只导当前页会让他以为数据被截断了。
|
||||||
|
|
||||||
|
⚠️ 损失金额按 `scrap_list:loss_amount` 权限决定可不可见(与列表页同一口径)。
|
||||||
|
没权限的人导出的表里该列为空,而不是偷偷带上 —— 否则导出就成了绕过
|
||||||
|
字段级权限的后门。
|
||||||
|
⚠️ 行数上限 20000:防止有人不加筛选把整个台账导出来把内存打满。
|
||||||
|
截断时在表头写明「仅导出前 N 行」,**不静默截断**。
|
||||||
|
"""
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, Alignment, PatternFill
|
||||||
|
|
||||||
|
start_date = request.args.get('start_date', '')
|
||||||
|
end_date = request.args.get('end_date', '')
|
||||||
|
keyword = request.args.get('keyword', '')
|
||||||
|
search_type = request.args.get('search_type', 'all')
|
||||||
|
sku = request.args.get('sku', '')
|
||||||
|
reason_category = request.args.get('reason_category', '')
|
||||||
|
|
||||||
|
from app.utils.advanced_filter import parse_advanced_filters
|
||||||
|
advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', ''))
|
||||||
|
|
||||||
|
EXPORT_LIMIT = 20000
|
||||||
|
try:
|
||||||
|
result = ScrapService.query_records(
|
||||||
|
page=1, page_size=EXPORT_LIMIT,
|
||||||
|
sku=sku, start_date=start_date, end_date=end_date,
|
||||||
|
keyword=keyword, search_type=search_type,
|
||||||
|
advanced_filters=advanced_filters,
|
||||||
|
reason_category=reason_category,
|
||||||
|
)
|
||||||
|
orders = result.get('list') or []
|
||||||
|
total = result.get('total') or len(orders)
|
||||||
|
|
||||||
|
perms = get_current_user_permissions()
|
||||||
|
can_view_loss = 'scrap_list:*' in perms
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = '报废记录'
|
||||||
|
|
||||||
|
headers = ['报废单号', '申请人', '操作人', '报废时间', '原因分类',
|
||||||
|
'SKU', '物料名称', '规格型号', '库位', '批次/序列号',
|
||||||
|
'报废数量', '损失金额', '报废原因', '审批状态']
|
||||||
|
if not can_view_loss:
|
||||||
|
headers.remove('损失金额')
|
||||||
|
ws.append(headers)
|
||||||
|
head_fill = PatternFill('solid', fgColor='DDEBF7')
|
||||||
|
for c in ws[1]:
|
||||||
|
c.font = Font(bold=True)
|
||||||
|
c.fill = head_fill
|
||||||
|
c.alignment = Alignment(horizontal='center', vertical='center')
|
||||||
|
|
||||||
|
for o in orders:
|
||||||
|
items = o.get('items') or [{}]
|
||||||
|
for it in items:
|
||||||
|
row = [
|
||||||
|
o.get('scrap_request_no', ''), o.get('applicant_name', ''),
|
||||||
|
o.get('operator_name', ''), o.get('scrap_time', ''),
|
||||||
|
o.get('reason_category_label', '') or '-',
|
||||||
|
it.get('sku', ''), it.get('material_name', ''), it.get('spec_model', ''),
|
||||||
|
it.get('warehouse_location', ''), it.get('batch_number', ''),
|
||||||
|
it.get('quantity', ''), it.get('loss_amount', ''),
|
||||||
|
it.get('reason', ''), o.get('approval_status', ''),
|
||||||
|
]
|
||||||
|
if not can_view_loss:
|
||||||
|
del row[11]
|
||||||
|
ws.append(row)
|
||||||
|
|
||||||
|
# 列宽按内容自适应(与 stock.py 的盘点导出同一做法)
|
||||||
|
for col in ws.columns:
|
||||||
|
width = 0
|
||||||
|
letter = col[0].column_letter
|
||||||
|
for cell in col:
|
||||||
|
try:
|
||||||
|
width = max(width, len(str(cell.value)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ws.column_dimensions[letter].width = min(width + 2, 30)
|
||||||
|
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
note = '' if total <= EXPORT_LIMIT else f'_仅前{EXPORT_LIMIT}条(共{total})'
|
||||||
|
return send_file(
|
||||||
|
output,
|
||||||
|
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f'报废记录_{ts}{note}.xlsx',
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'code': 500, 'msg': f'导出失败: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Service 层:报废核心逻辑
|
# Service 层:报废核心逻辑
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@ -405,20 +515,47 @@ class ScrapService:
|
|||||||
if r.source_table == 'trans_defective_goods' and r.stock_id}
|
if r.source_table == 'trans_defective_goods' and r.stock_id}
|
||||||
if tdg_ids:
|
if tdg_ids:
|
||||||
from app.models.transaction import TransDefectiveGoods
|
from app.models.transaction import TransDefectiveGoods
|
||||||
for g in TransDefectiveGoods.query.filter(
|
goods_rows = TransDefectiveGoods.query.filter(
|
||||||
TransDefectiveGoods.id.in_(tdg_ids)).all():
|
TransDefectiveGoods.id.in_(tdg_ids)).all()
|
||||||
|
|
||||||
|
# ★ 库位/批次:**快照优先,回查兜底**(口径与其他来源一致 = 原库位)。
|
||||||
|
#
|
||||||
|
# 原实现把这两列硬编码成空串,理由是「源库存行可能已被物理删除」。
|
||||||
|
# 该理由对 material_name/spec_model 成立(所以它们有冗余快照),
|
||||||
|
# 但库位/批次当时没有快照列,一律置空等于放弃了「源行还在」这个
|
||||||
|
# 绝大多数情形 —— 报表上明明取得到也显示 "-"。
|
||||||
|
#
|
||||||
|
# 现在两级:① phase14 新增的台账快照(退回时取自源行,免疫删除);
|
||||||
|
# ② 存量行没有快照,回查源库存行(与 trans_borrow 分支同一做法,
|
||||||
|
# _from_stock 本就容忍行不存在)。两级都落空才置空串。
|
||||||
|
src_ids = {}
|
||||||
|
for g in goods_rows:
|
||||||
|
if g.source_table in model_map and g.stock_id:
|
||||||
|
src_ids.setdefault(g.source_table, set()).add(g.stock_id)
|
||||||
|
src_map = {}
|
||||||
|
for table, ids in src_ids.items():
|
||||||
|
model = model_map[table]
|
||||||
|
for obj in (model.query.options(joinedload(model.base))
|
||||||
|
.filter(model.id.in_(ids)).all()):
|
||||||
|
src_map[(table, obj.id)] = _from_stock(obj)
|
||||||
|
|
||||||
|
for g in goods_rows:
|
||||||
|
fallback = src_map.get((g.source_table, g.stock_id)) or {}
|
||||||
resolved[('trans_defective_goods', g.id)] = {
|
resolved[('trans_defective_goods', g.id)] = {
|
||||||
'material_name': g.material_name or '',
|
'material_name': g.material_name or '',
|
||||||
'spec_model': g.spec_model or '',
|
'spec_model': g.spec_model or '',
|
||||||
'warehouse_location': '',
|
'warehouse_location': (g.warehouse_location
|
||||||
'batch_number': '',
|
or fallback.get('warehouse_location') or ''),
|
||||||
|
'batch_number': (g.batch_number
|
||||||
|
or fallback.get('batch_number') or ''),
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def query_records(page=1, page_size=50, sku='', start_date='', end_date='',
|
def query_records(page=1, page_size=50, sku='', start_date='', end_date='',
|
||||||
keyword='', search_type='all', advanced_filters=None):
|
keyword='', search_type='all', advanced_filters=None,
|
||||||
|
reason_category=''):
|
||||||
"""
|
"""
|
||||||
分页查询报废记录 —— ★ 按报废申请单号分组,返回「订单级」结果。
|
分页查询报废记录 —— ★ 按报废申请单号分组,返回「订单级」结果。
|
||||||
|
|
||||||
@ -432,6 +569,18 @@ class ScrapService:
|
|||||||
if sku:
|
if sku:
|
||||||
query = query.filter(TransScrap.sku.like(f'%{sku}%'))
|
query = query.filter(TransScrap.sku.like(f'%{sku}%'))
|
||||||
|
|
||||||
|
# ★ 原因分类筛选:两种互斥口径 ——
|
||||||
|
# 生产报废 = 所有走 Track 的;库存报废 = MOM 自身流程走下来的。
|
||||||
|
# 'STOCK' 要同时**兜住空值**:本列上线前的历史台账没有分类,而它们
|
||||||
|
# 全都是 MOM 自身流程产生的(Track 那条链一定会写 PRODUCTION)。
|
||||||
|
# 不兜的话「筛库存报废」会漏掉全部历史单,用户会以为数据丢了。
|
||||||
|
rc = (reason_category or '').strip().upper()
|
||||||
|
if rc == 'PRODUCTION':
|
||||||
|
query = query.filter(TransScrap.reason_category == 'PRODUCTION')
|
||||||
|
elif rc == 'STOCK':
|
||||||
|
query = query.filter(db.or_(TransScrap.reason_category == 'STOCK',
|
||||||
|
TransScrap.reason_category.is_(None)))
|
||||||
|
|
||||||
if start_date:
|
if start_date:
|
||||||
query = query.filter(TransScrap.operation_time >= start_date)
|
query = query.filter(TransScrap.operation_time >= start_date)
|
||||||
if end_date:
|
if end_date:
|
||||||
@ -672,6 +821,8 @@ class ScrapService:
|
|||||||
'total_loss': 0.0,
|
'total_loss': 0.0,
|
||||||
'total_quantity': 0.0,
|
'total_quantity': 0.0,
|
||||||
'reason': r.reason or '',
|
'reason': r.reason or '',
|
||||||
|
'reason_category': r.reason_category or '',
|
||||||
|
'reason_category_label': scrap_category_label(r.reason_category),
|
||||||
'items': [],
|
'items': [],
|
||||||
}
|
}
|
||||||
groups[gkey] = g
|
groups[gkey] = g
|
||||||
@ -690,6 +841,8 @@ class ScrapService:
|
|||||||
'batch_number': info.get('batch_number', ''),
|
'batch_number': info.get('batch_number', ''),
|
||||||
'quantity': qty,
|
'quantity': qty,
|
||||||
'reason': r.reason or '',
|
'reason': r.reason or '',
|
||||||
|
'reason_category': r.reason_category or '',
|
||||||
|
'reason_category_label': scrap_category_label(r.reason_category),
|
||||||
'source_table': r.source_table or '',
|
'source_table': r.source_table or '',
|
||||||
'loss_amount': round(loss, 2),
|
'loss_amount': round(loss, 2),
|
||||||
})
|
})
|
||||||
@ -791,13 +944,20 @@ def scrap_check_approval():
|
|||||||
@jwt_required()
|
@jwt_required()
|
||||||
@permission_required('scrap_apply')
|
@permission_required('scrap_apply')
|
||||||
def create_scrap_request():
|
def create_scrap_request():
|
||||||
"""提交报废申请(不扣库存;扣减在库管执行时)"""
|
"""提交报废申请(不扣库存;扣减在库管执行时)
|
||||||
|
|
||||||
|
审批人有两条路(都不传则回落默认审批角色,见 scrap_approval_service):
|
||||||
|
· approver_id —— 指定具体某个人
|
||||||
|
· allowed_approvers —— 角色级名单,如 [{"type":"role","value":"SUPERVISOR"}]
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
from app.services.scrap_approval_service import ScrapApprovalService
|
from app.services.scrap_approval_service import ScrapApprovalService
|
||||||
identity = get_jwt_identity()
|
identity = get_jwt_identity()
|
||||||
if not identity:
|
if not identity:
|
||||||
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
|
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
# 公司快照:角色级审批据此做公司隔离(见 assert_same_company)。
|
||||||
|
# 申请人所属公司即「部门」,从 JWT 取;取不到留空(旧单口径,跳过公司校验)。
|
||||||
req = ScrapApprovalService.submit_approval(
|
req = ScrapApprovalService.submit_approval(
|
||||||
applicant_id=int(identity),
|
applicant_id=int(identity),
|
||||||
items=data.get('items', []),
|
items=data.get('items', []),
|
||||||
@ -805,6 +965,8 @@ def create_scrap_request():
|
|||||||
remark=data.get('remark'),
|
remark=data.get('remark'),
|
||||||
approver_id=data.get('approver_id'),
|
approver_id=data.get('approver_id'),
|
||||||
force_approval=(_current_user_role() == 'WAREHOUSE_MGR'),
|
force_approval=(_current_user_role() == 'WAREHOUSE_MGR'),
|
||||||
|
reason_category=data.get('reason_category'),
|
||||||
|
company_name=((get_jwt() or {}).get('company_name') or '').strip() or None,
|
||||||
)
|
)
|
||||||
return jsonify({'code': 200, 'msg': '报废申请已提交', 'data': req.to_dict()}), 200
|
return jsonify({'code': 200, 'msg': '报废申请已提交', 'data': req.to_dict()}), 200
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@ -835,10 +997,18 @@ def list_scrap_requests():
|
|||||||
status = int(status) if status not in (None, '', 'all') else None
|
status = int(status) if status not in (None, '', 'all') else None
|
||||||
priv = is_privileged_viewer()
|
priv = is_privileged_viewer()
|
||||||
|
|
||||||
kwargs = {'page': page, 'limit': limit, 'status': status}
|
# 公司隔离:主管共 6 人(IRIS 5 / LICA 1),不按公司收敛就是跨公司可见。
|
||||||
|
# 超管/跨域返回 None → 不过滤(与 get_current_company_filter 口径一致)。
|
||||||
|
company_limit = get_current_company_filter()
|
||||||
|
|
||||||
|
kwargs = {'page': page, 'limit': limit, 'status': status,
|
||||||
|
'company_name': company_limit}
|
||||||
if scope == 'pending':
|
if scope == 'pending':
|
||||||
kwargs['status'] = 0
|
kwargs['status'] = 0
|
||||||
|
# ★ 待办要同时按「指名的」和「我这个角色的」捞 —— 角色级审批下,
|
||||||
|
# 单据通常只写角色不写人。两者在 get_list 里是 OR 关系。
|
||||||
kwargs['approver_id'] = identity
|
kwargs['approver_id'] = identity
|
||||||
|
kwargs['approver_role'] = _current_user_role()
|
||||||
elif scope == 'executable':
|
elif scope == 'executable':
|
||||||
kwargs['status'] = 1
|
kwargs['status'] = 1
|
||||||
elif scope == 'all':
|
elif scope == 'all':
|
||||||
@ -865,9 +1035,11 @@ def get_scrap_request_detail(request_id):
|
|||||||
if not req:
|
if not req:
|
||||||
return jsonify({'code': 404, 'msg': '报废申请不存在'}), 404
|
return jsonify({'code': 404, 'msg': '报废申请不存在'}), 404
|
||||||
if not is_privileged_viewer() and int(req.applicant_id) != int(get_jwt_identity()):
|
if not is_privileged_viewer() and int(req.applicant_id) != int(get_jwt_identity()):
|
||||||
# 被指定审批人也可查看
|
# 被指定审批人(或审批角色)也可查看。
|
||||||
allowed = req.get_allowed_approvers() or []
|
# ★ 走 can_operator_approve 这个单一事实来源,不要在这里再抄一份名单判定 ——
|
||||||
if str(get_jwt_identity()) not in [str(a.get('value')) for a in allowed if a.get('type') == 'user']:
|
# 抄出去就开始漂移,而且空名单的 Fail-Closed 语义容易抄漏。
|
||||||
|
from app.services.scrap_approval_service import ScrapApprovalService
|
||||||
|
if not ScrapApprovalService.can_operator_approve(req, int(get_jwt_identity())):
|
||||||
return jsonify({'code': 403, 'msg': '无权查看该报废申请'}), 403
|
return jsonify({'code': 403, 'msg': '无权查看该报废申请'}), 403
|
||||||
return jsonify({'code': 200, 'msg': '获取成功', 'data': req.to_dict()}), 200
|
return jsonify({'code': 200, 'msg': '获取成功', 'data': req.to_dict()}), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -890,6 +1062,12 @@ def approve_scrap_request(request_id):
|
|||||||
return jsonify({'code': 200, 'msg': '操作成功', 'data': req.to_dict()}), 200
|
return jsonify({'code': 200, 'msg': '操作成功', 'data': req.to_dict()}), 200
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||||
|
except PermissionError as e:
|
||||||
|
# ★ 必须显式捕获:PermissionError 是 OSError 的子类,**不是** ValueError。
|
||||||
|
# 漏了这一条,「跨公司审批」会落到下面的兜底分支变成 500 —— 权限拒绝
|
||||||
|
# 被伪装成服务端故障,排查时会往完全错误的方向找。
|
||||||
|
# (assert_same_company 抛的就是它)
|
||||||
|
return jsonify({'code': 403, 'msg': str(e)}), 403
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500
|
return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500
|
||||||
|
|||||||
@ -115,6 +115,69 @@ IGNORE_FIELDS = {
|
|||||||
IGNORE_FIELD_KEYWORDS = ('embedding', 'image_data', 'photo_blob')
|
IGNORE_FIELD_KEYWORDS = ('embedding', 'image_data', 'photo_blob')
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 级联操作聚合
|
||||||
|
# =============================================================================
|
||||||
|
# 「一次业务点击 → 一条审计记录」。
|
||||||
|
#
|
||||||
|
# 背景:BOM 归档/启停、权限分配这类接口一次请求会改动整组数据,ORM 监听器
|
||||||
|
# 遂逐行写审计。实测的突发规模(同一秒、同接口、同操作人):
|
||||||
|
#
|
||||||
|
# /api/v1/inbound/stock/draft/start-new 924 条
|
||||||
|
# /api/v1/inbound/stock/stocktake/generate-missing 898 条
|
||||||
|
# /api/v1/permissions/assign 381 条
|
||||||
|
# /api/v1/outbound 110 条
|
||||||
|
# /api/v1/bom/save 83 条
|
||||||
|
# /api/v1/bom/archive 58 条
|
||||||
|
#
|
||||||
|
# 全库共 311 次「单请求 ≥20 条」的突发。逐行审计在本该表达"一次业务动作"的
|
||||||
|
# 地方产生了成百条重复日志,审计页被刷屏、日报也被灌水。
|
||||||
|
#
|
||||||
|
# ★ 为什么用**白名单**而不是全局默认合并:
|
||||||
|
# 合并会改变审计的**语义粒度**(日报里的"修改 354 条"可能变成几十条),
|
||||||
|
# 全局改会让业务方以为日志坏了。白名单的失败模式也更安全 ——
|
||||||
|
# 新接口忘了登记只是"仍然刷屏",而不会把两个不相干的业务动作错误合并
|
||||||
|
# (错误合并 = 把 A 的改动记到 B 头上,是审计里最危险的一类错)。
|
||||||
|
#
|
||||||
|
# ★ 匹配规则:按**路径段前缀**,不是子串。`/api/v1/bom/SF-3500-9` 命中
|
||||||
|
# `bom`,而 `/api/v1/bom_draft_x` 不会(段名不等)。版本前缀
|
||||||
|
# (/api、/api/v1)先剥掉,兼容 /api/... 的老路由。
|
||||||
|
AGGREGATE_PATH_MARKERS = (
|
||||||
|
'bom', # save/archive/status/draft/* 与 DELETE /bom/<no>
|
||||||
|
'permissions/assign',
|
||||||
|
'inbound/stock/draft/start-new',
|
||||||
|
'inbound/stock/stocktake/generate-missing',
|
||||||
|
'outbound',
|
||||||
|
'import/execute',
|
||||||
|
)
|
||||||
|
|
||||||
|
# 聚合日志里保留的受影响对象清单上限。
|
||||||
|
# ★ 超出时**显式标注** targets_truncated 与真实总数,不静默丢弃 ——
|
||||||
|
# target_id 逐个都进清单的话,924 条的突发会生成几十 KB 的 details,
|
||||||
|
# 而列表接口一页 50 条会把它翻 50 倍带出去。
|
||||||
|
AGGREGATE_TARGETS_LIMIT = 200
|
||||||
|
|
||||||
|
# 累加器挂在 session.info 上。
|
||||||
|
# ★ 不能用 connection.info:连接来自池子,会跨请求残留 ——
|
||||||
|
# 那会把上一个请求的变更算进下一个请求的日志里。
|
||||||
|
AGGREGATE_INFO_KEY = '_audit_aggregate'
|
||||||
|
|
||||||
|
|
||||||
|
def _is_aggregate_path(url):
|
||||||
|
"""该请求路径是否命中级联聚合白名单"""
|
||||||
|
segments = [s for s in str(url or '').split('?')[0].split('/') if s]
|
||||||
|
# 剥掉 api 与版本前缀(/api/v1/bom/x → bom/x;/api/bom/x → bom/x)
|
||||||
|
if segments and segments[0].lower() == 'api':
|
||||||
|
segments = segments[1:]
|
||||||
|
if segments and len(segments[0]) > 1 and segments[0][0] in 'vV' and segments[0][1:].isdigit():
|
||||||
|
segments = segments[1:]
|
||||||
|
for marker in AGGREGATE_PATH_MARKERS:
|
||||||
|
want = marker.split('/')
|
||||||
|
if segments[:len(want)] == want:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 序列化
|
# 序列化
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -300,6 +363,150 @@ def _get_request_user_info():
|
|||||||
# 核心:写入审计日志
|
# 核心:写入审计日志
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
_INSERT_AUDIT_SQL = text("""
|
||||||
|
INSERT INTO audit_logs
|
||||||
|
(user_id, username, display_name, action, module,
|
||||||
|
target_id, target_name, details, ip_address, method, url, created_at)
|
||||||
|
VALUES
|
||||||
|
(:user_id, :username, :display_name, :action, :module,
|
||||||
|
:target_id, :target_name, cast(:details AS jsonb), :ip_address,
|
||||||
|
:method, :url, :created_at)
|
||||||
|
""")
|
||||||
|
|
||||||
|
_MISSING = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _common_part(a, b):
|
||||||
|
"""
|
||||||
|
两个 details 的**公共部分**:键都在、且值相等;dict 递归。
|
||||||
|
|
||||||
|
用于把一批变更合并成"所有行都成立"的那部分。
|
||||||
|
|
||||||
|
★ 只取公共部分,绝不用某一行的值代表整批 —— 那是**编造**:
|
||||||
|
把 A 行的改动说成整批的改动,读者会照着它去追一个错误的结论。
|
||||||
|
不公共的部分由 aggregate.targets 交代"动过哪些对象"。
|
||||||
|
"""
|
||||||
|
if isinstance(a, dict) and isinstance(b, dict):
|
||||||
|
out = {}
|
||||||
|
for key in a.keys() & b.keys():
|
||||||
|
v = _common_part(a[key], b[key])
|
||||||
|
if v is not _MISSING:
|
||||||
|
out[key] = v
|
||||||
|
# 空 dict 是"没有任何公共字段",留着会渲染成空的变更列表
|
||||||
|
return out if out else _MISSING
|
||||||
|
if a == b:
|
||||||
|
return a
|
||||||
|
return _MISSING
|
||||||
|
|
||||||
|
|
||||||
|
def _accumulate_aggregate(target, action, module, target_id, target_name,
|
||||||
|
details):
|
||||||
|
"""
|
||||||
|
把这一次变更累加到当前 session 的聚合缓冲里(不写库)。
|
||||||
|
|
||||||
|
返回 True 表示已接管(调用方不要再逐行 INSERT)。
|
||||||
|
|
||||||
|
★ 缓冲挂在 session.info:session 是请求级的,随请求销毁;
|
||||||
|
连接是池化的,挂在 connection.info 上会跨请求残留。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from sqlalchemy.orm import object_session
|
||||||
|
session = object_session(target)
|
||||||
|
if session is None:
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user = _get_request_user_info()
|
||||||
|
key = (module, action, user.get('url') or '')
|
||||||
|
|
||||||
|
buf = session.info.setdefault(AGGREGATE_INFO_KEY, {})
|
||||||
|
slot = buf.get(key)
|
||||||
|
if slot is None:
|
||||||
|
slot = buf[key] = {
|
||||||
|
'module': module,
|
||||||
|
'action': action,
|
||||||
|
'user': user,
|
||||||
|
'count': 0,
|
||||||
|
'targets': [],
|
||||||
|
'seen': set(),
|
||||||
|
'first_id': target_id,
|
||||||
|
'first_name': target_name,
|
||||||
|
'common': None, # None = 还没有任何一行参与比较
|
||||||
|
}
|
||||||
|
slot['count'] += 1
|
||||||
|
if target_id not in slot['seen']:
|
||||||
|
slot['seen'].add(target_id)
|
||||||
|
slot['targets'].append({'id': target_id, 'name': target_name})
|
||||||
|
slot['common'] = (details if slot['common'] is None
|
||||||
|
else _common_part(slot['common'], details))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _flush_aggregated_audit(session, flush_context=None):
|
||||||
|
"""
|
||||||
|
Session after_flush 钩子:把聚合缓冲写成**一条** AuditLog。
|
||||||
|
|
||||||
|
★★ 钩子必须挂在 after_flush,不能挂 before_commit —— 这是个踩过的坑:
|
||||||
|
Session.commit() 的顺序是「before_commit → flush → after_flush → COMMIT」,
|
||||||
|
而累加**发生在 flush 期间**(ORM 的 before_update 回调)。挂在
|
||||||
|
before_commit 上,钩子跑的时候缓冲还是空的,等 flush 把它填满后
|
||||||
|
再没人来写 —— 结果是**审计整批丢失**(实测归档请求产出 0 条日志,
|
||||||
|
业务却已提交,正是最危险的"改了但没记录")。
|
||||||
|
|
||||||
|
★ 为什么不用 after_request / teardown:它们跑在业务事务之外,
|
||||||
|
业务回滚也会留下一条"成功"的审计 —— 那是假账。
|
||||||
|
after_flush 仍在同一事务内,故聚合日志与业务改动**同生共死**。
|
||||||
|
|
||||||
|
★ 幂等:一次 commit 可能触发多次 flush(自动 flush)。取出即清空,
|
||||||
|
后续 flush 若有新变更会再写一条 —— 那对应另一个原子单元,是对的。
|
||||||
|
|
||||||
|
★ 没有缓冲 = 不是白名单请求 / 无变更,直接返回(绝大多数请求走这里)。
|
||||||
|
"""
|
||||||
|
slots = session.info.pop(AGGREGATE_INFO_KEY, None)
|
||||||
|
if not slots:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
conn = session.connection()
|
||||||
|
for slot in slots.values():
|
||||||
|
common = slot['common'] if isinstance(slot['common'], dict) else {}
|
||||||
|
targets_total = len(slot['targets'])
|
||||||
|
targets = slot['targets'][:AGGREGATE_TARGETS_LIMIT]
|
||||||
|
agg = {
|
||||||
|
'count': slot['count'],
|
||||||
|
'targets': targets,
|
||||||
|
}
|
||||||
|
# 只在「变更次数 ≠ 对象数」时才带 targets_total:多数批次两者相等
|
||||||
|
# (一次变更一行),多一个恒等于 count 的字段只会让抽屉多一行噪声
|
||||||
|
if targets_total != slot['count']:
|
||||||
|
agg['targets_total'] = targets_total
|
||||||
|
if targets_total > len(targets):
|
||||||
|
agg['targets_truncated'] = True
|
||||||
|
details = dict(common)
|
||||||
|
details['aggregate'] = agg
|
||||||
|
user = slot['user']
|
||||||
|
conn.execute(_INSERT_AUDIT_SQL, {
|
||||||
|
'user_id': user.get('user_id'),
|
||||||
|
'username': user.get('username') or 'system',
|
||||||
|
'display_name': user.get('display_name') or '',
|
||||||
|
'action': slot['action'],
|
||||||
|
'module': slot['module'],
|
||||||
|
'target_id': slot['first_id'],
|
||||||
|
'target_name': (slot['first_name'] or '')[:200],
|
||||||
|
'details': json.dumps(details, cls=_AuditJSONEncoder),
|
||||||
|
'ip_address': user.get('ip'),
|
||||||
|
'method': user.get('method'),
|
||||||
|
'url': user.get('url'),
|
||||||
|
'created_at': beijing_time(),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
# 与逐行写入一致:审计失败不影响业务,但必须留下痕迹
|
||||||
|
try:
|
||||||
|
current_app.logger.error(f"Audit aggregate flush failed: {e}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _create_audit_log(connection, mapper, target, action, details):
|
def _create_audit_log(connection, mapper, target, action, details):
|
||||||
"""
|
"""
|
||||||
使用事件回调传入的 connection 直接 INSERT。
|
使用事件回调传入的 connection 直接 INSERT。
|
||||||
@ -320,6 +527,14 @@ def _create_audit_log(connection, mapper, target, action, details):
|
|||||||
user = _get_request_user_info()
|
user = _get_request_user_info()
|
||||||
module = _get_module_name(mapper)
|
module = _get_module_name(mapper)
|
||||||
|
|
||||||
|
# ★ 级联聚合白名单:命中则只累加,不逐行写库。
|
||||||
|
# 真正的写入发生在 Session before_commit(见 _flush_aggregated_audit),
|
||||||
|
# 一次业务点击最终只留一条日志。
|
||||||
|
if _is_aggregate_path(user.get('url')):
|
||||||
|
if _accumulate_aggregate(target, action, module, target_id,
|
||||||
|
target_name, details):
|
||||||
|
return
|
||||||
|
|
||||||
sql = text("""
|
sql = text("""
|
||||||
INSERT INTO audit_logs
|
INSERT INTO audit_logs
|
||||||
(user_id, username, display_name, action, module,
|
(user_id, username, display_name, action, module,
|
||||||
@ -457,10 +672,34 @@ def after_insert_listener(mapper, connection, target):
|
|||||||
_BOUND_TABLES = set()
|
_BOUND_TABLES = set()
|
||||||
|
|
||||||
|
|
||||||
|
_AGGREGATE_HOOK_BOUND = False
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_aggregate_hook():
|
||||||
|
"""
|
||||||
|
注册 Session after_flush 钩子(幂等)。
|
||||||
|
|
||||||
|
★ 用 after_flush 而不是 before_commit / after_commit,理由见
|
||||||
|
_flush_aggregated_audit 的说明(前者太早、缓冲还空着;后者已在
|
||||||
|
业务事务之外)。
|
||||||
|
"""
|
||||||
|
global _AGGREGATE_HOOK_BOUND
|
||||||
|
if _AGGREGATE_HOOK_BOUND:
|
||||||
|
return
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
event.listen(Session, 'after_flush', _flush_aggregated_audit)
|
||||||
|
_AGGREGATE_HOOK_BOUND = True
|
||||||
|
|
||||||
|
|
||||||
def register_audit_listeners(db):
|
def register_audit_listeners(db):
|
||||||
"""
|
"""
|
||||||
按白名单向已完成映射的模型注册事件监听器,返回本次**新增**绑定的模型数。
|
按白名单向已完成映射的模型注册事件监听器,返回本次**新增**绑定的模型数。
|
||||||
|
|
||||||
|
★ 务必要连带注册级联聚合的 Session 钩子(见 _bind_aggregate_hook):
|
||||||
|
白名单接口的变更只累加、不逐行写库,钩子缺席就永远不落地 ——
|
||||||
|
那是**丢审计**,比刷屏严重得多。故绑在这里而不是靠 import 副作用。
|
||||||
|
|
||||||
★ 为什么需要"惰性补绑"(见 ensure_audit_listeners):
|
★ 为什么需要"惰性补绑"(见 ensure_audit_listeners):
|
||||||
本项目大量模型是在**函数体内延迟导入**的(31 处,例如
|
本项目大量模型是在**函数体内延迟导入**的(31 处,例如
|
||||||
app/api/v1/scrap.py 内部 `from app.models.scrap_approval import ScrapApproval`),
|
app/api/v1/scrap.py 内部 `from app.models.scrap_approval import ScrapApproval`),
|
||||||
@ -476,6 +715,7 @@ def register_audit_listeners(db):
|
|||||||
写日志必然抛 AttributeError 并被静默吞掉。
|
写日志必然抛 AttributeError 并被静默吞掉。
|
||||||
现改为按 **表名** 从 db.metadata 取模型,不依赖 app.models 的导出。
|
现改为按 **表名** 从 db.metadata 取模型,不依赖 app.models 的导出。
|
||||||
"""
|
"""
|
||||||
|
_bind_aggregate_hook()
|
||||||
count = 0
|
count = 0
|
||||||
for tablename in list(WHITELIST_TABLES):
|
for tablename in list(WHITELIST_TABLES):
|
||||||
if tablename in _BOUND_TABLES:
|
if tablename in _BOUND_TABLES:
|
||||||
@ -508,6 +748,7 @@ def ensure_audit_listeners():
|
|||||||
|
|
||||||
调用开销:一次集合差集运算;已全部绑定后立即返回。
|
调用开销:一次集合差集运算;已全部绑定后立即返回。
|
||||||
"""
|
"""
|
||||||
|
_bind_aggregate_hook()
|
||||||
if _BOUND_TABLES >= WHITELIST_TABLES:
|
if _BOUND_TABLES >= WHITELIST_TABLES:
|
||||||
return 0
|
return 0
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -148,6 +148,15 @@ class TransOutbound(db.Model):
|
|||||||
# ⚠ 该列上线前的历史行为 NULL —— 存量出库与其来源审批单之间没有任何可用
|
# ⚠ 该列上线前的历史行为 NULL —— 存量出库与其来源审批单之间没有任何可用
|
||||||
# 关联,无从回填;退回时由库管在选择器里明确指定。
|
# 关联,无从回填;退回时由库管在选择器里明确指定。
|
||||||
applicant_id = db.Column(db.Integer, index=True)
|
applicant_id = db.Column(db.Integer, index=True)
|
||||||
|
# ★ 来源申请单ID:创建出库时从**关联审批单**带出(出库时 request_id 已强制
|
||||||
|
# 必填,approval 恒非 None)。与上面的 applicant_id 语义正交 ——
|
||||||
|
# applicant_id 是「人」,本列是「那张单」。
|
||||||
|
# 为什么要它:原先只落了 applicant_id,从一条出库明细反查不到它属于哪张
|
||||||
|
# 申请单(单号 request_no、申请说明、明细快照 items_json 都在申请单上)。
|
||||||
|
# ⚠ 该列上线前的历史行为 NULL —— 存量出库与来源审批单之间没有任何可用关联,
|
||||||
|
# 无从回填。
|
||||||
|
# DDL 见 db_migrations/phase11_trans_outbound_request_link.sql
|
||||||
|
request_id = db.Column(db.Integer, index=True)
|
||||||
signature_path = db.Column(db.Text) # 电子签名图片路径
|
signature_path = db.Column(db.Text) # 电子签名图片路径
|
||||||
outbound_time = db.Column(db.DateTime, default=beijing_time)
|
outbound_time = db.Column(db.DateTime, default=beijing_time)
|
||||||
operator_name = db.Column(db.String(100)) # 操作员
|
operator_name = db.Column(db.String(100)) # 操作员
|
||||||
@ -179,6 +188,7 @@ class TransOutbound(db.Model):
|
|||||||
'returnable_quantity': qty - returned,
|
'returnable_quantity': qty - returned,
|
||||||
'unit_price': float(self.unit_price) if self.unit_price else 0,
|
'unit_price': float(self.unit_price) if self.unit_price else 0,
|
||||||
'consumer_name': self.consumer_name,
|
'consumer_name': self.consumer_name,
|
||||||
|
'request_id': self.request_id,
|
||||||
'signature_path': self.signature_path,
|
'signature_path': self.signature_path,
|
||||||
'outbound_time': self.outbound_time.strftime('%Y-%m-%d %H:%M:%S') if self.outbound_time else None,
|
'outbound_time': self.outbound_time.strftime('%Y-%m-%d %H:%M:%S') if self.outbound_time else None,
|
||||||
'operator_name': self.operator_name,
|
'operator_name': self.operator_name,
|
||||||
|
|||||||
@ -2,6 +2,85 @@ from app.extensions import db, beijing_time
|
|||||||
import json
|
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():
|
def _beijing_now():
|
||||||
"""
|
"""
|
||||||
统一时间口径:naive 北京时间,与 created_at / OutboundApproval / BorrowApproval 一致。
|
统一时间口径:naive 北京时间,与 created_at / OutboundApproval / BorrowApproval 一致。
|
||||||
@ -29,6 +108,20 @@ class ScrapApproval(db.Model):
|
|||||||
applicant_id = db.Column(db.Integer, nullable=False, index=True)
|
applicant_id = db.Column(db.Integer, nullable=False, index=True)
|
||||||
remark = db.Column(db.Text) # 报废原因
|
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-已执行(已报废)
|
# 状态: 0-待审批, 1-已通过(待执行), 2-已驳回, 3-已执行(已报废)
|
||||||
status = db.Column(db.Integer, default=0, nullable=False, index=True)
|
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_id': self.applicant_id,
|
||||||
'applicant_name': self._user_name(self.applicant_id),
|
'applicant_name': self._user_name(self.applicant_id),
|
||||||
'remark': self.remark or '',
|
'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,
|
'status': self.status,
|
||||||
'allowed_approvers': self.get_allowed_approvers(),
|
'allowed_approvers': self.get_allowed_approvers(),
|
||||||
'actual_approver_id': self.actual_approver_id,
|
'actual_approver_id': self.actual_approver_id,
|
||||||
|
|||||||
@ -385,6 +385,10 @@ class TransScrap(db.Model):
|
|||||||
total_loss = db.Column(db.Numeric(19, 4))
|
total_loss = db.Column(db.Numeric(19, 4))
|
||||||
# ★ 关联报废申请单号(审批流写台账用;DDL 见 db_migrations/add_scrap_approval.sql)
|
# ★ 关联报废申请单号(审批流写台账用;DDL 见 db_migrations/add_scrap_approval.sql)
|
||||||
scrap_request_no = db.Column(db.String(100), index=True)
|
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):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
@ -394,6 +398,7 @@ class TransScrap(db.Model):
|
|||||||
'stock_id': self.stock_id,
|
'stock_id': self.stock_id,
|
||||||
'quantity': float(self.quantity) if self.quantity is not None else None,
|
'quantity': float(self.quantity) if self.quantity is not None else None,
|
||||||
'reason': self.reason,
|
'reason': self.reason,
|
||||||
|
'reason_category': self.reason_category,
|
||||||
'operator_name': self.operator_name,
|
'operator_name': self.operator_name,
|
||||||
'operation_time': self.operation_time.strftime('%Y-%m-%d %H:%M:%S') if self.operation_time else None,
|
'operation_time': self.operation_time.strftime('%Y-%m-%d %H:%M:%S') if self.operation_time else None,
|
||||||
'approver_name': self.approver_name,
|
'approver_name': self.approver_name,
|
||||||
@ -461,6 +466,13 @@ class TransReturn(db.Model):
|
|||||||
# DDL 见 db_migrations/add_return_view_support.sql
|
# DDL 见 db_migrations/add_return_view_support.sql
|
||||||
company_name = db.Column(db.String(255), index=True)
|
company_name = db.Column(db.String(255), index=True)
|
||||||
|
|
||||||
|
# ★ 外部系统唯一引用,格式 <company>:<外部单号>。
|
||||||
|
# 外部接口(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):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
@ -474,6 +486,7 @@ class TransReturn(db.Model):
|
|||||||
'operator': self.operator,
|
'operator': self.operator,
|
||||||
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None,
|
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None,
|
||||||
'company_name': self.company_name,
|
'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))
|
sku = db.Column(db.String(100))
|
||||||
material_name = db.Column(db.String(200))
|
material_name = db.Column(db.String(200))
|
||||||
spec_model = db.Column(db.String(255))
|
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
|
# ★ 不变式:restocked_qty + scrapped_qty + remaining_qty = quantity
|
||||||
@ -592,6 +616,8 @@ class TransDefectiveGoods(db.Model):
|
|||||||
'sku': self.sku,
|
'sku': self.sku,
|
||||||
'material_name': self.material_name,
|
'material_name': self.material_name,
|
||||||
'spec_model': self.spec_model,
|
'spec_model': self.spec_model,
|
||||||
|
'warehouse_location': self.warehouse_location or '',
|
||||||
|
'batch_number': self.batch_number or '',
|
||||||
'quantity': qty,
|
'quantity': qty,
|
||||||
'remaining_qty': remain,
|
'remaining_qty': remain,
|
||||||
# ★ 三个去向列各自独立取值。改造前 restocked_qty 由
|
# ★ 三个去向列各自独立取值。改造前 restocked_qty 由
|
||||||
|
|||||||
1388
inventory-backend/app/services/audit_export_service.py
Normal file
1388
inventory-backend/app/services/audit_export_service.py
Normal file
File diff suppressed because it is too large
Load Diff
669
inventory-backend/app/services/daily_report_service.py
Normal file
669
inventory-backend/app/services/daily_report_service.py
Normal file
@ -0,0 +1,669 @@
|
|||||||
|
"""
|
||||||
|
MOM 系统日报 —— 采集当日数据 → 渲染纯文本 → 发送邮件。
|
||||||
|
|
||||||
|
设计取舍
|
||||||
|
--------
|
||||||
|
★ **纯代码统计,不接 AI**。
|
||||||
|
|
||||||
|
日报是固定格式的结构化统计(计数 + 清单),不是创造性任务。此前那份日报由
|
||||||
|
Dify(外部 AI 服务,见 api/v1/ai_proxy.py)生成,好处是能写人话,代价是:
|
||||||
|
· 外部依赖一断,整份日报就没了(这正是"AI 坏了"之后发生的事情);
|
||||||
|
· 按次计费、要走网络、要管密钥;
|
||||||
|
· **会在报表里编数字** —— 日报是给人做决策用的,数字必须可信。
|
||||||
|
|
||||||
|
换成确定性模板后,这些代价全部消失,而日报本来也不需要创造力。
|
||||||
|
|
||||||
|
★ 口径与前端「操作审计日志」页保持一致,避免两处对同一天给出不同数字:
|
||||||
|
· action 一律经 canon_action() 归一化(历史数据里 CREATE 与「新增」混用,
|
||||||
|
不归一会漏掉早期数据);
|
||||||
|
· 默认排除 system 占位账号(与审计页默认的"真实用户"视图一致)。
|
||||||
|
注意这是**占位账号**,不是"系统自动产生的日志" —— 判据是 username。
|
||||||
|
|
||||||
|
★ 凡有上限的地方一律显式写明「另有 N 条」,不静默截断。报表的读法就是
|
||||||
|
"看到的就是全部",偷偷砍掉会让人对数据产生错误信心。
|
||||||
|
|
||||||
|
★ **正文摘要 + Excel 附件全量**(2026-09 起)。
|
||||||
|
|
||||||
|
此前正文把明细压到极简(只列变更字段≥3 的记录、新增只给模块计数),
|
||||||
|
好处是一屏看完,代价是"199 条新增只看到 3 个模块名"—— 想查某条具体
|
||||||
|
记录改了什么,正文根本给不出来。
|
||||||
|
|
||||||
|
但也不能把这些全塞进正文:实测某日 354 条修改展开近 3000 行,邮件客户端
|
||||||
|
渲染卡顿,部分企业邮件网关直接把超长自动邮件判为垃圾或截断 —— 这是比
|
||||||
|
"信息少"更难排查的故障(发件方照样收到 250,看起来一切正常)。
|
||||||
|
|
||||||
|
故拆成两路:**正文保持摘要**(`render()`,一眼看完发生了什么),
|
||||||
|
**附件给全量明细**(`render_excel()`,每类操作一个工作表,不设阈值)。
|
||||||
|
|
||||||
|
★ 取值/翻译/Excel 排版逻辑**不在这里**,在 `audit_export_service`。
|
||||||
|
审计页的按条件导出要用同一套(否则会出现"日报里是人名、导出里是裸 ID"),
|
||||||
|
故那一层由两个消费者共用,本模块只负责「取哪些行、怎么组织成日报」。
|
||||||
|
|
||||||
|
时间口径
|
||||||
|
--------
|
||||||
|
audit_logs.created_at 由 beijing_time() 写入,是 naive 北京时间。
|
||||||
|
本模块按**北京时间的自然日**切分 [00:00, 次日 00:00)。
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.base import MaterialBase
|
||||||
|
from app.models.outbound import TransOutbound
|
||||||
|
from app.models.transaction import TransBorrow
|
||||||
|
from app.services.audit_export_service import (
|
||||||
|
DAILY_REPORT_HEAD,
|
||||||
|
EXCEL_SNAPSHOT_COL_LIMIT,
|
||||||
|
SYSTEM_USERNAME,
|
||||||
|
UNKNOWN,
|
||||||
|
cell,
|
||||||
|
changes_of,
|
||||||
|
daily_report_head_values,
|
||||||
|
fill_sheet,
|
||||||
|
fmt_name,
|
||||||
|
fmt_value,
|
||||||
|
load_ref_maps,
|
||||||
|
operator_of,
|
||||||
|
resolved_changes,
|
||||||
|
snapshot_headers,
|
||||||
|
snapshot_of,
|
||||||
|
)
|
||||||
|
from app.utils.audit_labels import (
|
||||||
|
SNAPSHOT_CREATED,
|
||||||
|
SNAPSHOT_DELETED,
|
||||||
|
canon_action,
|
||||||
|
)
|
||||||
|
from app.utils.constants import OutboundType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DailyReportService:
|
||||||
|
"""日报采集与渲染。所有方法均为无状态,便于手动触发与测试。"""
|
||||||
|
|
||||||
|
# 【修改】只详列「变更字段数 >= 该值」的记录 —— 全列会把日报冲成一屏流水
|
||||||
|
DETAIL_MIN_FIELDS = 3
|
||||||
|
# 【修改】详列条数上限(超出部分显式报数,不静默丢弃)
|
||||||
|
DETAIL_LIMIT = 30
|
||||||
|
# 【新增】模块清单条数上限
|
||||||
|
MODULE_LIMIT = 20
|
||||||
|
# 【出库】【借库】单据条数上限
|
||||||
|
DOC_LIMIT = 50
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 时间边界
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _day_bounds(report_date=None):
|
||||||
|
"""返回 [当天 00:00, 次日 00:00)。report_date 为 date 对象,缺省取今天。"""
|
||||||
|
if report_date is None:
|
||||||
|
report_date = datetime.now().date()
|
||||||
|
start = datetime(report_date.year, report_date.month, report_date.day)
|
||||||
|
return start, start + timedelta(days=1)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 物料信息解析(出库/借库明细要显示名称/规格/分类)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _load_material_index(pairs):
|
||||||
|
"""
|
||||||
|
批量解析 {(source_table, stock_id)} → {'name','spec','category'}。
|
||||||
|
|
||||||
|
★ 批量而非逐条:一张日报可能涉及上百行明细,逐行查库就是 N+1。
|
||||||
|
★ 取不到就跳过(源库存行可能已被物理删除),由渲染层显示占位符 ——
|
||||||
|
不因为缺一个物料名就让整份日报发不出去。
|
||||||
|
"""
|
||||||
|
from app.models.inbound.buy import StockBuy
|
||||||
|
from app.models.inbound.product import StockProduct
|
||||||
|
from app.models.inbound.semi import StockSemi
|
||||||
|
|
||||||
|
model_map = {
|
||||||
|
'stock_buy': StockBuy,
|
||||||
|
'stock_semi': StockSemi,
|
||||||
|
'stock_product': StockProduct,
|
||||||
|
}
|
||||||
|
|
||||||
|
by_table = defaultdict(set)
|
||||||
|
for st, sid in pairs:
|
||||||
|
st = (st or '').strip()
|
||||||
|
if st in model_map and sid:
|
||||||
|
try:
|
||||||
|
by_table[st].add(int(sid))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
index = {}
|
||||||
|
for table, ids in by_table.items():
|
||||||
|
model = model_map[table]
|
||||||
|
rows = model.query.filter(model.id.in_(ids)).all()
|
||||||
|
base_ids = {r.base_id for r in rows if getattr(r, 'base_id', None)}
|
||||||
|
bases = {}
|
||||||
|
if base_ids:
|
||||||
|
bases = {
|
||||||
|
b.id: b
|
||||||
|
for b in MaterialBase.query.filter(MaterialBase.id.in_(base_ids)).all()
|
||||||
|
}
|
||||||
|
for r in rows:
|
||||||
|
b = bases.get(getattr(r, 'base_id', None))
|
||||||
|
if b is None:
|
||||||
|
continue
|
||||||
|
# 分类:material_base.category 本身**已经是完整路径**
|
||||||
|
#(实测 'IRIS/原材料/结构/非标OS'、'LICA/生产配件'),
|
||||||
|
# company_name 是它的首段、material_type 是它的末段 ——
|
||||||
|
# 再拼一遍会得到 'LICA/LICA/生产配件/生产配件' 这种重复。
|
||||||
|
# 故直接取用,不拼接。
|
||||||
|
index[(table, r.id)] = {
|
||||||
|
'name': (b.name or '').strip(),
|
||||||
|
'spec': (b.spec_model or '').strip(),
|
||||||
|
'category': (b.category or '').strip(),
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 采集
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def collect(report_date=None):
|
||||||
|
"""
|
||||||
|
采集指定日期的日报数据。
|
||||||
|
|
||||||
|
Returns: dict(结构见下方各处注释),供 render() 使用,也可单独用于调试。
|
||||||
|
"""
|
||||||
|
start, end = DailyReportService._day_bounds(report_date)
|
||||||
|
|
||||||
|
# ---------- 审计日志 ----------
|
||||||
|
audit_rows = AuditLog.query.filter(
|
||||||
|
AuditLog.created_at >= start,
|
||||||
|
AuditLog.created_at < end,
|
||||||
|
AuditLog.username != SYSTEM_USERNAME,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
by_action = defaultdict(list)
|
||||||
|
for r in audit_rows:
|
||||||
|
by_action[canon_action(r.action)].append(r)
|
||||||
|
|
||||||
|
create_by_module = Counter((r.module or '未知模块') for r in by_action['CREATE'])
|
||||||
|
|
||||||
|
# 【修改】按操作人分组,只保留「变更字段数 >= 阈值」的记录供详列
|
||||||
|
update_by_operator = defaultdict(list)
|
||||||
|
for r in by_action['UPDATE']:
|
||||||
|
update_by_operator[operator_of(r)].append(r)
|
||||||
|
|
||||||
|
# ---------- 变更值 / 快照里的 ID 批量解析 ----------
|
||||||
|
# 「实际审批人ID: 空 → 7」这种显示没有意义,要变成「杜邢宸」。
|
||||||
|
# 逐条查库是 N+1,load_ref_maps 会按 ref 类型批量查一次。
|
||||||
|
ref_maps = load_ref_maps(audit_rows)
|
||||||
|
|
||||||
|
# ---------- 出库 ----------
|
||||||
|
outbound_rows = TransOutbound.query.filter(
|
||||||
|
TransOutbound.outbound_time >= start,
|
||||||
|
TransOutbound.outbound_time < end,
|
||||||
|
).order_by(TransOutbound.outbound_time).all()
|
||||||
|
|
||||||
|
# ---------- 借库 ----------
|
||||||
|
borrow_rows = TransBorrow.query.filter(
|
||||||
|
TransBorrow.borrow_time >= start,
|
||||||
|
TransBorrow.borrow_time < end,
|
||||||
|
).order_by(TransBorrow.borrow_time).all()
|
||||||
|
|
||||||
|
# ---------- 物料信息批量解析 ----------
|
||||||
|
pairs = [(r.source_table, r.stock_id) for r in outbound_rows]
|
||||||
|
pairs += [(r.source_table, r.stock_id) for r in borrow_rows]
|
||||||
|
material_index = DailyReportService._load_material_index(pairs)
|
||||||
|
|
||||||
|
def _item_of(row):
|
||||||
|
info = material_index.get(
|
||||||
|
((row.source_table or '').strip(), row.stock_id), {}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'sku': row.sku or '',
|
||||||
|
'name': info.get('name') or '',
|
||||||
|
'spec': info.get('spec') or '',
|
||||||
|
'category': info.get('category') or '',
|
||||||
|
'quantity': float(row.quantity or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 出库按单号聚合
|
||||||
|
outbound_docs = defaultdict(list)
|
||||||
|
for r in outbound_rows:
|
||||||
|
outbound_docs[r.outbound_no or f"#{r.id}"].append(r)
|
||||||
|
outbound_list = []
|
||||||
|
for no, rows in outbound_docs.items():
|
||||||
|
head = rows[0]
|
||||||
|
outbound_list.append({
|
||||||
|
'no': no,
|
||||||
|
'operator': fmt_name(head.operator_name),
|
||||||
|
'consumer': head.consumer_name or '',
|
||||||
|
'type': OutboundType.label(head.outbound_type),
|
||||||
|
'time': head.outbound_time.strftime('%H:%M') if head.outbound_time else '',
|
||||||
|
'items': [_item_of(r) for r in rows],
|
||||||
|
})
|
||||||
|
outbound_list.sort(key=lambda d: d['time'])
|
||||||
|
|
||||||
|
# 借库按单号聚合
|
||||||
|
borrow_docs = defaultdict(list)
|
||||||
|
for r in borrow_rows:
|
||||||
|
borrow_docs[r.borrow_no or f"#{r.id}"].append(r)
|
||||||
|
borrow_list = []
|
||||||
|
for no, rows in borrow_docs.items():
|
||||||
|
head = rows[0]
|
||||||
|
borrow_list.append({
|
||||||
|
'no': no,
|
||||||
|
'borrower': fmt_name(head.borrower_name),
|
||||||
|
'operator': fmt_name(head.dispatch_operator),
|
||||||
|
'returned': bool(head.is_returned),
|
||||||
|
'time': head.borrow_time.strftime('%H:%M') if head.borrow_time else '',
|
||||||
|
'items': [_item_of(r) for r in rows],
|
||||||
|
})
|
||||||
|
borrow_list.sort(key=lambda d: d['time'])
|
||||||
|
|
||||||
|
# 附件要按时间顺序展开全量明细,故快照式地保留原始行
|
||||||
|
# (正文只用到下面的计数字段,用不到这些行)。
|
||||||
|
audit_rows_sorted = sorted(audit_rows, key=lambda r: r.created_at or start)
|
||||||
|
by_action_sorted = defaultdict(list)
|
||||||
|
for r in audit_rows_sorted:
|
||||||
|
by_action_sorted[canon_action(r.action)].append(r)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'report_date': start.strftime('%Y-%m-%d'),
|
||||||
|
'audit': {
|
||||||
|
'create_total': len(by_action['CREATE']),
|
||||||
|
'update_total': len(by_action['UPDATE']),
|
||||||
|
'delete_total': len(by_action['DELETE']),
|
||||||
|
# 附件明细用的原始行(按时间升序);正文只用上面的计数
|
||||||
|
'create_rows': by_action_sorted['CREATE'],
|
||||||
|
'update_rows': by_action_sorted['UPDATE'],
|
||||||
|
'delete_rows': by_action_sorted['DELETE'],
|
||||||
|
'create_by_module': create_by_module.most_common(),
|
||||||
|
'update_by_operator': {
|
||||||
|
k: v for k, v in sorted(
|
||||||
|
update_by_operator.items(), key=lambda kv: -len(kv[1])
|
||||||
|
)
|
||||||
|
},
|
||||||
|
# 变更值里 ID → 实体的解析结果(key 为 ref 类型),渲染时用
|
||||||
|
'ref_maps': ref_maps,
|
||||||
|
},
|
||||||
|
'outbound': outbound_list,
|
||||||
|
'borrow': borrow_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 渲染
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _render_changes(lines, module, changes, ref_maps):
|
||||||
|
"""
|
||||||
|
输出一组字段变更:值先翻译,翻不动才显示原值(见 resolved_changes)。
|
||||||
|
|
||||||
|
正文用 fmt_value(空值显示「空」、超长截断 40 字);附表走 audit_export
|
||||||
|
的 cell(空值留空、截断 2000 字)—— 两处对格式化的要求相反。
|
||||||
|
"""
|
||||||
|
for label, old, new in resolved_changes(module, changes, ref_maps):
|
||||||
|
lines.append(
|
||||||
|
f" · {label}: {fmt_value(old)} → {fmt_value(new)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _render_audit(lines, audit):
|
||||||
|
# ---------- 新增 ----------
|
||||||
|
lines.append("【新增】")
|
||||||
|
total = audit['create_total']
|
||||||
|
lines.append(f" 总量: {total} 条")
|
||||||
|
if total == 0:
|
||||||
|
lines.append(" 今日无新增记录")
|
||||||
|
else:
|
||||||
|
mods = audit['create_by_module']
|
||||||
|
for name, cnt in mods[:DailyReportService.MODULE_LIMIT]:
|
||||||
|
lines.append(f" · {name}: {cnt} 条")
|
||||||
|
if len(mods) > DailyReportService.MODULE_LIMIT:
|
||||||
|
rest = len(mods) - DailyReportService.MODULE_LIMIT
|
||||||
|
lines.append(f" (另有 {rest} 个模块未列出)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# ---------- 修改 ----------
|
||||||
|
lines.append("【修改】")
|
||||||
|
total = audit['update_total']
|
||||||
|
lines.append(f" 总量: {total} 条")
|
||||||
|
if total == 0:
|
||||||
|
lines.append(" 今日无修改记录")
|
||||||
|
lines.append("")
|
||||||
|
return
|
||||||
|
# ★ 只详列「有实质变更」的操作人;其余合并成一行报数。
|
||||||
|
# 否则每天几十位操作人各占两行「无变更字段≥3的记录」,日报一半是废话。
|
||||||
|
quiet_ops = 0
|
||||||
|
quiet_records = 0
|
||||||
|
for operator, rows in audit['update_by_operator'].items():
|
||||||
|
detailed = [r for r in rows
|
||||||
|
if len(changes_of(r)) >= DailyReportService.DETAIL_MIN_FIELDS]
|
||||||
|
if not detailed:
|
||||||
|
quiet_ops += 1
|
||||||
|
quiet_records += len(rows)
|
||||||
|
continue
|
||||||
|
lines.append(f" 【{operator}】共 {len(rows)} 条")
|
||||||
|
lines.append(f" 以下记录变更字段≥{DailyReportService.DETAIL_MIN_FIELDS}:")
|
||||||
|
for r in detailed[:DailyReportService.DETAIL_LIMIT]:
|
||||||
|
lines.append(f" · [{r.module or '未知模块'}] {r.url or ''}")
|
||||||
|
DailyReportService._render_changes(
|
||||||
|
lines, r.module, changes_of(r),
|
||||||
|
audit.get('ref_maps') or {},
|
||||||
|
)
|
||||||
|
if len(detailed) > DailyReportService.DETAIL_LIMIT:
|
||||||
|
rest = len(detailed) - DailyReportService.DETAIL_LIMIT
|
||||||
|
lines.append(f" (另有 {rest} 条未列出)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if quiet_ops:
|
||||||
|
lines.append(
|
||||||
|
f" 另有 {quiet_ops} 位操作人(共 {quiet_records} 条)"
|
||||||
|
f"无变更字段≥{DailyReportService.DETAIL_MIN_FIELDS}的记录,未逐条列出"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _render_docs(lines, title, docs, head_fields, empty_text):
|
||||||
|
lines.append(f"【{title}】")
|
||||||
|
lines.append(f" 总量: {len(docs)} 条")
|
||||||
|
if not docs:
|
||||||
|
lines.append(f" {empty_text}")
|
||||||
|
lines.append("")
|
||||||
|
return
|
||||||
|
for d in docs[:DailyReportService.DOC_LIMIT]:
|
||||||
|
lines.append(f" · {d['no']}")
|
||||||
|
lines.append(" " + " | ".join(f"{k}: {d[v]}" for k, v in head_fields))
|
||||||
|
for it in d['items']:
|
||||||
|
lines.append(
|
||||||
|
f" 物料: {it['name']} | 规格: {it['spec'] or UNKNOWN} | "
|
||||||
|
f"数量: {it['quantity']} | 分类: {it['category'] or UNKNOWN}"
|
||||||
|
)
|
||||||
|
if len(docs) > DailyReportService.DOC_LIMIT:
|
||||||
|
rest = len(docs) - DailyReportService.DOC_LIMIT
|
||||||
|
lines.append(f" (另有 {rest} 张单据未列出)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def render(stats):
|
||||||
|
"""把 collect() 的结果渲染成纯文本正文。返回 (subject, body)。"""
|
||||||
|
date_str = stats['report_date']
|
||||||
|
lines = [f"📋 MOM系统日报 — {date_str}", "=" * 60, ""]
|
||||||
|
|
||||||
|
DailyReportService._render_audit(lines, stats['audit'])
|
||||||
|
|
||||||
|
lines.append("【删除】")
|
||||||
|
lines.append(f" 总量: {stats['audit']['delete_total']} 条")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
DailyReportService._render_docs(
|
||||||
|
lines, "出库", stats['outbound'],
|
||||||
|
head_fields=[('操作员', 'operator'), ('客户', 'consumer'), ('类型', 'type')],
|
||||||
|
empty_text="今日无出库记录",
|
||||||
|
)
|
||||||
|
DailyReportService._render_docs(
|
||||||
|
lines, "借库", stats['borrow'],
|
||||||
|
head_fields=[('借用人', 'borrower'), ('库管', 'operator')],
|
||||||
|
empty_text="今日无借库记录",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ★ 必须显式告知附件的存在与文件名。正文里的数字是**被截断后**的
|
||||||
|
# (每个操作人最多 DETAIL_LIMIT 条、新增只有模块计数),不看附件
|
||||||
|
# 会把摘要误当成全部 —— 这正是「正文摘要 + 附件全量」最容易踩的坑。
|
||||||
|
lines.append("=" * 60)
|
||||||
|
lines.append(
|
||||||
|
f"📎 以上为摘要。全量明细(每条新增/修改/删除记录、全部变更字段、"
|
||||||
|
f"所有出库借库单)见附件:{DailyReportService.excel_filename(date_str)}"
|
||||||
|
)
|
||||||
|
lines.append("此邮件由 MOM 系统自动发送,请勿回复。")
|
||||||
|
|
||||||
|
subject = f"MOM系统日报 {date_str}"
|
||||||
|
return subject, '\n'.join(lines)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Excel 附件
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def excel_filename(date_str):
|
||||||
|
"""附件文件名。集中在此,避免正文里的提示与实际附件名对不上。"""
|
||||||
|
return f"MOM系统日报_{date_str}.xlsx"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _snapshot_columns(rows, which):
|
||||||
|
"""
|
||||||
|
一批记录的快照字段并集 → 列名列表(按出现频次降序)。
|
||||||
|
|
||||||
|
频次降序而不是首次出现顺序:同一个模块的记录字段一致,高频字段排在
|
||||||
|
左边,跨模块时才排到右侧,人一眼就能从左往右找到想要的列。
|
||||||
|
|
||||||
|
返回 (列名列表, 被丢弃的列数) —— 丢弃数交给调用方显式写进汇总表。
|
||||||
|
"""
|
||||||
|
counts = Counter()
|
||||||
|
for r in rows:
|
||||||
|
for k in snapshot_of(r, which):
|
||||||
|
counts[k] += 1
|
||||||
|
cols = [k for k, _ in counts.most_common()]
|
||||||
|
if len(cols) > EXCEL_SNAPSHOT_COL_LIMIT:
|
||||||
|
return cols[:EXCEL_SNAPSHOT_COL_LIMIT], len(cols) - EXCEL_SNAPSHOT_COL_LIMIT
|
||||||
|
return cols, 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def render_excel(stats):
|
||||||
|
"""
|
||||||
|
把 collect() 的结果渲染成 .xlsx 字节流。
|
||||||
|
|
||||||
|
工作表:
|
||||||
|
汇总 —— 各操作总量、新增按模块、修改按操作人
|
||||||
|
新增明细 —— 每条新增记录 + 它的完整字段快照(列=字段并集)
|
||||||
|
修改明细 —— 每条修改记录的每个变更字段占一行(长表,便于筛选透视)
|
||||||
|
删除明细 —— 每条删除记录 + 删除前快照
|
||||||
|
出库明细 —— 每个物料行占一行,单据级字段重复填充
|
||||||
|
借库明细 —— 同上
|
||||||
|
|
||||||
|
★ 全程在内存(BytesIO)里构建,**不落盘**。日报是每天一封的定时任务,
|
||||||
|
一旦落盘就得额外写清理逻辑,否则磁盘会被日复一日地慢慢吃掉;
|
||||||
|
而这份附件对系统本身没有任何留存价值(收件人邮箱里已有一份)。
|
||||||
|
|
||||||
|
★ 「修改明细」用长表而不是宽表:宽表要取所有记录的变更字段并集,
|
||||||
|
而不同模块改的字段完全不同,并集轻松上百列,且绝大多数格子是空的。
|
||||||
|
长表(一条变更一行)的列固定 10 个,想按字段筛"今天所有改过状态的
|
||||||
|
记录"只需对「字段」列做一次筛选。
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
audit = stats['audit']
|
||||||
|
ref_maps = audit.get('ref_maps') or {}
|
||||||
|
date_str = stats['report_date']
|
||||||
|
|
||||||
|
create_rows = audit.get('create_rows') or []
|
||||||
|
update_rows = audit.get('update_rows') or []
|
||||||
|
delete_rows = audit.get('delete_rows') or []
|
||||||
|
|
||||||
|
notes = [] # 显式记录附件里做过的取舍,写进汇总表
|
||||||
|
sheets = [] # [(标题, 表头, 数据行, 换行列号)]
|
||||||
|
|
||||||
|
# ---------- 新增明细 ----------
|
||||||
|
create_cols, create_dropped = DailyReportService._snapshot_columns(
|
||||||
|
create_rows, SNAPSHOT_CREATED)
|
||||||
|
if create_dropped:
|
||||||
|
notes.append(
|
||||||
|
f"「新增明细」快照字段过多,按出现频次省略了 {create_dropped} 个低频列"
|
||||||
|
)
|
||||||
|
create_data = []
|
||||||
|
for r in create_rows:
|
||||||
|
snap = snapshot_of(r, SNAPSHOT_CREATED)
|
||||||
|
create_data.append(
|
||||||
|
daily_report_head_values(r)
|
||||||
|
+ [cell(snap.get(c), r.module, c, ref_maps) for c in create_cols]
|
||||||
|
)
|
||||||
|
sheets.append((
|
||||||
|
'新增明细',
|
||||||
|
DAILY_REPORT_HEAD + snapshot_headers(create_cols, DAILY_REPORT_HEAD),
|
||||||
|
create_data,
|
||||||
|
(6,), # URL 列自动换行
|
||||||
|
))
|
||||||
|
|
||||||
|
# ---------- 修改明细(长表:一条变更一行)----------
|
||||||
|
update_data = []
|
||||||
|
for r in update_rows:
|
||||||
|
changes = resolved_changes(r.module, changes_of(r), ref_maps)
|
||||||
|
if not changes:
|
||||||
|
# ★ 一条记录不能因为"改的全是图片/链接等噪声字段"就在附件里
|
||||||
|
# 凭空消失 —— 它确实发生过。占位一行,并说明原因。
|
||||||
|
update_data.append(
|
||||||
|
daily_report_head_values(r)
|
||||||
|
+ [0, '(仅变更了图片/链接/更新时间等噪声字段)', '', '']
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
head = daily_report_head_values(r)
|
||||||
|
for label, old, new in changes:
|
||||||
|
# old / new 已由 resolved_changes 翻译过,故这里只做格式化,
|
||||||
|
# 不再传 module —— 重复解析一次不但浪费,还会让读者以为
|
||||||
|
# 这列的值是原始值。
|
||||||
|
update_data.append(head + [len(changes), label, cell(old), cell(new)])
|
||||||
|
sheets.append((
|
||||||
|
'修改明细',
|
||||||
|
DAILY_REPORT_HEAD + ['变更字段数', '字段', '旧值', '新值'],
|
||||||
|
update_data,
|
||||||
|
(6, 9, 10),
|
||||||
|
))
|
||||||
|
|
||||||
|
# ---------- 删除明细 ----------
|
||||||
|
delete_cols, delete_dropped = DailyReportService._snapshot_columns(
|
||||||
|
delete_rows, SNAPSHOT_DELETED)
|
||||||
|
if delete_dropped:
|
||||||
|
notes.append(
|
||||||
|
f"「删除明细」快照字段过多,按出现频次省略了 {delete_dropped} 个低频列"
|
||||||
|
)
|
||||||
|
delete_data = []
|
||||||
|
for r in delete_rows:
|
||||||
|
snap = snapshot_of(r, SNAPSHOT_DELETED)
|
||||||
|
delete_data.append(
|
||||||
|
daily_report_head_values(r)
|
||||||
|
+ [cell(snap.get(c), r.module, c, ref_maps) for c in delete_cols]
|
||||||
|
)
|
||||||
|
sheets.append((
|
||||||
|
'删除明细',
|
||||||
|
DAILY_REPORT_HEAD + snapshot_headers(delete_cols, DAILY_REPORT_HEAD),
|
||||||
|
delete_data,
|
||||||
|
(6,),
|
||||||
|
))
|
||||||
|
|
||||||
|
# ---------- 出库明细 ----------
|
||||||
|
outbound_data = []
|
||||||
|
for d in stats['outbound']:
|
||||||
|
for it in d['items']:
|
||||||
|
outbound_data.append([
|
||||||
|
d['no'], d['time'], d['operator'], d['consumer'], d['type'],
|
||||||
|
it['name'], it['spec'], it['quantity'], it['category'],
|
||||||
|
])
|
||||||
|
sheets.append((
|
||||||
|
'出库明细',
|
||||||
|
['出库单号', '时间', '操作员', '客户', '出库类型',
|
||||||
|
'物料名称', '规格型号', '数量', '分类'],
|
||||||
|
outbound_data, (),
|
||||||
|
))
|
||||||
|
|
||||||
|
# ---------- 借库明细 ----------
|
||||||
|
borrow_data = []
|
||||||
|
for d in stats['borrow']:
|
||||||
|
for it in d['items']:
|
||||||
|
borrow_data.append([
|
||||||
|
d['no'], d['time'], d['borrower'], d['operator'],
|
||||||
|
'是' if d['returned'] else '否',
|
||||||
|
it['name'], it['spec'], it['quantity'], it['category'],
|
||||||
|
])
|
||||||
|
sheets.append((
|
||||||
|
'借库明细',
|
||||||
|
['借出单号', '时间', '借用人', '库管', '是否已归还',
|
||||||
|
'物料名称', '规格型号', '数量', '分类'],
|
||||||
|
borrow_data, (),
|
||||||
|
))
|
||||||
|
|
||||||
|
# ---------- 汇总(含上面收集到的 notes)----------
|
||||||
|
summary_rows = [
|
||||||
|
('总量', '新增记录', audit['create_total']),
|
||||||
|
('总量', '修改记录', audit['update_total']),
|
||||||
|
('总量', '删除记录', audit['delete_total']),
|
||||||
|
('出库', '单据数', len(stats['outbound'])),
|
||||||
|
('出库', '物料行数', len(outbound_data)),
|
||||||
|
('借库', '单据数', len(stats['borrow'])),
|
||||||
|
('借库', '物料行数', len(borrow_data)),
|
||||||
|
]
|
||||||
|
for name, cnt in audit['create_by_module']:
|
||||||
|
summary_rows.append(('新增·按模块', name, cnt))
|
||||||
|
for operator, rows in audit['update_by_operator'].items():
|
||||||
|
summary_rows.append(('修改·按操作人', operator, len(rows)))
|
||||||
|
# ★ 附件的取舍写在这里,而不是只打日志:收件人看到的是附件,
|
||||||
|
# 日志他看不到。多一列"说明"专放这类提示。
|
||||||
|
summary_rows.append(('数据口径', '统计日期', date_str))
|
||||||
|
summary_rows.append((
|
||||||
|
'数据口径', '统计范围',
|
||||||
|
'北京时间当日 00:00–24:00;已排除 system 占位账号',
|
||||||
|
))
|
||||||
|
for note in notes:
|
||||||
|
summary_rows.append(('数据口径', note, ''))
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = '汇总'
|
||||||
|
fill_sheet(ws, ['分类', '项目', '数值/说明'], summary_rows, wrap_cols=(3,))
|
||||||
|
|
||||||
|
for title, headers, rows, wrap_cols in sheets:
|
||||||
|
fill_sheet(wb.create_sheet(title=title), headers, rows, wrap_cols)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 发送
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def build_report(report_date=None):
|
||||||
|
"""采集 + 渲染,但不发送 —— 用于预览与调试。返回 (subject, body, stats)。"""
|
||||||
|
stats = DailyReportService.collect(report_date)
|
||||||
|
subject, body = DailyReportService.render(stats)
|
||||||
|
return subject, body, stats
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_daily_report(report_date=None, recipients=None):
|
||||||
|
"""
|
||||||
|
采集 → 渲染 → 发送。供定时任务与手动触发共用。
|
||||||
|
|
||||||
|
recipients 缺省时取 config.MAIL_DAILY_REPORT_RECIPIENTS。
|
||||||
|
返回 {'subject','recipients','stats'},便于调用方打日志。
|
||||||
|
"""
|
||||||
|
from flask import current_app
|
||||||
|
from app.utils.email_service import send_email
|
||||||
|
|
||||||
|
if recipients is None:
|
||||||
|
raw = current_app.config.get('MAIL_DAILY_REPORT_RECIPIENTS', '') or ''
|
||||||
|
recipients = [e.strip() for e in raw.split(',') if e.strip()]
|
||||||
|
if isinstance(recipients, str):
|
||||||
|
recipients = [e.strip() for e in recipients.split(',') if e.strip()]
|
||||||
|
if not recipients:
|
||||||
|
logger.warning("[日报] 未配置收件人(MAIL_DAILY_REPORT_RECIPIENTS),跳过发送")
|
||||||
|
return {'subject': None, 'recipients': [], 'stats': None}
|
||||||
|
|
||||||
|
subject, body, stats = DailyReportService.build_report(report_date)
|
||||||
|
|
||||||
|
# ★ 附件失败不连累正文:正文摘要本身是有效信息,发出去远好过整封不发。
|
||||||
|
# 但**必须记 error**(不是 warning)—— 降级后邮件看起来完全正常,
|
||||||
|
# 不留下显眼日志的话,附件静默丢失可以持续几个月没人发现。
|
||||||
|
attachments = None
|
||||||
|
try:
|
||||||
|
data = DailyReportService.render_excel(stats)
|
||||||
|
attachments = [{
|
||||||
|
'filename': DailyReportService.excel_filename(stats['report_date']),
|
||||||
|
'data': data,
|
||||||
|
}]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[日报] Excel 附件生成失败,本次降级为纯文本正文: {e}",
|
||||||
|
exc_info=True)
|
||||||
|
|
||||||
|
send_email(recipients, subject, body, attachments=attachments)
|
||||||
|
logger.info(
|
||||||
|
f"[日报] 已发送 {stats['report_date']} → {recipients}"
|
||||||
|
f"(附件 {len(attachments[0]['data']) if attachments else 0} 字节)"
|
||||||
|
)
|
||||||
|
return {'subject': subject, 'recipients': recipients, 'stats': stats}
|
||||||
@ -4,6 +4,7 @@ from app.extensions import db
|
|||||||
from app.models.inbound.buy import StockBuy
|
from app.models.inbound.buy import StockBuy
|
||||||
from app.models.inbound.product import StockProduct
|
from app.models.inbound.product import StockProduct
|
||||||
from app.models.base import MaterialBase
|
from app.models.base import MaterialBase
|
||||||
|
from app.models.purchase import PurchaseRequest
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from sqlalchemy import or_, func, text, and_
|
from sqlalchemy import or_, func, text, and_
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@ -153,9 +154,27 @@ class BuyInboundService:
|
|||||||
in_qty = float(data.get('in_quantity') or 0)
|
in_qty = float(data.get('in_quantity') or 0)
|
||||||
u_price = float(data.get('unit_price') or 0)
|
u_price = float(data.get('unit_price') or 0)
|
||||||
tax_rate = float(data.get('tax_rate') or 0)
|
tax_rate = float(data.get('tax_rate') or 0)
|
||||||
|
|
||||||
# 计算税后单价
|
|
||||||
post_tax_price = float(data.get('post_tax_unit_price') or 0)
|
post_tax_price = float(data.get('post_tax_unit_price') or 0)
|
||||||
|
|
||||||
|
# [新增] 按单入库:关联采购申请单
|
||||||
|
request_id = data.get('request_id')
|
||||||
|
|
||||||
|
# ★ 补价:库管角色没有 inbound_purchase:unit_price / total_price 权限,
|
||||||
|
# 采购单接口(api/v1/purchase.py)会把价格字段整个 pop 掉,前端导入时
|
||||||
|
# 因此带不出价(buy.vue 的 hasUnitPrice/hasTotalPrice 双双落空,
|
||||||
|
# 两个分支都不进)→ 入库成本被记成 0。
|
||||||
|
# 这里在落库前从采购申请单补回:价格**不经过前端**,库管依旧看不到
|
||||||
|
# 采购价,权限边界不变,但成本可追溯。
|
||||||
|
# 注:采购申请单的 unit_price 存的是**含税**单价(见 purchase/index.vue
|
||||||
|
# 的列名"含税单价"),需要反算不含税单价。
|
||||||
|
if request_id and u_price <= 0 and post_tax_price <= 0:
|
||||||
|
_req = PurchaseRequest.query.get(request_id)
|
||||||
|
if _req and float(_req.unit_price or 0) > 0:
|
||||||
|
tax_rate = float(_req.tax_rate or 0)
|
||||||
|
post_tax_price = float(_req.unit_price)
|
||||||
|
u_price = post_tax_price / (1 + tax_rate / 100)
|
||||||
|
|
||||||
|
# 计算税后单价(保底:只给了不含税价时反推)
|
||||||
if post_tax_price == 0 and u_price > 0:
|
if post_tax_price == 0 and u_price > 0:
|
||||||
tax_multiplier = 1 + (tax_rate / 100)
|
tax_multiplier = 1 + (tax_rate / 100)
|
||||||
post_tax_price = u_price * tax_multiplier
|
post_tax_price = u_price * tax_multiplier
|
||||||
@ -170,9 +189,6 @@ class BuyInboundService:
|
|||||||
generated_sku = str(next_global_id).zfill(10) if next_global_id else datetime.now().strftime('%Y%m%d%H%M%S')
|
generated_sku = str(next_global_id).zfill(10) if next_global_id else datetime.now().strftime('%Y%m%d%H%M%S')
|
||||||
final_barcode = data.get('barcode') or generated_sku
|
final_barcode = data.get('barcode') or generated_sku
|
||||||
|
|
||||||
# [新增] 按单入库:关联采购申请单
|
|
||||||
request_id = data.get('request_id')
|
|
||||||
|
|
||||||
new_stock = StockBuy(
|
new_stock = StockBuy(
|
||||||
base_id=material.id, global_print_id=next_global_id, sku=generated_sku, barcode=final_barcode,
|
base_id=material.id, global_print_id=next_global_id, sku=generated_sku, barcode=final_barcode,
|
||||||
in_date=in_date_val, serial_number=data.get('serial_number'), batch_number=data.get('batch_number'),
|
in_date=in_date_val, serial_number=data.get('serial_number'), batch_number=data.get('batch_number'),
|
||||||
@ -201,8 +217,10 @@ class BuyInboundService:
|
|||||||
db.session.flush() # 获取 new_stock.id
|
db.session.flush() # 获取 new_stock.id
|
||||||
|
|
||||||
# [新增] 按单入库:反写采购申请单状态为"已完成"
|
# [新增] 按单入库:反写采购申请单状态为"已完成"
|
||||||
|
# 注:原先此处有一句局部 `from app.models.purchase import PurchaseRequest`,
|
||||||
|
# 它会让 Python 把 PurchaseRequest 视为本函数的局部变量,导致上方补价
|
||||||
|
# 逻辑(在 import 之前使用)抛 UnboundLocalError。已改用文件顶部的全局导入。
|
||||||
if request_id:
|
if request_id:
|
||||||
from app.models.purchase import PurchaseRequest
|
|
||||||
purchase_req = db.session.get(PurchaseRequest, request_id)
|
purchase_req = db.session.get(PurchaseRequest, request_id)
|
||||||
if purchase_req:
|
if purchase_req:
|
||||||
if purchase_req.status != 1:
|
if purchase_req.status != 1:
|
||||||
|
|||||||
@ -226,6 +226,8 @@ class ProductInboundService:
|
|||||||
'sku': material.spec_model or material.name,
|
'sku': material.spec_model or material.name,
|
||||||
'serial_number': new_stock.serial_number,
|
'serial_number': new_stock.serial_number,
|
||||||
'quantity': float(new_stock.in_quantity or 0),
|
'quantity': float(new_stock.in_quantity or 0),
|
||||||
|
# ★ 公司名决定通知哪个 Track 实例(IRIS / LICA),缺失则回落默认
|
||||||
|
'company_name': getattr(material, 'company_name', None),
|
||||||
'operator': get_current_operator(),
|
'operator': get_current_operator(),
|
||||||
})
|
})
|
||||||
return new_stock
|
return new_stock
|
||||||
|
|||||||
@ -265,6 +265,8 @@ class SemiInboundService:
|
|||||||
'sku': material.spec_model or material.name,
|
'sku': material.spec_model or material.name,
|
||||||
'serial_number': webhook_sn,
|
'serial_number': webhook_sn,
|
||||||
'quantity': float(new_stock.in_quantity or 0),
|
'quantity': float(new_stock.in_quantity or 0),
|
||||||
|
# ★ 公司名决定通知哪个 Track 实例(IRIS / LICA),缺失则回落默认
|
||||||
|
'company_name': getattr(material, 'company_name', None),
|
||||||
'operator': get_current_operator(),
|
'operator': get_current_operator(),
|
||||||
})
|
})
|
||||||
return new_stock
|
return new_stock
|
||||||
|
|||||||
@ -285,6 +285,13 @@ class OutboundService:
|
|||||||
# 在那里引用 approval 会直接 UnboundLocalError(上线时踩过)。
|
# 在那里引用 approval 会直接 UnboundLocalError(上线时踩过)。
|
||||||
common_data['applicant_id'] = approval.applicant_id
|
common_data['applicant_id'] = approval.applicant_id
|
||||||
|
|
||||||
|
# ★ 来源申请单也一并记下(同样必须在 approval 取出**之后**)。只落
|
||||||
|
# applicant_id 的话,从一条出库明细反查不到它属于哪张单 —— 单号
|
||||||
|
# request_no、申请说明、明细快照 items_json 全都在申请单上。
|
||||||
|
# ⚠ 这是**表里真实存在的列**,可以进 common_data;下面 notify_track
|
||||||
|
# 里那些只发给 Track 的展示字段(request_no / applicant_name)不行。
|
||||||
|
common_data['request_id'] = approval.id
|
||||||
|
|
||||||
model_map = {
|
model_map = {
|
||||||
'stock_buy': StockBuy,
|
'stock_buy': StockBuy,
|
||||||
'stock_semi': StockSemi,
|
'stock_semi': StockSemi,
|
||||||
@ -359,7 +366,11 @@ class OutboundService:
|
|||||||
repair.shipping_date = current_time
|
repair.shipping_date = current_time
|
||||||
|
|
||||||
# 收集 Track 联动信息(维修单带 serial_number)
|
# 收集 Track 联动信息(维修单带 serial_number)
|
||||||
track_notifications.append((getattr(repair, 'serial_number', None), source_table, quantity))
|
# ★ 连同 company_name 一起攒起来,发送时按公司路由到对应 Track 实例
|
||||||
|
track_notifications.append((
|
||||||
|
getattr(repair, 'serial_number', None), source_table, quantity,
|
||||||
|
getattr(getattr(repair, 'base', None), 'company_name', None),
|
||||||
|
))
|
||||||
|
|
||||||
# 创建出库记录
|
# 创建出库记录
|
||||||
new_record = TransOutbound(
|
new_record = TransOutbound(
|
||||||
@ -394,7 +405,11 @@ class OutboundService:
|
|||||||
raise ValueError(f"库存记录不存在 (ID: {stock_id})")
|
raise ValueError(f"库存记录不存在 (ID: {stock_id})")
|
||||||
|
|
||||||
# 收集 Track 联动信息(库存表 serial_number = Track 身份证)
|
# 收集 Track 联动信息(库存表 serial_number = Track 身份证)
|
||||||
track_notifications.append((getattr(stock_record, 'serial_number', None), source_table, quantity))
|
# ★ 连同 company_name 一起攒起来,发送时按公司路由到对应 Track 实例
|
||||||
|
track_notifications.append((
|
||||||
|
getattr(stock_record, 'serial_number', None), source_table, quantity,
|
||||||
|
getattr(getattr(stock_record, 'base', None), 'company_name', None),
|
||||||
|
))
|
||||||
|
|
||||||
new_record = TransOutbound(
|
new_record = TransOutbound(
|
||||||
sku=item.get('sku'),
|
sku=item.get('sku'),
|
||||||
@ -419,11 +434,11 @@ class OutboundService:
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# ★ 出库后通知 Track(发货出库 → Track 标记"已出库")
|
# ★ 出库后通知 Track(发货出库 → Track 标记"已出库")
|
||||||
# 仅在配置 TRACK_OUTBOUND_WEBHOOK_URL 时生效;notify_track 自身容错不阻断业务
|
# 按每条的 company_name 路由到对应实例(IRIS / LICA)。
|
||||||
|
# 这里刻意**不**显式传 url:显式 url 优先级最高,会一律打到默认实例,
|
||||||
|
# 导致 LICA 的出库永远收不到联动。notify_track 自身容错,不阻断业务。
|
||||||
try:
|
try:
|
||||||
from flask import current_app
|
for sn, src_tbl, qty, company in track_notifications:
|
||||||
outbound_url = current_app.config.get('TRACK_OUTBOUND_WEBHOOK_URL')
|
|
||||||
for sn, src_tbl, qty in track_notifications:
|
|
||||||
if not sn:
|
if not sn:
|
||||||
continue
|
continue
|
||||||
notify_track({
|
notify_track({
|
||||||
@ -432,8 +447,29 @@ class OutboundService:
|
|||||||
'serial_number': str(sn),
|
'serial_number': str(sn),
|
||||||
'quantity': float(qty or 0),
|
'quantity': float(qty or 0),
|
||||||
'outbound_type': common_data['outbound_type'],
|
'outbound_type': common_data['outbound_type'],
|
||||||
|
'company_name': company,
|
||||||
'operator': get_current_operator(),
|
'operator': get_current_operator(),
|
||||||
}, url=outbound_url)
|
# ↓ 单据上下文:Track 侧据此在产品详情展示「这台设备对应
|
||||||
|
# MOM 的哪张出库单」。这几个字段整批共用,直接读
|
||||||
|
# common_data / approval(两者在 notify_track 处都在作用域内)。
|
||||||
|
# ⚠ 只发给 Track 的展示字段,**不要**写进 common_data ——
|
||||||
|
# 它会被 ** 展开进 TransOutbound(**common_data),
|
||||||
|
# 多一个不在表里的键会直接抛 TypeError。
|
||||||
|
'outbound_no': common_data['outbound_no'],
|
||||||
|
'request_no': approval.request_no,
|
||||||
|
'consumer_name': common_data['consumer_name'],
|
||||||
|
'applicant_name': approval._get_user_name(approval.applicant_id),
|
||||||
|
# ⚠️ 必须带 +08:00 偏移,不能直接发 current_time。
|
||||||
|
# current_time 是 datetime.now(beijing_tz) 去掉 tzinfo 后的
|
||||||
|
# **北京墙上时间**(MOM 自己的库是 naive 的 timestamp,所以
|
||||||
|
# 本地这么存没问题)。但它一旦以无偏移的形式发出去,
|
||||||
|
# Track 的 timestamptz 列会按 **UTC** 解释,实测整整差 8
|
||||||
|
# 小时(发 15:00 存成 15:00+00,前端在北京渲染成 23:00)。
|
||||||
|
# 这里把 tzinfo 装回去还原真实时刻,发出去就是明确的
|
||||||
|
# "2026-09-23T15:00:00+08:00"。
|
||||||
|
'outbound_time': current_time.replace(tzinfo=beijing_tz).isoformat(),
|
||||||
|
'remark': common_data['remark'],
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger(__name__).warning(f"⚠️ Track 出库通知失败: {e}")
|
logging.getLogger(__name__).warning(f"⚠️ Track 出库通知失败: {e}")
|
||||||
|
|||||||
422
inventory-backend/app/services/return_service.py
Normal file
422
inventory-backend/app/services/return_service.py
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
"""原单退回(逆向物流)— 业务逻辑层
|
||||||
|
|
||||||
|
从 `app/api/v1/inbound/stock.py` 抽出来的。**抽出来的唯一动机**是给内部接口
|
||||||
|
(Track → MOM 生产报废)复用:那条链要「退回(不良品) + 提报废申请」在一个事务里
|
||||||
|
完成,而视图函数夹着 JWT 依赖(`get_current_company_filter()` / `_normalize_user_id()`),
|
||||||
|
内部接口没有 JWT,直接调会抛错。
|
||||||
|
|
||||||
|
抽的时候刻意**不改行为**,只做两件事:
|
||||||
|
· 把 JWT 依赖变成**显式入参**(`company_limit` / `operator_name`);
|
||||||
|
· 把 `commit` 变成开关,让调用方能接管提交点。
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════════════════════
|
||||||
|
为什么「已出库的料要报废」必须走退回
|
||||||
|
═══════════════════════════════════════════════════════════════════════════
|
||||||
|
料一经出库,那条库存行的 available_quantity / stock_quantity 在出库时就已扣掉
|
||||||
|
(见 outbound_service 的 restore_then_deduct)。而报废的上限正是可用库存,
|
||||||
|
所以对同一批料**发起不了**标准库存行报废。
|
||||||
|
|
||||||
|
本模块的 `is_defective=True` 分支就是这个矛盾的解:坏件转进独立的
|
||||||
|
`trans_defective_goods` 在管台账,**原库存表分毫不动** —— 既不重复扣减,
|
||||||
|
也不把坏件混回可分配池(status 是行级属性,加回原行会让整行良品被连坐隔离)。
|
||||||
|
|
||||||
|
⚠️ 因此:**不要**为了「让报废更直接」而在这里给库存行加数量。那是重复扣减。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.extensions import db, beijing_time
|
||||||
|
from app.models.outbound import TransOutbound
|
||||||
|
from app.models.transaction import (
|
||||||
|
TransReturn,
|
||||||
|
TransDefectiveGoods,
|
||||||
|
RETURN_TYPE_GOOD,
|
||||||
|
RETURN_TYPE_DEFECTIVE,
|
||||||
|
DEFECTIVE_STATUS_PENDING,
|
||||||
|
)
|
||||||
|
from app.services.inventory_reservation import (
|
||||||
|
STOCK_STATUS_IN_STOCK,
|
||||||
|
stock_model_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def lock_source_stock_row(source_table, stock_id):
|
||||||
|
"""解析并锁定退回目标的**原库存行**。业务不满足即抛 ValueError。
|
||||||
|
|
||||||
|
三条 Fail-Closed 规则:
|
||||||
|
1. source_table 必须是三张库存表之一 —— 维修单等非库存来源没有可退回的行;
|
||||||
|
2. 库存行必须仍然存在 —— 入库模块会物理删除库存行(见
|
||||||
|
buy/semi/product_service 的 db.session.delete(stock)),实测 1077 条
|
||||||
|
出库记录中已有 7 条指向不存在的行;
|
||||||
|
3. 调用方拿到行后还需自行做公司隔离与状态校验(见 assert_company_owns)。
|
||||||
|
|
||||||
|
★ 为什么必须加锁:本行随后会被加减数量,且与出库/报废/状态变更并发。
|
||||||
|
不加锁会出现「读-改-写」丢失更新(lost update)。
|
||||||
|
|
||||||
|
★ 与原 API 层 `get_stock_model` 的差异:这里用 `inventory_reservation.stock_model_map()`。
|
||||||
|
服务层不得反向 import API 层(会成循环依赖)。
|
||||||
|
"""
|
||||||
|
model = stock_model_map().get(source_table)
|
||||||
|
if model is None:
|
||||||
|
raise ValueError(
|
||||||
|
f'来源「{source_table or "(空)"}」不支持退回,'
|
||||||
|
f'仅支持 stock_buy / stock_semi / stock_product'
|
||||||
|
)
|
||||||
|
|
||||||
|
row = model.query.with_for_update().get(stock_id) if stock_id else None
|
||||||
|
if not row:
|
||||||
|
raise ValueError(
|
||||||
|
f'原库存行已不存在({source_table}#{stock_id}),无法自动退回,'
|
||||||
|
f'请改走入库流程手工登记这批实物'
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def assert_company_owns(row, company_limit):
|
||||||
|
"""行级多租户隔离:非跨域用户只能操作本公司库存。不满足即抛 PermissionError。
|
||||||
|
|
||||||
|
口径与扫码出库(OutboundService.get_stock_by_barcode)、状态变更接口完全一致
|
||||||
|
—— 都走 MaterialBase.company_name,避免多处隔离逻辑分叉。
|
||||||
|
|
||||||
|
★ `company_limit` 是**显式入参**,不在函数内读 JWT:内部接口(Track → MOM)
|
||||||
|
没有 JWT,`get_current_company_filter()` 里的 `get_jwt()` 会抛 RuntimeError,
|
||||||
|
被全局 errorhandler 吞成一条没有信息的 500,排查极难。
|
||||||
|
"""
|
||||||
|
if company_limit is None:
|
||||||
|
return
|
||||||
|
base = getattr(row, 'base', None)
|
||||||
|
if (company_limit == '__NO_COMPANY__' or base is None
|
||||||
|
or (base.company_name or '') != company_limit):
|
||||||
|
raise PermissionError('无权操作其他公司的库存')
|
||||||
|
|
||||||
|
|
||||||
|
def return_from_outbound(*, outbound_id, return_qty, is_defective, reason=None,
|
||||||
|
need_reissue=False, reissue_qty=None,
|
||||||
|
reissue_applicant_id=None, operator_name='System',
|
||||||
|
company_limit=None, source_ref=None, commit=True):
|
||||||
|
"""原单退回。返回 dict(形状与 API 响应的 data 字段一致)。
|
||||||
|
|
||||||
|
:param company_limit: 调用方所在公司(超管/内部接口传 None = 不限)
|
||||||
|
:param source_ref: 外部系统唯一引用(`<company>:<外部单号>`),供判重
|
||||||
|
:param commit: False 时只 flush,提交权交给调用方(组合操作用)
|
||||||
|
|
||||||
|
⚠️ 调用方负责处理异常与 rollback;本函数不吞异常。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return_qty = float(return_qty or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError('return_qty 无效')
|
||||||
|
if return_qty <= 0:
|
||||||
|
raise ValueError('退回数量必须大于 0')
|
||||||
|
if is_defective is None:
|
||||||
|
raise ValueError('is_defective 为必填(true=不良品退回,false=良品退回)')
|
||||||
|
is_defective = bool(is_defective)
|
||||||
|
|
||||||
|
# ---- 1. 锁定原出库明细并校验退回额度 ----
|
||||||
|
# ★ 行锁不可省:并发两笔退回若各自读到相同的 returned_quantity,会双双
|
||||||
|
# 通过额度校验,合计退回量超过出库量 —— 凭空多出库存。
|
||||||
|
outbound = TransOutbound.query.with_for_update().get(outbound_id)
|
||||||
|
if not outbound:
|
||||||
|
raise ValueError(f'出库记录不存在(ID: {outbound_id})')
|
||||||
|
|
||||||
|
shipped = float(outbound.quantity or 0)
|
||||||
|
returned = float(outbound.returned_quantity or 0)
|
||||||
|
returnable = shipped - returned
|
||||||
|
if return_qty > returnable:
|
||||||
|
raise ValueError(
|
||||||
|
f'退回数量({return_qty})超出可退额度({returnable}):'
|
||||||
|
f'原出库 {shipped},已退回 {returned}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 2. 锁定原库存行 + 多租户隔离 ----
|
||||||
|
stock_row = lock_source_stock_row(outbound.source_table, outbound.stock_id)
|
||||||
|
assert_company_owns(stock_row, company_limit)
|
||||||
|
|
||||||
|
# 公司快照:退回看板的隔离判定不能依赖 join 链 —— 源库存行会被入库模块
|
||||||
|
# 物理删除,届时链路断裂会让记录对普通用户静默消失。见 TransReturn 注释。
|
||||||
|
_base = getattr(stock_row, 'base', None)
|
||||||
|
snapshot_company = ((_base.company_name if _base else '') or '').strip() or None
|
||||||
|
|
||||||
|
goods = None
|
||||||
|
if is_defective:
|
||||||
|
# ================= 不良品分支 =================
|
||||||
|
# ★ 原库存表**分毫不动**:坏件全程存放于独立在管台账,既不占用库存
|
||||||
|
# 数量、也不改库存行 status,从根上杜绝「坏件混进可分配池」。
|
||||||
|
base = getattr(stock_row, 'base', None)
|
||||||
|
goods = TransDefectiveGoods(
|
||||||
|
outbound_id=outbound.id,
|
||||||
|
source_table=outbound.source_table,
|
||||||
|
stock_id=outbound.stock_id,
|
||||||
|
base_id=getattr(stock_row, 'base_id', None),
|
||||||
|
sku=getattr(stock_row, 'sku', '') or '',
|
||||||
|
material_name=(base.name if base else '') or '',
|
||||||
|
spec_model=(base.spec_model if base else '') or '',
|
||||||
|
# ★ 原库位/批次快照:此刻 stock_row 已 with_for_update() 锁在手里,
|
||||||
|
# 是**唯一**能可靠取到这两个值的时机 —— 源库存行日后会被入库模块
|
||||||
|
# 物理删除,届时回查落空,报表上就只剩 "-"。
|
||||||
|
# 取值口径与 scrap.py 的 _from_stock 一致(成品表无 batch_number,
|
||||||
|
# 回退到 serial_number)。
|
||||||
|
warehouse_location=getattr(stock_row, 'warehouse_location', '') or '',
|
||||||
|
batch_number=(getattr(stock_row, 'batch_number', '')
|
||||||
|
or getattr(stock_row, 'serial_number', '') or ''),
|
||||||
|
quantity=return_qty,
|
||||||
|
remaining_qty=return_qty,
|
||||||
|
status=DEFECTIVE_STATUS_PENDING,
|
||||||
|
company_name=(base.company_name if base else '') or '',
|
||||||
|
reason=reason,
|
||||||
|
operator=operator_name,
|
||||||
|
)
|
||||||
|
db.session.add(goods)
|
||||||
|
outcome = '不良品已转入在管台账'
|
||||||
|
else:
|
||||||
|
# ================= 良品分支 =================
|
||||||
|
# ★ 状态防呆:把良品加回一个已冻结/不良品的行,会让良品被该行的状态
|
||||||
|
# 连带隔离(status 是行级属性)—— 静默造成良品不可用。宁可报错让
|
||||||
|
# 人先决定该行的归属。
|
||||||
|
current = (stock_row.status or '').strip()
|
||||||
|
if current != STOCK_STATUS_IN_STOCK:
|
||||||
|
raise ValueError(
|
||||||
|
f'原库存行当前状态为「{current or "未设置"}」,'
|
||||||
|
f'良品退回要求该行处于「{STOCK_STATUS_IN_STOCK}」状态'
|
||||||
|
)
|
||||||
|
stock_row.stock_quantity = float(stock_row.stock_quantity or 0) + return_qty
|
||||||
|
stock_row.available_quantity = float(stock_row.available_quantity or 0) + return_qty
|
||||||
|
outcome = '良品已加回原库存'
|
||||||
|
|
||||||
|
# ---- 3. 累加退回额度 + 写退回流水 ----
|
||||||
|
outbound.returned_quantity = returned + return_qty
|
||||||
|
|
||||||
|
ledger = TransReturn(
|
||||||
|
outbound_id=outbound.id,
|
||||||
|
stock_id=outbound.stock_id,
|
||||||
|
source_table=outbound.source_table,
|
||||||
|
sku=outbound.sku,
|
||||||
|
return_qty=return_qty,
|
||||||
|
return_type=RETURN_TYPE_DEFECTIVE if is_defective else RETURN_TYPE_GOOD,
|
||||||
|
reason=reason,
|
||||||
|
operator=operator_name,
|
||||||
|
company_name=snapshot_company,
|
||||||
|
source_ref=(source_ref or '').strip() or None,
|
||||||
|
)
|
||||||
|
db.session.add(ledger)
|
||||||
|
# ★ 这个 flush 不能省:要拿 ledger.id 回填在管台账,补发单也要 source_return_id
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# 在管台账回填来源流水 id,形成「出库 → 退回流水 → 在管台账」的追溯闭环
|
||||||
|
if goods is not None:
|
||||||
|
goods.return_id = ledger.id
|
||||||
|
|
||||||
|
# ==================================================================
|
||||||
|
# ---- 4. 补发(可选)----
|
||||||
|
# 退回后申请人往往**仍然需要这件东西**(尤其是坏件 —— 原需求并未
|
||||||
|
# 被满足)。勾选即自动生成一张**免审批**的出库单并关联回本笔退回,
|
||||||
|
# 使「退回 → 补发」形成闭环;否则现场只能靠人记住再手建一张单,
|
||||||
|
# 而那张单与原单看不出任何关系。
|
||||||
|
#
|
||||||
|
# ★ 库存不足时**整笔回滚**(下面的 reserve_for_items 会抛错)。
|
||||||
|
# 若只让补发静默失败,「需要补发」的意图就丢了 —— 那正是本功能
|
||||||
|
# 要解决的问题。回滚后库管会看到明确提示,可取消勾选重试。
|
||||||
|
# ==================================================================
|
||||||
|
reissue = None
|
||||||
|
if need_reissue:
|
||||||
|
reissue, reissue_qty = _create_reissue(
|
||||||
|
outbound=outbound, stock_row=stock_row, ledger=ledger,
|
||||||
|
return_qty=return_qty, reissue_qty=reissue_qty,
|
||||||
|
reissue_applicant_id=reissue_applicant_id,
|
||||||
|
company_limit=company_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
db.session.commit()
|
||||||
|
else:
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'outbound_id': outbound.id,
|
||||||
|
'outbound_no': outbound.outbound_no or '',
|
||||||
|
'return_id': ledger.id,
|
||||||
|
'return_type': ledger.return_type,
|
||||||
|
'return_qty': return_qty,
|
||||||
|
'returned_quantity': float(outbound.returned_quantity),
|
||||||
|
'returnable_quantity': shipped - float(outbound.returned_quantity),
|
||||||
|
'defective_goods_id': goods.id if goods is not None else None,
|
||||||
|
'company_name': snapshot_company or '',
|
||||||
|
'product_id': getattr(stock_row, 'base_id', None),
|
||||||
|
'outcome': outcome,
|
||||||
|
'reissue': ({
|
||||||
|
'id': reissue.id,
|
||||||
|
'request_no': reissue.request_no,
|
||||||
|
'quantity': reissue_qty,
|
||||||
|
} if reissue else None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_reissue(*, outbound, stock_row, ledger, return_qty, reissue_qty,
|
||||||
|
reissue_applicant_id, company_limit):
|
||||||
|
"""生成免审批补发单。返回 (reissue, reissue_qty)。失败即抛错(整笔回滚)。"""
|
||||||
|
# 单号生成器在 OutboundApprovalService 上(不在 OutboundService)
|
||||||
|
from app.services.outbound_service import OutboundApprovalService
|
||||||
|
from app.services.inventory_reservation import reserve_for_items
|
||||||
|
from app.models.outbound import OutboundApproval
|
||||||
|
|
||||||
|
if reissue_qty is None:
|
||||||
|
reissue_qty = return_qty # 默认与本次退回量一致
|
||||||
|
try:
|
||||||
|
reissue_qty = float(reissue_qty)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError('补发数量格式无效')
|
||||||
|
if reissue_qty <= 0:
|
||||||
|
raise ValueError('补发数量必须大于 0')
|
||||||
|
if reissue_qty > return_qty:
|
||||||
|
raise ValueError(
|
||||||
|
f'补发数量({reissue_qty})不能大于本次退回数量({return_qty})'
|
||||||
|
)
|
||||||
|
|
||||||
|
base = getattr(stock_row, 'base', None)
|
||||||
|
if base is None:
|
||||||
|
raise ValueError('原库存行的物料主数据已不存在,无法生成补发单')
|
||||||
|
|
||||||
|
# 提交即预占,strict=True —— 与出库申请同一口径,不足即整单失败
|
||||||
|
reserved_items, _shortages = reserve_for_items(
|
||||||
|
[{
|
||||||
|
'base_id': base.id,
|
||||||
|
'name': base.name or '',
|
||||||
|
'spec_model': base.spec_model or '',
|
||||||
|
'quantity': reissue_qty,
|
||||||
|
}],
|
||||||
|
company_limit=company_limit,
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ★ 申请人(补发给谁)优先级:
|
||||||
|
# ① 前端显式指定 reissue_applicant_id —— 现场最清楚该给谁;
|
||||||
|
# ② 回退到原出库明细记录的 applicant_id(创建出库时从审批单带出的
|
||||||
|
# 真实原申请人);
|
||||||
|
# ③ 两者都没有 → **报错要求指定**。
|
||||||
|
#
|
||||||
|
# ★ 绝不回退为「当前操作人」:补发是**原申请人的需求**,挂到办理
|
||||||
|
# 退回的库管名下逻辑不通 —— 那张单会出现在库管的「我的申请」里,
|
||||||
|
# 而真正该拿东西的人什么也看不到。
|
||||||
|
# ⚠ 存量出库明细的 applicant_id 为 NULL(实测 1364 行中 1127 行为 NULL,
|
||||||
|
# 历史无从回填),此时必须由调用方明确指定 —— 宁可多一步,也不猜错人。
|
||||||
|
if reissue_applicant_id:
|
||||||
|
try:
|
||||||
|
_applicant = int(reissue_applicant_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError('补发申请人ID格式无效')
|
||||||
|
from app.models.system import SysUser
|
||||||
|
if not SysUser.query.get(_applicant):
|
||||||
|
raise ValueError(f'补发申请人不存在(ID:{reissue_applicant_id})')
|
||||||
|
elif outbound.applicant_id:
|
||||||
|
_applicant = int(outbound.applicant_id)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
'无法确定补发单申请人:这张出库单产生于「申请人」字段上线之前,'
|
||||||
|
'请指定「补发给谁」'
|
||||||
|
)
|
||||||
|
|
||||||
|
reissue = OutboundApproval(
|
||||||
|
request_no=OutboundApprovalService.generate_request_no(),
|
||||||
|
applicant_id=_applicant,
|
||||||
|
outbound_type=outbound.outbound_type,
|
||||||
|
# 免审批:原需求已经批过一次,补发只是兑现它,重复审批是负担
|
||||||
|
status=1,
|
||||||
|
approved_at=beijing_time(),
|
||||||
|
source_return_id=ledger.id,
|
||||||
|
remark=(f'原单退回补发(原出库单 {outbound.outbound_no or outbound.id}'
|
||||||
|
f',原领用人 {outbound.consumer_name or "未知"})'),
|
||||||
|
)
|
||||||
|
reissue.set_items(reserved_items)
|
||||||
|
reissue.allowed_approvers = '[]'
|
||||||
|
db.session.add(reissue)
|
||||||
|
db.session.flush()
|
||||||
|
return reissue, reissue_qty
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 生产报废:退回(不良品) + 提交报废申请 —— **一个原子操作**
|
||||||
|
# =============================================================================
|
||||||
|
def return_and_submit_production_scrap(*, outbound_id, return_qty, applicant_id,
|
||||||
|
operator_name='Track系统',
|
||||||
|
company_limit=None, reason=None,
|
||||||
|
reason_category='PRODUCTION',
|
||||||
|
source_ref=None, submit_scrap=True,
|
||||||
|
scrap_qty=None):
|
||||||
|
"""生产领用的料报废:先退回登记为在管不良品,(可选)再提交报废申请。
|
||||||
|
|
||||||
|
:param submit_scrap: True = 退回并提报废申请(一步到位);
|
||||||
|
False = 只退回登记为在管不良品(以后可能修好回库)
|
||||||
|
:param source_ref: `<company>:<外部单号>`,幂等锚点
|
||||||
|
|
||||||
|
★ 本函数是**唯一提交点**。退回段与报废申请段共用同一个 db.session,
|
||||||
|
任一步抛错 → 调用方 rollback() → 整笔回滚。绝不会出现
|
||||||
|
「料已退回成在管不良品、但报废申请没提交」这种半截状态。
|
||||||
|
|
||||||
|
★ 为什么 scrap_qty 允许小于 return_qty:本次未必全废(部分修好、
|
||||||
|
部分继续用)。但**不能大于** —— 那必然生成一张执行不了的申请单
|
||||||
|
(在管台账的 cap 就是本次退回量)。
|
||||||
|
|
||||||
|
⚠️ 只走不良品分支(is_defective 恒 True)。良品分支会往库存行加数量,
|
||||||
|
对外部接口来说那等于凭空造库存,爆炸半径太大,不开放。
|
||||||
|
"""
|
||||||
|
from app.services.scrap_approval_service import ScrapApprovalService
|
||||||
|
|
||||||
|
if submit_scrap and scrap_qty is None:
|
||||||
|
scrap_qty = return_qty
|
||||||
|
if scrap_qty is not None:
|
||||||
|
try:
|
||||||
|
scrap_qty = float(scrap_qty)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError('scrap_qty 无效')
|
||||||
|
if scrap_qty <= 0:
|
||||||
|
raise ValueError('scrap_qty 必须大于 0')
|
||||||
|
if scrap_qty > float(return_qty):
|
||||||
|
raise ValueError(
|
||||||
|
f'scrap_qty({scrap_qty})不能大于本次退回数量({return_qty})'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 第 1 段:退回(只 flush,不 commit)----
|
||||||
|
ret = return_from_outbound(
|
||||||
|
outbound_id=outbound_id,
|
||||||
|
return_qty=return_qty,
|
||||||
|
is_defective=True, # ★ 恒为不良品,不开放良品分支
|
||||||
|
reason=reason,
|
||||||
|
need_reissue=False, # ★ 补发不开放给外部接口
|
||||||
|
operator_name=operator_name,
|
||||||
|
company_limit=company_limit,
|
||||||
|
source_ref=source_ref,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ret.get('defective_goods_id'):
|
||||||
|
# 走到这里说明撤回分支出了问题(is_defective=True 必然产出在管行)
|
||||||
|
raise ValueError('内部错误:未生成在管不良品记录')
|
||||||
|
|
||||||
|
# ---- 第 2 段:报废申请(只 flush,不 commit)----
|
||||||
|
scrap = None
|
||||||
|
if submit_scrap:
|
||||||
|
scrap = ScrapApprovalService.submit_approval(
|
||||||
|
applicant_id=applicant_id,
|
||||||
|
items=[{
|
||||||
|
'source_table': 'trans_defective_goods',
|
||||||
|
'stock_id': ret['defective_goods_id'],
|
||||||
|
'scrap_qty': scrap_qty,
|
||||||
|
}],
|
||||||
|
remark=reason,
|
||||||
|
reason_category=reason_category,
|
||||||
|
company_name=ret.get('company_name') or None,
|
||||||
|
source_ref=source_ref,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 唯一提交点 ----
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(
|
||||||
|
f"[ProductionScrap] 受理完成 source_ref={source_ref} "
|
||||||
|
f"outbound={outbound_id} qty={return_qty} submit_scrap={submit_scrap} "
|
||||||
|
f"defective_goods={ret.get('defective_goods_id')} "
|
||||||
|
f"request_no={getattr(scrap, 'request_no', None)}"
|
||||||
|
)
|
||||||
|
return ret, scrap
|
||||||
@ -29,6 +29,22 @@ def _beijing():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
SCRAP_ALWAYS_REQUIRES_APPROVAL = True
|
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() 硬编码三张库存表。来源差异已全部收敛到
|
# 注:原先此处有 _stock_models() 硬编码三张库存表。来源差异已全部收敛到
|
||||||
# app/services/scrap_sources.py 的来源适配层(它复用
|
# app/services/scrap_sources.py 的来源适配层(它复用
|
||||||
# inventory_reservation.stock_model_map(),避免第四份重复定义),
|
# inventory_reservation.stock_model_map(),避免第四份重复定义),
|
||||||
@ -37,6 +53,139 @@ SCRAP_ALWAYS_REQUIRES_APPROVAL = True
|
|||||||
|
|
||||||
class ScrapApprovalService:
|
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
|
@staticmethod
|
||||||
def generate_request_no():
|
def generate_request_no():
|
||||||
now = _beijing()
|
now = _beijing()
|
||||||
@ -50,15 +199,39 @@ class ScrapApprovalService:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def submit_approval(applicant_id, items, allowed_approvers=None, remark=None,
|
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 / 快照字段。
|
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:
|
if not items:
|
||||||
raise ValueError("报废明细不能为空")
|
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 里。
|
# 可报废上限、扣减行为与快照字段,差异全部收敛在 scrap_sources 里。
|
||||||
# 原先此处硬编码「只认三张库存表」,导致借出未还与在管不良品只能
|
# 原先此处硬编码「只认三张库存表」,导致借出未还与在管不良品只能
|
||||||
@ -114,42 +287,96 @@ class ScrapApprovalService:
|
|||||||
from app.services.approval_control import resolve_approval_control
|
from app.services.approval_control import resolve_approval_control
|
||||||
_, flagged_materials = resolve_approval_control(normalized)
|
_, flagged_materials = resolve_approval_control(normalized)
|
||||||
|
|
||||||
if not 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:
|
if flagged_materials:
|
||||||
_names = ";".join(f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials)
|
_names = ";".join(
|
||||||
raise ValueError(f"以下物料需审批报废:{_names}。请选择审批人后再提交")
|
f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials
|
||||||
raise ValueError("报废申请必须选择审批人后再提交")
|
)
|
||||||
|
logger.info(f"[ScrapApproval] 命中需审批物料(角色级审批):{_names}")
|
||||||
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
|
|
||||||
|
|
||||||
req = ScrapApproval(
|
req = ScrapApproval(
|
||||||
request_no=ScrapApprovalService.generate_request_no(),
|
request_no=ScrapApprovalService.generate_request_no(),
|
||||||
applicant_id=applicant_id,
|
applicant_id=applicant_id,
|
||||||
remark=remark,
|
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_items(normalized)
|
||||||
req.set_allowed_approvers(allowed_approvers)
|
req.set_allowed_approvers(final_approvers)
|
||||||
|
|
||||||
# ★ 恒为「待审批」,不再走免审批自动通过分支
|
# ★ 恒为「待审批」,不再走免审批自动通过分支
|
||||||
req.status = 0
|
req.status = 0
|
||||||
|
|
||||||
db.session.add(req)
|
db.session.add(req)
|
||||||
|
if commit:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
logger.info(f"[ScrapApproval] 提交成功 {req.request_no} approver={approver_id}")
|
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
|
return req
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 列表
|
# 列表
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@staticmethod
|
@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
|
query = ScrapApproval.query
|
||||||
if status is not None:
|
if status is not None:
|
||||||
query = query.filter(ScrapApproval.status == status)
|
query = query.filter(ScrapApproval.status == status)
|
||||||
if applicant_id is not None:
|
if applicant_id is not None:
|
||||||
query = query.filter(ScrapApproval.applicant_id == applicant_id)
|
query = query.filter(ScrapApproval.applicant_id == applicant_id)
|
||||||
|
|
||||||
|
approver_conds = []
|
||||||
if approver_id is not None:
|
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())
|
query = query.order_by(ScrapApproval.created_at.desc())
|
||||||
pg = query.paginate(page=page, per_page=limit, error_out=False)
|
pg = query.paginate(page=page, per_page=limit, error_out=False)
|
||||||
return {
|
return {
|
||||||
@ -170,7 +397,7 @@ class ScrapApprovalService:
|
|||||||
if req.status != 0:
|
if req.status != 0:
|
||||||
raise ValueError("当前状态不允许审批(仅待审批可操作)")
|
raise ValueError("当前状态不允许审批(仅待审批可操作)")
|
||||||
|
|
||||||
# 仅被指定的审批人可操作。
|
# 仅被指定的审批人(或审批角色)可操作。
|
||||||
#
|
#
|
||||||
# ★ Fail-Closed:原实现是 `if user_entries and str(operator_id) not in ...`
|
# ★ Fail-Closed:原实现是 `if user_entries and str(operator_id) not in ...`
|
||||||
# —— 当 allowed_approvers 为空(或条目里没有 type='user')时 user_entries
|
# —— 当 allowed_approvers 为空(或条目里没有 type='user')时 user_entries
|
||||||
@ -178,12 +405,18 @@ class ScrapApprovalService:
|
|||||||
# 「报废一律需审批」直接矛盾:留一扇「无审批人则人人可审」的门,
|
# 「报废一律需审批」直接矛盾:留一扇「无审批人则人人可审」的门,
|
||||||
# 等于没有审批。现改为无名单即拒绝。
|
# 等于没有审批。现改为无名单即拒绝。
|
||||||
# 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。
|
# 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。
|
||||||
|
#
|
||||||
|
# ★ 角色级审批(2026-09):名单里现在还可以是 {"type":"role","value":"SUPERVISOR"}。
|
||||||
|
# 放宽的只是「条目类型」,**空名单依然拒绝所有人** ——
|
||||||
|
# 这条不变量在 can_operator_approve 里,任何改动都不得把它改回 fail-open。
|
||||||
allowed = req.get_allowed_approvers() or []
|
allowed = req.get_allowed_approvers() or []
|
||||||
user_entries = [str(a.get('value')) for a in allowed if a.get('type') == 'user']
|
user_entries, role_entries = ScrapApprovalService._approver_entries(allowed)
|
||||||
if not user_entries:
|
if not user_entries and not role_entries:
|
||||||
raise ValueError("该申请单未指定审批人,无法审批,请联系管理员处理")
|
raise ValueError("该申请单未指定审批人或审批角色,无法审批,请联系管理员处理")
|
||||||
if str(operator_id) not in user_entries:
|
if not ScrapApprovalService.can_operator_approve(req, operator_id):
|
||||||
raise ValueError("只有被指定的审批人可以审批该申请")
|
raise ValueError("只有被指定的审批人或审批角色可以审批该申请")
|
||||||
|
# ★ 角色级审批的必要配套:不隔离公司,LICA 主管就能审 IRIS 的单
|
||||||
|
ScrapApprovalService.assert_same_company(req, operator_id)
|
||||||
|
|
||||||
if action == 'approve':
|
if action == 'approve':
|
||||||
req.status = 1
|
req.status = 1
|
||||||
|
|||||||
@ -115,9 +115,17 @@ class ScrapSourceAdapter:
|
|||||||
# --- 台账公共字段 ---
|
# --- 台账公共字段 ---
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _ledger_kwargs(req, operator_name):
|
def _ledger_kwargs(req, operator_name):
|
||||||
|
"""写 TransScrap 的公共字段 —— **三种来源适配器全部经过这里**。
|
||||||
|
|
||||||
|
★ 这是报废原因分类落到台账的**唯一出口**,分类只在这里带一次,
|
||||||
|
三个 adapter(库存行 / 在管不良品 / 借出未还)就都通了。
|
||||||
|
「统计生产报废金额」按 trans_scrap.reason_category 分组,不要靠
|
||||||
|
reason 自由文本去匹配。
|
||||||
|
"""
|
||||||
from app.models.scrap_approval import ScrapApproval
|
from app.models.scrap_approval import ScrapApproval
|
||||||
return {
|
return {
|
||||||
'reason': req.remark or '',
|
'reason': req.remark or '',
|
||||||
|
'reason_category': getattr(req, 'reason_category', None),
|
||||||
'operator_name': operator_name,
|
'operator_name': operator_name,
|
||||||
'approver_name': ScrapApproval._user_name(req.actual_approver_id),
|
'approver_name': ScrapApproval._user_name(req.actual_approver_id),
|
||||||
'approval_status': 'executed',
|
'approval_status': 'executed',
|
||||||
@ -238,9 +246,39 @@ class DefectiveScrapAdapter(ScrapSourceAdapter):
|
|||||||
def cap(self, row):
|
def cap(self, row):
|
||||||
return float(getattr(row, 'remaining_qty', 0) or 0)
|
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):
|
def snapshot(self, row, qty, raw):
|
||||||
# 物料名/规格取自台账自身的冗余快照,**不联表 MaterialBase** ——
|
# 物料名/规格取自台账自身的冗余快照,**不联表 MaterialBase** ——
|
||||||
# 原库存行可能已被物理删除,联表会取到空值。
|
# 原库存行可能已被物理删除,联表会取到空值。
|
||||||
|
location, batch_number = self._resolve_location(row)
|
||||||
return {
|
return {
|
||||||
'source_table': self.source_table,
|
'source_table': self.source_table,
|
||||||
'stock_id': row.id,
|
'stock_id': row.id,
|
||||||
@ -248,8 +286,8 @@ class DefectiveScrapAdapter(ScrapSourceAdapter):
|
|||||||
'sku': getattr(row, 'sku', '') or '',
|
'sku': getattr(row, 'sku', '') or '',
|
||||||
'name': getattr(row, 'material_name', '') or raw.get('name') or '',
|
'name': getattr(row, 'material_name', '') or raw.get('name') or '',
|
||||||
'spec_model': getattr(row, 'spec_model', '') or raw.get('spec_model') or '',
|
'spec_model': getattr(row, 'spec_model', '') or raw.get('spec_model') or '',
|
||||||
'location': '', # 台账无库位字段
|
'location': location,
|
||||||
'batch_number': '',
|
'batch_number': batch_number,
|
||||||
'scrap_qty': qty,
|
'scrap_qty': qty,
|
||||||
'available_at_apply': self.cap(row),
|
'available_at_apply': self.cap(row),
|
||||||
'scrap_mode': self.scrap_mode,
|
'scrap_mode': self.scrap_mode,
|
||||||
|
|||||||
@ -29,15 +29,28 @@ def lookup_product(code):
|
|||||||
sku/material_name/spec_model/material_type/order_no),未命中或异常返回 None。
|
sku/material_name/spec_model/material_type/order_no),未命中或异常返回 None。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
base_url = (current_app.config.get('TRACK_API_URL') or '').strip()
|
|
||||||
api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip()
|
api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip()
|
||||||
if not base_url:
|
|
||||||
logger.info("[TrackQuery] 未配置 TRACK_API_URL,跳过查询")
|
|
||||||
return None
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
logger.info("[TrackQuery] 未配置 TRACK_WEBHOOK_KEY,跳过查询")
|
logger.info("[TrackQuery] 未配置 TRACK_WEBHOOK_KEY,跳过查询")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# ★ 按当前用户所属公司路由到对应的 Track 实例(IRIS / LICA)。
|
||||||
|
# 复用 get_current_company_filter():超管/跨域用户返回 None → 回落默认实例。
|
||||||
|
# 改造前这里写死读 TRACK_API_URL,LICA 员工在 LICA 业务里扫码会打到 IRIS 库,
|
||||||
|
# 查不到 → MOM 误报"物料不存在"。
|
||||||
|
from app.services.track_webhook_service import resolve_track_route
|
||||||
|
from app.utils.decorators import get_current_company_filter
|
||||||
|
try:
|
||||||
|
company = get_current_company_filter()
|
||||||
|
except Exception:
|
||||||
|
# 无请求/JWT 上下文(脚本、后台任务)时回落默认实例,不阻断查询
|
||||||
|
company = None
|
||||||
|
|
||||||
|
base_url = resolve_track_route(company, 'api')
|
||||||
|
if not base_url:
|
||||||
|
logger.info("[TrackQuery] 未解析到 Track API 地址,跳过查询")
|
||||||
|
return None
|
||||||
|
|
||||||
url = f'{base_url}/api/v1/external/products/lookup'
|
url = f'{base_url}/api/v1/external/products/lookup'
|
||||||
headers = {
|
headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@ -37,6 +37,54 @@ def _post_to_track(payload, url, api_key):
|
|||||||
logger.error("[TrackWebhook] 发送失败 url=%s, err=%s", url, e)
|
logger.error("[TrackWebhook] 发送失败 url=%s, err=%s", url, e)
|
||||||
|
|
||||||
|
|
||||||
|
# 各通道未命中路由表时回落到哪个扁平配置变量(保持改造前的单实例行为)
|
||||||
|
_TRACK_ROUTE_FALLBACK = {
|
||||||
|
'api': 'TRACK_API_URL',
|
||||||
|
'inbound': 'TRACK_WEBHOOK_URL',
|
||||||
|
'outbound': 'TRACK_OUTBOUND_WEBHOOK_URL',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_track_route(company_name, channel):
|
||||||
|
"""按公司名解析 Track 实例地址。
|
||||||
|
|
||||||
|
channel: 'api' | 'inbound' | 'outbound'
|
||||||
|
|
||||||
|
⚠️ 未命中必须**回落到扁平变量**,不能返回空 —— 漏配一个 key 就静默断链,
|
||||||
|
比配置写错更难排查。
|
||||||
|
"""
|
||||||
|
routes = current_app.config.get('TRACK_ROUTES') or {}
|
||||||
|
|
||||||
|
if company_name and company_name in routes:
|
||||||
|
url = ((routes.get(company_name) or {}).get(channel) or '').strip()
|
||||||
|
if url:
|
||||||
|
return url
|
||||||
|
# 公司在表里但该通道没配 —— 这是配置漏项,务必暴露出来
|
||||||
|
logger.warning(
|
||||||
|
"[TrackRoute] 公司 %s 的 %s 通道未配置,回落到全局默认地址",
|
||||||
|
company_name, channel,
|
||||||
|
)
|
||||||
|
elif company_name:
|
||||||
|
# 非空但不在路由表内:可能是拼写异常的公司名,也可能是
|
||||||
|
# get_current_company_filter() 的 '__NO_COMPANY__' 哨兵(用户 JWT 无公司)。
|
||||||
|
# 不猜、不阻断,只暴露给业务确认。
|
||||||
|
logger.warning(
|
||||||
|
"[TrackRoute] 公司 '%s' 不在 TRACK_ROUTES 中,回落到全局默认地址(channel=%s)。"
|
||||||
|
"若该值不是合法部门名,请业务确认主数据",
|
||||||
|
company_name, channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
fallback_key = _TRACK_ROUTE_FALLBACK.get(channel)
|
||||||
|
if not fallback_key:
|
||||||
|
return ''
|
||||||
|
return (current_app.config.get(fallback_key) or '').strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_from_event(event):
|
||||||
|
"""从事件名推断路由通道;缺省按入库处理。"""
|
||||||
|
return 'outbound' if 'outbound' in (event or '').lower() else 'inbound'
|
||||||
|
|
||||||
|
|
||||||
def get_current_operator():
|
def get_current_operator():
|
||||||
"""从 JWT 中安全获取当前操作人姓名(失败返回空字符串,不抛异常)"""
|
"""从 JWT 中安全获取当前操作人姓名(失败返回空字符串,不抛异常)"""
|
||||||
try:
|
try:
|
||||||
@ -50,7 +98,7 @@ def get_current_operator():
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def notify_track(payload, url=None):
|
def notify_track(payload, url=None, channel=None):
|
||||||
"""
|
"""
|
||||||
异步通知 Track 系统业务事件(入库/出库等)。
|
异步通知 Track 系统业务事件(入库/出库等)。
|
||||||
|
|
||||||
@ -58,10 +106,15 @@ def notify_track(payload, url=None):
|
|||||||
- 未配置 URL / Key 为空 -> 静默跳过
|
- 未配置 URL / Key 为空 -> 静默跳过
|
||||||
- 网络异常 / 超时 -> 仅记录日志
|
- 网络异常 / 超时 -> 仅记录日志
|
||||||
|
|
||||||
:param url: 指定 Track webhook 地址;不传则用默认 TRACK_WEBHOOK_URL(入库)
|
:param url: 显式指定 Track webhook 地址,优先级最高(指定后不再做公司路由)
|
||||||
|
:param channel: 路由通道 'inbound' | 'outbound';不传则按 payload['event'] 推断。
|
||||||
|
仅在不传 url 时生效——实例由 payload['company_name'] 决定。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
url = (url or current_app.config.get('TRACK_WEBHOOK_URL') or '').strip()
|
if not url:
|
||||||
|
channel = channel or _channel_from_event(payload.get('event'))
|
||||||
|
url = resolve_track_route(payload.get('company_name'), channel)
|
||||||
|
url = (url or '').strip()
|
||||||
api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip()
|
api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip()
|
||||||
if not url:
|
if not url:
|
||||||
logger.info("[TrackWebhook] 未配置 webhook URL,跳过通知")
|
logger.info("[TrackWebhook] 未配置 webhook URL,跳过通知")
|
||||||
|
|||||||
814
inventory-backend/app/utils/audit_labels.py
Normal file
814
inventory-backend/app/utils/audit_labels.py
Normal file
@ -0,0 +1,814 @@
|
|||||||
|
"""
|
||||||
|
审计日志的中文化标签 —— **唯一来源**。
|
||||||
|
|
||||||
|
为什么要有这个模块
|
||||||
|
------------------
|
||||||
|
同一套「字段名 → 中文」映射原先在两个地方各存一份:前端
|
||||||
|
`views/system/AuditLog.vue` 的 fieldMap,以及后端 `api/v1/audit.py` 里的
|
||||||
|
ACTION_ALIASES。两份手工同步的副本必然漂移 —— 改一边漏一边,页面上就会
|
||||||
|
冒出英文列名或对不上的中文。
|
||||||
|
|
||||||
|
现在统一收在这里:
|
||||||
|
· 前端经 `GET /api/v1/audit/labels` 拉取,不再自带副本;
|
||||||
|
· 后端 API 层与日报服务直接 import,不重复定义。
|
||||||
|
|
||||||
|
★ 加新字段时**只改本文件**。
|
||||||
|
|
||||||
|
三层映射
|
||||||
|
--------
|
||||||
|
1. ACTION_ALIASES / canon_action —— 操作类型归一化。
|
||||||
|
历史数据里 action 有两套写法:早期装饰器写中文(新增/修改/批量删除…),
|
||||||
|
现行监听器写大写英文(CREATE/UPDATE/DELETE)。归一到规范值后,
|
||||||
|
筛选与统计才不会漏掉历史数据。
|
||||||
|
2. ACTION_LABELS —— 规范值 → 中文显示名。
|
||||||
|
3. FIELD_LABELS —— 数据库列名 → 中文列名。未命中时由 field_label()
|
||||||
|
兜底为原字段名(宁可显示英文,也不要显示空白)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 1. 操作类型归一化
|
||||||
|
# =============================================================================
|
||||||
|
# 问题背景(沿用 api/v1/audit.py 的原始注释):
|
||||||
|
# 前端下拉框直接取 DISTINCT action,于是同时出现「CREATE」和「新增」两个
|
||||||
|
# 选项,而表格里二者又都显示为「新增」,用户无法分辨。用户选了看得懂的中文项,
|
||||||
|
# 只能搜到 3-4 月的历史数据,误以为"没有最近的内容"。
|
||||||
|
#
|
||||||
|
# 处理:对外只暴露规范值(CREATE/UPDATE/DELETE),筛选时自动展开到全部别名,
|
||||||
|
# 历史数据无需迁移即可被正确检索。
|
||||||
|
ACTION_ALIASES = {
|
||||||
|
'CREATE': ('CREATE', 'create', 'INSERT', 'insert', '新增', '批量生成'),
|
||||||
|
'UPDATE': ('UPDATE', 'update', '修改', '分配', '归还'),
|
||||||
|
'DELETE': ('DELETE', 'delete', '删除', '批量删除'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 反向索引:任意别名 → 规范值
|
||||||
|
_ALIAS_TO_CANON = {
|
||||||
|
alias: canon
|
||||||
|
for canon, aliases in ACTION_ALIASES.items()
|
||||||
|
for alias in aliases
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def canon_action(action):
|
||||||
|
"""把任意写法的 action 归一化为规范值;无法识别时原样返回"""
|
||||||
|
return _ALIAS_TO_CANON.get((action or '').strip(), (action or '').strip())
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 2. 操作类型显示名
|
||||||
|
# =============================================================================
|
||||||
|
ACTION_LABELS = {
|
||||||
|
'CREATE': '新增',
|
||||||
|
'UPDATE': '修改',
|
||||||
|
'DELETE': '删除',
|
||||||
|
'EXPORT': '导出',
|
||||||
|
'IMPORT': '导入',
|
||||||
|
'LOGIN': '登录',
|
||||||
|
'LOGOUT': '登出',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def action_label(action):
|
||||||
|
"""操作类型 → 中文;先归一化再查表,未命中时返回归一化后的原值"""
|
||||||
|
canon = canon_action(action)
|
||||||
|
return ACTION_LABELS.get(canon, canon)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 3. 字段名中文化映射
|
||||||
|
#
|
||||||
|
# ★ 覆盖范围:审批单 / 流水 / 库存 / 主数据 / 系统管理 五类核心业务表。
|
||||||
|
# 未命中的字段由 field_label() 兜底为「原字段名」,不会再出现大面积英文列名。
|
||||||
|
# ★ 用法:变更对比、新增详情、删除快照三个区块统一经 field_label() 取值。
|
||||||
|
# (改造前仅"变更对比"区用了映射,另外两区直接渲染原始 key,
|
||||||
|
# 这是"详情里一堆英文列名"的直接原因。)
|
||||||
|
# =============================================================================
|
||||||
|
FIELD_LABELS = {
|
||||||
|
# --- 通用 ---
|
||||||
|
'id': 'ID',
|
||||||
|
'name': '名称',
|
||||||
|
'title': '标题',
|
||||||
|
'remark': '备注',
|
||||||
|
'reason': '原因',
|
||||||
|
'reason_category': '原因分类',
|
||||||
|
'status': '状态',
|
||||||
|
'created_at': '创建时间',
|
||||||
|
'updated_at': '更新时间',
|
||||||
|
'is_active': '是否启用',
|
||||||
|
'is_enabled': '是否启用',
|
||||||
|
'company_name': '所属公司',
|
||||||
|
'operator_name': '操作人',
|
||||||
|
'operator': '操作人',
|
||||||
|
|
||||||
|
# --- 物料主数据 ---
|
||||||
|
'material_name': '物料名称',
|
||||||
|
'spec_model': '规格型号',
|
||||||
|
'category': '类别',
|
||||||
|
'material_type': '物料类型',
|
||||||
|
'unit': '单位',
|
||||||
|
'base_id': '物料ID',
|
||||||
|
'sku': 'SKU',
|
||||||
|
'batch_number': '批次号',
|
||||||
|
'serial_number': '序列号',
|
||||||
|
'barcode': '条码',
|
||||||
|
'warehouse_location': '库位',
|
||||||
|
'warehouse_loc': '库位',
|
||||||
|
'reference_price': '参考价格',
|
||||||
|
'is_approval_required': '是否需审批',
|
||||||
|
'is_inspection_required': '是否需质检',
|
||||||
|
|
||||||
|
# --- 库存数量 ---
|
||||||
|
'in_quantity': '入库数量',
|
||||||
|
'stock_quantity': '总库存',
|
||||||
|
'available_quantity': '可用库存',
|
||||||
|
'out_quantity': '出库数量',
|
||||||
|
'quantity': '数量',
|
||||||
|
'pre_tax_unit_price': '不含税单价',
|
||||||
|
'post_tax_unit_price': '含税单价',
|
||||||
|
'unit_price': '单价',
|
||||||
|
'total_price': '总价',
|
||||||
|
'tax_rate': '税率',
|
||||||
|
'supplier_name': '供应商',
|
||||||
|
'buyer_name': '采购员',
|
||||||
|
|
||||||
|
# --- 审批单 ---
|
||||||
|
'request_no': '申请单号',
|
||||||
|
'applicant_id': '申请人ID',
|
||||||
|
'allowed_approvers': '允许审批人',
|
||||||
|
'actual_approver_id': '实际审批人ID',
|
||||||
|
'approved_at': '审批时间',
|
||||||
|
'reject_reason': '驳回原因',
|
||||||
|
'items_json': '物料明细',
|
||||||
|
'outbound_type': '出库类型',
|
||||||
|
'consumer_name': '领用人/客户',
|
||||||
|
'borrower_name': '借用人',
|
||||||
|
'executed_at': '执行时间',
|
||||||
|
'executor_name': '执行人',
|
||||||
|
|
||||||
|
# --- 流水 ---
|
||||||
|
'outbound_no': '出库单号',
|
||||||
|
'outbound_time': '出库时间',
|
||||||
|
'borrow_no': '借出单号',
|
||||||
|
'borrow_time': '借出时间',
|
||||||
|
'expected_return_time': '预计归还时间',
|
||||||
|
'return_time': '归还时间',
|
||||||
|
'return_operator': '归还操作人',
|
||||||
|
'returned_quantity': '已归还数量',
|
||||||
|
'is_returned': '是否已归还',
|
||||||
|
'scrap_request_no': '报废申请单号',
|
||||||
|
'operation_time': '操作时间',
|
||||||
|
'source_table': '来源表',
|
||||||
|
'source_ref': '外部单号',
|
||||||
|
'stock_id': '库存ID',
|
||||||
|
'cost_at_scrap': '报废成本',
|
||||||
|
'total_loss': '损失金额',
|
||||||
|
|
||||||
|
# --- 逆向物流(退不良/在管不良品)---
|
||||||
|
'remaining_qty': '在管数量',
|
||||||
|
'restocked_qty': '累计已回库',
|
||||||
|
'scrapped_qty': '累计已报废',
|
||||||
|
'return_qty': '退回数量',
|
||||||
|
'return_type': '退回类型',
|
||||||
|
'return_id': '退回流水ID',
|
||||||
|
'outbound_id': '出库记录ID',
|
||||||
|
'signature_path': '签名',
|
||||||
|
'reissue_qty': '补发数量',
|
||||||
|
|
||||||
|
# --- 采购 / BOM ---
|
||||||
|
'purchase_date': '采购日期',
|
||||||
|
'requester_id': '申请人ID',
|
||||||
|
'approver_id': '审批人ID',
|
||||||
|
'bom_no': 'BOM编号',
|
||||||
|
'bom_version': 'BOM版本',
|
||||||
|
'parent_id': '父级ID',
|
||||||
|
'child_id': '子级ID',
|
||||||
|
|
||||||
|
# --- 系统管理 ---
|
||||||
|
'username': '用户名',
|
||||||
|
'display_name': '显示名',
|
||||||
|
'email': '邮箱',
|
||||||
|
'role': '角色',
|
||||||
|
'department': '部门',
|
||||||
|
'password_hash': '密码哈希',
|
||||||
|
'code': '编码',
|
||||||
|
'path': '路径',
|
||||||
|
'sort_order': '排序',
|
||||||
|
'is_visible': '是否可见',
|
||||||
|
'menu_code': '菜单编码',
|
||||||
|
'element_type': '元素类型',
|
||||||
|
'role_code': '角色编码',
|
||||||
|
'target_code': '目标编码',
|
||||||
|
'user_agent': '浏览器标识',
|
||||||
|
'ip_address': 'IP地址',
|
||||||
|
'visibility_level': '可见级别',
|
||||||
|
'is_archived': '是否归档',
|
||||||
|
|
||||||
|
# --- 采购 / 入库(stock_* 表)---
|
||||||
|
# 这几个是实测缺失最多的:采购入库快照整表展开,缺一个就漏一排英文。
|
||||||
|
'buyer_email': '采购邮箱',
|
||||||
|
'in_date': '入库日期',
|
||||||
|
'currency': '币种',
|
||||||
|
'exchange_rate': '汇率',
|
||||||
|
'inspection_status': '质检状态',
|
||||||
|
'inspection_report': '质检报告',
|
||||||
|
'inspection_report_link': '质检报告链接',
|
||||||
|
'quality_status': '质检状态',
|
||||||
|
'quality_report_link': '质检报告链接',
|
||||||
|
'arrival_photo': '到货照片',
|
||||||
|
'product_photo': '成品照片',
|
||||||
|
'global_print_id': '全局打印ID',
|
||||||
|
'base': '基础物料',
|
||||||
|
'detail_link': '详情链接',
|
||||||
|
'original_link': '原始链接',
|
||||||
|
'supplier_link': '供应商链接',
|
||||||
|
'request_id': '申请ID',
|
||||||
|
'purchase_request': '采购申请',
|
||||||
|
'order_id': '订单ID',
|
||||||
|
'is_ordered': '是否已下单',
|
||||||
|
'sale_price': '销售价',
|
||||||
|
'location': '库位',
|
||||||
|
'common_name': '通用名',
|
||||||
|
'material': '物料',
|
||||||
|
'images': '图片',
|
||||||
|
|
||||||
|
# --- 生产(半成品/成品)---
|
||||||
|
'work_order_code': '工单号',
|
||||||
|
'bom_code': 'BOM编号',
|
||||||
|
'production_manager': '生产负责人',
|
||||||
|
'production_date': '生产日期',
|
||||||
|
'production_start_time': '生产开始时间',
|
||||||
|
'production_end_time': '生产结束时间',
|
||||||
|
'production_time_range': '生产时间段',
|
||||||
|
'raw_material_cost': '原材料成本',
|
||||||
|
'manual_cost': '人工成本',
|
||||||
|
|
||||||
|
# --- BOM ---
|
||||||
|
'loss_rate': '损耗率',
|
||||||
|
'dosage': '用量',
|
||||||
|
'parent': '父件',
|
||||||
|
'child': '子件',
|
||||||
|
'version': '版本',
|
||||||
|
'bom_remark': 'BOM备注',
|
||||||
|
'child_bom_no': '子BOM编号',
|
||||||
|
'child_bom_version': '子BOM版本',
|
||||||
|
|
||||||
|
# --- 退回 / 借还 ---
|
||||||
|
'return_signature': '归还签名',
|
||||||
|
'return_location': '归还库位',
|
||||||
|
'borrow_signature': '借出签名',
|
||||||
|
'current_holder_id': '当前持有人ID',
|
||||||
|
'current_holder_name': '当前持有人',
|
||||||
|
'borrower_id': '借用人ID',
|
||||||
|
'dispatch_operator': '发放操作人',
|
||||||
|
'source_return_id': '来源退回ID',
|
||||||
|
|
||||||
|
# --- 预警设置 ---
|
||||||
|
'yellow_threshold': '黄色阈值',
|
||||||
|
'red_threshold': '红色阈值',
|
||||||
|
'yellow_emails': '黄色预警邮箱',
|
||||||
|
'red_emails': '红色预警邮箱',
|
||||||
|
'last_notified_at': '上次通知时间',
|
||||||
|
|
||||||
|
# --- 审批 ---
|
||||||
|
'approver_name': '审批人',
|
||||||
|
'approval_status': '审批状态',
|
||||||
|
|
||||||
|
# --- 盘点 / 扫码(长尾,实测仅个位数条记录,但不补就会在详情里裸露英文)---
|
||||||
|
'diff_qty': '差异数量',
|
||||||
|
'stock_qty': '库存数量',
|
||||||
|
'scan_time': '扫码时间',
|
||||||
|
'session_id': '会话标识',
|
||||||
|
'uuid': '唯一标识',
|
||||||
|
'image_url': '图片地址',
|
||||||
|
'user_id': '用户ID',
|
||||||
|
'type': '类型',
|
||||||
|
|
||||||
|
# --- 快照里的数组字段 ---
|
||||||
|
# ★ 这些在详情抽屉里会**直接当表格标题**用(见前端的分层渲染),
|
||||||
|
# 漏一个就是在表头上裸露英文。
|
||||||
|
'items': '物料明细',
|
||||||
|
'rules': '规则',
|
||||||
|
'children': '子项',
|
||||||
|
'arrival_photo': '到货照片',
|
||||||
|
'generalImage': '通用图片',
|
||||||
|
'generalManual': '通用手册',
|
||||||
|
'permissions': '权限',
|
||||||
|
'signature_path': '签名',
|
||||||
|
|
||||||
|
# --- payload 型快照的字段 ---
|
||||||
|
# ★ 来源:接口层手工记录的请求体整包(借库/出库/采购入库/用户管理/预警设置)。
|
||||||
|
# 它的命名与 ORM 快照**不一致**,同一概念有两种写法:
|
||||||
|
# qty_stock / stock_quantity —— 都是"库存数量"
|
||||||
|
# commonName / common_name —— 都是"通用名"
|
||||||
|
# companyName / company_name —— 都是"所属公司"
|
||||||
|
# 这是历史遗留,改数据不可逆,故两种写法都登记。
|
||||||
|
'qty_stock': '库存数量',
|
||||||
|
'qty_available': '可用数量',
|
||||||
|
'qty_inbound': '入库数量',
|
||||||
|
'inventoryCount': '库存数量',
|
||||||
|
'availableCount': '可用数量',
|
||||||
|
'pending_quantity': '待处理数量',
|
||||||
|
'inbound_date': '入库日期',
|
||||||
|
'cn_name': '中文名',
|
||||||
|
'commonName': '通用名',
|
||||||
|
'companyName': '所属公司',
|
||||||
|
'spec': '规格型号',
|
||||||
|
'price': '价格',
|
||||||
|
'purchaser': '采购员',
|
||||||
|
'purchaser_email': '采购邮箱',
|
||||||
|
'print_copies': '打印份数',
|
||||||
|
'global_print_id_str': '全局打印ID(文本)',
|
||||||
|
'source_link': '来源链接',
|
||||||
|
'unit_total_cost': '单位总成本',
|
||||||
|
'current_location': '当前位置',
|
||||||
|
'isEnabled': '是否启用',
|
||||||
|
'isInspectionRequired': '是否需质检',
|
||||||
|
'visibilityLevel': '可见级别',
|
||||||
|
'warningEnabled': '是否启用预警',
|
||||||
|
'warningStatus': '预警状态',
|
||||||
|
'warningRed': '红色预警',
|
||||||
|
'warningYellow': '黄色预警',
|
||||||
|
'manual_link': '说明书链接',
|
||||||
|
'manual_link_remark': '说明书链接备注',
|
||||||
|
'product_image': '产品图片',
|
||||||
|
'product_image_remark': '产品图片备注',
|
||||||
|
'purchase_link': '采购链接',
|
||||||
|
|
||||||
|
# --- 级联聚合日志的汇总字段 ---
|
||||||
|
# ★ 这些是 audit_listener 写聚合日志时自造的元字段(见其 AGGREGATE_PATH_MARKERS),
|
||||||
|
# 不是任何业务表上的列。名字都很通用(count/targets),
|
||||||
|
# 目前业务快照里没有同名键;将来若出现,要改成按 details 结构区分而非按字段名。
|
||||||
|
'count': '变更次数',
|
||||||
|
'targets': '受影响对象',
|
||||||
|
'targets_total': '对象总数',
|
||||||
|
'targets_truncated': '对象清单已截断',
|
||||||
|
# 早期版本的聚合载荷里带过 action(与日志自身的 action 重复,已不再写入),
|
||||||
|
# 但历史行里还在,留个标签免得它在详情里裸露英文
|
||||||
|
'action': '操作类型',
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 6. 详情快照里**不展示**的纯技术字段
|
||||||
|
#
|
||||||
|
# ★ 判据:这些字段对业务人员零信息量,且常常极端冗长。
|
||||||
|
# 典型是 image_embeddings 表的 `embedding`(1536 维向量),一条快照里
|
||||||
|
# 塞进去就是几 KB 的乱码;`target_id` / `module_name` 则是审计层的元数据,
|
||||||
|
# 被早期快照采集误带进了业务对象。
|
||||||
|
#
|
||||||
|
# ★ 只收「纯技术」的。链接/图片**不收**:那是业务内容,用户点进去是有用的;
|
||||||
|
# 它们在"变更对比"里已经被 IGNORED_CHANGE_FIELDS 单独处理了。
|
||||||
|
#
|
||||||
|
# ★ 这份清单放在后端而不是前端硬编:它会随新表增长,前端再存一份必然漂移。
|
||||||
|
# 前端经 GET /audit/labels 的 hiddenFields 取。
|
||||||
|
# =============================================================================
|
||||||
|
HIDDEN_SNAPSHOT_FIELDS = frozenset({
|
||||||
|
'id',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'tenant_id',
|
||||||
|
'target_id', # 审计层元数据,不是业务字段
|
||||||
|
'module_name',
|
||||||
|
'embedding', # pgvector 向量列,单条可达数 KB
|
||||||
|
'img_embedding',
|
||||||
|
'arrival_image_embedding',
|
||||||
|
'qc_report_image_embedding',
|
||||||
|
'image_embedding',
|
||||||
|
'password', # ★ 见下方凭据关键词的说明
|
||||||
|
'password_hash',
|
||||||
|
})
|
||||||
|
|
||||||
|
# 凭据类字段的**关键词**拦截。
|
||||||
|
#
|
||||||
|
# ★★ 这不是洁癖,是实测出来的洞:`用户管理/新增` 的 payload 快照里存着
|
||||||
|
# **明文密码**(27 条,实测 pw_len 6/8/11,形如 '1234…'、以用户名开头…)——
|
||||||
|
# 写入路径把请求体整包记进了审计,而请求体里是哈希前的原始密码。
|
||||||
|
# 这些内容会直接显示在详情抽屉里,并随导出进 Excel。
|
||||||
|
#
|
||||||
|
# ★ 用关键词而不是逐个登记:今天漏的是 payload 里的 password,
|
||||||
|
# 明天可能是 reset_token、api_secret。凡是键名沾这些词的都不展示 ——
|
||||||
|
# 新表加了凭据字段也自动被挡住。
|
||||||
|
_CREDENTIAL_KEYWORDS = ('password', 'passwd', 'secret', 'token', 'private_key')
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 7. 快照(details)的键与展示标题
|
||||||
|
#
|
||||||
|
# ★ 历史上有**三种**互不相同的存法,由不同时期的写入路径产生:
|
||||||
|
# {'created': {...}} —— ORM 监听器的 INSERT 快照
|
||||||
|
# {'deleted_snapshot': {...}} —— ORM 监听器的 DELETE 快照
|
||||||
|
# {'payload': {...}} —— 接口层手工记录的业务数据整包
|
||||||
|
# (借库/出库/采购入库等,内部还嵌 items 数组)
|
||||||
|
# `changes` 是第四种,但语义是"变更对比"而非快照,不走这套渲染。
|
||||||
|
#
|
||||||
|
# ★ 键名与标题都收在这里、由 GET /audit/labels 下发:前端不该硬编这三行 ——
|
||||||
|
# 将来再加第四种存法,只改这里即可(本项目已多次栽在"前端存一份副本"上)。
|
||||||
|
#
|
||||||
|
# ★ 顺序即优先级:一条记录同时有多个键时取第一个能用的。
|
||||||
|
# =============================================================================
|
||||||
|
SNAPSHOT_CREATED = 'created'
|
||||||
|
SNAPSHOT_DELETED = 'deleted_snapshot'
|
||||||
|
SNAPSHOT_PAYLOAD = 'payload'
|
||||||
|
CHANGES_KEY = 'changes'
|
||||||
|
|
||||||
|
# 聚合键:级联接口的批量变更被合并成一条日志时,受影响对象清单存在这里
|
||||||
|
# (见 audit_listener 的级联聚合)。它不是"快照",但走同一套分层渲染,
|
||||||
|
# 故一并列在 VIEWS 里 —— 抽屉里会把它渲染成一张对象清单表。
|
||||||
|
SNAPSHOT_AGGREGATE = 'aggregate'
|
||||||
|
|
||||||
|
SNAPSHOT_VIEWS = (
|
||||||
|
(SNAPSHOT_CREATED, '新增数据快照'),
|
||||||
|
(SNAPSHOT_DELETED, '删除前数据快照'),
|
||||||
|
(SNAPSHOT_PAYLOAD, '业务数据'),
|
||||||
|
(SNAPSHOT_AGGREGATE, '批量汇总'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_hidden_credential_field(key):
|
||||||
|
"""
|
||||||
|
该字段是否属于**凭据类**(密码/令牌/密钥)—— 这类字段不仅要"不显示",
|
||||||
|
还要在接口出口**整键剥掉**(见 audit_export_service.sanitize_details)。
|
||||||
|
|
||||||
|
★ 单列一个函数而不是复用 is_hidden_snapshot_field:两者处置方式不同。
|
||||||
|
技术字段(id/embedding)只是不该展示;凭据字段是**泄漏**,
|
||||||
|
必须连响应体里都不能有。混在一起会让"要不要剥掉"的语义变含糊。
|
||||||
|
"""
|
||||||
|
k = str(key or '').strip().lower()
|
||||||
|
return any(w in k for w in _CREDENTIAL_KEYWORDS)
|
||||||
|
|
||||||
|
|
||||||
|
def is_hidden_snapshot_field(key):
|
||||||
|
"""
|
||||||
|
该字段是否属于「不该在详情快照里展示」的字段。
|
||||||
|
|
||||||
|
三类:
|
||||||
|
· 名单内的纯技术字段(id / 时间戳 / 审计元数据)
|
||||||
|
· `*embedding` 向量列 —— 按**后缀**拦截:各表命名不一
|
||||||
|
(img_embedding / arrival_image_embedding / qc_report_image_embedding…),
|
||||||
|
逐个登记必然漏。新表加向量列不必再改这里。
|
||||||
|
· 凭据类字段 —— 见 is_hidden_credential_field
|
||||||
|
|
||||||
|
★ 前端同一套规则再判一次(AuditLog.vue 的 isHiddenField):
|
||||||
|
后端名单管"点名"的,关键词两端各自判,语义一致。
|
||||||
|
"""
|
||||||
|
k = str(key or '').strip().lower()
|
||||||
|
if k in HIDDEN_SNAPSHOT_FIELDS or k.endswith('embedding'):
|
||||||
|
return True
|
||||||
|
return is_hidden_credential_field(k)
|
||||||
|
|
||||||
|
|
||||||
|
def field_label(key):
|
||||||
|
"""字段名 → 中文;未命中时原样返回字段名"""
|
||||||
|
k = str(key)
|
||||||
|
return FIELD_LABELS.get(k, k)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 4. 枚举值中文化 —— ★ 必须按模块区分
|
||||||
|
#
|
||||||
|
# 同一个字段名在不同模块里是**不同的东西**:
|
||||||
|
# · 出库管理.status 的 3 表示「已完成(已出库)」
|
||||||
|
# · 借还管理.status 的 3 表示「已完成(已借出)」
|
||||||
|
# · 报废管理.status 的 3 表示「已执行(已报废)」
|
||||||
|
# · 借还管理.status 还可能是字符串 'borrowed'/'returned'(那是 trans_borrow,
|
||||||
|
# 不是审批单 —— 这个模块下两张表共用了 status 这个列名)
|
||||||
|
# · 退回管理.status 本来就是中文(待处理/已报废),无需翻译
|
||||||
|
#
|
||||||
|
# 不区分模块就会出现「报废单显示成已出库」这种错。
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# 审批单通用取值(出库/借还/报废/采购四条审批流共用同一套编码)
|
||||||
|
_APPROVAL_STATUS = {
|
||||||
|
0: '待审批',
|
||||||
|
1: '已通过',
|
||||||
|
2: '已驳回',
|
||||||
|
3: '已完成',
|
||||||
|
4: '已撤回',
|
||||||
|
}
|
||||||
|
|
||||||
|
STATUS_LABELS_BY_MODULE = {
|
||||||
|
# 出库审批流(OutboundApproval)
|
||||||
|
'出库管理': {**_APPROVAL_STATUS, 3: '已完成(已出库)', 4: '已完结'},
|
||||||
|
# 借还模块下有两张表共用 status:审批单(数字)与 trans_borrow(字符串)
|
||||||
|
'借还管理': {
|
||||||
|
**_APPROVAL_STATUS,
|
||||||
|
3: '已完成(已借出)',
|
||||||
|
4: '已撤回',
|
||||||
|
'borrowed': '借出中',
|
||||||
|
'returned': '已归还',
|
||||||
|
'scrapped': '已报废',
|
||||||
|
},
|
||||||
|
# 报废审批流(ScrapApproval):4 是「已撤回」,不是「已完结」
|
||||||
|
'报废管理': {**_APPROVAL_STATUS, 3: '已执行(已报废)'},
|
||||||
|
# 采购申请 / 采购单(PurchaseRequest)
|
||||||
|
'采购管理': {**_APPROVAL_STATUS, 3: '已完成', 4: '已完结'},
|
||||||
|
'purchase_request': {**_APPROVAL_STATUS, 3: '已完成', 4: '已完结'},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 未登记的模块退回通用编码 —— 比显示裸数字强,也比乱猜安全
|
||||||
|
DEFAULT_STATUS_LABELS = _APPROVAL_STATUS
|
||||||
|
|
||||||
|
# 走枚举翻译的字段(目前只有 status;将来有别的码值列在此追加)
|
||||||
|
ENUM_FIELDS = frozenset({'status'})
|
||||||
|
|
||||||
|
# 布尔列 → 是/否
|
||||||
|
BOOLEAN_FIELDS = frozenset({
|
||||||
|
'is_enabled', 'is_active', 'is_visible', 'is_archived',
|
||||||
|
'is_returned', 'is_approval_required', 'is_inspection_required',
|
||||||
|
})
|
||||||
|
|
||||||
|
# 引用 sys_user.id 的列 —— 值需解析成姓名才有意义
|
||||||
|
USER_ID_FIELDS = frozenset({
|
||||||
|
'actual_approver_id', 'approver_id', 'applicant_id',
|
||||||
|
'requester_id', 'user_id', 'returner_id',
|
||||||
|
'from_user_id', 'to_user_id',
|
||||||
|
})
|
||||||
|
|
||||||
|
# 引用 material_base.id 的列 —— 值需解析成物料名才有意义
|
||||||
|
MATERIAL_ID_FIELDS = frozenset({'base_id'})
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ID 字段指向什么实体
|
||||||
|
#
|
||||||
|
# ★ 同名 ID 在不同模块指向**完全不同的表** —— 这是必须按模块区分的原因:
|
||||||
|
# parent_id 在「系统管理」里是菜单的上级菜单(sys_menu),
|
||||||
|
# 在「BOM管理」里却是材质/物料节点(material_base)。
|
||||||
|
# 故 (模块, 字段) 优先,未命中再退回按字段名匹配。
|
||||||
|
#
|
||||||
|
# ★ 登记原则:**只登记能真正查到实体的**。查不到的 ID 宁可显示原值,
|
||||||
|
# 也不要硬编一个可能错的中文。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ID_REF_BY_MODULE = {
|
||||||
|
('系统管理', 'parent_id'): 'menu',
|
||||||
|
('系统管理', 'menu_id'): 'menu',
|
||||||
|
}
|
||||||
|
|
||||||
|
ID_REF_BY_FIELD = {
|
||||||
|
'base_id': 'material',
|
||||||
|
'parent_id': 'material', # BOM 结构里的父节点(非「系统管理」模块时)
|
||||||
|
'child_id': 'material',
|
||||||
|
'return_id': 'return_ledger',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def id_ref_of(module, field):
|
||||||
|
"""该 (模块, 字段) 的 ID 指向什么实体;未登记返回 None。"""
|
||||||
|
mod = (module or '').strip()
|
||||||
|
hit = ID_REF_BY_MODULE.get((mod, field))
|
||||||
|
if hit:
|
||||||
|
return hit
|
||||||
|
return ID_REF_BY_FIELD.get(field)
|
||||||
|
|
||||||
|
|
||||||
|
def enum_label(module, field, value):
|
||||||
|
"""
|
||||||
|
把枚举码值翻成中文。返回 None 表示「不适用/未登记」,由调用方原样显示。
|
||||||
|
|
||||||
|
★ 只对数值型/已登记的值做翻译,**不猜** —— 未登记的模块用通用表兜底,
|
||||||
|
通用表也查不到就返回 None(显示原值),绝不硬编一个可能错的中文。
|
||||||
|
"""
|
||||||
|
if field not in ENUM_FIELDS:
|
||||||
|
return None
|
||||||
|
mapping = STATUS_LABELS_BY_MODULE.get((module or '').strip(), DEFAULT_STATUS_LABELS)
|
||||||
|
if isinstance(value, bool): # bool 是 int 的子类,先挡掉
|
||||||
|
return None
|
||||||
|
if isinstance(value, (int, float)) and float(value).is_integer():
|
||||||
|
return mapping.get(int(value))
|
||||||
|
if isinstance(value, str):
|
||||||
|
return mapping.get(value.strip())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def bool_label(value):
|
||||||
|
"""布尔值 → 是/否;非布尔返回 None"""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return '是' if value else '否'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# 人名类**字符串**字段:库里存法不统一('杜邢宸/duxingchen'),需规整成「名(账号)」。
|
||||||
|
#
|
||||||
|
# ★★ 必须按字段名限定,**绝不能**对所有含 '/' 的字符串做规整:
|
||||||
|
# 规格型号的值就长这样('Det0001/Det0001'),一刀切会把它变成
|
||||||
|
# 'Det0001(Det0001)' —— 那是静默篡改业务数据,比不翻译危险得多。
|
||||||
|
PERSON_NAME_FIELDS = frozenset({
|
||||||
|
'executor_name', 'operator_name', 'operator',
|
||||||
|
'return_operator', 'approver_name', 'applicant_name',
|
||||||
|
'borrower_name', 'dispatch_operator', 'purchaser',
|
||||||
|
'production_manager', 'from_user_name', 'to_user_name',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def person_name_label(value):
|
||||||
|
"""
|
||||||
|
'杜邢宸/duxingchen' → '杜邢宸(duxingchen)'。
|
||||||
|
|
||||||
|
不是字符串、空串、或不含 '/' 时返回 None(由调用方原样显示)——
|
||||||
|
只做规整不做兜底,避免把空值变成 '-' 之类的占位符。
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
s = value.strip()
|
||||||
|
if not s or '/' not in s:
|
||||||
|
return None
|
||||||
|
name, _, acct = s.partition('/')
|
||||||
|
name, acct = name.strip(), acct.strip()
|
||||||
|
if name and acct:
|
||||||
|
return f"{name}({acct})"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 5. 模块聚合别名 —— 让一个业务概念跨越历史上的口径变更
|
||||||
|
#
|
||||||
|
# ★ 背景:审计日志的 module 是**监听器写死的字符串**,而监听器换过一次。
|
||||||
|
# `app/utils/audit_events.py`(旧的全局监听器)产出「入库管理」;
|
||||||
|
# 现行的 `app/core/audit_listener.py` 把入库三表
|
||||||
|
# stock_buy / stock_semi / stock_product 统一归为「库存管理」
|
||||||
|
# (见 audit_listener.py 的 _get_module_name),不再产出「入库管理」。
|
||||||
|
#
|
||||||
|
# 实测的分界点是 2026-09-10:
|
||||||
|
# 入库管理 16118 条 2026-04-22 ~ 2026-09-10
|
||||||
|
# 库存管理 1371 条 2026-09-10 ~ 至今
|
||||||
|
#
|
||||||
|
# 后果:只按单个 module 值筛选「入库」,会正好在 9-10 那天断掉 ——
|
||||||
|
# 选「入库管理」看不到 9-10 之后的,选「库存管理」看不到之前的。
|
||||||
|
# 报表读起来像是"入库记录突然没了",极难排查。
|
||||||
|
#
|
||||||
|
# ★ 解法只在**查询期**做别名展开,不迁移历史数据:改数据不可逆,
|
||||||
|
# 而聚合查询是无损的。新增值只需往元组里追加。
|
||||||
|
#
|
||||||
|
# ★ 与 STATUS_LABELS_BY_MODULE 一样,这份表是**唯一来源**:
|
||||||
|
# 前端经 GET /audit/labels 取(前端 AuditLog.vue 自带的 moduleMap
|
||||||
|
# 已经是手工同步的副本,不能再塞第二份进去)。
|
||||||
|
# =============================================================================
|
||||||
|
MODULE_GROUPS = {
|
||||||
|
'入库': ('入库管理', '采购入库', '成品入库', '库存管理'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 历史遗留的**英文** module 值 → 中文展示名。
|
||||||
|
#
|
||||||
|
# ★ 来源:早期全局监听器(app/utils/audit_events.py,现已停用)没有表白名单,
|
||||||
|
# 系统表、草稿表、向量表都会被审计,而 _infer_module_name 对未登记的类名
|
||||||
|
# 直接回退成**表名**,于是这些英文值混进了 module 列。
|
||||||
|
# 实测存量:image_embeddings 2719 条、purchase_request 75 条、sys_element 6 条;
|
||||||
|
# 表里其余的(stock_buy 等)当前没有数据,但换个环境可能出现,一并留着。
|
||||||
|
#
|
||||||
|
# ★ 这份表原本硬编在前端 AuditLog.vue 的 moduleMap 里,**已经漂移过一次**:
|
||||||
|
# 前端加了表名后端不知道、后端加了前端不显示。收进本文件后,前端经
|
||||||
|
# GET /audit/labels 的 moduleDisplay 取,和字段名/状态码一样只有一份。
|
||||||
|
MODULE_LABELS = {
|
||||||
|
'image_embeddings': '图像特征(历史)',
|
||||||
|
'purchase_request': '采购申请',
|
||||||
|
'sys_element': '系统元素',
|
||||||
|
'sys_menu': '系统菜单',
|
||||||
|
'sys_user': '系统用户',
|
||||||
|
'sys_role': '系统角色',
|
||||||
|
'sys_role_permission': '角色权限',
|
||||||
|
'sys_warehouse_location': '库位设置',
|
||||||
|
'material_base': '物料主数据',
|
||||||
|
'material_warning_settings': '物料预警设置',
|
||||||
|
'stock_buy': '采购库存',
|
||||||
|
'stock_semi': '半成品库存',
|
||||||
|
'stock_product': '成品库存',
|
||||||
|
'stock_adjustment': '库存调整',
|
||||||
|
'trans_outbound': '出库流水',
|
||||||
|
'trans_borrow': '借还流水',
|
||||||
|
'trans_scrap': '报废流水',
|
||||||
|
'trans_repair': '维修单',
|
||||||
|
'bom_table': 'BOM配方',
|
||||||
|
'bom_draft_table': 'BOM草稿',
|
||||||
|
'stocktake_draft': '盘点草稿',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def module_display(module):
|
||||||
|
"""
|
||||||
|
单个 module 值 → 展示名。
|
||||||
|
|
||||||
|
三级解析,顺序不能换:
|
||||||
|
1. 聚合成员 → 聚合名(「入库管理」→「入库」)
|
||||||
|
2. 英文历史值 → 中文(「image_embeddings」→「图像特征(历史)」)
|
||||||
|
3. 其余原样(现行监听器写的就是中文,无需翻译)
|
||||||
|
|
||||||
|
★ 第 1 级必须在第 2 级之前:聚合名优先,否则「库存管理」会被
|
||||||
|
MODULE_LABELS 之类的表抢先命中而显示成别的。
|
||||||
|
"""
|
||||||
|
m = (module or '').strip()
|
||||||
|
for group, members in MODULE_GROUPS.items():
|
||||||
|
if m in members:
|
||||||
|
return group
|
||||||
|
return MODULE_LABELS.get(m, m)
|
||||||
|
|
||||||
|
|
||||||
|
def module_display_map():
|
||||||
|
"""
|
||||||
|
完整展示映射 {原始值: 展示名},下发给前端。
|
||||||
|
|
||||||
|
★ 必须把**聚合成员**也算进来(不只是 MODULE_LABELS):表格与详情里拿到
|
||||||
|
的是原始 module 值,不映射的话会出现「下拉显示『入库』、表格显示
|
||||||
|
『库存管理』」——用户会以为筛选没生效。
|
||||||
|
"""
|
||||||
|
out = dict(MODULE_LABELS)
|
||||||
|
for group, members in MODULE_GROUPS.items():
|
||||||
|
for m in members:
|
||||||
|
out[m] = group
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def module_options(raw_modules):
|
||||||
|
"""
|
||||||
|
库里的原始 module 值 → 前端「模块」下拉的最终选项 [{value, label}]。
|
||||||
|
|
||||||
|
★ 为什么由后端算,而不是前端拉原始值自己拼:
|
||||||
|
分组规则(哪些历史值属于同一个业务概念)由 MODULE_GROUPS 管。前端再算
|
||||||
|
一遍就是第二份副本 —— 将来往组里加成员,前端会**静默失效且没有任何
|
||||||
|
报错**。这是本项目反复踩过的坑(见本文件开头、以及前端 AuditLog.vue
|
||||||
|
那个已经漂移过一次的 moduleMap)。
|
||||||
|
|
||||||
|
★ 已归入聚合项的历史值**不再单独列出**:`入库管理`/`采购入库`/`成品入库`
|
||||||
|
与 `库存管理` 是被改名的同一批数据。同时列出会让用户以为它们是不同的
|
||||||
|
东西,选了历史值又只看得到断裂前的一半 —— 那正是 2026-09-10 的口径
|
||||||
|
断裂在界面上的表现。
|
||||||
|
|
||||||
|
代价:无法只查 `库存管理`(09-10 之后的 1371 条)。这是有意取舍 ——
|
||||||
|
那几个名字本身就是历史包袱,把选择权从用户手里拿走比让他选错更安全。
|
||||||
|
"""
|
||||||
|
grouped = {m for members in MODULE_GROUPS.values() for m in members}
|
||||||
|
# 聚合项置顶,其余按名称排序(DISTINCT 返回顺序不保证,排序后位置稳定)
|
||||||
|
opts = [{'value': name, 'label': name} for name in MODULE_GROUPS]
|
||||||
|
# ★ label 走 module_display,英文历史值在这里就变成中文 ——
|
||||||
|
# value 保持原始字符串,否则筛选匹配不上(后端按 module 列的原值过滤)
|
||||||
|
opts += [{'value': m, 'label': module_display(m)}
|
||||||
|
for m in sorted(set(raw_modules or [])) if m and m not in grouped]
|
||||||
|
return opts
|
||||||
|
|
||||||
|
|
||||||
|
def expand_modules(values):
|
||||||
|
"""
|
||||||
|
筛选值 → 实际要匹配的 module 列表。
|
||||||
|
|
||||||
|
接受三种输入并混合使用:
|
||||||
|
· 聚合名('入库(全部)')→ 展开为其全部成员值
|
||||||
|
· 真实 module 值('入库管理')→ 原样保留
|
||||||
|
· 未登记的任意值 → 原样保留(不猜、不丢弃,查不到就是查不到)
|
||||||
|
|
||||||
|
返回**去重后**的列表 —— 用户同时选了「入库(全部)」与「入库管理」时,
|
||||||
|
展开会出现重复值,虽然 .in_() 去重与否结果相同,但重复值会让
|
||||||
|
SQL 参数列表无谓变长。
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for v in values:
|
||||||
|
v = (v or '').strip()
|
||||||
|
if not v:
|
||||||
|
continue
|
||||||
|
out.extend(MODULE_GROUPS.get(v, (v,)))
|
||||||
|
# dict.fromkeys 去重且保序(Python 3.7+ 的 dict 有序)
|
||||||
|
return list(dict.fromkeys(out))
|
||||||
|
|
||||||
|
|
||||||
|
def _stringify_keys(mapping):
|
||||||
|
"""
|
||||||
|
把映射的键统一转成字符串。
|
||||||
|
|
||||||
|
★ 两重必要性:
|
||||||
|
1. JSON 对象的键本来就只能是字符串;
|
||||||
|
2. Flask 的 jsonify 默认开启 sort_keys,而「借还管理」的状态表里
|
||||||
|
int 键(0-4)与 str 键('borrowed')混用 —— 排序时会直接抛
|
||||||
|
TypeError: '<' not supported between instances of 'str' and 'int'。
|
||||||
|
实测这个错会让接口 500,且被全局错误处理器吞掉、日志里看不到堆栈。
|
||||||
|
|
||||||
|
前端取值时用 String(value) 归一化,正好对上字符串键。
|
||||||
|
"""
|
||||||
|
return {str(k): v for k, v in mapping.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def labels_payload():
|
||||||
|
"""
|
||||||
|
下发给前端的合并映射。
|
||||||
|
|
||||||
|
前端替换数据来源即可,取用方式不用改。除字段名外还带上**码值**映射,
|
||||||
|
这样审计页的详情弹窗也不必再显示「状态: 1 → 3」。
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'action': ACTION_LABELS,
|
||||||
|
'field': FIELD_LABELS,
|
||||||
|
'status': {
|
||||||
|
mod: _stringify_keys(m) for mod, m in STATUS_LABELS_BY_MODULE.items()
|
||||||
|
},
|
||||||
|
'defaultStatus': _stringify_keys(DEFAULT_STATUS_LABELS),
|
||||||
|
'booleanFields': sorted(BOOLEAN_FIELDS),
|
||||||
|
'enumFields': sorted(ENUM_FIELDS),
|
||||||
|
# 模块聚合规则及其成员。前端**不需要**它来渲染下拉(下拉的选项由
|
||||||
|
# /audit/modules 下发),保留是为了让"规则"本身可被查看/调试。
|
||||||
|
'moduleGroups': {k: list(v) for k, v in MODULE_GROUPS.items()},
|
||||||
|
# {原始 module 值: 展示名} —— 前端用它把表格/详情里的原始值翻成中文,
|
||||||
|
# 否则会「下拉显示『入库』、表格显示『库存管理』」。
|
||||||
|
# ★ 含英文历史值,前端不再自带一份 moduleMap。
|
||||||
|
'moduleDisplay': module_display_map(),
|
||||||
|
# 详情快照里不展示的纯技术字段(前端过滤用,见 HIDDEN_SNAPSHOT_FIELDS)
|
||||||
|
'hiddenFields': sorted(HIDDEN_SNAPSHOT_FIELDS),
|
||||||
|
# 快照键 → 展示标题,按优先级排列(见 SNAPSHOT_VIEWS)。
|
||||||
|
# 前端据此把 created / deleted_snapshot / payload 三种存法抹平,
|
||||||
|
# 不硬编键名。
|
||||||
|
'snapshotViews': [{'key': k, 'title': t} for k, t in SNAPSHOT_VIEWS],
|
||||||
|
}
|
||||||
@ -25,3 +25,33 @@ class UserRole:
|
|||||||
PURCHASER: '采购员',
|
PURCHASER: '采购员',
|
||||||
SALES: '销售'
|
SALES: '销售'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OutboundType:
|
||||||
|
"""
|
||||||
|
出库类型(trans_outbound.outbound_type)。
|
||||||
|
|
||||||
|
★ 前端 views/outbound/index.vue 的 formatType() 里另有一份同名映射,
|
||||||
|
两份是手工同步的副本。日报(后端)需要同一份口径,故在此定义;
|
||||||
|
**改这里时请一并改前端**,或后续像审计标签那样改为接口下发。
|
||||||
|
"""
|
||||||
|
SALES = 'SALES' # 销售出库
|
||||||
|
USE = 'USE' # 内部领用
|
||||||
|
PRODUCTION = 'PRODUCTION' # 生产出库
|
||||||
|
SCRAP = 'SCRAP' # 报废
|
||||||
|
LOSS = 'LOSS' # 盘亏出库
|
||||||
|
REPAIR = 'REPAIR' # 维修出库
|
||||||
|
|
||||||
|
LABELS = {
|
||||||
|
SALES: '销售出库',
|
||||||
|
USE: '内部领用',
|
||||||
|
PRODUCTION: '生产出库',
|
||||||
|
SCRAP: '报废',
|
||||||
|
LOSS: '盘亏出库',
|
||||||
|
REPAIR: '维修出库',
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def label(cls, code):
|
||||||
|
"""未命中时原样返回,避免报表上出现空白"""
|
||||||
|
return cls.LABELS.get((code or '').strip(), (code or '').strip())
|
||||||
@ -7,13 +7,48 @@ import os
|
|||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
import logging
|
import logging
|
||||||
|
from email.mime.application import MIMEApplication
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.header import Header
|
from email.header import Header
|
||||||
|
from email.utils import formatdate, make_msgid
|
||||||
from typing import List, Union
|
from typing import List, Union
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 附件扩展名 → MIME 子类型。
|
||||||
|
#
|
||||||
|
# ★ .xlsx 必须用官方类型,不能图省事写 'xlsx':那样 Content-Type 会变成
|
||||||
|
# application/xlsx,Outlook 等客户端会当成未知二进制,附件无法双击直接
|
||||||
|
# 打开(得先存盘再手动改后缀),用户只会反馈"附件打不开"。
|
||||||
|
_XLSX_SUBTYPE = 'vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
_ATTACHMENT_SUBTYPES = {
|
||||||
|
'.xlsx': _XLSX_SUBTYPE,
|
||||||
|
'.xls': 'vnd.ms-excel',
|
||||||
|
'.csv': 'vnd.ms-excel',
|
||||||
|
'.pdf': 'pdf',
|
||||||
|
'.zip': 'zip',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_subtype(filename: str) -> str:
|
||||||
|
"""按扩展名给出 MIME 子类型;未登记的回落到 octet-stream(即默认值)"""
|
||||||
|
ext = os.path.splitext(filename or '')[1].lower()
|
||||||
|
return _ATTACHMENT_SUBTYPES.get(ext, 'octet-stream')
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_attachment(att):
|
||||||
|
"""
|
||||||
|
附件入参 → (文件名, 字节)。接受两种写法,避免调用方纠结:
|
||||||
|
|
||||||
|
{'filename': '日报.xlsx', 'data': b'...'}
|
||||||
|
('日报.xlsx', b'...')
|
||||||
|
"""
|
||||||
|
if isinstance(att, dict):
|
||||||
|
return att.get('filename') or 'attachment', att.get('data') or b''
|
||||||
|
filename, data = att
|
||||||
|
return filename or 'attachment', data or b''
|
||||||
|
|
||||||
|
|
||||||
def _get_config():
|
def _get_config():
|
||||||
"""
|
"""
|
||||||
@ -45,7 +80,8 @@ def _get_config():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg: dict = None):
|
def send_email(to_email: Union[str, List[str]], subject: str, content: str,
|
||||||
|
cfg: dict = None, attachments: list = None):
|
||||||
"""
|
"""
|
||||||
通用邮件发送函数
|
通用邮件发送函数
|
||||||
|
|
||||||
@ -54,13 +90,24 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg:
|
|||||||
subject: 邮件主题
|
subject: 邮件主题
|
||||||
content: 邮件正文(纯文本)
|
content: 邮件正文(纯文本)
|
||||||
cfg: 可选,预先获取的配置字典(用于异步线程传参,避免丢失 Flask context)
|
cfg: 可选,预先获取的配置字典(用于异步线程传参,避免丢失 Flask context)
|
||||||
|
attachments: 可选,附件列表。每项为
|
||||||
|
{'filename': '日报.xlsx', 'data': b'...'} 或 ('日报.xlsx', b'...')。
|
||||||
|
数据是**字节**而非路径 —— 调用方在内存里生成,不落盘。
|
||||||
|
|
||||||
发送失败时打印日志,不抛出异常
|
发送失败时打印日志,不抛出异常
|
||||||
"""
|
"""
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
|
|
||||||
print(f"[DEBUG send_email] cfg = {cfg}")
|
# ★ 不要把 cfg 整个打出来 —— 它含 'password'(邮箱授权码),
|
||||||
|
# 一 print 就落到 stdout → docker logs,任何能看日志的人都拿得到发信凭证。
|
||||||
|
# 只打非敏感字段,密码只报"有没有设"。
|
||||||
|
_pw_state = '已设置' if cfg.get('password') else '空'
|
||||||
|
print(
|
||||||
|
f"[DEBUG send_email] server={cfg.get('server')} port={cfg.get('port')} "
|
||||||
|
f"sender={cfg.get('sender')} ssl={cfg.get('use_ssl')} tls={cfg.get('use_tls')} "
|
||||||
|
f"enabled={cfg.get('enabled')} password={_pw_state}"
|
||||||
|
)
|
||||||
|
|
||||||
# 发送总开关
|
# 发送总开关
|
||||||
if not cfg.get('enabled'):
|
if not cfg.get('enabled'):
|
||||||
@ -82,13 +129,41 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = MIMEMultipart()
|
# ★ 显式 'mixed':虽然 MIMEMultipart() 默认就是 mixed,但正文+附件
|
||||||
|
# 的语义要求就在这里,写出来才不会被后人"顺手"改成 related/alternative。
|
||||||
|
msg = MIMEMultipart('mixed')
|
||||||
msg['From'] = cfg['sender']
|
msg['From'] = cfg['sender']
|
||||||
msg['To'] = ', '.join(recipients)
|
msg['To'] = ', '.join(recipients)
|
||||||
msg['Subject'] = Header(subject, 'utf-8')
|
msg['Subject'] = Header(subject, 'utf-8')
|
||||||
|
# ★ Date 与 Message-ID 是 RFC 5322 的**必需/强烈建议**头,
|
||||||
|
# smtplib 不会自动补。缺了它们,收件方的反垃圾引擎会显著加分
|
||||||
|
# ("来源不明的自动化邮件"),很容易被判成垃圾或进隔离区 ——
|
||||||
|
# 而发件服务器照样回 250,客户端完全看不出来。
|
||||||
|
# 实测本项目的邮件此前两个头都没有。
|
||||||
|
msg['Date'] = formatdate(localtime=True)
|
||||||
|
msg['Message-ID'] = make_msgid(domain=(cfg['sender'].split('@')[-1] or None))
|
||||||
msg.attach(MIMEText(content, 'plain', 'utf-8'))
|
msg.attach(MIMEText(content, 'plain', 'utf-8'))
|
||||||
|
|
||||||
print(f"DEBUG: 准备向服务器提交发信请求,收件人: {recipients} 发件人: {cfg['username']}")
|
# 附件:内容已在内存里,直接 base64 编码挂上,不经过文件系统
|
||||||
|
attached = 0
|
||||||
|
for att in (attachments or []):
|
||||||
|
filename, data = _normalize_attachment(att)
|
||||||
|
if not data:
|
||||||
|
logger.warning(f"[Email] 附件 {filename} 内容为空,已跳过")
|
||||||
|
continue
|
||||||
|
part = MIMEApplication(data, _subtype=_attachment_subtype(filename))
|
||||||
|
# ★ 中文文件名必须走 RFC 2231(('utf-8','',name) 三元组),
|
||||||
|
# 直接塞原始中文会生成非法的 Content-Disposition 头,
|
||||||
|
# 客户端显示成乱码或一串 =?utf-8?b?...?= 原文。
|
||||||
|
part.add_header('Content-Disposition', 'attachment',
|
||||||
|
filename=('utf-8', '', filename))
|
||||||
|
msg.attach(part)
|
||||||
|
attached += 1
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"DEBUG: 准备向服务器提交发信请求,收件人: {recipients} "
|
||||||
|
f"发件人: {cfg['username']} 附件: {attached} 个"
|
||||||
|
)
|
||||||
|
|
||||||
if cfg.get('use_ssl'):
|
if cfg.get('use_ssl'):
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
@ -120,7 +195,8 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg:
|
|||||||
logger.error(f"[Email] 发送邮件时发生未知异常: {e}")
|
logger.error(f"[Email] 发送邮件时发生未知异常: {e}")
|
||||||
|
|
||||||
|
|
||||||
def send_email_async(to_email: Union[str, List[str]], subject: str, content: str):
|
def send_email_async(to_email: Union[str, List[str]], subject: str, content: str,
|
||||||
|
attachments: list = None):
|
||||||
"""
|
"""
|
||||||
异步发送邮件(守护线程,不阻塞主请求线程)。
|
异步发送邮件(守护线程,不阻塞主请求线程)。
|
||||||
|
|
||||||
@ -133,7 +209,7 @@ def send_email_async(to_email: Union[str, List[str]], subject: str, content: str
|
|||||||
t = threading.Thread(
|
t = threading.Thread(
|
||||||
target=send_email,
|
target=send_email,
|
||||||
args=(to_email, subject, content),
|
args=(to_email, subject, content),
|
||||||
kwargs={'cfg': cfg},
|
kwargs={'cfg': cfg, 'attachments': attachments},
|
||||||
daemon=True
|
daemon=True
|
||||||
)
|
)
|
||||||
t.start()
|
t.start()
|
||||||
|
|||||||
70
inventory-backend/app/utils/job_lock.py
Normal file
70
inventory-backend/app/utils/job_lock.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
跨进程的定时任务互斥锁。
|
||||||
|
|
||||||
|
为什么需要这个
|
||||||
|
--------------
|
||||||
|
`gunicorn.conf.py` 配了 8 个 worker(`workers = min(cpu*2+1, 8)`),而 `run.py`
|
||||||
|
是在**模块级**启动 APScheduler 的 —— gunicorn 未开 preload_app,每个 worker
|
||||||
|
都会独立 import 一次 `run.py`,于是**每个 worker 各起一份调度器**,同一个
|
||||||
|
cron 任务在相同时刻被并发执行 8 次。
|
||||||
|
|
||||||
|
实测:容器里跑着 8 个 worker 进程,`create_app()` 与"调度器已启动"两条日志
|
||||||
|
的出现次数完全相等 —— 每个 worker 都配了一份。
|
||||||
|
|
||||||
|
后果:库存预警邮件每天实际会重复发 8 封。
|
||||||
|
|
||||||
|
APScheduler 自带的 `max_instances` 只在**单个调度器实例内**生效,管不了
|
||||||
|
跨进程;`replace_existing` 同理。这里用 PostgreSQL 的**会话级咨询锁**
|
||||||
|
(`pg_try_advisory_lock`)做真正的跨进程互斥:抢到锁的那个 worker 才执行,
|
||||||
|
其余直接跳过。好处是不用引入 Redis 之类的新组件(compose 里也没有 redis,
|
||||||
|
`redis_client` 恒为 None,相关装饰器全程 fail-open,指望不上)。
|
||||||
|
|
||||||
|
★ 会话级锁绑定在**连接**上,必须在同一条连接上解锁 —— 所以这里显式取一条
|
||||||
|
专用连接,用完归还,不复用请求上下文里的 session。
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 锁标识:PostgreSQL 咨询锁是 bigint,取固定值便于排查(不要用随机数)。
|
||||||
|
LOCK_INVENTORY_WARNING = 891001001 # 库存预警每日邮件
|
||||||
|
LOCK_DAILY_REPORT = 891001002 # MOM 系统日报
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def advisory_lock(key):
|
||||||
|
"""
|
||||||
|
尝试获取会话级咨询锁,产出「是否抢到」。
|
||||||
|
|
||||||
|
用法::
|
||||||
|
|
||||||
|
with advisory_lock(LOCK_DAILY_REPORT) as acquired:
|
||||||
|
if not acquired:
|
||||||
|
return # 别的 worker 正在跑,本轮跳过
|
||||||
|
...实际干活...
|
||||||
|
|
||||||
|
★ 抢不到锁是**正常路径**,不是错误 —— 说明另一个 worker 正在执行同一任务。
|
||||||
|
调用方应当静默跳过,而不是报警。
|
||||||
|
★ 拿锁失败(数据库不可用等)会让异常向上抛:宁可让调度器记一次失败,
|
||||||
|
也不要"没抢到锁"和"抢锁时数据库挂了"两种截然不同的情况被混为一谈。
|
||||||
|
"""
|
||||||
|
conn = db.engine.connect()
|
||||||
|
acquired = False
|
||||||
|
try:
|
||||||
|
acquired = bool(
|
||||||
|
conn.execute(text("SELECT pg_try_advisory_lock(:k)"), {"k": key}).scalar()
|
||||||
|
)
|
||||||
|
yield acquired
|
||||||
|
finally:
|
||||||
|
if acquired:
|
||||||
|
try:
|
||||||
|
conn.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": key})
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
# 解锁失败不应盖过业务异常;连接关闭时锁也会随之释放
|
||||||
|
logger.warning(f"[JobLock] 释放咨询锁 {key} 失败: {e}")
|
||||||
|
conn.close()
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
@ -69,6 +70,10 @@ class Config:
|
|||||||
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'wms@iris-rs.cn')
|
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'wms@iris-rs.cn')
|
||||||
# 是否启用邮件发送功能(开发环境可设为 false 禁用)
|
# 是否启用邮件发送功能(开发环境可设为 false 禁用)
|
||||||
MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes')
|
MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes')
|
||||||
|
# MOM 系统日报收件人(逗号分隔多个)。留空则日报任务跳过发送并在日志告警。
|
||||||
|
MAIL_DAILY_REPORT_RECIPIENTS = os.getenv(
|
||||||
|
'MAIL_DAILY_REPORT_RECIPIENTS', 'duxingchen@iris-rs.cn'
|
||||||
|
)
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# 7. Track 系统 Webhook 通知配置 (发送侧)
|
# 7. Track 系统 Webhook 通知配置 (发送侧)
|
||||||
@ -85,3 +90,31 @@ class Config:
|
|||||||
TRACK_API_URL = os.getenv('TRACK_API_URL', 'http://track_backend:8000')
|
TRACK_API_URL = os.getenv('TRACK_API_URL', 'http://track_backend:8000')
|
||||||
# Track 出库 webhook 接收地址(发货出库时通知 Track 标记"已出库")
|
# Track 出库 webhook 接收地址(发货出库时通知 Track 标记"已出库")
|
||||||
TRACK_OUTBOUND_WEBHOOK_URL = os.getenv('TRACK_OUTBOUND_WEBHOOK_URL', '')
|
TRACK_OUTBOUND_WEBHOOK_URL = os.getenv('TRACK_OUTBOUND_WEBHOOK_URL', '')
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 9. 多 Track 实例路由表 (IRIS / LICA)
|
||||||
|
# =========================================================
|
||||||
|
# JSON 结构: {"IRIS": {"api": "...", "inbound": "...", "outbound": "..."}, "LICA": {...}}
|
||||||
|
# 由 material_base.company_name 决定业务落到哪个 Track 实例。
|
||||||
|
# 缺省为空字典 → 全部回落到上面第 7/8 节的扁平变量,行为与改造前一致。
|
||||||
|
#
|
||||||
|
# ⚠️ 解析失败直接让进程起不来(fail fast)——路由表写坏却静默回落,
|
||||||
|
# 会造成"某些公司悄悄断链",比启动报错难排查得多。
|
||||||
|
try:
|
||||||
|
TRACK_ROUTES = json.loads(os.getenv('TRACK_ROUTES', '{}') or '{}')
|
||||||
|
except ValueError as e:
|
||||||
|
raise RuntimeError(f'TRACK_ROUTES 不是合法 JSON,请检查环境变量: {e}')
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 10. 对内接口配置 (接收侧:Track → MOM)
|
||||||
|
# =========================================================
|
||||||
|
# Track 调 MOM 内部接口(如生产报废受理)的共享密钥,请求头 X-API-Key。
|
||||||
|
#
|
||||||
|
# ★ 与 TRACK_WEBHOOK_KEY 刻意分离,不复用:
|
||||||
|
# · 方向相反 —— 那个是 MOM 发给 Track 的凭证,这个是 Track 发给 MOM 的;
|
||||||
|
# · 权限不同 —— 本密钥能发起报废审批(间接影响台账与成本),
|
||||||
|
# 万一泄漏,两边各自轮换即可,不会连坐。
|
||||||
|
#
|
||||||
|
# ★ 未配置时内部接口一律返回 503(Fail-Closed),**不做静默放行** ——
|
||||||
|
# 一个默认开着的写接口,比一个没配好的接口危险得多。
|
||||||
|
MOM_INTERNAL_API_KEY = os.getenv('MOM_INTERNAL_API_KEY', '')
|
||||||
|
|||||||
@ -1,10 +1,31 @@
|
|||||||
# inventory-backend/run.py
|
# inventory-backend/run.py
|
||||||
|
import sys
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
|
||||||
|
# ★ stdout 行缓冲。
|
||||||
|
# gunicorn 的 stdout 是管道,Python 默认对它做**块缓冲** —— print() 的内容
|
||||||
|
# 会攒在缓冲区里,docker logs 里看不到。实测 17:30 的日报任务:8 个 worker
|
||||||
|
# 里只刷出 2 条日志,连抢到锁那个 worker 的成败都没出现,完全无从排查。
|
||||||
|
# 调度任务是后台线程,出问题本来就不容易发现,再叠加缓冲就等于瞎跑。
|
||||||
|
if hasattr(sys.stdout, 'reconfigure'):
|
||||||
|
sys.stdout.reconfigure(line_buffering=True)
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# 启动时注册库存预警定时任务(每天 9:30 北京时)
|
# 定时任务注册
|
||||||
|
#
|
||||||
|
# ★★ 每个任务都必须套 advisory_lock,否则会被执行 **8 次**。
|
||||||
|
#
|
||||||
|
# gunicorn.conf.py 配了 8 个 worker,且未开 preload_app —— 每个 worker
|
||||||
|
# 都会独立 import 一次本文件,于是在模块级启动的这份调度器会**每个
|
||||||
|
# worker 各起一份**,同一个 cron 任务在相同时刻被并发执行 8 次。
|
||||||
|
# (库存预警此前正是如此:5 条预警配置,每天实际发 8 封重复邮件。
|
||||||
|
# APScheduler 的 max_instances 只管单个调度器实例内,管不了跨进程。)
|
||||||
|
#
|
||||||
|
# advisory_lock 用 PostgreSQL 会话级咨询锁做真正的跨进程互斥,
|
||||||
|
# 抢到锁的 worker 才执行,其余静默跳过。详见 app/utils/job_lock.py。
|
||||||
# =========================================================
|
# =========================================================
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
@ -13,14 +34,39 @@ import pytz
|
|||||||
beijing_tz = pytz.timezone('Asia/Shanghai')
|
beijing_tz = pytz.timezone('Asia/Shanghai')
|
||||||
|
|
||||||
|
|
||||||
|
# 调度日志一律再加一层 flush=True(理由见文件头部的行缓冲说明)——
|
||||||
|
# 定时任务跑在后台线程,出问题时唯一的线索就是这几行输出,不能丢。
|
||||||
def _run_warning_job():
|
def _run_warning_job():
|
||||||
|
"""库存预警扫描与邮件发送(每天 9:30 北京时间)"""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
|
from app.utils.job_lock import advisory_lock, LOCK_INVENTORY_WARNING
|
||||||
|
with advisory_lock(LOCK_INVENTORY_WARNING) as acquired:
|
||||||
|
if not acquired:
|
||||||
|
# 正常路径:另一个 worker 正在执行同一任务
|
||||||
|
print("[Scheduler] 库存预警:另一 worker 正在执行,本轮跳过", flush=True)
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
from app.services.inventory_task import InventoryWarningService
|
from app.services.inventory_task import InventoryWarningService
|
||||||
result = InventoryWarningService.check_and_send_warning_emails()
|
result = InventoryWarningService.check_and_send_warning_emails()
|
||||||
print(f"[Scheduler] 库存预警扫描完成: red={result['red_count']}, yellow={result['yellow_count']}")
|
print(f"[Scheduler] 库存预警扫描完成: red={result['red_count']}, yellow={result['yellow_count']}", flush=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Scheduler] 库存预警任务失败: {e}")
|
print(f"[Scheduler] 库存预警任务失败: {e}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_daily_report_job():
|
||||||
|
"""MOM 系统日报(每天 17:30 北京时间)"""
|
||||||
|
with app.app_context():
|
||||||
|
from app.utils.job_lock import advisory_lock, LOCK_DAILY_REPORT
|
||||||
|
with advisory_lock(LOCK_DAILY_REPORT) as acquired:
|
||||||
|
if not acquired:
|
||||||
|
print("[Scheduler] 系统日报:另一 worker 正在执行,本轮跳过", flush=True)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from app.services.daily_report_service import DailyReportService
|
||||||
|
r = DailyReportService.send_daily_report()
|
||||||
|
print(f"[Scheduler] 系统日报已发送: {r['subject']} -> {r['recipients']}", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Scheduler] 系统日报任务失败: {e}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
scheduler = BackgroundScheduler(timezone=beijing_tz)
|
scheduler = BackgroundScheduler(timezone=beijing_tz)
|
||||||
@ -31,8 +77,15 @@ scheduler.add_job(
|
|||||||
name='库存预警每日邮件发送',
|
name='库存预警每日邮件发送',
|
||||||
replace_existing=True
|
replace_existing=True
|
||||||
)
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
func=_run_daily_report_job,
|
||||||
|
trigger=CronTrigger(hour=17, minute=30, timezone=beijing_tz),
|
||||||
|
id='daily_report',
|
||||||
|
name='MOM系统日报',
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
print("✅ 库存预警定时任务已启动(每天 9:30 北京时间执行)")
|
print("✅ 定时任务已启动:库存预警 9:30 / MOM系统日报 17:30(北京时间)", flush=True)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
# =================================================
|
# =================================================
|
||||||
|
|||||||
@ -239,7 +239,7 @@ const handleLogout = () => {
|
|||||||
<footer v-if="!isLoginPage" class="app-footer">
|
<footer v-if="!isLoginPage" class="app-footer">
|
||||||
<span class="version-tag">
|
<span class="version-tag">
|
||||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||||
当前版本:V3.90
|
当前版本:V3.95
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@ -24,3 +24,40 @@ export function getAuditModules() {
|
|||||||
method: 'get'
|
method: 'get'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取可选操作人列表
|
||||||
|
//
|
||||||
|
// ★ 返回 [{value: 账号, label: '名(账号)'}],按记录数降序,已排除 system。
|
||||||
|
// 从审计日志本身取而非用户表:用户被删/改名后仍能按历史账号筛选。
|
||||||
|
export function getAuditOperators() {
|
||||||
|
return request({
|
||||||
|
url: '/audit/operators',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取操作类型 / 字段名的中文映射
|
||||||
|
// ★ 后端为唯一来源,前端不再自带副本(见 views/system/AuditLog.vue 的说明)
|
||||||
|
export function getAuditLabels() {
|
||||||
|
return request({
|
||||||
|
url: '/audit/labels',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按筛选条件导出审计日志 Excel
|
||||||
|
//
|
||||||
|
// ★ 与列表页**同一套筛选参数**,但后端导出的是**全部匹配结果**而不是当前页 ——
|
||||||
|
// 用户点「导出」要的就是全量,只导当前页会让他以为数据被截断了。
|
||||||
|
// ★ responseType: 'blob' 不能省,否则 axios 会把二进制当文本解析,文件打不开。
|
||||||
|
export function exportAuditLogs(params: any) {
|
||||||
|
return request({
|
||||||
|
url: '/audit/logs/export',
|
||||||
|
method: 'get',
|
||||||
|
params,
|
||||||
|
responseType: 'blob' as any,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -29,6 +29,25 @@ export function getScrapRecords(params: any) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. 报废记录导出 Excel
|
||||||
|
//
|
||||||
|
// ★ 与列表页**同一套筛选参数**,但后端导出的是**全部匹配结果**而不是当前页 ——
|
||||||
|
// 用户点「导出」要的就是全量,只导当前页会让他以为数据被截断了。
|
||||||
|
// ★ responseType: 'blob' 不能省,否则 axios 会把二进制当文本解析,文件打不开。
|
||||||
|
// ★ 损失金额由后端按 scrap_list:loss_amount 权限决定可不可见(与列表页同口径),
|
||||||
|
// 前端不做二次处理 —— 否则导出会变成绕过字段级权限的后门。
|
||||||
|
export function exportScrapRecords(params: any) {
|
||||||
|
return request({
|
||||||
|
url: '/v1/scrap/records/export',
|
||||||
|
method: 'get',
|
||||||
|
params,
|
||||||
|
responseType: 'blob' as any,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 3.1 报废申请专用库存列表(独立于出库选单权限,Fail-Closed 剥离价格)
|
// 3.1 报废申请专用库存列表(独立于出库选单权限,Fail-Closed 剥离价格)
|
||||||
export function getScrapStockList(params: { page?: number; pageSize?: number; keyword?: string }) {
|
export function getScrapStockList(params: { page?: number; pageSize?: number; keyword?: string }) {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@ -73,6 +73,19 @@
|
|||||||
<template #default="{ row }">{{ row.applicant_name || getApplicantName(row.applicant_id) }}</template>
|
<template #default="{ row }">{{ row.applicant_name || getApplicantName(row.applicant_id) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 原因分类:审批人得知道批的是哪个环节的损失。
|
||||||
|
「生产损耗」= 好料领用后变坏;「库存/采购」= 在库或买来就有问题。
|
||||||
|
存量单没有分类(列是后加的),显示 '-' 而不是猜一个 -->
|
||||||
|
<el-table-column label="原因分类" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.reason_category_label" size="small"
|
||||||
|
:type="row.reason_category === 'PRODUCTION' ? 'warning' : 'info'">
|
||||||
|
{{ row.reason_category_label }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="remark" label="申请原因" min-width="180" show-overflow-tooltip />
|
<el-table-column prop="remark" label="申请原因" min-width="180" show-overflow-tooltip />
|
||||||
|
|
||||||
<el-table-column label="报废种类" width="100" align="center">
|
<el-table-column label="报废种类" width="100" align="center">
|
||||||
|
|||||||
@ -35,9 +35,24 @@
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 原因分类:两种互斥口径 ——
|
||||||
|
生产报废 = **所有走 Track 的**(料领到产线后在生产中变坏);
|
||||||
|
库存报废 = **MOM 自身流程走下来的**(不看 Track、直接选库存行报废等)。
|
||||||
|
⚠️ 选「库存报废」时后端会**一并兜住空值**:本列上线前的历史台账没有
|
||||||
|
分类,而它们全都是 MOM 自身流程产生的,不兜会漏掉全部历史单 -->
|
||||||
|
<el-form-item label="原因分类">
|
||||||
|
<el-select v-model="listQuery.reason_category" clearable
|
||||||
|
placeholder="全部" style="width: 130px">
|
||||||
|
<el-option label="生产报废" value="PRODUCTION" />
|
||||||
|
<el-option label="库存报废" value="STOCK" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="fetchData">查询</el-button>
|
<el-button type="primary" @click="fetchData">查询</el-button>
|
||||||
<el-button @click="resetFilter">重置</el-button>
|
<el-button @click="resetFilter">重置</el-button>
|
||||||
|
<!-- 导出:与当前筛选条件一致,但导的是**全部匹配结果**而不是当前页 -->
|
||||||
|
<el-button type="success" :loading="exporting" @click="handleExport">导出 Excel</el-button>
|
||||||
|
|
||||||
<!-- ★ 高级筛选:对齐 material/list.vue 既有模式 -->
|
<!-- ★ 高级筛选:对齐 material/list.vue 既有模式 -->
|
||||||
<el-popover
|
<el-popover
|
||||||
@ -114,6 +129,21 @@
|
|||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<!-- 报废原因分类:回答「这笔损失出在哪个环节」。
|
||||||
|
生产损耗(好料领用后变坏)vs 库存/采购(在库或买来就有问题)。
|
||||||
|
★ 只展示,不做任何业务判断 —— 分类由提交方**显式**写入,
|
||||||
|
不能从来源推导(Track 的生产报废与手工的不良品退回共用
|
||||||
|
同一张 trans_defective_goods 表,推导会把生产损失算成库存损失)。
|
||||||
|
⚠️ 存量单没有分类(列是后加的),显示 '-' 而不是猜一个 -->
|
||||||
|
<el-table-column label="原因分类" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.reason_category_label" size="small"
|
||||||
|
:type="row.reason_category === 'PRODUCTION' ? 'warning' : 'info'">
|
||||||
|
{{ row.reason_category_label }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="reason" label="报废原因" min-width="160" show-overflow-tooltip>
|
<el-table-column prop="reason" label="报废原因" min-width="160" show-overflow-tooltip>
|
||||||
<template #default="{ row }">{{ row.reason || '-' }}</template>
|
<template #default="{ row }">{{ row.reason || '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -156,6 +186,17 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 原因分类也放主行:不用展开就能一眼看出这笔损失出在哪个环节 -->
|
||||||
|
<el-table-column label="原因分类" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.reason_category_label" size="small"
|
||||||
|
:type="row.reason_category === 'PRODUCTION' ? 'warning' : 'info'">
|
||||||
|
{{ row.reason_category_label }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="审批状态" width="110" align="center">
|
<el-table-column label="审批状态" width="110" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="getStatusType(row.approval_status)">
|
<el-tag :type="getStatusType(row.approval_status)">
|
||||||
@ -180,7 +221,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { getScrapRecords } from '@/api/scrap'
|
import { getScrapRecords, exportScrapRecords } from '@/api/scrap'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@ -197,6 +238,8 @@ const listQuery = reactive({
|
|||||||
search_type: 'all',
|
search_type: 'all',
|
||||||
dateRange: [] as string[],
|
dateRange: [] as string[],
|
||||||
advancedFilters: [] as any[],
|
advancedFilters: [] as any[],
|
||||||
|
// '' = 全部;PRODUCTION = 生产报废;STOCK = 库存报废
|
||||||
|
reason_category: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- ★ 高级筛选 ---
|
// --- ★ 高级筛选 ---
|
||||||
@ -255,6 +298,8 @@ const fetchData = async () => {
|
|||||||
search_type: listQuery.search_type,
|
search_type: listQuery.search_type,
|
||||||
// ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
|
// ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
|
||||||
advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
|
advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
|
||||||
|
// 原因分类:空串 = 不筛
|
||||||
|
reason_category: listQuery.reason_category || '',
|
||||||
}
|
}
|
||||||
if (listQuery.dateRange && listQuery.dateRange.length === 2) {
|
if (listQuery.dateRange && listQuery.dateRange.length === 2) {
|
||||||
params.start_date = listQuery.dateRange[0]
|
params.start_date = listQuery.dateRange[0]
|
||||||
@ -294,12 +339,53 @@ const resetFilter = () => {
|
|||||||
listQuery.search_type = 'all'
|
listQuery.search_type = 'all'
|
||||||
listQuery.dateRange = []
|
listQuery.dateRange = []
|
||||||
listQuery.advancedFilters = []
|
listQuery.advancedFilters = []
|
||||||
|
listQuery.reason_category = ''
|
||||||
advancedConditions.value = [{ field: '', operator: '', value: '' }]
|
advancedConditions.value = [{ field: '', operator: '', value: '' }]
|
||||||
appliedConditions.value = []
|
appliedConditions.value = []
|
||||||
listQuery.page = 1
|
listQuery.page = 1
|
||||||
fetchData()
|
fetchData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 导出 Excel ----
|
||||||
|
// 与列表**同一套筛选条件**,但导的是全部匹配结果(后端不限当前页)。
|
||||||
|
// 下载是浏览器的静默行为,页面不会跳转 —— 所以要给一条「导出中」的反馈,
|
||||||
|
// 否则大表导出时用户会以为按钮没反应。
|
||||||
|
const exporting = ref(false)
|
||||||
|
const handleExport = async () => {
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
keyword: listQuery.keyword,
|
||||||
|
search_type: listQuery.search_type,
|
||||||
|
advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
|
||||||
|
reason_category: listQuery.reason_category || '',
|
||||||
|
}
|
||||||
|
if (listQuery.dateRange && listQuery.dateRange.length === 2) {
|
||||||
|
params.start_date = listQuery.dateRange[0]
|
||||||
|
params.end_date = listQuery.dateRange[1]
|
||||||
|
}
|
||||||
|
const res: any = await exportScrapRecords(params)
|
||||||
|
const blob = new Blob([res], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
})
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
const ymd = new Date().toISOString().split('T')[0]
|
||||||
|
link.download = `报废记录_${ymd}.xlsx`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
ElMessage.success('导出成功')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('导出报废记录失败', e)
|
||||||
|
ElMessage.error('导出失败,请重试')
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 报废台账写在执行/提交时,approval_status 的取值为这两个
|
// 报废台账写在执行/提交时,approval_status 的取值为这两个
|
||||||
const getStatusType = (status: string) => {
|
const getStatusType = (status: string) => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
|
|||||||
@ -235,8 +235,41 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
|
|
||||||
<el-col :span="24" :md="8">
|
<el-col :span="24" :md="8">
|
||||||
|
<!--
|
||||||
|
★ 领用人改为**下拉选择**(此前是自由文本框)。
|
||||||
|
为什么:实测 1497 条出库里有 7 条领用人对不上任何在职人员,
|
||||||
|
其中就有把姓名打成「刘」这种错字 —— 手输必然会有这类问题。
|
||||||
|
现在从人员名单里选,与「经办人(库管)」的交互也统一了。
|
||||||
|
|
||||||
|
★ 选了审批单会自动带出申请人(见 handleRequestChange),
|
||||||
|
仍可改成别人。
|
||||||
|
|
||||||
|
★ 保留 allow-create:销售出库的客户可能是外部人员、没有账号,
|
||||||
|
封死会让这类出库没法登记。但**手输的名字会给出提示**
|
||||||
|
(见下方 consumerUnknown 的告警),既不挡业务也不放任错字。
|
||||||
|
-->
|
||||||
<el-form-item label="领用人/客户" prop="consumer_name">
|
<el-form-item label="领用人/客户" prop="consumer_name">
|
||||||
<el-input v-model="form.consumer_name" placeholder="请输入姓名" />
|
<el-select
|
||||||
|
v-model="form.consumer_name"
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
:filter-method="filterUsers"
|
||||||
|
placeholder="选择或输入姓名"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="u in filteredUsers"
|
||||||
|
:key="u.id"
|
||||||
|
:label="u.name"
|
||||||
|
:value="u.name"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<!-- ★ 软提示而非硬拦截:不在名单里也能提交(外部客户),
|
||||||
|
但要让库管看见"这个名字系统里没有" —— 错字就是这么挡住的 -->
|
||||||
|
<div v-if="consumerUnknown" class="field-warn">
|
||||||
|
「{{ form.consumer_name }}」不在在职人员名单中,请确认没写错
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|
||||||
@ -385,6 +418,8 @@ import {
|
|||||||
getScanDraft, saveScanDraft, clearScanDraft, getScanDraftOverview,
|
getScanDraft, saveScanDraft, clearScanDraft, getScanDraftOverview,
|
||||||
} from '@/api/outbound'
|
} from '@/api/outbound'
|
||||||
import { uploadFile } from '@/api/common/upload'
|
import { uploadFile } from '@/api/common/upload'
|
||||||
|
// 在职人员名单 —— 领用人下拉的数据源(与出库补发的「补发给谁」同一份)
|
||||||
|
import { getActiveUsers } from '@/api/common/users'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@ -416,6 +451,76 @@ const lastY = ref(0)
|
|||||||
|
|
||||||
const operatorOptions = ref<string[]>([])
|
const operatorOptions = ref<string[]>([])
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 领用人选择器
|
||||||
|
//
|
||||||
|
// 数据源是统一的在职人员接口(与「补发给谁」同一个),公司隔离由后端负责。
|
||||||
|
// ============================================================================
|
||||||
|
const activeUsers = ref<Array<{ id: number; name: string; account: string }>>([])
|
||||||
|
const userQuery = ref('')
|
||||||
|
|
||||||
|
// 动态模糊匹配:姓名与**拼音**都能搜。
|
||||||
|
// ★ 默认的 filterable 只匹配 label(也就是姓名),输 gaoxue 匹配不到高雪;
|
||||||
|
// 账号字段正是那段拼音,这里一并比对。
|
||||||
|
const filterUsers = (query: string) => {
|
||||||
|
userQuery.value = query || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredUsers = computed(() => {
|
||||||
|
const q = userQuery.value.trim().toLowerCase()
|
||||||
|
if (!q) return activeUsers.value
|
||||||
|
return activeUsers.value.filter(u =>
|
||||||
|
u.name.toLowerCase().includes(q) ||
|
||||||
|
(u.account || '').toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前填的领用人是否**不在**在职名单里。
|
||||||
|
// ★ 只做提示不做拦截:外部客户确实可能没有账号,但错字必须让人看见。
|
||||||
|
const consumerUnknown = computed(() => {
|
||||||
|
const v = String(form.consumer_name || '').trim()
|
||||||
|
if (!v || !activeUsers.value.length) return false
|
||||||
|
return !activeUsers.value.some(u => u.name === v)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadActiveUsers = async () => {
|
||||||
|
try {
|
||||||
|
const res: any = await getActiveUsers()
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
activeUsers.value = res.data
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[出库] 在职人员名单拉取失败,领用人需手动输入', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领用人默认取所选审批单的**申请人**。
|
||||||
|
*
|
||||||
|
* ★ 优先按 applicant_id 在在职名单里反查,而不是直接用接口返回的
|
||||||
|
* applicant_name:后者是 '高雪/gaoxue'(后端 _get_user_name 返回完整
|
||||||
|
* username),直接落库会把台账里存姓名的口径搞乱(实测 1497 行里
|
||||||
|
* 没有一条带 '/' 的领用人)。按 id 反查拿到的姓名与下拉选项**同源**。
|
||||||
|
*/
|
||||||
|
const fillConsumerFromRequest = () => {
|
||||||
|
const req = selectedRequest.value
|
||||||
|
if (!req) return
|
||||||
|
|
||||||
|
const hit = activeUsers.value.find(u => u.id === req.applicant_id)
|
||||||
|
if (hit) {
|
||||||
|
form.consumer_name = hit.name
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回落:申请人已离职(不在在职名单)或名单还没加载完
|
||||||
|
const raw = String(req.applicant_name || '')
|
||||||
|
const nm = raw.split('/')[0].trim()
|
||||||
|
// 后端查不到用户时会返回 '未知用户(7)' / '用户(7)',这种不能当姓名用
|
||||||
|
if (nm && !nm.startsWith('未知用户') && !nm.startsWith('用户(')) {
|
||||||
|
form.consumer_name = nm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
outbound_type: '',
|
outbound_type: '',
|
||||||
consumer_name: '',
|
consumer_name: '',
|
||||||
@ -566,6 +671,8 @@ const handleRequestChange = async (val: number | null) => {
|
|||||||
if (selectedRequest.value?.outbound_type) {
|
if (selectedRequest.value?.outbound_type) {
|
||||||
form.outbound_type = selectedRequest.value.outbound_type
|
form.outbound_type = selectedRequest.value.outbound_type
|
||||||
}
|
}
|
||||||
|
// ★ 领用人默认就是申请人(仍可改成别人)
|
||||||
|
fillConsumerFromRequest()
|
||||||
}
|
}
|
||||||
|
|
||||||
cartItems.value = []
|
cartItems.value = []
|
||||||
@ -805,6 +912,8 @@ onMounted(() => {
|
|||||||
operatorOptions.value.push(userStore.username)
|
operatorOptions.value.push(userStore.username)
|
||||||
}
|
}
|
||||||
loadHistoryOperators()
|
loadHistoryOperators()
|
||||||
|
// 领用人下拉的数据源(与「补发给谁」同一个接口)
|
||||||
|
loadActiveUsers()
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadHistoryOperators = async () => {
|
const loadHistoryOperators = async () => {
|
||||||
@ -1298,6 +1407,8 @@ onUnmounted(() => {
|
|||||||
/* ★ 审批单选择 */
|
/* ★ 审批单选择 */
|
||||||
.approval-request-select { margin-bottom: 16px; }
|
.approval-request-select { margin-bottom: 16px; }
|
||||||
.select-tip { margin: 6px 0 0 0; color: #909399; font-size: 12px; }
|
.select-tip { margin: 6px 0 0 0; color: #909399; font-size: 12px; }
|
||||||
|
/* 领用人不在在职名单时的软提示(外部客户不算错,但要让人看见) */
|
||||||
|
.field-warn { margin-top: 4px; color: #E6A23C; font-size: 12px; line-height: 1.4; }
|
||||||
|
|
||||||
/* ★ 计划清单 */
|
/* ★ 计划清单 */
|
||||||
.planned-items-section {
|
.planned-items-section {
|
||||||
|
|||||||
@ -1943,14 +1943,21 @@ const confirmPurchaseImport = () => {
|
|||||||
form.tax_rate = Number(po.tax_rate)
|
form.tax_rate = Number(po.tax_rate)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasTotalPrice) {
|
// 导入新单前清掉上一单残留的"手工总价"标记,否则 updatePrices 末尾会用旧值回头覆盖
|
||||||
// 优先: 含税总价 → 调用 post_total 逻辑反算单价
|
postTaxTotalManuallySet.value = false
|
||||||
postTaxTotalInput.value = Number(po.total_price)
|
postTaxTotalInput.value = undefined
|
||||||
onPostTaxTotalChange(postTaxTotalInput.value)
|
|
||||||
} else if (hasUnitPrice) {
|
if (hasUnitPrice) {
|
||||||
// 备选: 含税单价 → 反算不含税
|
// ★ 单价优先。申请单的 total_price 是「申请数量」的钱,而实际入库数量未必相同
|
||||||
|
// —— 厂家怕出问题多发(申请 100 个、实发 104 个)是常态。用总价去除以实际
|
||||||
|
// 入库数量会把单价摊薄或抬高,必须一律以单价为准,总价由「单价 × 实际入库数量」得出。
|
||||||
|
// 含税单价 → 反算不含税
|
||||||
form.post_tax_unit_price = Number(po.unit_price)
|
form.post_tax_unit_price = Number(po.unit_price)
|
||||||
updatePrices('post')
|
updatePrices('post')
|
||||||
|
} else if (hasTotalPrice) {
|
||||||
|
// 兜底:申请单只有总价、没有单价时,才按总价反算
|
||||||
|
postTaxTotalInput.value = Number(po.total_price)
|
||||||
|
onPostTaxTotalChange(postTaxTotalInput.value)
|
||||||
}
|
}
|
||||||
if (po.supplier_link) {
|
if (po.supplier_link) {
|
||||||
form.detail_link = po.supplier_link
|
form.detail_link = po.supplier_link
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user