Compare commits
17 Commits
578c15dc95
...
815ad01a5b
| Author | SHA1 | Date | |
|---|---|---|---|
| 815ad01a5b | |||
| ffbf4689f1 | |||
| 2a9e2e4560 | |||
| c8cf2bd52b | |||
| eff9b62224 | |||
| 67d3113fe1 | |||
| e622697d7c | |||
| aa9322bcbc | |||
| 4146f35010 | |||
| 6bdc8a82f2 | |||
| d06ce45a72 | |||
| 2abac8b3f9 | |||
| 93f149739d | |||
| 4cd3eefdf4 | |||
| e03c035ee0 | |||
| 6aba09880c | |||
| 9622baf76e |
10
db_migrations/add_material_approval_flag.sql
Normal file
10
db_migrations/add_material_approval_flag.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- =============================================================================
|
||||
-- 一次性迁移:物料“出库/借库需审批”开关
|
||||
-- material_base.is_approval_required boolean NOT NULL DEFAULT false
|
||||
-- 语义:命中该标记物料的出库/借库申请仍需审批;其余默认不审批、自动通过后直接交库管执行。
|
||||
-- 存量默认 false(不审批),需要审批的物料再在基础信息中逐个/批量勾选。
|
||||
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
BEGIN;
|
||||
ALTER TABLE material_base ADD COLUMN IF NOT EXISTS is_approval_required boolean NOT NULL DEFAULT false;
|
||||
COMMIT;
|
||||
32
db_migrations/add_material_approval_perm.sql
Normal file
32
db_migrations/add_material_approval_perm.sql
Normal file
@ -0,0 +1,32 @@
|
||||
-- =============================================================================
|
||||
-- 一次性迁移:物料“出库/借库需审批”开关的独立权限项
|
||||
-- 权限码 material_list:isApprovalRequired(操作级)
|
||||
-- 仅授予:SUPER_ADMIN(超管)、SUPERVISOR(主管)。其余角色不可见、不可修改。
|
||||
-- 幂等:重复执行安全(先收回“非主管/超管”的该权限,再确保主管/超管持有)。
|
||||
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
BEGIN;
|
||||
|
||||
-- 1) 注册权限元素(幂等)
|
||||
INSERT INTO sys_element (menu_code, name, code, element_type)
|
||||
SELECT 'material_list', '出库/借库需审批(修改)', 'material_list:isApprovalRequired', 'operation'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'material_list:isApprovalRequired');
|
||||
|
||||
-- 2) 收回除主管/超管外所有角色的该权限(幂等)
|
||||
DELETE FROM sys_role_permission
|
||||
WHERE target_code = 'material_list:isApprovalRequired'
|
||||
AND type = 'element'
|
||||
AND role_code NOT IN ('SUPER_ADMIN', 'SUPERVISOR');
|
||||
|
||||
-- 3) 确保主管/超管持有(幂等)
|
||||
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
|
||||
SELECT v.role_code, 'material_list:isApprovalRequired', 'element', NULL
|
||||
FROM (VALUES ('SUPER_ADMIN'), ('SUPERVISOR')) AS v(role_code)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_permission r
|
||||
WHERE r.role_code = v.role_code
|
||||
AND r.target_code = 'material_list:isApprovalRequired'
|
||||
AND r.type = 'element'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
12
db_migrations/normalize_bom_tri_state.sql
Normal file
12
db_migrations/normalize_bom_tri_state.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- =============================================================================
|
||||
-- 一次性迁移:BOM 三态互斥归一(启用/停用/归档 三者互斥)
|
||||
-- 规则:归档=彻底废弃 → is_archived=True 且 is_enabled=False
|
||||
-- 启用=在用;停用=有问题/暂停
|
||||
-- 把历史上“归档但仍启用(True/True)”的行归一到“归档且停用(True/False)”
|
||||
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
BEGIN;
|
||||
UPDATE bom_table
|
||||
SET is_enabled = FALSE
|
||||
WHERE is_archived = TRUE AND is_enabled = TRUE;
|
||||
COMMIT;
|
||||
@ -539,6 +539,44 @@ def batch_set_inspection():
|
||||
return jsonify({"code": 500, "msg": f"批量设置强制质检失败: {str(e)}"}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 2.8 批量设置“出库/借库需审批” (POST /api/v1/inbound/base/batch-approval)
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/batch-approval', methods=['POST'])
|
||||
@permission_required('material_list:isApprovalRequired')
|
||||
def batch_set_approval_required():
|
||||
"""
|
||||
批量设置物料“出库/借库需审批”标记(仿强制质检批量接口)。
|
||||
请求体: { "ids": [1,2,3], "isApprovalRequired": true }
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"code": 400, "msg": "No data provided"}), 400
|
||||
|
||||
ids = data.get('ids', [])
|
||||
is_approval_required = bool(data.get('isApprovalRequired', False))
|
||||
if not ids:
|
||||
return jsonify({"code": 400, "msg": "请选择要设置的物料"}), 400
|
||||
|
||||
updated_count = 0
|
||||
for base_id in ids:
|
||||
material = MaterialBase.query.get(base_id)
|
||||
if material:
|
||||
material.is_approval_required = is_approval_required
|
||||
updated_count += 1
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": f"批量设置成功,已更新 {updated_count} 条记录",
|
||||
"data": {"updated": updated_count}
|
||||
})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"批量设置需审批失败: {str(e)}")
|
||||
return jsonify({"code": 500, "msg": f"批量设置需审批失败: {str(e)}"}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 2.7 智能分组求最大连号 API (GET /api/v1/inbound/base/spec-latest)
|
||||
# ==============================================================================
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from app.services.outbound_service import OutboundService
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||
from app.utils.decorators import permission_required, audit_log, prevent_double_submit
|
||||
from app.utils.decorators import permission_required, audit_log, prevent_double_submit, is_privileged_viewer
|
||||
from app.services.auth_service import AuthService
|
||||
import traceback
|
||||
|
||||
@ -175,8 +175,22 @@ def get_outbound_list():
|
||||
search_type = request.args.get('search_type', 'all')
|
||||
company = request.args.get('company', '')
|
||||
|
||||
# ★ 数据权限:普通用户只看“领用人=本人姓名(不含账号前缀)”的出库记录;管理者看全部
|
||||
consumer_name = None
|
||||
if not is_privileged_viewer():
|
||||
_identity = get_jwt_identity()
|
||||
if _identity:
|
||||
from app.models.system import SysUser
|
||||
_u = SysUser.query.get(int(_identity))
|
||||
# username 形如 “中文名/xiaolongxia” → 取“/”前的领用人姓名
|
||||
_uname = _u.username if _u else ''
|
||||
consumer_name = _uname.split('/')[0].strip() if _uname else None
|
||||
|
||||
# ★ [修改] 调用分组查询服务,支持搜索类型
|
||||
result = OutboundService.get_grouped_list(page, limit, keyword, search_type=search_type, company=company)
|
||||
result = OutboundService.get_grouped_list(
|
||||
page, limit, keyword, search_type=search_type,
|
||||
company=company, consumer_name=consumer_name
|
||||
)
|
||||
|
||||
# 字段级脱敏
|
||||
user_permissions = get_current_user_permissions()
|
||||
@ -429,6 +443,28 @@ def create_outbound_request():
|
||||
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 4.1 出库申请预检(判断所选物料是否需审批,驱动前端是否显示审批人)
|
||||
# POST /api/v1/outbound/request/check-approval
|
||||
# --------------------------------------------------------
|
||||
@outbound_bp.route('/request/check-approval', methods=['POST'])
|
||||
@jwt_required()
|
||||
@permission_required('outbound_selection')
|
||||
def check_outbound_approval():
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
items = data.get('items', []) or []
|
||||
from app.services.approval_control import resolve_approval_control
|
||||
need_approval, flagged = resolve_approval_control(items)
|
||||
return jsonify({
|
||||
"code": 200, "msg": "success",
|
||||
"data": {"need_approval": need_approval, "materials": flagged}
|
||||
}), 200
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": f"预检失败: {str(e)}"}), 500
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 5. 审批出库申请
|
||||
# PATCH /api/v1/outbound/request/<id>/approve
|
||||
@ -551,6 +587,11 @@ def get_outbound_request_list():
|
||||
if status is not None:
|
||||
status = int(status)
|
||||
|
||||
# ★ 数据权限:普通申请人只能看“自己的”出库记录;库管/主管/超管(或跨域)才可看他人
|
||||
if not is_privileged_viewer():
|
||||
identity = get_jwt_identity()
|
||||
applicant_id = int(identity) if identity else None
|
||||
|
||||
result = OutboundApprovalService.get_request_list(
|
||||
page=page,
|
||||
per_page=limit,
|
||||
@ -584,6 +625,12 @@ def get_outbound_request_detail(request_id):
|
||||
if not approval:
|
||||
return jsonify({'code': 404, 'msg': '审批单不存在'}), 404
|
||||
|
||||
# ★ 数据权限:普通申请人只能看自己的单;库管/主管/超管(或跨域)可看任意
|
||||
if not is_privileged_viewer():
|
||||
identity = get_jwt_identity()
|
||||
if int(approval.applicant_id or 0) != int(identity or 0):
|
||||
return jsonify({'code': 403, 'msg': '无权查看他人的出库记录'}), 403
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '获取成功',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from flask import Blueprint, jsonify, request # .material -> .base refactor checked
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||
from app.utils.decorators import permission_required, audit_log, prevent_double_submit
|
||||
from app.utils.decorators import permission_required, audit_log, prevent_double_submit, is_privileged_viewer
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.trans_service import TransService
|
||||
from app.services.borrow_service import BorrowApprovalService
|
||||
@ -163,7 +163,20 @@ def get_records():
|
||||
keyword = request.args.get('keyword', '')
|
||||
search_type = request.args.get('search_type', 'all')
|
||||
|
||||
res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword, search_type=search_type)
|
||||
# ★ 数据权限:普通用户只看“借用人=本人姓名(不含账号前缀)”的借还记录;管理者看全部
|
||||
borrower_name = None
|
||||
if not is_privileged_viewer():
|
||||
_identity = get_jwt_identity()
|
||||
if _identity:
|
||||
from app.models.system import SysUser
|
||||
_u = SysUser.query.get(int(_identity))
|
||||
_uname = _u.username if _u else ''
|
||||
borrower_name = _uname.split('/')[0].strip() if _uname else None
|
||||
|
||||
res = TransService.get_records(
|
||||
page=page, limit=10, status=status, keyword=keyword,
|
||||
search_type=search_type, borrower_name=borrower_name
|
||||
)
|
||||
|
||||
# ★ service 层异常时:code==500 的字典(带 traceback),需要直通到前端,便于排查
|
||||
if isinstance(res, dict) and res.get('code') == 500:
|
||||
@ -311,6 +324,25 @@ def close_borrow_request(request_id):
|
||||
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||
|
||||
|
||||
# --- 借库申请预检(判断所选物料是否需审批,驱动前端是否显示审批人) ---
|
||||
@trans_bp.route('/borrow/request/check-approval', methods=['POST'])
|
||||
@jwt_required()
|
||||
@permission_required('op_borrow_apply')
|
||||
def check_borrow_approval():
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
items = data.get('items', []) or []
|
||||
from app.services.approval_control import resolve_approval_control
|
||||
need_approval, flagged = resolve_approval_control(items)
|
||||
return jsonify({
|
||||
"code": 200, "msg": "success",
|
||||
"data": {"need_approval": need_approval, "materials": flagged}
|
||||
}), 200
|
||||
except Exception as e:
|
||||
import traceback; traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": f"预检失败: {str(e)}"}), 500
|
||||
|
||||
|
||||
# --- 获取借库审批单列表 ---
|
||||
@trans_bp.route('/borrow/request', methods=['GET'])
|
||||
@jwt_required()
|
||||
@ -330,6 +362,11 @@ def get_borrow_request_list():
|
||||
if status is not None:
|
||||
status = int(status)
|
||||
|
||||
# ★ 数据权限:普通申请人只能看“自己的”借还记录;库管/主管/超管(或跨域)才可看他人
|
||||
if not is_privileged_viewer():
|
||||
identity = get_jwt_identity()
|
||||
applicant_id = int(identity) if identity else None
|
||||
|
||||
result = BorrowApprovalService.get_request_list(
|
||||
page=page, per_page=limit, applicant_id=applicant_id, status=status
|
||||
)
|
||||
|
||||
@ -39,6 +39,9 @@ class MaterialBase(db.Model):
|
||||
# 强制质检标记(采购入库时必须上传检测报告)
|
||||
is_inspection_required = db.Column(db.Boolean, default=False, comment='是否强制要求质检')
|
||||
|
||||
# 出库/借库需审批标记:命中该物料的出/借申请仍需走审批;否则默认自动通过、直接交库管执行
|
||||
is_approval_required = db.Column(db.Boolean, default=False, comment='出库/借库需审批')
|
||||
|
||||
# 参考价格
|
||||
reference_price = db.Column(db.Numeric(10, 2), nullable=True, comment='参考价格')
|
||||
|
||||
@ -99,6 +102,8 @@ class MaterialBase(db.Model):
|
||||
'isEnabled': bool(self.is_enabled),
|
||||
# 强制质检标记
|
||||
'isInspectionRequired': bool(self.is_inspection_required),
|
||||
# 出库/借库需审批标记
|
||||
'isApprovalRequired': bool(self.is_approval_required),
|
||||
# 参考价格
|
||||
'referencePrice': float(self.reference_price) if self.reference_price is not None else None,
|
||||
}
|
||||
|
||||
88
inventory-backend/app/services/approval_control.py
Normal file
88
inventory-backend/app/services/approval_control.py
Normal file
@ -0,0 +1,88 @@
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
|
||||
def _code_of(spec):
|
||||
"""取规格的首段编码('S0013/S0013' -> 'S0013';'S0013' -> 'S0013')"""
|
||||
s = (spec or '').strip()
|
||||
if not s:
|
||||
return ''
|
||||
return s.split('/')[0].strip().upper()
|
||||
|
||||
|
||||
def _materials_flagged():
|
||||
"""一次性取出所有“启用且需审批”的物料(量小,逐项判定前先全量拉一次)"""
|
||||
rows = MaterialBase.query.filter(
|
||||
MaterialBase.is_enabled == True,
|
||||
MaterialBase.is_approval_required == True
|
||||
).all()
|
||||
return rows
|
||||
|
||||
|
||||
def _is_int(v):
|
||||
try:
|
||||
int(v)
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def resolve_approval_control(items):
|
||||
"""
|
||||
判断一批出库/借库申请明细是否需要审批。
|
||||
|
||||
判定优先级:
|
||||
1) 方案1(最高):明细带有效 base_id → 直接按物料主键查 is_approval_required,
|
||||
且只要有有效 ID,无论命中与否都不再做任何字符串兜底(杜绝同名/同规格误杀)。
|
||||
2) 方案2(无 ID 兜底):名称必须 100% 精确一致 AND 核心规格一致(斜杠前段,
|
||||
或前端未传规格视为防呆拦截),两条件同时成立才判定命中。
|
||||
"""
|
||||
flagged = []
|
||||
seen = set()
|
||||
|
||||
def _record(m_obj, req_name):
|
||||
if m_obj.id in seen:
|
||||
return
|
||||
seen.add(m_obj.id)
|
||||
flagged.append({
|
||||
'name': m_obj.name or req_name,
|
||||
'spec_model': m_obj.spec_model or '',
|
||||
'base_id': m_obj.id,
|
||||
})
|
||||
|
||||
# 1. 查出所有“启用且需审批”的危险物料库
|
||||
flagged_materials = _materials_flagged()
|
||||
|
||||
for item in items or []:
|
||||
name = str(item.get('name') or '').strip()
|
||||
spec = str(item.get('spec_model') or '').strip()
|
||||
base_id = item.get('base_id')
|
||||
|
||||
# --- 方案 1:最高优先级,依据 base_id 绝对判定 ---
|
||||
if base_id is not None:
|
||||
bid = int(base_id) if _is_int(base_id) else 0
|
||||
if bid > 0:
|
||||
m = MaterialBase.query.get(bid)
|
||||
if m and m.is_enabled and m.is_approval_required:
|
||||
_record(m, name)
|
||||
# ★ 关键:只要有有效 ID,无论是否需审批,绝不允许往下走字符串兜底
|
||||
continue
|
||||
|
||||
# --- 方案 2:无 ID 时的降级兜底 (名称精确相等 AND 核心规格相等) ---
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# 提取当前申请行的核心规格
|
||||
req_core_spec = _code_of(spec)
|
||||
|
||||
for fm in flagged_materials:
|
||||
# 条件 A:名称必须 100% 绝对一致
|
||||
if fm.name != name:
|
||||
continue
|
||||
|
||||
# 条件 B:核心规格一致 (或前端未传规格视为防呆拦截)
|
||||
fm_core_spec = _code_of(fm.spec_model)
|
||||
if not req_core_spec or req_core_spec == fm_core_spec:
|
||||
_record(fm, name)
|
||||
break # 命中即判定本申请行需审批,跳出内层循环
|
||||
|
||||
return bool(flagged), flagged
|
||||
@ -152,11 +152,11 @@ class BomService:
|
||||
if status == 'enabled':
|
||||
query = query.filter(BomTable.is_enabled == True, BomTable.is_archived == False)
|
||||
elif status == 'disabled':
|
||||
query = query.filter(BomTable.is_enabled == False)
|
||||
query = query.filter(BomTable.is_enabled == False, BomTable.is_archived == False)
|
||||
elif status == 'archived':
|
||||
query = query.filter(BomTable.is_archived == True)
|
||||
elif active_only:
|
||||
query = query.filter(BomTable.is_enabled == True)
|
||||
query = query.filter(BomTable.is_enabled == True, BomTable.is_archived == False)
|
||||
|
||||
# 【行级数据隔离】基于 JWT 多租户公司过滤
|
||||
company_limit = get_current_company_filter()
|
||||
@ -263,7 +263,7 @@ class BomService:
|
||||
if status == 'enabled':
|
||||
query = query.filter(BomTable.is_enabled == True, BomTable.is_archived == False)
|
||||
elif status == 'disabled':
|
||||
query = query.filter(BomTable.is_enabled == False)
|
||||
query = query.filter(BomTable.is_enabled == False, BomTable.is_archived == False)
|
||||
elif status == 'archived':
|
||||
query = query.filter(BomTable.is_archived == True)
|
||||
|
||||
@ -404,6 +404,7 @@ class BomService:
|
||||
'parent_name': parent_material.name if parent_material else '',
|
||||
'parent_spec': parent_material.spec_model if parent_material else '',
|
||||
'is_enabled': first.BomTable.is_enabled,
|
||||
'remark': first.BomTable.remark or '', # ★ 主表备注
|
||||
'children': children
|
||||
}
|
||||
|
||||
@ -544,10 +545,9 @@ class BomService:
|
||||
@staticmethod
|
||||
def update_enabled(bom_no, version, is_enabled):
|
||||
"""
|
||||
仅更新某 BOM 版本(整组行)的启用状态,供列表/只读视图"启用/停用"开关使用。
|
||||
三态互斥:切“启用/停用”时同步清除“归档”。
|
||||
启用=在用;停用=有问题/暂停;两者都与归档互斥。
|
||||
不触碰子件结构;成功后清除该 bom_no 的缓存。
|
||||
注意:只按 bom_no+version 精确匹配,不加 is_enabled/is_archived 等多余过滤——
|
||||
停用/归档中的版本只要记录存在即可继续改状态。
|
||||
"""
|
||||
bom_no = (bom_no or '').strip()
|
||||
version = (version or '').strip()
|
||||
@ -558,6 +558,7 @@ class BomService:
|
||||
raise ValueError(f'BOM不存在(bom_no={bom_no}, version={version})')
|
||||
for rec in rows:
|
||||
rec.is_enabled = bool(is_enabled)
|
||||
rec.is_archived = False # 切启/停即退出归档(三者互斥)
|
||||
db.session.commit()
|
||||
_cache_delete(bom_no, version)
|
||||
logger.info(f"[BOM Cache] update_enabled → 缓存已失效 bom_no={bom_no} version={version} enabled={is_enabled}")
|
||||
@ -566,8 +567,9 @@ class BomService:
|
||||
@staticmethod
|
||||
def update_archived(bom_no, version, archived):
|
||||
"""
|
||||
仅更新某 BOM 版本(整组行)的归档标记。
|
||||
归档=仍启用可查看/编辑,但不再作为其它 BOM 子件的引用候选;取消归档即恢复正常引用。
|
||||
三态互斥归档:归档=彻底废弃(is_archived=True 且 is_enabled=False);
|
||||
取消归档=恢复“启用(在用)”(is_archived=False 且 is_enabled=True)。
|
||||
归档后不出现在启用/停用视图、不作为子件引用候选。
|
||||
"""
|
||||
bom_no = (bom_no or '').strip()
|
||||
version = (version or '').strip()
|
||||
@ -576,8 +578,10 @@ class BomService:
|
||||
rows = BomTable.query.filter_by(bom_no=bom_no, version=version).all()
|
||||
if not rows:
|
||||
raise ValueError(f'BOM不存在(bom_no={bom_no}, version={version})')
|
||||
target_archived = bool(archived)
|
||||
for rec in rows:
|
||||
rec.is_archived = bool(archived)
|
||||
rec.is_archived = target_archived
|
||||
rec.is_enabled = not target_archived # 归档→停用;取消归档→启用(互斥)
|
||||
db.session.commit()
|
||||
_cache_delete(bom_no, version)
|
||||
logger.info(f"[BOM Cache] update_archived → 缓存已失效 bom_no={bom_no} version={version} archived={archived}")
|
||||
|
||||
@ -33,6 +33,33 @@ class BorrowApprovalService:
|
||||
|
||||
return f"APR-BOR-{date_str}-{time_str}-{sequence:04d}"
|
||||
|
||||
@staticmethod
|
||||
def _items_require_approval(items):
|
||||
"""
|
||||
明细中任一物料命中“出库/借库需审批”→ 需要审批。
|
||||
申请明细只存 name/spec_model,故按 (name, spec_model) 反查启用物料判定。
|
||||
"""
|
||||
from app.models.base import MaterialBase
|
||||
seen = set()
|
||||
for item in items:
|
||||
name = str(item.get('name') or '').strip()
|
||||
spec = str(item.get('spec_model') or '').strip()
|
||||
if not name:
|
||||
continue
|
||||
key = (name, spec)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
hit = MaterialBase.query.filter(
|
||||
MaterialBase.name == name,
|
||||
MaterialBase.spec_model == spec,
|
||||
MaterialBase.is_enabled == True,
|
||||
MaterialBase.is_approval_required == True
|
||||
).first()
|
||||
if hit:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def submit_approval(applicant_id, items, allowed_approvers, remark=None, approver_id=None,
|
||||
borrower_name=None):
|
||||
@ -61,6 +88,14 @@ class BorrowApprovalService:
|
||||
if not items:
|
||||
raise ValueError("借库物品列表不能为空")
|
||||
|
||||
# ★ 借库申请必填:申请原因;预计归还日期(除非勾选长期借用/不限归还)
|
||||
if not str(remark or '').strip():
|
||||
raise ValueError("借库申请原因不能为空")
|
||||
_has_indefinite = any(bool(item.get('is_indefinite')) for item in items)
|
||||
_has_date = any(str(item.get('expected_return_time') or '').strip() for item in items)
|
||||
if not _has_indefinite and not _has_date:
|
||||
raise ValueError("请填写预计归还日期,或勾选长期借用(不限归还)")
|
||||
|
||||
required_fields = ['name', 'spec_model', 'quantity']
|
||||
for idx, item in enumerate(items):
|
||||
missing_fields = [f for f in required_fields if f not in item or str(item.get(f) or '').strip() == '']
|
||||
@ -76,30 +111,53 @@ class BorrowApprovalService:
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValueError(f"第 {idx + 1} 条物品的 quantity 格式无效: {str(e)}")
|
||||
|
||||
if not allowed_approvers:
|
||||
raise ValueError("必须指定至少一位审批人")
|
||||
# ★ 需审批判定:明细含“出库/借库需审批”物料 → 走原审批流程;否则默认自动通过
|
||||
from app.services.approval_control import resolve_approval_control
|
||||
need_approval, flagged_materials = resolve_approval_control(items)
|
||||
if need_approval and not approver_id:
|
||||
_names = ";".join(f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials)
|
||||
raise ValueError(f"以下物料需审批出库/借库:{_names}。请选择审批人后再提交")
|
||||
|
||||
if approver_id:
|
||||
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
|
||||
elif not need_approval:
|
||||
allowed_approvers = [] # 免审批单不绑定审批人
|
||||
|
||||
request_no = BorrowApprovalService.generate_request_no()
|
||||
|
||||
approval = BorrowApproval(
|
||||
request_no=request_no,
|
||||
applicant_id=applicant_id,
|
||||
remark=remark,
|
||||
borrower_name=borrower_name,
|
||||
status=0, # 待审批
|
||||
)
|
||||
if need_approval:
|
||||
approval = BorrowApproval(
|
||||
request_no=request_no,
|
||||
applicant_id=applicant_id,
|
||||
remark=remark,
|
||||
borrower_name=borrower_name,
|
||||
status=0, # 待审批
|
||||
)
|
||||
else:
|
||||
# 默认不审批:创建即已通过(status=1),直接进入“待库管执行”
|
||||
from datetime import datetime, timezone, timedelta
|
||||
approval = BorrowApproval(
|
||||
request_no=request_no,
|
||||
applicant_id=applicant_id,
|
||||
remark=remark,
|
||||
borrower_name=borrower_name,
|
||||
status=1,
|
||||
actual_approver_id=applicant_id,
|
||||
approved_at=datetime.now(timezone(timedelta(hours=8))),
|
||||
)
|
||||
|
||||
approval.set_items(items)
|
||||
approval.set_allowed_approvers(allowed_approvers)
|
||||
if allowed_approvers:
|
||||
approval.set_allowed_approvers(allowed_approvers)
|
||||
else:
|
||||
approval.allowed_approvers = '[]'
|
||||
|
||||
db.session.add(approval)
|
||||
db.session.commit()
|
||||
|
||||
# ★ 创建成功后,发送邮件通知审批人(静默处理,不阻断主流程)
|
||||
BorrowApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id)
|
||||
# 仅需审批单通知审批人;免审批单静默进入“待库管执行”
|
||||
if need_approval:
|
||||
BorrowApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id)
|
||||
|
||||
return approval
|
||||
|
||||
|
||||
@ -327,7 +327,7 @@ class OutboundService:
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def get_grouped_list(page=1, per_page=10, keyword=None, search_type='all', start_date=None, end_date=None, company=None):
|
||||
def get_grouped_list(page=1, per_page=10, keyword=None, search_type='all', start_date=None, end_date=None, company=None, consumer_name=None):
|
||||
"""
|
||||
查询出库记录(按出库单号分组),包含详细物品信息
|
||||
支持跨表搜索:单号、领用人、SKU、物料名称、规格型号
|
||||
@ -543,6 +543,21 @@ class OutboundService:
|
||||
if company_limit is not None:
|
||||
stmt = stmt.filter(TransOutbound.outbound_no.in_(comp_all))
|
||||
|
||||
# ★ 数据权限:普通用户只看“领用人=本人姓名(不含账号前缀)”的出库记录;
|
||||
# 同时兼容库里存成“姓名/xiaolongxia”全名的记录(姓名 + '/' 前缀也命中)
|
||||
if consumer_name:
|
||||
from sqlalchemy import or_
|
||||
_own_out_nos = (
|
||||
db.session.query(TransOutbound.outbound_no)
|
||||
.filter(or_(
|
||||
TransOutbound.consumer_name == consumer_name,
|
||||
TransOutbound.consumer_name.like(f"{consumer_name}/%")
|
||||
))
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
stmt = stmt.filter(TransOutbound.outbound_no.in_(_own_out_nos))
|
||||
|
||||
stmt = stmt.order_by(desc('max_time'))
|
||||
|
||||
# 使用 distinct 确保跨表查询不重复
|
||||
@ -700,6 +715,33 @@ class OutboundApprovalService:
|
||||
|
||||
return f"APR-OUT-{date_str}-{time_str}-{sequence:04d}"
|
||||
|
||||
@staticmethod
|
||||
def _items_require_approval(items):
|
||||
"""
|
||||
明细中任一物料命中“出库/借库需审批”→ 需要审批。
|
||||
申请明细只存 name/spec_model,故按 (name, spec_model) 反查启用物料判定。
|
||||
"""
|
||||
from app.models.base import MaterialBase
|
||||
seen = set()
|
||||
for item in items:
|
||||
name = str(item.get('name') or '').strip()
|
||||
spec = str(item.get('spec_model') or '').strip()
|
||||
if not name:
|
||||
continue
|
||||
key = (name, spec)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
hit = MaterialBase.query.filter(
|
||||
MaterialBase.name == name,
|
||||
MaterialBase.spec_model == spec,
|
||||
MaterialBase.is_enabled == True,
|
||||
MaterialBase.is_approval_required == True
|
||||
).first()
|
||||
if hit:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def create_request(applicant_id, items, allowed_approvers, remark=None, approver_id=None, outbound_type=None):
|
||||
"""
|
||||
@ -745,33 +787,55 @@ class OutboundApprovalService:
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValueError(f"第 {idx + 1} 条物品的 quantity 格式无效: {str(e)}")
|
||||
|
||||
# ★ 校验 allowed_approvers 非空
|
||||
if not allowed_approvers:
|
||||
raise ValueError("必须指定至少一位审批人")
|
||||
# ★ 需审批判定:明细含“出库/借库需审批”物料 → 走原审批流程;否则默认自动通过
|
||||
from app.services.approval_control import resolve_approval_control
|
||||
need_approval, flagged_materials = resolve_approval_control(items)
|
||||
if need_approval and not approver_id:
|
||||
_names = ";".join(f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials)
|
||||
raise ValueError(f"以下物料需审批出库/借库:{_names}。请选择审批人后再提交")
|
||||
|
||||
# ★ 指定审批人模式:approver_id 覆盖 allowed_approvers
|
||||
if approver_id:
|
||||
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
|
||||
elif not need_approval:
|
||||
allowed_approvers = [] # 免审批单不绑定审批人
|
||||
|
||||
request_no = OutboundApprovalService.generate_request_no()
|
||||
|
||||
approval = OutboundApproval(
|
||||
request_no=request_no,
|
||||
applicant_id=applicant_id,
|
||||
remark=remark,
|
||||
outbound_type=outbound_type, # 申请时确定的出库类型,扫码出库时带出
|
||||
status=0, # 待审批
|
||||
)
|
||||
if need_approval:
|
||||
approval = OutboundApproval(
|
||||
request_no=request_no,
|
||||
applicant_id=applicant_id,
|
||||
remark=remark,
|
||||
outbound_type=outbound_type, # 申请时确定的出库类型,扫码出库时带出
|
||||
status=0, # 待审批
|
||||
)
|
||||
else:
|
||||
# 默认不审批:创建即已通过(status=1),直接进入“待库管执行”
|
||||
from datetime import datetime, timezone, timedelta
|
||||
approval = OutboundApproval(
|
||||
request_no=request_no,
|
||||
applicant_id=applicant_id,
|
||||
remark=remark,
|
||||
outbound_type=outbound_type,
|
||||
status=1,
|
||||
actual_approver_id=applicant_id,
|
||||
approved_at=datetime.now(timezone(timedelta(hours=8))),
|
||||
)
|
||||
|
||||
# 直接存储前端传来的物料信息快照,不查询/不关联具体库存记录
|
||||
approval.set_items(items)
|
||||
approval.set_allowed_approvers(allowed_approvers)
|
||||
if allowed_approvers:
|
||||
approval.set_allowed_approvers(allowed_approvers)
|
||||
else:
|
||||
approval.allowed_approvers = '[]'
|
||||
|
||||
db.session.add(approval)
|
||||
db.session.commit()
|
||||
|
||||
# ★ 创建成功后,发送邮件通知审批人(精确通知 approver_id 对应的邮箱)
|
||||
OutboundApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id)
|
||||
# 仅需审批单通知审批人;免审批单静默进入“待库管执行”
|
||||
if need_approval:
|
||||
OutboundApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id)
|
||||
|
||||
return approval
|
||||
|
||||
|
||||
@ -410,7 +410,7 @@ class TransService:
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all'):
|
||||
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all', borrower_name=None):
|
||||
"""
|
||||
获取借还记录列表(按单号 borrow_no 维度分页,避免明细撑爆 pageSize)
|
||||
|
||||
@ -648,6 +648,22 @@ class TransService:
|
||||
# ====================================================================
|
||||
borrow_no_q = db.session.query(order_subq.c.borrow_no)
|
||||
|
||||
# ★ 数据权限:普通用户只看“借用人=本人姓名(不含账号前缀)”的借还记录;
|
||||
# 兼容库里存成“姓名/xiaolongxia”全名(姓名 + '/' 前缀)的情况
|
||||
if borrower_name:
|
||||
own_borrow_nos_subq = (
|
||||
db.session.query(TransBorrow.borrow_no)
|
||||
.filter(or_(
|
||||
TransBorrow.borrower_name == borrower_name,
|
||||
TransBorrow.borrower_name.like(f"{borrower_name}/%")
|
||||
))
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
borrow_no_q = borrow_no_q.filter(
|
||||
order_subq.c.borrow_no.in_(own_borrow_nos_subq)
|
||||
)
|
||||
|
||||
# 关键词过滤
|
||||
if keyword_borrow_nos_subq is not None:
|
||||
borrow_no_q = borrow_no_q.filter(
|
||||
|
||||
@ -226,6 +226,28 @@ def _has_cross_domain_permission(user_role, user_company=''):
|
||||
return False
|
||||
|
||||
|
||||
# 能查看“他人借还/出库记录”的管理者角色(仓库管理员/主管/超管)。普通申请人(含入库员/出库员等)只能看自己的记录。
|
||||
# 注意:入库员(INBOUND)、出库员(OUTBOUND)不是管理者视角,不应放此名单;如需让他们看全部请确认为库管角色后再加入。
|
||||
PRIVILEGED_VIEWER_ROLES = ('SUPER_ADMIN', 'SUPERVISOR', 'WAREHOUSE_MGR')
|
||||
|
||||
|
||||
def is_privileged_viewer():
|
||||
"""
|
||||
判断当前登录用户是否属于“管理者视角”(可查看他人借还/出库记录)。
|
||||
规则:角色为 SUPER_ADMIN/SUPERVISOR/WAREHOUSE_MGR,或拥有 crossDomain 权限。
|
||||
普通申请人(其余角色)只能看自己的记录(由各列表 handler 强制 applicant_id=当前用户)。
|
||||
"""
|
||||
from flask_jwt_extended import get_jwt
|
||||
claims = get_jwt()
|
||||
role = (claims.get('role') or '').upper()
|
||||
if role in PRIVILEGED_VIEWER_ROLES:
|
||||
return True
|
||||
user_company = claims.get('company_name', '')
|
||||
if _has_cross_domain_permission(role, user_company):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_current_company_filter():
|
||||
"""
|
||||
多租户数据权限隔离工具函数。
|
||||
|
||||
@ -14,6 +14,7 @@ STOCK_FIELD_RBAC_MAPPING = {
|
||||
"category": "material_list:category", "type": "material_list:type",
|
||||
"spec": "material_list:spec", "unit": "material_list:unit",
|
||||
"companyName": "material_list:companyName", "isInspectionRequired": "material_list:isInspectionRequired",
|
||||
"isApprovalRequired": "material_list:isApprovalRequired",
|
||||
"generalImage": "material_list:files", "generalManual": "material_list:files",
|
||||
"productImageRemark": "material_list:productImageRemark",
|
||||
"manualLinkRemark": "material_list:manualLinkRemark",
|
||||
|
||||
@ -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.73
|
||||
当前版本:V3.74
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
|
||||
@ -71,6 +71,15 @@ export function batchSetInspection(data: { ids: number[], isInspectionRequired:
|
||||
})
|
||||
}
|
||||
|
||||
// 6.1 批量设置“出库/借库需审批”
|
||||
export function batchSetApprovalRequired(data: { ids: number[], isApprovalRequired: boolean }) {
|
||||
return request({
|
||||
url: '/inbound/base/batch-approval',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 7. 获取智能分组规格最大连号
|
||||
export function getLatestSpecs() {
|
||||
return request({
|
||||
|
||||
@ -99,6 +99,15 @@ export function submitOutboundRequest(data: {
|
||||
})
|
||||
}
|
||||
|
||||
// 出库申请预检:判断所选物料是否需审批
|
||||
export function checkOutboundApproval(data: { items: Array<{ name?: string; spec_model?: string }> }) {
|
||||
return request({
|
||||
url: '/v1/outbound/request/check-approval',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取出库审批申请单列表
|
||||
* @param params 支持 status, page, limit
|
||||
|
||||
@ -149,6 +149,15 @@ export function submitBorrowRequest(data: {
|
||||
})
|
||||
}
|
||||
|
||||
// 借库申请预检:判断所选物料是否需审批
|
||||
export function checkBorrowApproval(data: { items: Array<{ name?: string; spec_model?: string }> }) {
|
||||
return request({
|
||||
url: '/v1/transactions/borrow/request/check-approval',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取借库审批申请单列表
|
||||
* @param params 支持 status, page, limit
|
||||
|
||||
@ -394,7 +394,10 @@
|
||||
</el-table>
|
||||
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="* 指定审批人" required>
|
||||
<el-form-item v-if="approvalNeedVisible" label="指定审批人">
|
||||
<div v-if="approvalNeedText" style="color:#E6A23C; font-size:12px; line-height:1.4; margin-bottom:4px;">
|
||||
以下物料需审批出库/借库:{{ approvalNeedText }},请选择审批人
|
||||
</div>
|
||||
<el-select
|
||||
v-model="requestApproverId"
|
||||
placeholder="请选择审批人"
|
||||
@ -409,7 +412,7 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="预计归还日期">
|
||||
<el-form-item label="预计归还日期" :required="!requestIndefinite">
|
||||
<el-date-picker
|
||||
v-model="requestExpectedReturn"
|
||||
type="date"
|
||||
@ -423,12 +426,12 @@
|
||||
长期借用
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item label="申请原因">
|
||||
<el-form-item label="申请原因" required>
|
||||
<el-input
|
||||
v-model="requestRemark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请填写借库申请原因(选填)"
|
||||
placeholder="请填写借库申请原因(必填)"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
@ -518,7 +521,7 @@ import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
|
||||
import { printSelectionList } from '@/api/inbound/stock'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { submitBorrowRequest, getBorrowStockList } from '@/api/transaction'
|
||||
import { submitBorrowRequest, getBorrowStockList, checkBorrowApproval } from '@/api/transaction'
|
||||
import { bomMatchStock } from '@/api/outbound'
|
||||
import { getApproversList } from '@/api/auth'
|
||||
import { getBomList, getBomDetail, getBomWithStock } from '@/api/bom'
|
||||
@ -554,6 +557,9 @@ const requestIndefinite = ref(false)
|
||||
const requestApproverId = ref<number | null>(null)
|
||||
const approvers = ref<any[]>([])
|
||||
const requestSubmitting = ref(false)
|
||||
// 是否需审批(由提交前预检驱动,需审批才显示审批人输入框)
|
||||
const approvalNeedVisible = ref(false)
|
||||
const approvalNeedText = ref('')
|
||||
|
||||
const stockList = ref<any[]>([])
|
||||
const stockTotal = ref(0)
|
||||
@ -1052,6 +1058,8 @@ const openRequestDialog = () => {
|
||||
requestApproverId.value = null
|
||||
requestExpectedReturn.value = ''
|
||||
requestIndefinite.value = false
|
||||
approvalNeedVisible.value = false // 打开时默认隐藏审批人,命中需审批时由预检显示
|
||||
approvalNeedText.value = ''
|
||||
loadApprovers()
|
||||
requestDialogVisible.value = true
|
||||
}
|
||||
@ -1094,26 +1102,58 @@ const buildRemarkWithShortage = (baseRemark: string): string => {
|
||||
}
|
||||
|
||||
const confirmSubmitRequest = async () => {
|
||||
// ★ 借库申请必填拦截:申请原因;预计归还日期(除非长期借用/不限归还)
|
||||
const _reasonText = requestRemark.value.trim()
|
||||
if (!_reasonText) {
|
||||
ElMessage.warning('请填写借库申请原因')
|
||||
return
|
||||
}
|
||||
if (!requestIndefinite.value && !requestExpectedReturn.value) {
|
||||
ElMessage.warning('请填写预计归还日期,或勾选“长期借用(不限归还)”')
|
||||
return
|
||||
}
|
||||
|
||||
requestSubmitting.value = true
|
||||
try {
|
||||
const items = validSelectedItems.value.map((item: any) => ({
|
||||
base_id: item.base_id ?? null,
|
||||
name: item.name || '',
|
||||
spec_model: item.standard || '',
|
||||
warehouse_location: item.warehouse_location || '',
|
||||
quantity: item.export_quantity || 0,
|
||||
expected_return_time: requestIndefinite.value ? null : requestExpectedReturn.value,
|
||||
is_indefinite: requestIndefinite.value
|
||||
}))
|
||||
|
||||
// ★ 提交前预检:判断所选物料是否需审批 → 只有需审批才显示并必选审批人
|
||||
let needApproval = false
|
||||
let flagged: any[] = []
|
||||
try {
|
||||
const chk: any = await checkBorrowApproval({ items })
|
||||
needApproval = !!chk?.data?.need_approval
|
||||
flagged = chk?.data?.materials || []
|
||||
} catch (e) { needApproval = false; flagged = [] }
|
||||
approvalNeedVisible.value = needApproval
|
||||
approvalNeedText.value = flagged.map((m: any) => `${m.name}(${m.spec_model || '-'})`).join(';')
|
||||
|
||||
if (needApproval && !requestApproverId.value) {
|
||||
ElMessage.warning(`以下物料需审批出库/借库:${approvalNeedText.value}。请先选择审批人`)
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
items: validSelectedItems.value.map((item: any) => ({
|
||||
name: item.name || '',
|
||||
spec_model: item.standard || '',
|
||||
warehouse_location: item.warehouse_location || '',
|
||||
quantity: item.export_quantity || 0,
|
||||
expected_return_time: requestIndefinite.value ? null : requestExpectedReturn.value,
|
||||
is_indefinite: requestIndefinite.value
|
||||
})),
|
||||
items,
|
||||
remark: buildRemarkWithShortage(requestRemark.value.trim()),
|
||||
approver_id: requestApproverId.value
|
||||
approver_id: needApproval ? requestApproverId.value : null
|
||||
}
|
||||
|
||||
await submitBorrowRequest(payload)
|
||||
|
||||
requestDialogVisible.value = false
|
||||
selectedItems.value = []
|
||||
ElMessage.success('借库申请已提交,等待主管审批!')
|
||||
approvalNeedVisible.value = false
|
||||
approvalNeedText.value = ''
|
||||
ElMessage.success('借库申请已提交(含需审批物料时将进入审批,否则直接交库管执行)')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.message || err?.msg || '提交申请失败,请重试')
|
||||
} finally {
|
||||
|
||||
@ -328,6 +328,15 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.isApprovalRequired.visible && userStore.hasPermission('material_list:isApprovalRequired')" label="出库/借库需审批" min-width="130" align="center">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
:model-value="!!scope.row.isApprovalRequired"
|
||||
:disabled="!userStore.hasPermission('material_list:isApprovalRequired')"
|
||||
@change="(val: boolean) => toggleApprovalRequired(scope.row, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.referencePrice.visible" prop="referencePrice" label="参考价格" min-width="120" align="center" sortable="custom">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.referencePrice != null" class="money-text">{{ scope.row.referencePrice?.toFixed(2) }}</span>
|
||||
@ -729,6 +738,7 @@ import {
|
||||
exportAssetStatistics,
|
||||
batchSetWarning,
|
||||
batchSetInspection,
|
||||
batchSetApprovalRequired,
|
||||
markWarningOrdered,
|
||||
getMaterialUnitsAPI
|
||||
} from '@/api/material_base';
|
||||
@ -958,6 +968,7 @@ const columns = reactive({
|
||||
files: { visible: true },
|
||||
isEnabled: { visible: true },
|
||||
isInspectionRequired: { visible: true },
|
||||
isApprovalRequired: { visible: true },
|
||||
referencePrice: { visible: true },
|
||||
warningStatus: { visible: true }
|
||||
});
|
||||
@ -977,6 +988,7 @@ const permissionMap: Record<string, string> = {
|
||||
files: 'material_list:files',
|
||||
isEnabled: 'material_list:isEnabled',
|
||||
isInspectionRequired: 'material_list:operation',
|
||||
isApprovalRequired: 'material_list:operation',
|
||||
referencePrice: 'material_list:referencePrice',
|
||||
warningStatus: 'material_list:view_warning'
|
||||
};
|
||||
@ -1747,6 +1759,20 @@ const submitBatchInspection = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 单行切换“出库/借库需审批”
|
||||
const toggleApprovalRequired = async (row: any, val: boolean) => {
|
||||
if (!row || !row.id) return;
|
||||
const prev = !!row.isApprovalRequired;
|
||||
row.isApprovalRequired = val; // 乐观更新
|
||||
try {
|
||||
await batchSetApprovalRequired({ ids: [row.id], isApprovalRequired: val });
|
||||
ElMessage.success(val ? '已标记:该物料出库/借库需审批' : '已取消:该物料出库/借库免审批');
|
||||
} catch (error: any) {
|
||||
row.isApprovalRequired = prev;
|
||||
ElMessage.error(error?.msg || '设置失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 表格行样式(根据预警状态)
|
||||
const tableRowClassName = ({ row }: { row: MaterialBaseVO }) => {
|
||||
if (row.warningStatus === 2) {
|
||||
|
||||
@ -409,7 +409,10 @@
|
||||
<el-option label="维修出库" value="REPAIR" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="* 指定审批人" required>
|
||||
<el-form-item v-if="approvalNeedVisible" label="指定审批人">
|
||||
<div v-if="approvalNeedText" style="color:#E6A23C; font-size:12px; line-height:1.4; margin-bottom:4px;">
|
||||
以下物料需审批出库/借库:{{ approvalNeedText }},请选择审批人
|
||||
</div>
|
||||
<el-select
|
||||
v-model="requestApproverId"
|
||||
placeholder="请选择审批人"
|
||||
@ -519,7 +522,7 @@ import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
|
||||
import { getStockList, printSelectionList } from '@/api/inbound/stock'
|
||||
import { getBomList, getBomDetail, getBomWithStock } from '@/api/bom'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { submitOutboundRequest, bomMatchStock } from '@/api/outbound'
|
||||
import { submitOutboundRequest, bomMatchStock, checkOutboundApproval } from '@/api/outbound'
|
||||
import { getApproversList } from '@/api/auth'
|
||||
|
||||
const userStore = useUserStore()
|
||||
@ -551,6 +554,9 @@ const requestOutboundType = ref('') // 申请时必选出库类型,扫码出
|
||||
const requestApproverId = ref<number | null>(null)
|
||||
const approvers = ref<any[]>([])
|
||||
const requestSubmitting = ref(false)
|
||||
// 是否需审批(由提交前预检驱动,需审批才显示审批人输入框)
|
||||
const approvalNeedVisible = ref(false)
|
||||
const approvalNeedText = ref('')
|
||||
|
||||
const stockList = ref<any[]>([]) // 服务端分页数据
|
||||
const stockTotal = ref(0)
|
||||
@ -1079,6 +1085,8 @@ const openRequestDialog = () => {
|
||||
}
|
||||
requestRemark.value = ''
|
||||
requestApproverId.value = null
|
||||
approvalNeedVisible.value = false // 打开时默认隐藏审批人,命中需审批时由预检显示
|
||||
approvalNeedText.value = ''
|
||||
loadApprovers()
|
||||
requestDialogVisible.value = true
|
||||
}
|
||||
@ -1100,10 +1108,6 @@ const confirmSubmitRequest = async () => {
|
||||
ElMessage.warning('请填写申请原因')
|
||||
return
|
||||
}
|
||||
if (!requestApproverId.value) {
|
||||
ElMessage.warning('请选择指定审批人')
|
||||
return
|
||||
}
|
||||
if (!requestOutboundType.value) {
|
||||
ElMessage.warning('请选择出库类型')
|
||||
return
|
||||
@ -1111,17 +1115,36 @@ const confirmSubmitRequest = async () => {
|
||||
|
||||
requestSubmitting.value = true
|
||||
try {
|
||||
const items = validSelectedItems.value.map(item => ({
|
||||
material_type: item.typeLabel || item.type || '',
|
||||
base_id: item.base_id ?? null,
|
||||
name: item.name || '',
|
||||
spec_model: item.standard || '',
|
||||
warehouse_location: item.warehouse_location || '',
|
||||
quantity: item.export_quantity || 0
|
||||
}))
|
||||
|
||||
// ★ 提交前预检:判断所选物料是否需审批 → 只有需审批才显示并必选审批人
|
||||
let needApproval = false
|
||||
let flagged: any[] = []
|
||||
try {
|
||||
const chk: any = await checkOutboundApproval({ items })
|
||||
needApproval = !!chk?.data?.need_approval
|
||||
flagged = chk?.data?.materials || []
|
||||
} catch (e) { needApproval = false; flagged = [] }
|
||||
approvalNeedVisible.value = needApproval
|
||||
approvalNeedText.value = flagged.map((m: any) => `${m.name}(${m.spec_model || '-'})`).join(';')
|
||||
|
||||
if (needApproval && !requestApproverId.value) {
|
||||
ElMessage.warning(`以下物料需审批出库/借库:${approvalNeedText.value}。请先选择审批人`)
|
||||
return
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
items: validSelectedItems.value.map(item => ({
|
||||
material_type: item.typeLabel || item.type || '',
|
||||
name: item.name || '',
|
||||
spec_model: item.standard || '',
|
||||
warehouse_location: item.warehouse_location || '',
|
||||
quantity: item.export_quantity || 0
|
||||
})),
|
||||
items,
|
||||
remark: buildRemarkWithShortage(trimmed),
|
||||
outbound_type: requestOutboundType.value,
|
||||
approver_id: requestApproverId.value
|
||||
approver_id: needApproval ? requestApproverId.value : null
|
||||
}
|
||||
|
||||
await submitOutboundRequest(payload)
|
||||
@ -1129,7 +1152,9 @@ const confirmSubmitRequest = async () => {
|
||||
// 成功:关闭弹窗、清空列表、提示
|
||||
requestDialogVisible.value = false
|
||||
selectedItems.value = []
|
||||
ElMessage.success('出库申请已提交,等待主管审批!')
|
||||
approvalNeedVisible.value = false
|
||||
approvalNeedText.value = ''
|
||||
ElMessage.success('出库申请已提交(含需审批物料时将进入审批,否则直接交库管执行)')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.message || err?.msg || '提交申请失败,请重试')
|
||||
} finally {
|
||||
|
||||
@ -1933,25 +1933,24 @@ const confirmPurchaseImport = () => {
|
||||
const qty = Number(po.quantity) || 1
|
||||
form.in_quantity = qty
|
||||
|
||||
// 价格字段:优先用含税总价反算,否则用含税单价
|
||||
if (hasFormFieldPermission('unit_price')) {
|
||||
const hasUnitPrice = po.unit_price !== null && po.unit_price !== undefined && Number(po.unit_price) > 0
|
||||
const hasTotalPrice = po.total_price !== null && po.total_price !== undefined && Number(po.total_price) > 0
|
||||
// 价格字段:无论前端是否对当前角色显示价格,导入都应把采购单价格带入(隐藏字段,用于成本)。
|
||||
// 库管在前端看不到价格输入/列,由字段权限控制展示;这里只做数据带入,不因“不能看价格”而跳过。
|
||||
const hasUnitPrice = po.unit_price !== null && po.unit_price !== undefined && Number(po.unit_price) > 0
|
||||
const hasTotalPrice = po.total_price !== null && po.total_price !== undefined && Number(po.total_price) > 0
|
||||
|
||||
// 税率带过来
|
||||
if (po.tax_rate != null) {
|
||||
form.tax_rate = Number(po.tax_rate)
|
||||
}
|
||||
// 税率带过来
|
||||
if (po.tax_rate != null) {
|
||||
form.tax_rate = Number(po.tax_rate)
|
||||
}
|
||||
|
||||
if (hasTotalPrice) {
|
||||
// 优先: 含税总价 → 调用 post_total 逻辑反算单价
|
||||
postTaxTotalInput.value = Number(po.total_price)
|
||||
onPostTaxTotalChange(postTaxTotalInput.value)
|
||||
} else if (hasUnitPrice) {
|
||||
// 备选: 含税单价 → 反算不含税
|
||||
form.post_tax_unit_price = Number(po.unit_price)
|
||||
updatePrices('post')
|
||||
}
|
||||
if (hasTotalPrice) {
|
||||
// 优先: 含税总价 → 调用 post_total 逻辑反算单价
|
||||
postTaxTotalInput.value = Number(po.total_price)
|
||||
onPostTaxTotalChange(postTaxTotalInput.value)
|
||||
} else if (hasUnitPrice) {
|
||||
// 备选: 含税单价 → 反算不含税
|
||||
form.post_tax_unit_price = Number(po.unit_price)
|
||||
updatePrices('post')
|
||||
}
|
||||
if (po.supplier_link) {
|
||||
form.detail_link = po.supplier_link
|
||||
|
||||
@ -435,6 +435,8 @@ const handleApprovalChange = (val: number | null) => {
|
||||
isIndefinite.value = false
|
||||
form.expected_return_time = ''
|
||||
}
|
||||
// ★ 自动关联:把该借库申请单的“申请原因(remark)”自动带入执行备注,库管无需重复填写(可手动改)
|
||||
form.remark = (req && req.remark) || ''
|
||||
cartItems.value = []
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
|
||||
Reference in New Issue
Block a user