39 Commits

Author SHA1 Message Date
0b4a2b29d6 V3.91,9.22推送 2026-09-22 14:21:00 +08:00
3851ebd7bc fix(inbound): 导入采购单改为单价优先,修复多发/少发时单价被摊薄
原逻辑是总价优先(hasTotalPrice 分支在前):用「申请单总价 ÷ 本次入库数量」
反算单价。但申请单的 total_price 是「申请数量」的钱,实际入库数量未必相同
—— 厂家怕出问题多发(申请 100 个、实发 104 个)是常态,用总价除以实际数量
会把单价摊薄或抬高。

改为单价优先、总价仅作兜底,总价由「单价 × 实际入库数量」得出。与后端
buy_service 的补价口径一致。

顺带修一处隐患:导入新单前清掉 postTaxTotalManuallySet / postTaxTotalInput。
此前若上一单手工改过含税总价,这两个 ref 会残留,updatePrices 末尾会拿着
旧总价回头覆盖新导入的价格 —— 连续导入两张单时必现。

影响面:库管本就拿不到价格(接口剥离),此段对其一直是空转;受影响的是有
价格权限的角色在页面上导入采购单时的计算口径。
2026-09-22 14:09:09 +08:00
d99396ecb1 fix(inbound): 按单入库时从采购申请单补回成本价
库管角色没有 inbound_purchase:unit_price / total_price 权限,采购单接口
(api/v1/purchase.py:46-53)会把价格字段整个 pop 掉,前端导入时因此带不出价
—— buy.vue 的 hasUnitPrice / hasTotalPrice 双双落空,两个分支都不进,入库
成本被记成 0。

而前端 buy.vue:1936 的注释写得很清楚:"无论前端是否对当前角色显示价格,导入
都应把采购单价格带入(隐藏字段,用于成本)"。前端想带、后端不给,价格就断在
中间——不是谁写错了,是两边对"隐藏"的理解不同。

改为在落库前从采购申请单补回:价格不经过前端,库管依旧看不到采购价,权限
边界不变,但成本可追溯。仅在前端未带价时补,有权限的角色手动填过价则尊重
提交值。采购申请单的 unit_price 存的是**含税**单价(见 purchase/index.vue
列名"含税单价"),需反算不含税。

顺带清理 buy_service.py:221 的函数内局部 import —— 局部 import 会让 Python
把 PurchaseRequest 视为本函数的局部变量,导致位于其之前的引用抛
UnboundLocalError(本次新增的补价逻辑正踩到这个,测试时暴露)。改用文件顶部
的全局导入。

实测:测试申请单 含税 12.5 / 税率 13%,以库管身份提交且**故意不带任何价格
字段**,落库结果 税前 11.0619 (=12.5/1.13) / 含税 12.5 / 总价 110.6195 /
税率 13.00,全部正确;响应体经 filter_item_by_permissions 后仍不含价格字段,
权限边界未变。

另:已有 6 条历史入库单(走采购申请但单价为 0)已按「单价 × 实际入库数量」
回填,不回填总价。
2026-09-22 14:09:08 +08:00
91db54982c feat(track): 入库/出库 webhook 按公司路由到对应实例
三个触发点此前都不带 company_name,无从路由:
  · product_service / semi_service:入库通知补 material.company_name
  · outbound_service:track_notifications 元组加 company(取自
    stock_record.base.company_name;维修单走 repair.base,两处
    relationship 均已定义),发送时带进 payload

出库路径同时**去掉**了显式传入的 url=TRACK_OUTBOUND_WEBHOOK_URL ——
url 参数优先级最高,保留它会让出库永远打到默认实例,company 路由形同
虚设。url 参数本身保留,需要强制覆盖时仍可用。

实测:
  LICA 入库:待仓库收货 → 已入库,IRIS 库该 SN 0 行
  LICA 出库:已入库 → 已出库,IRIS 库该 SN 0 行
  IRIS 回归:对已入库设备重发入库通知,状态幂等不变
  精确隔离:发一条 IRIS 通知,lica_backend 日志 +0 行、track_backend +19 行

