8 Commits

Author SHA1 Message Date
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
13 changed files with 1241 additions and 62 deletions

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

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

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

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

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

@ -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.85
当前版本:V3.86
</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

@ -380,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>
@ -400,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>
@ -785,7 +793,6 @@ import {
batchSetWarning,
batchSetInspection,
batchSetApprovalRequired,
markWarningOrdered,
getMaterialUnitsAPI,
getOdooSummary
} from '@/api/material_base';
@ -837,7 +844,8 @@ interface MaterialBaseVO {
inventoryCount?: number;
availableCount?: number;
warningStatus?: number;
warningOrdered?: boolean;
// 采购在途:后端自动派生(有活跃采购单即 true取代了原先人工标记的 warningOrdered
isPurchasing?: boolean;
warningRedEmails?: string;
warningYellowEmails?: string;
referencePrice?: number;
@ -1567,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) || ''
})
}
})