Compare commits
13 Commits
ffd3e2652c
...
f4a01be951
| Author | SHA1 | Date | |
|---|---|---|---|
| f4a01be951 | |||
| d1337d12e1 | |||
| 6520f1c97c | |||
| 58fa42bff3 | |||
| e4d2b2ec68 | |||
| 511e822d12 | |||
| 27436f5efe | |||
| 9eb4792d4a | |||
| a252013573 | |||
| fd99d33a0d | |||
| dda6e4c787 | |||
| 69c38a1bf7 | |||
| fbc9296056 |
121
db_migrations/add_defective_operation_perms.sql
Normal file
121
db_migrations/add_defective_operation_perms.sql
Normal file
@ -0,0 +1,121 @@
|
||||
-- =============================================================================
|
||||
-- 逆向物流动作权限码(拒绝搭便车)
|
||||
--
|
||||
-- 背景
|
||||
-- 退回接口已拆出专用码 outbound_return(见 add_outbound_return_perm.sql)。
|
||||
-- 余下三个逆向动作此前仍在复用 inventory_stocktake:operation(盲盘作业),
|
||||
-- 职责错配、审计不合规,此处一并收口:
|
||||
--
|
||||
-- code 名称 宿主菜单 用途
|
||||
-- -------------------- -------------- ---------------- ------------------
|
||||
-- defective_restock 不良品回库 defective_goods 修好后加回原库存
|
||||
-- defective_scrap 不良品报废 defective_goods 确认无法维修后销毁
|
||||
-- stock_change_status 库存状态变更 inventory_mgmt 冻结 / 标不良 / 恢复
|
||||
--
|
||||
-- ★ 三者均为**无冒号**形式,刻意的。原因见 add_outbound_return_perm.sql:
|
||||
-- 形如 <menu>:<action> 的权限码会命中 _expand_operation_perms() 的
|
||||
-- 前缀桥接 —— 例如 inventory_stocktake 下已有 inventory_stocktake:operation,
|
||||
-- 若改用 inventory_stocktake:restock,则所有持有盘点操作权的角色会被自动
|
||||
-- 放行,授权面失控。无冒号码不触发桥接,与前端 hasPermission 的精确匹配一致。
|
||||
--
|
||||
-- ★ 顺带补建 defective_goods 菜单:二期只加了前端路由(侧边栏由路由驱动,
|
||||
-- 页面本就能访问),但 sys_menu 中没有对应行,导致该页面在权限管理界面
|
||||
-- 不可见、其下权限也无法授予。sys_element.menu_code 有指向 sys_menu(code)
|
||||
-- 的外键,故必须先把菜单建出来。
|
||||
--
|
||||
-- 默认授予:SUPER_ADMIN(超管)、SUPERVISOR(主管)、WAREHOUSE_MGR(库管)
|
||||
-- company_name 置 NULL = 全局模板权限,对所有公司生效(与既有授权一致)。
|
||||
-- ★ 刻意**不含** OUTBOUND:业务确认出库员只负责正向拣货发货,
|
||||
-- 不参与任何逆向物流操作。
|
||||
--
|
||||
-- 幂等:全部带存在性判断,可重复执行。
|
||||
-- 执行:docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1) 补建「不良品在管台账」菜单(挂在 入库管理 inventory_mgmt 下)
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO sys_menu (parent_id, name, code, path, sort_order, is_visible)
|
||||
SELECT (SELECT id FROM sys_menu WHERE code = 'inventory_mgmt'),
|
||||
'不良品在管台账', 'defective_goods', '/inventory/defective', 6, TRUE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE code = 'defective_goods');
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2) 注册三个权限元素
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO sys_element (menu_code, name, code, element_type)
|
||||
SELECT v.menu_code, v.name, v.code, 'operation'
|
||||
FROM (VALUES
|
||||
('defective_goods', '不良品回库', 'defective_restock'),
|
||||
('defective_goods', '不良品报废', 'defective_scrap'),
|
||||
('inventory_mgmt', '库存状态变更', 'stock_change_status')
|
||||
) AS v(menu_code, name, code)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_element e WHERE e.code = v.code);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3) 默认授予三个角色(菜单 + 元素)
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3.1 菜单
|
||||
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
|
||||
SELECT v.role_code, 'defective_goods', 'menu', NULL
|
||||
FROM (VALUES ('SUPER_ADMIN'), ('SUPERVISOR'), ('WAREHOUSE_MGR')) AS v(role_code)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_permission r
|
||||
WHERE r.role_code = v.role_code AND r.target_code = 'defective_goods'
|
||||
AND r.type = 'menu'
|
||||
);
|
||||
|
||||
-- 3.2 元素
|
||||
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
|
||||
SELECT r.role_code, v.target_code, 'element', NULL
|
||||
FROM (VALUES ('SUPER_ADMIN'), ('SUPERVISOR'), ('WAREHOUSE_MGR')) AS r(role_code)
|
||||
CROSS JOIN (VALUES
|
||||
('defective_restock'), ('defective_scrap'), ('stock_change_status')
|
||||
) AS v(target_code)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_permission x
|
||||
WHERE x.role_code = r.role_code AND x.target_code = v.target_code
|
||||
AND x.type = 'element'
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4) 执行后核对
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo '--- 菜单已建立 ---'
|
||||
SELECT code, name, path FROM sys_menu WHERE code = 'defective_goods';
|
||||
|
||||
\echo '--- 三个权限元素(应 3 行)---'
|
||||
SELECT e.code, e.name, e.menu_code, e.element_type
|
||||
FROM sys_element e
|
||||
WHERE e.code IN ('defective_restock', 'defective_scrap', 'stock_change_status')
|
||||
ORDER BY e.code;
|
||||
|
||||
\echo '--- 授权明细(应 3 菜单 + 9 元素 = 12 行)---'
|
||||
SELECT target_code, type, string_agg(role_code, ', ' ORDER BY role_code) AS roles
|
||||
FROM sys_role_permission
|
||||
WHERE target_code IN ('defective_goods', 'defective_restock',
|
||||
'defective_scrap', 'stock_change_status')
|
||||
GROUP BY target_code, type ORDER BY type, target_code;
|
||||
|
||||
\echo '--- 确认 OUTBOUND 未获得任何逆向权限(应为 0 行)---'
|
||||
SELECT target_code FROM sys_role_permission
|
||||
WHERE role_code = 'OUTBOUND'
|
||||
AND target_code IN ('outbound_return', 'defective_goods', 'defective_restock',
|
||||
'defective_scrap', 'stock_change_status');
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- DELETE FROM sys_role_permission
|
||||
-- WHERE target_code IN ('defective_goods','defective_restock',
|
||||
-- 'defective_scrap','stock_change_status');
|
||||
-- DELETE FROM sys_element
|
||||
-- WHERE code IN ('defective_restock','defective_scrap','stock_change_status');
|
||||
-- DELETE FROM sys_menu WHERE code = 'defective_goods';
|
||||
-- COMMIT;
|
||||
84
db_migrations/add_outbound_return_perm.sql
Normal file
84
db_migrations/add_outbound_return_perm.sql
Normal file
@ -0,0 +1,84 @@
|
||||
-- =============================================================================
|
||||
-- 出库退回权限码(库管 SOP)
|
||||
--
|
||||
-- 背景
|
||||
-- 退回是实物交接动作,按仓库管理规范只能由具备「库管」职责的人员执行,
|
||||
-- 不能对所有领料员工开放。原先该接口复用的是 inventory_stocktake:operation
|
||||
-- (盲盘作业),语义不符且粒度太粗。此处拆出专用权限码。
|
||||
--
|
||||
-- 权限码设计
|
||||
-- code = 'outbound_return',注册在 menu_code = 'outbound_list'(出库记录)下,
|
||||
-- element_type = 'operation'。
|
||||
--
|
||||
-- ★ 为什么用**无冒号**形式而不是 outbound_list:return:
|
||||
-- 1) 本系统对「动作类权限」的既有约定就是无冒号码 —— scrap_apply /
|
||||
-- scrap_execute / scrap_approval 均为此形式;
|
||||
-- 2) 更关键的是后端 _expand_operation_perms() 的**前缀桥接**:
|
||||
-- 形如 <menu>:<action> 的权限码,只要用户持有该菜单下任一以
|
||||
-- :operation/:edit/:delete/... 结尾的权限就会被放行。
|
||||
-- outbound_list 下已存在 outbound_list:operation,
|
||||
-- 若用 outbound_list:return,则所有持有 outbound_list:operation 的角色
|
||||
-- (OUTBOUND / SUPER_ADMIN / SUPERVISOR / WAREHOUSE_MGR)都会自动获得
|
||||
-- 退回权 —— 授权面被放大,且与前端 hasPermission 的**精确匹配**不一致,
|
||||
-- 会出现「接口能调、按钮却看不到」的错位。
|
||||
-- 无冒号码不触发桥接,前后端判定完全一致。
|
||||
--
|
||||
-- 3) 为什么不用 stock:return:sys_element.menu_code 有指向 sys_menu 的
|
||||
-- **外键**,而系统中不存在 code='stock' 的菜单,该权限无法注册。
|
||||
--
|
||||
-- 默认授予:SUPER_ADMIN(超管)、SUPERVISOR(主管)、WAREHOUSE_MGR(库管)
|
||||
-- company_name 置 NULL = 全局模板权限,对所有公司生效(与既有授权一致)。
|
||||
--
|
||||
-- ⚠ 注意:改为专用码后,OUTBOUND 角色将**失去**退回权。
|
||||
-- 该系统把 OUTBOUND 视为库管操作角色(它同时持有 scrap_execute、
|
||||
-- inventory_stocktake:operation)。如业务上出库管理员也应能退回,
|
||||
-- 只需在下方 VALUES 中追加 ('OUTBOUND')。
|
||||
--
|
||||
-- 幂等:全部带 WHERE NOT EXISTS,可重复执行。
|
||||
-- 执行:docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1) 注册权限元素
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO sys_element (menu_code, name, code, element_type)
|
||||
SELECT 'outbound_list', '出库退回(库管)', 'outbound_return', 'operation'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_element WHERE code = 'outbound_return'
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2) 默认授予三个角色
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
|
||||
SELECT v.role_code, 'outbound_return', 'element', NULL
|
||||
FROM (VALUES ('SUPER_ADMIN'), ('SUPERVISOR'), ('WAREHOUSE_MGR')) AS v(role_code)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_permission r
|
||||
WHERE r.role_code = v.role_code
|
||||
AND r.target_code = 'outbound_return'
|
||||
AND r.type = 'element'
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3) 执行后核对
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo '--- 权限元素已注册 ---'
|
||||
SELECT code, name, menu_code, element_type FROM sys_element WHERE code = 'outbound_return';
|
||||
|
||||
\echo '--- 授权角色(应为 3 行)---'
|
||||
SELECT role_code, target_code, COALESCE(company_name, '<NULL>') AS company_name
|
||||
FROM sys_role_permission WHERE target_code = 'outbound_return' ORDER BY role_code;
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- DELETE FROM sys_role_permission WHERE target_code = 'outbound_return';
|
||||
-- DELETE FROM sys_element WHERE code = 'outbound_return';
|
||||
-- COMMIT;
|
||||
125
db_migrations/add_return_view_support.sql
Normal file
125
db_migrations/add_return_view_support.sql
Normal file
@ -0,0 +1,125 @@
|
||||
-- =============================================================================
|
||||
-- 退回看板支撑:权限码 + 菜单 + 公司快照列
|
||||
--
|
||||
-- 内容分三段:
|
||||
-- 1) trans_return 补 company_name 快照列(Schema)
|
||||
-- 2) 两个新权限码:defective_list / outbound_return_list(权限)
|
||||
-- 3) 新建「退回记录」菜单并挂到出库管理下(菜单)
|
||||
--
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1) 为什么补 company_name
|
||||
-- 退回流水的多租户隔离原先只能靠 join 链推:
|
||||
-- trans_return → trans_outbound → (source_table, stock_id) → 库存表 → 物料主表
|
||||
-- 但库存行会被入库模块**物理删除**(实测 1077 条出库记录中已有 7 条悬空)。
|
||||
-- 一旦源库存行被删,链路断裂 → 公司无法判定 → 该条退回记录对普通用户
|
||||
-- **静默消失**。审计视图里静默丢数据是不可接受的。
|
||||
-- 故在本表直接落一份公司快照,隔离判定不再依赖任何 join。
|
||||
-- 与 trans_defective_goods.company_name 的处理保持一致。
|
||||
--
|
||||
-- 本表当前为空,无需回填。
|
||||
--
|
||||
-- 2) 为什么两个权限码都是无冒号形式
|
||||
-- 理由见 add_outbound_return_perm.sql:形如 <menu>:<action> 的码会命中
|
||||
-- _expand_operation_perms() 的前缀桥接,导致持有同菜单下 :operation 之类
|
||||
-- 权限的角色被自动放行。无冒号码不触发桥接,与前端精确匹配一致。
|
||||
--
|
||||
-- 3) 默认授予:SUPER_ADMIN(超管)、SUPERVISOR(主管)、WAREHOUSE_MGR(库管)
|
||||
-- 不含 OUTBOUND —— 出库员只负责正向拣货发货,不参与逆向物流。
|
||||
--
|
||||
-- 幂等:全部带存在性判断,可重复执行。
|
||||
-- 执行:docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1) trans_return 公司快照列
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE trans_return
|
||||
ADD COLUMN IF NOT EXISTS company_name varchar(255);
|
||||
|
||||
COMMENT ON COLUMN trans_return.company_name IS
|
||||
'退回发生时的所属公司快照;用于行级隔离,避免源库存行被删后隔离判定失效';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_trans_return_company
|
||||
ON trans_return (company_name);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2) 新建「退回记录」菜单(挂在 出库管理 outbound_mgmt 下)
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO sys_menu (parent_id, name, code, path, sort_order, is_visible)
|
||||
SELECT (SELECT id FROM sys_menu WHERE code = 'outbound_mgmt'),
|
||||
'退回记录', 'outbound_returns', '/outbound/returns', 5, TRUE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE code = 'outbound_returns');
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3) 注册两个权限元素
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO sys_element (menu_code, name, code, element_type)
|
||||
SELECT v.menu_code, v.name, v.code, 'operation'
|
||||
FROM (VALUES
|
||||
('defective_goods', '不良品台账查看', 'defective_list'),
|
||||
('outbound_returns', '退回记录查看', 'outbound_return_list')
|
||||
) AS v(menu_code, name, code)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_element e WHERE e.code = v.code);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4) 默认授予三个角色(菜单 + 元素)
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4.1 菜单:退回记录
|
||||
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
|
||||
SELECT v.role_code, 'outbound_returns', 'menu', NULL
|
||||
FROM (VALUES ('SUPER_ADMIN'), ('SUPERVISOR'), ('WAREHOUSE_MGR')) AS v(role_code)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_permission r
|
||||
WHERE r.role_code = v.role_code AND r.target_code = 'outbound_returns'
|
||||
AND r.type = 'menu'
|
||||
);
|
||||
|
||||
-- 4.2 元素:两个查看权
|
||||
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
|
||||
SELECT r.role_code, v.target_code, 'element', NULL
|
||||
FROM (VALUES ('SUPER_ADMIN'), ('SUPERVISOR'), ('WAREHOUSE_MGR')) AS r(role_code)
|
||||
CROSS JOIN (VALUES ('defective_list'), ('outbound_return_list')) AS v(target_code)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_permission x
|
||||
WHERE x.role_code = r.role_code AND x.target_code = v.target_code
|
||||
AND x.type = 'element'
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5) 执行后核对
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo '--- 1) trans_return 新列 ---'
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_name = 'trans_return' AND column_name = 'company_name';
|
||||
|
||||
\echo '--- 2) 菜单 ---'
|
||||
SELECT code, name, path FROM sys_menu WHERE code = 'outbound_returns';
|
||||
|
||||
\echo '--- 3) 权限码与授权(应 2 元素 + 1 菜单 = 3 组)---'
|
||||
SELECT target_code, type, string_agg(role_code, ', ' ORDER BY role_code) AS roles
|
||||
FROM sys_role_permission
|
||||
WHERE target_code IN ('defective_list', 'outbound_return_list', 'outbound_returns')
|
||||
GROUP BY target_code, type ORDER BY type DESC, target_code;
|
||||
|
||||
\echo '--- 4) OUTBOUND 仍无任何逆向权限(应 0 行)---'
|
||||
SELECT target_code FROM sys_role_permission
|
||||
WHERE role_code = 'OUTBOUND'
|
||||
AND target_code IN ('outbound_return', 'outbound_return_list', 'defective_list',
|
||||
'defective_restock', 'defective_scrap', 'stock_change_status',
|
||||
'defective_goods', 'outbound_returns');
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- DELETE FROM sys_role_permission WHERE target_code IN
|
||||
-- ('defective_list','outbound_return_list','outbound_returns');
|
||||
-- DELETE FROM sys_element WHERE code IN ('defective_list','outbound_return_list');
|
||||
-- DELETE FROM sys_menu WHERE code = 'outbound_returns';
|
||||
-- ALTER TABLE trans_return DROP COLUMN IF EXISTS company_name;
|
||||
-- COMMIT;
|
||||
140
db_migrations/fill_missing_stock_status.sql
Normal file
140
db_migrations/fill_missing_stock_status.sql
Normal file
@ -0,0 +1,140 @@
|
||||
-- =============================================================================
|
||||
-- 一次性迁移:库存状态列存量洗刷(配合 status 硬隔离上线)
|
||||
--
|
||||
-- 背景
|
||||
-- 三张库存表(stock_buy / stock_semi / stock_product)的 status 列自建表起
|
||||
-- 就是「只写不读」的:入库时写一次 '在库',此后全仓再无任何代码更新它,
|
||||
-- 分配器也从不看它。现分配器已改为「仅 status = '在库' 才可被分配」
|
||||
-- (见 app/services/inventory_reservation.py::allocatable_filter)。
|
||||
--
|
||||
-- 该过滤是 Fail-Closed 的:status 为 NULL、空串或任何非 '在库' 值的行
|
||||
-- 都会被排除在候选集之外。因此上线该过滤**之前**必须先跑本脚本,把
|
||||
-- 本该可用的存量行刷回 '在库',否则它们将集体无法出库。
|
||||
--
|
||||
-- 洗刷范围(刻意收敛,只动 status 一列)
|
||||
-- (status IS NULL OR status <> '在库')
|
||||
-- AND (stock_quantity > 0 OR available_quantity > 0)
|
||||
--
|
||||
-- · 为什么带 stock_quantity 条件,而不只是 available_quantity:
|
||||
-- 借出未还的行 available_quantity 可能为 0 而 stock_quantity > 0。
|
||||
-- 归还时 process_return() 会给该行加回 available_quantity,届时若
|
||||
-- status 仍为 NULL,这行就永远出不去了。此处一并覆盖,堵住这个未来缺口。
|
||||
--
|
||||
-- · 为什么排除「无实物的行」(stock_quantity / available_quantity 均为空或 0):
|
||||
-- 这类行多为历史脏数据(实测 stock_buy id=807 是整行全空的幽灵记录)。
|
||||
-- 把它们刷成 '在库' 等于凭空声明一批并不存在的合格库存,是有害的。
|
||||
-- 它们本来就不会被分配(分配器要求 available_quantity > 0),保持原样无害。
|
||||
-- 可用文末「0-b)」的查询把它们捞出来人工核对。
|
||||
--
|
||||
-- ● 为什么**不**刷 quality_status / inspection_status(对原始需求的偏离,务必知悉)
|
||||
-- 1) stock_buy **没有** quality_status 列,只有 inspection_status。
|
||||
-- 统一写 `SET quality_status='合格'` 会直接报 column does not exist,
|
||||
-- 脚本根本跑不完。
|
||||
-- 2) 更要命的是语义:stock_semi 有 24 行 quality_status='待检'、
|
||||
-- stock_buy 有 2042 行 inspection_status='未检'(其中 1766 行可用量 > 0)。
|
||||
-- 把它们刷成 '合格' 属于**伪造质检结论** —— 等于把从未检验过的货
|
||||
-- 当成合格品放行,与本次改造「不让坏件流出」的目的正好相反。
|
||||
-- 因此本脚本只洗 status。如确有补空需求,见文末「3) 可选:质量列补空」,
|
||||
-- 那里只补 NULL,绝不覆盖任何已有取值。
|
||||
--
|
||||
-- 幂等性
|
||||
-- WHERE 条件保证可重复执行:第二次执行影响 0 行。
|
||||
--
|
||||
-- 执行
|
||||
-- docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 0) 执行前预览:将被洗刷的行数(建议先单独跑这一段确认无误再跑 1)
|
||||
--
|
||||
-- ★ 用 COALESCE 而非裸比较:库存表允许数量列为 NULL,而 `NULL > 0` 求值为
|
||||
-- NULL(非真),裸写会让「数量为 NULL」的脏行既不进洗刷、也不进 0-b 诊断,
|
||||
-- 在两次查询里凭空消失。COALESCE 让两个条件互为补集,口径严密。
|
||||
-- ---------------------------------------------------------------------------
|
||||
SELECT 'stock_buy' AS tbl, count(*) AS will_update
|
||||
FROM stock_buy
|
||||
WHERE (status IS NULL OR status <> '在库')
|
||||
AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0)
|
||||
UNION ALL
|
||||
SELECT 'stock_semi', count(*)
|
||||
FROM stock_semi
|
||||
WHERE (status IS NULL OR status <> '在库')
|
||||
AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0)
|
||||
UNION ALL
|
||||
SELECT 'stock_product', count(*)
|
||||
FROM stock_product
|
||||
WHERE (status IS NULL OR status <> '在库')
|
||||
AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 0-b) 被跳过的行(无实物但状态异常,多为历史脏数据)—— 仅诊断,不修改
|
||||
-- 与 0) 严格互补,两者行数之和 = 该表 status 非「在库」的总行数
|
||||
-- ---------------------------------------------------------------------------
|
||||
SELECT 'stock_buy' AS tbl, id, sku, barcode, status, stock_quantity, available_quantity
|
||||
FROM stock_buy
|
||||
WHERE (status IS NULL OR status <> '在库')
|
||||
AND COALESCE(stock_quantity, 0) <= 0 AND COALESCE(available_quantity, 0) <= 0
|
||||
UNION ALL
|
||||
SELECT 'stock_semi', id, sku, barcode, status, stock_quantity, available_quantity
|
||||
FROM stock_semi
|
||||
WHERE (status IS NULL OR status <> '在库')
|
||||
AND COALESCE(stock_quantity, 0) <= 0 AND COALESCE(available_quantity, 0) <= 0
|
||||
UNION ALL
|
||||
SELECT 'stock_product', id, sku, barcode, status, stock_quantity, available_quantity
|
||||
FROM stock_product
|
||||
WHERE (status IS NULL OR status <> '在库')
|
||||
AND COALESCE(stock_quantity, 0) <= 0 AND COALESCE(available_quantity, 0) <= 0;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1) 洗刷 status(三表口径一致;stock_buy 无 quality_status,故不涉及)
|
||||
-- ---------------------------------------------------------------------------
|
||||
UPDATE stock_buy
|
||||
SET status = '在库'
|
||||
WHERE (status IS NULL OR status <> '在库')
|
||||
AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0);
|
||||
|
||||
UPDATE stock_semi
|
||||
SET status = '在库'
|
||||
WHERE (status IS NULL OR status <> '在库')
|
||||
AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0);
|
||||
|
||||
UPDATE stock_product
|
||||
SET status = '在库'
|
||||
WHERE (status IS NULL OR status <> '在库')
|
||||
AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2) 执行后核对:三行结果应全部为 0
|
||||
-- (统计口径与 1) 的 WHERE 完全一致,故「有实物且状态非在库」的行应为 0)
|
||||
-- ---------------------------------------------------------------------------
|
||||
SELECT 'stock_buy' AS tbl, count(*) AS remaining
|
||||
FROM stock_buy
|
||||
WHERE (status IS NULL OR status <> '在库')
|
||||
AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0)
|
||||
UNION ALL
|
||||
SELECT 'stock_semi', count(*)
|
||||
FROM stock_semi
|
||||
WHERE (status IS NULL OR status <> '在库')
|
||||
AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0)
|
||||
UNION ALL
|
||||
SELECT 'stock_product', count(*)
|
||||
FROM stock_product
|
||||
WHERE (status IS NULL OR status <> '在库')
|
||||
AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3) 可选:质量列补空 —— 默认**注释掉**,需要时人工确认后再放开
|
||||
--
|
||||
-- ★ 只补 NULL,不覆盖任何已有取值。
|
||||
-- '待检' / '未检' 是真实业务状态,绝不能被本脚本抹成 '合格'。
|
||||
-- 实测当前三表均无 NULL 的质量列(stock_buy.inspection_status 有 2042 行为
|
||||
-- '未检',是有值状态,不在补空范围内),故本段在当前数据上是空操作。
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- UPDATE stock_semi SET quality_status = '合格' WHERE quality_status IS NULL;
|
||||
-- UPDATE stock_product SET quality_status = '合格' WHERE quality_status IS NULL;
|
||||
-- UPDATE stock_buy SET inspection_status = '未检' WHERE inspection_status IS NULL;
|
||||
-- ↑ 采购件补的是「未检」而非「合格」:默认未检验是保守且诚实的取值。
|
||||
|
||||
COMMIT;
|
||||
141
db_migrations/phase2_return_and_defective_goods.sql
Normal file
141
db_migrations/phase2_return_and_defective_goods.sql
Normal file
@ -0,0 +1,141 @@
|
||||
-- =============================================================================
|
||||
-- 二期迁移:通用原单退回 + 不良品在管台账
|
||||
--
|
||||
-- 背景
|
||||
-- 逆向物流二期。出库后的实物退回分两条路径:
|
||||
-- · 良品退回(错领/多领)—— 直接加回原库存行
|
||||
-- · 不良品退回 —— **不写库存表**,转入独立的坏件在管台账
|
||||
-- trans_defective_goods,修好后一键回库
|
||||
--
|
||||
-- 不良品不入库存表是本次的**核心架构决策**:库存表的 status 是行级属性,
|
||||
-- 而质量是件级属性。若把坏件加回原行,一行可能同时含良品与坏件,只能整行
|
||||
-- 打不良(实测 stock_buy 单行最大 4789 件,中位 8 件,整行打不良会凭空
|
||||
-- 损失大量良品)。故坏件全程独立于库存表,只在修好回库时才回到原行。
|
||||
--
|
||||
-- 与之配套:worklist 表 trans_defective_goods 与维修模块(trans_repair)
|
||||
-- **完全解耦** —— trans_repair 是 SN 单台粒度、无任何数量列,承载不了
|
||||
-- "一批坏件"(实测 50.6% 的出库是多件,中位 2、最大 186)。
|
||||
--
|
||||
-- 变更内容(全部为**追加式**,不改动/不删除任何既有列与行)
|
||||
-- 1) trans_outbound 新增 returned_quantity —— 每条出库明细的累计退回量
|
||||
-- 2) 新建 trans_return —— 退回流水(每一次退回一条)
|
||||
-- 3) 新建 trans_defective_goods —— 不良品在管台账(可部分回库)
|
||||
--
|
||||
-- ★ 类型选择说明:returned_quantity 用 numeric(19,4) 而非 float。
|
||||
-- 本系统所有数量列一律 numeric(19,4)(trans_outbound.quantity /
|
||||
-- trans_borrow.returned_quantity 等),且 returned_quantity 要参与
|
||||
-- `return_qty <= quantity - returned_quantity` 这种判等/比较运算。
|
||||
-- 用 float 会引入二进制浮点误差,反复部分退回后可能出现
|
||||
-- "已退满却仍判定为未退满"或反之的错判。
|
||||
--
|
||||
-- 安全性
|
||||
-- · 纯追加式 DDL:ADD COLUMN 带 DEFAULT 0,既有 1077 行出库记录自动补 0,
|
||||
-- 不改写任何业务数据;两张新表不影响现有查询。
|
||||
-- · 幂等:全部使用 IF NOT EXISTS,可重复执行。
|
||||
-- · 回滚:见文末「回滚段」(同样为纯 DDL,不触碰业务数据)。
|
||||
--
|
||||
-- 执行
|
||||
-- docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1) 出库明细:累计退回量
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE trans_outbound
|
||||
ADD COLUMN IF NOT EXISTS returned_quantity numeric(19,4) NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN trans_outbound.returned_quantity IS '累计已退回数量(良品+不良品),不得超过 quantity';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2) 退回流水
|
||||
-- 每一次退回写一条,**不做覆盖式更新** —— 避免重演 trans_borrow 归还时
|
||||
-- 把 return_time/operator 覆盖掉、导致部分归还历史丢失的老问题。
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS trans_return (
|
||||
id serial PRIMARY KEY,
|
||||
outbound_id integer NOT NULL, -- 原出库明细 trans_outbound.id
|
||||
stock_id integer, -- 原库存行 id(快照)
|
||||
source_table varchar(50), -- 原库存表名(快照)
|
||||
sku varchar(100), -- 冗余,便于列表展示免联表
|
||||
return_qty numeric(19,4) NOT NULL DEFAULT 0,
|
||||
return_type varchar(20) NOT NULL, -- '良品' | '不良品'
|
||||
reason text,
|
||||
operator varchar(100),
|
||||
return_time timestamp without time zone DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
COMMENT ON TABLE trans_return IS '原单退回流水(每次退回一条,不覆盖)';
|
||||
COMMENT ON COLUMN trans_return.return_type IS '良品 / 不良品';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_trans_return_outbound
|
||||
ON trans_return (outbound_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_trans_return_stock
|
||||
ON trans_return (source_table, stock_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_trans_return_time
|
||||
ON trans_return (return_time);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3) 不良品在管台账
|
||||
-- 一行 = 一批同源坏件。支持部分回库:remaining_qty 随回库递减。
|
||||
-- 状态机:待处理 → (部分回库) → 已回库
|
||||
-- ↘ 已报废
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS trans_defective_goods (
|
||||
id serial PRIMARY KEY,
|
||||
return_id integer, -- 来源 trans_return.id(追溯)
|
||||
outbound_id integer, -- 来源 trans_outbound.id(追溯)
|
||||
source_table varchar(50) NOT NULL, -- 回库目标库存表
|
||||
stock_id integer NOT NULL, -- 回库目标库存行
|
||||
base_id integer, -- 物料主数据(联表展示用)
|
||||
sku varchar(100),
|
||||
material_name varchar(200),
|
||||
spec_model varchar(255),
|
||||
quantity numeric(19,4) NOT NULL DEFAULT 0, -- 进入在管时的原始数量
|
||||
remaining_qty numeric(19,4) NOT NULL DEFAULT 0, -- 当前仍在管数量
|
||||
status varchar(20) NOT NULL DEFAULT '待处理',
|
||||
company_name varchar(255), -- 行级隔离用(本表承载实物,按库存表口径存公司)
|
||||
reason text,
|
||||
operator varchar(100),
|
||||
remark text,
|
||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
COMMENT ON TABLE trans_defective_goods IS '不良品在管台账;与库存表解耦,回库时才回到 source_table#stock_id';
|
||||
COMMENT ON COLUMN trans_defective_goods.remaining_qty IS '仍在管数量;每次回库递减,归零即整批回库完成';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_tdg_status
|
||||
ON trans_defective_goods (status);
|
||||
CREATE INDEX IF NOT EXISTS ix_tdg_source
|
||||
ON trans_defective_goods (source_table, stock_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_tdg_company
|
||||
ON trans_defective_goods (company_name);
|
||||
CREATE INDEX IF NOT EXISTS ix_tdg_outbound
|
||||
ON trans_defective_goods (outbound_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4) 执行后核对
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo '--- 1) returned_quantity 列已就位,既有行补 0 ---'
|
||||
SELECT count(*) AS outbound_rows,
|
||||
count(*) FILTER (WHERE returned_quantity = 0) AS zero_filled
|
||||
FROM trans_outbound;
|
||||
|
||||
\echo '--- 2) 新表已建立 ---'
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE tablename IN ('trans_return', 'trans_defective_goods') ORDER BY 1;
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段(仅在需要撤销本次迁移时执行;同样纯 DDL,不动业务数据)
|
||||
-- 注意:一旦已有退回/在管数据落库,回滚会一并丢弃这些数据。
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- DROP TABLE IF EXISTS trans_defective_goods;
|
||||
-- DROP TABLE IF EXISTS trans_return;
|
||||
-- ALTER TABLE trans_outbound DROP COLUMN IF EXISTS returned_quantity;
|
||||
-- COMMIT;
|
||||
84
db_migrations/phase3_defective_qty_tracking.sql
Normal file
84
db_migrations/phase3_defective_qty_tracking.sql
Normal file
@ -0,0 +1,84 @@
|
||||
-- =============================================================================
|
||||
-- 三期迁移:坏件处置量跟踪(配合三期的「坏件报废」闭环)
|
||||
--
|
||||
-- 背景
|
||||
-- 二期只支持坏件的「回库」一种出口,故用 `quantity - remaining_qty` 就能
|
||||
-- 反推已回库量。三期补上「报废」出口后,这个反推就不再成立 ——
|
||||
-- 它会把报废掉的数量误算成已回库量。
|
||||
--
|
||||
-- 因此拆出两个独立累计列:restocked_qty(累计回库)与 scrapped_qty(累计报废)。
|
||||
--
|
||||
-- ★ 不变式(全表恒成立,测试中逐行校验):
|
||||
-- restocked_qty + scrapped_qty + remaining_qty = quantity
|
||||
-- 暂不加 DB 层 CHECK 约束:本系统库存数量普遍以 Python float 累加后再
|
||||
-- 落 numeric(19,4),极端小数下可能出现 4 位以外的舍入抖动,硬约束会把
|
||||
-- 一次合法操作变成 500。改为在接口层维护并由测试守住不变式。
|
||||
--
|
||||
-- 状态值归一
|
||||
-- 二期把「部分处置」命名为 '部分回库'。三期报废也会产生部分状态,旧名不再
|
||||
-- 准确,统一改为 '处理中'。新状态机(取值见 app/models/transaction.py):
|
||||
-- 待处理 → 处理中 → ┬ 已回库(全部回库)
|
||||
-- ├ 已报废(全部报废)
|
||||
-- └ 已闭环(回库与报废混合)
|
||||
--
|
||||
-- 安全性:纯追加式 DDL;新列带 DEFAULT 0,既有行自动补 0。
|
||||
-- 幂等:全部 IF NOT EXISTS / 带条件的 UPDATE,可重复执行。
|
||||
--
|
||||
-- 执行
|
||||
-- docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1) 处置量跟踪列
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE trans_defective_goods
|
||||
ADD COLUMN IF NOT EXISTS restocked_qty numeric(19,4) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS scrapped_qty numeric(19,4) NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN trans_defective_goods.restocked_qty IS '累计已回库数量';
|
||||
COMMENT ON COLUMN trans_defective_goods.scrapped_qty IS '累计已报废数量';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2) 存量回填
|
||||
-- 迁移前只有「回库」一种出口,故「已减少的在管量」全部归入 restocked_qty。
|
||||
-- 本表在写此迁移时为空,此段为幂等防御,防止在其它环境(已有数据)执行时
|
||||
-- 出现 restocked/scrapped 全 0 而 remaining 已减少的不一致状态。
|
||||
-- ---------------------------------------------------------------------------
|
||||
UPDATE trans_defective_goods
|
||||
SET restocked_qty = GREATEST(COALESCE(quantity, 0) - COALESCE(remaining_qty, 0), 0)
|
||||
WHERE restocked_qty = 0
|
||||
AND scrapped_qty = 0
|
||||
AND COALESCE(quantity, 0) > COALESCE(remaining_qty, 0);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3) 状态值归一:'部分回库' → '处理中'
|
||||
-- ---------------------------------------------------------------------------
|
||||
UPDATE trans_defective_goods SET status = '处理中' WHERE status = '部分回库';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4) 执行后核对:不一致行数必须为 0
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo '--- 处置量不变式核对(inconsistent 应为 0)---'
|
||||
SELECT count(*) AS total,
|
||||
count(*) FILTER (
|
||||
WHERE COALESCE(restocked_qty,0) + COALESCE(scrapped_qty,0)
|
||||
+ COALESCE(remaining_qty,0) <> COALESCE(quantity,0)
|
||||
) AS inconsistent
|
||||
FROM trans_defective_goods;
|
||||
|
||||
\echo '--- 状态取值分布 ---'
|
||||
SELECT COALESCE(status, '<NULL>') AS status, count(*) FROM trans_defective_goods GROUP BY 1;
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段(仅撤销三期变更;不动二期表结构)
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- ALTER TABLE trans_defective_goods DROP COLUMN IF EXISTS restocked_qty;
|
||||
-- ALTER TABLE trans_defective_goods DROP COLUMN IF EXISTS scrapped_qty;
|
||||
-- UPDATE trans_defective_goods SET status = '部分回库' WHERE status = '处理中';
|
||||
-- COMMIT;
|
||||
@ -310,7 +310,14 @@ def create_app():
|
||||
# 系统与业务模型 (SysRolePermission 等在 models.system 中)
|
||||
from app.models.system import SysUser, SysLog, SysMenu, SysElement, SysRolePermission, SysWarehouseLocation
|
||||
# 确保借还模型被加载
|
||||
from app.models.transaction import TransBorrow, TransRepair, TransScrap
|
||||
# ★ TransReturn / TransDefectiveGoods 为二期逆向物流新增,必须在此
|
||||
# 预加载:审计监听器按表名从 db.metadata 取模型,未预加载的表在
|
||||
# create_app() 完成时尚未映射,会漏绑审计(虽然后续惰性补绑能兜底,
|
||||
# 但预加载更可靠)。
|
||||
from app.models.transaction import (
|
||||
TransBorrow, TransRepair, TransScrap,
|
||||
TransReturn, TransDefectiveGoods,
|
||||
)
|
||||
# ★ 审批单模型(原仅在函数体内延迟导入,会导致审计监听器漏绑)
|
||||
from app.models.outbound import OutboundApproval
|
||||
from app.models.borrow import BorrowApproval
|
||||
|
||||
@ -2,7 +2,7 @@ from flask import Blueprint, jsonify, request, send_file, current_app
|
||||
from app.extensions import db, beijing_time
|
||||
from datetime import datetime, timedelta
|
||||
from flask_jwt_extended import jwt_required, get_jwt, get_jwt_identity
|
||||
from app.utils.decorators import permission_required, get_current_company_filter
|
||||
from app.utils.decorators import permission_required, get_current_company_filter, prevent_double_submit
|
||||
from sqlalchemy.orm import joinedload
|
||||
import uuid as uuid_module
|
||||
import io
|
||||
@ -22,9 +22,28 @@ from app.models.inbound.stocktake import (
|
||||
STOCKTAKE_STATUS_ACTIVE,
|
||||
STOCKTAKE_STATUS_FINISHED,
|
||||
)
|
||||
from app.models.transaction import TransBorrow
|
||||
from app.models.transaction import (
|
||||
TransBorrow,
|
||||
TransReturn,
|
||||
TransDefectiveGoods,
|
||||
RETURN_TYPE_GOOD,
|
||||
RETURN_TYPE_DEFECTIVE,
|
||||
DEFECTIVE_STATUS_PENDING,
|
||||
DEFECTIVE_STATUS_IN_PROGRESS,
|
||||
RESTOCKABLE_DEFECTIVE_STATUSES,
|
||||
SCRAPPABLE_DEFECTIVE_STATUSES,
|
||||
VALID_DEFECTIVE_STATUSES,
|
||||
defective_close_status,
|
||||
)
|
||||
from app.models.outbound import TransOutbound
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
# 库存状态语义的单一事实来源(与分配器共用同一套常量,避免两处定义漂移)
|
||||
from app.services.inventory_reservation import (
|
||||
VALID_STOCK_STATUSES,
|
||||
STOCK_STATUS_IN_STOCK,
|
||||
)
|
||||
|
||||
# 尝试导入用户模型
|
||||
try:
|
||||
from app.models.system import SysUser
|
||||
@ -2427,9 +2446,691 @@ def update_stocktake_quantity():
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'code': 200, 'msg': '更新成功'}), 200
|
||||
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'更新失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 库存状态变更接口(逆向物流的基础能力)
|
||||
# ==============================================================================
|
||||
# ★ 为什么需要它
|
||||
# 分配器已引入「仅 status='在库' 方能被分配」的硬隔离
|
||||
# (见 app/services/inventory_reservation.py::allocatable_filter)。
|
||||
# 但在此之前,**全系统没有任何入口**能改写 stock 行的 status —— 状态只能靠
|
||||
# 手工改库,于是「坏件退回 / 送修 / 冻结」在系统里没有落点,逆向物流无从谈起。
|
||||
# 本接口补上这一环。
|
||||
#
|
||||
# ★ 阶段二接入预告
|
||||
# 退回 / 送修 / 报废流程落地时,应**复用本接口背后的同一条变更路径**
|
||||
# (而不是各自复制一份赋值逻辑),以保证「什么状态算可出货」在全系统
|
||||
# 只有一处定义。届时可考虑抽成 service 层函数,本路由只做鉴权与解析。
|
||||
#
|
||||
# ★ 审计留痕
|
||||
# stock_buy / stock_semi / stock_product 均在 audit_listener 的白名单内
|
||||
# (app/core/audit_listener.py:44-48),因此 status / quality_status 的
|
||||
# 变更会由 SQLAlchemy 事件监听器**自动**写入 audit_logs,记录操作人
|
||||
# (取自 JWT)、IP、变更前后的值 —— 本接口刻意不手工记账,避免双写。
|
||||
# 注意:监听器要求 HTTP 请求上下文,故状态变更必须在请求内直接落库,
|
||||
# 不可丢给后台任务,否则会静默失去审计痕迹。
|
||||
|
||||
@bp.route('/<int:stock_id>/change-status', methods=['POST'])
|
||||
# ★ 专用权限码。原先搭 inventory_stocktake:operation(盲盘作业)的便车,
|
||||
# 但「冻结/标不良」是库存状态治理动作,与盘点作业职责不同,审计上不合规。
|
||||
# 无冒号形式,不触发 _expand_operation_perms 的前缀桥接。
|
||||
# 权限注册见 db_migrations/add_defective_operation_perms.sql
|
||||
@permission_required('stock_change_status')
|
||||
def change_stock_status(stock_id):
|
||||
"""
|
||||
变更单条库存行的状态(在库 / 冻结 / 不良品)。
|
||||
|
||||
Body(JSON):
|
||||
{
|
||||
"source_table": "stock_buy" | "stock_semi" | "stock_product", # 必填
|
||||
"status": "在库" | "冻结" | "不良品", # 必填
|
||||
"quality_status": "合格" | "不合格" | "待检" # 可选
|
||||
}
|
||||
|
||||
典型用法:
|
||||
· 发现坏件 → status='不良品',从此不再被分配出货
|
||||
· 争议/盘点待查 → status='冻结'
|
||||
· 维修完成放回池子 → status='在库'
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
source_table = (data.get('source_table') or '').strip()
|
||||
new_status = (data.get('status') or '').strip()
|
||||
quality_status = data.get('quality_status')
|
||||
|
||||
# ---- 1. 参数校验(脏值一律挡在入口,不让它进库)----
|
||||
if not source_table or not new_status:
|
||||
return jsonify({'code': 400, 'msg': 'source_table 与 status 均为必填'}), 400
|
||||
|
||||
model = get_stock_model(source_table)
|
||||
if model is None:
|
||||
return jsonify({
|
||||
'code': 400,
|
||||
'msg': f'不支持的 source_table: {source_table},'
|
||||
f'仅支持 stock_buy / stock_semi / stock_product',
|
||||
}), 400
|
||||
|
||||
if new_status not in VALID_STOCK_STATUSES:
|
||||
return jsonify({
|
||||
'code': 400,
|
||||
'msg': f'不支持的 status: {new_status},'
|
||||
f'仅支持 {"、".join(VALID_STOCK_STATUSES)}',
|
||||
}), 400
|
||||
|
||||
try:
|
||||
# ---- 2. 行锁 + 取行 ----
|
||||
# ★ with_for_update 是必须的:本接口会与出库/借库的预占、报废的扣减
|
||||
# 并发。不加锁的话「冻结」可能与「扣减」交错 —— 冻完之后该行仍被
|
||||
# 扣走并发货,冻结形同虚设。
|
||||
row = model.query.with_for_update().get(stock_id)
|
||||
if not row:
|
||||
return jsonify({
|
||||
'code': 404,
|
||||
'msg': f'库存记录不存在: {source_table}#{stock_id}',
|
||||
}), 404
|
||||
|
||||
# ---- 3. 多租户隔离:非跨域用户只能动本公司的库存 ----
|
||||
# 与分配器的口径一致(分配器按 MaterialBase.company_name 过滤候选行),
|
||||
# 否则普通用户可越权冻结他司库存,等于一种拒绝服务。
|
||||
company_limit = get_current_company_filter()
|
||||
if company_limit is not None:
|
||||
base = row.base
|
||||
if (company_limit == '__NO_COMPANY__' or base is None
|
||||
or (base.company_name or '') != company_limit):
|
||||
return jsonify({'code': 403, 'msg': '无权操作其他公司的库存'}), 403
|
||||
|
||||
# ---- 4. 质量列:按表实际拥有的列写入,缺列时明确报错而非静默丢弃 ----
|
||||
# ★ 实测 stock_buy 没有 quality_status 列(只有 inspection_status),
|
||||
# 静默忽略会让调用方以为写成功了。
|
||||
if quality_status is not None:
|
||||
if not hasattr(row, 'quality_status'):
|
||||
return jsonify({
|
||||
'code': 400,
|
||||
'msg': f'{source_table} 没有 quality_status 字段;'
|
||||
f'采购件的检验状态请通过入库单的 inspection_status 维护',
|
||||
}), 400
|
||||
row.quality_status = quality_status
|
||||
|
||||
old_status = row.status
|
||||
old_quality = getattr(row, 'quality_status', None)
|
||||
|
||||
row.status = new_status
|
||||
|
||||
# 提交后由 audit_listener 自动记录本次变更(含操作人与前后值)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '状态变更成功',
|
||||
'data': {
|
||||
'source_table': source_table,
|
||||
'stock_id': stock_id,
|
||||
'sku': row.sku,
|
||||
'old_status': old_status,
|
||||
'status': row.status,
|
||||
'old_quality_status': old_quality,
|
||||
'quality_status': getattr(row, 'quality_status', None),
|
||||
},
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'状态变更失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 原单退回 & 不良品回库(逆向物流二期)
|
||||
# ==============================================================================
|
||||
# 架构要点(详见 app/models/transaction.py 的模块注释与
|
||||
# db_migrations/phase2_return_and_defective_goods.sql):
|
||||
#
|
||||
# · 良品退回 → 加回原库存行(stock_quantity 与 available_quantity 同步加回,
|
||||
# 与出库时 restore_then_deduct 的扣减口径严格对称)
|
||||
# · 不良品退回 → **完全不动库存表**,转入独立的 trans_defective_goods 在管
|
||||
# 台账;修好后按 remaining_qty 部分/整批回库
|
||||
#
|
||||
# 为什么坏件不进库存表:status 是**行级**属性,质量是**件级**属性。把坏件
|
||||
# 加回原行只能整行打不良,而实测 stock_buy 单行最大 4789 件、中位 8 件 ——
|
||||
# 退 1 件坏件会让整行良品一起被隔离,是静默的大规模库存损失。
|
||||
|
||||
|
||||
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)。
|
||||
"""
|
||||
model = get_stock_model(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):
|
||||
"""
|
||||
行级多租户隔离:非跨域用户只能操作本公司库存。不满足即抛 PermissionError。
|
||||
|
||||
口径与扫码出库(OutboundService.get_stock_by_barcode)、状态变更接口完全一致
|
||||
—— 都走 MaterialBase.company_name,避免三处隔离逻辑分叉。
|
||||
"""
|
||||
company_limit = 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'])
|
||||
# ★ 专用查看权限。原先复用 inventory_stocktake(盲盘作业)—— 实测 SALES(销售)
|
||||
# 角色持有该权限,意味着销售人员能读整份不良品台账,与业务对台账可见性的
|
||||
# 要求不符。无冒号形式不触发前缀桥接。注册见 add_return_view_support.sql
|
||||
@permission_required('defective_list')
|
||||
def list_defective_goods():
|
||||
"""
|
||||
不良品在管台账分页查询(供「不良品在管台账」看板页使用)。
|
||||
|
||||
Query:
|
||||
page 页码,默认 1
|
||||
page_size 每页条数,默认 20
|
||||
status 状态精确过滤(待处理/处理中/已回库/已报废/已闭环);'全部' 或空 = 不过滤
|
||||
keyword 模糊匹配 物料名称 / SKU / 规格型号
|
||||
start_date / end_date 按退回时间(created_at)过滤
|
||||
|
||||
行级隔离:本表自带 company_name 快照,直接按它过滤。刻意不联表
|
||||
MaterialBase —— 坏件的原库存行可能已被删除,联表会让记录整批消失。
|
||||
"""
|
||||
page = request.args.get('page', 1, type=int) or 1
|
||||
page_size = request.args.get('page_size', 20, type=int) or 20
|
||||
page_size = min(max(page_size, 1), 200) # 防超大分页拖垮库
|
||||
|
||||
status = (request.args.get('status') or '').strip()
|
||||
keyword = (request.args.get('keyword') or '').strip()
|
||||
start_date = (request.args.get('start_date') or '').strip()
|
||||
end_date = (request.args.get('end_date') or '').strip()
|
||||
|
||||
try:
|
||||
query = TransDefectiveGoods.query
|
||||
|
||||
# 行级隔离(超管/跨域 company_limit 为 None,不受限)
|
||||
company_limit = get_current_company_filter()
|
||||
if company_limit is not None:
|
||||
query = query.filter(TransDefectiveGoods.company_name == company_limit)
|
||||
|
||||
if status and status not in ('全部', 'all'):
|
||||
if status not in VALID_DEFECTIVE_STATUSES:
|
||||
return jsonify({
|
||||
'code': 400,
|
||||
'msg': f'不支持的状态:{status},'
|
||||
f'仅支持 {"、".join(VALID_DEFECTIVE_STATUSES)}',
|
||||
}), 400
|
||||
query = query.filter(TransDefectiveGoods.status == status)
|
||||
|
||||
if keyword:
|
||||
like = f'%{keyword}%'
|
||||
query = query.filter(db.or_(
|
||||
TransDefectiveGoods.material_name.ilike(like),
|
||||
TransDefectiveGoods.sku.ilike(like),
|
||||
TransDefectiveGoods.spec_model.ilike(like),
|
||||
))
|
||||
|
||||
# 日期边界补全时分秒,避免 10 位日期被当成零点截断(与全系统口径一致)
|
||||
if start_date and len(start_date) == 10:
|
||||
start_date = f'{start_date} 00:00:00'
|
||||
if end_date and len(end_date) == 10:
|
||||
end_date = f'{end_date} 23:59:59'
|
||||
if start_date:
|
||||
query = query.filter(TransDefectiveGoods.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(TransDefectiveGoods.created_at <= end_date)
|
||||
|
||||
# 默认按退回时间倒序:最新的坏件最需要处理
|
||||
query = query.order_by(TransDefectiveGoods.created_at.desc(),
|
||||
TransDefectiveGoods.id.desc())
|
||||
|
||||
pg = query.paginate(page=page, per_page=page_size, error_out=False)
|
||||
|
||||
# 汇总卡:在管总量(剩余待处理合计),供看板顶部展示
|
||||
pending_total = db.session.query(
|
||||
db.func.coalesce(db.func.sum(TransDefectiveGoods.remaining_qty), 0)
|
||||
)
|
||||
if company_limit is not None:
|
||||
pending_total = pending_total.filter(
|
||||
TransDefectiveGoods.company_name == company_limit)
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': {
|
||||
'list': [g.to_dict() for g in pg.items],
|
||||
'total': pg.total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'pending_total': float(pending_total.scalar() or 0),
|
||||
},
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'查询失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
# 注:原此处的 _defective_unit_cost() 已迁至 app/services/scrap_sources.py 的
|
||||
# defective_unit_cost() —— 服务层不得反向 import API 层,而报废扣减逻辑
|
||||
# (含成本取价)现由来源适配器自持。
|
||||
|
||||
|
||||
@bp.route('/return-from-outbound', methods=['POST'])
|
||||
# ★ 库管 SOP 专用权限码:退回是实物交接动作,只应由具备库管职责的人员执行。
|
||||
# 刻意使用**无冒号**的 'outbound_return' 而非 'outbound_list:return':
|
||||
# 后者会命中 _expand_operation_perms() 的前缀桥接(outbound_list 下存在
|
||||
# outbound_list:operation),导致持有该权限的角色被一并放行,授权面失控。
|
||||
# 无冒号码不触发桥接,判定与前端 hasPermission 的精确匹配完全一致。
|
||||
# DDL/授权见 db_migrations/add_outbound_return_perm.sql
|
||||
@permission_required('outbound_return')
|
||||
# ★ 幂等锁置于 permission_required 内层(理由见 restock_defective_goods)
|
||||
@prevent_double_submit(lock_timeout=5)
|
||||
def return_from_outbound():
|
||||
"""
|
||||
通用原单退回。
|
||||
|
||||
Body(JSON):
|
||||
{
|
||||
"outbound_id": 123, # 必填,trans_outbound.id(出库**明细行**,非单号)
|
||||
"return_qty": 2, # 必填,本次退回数量
|
||||
"is_defective": false, # 必填,true=不良品退回,false=良品退回
|
||||
"reason": "错领退回" # 可选
|
||||
}
|
||||
|
||||
两条分支的差异:
|
||||
· 良品 → 加回原库存行的 stock_quantity 与 available_quantity
|
||||
· 不良品 → 库存表分毫不动,转 trans_defective_goods 在管台账
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
operator_name = _normalize_user_id()
|
||||
|
||||
outbound_id = data.get('outbound_id')
|
||||
is_defective = data.get('is_defective')
|
||||
reason = (data.get('reason') or '').strip() or None
|
||||
|
||||
# ---- 1. 入参校验(脏值一律挡在入口)----
|
||||
if not outbound_id:
|
||||
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:
|
||||
return_qty = float(data.get('return_qty') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'code': 400, 'msg': 'return_qty 无效'}), 400
|
||||
if return_qty <= 0:
|
||||
return jsonify({'code': 400, 'msg': '退回数量必须大于 0'}), 400
|
||||
|
||||
try:
|
||||
# ---- 2. 锁定原出库明细并校验退回额度 ----
|
||||
# ★ 行锁不可省:并发两笔退回若各自读到相同的 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}'
|
||||
)
|
||||
|
||||
# ---- 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
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': f'退回成功,{outcome}',
|
||||
'data': {
|
||||
'outbound_id': outbound.id,
|
||||
'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,
|
||||
},
|
||||
}), 200
|
||||
|
||||
except PermissionError as e:
|
||||
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
|
||||
|
||||
|
||||
@bp.route('/defective/<int:goods_id>/restock', methods=['POST'])
|
||||
# ★ 专用权限码(原先搭 inventory_stocktake:operation 的便车)。
|
||||
# 无冒号形式,不触发前缀桥接。注册见 add_defective_operation_perms.sql
|
||||
@permission_required('defective_restock')
|
||||
# ★ 幂等锁必须置于 permission_required **内层**:prevent_double_submit 依赖
|
||||
# get_jwt_identity(),若放在外层则 JWT 尚未验证 → 抛错 → 被其 except 捕获
|
||||
# 后 fail-open 降级放行,锁形同虚设。
|
||||
@prevent_double_submit(lock_timeout=5)
|
||||
def restock_defective_goods(goods_id):
|
||||
"""
|
||||
不良品修好后一键回库。
|
||||
|
||||
Body(JSON):
|
||||
{
|
||||
"restock_qty": 2, # 可选,缺省 = 全部剩余在管量
|
||||
"remark": "已更换主板" # 可选
|
||||
}
|
||||
|
||||
★ 与 trans_repair(维修模块)完全解耦:本接口操作的是 trans_defective_goods
|
||||
在管台账。trans_repair 是 SN 单台粒度且无任何数量列,承载不了「一批坏件」。
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
operator_name = _normalize_user_id()
|
||||
|
||||
try:
|
||||
# ---- 1. 锁定在管记录 ----
|
||||
# ★ 行锁不可省:并发两次回库若各自读到相同的 remaining_qty,会双双通过
|
||||
# 校验,合计回库量超过在管量 —— 凭空多出库存。
|
||||
goods = TransDefectiveGoods.query.with_for_update().get(goods_id)
|
||||
if not goods:
|
||||
raise ValueError(f'不良品在管记录不存在(ID: {goods_id})')
|
||||
|
||||
# ---- 2. 状态守门(Fail-Closed)----
|
||||
# 已回库 → 再回库就是凭空多一份库存;已报废 → 实物已销毁。
|
||||
if goods.status not in RESTOCKABLE_DEFECTIVE_STATUSES:
|
||||
raise ValueError(
|
||||
f'当前状态为「{goods.status}」,不可回库'
|
||||
f'(仅 {"、".join(RESTOCKABLE_DEFECTIVE_STATUSES)} 可回库)'
|
||||
)
|
||||
|
||||
remaining = float(goods.remaining_qty or 0)
|
||||
if remaining <= 0:
|
||||
raise ValueError('该记录在管数量为 0,无可回库数量')
|
||||
|
||||
raw = data.get('restock_qty')
|
||||
if raw is None or raw == '':
|
||||
restock_qty = remaining # 缺省:整批剩余一次回库
|
||||
else:
|
||||
try:
|
||||
restock_qty = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('restock_qty 无效')
|
||||
|
||||
if restock_qty <= 0:
|
||||
raise ValueError('回库数量必须大于 0')
|
||||
if restock_qty > remaining:
|
||||
raise ValueError(f'回库数量({restock_qty})超过在管数量({remaining})')
|
||||
|
||||
# ---- 3. 回到原库存行 ----
|
||||
stock_row = _lock_source_stock_row(goods.source_table, goods.stock_id)
|
||||
_assert_company_owns(stock_row)
|
||||
|
||||
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) + restock_qty
|
||||
stock_row.available_quantity = float(stock_row.available_quantity or 0) + restock_qty
|
||||
|
||||
# ---- 4. 递减在管量、累加回库量并推进状态机 ----
|
||||
new_remaining = remaining - restock_qty
|
||||
goods.remaining_qty = new_remaining
|
||||
goods.restocked_qty = float(goods.restocked_qty or 0) + restock_qty
|
||||
# ★ 终态由「累计去向」推导而非「最后一次动作」:本批可能既回库过、
|
||||
# 又报废过,按最后一次动作定状态会产生误导(见 defective_close_status)
|
||||
goods.status = (
|
||||
defective_close_status(goods.restocked_qty, goods.scrapped_qty)
|
||||
if new_remaining <= 0 else DEFECTIVE_STATUS_IN_PROGRESS
|
||||
)
|
||||
|
||||
# ★ 刻意**不覆盖** goods.operator:该字段记录的是「谁退回来的」,
|
||||
# 覆盖会丢掉退回环节的责任人。本次回库人由 audit_listener 自动
|
||||
# 写入 audit_logs(trans_defective_goods 已在审计白名单内)。
|
||||
if data.get('remark'):
|
||||
goods.remark = str(data['remark']).strip()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '回库成功',
|
||||
'data': {
|
||||
'id': goods.id,
|
||||
'restock_qty': restock_qty,
|
||||
'remaining_qty': float(goods.remaining_qty),
|
||||
'status': goods.status,
|
||||
'source_table': goods.source_table,
|
||||
'stock_id': goods.stock_id,
|
||||
},
|
||||
}), 200
|
||||
|
||||
except PermissionError as e:
|
||||
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
|
||||
|
||||
|
||||
@bp.route('/defective/<int:goods_id>/scrap-request', methods=['POST'])
|
||||
# ★ 专用权限码(原先搭 inventory_stocktake:operation 的便车)。
|
||||
# 无冒号形式,不触发前缀桥接。注册见 add_defective_operation_perms.sql
|
||||
@permission_required('defective_scrap')
|
||||
# ★ 幂等锁置于 permission_required 内层(理由见 restock_defective_goods)
|
||||
@prevent_double_submit(lock_timeout=5)
|
||||
def submit_defective_scrap_request(goods_id):
|
||||
"""
|
||||
提交在管坏件的**报废申请**(需审批人审批,通过后由库管执行报废)。
|
||||
|
||||
Body(JSON):
|
||||
{
|
||||
"scrap_qty": 2, # 可选,缺省 = 全部剩余在管量
|
||||
"reason": "主板烧毁", # 可选,写入申请单备注
|
||||
"approver_id": 7 # 必填,指定审批人
|
||||
}
|
||||
|
||||
★ 为什么不在这里扣减:
|
||||
报废一律需审批(SCRAP_ALWAYS_REQUIRES_APPROVAL)。本接口只创建申请单,
|
||||
**不预占 remaining_qty**,扣减发生在审批通过后的执行阶段(由
|
||||
ScrapApprovalService 经来源适配器调用)。这与报废模块既有的
|
||||
「仅锁定意向,不扣库存」哲学一致。
|
||||
|
||||
副作用:同一批坏件可重复提交多张申请单;执行期由适配器按 Fail-Closed
|
||||
拒绝超额的那几张(整单回滚、单据保持可撤回),不会出现超报废。
|
||||
|
||||
★ 与库存表的关系:坏件从未进入库存表,执行时也**不动任何库存行**,
|
||||
只改在管台账并写 trans_scrap。与「库存行报废」互不重叠,不会重复扣减。
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
operator_id = get_jwt_identity()
|
||||
|
||||
try:
|
||||
# ---- 1. 取在管记录并做前置校验(早失败,避免生成必然执行不了的申请单)----
|
||||
goods = TransDefectiveGoods.query.get(goods_id)
|
||||
if not goods:
|
||||
raise ValueError(f'不良品在管记录不存在(ID: {goods_id})')
|
||||
|
||||
if goods.status not in SCRAPPABLE_DEFECTIVE_STATUSES:
|
||||
raise ValueError(
|
||||
f'当前状态为「{goods.status}」,不可申请报废'
|
||||
f'(仅 {"、".join(SCRAPPABLE_DEFECTIVE_STATUSES)} 可申请)'
|
||||
)
|
||||
|
||||
remaining = float(goods.remaining_qty or 0)
|
||||
if remaining <= 0:
|
||||
raise ValueError('该记录在管数量为 0,无可报废数量')
|
||||
|
||||
raw = data.get('scrap_qty')
|
||||
if raw is None or raw == '':
|
||||
scrap_qty = remaining # 缺省:整批剩余一次申请
|
||||
else:
|
||||
try:
|
||||
scrap_qty = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('scrap_qty 无效')
|
||||
|
||||
if scrap_qty <= 0:
|
||||
raise ValueError('报废数量必须大于 0')
|
||||
if scrap_qty > remaining:
|
||||
raise ValueError(f'报废数量({scrap_qty})超过在管数量({remaining})')
|
||||
|
||||
# ---- 2. 多租户隔离 ----
|
||||
# 直接比对台账自身的 company_name 快照 —— 坏件的原库存行可能已被删除,
|
||||
# 不能依赖联表取公司(那会让这类记录绕过隔离)。
|
||||
company_limit = get_current_company_filter()
|
||||
if company_limit is not None:
|
||||
if (company_limit == '__NO_COMPANY__'
|
||||
or (goods.company_name or '') != company_limit):
|
||||
raise PermissionError('无权操作其他公司的不良品')
|
||||
|
||||
# ---- 3. 创建报废申请单(走统一审批流)----
|
||||
from app.services.scrap_approval_service import ScrapApprovalService
|
||||
|
||||
req = ScrapApprovalService.submit_approval(
|
||||
applicant_id=operator_id,
|
||||
items=[{
|
||||
'source_table': 'trans_defective_goods',
|
||||
'stock_id': goods.id,
|
||||
'scrap_qty': scrap_qty,
|
||||
}],
|
||||
remark=(data.get('reason') or '').strip() or None,
|
||||
approver_id=data.get('approver_id'),
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '报废申请已提交,待审批人审批',
|
||||
'data': {
|
||||
'id': goods.id,
|
||||
'request_id': req.id,
|
||||
'request_no': req.request_no,
|
||||
'scrap_qty': scrap_qty,
|
||||
# ★ 明确回传「在管量未变」—— 前端据此提示用户,避免误以为已报废
|
||||
'remaining_qty': float(goods.remaining_qty or 0),
|
||||
},
|
||||
}), 200
|
||||
|
||||
except PermissionError as e:
|
||||
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
|
||||
|
||||
@ -148,6 +148,11 @@ def scan_barcode():
|
||||
'msg': '未找到对应的库存记录,请确认条码是否正确'
|
||||
}), 404
|
||||
|
||||
except ValueError as e:
|
||||
# ★ 业务性拒绝(物料状态异常等):属于「扫到了但按规定不能出」,
|
||||
# 不是系统故障 —— 返回 400 + 明确文案,便于前端红字提示工人。
|
||||
# 若落到下方 500 分支,前端只会显示「服务器错误」,工人无从判断。
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'扫描查询出错: {str(e)}'}), 500
|
||||
@ -273,6 +278,155 @@ def get_outbound_list():
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
|
||||
|
||||
def _resolve_return_materials(rows):
|
||||
"""
|
||||
批量解析退回流水对应的物料名称/规格。
|
||||
|
||||
trans_return 只存 (source_table, stock_id) 多态指针,需回查三张库存表。
|
||||
★ 源库存行可能已被物理删除(实测出库记录中已有悬空行),取不到时返回
|
||||
空字符串由前端显示占位 —— 刻意**不**因此丢弃该行:退回台账的完整性
|
||||
优先于展示美观,缺名字总比少一条记录好。
|
||||
"""
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi,
|
||||
'stock_product': StockProduct}
|
||||
resolved = {}
|
||||
for table, model in model_map.items():
|
||||
ids = {r.stock_id for r in rows if r.source_table == table and r.stock_id}
|
||||
if not ids:
|
||||
continue
|
||||
for obj in model.query.options(joinedload(model.base)).filter(
|
||||
model.id.in_(ids)).all():
|
||||
base = getattr(obj, 'base', None)
|
||||
resolved[(table, obj.id)] = {
|
||||
'material_name': (base.name if base else '') or '',
|
||||
'spec_model': (base.spec_model if base else '') or '',
|
||||
}
|
||||
return resolved
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 退回流水(只读台账)
|
||||
# GET /api/v1/outbound/returns
|
||||
# --------------------------------------------------------
|
||||
@outbound_bp.route('/returns', methods=['GET'])
|
||||
# ★ 专用查看权限,仅授予超管/主管/库管三个核心角色。
|
||||
# 无冒号形式不触发 _expand_operation_perms 的前缀桥接,
|
||||
# 注册见 db_migrations/add_return_view_support.sql
|
||||
@permission_required('outbound_return_list')
|
||||
def list_returns():
|
||||
"""
|
||||
原单退回流水台账(只读,无任何写操作)。
|
||||
|
||||
Query:
|
||||
page / page_size 分页,默认 1 / 20
|
||||
keyword 模糊匹配 出库单号 / SKU / 操作人
|
||||
return_type '良品' / '不良品';'全部' 或留空 = 不过滤
|
||||
start_date / end_date 按退回时间过滤(10 位日期自动补时分秒)
|
||||
|
||||
★ 行级隔离直接按 trans_return.company_name **快照**过滤,不走 join 链:
|
||||
源库存行会被入库模块物理删除,链路一断该记录就会对普通用户静默消失。
|
||||
"""
|
||||
from app.extensions import db
|
||||
from app.utils.decorators import get_current_company_filter
|
||||
from app.models.transaction import TransReturn, VALID_RETURN_TYPES
|
||||
from app.models.outbound import TransOutbound
|
||||
|
||||
page = request.args.get('page', 1, type=int) or 1
|
||||
page_size = request.args.get('page_size', 20, type=int) or 20
|
||||
page_size = min(max(page_size, 1), 200) # 防超大分页拖垮库
|
||||
|
||||
keyword = (request.args.get('keyword') or '').strip()
|
||||
return_type = (request.args.get('return_type') or '').strip()
|
||||
start_date = (request.args.get('start_date') or '').strip()
|
||||
end_date = (request.args.get('end_date') or '').strip()
|
||||
|
||||
try:
|
||||
query = TransReturn.query
|
||||
|
||||
# 行级隔离(超管/跨域 company_limit 为 None,不受限)
|
||||
company_limit = get_current_company_filter()
|
||||
if company_limit is not None:
|
||||
query = query.filter(TransReturn.company_name == company_limit)
|
||||
|
||||
if return_type and return_type not in ('全部', 'all'):
|
||||
if return_type not in VALID_RETURN_TYPES:
|
||||
return jsonify({
|
||||
'code': 400,
|
||||
'msg': f'不支持的退回类型:{return_type},'
|
||||
f'仅支持 {"、".join(VALID_RETURN_TYPES)}',
|
||||
}), 400
|
||||
query = query.filter(TransReturn.return_type == return_type)
|
||||
|
||||
# 日期边界补全时分秒,避免 10 位日期被当成零点截断(与全系统口径一致)
|
||||
if start_date and len(start_date) == 10:
|
||||
start_date = f'{start_date} 00:00:00'
|
||||
if end_date and len(end_date) == 10:
|
||||
end_date = f'{end_date} 23:59:59'
|
||||
if start_date:
|
||||
query = query.filter(TransReturn.return_time >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(TransReturn.return_time <= end_date)
|
||||
|
||||
if keyword:
|
||||
like = f'%{keyword}%'
|
||||
# 出库单号不在本表,先经 trans_outbound 求出命中的 outbound_id 集合
|
||||
matched = db.session.query(TransOutbound.id).filter(
|
||||
TransOutbound.outbound_no.ilike(like)
|
||||
).subquery()
|
||||
query = query.filter(db.or_(
|
||||
TransReturn.sku.ilike(like),
|
||||
TransReturn.operator.ilike(like),
|
||||
TransReturn.outbound_id.in_(db.session.query(matched.c.id)),
|
||||
))
|
||||
|
||||
# 默认按退回时间倒序:最新退回的最需要核对
|
||||
query = query.order_by(TransReturn.return_time.desc(),
|
||||
TransReturn.id.desc())
|
||||
pg = query.paginate(page=page, per_page=page_size, error_out=False)
|
||||
|
||||
rows = pg.items
|
||||
|
||||
# ---- 批量补出库单号(避免 N+1)----
|
||||
outbound_ids = {r.outbound_id for r in rows if r.outbound_id}
|
||||
outbound_map = {}
|
||||
if outbound_ids:
|
||||
for o in TransOutbound.query.filter(
|
||||
TransOutbound.id.in_(outbound_ids)).all():
|
||||
outbound_map[o.id] = o.outbound_no
|
||||
|
||||
# ---- 批量补物料名 ----
|
||||
mat_map = _resolve_return_materials(rows)
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
d = r.to_dict()
|
||||
d['outbound_no'] = outbound_map.get(r.outbound_id, '')
|
||||
info = mat_map.get((r.source_table, r.stock_id)) or {}
|
||||
d['material_name'] = info.get('material_name', '')
|
||||
d['spec_model'] = info.get('spec_model', '')
|
||||
items.append(d)
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': {
|
||||
'list': items,
|
||||
'total': pg.total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
},
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'查询失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
def _allocate_bom_requirements(requirements, company_limit,
|
||||
StockBuy, StockSemi, StockProduct, MaterialBase):
|
||||
"""
|
||||
@ -318,6 +472,16 @@ def _allocate_bom_requirements(requirements, company_limit,
|
||||
# 按 (base_id, source_table) 归集
|
||||
rows_by_base = {}
|
||||
|
||||
# ★ 状态门槛:本函数是**全系统库存分配的唯一权威入口** ——
|
||||
# 出库申请与借库申请都经 reserve_for_items() 走到这里拿候选行,
|
||||
# 故在此处加一条即对两者同时生效(报废不走分配器,见 scrap_approval_service)。
|
||||
# 规则见 inventory_reservation.allocatable_filter:仅「在库」可被分配,
|
||||
# 「冻结」「不良品」以及 status 为 NULL 的行一律不进入候选集。
|
||||
#
|
||||
# Fail-Closed 说明:若该条件导致查询异常,下方 except 会 continue 掉整张表,
|
||||
# 表现为「该物料无库存」而非「放行坏件」,方向上是安全的。
|
||||
from app.services.inventory_reservation import allocatable_filter
|
||||
|
||||
for model, source_table, type_label, type_key in (
|
||||
(StockBuy, 'stock_buy', '采购件', 'material'),
|
||||
(StockSemi, 'stock_semi', '半成品', 'semi'),
|
||||
@ -327,6 +491,7 @@ def _allocate_bom_requirements(requirements, company_limit,
|
||||
q = model.query.filter(
|
||||
model.base_id.in_(base_ids),
|
||||
model.available_quantity > 0, # ★ 只取真正可用的行
|
||||
allocatable_filter(model), # ★ 硬隔离:非「在库」一律不出货
|
||||
)
|
||||
if company_limit is not None:
|
||||
q = q.filter(model.base.has(MaterialBase.company_name == company_limit))
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
# inventory-backend/app/api/v1/scrap.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
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.services.auth_service import AuthService
|
||||
from app.extensions import db
|
||||
from app.models.transaction import TransScrap, TransRepair
|
||||
from app.models.transaction import (
|
||||
TransScrap, TransRepair, TransDefectiveGoods, OPEN_DEFECTIVE_STATUSES,
|
||||
)
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
@ -69,8 +71,34 @@ def scan_barcode():
|
||||
if not barcode:
|
||||
return jsonify({'code': 400, 'msg': '请提供条码'}), 400
|
||||
|
||||
# ★ 可选:当前正在执行的报废申请单 id。
|
||||
# 传入后,扫码命中多个来源时优先返回该单批准明细里指定的那一条。
|
||||
# 必要性:在管不良品的 SKU 复制自原库存行,同一码可能同时命中
|
||||
# stock_buy#M 与 trans_defective_goods#N,这是两个不同实物,
|
||||
# 条码本身无法区分,只有单据上下文能决定该扫到哪个。
|
||||
request_id = request.args.get('request_id', type=int)
|
||||
prefer_pairs = None
|
||||
if request_id:
|
||||
try:
|
||||
from app.models.scrap_approval import ScrapApproval
|
||||
req = db.session.get(ScrapApproval, request_id)
|
||||
if req:
|
||||
prefer_pairs = set()
|
||||
for it in (req.get_items() or []):
|
||||
try:
|
||||
prefer_pairs.add((
|
||||
str(it.get('source_table') or '').strip(),
|
||||
int(it.get('stock_id')),
|
||||
))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
except Exception as e:
|
||||
# 优先集构造失败不应阻断扫码 —— 退回固定顺序选路,行为与改造前一致
|
||||
current_app.logger.warning(f"[scrap/scan] 构造优先命中集失败: {e}")
|
||||
prefer_pairs = None
|
||||
|
||||
try:
|
||||
result = ScrapService.get_stock_by_barcode(barcode)
|
||||
result = ScrapService.get_stock_by_barcode(barcode, prefer_pairs=prefer_pairs)
|
||||
if result:
|
||||
# ★ Fail-Closed: 扫码响应剥离价格字段
|
||||
result.pop('price', None)
|
||||
@ -84,42 +112,25 @@ def scan_barcode():
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 2. 提交报废单接口
|
||||
# POST /api/v1/scrap
|
||||
# [已移除] 直接报废接口 POST /api/v1/scrap
|
||||
#
|
||||
# 该接口绕过审批流直接写 trans_scrap 并扣减库存,与系统自陈的规则
|
||||
# 「报废一律需审批」(services/scrap_approval_service.py 的
|
||||
# SCRAP_ALWAYS_REQUIRES_APPROVAL)直接冲突,且构成职责分离漏洞 ——
|
||||
# 同一个库管可自行宣告实物销毁而无人复核。
|
||||
#
|
||||
# 现全部报废必须走:申请 → 审批 → 执行
|
||||
# 提交 POST /api/v1/scrap/request
|
||||
# 审批 PATCH /api/v1/scrap/request/<id>/approve
|
||||
# 执行 POST /api/v1/scrap/request/<id>/execute
|
||||
#
|
||||
# 前端 api/scrap.ts 的 createScrap() 已一并删除(此前已是死代码,
|
||||
# 页面早已切换到申请-审批-按单执行流程)。
|
||||
#
|
||||
# 注:权限元素 scrap_create:operation 保留在库中不删 ——
|
||||
# db_migrations/add_scrap_perm.sql 以它作为 scrap_apply / scrap_execute
|
||||
# 的授权来源,删掉会让该迁移重跑时授不出权限。
|
||||
# --------------------------------------------------------
|
||||
@scrap_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_scrap():
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
user_company = claims.get('company_name', '')
|
||||
if not user_role:
|
||||
return jsonify({'code': 403, 'msg': '未授权'}), 403
|
||||
|
||||
# 超级管理员直接放行
|
||||
if user_role.upper() != 'SUPER_ADMIN':
|
||||
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
|
||||
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
if 'scrap_create:operation' not in perms:
|
||||
return jsonify({'code': 403, 'msg': '权限不足'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
|
||||
|
||||
current_user_name = get_jwt_identity() or 'Unknown'
|
||||
|
||||
# items 必填
|
||||
if 'items' not in data or not data['items']:
|
||||
return jsonify({'code': 400, 'msg': '报废商品列表不能为空'}), 400
|
||||
|
||||
try:
|
||||
result = ScrapService.process_scrap(data, operator_name=current_user_name)
|
||||
return jsonify({'code': 200, 'msg': '报废成功', 'data': result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
db.session.rollback()
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
@ -189,8 +200,23 @@ def get_scrap_records():
|
||||
class ScrapService:
|
||||
|
||||
@staticmethod
|
||||
def get_stock_by_barcode(barcode):
|
||||
"""根据条码查找库存"""
|
||||
def get_stock_by_barcode(barcode, prefer_pairs=None):
|
||||
"""
|
||||
根据条码查找库存实物。
|
||||
|
||||
prefer_pairs: {(source_table, row_id), ...} 可选 —— 「优先命中集」,
|
||||
由调用方从**当前报废申请单的批准明细**构造。
|
||||
|
||||
★ 为什么需要它(这是必须的,不是优化):
|
||||
在管不良品的 SKU 是从原库存行**复制**的(退回时
|
||||
sku=getattr(stock_row,'sku','')),因此同一个条码可能同时命中
|
||||
stock_buy#M 与 trans_defective_goods#N —— 这是**两个不同的实物**。
|
||||
仅凭条码无法区分,原实现「按固定顺序返回首个命中」会让库存行
|
||||
永久遮蔽不良品,导致执行时报「不在批准明细中」。
|
||||
只有单据上下文能决定该扫到哪一个,故由调用方传入优先集。
|
||||
|
||||
返回值形状与改造前完全一致,前端无需按来源分支处理。
|
||||
"""
|
||||
if not barcode:
|
||||
return None
|
||||
clean_code = barcode.strip()
|
||||
@ -202,56 +228,80 @@ class ScrapService:
|
||||
return float(item.pre_tax_unit_price) if item.pre_tax_unit_price else 0
|
||||
return 0
|
||||
|
||||
# 1. 查询成品
|
||||
# ---- 收集全部候选(不再首个即返回)----
|
||||
candidates = []
|
||||
|
||||
prod = StockProduct.query.filter(
|
||||
db.or_(StockProduct.barcode == clean_code, StockProduct.sku == clean_code)
|
||||
).first()
|
||||
if prod:
|
||||
res = ScrapService._format_stock(prod, 'stock_product')
|
||||
res['price'] = get_price(prod, 'stock_product')
|
||||
return res
|
||||
candidates.append(res)
|
||||
|
||||
# 2. 查询半成品
|
||||
semi = StockSemi.query.filter(
|
||||
db.or_(StockSemi.barcode == clean_code, StockSemi.sku == clean_code)
|
||||
).first()
|
||||
if semi:
|
||||
res = ScrapService._format_stock(semi, 'stock_semi')
|
||||
res['price'] = 0
|
||||
return res
|
||||
candidates.append(res)
|
||||
|
||||
# 3. 查询原材料
|
||||
buy = StockBuy.query.filter(
|
||||
db.or_(StockBuy.barcode == clean_code, StockBuy.sku == clean_code)
|
||||
).first()
|
||||
if buy:
|
||||
res = ScrapService._format_stock(buy, 'stock_buy')
|
||||
res['price'] = get_price(buy, 'stock_buy')
|
||||
return res
|
||||
candidates.append(res)
|
||||
|
||||
# 4. 查询维修单 (TransRepair)
|
||||
repair = TransRepair.query.filter(
|
||||
db.or_(TransRepair.sku == clean_code, TransRepair.serial_number == clean_code)
|
||||
# 4. 查询在管不良品台账(逆向物流:坏件不入库存表,由独立台账承载)
|
||||
#
|
||||
# ★ 本分支取代原先的「维修单」分支。维修件报废能力已随直接报废接口
|
||||
# 一并移除(见文件头部 [已移除] 注释),且该来源早已被审批流的扫码
|
||||
# 校验判死 —— _build_scanned_index 会拒绝 trans_* 来源。
|
||||
#
|
||||
# ★ 物料名/规格直接取自台账的冗余快照字段,**不联表 MaterialBase** ——
|
||||
# 坏件的原库存行可能已被入库模块物理删除,联表会取到空值。
|
||||
defective = TransDefectiveGoods.query.filter(
|
||||
TransDefectiveGoods.sku == clean_code
|
||||
).filter(
|
||||
TransRepair.repair_status.notin_(['已出库', '报废转出'])
|
||||
# 仅未结案的可扫:终态(已回库/已报废/已闭环)在管量已归零
|
||||
TransDefectiveGoods.status.in_(OPEN_DEFECTIVE_STATUSES)
|
||||
).first()
|
||||
if repair:
|
||||
return {
|
||||
'id': repair.id,
|
||||
'sku': repair.sku,
|
||||
'barcode': repair.sku,
|
||||
'name': repair.material_name or '维修件',
|
||||
'spec': '',
|
||||
if defective:
|
||||
# 在管量即「可报废量」,回填到既有字段形状,前端无需按来源分支处理
|
||||
remain = float(defective.remaining_qty or 0)
|
||||
candidates.append({
|
||||
'id': defective.id,
|
||||
'sku': defective.sku,
|
||||
'barcode': defective.sku,
|
||||
'name': defective.material_name or '在管不良品',
|
||||
'spec': defective.spec_model or '',
|
||||
'category': '',
|
||||
'material_type': '',
|
||||
'warehouse_loc': repair.customer_location or '',
|
||||
'stock_quantity': 1,
|
||||
'available_quantity': 1,
|
||||
'source_table': 'trans_repair',
|
||||
'price': float(repair.sale_price) if repair.sale_price else 0
|
||||
}
|
||||
'warehouse_loc': '', # 台账无库位字段
|
||||
'stock_quantity': remain,
|
||||
'available_quantity': remain,
|
||||
'source_table': 'trans_defective_goods',
|
||||
})
|
||||
|
||||
return None
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# ---- 选路:单据上下文优先,否则维持改造前的固定顺序(首个命中)----
|
||||
# ★ 同一 SKU 同时存在于库存表与在管台账时,条码无法区分二者;
|
||||
# 批准明细说该报废哪一个,就返回哪一个。
|
||||
if prefer_pairs:
|
||||
for res in candidates:
|
||||
try:
|
||||
pair = (str(res.get('source_table') or '').strip(), int(res.get('id')))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if pair in prefer_pairs:
|
||||
return res
|
||||
|
||||
return candidates[0]
|
||||
|
||||
@staticmethod
|
||||
def _format_stock(item, table_type):
|
||||
@ -270,98 +320,6 @@ class ScrapService:
|
||||
'source_table': table_type,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def process_scrap(data, operator_name='System'):
|
||||
"""处理报废:扣减库存并记录报废单"""
|
||||
items = data.get('items', [])
|
||||
reason = data.get('reason', '')
|
||||
|
||||
if not reason:
|
||||
raise ValueError('请填写报废原因')
|
||||
|
||||
created_records = []
|
||||
|
||||
for item in items:
|
||||
stock_id = item.get('id')
|
||||
source_table = item.get('source_table')
|
||||
scrap_qty = float(item.get('quantity', 0))
|
||||
|
||||
if not stock_id or not source_table or scrap_qty <= 0:
|
||||
continue
|
||||
|
||||
# 处理维修单报废
|
||||
if source_table == 'trans_repair':
|
||||
repair = TransRepair.query.get(stock_id)
|
||||
if not repair:
|
||||
raise ValueError(f'维修单不存在: ID={stock_id}')
|
||||
|
||||
# 更新维修单状态为报废转出
|
||||
repair.repair_status = '报废转出'
|
||||
|
||||
# 创建报废记录
|
||||
scrap_record = TransScrap(
|
||||
sku=repair.sku,
|
||||
source_table='trans_repair',
|
||||
stock_id=stock_id,
|
||||
quantity=1,
|
||||
reason=reason,
|
||||
operator_name=operator_name,
|
||||
approval_status='approved',
|
||||
cost_at_scrap=float(repair.cost_price) if repair.cost_price else 0,
|
||||
total_loss=float(repair.cost_price) if repair.cost_price else 0
|
||||
)
|
||||
db.session.add(scrap_record)
|
||||
created_records.append(scrap_record)
|
||||
continue
|
||||
|
||||
# 获取库存记录 — ★ 修复并发:使用悲观锁防止超卖/负库存
|
||||
stock_record = None
|
||||
if source_table == 'stock_product':
|
||||
stock_record = StockProduct.query.with_for_update().get(stock_id)
|
||||
elif source_table == 'stock_semi':
|
||||
stock_record = StockSemi.query.with_for_update().get(stock_id)
|
||||
elif source_table == 'stock_buy':
|
||||
stock_record = StockBuy.query.with_for_update().get(stock_id)
|
||||
|
||||
if not stock_record:
|
||||
raise ValueError(f'库存记录不存在: ID={stock_id}')
|
||||
|
||||
# 检查可用数量(锁已持有,TOCTOU 窗口已消除)
|
||||
avail_qty = float(stock_record.available_quantity) if stock_record.available_quantity else 0
|
||||
if avail_qty < scrap_qty:
|
||||
raise ValueError(f"SKU {stock_record.sku} 可用库存不足,当前可用: {avail_qty}")
|
||||
|
||||
# 计算损失金额
|
||||
unit_price = 0.0
|
||||
if source_table == 'stock_product':
|
||||
unit_price = float(stock_record.sale_price) if stock_record.sale_price else 0
|
||||
elif source_table == 'stock_buy':
|
||||
unit_price = float(stock_record.pre_tax_unit_price) if stock_record.pre_tax_unit_price else 0
|
||||
|
||||
total_loss = round(unit_price * scrap_qty, 2)
|
||||
|
||||
# 扣减库存
|
||||
stock_record.stock_quantity = float(stock_record.stock_quantity) - scrap_qty
|
||||
stock_record.available_quantity = float(stock_record.available_quantity) - scrap_qty
|
||||
|
||||
# 创建报废记录
|
||||
scrap_record = TransScrap(
|
||||
sku=stock_record.sku,
|
||||
source_table=source_table,
|
||||
stock_id=stock_id,
|
||||
quantity=scrap_qty,
|
||||
reason=reason,
|
||||
operator_name=operator_name,
|
||||
approval_status='approved',
|
||||
cost_at_scrap=unit_price,
|
||||
total_loss=total_loss
|
||||
)
|
||||
db.session.add(scrap_record)
|
||||
created_records.append(scrap_record)
|
||||
|
||||
db.session.commit()
|
||||
return {'count': len(created_records)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 辅助:用户名解析(库里存 '张三/zhangsan' 格式时取斜杠前的姓名)
|
||||
# ------------------------------------------------------------------
|
||||
@ -440,6 +398,22 @@ class ScrapService:
|
||||
'batch_number': getattr(rp, 'serial_number', '') or '',
|
||||
}
|
||||
|
||||
# 4) 不良品在管台账来源(三期):物料名与规格已冗余在台账本表,无需联表。
|
||||
# ★ 之所以冗余存这几个字段:坏件的原库存行可能已被删除(入库模块会
|
||||
# 物理删除库存行),联表取名称会得到空值,报表上就只剩一串 ID。
|
||||
tdg_ids = {r.stock_id for r in rows
|
||||
if r.source_table == 'trans_defective_goods' and r.stock_id}
|
||||
if tdg_ids:
|
||||
from app.models.transaction import TransDefectiveGoods
|
||||
for g in TransDefectiveGoods.query.filter(
|
||||
TransDefectiveGoods.id.in_(tdg_ids)).all():
|
||||
resolved[('trans_defective_goods', g.id)] = {
|
||||
'material_name': g.material_name or '',
|
||||
'spec_model': g.spec_model or '',
|
||||
'warehouse_location': '',
|
||||
'batch_number': '',
|
||||
}
|
||||
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
@ -488,7 +462,7 @@ class ScrapService:
|
||||
from app.utils.advanced_filter import (
|
||||
build_predicate, is_negative, invert_condition,
|
||||
)
|
||||
from sqlalchemy import or_, and_, tuple_, func as _func
|
||||
from sqlalchemy import or_, and_, tuple_, case, func as _func
|
||||
|
||||
parent_map = {
|
||||
'no': TransScrap.scrap_request_no,
|
||||
@ -506,11 +480,22 @@ class ScrapService:
|
||||
TransScrap.scrap_request_no == h.scrap_request_no,
|
||||
))
|
||||
else:
|
||||
# ★ 来源标记必须与内存分组键(query_records 里的 gkey)
|
||||
# 保持一致,否则「按单筛选」的结果与页面分组会对不上:
|
||||
# 同一分钟内、同一操作人的「不良品直报」与「普通直接报废」
|
||||
# 在页面是两个组,在筛选里却会互相带出。
|
||||
_origin_flag = case(
|
||||
(TransScrap.source_table == 'trans_defective_goods', 1),
|
||||
else_=0,
|
||||
)
|
||||
keys.append(and_(
|
||||
TransScrap.scrap_request_no.is_(None),
|
||||
_func.date_trunc('minute', TransScrap.operation_time)
|
||||
== _func.date_trunc('minute', h.operation_time),
|
||||
TransScrap.operator_name == h.operator_name,
|
||||
_origin_flag == (
|
||||
1 if h.source_table == 'trans_defective_goods' else 0
|
||||
),
|
||||
))
|
||||
return or_(*keys) if keys else None
|
||||
|
||||
@ -621,9 +606,20 @@ class ScrapService:
|
||||
).join(StockProduct, TransBorrow.stock_id == StockProduct.id).join(
|
||||
MaterialBase, StockProduct.base_id == MaterialBase.id
|
||||
).filter(MaterialBase.company_name == company_limit)
|
||||
# ★ 不良品在管报废(三期):台账自带 company_name 快照,直接按它过滤。
|
||||
# 刻意**不**联表 MaterialBase —— 那批坏件的原库存行可能已被删除,
|
||||
# 联表会让这类记录从普通用户视图中整批静默消失。
|
||||
from app.models.transaction import TransDefectiveGoods
|
||||
defective_subq = db.session.query(TransScrap.id).join(
|
||||
TransDefectiveGoods,
|
||||
db.and_(TransScrap.stock_id == TransDefectiveGoods.id,
|
||||
TransScrap.source_table == 'trans_defective_goods')
|
||||
).filter(TransDefectiveGoods.company_name == company_limit)
|
||||
|
||||
all_matches = buy_subq.union(
|
||||
semi_subq, product_subq, repair_subq,
|
||||
borrow_stock_buy, borrow_stock_semi, borrow_stock_product
|
||||
borrow_stock_buy, borrow_stock_semi, borrow_stock_product,
|
||||
defective_subq
|
||||
).subquery()
|
||||
query = query.filter(TransScrap.id.in_(all_matches))
|
||||
|
||||
@ -640,11 +636,19 @@ class ScrapService:
|
||||
|
||||
groups = {}
|
||||
for r in rows:
|
||||
# ★ 不良品在管报废(source_table='trans_defective_goods')同样没有审批
|
||||
# 单号,但它是**新业务**产生的记录,不是历史脏数据。沿用 LEGACY-
|
||||
# 前缀会让业务人员误判为遗留数据,故单独成组并换用「不良品直报-」。
|
||||
is_defective_origin = (r.source_table == 'trans_defective_goods')
|
||||
|
||||
if r.scrap_request_no:
|
||||
gkey = ('req', r.scrap_request_no)
|
||||
else:
|
||||
ts = r.operation_time.strftime('%Y-%m-%d %H:%M') if r.operation_time else '未知时间'
|
||||
gkey = ('legacy', ts, r.operator_name or '')
|
||||
# ★ 分组键带上来源标记:不良品直报自成一类,不与普通直接报废
|
||||
# 混进同一组(二者前缀不同,混组会导致标题语义不一致)。
|
||||
# 注意此键必须与下方 _order_key_pred 的 SQL 谓词保持一致。
|
||||
gkey = ('legacy', ts, r.operator_name or '', is_defective_origin)
|
||||
|
||||
g = groups.get(gkey)
|
||||
if g is None:
|
||||
@ -652,9 +656,10 @@ class ScrapService:
|
||||
if gkey[0] == 'req':
|
||||
req_no = r.scrap_request_no
|
||||
else:
|
||||
# 虚拟单号:无单号的历史直接报废,仍给出可读标识便于追溯
|
||||
# 虚拟单号:无审批单号的直接报废,给出可读标识便于追溯
|
||||
ts_raw = r.operation_time.strftime('%Y%m%d%H%M') if r.operation_time else '000000000000'
|
||||
req_no = f"LEGACY-{ts_raw}-{op_name or '未知'}"
|
||||
prefix = '不良品直报' if is_defective_origin else 'LEGACY'
|
||||
req_no = f"{prefix}-{ts_raw}-{op_name or '未知'}"
|
||||
g = {
|
||||
'scrap_request_no': req_no,
|
||||
'is_legacy': gkey[0] == 'legacy',
|
||||
|
||||
@ -111,31 +111,104 @@ def submit_return():
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
|
||||
|
||||
# --- 借库报废(未归还直接报废,关联报废单流程)---
|
||||
@trans_bp.route('/borrow/scrap', methods=['POST'])
|
||||
# --- 借库报废申请(未归还 → 提交报废申请,需审批)---
|
||||
@trans_bp.route('/borrow/scrap-request', methods=['POST'])
|
||||
@jwt_required()
|
||||
@permission_required('op_return:operation') # 复用归还权限:能归还的库管即可报废
|
||||
def scrap_borrow():
|
||||
@permission_required('op_return:operation') # 复用归还权限:能归还的库管即可申请报废
|
||||
# ★ 幂等锁置于 permission_required 内层:prevent_double_submit 依赖
|
||||
# get_jwt_identity(),放外层会因 JWT 未验证而抛错、被自身 except 捕获后降级放行
|
||||
@prevent_double_submit(lock_timeout=5)
|
||||
def submit_borrow_scrap_request():
|
||||
"""
|
||||
借库未归还直接报废(库管/主管操作)
|
||||
请求体: { "record_ids": [1, 2, 3], "reason": "物品丢失" }
|
||||
提交「借出未归还」的**报废申请**(需审批人审批,通过后由库管执行报废)。
|
||||
|
||||
请求体:
|
||||
{
|
||||
"record_ids": [1, 2, 3], # 必填,trans_borrow.id 列表(按整条待还量报废)
|
||||
"reason": "物品丢失", # 可选,写入申请单备注
|
||||
"approver_id": 7 # 必填,指定审批人
|
||||
}
|
||||
|
||||
★ 为什么改走审批:
|
||||
原先 POST /borrow/scrap 直接写 trans_scrap 并扣总库存,绕过审批,与系统
|
||||
自陈的「报废一律需审批」冲突,构成职责分离漏洞 —— 同一个库管可自行宣告
|
||||
实物损失而无人复核。现统一走:申请 → 审批 → 执行。
|
||||
|
||||
★ 执行方式:本来源为「免扫码」—— 东西在借用人手上,物理上不可能扫码;
|
||||
且执行只改台账与总库存,不产生任何可被挪用的可用库存。
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
record_ids = data.get('record_ids', [])
|
||||
reason = data.get('reason', '')
|
||||
record_ids = data.get('record_ids') or []
|
||||
reason = (data.get('reason') or '').strip()
|
||||
approver_id = data.get('approver_id')
|
||||
|
||||
if not record_ids:
|
||||
return jsonify({'code': 400, 'msg': '请选择要报废的借出记录'}), 400
|
||||
return jsonify({'code': 400, 'msg': '请选择要申请报废的借出记录'}), 400
|
||||
if not approver_id:
|
||||
return jsonify({'code': 400, 'msg': '请选择审批人'}), 400
|
||||
|
||||
operator_name = _current_username() or 'Unknown'
|
||||
try:
|
||||
result = TransService.scrap_borrow(record_ids, operator_name=operator_name, reason=reason)
|
||||
return jsonify({'code': 200, 'msg': f'已报废 {result["count"]} 条借出记录', 'data': result})
|
||||
from app.models.transaction import TransBorrow
|
||||
|
||||
# 逐条载入并校验。
|
||||
# ★ 已归还/已报废的**直接报错**,不静默跳过 —— 旧实现是 `continue`
|
||||
# 然后返回 count=0,用户以为成功实则什么都没发生(缺陷)。
|
||||
items = []
|
||||
missing = []
|
||||
for rid in record_ids:
|
||||
try:
|
||||
rid = int(rid)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f'借出记录 ID 无效:{rid}')
|
||||
record = TransBorrow.query.get(rid)
|
||||
if not record:
|
||||
missing.append(str(rid))
|
||||
continue
|
||||
if record.is_returned:
|
||||
raise ValueError(
|
||||
f"借用记录【{record.borrow_no or rid}】已归还或已报废,不可再申请报废"
|
||||
)
|
||||
pending = (float(record.quantity or 0)
|
||||
- float(record.returned_quantity or 0))
|
||||
if pending <= 0:
|
||||
raise ValueError(
|
||||
f"借用记录【{record.borrow_no or rid}】无待还数量,无需报废"
|
||||
)
|
||||
items.append({
|
||||
'source_table': 'trans_borrow',
|
||||
'stock_id': record.id,
|
||||
'scrap_qty': pending,
|
||||
})
|
||||
|
||||
if missing:
|
||||
raise ValueError(f'以下借出记录不存在:{"、".join(missing)}')
|
||||
if not items:
|
||||
raise ValueError('所选借出记录均无待还数量,无需报废')
|
||||
|
||||
from app.services.scrap_approval_service import ScrapApprovalService
|
||||
|
||||
req = ScrapApprovalService.submit_approval(
|
||||
applicant_id=get_jwt_identity(),
|
||||
items=items,
|
||||
remark=reason or None,
|
||||
approver_id=approver_id,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': f'报废申请已提交({len(items)} 条明细),待审批人审批',
|
||||
'data': {
|
||||
'request_id': req.id,
|
||||
'request_no': req.request_no,
|
||||
'count': len(items),
|
||||
},
|
||||
}), 200
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'报废失败: {str(e)}'}), 500
|
||||
return jsonify({'code': 500, 'msg': f'提交报废申请失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
# --- 记录列表 ---
|
||||
|
||||
@ -41,6 +41,8 @@ WHITELIST_TABLES = {
|
||||
'trans_borrow',
|
||||
'trans_scrap',
|
||||
'trans_repair',
|
||||
'trans_return', # 原单退回流水(二期)
|
||||
'trans_defective_goods', # 不良品在管台账(二期)
|
||||
# --- 库存(三表 + 库存调整)---
|
||||
'stock_buy',
|
||||
'stock_semi',
|
||||
@ -66,6 +68,8 @@ TABLE_LABELS = {
|
||||
'trans_borrow': '借还流水',
|
||||
'trans_scrap': '报废流水',
|
||||
'trans_repair': '维修单',
|
||||
'trans_return': '退回流水',
|
||||
'trans_defective_goods': '不良品在管',
|
||||
'stock_buy': '采购库存',
|
||||
'stock_semi': '半成品库存',
|
||||
'stock_product': '成品库存',
|
||||
@ -185,6 +189,8 @@ def _get_module_name(mapper):
|
||||
return '报废管理'
|
||||
if tablename == 'trans_repair':
|
||||
return '维修管理'
|
||||
if tablename in ('trans_return', 'trans_defective_goods'):
|
||||
return '退回管理'
|
||||
if tablename in ('stock_buy', 'stock_semi', 'stock_product'):
|
||||
return '库存管理'
|
||||
if tablename == 'stock_adjustment':
|
||||
|
||||
@ -139,16 +139,28 @@ class TransOutbound(db.Model):
|
||||
# [新增] 出库时的库位快照(从源库存记录带出,便于历史追溯)
|
||||
warehouse_location = db.Column(db.String(100))
|
||||
|
||||
# [新增] 累计已退回数量(良品 + 不良品口径合并),用于原单退回的额度校验。
|
||||
# ★ 用 numeric(19,4) 而非 float:本系统所有数量列一律 numeric(19,4),
|
||||
# 且该值要参与 `return_qty <= quantity - returned_quantity` 的判等比较,
|
||||
# 浮点误差会让反复部分退回后出现「已退满却判定未退满」的错判。
|
||||
# DDL 见 db_migrations/phase2_return_and_defective_goods.sql
|
||||
returned_quantity = db.Column(db.Numeric(19, 4), nullable=False, default=0)
|
||||
|
||||
remark = db.Column(db.Text)
|
||||
|
||||
def to_dict(self):
|
||||
qty = float(self.quantity) if self.quantity else 0
|
||||
returned = float(self.returned_quantity) if self.returned_quantity is not None else 0
|
||||
return {
|
||||
'id': self.id,
|
||||
'outbound_no': self.outbound_no,
|
||||
'sku': self.sku,
|
||||
'source_table': self.source_table,
|
||||
'outbound_type': self.outbound_type,
|
||||
'quantity': float(self.quantity) if self.quantity else 0,
|
||||
'quantity': qty,
|
||||
# [新增] 退回额度三件套,供前端判断该明细还能退多少
|
||||
'returned_quantity': returned,
|
||||
'returnable_quantity': qty - returned,
|
||||
'unit_price': float(self.unit_price) if self.unit_price else 0,
|
||||
'consumer_name': self.consumer_name,
|
||||
'signature_path': self.signature_path,
|
||||
|
||||
@ -217,3 +217,208 @@ class TransScrap(db.Model):
|
||||
'cost_at_scrap': float(self.cost_at_scrap) if self.cost_at_scrap is not None else None,
|
||||
'total_loss': float(self.total_loss) if self.total_loss is not None else None,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 原单退回(逆向物流)
|
||||
# =============================================================================
|
||||
# 设计要点
|
||||
# --------
|
||||
# 1. **流水不覆盖**:每一次退回写一条 trans_return,而不是在原记录上累加覆盖。
|
||||
# 这是刻意与 trans_borrow 划清界限 —— 后者在部分归还时会把
|
||||
# return_time / return_operator / return_signature 逐次覆盖,导致
|
||||
# 「谁在什么时候还了多少」永久丢失。退回流水不能再犯同样的错。
|
||||
#
|
||||
# 2. **不良品不入库存表**:库存表的 status 是**行级**属性,而质量是**件级**
|
||||
# 属性。把坏件加回原行,会让一行同时含良品与坏件 —— 只能整行打不良,
|
||||
# 而实测 stock_buy 单行最大 4789 件(中位 8 件),整行打不良等于凭空
|
||||
# 损失大量良品。故坏件全程存放在独立的 trans_defective_goods 台账里,
|
||||
# 只有修好回库那一刻才回到原库存行。
|
||||
#
|
||||
# 3. **与维修模块解耦**:trans_repair 是 SN 单台粒度、且没有任何数量列,
|
||||
# 承载不了「一批坏件」(实测 50.6% 的出库是多件,中位 2、最大 186)。
|
||||
# 故坏件台账独立建表,不复用 trans_repair。
|
||||
|
||||
RETURN_TYPE_GOOD = '良品'
|
||||
RETURN_TYPE_DEFECTIVE = '不良品'
|
||||
|
||||
VALID_RETURN_TYPES = (RETURN_TYPE_GOOD, RETURN_TYPE_DEFECTIVE)
|
||||
|
||||
|
||||
class TransReturn(db.Model):
|
||||
"""
|
||||
原单退回流水。
|
||||
|
||||
一行 = 一次退回动作(不做覆盖式更新)。累计退回量另存于
|
||||
trans_outbound.returned_quantity,本表负责回答「每一次是谁、何时、
|
||||
退了多少、良品还是不良品」。
|
||||
"""
|
||||
__tablename__ = 'trans_return'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
outbound_id = db.Column(db.Integer, nullable=False, index=True) # 原出库明细 trans_outbound.id
|
||||
stock_id = db.Column(db.Integer) # 原库存行 id(快照)
|
||||
source_table = db.Column(db.String(50)) # 原库存表名(快照)
|
||||
sku = db.Column(db.String(100)) # 冗余,避免列表页联表
|
||||
|
||||
return_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0)
|
||||
return_type = db.Column(db.String(20), nullable=False) # '良品' | '不良品'
|
||||
reason = db.Column(db.Text)
|
||||
operator = db.Column(db.String(100))
|
||||
return_time = db.Column(db.DateTime, default=beijing_time)
|
||||
|
||||
# ★ 公司快照:退回发生时的所属公司。
|
||||
# 不靠 join 推 —— 隔离判定的链路是
|
||||
# trans_return → trans_outbound → (source_table, stock_id) → 库存表 → 物料主表
|
||||
# 而库存行会被入库模块**物理删除**(实测 1077 条出库记录中已有 7 条悬空),
|
||||
# 链路一断,记录就会对普通用户静默消失。审计视图静默丢数据不可接受。
|
||||
# 与 trans_defective_goods.company_name 同一处理方式。
|
||||
# DDL 见 db_migrations/add_return_view_support.sql
|
||||
company_name = db.Column(db.String(255), index=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'outbound_id': self.outbound_id,
|
||||
'stock_id': self.stock_id,
|
||||
'source_table': self.source_table,
|
||||
'sku': self.sku,
|
||||
'return_qty': float(self.return_qty) if self.return_qty is not None else 0,
|
||||
'return_type': self.return_type,
|
||||
'reason': self.reason,
|
||||
'operator': self.operator,
|
||||
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None,
|
||||
'company_name': self.company_name,
|
||||
}
|
||||
|
||||
|
||||
# 状态机(三期升级):
|
||||
# 待处理 ──┬─→ 处理中 ──┬─→ 已回库(全部回库,无报废)
|
||||
# │ ├─→ 已报废(全部报废,无回库)
|
||||
# │ └─→ 已闭环(回库与报废混合,在管量归零)
|
||||
# └─(一次处置即为终态时,直接跳到对应的终态)
|
||||
#
|
||||
# ★ 三种终态而非单一「已闭环」,是为了让工作台一眼看出这批坏件的**去向**:
|
||||
# 修好回到库存了、还是被销毁了。混合处置无法用单一去向描述,才归到「已闭环」。
|
||||
DEFECTIVE_STATUS_PENDING = '待处理' # 刚退回,尚未做任何处置
|
||||
DEFECTIVE_STATUS_IN_PROGRESS = '处理中' # 已部分回库/部分报废,仍有在管量
|
||||
DEFECTIVE_STATUS_RESTOCKED = '已回库' # 全部回库,无报废
|
||||
DEFECTIVE_STATUS_SCRAPPED = '已报废' # 全部报废,无回库
|
||||
DEFECTIVE_STATUS_CLOSED = '已闭环' # 回库与报废混合,在管量归零
|
||||
|
||||
VALID_DEFECTIVE_STATUSES = (
|
||||
DEFECTIVE_STATUS_PENDING,
|
||||
DEFECTIVE_STATUS_IN_PROGRESS,
|
||||
DEFECTIVE_STATUS_RESTOCKED,
|
||||
DEFECTIVE_STATUS_SCRAPPED,
|
||||
DEFECTIVE_STATUS_CLOSED,
|
||||
)
|
||||
|
||||
# ★ 可继续处置的状态白名单(Fail-Closed:未列出的一律拒绝回库/报废)。
|
||||
# 三种终态都不可再动:已回库→再回库就是凭空多一份库存;已报废→实物已销毁;
|
||||
# 已闭环→在管量已归零。
|
||||
OPEN_DEFECTIVE_STATUSES = (
|
||||
DEFECTIVE_STATUS_PENDING,
|
||||
DEFECTIVE_STATUS_IN_PROGRESS,
|
||||
)
|
||||
|
||||
# 语义别名:回库与报废的准入白名单是同一组「未结案」状态
|
||||
RESTOCKABLE_DEFECTIVE_STATUSES = OPEN_DEFECTIVE_STATUSES
|
||||
SCRAPPABLE_DEFECTIVE_STATUSES = OPEN_DEFECTIVE_STATUSES
|
||||
|
||||
|
||||
def defective_close_status(restocked_qty, scrapped_qty):
|
||||
"""
|
||||
在管量归零时,由累计去向推导终态。
|
||||
|
||||
★ 为什么需要它:一次坏件批次可能既回库了一部分、又报废了剩余部分。
|
||||
此时 remaining_qty 归零,但既不是「已回库」也不是「已报废」——
|
||||
按最后一次动作定状态会产生误导(最后报废 ≠ 整批报废)。
|
||||
故由累计量推导,语义稳定且与动作顺序无关。
|
||||
"""
|
||||
restocked = float(restocked_qty or 0)
|
||||
scrapped = float(scrapped_qty or 0)
|
||||
if restocked > 0 and scrapped > 0:
|
||||
return DEFECTIVE_STATUS_CLOSED
|
||||
if scrapped > 0:
|
||||
return DEFECTIVE_STATUS_SCRAPPED
|
||||
return DEFECTIVE_STATUS_RESTOCKED
|
||||
|
||||
|
||||
class TransDefectiveGoods(db.Model):
|
||||
"""
|
||||
不良品在管台账。
|
||||
|
||||
一行 = 一批同源坏件。支持**部分回库**:remaining_qty 随每次回库递减,
|
||||
归零才算整批回库完成。
|
||||
|
||||
本表与库存表解耦:坏件在管期间不占用任何库存行的数量,也不改其 status。
|
||||
回库时才按 source_table + stock_id 回到原库存行。
|
||||
"""
|
||||
__tablename__ = 'trans_defective_goods'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# --- 来源追溯 ---
|
||||
return_id = db.Column(db.Integer, index=True) # trans_return.id
|
||||
outbound_id = db.Column(db.Integer, index=True) # trans_outbound.id
|
||||
|
||||
# --- 回库目标(原库存行)---
|
||||
source_table = db.Column(db.String(50), nullable=False)
|
||||
stock_id = db.Column(db.Integer, nullable=False)
|
||||
|
||||
# --- 物料快照(回库目标行可能被删,此处保留可读信息)---
|
||||
base_id = db.Column(db.Integer)
|
||||
sku = db.Column(db.String(100))
|
||||
material_name = db.Column(db.String(200))
|
||||
spec_model = db.Column(db.String(255))
|
||||
|
||||
# --- 数量 ---
|
||||
# ★ 不变式:restocked_qty + scrapped_qty + remaining_qty = quantity
|
||||
# 三个去向列相互独立,不可互推 —— 二期曾用 quantity - remaining_qty
|
||||
# 反推回库量,三期加入报废出口后该反推即失效。
|
||||
quantity = db.Column(db.Numeric(19, 4), nullable=False, default=0) # 进入在管时的原始数量
|
||||
remaining_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0) # 仍在管数量
|
||||
restocked_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0) # [三期] 累计已回库
|
||||
scrapped_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0) # [三期] 累计已报废
|
||||
|
||||
# --- 状态与归属 ---
|
||||
status = db.Column(db.String(20), nullable=False, default=DEFECTIVE_STATUS_PENDING)
|
||||
company_name = db.Column(db.String(255), index=True) # 行级隔离:本表承载实物,按库存表口径存公司
|
||||
reason = db.Column(db.Text)
|
||||
operator = db.Column(db.String(100))
|
||||
remark = db.Column(db.Text)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=beijing_time)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time)
|
||||
|
||||
def to_dict(self):
|
||||
qty = float(self.quantity) if self.quantity is not None else 0
|
||||
remain = float(self.remaining_qty) if self.remaining_qty is not None else 0
|
||||
restocked = float(self.restocked_qty) if self.restocked_qty is not None else 0
|
||||
scrapped = float(self.scrapped_qty) if self.scrapped_qty is not None else 0
|
||||
return {
|
||||
'id': self.id,
|
||||
'return_id': self.return_id,
|
||||
'outbound_id': self.outbound_id,
|
||||
'source_table': self.source_table,
|
||||
'stock_id': self.stock_id,
|
||||
'base_id': self.base_id,
|
||||
'sku': self.sku,
|
||||
'material_name': self.material_name,
|
||||
'spec_model': self.spec_model,
|
||||
'quantity': qty,
|
||||
'remaining_qty': remain,
|
||||
# ★ 三个去向列各自独立取值。改造前 restocked_qty 由
|
||||
# quantity - remaining_qty 反推,三期加入报废出口后会算错。
|
||||
'restocked_qty': restocked,
|
||||
'scrapped_qty': scrapped,
|
||||
'status': self.status,
|
||||
'company_name': self.company_name,
|
||||
'reason': self.reason,
|
||||
'operator': self.operator,
|
||||
'remark': self.remark,
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
|
||||
}
|
||||
|
||||
@ -233,9 +233,19 @@ class RepairInboundService:
|
||||
if status == '已出库':
|
||||
raise ValueError("禁止手动变更为已出库状态,请通过扫码出库模块进行操作")
|
||||
|
||||
# 禁止手动变更为报废转出状态,必须通过扫码报废模块进行
|
||||
# 禁止手动变更为报废转出状态。
|
||||
#
|
||||
# ★ 原文案是「请前往报废管理进行扫码操作」,但那条路已不存在:
|
||||
# 维修件报废原本由「直接报废」接口(POST /api/v1/scrap)的
|
||||
# trans_repair 分支实现,而该接口绕过审批、与系统自陈的
|
||||
# 「报废一律需审批」规则冲突,已整体移除。审批流的扫码校验
|
||||
# 也从来拒绝 trans_* 来源,故这条链路早已不通。
|
||||
#
|
||||
# 维修件报废的正式支持(含数量语义、审批接入)需单独排期。
|
||||
if status == '报废转出':
|
||||
raise ValueError("禁止手动变更为报废状态,请前往报废管理进行扫码操作")
|
||||
raise ValueError(
|
||||
"禁止手动变更为报废状态。维修件报废暂未开放,请联系管理员"
|
||||
)
|
||||
|
||||
repair = TransRepair.query.get(id)
|
||||
if not repair:
|
||||
|
||||
@ -81,6 +81,60 @@ def identity_label(key):
|
||||
return f"{key[1]}({key[2] or '-'})"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 库存状态门槛(硬隔离)
|
||||
# =============================================================================
|
||||
# ★ 背景:三张库存表(stock_buy / stock_semi / stock_product)建表起就带
|
||||
# status 列,但历史实现**只写不读** —— status 仅在入库时写一次 '在库',
|
||||
# 此后全仓再无任何代码更新它;分配器更是只看 available_quantity。
|
||||
# 后果:被标成「冻结 / 不良品」的库存行,只要可用量不为 0 就会被正常
|
||||
# 分配出货,坏件因此可以反复流出。
|
||||
#
|
||||
# 此处把 status 变成**真正的准入门槛**:只有白名单内的状态才参与分配。
|
||||
# 配套两件事,缺一不可:
|
||||
# · 历史数据洗刷 —— db_migrations/fill_missing_stock_status.sql
|
||||
# (存量行的 status 若为空/异常,上线本门槛后会集体无法出库)
|
||||
# · 状态变更入口 —— POST /api/v1/inbound/stock/<id>/change-status
|
||||
# (否则状态只能靠手工改库,逆向物流无入口)
|
||||
|
||||
STOCK_STATUS_IN_STOCK = '在库' # ★ 唯一允许被分配出货的状态
|
||||
STOCK_STATUS_FROZEN = '冻结' # 盘查/争议期间临时锁定,货还在但要先查清
|
||||
STOCK_STATUS_DEFECTIVE = '不良品' # 坏件:待维修或待报废,绝不允许再发出
|
||||
|
||||
# 允许写入库存行的状态全集(变更接口据此校验,防止脏值从接口进入)
|
||||
VALID_STOCK_STATUSES = (
|
||||
STOCK_STATUS_IN_STOCK,
|
||||
STOCK_STATUS_FROZEN,
|
||||
STOCK_STATUS_DEFECTIVE,
|
||||
)
|
||||
|
||||
# 「可被分配」的状态白名单。
|
||||
# ★ 用元组而非单值比较:将来若要放行更多状态(如「待检」),只改这里一处。
|
||||
ALLOCATABLE_STATUSES = (STOCK_STATUS_IN_STOCK,)
|
||||
|
||||
|
||||
def allocatable_filter(model):
|
||||
"""
|
||||
库存行的「可被分配出货」SQL 条件,供分配器拼进 WHERE。
|
||||
|
||||
★ Fail-Closed:status 为 NULL 的行**不会**被选中(NULL IN (...) 求值为
|
||||
NULL,非真)。这正是我们要的语义 —— 状态不明的货宁可不出,但代价是
|
||||
上线前必须先把存量数据洗刷干净,否则全库无法出库。
|
||||
洗刷脚本见 db_migrations/fill_missing_stock_status.sql。
|
||||
"""
|
||||
return model.status.in_(ALLOCATABLE_STATUSES)
|
||||
|
||||
|
||||
def is_allocatable(row):
|
||||
"""
|
||||
单行版判断:该库存记录当前是否可被分配。
|
||||
|
||||
供扫码(get_stock_by_barcode)与执行阶段(restore_then_deduct)等
|
||||
不走 SQL 过滤的路径复用,保证全链路用的是**同一套**状态语义。
|
||||
"""
|
||||
return norm_text(getattr(row, 'status', None)) in ALLOCATABLE_STATUSES
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 库存行动态解析
|
||||
# =============================================================================
|
||||
@ -384,6 +438,22 @@ def verify_scanned(scanned_items, approved_items):
|
||||
key = stock_identity(row)
|
||||
label = identity_label(key)
|
||||
|
||||
# ★ 状态准入(最终防线):状态异常的实物整单拒绝。
|
||||
#
|
||||
# 与分配器 allocatable_filter()、扫码入口 _assert_scan_allocatable()
|
||||
# 共用同一个 is_allocatable() 判定,三处语义不会分叉。
|
||||
#
|
||||
# 这道覆盖的是「申请已通过 → 工人正在扫」这段窗口内被冻结/标不良的
|
||||
# 情形 —— 分配器管不到(那是申请时刻),扫码入口也可能被绕过
|
||||
# (前端可被绕过、草稿可陈旧)。这里是写库前的最后一道。
|
||||
#
|
||||
# 抛错即整单回滚(调用方负责),预占由 restore_then_deduct 的调用方
|
||||
# 统一处理 —— 实际语义见 create_outbound_batch 里的 rollback 兜底:
|
||||
# 单据保持 status=1,可重试或走撤回/驳回,不会留下半扣留状态。
|
||||
if not is_allocatable(row):
|
||||
status = (getattr(row, 'status', None) or '').strip() or '未设置'
|
||||
raise ValueError(f"物料【{label}】状态异常(当前为 {status}),禁止出库")
|
||||
|
||||
if key not in approved_idx:
|
||||
raise ValueError(
|
||||
f"扫码物料【{label}】不在该申请单的批准明细中,禁止出库"
|
||||
@ -428,9 +498,10 @@ def restore_then_deduct(scanned_items, approved_items, deduct_stock=True):
|
||||
实物数不动。理由:
|
||||
· 归还只加 available(现有实现),若借出扣了 stock,
|
||||
一借一还后 stock 会永久少一份;
|
||||
· 借库转报废(scrap_borrow)在确认损失时扣 stock,
|
||||
其注释明确假设「可用库存已在借出时冻结」,
|
||||
若借出已扣 stock 会重复扣减。
|
||||
· 借库转报废在确认损失时扣 stock,其实现明确假设
|
||||
「可用库存已在借出时冻结」,若借出已扣 stock
|
||||
会重复扣减。(该实现现已迁入
|
||||
app/services/scrap_sources.py 的 BorrowScrapAdapter)
|
||||
语义:stock = 账面实物(含借出未还),
|
||||
available = 实际可取用。
|
||||
|
||||
|
||||
@ -75,6 +75,7 @@ class OutboundService:
|
||||
.filter(MaterialBase.company_name == company_limit)
|
||||
prod = prod_q.first()
|
||||
if prod:
|
||||
OutboundService._assert_scan_allocatable(prod)
|
||||
res = OutboundService._format_scan_result(prod, 'stock_product', reserved_map)
|
||||
res['price'] = get_price(prod, 'stock_product')
|
||||
return res
|
||||
@ -87,6 +88,7 @@ class OutboundService:
|
||||
.filter(MaterialBase.company_name == company_limit)
|
||||
semi = semi_q.first()
|
||||
if semi:
|
||||
OutboundService._assert_scan_allocatable(semi)
|
||||
res = OutboundService._format_scan_result(semi, 'stock_semi', reserved_map)
|
||||
res['price'] = 0
|
||||
return res
|
||||
@ -99,15 +101,19 @@ class OutboundService:
|
||||
.filter(MaterialBase.company_name == company_limit)
|
||||
buy = buy_q.first()
|
||||
if buy:
|
||||
OutboundService._assert_scan_allocatable(buy)
|
||||
res = OutboundService._format_scan_result(buy, 'stock_buy', reserved_map)
|
||||
res['price'] = get_price(buy, 'stock_buy')
|
||||
return res
|
||||
|
||||
# 查询维修单表 (按SKU或序列号查询,排除已出库状态)
|
||||
# 查询维修单表 (按SKU或序列号查询)
|
||||
# ★ 维修单不走库存 status 体系,用自身的 repair_status 做同等准入判断:
|
||||
# 已出库(货已交回客户)、报废转出(实物已销毁)都不该再被扫出库。
|
||||
# 原实现只排除 '已出库',报废转出的维修件仍可被扫走。
|
||||
repair = TransRepair.query.filter(
|
||||
or_(TransRepair.sku == clean_code, TransRepair.serial_number == clean_code)
|
||||
).filter(
|
||||
TransRepair.repair_status != '已出库'
|
||||
TransRepair.repair_status.notin_(['已出库', '报废转出'])
|
||||
).first()
|
||||
if repair:
|
||||
res = {
|
||||
@ -134,6 +140,27 @@ class OutboundService:
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _assert_scan_allocatable(item):
|
||||
"""
|
||||
扫码准入校验:状态异常的实物一律拦在扫码环节。
|
||||
|
||||
★ 为什么扫码就拦,而不只在提交时拦(verify_scanned 已有一道):
|
||||
提交时的校验是整单粒度的,报错只说「物料 X 状态异常」,工人得自己
|
||||
在一整单里找是哪一件。扫码即拦能立刻定位到手上这一件。
|
||||
两道校验用同一个 is_allocatable() 判定,语义不会分叉。
|
||||
|
||||
★ 与分配器的关系:分配器只决定"申请时能不能把这行算进候选",
|
||||
而冻结可能发生在「申请已通过、工人正在扫」的窗口内 ——
|
||||
扫码这道是覆盖该窗口的必要补充。
|
||||
"""
|
||||
from app.services.inventory_reservation import is_allocatable
|
||||
|
||||
if not is_allocatable(item):
|
||||
# status 为空时给出可读文案,避免报出 "当前为 " 这种半截话
|
||||
status = (getattr(item, 'status', None) or '').strip() or '未设置'
|
||||
raise ValueError(f"物料状态异常(当前为 {status}),禁止出库")
|
||||
|
||||
@staticmethod
|
||||
def _format_scan_result(item, table_name, reserved_map=None):
|
||||
"""
|
||||
@ -216,21 +243,37 @@ class OutboundService:
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||
|
||||
# ★ 审批单相关逻辑
|
||||
# ==================================================================
|
||||
# ★ 强制按单出库(Fail-Closed):所有出库必须关联有效的出库申请单
|
||||
#
|
||||
# 背景:改造前 request_id 为空时会跳过预占再平衡,落到所谓「散单」
|
||||
# 分支,而该分支的库存扣减逻辑从未落地 —— 旧注释点名的
|
||||
# _apply_reservation_override() 在全仓并不存在。实测后果:散单出库
|
||||
# 只写 TransOutbound 台账,stock_quantity / available_quantity
|
||||
# 分毫不动,同一批货可被反复出库而不减库存。
|
||||
#
|
||||
# 前端已彻底移除散单入口(views/outbound/create.vue 在未选单时把
|
||||
# 扫码框整块 disabled),但 HTTP 接口仍可被直接调用绕过 —— 故在此
|
||||
# 后端硬阻断,防 API 级绕过。
|
||||
#
|
||||
# ★ 强制按单同时保证库存扣减路径唯一:
|
||||
# 申请预占 → 扫码校验 → restore_then_deduct()
|
||||
# status 硬隔离(仅有「在库」可被分配)也依赖这条路径才全程有效。
|
||||
# ==================================================================
|
||||
request_id = data.get('request_id')
|
||||
approval = None
|
||||
if request_id:
|
||||
# 根据 request_id 查询审批单
|
||||
approval = OutboundApproval.query.get(request_id)
|
||||
if not approval:
|
||||
raise ValueError(f"关联的审批单不存在 (ID: {request_id})")
|
||||
if approval.status != 1:
|
||||
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'}
|
||||
current_status = status_map.get(approval.status, str(approval.status))
|
||||
raise ValueError(
|
||||
f"关联的审批单状态不允许出库 (当前状态: {current_status}),"
|
||||
f"仅已通过的审批单方可执行出库"
|
||||
)
|
||||
if not request_id:
|
||||
raise ValueError("非法操作:所有出库必须关联有效的出库申请单")
|
||||
|
||||
approval = OutboundApproval.query.get(request_id)
|
||||
if not approval:
|
||||
raise ValueError(f"关联的审批单不存在 (ID: {request_id})")
|
||||
if approval.status != 1:
|
||||
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'}
|
||||
current_status = status_map.get(approval.status, str(approval.status))
|
||||
raise ValueError(
|
||||
f"关联的审批单状态不允许出库 (当前状态: {current_status}),"
|
||||
f"仅已通过的审批单方可执行出库"
|
||||
)
|
||||
|
||||
model_map = {
|
||||
'stock_buy': StockBuy,
|
||||
@ -242,18 +285,25 @@ class OutboundService:
|
||||
track_notifications = []
|
||||
|
||||
# ==================================================================
|
||||
# ★ Phase 3:预占再平衡(仅针对关联审批单的出库)
|
||||
# ★ Phase 3:预占再平衡 —— 库存扣减的唯一路径
|
||||
#
|
||||
# 关联审批单时,申请阶段已把货预占在「申请时选定的批次」上。
|
||||
# 工人实际扫的可能是同物料的**另一个批次**(物理覆盖),因此这里:
|
||||
# 申请阶段已把货预占在「申请时选定的批次」上。工人实际扫的可能是
|
||||
# 同物料的**另一个批次**(物理覆盖),因此这里:
|
||||
# 1. 校验实扫的身份/数量未超出批准范围(base_id 主键,允许换批次)
|
||||
# 2. 释放全部预占
|
||||
# 3. 对实扫批次扣减 available_quantity 与 stock_quantity
|
||||
# 之后主循环只写 TransOutbound 流水,不再重复扣库存。
|
||||
#
|
||||
# 无关联审批单(散单)时跳过,走原有逐行扣减逻辑。
|
||||
# ★ 本段已改为**无条件执行**:上方强制 request_id 必填,approval 恒非
|
||||
# None。原「无关联审批单(散单)时跳过,走原有逐行扣减逻辑」的分支
|
||||
# 已删除 —— 它正是「散单出库不扣库存」的成因(被跳过的这里就是全仓
|
||||
# 唯一的扣减入口,而所谓的"原有逐行扣减逻辑"并不存在)。
|
||||
# ==================================================================
|
||||
if approval is not None:
|
||||
# ★ 自带 rollback:restore_then_deduct() 先释放预占、后校验可用量,
|
||||
# 中途抛错时 session 已带脏改动。它自身不 commit,若此处不兜底,
|
||||
# 脏状态会一路带到请求结束。校验失败一律整单回滚,单据保持
|
||||
# status=1 可重试(或走撤回/驳回),不会产生半扣留状态。
|
||||
try:
|
||||
from app.services.inventory_reservation import (
|
||||
verify_scanned, restore_then_deduct,
|
||||
)
|
||||
@ -274,6 +324,9 @@ class OutboundService:
|
||||
|
||||
verify_scanned(_scanned, _approved)
|
||||
restore_then_deduct(_scanned, _approved)
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
raise
|
||||
|
||||
try:
|
||||
for item in items:
|
||||
@ -347,10 +400,10 @@ class OutboundService:
|
||||
)
|
||||
db.session.add(new_record)
|
||||
|
||||
# ★ 如果关联了审批单,出库成功后更新审批单状态为"已完成"
|
||||
if approval:
|
||||
approval.status = 3 # 3-已完成
|
||||
# updated_at 会在 commit 时由 SQLAlchemy 自动更新
|
||||
# ★ 出库成功后更新审批单状态为"已完成"
|
||||
# approval 恒非 None(上方已强制 request_id 必填),无需再判空
|
||||
approval.status = 3 # 3-已完成
|
||||
# updated_at 会在 commit 时由 SQLAlchemy 自动更新
|
||||
|
||||
# ★ 先提交事务,释放所有行锁,避免 SMTP 调用延长锁持有时间
|
||||
db.session.commit()
|
||||
@ -767,13 +820,21 @@ class OutboundService:
|
||||
|
||||
grouped_map[ono]['total_amount'] += subtotal
|
||||
|
||||
returned = float(d.returned_quantity or 0)
|
||||
grouped_map[ono]['items'].append({
|
||||
# ★ 退回功能所需:前端「退回」按钮要把本字段回传给
|
||||
# POST /api/v1/inbound/stock/return-from-outbound 的 outbound_id。
|
||||
# 注意它是**出库明细行**的主键,不是出库单号。
|
||||
'id': d.id,
|
||||
'sku': d.sku,
|
||||
'name': item_name,
|
||||
'spec_model': item_spec,
|
||||
'category': item_cat,
|
||||
'material_type': item_type,
|
||||
'quantity': qty,
|
||||
# ★ 退回额度三件套:前端据此显示「已退 / 可退」并置灰已退满的行
|
||||
'returned_quantity': returned,
|
||||
'returnable_quantity': qty - returned,
|
||||
'unit_price': price,
|
||||
'subtotal': subtotal,
|
||||
'batch_sn': batch_sn,
|
||||
|
||||
@ -5,7 +5,6 @@ from sqlalchemy import func
|
||||
|
||||
from app.extensions import db, beijing_time
|
||||
from app.models.scrap_approval import ScrapApproval
|
||||
from app.models.transaction import TransScrap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -30,22 +29,10 @@ def _beijing():
|
||||
# =============================================================================
|
||||
SCRAP_ALWAYS_REQUIRES_APPROVAL = True
|
||||
|
||||
|
||||
STOCK_MODELS = {}
|
||||
|
||||
|
||||
def _stock_models():
|
||||
"""延迟导入三张实物库存表,避免循环依赖"""
|
||||
if not STOCK_MODELS:
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
STOCK_MODELS.update({
|
||||
'stock_buy': StockBuy,
|
||||
'stock_semi': StockSemi,
|
||||
'stock_product': StockProduct,
|
||||
})
|
||||
return STOCK_MODELS
|
||||
# 注:原先此处有 _stock_models() 硬编码三张库存表。来源差异已全部收敛到
|
||||
# app/services/scrap_sources.py 的来源适配层(它复用
|
||||
# inventory_reservation.stock_model_map(),避免第四份重复定义),
|
||||
# 本模块不再直接持有库存表映射。
|
||||
|
||||
|
||||
class ScrapApprovalService:
|
||||
@ -72,44 +59,54 @@ class ScrapApprovalService:
|
||||
if not items:
|
||||
raise ValueError("报废明细不能为空")
|
||||
|
||||
models = _stock_models()
|
||||
# ★ 来源适配:三类来源(库存行 / 在管不良品 / 借出未还)各有不同的
|
||||
# 可报废上限、扣减行为与快照字段,差异全部收敛在 scrap_sources 里。
|
||||
# 原先此处硬编码「只认三张库存表」,导致借出未还与在管不良品只能
|
||||
# 各走直报接口绕过审批。详见 app/services/scrap_sources.py 模块头。
|
||||
from app.services.scrap_sources import get_adapter
|
||||
|
||||
normalized = []
|
||||
for idx, it in enumerate(items):
|
||||
st = (it.get('source_table') or '').strip()
|
||||
model = models.get(st)
|
||||
sid = it.get('stock_id')
|
||||
if not model or not sid:
|
||||
raise ValueError(f"第 {idx + 1} 条报废明细必须指定 source_table 与 stock_id(精准库存行)")
|
||||
adapter = get_adapter(st)
|
||||
if not adapter or not sid:
|
||||
raise ValueError(
|
||||
f"第 {idx + 1} 条报废明细必须指定 source_table 与 stock_id(精准实物)"
|
||||
)
|
||||
try:
|
||||
sid = int(sid)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"第 {idx + 1} 条 stock_id 无效")
|
||||
row = model.query.get(sid)
|
||||
row = adapter.load(sid)
|
||||
if not row:
|
||||
raise ValueError(f"第 {idx + 1} 条对应的库存记录不存在")
|
||||
raise ValueError(f"第 {idx + 1} 条对应的{adapter.label}记录不存在")
|
||||
try:
|
||||
qty = float(it.get('scrap_qty') or 0)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"第 {idx + 1} 条报废数量无效")
|
||||
if qty <= 0:
|
||||
raise ValueError(f"第 {idx + 1} 条报废数量必须大于 0")
|
||||
avail = float(getattr(row, 'available_quantity', 0) or 0)
|
||||
if qty > avail:
|
||||
raise ValueError(f"第 {idx + 1} 条报废数量({qty})超过可用库存({avail})")
|
||||
|
||||
base = getattr(row, 'base', None)
|
||||
normalized.append({
|
||||
'source_table': st,
|
||||
'stock_id': sid,
|
||||
'base_id': getattr(row, 'base_id', None),
|
||||
'sku': getattr(row, 'sku', '') or '',
|
||||
'name': (base.name if base else '') or it.get('name') or '',
|
||||
'spec_model': (base.spec_model if base else '') or it.get('spec_model') or '',
|
||||
'location': getattr(row, 'warehouse_location', '') or '',
|
||||
'batch_number': getattr(row, 'batch_number', '') or getattr(row, 'serial_number', '') or '',
|
||||
'scrap_qty': qty,
|
||||
'available_at_apply': avail,
|
||||
})
|
||||
cap = adapter.cap(row)
|
||||
if qty > cap:
|
||||
raise ValueError(
|
||||
f"第 {idx + 1} 条报废数量({qty})超过{adapter.cap_label}({cap})"
|
||||
)
|
||||
# 提交期的额外约束(如已归还的借用记录不得再报废)
|
||||
adapter.submit_guard(row, qty)
|
||||
|
||||
normalized.append(adapter.snapshot(row, qty, it))
|
||||
|
||||
# ★ 整单执行模式必须一致。
|
||||
# 现有三个提交入口(报废申请页 / 借还记录页 / 不良品看板)各自只提交
|
||||
# 单一来源,UI 上产不出混合单;此处显式拒绝非法构造,让 execute() 的
|
||||
# 分流逻辑保持简单可验证。
|
||||
modes = {n.get('scrap_mode') for n in normalized}
|
||||
if len(modes) > 1:
|
||||
raise ValueError(
|
||||
"同一张报废申请单不能混合「需扫码」与「免扫码」两类来源,请分开提交"
|
||||
)
|
||||
|
||||
# ★ 报废一律需审批(见 SCRAP_ALWAYS_REQUIRES_APPROVAL)。
|
||||
# resolve_approval_control 仍调用,但仅用于生成「哪些物料命中需审批」的提示文案,
|
||||
@ -173,10 +170,19 @@ class ScrapApprovalService:
|
||||
if req.status != 0:
|
||||
raise ValueError("当前状态不允许审批(仅待审批可操作)")
|
||||
|
||||
# 仅被指定的审批人可操作
|
||||
# 仅被指定的审批人可操作。
|
||||
#
|
||||
# ★ Fail-Closed:原实现是 `if user_entries and str(operator_id) not in ...`
|
||||
# —— 当 allowed_approvers 为空(或条目里没有 type='user')时 user_entries
|
||||
# 为空列表,条件短路为假,**任何登录用户都能审批**。这与本模块自陈的
|
||||
# 「报废一律需审批」直接矛盾:留一扇「无审批人则人人可审」的门,
|
||||
# 等于没有审批。现改为无名单即拒绝。
|
||||
# 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。
|
||||
allowed = req.get_allowed_approvers() or []
|
||||
user_entries = [str(a.get('value')) for a in allowed if a.get('type') == 'user']
|
||||
if user_entries and str(operator_id) not in user_entries:
|
||||
if not user_entries:
|
||||
raise ValueError("该申请单未指定审批人,无法审批,请联系管理员处理")
|
||||
if str(operator_id) not in user_entries:
|
||||
raise ValueError("只有被指定的审批人可以审批该申请")
|
||||
|
||||
if action == 'approve':
|
||||
@ -262,20 +268,25 @@ class ScrapApprovalService:
|
||||
@staticmethod
|
||||
def _match_key(source_table, stock_id, sku):
|
||||
"""
|
||||
★ 扫码匹配键 —— SKU 优先。
|
||||
★ 扫码匹配键 —— SKU 优先,**且必须带来源表**。
|
||||
|
||||
已核验:SKU 在同一库存表内唯一,且不存在跨表重名(stock_buy /
|
||||
stock_semi / stock_product 三表交叉无冲突),故 SKU 可安全作为
|
||||
跨来源的稳定标识。
|
||||
原实现返回 ('sku', s),其前提是「SKU 在三张库存表内唯一且跨表无重名」。
|
||||
引入逆向物流来源后这个前提不再成立:在管不良品台账的 SKU 是从
|
||||
原库存行**复制**的(退回时 sku=getattr(stock_row, 'sku', '')),
|
||||
故 `trans_defective_goods#N` 与其源 `stock_buy#M` 的 SKU **必然相同**。
|
||||
若同一张报废单同时含两者,不带来源就会串键 —— 扫码量会算到错误对象上。
|
||||
|
||||
例外:个别历史库存行的 SKU 为空,这类行无法用 SKU 标识,回退为
|
||||
source_table + stock_id 复合键。前缀区分('sku:' / 'row:')保证
|
||||
空 SKU 行绝不会与任何正常 SKU 串键。
|
||||
加入 source_table 后,纯库存单两侧同源、键仍匹配(对既有流程零行为
|
||||
变更),而跨来源的同 SKU 行彻底隔离。
|
||||
|
||||
空 SKU 的历史行仍回退为 row 复合键,两种键的首元素不同
|
||||
('sku' / 'row'),不会互相串键。
|
||||
"""
|
||||
st = (source_table or '').strip()
|
||||
s = ScrapApprovalService._norm_sku(sku)
|
||||
if s:
|
||||
return ('sku', s)
|
||||
return ('row', f"{source_table}#{stock_id}")
|
||||
return ('sku', st, s)
|
||||
return ('row', f"{st}#{stock_id}")
|
||||
|
||||
@staticmethod
|
||||
def _build_approved_index(items):
|
||||
@ -305,8 +316,10 @@ class ScrapApprovalService:
|
||||
return index
|
||||
|
||||
@staticmethod
|
||||
def _build_scanned_index(scanned_items, models):
|
||||
def _build_scanned_index(scanned_items):
|
||||
"""前端实扫明细 → {匹配键: 累计扫码数量}"""
|
||||
from app.services.scrap_sources import is_scan_source
|
||||
|
||||
index = {}
|
||||
for idx, s in enumerate(scanned_items):
|
||||
sku = ScrapApprovalService._norm_sku(s.get('sku'))
|
||||
@ -314,8 +327,14 @@ class ScrapApprovalService:
|
||||
sid = ScrapApprovalService._to_int(s.get('stock_id'))
|
||||
if not sku and (not st or sid is None):
|
||||
raise ValueError(f"第 {idx + 1} 条扫码明细缺少 SKU,且无有效的 source_table / stock_id")
|
||||
if st and st not in models:
|
||||
raise ValueError(f"第 {idx + 1} 条扫码来源不支持:{st}")
|
||||
# ★ 语义收窄而非放宽:扫码通道只接纳明确声明为 scan 的来源。
|
||||
# 免扫码来源(借出未还)与未知来源一律拒绝 —— 后者能挡住
|
||||
# 「扫码扫得到、执行却拒绝」的历史错配(trans_repair 曾如此)。
|
||||
if st and not is_scan_source(st):
|
||||
raise ValueError(
|
||||
f"第 {idx + 1} 条扫码来源不支持扫码提交:{st}"
|
||||
f"(该来源为免扫码执行来源,或来源非法)"
|
||||
)
|
||||
qty = float(s.get('quantity') or 0)
|
||||
if qty <= 0:
|
||||
raise ValueError(f"第 {idx + 1} 条扫码数量必须大于 0")
|
||||
@ -330,15 +349,24 @@ class ScrapApprovalService:
|
||||
@staticmethod
|
||||
def execute(request_id, operator_name='System', scanned_items=None):
|
||||
"""
|
||||
按单执行报废:以「实际扫码明细」为准,按 SKU 匹配批准明细后扣减库存。
|
||||
按单执行报废:按来源的执行模式分流处理。
|
||||
|
||||
scanned_items: [{'sku', 'quantity', 'source_table', 'stock_id'(可选,空 SKU 时必填)}]
|
||||
· 以 SKU 为主校验键:扫码 SKU 必须在申请单 items_json 中存在;
|
||||
· 同一 SKU 多次扫码累加,累计不得超过该 SKU 的批准总量;
|
||||
· 允许合法子集(少扫 = 本次不报废该行);
|
||||
· 扣减时以批准单配对的 source_table + stock_id 定位库存行加锁。
|
||||
仅承载「实扫到的实物」。免扫码来源(借出未还)**不经过**这里,
|
||||
由后端按批准量直接执行 —— 调用契约与改造前完全一致。
|
||||
|
||||
scan 来源(库存行 / 在管不良品):
|
||||
· 以 SKU + 来源表为主校验键(来源表用于隔离同 SKU 的跨来源行);
|
||||
· 同一键多次扫码累加,累计不得超过批准总量;
|
||||
· 允许合法子集(少扫 = 本次不报废该行)。
|
||||
|
||||
auto 来源(借出未还):
|
||||
· 按 items_json 中的 scrap_qty 全量执行,不参与扫码匹配。
|
||||
"""
|
||||
models = _stock_models()
|
||||
from app.services.scrap_sources import (
|
||||
get_adapter, SCRAP_MODE_AUTO, SCRAP_MODE_SCAN,
|
||||
)
|
||||
|
||||
req = db.session.get(ScrapApproval, request_id)
|
||||
if not req:
|
||||
raise ValueError("报废申请不存在")
|
||||
@ -349,29 +377,54 @@ class ScrapApprovalService:
|
||||
if not approved_items:
|
||||
raise ValueError("报废明细为空,无法执行")
|
||||
|
||||
# ★ 强制按单扫码:未提交实扫明细不允许执行
|
||||
if not scanned_items:
|
||||
# ★ 按执行模式分流。存量单据(全部是库存来源)没有 scrap_mode 字段,
|
||||
# 回落到适配器声明的模式 = scan,行为与改造前逐字一致。
|
||||
def _mode_of(item):
|
||||
mode = (item.get('scrap_mode') or '').strip()
|
||||
if mode in (SCRAP_MODE_SCAN, SCRAP_MODE_AUTO):
|
||||
return mode
|
||||
adapter = get_adapter(item.get('source_table'))
|
||||
return adapter.scrap_mode if adapter else SCRAP_MODE_SCAN
|
||||
|
||||
scan_items = [it for it in approved_items if _mode_of(it) == SCRAP_MODE_SCAN]
|
||||
auto_items = [it for it in approved_items if _mode_of(it) == SCRAP_MODE_AUTO]
|
||||
|
||||
# ★ 扫码仅在**本单存在需扫码明细**时强制。
|
||||
# 纯免扫码单(借出未还)允许 scanned_items 为空 —— 实物在借用人
|
||||
# 手上,要求扫码在物理上不可能。
|
||||
if scan_items and not scanned_items:
|
||||
raise ValueError("请先扫码并提交实际报废物料,再执行报废")
|
||||
|
||||
approved = ScrapApprovalService._build_approved_index(approved_items)
|
||||
scanned = ScrapApprovalService._build_scanned_index(scanned_items, models)
|
||||
approved = {}
|
||||
scanned = {}
|
||||
if scan_items:
|
||||
# 索引只由 scan 项构建:auto 项不参与匹配,避免同 SKU 串键
|
||||
approved = ScrapApprovalService._build_approved_index(scan_items)
|
||||
scanned = ScrapApprovalService._build_scanned_index(scanned_items)
|
||||
|
||||
# ★ 校验一:扫码 SKU 必须在批准明细内
|
||||
def _adapter_for(st):
|
||||
adapter = get_adapter(st)
|
||||
if adapter is None:
|
||||
raise ValueError(f"报废来源不支持:{st or '(空)'}")
|
||||
return adapter
|
||||
|
||||
# ★ 校验一:扫码物料必须在批准明细内
|
||||
for key, acc in scanned.items():
|
||||
if key not in approved:
|
||||
raise ValueError(
|
||||
f"SKU【{acc['label']}】不在该报废申请单的批准明细中(SKU 不匹配),禁止报废"
|
||||
f"物料【{acc['label']}】不在该报废申请单的批准明细中(SKU 不匹配),禁止报废"
|
||||
)
|
||||
|
||||
# ★ 校验二:同一 SKU 的累计扫码量不得超过批准总量
|
||||
# ★ 校验二:同一物料的累计扫码量不得超过批准总量
|
||||
for key, acc in scanned.items():
|
||||
appr = approved[key]
|
||||
if acc['qty'] > appr['qty']:
|
||||
raise ValueError(
|
||||
f"SKU【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废"
|
||||
f"物料【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废"
|
||||
)
|
||||
|
||||
# ★ 扣减:按批准单配对的 source_table + stock_id 定位库存行,逐行加锁扣减
|
||||
# ★ scan 项扣减:按批准单配对的 source_table + stock_id 定位实物,逐行加锁扣减。
|
||||
# 扣减行为由来源适配器自持(库存行双扣 / 在管不良品只动台账)。
|
||||
for key, acc in scanned.items():
|
||||
appr = approved[key]
|
||||
remaining = acc['qty']
|
||||
@ -383,38 +436,22 @@ class ScrapApprovalService:
|
||||
if take <= 0:
|
||||
continue
|
||||
|
||||
st, sid = ref['source_table'], ref['stock_id']
|
||||
row = models[st].query.with_for_update().get(sid)
|
||||
if not row:
|
||||
raise ValueError(f"库存记录已不存在({acc['label']})")
|
||||
|
||||
avail = float(getattr(row, 'available_quantity', 0) or 0)
|
||||
stock = float(getattr(row, 'stock_quantity', 0) or 0)
|
||||
if take > avail:
|
||||
raise ValueError(f"库存 SKU【{acc['label']}】可用不足(剩 {avail}),无法报废 {take}")
|
||||
if take > stock:
|
||||
raise ValueError(f"库存 SKU【{acc['label']}】实物不足(剩 {stock}),无法报废 {take}")
|
||||
|
||||
# ★ 真正扣减:报废 = 实物销毁,实物库存与可用库存需同时扣减
|
||||
row.available_quantity = avail - take
|
||||
row.stock_quantity = stock - take
|
||||
db.session.flush()
|
||||
|
||||
# 写报废流水(台账)
|
||||
db.session.add(TransScrap(
|
||||
sku=getattr(row, 'sku', '') or acc['label'],
|
||||
source_table=st,
|
||||
stock_id=sid,
|
||||
quantity=take,
|
||||
reason=req.remark or '',
|
||||
operator_name=operator_name,
|
||||
approver_name=ScrapApproval._user_name(req.actual_approver_id),
|
||||
approval_status='executed',
|
||||
scrap_request_no=req.request_no,
|
||||
))
|
||||
|
||||
_adapter_for(ref['source_table']).deduct(
|
||||
ref['stock_id'], take, req, operator_name,
|
||||
)
|
||||
remaining -= take
|
||||
|
||||
# ★ auto 项扣减:免扫码来源按批准量全量执行。
|
||||
# 不参与上面的扫码匹配 —— 若把它塞进同一个索引,同 SKU 会与
|
||||
# scan 项串键,扫码量被算到错误对象上。
|
||||
for it in auto_items:
|
||||
qty = float(it.get('scrap_qty') or 0)
|
||||
if qty <= 0:
|
||||
continue
|
||||
_adapter_for(it.get('source_table')).deduct(
|
||||
it.get('stock_id'), qty, req, operator_name,
|
||||
)
|
||||
|
||||
req.status = 3
|
||||
req.executed_at = _beijing()
|
||||
req.executor_name = operator_name
|
||||
|
||||
470
inventory-backend/app/services/scrap_sources.py
Normal file
470
inventory-backend/app/services/scrap_sources.py
Normal file
@ -0,0 +1,470 @@
|
||||
"""
|
||||
报废来源适配层 —— 统一三类报废来源的解析、校验、快照与扣减。
|
||||
|
||||
背景
|
||||
----
|
||||
报废审批流(ScrapApprovalService)原先只认三张库存表,导致「借出未还」与
|
||||
「在管不良品」两类来源只能各自走直报接口绕过审批 —— 与系统自陈的
|
||||
「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL)规则冲突,构成职责分离
|
||||
漏洞:同一个库管可自行宣告实物销毁而无人复核。
|
||||
|
||||
本模块把「来源差异」收敛到适配器,审批服务不必再关心来源细节。
|
||||
|
||||
执行模式
|
||||
--------
|
||||
scan —— 需扫码执行。实物在仓库内、有可扫标识,扫码能防「申请报 A、实际毁 B」。
|
||||
auto —— 按批准量执行。实物不在库,物理上无法扫码。
|
||||
|
||||
★ 为什么「在管不良品」保留扫码:坏件就在仓库里、有 SKU,扫码是有效的
|
||||
实物在场核验,且成本极低。
|
||||
★ 为什么「借出未还」免扫码:东西在借用人手上,不可能扫到;且该来源执行
|
||||
只改台账与总库存,**不产生任何可被挪用的可用库存**,风险等级不同量级。
|
||||
残余风险(执行人可能在未实际销毁时点「执行」)由 scrap_execute 权限与
|
||||
audit_logs 留痕兜底。
|
||||
|
||||
多租户
|
||||
------
|
||||
库存行与借出记录经查询层隔离;在管不良品用**台账自带的 company_name 快照**,
|
||||
刻意不联表 MaterialBase —— 坏件的原库存行可能已被入库模块物理删除。
|
||||
"""
|
||||
import logging
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 执行模式
|
||||
SCRAP_MODE_SCAN = 'scan' # 需扫码执行
|
||||
SCRAP_MODE_AUTO = 'auto' # 按批准量执行
|
||||
|
||||
|
||||
def _stock_model_map():
|
||||
"""复用 inventory_reservation 的库存表映射,避免第四份重复定义。"""
|
||||
from app.services.inventory_reservation import stock_model_map
|
||||
return stock_model_map()
|
||||
|
||||
|
||||
def defective_unit_cost(goods):
|
||||
"""
|
||||
取坏件单价,用于报废台账的 cost_at_scrap / total_loss(best-effort)。
|
||||
|
||||
取价口径与既有报废模块一致:成品取 sale_price,采购件取 pre_tax_unit_price,
|
||||
半成品无价(返回 0)。
|
||||
|
||||
★ 原库存行可能已被删除(入库模块会物理删除库存行),故取不到时返回 0。
|
||||
刻意**不**因缺行而中断报废:实物已经销毁,台账必须先记上,
|
||||
成本缺失是可接受的降级,记录丢失不是。
|
||||
"""
|
||||
model = _stock_model_map().get(goods.source_table)
|
||||
if model is None or not goods.stock_id:
|
||||
return 0.0
|
||||
row = model.query.get(goods.stock_id)
|
||||
if not row:
|
||||
return 0.0
|
||||
if goods.source_table == 'stock_product':
|
||||
return float(getattr(row, 'sale_price', 0) or 0)
|
||||
if goods.source_table == 'stock_buy':
|
||||
return float(getattr(row, 'pre_tax_unit_price', 0) or 0)
|
||||
return 0.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 基类
|
||||
# =============================================================================
|
||||
|
||||
class ScrapSourceAdapter:
|
||||
"""
|
||||
报废来源适配器基类。
|
||||
|
||||
子类必须提供 source_table / scrap_mode / label / cap_label 四个类属性,
|
||||
并实现 load / cap / snapshot / deduct。
|
||||
"""
|
||||
|
||||
source_table = ''
|
||||
scrap_mode = SCRAP_MODE_SCAN
|
||||
label = '' # 中文来源名,用于报表与错误文案
|
||||
cap_label = '可报废量' # 上限的中文说法,用于错误文案
|
||||
|
||||
# --- 提交阶段(不加锁,乐观读)---
|
||||
def load(self, sid):
|
||||
"""按主键取来源行;不存在返回 None。"""
|
||||
raise NotImplementedError
|
||||
|
||||
def cap(self, row):
|
||||
"""该来源当前的可报废上限。"""
|
||||
raise NotImplementedError
|
||||
|
||||
def submit_guard(self, row, qty):
|
||||
"""
|
||||
提交期的额外校验(symmetry guard)。
|
||||
|
||||
★ 存在的意义:提交与执行的校验必须对称,否则会出现「申请能过、
|
||||
执行必失败」的单据。默认无额外约束,子类按需覆盖。
|
||||
"""
|
||||
return None
|
||||
|
||||
def snapshot(self, row, qty, raw):
|
||||
"""产出写入 items_json 的明细快照。"""
|
||||
raise NotImplementedError
|
||||
|
||||
# --- 执行阶段(加锁 + 扣减 + 写台账)---
|
||||
def deduct(self, row_id, qty, req, operator_name):
|
||||
"""加锁重取 → 二次校验 → 扣减 → 写 TransScrap。不满足即抛 ValueError。"""
|
||||
raise NotImplementedError
|
||||
|
||||
# --- 台账公共字段 ---
|
||||
@staticmethod
|
||||
def _ledger_kwargs(req, operator_name):
|
||||
from app.models.scrap_approval import ScrapApproval
|
||||
return {
|
||||
'reason': req.remark or '',
|
||||
'operator_name': operator_name,
|
||||
'approver_name': ScrapApproval._user_name(req.actual_approver_id),
|
||||
'approval_status': 'executed',
|
||||
'scrap_request_no': req.request_no,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 一类:库存行(三张库存表)
|
||||
# =============================================================================
|
||||
|
||||
class StockRowAdapter(ScrapSourceAdapter):
|
||||
"""
|
||||
常规库存行来源。语义与改造前的 execute() 逐字一致。
|
||||
|
||||
扣减:available_quantity 与 stock_quantity **同时扣**(报废 = 实物销毁)。
|
||||
扫码:必需 —— 库存行是同质可替换物,在库内处于执行人物理控制之下,
|
||||
存在以次充好、批次腾挪的空间,扫码把「批准批次」与「实毁批次」钉死。
|
||||
"""
|
||||
|
||||
scrap_mode = SCRAP_MODE_SCAN
|
||||
cap_label = '可用库存'
|
||||
|
||||
_LABELS = {
|
||||
'stock_buy': '采购件',
|
||||
'stock_semi': '半成品',
|
||||
'stock_product': '成品',
|
||||
}
|
||||
|
||||
def __init__(self, source_table, model):
|
||||
self.source_table = source_table
|
||||
self.model = model
|
||||
self.label = self._LABELS.get(source_table, source_table)
|
||||
|
||||
def load(self, sid):
|
||||
return self.model.query.get(sid)
|
||||
|
||||
def cap(self, row):
|
||||
return float(getattr(row, 'available_quantity', 0) or 0)
|
||||
|
||||
def submit_guard(self, row, qty):
|
||||
# ★ 对称性修复:旧代码提交只校验 available_quantity,而执行同时校验
|
||||
# available 与 stock_quantity —— 会出现「申请通过、执行必然失败」。
|
||||
# 此处提前拦下。
|
||||
stock = float(getattr(row, 'stock_quantity', 0) or 0)
|
||||
if qty > stock:
|
||||
raise ValueError(f"报废数量({qty})超过实物库存({stock})")
|
||||
|
||||
def snapshot(self, row, qty, raw):
|
||||
base = getattr(row, 'base', None)
|
||||
return {
|
||||
'source_table': self.source_table,
|
||||
'stock_id': row.id,
|
||||
'base_id': getattr(row, 'base_id', None),
|
||||
'sku': getattr(row, 'sku', '') or '',
|
||||
'name': (base.name if base else '') or raw.get('name') or '',
|
||||
'spec_model': (base.spec_model if base else '') or raw.get('spec_model') or '',
|
||||
'location': getattr(row, 'warehouse_location', '') or '',
|
||||
'batch_number': (getattr(row, 'batch_number', '')
|
||||
or getattr(row, 'serial_number', '') or ''),
|
||||
'scrap_qty': qty,
|
||||
'available_at_apply': self.cap(row),
|
||||
'scrap_mode': self.scrap_mode,
|
||||
}
|
||||
|
||||
def deduct(self, row_id, qty, req, operator_name):
|
||||
from app.models.transaction import TransScrap
|
||||
|
||||
row = self.model.query.with_for_update().get(row_id)
|
||||
if not row:
|
||||
raise ValueError(f"库存记录已不存在({self.source_table}#{row_id})")
|
||||
|
||||
avail = float(getattr(row, 'available_quantity', 0) or 0)
|
||||
stock = float(getattr(row, 'stock_quantity', 0) or 0)
|
||||
label = getattr(row, 'sku', '') or f"{self.source_table}#{row_id}"
|
||||
if qty > avail:
|
||||
raise ValueError(f"库存 SKU【{label}】可用不足(剩 {avail}),无法报废 {qty}")
|
||||
if qty > stock:
|
||||
raise ValueError(f"库存 SKU【{label}】实物不足(剩 {stock}),无法报废 {qty}")
|
||||
|
||||
# 报废 = 实物销毁:实物数与可用数同时扣减
|
||||
row.available_quantity = avail - qty
|
||||
row.stock_quantity = stock - qty
|
||||
db.session.flush()
|
||||
|
||||
db.session.add(TransScrap(
|
||||
sku=getattr(row, 'sku', '') or label,
|
||||
source_table=self.source_table,
|
||||
stock_id=row_id,
|
||||
quantity=qty,
|
||||
**self._ledger_kwargs(req, operator_name),
|
||||
))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 二类:在管不良品台账
|
||||
# =============================================================================
|
||||
|
||||
class DefectiveScrapAdapter(ScrapSourceAdapter):
|
||||
"""
|
||||
在管不良品来源(逆向物流)。
|
||||
|
||||
扣减:remaining_qty -=、scrapped_qty +=、推进状态机;**完全不动任何库存表**
|
||||
—— 坏件从未进入库存表,这是本次逆向物流的核心架构决策。
|
||||
扫码:必需(业务方决策)—— 坏件实物在仓、有 SKU,扫码是有效核验。
|
||||
成本:按 defective_unit_cost 取价,保持改造前直报接口的口径。
|
||||
"""
|
||||
|
||||
source_table = 'trans_defective_goods'
|
||||
scrap_mode = SCRAP_MODE_SCAN
|
||||
label = '在管不良品'
|
||||
cap_label = '在管数量'
|
||||
|
||||
def load(self, sid):
|
||||
from app.models.transaction import TransDefectiveGoods
|
||||
return TransDefectiveGoods.query.get(sid)
|
||||
|
||||
def cap(self, row):
|
||||
return float(getattr(row, 'remaining_qty', 0) or 0)
|
||||
|
||||
def snapshot(self, row, qty, raw):
|
||||
# 物料名/规格取自台账自身的冗余快照,**不联表 MaterialBase** ——
|
||||
# 原库存行可能已被物理删除,联表会取到空值。
|
||||
return {
|
||||
'source_table': self.source_table,
|
||||
'stock_id': row.id,
|
||||
'base_id': getattr(row, 'base_id', None),
|
||||
'sku': getattr(row, 'sku', '') or '',
|
||||
'name': getattr(row, 'material_name', '') or raw.get('name') or '',
|
||||
'spec_model': getattr(row, 'spec_model', '') or raw.get('spec_model') or '',
|
||||
'location': '', # 台账无库位字段
|
||||
'batch_number': '',
|
||||
'scrap_qty': qty,
|
||||
'available_at_apply': self.cap(row),
|
||||
'scrap_mode': self.scrap_mode,
|
||||
}
|
||||
|
||||
def deduct(self, row_id, qty, req, operator_name):
|
||||
from app.models.transaction import (
|
||||
TransScrap, TransDefectiveGoods, OPEN_DEFECTIVE_STATUSES,
|
||||
DEFECTIVE_STATUS_IN_PROGRESS, defective_close_status,
|
||||
)
|
||||
|
||||
goods = TransDefectiveGoods.query.with_for_update().get(row_id)
|
||||
if not goods:
|
||||
raise ValueError(f"不良品在管记录已不存在(#{row_id})")
|
||||
|
||||
# 状态守门(Fail-Closed):终态不得再处置
|
||||
if goods.status not in OPEN_DEFECTIVE_STATUSES:
|
||||
raise ValueError(
|
||||
f"在管不良品【{goods.sku or row_id}】当前状态为「{goods.status}」,不可报废"
|
||||
)
|
||||
|
||||
remaining = float(goods.remaining_qty or 0)
|
||||
if qty > remaining:
|
||||
# ★ Fail-Closed:批准后可能被另一张单先报废、或已部分回库,
|
||||
# 此时静默夹到剩余量会让单据状态(已执行)与实际扣减不符,
|
||||
# 审计上不可接受。整单报错,由申请人撤回后重提。
|
||||
raise ValueError(
|
||||
f"在管不良品【{goods.sku or row_id}】在管量不足"
|
||||
f"(剩 {remaining}),无法报废 {qty}"
|
||||
)
|
||||
|
||||
new_remaining = remaining - qty
|
||||
goods.remaining_qty = new_remaining
|
||||
goods.scrapped_qty = float(goods.scrapped_qty or 0) + qty
|
||||
# 终态由「累计去向」推导而非「最后一次动作」—— 一批坏件可能既回库过
|
||||
# 又报废过,按最后动作定状态会产生误导(见 defective_close_status)
|
||||
goods.status = (
|
||||
defective_close_status(goods.restocked_qty, goods.scrapped_qty)
|
||||
if new_remaining <= 0 else DEFECTIVE_STATUS_IN_PROGRESS
|
||||
)
|
||||
db.session.flush()
|
||||
|
||||
unit_cost = defective_unit_cost(goods)
|
||||
db.session.add(TransScrap(
|
||||
sku=goods.sku or '',
|
||||
source_table=self.source_table,
|
||||
stock_id=goods.id,
|
||||
quantity=qty,
|
||||
cost_at_scrap=unit_cost,
|
||||
total_loss=round(unit_cost * qty, 2),
|
||||
**self._ledger_kwargs(req, operator_name),
|
||||
))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 三类:借出未还(借库转报废)
|
||||
# =============================================================================
|
||||
|
||||
class BorrowScrapAdapter(ScrapSourceAdapter):
|
||||
"""
|
||||
借出未还来源(借库转报废)。
|
||||
|
||||
扣减:标记借用记录为已报废;内层库存行**只扣 stock_quantity**,不动
|
||||
available_quantity —— 可用量已在借出时冻结(见
|
||||
trans_service.execute_dispatch 的 deduct_stock=False 及其注释)。
|
||||
扫码:免扫码(业务方决策,理由见模块头)。
|
||||
成本:0/0 —— 与改造前 scrap_borrow 口径一致。
|
||||
"""
|
||||
|
||||
source_table = 'trans_borrow'
|
||||
scrap_mode = SCRAP_MODE_AUTO
|
||||
label = '借出未还'
|
||||
cap_label = '待还数量'
|
||||
|
||||
def load(self, sid):
|
||||
from app.models.transaction import TransBorrow
|
||||
return TransBorrow.query.get(sid)
|
||||
|
||||
def cap(self, row):
|
||||
return (float(getattr(row, 'quantity', 0) or 0)
|
||||
- float(getattr(row, 'returned_quantity', 0) or 0))
|
||||
|
||||
def submit_guard(self, row, qty):
|
||||
# 已归还/已报废的借用记录不得再报废。旧实现是静默 continue 并返回
|
||||
# count=0(缺陷:用户以为成功),这里改为明确报错。
|
||||
if getattr(row, 'is_returned', False):
|
||||
raise ValueError(
|
||||
f"借用记录【{getattr(row, 'borrow_no', '')}】已归还或已报废,不可再报废"
|
||||
)
|
||||
|
||||
def snapshot(self, row, qty, raw):
|
||||
name, spec = self._resolve_material(row)
|
||||
return {
|
||||
'source_table': self.source_table,
|
||||
'stock_id': row.id, # ★ 存 TransBorrow.id,与既有约定一致
|
||||
'base_id': None,
|
||||
'sku': getattr(row, 'sku', '') or '',
|
||||
'name': name or raw.get('name') or '',
|
||||
'spec_model': spec or raw.get('spec_model') or '',
|
||||
'location': getattr(row, 'location', '') or '',
|
||||
'batch_number': getattr(row, 'barcode', '') or '',
|
||||
'scrap_qty': qty,
|
||||
'available_at_apply': self.cap(row),
|
||||
'scrap_mode': self.scrap_mode,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_material(record):
|
||||
"""经借用记录回查其源库存行取物料名/规格;行已删除时返回空串。"""
|
||||
model = _stock_model_map().get(getattr(record, 'source_table', ''))
|
||||
if model is None or not getattr(record, 'stock_id', None):
|
||||
return '', ''
|
||||
row = model.query.get(record.stock_id)
|
||||
base = getattr(row, 'base', None) if row else None
|
||||
return ((base.name if base else '') or '',
|
||||
(base.spec_model if base else '') or '')
|
||||
|
||||
def deduct(self, row_id, qty, req, operator_name):
|
||||
from datetime import datetime
|
||||
from app.models.transaction import TransScrap, TransBorrow
|
||||
|
||||
record = TransBorrow.query.with_for_update().get(row_id)
|
||||
if not record:
|
||||
raise ValueError(f"借用记录已不存在(#{row_id})")
|
||||
if record.is_returned:
|
||||
raise ValueError(
|
||||
f"借用记录【{record.borrow_no}】已归还或已报废,不可再报废"
|
||||
)
|
||||
|
||||
pending = (float(record.quantity or 0)
|
||||
- float(record.returned_quantity or 0))
|
||||
if qty > pending:
|
||||
# Fail-Closed:批准后可能已被部分归还,同不良品来源的理由
|
||||
raise ValueError(
|
||||
f"借用记录【{record.borrow_no}】待还量不足(剩 {pending}),无法报废 {qty}"
|
||||
)
|
||||
|
||||
# 1) 标记借用记录:不再追讨归还
|
||||
record.is_returned = True
|
||||
record.status = 'scrapped'
|
||||
record.return_time = datetime.now()
|
||||
record.return_operator = operator_name
|
||||
|
||||
# 2) 扣总库存(该物品确认损失);可用量已在借出时冻结,不重复扣
|
||||
model = _stock_model_map().get(record.source_table)
|
||||
if model is not None and record.stock_id:
|
||||
stock = model.query.with_for_update().get(record.stock_id)
|
||||
if stock:
|
||||
stock_qty = float(stock.stock_quantity or 0)
|
||||
if qty > stock_qty:
|
||||
raise ValueError(
|
||||
f"SKU {record.sku} 实物库存不足(剩 {stock_qty}),无法报废 {qty}"
|
||||
)
|
||||
stock.stock_quantity = stock_qty - qty
|
||||
else:
|
||||
# 源库存行已被物理删除:损失无法落在库存账上。
|
||||
# 刻意不中断 —— 实物已确认无法归还,台账必须先记上;
|
||||
# 但显式告警,避免这种「账上无痕」的损失悄无声息。
|
||||
logger.warning(
|
||||
"[报废] 借出记录 #%s 的源库存行已不存在(%s#%s),"
|
||||
"本次报废未在库存账上体现",
|
||||
row_id, record.source_table, record.stock_id,
|
||||
)
|
||||
db.session.flush()
|
||||
|
||||
# 3) 写报废台账。成本口径保持 0/0(与改造前 scrap_borrow 一致)
|
||||
db.session.add(TransScrap(
|
||||
sku=record.sku or '',
|
||||
source_table=self.source_table,
|
||||
stock_id=record.id,
|
||||
quantity=qty,
|
||||
cost_at_scrap=0,
|
||||
total_loss=0,
|
||||
**self._ledger_kwargs(req, operator_name),
|
||||
))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 注册表
|
||||
# =============================================================================
|
||||
|
||||
def _build_registry():
|
||||
models = _stock_model_map()
|
||||
registry = {
|
||||
st: StockRowAdapter(st, model) for st, model in models.items()
|
||||
}
|
||||
registry[DefectiveScrapAdapter.source_table] = DefectiveScrapAdapter()
|
||||
registry[BorrowScrapAdapter.source_table] = BorrowScrapAdapter()
|
||||
return registry
|
||||
|
||||
|
||||
_REGISTRY = None
|
||||
|
||||
|
||||
def get_adapter(source_table):
|
||||
"""按来源表名取适配器;不支持则返回 None。"""
|
||||
global _REGISTRY
|
||||
if _REGISTRY is None:
|
||||
_REGISTRY = _build_registry()
|
||||
return _REGISTRY.get((source_table or '').strip())
|
||||
|
||||
|
||||
def all_source_tables():
|
||||
global _REGISTRY
|
||||
if _REGISTRY is None:
|
||||
_REGISTRY = _build_registry()
|
||||
return tuple(_REGISTRY.keys())
|
||||
|
||||
|
||||
def is_scan_source(source_table):
|
||||
"""
|
||||
该来源是否走扫码执行。
|
||||
|
||||
★ 未知来源一律返回 False —— execute 的扫码索引只接纳明确声明的 scan 来源,
|
||||
避免历史上 trans_repair 那类「扫码能扫到、执行却拒绝」的错配重演。
|
||||
"""
|
||||
adapter = get_adapter(source_table)
|
||||
return bool(adapter and adapter.scrap_mode == SCRAP_MODE_SCAN)
|
||||
@ -96,10 +96,12 @@ class TransService:
|
||||
# 下方主循环只写 TransBorrow 流水,不再重复扣库存。
|
||||
#
|
||||
# ★ deduct_stock=False:借出是**可逆的**(会归还),只冻结可用数。
|
||||
# 若此处扣了实物,会与两处现有实现冲突:
|
||||
# 若此处扣了实物,会与两处冲突:
|
||||
# · 归还(process_return)只加 available —— 一借一还后 stock 永久少一份;
|
||||
# · 借库转报废(scrap_borrow)在确认损失时扣 stock,其注释明确假设
|
||||
# 「可用库存已在借出时冻结」—— 若借出已扣会重复扣减。
|
||||
# · 借库转报废在确认损失时扣 stock,其实现明确假设「可用库存已在
|
||||
# 借出时冻结」—— 若借出已扣会重复扣减。
|
||||
# (该实现原为 TransService.scrap_borrow,现已迁入
|
||||
# app/services/scrap_sources.py 的 BorrowScrapAdapter.deduct)
|
||||
# ==============================================================
|
||||
# ★ 防线 2.5(Fail-Closed):实扫明细的 source_table 必须全部可识别。
|
||||
#
|
||||
@ -357,90 +359,6 @@ class TransService:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def scrap_borrow(record_ids, operator_name='System', reason=''):
|
||||
"""
|
||||
借库未归还直接报废(关联报废单流程)
|
||||
|
||||
适用场景:借出的物品确认丢失/损坏/无法归还,库管/主管手动报废。
|
||||
- 该笔借用不再追讨归还,从借还记录中标记为已报废
|
||||
- 生成一条 TransScrap 报废记录(关联 trans_borrow)
|
||||
- 总库存 stock_quantity 扣减(该物品确认损失);可用库存已在借出时冻结,无需重复扣
|
||||
|
||||
Args:
|
||||
record_ids: TransBorrow 记录ID列表
|
||||
operator_name: 操作人
|
||||
reason: 报废原因
|
||||
|
||||
Returns:
|
||||
{count: 报废条数}
|
||||
"""
|
||||
from app.models.transaction import TransScrap
|
||||
|
||||
if not record_ids:
|
||||
raise ValueError('请选择要报废的借出记录')
|
||||
if not reason:
|
||||
raise ValueError('请填写报废原因')
|
||||
|
||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||
created_records = []
|
||||
scraped_count = 0
|
||||
|
||||
try:
|
||||
for rid in record_ids:
|
||||
record = TransBorrow.query.with_for_update().get(rid)
|
||||
if not record:
|
||||
continue
|
||||
# 仅未归还的借用可报废
|
||||
if record.is_returned:
|
||||
continue
|
||||
pending_qty = float(record.quantity) - float(record.returned_quantity or 0)
|
||||
if pending_qty <= 0:
|
||||
continue
|
||||
|
||||
# 1. 标记借用记录为已报废(不再追讨归还)
|
||||
record.is_returned = True
|
||||
record.status = 'scrapped'
|
||||
record.return_time = datetime.now()
|
||||
record.return_operator = operator_name
|
||||
|
||||
# 2. 扣减总库存(该物品确认损失);可用库存已在借出时冻结
|
||||
ModelClass = model_map.get(record.source_table)
|
||||
if ModelClass:
|
||||
stock = ModelClass.query.with_for_update().get(record.stock_id)
|
||||
if stock:
|
||||
stock_qty = float(stock.stock_quantity or 0)
|
||||
# 不足则报错整单回滚:直接夹到 0 会让 available_quantity > stock_quantity,
|
||||
# 凭空多出可用库存
|
||||
if pending_qty > stock_qty:
|
||||
raise ValueError(
|
||||
f"SKU {record.sku} 实物库存不足(剩 {stock_qty}),无法报废 {pending_qty}"
|
||||
)
|
||||
stock.stock_quantity = stock_qty - pending_qty
|
||||
|
||||
# 3. 创建报废记录
|
||||
scrap_record = TransScrap(
|
||||
sku=record.sku,
|
||||
source_table='trans_borrow', # 标识来源为借出转报废
|
||||
stock_id=record.id,
|
||||
quantity=pending_qty,
|
||||
reason=f"[借库报废] {reason}",
|
||||
operator_name=operator_name,
|
||||
approval_status='approved',
|
||||
cost_at_scrap=0,
|
||||
total_loss=0
|
||||
)
|
||||
db.session.add(scrap_record)
|
||||
created_records.append(scrap_record)
|
||||
scraped_count += 1
|
||||
|
||||
db.session.commit()
|
||||
return {'count': scraped_count}
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all',
|
||||
borrower_name=None, start_date=None, end_date=None,
|
||||
|
||||
@ -239,7 +239,7 @@ const handleLogout = () => {
|
||||
<footer v-if="!isLoginPage" class="app-footer">
|
||||
<span class="version-tag">
|
||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||
当前版本:V3.80
|
||||
当前版本:V3.81
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
|
||||
122
inventory-web/src/api/inbound/return.ts
Normal file
122
inventory-web/src/api/inbound/return.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// ============================================================================
|
||||
// 逆向物流(原单退回 + 不良品在管台账)
|
||||
//
|
||||
// 后端路由见 inventory-backend/app/api/v1/inbound/stock.py
|
||||
// 设计要点:
|
||||
// · 良品退回 → 直接加回原库存行
|
||||
// · 不良品退回 → 不进库存表,转入 trans_defective_goods 在管台账,
|
||||
// 修好后回库 / 无法维修则报废销毁
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 原单退回。
|
||||
*
|
||||
* @param data {
|
||||
* outbound_id: number 必填,trans_outbound.id(出库**明细行**主键,非单号)
|
||||
* return_qty: number 必填,本次退回数量(>0)
|
||||
* is_defective: boolean 必填,true=不良品转在管台账,false=良品加回库存
|
||||
* reason: string 可选,退回原因
|
||||
* }
|
||||
*/
|
||||
export function returnFromOutbound(data: {
|
||||
outbound_id: number
|
||||
return_qty: number
|
||||
is_defective: boolean
|
||||
reason?: string
|
||||
}) {
|
||||
return request({
|
||||
url: '/inbound/stock/return-from-outbound',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 不良品在管台账分页查询。
|
||||
*
|
||||
* @param params { page, page_size, status?, keyword?, start_date?, end_date? }
|
||||
* status 传 '全部' 或留空表示不过滤
|
||||
*/
|
||||
export function getDefectiveList(params: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: string
|
||||
keyword?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
company_name?: string
|
||||
}) {
|
||||
return request({
|
||||
url: '/inbound/stock/defective',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 修复回库:把修好的数量加回原库存行。
|
||||
*
|
||||
* @param id 在管台账 id
|
||||
* @param data { restock_qty?: number, remark?: string }
|
||||
* restock_qty 缺省 = 全部剩余在管量
|
||||
*/
|
||||
export function restockDefective(id: number, data: { restock_qty?: number; remark?: string }) {
|
||||
return request({
|
||||
url: `/inbound/stock/defective/${id}/restock`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交在管坏件的**报废申请**(需审批人审批,通过后由库管执行报废)。
|
||||
*
|
||||
* ★ 已由「直接报废」改为「申请」:
|
||||
* 报废一律需审批(后端 SCRAP_ALWAYS_REQUIRES_APPROVAL)。原
|
||||
* POST /defective/<id>/scrap 直接写 trans_scrap 并扣在管量,绕过审批,
|
||||
* 构成职责分离漏洞(同一库管可自行宣告实物销毁而无人复核),已删除。
|
||||
*
|
||||
* ★ 副作用提醒:申请**不预占**在管量,`remaining_qty` 提交后不变。
|
||||
* 前端必须提示用户「待审批并执行后才会扣减」,否则会诱发重复提交。
|
||||
*
|
||||
* @param id 在管台账 id
|
||||
* @param data { scrap_qty?: number, reason?: string, approver_id: number }
|
||||
* scrap_qty 缺省 = 全部剩余在管量;approver_id 必填
|
||||
*/
|
||||
export function submitDefectiveScrapRequest(
|
||||
id: number,
|
||||
data: { scrap_qty?: number; reason?: string; approver_id: number },
|
||||
) {
|
||||
return request({
|
||||
url: `/inbound/stock/defective/${id}/scrap-request`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 退回流水台账(只读)。
|
||||
*
|
||||
* @param params {
|
||||
* page?, page_size?,
|
||||
* keyword?, 模糊匹配 出库单号 / SKU / 操作人
|
||||
* return_type?, '良品' | '不良品';'全部' 或留空 = 不过滤
|
||||
* start_date?, end_date? 按退回时间过滤(YYYY-MM-DD)
|
||||
* }
|
||||
*/
|
||||
export function getReturnList(params: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
keyword?: string
|
||||
return_type?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}) {
|
||||
return request({
|
||||
url: '/outbound/returns',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
@ -1,22 +1,24 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 1. 扫码查询库存
|
||||
export function scanBarcode(barcode: string) {
|
||||
//
|
||||
// ★ requestId 必须传:在管不良品的 SKU 复制自原库存行,同一码可能同时
|
||||
// 命中 stock_buy#M 与 trans_defective_goods#N —— 这是两个不同实物,
|
||||
// 条码本身无法区分。带上当前申请单 id 后,后端会优先返回该单批准明细
|
||||
// 指定的那一条,避免「清单里明明有、扫码却说不在批准明细中」。
|
||||
export function scanBarcode(barcode: string, requestId?: number) {
|
||||
return request({
|
||||
url: '/v1/scrap/scan',
|
||||
method: 'get',
|
||||
params: { barcode }
|
||||
params: { barcode, request_id: requestId }
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 提交报废单
|
||||
export function createScrap(data: any) {
|
||||
return request({
|
||||
url: '/v1/scrap',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
// [已移除] 直接报废 createScrap —— 对应后端 POST /api/v1/scrap,该接口绕过
|
||||
// 审批流直接写 trans_scrap 并扣库存,与系统自陈的「报废一律需审批」规则冲突,
|
||||
// 构成职责分离漏洞(同一库管可自行宣告实物销毁而无人复核),已整体删除。
|
||||
// 所有报废改走:submitScrapRequest → approveScrapRequest → executeScrapByRequest。
|
||||
// 该函数此前已是死代码(全仓无调用方),删除无行为影响。
|
||||
|
||||
// 3. 报废记录列表查询
|
||||
export function getScrapRecords(params: any) {
|
||||
|
||||
@ -230,4 +230,30 @@ export function dispatchBorrow(data: {
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交「借出未归还」的**报废申请**(需审批人审批,通过后由库管执行报废)。
|
||||
*
|
||||
* ★ 已由「直接报废」改为「申请」:
|
||||
* 原 POST /v1/transactions/borrow/scrap 直接写 trans_scrap 并扣总库存,
|
||||
* 绕过审批、构成职责分离漏洞,已删除。现统一走 申请 → 审批 → 执行。
|
||||
*
|
||||
* ★ 副作用提醒:申请**不立即改变**借用记录状态,提交后该笔仍显示为未归还。
|
||||
* 前端必须提示用户「待审批并执行后才会标记为已报废」。
|
||||
*
|
||||
* @param data record_ids: trans_borrow.id 列表(按整条待还量报废)
|
||||
* reason?: 写入申请单备注
|
||||
* approver_id: 必填,指定审批人
|
||||
*/
|
||||
export function submitBorrowScrapRequest(data: {
|
||||
record_ids: number[]
|
||||
reason?: string
|
||||
approver_id: number
|
||||
}) {
|
||||
return request({
|
||||
url: '/v1/transactions/borrow/scrap-request',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
@ -46,18 +46,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 1. 获取当前激活的菜单路径
|
||||
const activeMenu = computed(() => {
|
||||
return route.path
|
||||
})
|
||||
|
||||
// 2. 获取需要在菜单中显示的路由(过滤掉 hidden 的路由)
|
||||
// 2. ★ 单个路由是否对当前用户可见
|
||||
//
|
||||
// 只认 meta.permissions(数组,满足任一即可)。历史遗留的
|
||||
// meta.permission(单数)**刻意保持不生效** —— 一旦开始消费它,
|
||||
// 「维修管理」等菜单会对部分角色突然消失,属于超出本次范围的静默行为变更。
|
||||
// 若要统一,需单独排期并逐个核对其余 meta.permission 的影响面。
|
||||
const canSee = (meta: any) => {
|
||||
if (!meta) return true
|
||||
if (meta.hidden) return false
|
||||
|
||||
const need = meta.permissions
|
||||
if (!Array.isArray(need) || need.length === 0) return true
|
||||
return need.some((code: string) => userStore.hasPermission(code))
|
||||
}
|
||||
|
||||
// 3. 获取需要在菜单中显示的路由
|
||||
// ★ 必须**递归过滤子路由**:原实现只过滤顶层,子路由的 meta.permissions
|
||||
// 不起作用,未授权用户仍能在菜单里看到入口。父菜单的子项被过滤光后,
|
||||
// 父菜单本身也不再显示。
|
||||
const menuRoutes = computed(() => {
|
||||
return router.options.routes.filter((r: any) => !r.meta?.hidden)
|
||||
return router.options.routes
|
||||
.filter((r: any) => canSee(r.meta))
|
||||
.map((r: any) => {
|
||||
if (!r.children) return r
|
||||
return { ...r, children: r.children.filter((c: any) => canSee(c.meta)) }
|
||||
})
|
||||
.filter((r: any) => !r.children || r.children.length > 0)
|
||||
})
|
||||
|
||||
// 3. 辅助函数:获取 meta 信息
|
||||
|
||||
@ -106,6 +106,16 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: 'RepairManagement',
|
||||
component: () => import('@/views/stock/inbound/repair.vue'),
|
||||
meta: { title: '维修管理', permission: 'inbound_repair' }
|
||||
},
|
||||
// ★ 不良品在管台账(逆向物流):退回的坏件不入库存表,
|
||||
// 实物在仓但不在库存行中,故单独成页承载其去向与处置。
|
||||
// meta.permissions 生效于 Sidebar(菜单隐藏)与路由守卫(直达 URL 拦截),
|
||||
// 后端 list API 同名权限码校验,三处一致。
|
||||
{
|
||||
path: 'defective',
|
||||
name: 'DefectiveGoods',
|
||||
component: () => import('@/views/stock/defective/index.vue'),
|
||||
meta: { title: '不良品在管台账', permissions: ['defective_list'] }
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -166,6 +176,19 @@ const routes: Array<RouteRecordRaw> = [
|
||||
title: '出库审批',
|
||||
icon: 'Stamp'
|
||||
}
|
||||
},
|
||||
// ★ 退回记录(逆向物流只读台账)
|
||||
// meta.permissions 由 Sidebar 过滤菜单 + 路由守卫拦截直达 URL,
|
||||
// 后端同名权限码校验,三处一致。权限码注册见
|
||||
// db_migrations/add_return_view_support.sql
|
||||
{
|
||||
path: 'returns',
|
||||
name: 'OutboundReturns',
|
||||
component: () => import('@/views/outbound/returns/index.vue'),
|
||||
meta: {
|
||||
title: '退回记录',
|
||||
permissions: ['outbound_return_list']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -396,15 +419,33 @@ router.beforeEach((to, from, next) => {
|
||||
|
||||
// 权限检查逻辑
|
||||
if (to.meta.roles && Array.isArray(to.meta.roles)) {
|
||||
if (to.meta.roles.includes(userRole)) {
|
||||
next()
|
||||
} else {
|
||||
if (!to.meta.roles.includes(userRole)) {
|
||||
console.warn(`权限不足: 用户角色 ${userRole} 不在允许列表 ${to.meta.roles} 中`)
|
||||
next('/dashboard')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
|
||||
// ★ 权限码检查(meta.permissions,满足任一即可)
|
||||
//
|
||||
// 与 Sidebar 的菜单过滤、后端 @permission_required 使用**同一批权限码**:
|
||||
// · Sidebar 负责「菜单里看不见入口」
|
||||
// · 这里负责「直接敲 URL 也进不去」
|
||||
// · 后端负责「绕过前端直接调接口也不行」(最终防线)
|
||||
// 三层任一失效都不至于越权。
|
||||
if (Array.isArray(to.meta.permissions) && to.meta.permissions.length > 0) {
|
||||
const allowed = to.meta.permissions.some(
|
||||
(code: string) => userStore.hasPermission(code)
|
||||
)
|
||||
if (!allowed) {
|
||||
console.warn(`权限不足: 缺少 ${to.meta.permissions.join(' / ')},已跳转首页`)
|
||||
ElMessage.warning('您没有访问该页面的权限')
|
||||
next('/dashboard')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@ -0,0 +1,36 @@
|
||||
// ============================================================================
|
||||
// 通用格式化工具
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 数量显示格式化 —— 整数不显示小数点,小数去除末尾多余的 0。
|
||||
*
|
||||
* 1.0000 -> '1'
|
||||
* 2.5000 -> '2.5'
|
||||
* 0.30000000000000004 -> '0.3' (后端 float 累加产生的尾巴)
|
||||
* null / undefined / '' -> fallback
|
||||
*
|
||||
* ★ 为什么先 toFixed(4) 再 Number:
|
||||
* 本系统数量列在库中一律为 numeric(19,4),但后端有一批运算写成
|
||||
* `float(a) + float(b)`,累加后可能带出 0.30000000000000004 这类二进制
|
||||
* 浮点尾数。先按量纲精度收敛到 4 位,再交给 Number 归一 —— Number('0.3000')
|
||||
* 即 0.3,String() 后自然就是 '0.3'。
|
||||
*/
|
||||
export function formatQty(value: any, fallback = '0'): string {
|
||||
if (value === null || value === undefined || value === '') return fallback
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return fallback
|
||||
return String(Number(n.toFixed(4)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 数量归一化为 number —— 供 el-input-number 等**需要数值而非字符串**的
|
||||
* 组件使用。作用同 formatQty,只是保留 number 类型。
|
||||
*
|
||||
* ★ 不要把 formatQty 的返回值喂给 el-input-number:传字符串会让 v-model
|
||||
* 失去数值语义,组件内部的步进/边界比较也会退化。
|
||||
*/
|
||||
export function normalizeQty(value: any, fallback = 0): number {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? Number(n.toFixed(4)) : fallback
|
||||
}
|
||||
|
||||
@ -76,7 +76,43 @@
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="scan-section">
|
||||
<!-- ★ 免扫码执行明细(借出未还):实物不在库、无法扫码,按批准量直接核销。
|
||||
刻意**不进购物车** —— 购物车的语义是「扫码证据」,混入 auto 项会让
|
||||
validateAgainstPlan / maxScrapFor 的扫码校验失去意义。 -->
|
||||
<div v-if="selectedRequest && autoItems.length > 0" class="auto-items-section">
|
||||
<el-alert type="info" :closable="false" show-icon>
|
||||
以下明细的实物不在仓库内(借出未还),<b>无需扫码</b>,
|
||||
执行时将按批准数量直接核销。
|
||||
</el-alert>
|
||||
<el-table :data="autoItems" border size="small" style="width: 100%; margin-top: 8px;">
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column label="来源" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="warning">{{ sourceLabel(row.source_table) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="sku" label="SKU" width="130" show-overflow-tooltip />
|
||||
<el-table-column label="执行数量" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span style="color: #E6A23C; font-weight: bold;">{{ row.scrap_qty ?? '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 纯 auto 单:没有可扫的实物,隐藏整个扫码区并明示执行方式 -->
|
||||
<div v-if="selectedRequest && scanItems.length === 0" class="scan-section">
|
||||
<el-alert
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="本单无可扫码物料"
|
||||
description="所有明细均为免扫码来源,将按批准数量直接执行报废。"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="scan-section">
|
||||
<!-- 扫码进度条 -->
|
||||
<div v-if="selectedRequest && scanTotalQty > 0" class="scan-progress">
|
||||
<div class="progress-info">
|
||||
@ -295,28 +331,49 @@ const selectedRequestId = computed({
|
||||
|
||||
const plannedItems = computed<any[]>(() => selectedRequest.value?.items ?? [])
|
||||
|
||||
// 扫码进度
|
||||
// ============================================================
|
||||
// ★ 按执行模式分流(与后端 scrap_mode 对齐)
|
||||
//
|
||||
// scan —— 需扫码:库存行、在管不良品(实物在库、有可扫标识)
|
||||
// auto —— 免扫码:借出未还(东西在借用人手上,物理上不可能扫),
|
||||
// 执行时按批准数量直接核销
|
||||
//
|
||||
// 存量单据没有 scrap_mode 字段,回落为 scan —— 行为与改造前完全一致。
|
||||
// ============================================================
|
||||
const modeOf = (it: any) => (it?.scrap_mode === 'auto' ? 'auto' : 'scan')
|
||||
const scanItems = computed<any[]>(() => plannedItems.value.filter((it: any) => modeOf(it) === 'scan'))
|
||||
const autoItems = computed<any[]>(() => plannedItems.value.filter((it: any) => modeOf(it) === 'auto'))
|
||||
|
||||
// 扫码进度 —— ★ 只统计需扫码的项。
|
||||
// 若把 auto 项也算进来,进度条永远达不到 100%,会让操作员误以为「没扫完」。
|
||||
const scanTotalQty = computed(() =>
|
||||
plannedItems.value.reduce((sum: number, it: any) => sum + (Number(it.scrap_qty) || 0), 0)
|
||||
scanItems.value.reduce((sum: number, it: any) => sum + (Number(it.scrap_qty) || 0), 0)
|
||||
)
|
||||
const scanScannedQty = computed(() =>
|
||||
cartItems.value.reduce((sum: number, it: any) => sum + (Number(it.scrap_quantity) || 0), 0)
|
||||
)
|
||||
const scanScannedTypes = computed(() => cartItems.value.length)
|
||||
const scanTotalTypes = computed(() => plannedItems.value.length)
|
||||
const scanTotalTypes = computed(() => scanItems.value.length)
|
||||
|
||||
// ============================================================
|
||||
// ★ SKU 主键匹配(与后端 _match_key 完全同口径)
|
||||
//
|
||||
// SKU 在同一库存表内唯一、且不存在跨表重名,故以 SKU 作为扫码匹配的唯一标识。
|
||||
// ★ 匹配键必须带来源表。原实现只用 SKU,其前提「SKU 跨表无重名」在引入
|
||||
// 逆向物流后不再成立:在管不良品台账的 SKU 是从原库存行**复制**的,
|
||||
// 故 trans_defective_goods#N 与其源 stock_buy#M 的 SKU 必然相同。
|
||||
// 若同一张单同时含两者,不带来源就会串键 —— 扫码量会算到错误对象上。
|
||||
// 纯库存单两侧同源、键仍匹配,对既有流程零行为变更。
|
||||
//
|
||||
// 个别历史库存行 SKU 为空,这类行回退为 source_table + stock_id 复合键;
|
||||
// 前缀区分(sku: / row:)确保空 SKU 行绝不会与正常 SKU 串键。
|
||||
// 前缀区分(sku: / row:)确保两种键绝不会互相串。
|
||||
// ============================================================
|
||||
const normSku = (sku: any) => String(sku ?? '').trim()
|
||||
|
||||
const matchKey = (sourceTable: any, stockId: any, sku: any) => {
|
||||
const s = normSku(sku)
|
||||
return s ? `sku:${s}` : `row:${String(sourceTable)}#${String(stockId)}`
|
||||
return s
|
||||
? `sku:${String(sourceTable)}:${s}`
|
||||
: `row:${String(sourceTable)}#${String(stockId)}`
|
||||
}
|
||||
|
||||
// 按匹配键找批准明细行
|
||||
@ -396,7 +453,14 @@ const unscannedList = computed(() =>
|
||||
const unscannedCount = computed(() => unscannedList.value.length)
|
||||
|
||||
const sourceLabel = (st: string) =>
|
||||
({ stock_buy: '采购件', stock_semi: '半成品', stock_product: '成品' } as any)[st] || st || '-'
|
||||
({
|
||||
stock_buy: '采购件',
|
||||
stock_semi: '半成品',
|
||||
stock_product: '成品',
|
||||
// 逆向物流两类来源(与后端 scrap_sources 的 label 对齐)
|
||||
trans_defective_goods: '在管不良品',
|
||||
trans_borrow: '借出未还',
|
||||
} as any)[st] || st || '-'
|
||||
|
||||
// --- 加载已批准的报废申请单 ---
|
||||
const loadApprovalRequests = async () => {
|
||||
@ -512,8 +576,9 @@ const handleManualInput = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 查库
|
||||
const res: any = await scanBarcode(code)
|
||||
// 2. 查库(★ 必须带当前申请单 id:同一 SKU 可能同时存在于库存表与
|
||||
// 在管不良品台账,两者是不同实物,只有单据上下文能决定该扫到哪个)
|
||||
const res: any = await scanBarcode(code, selectedRequest.value?.id)
|
||||
if (res.code === 200 && res.data) {
|
||||
const item = res.data
|
||||
|
||||
@ -588,7 +653,11 @@ const submitForm = async () => {
|
||||
return
|
||||
}
|
||||
if (!selectedRequest.value) return ElMessage.warning('请先选择要执行的报废申请单')
|
||||
if (cartItems.value.length === 0) return ElMessage.warning('请先扫码添加报废物料')
|
||||
// ★ 守卫放宽为「购物车与免扫码明细都为空才拦」——
|
||||
// 纯免扫码单(借出未还)不存在可扫的实物,购物车恒为空。
|
||||
if (cartItems.value.length === 0 && autoItems.value.length === 0) {
|
||||
return ElMessage.warning('请先扫码添加报废物料')
|
||||
}
|
||||
|
||||
// 逐项复核批准量
|
||||
for (const item of cartItems.value) {
|
||||
@ -603,8 +672,14 @@ const submitForm = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// ★ 确认框必须点明「另有 N 项免扫码项将一并执行」——
|
||||
// 否则操作员会以为只报废了购物车里扫到的那些,导致误判。
|
||||
const autoNote = autoItems.value.length > 0
|
||||
? `\n另有 ${autoItems.value.length} 项(借出未还)将按批准数量自动执行,无需扫码。`
|
||||
: ''
|
||||
await ElMessageBox.confirm(
|
||||
`确认按实扫数量执行报废吗?\n申请单:${selectedRequest.value.request_no}\n共 ${cartItems.value.length} 项 / ${scanScannedQty.value} 件\n\n执行后不可撤销。`,
|
||||
`确认按实扫数量执行报废吗?\n申请单:${selectedRequest.value.request_no}\n`
|
||||
+ `共 ${cartItems.value.length} 项 / ${scanScannedQty.value} 件${autoNote}\n\n执行后不可撤销。`,
|
||||
'执行确认',
|
||||
{ confirmButtonText: '确认执行', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
|
||||
@ -120,6 +120,35 @@
|
||||
<span style="color: #F56C6C; font-weight: bold;">¥{{ row.subtotal.toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- ★ 原单退回:良品加回库存 / 不良品转入异常待处理台账。
|
||||
已退满的行(returnable_quantity <= 0)按钮置灰不可点。
|
||||
|
||||
★ 权限双重保障,两者用同一个权限码 outbound_return,判定一致:
|
||||
· v-if="canReturn" 响应式判定。v-permission 指令只在 mounted
|
||||
执行一次,而表格行会重渲染,用响应式判定兜底更稳。
|
||||
· v-permission 本系统规范的按钮级指令,声明式表达权限要求,
|
||||
无权限时直接把元素移出 DOM(普通领料员工完全看不到)。 -->
|
||||
<el-table-column label="退回" width="150" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="canReturn">
|
||||
<el-button
|
||||
v-permission="'outbound_return'"
|
||||
type="warning"
|
||||
link
|
||||
size="small"
|
||||
:disabled="!row.returnable_quantity || row.returnable_quantity <= 0"
|
||||
@click="openReturnDialog(row)"
|
||||
>
|
||||
退回
|
||||
</el-button>
|
||||
<span v-if="row.returned_quantity > 0" class="returned-hint">
|
||||
已退 {{ formatQty(row.returned_quantity) }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="returned-hint">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
@ -183,18 +212,98 @@
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ★ 原单退回对话框 -->
|
||||
<el-dialog
|
||||
v-model="returnDialog.visible"
|
||||
title="原单退回"
|
||||
width="540px"
|
||||
:close-on-click-modal="false"
|
||||
@closed="resetReturnDialog"
|
||||
>
|
||||
<el-form :model="returnDialog.form" label-width="120px">
|
||||
<el-form-item label="物料">
|
||||
<span class="dialog-text">
|
||||
{{ returnDialog.row?.name || '-' }}
|
||||
<span class="dialog-sub">/ {{ returnDialog.row?.sku || '-' }}</span>
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="原出库数量">
|
||||
<span class="dialog-text">{{ formatQty(returnDialog.row?.quantity) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="已退回数量">
|
||||
<span class="dialog-text">{{ formatQty(returnDialog.row?.returned_quantity) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="本次可退最大">
|
||||
<span class="dialog-text dialog-strong">
|
||||
{{ formatQty(returnDialog.row?.returnable_quantity) }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="本次退回数量" required>
|
||||
<!-- 不再设 :precision="4" —— 那会把 1 显示成 1.0000。
|
||||
数量本身仍支持小数(后端按 numeric(19,4) 收)。 -->
|
||||
<el-input-number
|
||||
v-model="returnDialog.form.return_qty"
|
||||
:min="0"
|
||||
:max="normalizeQty(returnDialog.row?.returnable_quantity)"
|
||||
:step="1"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="退回类型" required>
|
||||
<el-select v-model="returnDialog.form.is_defective" style="width: 260px">
|
||||
<el-option :value="false" label="良品(加回库存)" />
|
||||
<el-option :value="true" label="不良品(转入异常待处理)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="退回原因" required>
|
||||
<el-input
|
||||
v-model="returnDialog.form.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="请填写退回原因(必填)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="returnDialog.visible = false">取消</el-button>
|
||||
<!-- loading 兼作前端防抖:提交期间按钮不可再点 -->
|
||||
<el-button type="primary" :loading="returnDialog.submitting" @click="submitReturn">
|
||||
确定提交
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, onMounted, reactive, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getOutboundList } from '@/api/outbound'
|
||||
import { returnFromOutbound } from '@/api/inbound/return'
|
||||
import { formatQty, normalizeQty } from '@/utils/format'
|
||||
import { Picture } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import CompanySelector from '@/components/CompanySelector.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 退回按钮的可见性。
|
||||
//
|
||||
// ★ 权限码必须与后端 @permission_required('outbound_return') 逐字一致,
|
||||
// 且刻意**不**套用本文件 hasColumnPermission() 里 `username === 'IRIS'`
|
||||
// 那类特例放行 —— 退回是实物交接的 SOP 动作,后端不认特例,前端也不该认,
|
||||
// 否则会出现「按钮可见但接口 403」的错位。
|
||||
// hasPermission() 内部已对 SUPER_ADMIN 放行,与后端装饰器的超管旁路对齐。
|
||||
// 权限码来源见 db_migrations/add_outbound_return_perm.sql
|
||||
const canReturn = computed(() => userStore.hasPermission('outbound_return'))
|
||||
|
||||
// 防抖定时器
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@ -381,6 +490,78 @@ const getTagType = (type: string) => {
|
||||
return map[type] || ''
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ★ 原单退回
|
||||
// ============================================================
|
||||
const returnDialog = reactive({
|
||||
visible: false,
|
||||
submitting: false,
|
||||
row: null as any,
|
||||
form: {
|
||||
return_qty: 1,
|
||||
is_defective: false,
|
||||
reason: '',
|
||||
},
|
||||
})
|
||||
|
||||
const openReturnDialog = (row: any) => {
|
||||
returnDialog.row = row
|
||||
// 默认带入「本次可退最大」,业务上整行退回最常见;用户可再调小做部分退回
|
||||
// 用 normalizeQty 抹掉后端 float 累加留下的尾数,避免输入框显示一长串小数
|
||||
returnDialog.form.return_qty = normalizeQty(row?.returnable_quantity)
|
||||
returnDialog.form.is_defective = false
|
||||
returnDialog.form.reason = ''
|
||||
returnDialog.visible = true
|
||||
}
|
||||
|
||||
const resetReturnDialog = () => {
|
||||
returnDialog.row = null
|
||||
returnDialog.submitting = false
|
||||
returnDialog.form.return_qty = 1
|
||||
returnDialog.form.is_defective = false
|
||||
returnDialog.form.reason = ''
|
||||
}
|
||||
|
||||
const submitReturn = async () => {
|
||||
if (returnDialog.submitting) return // 双保险:即便按钮 loading 被绕过也不重复提交
|
||||
|
||||
const maxQty = normalizeQty(returnDialog.row?.returnable_quantity)
|
||||
const qty = normalizeQty(returnDialog.form.return_qty)
|
||||
const reason = (returnDialog.form.reason || '').trim()
|
||||
|
||||
if (!qty || qty <= 0) {
|
||||
ElMessage.warning('请填写本次退回数量')
|
||||
return
|
||||
}
|
||||
if (qty > maxQty) {
|
||||
ElMessage.warning(`本次退回数量不能超过可退最大数量 ${maxQty}`)
|
||||
return
|
||||
}
|
||||
if (!reason) {
|
||||
ElMessage.warning('请填写退回原因')
|
||||
return
|
||||
}
|
||||
|
||||
returnDialog.submitting = true
|
||||
try {
|
||||
const res = await returnFromOutbound({
|
||||
outbound_id: returnDialog.row.id,
|
||||
return_qty: qty,
|
||||
is_defective: returnDialog.form.is_defective,
|
||||
reason,
|
||||
})
|
||||
ElMessage.success(res?.msg || '退回成功')
|
||||
returnDialog.visible = false
|
||||
// 刷新列表:退回额度与库存状态都已变化
|
||||
await fetchData()
|
||||
} catch (e) {
|
||||
// 错误提示由 request 拦截器统一弹出(400 会展示后端业务文案),此处只收尾
|
||||
console.error('[退回] 失败', e)
|
||||
} finally {
|
||||
returnDialog.submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
@ -419,6 +600,24 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
/* ★ 原单退回:明细行的「已退 N」提示与对话框文案样式 */
|
||||
.returned-hint {
|
||||
color: #E6A23C;
|
||||
font-size: 12px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.dialog-text {
|
||||
color: #303133;
|
||||
}
|
||||
.dialog-sub {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dialog-strong {
|
||||
color: #409EFF;
|
||||
font-weight: bold;
|
||||
}
|
||||
.image-slot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
193
inventory-web/src/views/outbound/returns/index.vue
Normal file
193
inventory-web/src/views/outbound/returns/index.vue
Normal file
@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 筛选区 -->
|
||||
<el-form :inline="true" class="filter-form" @submit.prevent>
|
||||
<el-form-item label="退回类型">
|
||||
<el-select v-model="query.return_type" placeholder="全部" style="width: 140px">
|
||||
<el-option label="全部" value="全部" />
|
||||
<el-option label="良品(加回库存)" value="良品" />
|
||||
<el-option label="不良品(转在管台账)" value="不良品" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="搜索">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="出库单号 / SKU / 操作人"
|
||||
style="width: 240px;"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="退回时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 260px"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table
|
||||
:data="list"
|
||||
v-loading="loading"
|
||||
border
|
||||
style="width: 100%; margin-top: 16px;"
|
||||
:header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
|
||||
>
|
||||
<el-table-column prop="return_time" label="退回时间" width="170" align="center">
|
||||
<template #default="{ row }">{{ row.return_time || '-' }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="outbound_no" label="原出库单号" min-width="190" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.outbound_no">{{ row.outbound_no }}</span>
|
||||
<span v-else class="muted">(原单已删除)</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="material_name" label="物料名称" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.material_name">{{ row.material_name }}</span>
|
||||
<span v-else class="muted">(原库存行已删除)</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="spec_model" label="规格型号" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.spec_model">{{ row.spec_model }}</span>
|
||||
<span v-else class="muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="sku" label="SKU" width="150" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="退回数量" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatQty(row.return_qty) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="退回类型" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.return_type === '不良品' ? 'danger' : 'success'">
|
||||
{{ row.return_type === '不良品' ? '不良品(转在管)' : '良品(已回库)' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="reason" label="退回原因" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.reason">{{ row.reason }}</span>
|
||||
<span v-else class="muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="operator" label="操作人" width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.operator">{{ row.operator }}</span>
|
||||
<span v-else class="muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top: 20px; text-align: right;">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="query.page_size"
|
||||
:current-page="query.page"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { getReturnList } from '@/api/inbound/return'
|
||||
import { formatQty } from '@/utils/format'
|
||||
|
||||
// ★ 只读台账:无任何写操作按钮,供库管核对历史退回记录。
|
||||
// 权限由 /outbound/returns 接口的 outbound_return_list 校验,
|
||||
// 菜单可见性与直达 URL 由路由 meta.permissions 控制。
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const dateRange = ref<string[]>([])
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: '',
|
||||
return_type: '全部',
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getReturnList({
|
||||
page: query.page,
|
||||
page_size: query.page_size,
|
||||
keyword: query.keyword || undefined,
|
||||
return_type: query.return_type,
|
||||
start_date: dateRange.value?.[0] || undefined,
|
||||
end_date: dateRange.value?.[1] || undefined,
|
||||
})
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total || 0
|
||||
} catch (e) {
|
||||
console.error('[退回记录] 查询失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
query.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handlePageChange = (val: number) => {
|
||||
query.page = val
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const resetFilter = () => {
|
||||
query.page = 1
|
||||
query.keyword = ''
|
||||
query.return_type = '全部'
|
||||
dateRange.value = []
|
||||
fetchData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-form :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.muted {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
525
inventory-web/src/views/stock/defective/index.vue
Normal file
525
inventory-web/src/views/stock/defective/index.vue
Normal file
@ -0,0 +1,525 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 顶部汇总:在管坏件是「不在库存表里」的实物,用这个数字提醒别漏盘 -->
|
||||
<el-alert
|
||||
v-if="pendingTotal > 0"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 12px;"
|
||||
>
|
||||
当前在管不良品剩余待处理:<b>{{ pendingTotal }}</b>
|
||||
件。这批实物不在库存表中,盘点时请以本台账为准。
|
||||
</el-alert>
|
||||
|
||||
<!-- 筛选区 -->
|
||||
<el-form :inline="true" class="filter-form" @submit.prevent>
|
||||
<el-form-item>
|
||||
<CompanySelector v-model="query.company_name" @change="handleSearch" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" placeholder="全部" style="width: 150px" clearable>
|
||||
<el-option label="全部" value="全部" />
|
||||
<el-option v-for="s in statusOptions" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="搜索">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="物料名称 / SKU / 规格"
|
||||
style="width: 240px;"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="退回时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 260px"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table
|
||||
:data="list"
|
||||
v-loading="loading"
|
||||
border
|
||||
style="width: 100%; margin-top: 16px;"
|
||||
:header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
|
||||
>
|
||||
<el-table-column prop="material_name" label="物料名称" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.material_name">{{ row.material_name }}</span>
|
||||
<span v-else style="color:#c0c4cc">(原库存行已删除)</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="spec_model" label="规格型号" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.spec_model">{{ row.spec_model }}</span>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="sku" label="SKU" width="150" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="退回时间" width="170" align="center">
|
||||
<template #default="{ row }">{{ row.created_at || '-' }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="operator" label="操作人" width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.operator">{{ row.operator }}</span>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="退回总数" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatQty(row.quantity) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="剩余待处理" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.remaining_qty > 0 ? '#E6A23C' : '#909399', fontWeight: 'bold' }">
|
||||
{{ formatQty(row.remaining_qty) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="190" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<!-- ★ 仅在还有在管量时提供处置入口:终态(已回库/已报废/已闭环)
|
||||
的 remaining_qty 已归零,再操作只会拿到后端 400。
|
||||
|
||||
★ 两个动作是**独立权限**(defective_restock / defective_scrap),
|
||||
可能有人只有其中一个,故分别判定、分别隐藏。
|
||||
v-if 是响应式判定(表格行会重渲染),v-permission 是本系统规范的
|
||||
按钮级指令(无权限直接移出 DOM),两者用同一权限码,判定一致。
|
||||
后端 @permission_required 用的是同样的码,见
|
||||
db_migrations/add_defective_operation_perms.sql -->
|
||||
<template v-if="row.remaining_qty > 0">
|
||||
<el-button
|
||||
v-if="canRestock"
|
||||
v-permission="'defective_restock'"
|
||||
type="success" link size="small"
|
||||
@click="openRestockDialog(row)"
|
||||
>
|
||||
修复回库
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canScrap"
|
||||
v-permission="'defective_scrap'"
|
||||
type="danger" link size="small"
|
||||
@click="openScrapDialog(row)"
|
||||
>
|
||||
申请报废
|
||||
</el-button>
|
||||
<span v-if="!canRestock && !canScrap" class="no-perm">无处置权限</span>
|
||||
</template>
|
||||
<span v-else class="no-perm">已结案</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top: 20px; text-align: right;">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="query.page_size"
|
||||
:current-page="query.page"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ============ 修复回库 ============ -->
|
||||
<el-dialog
|
||||
v-model="restockDialog.visible"
|
||||
title="修复回库"
|
||||
width="460px"
|
||||
:close-on-click-modal="false"
|
||||
@closed="resetRestockDialog"
|
||||
>
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="物料">
|
||||
<span class="dlg-text">{{ restockDialog.row?.material_name || '-' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="在管数量">
|
||||
<span class="dlg-text dlg-strong">{{ formatQty(restockDialog.row?.remaining_qty) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="本次回库数量" required>
|
||||
<!-- 不再设 :precision="4" —— 那会把 1 显示成 1.0000。
|
||||
数量本身仍支持小数(后端按 numeric(19,4) 收),只是不强制补零。 -->
|
||||
<el-input-number
|
||||
v-model="restockDialog.form.restock_qty"
|
||||
:min="0"
|
||||
:max="normalizeQty(restockDialog.row?.remaining_qty)"
|
||||
:step="1"
|
||||
style="width: 180px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="restockDialog.form.remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="如:已更换主板(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="dlg-tip">回库后数量将加回原库存行的实物数与可用数,状态变为「在库」。</div>
|
||||
<template #footer>
|
||||
<el-button @click="restockDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="restockDialog.submitting" @click="submitRestock">
|
||||
确定回库
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ============ 申请报废 ============ -->
|
||||
<el-dialog
|
||||
v-model="scrapDialog.visible"
|
||||
title="申请报废"
|
||||
width="480px"
|
||||
:close-on-click-modal="false"
|
||||
@closed="resetScrapDialog"
|
||||
>
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="物料">
|
||||
<span class="dlg-text">{{ scrapDialog.row?.material_name || '-' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="在管数量">
|
||||
<span class="dlg-text dlg-strong">{{ formatQty(scrapDialog.row?.remaining_qty) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="本次报废数量" required>
|
||||
<!-- 同「修复回库」:去掉 :precision 强制补零 -->
|
||||
<el-input-number
|
||||
v-model="scrapDialog.form.scrap_qty"
|
||||
:min="0"
|
||||
:max="normalizeQty(scrapDialog.row?.remaining_qty)"
|
||||
:step="1"
|
||||
style="width: 180px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="报废原因" required>
|
||||
<el-input
|
||||
v-model="scrapDialog.form.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="请填写报废原因(必填,审批人据此判断)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="指定审批人" required>
|
||||
<el-select
|
||||
v-model="scrapDialog.form.approver_id"
|
||||
placeholder="请选择审批人"
|
||||
style="width: 100%"
|
||||
filterable
|
||||
>
|
||||
<el-option v-for="u in approvers" :key="u.id" :label="u.username" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!-- ★ 必须说明「在管量不会立即变化」,否则用户会以为没生效而重复提交 -->
|
||||
<div class="dlg-tip dlg-warn">
|
||||
提交后进入审批流程,<b>在管数量不会立即扣减</b>;
|
||||
待审批人通过、库管执行报废后才会扣减并写入报废台账。
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="scrapDialog.visible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="scrapDialog.submitting" @click="submitScrap">
|
||||
提交报废申请
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getDefectiveList, restockDefective, submitDefectiveScrapRequest } from '@/api/inbound/return'
|
||||
import { getApproversList } from '@/api/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { formatQty, normalizeQty } from '@/utils/format'
|
||||
import CompanySelector from '@/components/CompanySelector.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 与后端 app/models/transaction.py 的 VALID_DEFECTIVE_STATUSES 保持一致
|
||||
const statusOptions = ['待处理', '处理中', '已回库', '已报废', '已闭环']
|
||||
|
||||
// ★ 处置权限判定。权限码必须与后端 @permission_required 逐字一致。
|
||||
// hasPermission() 内部已对 SUPER_ADMIN 放行,与后端装饰器的超管旁路对齐。
|
||||
const canRestock = computed(() => userStore.hasPermission('defective_restock'))
|
||||
const canScrap = computed(() => userStore.hasPermission('defective_scrap'))
|
||||
|
||||
const statusTagType = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'待处理': 'warning',
|
||||
'处理中': 'primary',
|
||||
'已回库': 'success',
|
||||
'已报废': 'danger',
|
||||
'已闭环': 'info',
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const pendingTotal = ref(0)
|
||||
const loading = ref(false)
|
||||
const dateRange = ref<string[]>([])
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
status: '全部',
|
||||
keyword: '',
|
||||
company_name: '' as string,
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getDefectiveList({
|
||||
page: query.page,
|
||||
page_size: query.page_size,
|
||||
status: query.status,
|
||||
keyword: query.keyword,
|
||||
start_date: dateRange.value?.[0] || undefined,
|
||||
end_date: dateRange.value?.[1] || undefined,
|
||||
company_name: query.company_name || undefined,
|
||||
})
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total || 0
|
||||
pendingTotal.value = res.data.pending_total || 0
|
||||
} catch (e) {
|
||||
console.error('[不良品台账] 查询失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
query.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handlePageChange = (val: number) => {
|
||||
query.page = val
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const resetFilter = () => {
|
||||
query.page = 1
|
||||
query.status = '全部'
|
||||
query.keyword = ''
|
||||
query.company_name = ''
|
||||
dateRange.value = []
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ★ 修复回库
|
||||
// ============================================================
|
||||
const restockDialog = reactive({
|
||||
visible: false,
|
||||
submitting: false,
|
||||
row: null as any,
|
||||
form: { restock_qty: 0, remark: '' },
|
||||
})
|
||||
|
||||
const openRestockDialog = (row: any) => {
|
||||
restockDialog.row = row
|
||||
// 用 normalizeQty 而非裸 Number:抹掉后端 float 累加留下的
|
||||
// 0.30000000000000004 这类尾巴,否则输入框会显示一长串小数
|
||||
restockDialog.form.restock_qty = normalizeQty(row?.remaining_qty)
|
||||
restockDialog.form.remark = ''
|
||||
restockDialog.visible = true
|
||||
}
|
||||
|
||||
const resetRestockDialog = () => {
|
||||
restockDialog.row = null
|
||||
restockDialog.submitting = false
|
||||
restockDialog.form.restock_qty = 0
|
||||
restockDialog.form.remark = ''
|
||||
}
|
||||
|
||||
const submitRestock = async () => {
|
||||
if (restockDialog.submitting) return // 双保险,防手抖连击
|
||||
|
||||
const max = normalizeQty(restockDialog.row?.remaining_qty)
|
||||
const qty = normalizeQty(restockDialog.form.restock_qty)
|
||||
if (!qty || qty <= 0) {
|
||||
ElMessage.warning('请填写本次回库数量')
|
||||
return
|
||||
}
|
||||
if (qty > max) {
|
||||
ElMessage.warning(`回库数量不能超过在管数量 ${max}`)
|
||||
return
|
||||
}
|
||||
|
||||
restockDialog.submitting = true
|
||||
try {
|
||||
const res = await restockDefective(restockDialog.row.id, {
|
||||
restock_qty: qty,
|
||||
remark: restockDialog.form.remark || undefined,
|
||||
})
|
||||
ElMessage.success(res?.msg || '回库成功')
|
||||
restockDialog.visible = false
|
||||
await fetchData()
|
||||
} catch (e) {
|
||||
console.error('[修复回库] 失败', e)
|
||||
} finally {
|
||||
restockDialog.submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ★ 申请报废(需审批人审批,通过后由库管执行)
|
||||
// ============================================================
|
||||
const approvers = ref<any[]>([])
|
||||
|
||||
const loadApprovers = async () => {
|
||||
try {
|
||||
const res = await getApproversList()
|
||||
approvers.value = res.data || []
|
||||
} catch (e) {
|
||||
console.error('[申请报废] 审批人列表加载失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const scrapDialog = reactive({
|
||||
visible: false,
|
||||
submitting: false,
|
||||
row: null as any,
|
||||
form: { scrap_qty: 0, reason: '', approver_id: null as number | null },
|
||||
})
|
||||
|
||||
const openScrapDialog = (row: any) => {
|
||||
scrapDialog.row = row
|
||||
scrapDialog.form.scrap_qty = normalizeQty(row?.remaining_qty)
|
||||
scrapDialog.form.reason = ''
|
||||
scrapDialog.form.approver_id = null
|
||||
scrapDialog.visible = true
|
||||
loadApprovers()
|
||||
}
|
||||
|
||||
const resetScrapDialog = () => {
|
||||
scrapDialog.row = null
|
||||
scrapDialog.submitting = false
|
||||
scrapDialog.form.scrap_qty = 0
|
||||
scrapDialog.form.reason = ''
|
||||
scrapDialog.form.approver_id = null
|
||||
}
|
||||
|
||||
const submitScrap = async () => {
|
||||
if (scrapDialog.submitting) return
|
||||
|
||||
const max = normalizeQty(scrapDialog.row?.remaining_qty)
|
||||
const qty = normalizeQty(scrapDialog.form.scrap_qty)
|
||||
const reason = (scrapDialog.form.reason || '').trim()
|
||||
|
||||
if (!qty || qty <= 0) {
|
||||
ElMessage.warning('请填写本次报废数量')
|
||||
return
|
||||
}
|
||||
if (qty > max) {
|
||||
ElMessage.warning(`报废数量不能超过在管数量 ${max}`)
|
||||
return
|
||||
}
|
||||
if (!reason) {
|
||||
ElMessage.warning('请填写报废原因')
|
||||
return
|
||||
}
|
||||
if (!scrapDialog.form.approver_id) {
|
||||
ElMessage.warning('请选择审批人')
|
||||
return
|
||||
}
|
||||
|
||||
scrapDialog.submitting = true
|
||||
try {
|
||||
const res = await submitDefectiveScrapRequest(scrapDialog.row.id, {
|
||||
scrap_qty: qty,
|
||||
reason,
|
||||
approver_id: scrapDialog.form.approver_id,
|
||||
})
|
||||
// ★ 提示里必须点明「在管量未变」—— 申请不预占,否则用户会以为没生效而重复提交
|
||||
ElMessage.success(
|
||||
`${res?.msg || '报废申请已提交'}:在管数量将在审批通过并执行后才扣减`,
|
||||
)
|
||||
scrapDialog.visible = false
|
||||
await fetchData()
|
||||
} catch (e) {
|
||||
console.error('[申请报废] 失败', e)
|
||||
} finally {
|
||||
scrapDialog.submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-form :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.dlg-text {
|
||||
color: #303133;
|
||||
}
|
||||
.dlg-strong {
|
||||
color: #E6A23C;
|
||||
font-weight: bold;
|
||||
}
|
||||
.dlg-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
padding-left: 10px;
|
||||
}
|
||||
/* 无处置权限 / 已结案的占位文案 */
|
||||
.no-perm {
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dlg-danger {
|
||||
color: #F56C6C;
|
||||
}
|
||||
/* 流程提示:告知「提交不等于立即生效」,避免重复提交 */
|
||||
.dlg-warn {
|
||||
color: #E6A23C;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@ -199,7 +199,7 @@
|
||||
v-if="row.status === 'borrowed' || row.status === 'partial_returned'"
|
||||
type="danger" link size="small"
|
||||
@click="openScrapDialog(row)"
|
||||
>报废</el-button>
|
||||
>申请报废</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -212,10 +212,10 @@
|
||||
style="margin-top:10px; text-align:right"
|
||||
/>
|
||||
|
||||
<!-- ★ 借库报废弹窗 -->
|
||||
<el-dialog v-model="scrapDialogVisible" title="借库报废(未归还)" width="650px" destroy-on-close>
|
||||
<!-- ★ 借库报废**申请**弹窗(需审批,不再直报) -->
|
||||
<el-dialog v-model="scrapDialogVisible" title="申请借库报废(未归还)" width="650px" destroy-on-close>
|
||||
<el-alert
|
||||
title="报废后该笔借出不再追讨归还,并从总库存中扣除。此操作不可恢复,请谨慎确认。"
|
||||
title="提交后进入审批流程:审批通过并执行后,该笔借出才不再追讨归还,并从总库存中扣除。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
@ -245,10 +245,15 @@
|
||||
placeholder="请填写报废原因,如:物品丢失 / 损坏无法归还"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="审批人" required>
|
||||
<el-select v-model="scrapApproverId" placeholder="请选择审批人" style="width: 100%" filterable>
|
||||
<el-option v-for="u in approvers" :key="u.id" :label="u.username" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="scrapDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="scrapSubmitting" @click="confirmScrap">确认报废</el-button>
|
||||
<el-button type="danger" :loading="scrapSubmitting" @click="confirmScrap">提交报废申请</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@ -262,6 +267,8 @@ import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
dayjs.locale('zh-cn')
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getApproversList } from '@/api/auth'
|
||||
import { submitBorrowScrapRequest } from '@/api/transaction'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
@ -365,11 +372,10 @@ const resetAdvancedFilter = () => {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// ★ 借库报废相关
|
||||
// ★ 借库报废**申请**相关(需审批,不再直报)
|
||||
const canScrap = computed(() =>
|
||||
userStore.role === 'SUPER_ADMIN' ||
|
||||
userStore.hasPermission('op_return:operation') ||
|
||||
userStore.username === 'IRIS'
|
||||
userStore.hasPermission('op_return:operation')
|
||||
)
|
||||
const scrapDialogVisible = ref(false)
|
||||
const scrapSubmitting = ref(false)
|
||||
@ -377,15 +383,28 @@ const scrapBorrowNo = ref('')
|
||||
const scrapCandidates = ref<any[]>([])
|
||||
const scrapSelected = ref<any[]>([])
|
||||
const scrapReason = ref('')
|
||||
const scrapApproverId = ref<number | null>(null)
|
||||
const approvers = ref<any[]>([])
|
||||
|
||||
// 打开报废弹窗:收集该单号下所有未归还明细
|
||||
const loadApprovers = async () => {
|
||||
try {
|
||||
const res = await getApproversList()
|
||||
approvers.value = res.data || []
|
||||
} catch (e) {
|
||||
console.error('[借库报废申请] 审批人列表加载失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开报废申请弹窗:收集该单号下所有未归还明细
|
||||
const openScrapDialog = (row: any) => {
|
||||
const unreturned = (row.children || []).filter((c: any) => (c.pending_quantity || 0) > 0)
|
||||
scrapBorrowNo.value = row.borrow_no
|
||||
scrapCandidates.value = unreturned
|
||||
scrapSelected.value = []
|
||||
scrapReason.value = ''
|
||||
scrapApproverId.value = null
|
||||
scrapDialogVisible.value = true
|
||||
loadApprovers()
|
||||
}
|
||||
|
||||
// 勾选变更
|
||||
@ -393,16 +412,18 @@ const handleScrapSelection = (rows: any[]) => {
|
||||
scrapSelected.value = rows
|
||||
}
|
||||
|
||||
// 确认报废
|
||||
// 提交报废申请
|
||||
const confirmScrap = async () => {
|
||||
if (scrapSelected.value.length === 0) return ElMessage.warning('请至少选择一条要报废的借出记录')
|
||||
if (scrapSelected.value.length === 0) return ElMessage.warning('请至少选择一条要申请报废的借出记录')
|
||||
if (!scrapReason.value.trim()) return ElMessage.warning('请填写报废原因')
|
||||
if (!scrapApproverId.value) return ElMessage.warning('请选择审批人')
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定报废 ${scrapSelected.value.length} 条借出记录吗?\n\n报废后不再追讨归还,并从总库存中扣除。此操作不可恢复!`,
|
||||
'⚠️ 报废确认',
|
||||
{ confirmButtonText: '确认报废', cancelButtonText: '取消', type: 'warning' }
|
||||
`确定对 ${scrapSelected.value.length} 条借出记录提交报废申请吗?\n\n`
|
||||
+ '提交后进入审批流程,审批通过并执行后才会扣减总库存。',
|
||||
'报废申请确认',
|
||||
{ confirmButtonText: '提交申请', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch (e) {
|
||||
return // 用户取消
|
||||
@ -411,16 +432,19 @@ const confirmScrap = async () => {
|
||||
scrapSubmitting.value = true
|
||||
try {
|
||||
const recordIds = scrapSelected.value.map((c: any) => c.id)
|
||||
await request({
|
||||
url: '/v1/transactions/borrow/scrap',
|
||||
method: 'post',
|
||||
data: { record_ids: recordIds, reason: scrapReason.value }
|
||||
const res = await submitBorrowScrapRequest({
|
||||
record_ids: recordIds,
|
||||
reason: scrapReason.value,
|
||||
approver_id: scrapApproverId.value,
|
||||
})
|
||||
ElMessage.success(`已报废 ${recordIds.length} 条借出记录`)
|
||||
// ★ 提示里点明「尚未扣减」—— 申请不立即改状态,否则用户会以为没生效而重复提交
|
||||
ElMessage.success(
|
||||
`${res?.msg || '报废申请已提交'}:审批通过并执行后才会标记为已报废`,
|
||||
)
|
||||
scrapDialogVisible.value = false
|
||||
fetchData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.msg || err?.message || '报废失败')
|
||||
ElMessage.error(err?.msg || err?.message || '提交报废申请失败')
|
||||
} finally {
|
||||
scrapSubmitting.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user