注意:不做"两个实例都推一遍"——两个 Track 的 webhook key 是同一把、
两边都会鉴权通过,而 Track 按 serial_number/sku 匹配,相同型号设备会
同时命中两库,把 IRIS 的设备误标成已入库,且产生 2 倍审计与流转节点。
2026-09-22 13:13:54 +08:00
0f929468a8 fix(track): track-lookup 按公司路由,修复 LICA 扫码误报"物料不存在"
lookup_product() 此前写死读全局 TRACK_API_URL(指向 IRIS)。LICA 员工在
LICA 业务里扫码 → 请求打到 IRIS 库 → 查不到 → MOM 报"物料不存在"。

改用 get_current_company_filter() + resolve_track_route(…, 'api') 选实例,
复用既有工具而非另写一套。无请求/JWT 上下文时(脚本、后台任务)包一层
异常保护回落默认实例,不阻断查询。

实测(身份证 0000000000000001 只存在于 LICA 库):
  LICA 员工 → company_filter='LICA' → 命中 sn='0000000000000001'
  IRIS 员工 → company_filter='IRIS' → 未命中
改造前两者都会打到 IRIS,LICA 员工必然查不到。
2026-09-22 13:13:53 +08:00
b85be2dfdf feat(track): 新增 resolve_track_route,按公司解析 Track 实例地址
resolve_track_route(company_name, channel) 支持 api / inbound / outbound
三个通道。未命中一律回落到扁平变量,**绝不返回空**——漏配一个 key 就
静默断链,比配置写错更难排查。

非空但不在路由表内的值(含 get_current_company_filter() 的
'__NO_COMPANY__' 哨兵、拼写异常的公司名)会打 WARNING 暴露出来,但不
阻断业务——这类值属于主数据问题,交给业务确认,代码不猜。

notify_track() 相应扩展:新增 channel 参数(不传则按 payload['event']
推断 inbound/outbound),url 仍为最高优先级(显式指定时不做公司路由)。

实测各分支:IRIS/LICA 各通道命中正确;None/空串/未知公司/缺通道/空路由表
全部按预期回落。
2026-09-22 13:13:53 +08:00
de34a939d0 feat(track): 新增 TRACK_ROUTES 多实例路由表配置
Track 现在有两个独立实例(IRIS / LICA),MOM 此前所有 Track 配置都写死
指向 IRIS,LICA 的业务拿不到入库/出库联动,且 /track-lookup 对 LICA
员工是坏的。

新增 TRACK_ROUTES 环境变量(JSON),按 material_base.company_name 决定
业务落到哪个实例:
  {"IRIS": {"api":..., "inbound":..., "outbound":...}, "LICA": {...}}

原有三个扁平变量(TRACK_API_URL / TRACK_WEBHOOK_URL /
TRACK_OUTBOUND_WEBHOOK_URL)保留为「未命中路由表」时的回落默认值;
TRACK_ROUTES 缺省为空表时,行为与改造前逐字节一致(向后兼容)。

⚠️ JSON 解析失败直接抛异常让进程起不来(fail fast)——路由表写坏却静默
回落,会造成"某些公司悄悄断链",比启动报错难排查得多。

docker-compose.yml 用容器名互访(inventory_api / track_backend /
lica_backend 三个容器同属 projects_default 网络,不走宿主机端口)。

判定维度用 company_name 而非 category:实测 category 两个方向都会错
(10 条 LICA 物料挂在 IRIS/ 分类下;IRIS 分类里含 "IRIS/成品/LICA/..."
那是产品线名不是部门名,共 171 条)。
2026-09-22 13:13:52 +08:00
3b777af87d V3.90,9.21推送 2026-09-21 14:26:07 +08:00
2319a053df fix(bom): 备注框移到子件列表上方,改为 textarea
原位置在编号/版本号那一行的 span=6 里,850px 弹窗扣掉 label-width:120px
和 gutter 后,输入框实际只剩 60 余像素,稍长一点的备注根本看不清。

