Compare commits
8 Commits
caaa1571d4
...
9ea79a55f8
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ea79a55f8 | |||
| 26a1857acb | |||
| 970c03fb44 | |||
| 70dd257392 | |||
| 690ee2c81d | |||
| b4445f07d9 | |||
| dec33b685c | |||
| fc5e62c848 |
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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, ...]
|
||||
|
||||
@ -673,6 +673,12 @@ class PermissionService:
|
||||
('inbound_purchase:unit_price', '采购单价', 'column'),
|
||||
('inbound_purchase:total_price', '采购总价', 'column'),
|
||||
('inbound_purchase:tax_rate', '税率', 'column'),
|
||||
# ★ 待采购清单(防重复采购)菜单权限。
|
||||
# 注册成 element 而非新建 SysMenu:侧边栏与路由守卫只读前端
|
||||
# meta.permissions + sys_role_permission,从不读 sys_menu;
|
||||
# 且 init_all_menus 的「冗余子菜单清理」会按 target_code 删权限行,
|
||||
# 新增 SysMenu 等于多一个被该清理逻辑波及的面。
|
||||
('inbound_purchase:pending_pool', '待采购清单', 'operation'),
|
||||
]
|
||||
for code, name, etype in purchase_elements:
|
||||
existing = SysElement.query.filter_by(
|
||||
@ -833,6 +839,48 @@ class PermissionService:
|
||||
print(f"[权限迁移] {role_code}: inbound_buy → inbound_purchase 已自动迁移")
|
||||
total_inserted += 1
|
||||
|
||||
# ★ 自动迁移:已有「采购申请」菜单权限的角色 → 自动获得「待采购清单」权限
|
||||
#
|
||||
# 为什么必须独立成段:上面的默认分配逻辑在角色**已有任何权限**时
|
||||
# 就整段跳过(见上方 existing_count > 0 的分支),存量角色根本走不到
|
||||
# 那里。不写这一段,除了超管之外没有任何角色能拿到新权限。
|
||||
#
|
||||
# 本段依赖 autoflush:上一个循环刚 add 的 inbound_purchase 行尚未
|
||||
# commit,这里的查询必须能看见它们(扩展默认 autoflush=True 保证了这点)。
|
||||
for role_code in known_roles:
|
||||
if role_code.upper() == 'SUPER_ADMIN':
|
||||
continue
|
||||
|
||||
# 以「采购申请菜单权限」为门控 —— 能进采购页的角色就该看到待采购清单。
|
||||
# ★ 同时镜像它的 company_name:若某角色的采购菜单是公司定制的
|
||||
# (company_name='A'),补出的权限必须落在同一作用域;硬写 NULL
|
||||
# 会让其他公司的同角色用户凭空多出这个菜单。
|
||||
src_perm = SysRolePermission.query.filter_by(
|
||||
role_code=role_code, target_code='inbound_purchase', type='menu'
|
||||
).first()
|
||||
if not src_perm:
|
||||
continue
|
||||
|
||||
# 幂等检查也必须带 company_name,否则 A 公司已有行会跳过 B 公司那条
|
||||
already = SysRolePermission.query.filter_by(
|
||||
role_code=role_code,
|
||||
target_code='inbound_purchase:pending_pool',
|
||||
type='element',
|
||||
company_name=src_perm.company_name,
|
||||
).first()
|
||||
if already:
|
||||
continue
|
||||
|
||||
db.session.add(SysRolePermission(
|
||||
role_code=role_code,
|
||||
target_code='inbound_purchase:pending_pool',
|
||||
type='element',
|
||||
company_name=src_perm.company_name,
|
||||
))
|
||||
total_inserted += 1
|
||||
print(f"[权限迁移] {role_code}: 已补充「待采购清单」权限"
|
||||
f"(作用域={src_perm.company_name or '全局'})")
|
||||
|
||||
# ★ SUPERVISOR 主管默认获得采购申请子菜单权限
|
||||
for role_code in known_roles:
|
||||
if role_code.upper() == 'SUPERVISOR':
|
||||
|
||||
@ -528,4 +528,259 @@ class PurchaseService:
|
||||
"""
|
||||
send_email_async(requester.email, subject, content)
|
||||
except Exception as e:
|
||||
print(f"[Email] 采购申请驳回通知失败: {e}")
|
||||
print(f"[Email] 采购申请驳回通知失败: {e}")
|
||||
|
||||
# ============================================================
|
||||
# 待采购池(防重复采购)
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_pending_purchase_pool(page=1, per_page=20, keyword=None,
|
||||
category=None, material_type=None,
|
||||
warning_status=None,
|
||||
include_reference_price=False):
|
||||
"""
|
||||
待采购池:找出「**有效供给**仍不足、需要再买」的物料。
|
||||
|
||||
业务目的:杜绝多名采购员对同一短缺物料重复购买,同时**不能漏掉缺口**。
|
||||
|
||||
核心是有效供给:
|
||||
|
||||
有效供给 = 当前物理总库存 + 在途量
|
||||
在途量 = SUM(该物料所有活跃采购单的剩余待入库量)
|
||||
= SUM(GREATEST(采购量 - 累计入库量, 0))
|
||||
|
||||
过滤规则:有效供给 <= 红/黄阈值 才留在池中。
|
||||
|
||||
★ 为什么不是「有采购单就排除」(本接口的第一版):
|
||||
红线 10、库存 0、某采购员只建了一张数量 5 的单 —— 若按 EXISTS 一刀切,
|
||||
该物料会**立刻从池中消失**,剩下 5 个缺口永远无人认领。这是掩耳盗铃。
|
||||
按量计算后它继续留在池中,suggested_qty 自动降到 5,提醒下一位只补 5 个。
|
||||
|
||||
★ 判定口径必须与物料列表预警(inbound/base_service.get_list)严格一致,
|
||||
否则会出现「列表亮红灯、池子却说不用买」的撕裂:
|
||||
- 用**物理库存总量**(三个 stock_* 表的 stock_quantity 之和),不是可用量
|
||||
- 阈值可能为 None,红黄两个阈值**各自独立**判断,不能 coalesce 成一个
|
||||
- 用 `<=` 而非 `<` —— 恰好等于阈值即算不足
|
||||
|
||||
★ 参考价格(reference_price)默认**不返回**(fail-closed)。
|
||||
它是 material_base.reference_price,属于受 material_list:referencePrice
|
||||
管控的价格数据 —— 与物料列表同源,就必须同码管控,否则这里会变成
|
||||
绕过该权限的后门(物料列表看不到、待采购清单却看得到)。
|
||||
调用方(API 层)按权限显式传 include_reference_price=True。
|
||||
|
||||
★ 关于 `<=`(2026-09-18 与业务方确认,**刻意维持,勿擅自改成 `<`**):
|
||||
阈值是「警戒下限」,到线即需关注;且物料列表预警、预警邮件用的是同一套
|
||||
`<=` 口径(那是系统原有约定,不是本接口另立的)。若只把本接口改成 `<`,
|
||||
会出现「列表亮黄灯、池子却把它排除」的撕裂。
|
||||
代价是有效供给恰好等于阈值时 suggested_qty 会落到最小值 1(看似
|
||||
「不用买却建议买 1 个」)—— 这是有意的缓冲,前端 tooltip 已单独说明。
|
||||
真要改成 `<`,必须**同时**改 base_service 的判级与 inventory_task 的
|
||||
触发条件,三处一起动。
|
||||
"""
|
||||
import math
|
||||
|
||||
from sqlalchemy import and_, case, cast, desc, or_
|
||||
from sqlalchemy.types import Numeric as SqlNumeric
|
||||
|
||||
from app.models.base import MaterialWarningSetting
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.product import StockProduct
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.utils.decorators import get_current_company_filter
|
||||
from app.utils.purchase_activity import in_transit_subquery
|
||||
|
||||
# ---- 三表库存聚合(口径同 base_service.get_list:146-170)----
|
||||
buy_sub = db.session.query(
|
||||
StockBuy.base_id,
|
||||
func.sum(StockBuy.stock_quantity).label('buy_inv'),
|
||||
func.sum(StockBuy.available_quantity).label('buy_avail')
|
||||
).group_by(StockBuy.base_id).subquery()
|
||||
|
||||
semi_sub = db.session.query(
|
||||
StockSemi.base_id,
|
||||
func.sum(StockSemi.stock_quantity).label('semi_inv'),
|
||||
func.sum(StockSemi.available_quantity).label('semi_avail')
|
||||
).group_by(StockSemi.base_id).subquery()
|
||||
|
||||
prod_sub = db.session.query(
|
||||
StockProduct.base_id,
|
||||
func.sum(StockProduct.stock_quantity).label('prod_inv'),
|
||||
func.sum(StockProduct.available_quantity).label('prod_avail')
|
||||
).group_by(StockProduct.base_id).subquery()
|
||||
|
||||
total_inv = (func.coalesce(buy_sub.c.buy_inv, 0)
|
||||
+ func.coalesce(semi_sub.c.semi_inv, 0)
|
||||
+ func.coalesce(prod_sub.c.prod_inv, 0))
|
||||
total_avail = (func.coalesce(buy_sub.c.buy_avail, 0)
|
||||
+ func.coalesce(semi_sub.c.semi_avail, 0)
|
||||
+ func.coalesce(prod_sub.c.prod_avail, 0))
|
||||
|
||||
# ★ 必须先物化再过滤:total_inv 是聚合表达式,直接在带 GROUP BY 的查询里
|
||||
# 参与比较会落到 HAVING 语义;物化成子查询后,它在外层就是普通列,
|
||||
# 可以安全地进 WHERE(与 base_service.get_list:175-191 同一手法)。
|
||||
inner_sub = (
|
||||
db.session.query(
|
||||
MaterialBase.id.label('base_id'),
|
||||
total_inv.label('total_inv'),
|
||||
total_avail.label('total_avail'),
|
||||
)
|
||||
.outerjoin(buy_sub, MaterialBase.id == buy_sub.c.base_id)
|
||||
.outerjoin(semi_sub, MaterialBase.id == semi_sub.c.base_id)
|
||||
.outerjoin(prod_sub, MaterialBase.id == prod_sub.c.base_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# ---- 在途量聚合(每个物料一张单一张单地累加剩余待入库量)----
|
||||
intransit_sub = in_transit_subquery()
|
||||
|
||||
inv_col = inner_sub.c.total_inv
|
||||
avail_col = inner_sub.c.total_avail
|
||||
# 没有活跃采购单的物料不在 intransit_sub 里,outerjoin 后为 NULL → 兜成 0
|
||||
intransit_col = func.coalesce(intransit_sub.c.intransit, 0)
|
||||
# ★ 有效供给:判缺货、算建议量,全部基于它,而不是光看库存
|
||||
effective_col = inv_col + intransit_col
|
||||
red_col = cast(MaterialWarningSetting.red_threshold, SqlNumeric)
|
||||
yellow_col = cast(MaterialWarningSetting.yellow_threshold, SqlNumeric)
|
||||
|
||||
# inner join 预警配置:缺货的前提就是预警已启用,outer join 无意义。
|
||||
# (material_warning_settings 按约定与物料 1:1 且已核实无重复行,
|
||||
# 故不会因 join 放大行数。若将来出现重复行,此处需要改为每物料取一条。)
|
||||
query = (
|
||||
db.session.query(MaterialBase, MaterialWarningSetting,
|
||||
inv_col, avail_col, intransit_col)
|
||||
.join(inner_sub, MaterialBase.id == inner_sub.c.base_id)
|
||||
.outerjoin(intransit_sub, MaterialBase.id == intransit_sub.c.base_id)
|
||||
.join(MaterialWarningSetting, MaterialBase.id == MaterialWarningSetting.base_id)
|
||||
.filter(MaterialWarningSetting.is_enabled.is_(True))
|
||||
)
|
||||
|
||||
# ---- ① 有效供给不足 ----
|
||||
# 对应 Python 侧的 `if 供给<=红 ... elif 供给<=黄`:命中任一即不足。
|
||||
# ★ 绝不写成 `supply <= coalesce(red, yellow)` —— 那会把「红阈值未配置」
|
||||
# 偷换成「用黄阈值」,且在两者都为 NULL 时静默退化成 NULL 比较。
|
||||
red_hit = and_(MaterialWarningSetting.red_threshold.isnot(None), effective_col <= red_col)
|
||||
yellow_hit = and_(MaterialWarningSetting.yellow_threshold.isnot(None), effective_col <= yellow_col)
|
||||
query = query.filter(or_(red_hit, yellow_hit))
|
||||
|
||||
# ---- 行级数据隔离(多租户)----
|
||||
company_limit = get_current_company_filter()
|
||||
if company_limit is not None:
|
||||
query = query.filter(MaterialBase.company_name == company_limit)
|
||||
|
||||
# ---- 可选筛选 ----
|
||||
if keyword:
|
||||
kw = f'%{keyword.strip()}%'
|
||||
query = query.filter(or_(
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
MaterialBase.company_name.ilike(kw),
|
||||
))
|
||||
|
||||
if category:
|
||||
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
|
||||
|
||||
if material_type:
|
||||
query = query.filter(MaterialBase.material_type.ilike(material_type.strip()))
|
||||
|
||||
if warning_status == 2:
|
||||
query = query.filter(red_hit)
|
||||
elif warning_status == 1:
|
||||
# 黄色要在红之外,否则 1/2 语义重叠
|
||||
query = query.filter(yellow_hit, ~red_hit)
|
||||
|
||||
# ---- 排序:最缺的排最上面(复刻 base_service.get_list:372-391 的口径)----
|
||||
warning_level = case(
|
||||
(red_hit, 2),
|
||||
(yellow_hit, 1),
|
||||
else_=0,
|
||||
)
|
||||
# 缺口按**有效供给**算,在途已经补上的部分不再计入缺口
|
||||
gap = case(
|
||||
(red_hit, red_col - effective_col),
|
||||
(yellow_hit, yellow_col - effective_col),
|
||||
else_=0,
|
||||
)
|
||||
query = query.order_by(desc(warning_level), desc(gap), MaterialBase.id.asc())
|
||||
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
items = []
|
||||
for row in pagination.items:
|
||||
material = row[0]
|
||||
setting = row[1]
|
||||
inv = float(row[2]) if row[2] is not None else 0.0
|
||||
avail = float(row[3]) if row[3] is not None else 0.0
|
||||
intransit = float(row[4]) if row[4] is not None else 0.0
|
||||
effective = inv + intransit
|
||||
|
||||
red = float(setting.red_threshold) if setting.red_threshold is not None else None
|
||||
yellow = float(setting.yellow_threshold) if setting.yellow_threshold is not None else None
|
||||
|
||||
# 判级用有效供给 —— 与上面的 SQL 过滤条件保持同一口径
|
||||
status = 0
|
||||
if red is not None and effective <= red:
|
||||
status = 2
|
||||
elif yellow is not None and effective <= yellow:
|
||||
status = 1
|
||||
|
||||
# 建议采购量 = 阈值 - 有效供给,一次性把供给抬出**整个**预警带,
|
||||
# 否则刚补完货下一条预警马上又来。
|
||||
# 取 max(红,黄) 而非「被触发的那个」——正常配置下红<黄,补到黄线即可
|
||||
# 完全退出;红黄配反的脏数据下也安全。
|
||||
# 双阈值都为 None 时降级为 None,不会出现 None - effective 的 TypeError。
|
||||
#
|
||||
# ★ 注意在途已计入 effective,所以「已买 5」时这里只会建议剩下的 5,
|
||||
# 而不是按库存从零算。这正是本次改造要修的那个漏洞。
|
||||
targets = [t for t in (red, yellow) if t is not None]
|
||||
target = max(targets) if targets else None
|
||||
suggested = max(1, math.ceil(target - effective)) if target is not None else None
|
||||
|
||||
items.append({
|
||||
'material_id': material.id,
|
||||
'name': material.name,
|
||||
'spec_model': material.spec_model or '',
|
||||
'unit': material.unit or '',
|
||||
'category': material.category or '',
|
||||
'material_type': material.material_type or '',
|
||||
'company_name': material.company_name or '',
|
||||
'image': PurchaseService._first_image(material.product_image),
|
||||
'purchase_link': material.purchase_link or '',
|
||||
# fail-closed:无权限时整个字段不出现,而不是给 None ——
|
||||
# 前端据字段是否存在决定列是否渲染,语义更干净
|
||||
**({'reference_price': float(material.reference_price)
|
||||
if material.reference_price is not None else None}
|
||||
if include_reference_price else {}),
|
||||
'inventory_count': inv,
|
||||
'available_count': avail,
|
||||
# 在途量与有效供给一并返回,前端才能向采购员解释「为什么只建议买这么多」
|
||||
'in_transit_qty': intransit,
|
||||
'effective_supply': effective,
|
||||
'warning_status': status,
|
||||
'warning_red': red,
|
||||
'warning_yellow': yellow,
|
||||
'target_threshold': target,
|
||||
'suggested_qty': suggested,
|
||||
'is_ordered': bool(setting.is_ordered),
|
||||
})
|
||||
|
||||
return {
|
||||
'items': items,
|
||||
'total': pagination.total,
|
||||
'pages': pagination.pages,
|
||||
'current_page': page,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _first_image(product_image):
|
||||
"""从 material_base.product_image 的 JSON 字符串里取第一张图,取不到返回空串。"""
|
||||
if not product_image:
|
||||
return ''
|
||||
try:
|
||||
if isinstance(product_image, str) and not product_image.startswith('['):
|
||||
return product_image # 兼容旧数据:单条 URL 直接存字符串
|
||||
parsed = json.loads(product_image)
|
||||
if isinstance(parsed, list) and parsed:
|
||||
return parsed[0] or ''
|
||||
except Exception:
|
||||
return ''
|
||||
return ''
|
||||
163
inventory-backend/app/utils/purchase_activity.py
Normal file
163
inventory-backend/app/utils/purchase_activity.py
Normal file
@ -0,0 +1,163 @@
|
||||
"""
|
||||
「活跃采购单」的判定与在途量计算 —— 防重复采购的唯一真相源。
|
||||
|
||||
本模块回答**两个不同的问题**,别混用:
|
||||
|
||||
active_purchase_exists() 有没有人在买? → 展示用(在途标识、邮件静音)
|
||||
in_transit_subquery() 已经买了多少还没到? → 算术用(待采购池的有效供给)
|
||||
|
||||
「有在途单」不等于「不会缺货」:红线上线 10、库存 0、已下单 5,仍然差 5。
|
||||
早期待采购池用 EXISTS 一刀切排除有单的物料,结果是只买了 5 个就把整个物料
|
||||
从池子里抹掉、剩下的缺口永远无人认领(掩耳盗铃)。现在改为按量计算。
|
||||
|
||||
判定的业务含义(两张表共用同一份 _active_condition):
|
||||
|
||||
status 0(待审批) / 1(已通过未入库) → 算数
|
||||
status 3(已完成) 但累计入库 < 采购量 → 算数(部分到货,缺口仍在)
|
||||
status 2(已驳回) → 不算数,物料正常回流
|
||||
status 4(已完结 / 强制结案) → 不算数,立即解锁
|
||||
|
||||
★ status==4 的解锁能力是刻意保留的逃生通道:供应商短交不补发、来料不良拒收
|
||||
不补发等异常单永远达不到采购量,若按「累计入库 < 采购量」一律算数,该物料
|
||||
会被永久占用、再也无法触发采购。库管把单据强制结案即可解除。
|
||||
|
||||
「部分到货」为什么能在不改表结构的前提下算出来:
|
||||
StockBuy.request_id 早已存在(打通「按单入库」链路时加的),且
|
||||
StockBuy.in_quantity 是入库原始量 —— 它不会被出库扣减(区别于
|
||||
stock_quantity),按 request_id 聚合即得每张单的累计到货量。
|
||||
"""
|
||||
from sqlalchemy import and_, func, or_
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
def received_quantity_subquery():
|
||||
"""
|
||||
每张采购单的累计到货量:{request_id: SUM(in_quantity)}。
|
||||
|
||||
只统计挂在该采购单下的入库行(request_id 非空)——散单入库不计入任何单据。
|
||||
"""
|
||||
# 惰性导入:app/models/inbound/buy.py 已在模块顶层 import 了
|
||||
# app/models/purchase.py,若此处顶层反向导入会形成循环。
|
||||
from app.models.inbound.buy import StockBuy
|
||||
|
||||
return (
|
||||
db.session.query(
|
||||
StockBuy.request_id.label('rid'),
|
||||
func.sum(StockBuy.in_quantity).label('received'),
|
||||
)
|
||||
.filter(StockBuy.request_id.isnot(None))
|
||||
.group_by(StockBuy.request_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
|
||||
def _active_condition(received_sub):
|
||||
"""
|
||||
「这张采购单还算数吗」的判定条件 —— active_purchase_exists 与
|
||||
in_transit_subquery 共用,保证两处口径永不漂移。
|
||||
|
||||
status 0(待审批) / 1(已通过未入库) → 算数
|
||||
status 3(已完成) 但累计入库 < 采购量 → 算数(部分到货,缺口仍在)
|
||||
status 2(已驳回) / 4(已完结) → 不算数
|
||||
"""
|
||||
from app.models.purchase import PurchaseRequest
|
||||
|
||||
return or_(
|
||||
# 待审批 + 已通过未入库
|
||||
PurchaseRequest.status.in_([0, 1]),
|
||||
# 已入库但没到齐 —— 只到一部分时仍算数,缺口要留在账上
|
||||
and_(
|
||||
PurchaseRequest.status == 3,
|
||||
func.coalesce(received_sub.c.received, 0) < PurchaseRequest.quantity,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def active_purchase_exists(base_id_col):
|
||||
"""
|
||||
构造「该物料存在活跃采购单」的 EXISTS 表达式。
|
||||
|
||||
★ 这是**展示用**的布尔判定(物料列表的「采购在途」标识、预警邮件静音),
|
||||
回答的是「有没有人在买」。它**不**回答「还差多少」——
|
||||
待采购池的过滤不能用它,那个要用 in_transit_subquery() 算有效供给。
|
||||
|
||||
Args:
|
||||
base_id_col: 待关联的物料主键列,通常是 MaterialBase.id。
|
||||
|
||||
Returns:
|
||||
SQLAlchemy EXISTS 表达式,可直接用于 .filter(~expr),
|
||||
或作为 SELECT 列表里的布尔列。
|
||||
|
||||
★ 为什么必须用 EXISTS 而不是 NOT IN:
|
||||
NOT IN 的子查询若返回 NULL,整个谓词会变成 NULL,调用方会**静默地**
|
||||
拿到零行。EXISTS 天然 NULL 安全。
|
||||
|
||||
★ 为什么显式 .correlate(...):
|
||||
调用方的外层 FROM 里除了 material_base 往往还有别的表
|
||||
(MaterialWarningSetting、物化后的库存聚合子查询等),
|
||||
不显式声明相关性时 SQLAlchemy 的自动推断有歧义风险。
|
||||
|
||||
★ 使用约束:必须**嵌入**一个 FROM 含该物料表的查询里使用,例如
|
||||
query.filter(~active_purchase_exists(MaterialBase.id))
|
||||
单独 compile 这个表达式时没有外层查询可供相关,SQLAlchemy 会把
|
||||
material_base 塞进内层 FROM 形成笛卡尔积 —— 那是编译期假象,
|
||||
不影响嵌入使用的正确性,但**不要**脱离查询单独执行它。
|
||||
"""
|
||||
from app.models.purchase import PurchaseRequest
|
||||
|
||||
received = received_quantity_subquery()
|
||||
|
||||
return (
|
||||
db.session.query(PurchaseRequest.id)
|
||||
.outerjoin(received, PurchaseRequest.id == received.c.rid)
|
||||
.filter(
|
||||
PurchaseRequest.base_id == base_id_col,
|
||||
_active_condition(received),
|
||||
)
|
||||
.correlate(base_id_col.table)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
def in_transit_subquery():
|
||||
"""
|
||||
每个物料的**在途数量**:{base_id: SUM(该单剩余待入库量)}。
|
||||
|
||||
单张活跃采购单的待入库量 = GREATEST(采购量 - 累计入库量, 0)
|
||||
|
||||
★ 为什么要 GREATEST 兜底:理论上活跃单的累计入库量不会超过采购量
|
||||
(超收的单会因 _active_condition 判为不活跃而被排除),但历史上
|
||||
出现过手工改状态、重复入库等脏数据,一个负值会**静默地**把别的单的
|
||||
缺口抵掉,让待采购池少算在途、多建议采购量。兜到 0 代价极小。
|
||||
|
||||
★ 与 active_purchase_exists 共用 _active_condition:两处若各写一遍,
|
||||
将来改判定条件必然漏掉一边 —— 这个仓库已经因为「同一逻辑写两份」
|
||||
吃过多次亏(见 material/list.vue 与 buyOdoo.vue 的双胞胎漂移)。
|
||||
|
||||
Returns:
|
||||
SQLAlchemy 子查询,列为 (base_id, intransit)。
|
||||
调用方需 outerjoin 后再 coalesce(..., 0),因为没采购单的物料不在结果里。
|
||||
"""
|
||||
from app.models.purchase import PurchaseRequest
|
||||
|
||||
received = received_quantity_subquery()
|
||||
|
||||
remaining = func.greatest(
|
||||
PurchaseRequest.quantity - func.coalesce(received.c.received, 0),
|
||||
0,
|
||||
)
|
||||
|
||||
return (
|
||||
db.session.query(
|
||||
PurchaseRequest.base_id.label('base_id'),
|
||||
func.sum(remaining).label('intransit'),
|
||||
)
|
||||
.outerjoin(received, PurchaseRequest.id == received.c.rid)
|
||||
# base_id 为空的历史单无法归属到任何物料,直接排除,
|
||||
# 否则会聚成一个 base_id=NULL 的组白白参与 join
|
||||
.filter(PurchaseRequest.base_id.isnot(None))
|
||||
.filter(_active_condition(received))
|
||||
.group_by(PurchaseRequest.base_id)
|
||||
.subquery()
|
||||
)
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -163,3 +163,47 @@ export function getApprovedUnstockedRequests(params: {
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 待采购池的单条物料(防重复采购)
|
||||
export interface PendingPoolItem {
|
||||
material_id: number
|
||||
name: string
|
||||
spec_model: string
|
||||
unit: string
|
||||
category: string
|
||||
material_type: string
|
||||
company_name: string
|
||||
image: string
|
||||
purchase_link: string
|
||||
// 仅供参考展示,受 material_list:referencePrice 管控 —— 无权限时后端**不返回该字段**
|
||||
reference_price?: number | null
|
||||
inventory_count: number
|
||||
available_count: number
|
||||
// 在途量 = 该物料所有活跃采购单的剩余待入库量之和
|
||||
in_transit_qty: number
|
||||
// 有效供给 = 库存 + 在途。判缺货与算建议量都基于它
|
||||
effective_supply: number
|
||||
warning_status: number // 0=正常 1=黄色 2=红色(进池的必然非 0)
|
||||
warning_red: number | null
|
||||
warning_yellow: number | null
|
||||
target_threshold: number | null
|
||||
suggested_qty: number | null
|
||||
is_ordered: boolean
|
||||
}
|
||||
|
||||
// 待采购清单:库存已触及预警线、且没有活跃采购单的物料。
|
||||
// 物料一旦有在途采购单(待审批/已通过/部分到货未齐)就会从这里消失。
|
||||
export function getPendingPurchasePool(params: {
|
||||
page?: number
|
||||
limit?: number
|
||||
keyword?: string
|
||||
category?: string
|
||||
type?: string
|
||||
warning_status?: number
|
||||
}) {
|
||||
return request({
|
||||
url: '/purchase/pending-pool',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
@ -213,6 +213,8 @@ const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/purchase',
|
||||
component: Layout,
|
||||
// ★ 父路由**不加** permissions:加了会让没有 pending_pool 权限的用户
|
||||
// 整个「采购管理」模块从侧边栏消失。
|
||||
meta: { title: '采购管理', icon: 'ShoppingCart' },
|
||||
children: [
|
||||
{
|
||||
@ -220,6 +222,12 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: 'PurchaseList',
|
||||
component: () => import('@/views/purchase/index.vue'),
|
||||
meta: { title: '采购申请' }
|
||||
},
|
||||
{
|
||||
path: 'pending',
|
||||
name: 'PurchasePendingPool',
|
||||
component: () => import('@/views/purchase/PendingPool.vue'),
|
||||
meta: { title: '待采购清单', permissions: ['inbound_purchase:pending_pool'] }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -359,9 +359,20 @@
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.warningStatus.visible && hasColPermission('warningStatus')" label="预警状态" width="120" align="center">
|
||||
<el-table-column v-if="columns.warningStatus.visible && hasColPermission('warningStatus')" label="预警状态" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.warningStatus === 2">
|
||||
<!-- ★ 预警降级:物料缺货但**已有在途采购单**时不再闪红灯。
|
||||
红灯的含义是「需要你去买」,而这里已经有人在买了 ——
|
||||
继续亮红只会制造重复采购的冲动。降级为蓝色「缺货(已采购)」。
|
||||
判定由后端 isPurchasing 给出,与待采购池共用同一份逻辑。
|
||||
注意这里只改**展示**:后端 warningStatus 原样保留(预警强排依赖它)。 -->
|
||||
<template v-if="row.isPurchasing && row.warningStatus > 0">
|
||||
<el-tag type="primary" size="small">缺货(已采购)</el-tag>
|
||||
<div style="font-size: 11px; color: #999;">
|
||||
阈值: {{ row.warningStatus === 2 ? row.warningRed : row.warningYellow }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="row.warningStatus === 2">
|
||||
<el-tag type="danger" size="small">红色预警</el-tag>
|
||||
<div style="font-size: 11px; color: #999;">阈值: {{ row.warningRed }}</div>
|
||||
</template>
|
||||
@ -379,10 +390,10 @@
|
||||
<template #default="scope">
|
||||
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
||||
<el-button v-if="userStore.hasPermission('material_list:edit_warning')" link type="warning" size="small" @click="handleSetSingleWarning(scope.row)">设置预警</el-button>
|
||||
<template v-if="userStore.hasPermission('material_list:edit_warning') && scope.row.warningStatus > 0">
|
||||
<el-button v-if="scope.row.warningOrdered" disabled size="small" type="info">采购在途</el-button>
|
||||
<el-button v-else link type="success" size="small" @click="handleMarkOrdered(scope.row)">标记已采购</el-button>
|
||||
</template>
|
||||
<!-- 采购在途:由后端 isPurchasing 自动派生(与待采购池同一判定)。
|
||||
不再提供人工标记入口 —— 人工标记要靠人记得去点、也靠人记得取消;
|
||||
这个是算出来的:建单即在途,驳回 / 强制结案即自动解除。 -->
|
||||
<el-tag v-if="scope.row.isPurchasing" type="primary" size="small">采购在途</el-tag>
|
||||
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -785,7 +796,6 @@ import {
|
||||
batchSetWarning,
|
||||
batchSetInspection,
|
||||
batchSetApprovalRequired,
|
||||
markWarningOrdered,
|
||||
getMaterialUnitsAPI
|
||||
} from '@/api/material_base';
|
||||
import { uploadFile, deleteFile } from '@/api/common/upload';
|
||||
@ -816,7 +826,8 @@ interface MaterialBaseVO {
|
||||
inventoryCount?: number;
|
||||
availableCount?: number;
|
||||
warningStatus?: number;
|
||||
warningOrdered?: boolean;
|
||||
// 采购在途:后端自动派生(有活跃采购单即 true),取代了原先人工标记的 warningOrdered
|
||||
isPurchasing?: boolean;
|
||||
warningRedEmails?: string;
|
||||
warningYellowEmails?: string;
|
||||
referencePrice?: number;
|
||||
@ -1756,26 +1767,10 @@ const handleSetSingleWarning = (row: MaterialBaseVO) => {
|
||||
warningDialog.visible = true;
|
||||
};
|
||||
|
||||
// 标记预警物料已采购
|
||||
const handleMarkOrdered = (row: MaterialBaseVO) => {
|
||||
ElMessageBox.confirm(
|
||||
'确认已对该预警物料下单?标记后在途期间将不再发送预警邮件。',
|
||||
'确认标记已采购',
|
||||
{
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
await markWarningOrdered({ baseId: row.id, isOrdered: true });
|
||||
ElMessage.success('已标记为已采购');
|
||||
getList();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.msg || '标记失败');
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
// ★ 原「标记已采购」人工入口已移除:采购在途状态改由后端 isPurchasing 自动派生
|
||||
// (见列表操作列的 el-tag)。人工标记要靠人记得点、也靠人记得取消,
|
||||
// 而自动判定在建单时即在途、单据驳回/强制结案时即解除,且与待采购池、
|
||||
// 预警邮件静音共用同一份逻辑,三处口径不会各说各话。
|
||||
|
||||
// 提交预警设置
|
||||
const submitWarning = async () => {
|
||||
|
||||
466
inventory-web/src/views/purchase/PendingPool.vue
Normal file
466
inventory-web/src/views/purchase/PendingPool.vue
Normal file
@ -0,0 +1,466 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 14px;"
|
||||
>
|
||||
<template #title>
|
||||
按<b>有效供给</b>(当前库存 + 在途量)判断:只有「有效供给仍低于预警线」的物料才会出现在这里。
|
||||
在途量取该物料所有活跃采购单(待审批 / 已通过 / 部分到货未齐)的<b>剩余待入库量之和</b>,
|
||||
所以已经买过一部分的物料不会消失 —— 它会继续留在清单里,建议采购量只补剩下的缺口。
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<!-- 顶部筛选 -->
|
||||
<el-form :inline="true" class="filter-form" @submit.prevent>
|
||||
<el-form-item label="搜索">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="物料名称 / 规格型号 / 公司"
|
||||
style="width: 240px"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
@clear="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="类别">
|
||||
<el-input
|
||||
v-model="filters.category"
|
||||
placeholder="类别前缀"
|
||||
style="width: 180px"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
@clear="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="类型">
|
||||
<el-input
|
||||
v-model="filters.type"
|
||||
placeholder="物料类型"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
@clear="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="预警等级">
|
||||
<el-radio-group v-model="filters.warningStatus" @change="handleQuery">
|
||||
<el-radio-button label="">全部</el-radio-button>
|
||||
<el-radio-button :label="2">红色</el-radio-button>
|
||||
<el-radio-button :label="1">黄色</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Refresh" @click="handleQuery">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('inbound_purchase:operation')"
|
||||
type="success"
|
||||
:icon="ShoppingCart"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchGenerate"
|
||||
>批量生成采购申请{{ selectedRows.length ? `(${selectedRows.length})` : '' }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
border
|
||||
stripe
|
||||
fit
|
||||
style="width: 100%; margin-top: 16px;"
|
||||
row-key="material_id"
|
||||
empty-text="暂无待采购物料(所有缺货物料都已有在途采购单)"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<!-- ★ 刻意不加 reserve-selection:它会让勾选跨页保留,但按钮上的数量
|
||||
与实际可见的勾选状态可能不一致(甚至保留已离开池子的物料),
|
||||
用户看到「已选 5 项」而屏幕上一个勾都没有。待采购池是小工作台,
|
||||
跨页批量的收益远小于这种静默不一致的代价。 -->
|
||||
<el-table-column type="selection" width="46" align="center" />
|
||||
|
||||
<el-table-column label="产品图" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.image"
|
||||
:src="getImageUrl(row.image)"
|
||||
:preview-src-list="[getImageUrl(row.image)]"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
style="width: 56px; height: 56px; border-radius: 4px;"
|
||||
>
|
||||
<template #error>
|
||||
<div class="img-fallback">无图</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<span v-else style="color: #c0c4cc; font-size: 12px;">无图</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="name" label="物料名称" min-width="220" show-overflow-tooltip />
|
||||
|
||||
<el-table-column prop="spec_model" label="规格型号" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.spec_model">{{ row.spec_model }}</span>
|
||||
<span v-else style="color: #c0c4cc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="unit" label="单位" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.unit || '-' }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="category" label="类别" min-width="170" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.category || '-' }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="当前库存" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="money-text">{{ row.inventory_count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="在途" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="row.in_transit_qty > 0"
|
||||
content="该物料所有活跃采购单的剩余待入库量之和"
|
||||
placement="top"
|
||||
>
|
||||
<span class="in-transit">+{{ row.in_transit_qty }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else style="color: #c0c4cc;">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="有效供给" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
:content="`库存 ${row.inventory_count} + 在途 ${row.in_transit_qty} = ${row.effective_supply}(判缺货用的就是它)`"
|
||||
placement="top"
|
||||
>
|
||||
<span class="effective">{{ row.effective_supply }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="可用库存" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'avail-warn': row.available_count <= 0 }">
|
||||
{{ row.available_count }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="预警" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.warning_status === 2" type="danger" size="small">红色预警</el-tag>
|
||||
<el-tag v-else-if="row.warning_status === 1" type="warning" size="small">黄色预警</el-tag>
|
||||
<span v-else style="color: #c0c4cc;">-</span>
|
||||
<div style="font-size: 11px; color: #909399; margin-top: 2px;">
|
||||
红 {{ fmt(row.warning_red) }} / 黄 {{ fmt(row.warning_yellow) }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="建议采购量" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="row.suggested_qty != null"
|
||||
:content="suggestedTip(row)"
|
||||
placement="top"
|
||||
>
|
||||
<span class="suggested">{{ row.suggested_qty }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else style="color: #c0c4cc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- ★ 参考价格是受 material_list:referencePrice 管控的价格数据(与物料列表同源
|
||||
同码)。这里与后端 include_reference_price 一一对应:后端无权限时整字段
|
||||
不返回,前端也不渲染该列 —— 只挡前端等于没挡,接口仍然裸奔。 -->
|
||||
<el-table-column
|
||||
v-if="userStore.hasPermission('material_list:referencePrice')"
|
||||
label="参考单价"
|
||||
width="110"
|
||||
align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.reference_price != null">{{ row.reference_price.toFixed(2) }}</span>
|
||||
<span v-else style="color: #c0c4cc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="采购链接" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<!-- ★ 必须过 safeHref 白名单:地址由用户自由填写,直接绑 href 会让
|
||||
javascript: 之类的伪协议被当成可点击链接执行 -->
|
||||
<el-link
|
||||
v-if="safeHref(row.purchase_link)"
|
||||
type="primary"
|
||||
:href="safeHref(row.purchase_link)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>🔗 前往采购</el-link>
|
||||
<span v-else-if="row.purchase_link" style="color: #909399;" :title="row.purchase_link">格式无效</span>
|
||||
<span v-else style="color: #c0c4cc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('inbound_purchase:operation')"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleGenerate(row)"
|
||||
>生成采购申请</el-button>
|
||||
<span v-else style="color: #c0c4cc; font-size: 12px;">无建单权限</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
:background="true"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, ShoppingCart } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getPendingPurchasePool, type PendingPoolItem } from '@/api/purchase'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<PendingPoolItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const selectedRows = ref<PendingPoolItem[]>([])
|
||||
|
||||
/**
|
||||
* 批量交接数据走 localStorage,而不是 URL query。
|
||||
*
|
||||
* 原因:批量可能勾选十几个物料,每个还带商家链接(material_base.purchase_link
|
||||
* 是 Text,实测单条已 516 字符)。塞进 URL 会撞上长度上限并被**静默截断** ——
|
||||
* 前端拿到半截 JSON 只会报解析失败,排查成本很高。localStorage 没有这个上限。
|
||||
* query 里只传一个一次性 key,取完即删。
|
||||
*/
|
||||
const BATCH_KEY_PREFIX = 'MOM_PURCHASE_BATCH_'
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
category: '',
|
||||
type: '',
|
||||
warningStatus: '' as '' | 1 | 2
|
||||
})
|
||||
|
||||
const fmt = (v: number | null) => (v == null ? '-' : v)
|
||||
|
||||
/**
|
||||
* 建议采购量的说明文案。
|
||||
*
|
||||
* ★ 为什么要分两种:有效供给**恰好等于**阈值时,`阈值 - 有效供给` 是 0,
|
||||
* 但后端有 `max(1, ...)` 保底,结果是 1。若直接套「阈值 − 有效供给 = 建议量」
|
||||
* 的模板,页面上会出现「4 − 4 = 1」这种**算不对的式子** —— 采购员一旦发现
|
||||
* 算式不成立,就不会再信这个数字。所以到线的情况单独讲清楚。
|
||||
*
|
||||
* 等于阈值仍算不足,是与物料列表预警、预警邮件一致的口径(见后端
|
||||
* purchase_service.get_pending_purchase_pool 的说明)。
|
||||
*/
|
||||
const suggestedTip = (row: PendingPoolItem) => {
|
||||
const target = row.target_threshold
|
||||
const eff = row.effective_supply
|
||||
if (target != null && target - eff >= 1) {
|
||||
return `补至 ${target}:目标阈值 ${target} − 有效供给 ${eff} = ${row.suggested_qty}(在途已计入,不会重复买)`
|
||||
}
|
||||
return `有效供给 ${eff} 已达阈值线 ${target},建议补 ${row.suggested_qty} 个作缓冲。` +
|
||||
`(等于阈值仍算不足 —— 与物料列表的预警口径一致)`
|
||||
}
|
||||
|
||||
const getImageUrl = (url: string) => (!url ? '' : url)
|
||||
|
||||
/** 采购链接白名单:只放行 http/https,其余一律不渲染成链接 */
|
||||
const safeHref = (url: any): string | null => {
|
||||
const s = String(url || '').trim()
|
||||
return /^https?:\/\//i.test(s) ? s : null
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: page.value,
|
||||
limit: pageSize.value
|
||||
}
|
||||
if (filters.keyword) params.keyword = filters.keyword
|
||||
if (filters.category) params.category = filters.category
|
||||
if (filters.type) params.type = filters.type
|
||||
if (filters.warningStatus !== '') params.warning_status = filters.warningStatus
|
||||
|
||||
const res: any = await getPendingPurchasePool(params)
|
||||
const data = res?.data ?? {}
|
||||
list.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || '加载待采购清单失败')
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuery = () => {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const resetFilter = () => {
|
||||
filters.keyword = ''
|
||||
filters.category = ''
|
||||
filters.type = ''
|
||||
filters.warningStatus = ''
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handlePageChange = (p: number) => { page.value = p; fetchData() }
|
||||
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchData() }
|
||||
|
||||
/**
|
||||
* 生成采购申请:新标签页打开采购申请页并自动回填。
|
||||
*
|
||||
* 用 window.open 而非 router.push 是刻意的 —— 待采购清单的工作流是**连续建单**,
|
||||
* 同标签跳转会卸载本页,采购员每开一单都要点回来重新筛选、丢失页码;
|
||||
* 新标签页保留列表状态。这与「基础信息」页既有的跳转方式也保持一致。
|
||||
*/
|
||||
const handleSelectionChange = (rows: PendingPoolItem[]) => {
|
||||
selectedRows.value = rows || []
|
||||
}
|
||||
|
||||
/** 把池中一行整理成弹窗明细行的预填数据(单行与批量共用同一套字段映射) */
|
||||
const toPrefillItem = (row: PendingPoolItem) => ({
|
||||
materialId: row.material_id,
|
||||
name: row.name || '',
|
||||
spec: row.spec_model || '',
|
||||
// 建议采购量作为默认数量带入
|
||||
quantity: row.suggested_qty != null && row.suggested_qty > 0 ? row.suggested_qty : undefined,
|
||||
// 供应商链接(基础表的采购链接)带入,采购员无需再去查
|
||||
supplierLink: String(row.purchase_link || '').trim() || undefined
|
||||
})
|
||||
|
||||
/**
|
||||
* 把预填数据交给采购申请页,开新标签页。
|
||||
*
|
||||
* ★ 单行与批量都走这里、都经 localStorage 中转 —— 曾经单行走 URL query,
|
||||
* 而 material_base.purchase_link 是 text(实测已有 516 字符的真实链接),
|
||||
* URL 长度受限会逼出一个「多长就不带」的硬编码阈值,导致同一个物料
|
||||
* 单行跳转带链接、批量跳转不带,行为不一致。现在两条路径统一,无长度上限。
|
||||
*
|
||||
* 开新标签页是刻意的:待采购清单的工作流是连续建单,同标签跳转会卸载本页、
|
||||
* 丢失筛选与勾选状态。
|
||||
*/
|
||||
const openPurchaseWithItems = (items: ReturnType<typeof toPrefillItem>[]) => {
|
||||
const key = BATCH_KEY_PREFIX + Date.now()
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(items))
|
||||
} catch (e) {
|
||||
ElMessage.error('暂存待建单数据失败,请重试')
|
||||
return
|
||||
}
|
||||
const routeUrl = router.resolve({ path: '/purchase', query: { batch_key: key } })
|
||||
window.open(routeUrl.href, '_blank')
|
||||
}
|
||||
|
||||
/** 单行:生成采购申请 */
|
||||
const handleGenerate = (row: PendingPoolItem) => {
|
||||
openPurchaseWithItems([toPrefillItem(row)])
|
||||
}
|
||||
|
||||
/** 批量:勾选 N 个物料 → 一次性铺成 N 行,一单多品合并采购 */
|
||||
const handleBatchGenerate = () => {
|
||||
if (selectedRows.value.length === 0) return
|
||||
openPurchaseWithItems(selectedRows.value.map(toPrefillItem))
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-container { padding: 20px; }
|
||||
|
||||
/* 搜索区版式:与采购申请 / 出库记录 / 报废记录保持一致 */
|
||||
.filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-form :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.suggested {
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 在途量与有效供给:本次改造新增的核心数字,用不同色区分于纯库存 */
|
||||
.in-transit {
|
||||
color: #e6a23c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.effective {
|
||||
font-weight: 600;
|
||||
border-bottom: 1px dashed #c0c4cc;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.avail-warn {
|
||||
color: #f56c6c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.img-fallback {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@ -252,9 +252,19 @@
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商家链接" min-width="160">
|
||||
<el-table-column label="商家链接" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.supplier_link" placeholder="https://" clearable />
|
||||
<!-- 后置跳转按钮:链接非法(非 http/https)时置灰,不做静默失败 -->
|
||||
<el-input v-model="row.supplier_link" placeholder="https://" clearable>
|
||||
<template #append>
|
||||
<el-button
|
||||
:icon="Link"
|
||||
:disabled="!safeHref(row.supplier_link)"
|
||||
title="在新标签页打开链接"
|
||||
@click="openLink(row.supplier_link)"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="150">
|
||||
@ -458,7 +468,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Refresh, Plus, Delete, CopyDocument, Picture } from '@element-plus/icons-vue'
|
||||
import { Refresh, Plus, Delete, CopyDocument, Picture, Link } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { searchMaterialPurchase } from '@/api/purchase'
|
||||
@ -916,29 +926,98 @@ const handleStatusChange = () => {
|
||||
const handlePageChange = (p: number) => { page.value = p; fetchData() }
|
||||
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchData() }
|
||||
|
||||
/**
|
||||
* 链接白名单:只放行 http/https。
|
||||
*
|
||||
* ★ 商家链接由用户自由填写,直接交给 window.open 会让 `javascript:` 之类的
|
||||
* 伪协议被当成可执行代码。与物料列表 / 待采购清单的 safeHref 同一约定。
|
||||
*/
|
||||
const safeHref = (url: any): string | null => {
|
||||
const s = String(url || '').trim()
|
||||
return /^https?:\/\//i.test(s) ? s : null
|
||||
}
|
||||
|
||||
/** 明细行「商家链接」的跳转按钮:新标签页打开,方便采购员比对价格 */
|
||||
const openLink = (url: any) => {
|
||||
const href = safeHref(url)
|
||||
if (!href) {
|
||||
ElMessage.warning('链接格式无效,仅支持 http / https 地址')
|
||||
return
|
||||
}
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
// --- 新建 ---
|
||||
const openCreateDialog = (prefill?: { materialId?: number; name?: string; spec?: string; unit?: string }) => {
|
||||
/** 把一条预填数据灌进明细行(单条与批量共用同一套字段映射) */
|
||||
const fillRowFromPrefill = (
|
||||
row: FormItemRow,
|
||||
src: { materialId: number; name?: string; spec?: string; quantity?: number; supplierLink?: string }
|
||||
) => {
|
||||
row.materialBaseId = src.materialId
|
||||
row.name = src.name || ''
|
||||
row.spec_model = src.spec || ''
|
||||
// 待采购清单带来的默认数量与供应商链接(都是 FormItemRow 已有字段,
|
||||
// 不需要动接口/模板/工厂函数)。unit 仍不带入 —— 明细行没有该字段。
|
||||
if (src.quantity && src.quantity > 0) {
|
||||
row.quantity = src.quantity
|
||||
}
|
||||
if (src.supplierLink) {
|
||||
row.supplier_link = src.supplierLink
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
const openCreateDialog = (prefill?: {
|
||||
materialId?: number
|
||||
name?: string
|
||||
spec?: string
|
||||
unit?: string
|
||||
quantity?: number
|
||||
supplierLink?: string
|
||||
/** 批量:待采购清单勾选多个物料时,一次性铺成多行 */
|
||||
batchItems?: Array<{
|
||||
materialId: number
|
||||
name?: string
|
||||
spec?: string
|
||||
quantity?: number
|
||||
supplierLink?: string
|
||||
}>
|
||||
}) => {
|
||||
dialogTitle.value = '新建采购申请'
|
||||
form.value = {
|
||||
approver_id: undefined,
|
||||
remark: ''
|
||||
}
|
||||
|
||||
// ★ 初始化明细行:若有预填物料则第一行带入,否则给一行空行
|
||||
const firstRow = createEmptyFormItem()
|
||||
if (prefill?.materialId) {
|
||||
firstRow.materialBaseId = prefill.materialId
|
||||
firstRow.name = prefill.name || ''
|
||||
firstRow.spec_model = prefill.spec || ''
|
||||
if (prefill?.batchItems && prefill.batchItems.length > 0) {
|
||||
// ★ 批量建单:勾选 N 个物料 → 明细表直接铺 N 行,一单多品合并采购。
|
||||
// materialOptions 必须把每一行的物料都塞进去,否则 el-select 找到不到
|
||||
// 选项,已填的物料会显示成空白(单条预填时也有这个约束)。
|
||||
formItems.value = prefill.batchItems.map(it =>
|
||||
fillRowFromPrefill(createEmptyFormItem(), it)
|
||||
)
|
||||
materialOptions.value = prefill.batchItems.map(it => ({
|
||||
id: it.materialId,
|
||||
name: it.name || '',
|
||||
spec_model: it.spec || ''
|
||||
}))
|
||||
} else if (prefill?.materialId) {
|
||||
formItems.value = [fillRowFromPrefill(createEmptyFormItem(), {
|
||||
materialId: prefill.materialId,
|
||||
name: prefill.name,
|
||||
spec: prefill.spec,
|
||||
quantity: prefill.quantity,
|
||||
supplierLink: prefill.supplierLink
|
||||
})]
|
||||
materialOptions.value = [{
|
||||
id: prefill.materialId,
|
||||
name: prefill.name || '',
|
||||
spec_model: prefill.spec || ''
|
||||
}]
|
||||
} else {
|
||||
formItems.value = [createEmptyFormItem()]
|
||||
materialOptions.value = []
|
||||
}
|
||||
formItems.value = [firstRow]
|
||||
|
||||
formDialogVisible.value = true
|
||||
|
||||
@ -1393,14 +1472,52 @@ onMounted(() => {
|
||||
fetchData()
|
||||
fetchApprovers()
|
||||
|
||||
// 从基础信息跳转过来的参数
|
||||
// 跨页跳转过来的预填参数。生产端有两处:
|
||||
// - 基础信息 / 基础信息(Odoo) 的「发起采购申请」按钮
|
||||
// - 待采购清单的「生成采购申请」按钮(额外带 quantity / supplier_link)
|
||||
//
|
||||
// ★ 本段依赖**组件重建**:两个生产端都用 window.open 开新标签页,
|
||||
// 每次都是全新实例,onMounted 必然触发。
|
||||
// 若将来有人改成「同 path 的 router.push」(如 /purchase?material_id=2 → material_id=3),
|
||||
// 本段不会重跑,预填会静默失效 —— 届时必须改造为
|
||||
// watch(() => route.query.material_id, ..., { immediate: true })。
|
||||
const query = route.query
|
||||
|
||||
// 批次 A:待采购清单「批量生成采购申请」——物料较多(含可长达数百字符的
|
||||
// 商家链接),塞进 URL 有长度风险,故走 localStorage 中转,query 里只传一个键。
|
||||
if (query.batch_key) {
|
||||
const key = String(query.batch_key)
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
// ★ 读完立刻删除:这是一次性交接数据,留着重进页面会重复弹出;
|
||||
// 多个标签页各自用独立的 key,互不干扰。
|
||||
localStorage.removeItem(key)
|
||||
const items = raw ? JSON.parse(raw) : []
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
openCreateDialog({ batchItems: items })
|
||||
} else {
|
||||
ElMessage.warning('批量数据已失效,请回到待采购清单重新勾选')
|
||||
}
|
||||
} catch (e) {
|
||||
localStorage.removeItem(key)
|
||||
ElMessage.error('批量数据解析失败,请重新勾选')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 批次 B:单条预填,走 URL query。
|
||||
// 生产端只有基础信息 / 基础信息(Odoo) 的「发起采购申请」按钮
|
||||
// (它们只带 id/名称/规格,不带链接,所以没有 URL 长度风险)。
|
||||
// 待采购清单的单行与批量按钮统一走上面的 batch_key,两条路径共用一套回填。
|
||||
if (query.material_id) {
|
||||
const rawQty = Number(query.quantity)
|
||||
openCreateDialog({
|
||||
materialId: Number(query.material_id),
|
||||
name: (query.name as string) || '',
|
||||
spec: (query.spec as string) || '',
|
||||
unit: (query.unit as string) || ''
|
||||
unit: (query.unit as string) || '',
|
||||
quantity: rawQty > 0 ? rawQty : undefined,
|
||||
supplierLink: (query.supplier_link as string) || ''
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user