Compare commits
39 Commits
07567d2f76
...
3.0AI添加
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b4a2b29d6 | |||
| 3851ebd7bc | |||
| d99396ecb1 | |||
| 91db54982c | |||
| 0f929468a8 | |||
| b85be2dfdf | |||
| de34a939d0 | |||
| 3b777af87d | |||
| 2319a053df | |||
| 6a7761abbb | |||
| 0abc5850d8 | |||
| cc80fdd9c9 | |||
| b6ce754ba7 | |||
| e0ea946e08 | |||
| 8db33fdf40 | |||
| 3bb5fc6eb8 | |||
| 4f922a55f3 | |||
| d7f7548cee | |||
| 9ea79a55f8 | |||
| 26a1857acb | |||
| 970c03fb44 | |||
| 70dd257392 | |||
| 690ee2c81d | |||
| b4445f07d9 | |||
| dec33b685c | |||
| fc5e62c848 | |||
| caaa1571d4 | |||
| 1b6a6f5c19 | |||
| b4e43d641a | |||
| ac22c9959b | |||
| 825c088e98 | |||
| f2bb95becc | |||
| 243355bc68 | |||
| eafdf992c7 | |||
| e3fe1fc12a | |||
| ccdd6e7306 | |||
| 1eaa203eba | |||
| 91c4ae85db | |||
| 191724a176 |
36
db_migrations/add_bom_remark.sql
Normal file
36
db_migrations/add_bom_remark.sql
Normal file
@ -0,0 +1,36 @@
|
||||
-- =============================================================================
|
||||
-- 一次性迁移:BOM 级备注落库
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 背景:
|
||||
-- 前端 BomManage.vue 一直有"备注"输入框(v-model="form.remark"),提交时也
|
||||
-- 确实放进了 payload(BomManage.vue:1273),但后端 BomService.save_bom() 从
|
||||
-- 未读取 data['remark'],父件级备注被**静默丢弃**——填了、保存、再打开必为空。
|
||||
--
|
||||
-- 根因是数据模型缺位:bom_table 是子件明细表(一行 = 一个子件),表里只有
|
||||
-- 子件级的 remark 列,没有任何位置存放 BOM 级备注。
|
||||
-- 连带 bug:读取侧 get_bom_detail() 用 first.BomTable.remark(第一个子件行的
|
||||
-- 备注)冒充主表备注,所以查看时会显示某个子件的备注(如"白色")或空。
|
||||
--
|
||||
-- 方案:
|
||||
-- bom_table / bom_draft_table 各增加 bom_remark 列,沿用 parent_id、is_enabled
|
||||
-- 既有的冗余写法:同一 BOM 版本的每一行存同一份值,读取时取首行。
|
||||
-- 与既有 remark(子件级)语义分离,互不干扰。
|
||||
--
|
||||
-- 存量数据:
|
||||
-- 无法回填 —— 历史上填过的备注从未入库,任何位置都找不到,只能从本迁移
|
||||
-- 之后开始记录。这是本次修复的已知代价。
|
||||
--
|
||||
-- 执行方式: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- 注意: 若后端启用 Redis 缓存(bom:tree:*),部署后需清缓存或重启后端,
|
||||
-- 避免 12h 内命中旧值。
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE bom_table ADD COLUMN IF NOT EXISTS bom_remark text;
|
||||
ALTER TABLE bom_draft_table ADD COLUMN IF NOT EXISTS bom_remark text;
|
||||
|
||||
COMMENT ON COLUMN bom_table.bom_remark IS 'BOM级备注(父件级,冗余存于每行,读取取首行)';
|
||||
COMMENT ON COLUMN bom_draft_table.bom_remark IS 'BOM级备注(父件级,冗余存于每行,读取取首行)';
|
||||
|
||||
COMMIT;
|
||||
68
db_migrations/phase10_purchase_supplier_link_text.sql
Normal file
68
db_migrations/phase10_purchase_supplier_link_text.sql
Normal file
@ -0,0 +1,68 @@
|
||||
-- =============================================================================
|
||||
-- 采购申请「商家地址链接」放宽为 text
|
||||
--
|
||||
-- 现象
|
||||
-- 新建采购申请时粘贴电商商品链接报错。实测该链接 **516 字符**,而
|
||||
-- purchase_request.supplier_link 是 varchar(500) —— 超出 16 个字符,
|
||||
-- PostgreSQL 直接拒绝(value too long for type character varying(500))。
|
||||
--
|
||||
-- 为什么用 text 而不是把 500 调大
|
||||
-- 电商外链(淘宝/1688 等)普遍带很长的跟踪参数,长度没有稳定上界:
|
||||
-- 本例已 516,再叠加一轮营销参数就可能破千。任何有限的 varchar(N) 都只是
|
||||
-- 把报错往后推,而且**截断是静默的** —— 用户只会觉得「链接打不开」。
|
||||
-- 同 material_base.purchase_link 的处理(那边一开始就用 text)。
|
||||
--
|
||||
-- 附带确认(无需改动)
|
||||
-- 全库扫描 varchar 且列名像 link/url/photo/image/signature/path 的列,仅本列
|
||||
-- 是用户粘贴的外链;sys_menu.path(200)、sys_warehouse_location.full_path(500)
|
||||
-- 是内部生成数据,stock_adjustment.linked_*(50/100) 是 SKU 与单号,均不受影响。
|
||||
--
|
||||
-- 幂等:ALTER TYPE 对已是 text 的列会报错,故用 DO 块先判断当前类型。
|
||||
-- 不含 psql 元命令,DataGrip 可直接执行。
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'purchase_request'
|
||||
AND column_name = 'supplier_link'
|
||||
AND data_type = 'character varying'
|
||||
) THEN
|
||||
-- 加长类型不会丢数据(varchar → text 是放宽,不是转换)
|
||||
ALTER TABLE purchase_request
|
||||
ALTER COLUMN supplier_link TYPE text;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN purchase_request.supplier_link IS
|
||||
'商家地址链接。text 无长度上限 —— 电商外链常带很长的跟踪参数,varchar(N) 会把「链接太长」变成静默失败';
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 执行后核对
|
||||
-- =============================================================================
|
||||
SELECT '=== 1) 列类型应为 text(character_maximum_length 为空)===' AS "核对项";
|
||||
SELECT column_name, data_type, character_maximum_length AS 上限
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'purchase_request' AND column_name = 'supplier_link';
|
||||
|
||||
SELECT '=== 2) 现有数据长度分布(确认无截断)===' AS "核对项";
|
||||
SELECT count(*) AS 链接总数,
|
||||
count(*) FILTER (WHERE supplier_link IS NOT NULL AND supplier_link <> '') AS 非空数,
|
||||
coalesce(max(length(supplier_link)), 0) AS 最长字符数
|
||||
FROM purchase_request;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段
|
||||
-- 注意:回滚前必须先确认没有超过 500 字符的数据,否则会失败。
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- ALTER TABLE purchase_request
|
||||
-- ALTER COLUMN supplier_link TYPE varchar(500);
|
||||
-- COMMIT;
|
||||
112
db_migrations/phase9_fix_return_time_utc.sql
Normal file
112
db_migrations/phase9_fix_return_time_utc.sql
Normal file
@ -0,0 +1,112 @@
|
||||
-- =============================================================================
|
||||
-- 修正存量「归还时间 / 报废时间」的 8 小时时区偏差
|
||||
--
|
||||
-- 问题
|
||||
-- process_return 与 scrap_sources.deduct 原先用 datetime.now() 写 return_time /
|
||||
-- operation_time,取的是**容器本地时间**(Docker 默认 UTC);而同一行的
|
||||
-- borrow_time 由 beijing_time() 写入。同一行两个时钟,差 8 小时。
|
||||
-- 实测(开发库):53 行 trans_borrow 出现「归还时间早于借出时间」这种
|
||||
-- 物理上不可能的数据,例如 id=80 借出 09-09 11:04、归还 09-09 03:46。
|
||||
--
|
||||
-- 代码侧已修
|
||||
-- 提交 f7c49f4(2026-09-17 09:18 北京时间)起改用 beijing_time(),
|
||||
-- **新写入的数据已经是北京时间**(已验证 TransReturn / TransBorrowReturn /
|
||||
-- TransScrap 三张表的模型默认值均为北京)。本脚本只处理存量。
|
||||
--
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- ★★ 执行前必读:请先跑「第 0 步 自检」,据其结果决定是否执行 ★★
|
||||
--
|
||||
-- 1) 若自检的「异常行数」为 0,且 UTC 区间计数为 0 —— 说明**本服务器无此问题**
|
||||
-- (例如 API 容器的 TZ 本就是 Asia/Shanghai,datetime.now() 一直是北京时间),
|
||||
-- **不要执行第 2 步**,否则会把正确的时间又 +8 小时。
|
||||
--
|
||||
-- 2) 分界时间 @boundary 的含义:**修复代码在本服务器部署生效的时刻**。
|
||||
-- · 该时刻之前写入的行 → UTC,需 +8h;
|
||||
-- · 该时刻之后写入的行 → 北京时间,不能动。
|
||||
-- 开发库用的是提交时间 2026-09-17 09:18(北京)= 01:18(UTC),
|
||||
-- 两组数据之间通常有一段空档,请用自检查看空档是否存在。
|
||||
-- 若你的部署时间不同,请替换下方两处 '2026-09-17 01:18:00'。
|
||||
--
|
||||
-- 幂等性
|
||||
-- ⚠ 本脚本**不幂等** —— 重复执行会把已经修正过的时间再加 8 小时。
|
||||
-- 请在执行前确认已备份,且只执行一次。
|
||||
--
|
||||
-- 回滚
|
||||
-- 把两处 `+ interval '8 hours'` 改成 `- interval '8 hours'`,用同一条件再执行一次。
|
||||
-- =============================================================================
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 第 0 步:自检(只读,先跑这一段看结果)
|
||||
-- =============================================================================
|
||||
SELECT '=== 0.1) 物理上不可能的「归还早于借出」行数 ===' AS "核对项";
|
||||
SELECT count(*) AS 异常行数
|
||||
FROM trans_borrow
|
||||
WHERE return_time IS NOT NULL AND borrow_time IS NOT NULL
|
||||
AND return_time < borrow_time;
|
||||
|
||||
SELECT '=== 0.2) 按分界切分:UTC 段 / 空档 / 北京段 ===' AS "核对项";
|
||||
SELECT
|
||||
count(*) FILTER (WHERE return_time < '2026-09-17 01:18:00') AS UTC段_需修正,
|
||||
count(*) FILTER (WHERE return_time >= '2026-09-17 01:18:00'
|
||||
AND return_time < '2026-09-17 09:18:00') AS 空档_应为0,
|
||||
count(*) FILTER (WHERE return_time >= '2026-09-17 09:18:00') AS 北京段_不可动
|
||||
FROM trans_borrow
|
||||
WHERE return_time IS NOT NULL;
|
||||
|
||||
SELECT '=== 0.3) trans_scrap 同样切分 ===' AS "核对项";
|
||||
SELECT
|
||||
count(*) FILTER (WHERE operation_time < '2026-09-17 01:18:00') AS UTC段_需修正,
|
||||
count(*) FILTER (WHERE operation_time >= '2026-09-17 01:18:00'
|
||||
AND operation_time < '2026-09-17 09:18:00') AS 空档_应为0,
|
||||
count(*) FILTER (WHERE operation_time >= '2026-09-17 09:18:00') AS 北京段_不可动
|
||||
FROM trans_scrap;
|
||||
|
||||
-- 判读:
|
||||
-- · 0.1 为 0 且 0.2/0.3 的 UTC 段也为 0 → 无需修正,停止。
|
||||
-- · 0.1 大于 0 → 确有此问题,可继续第 2 步。
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 第 1 步:备份(强烈建议)
|
||||
-- =============================================================================
|
||||
-- CREATE TABLE trans_borrow_bak_20260918 AS
|
||||
-- SELECT id, return_time, return_operator FROM trans_borrow;
|
||||
-- CREATE TABLE trans_scrap_bak_20260918 AS
|
||||
-- SELECT id, operation_time FROM trans_scrap;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 第 2 步:修正(确认第 0 步结果后再执行)
|
||||
-- =============================================================================
|
||||
BEGIN;
|
||||
|
||||
UPDATE trans_borrow
|
||||
SET return_time = return_time + interval '8 hours'
|
||||
WHERE return_time IS NOT NULL
|
||||
AND return_time < '2026-09-17 01:18:00'; -- ← 替换为你的部署时刻(UTC 口径)
|
||||
|
||||
UPDATE trans_scrap
|
||||
SET operation_time = operation_time + interval '8 hours'
|
||||
WHERE operation_time < '2026-09-17 01:18:00'; -- ← 同上
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 第 3 步:执行后核对
|
||||
-- =============================================================================
|
||||
SELECT '=== 3.1) 异常行数应为 0 ===' AS "核对项";
|
||||
SELECT count(*) AS 异常行数
|
||||
FROM trans_borrow
|
||||
WHERE return_time IS NOT NULL AND borrow_time IS NOT NULL
|
||||
AND return_time < borrow_time;
|
||||
|
||||
SELECT '=== 3.2) 抽样:归还时间应晚于借出时间,且落在工作时段 ===' AS "核对项";
|
||||
SELECT id,
|
||||
to_char(borrow_time, 'MM-DD HH24:MI') AS 借出,
|
||||
to_char(return_time, 'MM-DD HH24:MI') AS 归还
|
||||
FROM trans_borrow
|
||||
WHERE return_time IS NOT NULL
|
||||
ORDER BY return_time DESC
|
||||
LIMIT 8;
|
||||
@ -41,6 +41,16 @@ services:
|
||||
TRACK_API_URL: ${TRACK_API_URL:-}
|
||||
# Track 出库 webhook(发货出库时通知 Track 标记"已出库")
|
||||
TRACK_OUTBOUND_WEBHOOK_URL: ${TRACK_OUTBOUND_WEBHOOK_URL:-}
|
||||
# ★ 多 Track 实例路由表:按 material_base.company_name 决定走哪个实例。
|
||||
# 上面三个扁平变量保留为「未命中路由表」时的回落默认值(向后兼容)。
|
||||
# ★ 用容器名互访(同属 projects_default 网络),不走宿主机端口。
|
||||
TRACK_ROUTES: >-
|
||||
{"IRIS":{"api":"http://track_backend:8000",
|
||||
"inbound":"http://track_backend:8000/api/v1/external/webhooks/mom-inbound",
|
||||
"outbound":"http://track_backend:8000/api/v1/external/webhooks/mom-outbound"},
|
||||
"LICA":{"api":"http://lica_backend:8000",
|
||||
"inbound":"http://lica_backend:8000/api/v1/external/webhooks/mom-inbound",
|
||||
"outbound":"http://lica_backend:8000/api/v1/external/webhooks/mom-outbound"}}
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
|
||||
@ -198,6 +198,8 @@ def save_bom():
|
||||
'version': 'bom_manage:version',
|
||||
'is_enabled': 'bom_manage:status',
|
||||
'bom_no': 'bom_manage:bom_no',
|
||||
# ★ 顶级 remark = BOM 级备注,与子件级 remark 共用同一权限码
|
||||
'remark': 'bom_manage:remark',
|
||||
}
|
||||
# 清洗顶级字段
|
||||
for field in list(req_data.keys()):
|
||||
@ -531,7 +533,12 @@ def save_draft():
|
||||
if not parent_id:
|
||||
return jsonify({'code': 400, 'msg': 'parent_id 不能为空'}), 400
|
||||
|
||||
bom_draft_no = BomDraftService.save_draft(bom_no, version, parent_id, children)
|
||||
# ★ 这是运行时真正生效的草稿入口(bom_draft.py 的 bom_draft_bp 未在
|
||||
# app/__init__.py 注册,是死代码),BOM 级备注必须在这里透传
|
||||
bom_draft_no = BomDraftService.save_draft(
|
||||
bom_no, version, parent_id, children,
|
||||
bom_remark=data.get('remark', '') or ''
|
||||
)
|
||||
return jsonify({'code': 200, 'msg': '草稿暂存成功', 'data': {'bom_no': bom_draft_no}})
|
||||
|
||||
|
||||
|
||||
@ -238,6 +238,10 @@ def get_outbound_list():
|
||||
keyword = request.args.get('keyword', '')
|
||||
search_type = request.args.get('search_type', 'all')
|
||||
company = request.args.get('company', '')
|
||||
# ★ 出库日期范围:前端 el-date-picker 传 10 位 YYYY-MM-DD,
|
||||
# 时分秒补全在 service 层完成(与 /returns 等处口径一致)
|
||||
start_date = (request.args.get('start_date') or '').strip()
|
||||
end_date = (request.args.get('end_date') or '').strip()
|
||||
|
||||
# ★ 高级筛选:JSON 字符串 → 条件列表(解析失败退化为空,不影响主查询)
|
||||
from app.utils.advanced_filter import parse_advanced_filters
|
||||
@ -259,6 +263,7 @@ def get_outbound_list():
|
||||
# ★ [修改] 调用分组查询服务,支持搜索类型
|
||||
result = OutboundService.get_grouped_list(
|
||||
page, limit, keyword, search_type=search_type,
|
||||
start_date=start_date, end_date=end_date,
|
||||
company=company, consumer_name=consumer_name,
|
||||
advanced_filters=advanced_filters,
|
||||
)
|
||||
@ -448,7 +453,22 @@ def _allocate_bom_requirements(requirements, company_limit,
|
||||
from sqlalchemy.orm import joinedload # ★ 必须在此导入:本函数模块级作用域不可见
|
||||
|
||||
# 归一化需求,容忍字符串数字
|
||||
#
|
||||
# ★ 必须按 base_id 合并。同一物料会以多行进入本函数,来源都是正常业务:
|
||||
# · 购物车里同一物料的多批次就是多行(Selection.vue 提交时只带
|
||||
# base_id + quantity,stock_id 被丢弃);
|
||||
# · BOM 明细里同一子件被多处引用(前端 requirements 按 child_id 不去重);
|
||||
# · 调拨 / 补发等拆行场景。
|
||||
# 而下方分配是「按 base_id 拉全量批次行、降序分配」,且候选快照在整轮
|
||||
# for req 循环里**不更新** —— 同一个 base_id 出现 N 行,每行都会从同一份
|
||||
# 快照重新分配一遍,把同一个 stock_id 重复分配 N 次。
|
||||
# 超配在分配阶段不会暴露,直到 reserve_for_items 的二次校验
|
||||
# (take > avail)才炸,报「可用库存不足」而实际库存充足。
|
||||
# 合并后 required_qty 求和,分配语义不变(输入里的 stock_id 本就被忽略)。
|
||||
# ★ 不采用「合并入参 items」的方案:那是症状侧,_allocate_bom_requirements
|
||||
# 才是全系统库存分配的唯一权威入口,在此收口可同时覆盖出库与借库。
|
||||
reqs = []
|
||||
merged = {}
|
||||
for r in requirements:
|
||||
try:
|
||||
bid = int(r.get('base_id'))
|
||||
@ -460,8 +480,13 @@ def _allocate_bom_requirements(requirements, company_limit,
|
||||
need = 0.0
|
||||
if bid <= 0 or need <= 0:
|
||||
continue
|
||||
reqs.append({'base_id': bid, 'required_qty': need,
|
||||
'name': r.get('name') or '', 'spec_model': r.get('spec_model') or ''})
|
||||
hit = merged.get(bid)
|
||||
if hit is not None:
|
||||
hit['required_qty'] += need
|
||||
else:
|
||||
merged[bid] = {'base_id': bid, 'required_qty': need,
|
||||
'name': r.get('name') or '', 'spec_model': r.get('spec_model') or ''}
|
||||
reqs = list(merged.values())
|
||||
|
||||
if not reqs:
|
||||
return jsonify({'code': 400, 'msg': 'requirements 中无有效的 base_id/required_qty'}), 400
|
||||
|
||||
@ -53,6 +53,25 @@ def _filter_purchase_prices(item_dict):
|
||||
item_dict.pop('tax_rate', None)
|
||||
|
||||
|
||||
def _has_material_reference_price_perm():
|
||||
"""
|
||||
能否看物料的参考价格。
|
||||
|
||||
★ 参考价格(material_base.reference_price)是受 material_list:referencePrice
|
||||
管控的价格数据,与物料列表同源。任何暴露它的新接口都必须用**同一个码**,
|
||||
否则就成了绕过该权限的后门 —— 物料列表里挡住的数字,换个页面就看到了。
|
||||
判定方式与 _filter_purchase_prices 保持一致(超管/主管放行 + 逐个权限码比对)。
|
||||
"""
|
||||
from app.services.auth_service import AuthService
|
||||
claims = get_jwt()
|
||||
role = claims.get('role', '')
|
||||
if role.upper() in ('SUPER_ADMIN', 'SUPERVISOR'):
|
||||
return True
|
||||
perm_dict = AuthService.get_user_permissions(role, company_name=claims.get('company_name', ''))
|
||||
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
return 'material_list:referencePrice' in all_perms
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 1. 采购申请列表
|
||||
# GET /api/v1/purchase
|
||||
@ -423,3 +442,39 @@ def search_material_for_purchase():
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 9. 待采购池(防重复采购)
|
||||
# GET /api/v1/purchase/pending-pool?page=1&limit=20&keyword=xxx
|
||||
# --------------------------------------------------------
|
||||
@purchase_bp.route('/pending-pool', methods=['GET'])
|
||||
@jwt_required()
|
||||
@permission_required('inbound_purchase:pending_pool')
|
||||
def get_pending_purchase_pool():
|
||||
"""
|
||||
待采购清单:库存已触及预警线、且没有活跃采购单的物料。
|
||||
|
||||
防重逻辑的核心 —— 物料一旦有在途采购单(待审批/已通过/部分到货未齐),
|
||||
立即从本列表中消失;单据被驳回或强制结案后才会重新回流。
|
||||
"""
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
limit = request.args.get('limit', 20, type=int)
|
||||
keyword = request.args.get('keyword', '').strip() or None
|
||||
category = request.args.get('category', '').strip() or None
|
||||
material_type = request.args.get('type', '').strip() or None
|
||||
|
||||
warning_status = request.args.get('warning_status', type=int)
|
||||
|
||||
result = PurchaseService.get_pending_purchase_pool(
|
||||
page=page, per_page=limit, keyword=keyword,
|
||||
category=category, material_type=material_type,
|
||||
warning_status=warning_status,
|
||||
# 参考价格按 material_list:referencePrice 管控,无权限时不返回该字段
|
||||
include_reference_price=_has_material_reference_price_perm(),
|
||||
)
|
||||
return jsonify({'code': 200, 'msg': '获取成功', 'data': result}), 200
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'获取失败: {str(e)}'}), 500
|
||||
|
||||
@ -25,6 +25,10 @@ class BomTable(db.Model):
|
||||
loss_rate = db.Column(db.Numeric(5, 2), comment='损耗率%', default=0, nullable=True)
|
||||
remark = db.Column(db.Text, comment='备注')
|
||||
|
||||
# ★ BOM 级备注(父件级):与 parent_id/is_enabled 一样冗余存于本版本每一行,读取取首行。
|
||||
# 上面的 remark 是**子件级**备注,语义不同,勿混用。
|
||||
bom_remark = db.Column(db.Text, comment='BOM级备注(父件级,冗余存于每行,读取取首行)')
|
||||
|
||||
# ★ 子件引用的"自制 BOM"版本:子件物料若本身是自制件(作为其它 BOM 的父件),
|
||||
# 此列记录该子件实际引用的配方版本((bom_no, version) 共同定位),由新建/编辑 BOM 时选版本保存。
|
||||
# 外购件/无下级 BOM 的子件这两列为 NULL。
|
||||
|
||||
@ -23,6 +23,10 @@ class BomDraftTable(db.Model):
|
||||
loss_rate = db.Column(db.Numeric(5, 2), default=0, nullable=True, comment='损耗率%')
|
||||
remark = db.Column(db.Text, comment='备注')
|
||||
|
||||
# ★ BOM 级备注(父件级):与 bom_table.bom_remark 对齐,草稿暂存/发布期间不丢失。
|
||||
# 上面的 remark 是**子件级**备注。占位头行(child_id IS NULL)同样携带该值。
|
||||
bom_remark = db.Column(db.Text, comment='BOM级备注(父件级,冗余存于每行,读取取首行)')
|
||||
|
||||
# ★ 子件引用的"自制 BOM"版本:与 bom_table 的 child_bom_no/child_bom_version 对齐,
|
||||
# 保证草稿暂存/载入期间不丢失所选版本。
|
||||
child_bom_no = db.Column(db.String(100), nullable=True, index=True, comment='子件引用的自制BOM编号')
|
||||
|
||||
@ -17,7 +17,12 @@ class PurchaseRequest(db.Model):
|
||||
spec_model = db.Column(db.String(255), comment='规格型号')
|
||||
quantity = db.Column(db.Numeric(19, 4), nullable=False, comment='采购数量')
|
||||
purchase_date = db.Column(db.Date, nullable=False, comment='采购时间')
|
||||
supplier_link = db.Column(db.String(500), comment='商家地址链接')
|
||||
# ★ 用 Text 而非 String(N):电商外链常带很长的跟踪参数,长度没有稳定上界
|
||||
# (实测一条淘宝链接已 516 字符,超过原先的 varchar(500) 直接报错)。
|
||||
# 任何有限的 varchar(N) 都只是把报错往后推,且**截断是静默的** ——
|
||||
# 用户只会觉得「链接打不开」。同 material_base.purchase_link 的处理。
|
||||
# DDL 见 db_migrations/phase10_purchase_supplier_link_text.sql
|
||||
supplier_link = db.Column(db.Text, comment='商家地址链接')
|
||||
remark = db.Column(db.Text, comment='备注信息')
|
||||
images = db.Column(db.Text, comment='图片列表JSON')
|
||||
unit_price = db.Column(db.Numeric(19, 4), default=0, comment='不含税单价')
|
||||
|
||||
@ -16,11 +16,23 @@ def _borrow_user_name(uid):
|
||||
|
||||
|
||||
def _display_borrow_operator(op):
|
||||
"""归还人展示映射:历史存的是数字 user id → 映射为用户名;新数据存姓名则原样返回"""
|
||||
if op and str(op).strip().isdigit():
|
||||
mapped = _borrow_user_name(int(op))
|
||||
return mapped if mapped else op
|
||||
return op
|
||||
"""
|
||||
**经手库管**的展示映射(注意:不是「归还人」—— 两者是不同的人)。
|
||||
|
||||
该字段历史上存过三种口径,统一归一到「展示名」:
|
||||
· 数字 user id(更早期) → 反查 sys_user 取姓名;
|
||||
· 完整 username「姓名/拼音」 → 取斜杠前段(与全站展示口径一致);
|
||||
· 已经是展示名 → 原样返回。
|
||||
"""
|
||||
if not op:
|
||||
return op
|
||||
s = str(op).strip()
|
||||
if s.isdigit():
|
||||
mapped = _borrow_user_name(int(s))
|
||||
if mapped:
|
||||
return mapped.split('/')[0] if '/' in mapped else mapped
|
||||
return op
|
||||
return s.split('/')[0] if '/' in s else op
|
||||
|
||||
|
||||
class TransBorrow(db.Model):
|
||||
|
||||
@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
class BomDraftService:
|
||||
|
||||
@staticmethod
|
||||
def save_draft(bom_no, version, parent_id, children):
|
||||
def save_draft(bom_no, version, parent_id, children, bom_remark=''):
|
||||
try:
|
||||
# 1. 删除旧草稿
|
||||
old = BomDraftTable.query.filter_by(bom_no=bom_no, version=version).all()
|
||||
@ -22,7 +22,8 @@ class BomDraftService:
|
||||
if not children:
|
||||
dummy_draft = BomDraftTable(
|
||||
bom_no=bom_no, version=version, parent_id=parent_id,
|
||||
child_id=None, dosage=0, loss_rate=0, remark=''
|
||||
child_id=None, dosage=0, loss_rate=0, remark='',
|
||||
bom_remark=bom_remark
|
||||
)
|
||||
db.session.add(dummy_draft)
|
||||
else:
|
||||
@ -34,6 +35,7 @@ class BomDraftService:
|
||||
dosage=child.get('dosage', 0),
|
||||
loss_rate=child.get('loss_rate', 0),
|
||||
remark=child.get('remark', ''),
|
||||
bom_remark=bom_remark,
|
||||
child_bom_no=child.get('child_bom_no') or None,
|
||||
child_bom_version=child.get('child_bom_version') or None
|
||||
)
|
||||
@ -87,6 +89,7 @@ class BomDraftService:
|
||||
'parent_id': parent_id,
|
||||
'parent_name': parent_material.name if parent_material else '',
|
||||
'parent_spec': parent_material.spec_model if parent_material else '',
|
||||
'remark': first.bom_remark or '', # ★ BOM 级备注
|
||||
'children': children,
|
||||
}
|
||||
|
||||
@ -125,6 +128,8 @@ class BomDraftService:
|
||||
'bom_no': bom_no,
|
||||
'version': version,
|
||||
'parent_id': draft['parent_id'],
|
||||
# ★ 草稿的 BOM 级备注要带到正式表,否则发布即丢失
|
||||
'remark': draft.get('remark', '') or '',
|
||||
'children': [
|
||||
{
|
||||
'child_id': child['child_id'],
|
||||
|
||||
@ -404,7 +404,9 @@ class BomService:
|
||||
'parent_name': parent_material.name if parent_material else '',
|
||||
'parent_spec': parent_material.spec_model if parent_material else '',
|
||||
'is_enabled': first.BomTable.is_enabled,
|
||||
'remark': first.BomTable.remark or '', # ★ 主表备注
|
||||
# ★ BOM 级备注:改取 bom_remark。此前误取 first.BomTable.remark
|
||||
# (第一个子件行的备注)冒充主表备注,是本次一并修掉的连带 bug
|
||||
'remark': first.BomTable.bom_remark or '',
|
||||
'children': children
|
||||
}
|
||||
|
||||
@ -421,6 +423,8 @@ class BomService:
|
||||
parent_id = data['parent_id']
|
||||
children = data['children']
|
||||
is_enabled = data.get('is_enabled', True)
|
||||
# ★ BOM 级备注:写入本版本每一行(与 is_enabled 等父件级字段同口径)
|
||||
bom_remark = data.get('remark', '') or ''
|
||||
|
||||
if not bom_no:
|
||||
raise ValueError('BOM编号不能为空')
|
||||
@ -527,6 +531,7 @@ class BomService:
|
||||
child_id=child['child_id'],
|
||||
dosage=child.get('dosage', 0),
|
||||
remark=child.get('remark', ''),
|
||||
bom_remark=bom_remark,
|
||||
child_bom_no=child.get('child_bom_no') or None,
|
||||
child_bom_version=child.get('child_bom_version') or None,
|
||||
is_enabled=is_enabled
|
||||
|
||||
@ -171,6 +171,10 @@ class MaterialBaseService:
|
||||
|
||||
# 导入预警设置模型
|
||||
from app.models.base import MaterialWarningSetting
|
||||
# 「采购在途」判定 —— 与待采购池(purchase_service.get_pending_purchase_pool)
|
||||
# 共用同一份定义,避免两处口径漂移:有活跃采购单(待审批/已通过/部分到货未齐)
|
||||
# 即为在途;单据驳回或强制结案后自动解除。
|
||||
from app.utils.purchase_activity import active_purchase_exists
|
||||
|
||||
# ============================================================
|
||||
# 【核心修复】使用子查询方案:将聚合结果作为子查询
|
||||
@ -199,7 +203,11 @@ class MaterialBaseService:
|
||||
MaterialWarningSetting.yellow_threshold.label('warning_yellow'),
|
||||
MaterialWarningSetting.red_threshold.label('warning_red'),
|
||||
MaterialWarningSetting.red_emails.label('warning_red_emails'),
|
||||
MaterialWarningSetting.yellow_emails.label('warning_yellow_emails')
|
||||
MaterialWarningSetting.yellow_emails.label('warning_yellow_emails'),
|
||||
# ★ 只加列、不参与排序:enableWarningSort 用的是固定的 order_by
|
||||
# 表达式,与 SELECT 列表无关;且 sort_field_map 里没有它,
|
||||
# 前端无法按此列排序。既有行为逐位不变。
|
||||
active_purchase_exists(MaterialBase.id).label('is_purchasing')
|
||||
).outerjoin(inner_sub, MaterialBase.id == inner_sub.c.base_id) \
|
||||
.outerjoin(MaterialWarningSetting, MaterialBase.id == MaterialWarningSetting.base_id)
|
||||
|
||||
@ -423,6 +431,7 @@ class MaterialBaseService:
|
||||
# 说明查询只返回了 MaterialBase 单一对象
|
||||
item = row
|
||||
inv = avail = warning_enabled = warning_yellow = warning_red = None
|
||||
is_purchasing = False
|
||||
else:
|
||||
# 说明返回了 Row 对象 (包含多个字段)
|
||||
item = row[0]
|
||||
@ -433,6 +442,7 @@ class MaterialBaseService:
|
||||
warning_red = row[5] if len(row) > 5 else 0
|
||||
warning_red_emails = row[6] if len(row) > 6 else None
|
||||
warning_yellow_emails = row[7] if len(row) > 7 else None
|
||||
is_purchasing = row[8] if len(row) > 8 else False
|
||||
|
||||
# 安全兜底
|
||||
if not hasattr(item, 'to_dict'):
|
||||
@ -442,6 +452,10 @@ class MaterialBaseService:
|
||||
item_dict['inventoryCount'] = float(inv) if inv is not None else 0
|
||||
item_dict['availableCount'] = float(avail) if avail is not None else 0
|
||||
|
||||
# ★ 「采购在途」放在预警权限判断**之外**:能看到物料列表的人
|
||||
# 都应该知道这个物料已经在采购了(即使他没有查看预警的门槛)。
|
||||
item_dict['isPurchasing'] = bool(is_purchasing)
|
||||
|
||||
# 处理预警信息(仅当用户有权限时)
|
||||
if has_warning_permission:
|
||||
item_dict['warningEnabled'] = bool(warning_enabled) if warning_enabled is not None else False
|
||||
|
||||
@ -4,6 +4,7 @@ from app.extensions import db
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.product import StockProduct
|
||||
from app.models.base import MaterialBase
|
||||
from app.models.purchase import PurchaseRequest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import or_, func, text, and_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@ -153,9 +154,27 @@ class BuyInboundService:
|
||||
in_qty = float(data.get('in_quantity') or 0)
|
||||
u_price = float(data.get('unit_price') or 0)
|
||||
tax_rate = float(data.get('tax_rate') or 0)
|
||||
|
||||
# 计算税后单价
|
||||
post_tax_price = float(data.get('post_tax_unit_price') or 0)
|
||||
|
||||
# [新增] 按单入库:关联采购申请单
|
||||
request_id = data.get('request_id')
|
||||
|
||||
# ★ 补价:库管角色没有 inbound_purchase:unit_price / total_price 权限,
|
||||
# 采购单接口(api/v1/purchase.py)会把价格字段整个 pop 掉,前端导入时
|
||||
# 因此带不出价(buy.vue 的 hasUnitPrice/hasTotalPrice 双双落空,
|
||||
# 两个分支都不进)→ 入库成本被记成 0。
|
||||
# 这里在落库前从采购申请单补回:价格**不经过前端**,库管依旧看不到
|
||||
# 采购价,权限边界不变,但成本可追溯。
|
||||
# 注:采购申请单的 unit_price 存的是**含税**单价(见 purchase/index.vue
|
||||
# 的列名"含税单价"),需要反算不含税单价。
|
||||
if request_id and u_price <= 0 and post_tax_price <= 0:
|
||||
_req = PurchaseRequest.query.get(request_id)
|
||||
if _req and float(_req.unit_price or 0) > 0:
|
||||
tax_rate = float(_req.tax_rate or 0)
|
||||
post_tax_price = float(_req.unit_price)
|
||||
u_price = post_tax_price / (1 + tax_rate / 100)
|
||||
|
||||
# 计算税后单价(保底:只给了不含税价时反推)
|
||||
if post_tax_price == 0 and u_price > 0:
|
||||
tax_multiplier = 1 + (tax_rate / 100)
|
||||
post_tax_price = u_price * tax_multiplier
|
||||
@ -170,9 +189,6 @@ class BuyInboundService:
|
||||
generated_sku = str(next_global_id).zfill(10) if next_global_id else datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
final_barcode = data.get('barcode') or generated_sku
|
||||
|
||||
# [新增] 按单入库:关联采购申请单
|
||||
request_id = data.get('request_id')
|
||||
|
||||
new_stock = StockBuy(
|
||||
base_id=material.id, global_print_id=next_global_id, sku=generated_sku, barcode=final_barcode,
|
||||
in_date=in_date_val, serial_number=data.get('serial_number'), batch_number=data.get('batch_number'),
|
||||
@ -201,8 +217,10 @@ class BuyInboundService:
|
||||
db.session.flush() # 获取 new_stock.id
|
||||
|
||||
# [新增] 按单入库:反写采购申请单状态为"已完成"
|
||||
# 注:原先此处有一句局部 `from app.models.purchase import PurchaseRequest`,
|
||||
# 它会让 Python 把 PurchaseRequest 视为本函数的局部变量,导致上方补价
|
||||
# 逻辑(在 import 之前使用)抛 UnboundLocalError。已改用文件顶部的全局导入。
|
||||
if request_id:
|
||||
from app.models.purchase import PurchaseRequest
|
||||
purchase_req = db.session.get(PurchaseRequest, request_id)
|
||||
if purchase_req:
|
||||
if purchase_req.status != 1:
|
||||
|
||||
@ -226,6 +226,8 @@ class ProductInboundService:
|
||||
'sku': material.spec_model or material.name,
|
||||
'serial_number': new_stock.serial_number,
|
||||
'quantity': float(new_stock.in_quantity or 0),
|
||||
# ★ 公司名决定通知哪个 Track 实例(IRIS / LICA),缺失则回落默认
|
||||
'company_name': getattr(material, 'company_name', None),
|
||||
'operator': get_current_operator(),
|
||||
})
|
||||
return new_stock
|
||||
|
||||
@ -265,6 +265,8 @@ class SemiInboundService:
|
||||
'sku': material.spec_model or material.name,
|
||||
'serial_number': webhook_sn,
|
||||
'quantity': float(new_stock.in_quantity or 0),
|
||||
# ★ 公司名决定通知哪个 Track 实例(IRIS / LICA),缺失则回落默认
|
||||
'company_name': getattr(material, 'company_name', None),
|
||||
'operator': get_current_operator(),
|
||||
})
|
||||
return new_stock
|
||||
|
||||
@ -163,6 +163,23 @@ def stock_identity(model_row):
|
||||
return identity_key(base_id, name, spec, getattr(model_row, 'sku', ''))
|
||||
|
||||
|
||||
def stock_name_spec(model_row):
|
||||
"""
|
||||
库存行 → **名称型**身份键 ('name', 名称, 规格)。
|
||||
|
||||
★ 与 stock_identity() 的区别:后者优先用 base_id 产出 ('base', id),
|
||||
本函数**刻意丢掉 base_id**,强制走名称兜底。用途见
|
||||
build_legacy_approval_index() —— 给没有 base_id 的历史批准明细
|
||||
提供一条可比的旁路键。
|
||||
"""
|
||||
base = getattr(model_row, 'base', None)
|
||||
return identity_key(
|
||||
None,
|
||||
base.name if base else '',
|
||||
base.spec_model if base else '',
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 预占 / 释放
|
||||
# =============================================================================
|
||||
@ -435,6 +452,48 @@ def build_approval_index(approved_items):
|
||||
return index
|
||||
|
||||
|
||||
def build_legacy_approval_index(approved_items):
|
||||
"""
|
||||
无 base_id 的历史明细 → {('name', 名称, 规格): {'qty', 'label'}}
|
||||
|
||||
★ 为什么需要这个旁路索引
|
||||
------------------------
|
||||
2026-09-10 预占改造(b57c21a)之前建的单,items_json 里**没有 base_id**
|
||||
(亦无 stock_id / source_table / reserved)。build_approval_index() 对它
|
||||
只能产出名称型身份键 ('name', 名称, 规格);而扫码侧 stock_identity()
|
||||
拿的是库存行的 base_id,产出 ('base', id)。两侧键不同构,**永远不相等**,
|
||||
于是这类老单只要还没执行完就必然被 verify_scanned() 拒绝,报
|
||||
「扫码物料【…】不在该申请单的批准明细中」—— 明明批的就是这件货。
|
||||
|
||||
实测(单 APR-OUT-20260909-1313-0007,建于 09-09、改造上线前一天):
|
||||
批准侧 ('name', '派里肯安全箱1600(黑色)', 'PS-9640B001-black')
|
||||
扫码侧 ('base', 3012)
|
||||
identity_key() 的文档本就把 (name, spec_model) 写作「历史数据的兜底」,
|
||||
只是校验时只有批准侧降了级、扫码侧没有,两侧因此错开。
|
||||
|
||||
★ 严格限定适用范围(避免放宽现代单据的校验)
|
||||
· 只收录身份键为名称型(即无有效 base_id)的明细;有 base_id 的一律
|
||||
走 build_approval_index() 的主索引,此处跳过。
|
||||
· 名称与规格都为空的明细直接丢弃 —— 无法兜底,且绝不能退化成
|
||||
「任意物料都能匹配」。
|
||||
本索引为空时(无老单),verify_scanned() 的降级分支恒不命中,
|
||||
行为与改造前完全一致。
|
||||
"""
|
||||
index = {}
|
||||
for it in approved_items or []:
|
||||
key = identity_key(it.get('base_id'), it.get('name'), it.get('spec_model'))
|
||||
if key[0] != 'name':
|
||||
continue # 有 base_id → 归主索引,不放宽
|
||||
if not key[1] and not key[2]:
|
||||
continue # 名称规格皆空,无从兜底
|
||||
entry = index.setdefault(key, {'qty': 0.0, 'label': identity_label(key)})
|
||||
try:
|
||||
entry['qty'] += float(it.get('quantity') or it.get('allocated_qty') or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return index
|
||||
|
||||
|
||||
def verify_scanned(scanned_items, approved_items):
|
||||
"""
|
||||
★ Phase 3 校验:实扫明细的身份与数量必须落在批准范围内。
|
||||
@ -460,6 +519,12 @@ def verify_scanned(scanned_items, approved_items):
|
||||
if not approved_idx:
|
||||
raise ValueError('审批单明细为空,无法执行')
|
||||
|
||||
# ★ 无 base_id 的历史明细旁路(说明见 build_legacy_approval_index)。
|
||||
# 这里只把它当作「哪些名称型身份键确实来自老单据」的白名单 ——
|
||||
# 数量口径仍以 approved_idx 为准,两者对同一身份给出相同的批准量。
|
||||
# 无老单时为空集,下方降级分支恒不命中,行为与改造前完全一致。
|
||||
legacy_idx = build_legacy_approval_index(approved_items)
|
||||
|
||||
acc = {}
|
||||
normalized = []
|
||||
|
||||
@ -503,9 +568,22 @@ def verify_scanned(scanned_items, approved_items):
|
||||
raise ValueError(f"物料【{label}】状态异常(当前为 {status}),禁止出库")
|
||||
|
||||
if key not in approved_idx:
|
||||
raise ValueError(
|
||||
f"扫码物料【{label}】不在该申请单的批准明细中,禁止出库"
|
||||
)
|
||||
# ★ 降级匹配(老单专用旁路)
|
||||
#
|
||||
# 无 base_id 的历史批准明细身份键是名称型 ('name', 名称, 规格),
|
||||
# 扫码侧是 ('base', id) —— 两者不同构,直接比永远落空,
|
||||
# 批的就是这件货也会被判「不在批准明细中」。
|
||||
# 此处用库存行的名称+规格再比一次;命中则**改用批准侧的键**,
|
||||
# 使 acc 累计与 approved_idx 的批准量保持同口径。
|
||||
# 白名单是 legacy_idx 而非 approved_idx 本身:只有真正来自
|
||||
# 老单据的名称型键才放行,杜绝这条旁路把现代单据的校验放宽。
|
||||
alt = stock_name_spec(row)
|
||||
if alt not in legacy_idx:
|
||||
raise ValueError(
|
||||
f"扫码物料【{label}】不在该申请单的批准明细中,禁止出库"
|
||||
)
|
||||
key = alt
|
||||
label = identity_label(key)
|
||||
|
||||
acc[key] = acc.get(key, 0.0) + qty
|
||||
if acc[key] > approved_idx[key]['qty']:
|
||||
|
||||
@ -116,10 +116,16 @@ class InventoryWarningService:
|
||||
"""
|
||||
执行库存预警扫描与邮件发送
|
||||
|
||||
1. 查询所有 is_enabled=True 且 is_ordered=False 的预警配置
|
||||
1. 查询所有 is_enabled=True 且**没有活跃采购单**的预警配置
|
||||
2. 按 level 归类物料,按邮箱聚合(同一邮箱 → 一封邮件)
|
||||
3. 调用 send_email 发送,更新 last_notified_at
|
||||
|
||||
★ 静音条件从「人工标记 is_ordered」改成了「是否有活跃采购单」:
|
||||
人工标记的前端入口已随「采购在途」自动化改造一并移除,若不改这里,
|
||||
用户将彻底失去静音能力(本函数由出库执行流程触发,见
|
||||
outbound_service.py 出库完成后的调用)。自动化判定与待采购池、
|
||||
物料列表 isPurchasing 共用同一份定义,口径一致。
|
||||
|
||||
Returns:
|
||||
{
|
||||
"red_count": N, # 触发红色预警的物料数
|
||||
@ -134,10 +140,13 @@ class InventoryWarningService:
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
now = datetime.now(beijing_tz)
|
||||
|
||||
# 查询启用了预警且未标记采购的配置
|
||||
# 查询启用了预警、且当前没有活跃采购单的配置。
|
||||
# 有在途单 → 不必再催采购,静音;单据被驳回或强制结案后自动恢复告警。
|
||||
from app.utils.purchase_activity import active_purchase_exists
|
||||
|
||||
settings = MaterialWarningSetting.query.filter(
|
||||
MaterialWarningSetting.is_enabled == True,
|
||||
MaterialWarningSetting.is_ordered == False
|
||||
~active_purchase_exists(MaterialWarningSetting.base_id),
|
||||
).all()
|
||||
|
||||
red_rows_by_email = defaultdict(list) # email -> [物料row, ...]
|
||||
|
||||
@ -238,10 +238,9 @@ class OutboundService:
|
||||
'signature_path': data.get('signature_path'),
|
||||
'operator_name': operator_name,
|
||||
'remark': data.get('remark'),
|
||||
# ★ 申请人从**关联审批单**带出。落这一列是为了让后续「退回 → 补发」
|
||||
# 能把补发单挂回真正该拿东西的人名下 —— consumer_name 是自由文本
|
||||
# (可能是客户名),不能作为依据。
|
||||
'applicant_id': approval.applicant_id,
|
||||
# ⚠ applicant_id 不在这里赋值 —— 此刻 approval 尚未查询(见下方
|
||||
# 第 2 步「强制按单出库」),在这里引用它会直接 UnboundLocalError。
|
||||
# 待 approval 取出并校验后再补进本字典。
|
||||
}
|
||||
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
@ -279,6 +278,13 @@ class OutboundService:
|
||||
f"仅已通过的审批单方可执行出库"
|
||||
)
|
||||
|
||||
# ★ 申请人从**关联审批单**带出。落这一列是为了让后续「退回 → 补发」能把
|
||||
# 补发单挂回真正该拿东西的人名下 —— consumer_name 是扫码时自由填写的
|
||||
# 领用人/客户名,不能作为依据。
|
||||
# ⚠ 必须放在 approval 取出并校验**之后**:common_data 在上面就已构造,
|
||||
# 在那里引用 approval 会直接 UnboundLocalError(上线时踩过)。
|
||||
common_data['applicant_id'] = approval.applicant_id
|
||||
|
||||
model_map = {
|
||||
'stock_buy': StockBuy,
|
||||
'stock_semi': StockSemi,
|
||||
@ -353,7 +359,11 @@ class OutboundService:
|
||||
repair.shipping_date = current_time
|
||||
|
||||
# 收集 Track 联动信息(维修单带 serial_number)
|
||||
track_notifications.append((getattr(repair, 'serial_number', None), source_table, quantity))
|
||||
# ★ 连同 company_name 一起攒起来,发送时按公司路由到对应 Track 实例
|
||||
track_notifications.append((
|
||||
getattr(repair, 'serial_number', None), source_table, quantity,
|
||||
getattr(getattr(repair, 'base', None), 'company_name', None),
|
||||
))
|
||||
|
||||
# 创建出库记录
|
||||
new_record = TransOutbound(
|
||||
@ -388,7 +398,11 @@ class OutboundService:
|
||||
raise ValueError(f"库存记录不存在 (ID: {stock_id})")
|
||||
|
||||
# 收集 Track 联动信息(库存表 serial_number = Track 身份证)
|
||||
track_notifications.append((getattr(stock_record, 'serial_number', None), source_table, quantity))
|
||||
# ★ 连同 company_name 一起攒起来,发送时按公司路由到对应 Track 实例
|
||||
track_notifications.append((
|
||||
getattr(stock_record, 'serial_number', None), source_table, quantity,
|
||||
getattr(getattr(stock_record, 'base', None), 'company_name', None),
|
||||
))
|
||||
|
||||
new_record = TransOutbound(
|
||||
sku=item.get('sku'),
|
||||
@ -413,11 +427,11 @@ class OutboundService:
|
||||
db.session.commit()
|
||||
|
||||
# ★ 出库后通知 Track(发货出库 → Track 标记"已出库")
|
||||
# 仅在配置 TRACK_OUTBOUND_WEBHOOK_URL 时生效;notify_track 自身容错不阻断业务
|
||||
# 按每条的 company_name 路由到对应实例(IRIS / LICA)。
|
||||
# 这里刻意**不**显式传 url:显式 url 优先级最高,会一律打到默认实例,
|
||||
# 导致 LICA 的出库永远收不到联动。notify_track 自身容错,不阻断业务。
|
||||
try:
|
||||
from flask import current_app
|
||||
outbound_url = current_app.config.get('TRACK_OUTBOUND_WEBHOOK_URL')
|
||||
for sn, src_tbl, qty in track_notifications:
|
||||
for sn, src_tbl, qty, company in track_notifications:
|
||||
if not sn:
|
||||
continue
|
||||
notify_track({
|
||||
@ -426,8 +440,9 @@ class OutboundService:
|
||||
'serial_number': str(sn),
|
||||
'quantity': float(qty or 0),
|
||||
'outbound_type': common_data['outbound_type'],
|
||||
'company_name': company,
|
||||
'operator': get_current_operator(),
|
||||
}, url=outbound_url)
|
||||
})
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"⚠️ Track 出库通知失败: {e}")
|
||||
@ -702,8 +717,11 @@ class OutboundService:
|
||||
)
|
||||
continue
|
||||
|
||||
if start_date and end_date:
|
||||
stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date))
|
||||
# ★ 日期边界分别判断:用 BETWEEN 时若只传一侧会整段静默失效
|
||||
if start_date:
|
||||
stmt = stmt.filter(TransOutbound.outbound_time >= start_date)
|
||||
if end_date:
|
||||
stmt = stmt.filter(TransOutbound.outbound_time <= end_date)
|
||||
|
||||
# 【行级数据隔离】应用公司过滤到主查询
|
||||
if company_limit is not None:
|
||||
|
||||
@ -673,6 +673,12 @@ class PermissionService:
|
||||
('inbound_purchase:unit_price', '采购单价', 'column'),
|
||||
('inbound_purchase:total_price', '采购总价', 'column'),
|
||||
('inbound_purchase:tax_rate', '税率', 'column'),
|
||||
# ★ 待采购清单(防重复采购)菜单权限。
|
||||
# 注册成 element 而非新建 SysMenu:侧边栏与路由守卫只读前端
|
||||
# meta.permissions + sys_role_permission,从不读 sys_menu;
|
||||
# 且 init_all_menus 的「冗余子菜单清理」会按 target_code 删权限行,
|
||||
# 新增 SysMenu 等于多一个被该清理逻辑波及的面。
|
||||
('inbound_purchase:pending_pool', '待采购清单', 'operation'),
|
||||
]
|
||||
for code, name, etype in purchase_elements:
|
||||
existing = SysElement.query.filter_by(
|
||||
@ -833,6 +839,48 @@ class PermissionService:
|
||||
print(f"[权限迁移] {role_code}: inbound_buy → inbound_purchase 已自动迁移")
|
||||
total_inserted += 1
|
||||
|
||||
# ★ 自动迁移:已有「采购申请」菜单权限的角色 → 自动获得「待采购清单」权限
|
||||
#
|
||||
# 为什么必须独立成段:上面的默认分配逻辑在角色**已有任何权限**时
|
||||
# 就整段跳过(见上方 existing_count > 0 的分支),存量角色根本走不到
|
||||
# 那里。不写这一段,除了超管之外没有任何角色能拿到新权限。
|
||||
#
|
||||
# 本段依赖 autoflush:上一个循环刚 add 的 inbound_purchase 行尚未
|
||||
# commit,这里的查询必须能看见它们(扩展默认 autoflush=True 保证了这点)。
|
||||
for role_code in known_roles:
|
||||
if role_code.upper() == 'SUPER_ADMIN':
|
||||
continue
|
||||
|
||||
# 以「采购申请菜单权限」为门控 —— 能进采购页的角色就该看到待采购清单。
|
||||
# ★ 同时镜像它的 company_name:若某角色的采购菜单是公司定制的
|
||||
# (company_name='A'),补出的权限必须落在同一作用域;硬写 NULL
|
||||
# 会让其他公司的同角色用户凭空多出这个菜单。
|
||||
src_perm = SysRolePermission.query.filter_by(
|
||||
role_code=role_code, target_code='inbound_purchase', type='menu'
|
||||
).first()
|
||||
if not src_perm:
|
||||
continue
|
||||
|
||||
# 幂等检查也必须带 company_name,否则 A 公司已有行会跳过 B 公司那条
|
||||
already = SysRolePermission.query.filter_by(
|
||||
role_code=role_code,
|
||||
target_code='inbound_purchase:pending_pool',
|
||||
type='element',
|
||||
company_name=src_perm.company_name,
|
||||
).first()
|
||||
if already:
|
||||
continue
|
||||
|
||||
db.session.add(SysRolePermission(
|
||||
role_code=role_code,
|
||||
target_code='inbound_purchase:pending_pool',
|
||||
type='element',
|
||||
company_name=src_perm.company_name,
|
||||
))
|
||||
total_inserted += 1
|
||||
print(f"[权限迁移] {role_code}: 已补充「待采购清单」权限"
|
||||
f"(作用域={src_perm.company_name or '全局'})")
|
||||
|
||||
# ★ SUPERVISOR 主管默认获得采购申请子菜单权限
|
||||
for role_code in known_roles:
|
||||
if role_code.upper() == 'SUPERVISOR':
|
||||
|
||||
@ -528,4 +528,259 @@ class PurchaseService:
|
||||
"""
|
||||
send_email_async(requester.email, subject, content)
|
||||
except Exception as e:
|
||||
print(f"[Email] 采购申请驳回通知失败: {e}")
|
||||
print(f"[Email] 采购申请驳回通知失败: {e}")
|
||||
|
||||
# ============================================================
|
||||
# 待采购池(防重复采购)
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_pending_purchase_pool(page=1, per_page=20, keyword=None,
|
||||
category=None, material_type=None,
|
||||
warning_status=None,
|
||||
include_reference_price=False):
|
||||
"""
|
||||
待采购池:找出「**有效供给**仍不足、需要再买」的物料。
|
||||
|
||||
业务目的:杜绝多名采购员对同一短缺物料重复购买,同时**不能漏掉缺口**。
|
||||
|
||||
核心是有效供给:
|
||||
|
||||
有效供给 = 当前物理总库存 + 在途量
|
||||
在途量 = SUM(该物料所有活跃采购单的剩余待入库量)
|
||||
= SUM(GREATEST(采购量 - 累计入库量, 0))
|
||||
|
||||
过滤规则:有效供给 <= 红/黄阈值 才留在池中。
|
||||
|
||||
★ 为什么不是「有采购单就排除」(本接口的第一版):
|
||||
红线 10、库存 0、某采购员只建了一张数量 5 的单 —— 若按 EXISTS 一刀切,
|
||||
该物料会**立刻从池中消失**,剩下 5 个缺口永远无人认领。这是掩耳盗铃。
|
||||
按量计算后它继续留在池中,suggested_qty 自动降到 5,提醒下一位只补 5 个。
|
||||
|
||||
★ 判定口径必须与物料列表预警(inbound/base_service.get_list)严格一致,
|
||||
否则会出现「列表亮红灯、池子却说不用买」的撕裂:
|
||||
- 用**物理库存总量**(三个 stock_* 表的 stock_quantity 之和),不是可用量
|
||||
- 阈值可能为 None,红黄两个阈值**各自独立**判断,不能 coalesce 成一个
|
||||
- 用 `<=` 而非 `<` —— 恰好等于阈值即算不足
|
||||
|
||||
★ 参考价格(reference_price)默认**不返回**(fail-closed)。
|
||||
它是 material_base.reference_price,属于受 material_list:referencePrice
|
||||
管控的价格数据 —— 与物料列表同源,就必须同码管控,否则这里会变成
|
||||
绕过该权限的后门(物料列表看不到、待采购清单却看得到)。
|
||||
调用方(API 层)按权限显式传 include_reference_price=True。
|
||||
|
||||
★ 关于 `<=`(2026-09-18 与业务方确认,**刻意维持,勿擅自改成 `<`**):
|
||||
阈值是「警戒下限」,到线即需关注;且物料列表预警、预警邮件用的是同一套
|
||||
`<=` 口径(那是系统原有约定,不是本接口另立的)。若只把本接口改成 `<`,
|
||||
会出现「列表亮黄灯、池子却把它排除」的撕裂。
|
||||
代价是有效供给恰好等于阈值时 suggested_qty 会落到最小值 1(看似
|
||||
「不用买却建议买 1 个」)—— 这是有意的缓冲,前端 tooltip 已单独说明。
|
||||
真要改成 `<`,必须**同时**改 base_service 的判级与 inventory_task 的
|
||||
触发条件,三处一起动。
|
||||
"""
|
||||
import math
|
||||
|
||||
from sqlalchemy import and_, case, cast, desc, or_
|
||||
from sqlalchemy.types import Numeric as SqlNumeric
|
||||
|
||||
from app.models.base import MaterialWarningSetting
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.product import StockProduct
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.utils.decorators import get_current_company_filter
|
||||
from app.utils.purchase_activity import in_transit_subquery
|
||||
|
||||
# ---- 三表库存聚合(口径同 base_service.get_list:146-170)----
|
||||
buy_sub = db.session.query(
|
||||
StockBuy.base_id,
|
||||
func.sum(StockBuy.stock_quantity).label('buy_inv'),
|
||||
func.sum(StockBuy.available_quantity).label('buy_avail')
|
||||
).group_by(StockBuy.base_id).subquery()
|
||||
|
||||
semi_sub = db.session.query(
|
||||
StockSemi.base_id,
|
||||
func.sum(StockSemi.stock_quantity).label('semi_inv'),
|
||||
func.sum(StockSemi.available_quantity).label('semi_avail')
|
||||
).group_by(StockSemi.base_id).subquery()
|
||||
|
||||
prod_sub = db.session.query(
|
||||
StockProduct.base_id,
|
||||
func.sum(StockProduct.stock_quantity).label('prod_inv'),
|
||||
func.sum(StockProduct.available_quantity).label('prod_avail')
|
||||
).group_by(StockProduct.base_id).subquery()
|
||||
|
||||
total_inv = (func.coalesce(buy_sub.c.buy_inv, 0)
|
||||
+ func.coalesce(semi_sub.c.semi_inv, 0)
|
||||
+ func.coalesce(prod_sub.c.prod_inv, 0))
|
||||
total_avail = (func.coalesce(buy_sub.c.buy_avail, 0)
|
||||
+ func.coalesce(semi_sub.c.semi_avail, 0)
|
||||
+ func.coalesce(prod_sub.c.prod_avail, 0))
|
||||
|
||||
# ★ 必须先物化再过滤:total_inv 是聚合表达式,直接在带 GROUP BY 的查询里
|
||||
# 参与比较会落到 HAVING 语义;物化成子查询后,它在外层就是普通列,
|
||||
# 可以安全地进 WHERE(与 base_service.get_list:175-191 同一手法)。
|
||||
inner_sub = (
|
||||
db.session.query(
|
||||
MaterialBase.id.label('base_id'),
|
||||
total_inv.label('total_inv'),
|
||||
total_avail.label('total_avail'),
|
||||
)
|
||||
.outerjoin(buy_sub, MaterialBase.id == buy_sub.c.base_id)
|
||||
.outerjoin(semi_sub, MaterialBase.id == semi_sub.c.base_id)
|
||||
.outerjoin(prod_sub, MaterialBase.id == prod_sub.c.base_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# ---- 在途量聚合(每个物料一张单一张单地累加剩余待入库量)----
|
||||
intransit_sub = in_transit_subquery()
|
||||
|
||||
inv_col = inner_sub.c.total_inv
|
||||
avail_col = inner_sub.c.total_avail
|
||||
# 没有活跃采购单的物料不在 intransit_sub 里,outerjoin 后为 NULL → 兜成 0
|
||||
intransit_col = func.coalesce(intransit_sub.c.intransit, 0)
|
||||
# ★ 有效供给:判缺货、算建议量,全部基于它,而不是光看库存
|
||||
effective_col = inv_col + intransit_col
|
||||
red_col = cast(MaterialWarningSetting.red_threshold, SqlNumeric)
|
||||
yellow_col = cast(MaterialWarningSetting.yellow_threshold, SqlNumeric)
|
||||
|
||||
# inner join 预警配置:缺货的前提就是预警已启用,outer join 无意义。
|
||||
# (material_warning_settings 按约定与物料 1:1 且已核实无重复行,
|
||||
# 故不会因 join 放大行数。若将来出现重复行,此处需要改为每物料取一条。)
|
||||
query = (
|
||||
db.session.query(MaterialBase, MaterialWarningSetting,
|
||||
inv_col, avail_col, intransit_col)
|
||||
.join(inner_sub, MaterialBase.id == inner_sub.c.base_id)
|
||||
.outerjoin(intransit_sub, MaterialBase.id == intransit_sub.c.base_id)
|
||||
.join(MaterialWarningSetting, MaterialBase.id == MaterialWarningSetting.base_id)
|
||||
.filter(MaterialWarningSetting.is_enabled.is_(True))
|
||||
)
|
||||
|
||||
# ---- ① 有效供给不足 ----
|
||||
# 对应 Python 侧的 `if 供给<=红 ... elif 供给<=黄`:命中任一即不足。
|
||||
# ★ 绝不写成 `supply <= coalesce(red, yellow)` —— 那会把「红阈值未配置」
|
||||
# 偷换成「用黄阈值」,且在两者都为 NULL 时静默退化成 NULL 比较。
|
||||
red_hit = and_(MaterialWarningSetting.red_threshold.isnot(None), effective_col <= red_col)
|
||||
yellow_hit = and_(MaterialWarningSetting.yellow_threshold.isnot(None), effective_col <= yellow_col)
|
||||
query = query.filter(or_(red_hit, yellow_hit))
|
||||
|
||||
# ---- 行级数据隔离(多租户)----
|
||||
company_limit = get_current_company_filter()
|
||||
if company_limit is not None:
|
||||
query = query.filter(MaterialBase.company_name == company_limit)
|
||||
|
||||
# ---- 可选筛选 ----
|
||||
if keyword:
|
||||
kw = f'%{keyword.strip()}%'
|
||||
query = query.filter(or_(
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
MaterialBase.company_name.ilike(kw),
|
||||
))
|
||||
|
||||
if category:
|
||||
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
|
||||
|
||||
if material_type:
|
||||
query = query.filter(MaterialBase.material_type.ilike(material_type.strip()))
|
||||
|
||||
if warning_status == 2:
|
||||
query = query.filter(red_hit)
|
||||
elif warning_status == 1:
|
||||
# 黄色要在红之外,否则 1/2 语义重叠
|
||||
query = query.filter(yellow_hit, ~red_hit)
|
||||
|
||||
# ---- 排序:最缺的排最上面(复刻 base_service.get_list:372-391 的口径)----
|
||||
warning_level = case(
|
||||
(red_hit, 2),
|
||||
(yellow_hit, 1),
|
||||
else_=0,
|
||||
)
|
||||
# 缺口按**有效供给**算,在途已经补上的部分不再计入缺口
|
||||
gap = case(
|
||||
(red_hit, red_col - effective_col),
|
||||
(yellow_hit, yellow_col - effective_col),
|
||||
else_=0,
|
||||
)
|
||||
query = query.order_by(desc(warning_level), desc(gap), MaterialBase.id.asc())
|
||||
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
items = []
|
||||
for row in pagination.items:
|
||||
material = row[0]
|
||||
setting = row[1]
|
||||
inv = float(row[2]) if row[2] is not None else 0.0
|
||||
avail = float(row[3]) if row[3] is not None else 0.0
|
||||
intransit = float(row[4]) if row[4] is not None else 0.0
|
||||
effective = inv + intransit
|
||||
|
||||
red = float(setting.red_threshold) if setting.red_threshold is not None else None
|
||||
yellow = float(setting.yellow_threshold) if setting.yellow_threshold is not None else None
|
||||
|
||||
# 判级用有效供给 —— 与上面的 SQL 过滤条件保持同一口径
|
||||
status = 0
|
||||
if red is not None and effective <= red:
|
||||
status = 2
|
||||
elif yellow is not None and effective <= yellow:
|
||||
status = 1
|
||||
|
||||
# 建议采购量 = 阈值 - 有效供给,一次性把供给抬出**整个**预警带,
|
||||
# 否则刚补完货下一条预警马上又来。
|
||||
# 取 max(红,黄) 而非「被触发的那个」——正常配置下红<黄,补到黄线即可
|
||||
# 完全退出;红黄配反的脏数据下也安全。
|
||||
# 双阈值都为 None 时降级为 None,不会出现 None - effective 的 TypeError。
|
||||
#
|
||||
# ★ 注意在途已计入 effective,所以「已买 5」时这里只会建议剩下的 5,
|
||||
# 而不是按库存从零算。这正是本次改造要修的那个漏洞。
|
||||
targets = [t for t in (red, yellow) if t is not None]
|
||||
target = max(targets) if targets else None
|
||||
suggested = max(1, math.ceil(target - effective)) if target is not None else None
|
||||
|
||||
items.append({
|
||||
'material_id': material.id,
|
||||
'name': material.name,
|
||||
'spec_model': material.spec_model or '',
|
||||
'unit': material.unit or '',
|
||||
'category': material.category or '',
|
||||
'material_type': material.material_type or '',
|
||||
'company_name': material.company_name or '',
|
||||
'image': PurchaseService._first_image(material.product_image),
|
||||
'purchase_link': material.purchase_link or '',
|
||||
# fail-closed:无权限时整个字段不出现,而不是给 None ——
|
||||
# 前端据字段是否存在决定列是否渲染,语义更干净
|
||||
**({'reference_price': float(material.reference_price)
|
||||
if material.reference_price is not None else None}
|
||||
if include_reference_price else {}),
|
||||
'inventory_count': inv,
|
||||
'available_count': avail,
|
||||
# 在途量与有效供给一并返回,前端才能向采购员解释「为什么只建议买这么多」
|
||||
'in_transit_qty': intransit,
|
||||
'effective_supply': effective,
|
||||
'warning_status': status,
|
||||
'warning_red': red,
|
||||
'warning_yellow': yellow,
|
||||
'target_threshold': target,
|
||||
'suggested_qty': suggested,
|
||||
'is_ordered': bool(setting.is_ordered),
|
||||
})
|
||||
|
||||
return {
|
||||
'items': items,
|
||||
'total': pagination.total,
|
||||
'pages': pagination.pages,
|
||||
'current_page': page,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _first_image(product_image):
|
||||
"""从 material_base.product_image 的 JSON 字符串里取第一张图,取不到返回空串。"""
|
||||
if not product_image:
|
||||
return ''
|
||||
try:
|
||||
if isinstance(product_image, str) and not product_image.startswith('['):
|
||||
return product_image # 兼容旧数据:单条 URL 直接存字符串
|
||||
parsed = json.loads(product_image)
|
||||
if isinstance(parsed, list) and parsed:
|
||||
return parsed[0] or ''
|
||||
except Exception:
|
||||
return ''
|
||||
return ''
|
||||
@ -29,15 +29,28 @@ def lookup_product(code):
|
||||
sku/material_name/spec_model/material_type/order_no),未命中或异常返回 None。
|
||||
"""
|
||||
try:
|
||||
base_url = (current_app.config.get('TRACK_API_URL') or '').strip()
|
||||
api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip()
|
||||
if not base_url:
|
||||
logger.info("[TrackQuery] 未配置 TRACK_API_URL,跳过查询")
|
||||
return None
|
||||
if not api_key:
|
||||
logger.info("[TrackQuery] 未配置 TRACK_WEBHOOK_KEY,跳过查询")
|
||||
return None
|
||||
|
||||
# ★ 按当前用户所属公司路由到对应的 Track 实例(IRIS / LICA)。
|
||||
# 复用 get_current_company_filter():超管/跨域用户返回 None → 回落默认实例。
|
||||
# 改造前这里写死读 TRACK_API_URL,LICA 员工在 LICA 业务里扫码会打到 IRIS 库,
|
||||
# 查不到 → MOM 误报"物料不存在"。
|
||||
from app.services.track_webhook_service import resolve_track_route
|
||||
from app.utils.decorators import get_current_company_filter
|
||||
try:
|
||||
company = get_current_company_filter()
|
||||
except Exception:
|
||||
# 无请求/JWT 上下文(脚本、后台任务)时回落默认实例,不阻断查询
|
||||
company = None
|
||||
|
||||
base_url = resolve_track_route(company, 'api')
|
||||
if not base_url:
|
||||
logger.info("[TrackQuery] 未解析到 Track API 地址,跳过查询")
|
||||
return None
|
||||
|
||||
url = f'{base_url}/api/v1/external/products/lookup'
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@ -37,6 +37,54 @@ def _post_to_track(payload, url, api_key):
|
||||
logger.error("[TrackWebhook] 发送失败 url=%s, err=%s", url, e)
|
||||
|
||||
|
||||
# 各通道未命中路由表时回落到哪个扁平配置变量(保持改造前的单实例行为)
|
||||
_TRACK_ROUTE_FALLBACK = {
|
||||
'api': 'TRACK_API_URL',
|
||||
'inbound': 'TRACK_WEBHOOK_URL',
|
||||
'outbound': 'TRACK_OUTBOUND_WEBHOOK_URL',
|
||||
}
|
||||
|
||||
|
||||
def resolve_track_route(company_name, channel):
|
||||
"""按公司名解析 Track 实例地址。
|
||||
|
||||
channel: 'api' | 'inbound' | 'outbound'
|
||||
|
||||
⚠️ 未命中必须**回落到扁平变量**,不能返回空 —— 漏配一个 key 就静默断链,
|
||||
比配置写错更难排查。
|
||||
"""
|
||||
routes = current_app.config.get('TRACK_ROUTES') or {}
|
||||
|
||||
if company_name and company_name in routes:
|
||||
url = ((routes.get(company_name) or {}).get(channel) or '').strip()
|
||||
if url:
|
||||
return url
|
||||
# 公司在表里但该通道没配 —— 这是配置漏项,务必暴露出来
|
||||
logger.warning(
|
||||
"[TrackRoute] 公司 %s 的 %s 通道未配置,回落到全局默认地址",
|
||||
company_name, channel,
|
||||
)
|
||||
elif company_name:
|
||||
# 非空但不在路由表内:可能是拼写异常的公司名,也可能是
|
||||
# get_current_company_filter() 的 '__NO_COMPANY__' 哨兵(用户 JWT 无公司)。
|
||||
# 不猜、不阻断,只暴露给业务确认。
|
||||
logger.warning(
|
||||
"[TrackRoute] 公司 '%s' 不在 TRACK_ROUTES 中,回落到全局默认地址(channel=%s)。"
|
||||
"若该值不是合法部门名,请业务确认主数据",
|
||||
company_name, channel,
|
||||
)
|
||||
|
||||
fallback_key = _TRACK_ROUTE_FALLBACK.get(channel)
|
||||
if not fallback_key:
|
||||
return ''
|
||||
return (current_app.config.get(fallback_key) or '').strip()
|
||||
|
||||
|
||||
def _channel_from_event(event):
|
||||
"""从事件名推断路由通道;缺省按入库处理。"""
|
||||
return 'outbound' if 'outbound' in (event or '').lower() else 'inbound'
|
||||
|
||||
|
||||
def get_current_operator():
|
||||
"""从 JWT 中安全获取当前操作人姓名(失败返回空字符串,不抛异常)"""
|
||||
try:
|
||||
@ -50,7 +98,7 @@ def get_current_operator():
|
||||
return ''
|
||||
|
||||
|
||||
def notify_track(payload, url=None):
|
||||
def notify_track(payload, url=None, channel=None):
|
||||
"""
|
||||
异步通知 Track 系统业务事件(入库/出库等)。
|
||||
|
||||
@ -58,10 +106,15 @@ def notify_track(payload, url=None):
|
||||
- 未配置 URL / Key 为空 -> 静默跳过
|
||||
- 网络异常 / 超时 -> 仅记录日志
|
||||
|
||||
:param url: 指定 Track webhook 地址;不传则用默认 TRACK_WEBHOOK_URL(入库)
|
||||
:param url: 显式指定 Track webhook 地址,优先级最高(指定后不再做公司路由)
|
||||
:param channel: 路由通道 'inbound' | 'outbound';不传则按 payload['event'] 推断。
|
||||
仅在不传 url 时生效——实例由 payload['company_name'] 决定。
|
||||
"""
|
||||
try:
|
||||
url = (url or current_app.config.get('TRACK_WEBHOOK_URL') or '').strip()
|
||||
if not url:
|
||||
channel = channel or _channel_from_event(payload.get('event'))
|
||||
url = resolve_track_route(payload.get('company_name'), channel)
|
||||
url = (url or '').strip()
|
||||
api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip()
|
||||
if not url:
|
||||
logger.info("[TrackWebhook] 未配置 webhook URL,跳过通知")
|
||||
|
||||
@ -1089,7 +1089,10 @@ class TransService:
|
||||
# 无限期梯队内:单号内最早借出时间(呆滞借用盘点)
|
||||
func.min(TransBorrow.borrow_time).label('min_borrow_time'),
|
||||
# 单内是否含"有限期"明细:任一 expected_return_time 非空 → 1(有限期单排前)
|
||||
func.max(case((TransBorrow.expected_return_time.isnot(None), 1), else_=0)).label('has_finite')
|
||||
func.max(case((TransBorrow.expected_return_time.isnot(None), 1), else_=0)).label('has_finite'),
|
||||
# 「已归还」页签的排序键:单号内**最晚**一次归还时间
|
||||
# (多明细分批归还时,整单结清的那一刻才是有意义的节点)
|
||||
func.max(TransBorrow.return_time).label('max_return_time')
|
||||
)
|
||||
.group_by(TransBorrow.borrow_no)
|
||||
.subquery()
|
||||
@ -1434,18 +1437,31 @@ class TransService:
|
||||
)
|
||||
)
|
||||
|
||||
# ★ 默认排序(多级复合,符合"优先关注快到期/逾期"业务):
|
||||
# 1) 有限期单(含 expected_return_time)排前,无限期单排后
|
||||
# 2) 有限期内按最早预计归还时间 ASC(越快到期/逾期越久越靠前)
|
||||
# 3) 无限期内按最早借出时间 ASC(借出越久越靠前,暴露长期未还的呆滞借用)
|
||||
borrow_no_q = borrow_no_q.order_by(
|
||||
case((order_subq.c.has_finite == 0, 1), else_=0).asc(),
|
||||
nullslast(asc(order_subq.c.sort_key)),
|
||||
# ★ 无限期梯队内按借出时间**从近到远**(desc)。
|
||||
# 原实现是 asc「借出越久越靠前」,设计意图是暴露呆滞借用;
|
||||
# 业务方明确要求改为从近到远,故反转。
|
||||
desc(order_subq.c.min_borrow_time)
|
||||
# ★ 排序按页签分开:
|
||||
#
|
||||
# 「已归还」页签 —— 按**归还时间倒序**(从近到远)。
|
||||
# 这张列表此时回答的是「最近还了哪几笔」,而不是「哪笔快到期」,
|
||||
# 所以不能沿用未归还那套「逾期优先」的排序,否则最近刚还的
|
||||
# 反而排在最后。取单号内**最晚**一次归还时间:多明细分批归还时,
|
||||
# 整单结清的那一刻才是有意义的节点,也与主行「归还时间」列的
|
||||
# 展示口径一致(前端同样取 latest)。
|
||||
#
|
||||
# 其余页签(全部 / 未归还)—— 沿用「优先关注快到期/逾期」:
|
||||
# 1) 有限期单(含 expected_return_time)排前,无限期单排后
|
||||
# 2) 有限期内按最早预计归还时间 ASC(越快到期/逾期越久越靠前)
|
||||
# 3) 无限期内按最早借出时间 DESC(从近到远)
|
||||
_order_by = (
|
||||
[nullslast(desc(order_subq.c.max_return_time)),
|
||||
desc(order_subq.c.borrow_no)] # 同一时刻的稳定兜底
|
||||
if status == 'returned' else
|
||||
[case((order_subq.c.has_finite == 0, 1), else_=0).asc(),
|
||||
nullslast(asc(order_subq.c.sort_key)),
|
||||
# ★ 无限期梯队内按借出时间**从近到远**(desc)。
|
||||
# 原实现是 asc「借出越久越靠前」,设计意图是暴露呆滞借用;
|
||||
# 业务方明确要求改为从近到远,故反转。
|
||||
desc(order_subq.c.min_borrow_time)]
|
||||
)
|
||||
borrow_no_q = borrow_no_q.order_by(*_order_by)
|
||||
|
||||
# 分页(基准 = borrow_no 单号数)
|
||||
pagination = borrow_no_q.paginate(page=page, per_page=limit, error_out=False)
|
||||
@ -1570,6 +1586,35 @@ class TransService:
|
||||
TransBorrowTransfer.status == TRANSFER_STATUS_PENDING,
|
||||
).all() if _ids else []
|
||||
_pending_map = {t.borrow_id: t.to_dict() for t in _pending}
|
||||
# ============================================================
|
||||
# ★ 实际归还人(trans_borrow_return.returner_id)
|
||||
#
|
||||
# 与主表的 return_operator(**经手库管**)是**两个不同的人**:
|
||||
# · returner_id —— 把东西交回窗口的人(已校验 == 当时持有人)
|
||||
# · return_operator —— 办理还库的库管
|
||||
# 列表原先把后者标成「归还人」展示,属标签错误;此处补上真正
|
||||
# 的归还人,供前端分列展示。
|
||||
# ⚠ 本表是二期才建的,**历史归还没有这个记录** —— 那部分行的
|
||||
# returners 为空,前端显示为空并提示「历史数据未记录」,
|
||||
# 而不是拿库管的名字顶上(那正是本次要修的错)。
|
||||
# ============================================================
|
||||
_ret_rows = TransBorrowReturn.query.filter(
|
||||
TransBorrowReturn.borrow_id.in_(_ids)
|
||||
).all() if _ids else []
|
||||
_ret_uids = {t.returner_id for t in _ret_rows if t.returner_id}
|
||||
_ret_names = {}
|
||||
if _ret_uids:
|
||||
from app.models.system import SysUser
|
||||
for _u in SysUser.query.filter(SysUser.id.in_(_ret_uids)).all():
|
||||
_ret_names[_u.id] = user_display_name(_u)
|
||||
_ret_map = {}
|
||||
for _t in _ret_rows:
|
||||
_nm = _ret_names.get(_t.returner_id)
|
||||
if _nm:
|
||||
_ret_map.setdefault(_t.borrow_id, set()).add(_nm)
|
||||
for d in items_with_names:
|
||||
d['returners'] = sorted(_ret_map.get(d.get('id'), set()))
|
||||
|
||||
for d in items_with_names:
|
||||
_pt = _pending_map.get(d.get('id'))
|
||||
if _pt is not None:
|
||||
|
||||
163
inventory-backend/app/utils/purchase_activity.py
Normal file
163
inventory-backend/app/utils/purchase_activity.py
Normal file
@ -0,0 +1,163 @@
|
||||
"""
|
||||
「活跃采购单」的判定与在途量计算 —— 防重复采购的唯一真相源。
|
||||
|
||||
本模块回答**两个不同的问题**,别混用:
|
||||
|
||||
active_purchase_exists() 有没有人在买? → 展示用(在途标识、邮件静音)
|
||||
in_transit_subquery() 已经买了多少还没到? → 算术用(待采购池的有效供给)
|
||||
|
||||
「有在途单」不等于「不会缺货」:红线上线 10、库存 0、已下单 5,仍然差 5。
|
||||
早期待采购池用 EXISTS 一刀切排除有单的物料,结果是只买了 5 个就把整个物料
|
||||
从池子里抹掉、剩下的缺口永远无人认领(掩耳盗铃)。现在改为按量计算。
|
||||
|
||||
判定的业务含义(两张表共用同一份 _active_condition):
|
||||
|
||||
status 0(待审批) / 1(已通过未入库) → 算数
|
||||
status 3(已完成) 但累计入库 < 采购量 → 算数(部分到货,缺口仍在)
|
||||
status 2(已驳回) → 不算数,物料正常回流
|
||||
status 4(已完结 / 强制结案) → 不算数,立即解锁
|
||||
|
||||
★ status==4 的解锁能力是刻意保留的逃生通道:供应商短交不补发、来料不良拒收
|
||||
不补发等异常单永远达不到采购量,若按「累计入库 < 采购量」一律算数,该物料
|
||||
会被永久占用、再也无法触发采购。库管把单据强制结案即可解除。
|
||||
|
||||
「部分到货」为什么能在不改表结构的前提下算出来:
|
||||
StockBuy.request_id 早已存在(打通「按单入库」链路时加的),且
|
||||
StockBuy.in_quantity 是入库原始量 —— 它不会被出库扣减(区别于
|
||||
stock_quantity),按 request_id 聚合即得每张单的累计到货量。
|
||||
"""
|
||||
from sqlalchemy import and_, func, or_
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
def received_quantity_subquery():
|
||||
"""
|
||||
每张采购单的累计到货量:{request_id: SUM(in_quantity)}。
|
||||
|
||||
只统计挂在该采购单下的入库行(request_id 非空)——散单入库不计入任何单据。
|
||||
"""
|
||||
# 惰性导入:app/models/inbound/buy.py 已在模块顶层 import 了
|
||||
# app/models/purchase.py,若此处顶层反向导入会形成循环。
|
||||
from app.models.inbound.buy import StockBuy
|
||||
|
||||
return (
|
||||
db.session.query(
|
||||
StockBuy.request_id.label('rid'),
|
||||
func.sum(StockBuy.in_quantity).label('received'),
|
||||
)
|
||||
.filter(StockBuy.request_id.isnot(None))
|
||||
.group_by(StockBuy.request_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
|
||||
def _active_condition(received_sub):
|
||||
"""
|
||||
「这张采购单还算数吗」的判定条件 —— active_purchase_exists 与
|
||||
in_transit_subquery 共用,保证两处口径永不漂移。
|
||||
|
||||
status 0(待审批) / 1(已通过未入库) → 算数
|
||||
status 3(已完成) 但累计入库 < 采购量 → 算数(部分到货,缺口仍在)
|
||||
status 2(已驳回) / 4(已完结) → 不算数
|
||||
"""
|
||||
from app.models.purchase import PurchaseRequest
|
||||
|
||||
return or_(
|
||||
# 待审批 + 已通过未入库
|
||||
PurchaseRequest.status.in_([0, 1]),
|
||||
# 已入库但没到齐 —— 只到一部分时仍算数,缺口要留在账上
|
||||
and_(
|
||||
PurchaseRequest.status == 3,
|
||||
func.coalesce(received_sub.c.received, 0) < PurchaseRequest.quantity,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def active_purchase_exists(base_id_col):
|
||||
"""
|
||||
构造「该物料存在活跃采购单」的 EXISTS 表达式。
|
||||
|
||||
★ 这是**展示用**的布尔判定(物料列表的「采购在途」标识、预警邮件静音),
|
||||
回答的是「有没有人在买」。它**不**回答「还差多少」——
|
||||
待采购池的过滤不能用它,那个要用 in_transit_subquery() 算有效供给。
|
||||
|
||||
Args:
|
||||
base_id_col: 待关联的物料主键列,通常是 MaterialBase.id。
|
||||
|
||||
Returns:
|
||||
SQLAlchemy EXISTS 表达式,可直接用于 .filter(~expr),
|
||||
或作为 SELECT 列表里的布尔列。
|
||||
|
||||
★ 为什么必须用 EXISTS 而不是 NOT IN:
|
||||
NOT IN 的子查询若返回 NULL,整个谓词会变成 NULL,调用方会**静默地**
|
||||
拿到零行。EXISTS 天然 NULL 安全。
|
||||
|
||||
★ 为什么显式 .correlate(...):
|
||||
调用方的外层 FROM 里除了 material_base 往往还有别的表
|
||||
(MaterialWarningSetting、物化后的库存聚合子查询等),
|
||||
不显式声明相关性时 SQLAlchemy 的自动推断有歧义风险。
|
||||
|
||||
★ 使用约束:必须**嵌入**一个 FROM 含该物料表的查询里使用,例如
|
||||
query.filter(~active_purchase_exists(MaterialBase.id))
|
||||
单独 compile 这个表达式时没有外层查询可供相关,SQLAlchemy 会把
|
||||
material_base 塞进内层 FROM 形成笛卡尔积 —— 那是编译期假象,
|
||||
不影响嵌入使用的正确性,但**不要**脱离查询单独执行它。
|
||||
"""
|
||||
from app.models.purchase import PurchaseRequest
|
||||
|
||||
received = received_quantity_subquery()
|
||||
|
||||
return (
|
||||
db.session.query(PurchaseRequest.id)
|
||||
.outerjoin(received, PurchaseRequest.id == received.c.rid)
|
||||
.filter(
|
||||
PurchaseRequest.base_id == base_id_col,
|
||||
_active_condition(received),
|
||||
)
|
||||
.correlate(base_id_col.table)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
def in_transit_subquery():
|
||||
"""
|
||||
每个物料的**在途数量**:{base_id: SUM(该单剩余待入库量)}。
|
||||
|
||||
单张活跃采购单的待入库量 = GREATEST(采购量 - 累计入库量, 0)
|
||||
|
||||
★ 为什么要 GREATEST 兜底:理论上活跃单的累计入库量不会超过采购量
|
||||
(超收的单会因 _active_condition 判为不活跃而被排除),但历史上
|
||||
出现过手工改状态、重复入库等脏数据,一个负值会**静默地**把别的单的
|
||||
缺口抵掉,让待采购池少算在途、多建议采购量。兜到 0 代价极小。
|
||||
|
||||
★ 与 active_purchase_exists 共用 _active_condition:两处若各写一遍,
|
||||
将来改判定条件必然漏掉一边 —— 这个仓库已经因为「同一逻辑写两份」
|
||||
吃过多次亏(见 material/list.vue 与 buyOdoo.vue 的双胞胎漂移)。
|
||||
|
||||
Returns:
|
||||
SQLAlchemy 子查询,列为 (base_id, intransit)。
|
||||
调用方需 outerjoin 后再 coalesce(..., 0),因为没采购单的物料不在结果里。
|
||||
"""
|
||||
from app.models.purchase import PurchaseRequest
|
||||
|
||||
received = received_quantity_subquery()
|
||||
|
||||
remaining = func.greatest(
|
||||
PurchaseRequest.quantity - func.coalesce(received.c.received, 0),
|
||||
0,
|
||||
)
|
||||
|
||||
return (
|
||||
db.session.query(
|
||||
PurchaseRequest.base_id.label('base_id'),
|
||||
func.sum(remaining).label('intransit'),
|
||||
)
|
||||
.outerjoin(received, PurchaseRequest.id == received.c.rid)
|
||||
# base_id 为空的历史单无法归属到任何物料,直接排除,
|
||||
# 否则会聚成一个 base_id=NULL 的组白白参与 join
|
||||
.filter(PurchaseRequest.base_id.isnot(None))
|
||||
.filter(_active_condition(received))
|
||||
.group_by(PurchaseRequest.base_id)
|
||||
.subquery()
|
||||
)
|
||||
@ -1,3 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
@ -85,3 +86,17 @@ class Config:
|
||||
TRACK_API_URL = os.getenv('TRACK_API_URL', 'http://track_backend:8000')
|
||||
# Track 出库 webhook 接收地址(发货出库时通知 Track 标记"已出库")
|
||||
TRACK_OUTBOUND_WEBHOOK_URL = os.getenv('TRACK_OUTBOUND_WEBHOOK_URL', '')
|
||||
|
||||
# =========================================================
|
||||
# 9. 多 Track 实例路由表 (IRIS / LICA)
|
||||
# =========================================================
|
||||
# JSON 结构: {"IRIS": {"api": "...", "inbound": "...", "outbound": "..."}, "LICA": {...}}
|
||||
# 由 material_base.company_name 决定业务落到哪个 Track 实例。
|
||||
# 缺省为空字典 → 全部回落到上面第 7/8 节的扁平变量,行为与改造前一致。
|
||||
#
|
||||
# ⚠️ 解析失败直接让进程起不来(fail fast)——路由表写坏却静默回落,
|
||||
# 会造成"某些公司悄悄断链",比启动报错难排查得多。
|
||||
try:
|
||||
TRACK_ROUTES = json.loads(os.getenv('TRACK_ROUTES', '{}') or '{}')
|
||||
except ValueError as e:
|
||||
raise RuntimeError(f'TRACK_ROUTES 不是合法 JSON,请检查环境变量: {e}')
|
||||
|
||||
@ -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.82
|
||||
当前版本:V3.91
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
|
||||
@ -163,3 +163,47 @@ export function getApprovedUnstockedRequests(params: {
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 待采购池的单条物料(防重复采购)
|
||||
export interface PendingPoolItem {
|
||||
material_id: number
|
||||
name: string
|
||||
spec_model: string
|
||||
unit: string
|
||||
category: string
|
||||
material_type: string
|
||||
company_name: string
|
||||
image: string
|
||||
purchase_link: string
|
||||
// 仅供参考展示,受 material_list:referencePrice 管控 —— 无权限时后端**不返回该字段**
|
||||
reference_price?: number | null
|
||||
inventory_count: number
|
||||
available_count: number
|
||||
// 在途量 = 该物料所有活跃采购单的剩余待入库量之和
|
||||
in_transit_qty: number
|
||||
// 有效供给 = 库存 + 在途。判缺货与算建议量都基于它
|
||||
effective_supply: number
|
||||
warning_status: number // 0=正常 1=黄色 2=红色(进池的必然非 0)
|
||||
warning_red: number | null
|
||||
warning_yellow: number | null
|
||||
target_threshold: number | null
|
||||
suggested_qty: number | null
|
||||
is_ordered: boolean
|
||||
}
|
||||
|
||||
// 待采购清单:库存已触及预警线、且没有活跃采购单的物料。
|
||||
// 物料一旦有在途采购单(待审批/已通过/部分到货未齐)就会从这里消失。
|
||||
export function getPendingPurchasePool(params: {
|
||||
page?: number
|
||||
limit?: number
|
||||
keyword?: string
|
||||
category?: string
|
||||
type?: string
|
||||
warning_status?: number
|
||||
}) {
|
||||
return request({
|
||||
url: '/purchase/pending-pool',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
@ -213,6 +213,8 @@ const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/purchase',
|
||||
component: Layout,
|
||||
// ★ 父路由**不加** permissions:加了会让没有 pending_pool 权限的用户
|
||||
// 整个「采购管理」模块从侧边栏消失。
|
||||
meta: { title: '采购管理', icon: 'ShoppingCart' },
|
||||
children: [
|
||||
{
|
||||
@ -220,6 +222,12 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: 'PurchaseList',
|
||||
component: () => import('@/views/purchase/index.vue'),
|
||||
meta: { title: '采购申请' }
|
||||
},
|
||||
{
|
||||
path: 'pending',
|
||||
name: 'PurchasePendingPool',
|
||||
component: () => import('@/views/purchase/PendingPool.vue'),
|
||||
meta: { title: '待采购清单', permissions: ['inbound_purchase:pending_pool'] }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -145,12 +145,7 @@
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="备注" v-if="hasFormFieldPermission('remark')">
|
||||
<el-input v-model="form.remark" placeholder="备注信息(可选)" :disabled="isReadOnlyMode" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-col :span="14">
|
||||
<el-form-item label="版本号" prop="version" v-if="hasFormFieldPermission('version')">
|
||||
<template v-if="isSaveAsMode">
|
||||
<el-radio-group v-model="form.versionUpgradeType" :disabled="isReadOnlyMode" @change="onVersionUpgradeTypeChange">
|
||||
@ -165,6 +160,21 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- ★ BOM 级备注:独立整行,放在子件列表上方。
|
||||
原位置在编号/版本号那一行的 span=6 里,扣掉 120px 的 label 后
|
||||
输入框实际只剩 60 余像素,稍长一点的备注根本看不清。 -->
|
||||
<el-form-item label="备注" v-if="hasFormFieldPermission('remark')" style="margin-bottom: 5px;">
|
||||
<el-input
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="BOM 级备注(可选),如适用场景、工艺要求等"
|
||||
:disabled="isReadOnlyMode"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div style="font-weight: bold; margin: 15px 0 10px 0; border-left: 4px solid #409EFF; padding-left: 10px;">
|
||||
子件列表
|
||||
<span style="font-weight: normal; font-size: 12px; color: #909399; margin-left: 10px;">(已选 {{ filteredChildren.length }} 条)</span>
|
||||
@ -408,6 +418,9 @@ const getDraftHash = () => {
|
||||
bom_no: form.bom_no,
|
||||
version: form.version,
|
||||
parent_id: form.parent_id,
|
||||
// ★ BOM 级备注也要参与比对,否则"只改了备注"会被防呆逻辑
|
||||
// 拦成"草稿数据无变动,无需重复暂存"
|
||||
remark: form.remark || '',
|
||||
children
|
||||
})
|
||||
}
|
||||
@ -852,6 +865,7 @@ const executeSaveDraftRequest = async (targetBomNo: string) => {
|
||||
bom_no: targetBomNo,
|
||||
version: draftVersion,
|
||||
parent_id: form.parent_id,
|
||||
remark: form.remark || '',
|
||||
children
|
||||
})
|
||||
if (res.code === 200) {
|
||||
|
||||
@ -200,6 +200,7 @@
|
||||
<el-checkbox v-if="hasColPermission('files')" v-model="columns.files.visible" label="资料" />
|
||||
<el-checkbox v-if="hasColPermission('isEnabled')" v-model="columns.isEnabled.visible" label="状态" />
|
||||
<el-checkbox v-if="hasColPermission('isInspectionRequired')" v-model="columns.isInspectionRequired.visible" label="强制质检" />
|
||||
<el-checkbox v-if="hasColPermission('isApprovalRequired')" v-model="columns.isApprovalRequired.visible" label="出库/借库需审批" />
|
||||
<el-checkbox v-if="hasColPermission('referencePrice')" v-model="columns.referencePrice.visible" label="参考价格" />
|
||||
<el-checkbox v-if="hasColPermission('purchaseLink')" v-model="columns.purchaseLink.visible" label="采购链接" />
|
||||
<el-checkbox v-if="hasColPermission('warningStatus')" v-model="columns.warningStatus.visible" label="预警状态" />
|
||||
@ -350,6 +351,15 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.isApprovalRequired.visible && hasColPermission('isApprovalRequired')" label="出库/借库需审批" min-width="130" align="center">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
:model-value="!!scope.row.isApprovalRequired"
|
||||
:disabled="!userStore.hasPermission('material_list:isApprovalRequired')"
|
||||
@change="(val: boolean) => toggleApprovalRequired(scope.row, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.referencePrice.visible && hasColPermission('referencePrice')" prop="referencePrice" label="参考价格" min-width="120" align="center" sortable="custom">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.referencePrice != null" class="money-text">{{ scope.row.referencePrice?.toFixed(2) }}</span>
|
||||
@ -370,9 +380,18 @@
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.warningStatus.visible && hasColPermission('warningStatus')" label="预警状态" width="120" align="center">
|
||||
<el-table-column v-if="columns.warningStatus.visible && hasColPermission('warningStatus')" label="预警状态" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.warningStatus === 2">
|
||||
<!-- ★ 预警降级:缺货但已有在途采购单时不再闪红灯 —— 红灯的含义是
|
||||
「需要你去买」,而这里已经有人在买了。判定来自后端 isPurchasing,
|
||||
与待采购池共用同一份逻辑。只改展示,后端 warningStatus 原样保留。 -->
|
||||
<template v-if="row.isPurchasing && row.warningStatus > 0">
|
||||
<el-tag type="primary" size="small">缺货(已采购)</el-tag>
|
||||
<div style="font-size: 11px; color: #999;">
|
||||
阈值: {{ row.warningStatus === 2 ? row.warningRed : row.warningYellow }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="row.warningStatus === 2">
|
||||
<el-tag type="danger" size="small">红色预警</el-tag>
|
||||
<div style="font-size: 11px; color: #999;">阈值: {{ row.warningRed }}</div>
|
||||
</template>
|
||||
@ -390,10 +409,9 @@
|
||||
<template #default="scope">
|
||||
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
||||
<el-button v-if="userStore.hasPermission('material_list:edit_warning')" link type="warning" size="small" @click="handleSetSingleWarning(scope.row)">设置预警</el-button>
|
||||
<template v-if="userStore.hasPermission('material_list:edit_warning') && scope.row.warningStatus > 0">
|
||||
<el-button v-if="scope.row.warningOrdered" disabled size="small" type="info">采购在途</el-button>
|
||||
<el-button v-else link type="success" size="small" @click="handleMarkOrdered(scope.row)">标记已采购</el-button>
|
||||
</template>
|
||||
<!-- 采购在途:由后端 isPurchasing 自动派生(与待采购池同一判定)。
|
||||
不再提供人工标记入口 —— 建单即在途,驳回 / 强制结案即自动解除。 -->
|
||||
<el-tag v-if="scope.row.isPurchasing" type="primary" size="small">采购在途</el-tag>
|
||||
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -434,10 +452,19 @@
|
||||
>
|
||||
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
|
||||
</el-link>
|
||||
<el-link
|
||||
v-if="form.id"
|
||||
type="warning"
|
||||
:underline="false"
|
||||
style="font-size: 14px;"
|
||||
@click="createPurchaseForMaterial"
|
||||
>
|
||||
<el-icon style="margin-right: 4px"><ShoppingCart /></el-icon>发起采购申请
|
||||
</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" :disabled="formDisabled">
|
||||
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
@ -655,7 +682,7 @@
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="cancel" :disabled="isUploading">取 消</el-button>
|
||||
<el-button type="primary" @click="submitForm" :loading="submitLoading || isUploading">确 定</el-button>
|
||||
<el-button type="primary" @click="submitForm" :loading="submitLoading || isUploading" :disabled="formDisabled">确 定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@ -748,7 +775,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue';
|
||||
import { Plus, Document, DocumentCopy, Refresh, Setting, Rank, Camera, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture, FolderOpened, Loading, Upload } from '@element-plus/icons-vue';
|
||||
import { Plus, Document, DocumentCopy, Refresh, Setting, Rank, Camera, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture, FolderOpened, Loading, Upload, ShoppingCart } from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
@ -765,7 +792,7 @@ import {
|
||||
exportAssetStatistics,
|
||||
batchSetWarning,
|
||||
batchSetInspection,
|
||||
markWarningOrdered,
|
||||
batchSetApprovalRequired,
|
||||
getMaterialUnitsAPI,
|
||||
getOdooSummary
|
||||
} from '@/api/material_base';
|
||||
@ -789,6 +816,16 @@ const canEditRemark = computed(() => {
|
||||
return userStore.hasPermission('material_list:remark_edit');
|
||||
});
|
||||
|
||||
// 表单全局禁用:没有 material_list:operation 权限时,所有表单字段不可编辑
|
||||
// ★ 为什么必须有:列表页的「编辑/新增」按钮虽然有权限守卫,但 URL 深链
|
||||
// ?edit_id= 不受守卫,可直接打开本弹窗。若此处不禁用,用户能改完并点确定,
|
||||
// 最终被后端 @permission_required('material_list:operation') 拦下 —— 白填一遍。
|
||||
// (与 list.vue 对齐)
|
||||
const formDisabled = computed(() => {
|
||||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return false;
|
||||
return !userStore.hasPermission('material_list:operation');
|
||||
});
|
||||
|
||||
// --- 类型定义 ---
|
||||
interface MaterialBaseVO {
|
||||
id: number;
|
||||
@ -807,7 +844,8 @@ interface MaterialBaseVO {
|
||||
inventoryCount?: number;
|
||||
availableCount?: number;
|
||||
warningStatus?: number;
|
||||
warningOrdered?: boolean;
|
||||
// 采购在途:后端自动派生(有活跃采购单即 true),取代了原先人工标记的 warningOrdered
|
||||
isPurchasing?: boolean;
|
||||
warningRedEmails?: string;
|
||||
warningYellowEmails?: string;
|
||||
referencePrice?: number;
|
||||
@ -1039,7 +1077,8 @@ const columns = reactive({
|
||||
commonName: { visible: true }, category: { visible: true }, type: { visible: true },
|
||||
spec: { visible: true }, unit: { visible: true }, inventory: { visible: true },
|
||||
available: { visible: true }, files: { visible: true }, isEnabled: { visible: true },
|
||||
isInspectionRequired: { visible: true }, referencePrice: { visible: true }, warningStatus: { visible: true },
|
||||
isInspectionRequired: { visible: true }, isApprovalRequired: { visible: true },
|
||||
referencePrice: { visible: true }, warningStatus: { visible: true },
|
||||
purchaseLink: { visible: true }
|
||||
});
|
||||
|
||||
@ -1050,7 +1089,9 @@ const permissionMap: Record<string, string> = {
|
||||
available: 'material_list:availableCount', files: 'material_list:files', isEnabled: 'material_list:isEnabled',
|
||||
// ★ 与后端读过滤(field_permissions.py)对齐;原先写 material_list:operation
|
||||
// 会与后端口径不一致,渲染出一列全是「-」的空表头
|
||||
isInspectionRequired: 'material_list:isInspectionRequired', referencePrice: 'material_list:referencePrice',
|
||||
isInspectionRequired: 'material_list:isInspectionRequired',
|
||||
isApprovalRequired: 'material_list:isApprovalRequired',
|
||||
referencePrice: 'material_list:referencePrice',
|
||||
purchaseLink: 'material_list:purchaseLink',
|
||||
warningStatus: 'material_list:view_warning'
|
||||
};
|
||||
@ -1413,7 +1454,10 @@ const isArraysEqual = (a: any[], b: any[]): boolean => {
|
||||
|
||||
const buildPartialPayload = (current: any, original: any): any => {
|
||||
const payload: any = { id: current.id };
|
||||
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName', 'productImageRemark', 'manualLinkRemark', 'purchaseLink'];
|
||||
// ★ referencePrice 必须在内:漏掉它会走成「未变更」——只改参考价格时提示
|
||||
// 「没有检测到数据变更,无需保存」,连带改其他字段时提示「修改成功」
|
||||
// 但 payload 里压根没有该字段,价格静默丢失。(与 list.vue 对齐)
|
||||
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'referencePrice', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName', 'productImageRemark', 'manualLinkRemark', 'purchaseLink'];
|
||||
for (const key of compareFields) {
|
||||
const currentVal = current[key]; const originalVal = original[key];
|
||||
if (Array.isArray(currentVal) && Array.isArray(originalVal)) { if (!isArraysEqual(currentVal, originalVal)) payload[key] = currentVal; }
|
||||
@ -1470,6 +1514,35 @@ const createBomForMaterial = () => {
|
||||
window.open(routeUrl.href, '_blank');
|
||||
};
|
||||
|
||||
// 编辑弹窗里的「发起采购申请」:带物料信息跳采购管理并自动预填(与 list.vue 对齐)
|
||||
const createPurchaseForMaterial = () => {
|
||||
if (!form.value.id) return ElMessage.warning('请先保存物料基础信息后再操作');
|
||||
const routeUrl = router.resolve({
|
||||
path: '/purchase',
|
||||
query: {
|
||||
material_id: String(form.value.id),
|
||||
name: form.value.name,
|
||||
spec: form.value.spec,
|
||||
unit: form.value.unit
|
||||
}
|
||||
});
|
||||
window.open(routeUrl.href, '_blank');
|
||||
};
|
||||
|
||||
// 列表内直接切换「出库/借库需审批」(与 list.vue 对齐)
|
||||
const toggleApprovalRequired = async (row: any, val: boolean) => {
|
||||
if (!row || !row.id) return;
|
||||
const prev = !!row.isApprovalRequired;
|
||||
row.isApprovalRequired = val; // 乐观更新
|
||||
try {
|
||||
await batchSetApprovalRequired({ ids: [row.id], isApprovalRequired: val });
|
||||
ElMessage.success(val ? '已标记:该物料出库/借库需审批' : '已取消:该物料出库/借库免审批');
|
||||
} catch (error: any) {
|
||||
row.isApprovalRequired = prev;
|
||||
ElMessage.error(error?.msg || '设置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = JSON.parse(JSON.stringify(initForm));
|
||||
fileListImage.value = []; fileListManual.value = [];
|
||||
@ -1502,13 +1575,10 @@ const handleSetSingleWarning = (row: MaterialBaseVO) => {
|
||||
warningDialog.title = '设置预警'; warningDialog.visible = true;
|
||||
};
|
||||
|
||||
const handleMarkOrdered = (row: MaterialBaseVO) => {
|
||||
ElMessageBox.confirm('确认已对该预警物料下单?标记后在途期间将不再发送预警邮件。', '确认标记已采购', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
|
||||
.then(async () => {
|
||||
try { await markWarningOrdered({ baseId: row.id, isOrdered: true }); ElMessage.success('已标记为已采购'); getList(); }
|
||||
catch (error: any) { ElMessage.error(error?.msg || '标记失败'); }
|
||||
}).catch(() => {});
|
||||
};
|
||||
// ★ 原「标记已采购」人工入口已移除:采购在途状态改由后端 isPurchasing 自动派生
|
||||
// (见列表操作列的 el-tag)。人工标记要靠人记得点、也靠人记得取消,
|
||||
// 而自动判定在建单时即在途、单据驳回/强制结案时即解除,且与待采购池、
|
||||
// 预警邮件静音共用同一份逻辑,三处口径不会各说各话。
|
||||
|
||||
const submitWarning = async () => {
|
||||
if (!warningFormRef.value) return; await warningFormRef.value.validate();
|
||||
|
||||
@ -359,9 +359,20 @@
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.warningStatus.visible && hasColPermission('warningStatus')" label="预警状态" width="120" align="center">
|
||||
<el-table-column v-if="columns.warningStatus.visible && hasColPermission('warningStatus')" label="预警状态" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.warningStatus === 2">
|
||||
<!-- ★ 预警降级:物料缺货但**已有在途采购单**时不再闪红灯。
|
||||
红灯的含义是「需要你去买」,而这里已经有人在买了 ——
|
||||
继续亮红只会制造重复采购的冲动。降级为蓝色「缺货(已采购)」。
|
||||
判定由后端 isPurchasing 给出,与待采购池共用同一份逻辑。
|
||||
注意这里只改**展示**:后端 warningStatus 原样保留(预警强排依赖它)。 -->
|
||||
<template v-if="row.isPurchasing && row.warningStatus > 0">
|
||||
<el-tag type="primary" size="small">缺货(已采购)</el-tag>
|
||||
<div style="font-size: 11px; color: #999;">
|
||||
阈值: {{ row.warningStatus === 2 ? row.warningRed : row.warningYellow }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="row.warningStatus === 2">
|
||||
<el-tag type="danger" size="small">红色预警</el-tag>
|
||||
<div style="font-size: 11px; color: #999;">阈值: {{ row.warningRed }}</div>
|
||||
</template>
|
||||
@ -379,10 +390,10 @@
|
||||
<template #default="scope">
|
||||
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
||||
<el-button v-if="userStore.hasPermission('material_list:edit_warning')" link type="warning" size="small" @click="handleSetSingleWarning(scope.row)">设置预警</el-button>
|
||||
<template v-if="userStore.hasPermission('material_list:edit_warning') && scope.row.warningStatus > 0">
|
||||
<el-button v-if="scope.row.warningOrdered" disabled size="small" type="info">采购在途</el-button>
|
||||
<el-button v-else link type="success" size="small" @click="handleMarkOrdered(scope.row)">标记已采购</el-button>
|
||||
</template>
|
||||
<!-- 采购在途:由后端 isPurchasing 自动派生(与待采购池同一判定)。
|
||||
不再提供人工标记入口 —— 人工标记要靠人记得去点、也靠人记得取消;
|
||||
这个是算出来的:建单即在途,驳回 / 强制结案即自动解除。 -->
|
||||
<el-tag v-if="scope.row.isPurchasing" type="primary" size="small">采购在途</el-tag>
|
||||
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -785,7 +796,6 @@ import {
|
||||
batchSetWarning,
|
||||
batchSetInspection,
|
||||
batchSetApprovalRequired,
|
||||
markWarningOrdered,
|
||||
getMaterialUnitsAPI
|
||||
} from '@/api/material_base';
|
||||
import { uploadFile, deleteFile } from '@/api/common/upload';
|
||||
@ -816,7 +826,8 @@ interface MaterialBaseVO {
|
||||
inventoryCount?: number;
|
||||
availableCount?: number;
|
||||
warningStatus?: number;
|
||||
warningOrdered?: boolean;
|
||||
// 采购在途:后端自动派生(有活跃采购单即 true),取代了原先人工标记的 warningOrdered
|
||||
isPurchasing?: boolean;
|
||||
warningRedEmails?: string;
|
||||
warningYellowEmails?: string;
|
||||
referencePrice?: number;
|
||||
@ -1756,26 +1767,10 @@ const handleSetSingleWarning = (row: MaterialBaseVO) => {
|
||||
warningDialog.visible = true;
|
||||
};
|
||||
|
||||
// 标记预警物料已采购
|
||||
const handleMarkOrdered = (row: MaterialBaseVO) => {
|
||||
ElMessageBox.confirm(
|
||||
'确认已对该预警物料下单?标记后在途期间将不再发送预警邮件。',
|
||||
'确认标记已采购',
|
||||
{
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
await markWarningOrdered({ baseId: row.id, isOrdered: true });
|
||||
ElMessage.success('已标记为已采购');
|
||||
getList();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.msg || '标记失败');
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
// ★ 原「标记已采购」人工入口已移除:采购在途状态改由后端 isPurchasing 自动派生
|
||||
// (见列表操作列的 el-tag)。人工标记要靠人记得点、也靠人记得取消,
|
||||
// 而自动判定在建单时即在途、单据驳回/强制结案时即解除,且与待采购池、
|
||||
// 预警邮件静音共用同一份逻辑,三处口径不会各说各话。
|
||||
|
||||
// 提交预警设置
|
||||
const submitWarning = async () => {
|
||||
|
||||
466
inventory-web/src/views/purchase/PendingPool.vue
Normal file
466
inventory-web/src/views/purchase/PendingPool.vue
Normal file
@ -0,0 +1,466 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 14px;"
|
||||
>
|
||||
<template #title>
|
||||
按<b>有效供给</b>(当前库存 + 在途量)判断:只有「有效供给仍低于预警线」的物料才会出现在这里。
|
||||
在途量取该物料所有活跃采购单(待审批 / 已通过 / 部分到货未齐)的<b>剩余待入库量之和</b>,
|
||||
所以已经买过一部分的物料不会消失 —— 它会继续留在清单里,建议采购量只补剩下的缺口。
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<!-- 顶部筛选 -->
|
||||
<el-form :inline="true" class="filter-form" @submit.prevent>
|
||||
<el-form-item label="搜索">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="物料名称 / 规格型号 / 公司"
|
||||
style="width: 240px"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
@clear="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="类别">
|
||||
<el-input
|
||||
v-model="filters.category"
|
||||
placeholder="类别前缀"
|
||||
style="width: 180px"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
@clear="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="类型">
|
||||
<el-input
|
||||
v-model="filters.type"
|
||||
placeholder="物料类型"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
@clear="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="预警等级">
|
||||
<el-radio-group v-model="filters.warningStatus" @change="handleQuery">
|
||||
<el-radio-button label="">全部</el-radio-button>
|
||||
<el-radio-button :label="2">红色</el-radio-button>
|
||||
<el-radio-button :label="1">黄色</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Refresh" @click="handleQuery">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('inbound_purchase:operation')"
|
||||
type="success"
|
||||
:icon="ShoppingCart"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchGenerate"
|
||||
>批量生成采购申请{{ selectedRows.length ? `(${selectedRows.length})` : '' }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
border
|
||||
stripe
|
||||
fit
|
||||
style="width: 100%; margin-top: 16px;"
|
||||
row-key="material_id"
|
||||
empty-text="暂无待采购物料(所有缺货物料都已有在途采购单)"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<!-- ★ 刻意不加 reserve-selection:它会让勾选跨页保留,但按钮上的数量
|
||||
与实际可见的勾选状态可能不一致(甚至保留已离开池子的物料),
|
||||
用户看到「已选 5 项」而屏幕上一个勾都没有。待采购池是小工作台,
|
||||
跨页批量的收益远小于这种静默不一致的代价。 -->
|
||||
<el-table-column type="selection" width="46" align="center" />
|
||||
|
||||
<el-table-column label="产品图" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.image"
|
||||
:src="getImageUrl(row.image)"
|
||||
:preview-src-list="[getImageUrl(row.image)]"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
style="width: 56px; height: 56px; border-radius: 4px;"
|
||||
>
|
||||
<template #error>
|
||||
<div class="img-fallback">无图</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<span v-else style="color: #c0c4cc; font-size: 12px;">无图</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="name" label="物料名称" min-width="220" show-overflow-tooltip />
|
||||
|
||||
<el-table-column prop="spec_model" label="规格型号" min-width="160" 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="unit" label="单位" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.unit || '-' }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="category" label="类别" min-width="170" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.category || '-' }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="当前库存" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="money-text">{{ row.inventory_count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="在途" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="row.in_transit_qty > 0"
|
||||
content="该物料所有活跃采购单的剩余待入库量之和"
|
||||
placement="top"
|
||||
>
|
||||
<span class="in-transit">+{{ row.in_transit_qty }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else style="color: #c0c4cc;">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="有效供给" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
:content="`库存 ${row.inventory_count} + 在途 ${row.in_transit_qty} = ${row.effective_supply}(判缺货用的就是它)`"
|
||||
placement="top"
|
||||
>
|
||||
<span class="effective">{{ row.effective_supply }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="可用库存" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'avail-warn': row.available_count <= 0 }">
|
||||
{{ row.available_count }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="预警" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.warning_status === 2" type="danger" size="small">红色预警</el-tag>
|
||||
<el-tag v-else-if="row.warning_status === 1" type="warning" size="small">黄色预警</el-tag>
|
||||
<span v-else style="color: #c0c4cc;">-</span>
|
||||
<div style="font-size: 11px; color: #909399; margin-top: 2px;">
|
||||
红 {{ fmt(row.warning_red) }} / 黄 {{ fmt(row.warning_yellow) }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="建议采购量" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="row.suggested_qty != null"
|
||||
:content="suggestedTip(row)"
|
||||
placement="top"
|
||||
>
|
||||
<span class="suggested">{{ row.suggested_qty }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else style="color: #c0c4cc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- ★ 参考价格是受 material_list:referencePrice 管控的价格数据(与物料列表同源
|
||||
同码)。这里与后端 include_reference_price 一一对应:后端无权限时整字段
|
||||
不返回,前端也不渲染该列 —— 只挡前端等于没挡,接口仍然裸奔。 -->
|
||||
<el-table-column
|
||||
v-if="userStore.hasPermission('material_list:referencePrice')"
|
||||
label="参考单价"
|
||||
width="110"
|
||||
align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.reference_price != null">{{ row.reference_price.toFixed(2) }}</span>
|
||||
<span v-else style="color: #c0c4cc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="采购链接" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<!-- ★ 必须过 safeHref 白名单:地址由用户自由填写,直接绑 href 会让
|
||||
javascript: 之类的伪协议被当成可点击链接执行 -->
|
||||
<el-link
|
||||
v-if="safeHref(row.purchase_link)"
|
||||
type="primary"
|
||||
:href="safeHref(row.purchase_link)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>🔗 前往采购</el-link>
|
||||
<span v-else-if="row.purchase_link" style="color: #909399;" :title="row.purchase_link">格式无效</span>
|
||||
<span v-else style="color: #c0c4cc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('inbound_purchase:operation')"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleGenerate(row)"
|
||||
>生成采购申请</el-button>
|
||||
<span v-else style="color: #c0c4cc; font-size: 12px;">无建单权限</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
:background="true"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, ShoppingCart } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getPendingPurchasePool, type PendingPoolItem } from '@/api/purchase'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<PendingPoolItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const selectedRows = ref<PendingPoolItem[]>([])
|
||||
|
||||
/**
|
||||
* 批量交接数据走 localStorage,而不是 URL query。
|
||||
*
|
||||
* 原因:批量可能勾选十几个物料,每个还带商家链接(material_base.purchase_link
|
||||
* 是 Text,实测单条已 516 字符)。塞进 URL 会撞上长度上限并被**静默截断** ——
|
||||
* 前端拿到半截 JSON 只会报解析失败,排查成本很高。localStorage 没有这个上限。
|
||||
* query 里只传一个一次性 key,取完即删。
|
||||
*/
|
||||
const BATCH_KEY_PREFIX = 'MOM_PURCHASE_BATCH_'
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
category: '',
|
||||
type: '',
|
||||
warningStatus: '' as '' | 1 | 2
|
||||
})
|
||||
|
||||
const fmt = (v: number | null) => (v == null ? '-' : v)
|
||||
|
||||
/**
|
||||
* 建议采购量的说明文案。
|
||||
*
|
||||
* ★ 为什么要分两种:有效供给**恰好等于**阈值时,`阈值 - 有效供给` 是 0,
|
||||
* 但后端有 `max(1, ...)` 保底,结果是 1。若直接套「阈值 − 有效供给 = 建议量」
|
||||
* 的模板,页面上会出现「4 − 4 = 1」这种**算不对的式子** —— 采购员一旦发现
|
||||
* 算式不成立,就不会再信这个数字。所以到线的情况单独讲清楚。
|
||||
*
|
||||
* 等于阈值仍算不足,是与物料列表预警、预警邮件一致的口径(见后端
|
||||
* purchase_service.get_pending_purchase_pool 的说明)。
|
||||
*/
|
||||
const suggestedTip = (row: PendingPoolItem) => {
|
||||
const target = row.target_threshold
|
||||
const eff = row.effective_supply
|
||||
if (target != null && target - eff >= 1) {
|
||||
return `补至 ${target}:目标阈值 ${target} − 有效供给 ${eff} = ${row.suggested_qty}(在途已计入,不会重复买)`
|
||||
}
|
||||
return `有效供给 ${eff} 已达阈值线 ${target},建议补 ${row.suggested_qty} 个作缓冲。` +
|
||||
`(等于阈值仍算不足 —— 与物料列表的预警口径一致)`
|
||||
}
|
||||
|
||||
const getImageUrl = (url: string) => (!url ? '' : url)
|
||||
|
||||
/** 采购链接白名单:只放行 http/https,其余一律不渲染成链接 */
|
||||
const safeHref = (url: any): string | null => {
|
||||
const s = String(url || '').trim()
|
||||
return /^https?:\/\//i.test(s) ? s : null
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: page.value,
|
||||
limit: pageSize.value
|
||||
}
|
||||
if (filters.keyword) params.keyword = filters.keyword
|
||||
if (filters.category) params.category = filters.category
|
||||
if (filters.type) params.type = filters.type
|
||||
if (filters.warningStatus !== '') params.warning_status = filters.warningStatus
|
||||
|
||||
const res: any = await getPendingPurchasePool(params)
|
||||
const data = res?.data ?? {}
|
||||
list.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || '加载待采购清单失败')
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuery = () => {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const resetFilter = () => {
|
||||
filters.keyword = ''
|
||||
filters.category = ''
|
||||
filters.type = ''
|
||||
filters.warningStatus = ''
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handlePageChange = (p: number) => { page.value = p; fetchData() }
|
||||
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchData() }
|
||||
|
||||
/**
|
||||
* 生成采购申请:新标签页打开采购申请页并自动回填。
|
||||
*
|
||||
* 用 window.open 而非 router.push 是刻意的 —— 待采购清单的工作流是**连续建单**,
|
||||
* 同标签跳转会卸载本页,采购员每开一单都要点回来重新筛选、丢失页码;
|
||||
* 新标签页保留列表状态。这与「基础信息」页既有的跳转方式也保持一致。
|
||||
*/
|
||||
const handleSelectionChange = (rows: PendingPoolItem[]) => {
|
||||
selectedRows.value = rows || []
|
||||
}
|
||||
|
||||
/** 把池中一行整理成弹窗明细行的预填数据(单行与批量共用同一套字段映射) */
|
||||
const toPrefillItem = (row: PendingPoolItem) => ({
|
||||
materialId: row.material_id,
|
||||
name: row.name || '',
|
||||
spec: row.spec_model || '',
|
||||
// 建议采购量作为默认数量带入
|
||||
quantity: row.suggested_qty != null && row.suggested_qty > 0 ? row.suggested_qty : undefined,
|
||||
// 供应商链接(基础表的采购链接)带入,采购员无需再去查
|
||||
supplierLink: String(row.purchase_link || '').trim() || undefined
|
||||
})
|
||||
|
||||
/**
|
||||
* 把预填数据交给采购申请页,开新标签页。
|
||||
*
|
||||
* ★ 单行与批量都走这里、都经 localStorage 中转 —— 曾经单行走 URL query,
|
||||
* 而 material_base.purchase_link 是 text(实测已有 516 字符的真实链接),
|
||||
* URL 长度受限会逼出一个「多长就不带」的硬编码阈值,导致同一个物料
|
||||
* 单行跳转带链接、批量跳转不带,行为不一致。现在两条路径统一,无长度上限。
|
||||
*
|
||||
* 开新标签页是刻意的:待采购清单的工作流是连续建单,同标签跳转会卸载本页、
|
||||
* 丢失筛选与勾选状态。
|
||||
*/
|
||||
const openPurchaseWithItems = (items: ReturnType<typeof toPrefillItem>[]) => {
|
||||
const key = BATCH_KEY_PREFIX + Date.now()
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(items))
|
||||
} catch (e) {
|
||||
ElMessage.error('暂存待建单数据失败,请重试')
|
||||
return
|
||||
}
|
||||
const routeUrl = router.resolve({ path: '/purchase', query: { batch_key: key } })
|
||||
window.open(routeUrl.href, '_blank')
|
||||
}
|
||||
|
||||
/** 单行:生成采购申请 */
|
||||
const handleGenerate = (row: PendingPoolItem) => {
|
||||
openPurchaseWithItems([toPrefillItem(row)])
|
||||
}
|
||||
|
||||
/** 批量:勾选 N 个物料 → 一次性铺成 N 行,一单多品合并采购 */
|
||||
const handleBatchGenerate = () => {
|
||||
if (selectedRows.value.length === 0) return
|
||||
openPurchaseWithItems(selectedRows.value.map(toPrefillItem))
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-container { padding: 20px; }
|
||||
|
||||
/* 搜索区版式:与采购申请 / 出库记录 / 报废记录保持一致 */
|
||||
.filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-form :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.suggested {
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 在途量与有效供给:本次改造新增的核心数字,用不同色区分于纯库存 */
|
||||
.in-transit {
|
||||
color: #e6a23c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.effective {
|
||||
font-weight: 600;
|
||||
border-bottom: 1px dashed #c0c4cc;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.avail-warn {
|
||||
color: #f56c6c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.img-fallback {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@ -252,9 +252,19 @@
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商家链接" min-width="160">
|
||||
<el-table-column label="商家链接" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.supplier_link" placeholder="https://" clearable />
|
||||
<!-- 后置跳转按钮:链接非法(非 http/https)时置灰,不做静默失败 -->
|
||||
<el-input v-model="row.supplier_link" placeholder="https://" clearable>
|
||||
<template #append>
|
||||
<el-button
|
||||
:icon="Link"
|
||||
:disabled="!safeHref(row.supplier_link)"
|
||||
title="在新标签页打开链接"
|
||||
@click="openLink(row.supplier_link)"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="150">
|
||||
@ -458,7 +468,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Refresh, Plus, Delete, CopyDocument, Picture } from '@element-plus/icons-vue'
|
||||
import { Refresh, Plus, Delete, CopyDocument, Picture, Link } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { searchMaterialPurchase } from '@/api/purchase'
|
||||
@ -916,29 +926,98 @@ const handleStatusChange = () => {
|
||||
const handlePageChange = (p: number) => { page.value = p; fetchData() }
|
||||
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchData() }
|
||||
|
||||
/**
|
||||
* 链接白名单:只放行 http/https。
|
||||
*
|
||||
* ★ 商家链接由用户自由填写,直接交给 window.open 会让 `javascript:` 之类的
|
||||
* 伪协议被当成可执行代码。与物料列表 / 待采购清单的 safeHref 同一约定。
|
||||
*/
|
||||
const safeHref = (url: any): string | null => {
|
||||
const s = String(url || '').trim()
|
||||
return /^https?:\/\//i.test(s) ? s : null
|
||||
}
|
||||
|
||||
/** 明细行「商家链接」的跳转按钮:新标签页打开,方便采购员比对价格 */
|
||||
const openLink = (url: any) => {
|
||||
const href = safeHref(url)
|
||||
if (!href) {
|
||||
ElMessage.warning('链接格式无效,仅支持 http / https 地址')
|
||||
return
|
||||
}
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
// --- 新建 ---
|
||||
const openCreateDialog = (prefill?: { materialId?: number; name?: string; spec?: string; unit?: string }) => {
|
||||
/** 把一条预填数据灌进明细行(单条与批量共用同一套字段映射) */
|
||||
const fillRowFromPrefill = (
|
||||
row: FormItemRow,
|
||||
src: { materialId: number; name?: string; spec?: string; quantity?: number; supplierLink?: string }
|
||||
) => {
|
||||
row.materialBaseId = src.materialId
|
||||
row.name = src.name || ''
|
||||
row.spec_model = src.spec || ''
|
||||
// 待采购清单带来的默认数量与供应商链接(都是 FormItemRow 已有字段,
|
||||
// 不需要动接口/模板/工厂函数)。unit 仍不带入 —— 明细行没有该字段。
|
||||
if (src.quantity && src.quantity > 0) {
|
||||
row.quantity = src.quantity
|
||||
}
|
||||
if (src.supplierLink) {
|
||||
row.supplier_link = src.supplierLink
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
const openCreateDialog = (prefill?: {
|
||||
materialId?: number
|
||||
name?: string
|
||||
spec?: string
|
||||
unit?: string
|
||||
quantity?: number
|
||||
supplierLink?: string
|
||||
/** 批量:待采购清单勾选多个物料时,一次性铺成多行 */
|
||||
batchItems?: Array<{
|
||||
materialId: number
|
||||
name?: string
|
||||
spec?: string
|
||||
quantity?: number
|
||||
supplierLink?: string
|
||||
}>
|
||||
}) => {
|
||||
dialogTitle.value = '新建采购申请'
|
||||
form.value = {
|
||||
approver_id: undefined,
|
||||
remark: ''
|
||||
}
|
||||
|
||||
// ★ 初始化明细行:若有预填物料则第一行带入,否则给一行空行
|
||||
const firstRow = createEmptyFormItem()
|
||||
if (prefill?.materialId) {
|
||||
firstRow.materialBaseId = prefill.materialId
|
||||
firstRow.name = prefill.name || ''
|
||||
firstRow.spec_model = prefill.spec || ''
|
||||
if (prefill?.batchItems && prefill.batchItems.length > 0) {
|
||||
// ★ 批量建单:勾选 N 个物料 → 明细表直接铺 N 行,一单多品合并采购。
|
||||
// materialOptions 必须把每一行的物料都塞进去,否则 el-select 找到不到
|
||||
// 选项,已填的物料会显示成空白(单条预填时也有这个约束)。
|
||||
formItems.value = prefill.batchItems.map(it =>
|
||||
fillRowFromPrefill(createEmptyFormItem(), it)
|
||||
)
|
||||
materialOptions.value = prefill.batchItems.map(it => ({
|
||||
id: it.materialId,
|
||||
name: it.name || '',
|
||||
spec_model: it.spec || ''
|
||||
}))
|
||||
} else if (prefill?.materialId) {
|
||||
formItems.value = [fillRowFromPrefill(createEmptyFormItem(), {
|
||||
materialId: prefill.materialId,
|
||||
name: prefill.name,
|
||||
spec: prefill.spec,
|
||||
quantity: prefill.quantity,
|
||||
supplierLink: prefill.supplierLink
|
||||
})]
|
||||
materialOptions.value = [{
|
||||
id: prefill.materialId,
|
||||
name: prefill.name || '',
|
||||
spec_model: prefill.spec || ''
|
||||
}]
|
||||
} else {
|
||||
formItems.value = [createEmptyFormItem()]
|
||||
materialOptions.value = []
|
||||
}
|
||||
formItems.value = [firstRow]
|
||||
|
||||
formDialogVisible.value = true
|
||||
|
||||
@ -1393,14 +1472,52 @@ onMounted(() => {
|
||||
fetchData()
|
||||
fetchApprovers()
|
||||
|
||||
// 从基础信息跳转过来的参数
|
||||
// 跨页跳转过来的预填参数。生产端有两处:
|
||||
// - 基础信息 / 基础信息(Odoo) 的「发起采购申请」按钮
|
||||
// - 待采购清单的「生成采购申请」按钮(额外带 quantity / supplier_link)
|
||||
//
|
||||
// ★ 本段依赖**组件重建**:两个生产端都用 window.open 开新标签页,
|
||||
// 每次都是全新实例,onMounted 必然触发。
|
||||
// 若将来有人改成「同 path 的 router.push」(如 /purchase?material_id=2 → material_id=3),
|
||||
// 本段不会重跑,预填会静默失效 —— 届时必须改造为
|
||||
// watch(() => route.query.material_id, ..., { immediate: true })。
|
||||
const query = route.query
|
||||
|
||||
// 批次 A:待采购清单「批量生成采购申请」——物料较多(含可长达数百字符的
|
||||
// 商家链接),塞进 URL 有长度风险,故走 localStorage 中转,query 里只传一个键。
|
||||
if (query.batch_key) {
|
||||
const key = String(query.batch_key)
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
// ★ 读完立刻删除:这是一次性交接数据,留着重进页面会重复弹出;
|
||||
// 多个标签页各自用独立的 key,互不干扰。
|
||||
localStorage.removeItem(key)
|
||||
const items = raw ? JSON.parse(raw) : []
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
openCreateDialog({ batchItems: items })
|
||||
} else {
|
||||
ElMessage.warning('批量数据已失效,请回到待采购清单重新勾选')
|
||||
}
|
||||
} catch (e) {
|
||||
localStorage.removeItem(key)
|
||||
ElMessage.error('批量数据解析失败,请重新勾选')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 批次 B:单条预填,走 URL query。
|
||||
// 生产端只有基础信息 / 基础信息(Odoo) 的「发起采购申请」按钮
|
||||
// (它们只带 id/名称/规格,不带链接,所以没有 URL 长度风险)。
|
||||
// 待采购清单的单行与批量按钮统一走上面的 batch_key,两条路径共用一套回填。
|
||||
if (query.material_id) {
|
||||
const rawQty = Number(query.quantity)
|
||||
openCreateDialog({
|
||||
materialId: Number(query.material_id),
|
||||
name: (query.name as string) || '',
|
||||
spec: (query.spec as string) || '',
|
||||
unit: (query.unit as string) || ''
|
||||
unit: (query.unit as string) || '',
|
||||
quantity: rawQty > 0 ? rawQty : undefined,
|
||||
supplierLink: (query.supplier_link as string) || ''
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@ -1943,14 +1943,21 @@ const confirmPurchaseImport = () => {
|
||||
form.tax_rate = Number(po.tax_rate)
|
||||
}
|
||||
|
||||
if (hasTotalPrice) {
|
||||
// 优先: 含税总价 → 调用 post_total 逻辑反算单价
|
||||
postTaxTotalInput.value = Number(po.total_price)
|
||||
onPostTaxTotalChange(postTaxTotalInput.value)
|
||||
} else if (hasUnitPrice) {
|
||||
// 备选: 含税单价 → 反算不含税
|
||||
// 导入新单前清掉上一单残留的"手工总价"标记,否则 updatePrices 末尾会用旧值回头覆盖
|
||||
postTaxTotalManuallySet.value = false
|
||||
postTaxTotalInput.value = undefined
|
||||
|
||||
if (hasUnitPrice) {
|
||||
// ★ 单价优先。申请单的 total_price 是「申请数量」的钱,而实际入库数量未必相同
|
||||
// —— 厂家怕出问题多发(申请 100 个、实发 104 个)是常态。用总价去除以实际
|
||||
// 入库数量会把单价摊薄或抬高,必须一律以单价为准,总价由「单价 × 实际入库数量」得出。
|
||||
// 含税单价 → 反算不含税
|
||||
form.post_tax_unit_price = Number(po.unit_price)
|
||||
updatePrices('post')
|
||||
} else if (hasTotalPrice) {
|
||||
// 兜底:申请单只有总价、没有单价时,才按总价反算
|
||||
postTaxTotalInput.value = Number(po.total_price)
|
||||
onPostTaxTotalChange(postTaxTotalInput.value)
|
||||
}
|
||||
if (po.supplier_link) {
|
||||
form.detail_link = po.supplier_link
|
||||
|
||||
@ -209,9 +209,29 @@
|
||||
<el-tag type="info">{{ row.children ? row.children.length : 0 }} 项</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- ★ 归还人改为整单汇总:主行原取「首条明细」的归还人,多明细分批归还时
|
||||
会只显示其中一位,令人误以为其他人没还过。 -->
|
||||
<el-table-column v-if="hasColumnPermission('return_operator')" label="归还人" min-width="90">
|
||||
<!-- ★ 「归还人」与「经手库管」是**两个不同的人**,必须分列:
|
||||
returners ← trans_borrow_return.returner_id(把东西交回窗口的人)
|
||||
return_operators ← trans_borrow.return_operator(办理还库的库管)
|
||||
原先只有一列,标着「归还人」读的却是 return_operator —— 展示成了库管。
|
||||
⚠ 实际归还人流水是二期才建的,**历史归还查不到**:此时列显示为空并给出
|
||||
提示,而不是拿库管的名字顶上(那正是本次要修的错)。 -->
|
||||
<el-table-column label="归还人" min-width="90">
|
||||
<template #default="{row}">
|
||||
<template v-if="row.returners && row.returners.length">
|
||||
<span v-for="n in row.returners" :key="n" class="name-fixed">{{ formatName(n) }}</span>
|
||||
</template>
|
||||
<el-tooltip
|
||||
v-else-if="row.status === 'returned' || row.status === 'scrapped'"
|
||||
placement="top"
|
||||
content="该笔归还发生在「实际归还人」记录上线之前,系统当时只留了经手库管"
|
||||
>
|
||||
<span class="text-info">—</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="text-info">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- 经手库管:办理还库的库管,与「归还人」不是同一人,故单列展示 -->
|
||||
<el-table-column v-if="hasColumnPermission('return_operator')" label="经手库管" min-width="90">
|
||||
<template #default="{row}">
|
||||
<template v-if="row.return_operators && row.return_operators.length">
|
||||
<span v-for="op in row.return_operators" :key="op" class="name-fixed">{{ formatName(op) }}</span>
|
||||
|
||||
@ -122,7 +122,7 @@
|
||||
<el-divider content-position="left">还库确认</el-divider>
|
||||
|
||||
<div style="margin-bottom: 10px; font-size: 14px; color: #606266; padding: 0 10px;">
|
||||
请库管员在此签字确认入库:
|
||||
请领用人在此签字确认还库:
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
@ -155,7 +155,7 @@
|
||||
</div>
|
||||
<div v-else class="unsigned-placeholder">
|
||||
<el-icon :size="24"><EditPen /></el-icon>
|
||||
<span>点击此处进行库管签名</span>
|
||||
<span>点击此处进行领用人签名</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="signature-box" style="background-color: #f5f5f5; cursor: not-allowed;">
|
||||
@ -388,7 +388,7 @@ const preSubmitCheck = async () => {
|
||||
if (returnList.value.length === 0) return
|
||||
|
||||
if (!signatureFile.value) {
|
||||
ElMessage.error('库管必须签字确认')
|
||||
ElMessage.error('领用人必须签字确认')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user