改为独立整行 textarea(rows=2, maxlength=500, show-word-limit),放在
「子件列表」标题上方;编号行改为 span 10+14 填满。

连带修两处相关缺陷:
  · 暂存草稿的 payload 补 remark —— 此前根本没往后端传
  · getDraftHash 补 remark —— 否则"只改了备注"会被防呆逻辑拦成
    "草稿数据无变动,无需重复暂存"
2026-09-21 14:13:15 +08:00
6a7761abbb fix(bom): 草稿链路透传 BOM 级备注,发布后不丢失
save_draft / get_draft_detail / publish_draft 三处此前都不带 BOM 级备注,
暂存后再恢复、或草稿直接发布,备注都会丢。

同时给 /save 的权限清洗表补上顶级 remark(与子件级 remark 共用
bom_manage:remark 权限码)。

注意:运行时真正生效的草稿入口是 bom.py:521 的 /draft/save。
app/api/v1/bom_draft.py 里的 bom_draft_bp 从未在 app/__init__.py 注册,
是死代码(两份都调同一个 BomDraftService,本次两处都改以保持同步)。

实测(临时 BOM,验证后已清理):
  草稿暂存带备注 → 读回 '草稿BOM级备注-测试'
  草稿 → 发布 → 正式表 读回 '草稿备注-发布后应保留'
2026-09-21 14:13:14 +08:00
0abc5850d8 fix(bom): save_bom 落库 BOM 级备注,get_bom_detail 改读 bom_remark
save_bom() 此前从未读取 data['remark'],父件级备注被静默丢弃;
get_bom_detail() 则用 first.BomTable.remark(第一个子件行的备注)冒充
主表备注——所以填了保存后再打开必为空,偶尔还会显示出某个子件的备注
(库里 1196 行只有 1 行有 remark,值是"白色")。

这正是"BOM 备注看不见"的根因。
2026-09-21 14:13:14 +08:00
cc80fdd9c9 fix(bom): 增加 bom_remark 列,为 BOM 级备注提供存储位置
bom_table 是子件明细表(一行 = 一个子件),表里只有子件级的 remark 列,
BOM 级备注没有任何地方可存。前端一直有备注输入框、提交时也确实放进了
payload,但后端 save_bom 收到后无处可写,只能静默丢弃。

沿用 parent_id / is_enabled 既有的冗余写法:同一 BOM 版本的每一行存同一
份值,读取时取首行。bom_draft_table 同步加列,保证草稿暂存与发布期间
不丢失。与既有 remark(子件级)语义分离,互不干扰。

存量数据无法回填——历史上填过的备注从未入库,只能从本迁移之后开始记录。

执行方式: docker exec -i inventory_db psql -U test -d inventory_system < db_migrations/add_bom_remark.sql
2026-09-21 14:13:13 +08:00
b6ce754ba7 V3.89,9.21推送 2026-09-21 12:03:07 +08:00
e0ea946e08 fix(outbound): 出库列表补传日期参数,修复日期筛选静默失效
前端 el-date-picker 一直传 start_date/end_date(outbound/index.vue:472),
service 层 get_grouped_list() 也早有完整实现(签名接收 + 10 位日期补全
时分秒 + outbound_time 过滤),但中间 API 层从未读取这两个参数:

  outbound.py: get_outbound_list() 只读 page/limit/keyword/search_type/
  company/advancedFilters,调用 get_grouped_list() 时也没传日期。

get_grouped_list 全项目仅此一个调用方,因此 start_date/end_date 恒为
None,service 层 `if start_date and end_date:` 永远为假——用户在页面上
选了日期,后端直接忽略,结果集与不筛选时完全一致,且不报错。

修复:
  · outbound.py 读取 start_date/end_date 并透传给 service
  · outbound_service.py 把 BETWEEN 改为分别判断(原写法只传一侧时
    整段条件静默失效,与 /returns 等接口的边界处理对齐)

实测验证(SUPER_ADMIN 全量口径,与 SQL count(distinct outbound_no) 逐项对齐):
  无日期            494  (基准)
  2026-08-24 单日     3  SQL=3
  2026-05-11 单日     9  SQL=9
  2026-08 整月       88  SQL=88
  只传 start_date   229  SQL=229
