9 Commits

Author SHA1 Message Date
b4e43d641a V3.84,9.18推送 2026-09-18 10:10:12 +08:00
ac22c9959b feat(material): 基础信息(Odoo)补回「出库/借库需审批」列
buyOdoo.vue 是 list.vue 的手工副本。93f1497 / 4146f35 给 list.vue 加了
「出库/借库需审批」列,未同步到 Odoo 页 —— isApprovalRequired 在
list.vue 出现 9 次,在 buyOdoo.vue 为 0 次,该页既看不到也改不了这个开关。

补上表格列(带切换开关与操作权限守卫)、列显示开关、columns 与
permissionMap 条目、toggleApprovalRequired 函数及 batchSetApprovalRequired
接口引用。
2026-09-18 10:07:27 +08:00
825c088e98 feat(material): 基础信息(Odoo)补回「发起采购申请」入口
buyOdoo.vue 是 list.vue 的手工副本。31903bd 给 list.vue 弹窗加了
「发起采购申请」,未同步到 Odoo 页,两个页面的编辑弹窗头部不一致。

补上弹窗头部链接、createPurchaseForMaterial 函数及 ShoppingCart 图标。
2026-09-18 10:07:24 +08:00
f2bb95becc fix(material): 基础信息(Odoo)补回表单禁用权限守卫
buyOdoo.vue 是 list.vue 的手工副本。68fd93b 给 list.vue 加了 formDisabled
(无 material_list:operation 权限时整个表单只读),未同步到 Odoo 页。

列表页的「编辑/新增」按钮虽有权限守卫,但 URL 深链 ?edit_id= 不受守卫,
可直接打开 Odoo 页弹窗:字段能改、确定能点,提交后才被后端
@permission_required('material_list:operation') 拦下,用户白填一遍。

补上 formDisabled computed 及表单、确定按钮两处绑定,与 list.vue 对齐。
2026-09-18 10:07:21 +08:00
243355bc68 fix(material): 基础信息(Odoo)补回参考价格脏检查白名单
buyOdoo.vue 是 list.vue 的手工副本。8791914 修复「参考价格不可改」时只改了
list.vue,同一个 bug 在 Odoo 页存活至今:

- 只改参考价格:payload 无变更,提示「没有检测到数据变更,无需保存」
- 连带改其他字段:提示「修改成功」,但 payload 不含 referencePrice,价格未入库

compareFields 补上 referencePrice,与 list.vue 对齐。
2026-09-18 10:07:18 +08:00
eafdf992c7 fix(borrow): 借还记录「归还人」列错标成了经手库管,拆成两列
问题
----
列表里那一列标着「归还人」,读的却是 trans_borrow.return_operator —— 而该字段
存的是**办理还库的库管**,不是来还东西的人。两者本就是不同的人:
  · returner_id(trans_borrow_return)—— 把东西交回窗口的人,已校验 == 当时持有人
  · return_operator                    —— 经手办理的库管
实测(借用行 120):return_operator = 杜邢宸/duxingchen(库管),
而实际归还人是 returner_id = 21(测试)—— 页面却显示成了「杜邢宸」。

改动
----
一、后端 get_records 增补 returners:从 trans_borrow_return 取 returner_id 并
    反查 sys_user 得到姓名(去重按明细挂回)。批量查一次,不做 N+1。
二、前端拆成两列,各自名副其实:
      「归还人」   ← returners(后端新增)
      「经手库管」 ← return_operators(原列改为正确标签)
三、顺带修展示口径:return_operator 存的是**完整 username**(高闯/gaochuang),
    未按全站口径截断。_display_borrow_operator 现统一归一到展示名
    (数字 id 反查 / 姓名、斜杠前段 / 已是展示名 原样),并修正其 docstring
    —— 它原本也把该字段称作「归还人」,是同一个误解的源头。

★ 历史数据的现实
  实际归还人流水是二期才建的,**历史归还没有这个记录**。这部分行的「归还人」
  显示为空并挂 tooltip 说明「该笔归还发生在实际归还人记录上线之前」——
  刻意不拿库管的名字顶上,那正是本次要修的错。

验证
  BOR-20260918-0001 → 归还人=['测试']、经手库管='杜邢宸'  ✓ 两者分开
  BOR-20260914-0001 → 归还人=[](历史)、经手库管='高闯'    ✓
  归一化:'高闯/gaochuang'→'高闯'、'21'→'测试'             ✓
  前端 vite build 通过;本次无需 DB 迁移。
2026-09-18 09:43:25 +08:00
e3fe1fc12a feat(borrow): 借还记录「已归还」页签改为按归还时间倒序
问题
----
三个页签共用同一套排序(有限期单在前 → 最早应还时间 ASC → 最早借出时间 DESC),
这套「优先关注快到期/逾期」的逻辑对「未归还」是对的,但对「已归还」正好**
反了**:已归还列表要回答的是「最近还了哪几笔」,而按应还时间排会让最近刚还的
那几笔排到最后。