修复前上述四项均恒等于 494。运行中容器经 --reload 重载后实测一致。

影响范围:仅出库主列表。其余带日期筛选的接口(/returns、transactions、
scrap、purchase、audit、inbound_summary)均已正确读取参数,未受影响。
2026-09-21 10:33:47 +08:00
8db33fdf40 V3.88,9.20推送 2026-09-20 17:01:52 +08:00
3bb5fc6eb8 fix(outbound): 执行校验兼容无 base_id 的历史单据,修复老单必然被拒
2026-09-10 预占改造(b57c21a)之前建的单,items_json 里没有 base_id。
这类单只要在改造上线时仍处于「已通过待执行」状态,就再也执行不了:

  批准侧 build_approval_index() → identity_key(base_id, name, spec)
    无 base_id → 退化成 ('name', 名称, 规格)
  扫码侧 stock_identity()      → ('base', id)
两侧键不同构,**永远不相等**,verify_scanned() 必然抛
「扫码物料【…】不在该申请单的批准明细中,禁止出库」——批的就是这件货。

identity_key() 的文档本就把 (name, spec_model) 写作「历史数据的兜底」,
只是校验时只有批准侧降了级、扫码侧没有,两侧因此错开。

实测(APR-OUT-20260909-1313-0007,建于 09-09,改造上线前一天):
  批准侧 ('name', '派里肯安全箱1600(黑色)', 'PS-9640B001-black')
  扫码侧 ('base', 3012)

修复:
  · 新增 stock_name_spec() —— 库存行 → 名称型身份键,刻意丢掉 base_id
  · 新增 build_legacy_approval_index() —— 无 base_id 的历史明细旁路索引
  · verify_scanned() —— 主索引落空时用库存行名称+规格再比一次;命中则
    改用**批准侧的键**继续,使 acc 累计与 approved_idx 的批准量同口径

安全边界(关键):降级只对真正来自老单据的名称型键放行,白名单是
legacy_idx 而非 approved_idx 本身——build_legacy_approval_index() 只收
identity_key() 产出名称型键的明细,有 base_id 的一律跳过;名称与规格皆空
的明细直接丢弃,绝不退化成「任意物料都能匹配」。无老单时该索引为空集,
降级分支恒不命中,行为与改造前逐字节一致。

影响范围:当时处于 status=1 的老单全库仅 1 张(即上述单号)。其余 358 张
老单(已完成 327 / 已驳回 9 / 已完结 22)均为终态,不受影响——它们在改造
上线前就已执行完毕。

实测验证:
  T1 老单 360 + 扫批准范围内物料     修复前必拒 → 修复后通过
  T2 老单 360 + 扫单外物料           仍拒绝
  T3 老单 360 + 数量超批准           仍拒绝
  T4 现代单 + 扫批准范围内物料       通过(旁路索引 0 键,未受影响)
  T5 现代单 + 扫单外物料             仍拒绝

副作用改善:降级后标签改用批准侧的键,超量报错从「物料#3012」变为
「派里肯安全箱1600(黑色)(PS-9640B001-black)」,可读性提升。
2026-09-20 16:43:37 +08:00
4f922a55f3 V3.87,9.20推送 2026-09-20 15:11:10 +08:00
d7f7548cee fix(outbound): 库存分配按 base_id 合并需求,修复重复行导致的假性库存不足
_allocate_bom_requirements 在整轮 for req 循环中复用同一份可用量快照
(rows_by_base 建好后不再更新),而 for 循环本身不按 base_id 去重。同一
物料以多行进入时,每行都从同一份快照重新分配一遍,把同一个 stock_id
重复分配 N 次,累计分配量可超过该行真实可用量。

超配在分配阶段不会暴露,直到 reserve_for_items 的二次校验
(take > avail)才抛错,报「可用库存不足(需 X,实剩 Y)」,而实际库存
充足。实剩为 0.0 是当最大候选批次的可用量恰好等于单行需求时的收尾形态。

重复行来自正常业务,非脏数据:
  - 购物车里同一物料的多批次就是多行(Selection.vue 提交时只带
    base_id + quantity,stock_id/source_table 被丢弃);
  - BOM 明细里同一子件被多处引用(前端 requirements 按 child_id 不去重);
  - 调拨 / 补发等拆行场景。

修复:在函数入口按 base_id 合并 reqs、required_qty 求和。分配语义不变
—— 分配器本就按 base_id 拉全量批次行、降序分配,输入里的 stock_id 从来
就被忽略。

选在此处收口而非合并调用方的 items:_allocate_bom_requirements 是全系统
库存分配的唯一权威入口(出库与借库都经 reserve_for_items 走到这里),
一处修改同时覆盖两条链路。

实测(base_id=711,四个批次可用量合计 124):
  三行各 30  修复前 ERROR(需30/实剩10) → 修复后 OK,2117 出 70 + 1182 出 20
  两行各 30  修复前静默超配(两行都绑 2117) → 修复后 OK,合并为 2117 出 60
  十行各 30  修复后报真实缺口「需 300,可用 124」(走正常缺料分支)
2026-09-20 15:09:14 +08:00
9ea79a55f8 V3.86,9.18推送 2026-09-18 14:41:53 +08:00
26a1857acb fix(purchase): 待采购清单的参考价格改按 material_list:referencePrice 管控
修复一个权限旁路:待采购清单的「参考单价」原本后端无条件返回、前端列也没有
任何门控,而 material_list:referencePrice 只授予 SUPER_ADMIN 与 SUPERVISOR。

结果是 WAREHOUSE_MGR(库管) / INBOUND(入库员) / SALES(销售) 虽然都持有
inbound_purchase:pending_pool、能打开待采购清单,就会看到参考价格 —— 而这个
数字他们在物料列表里是被挡住的。等于本页面成了绕过该权限的后门。

参考价格来自 material_base.reference_price,与物料列表同源,因此复用同一个
权限码管控,不另造新码(同源数据用同码,口径才不会漂)。

改动:
- 后端 get_pending_purchase_pool 新增 include_reference_price 参数,
  fail-closed 默认 False;无权限时**整个字段不返回**而不是给 None
- 新增 _has_material_reference_price_perm(),判定方式与既有的
  _filter_purchase_prices 保持一致(超管/主管放行 + 逐个权限码比对)
- 前端「参考单价」列加 hasPermission 门控,TS 类型改为可选

★ 只挡前端等于没挡(接口仍然裸奔),前后端必须同时改。
2026-09-18 14:36:02 +08:00
970c03fb44 feat(material): 基础信息双胞胎页面:移除人工「标记已采购」,改自动「采购在途」
list.vue 与 buyOdoo.vue 同步改造。这两个文件是手工维护的副本、不是共享组件,
改动必须两边都做 —— 本项目已多次因只改单边导致同一 bug 在另一页长期存活
(参考价格脏检查、表单禁用守卫、发起采购申请按钮等都曾漏同步)。

改动:
  - 移除「标记已采购」按钮、handleMarkOrdered 函数与 markWarningOrdered 引用
  - 操作列改为只读 el-tag「采购在途」,由后端 isPurchasing 派生
  - 预警状态列降级:缺货但已有在途采购单时显示蓝色「缺货(已采购)」而非红/黄

★ 降级只改前端展示,后端 warningStatus 原样保留 —— 预警强排
  (enableWarningSort 的 order_by case())依赖它,改后端会连带影响排序。

★ 顺带修掉一个历史问题:前端 `scope.row.warningOrdered` 这个字段后端从未输出过
  (后端只给 isOrdered,且列表接口根本没查这一列),所以那个 disabled 的
  「采购在途」按钮从来不会显示,用户标记后永远看不到已标记状态。
2026-09-18 14:31:22 +08:00
70dd257392 feat(purchase): 采购申请弹窗支持批量建单与商家链接一键跳转
批量建单:
  openCreateDialog 新增 batchItems 分支 —— 待采购清单勾选多个物料时,
  明细表一次性铺成 N 行,实现一单多品的合并采购。单条与批量共用
  fillRowFromPrefill 做字段映射,不各写一份。