改动
----
order_subq 增加 max_return_time(单号内**最晚**一次归还时间)作为排序键,
并按页签分流:
  · 已归还     → nullslast(max_return_time DESC),borrow_no DESC 兜底保证稳定
  · 全部/未归还 → 原三级排序不变

★ 为什么取「最晚」而不是「最早」一次归还时间
  多明细分批归还时,整单结清的那一刻才是有意义的节点;且与主行「归还时间」列
  的展示口径一致(前端同样取 latest),排序依据与可见值不会打架。

验证(真实数据)
  已归还页签:09-15 11:41 → 09-14 16:02 → 09-09 11:46 → 09-08 13:22 →
              09-04 17:26 → 09-04 09:45 → 09-03 15:25,第 2 页续 08-27 → …,
              严格递减且**跨页连续**;
  未归还页签:排序与改动前完全一致(回归确认)。
2026-09-18 09:34:45 +08:00
ccdd6e7306 fix(purchase): 商家地址链接放宽为 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)。

附带确认(全部扫描,无需改动)
  从数据库层面扫了所有「有长度上限、且列名像 link/url/photo/image/
  signature/path」的列:
    · purchase_request.supplier_link(500)  ← 本次唯一真正有问题的
    · sys_menu.path(200)、sys_warehouse_location.full_path(500)  内部生成数据
    · stock_adjustment.linked_sku(100)/linked_outbound_no(50)     SKU 与单号
  用户粘贴外链的列仅此一处。

迁移用 DO 块先判断当前类型,可重复执行;含核对段与回滚段。
验证:把那条 516 字符的真实链接写入再读回,长度一致、内容未截断。
2026-09-18 09:34:44 +08:00
1eaa203eba fix(db): 修正存量「归还时间/报废时间」的 8 小时时区偏差(附自检)
问题
----
process_return 与 scrap_sources.deduct 原先用 datetime.now()(容器本地时间,
Docker 默认 UTC)写 return_time / operation_time,而同一行的 borrow_time 由
beijing_time() 写入 —— 同一行两个时钟,差 8 小时。

实测(开发库)trans_borrow 有 53 行呈现「归还时间早于借出时间」这种物理上
不可能的数据,例如 id=80 借出 09-09 11:04、归还 09-09 03:46。

代码侧已修(f7c49f4),新数据已是北京时间 —— 本次只处理存量。

★ 脚本刻意做了「先自检、后修正」的两段式
  服务器 API 容器的 TZ 未必是 UTC(若本就是 Asia/Shanghai,datetime.now()
  一直是北京时间,则**根本无需修正**)。若不做自检直接 +8h,会把正确的时间
  改错。故第 0 步先输出:异常行数、以及按分界切出的「UTC 段/空档/北京段」
  三段计数,由使用人据结果决定是否执行第 2 步。

★ 分界与幂等性都在脚本里写明
  · 分界 = 修复代码在该服务器**部署生效的时刻**(不是提交时刻),需按实际调整;
  · 脚本**不幂等** —— 重复执行会再加 8 小时,已显式警示并给出回滚写法。

开发库已按此逻辑修正:53 行 trans_borrow + 1 行 trans_scrap,
修正后「归还早于借出」为 0,抽样时间落在正常工作时段。
2026-09-18 09:27:17 +08:00
8 changed files with 355 additions and 28 deletions

View 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) 列类型应为 textcharacter_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;

View 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。
--
-- 代码侧已修
-- 提交 f7c49f42026-09-17 09:18 北京时间)起改用 beijing_time()
-- **新写入的数据已经是北京时间**(已验证 TransReturn / TransBorrowReturn /
-- TransScrap 三张表的模型默认值均为北京)。本脚本只处理存量。
--
-- ---------------------------------------------------------------------------
-- ★★ 执行前必读:请先跑「第 0 步 自检」,据其结果决定是否执行 ★★
--
-- 1) 若自检的「异常行数」为 0且 UTC 区间计数为 0 —— 说明**本服务器无此问题**
-- (例如 API 容器的 TZ 本就是 Asia/Shanghaidatetime.now() 一直是北京时间),
-- **不要执行第 2 步**,否则会把正确的时间又 +8 小时。
--
-- 2) 分界时间 @boundary 的含义:**修复代码在本服务器部署生效的时刻**。
-- · 该时刻之前写入的行 → UTC需 +8h
-- · 该时刻之后写入的行 → 北京时间,不能动。
-- 开发库用的是提交时间 2026-09-17 09:18北京= 01:18UTC
-- 两组数据之间通常有一段空档,请用自检查看空档是否存在。
-- 若你的部署时间不同,请替换下方两处 '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;

View File

@ -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='不含税单价')

View File

@ -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):

View File

@ -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:

View File

@ -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.83
当前版本:V3.84
</span>
</footer>

View File

@ -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>
@ -434,10 +444,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 +674,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 +767,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,6 +784,7 @@ import {
exportAssetStatistics,
batchSetWarning,
batchSetInspection,
batchSetApprovalRequired,
markWarningOrdered,
getMaterialUnitsAPI,
getOdooSummary
@ -789,6 +809,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;
@ -1039,7 +1069,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 +1081,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 +1446,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 +1506,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 = [];

View File

@ -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>