商家链接一键跳转:
  输入框加后置按钮,点击在新标签页打开,方便采购员比对价格。
  ★ 链接先过 safeHref 白名单(只放行 http/https)—— 该字段由用户自由填写,
    直接交给 window.open 会让 javascript: 之类的伪协议被当成代码执行。
    非法时按钮置灰,不做静默失败。

交接方式:
  单行与批量统一走 localStorage(query 里只传一次性 key,取完即删),
  不经过 URL。原因:material_base.purchase_link 是 text,实测已有 516 字符的
  真实外链,走 URL 会撞上长度上限并被静默截断。曾经单行走 query 时被迫引入
  一个「链接多长就不带」的硬编码阈值,导致同一物料单行跳转带链接、批量跳转
  不带 —— 现在两条路径统一,无长度上限。
2026-09-18 14:31:14 +08:00
690ee2c81d feat(purchase): 新增「待采购清单」页面
路由 router/index.ts:
  /purchase 下新增子路由 pending(name: PurchasePendingPool)。
  ★ 父路由刻意不加 permissions —— 加了会让没有 pending_pool 权限的用户
    整个「采购管理」模块从侧边栏消失。

页面 views/purchase/PendingPool.vue:
  筛选栏 + 表格 + 分页,列含产品图、规格、当前库存、在途、有效供给、
  预警、建议采购量(tooltip 展示完整算式)、参考单价、采购链接、操作。
  采购链接渲染走 safeHref 白名单,非法地址不渲染成可点击链接。

api/purchase.ts:
  新增 getPendingPurchasePool 与 PendingPoolItem 类型。

★ 需告知业务方的可见变化:「采购管理」从扁平单项变为可展开子菜单
  (Sidebar 在子路由只有 1 个时渲染 el-menu-item,2 个时渲染 el-sub-menu)。
2026-09-18 14:31:07 +08:00
b4445f07d9 feat(material): 物料列表新增「采购在途」自动标识,预警邮件改用活跃单静音
base_service.get_list:
  新增 isPurchasing 字段,与待采购池共用同一份活跃单判定。放在预警权限
  判断之外 —— 能看到物料列表的人都该知道这个物料已经在采购了。

inventory_task:
  静音条件从人工标记 is_ordered 改为「该物料无活跃采购单」。

★ 为什么必须同时改邮件:预警邮件由出库执行流程触发(outbound_service 出库
  完成后的调用),功能是活的;而它的静音入口(前端人工「标记已采购」)随本次
  改造一并移除。若不改这里,用户会彻底失去静音能力,每个缺货物料在每次出库后
  都会发信。自动判定在建单时静默、单据驳回或强制结案后自动恢复,比人工标记准。

两张表的判定口径都由 utils/purchase_activity.py 提供,不各写一遍。
2026-09-18 14:31:00 +08:00
dec33b685c feat(purchase): 新增待采购池接口(防重复采购)
GET /api/v1/purchase/pending-pool:找出「有效供给仍低于预警线」的物料。

  有效供给 = 物理总库存 + 在途量
  过滤规则 = 有效供给 <= 红/黄阈值 才进池
  建议采购量 = max(1, 目标阈值 - 有效供给)

★ 为什么不用「有活跃单就排除」:红线 10、库存 0、某采购员只建了一张
  数量 5 的单时,该物料会立刻从池中消失,剩下 5 个缺口永远无人认领。
  改为按量计算后它继续留池,suggested_qty 自动降到 5。

★ 用 <= 而非 < 是与业务方确认后刻意维持的口径(与物料列表预警、预警邮件
  同源),勿擅自改成严格小于 —— 只改本接口会造成「列表亮黄灯、池子却排除」
  的撕裂,真要改必须三处一起动。代价是恰好等于阈值时建议量落到保底的 1,
  前端 tooltip 已单独说明,不展示算不成立的错误算式。

权限:新增 inbound_purchase:pending_pool(注册为 sys_element 而非新建
SysMenu)。因 ensure_default_permissions 在角色已有权限时整段跳过,另写了
存量角色自动迁移,并按源行镜像 company_name 避免跨公司作用域泄漏。
2026-09-18 14:30:53 +08:00
fc5e62c848 feat(purchase): 新增「活跃采购单」判定与在途量计算模块
防重复采购的唯一真相源。刻意回答两个不同的问题,别混用:
- active_purchase_exists()  有没有人在买?      → 展示用
- in_transit_subquery()     已经买了多少还没到? → 算术用

活跃单定义:status 0/1,或 status=3 且累计入库 < 采购量。
status=4(强制结案)刻意不在其中 —— 短交不补发、来料拒收不补发这类
异常单永远达不到采购量,靠库管强制结案解锁,避免物料被永久占用。

「部分到货」无需改表即可算出:StockBuy.request_id 早已存在,且
StockBuy.in_quantity 是入库原始量(不被出库扣减,区别于 stock_quantity),
按 request_id 聚合即得每张单的累计到货量。
2026-09-18 14:30:40 +08:00
caaa1571d4 V3.85,9.18推送 2026-09-18 10:17:05 +08:00
1b6a6f5c19 fix(return): 还库签名文案统一为「领用人」,消除与页面标题的口径冲突
同一签名位的四处文案不一致:页面标题(第 7 行)写「领用人签字」,
而正文提示(125 行)与校验报错(391 行)写「库管员」、占位文案
(158 行)写「库管签名」,用户无法判断该谁签。

全部统一为「领用人」。其中第 125 行原为「签字确认入库」,与本节
分节标题「还库确认」自身矛盾,一并改为「签字确认还库」。
2026-09-18 10:15:18 +08:00
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
91c4ae85db V3.83,9.17推送 2026-09-17 13:40:56 +08:00
191724a176 fix(outbound): 修复扫码出库 500 —— applicant_id 用了尚未赋值的 approval
现象
----
所有扫码出库报 500(前端 create.vue 提交即失败)。

根因
----
上一个提交把 'applicant_id': approval.applicant_id 写进了 common_data,
但 common_data 构造在第 234 行,而 approval 直到第 269 行(「强制按单出库」
那一段)才查询赋值 —— 典型的变量先用后赋,直接 UnboundLocalError。
它影响的是**每一条**出库请求,属于必然复现而非偶发。

修复
----
把赋值移到 approval 取出并校验**之后**:common_data['applicant_id'] = ...
并在原处留注释说明为什么不能写在字典字面量里,避免后人搬回去。

★ 我的测试为什么没抓到
  上一轮只测了「退回 → 补发」这条路径,而 applicant_id 的写入在**扫码出库**
  路径上 —— 两条路径不重合,所以漏了。本次补测了完整的
  「出库申请(预占) → 扫码出库(扣减)」链路。

验证(6 项断言全通过,走真实接口链路)
  申请单创建(status=1,申请人=12,预占 stock_buy#2058 两件)
    → 扫码出库不再抛 500
    → 出库明细生成且 applicant_id = 12(不再是 NULL)
    → consumer_name 照常写入
    → 审批单置为已完成
    → 库存实际扣减 2
  清理后库存与数据零残留。

  ★ 顺带在真实数据上得到印证:生产库中已有一笔由业务方重试成功的出库
    (OUT-20260917-1333-0001),其 applicant_id 正确等于所关联申请单的申请人。
2026-09-17 13:35:47 +08:00
38 changed files with 1961 additions and 139 deletions

View 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;

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

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。
--
-- 代码侧已修
-- 提交 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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编号')

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

@ -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'],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, ...]

View File

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

View File

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

View File

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

View File

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

View File

@ -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,跳过通知")

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

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

View File

@ -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}')

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.82
当前版本:V3.91
</span>
</footer>

View File

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

View File

@ -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'] }
}
]
},

View File

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

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>
@ -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();

View File

@ -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 () => {

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

View File

@ -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) || ''
})
}
})

View File

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

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>

View File

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