84 Commits

Author SHA1 Message Date
dxc
259f3a7e0d 4.29扫码获取库位小工具接口 2026-04-29 15:40:43 +08:00
dxc
00839863f5 4.29出库审批流程完善,通知申请人以及库管邮件功能 2026-04-29 09:11:37 +08:00
DXC
8276597a67 fix(email): 审批通知逻辑重构 - 通过时同时通知库管和申请人,驳回仅通知申请人;精简 DEBUG 日志 2026-04-29 09:10:34 +08:00
dxc
6ef425b9e4 4.28出库审批 2026-04-28 16:55:18 +08:00
DXC
ccbce82c2e fix(email): 审批通过后库管通知增加明细+DEBUG日志,修复MAIL_DEFAULT_SENDER格式问题 2026-04-28 16:46:12 +08:00
dxc
183b93012e 4.28 2026-04-28 16:07:11 +08:00
DXC
62c0e3738e fix(outbound+trans): 修复POST接口错误数据清洗导致的sku/quantity字段被清除Bug,并新增出库审批工作流全链路 2026-04-28 16:02:34 +08:00
DXC
97e7618bf3 feat(bom+inbound): BOM子件跳转规格修复 + 成品/半成品按钮迁移到标题行 2026-04-28 10:10:45 +08:00
DXC
e08eaff40a feat(outbound): 库存列表按规格+库位聚合 + BOM明细类型修复 2026-04-28 09:23:59 +08:00
dxc
40e405becd 4.27 2026-04-27 16:33:54 +08:00
DXC
d6ae9499db feat: 新增首页全局搜索功能,支持跨模块多词搜索 2026-04-27 15:57:26 +08:00
DXC
ec71cb24f4 feat: 新增物料/成品/半成品页面一键直达BOM管理功能 2026-04-27 15:24:07 +08:00
DXC
9fa471f68a fix: 修复物料列表跳转联动与弹窗定位逻辑 2026-04-27 14:41:26 +08:00
DXC
b002c50d81 fix(material): 修复rows.find数据结构兼容问题 2026-04-24 15:03:01 +08:00
DXC
c0175e13fe fix(material): 增强edit_id自动弹窗的调试日志和容错逻辑 2026-04-24 14:52:45 +08:00
DXC
fa0af40ec7 feat(material): 物料列表页支持URL参数edit_id自动弹出编辑框 2026-04-24 14:47:59 +08:00
DXC
1499d2d45c feat(buy): 入库编辑弹窗增加'前往修改基础信息'跳转按钮 2026-04-24 14:45:43 +08:00
DXC
605462cc33 fix: 解除库存盘点弹窗500条限制并修复字段匹配 2026-04-24 13:40:08 +08:00
DXC
48f2011a38 fix: 盘点草稿已盘数量统计兼容字段名 quantity 2026-04-24 13:32:46 +08:00
DXC
996056d46a fix: 修复库存盘点已盘数量卡在500的问题 2026-04-24 13:19:57 +08:00
DXC
f0c200a15f fix: delete_bom use .all() instead of .first() to delete all child records 2026-04-24 11:42:42 +08:00
DXC
3c9cb06dbc fix: save_bom add db.session.flush() to fix unique constraint conflict 2026-04-23 13:34:41 +08:00
dxc
d9c95084ad 4.23基础西悉尼图像补足 2026-04-23 12:48:34 +08:00
DXC
1e17547c6e feat: 文件展示与提交逻辑优化 2026-04-23 10:25:28 +08:00
dxc
03518c99f3 库存盘点添加扫码保留数值的功能 2026-04-22 11:59:50 +08:00
DXC
1205d9c7e8 feat(audit): 优化审计日志的人性化展示 2026-04-22 10:44:13 +08:00
DXC
4b794b9bcc feat(audit): 添加全局无侵入审计日志拦截器 2026-04-22 10:41:15 +08:00
dxc
ab353e5b34 库存盘点添加扫码保留数值的功能 2026-04-22 10:05:25 +08:00
dxc
5334be0cfa 基础信息修改针对于类别删除4层限制 2026-04-21 16:33:55 +08:00
DXC
2006b7275f fix(outbound): 终极护航版confirmPrint,iframe克隆样式+强制分页CSS 2026-04-21 14:39:48 +08:00
DXC
dd54e047dd fix(outbound): 重构confirmPrint为iframe打印模式,彻底粉碎全局CSS高度锁死 2026-04-21 14:35:34 +08:00
DXC
f53b16f512 fix(outbound): 重构@media print,解除el-container/el-main/el-scrollbar父级裁剪锁定 2026-04-21 14:26:11 +08:00
DXC
8583b811e1 fix(outbound): 将@media print移至非scoped样式,解除body/html高度锁死实现自由分页 2026-04-21 14:01:20 +08:00
DXC
6f5a7cf0db fix(outbound): #print-area改为absolute解除高度锁死,新增行防断裂保护 2026-04-21 13:55:26 +08:00
DXC
01ce9c1432 fix(outbound): 修复预览弹窗el-table打印分页截断问题 2026-04-21 13:25:15 +08:00
DXC
0ab7050e03 feat(outbound): 批量模式下支持点击整行切换选中状态 2026-04-21 13:20:13 +08:00
DXC
cd714d0c16 feat(outbound): 优化批量操作UX,改为进入/退出批量模式设计 2026-04-21 13:14:50 +08:00
DXC
a6409ac091 feat(outbound): 添加批量移除和一键清空功能 2026-04-21 13:11:29 +08:00
DXC
eba558c9d9 feat(outbound): 优化按 BOM 添加逻辑,支持缺件时按现有库存部分出库 2026-04-21 13:06:03 +08:00
DXC
a5a35777b5 fix(inventory): 修复 BOM 齐套分析时 allStockData 未加载导致可用库存为 0 2026-04-20 18:44:35 +08:00
DXC
466e94c4dd fix(inventory): 修复 BOM 编辑回显显示 ID 及出库齐套分析库存为 0 的问题 2026-04-20 18:30:51 +08:00
DXC
59a6a10803 fix(audit): 完善前端审计日志详情展示,支持同时渲染changes/deleted_snapshot/created结构 2026-04-20 16:11:14 +08:00
DXC
f8f5b05d7d refactor(audit): 废弃装饰器+分离架构,改为监听器单体直写入库 2026-04-20 16:04:01 +08:00
DXC
a849e14b2c refactor(orm): 将所有的批量 delete/update 重构为对象级操作,以确保触发 SQLAlchemy 审计事件 2026-04-20 15:43:48 +08:00
DXC
7e72c12f30 fix(audit): 修复 decorators.py 中缺失 has_request_context 导入导致的致命 NameError 2026-04-20 15:10:12 +08:00
DXC
decb7f5e1f debug(audit): 添加X光调试-追踪断点 2026-04-20 15:01:20 +08:00
DXC
1c8def7e6f refactor(audit): 分离架构-监听器计算装饰器入库 2026-04-20 14:41:40 +08:00
DXC
9a0982e76d feat(audit-ui): 重新应用前端审计详情弹窗渲染逻辑,支持高级对比结构 2026-04-20 13:20:23 +08:00
DXC
381d1fa675 feat(audit): 平滑升级-监听器+装饰器共存,装饰器自动检测并跳过已处理日志 2026-04-20 13:15:25 +08:00
dxc
becd3cb010 审计日志修改完善 2026-04-20 11:39:28 +08:00
DXC
7d683f3e65 fix: implement strict regex validation (no pure numbers, no special chars) on both frontend user creation form and backend auth service 2026-04-17 13:33:51 +08:00
DXC
772f3f45f4 feat(profile): implement independent email update dialog to prevent accidental password resets during partial updates 2026-04-17 12:48:30 +08:00
DXC
d651d19e86 feat(system): implement robust batch user creation integrating existing pinyin logic and backend duplication prevention 2026-04-17 12:16:45 +08:00
DXC
8291a89898 feat(backend): apply global cross-company data isolation logic across all inbound, outbound, and stock services 2026-04-17 09:57:00 +08:00
DXC
6c0e13e52d fix(frontend): reclassify global cross-company privilege as an operation to fix UI placement, and strictly enforce permission-driven table columns by bypassing localstorage 2026-04-17 09:36:23 +08:00
DXC
bd93a3d70b feat: implement clean global cross-organization permission node and backend service isolation logic 2026-04-17 08:59:55 +08:00
DXC
477da7c434 fix-security-correct-field-permission-mapping-and-403-denial 2026-04-14 15:37:39 +08:00
DXC
ae05f3bb75 fix-security-field-permission-matching 2026-04-14 15:09:15 +08:00
DXC
ae1fd1afd4 fix-security-permission-codes 2026-04-14 14:49:53 +08:00
DXC
db077a6033 fix(security): resolve incorrect field-level desensitization causing null values for authorized columns 2026-04-14 09:28:46 +08:00
DXC
c91f8ec693 fix(auth): prevent AttributeError when querying permissions for users with no role 2026-04-14 08:56:47 +08:00
DXC
0e8ddd0851 feat(security): implement strict row-level data isolation based on user company 2026-04-14 08:38:50 +08:00
DXC
81bfb29b50 fix(spec-helper): correct data assignment from api response for unwrapped response 2026-04-13 09:29:28 +08:00
DXC
f7a789a196 feat(spec-helper): align frontend interface with new sequence data and display item counts 2026-04-13 09:22:48 +08:00
DXC
6aa2142f01 refactor(material): implement contiguous sequence grouping for specs with count-based descending sort 2026-04-13 09:14:59 +08:00
DXC
4728f91cc7 chore(vite): configure dev server and exact HMR client port for Docker environment 2026-04-13 09:03:33 +08:00
DXC
14eedaa57a fix(spec-helper): fix typescript syntax error in specData ref definition 2026-04-13 08:31:57 +08:00
DXC
c7ac092be4 feat(material): add global floating helper to track latest specification codes with smart grouping 2026-04-13 08:28:27 +08:00
DXC
e23e8c6a9e fix(scrap): resolve material names, specs and operator names in list query 2026-04-09 17:49:51 +08:00
DXC
454f9b1184 feat(scrap): integrate repair items into physical scrap scanning flow and lock manual status 2026-04-09 09:53:20 +08:00
DXC
d3a143288b fix(repair): enforce mandatory frontend form validations with proper prop bindings 2026-04-09 09:28:54 +08:00
DXC
f4d14f718d refactor(repair): strictly gate 'Already Outbound' status to system-only trigger 2026-04-09 09:12:42 +08:00
DXC
48efbed46b feat(repair): add quick status filters, mandatory validations, FIFO sorting, and hide actions on finished orders 2026-04-09 09:04:05 +08:00
DXC
0a9c8cd39c fix(repair): add edit action, mandatory validations, default date, and fix outbound SN mapping 2026-04-09 08:49:50 +08:00
DXC
09936cb045 fix(outbound): integrate TransRepair into global barcode scanning and outbound checkout flow 2026-04-09 08:38:48 +08:00
DXC
3085d9f447 feat(repair): decouple material base, sync global sku sequence and add scan/print features 2026-04-08 19:36:14 +08:00
DXC
cf7dc04db7 feat(route): register repair management page in frontend router 2026-04-08 19:08:43 +08:00
DXC
7f2b9bc7ce feat(repair): implement frontend API and Vue pages for repair management 2026-04-08 18:58:53 +08:00
DXC
41b5118ecd feat(repair): implement backend CRUD services and API routes with RBAC permissions for repair module 2026-04-08 18:34:48 +08:00
DXC
ec468b266d refactor(repair): upgrade TransRepair model with base_id, status, and SN for independent operation 2026-04-08 18:23:22 +08:00
dxc
4eb6bd792b 4.8历史库位进行查询,库位创建批量删除以及排序修改 2026-04-08 17:50:55 +08:00
DXC
4df471add2 fix(inbound): correct date attributes for StockProduct and StockSemi in history location query 2026-04-08 17:45:29 +08:00
DXC
c72e6e198e fix(inbound): remove redundant /api prefix in history location requests 2026-04-08 17:38:19 +08:00
DXC
4a4baa2f8f fix: sort warehouse tree by name, fix tree batch delete cascade, and implement safe history location autofill 2026-04-08 17:32:00 +08:00
81 changed files with 7629 additions and 868 deletions

View File

@ -2,7 +2,10 @@
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit *)"
"Bash(git commit *)",
"Bash(git *)",
"Bash(del *)",
"Bash(rm *)"
]
},
"$version": 3

Binary file not shown.

Binary file not shown.

0
deploy_full.sh Normal file → Executable file
View File

Binary file not shown.

45
deploy_patch.sh Normal file
View File

@ -0,0 +1,45 @@
#!/bin/bash
# === 配置项 ===
SERVER="dxc@172.16.0.198"
REMOTE_DIR="/opt/inventory-app"
TIMESTAMP=$(date +%Y%m%d_%H%M)
# 核心魔法:只定义你要发布的具体文件列表!
FILES_TO_DEPLOY=(
"inventory-backend/app/api/v1/inbound/base.py"
"inventory-backend/app/services/inbound/base_service.py"
"inventory-web/src/api/material_base.ts"
"inventory-web/src/components/SpecHelper/index.vue"
"inventory-web/src/layout/index.vue"
)
echo "==================================================="
echo "🚀 开始【局部补丁】部署 (仅覆盖特定的 ${#FILES_TO_DEPLOY[@]} 个文件)"
echo "==================================================="
# 1. 本地精准打包
echo "[1/3] 正在提取指定文件并打包..."
# tar 打包时会自动保留文件的原有目录结构
tar -czf patch.tar.gz "${FILES_TO_DEPLOY[@]}"
if [ $? -ne 0 ]; then echo "❌ 打包失败,请检查文件列表中的路径是否正确!"; exit 1; fi
# 2. 传输到生产环境的 /tmp 目录
echo "[2/3] 正在传输补丁包到服务器..."
scp patch.tar.gz $SERVER:/tmp/patch.tar.gz
# 3. 服务器执行覆盖与重启
echo "[3/3] 正在服务器上覆盖指定文件并热更新 (可能需要输入密码)..."
# 注意:这里直接在 $REMOTE_DIR 解压tar 会按照原路径精准覆盖那 5 个文件,绝对不碰别的!
ssh -t $SERVER "cd $REMOTE_DIR && \
sudo tar -xzf /tmp/patch.tar.gz && \
sudo docker compose -f docker-compose.prod.yml build backend frontend && \
sudo docker compose -f docker-compose.prod.yml up -d backend frontend && \
sudo rm /tmp/patch.tar.gz"
# 清理本地临时压缩包
rm patch.tar.gz
echo "==================================================="
echo "✅ 局部部署完成!请刷新服务器网页查看最新规格连号助手。"
echo "==================================================="

View File

@ -1,7 +1,6 @@
version: '3.8'
services:
# --- 数据库服务 ---
db:
image: postgres:15-alpine
container_name: inventory_db
@ -11,42 +10,35 @@ services:
POSTGRES_PASSWORD: 1234
POSTGRES_DB: inventory_system
volumes:
# 数据持久化
- ./pgdata_docker:/var/lib/postgresql/data
ports:
- "5434:5432"
- "5435:5432"
# --- 后端 Flask 服务 ---
backend:
build:
context: ./inventory-backend # 指向你的新后端目录
context: ./inventory-backend
container_name: inventory_api
restart: always
ports:
- "8000:8000"
volumes:
- ./inventory-backend:/app # 挂载代码,实现热更新
# 【核心修改】显式挂载 uploads 目录,确保图片持久化且宿主机可见
- ./inventory-backend:/app
- ./inventory-backend/uploads:/app/uploads
command: gunicorn -c gunicorn.conf.py run:app --reload
environment:
# Host 必须写 'db'
DATABASE_URL: postgresql://test:1234@db:5432/inventory_system
depends_on:
- db
# --- 前端 Vue 开发服务 ---
frontend:
build:
context: ./inventory-web
container_name: inventory_ui
restart: always
# 把本地代码挂载进去,实现“热更新”
volumes:
- ./inventory-web:/app
- /app/node_modules # 排除 node_modules防止冲突
# 开发模式端口通常是 5173
- /app/node_modules
ports:
- "5173:5173"
- "5175:5173"
depends_on:
- backend
- backend

View File

@ -3,6 +3,7 @@
from flask import Flask
from config import Config
from app.extensions import db, migrate, cors, jwt
from app.api.v1.scan import scan_bp
import os
@ -20,6 +21,17 @@ def create_app():
# 允许所有 /api/ 开头的请求跨域,支持 credentials
cors.init_app(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True)
# =========================================================
# 1.1 注册全局审计日志监听器
# =========================================================
with app.app_context():
try:
from app.utils.audit_events import register_audit_events
register_audit_events(db)
print("✅ 审计事件监听器注册成功")
except Exception as e:
print(f"⚠️ 审计事件监听器注册失败: {e}")
# =========================================================
# 2. 注册蓝图 (Blueprints)
# ---------------------------------------------------------
@ -189,6 +201,28 @@ def create_app():
except ImportError as e:
print(f"❌ 错误: Warehouse 模块导入失败: {e}")
# -----------------------------------------------------
# 2.12 注册通用聚合搜索模块 (Common - Global Search)
# -----------------------------------------------------
try:
from app.api.v1.common import common_bp
# 标准: /api/v1/common/global-search
app.register_blueprint(common_bp, url_prefix='/api/v1/common')
# 兼容: /api/common/global-search
app.register_blueprint(common_bp, url_prefix='/api/common', name='common_legacy')
print("✅ Common 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Common 模块导入失败: {e}")
# -----------------------------------------------------
# 2.13 注册扫码查库存模块 (Scan)
# -----------------------------------------------------
try:
app.register_blueprint(scan_bp, url_prefix='/api/v1/scan')
print("✅ Scan 模块注册成功")
except Exception as e:
print(f"❌ 错误: Scan 模块注册失败: {e}")
# =========================================================
# 3. 预加载数据模型
# =========================================================
@ -216,4 +250,4 @@ def create_app():
except Exception as e:
print(f"⚠️ 模型预加载发生未知错误: {e}")
return app
return app

View File

@ -1,7 +1,11 @@
from flask import Blueprint
from .inbound import inbound_bp
from .bom import bom_bp
from .common import common_bp
from .scan import scan_bp
v1_bp = Blueprint('v1', __name__)
v1_bp.register_blueprint(inbound_bp, url_prefix='/inbound')
v1_bp.register_blueprint(bom_bp, url_prefix='/bom')
v1_bp.register_blueprint(common_bp, url_prefix='/common')
v1_bp.register_blueprint(scan_bp, url_prefix='/scan')

View File

@ -174,6 +174,49 @@ def create_user():
return jsonify({'msg': str(e)}), 400
# ==============================================================================
# 批量创建用户
# ==============================================================================
@auth_bp.route('/user/batch', methods=['POST'])
@jwt_required()
def batch_create_user():
try:
data_list = request.get_json()
if not data_list or not isinstance(data_list, list):
return jsonify({'msg': '请求数据必须是用户数组'}), 400
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
for data in data_list:
if 'system_user:*' not in user_permissions:
field_to_perm = {
'cn_name': 'system_user:username',
'username': 'system_user:username',
'password': 'system_user:password',
'department': 'system_user:department',
'role': 'system_user:role',
'email': 'system_user:email',
}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if field == 'password':
if 'system_user:operation' not in user_permissions:
data.pop(field, None)
continue
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
claims = get_jwt()
operator_role = claims.get('role')
results = AuthService.batch_create_users(data_list, operator_role)
return jsonify({'msg': '批量处理完成', 'data': results}), 200
except Exception as e:
current_app.logger.error(f"Batch User Create Failed: {str(e)}")
return jsonify({'msg': str(e)}), 500
# ==============================================================================
# 更新用户(管理员)
# ==============================================================================
@ -275,6 +318,41 @@ def get_my_permissions():
return jsonify({'msg': f'获取权限失败: {str(e)}'}), 500
# ==============================================================================
# 获取可指定审批人列表SUPERVISOR / SUPER_ADMIN 且 status=active
# ==============================================================================
@auth_bp.route('/users/approvers', methods=['GET'])
@jwt_required()
def get_approvers():
"""
查询角色为 SUPER_ADMIN 或 SUPERVISOR 且状态为活跃的用户列表
返回: [{id, username, email, role}]
"""
try:
from app.models.system import SysUser
users = SysUser.query.filter(
SysUser.role.in_(['SUPER_ADMIN', 'SUPERVISOR']),
SysUser.status == 'active'
).all()
return jsonify({
'msg': '获取成功',
'data': [
{
'id': u.id,
'username': u.username,
'email': u.email or '',
'role': u.role
} for u in users
]
}), 200
except Exception as e:
current_app.logger.error(f"Get Approvers Failed: {str(e)}")
return jsonify({'msg': f'获取审批人列表失败: {str(e)}'}), 500
# ==============================================================================
# 获取当前用户个人资料(自我查看)
# ==============================================================================
@ -371,3 +449,55 @@ def change_my_password():
except Exception as e:
current_app.logger.error(f"Change Password Failed: {str(e)}")
return jsonify({'msg': f'密码修改失败: {str(e)}'}), 500
# ==============================================================================
# 自我更新邮箱
# ==============================================================================
@auth_bp.route('/me/email', methods=['PUT'])
@jwt_required()
def update_my_email():
"""
自我更新邮箱接口
- 仅更新 email 字段,与密码修改完全隔离
- 防止后端意外清空用户密码
"""
try:
from app.models.system import SysUser
user_id = get_jwt_identity()
# 超级管理员user_id=0不允许修改邮箱
if user_id == 0:
return jsonify({'msg': '超级管理员邮箱由系统管理员管理'}), 400
data = request.get_json()
if not data:
return jsonify({'msg': '无效的请求数据'}), 400
email = data.get('email')
if not email:
return jsonify({'msg': '邮箱不能为空'}), 400
# 简单的邮箱格式校验
import re
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
return jsonify({'msg': '邮箱格式不正确'}), 400
user = SysUser.query.get(user_id)
if not user:
return jsonify({'msg': '用户不存在'}), 404
# 检查邮箱是否已被其他用户使用
existing = SysUser.query.filter(SysUser.email == email, SysUser.id != user_id).first()
if existing:
return jsonify({'msg': '该邮箱已被其他用户使用'}), 400
user.email = email
db.session.commit()
return jsonify({'msg': '邮箱更新成功'}), 200
except Exception as e:
current_app.logger.error(f"Update Email Failed: {str(e)}")
return jsonify({'msg': f'邮箱更新失败: {str(e)}'}), 500

View File

@ -214,12 +214,16 @@ def delete_bom(bom_no):
if version:
query = query.filter_by(version=version)
exist = query.first()
if not exist:
# 【核心修复】:使用 .all() 查出该 BOM 版本下的所有子件记录
records = query.all()
if not records:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 删除
query.delete()
# 循环删除所有关联记录(逐个 delete 可触发 SQLAlchemy 监听器记录审计日志)
for rec in records:
db.session.delete(rec)
db.session.commit()
return jsonify({
'code': 200,

View File

@ -0,0 +1,7 @@
# inventory-backend/app/api/v1/common/__init__.py
from flask import Blueprint
common_bp = Blueprint('common', __name__)
# 导入子模块,使其路由装饰器注册到 common_bp
from . import search

View File

@ -0,0 +1,99 @@
# inventory-backend/app/api/v1/common/search.py
from flask import jsonify, request
from . import common_bp
from app.models import MaterialBase
from app.models.inbound.buy import StockBuy
from app.models.bom import BomTable
from app.extensions import db
@common_bp.route('/global-search', methods=['GET'])
def global_search():
"""
全局聚合搜索接口(多词 AND 模式,无数量限制)
入参: keyword (字符串,支持空格分词,多词必须同时匹配)
搜索范围: 基础物料、采购库、BOM配方
"""
keyword = request.args.get('keyword', request.args.get('q', '')).strip()
keywords = keyword.split()
if not keywords:
return jsonify({"code": 200, "data": []})
merged_list = []
# ── 1. 基础物料 (MaterialBase) ──────────────────────────
# 真实字段: name, common_name, spec_model, category
material_conditions = []
for kw in keywords:
kw_term = f'%{kw}%'
material_conditions.append(
db.or_(
MaterialBase.name.ilike(kw_term),
MaterialBase.common_name.ilike(kw_term),
MaterialBase.spec_model.ilike(kw_term),
MaterialBase.category.ilike(kw_term)
)
)
bases = MaterialBase.query.filter(db.and_(*material_conditions)).all()
for b in bases:
merged_list.append({
"id": b.id,
"type": "material",
"title": b.name,
"subtitle": b.spec_model or b.common_name or '无规格型号',
"badge": "基础物料",
"extra": {"category": b.category or ''}
})
# ── 2. 采购库 (StockBuy) ─────────────────────────────────
# 真实字段: barcode, sku (通过 join 搜索关联的 MaterialBase.name)
stock_conditions = []
for kw in keywords:
kw_term = f'%{kw}%'
stock_conditions.append(
db.or_(
MaterialBase.name.ilike(kw_term),
StockBuy.barcode.ilike(kw_term),
StockBuy.sku.ilike(kw_term)
)
)
stocks = StockBuy.query.join(MaterialBase, StockBuy.base_id == MaterialBase.id).filter(
db.and_(*stock_conditions)
).all()
for s in stocks:
merged_list.append({
"id": s.base_id,
"stock_id": s.id,
"type": "stock_buy",
"title": s.base.name if s.base else '未知物料',
"subtitle": f"条码: {s.barcode or ''} | 库存: {s.stock_quantity}",
"badge": "采购库",
"extra": {"barcode": s.barcode or '', "status": s.status or ''}
})
# ── 3. BOM 配方 (BomTable) ──────────────────────────────
# 真实字段: bom_no, version
bom_conditions = []
for kw in keywords:
kw_term = f'%{kw}%'
bom_conditions.append(
db.or_(
BomTable.bom_no.ilike(kw_term),
BomTable.version.ilike(kw_term)
)
)
boms = BomTable.query.filter(db.and_(*bom_conditions)).all()
for bom in boms:
parent_name = bom.parent.name if bom.parent else ''
merged_list.append({
"id": bom.id,
"bom_no": bom.bom_no,
"type": "bom",
"title": f"{bom.bom_no} ({bom.version})",
"subtitle": f"父件: {parent_name}" if parent_name else f"版本: {bom.version}",
"badge": "配方BOM",
"extra": {"version": bom.version, "parent_id": bom.parent_id}
})
return jsonify({"code": 200, "data": merged_list})

View File

@ -10,6 +10,7 @@ from .base import inbound_base_bp
from .product import inbound_product_bp
from .inbound_summary import bp as inbound_summary_bp
from .stock import bp as inbound_stock_bp
from .repair import inbound_repair_bp
# 导入 service 模块,使其路由装饰器可以正常注册到 inbound_bp 上
from . import service
@ -21,5 +22,6 @@ inbound_bp.register_blueprint(inbound_base_bp, url_prefix='/base')
inbound_bp.register_blueprint(inbound_product_bp, url_prefix='/product')
inbound_bp.register_blueprint(inbound_summary_bp, url_prefix='/summary')
inbound_bp.register_blueprint(inbound_stock_bp, url_prefix='/stock')
inbound_bp.register_blueprint(inbound_repair_bp, url_prefix='/repair')
# service 模块的路由已经直接附加到 inbound_bp无需再注册子蓝图

View File

@ -381,6 +381,8 @@ def batch_set_warning():
red_val = item.get('redThreshold')
warning.yellow_threshold = float(yellow_val) if yellow_val is not None else 0
warning.red_threshold = float(red_val) if red_val is not None else 0
warning.yellow_emails = item.get('yellowEmails', warning.yellow_emails)
warning.red_emails = item.get('redEmails', warning.red_emails)
updated_count += 1
else:
# 创建新记录
@ -390,7 +392,9 @@ def batch_set_warning():
base_id=base_id,
is_enabled=item.get('isEnabled', False),
yellow_threshold=float(yellow_val) if yellow_val is not None else 0,
red_threshold=float(red_val) if red_val is not None else 0
red_threshold=float(red_val) if red_val is not None else 0,
yellow_emails=item.get('yellowEmails', ''),
red_emails=item.get('redEmails', '')
)
db.session.add(warning)
created_count += 1
@ -412,7 +416,48 @@ def batch_set_warning():
# ==============================================================================
# 2.6 批量设置强制质检 API (POST /api/v1/inbound/base/batch-inspection)
# 2.6 标记已采购 API (POST /api/v1/inbound/base/warning/mark-ordered)
# ==============================================================================
@inbound_base_bp.route('/warning/mark-ordered', methods=['POST'])
@permission_required('material_list:edit_warning')
def mark_warning_ordered():
"""
前端标记预警物料已处理采购(标记 is_ordered
请求体格式: {"baseId": 123, "isOrdered": true}
"""
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data provided"}), 400
base_id = data.get('baseId')
if not base_id:
return jsonify({"code": 400, "msg": "baseId 不能为空"}), 400
is_ordered = bool(data.get('isOrdered', False))
warning = MaterialWarningSetting.query.filter_by(base_id=base_id).first()
if not warning:
return jsonify({"code": 404, "msg": f"物料ID {base_id} 的预警配置不存在"}), 404
warning.is_ordered = is_ordered
db.session.commit()
status_text = "已标记为已采购" if is_ordered else "已重置为未采购"
return jsonify({
"code": 200,
"msg": status_text,
"data": warning.to_dict()
})
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 (POST /api/v1/inbound/base/batch-inspection)
# ==============================================================================
@inbound_base_bp.route('/batch-inspection', methods=['POST'])
@permission_required('material_list:operation')
@ -455,3 +500,21 @@ def batch_set_inspection():
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)
# ==============================================================================
@inbound_base_bp.route('/spec-latest', methods=['GET'])
@permission_required('material_list')
def get_spec_latest():
"""
获取所有规格型号的最大连号,按智能分组返回
返回格式: [{"group": "S", "latest": "S0115/S0115"}, {"group": "Opt4xxx", "latest": "Opt4018/Opt4018"}, ...]
"""
try:
data = MaterialBaseService.get_latest_specs()
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -43,8 +43,8 @@ def filter_item_by_permissions(item_dict, user_permissions):
'sku': 'inbound_buy:sku',
'barcode': 'inbound_buy:barcode',
'in_date': 'inbound_buy:in_date',
'serial_number': 'inbound_buy:serial_number',
'batch_number': 'inbound_buy:batch_number',
'serial_number': 'inbound_buy:sn_bn',
'batch_number': 'inbound_buy:sn_bn',
'status': 'inbound_buy:status',
'in_quantity': 'inbound_buy:in_quantity',
'stock_quantity': 'inbound_buy:stock_quantity',
@ -75,7 +75,10 @@ def filter_item_by_permissions(item_dict, user_permissions):
if 'inbound_buy:*' in user_permissions:
return item_dict
for field, perm_code in field_to_perm.items():
if field in item_dict and perm_code not in user_permissions:
# 提取不带前缀的基础权限码(如 'serial_number'
base_perm_code = perm_code.split(':')[-1] if ':' in perm_code else perm_code
# 如果用户的权限列表中,既没有长格式,也没有短格式,才将字段设为 None
if field in item_dict and perm_code not in user_permissions and base_perm_code not in user_permissions:
item_dict[field] = None
return item_dict
@ -210,7 +213,10 @@ def submit():
# 复制一份,避免遍历时修改字典
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
# 提取不带前缀的基础权限码(如 'serial_number'
base_perm_code = perm_code.split(':')[-1] if ':' in perm_code else perm_code
# 如果用户的权限列表中,既没有长格式,也没有短格式,才移除该字段
if perm_code and perm_code not in user_permissions and base_perm_code not in user_permissions:
data.pop(field, None)
# 库位必填校验(安全兜底)
@ -286,7 +292,10 @@ def update_buy(id):
# 复制一份,避免遍历时修改字典
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
# 提取不带前缀的基础权限码(如 'serial_number'
base_perm_code = perm_code.split(':')[-1] if ':' in perm_code else perm_code
# 如果用户的权限列表中,既没有长格式,也没有短格式,才移除该字段
if perm_code and perm_code not in user_permissions and base_perm_code not in user_permissions:
data.pop(field, None)
BuyInboundService.update_inbound(id, data)
@ -390,3 +399,21 @@ def get_location_suggestions():
return jsonify({"code": 400, "msg": "base_id required"}), 400
data = BuyInboundService.get_history_locations(base_id)
return jsonify({"code": 200, "msg": "success", "data": data})
# ------------------------------------------------------------------
# 11. 获取最近一次入库的库位(跨表查询)
# ------------------------------------------------------------------
@inbound_buy_bp.route('/last-location', methods=['GET'])
@permission_required('inbound_buy')
def get_last_location():
"""
获取指定物料最近一次入库的库位
查询顺序:采购入库 -> 成品入库 -> 半成品入库,返回最新入库的库位
"""
base_id = request.args.get('base_id', type=int)
if not base_id:
return jsonify({"code": 400, "msg": "base_id required"}), 400
location = BuyInboundService.get_last_location_by_base_id(base_id)
return jsonify({"code": 200, "msg": "success", "data": {"location": location}})

View File

@ -245,3 +245,21 @@ def calculate_bom_cost():
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 获取最近一次入库的库位(跨表查询)
# ------------------------------------------------------------------
@inbound_product_bp.route('/last-location', methods=['GET'])
@permission_required('inbound_product')
def get_last_location():
"""
获取指定物料最近一次入库的库位
查询顺序:成品入库 -> 采购入库 -> 半成品入库,返回最新入库的库位
"""
base_id = request.args.get('base_id', type=int)
if not base_id:
return jsonify({"code": 400, "msg": "base_id required"}), 400
location = ProductInboundService.get_last_location_by_base_id(base_id)
return jsonify({"code": 200, "msg": "success", "data": {"location": location}})

View File

@ -0,0 +1,137 @@
# inventory-backend/app/api/v1/inbound/repair.py
from flask import Blueprint, request, jsonify
from app.services.inbound.repair_service import RepairInboundService
from app.utils.decorators import permission_required, audit_log
import traceback
inbound_repair_bp = Blueprint('inbound_repair', __name__)
# ------------------------------------------------------------------
# 1. 获取维修单列表
# ------------------------------------------------------------------
@inbound_repair_bp.route('/list', methods=['GET'])
@permission_required('inbound_repair:list')
def get_list():
try:
params = {
'page': request.args.get('page', 1, type=int),
'page_size': request.args.get('page_size', 20, type=int),
'repair_no': request.args.get('repair_no'),
'sku': request.args.get('sku'),
'material_name': request.args.get('material_name'),
'serial_number': request.args.get('serial_number'),
'repair_status': request.args.get('repair_status'),
}
result = RepairInboundService.get_list(params)
return jsonify({'code': 200, 'msg': 'success', 'data': result})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# ------------------------------------------------------------------
# 2. 新增维修单
# ------------------------------------------------------------------
@inbound_repair_bp.route('/submit', methods=['POST'])
@permission_required('inbound_repair:add')
@audit_log(
module='维修管理',
action='新增',
get_target_name_fn=lambda: request.get_json().get('repair_no') if request.get_json() else None
)
def create():
try:
data = request.get_json()
result = RepairInboundService.create(data)
return jsonify({'code': 200, 'msg': 'success', 'data': result})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# ------------------------------------------------------------------
# 3. 更新维修单
# ------------------------------------------------------------------
@inbound_repair_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_repair:edit')
@audit_log(
module='维修管理',
action='更新',
get_target_name_fn=lambda: f"维修单ID:{request.view_args.get('id')}"
)
def update(id):
try:
data = request.get_json()
result = RepairInboundService.update(id, data)
if not result:
return jsonify({'code': 404, 'msg': '维修单不存在'}), 404
return jsonify({'code': 200, 'msg': 'success', 'data': result})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# ------------------------------------------------------------------
# 4. 更新维修状态
# ------------------------------------------------------------------
@inbound_repair_bp.route('/update-status', methods=['POST'])
@permission_required('inbound_repair:edit')
@audit_log(
module='维修管理',
action='更新状态',
get_target_name_fn=lambda: f"维修单ID:{request.get_json().get('id')}"
)
def update_status():
try:
data = request.get_json()
id = data.get('id')
status = data.get('status')
repair_log = data.get('repair_log')
if not id or not status:
return jsonify({'code': 400, 'msg': 'id 和 status 不能为空'}), 400
result = RepairInboundService.update_status(id, status, repair_log)
if not result:
return jsonify({'code': 404, 'msg': '维修单不存在'}), 404
return jsonify({'code': 200, 'msg': 'success', 'data': result})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# ------------------------------------------------------------------
# 5. 删除维修单
# ------------------------------------------------------------------
@inbound_repair_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('inbound_repair:delete')
@audit_log(
module='维修管理',
action='删除',
get_target_name_fn=lambda: f"维修单ID:{request.view_args.get('id')}"
)
def delete(id):
try:
success = RepairInboundService.delete(id)
if not success:
return jsonify({'code': 404, 'msg': '维修单不存在'}), 404
return jsonify({'code': 200, 'msg': '删除成功'})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# ------------------------------------------------------------------
# 6. 获取维修单详情
# ------------------------------------------------------------------
@inbound_repair_bp.route('/<int:id>', methods=['GET'])
@permission_required('inbound_repair:list')
def get_detail(id):
try:
result = RepairInboundService.get_by_id(id)
if not result:
return jsonify({'code': 404, 'msg': '维修单不存在'}), 404
return jsonify({'code': 200, 'msg': 'success', 'data': result})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -240,3 +240,21 @@ def calculate_bom_cost():
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 获取最近一次入库的库位(跨表查询)
# ------------------------------------------------------------------
@inbound_semi_bp.route('/last-location', methods=['GET'])
@permission_required('inbound_semi')
def get_last_location():
"""
获取指定物料最近一次入库的库位
查询顺序:半成品入库 -> 采购入库 -> 成品入库,返回最新入库的库位
"""
base_id = request.args.get('base_id', type=int)
if not base_id:
return jsonify({"code": 400, "msg": "base_id required"}), 400
location = SemiInboundService.get_last_location_by_base_id(base_id)
return jsonify({"code": 200, "msg": "success", "data": {"location": location}})

View File

@ -22,14 +22,18 @@ except ImportError:
SysUser = None
# 尝试导入半成品和成品
import logging
try:
from app.models.inbound.semi import StockSemi
except ImportError:
except Exception as e:
logging.error(f"❌ 致命错误StockSemi 模型导入失败: {e}")
StockSemi = None
try:
from app.models.inbound.product import StockProduct
except ImportError:
except Exception as e:
logging.error(f"❌ 致命错误StockProduct 模型导入失败: {e}")
StockProduct = None
@ -79,28 +83,50 @@ def get_stock_info(uuid_or_barcode):
根据 uuid 或 barcode 查询库存信息
返回: (item, source_table, stock_id)
"""
# 清洗输入:去掉前后空格和换行符
uuid_or_barcode = str(uuid_or_barcode).strip()
# 1. 成品
if StockProduct:
print(f"🔍 [QUERY DEBUG] 正在成品表搜关键词: {uuid_or_barcode}")
item = StockProduct.query.filter(
db.or_(StockProduct.barcode == uuid_or_barcode, StockProduct.sku == uuid_or_barcode)
db.or_(
StockProduct.barcode.ilike(f"%{uuid_or_barcode}%"),
StockProduct.sku.ilike(f"%{uuid_or_barcode}%"),
StockProduct.serial_number.ilike(f"%{uuid_or_barcode}%")
)
).first()
if item:
print(f"✅ [QUERY DEBUG] 命中成品! ID={item.id}, SKU={item.sku}")
return (item, 'stock_product', item.id)
else:
print(f"❌ [QUERY DEBUG] 成品表查询结束,无匹配项")
# 2. 半成品
if StockSemi:
print(f"🔍 [QUERY DEBUG] 正在半成品表搜关键词: {uuid_or_barcode}")
item = StockSemi.query.filter(
db.or_(StockSemi.barcode == uuid_or_barcode, StockSemi.sku == uuid_or_barcode)
db.or_(
StockSemi.barcode.ilike(f"%{uuid_or_barcode}%"),
StockSemi.sku.ilike(f"%{uuid_or_barcode}%"),
StockSemi.serial_number.ilike(f"%{uuid_or_barcode}%")
)
).first()
if item:
print(f"✅ [QUERY DEBUG] 命中半成品! ID={item.id}, SKU={item.sku}")
return (item, 'stock_semi', item.id)
# 3. 采购件
if StockBuy:
print(f"🔍 [QUERY DEBUG] 正在采购件表搜关键词: {uuid_or_barcode}")
item = StockBuy.query.filter(
db.or_(StockBuy.barcode == uuid_or_barcode, StockBuy.sku == uuid_or_barcode)
db.or_(
StockBuy.barcode.ilike(f"%{uuid_or_barcode}%"),
StockBuy.sku.ilike(f"%{uuid_or_barcode}%")
)
).first()
if item:
print(f"✅ [QUERY DEBUG] 命中采购件! ID={item.id}, SKU={item.sku}")
return (item, 'stock_buy', item.id)
return (None, None, None)
@ -216,42 +242,71 @@ def get_stock_list():
except Exception:
pass
# 3. 成品
# 3. 成品
if StockProduct:
try:
q = StockProduct.query.filter(StockProduct.stock_quantity > 0)
if keyword:
q = q.filter(
db.or_(
StockProduct.product_name.ilike(f'%{keyword}%'),
StockProduct.spec_model.ilike(f'%{keyword}%'),
StockProduct.sku.ilike(f'%{keyword}%')
)
q = StockProduct.query.filter(StockProduct.stock_quantity > 0)
if keyword:
q = q.filter(
db.or_(
StockProduct.base.has(MaterialBase.name.ilike(f'%{keyword}%')),
StockProduct.base.has(MaterialBase.spec_model.ilike(f'%{keyword}%')),
StockProduct.sku.ilike(f'%{keyword}%'),
StockProduct.barcode.ilike(f'%{keyword}%'),
StockProduct.serial_number.ilike(f'%{keyword}%')
)
rows = q.all()
for item in rows:
d = item.to_dict()
d['stock_type'] = 'product'
d['type'] = 'product'
d['typeLabel'] = '成品'
d['name'] = d.get('product_name', d.get('name', ''))
d['standard'] = d.get('spec_model', d.get('standard', ''))
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
all_items.append(d)
except Exception:
pass
)
rows = q.all()
for item in rows:
d = item.to_dict()
d['stock_type'] = 'product'
d['type'] = 'product'
d['typeLabel'] = '成品'
d['name'] = d.get('material_name', d.get('name', ''))
d['standard'] = d.get('spec_model', d.get('standard', ''))
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
all_items.append(d)
# ── 按规格+库位聚合(出库选单合并同类项)───────────────────────
is_aggregated = request.args.get('is_aggregated', 'false').lower() == 'true'
if is_aggregated:
grouped_dict = {}
for item in all_items:
# 核心聚合键:类型 + 规格型号 + 库位
group_key = f"{item.get('type')}_{item.get('standard')}_{item.get('warehouse_location', '')}"
if group_key in grouped_dict:
# 累加数量
existing = grouped_dict[group_key]
existing['available_quantity'] = float(existing.get('available_quantity', 0)) + float(item.get('available_quantity', 0))
existing['stock_quantity'] = float(existing.get('stock_quantity', 0)) + float(item.get('stock_quantity', 0))
# 保留 id 列表(出库提交时需用到)
existing_ids = existing.get('_ids', [])
existing_ids.append(item.get('id'))
existing['_ids'] = existing_ids
else:
# 存入代表项
grouped_dict[group_key] = item.copy()
# 强制统一数据类型以便前端处理
grouped_dict[group_key]['available_quantity'] = float(item.get('available_quantity', 0))
grouped_dict[group_key]['stock_quantity'] = float(item.get('stock_quantity', 0))
grouped_dict[group_key]['_ids'] = [item.get('id')]
# 替换原列表为聚合后的列表
all_items = list(grouped_dict.values())
# ── 手动切片分页 ────────────────────────────────────────────
total = len(all_items)
start = (page - 1) * pageSize
end = start + pageSize
end = start + pageSize
paged = all_items[start:end]
return jsonify({
'msg': '获取成功',
'data': {
'list': paged,
'total': total,
'page': page,
'list': paged,
'total': total,
'page': page,
'pageSize': pageSize
}
}), 200
@ -325,11 +380,22 @@ def get_drafts():
total = len(items)
start = (page - 1) * limit
end = start + limit
# 计算真实的去重"已盘数量"
counted_items_set = set()
for draft_item in items:
# 兼容判断 quantity 或 qty_actual
if draft_item.get('quantity') is not None or draft_item.get('qty_actual') is not None:
unique_key = f"{draft_item.get('source_table', '')}_{draft_item.get('stock_id', '')}"
counted_items_set.add(unique_key)
total_scanned_unique = len(counted_items_set)
paginated_items = items[start:end]
return jsonify({
'items': paginated_items,
'total': total,
'total_scanned': total_scanned_unique,
'page': page,
'limit': limit
}), 200
@ -350,6 +416,7 @@ def add_draft():
data = request.json
user_id = _normalize_user_id()
uuid = data.get('uuid')
print(f"🚀 [SCAN DEBUG] 后端实际接收到的 UUID 原文: |{uuid}| (长度: {len(str(uuid)) if uuid else 0})")
quantity = float(data.get('quantity', 1))
session_id = data.get('session_id')
# ★ 新增: 提取备注字段
@ -444,7 +511,11 @@ def clear_draft():
# 清除指定会话
query = query.filter_by(session_id=session_id)
count = query.delete()
# 改为对象级删除以触发审计事件
records = query.all()
count = len(records)
for rec in records:
db.session.delete(rec)
db.session.commit()
return jsonify({"message": f"已清除 {count} 条记录", "count": count}), 200
@ -461,8 +532,11 @@ def start_new_session():
清空整张草稿表,返回新的 session_id
"""
try:
# 清空整张草稿表
deleted_count = StocktakeDraft.query.delete()
# 清空整张草稿表(改为对象级删除以触发审计事件)
all_records = StocktakeDraft.query.all()
deleted_count = len(all_records)
for rec in all_records:
db.session.delete(rec)
db.session.commit()
# 生成新的 session_id
@ -789,8 +863,8 @@ def export_stocktake():
user = SysUser.query.get(int(user_id))
if not user:
user = SysUser.query.filter(SysUser.username.like(f"%/{user_id}")).first()
if not user:
user = SysUser.query.filter_by(username=str(user_id)).first()
# 注意:此处不再 fallback filter_by(username=...)
# 避免 PostgreSQL 将 user_id 数字与 username 字符串列做类型比较导致报错
if not user:
return str(user_id)
@ -1148,10 +1222,14 @@ def generate_missing_stocktake():
# ★ 幂等性保护:先删除当前 session 下系统自动生成的漏盘记录
# 特征user_id == 'system' (表示由系统自动生成)
deleted_count = StocktakeDraft.query.filter(
# 改为对象级删除以触发审计事件
system_records = StocktakeDraft.query.filter(
StocktakeDraft.session_id == session_id,
StocktakeDraft.user_id == 'system'
).delete()
).all()
deleted_count = len(system_records)
for rec in system_records:
db.session.delete(rec)
if deleted_count > 0:
db.session.commit()
print(f"[generate_missing] 已清理 {deleted_count} 条历史漏盘记录")

View File

@ -148,44 +148,6 @@ def create_outbound():
if not data.get('consumer_name') or not data.get('signature_path'):
return jsonify({'code': 400, 'msg': '领用人及签名信息缺失'}), 400
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if 'outbound_list:*' not in user_permissions:
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'outbound_no': 'outbound_list:outbound_no',
'outbound_time': 'outbound_list:outbound_time',
'outbound_type': 'outbound_list:outbound_type',
'total_amount': 'outbound_list:total_amount',
'consumer_name': 'outbound_list:consumer_name',
'operator_name': 'outbound_list:operator_name',
'remark': 'outbound_list:remark',
'signature_path': 'outbound_list:signature_path',
# 明细字段
'sku': 'outbound_list:sku',
'name': 'outbound_list:name',
'material_type': 'outbound_list:material_type',
'category': 'outbound_list:category',
'spec_model': 'outbound_list:spec_model',
'quantity': 'outbound_list:quantity',
'unit_price': 'outbound_list:unit_price',
'price': 'outbound_list:unit_price', # 兼容 price 字段
'subtotal': 'outbound_list:subtotal',
}
# 清洗顶层字段
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
# 清洗 items 中的每个商品字段
if 'items' in data and isinstance(data['items'], list):
for item in data['items']:
for field in list(item.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
item.pop(field, None)
try:
# ★ [修改] 调用批量创建服务
outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator)
@ -233,3 +195,244 @@ def get_outbound_list():
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# ==============================================================================
# 出库审批相关接口
# ==============================================================================
from app.services.outbound_service import OutboundApprovalService
def get_current_user_id():
"""获取当前用户ID"""
from app.models.system import SysUser
identity = get_jwt_identity()
if not identity:
return None
# JWT identity 是数据库主键整数,直接用 .get() 查询
user = SysUser.query.get(identity)
return user.id if user else None
def get_current_user_info():
"""获取当前用户信息和角色"""
from app.models.system import SysUser
identity = get_jwt_identity()
if not identity:
return None, None
# JWT identity 是数据库主键整数,直接用 .get() 查询
user = SysUser.query.get(identity)
return user.id if user else None, user.role if user else None
# --------------------------------------------------------
# 4. 创建出库审批单
# POST /api/v1/outbound/request
# --------------------------------------------------------
@outbound_bp.route('/request', methods=['POST'])
@jwt_required()
def create_outbound_request():
"""
创建出库审批单(申请阶段,用户只需提交宏观物料信息,无需关联具体库存记录)
请求体示例:
{
"items": [
{
"name": "物料A", // 物料名称 (必填)
"spec_model": "规格1", // 规格型号 (必填)
"quantity": 10, // 计划出库数量 (必填)
"warehouse_location": "A区-01-01", // 库位 (可选)
"remark": "备注信息" // 物品备注 (可选)
}
],
"allowed_approvers": [
{"type": "role", "value": "SUPERVISOR"},
{"type": "role", "value": "SUPER_ADMIN"}
],
"remark": "紧急出库申请"
}
"""
try:
user_id, user_role = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
items = data.get('items', [])
if not items:
return jsonify({'code': 400, 'msg': '出库物品列表不能为空'}), 400
# ★ 申请阶段仅校验宏观字段:名称、规格、数量
required_fields = ['name', 'spec_model', 'quantity']
for idx, item in enumerate(items):
missing = [f for f in required_fields if f not in item or item.get(f) is None or str(item.get(f)).strip() == '']
if missing:
return jsonify({
'code': 400,
'msg': f'{idx + 1}条物品缺少必填字段: {", ".join(missing)}'
f'必须包含: name(名称), spec_model(规格), quantity(数量)'
}), 400
try:
qty = float(item.get('quantity', 0))
if qty <= 0:
return jsonify({'code': 400, 'msg': f'{idx + 1}条物品的出库数量必须大于0'}), 400
except (TypeError, ValueError):
return jsonify({'code': 400, 'msg': f'{idx + 1}条物品的 quantity 格式无效'}), 400
# ★ 指定审批人:前端传 approver_id 则精准通知,否则用默认角色规则
approver_id = data.get('approver_id')
_default_approvers = [
{"type": "role", "value": "SUPERVISOR"},
{"type": "role", "value": "SUPER_ADMIN"}
]
allowed_approvers = data.get('allowed_approvers') or _default_approvers
# 创建审批单(直接存储前端传来的宏观信息快照,不查询库存)
approval = OutboundApprovalService.create_request(
applicant_id=user_id,
items=items,
allowed_approvers=allowed_approvers,
remark=data.get('remark'),
approver_id=approver_id
)
return jsonify({
'code': 200,
'msg': '审批单创建成功',
'data': approval.to_dict()
}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --------------------------------------------------------
# 5. 审批出库申请
# PATCH /api/v1/outbound/request/<id>/approve
# --------------------------------------------------------
@outbound_bp.route('/request/<int:request_id>/approve', methods=['PATCH'])
@jwt_required()
def approve_outbound_request(request_id):
"""
审批出库申请
请求体示例:
{
"action": "approve", // "approve" 通过, "reject" 驳回
"reject_reason": "库存不足" // 仅在驳回时需要
}
"""
try:
user_id, user_role = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
data = request.get_json() or {}
action = data.get('action', 'approve')
reject_reason = data.get('reject_reason')
if action not in ('approve', 'reject'):
return jsonify({'code': 400, 'msg': '无效的审批操作,仅支持 approve 或 reject'}), 400
if action == 'reject' and not reject_reason:
return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400
success, message, approval = OutboundApprovalService.approve(
request_id=request_id,
user_id=user_id,
user_role=user_role,
action=action,
reject_reason=reject_reason
)
if not success:
return jsonify({'code': 400, 'msg': message}), 400
return jsonify({
'code': 200,
'msg': message,
'data': approval.to_dict() if approval else None
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --------------------------------------------------------
# 6. 获取审批单列表
# GET /api/v1/outbound/request
# --------------------------------------------------------
@outbound_bp.route('/request', methods=['GET'])
@jwt_required()
def get_outbound_request_list():
"""
获取出库审批单列表
Query参数:
- page: 页码 (默认1)
- limit: 每页数量 (默认10)
- applicant_id: 按申请人筛选 (可选)
- status: 按状态筛选 (0待审/1通过/2驳回/3完成, 可选)
"""
try:
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
applicant_id = request.args.get('applicant_id')
if applicant_id:
applicant_id = int(applicant_id)
status = request.args.get('status')
if status is not None:
status = int(status)
result = OutboundApprovalService.get_request_list(
page=page,
per_page=limit,
applicant_id=applicant_id,
status=status
)
return jsonify({
'code': 200,
'msg': '获取成功',
'data': result
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 7. 获取单个审批单详情
# GET /api/v1/outbound/request/<id>
# --------------------------------------------------------
@outbound_bp.route('/request/<int:request_id>', methods=['GET'])
@jwt_required()
def get_outbound_request_detail(request_id):
"""获取出库审批单详情"""
try:
approval = OutboundApprovalService.get_request_by_id(request_id)
if not approval:
return jsonify({'code': 404, 'msg': '审批单不存在'}), 404
return jsonify({
'code': 200,
'msg': '获取成功',
'data': approval.to_dict()
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -0,0 +1,69 @@
"""
扫码查库存接口(移动端专用)
GET /api/v1/scan/inventory?barcode=xxx
"""
from flask import Blueprint, jsonify, request
from app.extensions import db
from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy
from app.models.inbound.product import StockProduct
from app.models.inbound.semi import StockSemi
scan_bp = Blueprint('scan', __name__, url_prefix='/scan')
def _build_response(stock_record, stock_type: str) -> dict:
"""联表 MaterialBase 提取物料信息并组装返回结构"""
material = MaterialBase.query.get(stock_record.base_id)
return {
'code': 200,
'data': {
'materialName': material.name if material else '未知物料',
'spec': material.spec_model if material else '',
'location': stock_record.warehouse_location or '',
'quantity': float(stock_record.available_quantity) if stock_record.available_quantity else 0.0,
'stockType': stock_type
}
}
@scan_bp.route('/inventory', methods=['GET'])
def scan_inventory():
"""
扫码精确查找库存
入参: barcode (query string)
逻辑: 在 StockBuy / StockProduct / StockSemi 三表中精确匹配,只要命中一张即返回
"""
barcode = (request.args.get('barcode') or '').strip()
if not barcode:
return jsonify({'code': 400, 'msg': 'barcode 参数不能为空'}), 400
# 1. 采购库
buy = StockBuy.query.filter(
StockBuy.barcode == barcode,
StockBuy.stock_quantity > 0
).first()
if buy:
return jsonify(_build_response(buy, '采购库'))
# 2. 成品库
product = StockProduct.query.filter(
StockProduct.barcode == barcode,
StockProduct.stock_quantity > 0
).first()
if product:
return jsonify(_build_response(product, '成品库'))
# 3. 半成品库
semi = StockSemi.query.filter(
StockSemi.barcode == barcode,
StockSemi.stock_quantity > 0
).first()
if semi:
return jsonify(_build_response(semi, '半成品库'))
# 4. 全部未命中
return jsonify({
'code': 404,
'msg': f'未找到条码 [{barcode}] 对应的库存记录,或该物料当前库存为零'
}), 404

View File

@ -4,10 +4,12 @@ from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required, audit_log
from app.services.auth_service import AuthService
from app.extensions import db
from app.models.transaction import TransScrap
from app.models.transaction import TransScrap, TransRepair
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase
from app.models.system import SysUser
import traceback
import math
@ -172,6 +174,28 @@ class ScrapService:
res['price'] = get_price(buy, 'stock_buy')
return res
# 4. 查询维修单 (TransRepair)
repair = TransRepair.query.filter(
db.or_(TransRepair.sku == clean_code, TransRepair.serial_number == clean_code)
).filter(
TransRepair.repair_status.notin_(['已出库', '报废转出'])
).first()
if repair:
return {
'id': repair.id,
'sku': repair.sku,
'barcode': repair.sku,
'name': repair.material_name or '维修件',
'spec': '',
'category': '',
'material_type': '',
'warehouse_loc': repair.customer_location or '',
'stock_quantity': 1,
'available_quantity': 1,
'source_table': 'trans_repair',
'price': float(repair.sale_price) if repair.sale_price else 0
}
return None
@staticmethod
@ -210,6 +234,31 @@ class ScrapService:
if not stock_id or not source_table or scrap_qty <= 0:
continue
# 处理维修单报废
if source_table == 'trans_repair':
repair = TransRepair.query.get(stock_id)
if not repair:
raise ValueError(f'维修单不存在: ID={stock_id}')
# 更新维修单状态为报废转出
repair.repair_status = '报废转出'
# 创建报废记录
scrap_record = TransScrap(
sku=repair.sku,
source_table='trans_repair',
stock_id=stock_id,
quantity=1,
reason=reason,
operator_name=operator_name,
approval_status='approved',
cost_at_scrap=float(repair.cost_price) if repair.cost_price else 0,
total_loss=float(repair.cost_price) if repair.cost_price else 0
)
db.session.add(scrap_record)
created_records.append(scrap_record)
continue
# 获取库存记录
stock_record = None
if source_table == 'stock_product':
@ -277,8 +326,63 @@ class ScrapService:
total = query.count()
records = query.offset((page - 1) * page_size).limit(page_size).all()
# 遍历结果,补充操作人姓名、物料名称、规格
result_list = []
for r in records:
item = r.to_dict()
# 1. 解析操作人姓名
if r.operator_name:
# operator_name 可能是用户ID或用户名尝试解析为真实姓名
try:
# 尝试将 operator_name 当作用户ID查询
user_id = int(r.operator_name)
user = SysUser.query.get(user_id)
if user:
# 解析存储格式: "张三/zhangsan"
raw_name = user.username
if '/' in raw_name:
item['operator_name'] = raw_name.split('/')[0]
except (ValueError, TypeError):
# 如果不是数字ID保持原值
pass
# 2. 多态解析物料名称与规格
material_name = ''
spec_model = ''
if r.source_table == 'trans_repair':
# 维修单
repair = TransRepair.query.get(r.stock_id)
if repair:
material_name = repair.material_name or ''
spec_model = ''
elif r.source_table in ['stock_buy', 'stock_semi', 'stock_product']:
# 常规库存表
stock_model = None
if r.source_table == 'stock_buy':
stock_model = StockBuy.query.get(r.stock_id)
elif r.source_table == 'stock_semi':
stock_model = StockSemi.query.get(r.stock_id)
elif r.source_table == 'stock_product':
stock_model = StockProduct.query.get(r.stock_id)
if stock_model and hasattr(stock_model, 'base_id') and stock_model.base_id:
base = MaterialBase.query.get(stock_model.base_id)
if base:
material_name = base.name or ''
spec_model = base.spec_model or ''
elif stock_model and hasattr(stock_model, 'base') and stock_model.base:
material_name = stock_model.base.name or ''
spec_model = stock_model.base.spec_model or ''
item['material_name'] = material_name
item['spec_model'] = spec_model
result_list.append(item)
return {
'list': [r.to_dict() for r in records],
'list': result_list,
'total': total,
'page': page,
'pageSize': page_size

View File

@ -66,26 +66,6 @@ def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records')
)
def create_borrow():
data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if '*' not in user_permissions:
field_to_perm = {
'borrow_no': 'op_records:borrow_no',
'borrower_name': 'op_records:borrower_name',
'sku': 'op_records:sku',
'borrow_time': 'op_records:borrow_time',
'return_time': 'op_records:return_time',
'status': 'op_records:status',
'expected_return_time': 'op_records:expected_return_time',
'return_location': 'op_records:return_location',
'borrow_signature': 'op_records:borrow_signature',
'return_signature': 'op_records:return_signature',
}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
try:
no = TransService.create_borrow(data)
return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}})
@ -120,26 +100,6 @@ def scan_borrowed_item():
)
def submit_return():
data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if '*' not in user_permissions:
field_to_perm = {
'borrow_no': 'op_records:borrow_no',
'borrower_name': 'op_records:borrower_name',
'sku': 'op_records:sku',
'borrow_time': 'op_records:borrow_time',
'return_time': 'op_records:return_time',
'status': 'op_records:status',
'expected_return_time': 'op_records:expected_return_time',
'return_location': 'op_records:return_location',
'borrow_signature': 'op_records:borrow_signature',
'return_signature': 'op_records:return_signature',
}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
user = get_jwt_identity() # 库管
try:
TransService.process_return(data, operator_name=user)

View File

@ -18,11 +18,15 @@ def build_tree(nodes, parent_id=None):
children = build_tree(nodes, node.id)
node_dict = node.to_dict()
if children:
node_dict['children'] = children
# 子节点按 name 升序排序
children_sorted = sorted(children, key=lambda x: x.get('name', ''))
node_dict['children'] = children_sorted
else:
node_dict['children'] = []
tree.append(node_dict)
return tree
# 当前层级按 name 升序排序
tree_sorted = sorted(tree, key=lambda x: x.get('name', ''))
return tree_sorted
@warehouse_bp.route('/tree', methods=['GET'])
@ -31,9 +35,9 @@ def get_tree():
获取库位树形结构
"""
try:
# 查询所有库位
all_locations = SysWarehouseLocation.query.order_by(SysWarehouseLocation.level, SysWarehouseLocation.id).all()
# 查询所有库位,按 name 升序排序
all_locations = SysWarehouseLocation.query.order_by(SysWarehouseLocation.name.asc()).all()
# 构建树形结构
tree_data = build_tree(all_locations, parent_id=None)

View File

@ -0,0 +1,210 @@
# inventory-backend/app/core/audit_listener.py
"""
SQLAlchemy Event Listener 审计监听器(单体架构版)
监听器亲自完成入库,不依赖 g 对象,不依赖装饰器回调。
只要模型发生 INSERT/UPDATE/DELETE监听器直接创建 AuditLog 并挂载到当前事务 session。
"""
from sqlalchemy import event, inspect
from flask import current_app, request, has_request_context
from datetime import datetime
IGNORE_FIELDS = {
'updated_at', 'update_time', 'modified_time', 'last_modified',
'created_at', 'create_time', 'created_on',
}
def _serialize_value(value):
"""序列化值确保 JSON 兼容"""
if value is None:
return None
if isinstance(value, datetime):
return value.strftime('%Y-%m-%d %H:%M:%S')
if isinstance(value, (bytes, bytearray)):
try:
return value.decode('utf-8')
except Exception:
return '[二进制数据]'
if hasattr(value, '__class__') and value.__class__.__name__ in ('InstanceState', 'LazyLoader'):
return str(value)
return value
def _is_audit_model(mapper):
"""判断模型是否需要审计"""
if hasattr(mapper.class_, 'audit_enabled') and mapper.class_.audit_enabled is False:
return False
AUDIT_WHITELIST = {
'MaterialBase', 'MaterialWarningSetting',
'StockBuy', 'StockSemi', 'StockProduct', 'StockService',
'RepairRecord', 'TransOutbound', 'TransBorrow', 'TransReturn',
'BomTable', 'StockTake', 'StockAdjust',
'TransScrap', 'SysUser'
}
return mapper.class_.__name__ in AUDIT_WHITELIST
def _get_module_name(mapper):
"""根据模型类名推断所属模块"""
name = mapper.class_.__name__
if 'Stock' in name or 'Buy' in name:
return '入库管理'
if 'Outbound' in name or 'TransOut' in name:
return '出库管理'
if 'Borrow' in name or 'Return' in name:
return '借还管理'
if 'Bom' in name:
return 'BOM管理'
if 'StockTake' in name or 'Adjust' in name or 'Scrap' in name:
return '盘点管理'
if 'Repair' in name:
return '维修管理'
if 'SysUser' in name or 'SysMenu' in name or 'SysRole' in name:
return '系统管理'
if 'Material' in name:
return '基础数据'
return '未知模块'
def _get_request_user_info():
"""从当前 HTTP 请求中尽力提取用户信息,获取不到拉倒"""
user_id, username, ip = None, 'system', ''
if has_request_context():
try:
from flask_jwt_extended import get_jwt_identity, get_jwt
user_id = get_jwt_identity()
claims = get_jwt()
username = claims.get('username', 'system')
except Exception:
pass
try:
ip = request.headers.get('X-Forwarded-For', '') or request.remote_addr or ''
if ip and ',' in ip:
ip = ip.split(',')[0].strip()
except Exception:
pass
return user_id, username, ip
# ============================================================
# 核心:监听器内部直接创建并挂载日志
# ============================================================
def _create_audit_log(session, mapper, target, action, details):
"""
监听器内部直接实例化 AuditLog 并加入当前事务 session。
由 SQLAlchemy 生命周期保证随主事务一同提交或回滚。
"""
try:
from app.models.audit import AuditLog
user_id, username, ip = _get_request_user_info()
module = _get_module_name(mapper)
target_id = None
if hasattr(target, 'id'):
target_id = target.id
elif hasattr(target, 'stock_id'):
target_id = target.stock_id
elif hasattr(target, 'bom_no'):
target_id = target.bom_no
log = AuditLog(
user_id=user_id,
username=username,
action=action,
module=module,
target_id=str(target_id) if target_id else '0',
details=details,
ip_address=ip
)
session.add(log)
except Exception as e:
current_app.logger.error(f"Audit log auto-creation failed: {e}")
def before_update_listener(mapper, connection, target):
"""UPDATE 事件:抓取字段变更明细"""
if not _is_audit_model(mapper): return
try:
state = inspect(target)
changes = {}
for attr in state.attrs:
if attr.key in IGNORE_FIELDS: continue
if attr.history.has_changes():
old_val = attr.history.deleted[0] if attr.history.deleted else None
new_val = attr.history.added[0] if attr.history.added else None
changes[attr.key] = {
'old': _serialize_value(old_val),
'new': _serialize_value(new_val)
}
if changes:
_create_audit_log(connection, mapper, target, 'update', {'changes': changes})
except Exception as e:
current_app.logger.error(f"Audit Update Error: {e}")
def before_delete_listener(mapper, connection, target):
"""DELETE 事件:抓取被删除对象的完整快照"""
if not _is_audit_model(mapper): return
try:
state = inspect(target)
snap = {}
for attr in state.attrs:
val = getattr(target, attr.key, None)
snap[attr.key] = _serialize_value(val)
_create_audit_log(connection, mapper, target, 'delete', {'deleted_snapshot': snap})
except Exception as e:
current_app.logger.error(f"Audit Delete Error: {e}")
def after_insert_listener(mapper, connection, target):
"""INSERT 事件:抓取新增对象的完整快照"""
if not _is_audit_model(mapper): return
try:
state = inspect(target)
snap = {}
for attr in state.attrs:
val = getattr(target, attr.key, None)
snap[attr.key] = _serialize_value(val)
_create_audit_log(connection, mapper, target, 'insert', {'created': snap})
except Exception:
pass
# ============================================================
# 注册函数
# ============================================================
def register_audit_listeners(db):
"""向所有需要审计的模型注册事件监听器"""
from app.models import (
MaterialBase, MaterialWarningSetting,
StockBuy, StockSemi, StockProduct, StockService,
RepairRecord, TransOutbound, TransBorrow, TransReturn,
BomTable, StockTake, StockAdjust,
TransScrap, SysUser
)
audit_models = [
MaterialBase, MaterialWarningSetting,
StockBuy, StockSemi, StockProduct, StockService,
RepairRecord, TransOutbound, TransBorrow, TransReturn,
BomTable, StockTake, StockAdjust,
TransScrap, SysUser
]
audit_models = [m for m in audit_models if m is not None]
count = 0
for model in audit_models:
try:
event.listen(model, 'before_update', before_update_listener, propagate=True)
event.listen(model, 'before_delete', before_delete_listener, propagate=True)
event.listen(model, 'after_insert', after_insert_listener, propagate=True)
count += 1
except Exception:
pass
return count

View File

@ -46,4 +46,14 @@ def init_extensions(app):
redis_client.ping()
app.logger.info("✅ Redis connected successfully")
except Exception as e:
app.logger.warning(f"⚠️ Redis connection failed: {e}, single-device login will be disabled")
app.logger.warning(f"⚠️ Redis connection failed: {e}, single-device login will be disabled")
# ★ 注册 SQLAlchemy 审计监听器
# 必须在 db.init_app 之后调用,确保所有模型已映射
try:
from app.core.audit_listener import register_audit_listeners
with app.app_context():
count = register_audit_listeners(db)
app.logger.info(f"✅ 审计监听器注册成功,共绑定 {count} 个模型")
except Exception as e:
app.logger.error(f"⚠️ 审计监听器注册失败: {e}")

View File

@ -14,6 +14,6 @@ except ImportError:
# 4. 出库记录 (如果有BuyService 用到了 TransOutbound)
try:
from app.models.outbound import TransOutbound
from app.models.outbound import TransOutbound, OutboundApproval
except ImportError:
pass

View File

@ -101,6 +101,10 @@ class MaterialWarningSetting(db.Model):
is_enabled = db.Column(db.Boolean, default=False, comment='是否启用预警')
yellow_threshold = db.Column(db.Numeric(10, 2), nullable=True, comment='黄色预警阈值')
red_threshold = db.Column(db.Numeric(10, 2), nullable=True, comment='红色预警阈值')
yellow_emails = db.Column(db.String(500), nullable=True, comment='黄色预警通知邮箱')
red_emails = db.Column(db.String(500), nullable=True, comment='红色预警通知邮箱')
is_ordered = db.Column(db.Boolean, default=False, comment='是否已处理采购')
last_notified_at = db.Column(db.DateTime, nullable=True, comment='上次邮件通知时间')
# 关联关系
material = db.relationship('MaterialBase', back_populates='warning_settings')
@ -111,5 +115,9 @@ class MaterialWarningSetting(db.Model):
'baseId': self.base_id,
'isEnabled': bool(self.is_enabled),
'yellowThreshold': float(self.yellow_threshold) if self.yellow_threshold is not None else None,
'redThreshold': float(self.red_threshold) if self.red_threshold is not None else None
'redThreshold': float(self.red_threshold) if self.red_threshold is not None else None,
'yellowEmails': self.yellow_emails or '',
'redEmails': self.red_emails or '',
'isOrdered': bool(self.is_ordered),
'lastNotifiedAt': self.last_notified_at.strftime('%Y-%m-%d %H:%M:%S') if self.last_notified_at else None
}

View File

@ -1,5 +1,110 @@
from app.extensions import db, beijing_time
from app.models.system import SysUser
from datetime import datetime
import json
class OutboundApproval(db.Model):
"""
出库审批单模型
用于管理出库申请的多级审批流程
"""
__tablename__ = 'outbound_approval'
id = db.Column(db.Integer, primary_key=True)
# 审批单号
request_no = db.Column(db.String(100), unique=True, nullable=False, index=True)
# 申请人ID
applicant_id = db.Column(db.Integer, nullable=False, index=True)
# 申请说明
remark = db.Column(db.Text)
# 状态: 0-待审批, 1-已通过, 2-已驳回, 3-已完成(已出库)
status = db.Column(db.Integer, default=0, nullable=False)
# 允许审批的人员列表 (JSON格式: [{"type": "role", "value": "admin"}, {"type": "user", "value": "123"}])
allowed_approvers = db.Column(db.Text)
# 实际审批人ID (多人审批时记录第一个通过的)
actual_approver_id = db.Column(db.Integer, index=True)
# 审批时间
approved_at = db.Column(db.DateTime)
# 驳回原因
reject_reason = db.Column(db.Text)
# 明细快照 (存储出库物品的名称、规格、库位、数量等信息无SKU字段)
items_json = db.Column(db.Text)
# 创建时间和更新时间
created_at = db.Column(db.DateTime, default=beijing_time, nullable=False)
updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time, nullable=False)
def _safe_parse_json(self, value):
"""
安全解析 JSON 字段:
- 如果 value 已是 list/dict直接返回
- 如果是 str尝试 json.loads()
- 解析失败或为 None/空,均返回 []
"""
if value is None:
return []
if isinstance(value, (list, dict)):
return value
if isinstance(value, str):
val = value.strip()
if not val:
return []
try:
parsed = json.loads(val)
return parsed if isinstance(parsed, list) else []
except (json.JSONDecodeError, TypeError, ValueError):
return []
return []
def get_items(self):
"""解析 items_json返回物品列表"""
return self._safe_parse_json(self.items_json)
def set_items(self, items):
"""设置 items_json"""
self.items_json = json.dumps(items, ensure_ascii=False) if items else '[]'
def get_allowed_approvers(self):
"""解析 allowed_approvers返回审批人列表"""
return self._safe_parse_json(self.allowed_approvers)
def set_allowed_approvers(self, approvers):
"""设置 allowed_approvers"""
self.allowed_approvers = json.dumps(approvers, ensure_ascii=False) if approvers else '[]'
def to_dict(self):
return {
'id': self.id,
'request_no': self.request_no,
'applicant_id': self.applicant_id,
'applicant_name': self._get_user_name(self.applicant_id),
'remark': self.remark,
'status': self.status,
'status_text': ['待审批', '已通过', '已驳回', '已完成'][self.status] if self.status in [0, 1, 2, 3] else '未知',
'allowed_approvers': self.get_allowed_approvers(),
'actual_approver_id': self.actual_approver_id,
'approver_name': self._get_user_name(self.actual_approver_id) if self.actual_approver_id else None,
'approved_at': self.approved_at.strftime('%Y-%m-%d %H:%M:%S') if self.approved_at else None,
'reject_reason': self.reject_reason,
'items': self.get_items(),
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
}
def _get_user_name(self, user_id):
"""根据用户ID获取用户名"""
if not user_id:
return ""
from app.models.system import SysUser
try:
# ★ 必须用 .get() 按主键 ID 查询,千万不能用 username=user_id 去查
user = SysUser.query.get(user_id)
return user.username if user else f"未知用户({user_id})"
except Exception as e:
return f"用户({user_id})"
class TransOutbound(db.Model):

View File

@ -70,39 +70,94 @@ class TransBorrow(db.Model):
class TransRepair(db.Model):
__tablename__ = 'trans_repair'
id = db.Column(db.Integer, primary_key=True)
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
# 维修单号 (新增)
repair_no = db.Column(db.String(50), nullable=True, unique=True)
# 关联基础信息 (新增)
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=True)
# SKU 保留
sku = db.Column(db.String(100))
# 物料名称 (独立录入时使用非关联base_id)
material_name = db.Column(db.String(200))
# 序列号SN (新增,用于单台追溯)
serial_number = db.Column(db.String(100), nullable=True)
# 来源追溯 (兼容旧数据)
source_table = db.Column(db.String(50))
stock_id = db.Column(db.Integer)
arrival_date = db.Column(db.Date)
expected_repair_time = db.Column(db.String(100))
shipping_date = db.Column(db.Date)
is_self_made = db.Column(db.Boolean, default=False)
related_product_id = db.Column(db.Integer)
related_contract_id = db.Column(db.String(100))
repair_manager = db.Column(db.String(100))
# 入库/接收时间
arrival_date = db.Column(db.Date)
# 维修状态 (新增)
repair_status = db.Column(db.String(50), default='待检测')
# 客户反馈
fault_description = db.Column(db.Text)
# 预计修复时间
expected_repair_time = db.Column(db.String(100))
# 维修日志/结果
repair_result = db.Column(db.Text)
# 维修人
repair_manager = db.Column(db.String(100))
# 出库交付时间
shipping_date = db.Column(db.Date)
# 客户名/来源
related_contract_id = db.Column(db.String(100))
# 客户名称 (新增)
customer_name = db.Column(db.String(100))
# 客户所在地 (新增)
customer_location = db.Column(db.String(255))
# 成本与售价
cost_price = db.Column(db.Numeric(19, 4))
sale_price = db.Column(db.Numeric(19, 4))
# 数据隔离 (新增)
company_id = db.Column(db.Integer, nullable=True)
# 关联关系
base = db.relationship('MaterialBase', backref='repairs')
def to_dict(self):
return {
'id': self.id,
'repair_no': self.repair_no,
'base_id': self.base_id,
'sku': self.sku,
'material_name': self.material_name,
'serial_number': self.serial_number,
'source_table': self.source_table,
'stock_id': self.stock_id,
'arrival_date': self.arrival_date.strftime('%Y-%m-%d') if self.arrival_date else None,
'repair_status': self.repair_status,
'expected_repair_time': self.expected_repair_time,
'shipping_date': self.shipping_date.strftime('%Y-%m-%d') if self.shipping_date else None,
'is_self_made': self.is_self_made,
'related_product_id': self.related_product_id,
'related_contract_id': self.related_contract_id,
'customer_name': self.customer_name,
'customer_location': self.customer_location,
'repair_manager': self.repair_manager,
'fault_description': self.fault_description,
'repair_result': self.repair_result,
'cost_price': float(self.cost_price) if self.cost_price is not None else None,
'sale_price': float(self.sale_price) if self.sale_price is not None else None,
'company_id': self.company_id,
}

View File

@ -57,7 +57,7 @@ def _get_token_from_redis(user_id):
class AuthService:
# 硬编码的超级管理员凭证
SUPER_ADMIN_USER = "IRIS"
SUPER_ADMIN_PASS = "licahk"
SUPER_ADMIN_PASS = "123321"
@staticmethod
def login(data):
@ -104,6 +104,8 @@ class AuthService:
user_id = user.id
user_info = user.to_dict()
user_info['role'] = user_role
# 获取用户所属公司(存于 department 字段)
user_company = user.department or ''
# 3. 生成 Token
# Token 中 identity 存数据库IDclaims 存登录账号ID
@ -115,7 +117,8 @@ class AuthService:
additional_claims={
'role': user_role,
'username': account_id, # 存纯账号ID
'display_name': user_info.get('username') # 存显示名
'display_name': user_info.get('username'), # 存显示名
'company_name': user_company # 存所属公司
}
)
@ -125,7 +128,8 @@ class AuthService:
additional_claims={
'role': user_role,
'username': account_id,
'display_name': user_info.get('display_name', account_id)
'display_name': user_info.get('display_name', account_id),
'company_name': user_company
}
)
@ -201,6 +205,16 @@ class AuthService:
if not cn_name or not pinyin_base:
raise Exception("姓名和账号不能为空")
# 后端兜底正则校验:允许中英数,禁止纯数字,无特殊字符
import re
name_pattern = re.compile(r'^(?!\d+$)[a-zA-Z0-9\u4e00-\u9fa5]+$')
if not name_pattern.match(cn_name):
raise Exception("姓名格式错误:仅支持中英文和数字,不能为纯数字,且不支持特殊字符")
if not name_pattern.match(pinyin_base):
raise Exception("账号格式错误:仅支持中英文和数字,不能为纯数字,且不支持特殊字符")
role_raw = data.get('role')
role = role_raw.upper() if role_raw else None
@ -216,7 +230,7 @@ class AuthService:
if operator_role_upper == UserRole.SUPERVISOR and role == UserRole.SUPER_ADMIN:
raise Exception("权限不足:主管无法创建超级管理员")
email = data.get('email', '')
email = data.get('email', '') or None # 空字符串转 None避免 unique 冲突
if email and SysUser.query.filter_by(email=email).first():
raise Exception("邮箱已被使用")
@ -256,6 +270,29 @@ class AuthService:
# 返回时最好把生成的ID告诉前端
return new_user.to_dict()
@staticmethod
def batch_create_users(data_list, operator_role):
"""
批量创建新用户。复用 create_user 的核心防重逻辑。
"""
results = []
for data in data_list:
try:
# 复用单条创建逻辑,它自带张三/zhangsan1的防重机制
new_user_dict = AuthService.create_user(data, operator_role)
results.append({
"cn_name": data.get('cn_name'),
"account_id": new_user_dict.get('account_id'),
"status": "success"
})
except Exception as e:
results.append({
"cn_name": data.get('cn_name'),
"error": str(e),
"status": "fail"
})
return results
@staticmethod
def update_user(user_id, data, operator_role):
"""
@ -341,9 +378,13 @@ class AuthService:
'elements': ['inbound_buy:unit_price', ...]
}
"""
# 防御性编程role_code 为空时直接返回空权限,避免后续 SQL 崩溃
if not role_code:
return {'menus': [], 'elements': []}
# 超级管理员返回所有权限(通配符)
from app.utils.constants import UserRole
if role_code and role_code.upper() == UserRole.SUPER_ADMIN:
if role_code.upper() == UserRole.SUPER_ADMIN:
# 返回通配符,表示拥有所有菜单和元素权限
return {
'menus': ['*'],
@ -351,6 +392,7 @@ class AuthService:
}
# 1. 查菜单权限
# 使用 func.upper() 处理数据库字段的大小写
menu_perms = SysRolePermission.query.filter(
func.upper(SysRolePermission.role_code) == role_code.upper(),
SysRolePermission.type == 'menu'
@ -363,12 +405,14 @@ class AuthService:
func.upper(SysRolePermission.role_code) == role_code.upper(),
SysRolePermission.type == 'element'
).all()
# 这里的 target_code 就是列的 code (如 unit_price)
# 为了防止不同页面有相同列名导致的混淆,我们之前数据库设计是做了隔离的
# 但为了前端处理方便,我们直接返回列的 code 集合
element_codes = [p.target_code for p in element_perms]
# 调试日志:输出查询结果便于排查字段权限问题
from flask import current_app
current_app.logger.info(
f"[权限查询] role={role_code}, 查询到菜单权限={menu_codes}, 元素权限={element_codes}"
)
return {
'menus': menu_codes,
'elements': element_codes

View File

@ -189,8 +189,13 @@ class BomService:
raise ValueError(f'保存失败!当前子件配置与已有版本 {ver} 完全一致,请勿重复保存')
# ===== 执行保存 =====
# 仅删除当前版本的旧记录
BomTable.query.filter_by(bom_no=bom_no, version=version).delete()
# 仅删除当前版本的旧记录(改为对象级删除以触发审计事件)
old_records = BomTable.query.filter_by(bom_no=bom_no, version=version).all()
for rec in old_records:
db.session.delete(rec)
# 【核心修复】:强制立即执行 DELETE 语句,为后续的 INSERT 腾出唯一键空间
db.session.flush()
for child in children:
bom = BomTable(
@ -260,7 +265,11 @@ class BomService:
existing = BomTable.query.filter_by(parent_id=parent_id).first()
bom_no = existing.bom_no if existing else BomService.generate_bom_no()
BomTable.query.filter_by(bom_no=bom_no, version=version).delete()
# 改为对象级删除以触发审计事件
old_records = BomTable.query.filter_by(bom_no=bom_no, version=version).all()
for rec in old_records:
db.session.delete(rec)
for item in child_list:
bom = BomTable(
bom_no=bom_no, version=version, parent_id=parent_id,

View File

@ -1,5 +1,6 @@
# 文件路径: app/services/inbound/base_service.py
from flask_jwt_extended import get_jwt
from app.extensions import db
from app.models.base import MaterialBase, MaterialWarningSetting
from app.models.inbound.buy import StockBuy
@ -209,9 +210,40 @@ class MaterialBaseService:
MaterialBase.spec_model.ilike(kw)
))
company = filters.get('company')
if company is not None and company != '':
query = query.filter(MaterialBase.company_name.ilike(company.strip()))
# ============================================================
# 【行级数据隔离】基于 JWT 中的 company_name 进行过滤
# ============================================================
from flask_jwt_extended import get_jwt
claims = get_jwt()
user_role = claims.get('role', '').upper() if claims.get('role') else ''
user_company = claims.get('company_name', '')
# 获取用户权限列表(用于检查 global:cross_company_op 特权)
from app.api.v1.inbound.base import get_current_user_permissions
user_perms = get_current_user_permissions() or []
normalized_perms = set(p.lower().replace('_', '').replace(':', '') for p in user_perms)
# 检查是否拥有全局特权或超管角色
has_cross_company = 'globalcrosscompanyop' in normalized_perms
# 获取前端传的查询参数
req_company = filters.get('company') if filters else None
if user_role != 'SUPER_ADMIN' and not has_cross_company:
# 【显式拒绝越权】如果前端传了公司参数且不是当前用户的公司返回403
if req_company and req_company != user_company:
from flask import abort
abort(403, description=f'越权访问:您无权查询 {req_company} 的数据')
# 正常查询本公司数据
if user_company:
query = query.filter(MaterialBase.company_name == user_company)
# 如果用户没有所属公司字段,则只显示公司为空的记录(或不允许查看)
elif user_role == 'SUPER_ADMIN' or has_cross_company:
# 超级管理员或有跨域特权:允许跨公司视角
if req_company:
query = query.filter(MaterialBase.company_name == req_company)
# 没选公司则不加过滤,看到全量
category = filters.get('category')
if category is not None and category != '':
@ -343,34 +375,29 @@ class MaterialBaseService:
if enable_warning_sort:
print("====== [DEBUG] 成功进入预警强排逻辑 ======")
# 直接在 order_by 中进行计算排序,不污染 select 列
inv_val = inner_sub.c.total_inv
red_val = cast(MaterialWarningSetting.red_threshold, Numeric)
yellow_val = cast(MaterialWarningSetting.yellow_threshold, Numeric)
# 预警等级计算:红=2, 黄=1, 正常=0
warning_level = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= red_val), 2),
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= yellow_val), 1),
(and_(MaterialWarningSetting.is_enabled.is_(True), red_val.isnot(None), inv_val <= red_val), 2),
(and_(MaterialWarningSetting.is_enabled.is_(True), yellow_val.isnot(None), inv_val <= yellow_val), 1),
else_=0
)
# 统一计算缺口 (Shortage) = 目标阈值 - 当前库存
# 红色算红色的缺口,黄色算黄色的缺口,越大说明缺的越多
shortage = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), red_val.isnot(None), inv_val <= red_val), red_val - inv_val),
(and_(MaterialWarningSetting.is_enabled.is_(True), yellow_val.isnot(None), inv_val <= yellow_val), yellow_val - inv_val),
else_=0
)
# 红色预警时的缺口
red_shortage = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= red_val), red_val - inv_val),
else_=0
)
# 黄色预警时的缺口
yellow_distance = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val > red_val, inv_val <= yellow_val), inv_val - red_val),
else_=999999
)
# 直接在 order_by 中使用 case() 表达式
query = query.order_by(
desc(warning_level),
desc(red_shortage),
asc(yellow_distance),
desc(inv_val),
desc(warning_level), # 1. 先按红、黄、正常排
desc(shortage), # 2. 同级别内,缺口越大的排越上面
desc(inv_val), # 3. 缺口一样,库存多的排上面
desc(MaterialBase.id)
)
elif order_by_column:
@ -430,12 +457,18 @@ class MaterialBaseService:
item_dict['warningRed'] = float(warning_red) if warning_red is not None else None
# 计算预警状态
if warning_enabled and warning_red is not None:
if warning_enabled:
invQty = item_dict['inventoryCount']
if invQty <= warning_red:
# 优先判断红色预警(如果设置了红阈值,且库存 <= 红阈值)
if warning_red is not None and invQty <= warning_red:
item_dict['warningStatus'] = 2 # 红色
# 其次判断黄色预警(如果设置了黄阈值,且库存 <= 黄阈值)
elif warning_yellow is not None and invQty <= warning_yellow:
item_dict['warningStatus'] = 1 # 黄色
# 都不满足则正常
else:
item_dict['warningStatus'] = 0 # 正常
else:
@ -618,9 +651,34 @@ class MaterialBaseService:
MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw)
))
company = filters.get('company')
if company is not None and company != '':
filter_conditions.append(MaterialBase.company_name.ilike(company.strip()))
# ============================================================
# 【行级数据隔离】基于 JWT 中的 company_name 进行过滤(高级筛选)
# ============================================================
from flask_jwt_extended import get_jwt
claims = get_jwt()
user_role = claims.get('role', '').upper() if claims.get('role') else ''
user_company = claims.get('company_name', '')
# 获取用户权限列表(用于检查 global:cross_company_op 特权)
from app.api.v1.inbound.base import get_current_user_permissions
user_perms = get_current_user_permissions() or []
normalized_perms = set(p.lower().replace('_', '').replace(':', '') for p in user_perms)
# 检查是否拥有全局特权或超管角色
has_cross_company = 'globalcrosscompanyop' in normalized_perms
req_company = filters.get('company') if filters else None
if user_role != 'SUPER_ADMIN' and not has_cross_company:
# 普通用户:强制隔离
if user_company:
filter_conditions.append(MaterialBase.company_name == user_company)
elif user_role == 'SUPER_ADMIN' or has_cross_company:
# 超级管理员或有跨域特权:允许跨公司视角
if req_company:
filter_conditions.append(MaterialBase.company_name == req_company)
category = filters.get('category')
if category is not None and category != '':
filter_conditions.append(MaterialBase.category.ilike(category.strip()))
@ -954,4 +1012,101 @@ class MaterialBaseService:
except Exception as e:
traceback.print_exc()
raise e
raise e
@staticmethod
def get_latest_specs():
"""
获取所有规格型号的最大连号,按连续区间分组返回
- 前缀统一大写处理
- 只有数字完全连续N, N+1, N+2...)才认定为同一组
- 数字不连续时断开,形成新组
- 按每组数量降序排列
- 返回每个连续区间的最大值
"""
import re
# 1. 查询所有不为空的规格型号
specs = MaterialBase.query.filter(
MaterialBase.spec_model.isnot(None),
MaterialBase.spec_model != ''
).all()
# 2. 解析并收集所有有效的 (prefix, num, original_spec)
parsed = []
for material in specs:
spec = material.spec_model
if not spec:
continue
base_spec = spec.split('/')[0]
match = re.match(r'^([A-Za-z]+)(\d+)$', base_spec)
if not match:
continue
prefix, num_str = match.groups()
prefix = prefix.upper()
num = int(num_str)
parsed.append((prefix, num, spec))
# 3. 先按 prefix 升序,再按 num 升序排序
parsed.sort(key=lambda x: (x[0], x[1]))
# 4. 遍历切分连续区间
# 核心逻辑:当 current_num != prev_num + 1 时,断开形成新组
intervals = []
current_prefix = None
current_start = None
current_end = None
current_last_spec = None
for prefix, num, spec in parsed:
if current_prefix is None:
current_prefix = prefix
current_start = num
current_end = num
current_last_spec = spec
elif prefix == current_prefix and num == current_end + 1:
current_end = num
current_last_spec = spec
else:
intervals.append({
'prefix': current_prefix,
'start': current_start,
'end': current_end,
'count': current_end - current_start + 1,
'latest': current_last_spec
})
current_prefix = prefix
current_start = num
current_end = num
current_last_spec = spec
if current_prefix is not None:
intervals.append({
'prefix': current_prefix,
'start': current_start,
'end': current_end,
'count': current_end - current_start + 1,
'latest': current_last_spec
})
# 5. 按每组数量降序排列,再按前缀升序
intervals.sort(key=lambda x: (-x['count'], x['prefix']))
# 6. 构建返回结果
result = []
for item in intervals:
prefix = item['prefix']
start = item['start']
end = item['end']
result.append({
"group": f"{prefix}({start}-{end})",
"count": item['count'],
"latest": item['latest']
})
return result

View File

@ -1,6 +1,8 @@
# inventory-backend/app/services/inbound/buy_service.py
from flask_jwt_extended import get_jwt
from app.extensions import db
from app.models.inbound.buy import StockBuy
from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, func, text, and_
@ -346,9 +348,34 @@ class BuyInboundService:
if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip())
# 3.1 公司独立搜索 [新增]
if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip())
# ============================================================
# 【行级数据隔离】基于 JWT 中的 company_name 进行过滤
# ============================================================
from flask_jwt_extended import get_jwt
claims = get_jwt()
user_role = claims.get('role', '').upper() if claims.get('role') else ''
user_company = claims.get('company_name', '')
# 获取用户权限列表(用于检查 global:cross_company_op 特权)
from app.api.v1.inbound.base import get_current_user_permissions
user_perms = get_current_user_permissions() or []
normalized_perms = set(p.lower().replace('_', '').replace(':', '') for p in user_perms)
# 检查是否拥有全局特权或超管角色
has_cross_company = 'globalcrosscompanyop' in normalized_perms
if user_role != 'SUPER_ADMIN' and not has_cross_company:
# 无特权:严禁查其他公司,强制绑定本公司
if company and company.strip() and company.strip() != user_company:
from flask import abort
abort(403, description=f'越权访问:您无权查询 {company} 的数据')
if user_company:
query = query.filter(MaterialBase.company_name == user_company)
elif user_role == 'SUPER_ADMIN' or has_cross_company:
# 有特权:允许下拉框传过来的 company 参数生效
if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip())
# 4. 状态筛选
if not statuses: statuses = ['在库', '借库']
@ -525,3 +552,42 @@ class BuyInboundService:
def get_history_locations(base_id):
return [r[0] for r in
db.session.query(StockBuy.warehouse_location).filter(StockBuy.base_id == base_id).distinct().all()]
@staticmethod
def get_last_location_by_base_id(base_id):
"""
获取指定物料最近一次入库的库位(跨表查询)
查询顺序:采购入库 -> 成品入库 -> 半成品入库,返回最新入库的库位
"""
from app.models.inbound.semi import StockSemi
# 1. 查询采购入库最新记录
last_buy = StockBuy.query.filter(
StockBuy.base_id == base_id
).order_by(StockBuy.in_date.desc()).first()
# 2. 查询成品入库最新记录
last_product = StockProduct.query.filter(
StockProduct.base_id == base_id
).order_by(StockProduct.production_date.desc()).first()
# 3. 查询半成品入库最新记录
last_semi = StockSemi.query.filter(
StockSemi.base_id == base_id
).order_by(StockSemi.production_date.desc()).first()
# 比较三个表中的最新入库时间,返回最新的库位
candidates = []
if last_buy and last_buy.warehouse_location:
candidates.append((last_buy.in_date, last_buy.warehouse_location))
if last_product and last_product.warehouse_location:
candidates.append((last_product.production_date, last_product.warehouse_location))
if last_semi and last_semi.warehouse_location:
candidates.append((last_semi.production_date, last_semi.warehouse_location))
if not candidates:
return ""
# 按时间倒序排序,返回最新的库位
candidates.sort(key=lambda x: x[0] if x[0] else datetime.min, reverse=True)
return candidates[0][1] if candidates[0][1] else ""

View File

@ -1,6 +1,8 @@
# app/services/inbound/product_service.py
from app.extensions import db
from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.outbound import TransOutbound
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, func, text, and_
@ -315,8 +317,34 @@ class ProductInboundService:
if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip())
if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip())
# ============================================================
# 【全局特权】基于 JWT 与 global:cross_company_op 的跨组织隔离
# ============================================================
from flask_jwt_extended import get_jwt
claims = get_jwt()
user_role = claims.get('role', '').upper() if claims.get('role') else ''
user_company = claims.get('company_name', '')
# 获取用户权限列表(用于检查 global:cross_company_op 特权)
from app.api.v1.inbound.base import get_current_user_permissions
user_perms = get_current_user_permissions() or []
normalized_perms = set(p.lower().replace('_', '').replace(':', '') for p in user_perms)
# 检查是否拥有全局特权或超管角色
has_cross_company = 'globalcrosscompanyop' in normalized_perms
if user_role != 'SUPER_ADMIN' and not has_cross_company:
# 无特权:严禁查其他公司,强制绑定本公司
if company and company.strip() and company.strip() != user_company:
from flask import abort
abort(403, description=f'越权访问:您无权查询 {company} 的数据')
if user_company:
query = query.filter(MaterialBase.company_name == user_company)
elif user_role == 'SUPER_ADMIN' or has_cross_company:
# 有特权:允许下拉框传过来的 company 参数生效
if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip())
if not statuses:
statuses = ['在库', '借库']
@ -576,3 +604,42 @@ class ProductInboundService:
except Exception as e:
traceback.print_exc()
raise e
@staticmethod
def get_last_location_by_base_id(base_id):
"""
获取指定物料最近一次入库的库位(跨表查询)
查询顺序:成品入库 -> 采购入库 -> 半成品入库,返回最新入库的库位
"""
from app.models.inbound.product import StockProduct
# 1. 查询成品入库最新记录
last_product = StockProduct.query.filter(
StockProduct.base_id == base_id
).order_by(StockProduct.production_date.desc()).first()
# 2. 查询采购入库最新记录
last_buy = StockBuy.query.filter(
StockBuy.base_id == base_id
).order_by(StockBuy.in_date.desc()).first()
# 3. 查询半成品入库最新记录
last_semi = StockSemi.query.filter(
StockSemi.base_id == base_id
).order_by(StockSemi.production_date.desc()).first()
# 比较三个表中的最新入库时间,返回最新的库位
candidates = []
if last_product and last_product.warehouse_location:
candidates.append((last_product.production_date, last_product.warehouse_location))
if last_buy and last_buy.warehouse_location:
candidates.append((last_buy.in_date, last_buy.warehouse_location))
if last_semi and last_semi.warehouse_location:
candidates.append((last_semi.production_date, last_semi.warehouse_location))
if not candidates:
return ""
# 按时间倒序排序,返回最新的库位
candidates.sort(key=lambda x: x[0] if x[0] else datetime.min, reverse=True)
return candidates[0][1] if candidates[0][1] else ""

View File

@ -0,0 +1,284 @@
# inventory-backend/app/services/inbound/repair_service.py
from app.extensions import db
from app.models.transaction import TransRepair
from app.models.base import MaterialBase
from datetime import datetime, timezone
from sqlalchemy import text
class RepairInboundService:
@staticmethod
def _generate_repair_no():
"""
生成唯一的维修单号
格式: REP-YYYYMMDD-0001 (按天递增)
策略: 查询当天最大的repair_no提取流水号+1
"""
today = datetime.now().strftime('%Y%m%d')
prefix = f"REP-{today}-"
# 查询当天最大的维修单号
latest = TransRepair.query.filter(
TransRepair.repair_no.like(f"{prefix}%")
).order_by(TransRepair.repair_no.desc()).first()
if latest and latest.repair_no:
try:
# 提取最后的流水号
last_seq = int(latest.repair_no.split('-')[-1])
new_seq = last_seq + 1
except (ValueError, IndexError):
new_seq = 1
else:
new_seq = 1
return f"{prefix}{new_seq:04d}"
@staticmethod
def _generate_sku():
"""
获取全局自增序列号生成10位SKU
格式: str(seq).zfill(10)
"""
try:
seq_sql = text("SELECT nextval('global_print_seq')")
result = db.session.execute(seq_sql)
next_global_id = result.scalar()
return str(next_global_id).zfill(10) if next_global_id else None
except:
return None
@staticmethod
def get_list(params):
"""
获取维修单列表
支持按 repair_no, sku, material_name, serial_number, repair_status 模糊查询
实现分页
"""
page = params.get('page', 1)
page_size = params.get('page_size', 20)
query = TransRepair.query
# 模糊查询条件
if params.get('repair_no'):
query = query.filter(TransRepair.repair_no.ilike(f"%{params['repair_no']}%"))
if params.get('sku'):
query = query.filter(TransRepair.sku.ilike(f"%{params['sku']}%"))
if params.get('serial_number'):
query = query.filter(TransRepair.serial_number.ilike(f"%{params['serial_number']}%"))
if params.get('repair_status'):
status_value = params['repair_status']
if status_value == '未出库':
# 未出库:排除已出库状态
query = query.filter(TransRepair.repair_status != '已出库')
elif status_value not in ['全部', '']:
# 其他明确状态:精确匹配
query = query.filter(TransRepair.repair_status == status_value)
# '全部' 或为空:不过滤状态
# 关联 MaterialBase 查询物料名称 或 直接搜索 TransRepair.material_name
if params.get('material_name'):
material_name_filter = params['material_name']
# 优先搜索直接存储的 material_name其次搜索关联的 base.name
query = query.outerjoin(MaterialBase, TransRepair.base_id == MaterialBase.id).filter(
db.or_(
TransRepair.material_name.ilike(f"%{material_name_filter}%"),
MaterialBase.name.ilike(f"%{material_name_filter}%")
)
)
# 按接收时间升序(先进先出)+ id 升序
query = query.order_by(db.asc(TransRepair.arrival_date), db.asc(TransRepair.id))
# ============================================================
# 【全局特权】基于 JWT 与 global:cross_company_op 的跨组织隔离
# ============================================================
from flask_jwt_extended import get_jwt
claims = get_jwt()
user_role = claims.get('role', '').upper() if claims.get('role') else ''
user_company = claims.get('company_name', '')
# 获取用户权限列表(用于检查 global:cross_company_op 特权)
from app.api.v1.inbound.base import get_current_user_permissions
user_perms = get_current_user_permissions() or []
normalized_perms = set(p.lower().replace('_', '').replace(':', '') for p in user_perms)
# 检查是否拥有全局特权或超管角色
has_cross_company = 'globalcrosscompanyop' in normalized_perms
# 维修表需要通过 base_id 关联 MaterialBase 进行公司过滤
if user_role != 'SUPER_ADMIN' and not has_cross_company:
# 无特权:强制绑定本公司
query = query.outerjoin(MaterialBase, TransRepair.base_id == MaterialBase.id)
if user_company:
query = query.filter(MaterialBase.company_name == user_company)
# 分页
pagination = query.paginate(page=page, per_page=page_size, error_out=False)
items = []
for item in pagination.items:
item_dict = item.to_dict()
# 如果有 base_id尝试获取物料名称
if item.base_id:
base = MaterialBase.query.get(item.base_id)
if base:
item_dict['material_name'] = base.name
item_dict['company_name'] = base.company_name
items.append(item_dict)
return {
'list': items,
'total': pagination.total,
'page': page,
'page_size': page_size
}
@staticmethod
def create(data):
"""
新增维修单
核心要求:
1. 生成以 REP- 打头的自增维修单号 (按天递增)
2. 从全局序列获取10位SKU (global_print_seq)
3. 支持不关联 base_id (独立录入模式)
4. 新增客户名称和客户所在地字段
"""
# 生成维修单号
repair_no = RepairInboundService._generate_repair_no()
# 获取全局SKU
sku = data.get('sku')
if not sku:
sku = RepairInboundService._generate_sku()
# 获取物料信息 (可选)
material_name = data.get('material_name')
company_name = None
if data.get('base_id'):
base = MaterialBase.query.get(data['base_id'])
if base:
material_name = base.name or material_name
company_name = base.company_name
if not sku:
sku = base.code
repair = TransRepair(
repair_no=repair_no,
base_id=data.get('base_id'),
sku=sku,
material_name=material_name,
serial_number=data.get('serial_number'),
arrival_date=data.get('arrival_date'),
repair_status=data.get('repair_status', '待检测'),
fault_description=data.get('fault_description'),
expected_repair_time=data.get('expected_repair_time'),
repair_result=data.get('repair_result'),
repair_manager=data.get('repair_manager'),
shipping_date=data.get('shipping_date'),
related_contract_id=data.get('related_contract_id'),
# 新增客户字段
customer_name=data.get('customer_name'),
customer_location=data.get('customer_location'),
cost_price=data.get('cost_price'),
sale_price=data.get('sale_price'),
company_id=data.get('company_id'),
source_table=data.get('source_table'),
stock_id=data.get('stock_id'),
is_self_made=data.get('is_self_made', False),
related_product_id=data.get('related_product_id'),
)
db.session.add(repair)
db.session.commit()
result = repair.to_dict()
result['material_name'] = material_name
result['company_name'] = company_name
return result
@staticmethod
def update(id, data):
"""
更新维修单
"""
repair = TransRepair.query.get(id)
if not repair:
return None
# 可更新字段
updatable_fields = [
'base_id', 'sku', 'material_name', 'serial_number', 'arrival_date', 'repair_status',
'fault_description', 'expected_repair_time', 'repair_result',
'repair_manager', 'shipping_date', 'related_contract_id',
'customer_name', 'customer_location', 'cost_price', 'sale_price', 'company_id'
]
for field in updatable_fields:
if field in data:
setattr(repair, field, data[field])
db.session.commit()
return repair.to_dict()
@staticmethod
def update_status(id, status, repair_log=None):
"""
专门用于更新维修状态和追加维修日志
"""
# 禁止手动变更为已出库状态,必须通过扫码出库模块进行
if status == '已出库':
raise ValueError("禁止手动变更为已出库状态,请通过扫码出库模块进行操作")
# 禁止手动变更为报废转出状态,必须通过扫码报废模块进行
if status == '报废转出':
raise ValueError("禁止手动变更为报废状态,请前往报废管理进行扫码操作")
repair = TransRepair.query.get(id)
if not repair:
return None
repair.repair_status = status
# 追加维修日志
if repair_log:
if repair.repair_result:
repair.repair_result = repair.repair_result + '\n' + f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {repair_log}"
else:
repair.repair_result = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {repair_log}"
db.session.commit()
return repair.to_dict()
@staticmethod
def delete(id):
"""
删除维修单
"""
repair = TransRepair.query.get(id)
if not repair:
return False
db.session.delete(repair)
db.session.commit()
return True
@staticmethod
def get_by_id(id):
"""
根据ID获取维修单详情
"""
repair = TransRepair.query.get(id)
if not repair:
return None
item_dict = repair.to_dict()
if repair.base_id:
base = MaterialBase.query.get(repair.base_id)
if base:
item_dict['material_name'] = base.name
item_dict['company_name'] = base.company_name
return item_dict

View File

@ -1,6 +1,8 @@
# app/services/inbound/semi_service.py
from app.extensions import db
from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy
from app.models.inbound.product import StockProduct
from app.models.outbound import TransOutbound
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, func, text, and_
@ -406,8 +408,34 @@ class SemiInboundService:
if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip())
if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip())
# ============================================================
# 【全局特权】基于 JWT 与 global:cross_company_op 的跨组织隔离
# ============================================================
from flask_jwt_extended import get_jwt
claims = get_jwt()
user_role = claims.get('role', '').upper() if claims.get('role') else ''
user_company = claims.get('company_name', '')
# 获取用户权限列表(用于检查 global:cross_company_op 特权)
from app.api.v1.inbound.base import get_current_user_permissions
user_perms = get_current_user_permissions() or []
normalized_perms = set(p.lower().replace('_', '').replace(':', '') for p in user_perms)
# 检查是否拥有全局特权或超管角色
has_cross_company = 'globalcrosscompanyop' in normalized_perms
if user_role != 'SUPER_ADMIN' and not has_cross_company:
# 无特权:严禁查其他公司,强制绑定本公司
if company and company.strip() and company.strip() != user_company:
from flask import abort
abort(403, description=f'越权访问:您无权查询 {company} 的数据')
if user_company:
query = query.filter(MaterialBase.company_name == user_company)
elif user_role == 'SUPER_ADMIN' or has_cross_company:
# 有特权:允许下拉框传过来的 company 参数生效
if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip())
if not statuses:
statuses = ['在库', '借库']
@ -653,3 +681,42 @@ class SemiInboundService:
except Exception as e:
traceback.print_exc()
raise e
@staticmethod
def get_last_location_by_base_id(base_id):
"""
获取指定物料最近一次入库的库位(跨表查询)
查询顺序:半成品入库 -> 采购入库 -> 成品入库,返回最新入库的库位
"""
from app.models.inbound.semi import StockSemi
# 1. 查询半成品入库最新记录
last_semi = StockSemi.query.filter(
StockSemi.base_id == base_id
).order_by(StockSemi.production_date.desc()).first()
# 2. 查询采购入库最新记录
last_buy = StockBuy.query.filter(
StockBuy.base_id == base_id
).order_by(StockBuy.in_date.desc()).first()
# 3. 查询成品入库最新记录
last_product = StockProduct.query.filter(
StockProduct.base_id == base_id
).order_by(StockProduct.production_date.desc()).first()
# 比较三个表中的最新入库时间,返回最新的库位
candidates = []
if last_semi and last_semi.warehouse_location:
candidates.append((last_semi.production_date, last_semi.warehouse_location))
if last_buy and last_buy.warehouse_location:
candidates.append((last_buy.in_date, last_buy.warehouse_location))
if last_product and last_product.warehouse_location:
candidates.append((last_product.production_date, last_product.warehouse_location))
if not candidates:
return ""
# 按时间倒序排序,返回最新的库位
candidates.sort(key=lambda x: x[0] if x[0] else datetime.min, reverse=True)
return candidates[0][1] if candidates[0][1] else ""

View File

@ -0,0 +1,199 @@
"""
库存预警扫描与邮件通知服务
定时(或手动触发)扫描所有 is_enabled=True 且 is_ordered=False 的预警配置,
按物料配置的邮箱独立发送,不依赖 SysUser 角色。
- 库存 <= red_threshold → 红色预警邮件(发 setting.red_emails
- red_threshold < 库存 <= yellow_threshold → 黄色预警邮件(发 setting.yellow_emails
- 同一收件人在多条记录中出现 → 聚合为一封邮件
- 发送成功后更新 last_notified_at
"""
from datetime import datetime, timezone, timedelta
from collections import defaultdict
from sqlalchemy import func
from app.extensions import db
from app.models.base import MaterialBase, MaterialWarningSetting
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
class InventoryWarningService:
@staticmethod
def _get_total_inventory(base_id: int) -> float:
"""
计算指定物料在所有库存表(采购件 + 半成品 + 成品)中的总库存量
"""
buy_q = db.session.query(func.sum(StockBuy.stock_quantity)).filter(
StockBuy.base_id == base_id
).scalar() or 0
semi_q = db.session.query(func.sum(StockSemi.stock_quantity)).filter(
StockSemi.base_id == base_id
).scalar() or 0
prod_q = db.session.query(func.sum(StockProduct.stock_quantity)).filter(
StockProduct.base_id == base_id
).scalar() or 0
return float(buy_q) + float(semi_q) + float(prod_q)
@staticmethod
def _parse_emails(email_str: str) -> list:
"""从逗号分隔字符串中提取并清洗有效邮箱列表"""
if not email_str or not email_str.strip():
return []
return [e.strip() for e in email_str.split(',') if e.strip() and '@' in e.strip()]
@staticmethod
def _build_text_table(rows: list, level: str) -> str:
"""
构建纯文本物料清单表格
Args:
rows: [{"name": ..., "spec": ..., "qty": ..., "threshold": ...}, ...]
level: "red""yellow",决定阈值列标题
"""
threshold_label = "红色阈值" if level == "red" else "黄色阈值"
lines = [
"名称 | 规格 | 当前库存 | " + threshold_label,
"-" * 60,
]
for r in rows:
name = r.get('name', '-') or '-'
spec = r.get('spec', '-') or '-'
qty = r.get('qty', '-')
th = r.get('threshold', '-')
lines.append(f"{name} | {spec} | {qty} | {th}")
return '\n'.join(lines)
@staticmethod
def check_and_send_warning_emails() -> dict:
"""
执行库存预警扫描与邮件发送
1. 查询所有 is_enabled=True 且 is_ordered=False 的预警配置
2. 按 level 归类物料,按邮箱聚合(同一邮箱 → 一封邮件)
3. 调用 send_email 发送,更新 last_notified_at
Returns:
{
"red_count": N, # 触发红色预警的物料数
"yellow_count": N, # 触发黄色预警的物料数
"red_sent": True/False,
"yellow_sent": True/False,
"timestamp": "..."
}
"""
from app.utils.email_service import send_email
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
# 查询启用了预警且未标记采购的配置
settings = MaterialWarningSetting.query.filter(
MaterialWarningSetting.is_enabled == True,
MaterialWarningSetting.is_ordered == False
).all()
red_rows_by_email = defaultdict(list) # email -> [物料row, ...]
yellow_rows_by_email = defaultdict(list)
total_red = 0
total_yellow = 0
sent_red = False
sent_yellow = False
processed_settings = []
for setting in settings:
base_id = setting.base_id
material = MaterialBase.query.get(base_id)
if not material:
continue
name = material.name
spec = material.spec_model or ''
red_th = float(setting.red_threshold) if setting.red_threshold is not None else None
yellow_th = float(setting.yellow_threshold) if setting.yellow_threshold is not None else None
inv = InventoryWarningService._get_total_inventory(base_id)
# ★ 红色预警:库存 <= red_threshold走 setting.red_emails ★
if red_th is not None and inv <= red_th:
total_red += 1
red_emails = InventoryWarningService._parse_emails(setting.red_emails)
if red_emails:
processed_settings.append(setting)
row = {
'name': name,
'spec': spec,
'qty': round(inv, 2),
'threshold': round(red_th, 2),
}
for email in red_emails:
red_rows_by_email[email].append(row)
else:
print(f"[InventoryWarning] 物料「{name}」红单跳过:无 red_emails 配置")
# ★ 黄色预警red_threshold < 库存 <= yellow_threshold走 setting.yellow_emails ★
elif (
(red_th is not None and yellow_th is not None and red_th < inv <= yellow_th)
or (red_th is None and yellow_th is not None and inv <= yellow_th)
):
total_yellow += 1
yellow_emails = InventoryWarningService._parse_emails(setting.yellow_emails)
if yellow_emails:
processed_settings.append(setting)
row = {
'name': name,
'spec': spec,
'qty': round(inv, 2),
'threshold': round(yellow_th, 2),
}
for email in yellow_emails:
yellow_rows_by_email[email].append(row)
else:
print(f"[InventoryWarning] 物料「{name}」黄单跳过:无 yellow_emails 配置")
else:
continue
# ★ 按邮箱聚合,批量发送红色预警邮件 ★
for email, rows in red_rows_by_email.items():
table = InventoryWarningService._build_text_table(rows, 'red')
subject = f"【红色预警】库存告急(共 {len(rows)} 条)"
content = (
f"您好,\n\n"
f"以下物料当前库存已达到红色预警阈值,请立即处理采购:\n\n"
f"{table}\n\n"
"详情请登录仓库管理系统查看。\n\n"
"此邮件由系统自动发送,请勿回复。"
)
send_email(email, subject, content)
sent_red = True
# ★ 按邮箱聚合,批量发送黄色预警邮件 ★
for email, rows in yellow_rows_by_email.items():
table = InventoryWarningService._build_text_table(rows, 'yellow')
subject = f"【黄色预警】库存偏低(共 {len(rows)} 条)"
content = (
f"您好,\n\n"
f"以下物料当前库存已达到黄色预警阈值,请关注采购进度:\n\n"
f"{table}\n\n"
"详情请登录仓库管理系统查看。\n\n"
"此邮件由系统自动发送,请勿回复。"
)
send_email(email, subject, content)
sent_yellow = True
# ★ 批量更新 last_notified_at ★
if processed_settings:
for s in processed_settings:
s.last_notified_at = now
db.session.commit()
return {
'red_count': total_red,
'yellow_count': total_yellow,
'red_sent': sent_red,
'yellow_sent': sent_yellow,
'timestamp': now.strftime('%Y-%m-%d %H:%M:%S')
}

View File

@ -2,7 +2,7 @@ import uuid # .material -> .base refactor checked
from datetime import datetime, timezone, timedelta
from sqlalchemy import or_, func, desc, and_
from app.extensions import db
from app.models.outbound import TransOutbound
from app.models.outbound import TransOutbound, OutboundApproval
# 引入所有库存模型以进行查询
from app.models.inbound.buy import StockBuy
@ -10,6 +10,10 @@ from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
# 引入基础信息表
from app.models.base import MaterialBase
# 引入维修单表
from app.models.transaction import TransRepair
# 引入系统用户表
from app.models.system import SysUser
class OutboundService:
@ -75,6 +79,31 @@ class OutboundService:
res['price'] = get_price(buy, 'stock_buy')
return res
# 查询维修单表 (按SKU或序列号查询排除已出库状态)
repair = TransRepair.query.filter(
or_(TransRepair.sku == clean_code, TransRepair.serial_number == clean_code)
).filter(
TransRepair.repair_status != '已出库'
).first()
if repair:
res = {
'id': repair.id,
'sku': repair.sku,
'name': repair.material_name or "维修件",
'spec_model': "",
'category': "",
'material_type': "",
'source_table': 'trans_repair',
'stock_quantity': 1,
'available_quantity': 1,
'batch_number': repair.serial_number or '',
'serial_number': repair.serial_number or '',
'warehouse_location': repair.customer_location or '',
'barcode': repair.sku,
'price': float(repair.sale_price) if repair.sale_price else 0
}
return res
return None
@staticmethod
@ -142,6 +171,22 @@ class OutboundService:
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
# ★ 审批单相关逻辑
request_id = data.get('request_id')
approval = None
if request_id:
# 根据 request_id 查询审批单
approval = OutboundApproval.query.get(request_id)
if not approval:
raise ValueError(f"关联的审批单不存在 (ID: {request_id})")
if approval.status != 1:
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'}
current_status = status_map.get(approval.status, str(approval.status))
raise ValueError(
f"关联的审批单状态不允许出库 (当前状态: {current_status})"
f"仅已通过的审批单方可执行出库"
)
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
@ -158,6 +203,30 @@ class OutboundService:
if quantity <= 0:
raise ValueError(f"SKU {item.get('sku')} 的出库数量必须大于0")
# 处理维修单出库
if source_table == 'trans_repair':
repair = TransRepair.query.with_for_update().get(stock_id)
if not repair:
raise ValueError(f"维修单不存在 (ID: {stock_id})")
# 更新维修单状态为已出库
repair.repair_status = '已出库'
repair.shipping_date = current_time
# 创建出库记录
new_record = TransOutbound(
sku=item.get('sku'),
source_table=source_table,
stock_id=stock_id,
barcode=item.get('barcode'),
quantity=quantity,
unit_price=unit_price,
outbound_time=current_time,
**common_data
)
db.session.add(new_record)
continue
ModelClass = model_map.get(source_table)
if not ModelClass:
continue
@ -184,6 +253,18 @@ class OutboundService:
)
db.session.add(new_record)
# ★ 出库后检查低库存预警
try:
from app.utils.stock_alert import check_and_alert
check_and_alert(stock_record.base_id)
except Exception as e:
current_app.logger.warning(f"⚠️ 低库存预警检查失败: {e}")
# ★ 如果关联了审批单,出库成功后更新审批单状态为"已完成"
if approval:
approval.status = 3 # 3-已完成
# updated_at 会在 commit 时由 SQLAlchemy 自动更新
db.session.commit()
return outbound_no
@ -418,8 +499,6 @@ class OutboundService:
ModelClass = model_map.get(d.source_table)
if ModelClass and d.stock_id:
# 注意这里在循环中查询可能会有N+1问题但考虑到单页数据量通常每单条目不多暂时可接受
# 生产环境建议优化为预加载或批量查询
try:
stock_item = ModelClass.query.get(d.stock_id)
if stock_item:
@ -474,3 +553,388 @@ class OutboundService:
'pages': pagination.pages,
'current_page': page
}
class OutboundApprovalService:
"""出库审批服务"""
@staticmethod
def generate_request_no():
"""
生成审批单号: APR-OUT-yyyyMMdd-HHmm-当日流水(4位)
"""
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
date_str = now.strftime('%Y%m%d')
time_str = now.strftime('%H%M')
prefix = f"APR-OUT-{date_str}-"
from app.models.outbound import OutboundApproval
latest = db.session.query(OutboundApproval.request_no).filter(
OutboundApproval.request_no.like(f"{prefix}%")
).order_by(OutboundApproval.id.desc()).first()
if latest:
last_seq = int(latest[0].split('-')[-1])
sequence = last_seq + 1
else:
sequence = 1
return f"APR-OUT-{date_str}-{time_str}-{sequence:04d}"
@staticmethod
def create_request(applicant_id, items, allowed_approvers, remark=None, approver_id=None):
"""
创建出库审批单(申请阶段,直接存储前端传来的物料信息快照,不关联具体库存记录)
Args:
applicant_id: 申请人ID
items: 出库物品明细列表,每个物品应包含:
- name: 物料名称 (必填)
- spec_model: 规格型号 (必填)
- quantity: 计划出库数量 (必填)
- warehouse_location: 库位 (可选)
- remark: 物品备注 (可选)
allowed_approvers: 允许审批的人员/角色列表
approver_id: 指定审批人ID可选传则覆盖 allowed_approvers
remark: 申请说明
Returns:
OutboundApproval 实例
Raises:
ValueError: 当 items 为空或缺少必填字段时抛出
"""
from app.models.outbound import OutboundApproval
# 校验 items 非空
if not items:
raise ValueError("出库物品列表不能为空")
# 校验每个物品的宏观字段 (name, spec_model, quantity)
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() == '']
if missing_fields:
raise ValueError(
f"{idx + 1} 条物品缺少必填字段: {', '.join(missing_fields)}"
f"必须包含: name, spec_model, quantity"
)
try:
qty = float(item.get('quantity', 0))
if qty <= 0:
raise ValueError(f"{idx + 1} 条物品的出库数量必须大于0")
except (TypeError, ValueError) as e:
raise ValueError(f"{idx + 1} 条物品的 quantity 格式无效: {str(e)}")
# ★ 校验 allowed_approvers 非空
if not allowed_approvers:
raise ValueError("必须指定至少一位审批人")
# ★ 指定审批人模式approver_id 覆盖 allowed_approvers
if approver_id:
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
request_no = OutboundApprovalService.generate_request_no()
approval = OutboundApproval(
request_no=request_no,
applicant_id=applicant_id,
remark=remark,
status=0, # 待审批
)
# 直接存储前端传来的物料信息快照,不查询/不关联具体库存记录
approval.set_items(items)
approval.set_allowed_approvers(allowed_approvers)
db.session.add(approval)
db.session.commit()
# ★ 创建成功后,发送邮件通知审批人(精确通知 approver_id 对应的邮箱)
OutboundApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id)
return approval
@staticmethod
def _get_emails_by_identifiers(applicant_id=None, role_codes=None):
"""
根据用户ID或角色列表查询邮箱地址
Args:
applicant_id: 用户ID (按 SysUser.id 查找)
role_codes: 角色代码列表,如 ['ADMIN', 'WAREHOUSE_ADMIN']
Returns:
去重后的邮箱地址列表
"""
emails = []
if applicant_id:
user = SysUser.query.get(int(applicant_id))
if user and user.email:
emails.append(user.email)
if role_codes:
for code in role_codes:
users = SysUser.query.filter_by(role=code).all()
for u in users:
if u.email:
emails.append(u.email)
return list(set(emails))
@staticmethod
def _notify_new_request(approval, applicant_id, approver_id=None):
"""发送新申请通知邮件给审批人(静默处理,不阻断主流程)"""
try:
from flask import current_app
from app.utils.email_service import send_new_request_notify
emails = []
if approver_id:
# ★ 精准通知模式:直接查询指定审批人
user = SysUser.query.get(int(approver_id))
if user and user.email:
emails.append(user.email)
else:
# 兜底:按角色查询
approvers = approval.get_allowed_approvers()
role_codes = []
for a in approvers:
if a.get('type') == 'role':
role_codes.append(a.get('value', ''))
emails = OutboundApprovalService._get_emails_by_identifiers(role_codes=role_codes)
if not emails:
current_app.logger.info(f"[Email] 审批单 {approval.request_no} 无审批人邮箱,跳过通知")
return
# 获取申请人姓名
applicant_name = ''
if applicant_id:
u = SysUser.query.get(applicant_id)
if u:
# username 格式为 "姓名/账号",取姓名部分
applicant_name = str(u.username).split('/')[0] if '/' in u.username else (u.username or str(applicant_id))
# ★ 发送通知,附完整物料清单
items = approval.get_items()
send_new_request_notify(
to_emails=emails,
request_no=approval.request_no,
applicant_name=applicant_name,
remark=approval.remark or '',
items=items
)
except Exception as e:
# ★ 捕获所有异常,确保邮件发送失败不阻断主流程
try:
from flask import current_app
current_app.logger.error(f"[Email] 发送新申请通知邮件失败: {e}")
except RuntimeError:
# 如果不在 Flask 应用上下文内,降级为标准日志
import logging
logging.getLogger(__name__).error(f"[Email] 发送新申请通知邮件失败: {e}")
@staticmethod
def can_approve(approval, user_id, user_role):
"""
检查用户是否有权限审批
Args:
approval: OutboundApproval 实例
user_id: 用户ID
user_role: 用户角色
Returns:
bool, 是否有权限
"""
approvers = approval.get_allowed_approvers()
# 超级管理员可以直接审批
if user_role and user_role.upper() == 'SUPER_ADMIN':
return True
for approver in approvers:
approver_type = approver.get('type', '')
approver_value = approver.get('value', '')
if approver_type == 'user' and str(approver_value) == str(user_id):
return True
if approver_type == 'role' and approver_value == user_role:
return True
return False
@staticmethod
def approve(request_id, user_id, user_role, action='approve', reject_reason=None):
"""
执行审批操作
Args:
request_id: 审批单ID
user_id: 审批人ID
user_role: 审批人角色
action: 'approve' 通过, 'reject' 驳回
reject_reason: 驳回原因
Returns:
(success: bool, message: str, approval: OutboundApproval or None)
"""
from app.models.outbound import OutboundApproval
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
approval = OutboundApproval.query.get(request_id)
if not approval:
return False, "审批单不存在", None
if approval.status != 0:
return False, f"审批单状态已更新,无法重复审批 (当前状态: {approval.status})", None
if not OutboundApprovalService.can_approve(approval, user_id, user_role):
return False, "您没有审批此单的权限", None
try:
if action == 'approve':
approval.status = 1 # 已通过
approval.actual_approver_id = user_id
approval.approved_at = current_time
elif action == 'reject':
approval.status = 2 # 已驳回
approval.reject_reason = reject_reason
else:
return False, "无效的审批操作", None
db.session.commit()
# ★ 审批成功后,发送邮件通知仓库管理员
OutboundApprovalService._notify_approval_result(approval, user_id, action)
return True, "审批成功", approval
except Exception as e:
db.session.rollback()
return False, f"审批失败: {str(e)}", None
@staticmethod
def _notify_approval_result(approval, approver_id, action):
"""发送审批结果通知邮件(静默处理,不阻断主流程)"""
import logging
logger = logging.getLogger(__name__)
try:
from app.utils.email_service import send_approval_result_notify, send_warehouse_dispatch_notify
from app.models.system import SysUser as SU
# 1. 提取申请人信息(供两个分支使用)
applicant_name = ''
applicant_emails = []
if approval.applicant_id:
user = SU.query.get(approval.applicant_id)
if user:
applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or '')
if user.email:
applicant_emails.append(user.email)
# 2. 提取物料明细(供通过分支使用)
items = approval.items_json if approval.items_json else []
# 3. 分支逻辑
if action == 'approve':
# 3.1 通知库管(带明细)
warehouse_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND']
warehouse_emails = OutboundApprovalService._get_emails_by_identifiers(role_codes=warehouse_role_codes)
if warehouse_emails:
try:
send_warehouse_dispatch_notify(
to_emails=warehouse_emails,
request_no=approval.request_no,
applicant_name=applicant_name,
items=items
)
except Exception as e:
logger.error(f"[Email] 通知库管失败: {e}")
# 3.2 通知申请人(已通过)
if applicant_emails:
try:
send_approval_result_notify(
to_emails=applicant_emails,
request_no=approval.request_no,
is_passed=True,
applicant_name=applicant_name
)
except Exception as e:
logger.error(f"[Email] 通知申请人通过失败: {e}")
elif action == 'reject':
# 3.3 通知申请人(已驳回)
if applicant_emails:
try:
send_approval_result_notify(
to_emails=applicant_emails,
request_no=approval.request_no,
is_passed=False,
reject_reason=approval.reject_reason or '未说明原因',
applicant_name=applicant_name
)
except Exception as e:
logger.error(f"[Email] 通知申请人驳回失败: {e}")
else:
logger.warning("[Email] 申请人无邮箱,无法发送驳回通知")
except Exception as e:
import traceback
traceback.print_exc()
logger.error(f"[Email] 外层发送异常: {e}")
@staticmethod
def get_request_list(page=1, per_page=10, applicant_id=None, status=None):
"""
获取审批单列表
Args:
page: 页码
per_page: 每页数量
applicant_id: 按申请人筛选 (可选)
status: 按状态筛选 (可选)
Returns:
分页结果
"""
from app.models.outbound import OutboundApproval
from sqlalchemy import desc
query = OutboundApproval.query
if applicant_id:
query = query.filter(OutboundApproval.applicant_id == applicant_id)
if status is not None:
query = query.filter(OutboundApproval.status == status)
query = query.order_by(desc(OutboundApproval.created_at))
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
return {
'items': [item.to_dict() for item in pagination.items],
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
}
@staticmethod
def get_request_by_id(request_id):
"""根据ID获取审批单"""
from app.models.outbound import OutboundApproval
return OutboundApproval.query.get(request_id)

View File

@ -109,8 +109,10 @@ class PermissionService:
try:
# 1. 开启事务 (Flask-SQLAlchemy 自动管理,但明确逻辑更好)
# 2. 删除该角色旧的所有权限
SysRolePermission.query.filter_by(role_code=role_code).delete()
# 2. 删除该角色旧的所有权限(改为对象级删除以触发审计事件)
old_perms = SysRolePermission.query.filter_by(role_code=role_code).all()
for p in old_perms:
db.session.delete(p)
# 3. 准备新数据
if permissions:
@ -374,10 +376,14 @@ class PermissionService:
).all()
for menu in legacy_menus:
# 删除关联的权限
SysRolePermission.query.filter_by(target_code=menu.code).delete()
# 删除关联的元素
SysElement.query.filter_by(menu_code=menu.code).delete()
# 删除关联的权限(改为对象级删除以触发审计事件)
old_perms = SysRolePermission.query.filter_by(target_code=menu.code).all()
for p in old_perms:
db.session.delete(p)
# 删除关联的元素(改为对象级删除以触发审计事件)
old_elements = SysElement.query.filter_by(menu_code=menu.code).all()
for e in old_elements:
db.session.delete(e)
# 删除菜单
db.session.delete(menu)
print(f"🗑️ 已清理旧版库存盘点菜单: {menu.code} ({menu.name})")
@ -429,6 +435,7 @@ class PermissionService:
('outbound_selection', '出库选单', '/outbound/selection', 'outbound_mgmt', 1),
('outbound_create', '扫码出库', '/outbound/create', 'outbound_mgmt', 2),
('outbound_list', '出库记录', '/outbound/index', 'outbound_mgmt', 3),
('outbound_approval', '出库审批', '/outbound/approval', 'outbound_mgmt', 4),
# BOM管理子菜单
('bom_manage', 'BOM配方管理', '/bom/manage', 'bom_mgmt', 1),
@ -456,8 +463,10 @@ class PermissionService:
).all()
for menu in orphaned_menus:
print(f"🗑️ 清理根级别冗余菜单: {menu.code} ({menu.name})")
# 删除关联的权限
SysRolePermission.query.filter_by(target_code=menu.code).delete()
# 删除关联的权限(改为对象级删除以触发审计事件)
old_perms = SysRolePermission.query.filter_by(target_code=menu.code).all()
for p in old_perms:
db.session.delete(p)
db.session.delete(menu)
# 第二步:清理重复菜单(同一个 code 存在多条记录,保留 ID 最小的)
@ -473,13 +482,20 @@ class PermissionService:
# 保留第一条,删除其他
for dup in duplicates[1:]:
print(f"🗑️ 清理重复菜单: {dup.code} (id={dup.id}, name={dup.name})")
SysRolePermission.query.filter_by(target_code=dup.code).delete()
SysElement.query.filter_by(menu_code=dup.code).delete()
# 改为对象级删除以触发审计事件
old_perms = SysRolePermission.query.filter_by(target_code=dup.code).all()
for p in old_perms:
db.session.delete(p)
old_elements = SysElement.query.filter_by(menu_code=dup.code).all()
for e in old_elements:
db.session.delete(e)
db.session.delete(dup)
# 第三步:强制重新设置所有子菜单的 parent_id确保没有遗漏
# 先将所有子菜单的 parent_id 设为 None然后重新设置
SysMenu.query.filter(SysMenu.code.in_(child_codes)).update({SysMenu.parent_id: None})
# 改为对象级更新以触发审计事件
child_menus = SysMenu.query.filter(SysMenu.code.in_(child_codes)).all()
for m in child_menus:
m.parent_id = None
# 创建或更新菜单
menu_map = {} # code -> menu obj

View File

@ -0,0 +1,323 @@
# inventory-backend/app/utils/audit_events.py
"""
全局无侵入的审计日志拦截器
监听所有模型的增删改操作,自动提取旧值和新值存入 audit_logs 表
完美对接前端 AuditLog.vue 的解析逻辑 (changes, deleted_snapshot, created)
"""
import json
from datetime import datetime, date
from decimal import Decimal
from flask import request, has_request_context
from sqlalchemy import event, text
class AuditJSONEncoder(json.JSONEncoder):
"""JSON 序列化增强器,支持 datetime/Decimal 等特殊类型"""
def default(self, obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj)
return str(obj)
def model_to_dict(obj):
"""将 SQLAlchemy 模型实例转换为字典"""
return {c.name: getattr(obj, c.name) for c in obj.__table__.columns}
def get_current_user_info():
"""
从当前 HTTP 请求上下文中提取用户信息
兼容 JWT 和匿名访问
"""
user_info = {
'user_id': 'system',
'username': 'system',
'display_name': 'System',
'ip_address': '127.0.0.1',
'method': 'SYSTEM',
'url': ''
}
if has_request_context():
# 获取 IP 地址
user_info['ip_address'] = request.headers.get('X-Forwarded-For', '') or request.remote_addr or '127.0.0.1'
if ',' in user_info['ip_address']:
user_info['ip_address'] = user_info['ip_address'].split(',')[0].strip()
user_info['method'] = request.method
user_info['url'] = request.path
# 尝试从 JWT 获取用户信息
try:
from flask_jwt_extended import get_jwt_identity, get_jwt
user_id = get_jwt_identity()
claims = get_jwt()
if user_id:
user_info['user_id'] = str(user_id)
if claims:
user_info['username'] = claims.get('username', 'unknown')
user_info['display_name'] = claims.get('display_name', claims.get('username', 'Unknown'))
except Exception:
pass
return user_info
def serialize_value(value):
"""序列化单个值,确保 JSON 兼容"""
if value is None:
return None
if isinstance(value, (datetime, date)):
return value.strftime('%Y-%m-%d %H:%M:%S')
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (bytes, bytearray)):
try:
return value.decode('utf-8')
except Exception:
return '[二进制数据]'
return value
# 需要忽略的审计字段(时间戳等自动维护字段)
IGNORE_FIELDS = {
'updated_at', 'update_time', 'modified_time', 'last_modified',
'created_at', 'create_time', 'created_on', 'version',
}
# 审计日志表名
AUDIT_TABLE = 'audit_logs'
# 不需要审计的表
IGNORE_TABLES = {'audit_logs', 'sys_log', 'syslog', 'alembic_version'}
def insert_audit_log(connection, action, target, details):
"""
使用 connection.execute 直接插入审计日志
避免干扰当前 session 事务,自动随主事务一起提交/回滚
"""
tablename = target.__tablename__
# 严禁监听日志表本身,防止无限递归
if tablename in IGNORE_TABLES:
return
# 获取目标 ID
target_id = ''
if hasattr(target, 'id'):
target_id = str(target.id)
elif hasattr(target, 'stock_id'):
target_id = str(target.stock_id)
elif hasattr(target, 'uuid'):
target_id = str(target.uuid)
elif hasattr(target, 'bom_no'):
target_id = str(target.bom_no)
# 获取目标名称(用于展示)
target_name = ''
for name_field in ['name', 'title', 'material_name', 'product_name', 'display_name', 'username']:
if hasattr(target, name_field):
val = getattr(target, name_field)
if val:
target_name = str(val)
break
# 如果当前表没名字,但它有关联的物料对象 (比如 material.name)
if not target_name and hasattr(target, 'material') and target.material:
target_name = getattr(target.material, 'name', '')
# 如果当前表有 material_id尝试从关联的 material 表查询名称
if not target_name and hasattr(target, 'material_id') and target.material_id:
try:
# 使用 connection 查询物料表获取名称
result = connection.execute(
text("SELECT name FROM material_base WHERE id = :id"),
{'id': target.material_id}
).fetchone()
if result:
target_name = str(result[0])
except Exception:
pass
# 如果实在找不到名字,再用 表名 + ID 兜底
if not target_name:
target_name = f"{tablename} ID:{target_id}"
user_info = get_current_user_info()
# 推断模块名称
module = _infer_module_name(tablename, target)
# 使用原始 SQL 插入,确保事务一致性
sql = text("""
INSERT INTO audit_logs
(user_id, username, display_name, action, module, target_id, target_name, details, ip_address, method, url, created_at)
VALUES
(:user_id, :username, :display_name, :action, :module, :target_id, :target_name, :details, :ip_address, :method, :url, :created_at)
""")
connection.execute(sql, {
'user_id': user_info['user_id'],
'username': user_info['username'],
'display_name': user_info['display_name'],
'action': action,
'module': module,
'target_id': target_id,
'target_name': target_name,
'details': json.dumps(details, cls=AuditJSONEncoder),
'ip_address': user_info['ip_address'],
'method': user_info['method'],
'url': user_info['url'],
'created_at': datetime.now()
})
def _infer_module_name(tablename, target):
"""根据表名或模型类推断所属模块"""
class_name = target.__class__.__name__
if any(kw in class_name for kw in ['Stock', 'Buy', 'Inbound']):
return '入库管理'
if any(kw in class_name for kw in ['Outbound']):
return '出库管理'
if any(kw in class_name for kw in ['Borrow', 'Return']):
return '借还管理'
if any(kw in class_name for kw in ['Repair']):
return '维修管理'
if any(kw in class_name for kw in ['Scrap']):
return '报废管理'
if any(kw in class_name for kw in ['Bom', 'BOM']):
return 'BOM管理'
if any(kw in class_name for kw in ['StockTake', 'StockAdjust', 'Adjustment']):
return '盘点管理'
if any(kw in class_name for kw in ['Material', 'Base']):
return '基础数据'
if any(kw in class_name for kw in ['SysUser', 'SysMenu', 'SysRole', 'SysPermission']):
return '系统管理'
if any(kw in class_name for kw in ['Warehouse', 'Location']):
return '库位管理'
return tablename or '未知模块'
def _has_changes(history):
"""检查历史记录对象是否有变更"""
return history.has_changes()
def register_audit_events(db):
"""
全局注册审计事件监听器
监听所有模型的 INSERT/UPDATE/DELETE 事件
"""
from sqlalchemy import inspect
@event.listens_for(db.Model, 'before_update', propagate=True)
def before_update_listener(mapper, connection, target):
"""UPDATE 事件:抓取字段变更明细"""
if target.__tablename__ in IGNORE_TABLES:
return
try:
state = inspect(target)
changes = {}
for attr in state.attrs:
prop = attr.key
# 跳过忽略字段
if prop in IGNORE_FIELDS:
continue
# 跳过关系属性
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
continue
if _has_changes(attr.history):
old_value = attr.history.deleted[0] if attr.history.deleted else None
new_value = attr.history.added[0] if attr.history.added else None
# 序列化值
old_serialized = serialize_value(old_value)
new_serialized = serialize_value(new_value)
# 只记录真正变化的字段
if old_serialized != new_serialized:
changes[prop] = {
'old': old_serialized,
'new': new_serialized
}
if changes:
insert_audit_log(connection, 'UPDATE', target, {'changes': changes})
except Exception as e:
import logging
logging.error(f"Audit Update Error: {e}")
@event.listens_for(db.Model, 'before_delete', propagate=True)
def before_delete_listener(mapper, connection, target):
"""DELETE 事件:抓取被删除对象的完整快照"""
if target.__tablename__ in IGNORE_TABLES:
return
try:
state = inspect(target)
snapshot = {}
for attr in state.attrs:
prop = attr.key
# 跳过忽略字段
if prop in IGNORE_FIELDS:
continue
# 跳过关系属性
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
continue
value = getattr(target, prop, None)
snapshot[prop] = serialize_value(value)
insert_audit_log(connection, 'DELETE', target, {'deleted_snapshot': snapshot})
except Exception as e:
import logging
logging.error(f"Audit Delete Error: {e}")
@event.listens_for(db.Model, 'after_insert', propagate=True)
def after_insert_listener(mapper, connection, target):
"""INSERT 事件:抓取新增对象的完整快照"""
if target.__tablename__ in IGNORE_TABLES:
return
try:
state = inspect(target)
snapshot = {}
for attr in state.attrs:
prop = attr.key
# 跳过忽略字段
if prop in IGNORE_FIELDS:
continue
# 跳过关系属性
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
continue
value = getattr(target, prop, None)
snapshot[prop] = serialize_value(value)
insert_audit_log(connection, 'CREATE', target, {'created': snapshot})
except Exception as e:
import logging
logging.error(f"Audit Insert Error: {e}")
# 返回注册成功信息
return True

View File

@ -1,11 +1,10 @@
# app/utils/decorators.py
from functools import wraps
from flask_jwt_extended import get_jwt, verify_jwt_in_request, get_jwt_identity
from flask import jsonify, g, request
from flask import jsonify, g, request, current_app, has_request_context
import logging
import json
def _verify_token_in_redis():
"""
验证当前 Token 是否与 Redis 中存储的 Token 一致(单设备登录互踢)
@ -14,31 +13,23 @@ def _verify_token_in_redis():
from flask import current_app
if redis_client is None:
# Redis 不可用,跳过验证
return True
try:
# 获取请求中的 Token
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return True
request_token = auth_header[7:] # 去掉 'Bearer ' 前缀
# 获取当前用户 ID
request_token = auth_header[7:]
claims = get_jwt()
user_id = claims.get('sub')
if user_id is None:
return True
# 从 Redis 获取存储的 Token
stored_token = redis_client.get(f"user_token_{user_id}")
# 如果 Redis 中没有存储的 Token可能是旧登录或 Redis 重启),允许通过
if stored_token is None:
return True
# 比较 Token 是否一致
if request_token != stored_token:
current_app.logger.warning(f"Token mismatch for user {user_id}: request token != stored token")
return False
@ -46,25 +37,18 @@ def _verify_token_in_redis():
return True
except Exception as e:
current_app.logger.error(f"Redis token verification error: {e}")
# 出错时默认放行,避免影响正常业务
return True
def _raise_token_mismatch_error():
"""抛出 Token 不一致的错误(用于单设备登录互踢)"""
"""抛出 Token 不一致的错误"""
return jsonify({
'msg': '您的账号已在其他设备登录,请重新登录',
'code': 401,
'reason': 'token_mismatch'
}), 401
def role_required(*roles):
"""
自定义装饰器:检查用户角色
使用方法: @role_required('super_admin', 'finance')
"""
"""自定义装饰器:检查用户角色"""
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
@ -72,7 +56,6 @@ def role_required(*roles):
user_role = claims.get('role')
user_role_upper = user_role.upper() if user_role else None
# 如果是超级管理员,拥有上帝视角,直接放行 (可选)
if user_role_upper == 'SUPER_ADMIN':
return fn(*args, **kwargs)
@ -80,16 +63,11 @@ def role_required(*roles):
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
return fn(*args, **kwargs)
return decorator
return wrapper
def login_required(fn):
"""
验证 JWT 令牌是否存在且有效
"""
"""验证 JWT 令牌是否存在且有效"""
@wraps(fn)
def decorator(*args, **kwargs):
try:
@ -98,40 +76,31 @@ def login_required(fn):
logging.warning(f"JWT verification failed: {e}")
return jsonify(msg='登录已过期,请重新登录'), 401
# 单设备登录互踢检查
if not _verify_token_in_redis():
return _raise_token_mismatch_error()
return fn(*args, **kwargs)
return decorator
def permission_required(permission_code):
"""
检查当前用户是否拥有指定权限码
使用方法: @permission_required('material:base:read')
"""
"""检查当前用户是否拥有指定权限码"""
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
# 首先验证 JWT
try:
verify_jwt_in_request()
except Exception as e:
logging.warning(f"JWT verification failed: {e}")
return jsonify(msg='登录已过期,请重新登录'), 401
# 单设备登录互踢检查
if not _verify_token_in_redis():
return _raise_token_mismatch_error()
claims = get_jwt()
user_role = claims.get('role')
# 超级管理员放行 (忽略大小写)
if user_role and user_role.upper() == 'SUPER_ADMIN':
return fn(*args, **kwargs)
# 根据角色查询数据库中的权限
try:
from app.services.auth_service import AuthService
perm_dict = AuthService.get_user_permissions(user_role)
@ -139,192 +108,24 @@ def permission_required(permission_code):
logging.warning(f"Failed to fetch permissions for role {user_role}: {e}")
return jsonify(msg='权限查询失败'), 403
# 合并菜单和元素权限
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
if permission_code not in all_perms:
# 详细的调试日志
print(f"🔴 [权限拦截] 角色 '{user_role}' 访问被拒!需要权限码: '{permission_code}', 但该角色实际拥有: {all_perms}")
logging.warning(
f"权限检查失败: 角色={user_role}, 所需权限={permission_code}, 实际权限列表={all_perms}")
logging.warning(f"权限检查失败: 角色={user_role}, 所需权限={permission_code}")
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
return fn(*args, **kwargs)
return decorator
return wrapper
def audit_log(module: str, action: str = None, get_target_id_fn=None, get_target_name_fn=None, get_details_fn=None):
def audit_log(module: str = None, action: str = None, get_target_id_fn=None, get_target_name_fn=None, get_details_fn=None):
"""
审计日志装饰器
用法: @audit_log(module='inbound_buy', action='create')
@audit_log(module='bom', action='update', get_target_id_fn=lambda: ..., get_details_fn=lambda req, resp: ...)
升级特性:
- 自动捕获请求 Payload 作为变更明细
- 自动过滤过长的 Base64 图片数据
- 支持自定义 get_details_fn 覆盖默认行为
已废弃!
由 SQLAlchemy 底层监听器app/core/audit_listener.py全面接管审计日志入库。
此装饰器保留空壳以防项目中其他文件 import 引用时报错。
"""
# 需要过滤的图片字段
IMAGE_FIELDS = {'arrival_photo', 'product_photo', 'photo', 'image', 'signature', 'borrow_signature', 'return_signature'}
def _filter_payload(payload):
"""过滤 Payload 中的大字段,防止数据库膨胀"""
if not payload or not isinstance(payload, dict):
return payload
filtered = {}
for key, value in payload.items():
if key.lower() in IMAGE_FIELDS and isinstance(value, str) and len(value) > 100:
filtered[key] = '[图片数据已省略]'
elif isinstance(value, dict):
filtered[key] = _filter_payload(value)
elif isinstance(value, list):
filtered[key] = [
_filter_payload(item) if isinstance(item, dict) else item
for item in value
]
else:
filtered[key] = value
return filtered
def _get_payload():
"""自动获取请求 Payload"""
# 尝试 JSON
payload = request.get_json(silent=True)
if payload:
return payload
# 尝试 Form Data
if request.form:
return request.form.to_dict()
return None
def wrapper(fn):
from functools import wraps
@wraps(fn)
def decorator(*args, **kwargs):
# 获取请求上下文
claims = get_jwt()
user_id = get_jwt_identity()
username = claims.get('username', '')
display_name = claims.get('display_name', '')
# ★ 修复 DetachedInstanceError在 fn() 执行前预先获取用户完整信息
# 这样可以避免在 fn() 提交 session 后再访问 User 对象导致游离
if not display_name and user_id:
try:
from app.models.system import SysUser
user = SysUser.query.get(user_id)
if user:
display_name = user.display_name or username
except Exception:
pass
# 预先获取 IP避免后续访问 request 对象异常)
ip_address = request.headers.get('X-Forwarded-For') or request.remote_addr or ''
if ip_address and ',' in ip_address:
ip_address = ip_address.split(',')[0].strip()
# 获取请求信息
http_method = request.method
url = request.url
user_agent = request.headers.get('User-Agent', '')[:500]
# 解析 action支持动态
final_action = action
if callable(action):
final_action = action()
# 预先获取 Payload用于后续 details 记录)
raw_payload = _get_payload()
filtered_payload = _filter_payload(raw_payload) if raw_payload else None
# 执行原函数(此时 Session 可能被提交或回滚)
response = fn(*args, **kwargs)
# 只记录成功的请求(响应状态码 200/201
status_code = 200
if hasattr(response, 'status_code'):
status_code = response.status_code
if status_code in [200, 201]:
try:
from app.models.audit import AuditLog
from app.extensions import db
from flask import current_app
# ★ 已在上方预先获取 display_name此处无需再查询 User 对象
# 使用预先获取的字符串数据,避免 DetachedInstanceError
# 获取 target_id
target_id = None
if get_target_id_fn:
try:
target_id = get_target_id_fn()
except Exception:
pass
if not target_id and hasattr(response, 'json'):
resp_data = response.get_json()
if resp_data and isinstance(resp_data, dict):
target_id = resp_data.get('id')
# 获取 target_name
target_name = None
if get_target_name_fn:
try:
target_name = get_target_name_fn()
except Exception:
pass
# 如果仍未获取到目标名称,尝试从响应 JSON 中常见字段获取
if not target_name and hasattr(response, 'json'):
resp_data = response.get_json()
if resp_data and isinstance(resp_data, dict):
# 优先从顶层获取
for field in ['order_no', 'outbound_no', 'borrow_no', 'adjustment_no', 'material_name']:
if field in resp_data:
target_name = resp_data[field]
break
# 再尝试从 data 字段获取(部分 API 返回格式)
if not target_name and 'data' in resp_data:
data = resp_data['data']
if isinstance(data, dict):
for field in ['order_no', 'outbound_no', 'borrow_no', 'adjustment_no', 'material_name']:
if field in data:
target_name = data[field]
break
# 获取 details
details = None
if get_details_fn:
# 优先使用自定义差异对比函数
try:
details = get_details_fn(request, response)
except Exception:
pass
elif filtered_payload:
# 默认:记录请求 Payload
details = {'payload': filtered_payload}
# 保存日志
log_entry = AuditLog(
user_id=user_id,
username=username,
display_name=display_name,
action=final_action or http_method.lower(),
module=module,
target_id=str(target_id) if target_id else None,
target_name=target_name,
details=details,
ip_address=ip_address,
user_agent=user_agent,
method=http_method,
url=url,
status_code=status_code
)
db.session.add(log_entry)
db.session.commit()
except Exception as e:
current_app.logger.error(f"审计日志记录失败: {str(e)}")
db.session.rollback()
return response
def decorator(*inner_args, **inner_kwargs):
return fn(*inner_args, **inner_kwargs)
return decorator
return wrapper
return wrapper

View File

@ -0,0 +1,256 @@
"""
邮件通知服务
使用 Python smtplib + email.mime 实现,支持 TLS/SSL SMTP 连接
从环境变量或 Flask config 读取邮件配置
"""
import os
import smtplib
import ssl
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
from typing import List, Union
logger = logging.getLogger(__name__)
def _get_config():
"""
读取邮件配置,优先从 Flask app config回退到环境变量
"""
try:
from flask import current_app
return {
'server': current_app.config.get('MAIL_SERVER', os.getenv('MAIL_SERVER')),
'port': current_app.config.get('MAIL_PORT', int(os.getenv('MAIL_PORT', 587))),
'username': current_app.config.get('MAIL_USERNAME', os.getenv('MAIL_USERNAME')),
'password': current_app.config.get('MAIL_PASSWORD', os.getenv('MAIL_PASSWORD')),
'sender': current_app.config.get('MAIL_DEFAULT_SENDER', os.getenv('MAIL_DEFAULT_SENDER')),
'use_tls': current_app.config.get('MAIL_USE_TLS', os.getenv('MAIL_USE_TLS', 'true').lower() in ('true', '1', 'yes')),
'use_ssl': current_app.config.get('MAIL_USE_SSL', os.getenv('MAIL_USE_SSL', 'false').lower() in ('true', '1', 'yes')),
'enabled': current_app.config.get('MAIL_ENABLED', os.getenv('MAIL_ENABLED', 'false').lower() in ('true', '1', 'yes')),
}
except RuntimeError:
# 不在 Flask 上下文时,直接读环境变量
return {
'server': os.getenv('MAIL_SERVER'),
'port': int(os.getenv('MAIL_PORT', 587)),
'username': os.getenv('MAIL_USERNAME'),
'password': os.getenv('MAIL_PASSWORD'),
'sender': os.getenv('MAIL_DEFAULT_SENDER'),
'use_tls': os.getenv('MAIL_USE_TLS', 'true').lower() in ('true', '1', 'yes'),
'use_ssl': os.getenv('MAIL_USE_SSL', 'false').lower() in ('true', '1', 'yes'),
'enabled': os.getenv('MAIL_ENABLED', 'false').lower() in ('true', '1', 'yes'),
}
def send_email(to_email: Union[str, List[str]], subject: str, content: str):
"""
通用邮件发送函数
Args:
to_email: 收件人,单个邮箱字符串或列表
subject: 邮件主题
content: 邮件正文(纯文本)
发送失败时打印日志,不抛出异常
"""
cfg = _get_config()
print(f"[DEBUG send_email] cfg = {cfg}")
# 发送总开关
if not cfg.get('enabled'):
print(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}")
logger.info(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}")
return
# 配置完整性检查
if not cfg.get('server') or not cfg.get('username') or not cfg.get('password'):
print(f"[Email] 邮件配置不完整 server={cfg.get('server')} username={cfg.get('username')} password={'已设' if cfg.get('password') else ''},跳过发送")
logger.warning("[Email] 邮件配置不完整 (MAIL_SERVER/USERNAME/PASSWORD 缺失),跳过发送")
return
# 标准化收件人列表
recipients = [to_email] if isinstance(to_email, str) else [r.strip() for r in to_email if r.strip()]
if not recipients:
print("[Email] 收件人地址为空,跳过发送")
logger.warning("[Email] 收件人地址为空,跳过发送")
return
try:
msg = MIMEMultipart()
msg['From'] = cfg['sender']
msg['To'] = ', '.join(recipients)
msg['Subject'] = Header(subject, 'utf-8')
msg.attach(MIMEText(content, 'plain', 'utf-8'))
print(f"DEBUG: 准备向服务器提交发信请求,收件人: {recipients} 发件人: {cfg['username']}")
if cfg.get('use_ssl'):
context = ssl.create_default_context()
with smtplib.SMTP_SSL(cfg['server'], cfg.get('port', 465), context=context) as server:
server.login(cfg['username'], cfg['password'])
server.sendmail(cfg['username'], recipients, msg.as_string())
else:
with smtplib.SMTP(cfg['server'], cfg.get('port', 587)) as server:
if cfg.get('use_tls'):
server.starttls(context=ssl.create_default_context())
server.login(cfg['username'], cfg['password'])
server.sendmail(cfg['username'], recipients, msg.as_string())
logger.info(f"[Email] 发送成功 -> {recipients}: {subject}")
except smtplib.SMTPAuthenticationError:
print(f"!!! 邮件发送核心报错: SMTPAuthenticationError - 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD授权码")
logger.error("[Email] 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD授权码")
except smtplib.SMTPRecipientsRefused as e:
print(f"!!! 邮件发送核心报错: SMTPRecipientsRefused - 收件人被服务器拒绝: {e}")
logger.error(f"[Email] 收件人被服务器拒绝: {e}")
except smtplib.SMTPException as e:
print(f"!!! 邮件发送核心报错: SMTPException - {e}")
logger.error(f"[Email] SMTP 异常: {e}")
except Exception as e:
import traceback
traceback.print_exc()
print(f"!!! 邮件发送核心报错: {type(e).__name__} - {e}")
logger.error(f"[Email] 发送邮件时发生未知异常: {e}")
def send_new_request_notify(to_emails: List[str], request_no: str,
applicant_name: str = '', remark: str = '',
items: list = None):
"""
通知审批人有新的出库申请单待审批(可附带物料清单)
Args:
to_emails: 审批人邮箱列表
request_no: 审批单号
applicant_name: 申请人姓名
remark: 申请备注
items: 物料明细列表(可选)
"""
print(f"[DEBUG send_new_request_notify] 入参 items={items}")
print(f"[DEBUG send_new_request_notify] items 类型={type(items)}, 长度={len(items) if items else 0}")
# 拼装物料表格
rows = []
rows.append("名称 | 规格 | 计划数量")
rows.append("-" * 40)
if items:
for item in items:
name = item.get('name', '-') or '-'
spec = item.get('spec_model', '-') or '-'
qty = item.get('quantity', '-') or '-'
rows.append(f"{name} | {spec} | {qty}")
else:
rows.append("(无物料明细)")
subject = f"【待审批】出库申请单 {request_no}"
content = f"""您好,
您有一笔新的出库审批申请待处理:
申请单号:{request_no}
申请人:{applicant_name or '未知'}
备注说明:{remark or ''}
物料清单如下:
{chr(10).join(rows)}
---
⚡ 快速通道:
请点击下方链接直接进入系统审批:
https://172.16.0.198/outbound/approval
---
请登录仓库管理系统进行审批。
此邮件由系统自动发送,请勿回复。
"""
send_email(to_emails, subject, content)
def send_approval_result_notify(to_emails: List[str], request_no: str,
is_passed: bool, reject_reason: str = '',
applicant_name: str = ''):
"""
通知审批结果
Args:
to_emails: 收件人邮箱列表
request_no: 审批单号
is_passed: 是否通过(通过时发给库管,驳回时发给申请人)
reject_reason: 驳回原因(仅 is_passed=False 时使用)
applicant_name: 申请人姓名(仅驳回通知时使用)
"""
if is_passed:
# ★ 发给申请人:告知已通过,去领料
subject = f"【已通过】出库申请单 {request_no}"
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"}
您的出库申请单 {request_no} 已审批通过,请联系仓库管理员领取物料。
请登录仓库管理系统查看详情。
此邮件由系统自动发送,请勿回复。
"""
else:
# ★ 发给申请人:告知被驳回
subject = f"【已驳回】出库申请单 {request_no}"
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"}
出库申请单 {request_no} 已被审批驳回。
驳回原因:{reject_reason or '未填写'}
请登录仓库管理系统查看详情,并根据驳回原因调整后重新提交申请。
此邮件由系统自动发送,请勿回复。
"""
send_email(to_emails, subject, content)
def send_warehouse_dispatch_notify(to_emails: List[str], request_no: str,
applicant_name: str = '', items: list = None):
"""
通知库管备货出库(包含完整物料清单)
Args:
to_emails: 库管邮箱列表
request_no: 审批单号
applicant_name: 申请人姓名
items: 物料明细列表,每个元素包含 name/spec_model/warehouse_location/quantity
"""
print(f"[DEBUG send_warehouse_dispatch_notify] 入参 items={items}")
print(f"[DEBUG send_warehouse_dispatch_notify] items 类型={type(items)}, 长度={len(items) if items else 0}")
rows = []
rows.append("名称 | 规格 | 库位 | 计划数量")
rows.append("-" * 50)
if items:
for item in items:
name = item.get('name', '-') or '-'
spec = item.get('spec_model', '-') or '-'
loc = item.get('warehouse_location', '-') or '-'
qty = item.get('quantity', '-') or '-'
rows.append(f"{name} | {spec} | {loc} | {qty}")
else:
rows.append("(无物料明细)")
subject = f"【待出库】出库申请单 {request_no} 已审批通过"
content = f"""您好,
出库申请单 {request_no} 已审批通过,请按以下清单准备备货:
{chr(10).join(rows)}
申请人:{applicant_name or '未知'}
请登录仓库管理系统执行"按单出库"操作。
此邮件由系统自动发送,请勿回复。
"""
send_email(to_emails, subject, content)
print(f"DEBUG: 准备向服务器提交发信请求,收件人: {to_emails}")

View File

@ -48,4 +48,24 @@ class Config:
# =========================================================
# 5. Redis 配置 (用于单设备登录互踢)
# =========================================================
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
# =========================================================
# 6. 邮件配置
# =========================================================
# 发件人邮箱(阿里企业邮箱)
MAIL_USERNAME = os.getenv('MAIL_USERNAME', 'wms@iris-rs.cn')
# 发件人邮箱密码 / 授权码
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD', 'Q7nYyyESWlaThKjx')
# SMTP 服务器地址(阿里企业邮发信服务器)
MAIL_SERVER = os.getenv('MAIL_SERVER', 'smtp.mxhichina.com')
# SMTP 端口(阿里邮箱使用 SSL 465
MAIL_PORT = int(os.getenv('MAIL_PORT', 465))
# 是否启用 TLS (587 端口通常需要)
MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'false').lower() in ('true', '1', 'yes')
# 是否启用 SSL (465 端口通常需要,阿里邮箱必须启用 SSL)
MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'true').lower() in ('true', '1', 'yes')
# 默认发件人(★ 必须与 MAIL_USERNAME 完全一致,否则阿里邮件服务器会拒绝)
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'wms@iris-rs.cn')
# 是否启用邮件发送功能(开发环境可设为 false 禁用)
MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes')

View File

@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview"
},
@ -35,4 +35,4 @@
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
}
}
}

View File

@ -4,7 +4,7 @@ import { useRouter, useRoute } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus'
import { InfoFilled, SwitchButton, UserFilled, Lock, User, ArrowDown } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { getMyProfile, changeMyPassword } from '@/api/auth'
import { getMyProfile, changeMyPassword, updateMyEmail } from '@/api/auth'
const router = useRouter()
const route = useRoute()
@ -34,13 +34,15 @@ interface ProfileData {
username: string
display_name: string
department: string
email: string
}
const profileForm = ref<ProfileData>({
id: 0,
username: '',
display_name: '',
department: ''
department: '',
email: ''
})
const passwordForm = ref({
@ -50,6 +52,62 @@ const passwordForm = ref({
const passwordFormRef = ref()
// ================================================================
// 绑定/修改邮箱
// ================================================================
const emailDialogVisible = ref(false)
const emailLoading = ref(false)
const emailFormRef = ref()
interface EmailForm {
email: string
}
const emailForm = ref<EmailForm>({
email: ''
})
const emailRules = {
email: [
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: ['blur', 'change'] }
]
}
// 打开邮箱弹窗
const openEmailDialog = () => {
emailForm.value.email = profileForm.value.email || ''
emailDialogVisible.value = true
}
// 提交邮箱修改
const submitEmailUpdate = async () => {
const formRef = emailFormRef.value
if (!formRef) return
await formRef.validate(async (valid: boolean) => {
if (valid) {
emailLoading.value = true
try {
await updateMyEmail({ email: emailForm.value.email })
ElMessage.success('邮箱绑定成功')
emailDialogVisible.value = false
// 刷新个人资料
openProfileDialog()
} catch (e: any) {
ElMessage.error(e?.response?.data?.msg || e?.message || '绑定失败')
} finally {
emailLoading.value = false
}
}
})
}
// 重置表单
const resetEmailForm = () => {
emailFormRef.value?.resetFields()
}
// 打开个人中心弹窗
const openProfileDialog = async () => {
profileDialogVisible.value = true
@ -176,7 +234,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.10(4.7部署
当前版本:V3.16(4.29部署
</span>
</footer>
@ -210,6 +268,12 @@ const handleLogout = () => {
<!-- 严格脱敏系统角色字段已移除不在此展示 -->
</div>
<div style="margin: 16px 0; text-align: center;">
<el-button type="primary" plain @click="openEmailDialog">
绑定/修改邮箱
</el-button>
</div>
<el-divider>
<el-icon><Lock /></el-icon> 修改密码
</el-divider>
@ -260,6 +324,19 @@ const handleLogout = () => {
</div>
</template>
</el-dialog>
<!-- 绑定/修改邮箱弹窗 -->
<el-dialog v-model="emailDialogVisible" title="绑定/修改邮箱" width="400px" @close="resetEmailForm">
<el-form :model="emailForm" :rules="emailRules" ref="emailFormRef" label-width="80px">
<el-form-item label="新邮箱" prop="email">
<el-input v-model="emailForm.email" placeholder="请输入有效邮箱地址" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="emailDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="emailLoading" @click="submitEmailUpdate">确认</el-button>
</template>
</el-dialog>
</div>
</template>

View File

@ -66,4 +66,30 @@ export function changeMyPassword(data: { new_password: string; confirm_password:
method: 'put',
data
})
}
// 【新增】自我更新邮箱(与密码修改完全隔离)
export function updateMyEmail(data: { email: string }) {
return request({
url: '/v1/auth/me/email',
method: 'put',
data
})
}
// 【新增】批量创建用户
export function batchCreateUser(data: any[]) {
return request({
url: '/v1/auth/user/batch',
method: 'post',
data
})
}
// ★ 获取可指定审批人列表SUPERVISOR / SUPER_ADMIN 且 status=active
export function getApproversList() {
return request({
url: '/v1/auth/users/approvers',
method: 'get'
})
}

View File

@ -0,0 +1,53 @@
import request from '@/utils/request'
// 1. 获取维修单列表
export function getRepairList(params: any) {
return request({
url: '/inbound/repair/list',
method: 'get',
params
})
}
// 2. 新增维修单
export function createRepair(data: any) {
return request({
url: '/inbound/repair/submit',
method: 'post',
data
})
}
// 3. 更新维修单
export function updateRepair(id: number, data: any) {
return request({
url: `/inbound/repair/${id}`,
method: 'put',
data
})
}
// 4. 更新维修状态
export function updateRepairStatus(data: any) {
return request({
url: '/inbound/repair/update-status',
method: 'post',
data
})
}
// 5. 删除维修单
export function deleteRepair(id: number) {
return request({
url: `/inbound/repair/${id}`,
method: 'delete'
})
}
// 6. 获取维修单详情
export function getRepairDetail(id: number) {
return request({
url: `/inbound/repair/${id}`,
method: 'get'
})
}

View File

@ -69,4 +69,21 @@ export function batchSetInspection(data: { ids: number[], isInspectionRequired:
method: 'post',
data
})
}
// 7. 获取智能分组规格最大连号
export function getLatestSpecs() {
return request({
url: '/inbound/base/spec-latest',
method: 'get'
})
}
// 8. 标记预警物料已采购
export function markWarningOrdered(data: { baseId: number; isOrdered: boolean }) {
return request({
url: '/inbound/base/warning/mark-ordered',
method: 'post',
data
})
}

View File

@ -77,4 +77,49 @@ export function getOutboundList(params: any) {
method: 'get',
params
})
}
/**
* 提交出库申请单(申请人 → 审批流)
*/
export function submitOutboundRequest(data: {
items: Array<{
material_type?: string
name: string
spec_model: string
warehouse_location?: string
quantity: number
}>
remark: string
}) {
return request({
url: '/v1/outbound/request',
method: 'post',
data
})
}
/**
* 获取出库审批申请单列表
* @param params 支持 status, page, limit
*/
export function getApprovalRequestList(params: { status?: number | ''; page?: number; limit?: number }) {
return request({
url: '/v1/outbound/request',
method: 'get',
params
})
}
/**
* 审批(通过 / 驳回)出库申请单
* @param id 审批单ID
* @param data action: 'approve' | 'reject'reject 时需传 reject_reason
*/
export function approveRequest(id: number, data: { action: 'approve' | 'reject'; reject_reason?: string }) {
return request({
url: `/v1/outbound/request/${id}/approve`,
method: 'patch',
data
})
}

View File

@ -0,0 +1,213 @@
<template>
<div class="spec-helper" :class="{ expanded }">
<!-- 触发按钮 -->
<div class="trigger-btn" @click="toggle">
<span class="arrow">{{ expanded ? '>' : '<' }}</span>
</div>
<!-- 面板内容 -->
<div class="panel">
<div class="panel-header">
<span class="title">规格连号助手</span>
<el-input
v-model="filterText"
placeholder="搜索分组或规格..."
clearable
size="small"
class="search-input"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<el-scrollbar class="data-list">
<div
v-for="item in filteredData"
:key="item.group"
class="data-item"
>
<div class="item-left">
<span class="group-tag">{{ item.group }}</span>
<el-tag size="small" type="info">{{ item.count }}</el-tag>
</div>
<span class="latest-spec">{{ item.latest }}</span>
</div>
<el-empty v-if="filteredData.length === 0" description="暂无数据" :image-size="60" />
</el-scrollbar>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { getLatestSpecs } from '@/api/material_base'
import { ElMessage } from 'element-plus'
interface SpecItem {
group: string
latest: string
count: number
}
const expanded = ref(false)
const filterText = ref('')
const specData = ref<SpecItem[]>([])
const toggle = () => {
expanded.value = !expanded.value
if (expanded.value && specData.value.length === 0) {
fetchData()
}
}
const fetchData = async () => {
try {
const res = await getLatestSpecs()
if (res.code === 200) {
specData.value = res.data || []
console.log('SpecHelper Data:', specData.value)
} else {
ElMessage.error(res.msg || '获取规格数据失败')
}
} catch (error) {
console.error('获取规格数据失败:', error)
ElMessage.error('获取规格数据失败')
}
}
const filteredData = computed(() => {
if (!filterText.value) {
return specData.value
}
const keyword = filterText.value.toLowerCase()
return specData.value.filter(
item =>
(item.group && item.group.toLowerCase().includes(keyword)) ||
(item.latest && item.latest.toLowerCase().includes(keyword))
)
})
onMounted(() => {
// 默认不加载,展开时再加载
})
</script>
<style scoped>
.spec-helper {
position: fixed;
right: 0;
top: 50%;
transform: translateY(-50%);
z-index: 9999;
display: flex;
align-items: center;
transition: transform 0.3s ease;
}
.spec-helper:not(.expanded) {
transform: translateY(-50%) translateX(calc(100% - 24px));
}
.trigger-btn {
width: 24px;
height: 60px;
background: #409eff;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px 0 0 4px;
flex-shrink: 0;
}
.trigger-btn:hover {
background: #66b1ff;
}
.arrow {
font-size: 14px;
font-weight: bold;
}
.panel {
width: 280px;
height: 400px;
background: white;
border-radius: 4px 0 0 4px;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
padding: 12px;
border-bottom: 1px solid #ebeef5;
display: flex;
flex-direction: column;
gap: 8px;
}
.title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.search-input {
width: 100%;
}
.data-list {
flex: 1;
overflow-y: auto;
}
.data-item {
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f5f7fa;
cursor: default;
}
.item-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
.data-item:hover {
background: #f5f7fa;
}
.group-tag {
font-size: 12px;
font-weight: 500;
color: #409eff;
background: #ecf5ff;
padding: 2px 6px;
border-radius: 3px;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.latest-spec {
font-size: 13px;
color: #303133;
font-family: monospace;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -5,12 +5,16 @@
<div class="main-container">
<AppMain />
</div>
<!-- 全局规格连号助手 -->
<SpecHelper />
</div>
</template>
<script setup lang="ts">
import Sidebar from './components/Sidebar/index.vue'
import AppMain from './components/AppMain.vue'
import SpecHelper from '@/components/SpecHelper/index.vue'
</script>
<style scoped>

View File

@ -92,6 +92,13 @@ const routes: Array<RouteRecordRaw> = [
name: 'InventorySummary',
component: () => import('@/views/stock/inbound/inbound_summary.vue'),
meta: { title: '入库记录' }
},
// 维修管理
{
path: 'repair',
name: 'RepairManagement',
component: () => import('@/views/stock/inbound/repair.vue'),
meta: { title: '维修管理', permission: 'inbound_repair' }
}
]
},
@ -143,6 +150,16 @@ const routes: Array<RouteRecordRaw> = [
name: 'OutboundList',
component: () => import('@/views/outbound/index.vue'),
meta: { title: '出库记录' }
},
{
path: 'approval',
name: 'OutboundApproval',
component: () => import('@/views/outbound/approval/index.vue'),
meta: {
title: '出库审批',
icon: 'Stamp',
roles: ['SUPER_ADMIN', 'SUPERVISOR']
}
}
]
},

View File

@ -69,6 +69,7 @@
class="beautified-select"
popper-class="bom-loadmore-popper parent-popper"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
@change="onParentChange"
>
<el-option
v-for="item in parentOptions"
@ -82,13 +83,26 @@
</div>
</el-option>
</el-select>
<el-link
v-if="form.parent_id"
type="primary"
:underline="false"
style="margin-left: 12px; font-size: 13px;"
@click="openParentMaterial"
>
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
</el-link>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')">
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="!userStore.hasPermission('bom_manage:operation')" />
</el-form-item>
</el-col>
<el-col :span="16"></el-col>
</el-row>
<el-row :gutter="20">
@ -134,34 +148,45 @@
/>
<el-table :data="filteredChildren" border style="width: 100%; margin-bottom: 15px" max-height="300">
<el-table-column label="子件物料" min-width="280" v-if="hasFormFieldPermission('child_id')">
<el-table-column label="子件物料" min-width="250" v-if="hasFormFieldPermission('child_id')">
<template #default="{ row, $index }">
<!-- ====== 改造:子件下拉 - 远程搜索 + 懒加载 ====== -->
<el-select
v-model="row.child_id"
placeholder="请搜索原料"
filterable
remote
reserve-keyword
:remote-method="(q: string) => handleRemoteSearch(q, 'child', $index)"
:loading="selectLoading"
style="width: 100%"
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
:popper-class="`bom-loadmore-popper child-popper-${$index}`"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', $index)"
>
<el-option
v-for="item in getChildOptions($index)"
:key="item.id"
:label="`${item.name} (${item.spec})`"
:value="item.id"
<div style="display: flex; align-items: center; gap: 8px;">
<el-select
v-model="row.child_id"
placeholder="请搜索原料"
filterable
remote
reserve-keyword
style="flex: 1;"
:remote-method="(q: string) => handleRemoteSearch(q, 'child', $index)"
:loading="selectLoading"
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
:popper-class="`bom-loadmore-popper child-popper-${$index}`"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', $index)"
>
<div class="option-row">
<span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span>
</div>
</el-option>
</el-select>
<el-option
v-for="item in getChildOptions($index)"
:key="item.id"
:label="`${item.name} (${item.spec})`"
:value="item.id"
>
<div class="option-row">
<span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span>
</div>
</el-option>
</el-select>
<el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id">
<el-button
type="primary"
link
:icon="EditPen"
@click.stop="openMaterialInNewTab(row.child_id, getChildSpec($index))"
style="font-size: 16px; padding: 4px;"
/>
</el-tooltip>
</div>
</template>
</el-table-column>
@ -201,8 +226,10 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { Plus, Search, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom'
import { getMaterialBaseList } from '@/api/inbound/stock'
import { useUserStore } from '@/stores/user'
@ -230,6 +257,8 @@ interface ChildRow {
}
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
@ -464,6 +493,31 @@ const filteredChildren = computed(() => {
})
})
// 获取子件规格(从 childDropdownStates 缓存中查找)
const getChildSpec = (index: number): string => {
const state = childDropdownStates.value.get(index)
if (!state || !form.children[index]?.child_id) return ''
const material = state.options.find((m: MaterialBase) => m.id === form.children[index].child_id)
return material?.spec || ''
}
// 在新标签页打开基础信息编辑
const openMaterialInNewTab = (targetId: number | null, keyword: string = '') => {
if (!targetId) return ElMessage.warning('请先选择物料')
const routeUrl = router.resolve({
path: '/material',
query: { edit_id: targetId, keyword }
})
window.open(routeUrl.href, '_blank')
}
const openParentMaterial = () => {
if (!form.parent_id) return ElMessage.warning('请先选择父件')
const parent = parentOptions.value.find((p: MaterialBase) => p.id === form.parent_id)
const keyword = parent?.spec || parent?.name || ''
openMaterialInNewTab(form.parent_id, keyword)
}
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
bom_no: 'bom_manage:bom_no',
@ -603,16 +657,40 @@ const loadDetail = async (bomNo: string, version: string) => {
const res = await getBomDetail(bomNo, version)
if (res.code === 200) {
const data = res.data
form.parent_id = data.parent_id
form.version = data.version
form.is_enabled = data.is_enabled
// 1. 映射子件基本数据
form.children = data.children.map((child: any) => ({
child_id: child.child_id,
dosage: child.dosage,
remark: child.remark || ''
}))
// 为每个子件行初始化下拉状态
form.children.forEach((_, idx) => initChildDropdownState(idx))
// 2. 初始化子件下拉状态,并预填充 options 解决回显显示 ID 的问题
form.children.forEach((child, idx) => {
initChildDropdownState(idx)
if (child.child_id) {
const state = childDropdownStates.value.get(idx)!
// 从原始 data.children 中取对应的名称和规格注入 options
const rawChildData = data.children[idx]
state.options = [{
id: rawChildData.child_id,
name: rawChildData.child_name || '未知物料', // 依赖后端返回 child_name
spec: rawChildData.child_spec || '' // 依赖后端返回 child_spec
}]
state.hasMore = false
}
})
// 3. 处理父件回显,预填充 parentOptions
if (data.parent_id) {
form.parent_id = data.parent_id
parentOptions.value = [{
id: data.parent_id,
name: data.parent_name || '未知产品', // 依赖后端返回 parent_name
spec: data.parent_spec || '' // 依赖后端返回 parent_spec
}]
}
if (data.parent_spec) {
form.bom_no = (data.parent_spec || '').split('/')[0].trim()
} else {
@ -721,6 +799,55 @@ const submitForm = async () => {
onMounted(() => {
fetchBomList()
// 【新增】:处理外部跳转自动打开 BOM带查重保护
if (route.query.create_for_id) {
const parentId = Number(route.query.create_for_id);
const parentName = (route.query.parent_name as string) || '';
const parentSpec = (route.query.parent_spec as string) || '';
// 把名称填入背景搜索框让背后的表格也只显示相关的BOM
searchKeyword.value = parentName;
// 延迟等待基础渲染
setTimeout(() => {
// 1. 先用 keyword 查询是否已有该父件的 BOM
getBomList({ keyword: parentName }).then((res: any) => {
const rows = res.data || [];
// 严格校验 parent_id
const existingBom = rows.find((b: any) => b.parent_id === parentId);
if (existingBom) {
// ★ 情况 A已经有BOM了直接打开编辑弹窗并拉取历史数据
ElMessage.success('检测到该物料已有 BOM已自动为您打开编辑');
handleEdit(existingBom);
} else {
// ★ 情况 B还没建过BOM打开新建并注入父件
handleCreate();
// 强行注入父件远程搜索选项
parentOptions.value = [{
id: parentId,
name: parentName,
spec: parentSpec
}];
// 给表单赋值
form.parent_id = parentId;
// 触发联动逻辑(自动带出版本和生成编号)
if (typeof onParentChange === 'function') {
setTimeout(() => {
onParentChange(parentId);
}, 100);
}
}
}).catch(err => {
console.error('BOM 查重失败', err);
ElMessage.error('获取 BOM 状态失败,请手动操作');
});
}, 300);
}
})
</script>

View File

@ -18,6 +18,32 @@
<div class="card-body">
<h2>IRIS 库存管理系统</h2>
<div style="display: flex; justify-content: center; margin: 20px 0 30px;">
<el-autocomplete
v-model="globalSearchText"
:fetch-suggestions="queryGlobalSearch"
placeholder="全局搜索:输入物料名称、规格、条码或 BOM 编号..."
style="width: 60%; max-width: 600px;"
size="large"
clearable
@select="handleSearchSelect"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #default="{ item }">
<div style="display: flex; justify-content: space-between; align-items: center; line-height: 1.5; padding: 4px 0;">
<div>
<div style="font-size: 14px; font-weight: bold; color: #303133;">{{ item.title }}</div>
<div style="font-size: 12px; color: #909399;">{{ item.subtitle }}</div>
</div>
<el-tag size="small" :type="getBadgeType(item.type)">{{ item.badge }}</el-tag>
</div>
</template>
</el-autocomplete>
</div>
<p class="subtitle">请选择您要进行的业务操作</p>
<div class="action-buttons">
@ -215,8 +241,9 @@ import { useRouter } from 'vue-router'
// 1. 引入 User Store
import { useUserStore } from '@/stores/user'
// 引入需要的图标
import { Box, TrendCharts, ShoppingCart, Operation, Setting, Location, Plus, Edit, Delete, Close } from '@element-plus/icons-vue'
import { Box, TrendCharts, ShoppingCart, Operation, Setting, Location, Plus, Edit, Delete, Close, Search } from '@element-plus/icons-vue'
import { getPrinterConfig, updatePrinterConfig } from '@/api/common/print'
import request from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getWarehouseTree, createWarehouse, updateWarehouse, deleteWarehouse, batchDeleteWarehouse, batchGenerateWarehouse } from '@/api/common/warehouse'
@ -234,6 +261,61 @@ const printerForm = reactive({
})
const loading = ref(false)
// 全局搜索相关
const globalSearchText = ref('')
const getBadgeType = (type: string) => {
const map: Record<string, string> = {
'material': 'success',
'stock_buy': 'primary',
'bom': 'warning'
}
return map[type] || 'info'
}
const queryGlobalSearch = async (queryString: string, cb: (data: any[]) => void) => {
if (!queryString || queryString.trim() === '') {
cb([])
return
}
try {
const res: any = await request({
url: '/v1/common/global-search',
method: 'get',
params: { keyword: queryString.trim() }
})
if (res.code === 200 && res.data) {
cb(res.data)
} else {
cb([])
}
} catch (error) {
console.error('全局搜索失败:', error)
cb([])
}
}
const handleSearchSelect = (item: any) => {
globalSearchText.value = ''
if (item.type === 'material') {
router.push({
path: '/material/index',
query: { edit_id: item.id, keyword: item.title }
})
} else if (item.type === 'stock_buy') {
router.push({
path: '/inventory/buy',
query: { keyword: item.title }
})
} else if (item.type === 'bom') {
router.push({
path: '/bom',
query: { keyword: item.title }
})
}
}
const openPrinterDialog = async () => {
try {
loading.value = true
@ -317,7 +399,10 @@ const selectedIds = ref<number[]>([])
const isBulkDeleteMode = ref(false)
const handleTreeCheck = (data: any, checked: any) => {
selectedIds.value = checked.checkedKeys
// 使用 getCheckedKeys(true) 只获取叶子节点,防止 el-tree 自动连带选中父节点导致误删
if (treeRef.value) {
selectedIds.value = treeRef.value.getCheckedKeys(true) as number[]
}
}
const cancelBatchMode = () => {

View File

@ -254,23 +254,28 @@
<span v-if="getImagesOnly(row.generalImage).length > 1" class="more-badge">+{{getImagesOnly(row.generalImage).length}}</span>
</div>
<el-popover v-if="row.generalManual && row.generalManual.length > 0" placement="top" trigger="hover" width="200">
<el-popover v-if="row.generalManual && row.generalManual.length > 0" placement="top" trigger="hover" width="260">
<template #reference>
<el-button link type="primary" :icon="Document" />
<el-button link type="primary" :icon="row.generalManual.some(l => !isExternalLink(l) && !isImageFile(l)) ? Files : Document" />
</template>
<div style="display: flex; flex-direction: column; gap: 5px;">
<div v-for="(link, idx) in row.generalManual" :key="idx">
<el-link v-if="isExternalLink(link)" :href="link" target="_blank" type="primary" :underline="false">
说明书 {{idx+1}} <el-icon><Link /></el-icon>
</el-link>
<el-image v-else-if="isImageFile(link)"
style="width: 100px; height: 100px"
:src="getImageUrl(link)"
:preview-src-list="[getImageUrl(link)]"
fit="cover"
preview-teleported
<!-- 图片文件 -->
<div v-for="(link, idx) in row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l))" :key="'img-' + idx">
<el-image
style="width: 80px; height: 80px; cursor: pointer;"
:src="getImageUrl(link)"
:preview-src-list="row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l)).map(u => getImageUrl(u))"
fit="cover"
preview-teleported
/>
<el-link v-else :href="getImageUrl(link)" target="_blank" type="info">PDF 文件 {{idx+1}}</el-link>
<span style="font-size: 12px; color: #999;">图片 {{idx+1}}</span>
</div>
<!-- 非图片文件 -->
<div v-for="(link, idx) in row.generalManual.filter(l => !isExternalLink(l) && !isImageFile(l))" :key="'file-' + idx">
<el-link @click.prevent="handleDownloadConfirm(link)" type="info" :underline="false">
<el-icon><Files /></el-icon>
{{ link.split('/').pop() }}
</el-link>
</div>
</div>
</el-popover>
@ -297,10 +302,14 @@
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="userStore.hasPermission('material_list:operation')" label="操作" min-width="200" fixed="right" align="center">
<el-table-column v-if="userStore.hasPermission('material_list:operation')" label="操作" width="280" fixed="right" align="center">
<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>
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
@ -321,8 +330,7 @@
<el-dialog
v-model="dialog.visible"
:title="dialog.title"
width="700px"
width="1200px"
append-to-body
destroy-on-close
@close="cancel"
@ -330,6 +338,20 @@
:close-on-press-escape="!isUploading"
:show-close="!isUploading"
>
<template #header>
<div style="display: flex; align-items: center; justify-content: space-between; padding-right: 20px;">
<span style="font-size: 18px; font-weight: 500;">{{ dialog.title }}</span>
<el-link
v-if="form.id"
type="success"
:underline="false"
style="font-size: 14px;"
@click="createBomForMaterial"
>
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
</el-link>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-row>
@ -361,6 +383,7 @@
<el-form-item label="类别" prop="category" v-if="hasFieldPermission('category')">
<div style="display: flex; width: 100%; align-items: center;">
<el-cascader
ref="categoryCascaderRef"
v-model="tempCategoryPrefix"
:options="categoryTreeOptions"
:props="{ expandTrigger: 'hover', checkStrictly: true, emitPath: true }"
@ -368,6 +391,7 @@
filterable
clearable
style="width: 50%;"
@change="onCategoryChange"
/>
<div style="padding: 0 8px; font-weight: bold; color: #909399;">/</div>
<el-input
@ -377,9 +401,7 @@
style="width: 50%;"
/>
</div>
<div style="font-size: 12px; color: #E6A23C; margin-top: 4px; line-height: 1.2;">
* 必须构成4层结构
</div>
</el-form-item>
</el-col>
</el-row>
@ -457,7 +479,32 @@
:on-remove="(file) => handleRemoveImage(file, 'generalManual')"
:before-upload="beforeAvatarUpload"
>
<el-icon><Plus /></el-icon>
<template #default>
<div v-if="!fileListManual.length" class="upload-add-trigger">
<el-icon><Plus /></el-icon>
</div>
</template>
<template #file="{ file }">
<div class="upload-file-item">
<template v-if="isImageFile(file.url)">
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
</template>
<template v-else>
<div class="file-thumbnail">
<el-icon size="28"><Document /></el-icon>
<span class="file-name">{{ truncateFileName(file.name) }}</span>
</div>
</template>
<span class="el-upload-list__item-actions">
<span class="el-upload-list__item-preview" @click="handlePreviewPicture(file)">
<el-icon><ZoomIn /></el-icon>
</span>
<span class="el-upload-list__item-delete" @click="() => handleRemoveImage(file, 'generalManual')">
<el-icon><Delete /></el-icon>
</span>
</span>
</div>
</template>
</el-upload>
<div class="camera-card" @click="triggerCamera('generalManual')">
<el-icon><Camera /></el-icon><span class="text">拍照</span>
@ -517,10 +564,16 @@
<el-input-number v-model="warningForm.redThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为红色预警" style="width: 100%" />
<div class="form-tip">库存数量 ≤ 此值时显示红色预警</div>
</el-form-item>
<el-form-item label="红色预警邮箱" v-if="warningForm.isEnabled">
<el-input v-model="warningForm.redEmails" placeholder="逗号分隔多个邮箱" clearable />
</el-form-item>
<el-form-item label="黄色阈值" prop="yellowThreshold" v-if="warningForm.isEnabled">
<el-input-number v-model="warningForm.yellowThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为黄色预警" style="width: 100%" />
<div class="form-tip">红色阈值 &lt; 库存 ≤ 此值时显示黄色预警</div>
</el-form-item>
<el-form-item label="黄色预警邮箱" v-if="warningForm.isEnabled">
<el-input v-model="warningForm.yellowEmails" placeholder="逗号分隔多个邮箱" clearable />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
@ -563,10 +616,13 @@
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, computed } from 'vue';
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck } from '@element-plus/icons-vue';
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { useUserStore } from '@/stores/user';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
import {
listMaterialBase,
@ -576,7 +632,8 @@ import {
getMaterialBaseOptions,
exportAssetStatistics,
batchSetWarning,
batchSetInspection
batchSetInspection,
markWarningOrdered
} from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload';
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
@ -600,6 +657,8 @@ interface MaterialBaseVO {
statusLoading?: boolean;
inventoryCount?: number;
availableCount?: number;
warningStatus?: number;
warningOrdered?: boolean;
}
interface QueryParams {
@ -673,6 +732,9 @@ const cameraDialogVisible = ref(false);
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null);
const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage');
// 脏检查 - 记录编辑前的原始数据
const originalForm = ref<any>(null);
// 复选框选中数据
const selectedItems = ref<MaterialBaseVO[]>([]);
const handleSelectionChange = (selection: MaterialBaseVO[]) => {
@ -742,7 +804,9 @@ const warningLoading = ref(false);
const warningForm = reactive({
isEnabled: false,
redThreshold: undefined as number | undefined,
yellowThreshold: undefined as number | undefined
yellowThreshold: undefined as number | undefined,
redEmails: '',
yellowEmails: ''
});
const warningRules = {
yellowThreshold: [
@ -842,6 +906,16 @@ const categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]);
const categoryTreeOptions = ref<CascaderOption[]>([]);
// 类别级联选择器的 ref
const categoryCascaderRef = ref<any>(null);
// 选中类别后自动收起下拉面板
const onCategoryChange = () => {
if (categoryCascaderRef.value) {
categoryCascaderRef.value.togglePopperVisible(false);
}
};
const tempCategoryPrefix = ref<string[]>([]);
const tempCategorySuffix = ref<string>('');
@ -891,19 +965,8 @@ const validateCategoryLevel = (rule: any, value: any, callback: any) => {
if (!prefixStr && !suffixStr) {
callback(new Error('请填写或选择类别'));
return;
}
let fullPath = '';
if (prefixStr && suffixStr) fullPath = prefixStr + '/' + suffixStr;
else if (prefixStr) fullPath = prefixStr;
else fullPath = suffixStr;
const levels = fullPath.split('/').filter(p => p.trim() !== '').length;
if (levels !== 4) {
callback(new Error(`必须严格满足4层结构当前为 ${levels} 层`));
} else {
// 只要有前缀或后缀就直接放行不再限制必须是4层
callback();
}
};
@ -1116,6 +1179,9 @@ const handleEdit = (row: MaterialBaseVO) => {
const data = JSON.parse(JSON.stringify(row));
Object.assign(form.value, data);
// 深拷贝保存原始数据用于脏检查
originalForm.value = JSON.parse(JSON.stringify(data));
if (data.category) {
const parts = data.category.split('/');
if (parts.length > 0) {
@ -1164,6 +1230,33 @@ const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
return false;
};
const isArraysEqual = (a: any[], b: any[]): boolean => {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((val, idx) => val === sortedB[idx]);
};
const buildPartialPayload = (current: any, original: any): any => {
const payload: any = { id: current.id };
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName'];
for (const key of compareFields) {
const currentVal = current[key];
const originalVal = original[key];
// 处理数组比较generalImage, generalManual
if (Array.isArray(currentVal) && Array.isArray(originalVal)) {
if (!isArraysEqual(currentVal, originalVal)) {
payload[key] = currentVal;
}
} else if (currentVal !== originalVal) {
payload[key] = currentVal;
}
}
return payload;
};
const submitForm = async () => {
if (!formRef.value) return;
@ -1189,19 +1282,44 @@ const submitForm = async () => {
if (prefixStr && suffixStr) fullCategory = prefixStr + '/' + suffixStr;
else fullCategory = prefixStr || suffixStr;
const payload = {
// 构建最终表单数据
const finalForm = {
...form.value,
category: fullCategory,
generalImage: finalImageList,
generalManual: finalManualList
};
// 脏检查:只提交变更的字段
let payload: any;
if (form.value.id && originalForm.value) {
// 编辑模式:生成部分更新 payload
payload = buildPartialPayload(finalForm, originalForm.value);
// 如果分类被修改,需要确保包含在 payload 中
if (payload.category === undefined && fullCategory !== originalForm.value.category) {
payload.category = fullCategory;
}
} else {
// 新增模式:提交完整数据
payload = finalForm;
}
// 如果没有变更,提示用户
const changedKeys = Object.keys(payload).filter(k => k !== 'id');
if (changedKeys.length === 0) {
ElMessage.info('没有检测到数据变更,无需保存');
submitLoading.value = false;
dialog.visible = false;
return;
}
const requestApi = form.value.id ? updateMaterialBase : addMaterialBase;
const actionText = form.value.id ? '修改' : '新增';
await requestApi(payload);
ElMessage.success(`${actionText}成功`);
dialog.visible = false;
originalForm.value = null;
getList();
getOptionsList();
} catch (error: any) {
@ -1218,6 +1336,22 @@ const cancel = () => {
resetForm();
};
// 快速基于此物料查看/创建 BOM
const createBomForMaterial = () => {
if (!form.value.id) {
return ElMessage.warning('请先保存物料基础信息后再操作');
}
const routeUrl = router.resolve({
path: '/bom',
query: {
create_for_id: form.value.id,
parent_name: form.value.name,
parent_spec: form.value.spec
}
});
window.open(routeUrl.href, '_blank');
};
const resetForm = () => {
form.value = JSON.parse(JSON.stringify(initForm));
fileListImage.value = [];
@ -1228,6 +1362,7 @@ const resetForm = () => {
imageExternalUrl.value = '';
manualExternalUrl.value = '';
originalForm.value = null;
if (formRef.value) formRef.value.resetFields();
};
@ -1268,11 +1403,34 @@ const handleSetSingleWarning = (row: MaterialBaseVO) => {
warningForm.isEnabled = row.warningEnabled || false;
warningForm.redThreshold = row.warningRed;
warningForm.yellowThreshold = row.warningYellow;
warningForm.redEmails = (row as any).redEmails || '';
warningForm.yellowEmails = (row as any).yellowEmails || '';
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(() => {});
};
// 提交预警设置
const submitWarning = async () => {
if (!warningFormRef.value) return;
@ -1295,7 +1453,9 @@ const submitWarning = async () => {
baseId,
isEnabled: warningForm.isEnabled,
redThreshold: red,
yellowThreshold: yellow
yellowThreshold: yellow,
redEmails: warningForm.redEmails || '',
yellowEmails: warningForm.yellowEmails || ''
}));
await batchSetWarning(data);
@ -1343,24 +1503,33 @@ const submitBatchInspection = async () => {
// 表格行样式(根据预警状态)
const tableRowClassName = ({ row }: { row: MaterialBaseVO }) => {
// 只有拥有 view_warning 权限且有预警状态时才显示特殊样式
if (!userStore.hasPermission('material_list:view_warning')) return '';
const status = (row as any).warningStatus;
if (status === 2) {
return 'warning-row-red'; // 红色预警
} else if (status === 1) {
return 'warning-row-yellow'; // 黄色预警
if (row.warningStatus === 2) {
return 'danger-row'; // 红色预警
} else if (row.warningStatus === 1) {
return 'warning-row'; // 黄色预警
}
return '';
};
}
// --- 文件上传辅助函数 ---
const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) }
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item)) }
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp)$/i.test(url) }
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(url) }
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && isImageFile(item)) }
const getNonImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && !isImageFile(item)) }
const truncateFileName = (name: string, maxLen = 12) => { return name.length > maxLen ? name.slice(0, maxLen - 3) + '...' : name }
const handleDownloadConfirm = (link: string) => {
const fileName = link.split('/').pop() || '文件';
ElMessageBox.confirm(`确认要下载/查看「${fileName}」吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
window.open(getImageUrl(link), '_blank');
}).catch(() => {});
}
const beforeAvatarUpload = (rawFile: any) => {
const isTypeValid = ['image/jpeg', 'image/png', 'application/pdf'].includes(rawFile.type);
@ -1411,8 +1580,13 @@ const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' |
}
const handlePreviewPicture = (uploadFile: any) => {
dialogImageUrl.value = uploadFile.url!;
dialogVisibleImage.value = true
const fileUrl = uploadFile.url || uploadFile.response?.url || '';
if (isImageFile(fileUrl)) {
dialogImageUrl.value = getImageUrl(fileUrl);
dialogVisibleImage.value = true;
} else {
window.open(getImageUrl(fileUrl), '_blank');
}
}
const triggerCamera = (field: 'generalImage' | 'generalManual') => {
@ -1479,10 +1653,48 @@ const resetAdvancedFilter = () => {
};
onMounted(() => {
// 1. 修复背景联动:直接对 reactive 对象赋值
if (route.query.keyword) {
queryParams.keyword = route.query.keyword as string;
queryParams.searchField = 'all';
}
// 先根据权限初始化列显示状态
initColumnPermissions();
// 此时 getList 会带着正确的 keyword 向后端请求过滤后的数据
getList();
getOptionsList();
// 2. 修复弹窗锁定逻辑
console.log('--- 准备检测外部跳转参数 ---', route.query);
if (route.query.edit_id) {
const editId = Number(route.query.edit_id);
const searchKeyword = (route.query.keyword as string) || '';
console.log('检测到 edit_id:', editId, '使用 keyword 搜索:', searchKeyword);
// 改用 keyword 而不是无效的 id 去向后端请求数据,确保目标物料在返回的列表中
listMaterialBase({ page: 1, pageSize: 50, keyword: searchKeyword }).then((res: any) => {
let rawData = res?.data?.list ?? res?.data?.items ?? res?.data ?? [];
if (!Array.isArray(rawData) && typeof rawData === 'object' && rawData !== null) {
rawData = [rawData];
}
const rows = Array.isArray(rawData) ? rawData : [];
// 3. 去掉危险的 rows[0] 兜底,严格匹配 ID
const targetRow = rows.find((r: any) => r.id === editId);
if (targetRow) {
console.log('找到精准目标物料,准备弹窗:', targetRow);
setTimeout(() => {
handleEdit(targetRow);
}, 800);
} else {
console.warn('未能在搜索结果中匹配到对应 ID 的物料,可能 keyword 与 ID 不匹配');
}
}).catch((error: any) => {
console.error('自动获取物料详情失败', error);
});
}
});
</script>
@ -1529,34 +1741,42 @@ onMounted(() => {
.file-preview-cell { display: flex; align-items: center; justify-content: center; position: relative; }
.more-badge { position: absolute; top: -5px; right: -5px; background: #909399; color: #fff; border-radius: 10px; padding: 0 4px; font-size: 10px; transform: scale(0.9); }
/* 预警行样式 - 加深颜色 */
:deep(.warning-row-red) {
--el-table-tr-bg-color: #ffcdd2 !important;
background-color: #ffcdd2 !important;
}
:deep(.warning-row-red td) {
background-color: transparent !important;
}
:deep(.warning-row-yellow) {
--el-table-tr-bg-color: #fff59d !important;
background-color: #fff59d !important;
}
:deep(.warning-row-yellow td) {
background-color: transparent !important;
/* 上传文件项样式 - 非图片文件显示 */
.upload-file-item { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; }
.upload-file-item .el-upload-list__item-thumbnail { width: 100%; height: 100%; object-fit: cover; }
.upload-file-item .file-thumbnail { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; background: #f5f7fa; color: #606266; }
.upload-file-item .file-thumbnail .file-name { font-size: 10px; margin-top: 4px; text-align: center; padding: 0 4px; word-break: break-all; max-width: 90px; }
.upload-file-item .el-upload-list__item-actions { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.6); opacity: 0; transition: opacity 0.3s; }
.upload-file-item:hover .el-upload-list__item-actions { opacity: 1; }
.upload-file-item .el-upload-list__item-actions .el-icon { color: #fff; font-size: 20px; cursor: pointer; margin: 0 4px; }
.upload-add-trigger { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
/* ================================================================
Element Plus 表格预警行样式 & 固定列重叠修复
================================================================ */
/* 黄色预警行底色 (全覆盖) */
:deep(.el-table .warning-row),
:deep(.el-table .warning-row > td.el-table__cell) {
background-color: #fcedc4 !important; /* 明显的黄色 */
}
/* 表单提示文字 */
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.4;
/* 红色预警行底色 (全覆盖) */
:deep(.el-table .danger-row),
:deep(.el-table .danger-row > td.el-table__cell) {
background-color: #fcd3d3 !important; /* 明显的红色 */
}
/* 固定列的按钮容器底色跟随所在行的背景色,视觉无缝融合 */
:deep(.el-table .el-table__cell.is-fixed) {
background-color: inherit !important;
}
/* 按钮间距微调,更紧凑 */
:deep(.el-table .el-table__cell.is-fixed .cell) {
display: flex;
gap: 6px;
justify-content: flex-start; /* 左对齐更自然 */
flex-wrap: nowrap; /* 尽量不换行 */
}
</style>
<style>
/* 增加下拉框的最大高度,使其能容纳更多选项而不必频繁滚动 */
.long-dropdown .el-select-dropdown__wrap {
max-height: 600px !important; /* 可以根据屏幕大小适当调整 */
}
</style>

View File

@ -30,7 +30,7 @@
</el-table-column>
<el-table-column label="规格型号" min-width="140">
<template #default="{ row }">
{{ row.material_spec || '-' }}
{{ row.spec_model || '-' }}
</template>
</el-table-column>
<el-table-column prop="quantity" label="报废数量" width="100" align="right" />

View File

@ -8,16 +8,38 @@
<span class="subtitle">(请添加需要出库的物品)</span>
</div>
<div>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Plus" @click="openManualSelect">
手动添加库存
</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" :icon="List" @click="openBomSelect">
BOM 套餐添加
</el-button>
<!-- 批量模式 -->
<template v-if="isBulkMode">
<el-button @click="cancelBulkMode">
取消
</el-button>
<el-button type="danger" :disabled="selectedRows.length === 0" @click="batchRemove">
移除选中 ({{ selectedRows.length }})
</el-button>
</template>
<!-- 普通模式 -->
<template v-else>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" plain :disabled="selectedItems.length === 0" @click="isBulkMode = true">
批量操作
</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="danger" :disabled="selectedItems.length === 0" @click="clearAll">
清空列表
</el-button>
<el-divider direction="vertical" />
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Plus" @click="openManualSelect">
手动添加库存
</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" :icon="List" @click="openBomSelect">
BOM 套餐添加
</el-button>
</template>
<el-divider direction="vertical" />
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="success" :icon="Printer" :disabled="selectedItems.length === 0" @click="handlePreview">
生成预览 & 打印
</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Plus" :disabled="selectedItems.length === 0" @click="openRequestDialog">
提交出库申请
</el-button>
</div>
</div>
</template>
@ -33,12 +55,17 @@
/>
<el-table
ref="tableRef"
v-else
:data="sortedSelectedItems"
border
style="width: 100%"
row-key="uniqueKey"
@selection-change="handleSelectionChange"
@row-click="handleBulkRowClick"
:row-class-name="getRowClassName"
>
<el-table-column v-if="isBulkMode" type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="50" align="center" />
<el-table-column label="类型" width="100" align="center">
@ -79,7 +106,7 @@
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="danger" link @click="removeRow($index)">移除</el-button>
<el-button v-if="!isBulkMode && userStore.hasPermission('outbound_selection:operation')" type="danger" link @click="removeRow($index)">移除</el-button>
</template>
</el-table-column>
</el-table>
@ -213,13 +240,12 @@
</div>
<template #footer>
<el-button @click="bomSelectVisible = false">取消</el-button>
<el-button
v-if="userStore.hasPermission('outbound_selection:operation')"
type="primary"
<el-button
v-if="userStore.hasPermission('outbound_selection:operation')"
type="primary"
@click="confirmBomAdd"
:disabled="hasShortage"
>
{{ hasShortage ? '库存不足,无法添加' : '一键计算并添加' }}
{{ hasShortage ? '仅添加现有库存' : '确认添加' }}
</el-button>
</template>
</el-dialog>
@ -266,6 +292,80 @@
</template>
</el-dialog>
<!-- 出库申请 Dialog -->
<el-dialog
v-model="requestDialogVisible"
title="提交出库申请"
width="700px"
destroy-on-close
class="no-print-content"
>
<el-alert
title="请确认以下物料申请清单,填写申请原因后提交"
type="info"
:closable="false"
style="margin-bottom: 16px;"
/>
<el-table :data="validSelectedItems" border size="small" style="margin-bottom: 16px;">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="getTypeTag(row.type)">{{ row.typeLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="standard" label="规格" min-width="120" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
<el-table-column prop="export_quantity" label="计划数量" width="100" align="center">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold;">{{ row.export_quantity }}</span>
</template>
</el-table-column>
</el-table>
<el-form label-width="80px">
<el-form-item label="* 指定审批人" required>
<el-select
v-model="requestApproverId"
placeholder="请选择审批人"
style="width: 100%"
filterable
>
<el-option
v-for="user in approvers"
:key="user.id"
:label="`${user.username} (${user.role === 'SUPER_ADMIN' ? '超级管理员' : '主管'})`"
:value="user.id"
/>
</el-select>
</el-form-item>
<el-form-item label="申请原因" required>
<el-input
v-model="requestRemark"
type="textarea"
:rows="3"
placeholder="请填写出库申请原因(必填)"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="requestDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="requestSubmitting"
@click="confirmSubmitRequest"
>
确认提交
</el-button>
</span>
</template>
</el-dialog>
<div id="print-area">
<div class="print-header">
<h1>IRIS出库拣货确认单</h1>
@ -335,11 +435,15 @@ import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
import { getAllStock, getStockList, printSelectionList } from '@/api/inbound/stock'
import { getBomList, getBomDetail } from '@/api/bom'
import { useUserStore } from '@/stores/user'
import { submitOutboundRequest } from '@/api/outbound'
import { getApproversList } from '@/api/auth'
const userStore = useUserStore()
// --- 状态变量 ---
const selectedItems = ref<any[]>([])
const selectedRows = ref<any[]>([])
const isBulkMode = ref(false)
// 按库位路径自然升序排序(优化拣货路径)
const sortedSelectedItems = computed(() => {
@ -356,6 +460,13 @@ const previewVisible = ref(false)
const exportLoading = ref(false)
const printLoading = ref(false)
// ★ 出库申请相关
const requestDialogVisible = ref(false)
const requestRemark = ref('')
const requestApproverId = ref<number | null>(null)
const approvers = ref<any[]>([])
const requestSubmitting = ref(false)
const allStockData = ref<any[]>([])
const stockList = ref<any[]>([]) // 服务端分页数据
const stockTotal = ref(0)
@ -368,6 +479,7 @@ let stockSearchTimer: ReturnType<typeof setTimeout> | null = null
// 表格引用
const manualTableRef = ref<InstanceType<typeof ElTable>>()
const tableRef = ref<InstanceType<typeof ElTable>>()
// BOM 相关
const bomOptions = ref<any[]>([])
@ -451,6 +563,31 @@ const getTypeTag = (type: string) => {
}
}
// --- 核心逻辑 0加载全量库存数据BOM 齐套计算依赖此数据) ---
const ensureAllStockLoaded = async () => {
if (allStockData.value.length === 0) {
try {
const res: any = await getAllStock()
const rawMaterials = (res.materials || []).map((i: any) => ({ ...i, type: 'material', typeLabel: '采购件' }))
const rawSemis = (res.semis || []).map((i: any) => ({ ...i, type: 'semi', typeLabel: '半成品' }))
const rawProducts = (res.products || []).map((i: any) => ({ ...i, type: 'product', typeLabel: '成品' }))
const list = [...rawMaterials, ...rawSemis, ...rawProducts]
allStockData.value = list.map((i: any) => ({
...i,
name: i.name || i.material_name || i.product_name || '未知名称',
standard: i.standard || i.spec_model || '',
warehouse_location: i.warehouse_location || i.warehouse_loc || i.full_path || '',
uniqueKey: `${i.type}_${i.id}`,
available_quantity: parseFloat(i.available_quantity) || 0,
availableCount: parseFloat(i.available_quantity) || 0,
export_quantity: 1
}))
} catch (e) {
ElMessage.error('加载全量库存数据失败BOM 功能可能受影响)')
}
}
}
// --- 核心逻辑 1手动添加库存 ---
// 服务端加载库存列表
@ -460,7 +597,8 @@ const loadStockList = async () => {
const res: any = await getStockList({
page: stockPage.value,
pageSize: stockPageSize.value,
keyword: searchKeyword.value.trim()
keyword: searchKeyword.value.trim(),
is_aggregated: true // ★ 触发后端按规格+库位合并
})
// 为每个item添加uniqueKey和确保warehouse_location字段正确映射
stockList.value = (res.data?.list || []).map((item: any) => ({
@ -482,30 +620,8 @@ const openManualSelect = async () => {
stockPage.value = 1
searchKeyword.value = ''
await loadStockList()
// 仅在 BOM 关联查询需要时加载全量(一次性缓存)
if (allStockData.value.length === 0) {
try {
const res: any = await getAllStock()
const rawMaterials = (res.materials || []).map((i: any) => ({ ...i, type: 'material', typeLabel: '采购件' }))
const rawSemis = (res.semis || []).map((i: any) => ({ ...i, type: 'semi', typeLabel: '半成品' }))
const rawProducts = (res.products || []).map((i: any) => ({ ...i, type: 'product', typeLabel: '成品' }))
const list = [...rawMaterials, ...rawSemis, ...rawProducts]
allStockData.value = list.map((i: any) => ({
...i,
name: i.name || i.material_name || i.product_name || '未知名称',
standard: i.standard || i.spec_model || '',
warehouse_location: i.warehouse_location || i.warehouse_loc || i.full_path || '',
uniqueKey: `${i.type}_${i.id}`,
available_quantity: parseFloat(i.available_quantity) || 0,
export_quantity: 1
}))
} catch (e) {
ElMessage.error('加载全量库存数据失败BOM 功能可能受影响)')
}
} else {
allStockData.value.forEach(item => item.export_quantity = 0)
}
await ensureAllStockLoaded()
allStockData.value.forEach(item => item.export_quantity = 0)
}
// 搜索框防抖触发服务端过滤
@ -610,6 +726,7 @@ const openBomSelect = async () => {
} catch (e) {
ElMessage.error('加载 BOM 列表失败')
}
await ensureAllStockLoaded()
}
// 监听 BOM 选择变化,自动加载明细并计算齐套性
@ -619,7 +736,7 @@ watch(selectedBomNo, async (newBomNo) => {
return
}
try {
const detailRes = await getBomDetail(newBomNo)
const detailRes: any = await getBomDetail(newBomNo)
currentBomDetail.value = detailRes.data?.children || []
} catch (e) {
ElMessage.error('加载 BOM 明细失败')
@ -628,55 +745,128 @@ watch(selectedBomNo, async (newBomNo) => {
})
const confirmBomAdd = async () => {
if(!selectedBomNo.value) return ElMessage.warning('请选择 BOM');
if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM')
if (allStockData.value.length === 0) {
await openManualSelect()
manualDialogVisible.value = false
await ensureAllStockLoaded()
}
try {
const detailRes = await getBomDetail(selectedBomNo.value)
const bomRows = detailRes.data?.children || []
if (currentBomDetail.value.length === 0) {
try {
const detailRes: any = await getBomDetail(selectedBomNo.value)
currentBomDetail.value = detailRes.data?.children || []
} catch (e) {
ElMessage.error('获取 BOM 详情失败')
return
}
}
let addedCount = 0;
bomRows.forEach((bomItem: any) => {
const needQty = (parseFloat(bomItem.dosage) || 0) * bomSets.value
// ★ BOM 添加时,匹配本地库存数据,带入库位信息
const stockCandidate = allStockData.value.find(s =>
(s.base_id && s.base_id == bomItem.child_id)
)
const bomRows = currentBomDetail.value
let addedCount = 0
let skippedCount = 0
if (stockCandidate) {
bomRows.forEach((bomItem: any) => {
const dosage = parseFloat(bomItem.dosage) || 0
const needQty = dosage * bomSets.value
const stockCandidate = allStockData.value.find(s =>
(s.base_id && s.base_id == bomItem.child_id)
)
if (stockCandidate) {
const availableQty = stockCandidate.availableCount || 0
const actualAddQty = Math.min(needQty, availableQty)
if (actualAddQty > 0) {
const existing = selectedItems.value.find(e => e.uniqueKey === stockCandidate.uniqueKey)
if (existing) {
existing.export_quantity += needQty
existing.export_quantity += actualAddQty
} else {
const newItem = JSON.parse(JSON.stringify(stockCandidate))
// 如果后端 BomService 也返回了 warehouse_location (聚合的),这里优先使用
if (bomItem.warehouse_location) {
newItem.warehouse_location = bomItem.warehouse_location
}
newItem.export_quantity = needQty
newItem.export_quantity = actualAddQty
selectedItems.value.push(newItem)
}
addedCount++
} else {
skippedCount++
}
})
if(addedCount > 0) {
ElMessage.success(`成功添加 BOM 相关物料,共 ${addedCount}`)
bomSelectVisible.value = false
} else {
ElMessage.warning('库存中未找到该 BOM 所需的任何原料')
skippedCount++
}
} catch(e) {
ElMessage.error('获取 BOM 详情失败')
})
if (addedCount > 0) {
const tip = skippedCount > 0 ? `(跳过 ${skippedCount} 种缺货物料)` : ''
ElMessage.success(`成功添加 ${addedCount} 类物料${tip}`)
bomSelectVisible.value = false
} else {
ElMessage.warning('该 BOM 所有物料库存均为 0')
}
}
// --- 通用逻辑 ---
const handleSelectionChange = (val: any[]) => {
selectedRows.value = val
if (val.length === 0 && isBulkMode.value) {
isBulkMode.value = false
}
}
const handleBulkRowClick = (row: any) => {
if (isBulkMode.value && tableRef.value) {
tableRef.value.toggleRowSelection(row, undefined)
}
}
const getRowClassName = () => {
return isBulkMode.value ? 'bulk-clickable-row' : ''
}
const cancelBulkMode = () => {
isBulkMode.value = false
selectedRows.value = []
}
const clearAll = () => {
ElMessageBox.confirm(
'确定要清空当前拣货车中的所有物品吗?',
'清空确认',
{
confirmButtonText: '确定清空',
cancelButtonText: '取消',
type: 'warning',
}
).then(() => {
selectedItems.value = []
selectedRows.value = []
isBulkMode.value = false
ElMessage.success('已清空拣货车')
}).catch(() => {})
}
const batchRemove = () => {
if (selectedRows.value.length === 0) return
ElMessageBox.confirm(
`确定要移除选中的 ${selectedRows.value.length} 项物品吗?`,
'批量移除确认',
{
confirmButtonText: '确定移除',
cancelButtonText: '取消',
type: 'warning',
}
).then(() => {
const keysToRemove = new Set(selectedRows.value.map(row => row.uniqueKey))
selectedItems.value = selectedItems.value.filter(item => !keysToRemove.has(item.uniqueKey))
selectedRows.value = []
isBulkMode.value = false
ElMessage.success(`已移除 ${keysToRemove.size} 项物品`)
}).catch(() => {})
}
const removeRow = (index: number) => {
selectedItems.value.splice(index, 1)
}
@ -691,9 +881,70 @@ const handlePreview = () => {
previewVisible.value = true
}
// ★ 出库申请
const openRequestDialog = () => {
if (validSelectedItems.value.length === 0) {
ElMessage.warning('请先添加物品并填写计划出库数量')
return
}
requestRemark.value = ''
requestApproverId.value = null
loadApprovers()
requestDialogVisible.value = true
}
// ★ 加载可指定审批人列表
const loadApprovers = async () => {
try {
const res: any = await getApproversList()
approvers.value = res.data || []
} catch (e) {
console.error('加载审批人列表失败', e)
approvers.value = []
}
}
const confirmSubmitRequest = async () => {
const trimmed = requestRemark.value.trim()
if (!trimmed) {
ElMessage.warning('请填写申请原因')
return
}
if (!requestApproverId.value) {
ElMessage.warning('请选择指定审批人')
return
}
requestSubmitting.value = true
try {
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
})),
remark: trimmed,
approver_id: requestApproverId.value
}
await submitOutboundRequest(payload)
// 成功:关闭弹窗、清空列表、提示
requestDialogVisible.value = false
selectedItems.value = []
ElMessage.success('出库申请已提交,等待主管审批!')
} catch (err: any) {
ElMessage.error(err?.message || err?.msg || '提交申请失败,请重试')
} finally {
requestSubmitting.value = false
}
}
const confirmPrint = async () => {
previewVisible.value = false;
// 记录日志
try {
const payload = validSelectedItems.value.map(item => ({
name: item.name, standard: item.standard, quantity: item.export_quantity
@ -702,7 +953,87 @@ const confirmPrint = async () => {
} catch (e) {}
setTimeout(() => {
window.print();
// 1. 获取要打印的区域 DOM
const printElement = document.getElementById('print-area');
if (!printElement) return;
// 2. 创建并挂载隐藏的 iframe
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.right = '0';
iframe.style.bottom = '0';
iframe.style.width = '0';
iframe.style.height = '0';
iframe.style.border = '0';
document.body.appendChild(iframe);
const iframeDoc = iframe.contentWindow?.document || iframe.contentDocument;
if (!iframeDoc) return;
// 3. 安全初始化 iframe 骨架(只写基本结构,不拼接任何业务代码)
iframeDoc.open();
iframeDoc.write('<!DOCTYPE html><html><head><title>出库单打印</title></head><body></body></html>');
iframeDoc.close();
// 4. 【核心修复】安全克隆所有样式节点,彻底告别乱码
const styles = document.querySelectorAll('style, link[rel="stylesheet"]');
styles.forEach(styleNode => {
iframeDoc.head.appendChild(styleNode.cloneNode(true));
});
// 5. 动态追加针对打印的强制分页 CSS
const customStyle = iframeDoc.createElement('style');
customStyle.innerHTML = `
/* 重置基础布局,解除所有高度死锁 */
html, body {
height: auto !important;
min-height: 100% !important;
overflow: visible !important;
background: white !important;
margin: 0;
padding: 0;
}
/* 规范 A4 纸张 */
@page {
size: A4 portrait;
margin: 10mm;
}
/* 确保打印区正常流式显示 */
#print-area {
display: block !important;
position: static !important;
width: 100% !important;
height: auto !important;
}
/* 核心:保护表格不被跨页截断 */
.print-table {
width: 100% !important;
table-layout: auto !important;
border-collapse: collapse;
}
.print-table tr, .print-table td, .print-table th {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
/* 隐藏不需要的全局 UI */
.el-overlay, .el-dialog__wrapper, .no-print-content {
display: none !important;
}
`;
iframeDoc.head.appendChild(customStyle);
// 6. 【核心修复】安全克隆打印区域到 body 中
iframeDoc.body.appendChild(printElement.cloneNode(true));
// 7. 延迟触发打印,等待样式完全渲染
setTimeout(() => {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
// 打印结束后清理 iframe
setTimeout(() => {
document.body.removeChild(iframe);
}, 1000);
}, 500); // 预留 500ms 渲染时间
}, 300);
}
@ -745,28 +1076,90 @@ const confirmExport = () => {
::v-deep(.el-card__body) { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* ================= ★★★ 打印专用样式 ★★★ ================= */
/* ================= ★★★ 打印区域排版样式 ★★★ ================= */
#print-area { display: none; }
@media print {
@page { margin: 0; size: auto; }
body * { visibility: hidden; }
.el-dialog__wrapper, .v-modal, .el-message, .no-print-content { display: none !important; }
#print-area, #print-area * { visibility: visible; }
#print-area {
position: fixed; left: 0; top: 0; width: 100%; height: 100%;
margin: 0; padding: 20mm; background-color: white;
display: block !important; z-index: 99999;
}
.print-header { text-align: center; margin-bottom: 20px; }
.print-header h1 { font-size: 24px; margin: 0 0 10px 0; font-weight: bold; color: #000; }
.print-meta-row { display: flex; justify-content: flex-start; font-size: 12px; margin-bottom: 5px; }
.header-line { border-bottom: 2px solid #000; margin-top: 5px; }
.print-table { width: 100%; border-collapse: collapse; margin-bottom: 40px; border: 1px solid #000; }
.print-table th, .print-table td { border: 1px solid #000; padding: 12px 8px; text-align: left; font-size: 14px; color: #000; }
.print-table th { text-align: center; font-weight: bold; }
.cell-padding { padding-left: 10px; }
.print-footer { display: flex; justify-content: space-between; margin-top: 60px; padding: 0 20px; }
.signature-item { display: flex; flex-direction: column; align-items: center; width: 30%; }
.sig-label { font-size: 14px; margin-bottom: 40px; text-align: left; width: 100%; }
.sig-line { border-bottom: 1px solid #000; width: 100%; height: 1px; display: block; }
.print-header { text-align: center; margin-bottom: 20px; }
.print-header h1 { font-size: 24px; margin: 0 0 10px 0; font-weight: bold; color: #000; }
.print-meta-row { display: flex; justify-content: flex-start; font-size: 12px; margin-bottom: 5px; }
.header-line { border-bottom: 2px solid #000; margin-top: 5px; }
.print-table { width: 100%; border-collapse: collapse; margin-bottom: 40px; border: 1px solid #000; }
.print-table th, .print-table td { border: 1px solid #000; padding: 12px 8px; text-align: left; font-size: 14px; color: #000; }
.print-table th { text-align: center; font-weight: bold; }
.cell-padding { padding-left: 10px; }
.print-table tr {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
.print-footer { display: flex; justify-content: space-between; margin-top: 60px; padding: 0 20px; }
.signature-item { display: flex; flex-direction: column; align-items: center; width: 30%; }
.sig-label { font-size: 14px; margin-bottom: 40px; text-align: left; width: 100%; }
.sig-line { border-bottom: 1px solid #000; width: 100%; height: 1px; display: block; }
/* ★★★ 修复预览弹窗中 el-table 打印分页截断问题 ★★★ */
.print-preview-content {
height: auto !important;
max-height: none !important;
overflow: visible !important;
}
.print-preview-content .el-table,
.print-preview-content .el-table__inner-wrapper,
.print-preview-content .el-table__body-wrapper,
.print-preview-content .el-table__body {
height: auto !important;
max-height: none !important;
overflow: visible !important;
}
.print-preview-content .el-scrollbar__wrap {
overflow: visible !important;
}
.print-preview-content .el-table tr {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
.print-preview-content .el-table__body-wrapper is-scrollable-none {
overflow: visible !important;
}
:deep(.bulk-clickable-row) {
cursor: pointer;
}
</style>
<style>
@media print {
@page { margin: 10mm; size: auto; }
/* 1. 保留原始:隐藏系统全局的无关元素 */
body * { visibility: hidden; }
.el-dialog__wrapper, .v-modal, .el-message, .no-print-content { display: none !important; }
/* 2. 【核心修复】:打通 Vue 和 Element 框架的所有父级容器,解除裁剪和定位死锁 */
html, body, #app, .el-container, .el-main, .el-scrollbar__wrap {
position: static !important;
overflow: visible !important;
height: auto !important;
min-height: auto !important;
}
/* 3. 保留原始:让打印区域显形,并用 absolute 顶至左上角,允许自然向下分页 */
#print-area, #print-area * { visibility: visible; }
#print-area {
position: absolute !important;
left: 0 !important;
top: 0 !important;
width: 100% !important;
height: auto !important;
margin: 0 !important;
padding: 0 !important;
background-color: white;
display: block !important;
z-index: 99999;
}
/* 4. 【核心修复】:防止表格行在跨页时被水平拦腰切断 */
.print-table tr, .print-table td, .print-table th {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
}
</style>

View File

@ -0,0 +1,375 @@
<template>
<div class="app-container">
<!-- 顶部工具栏 -->
<div class="filter-container">
<span style="font-weight: bold; font-size: 15px; margin-right: 8px;">审批状态:</span>
<el-radio-group v-model="filterStatus" size="default" @change="handleStatusChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button :label="0">待审批</el-radio-button>
<el-radio-button :label="1">已通过</el-radio-button>
<el-radio-button :label="2">已驳回</el-radio-button>
<el-radio-button :label="3">已完成</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="list"
border
stripe
style="margin-top: 16px;"
row-key="id"
:expand-row-keys="expandedRows"
@expand-change="handleExpandChange"
>
<!-- 展开行 -->
<el-table-column type="expand" width="60" align="center">
<template #default="{ row }">
<div style="padding: 12px 24px; background: #f5f7fa;">
<p style="margin: 0 0 10px 0; font-weight: bold; font-size: 13px; color: #606266;">
物料明细 {{ row.items?.length || 0 }}
</p>
<el-table
v-if="row.items?.length"
:data="row.items"
border
size="small"
style="width: 100%;"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="类型" width="90" align="center">
<template #default="{ row: item }">
<el-tag size="small">{{ item.material_type || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
<el-table-column prop="quantity" label="计划数量" width="100" align="center">
<template #default="{ row: item }">
<span style="color: #F56C6C; font-weight: bold;">{{ item.quantity ?? '-' }}</span>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无物料明细" :image-size="60" />
</div>
</template>
</el-table-column>
<el-table-column prop="request_no" label="申请单号" width="180">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="toggleExpand(row)">
{{ row.request_no }}
</el-link>
</template>
</el-table-column>
<el-table-column label="申请人" width="140">
<template #default="{ row }">
{{ getApplicantName(row.applicant_id) }}
</template>
</el-table-column>
<el-table-column prop="remark" label="申请原因" min-width="180" show-overflow-tooltip />
<el-table-column label="物料种类" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.items?.length || 0 }} </el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" width="170" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="审批信息" width="180">
<template #default="{ row }">
<template v-if="row.status === 1">
<span style="color: #67C23A;">{{ getApproverName(row.actual_approver_id) }}</span>
<br />
<span style="font-size: 12px; color: #909399;">{{ row.approved_at || '' }}</span>
</template>
<template v-else-if="row.status === 2">
<span style="color: #F56C6C;">已驳回</span>
<el-tooltip v-if="row.reject_reason" :content="row.reject_reason" placement="top">
<el-icon style="margin-left: 4px; cursor: pointer;"><Warning /></el-icon>
</el-tooltip>
</template>
<template v-else-if="row.status === 3">
<span style="color: #909399;">{{ getApproverName(row.actual_approver_id) }}</span>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<template v-if="row.status === 0">
<el-button
v-if="userStore.hasPermission('outbound_approval:operation')"
type="success"
size="small"
:loading="row._approving"
@click="handleApprove(row)"
>
通过
</el-button>
<el-button
v-if="userStore.hasPermission('outbound_approval:operation')"
type="danger"
size="small"
:loading="row._approving"
@click="openRejectDialog(row)"
>
驳回
</el-button>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
background
style="margin-top: 16px; justify-content: flex-end; display: flex;"
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, prev, pager, next, sizes"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
<!-- 驳回原因 Dialog -->
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="申请单号">
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
</el-form-item>
<el-form-item label="驳回原因" required>
<el-input
v-model="rejectReason"
type="textarea"
:rows="4"
placeholder="请填写驳回原因(必填)"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="rejectLoading" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Refresh, Warning } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getApprovalRequestList, approveRequest } from '@/api/outbound'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// --- 状态 ---
const list = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
const expandedRows = ref<string[]>([])
// 驳回 Dialog
const rejectDialogVisible = ref(false)
const currentRejectRow = ref<any>(null)
const rejectReason = ref('')
const rejectLoading = ref(false)
// 申请人 / 审批人名称缓存(避免重复查询)
const userNameCache = ref<Record<number, string>>({})
// --- 工具函数 ---
const statusText = (status: number) => {
const map: Record<number, string> = {
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'
}
return map[status] ?? '-'
}
const statusTagType = (status: number) => {
const map: Record<number, string> = {
0: 'warning', 1: 'success', 2: 'danger', 3: 'info'
}
return map[status] ?? 'info'
}
const getApplicantName = (id: number | null) => {
if (!id) return '-'
return userNameCache.value[id] ?? `用户 #${id}`
}
const getApproverName = (id: number | null) => {
if (!id) return '-'
return userNameCache.value[id] ?? `用户 #${id}`
}
// --- 展开行 ---
const toggleExpand = (row: any) => {
const idx = expandedRows.value.indexOf(row.id)
if (idx > -1) {
expandedRows.value.splice(idx, 1)
} else {
expandedRows.value.push(row.id)
}
}
const handleExpandChange = () => {
// expand 状态由 expandedRows 响应式控制,无需额外处理
}
// --- 数据获取 ---
const fetchData = async () => {
loading.value = true
try {
const params: any = {
page: page.value,
limit: pageSize.value
}
if (filterStatus.value !== '') {
params.status = filterStatus.value
}
const res: any = await getApprovalRequestList(params)
// 追加申请人名称缓存
const records = res.data?.items || []
records.forEach((r: any) => {
if (r.applicant_id && !userNameCache.value[r.applicant_id]) {
// 后端已返回 applicant_name 字段时直接用,否则标记待解析
if (r.applicant_name) {
userNameCache.value[r.applicant_id] = r.applicant_name
}
}
if (r.actual_approver_id && !userNameCache.value[r.actual_approver_id]) {
if (r.approver_name) {
userNameCache.value[r.actual_approver_id] = r.approver_name
}
}
// 附加空标记,防止重复请求
r._approving = false
})
list.value = records
total.value = res.data?.total || records.length || 0
} catch (err: any) {
ElMessage.error(err?.msg || '加载审批列表失败')
} finally {
loading.value = false
}
}
// --- 筛选 ---
const handleStatusChange = () => {
page.value = 1
expandedRows.value = []
fetchData()
}
// --- 分页 ---
const handlePageChange = (p: number) => {
page.value = p
fetchData()
}
const handleSizeChange = (s: number) => {
pageSize.value = s
page.value = 1
fetchData()
}
// --- 审批操作 ---
const handleApprove = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定要通过出库申请单 【${row.request_no}】 吗?`,
'审批确认',
{ confirmButtonText: '确定通过', cancelButtonText: '取消', type: 'info' }
)
} catch {
return
}
row._approving = true
try {
await approveRequest(row.id, { action: 'approve' })
ElMessage.success(`申请单 ${row.request_no} 已通过`)
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '审批操作失败')
} finally {
row._approving = false
}
}
const openRejectDialog = (row: any) => {
currentRejectRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmReject = async () => {
const reason = rejectReason.value.trim()
if (!reason) {
ElMessage.warning('请填写驳回原因')
return
}
rejectLoading.value = true
try {
await approveRequest(currentRejectRow.value.id, {
action: 'reject',
reject_reason: reason
})
ElMessage.success(`申请单 ${currentRejectRow.value.request_no} 已驳回`)
rejectDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '驳回操作失败')
} finally {
rejectLoading.value = false
}
}
// --- 初始化 ---
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.app-container {
padding: 20px;
}
.filter-container {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
</style>

View File

@ -15,6 +15,68 @@
</div>
</template>
<!-- 出库模式切换 -->
<div class="mode-switch-bar">
<el-radio-group v-model="outboundMode" size="default" @change="handleModeChange">
<el-radio-button value="by-request">按单出库</el-radio-button>
<el-radio-button value="direct">直接出库</el-radio-button>
</el-radio-group>
<span class="mode-hint">
{{ outboundMode === 'by-request' ? '需先选择已审批通过的申请单' : '无需审批单,自由扫码出库' }}
</span>
</div>
<!-- 按单出库审批单选择 -->
<div v-if="outboundMode === 'by-request'" class="approval-request-select">
<el-select
v-model="selectedRequestId"
placeholder="请选择已审批通过的出库申请单"
filterable
clearable
style="width: 100%"
:loading="requestsLoading"
@change="handleRequestChange"
>
<el-option
v-for="req in approvalRequests"
:key="req.id"
:value="req.id"
:label="req.request_no"
>
<span>{{ req.request_no }}</span>
<el-divider direction="vertical" />
<span>{{ req.applicant_name || '未知申请人' }}</span>
<el-divider direction="vertical" />
<span style="color: #909399; font-size: 13px">{{ req.remark || '无备注' }}</span>
</el-option>
</el-select>
<p class="select-tip">仅显示已通过status=1的审批单</p>
</div>
<!-- 按单出库计划清单预览 -->
<div v-if="outboundMode === 'by-request' && selectedRequest" class="planned-items-section">
<div class="planned-header">
<span class="planned-title">计划出库清单</span>
<el-tag type="success" size="small">{{ selectedRequest.items?.length || 0 }} </el-tag>
</div>
<el-table :data="selectedRequest.items || []" border size="small" style="width: 100%;">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.material_type || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" min-width="100" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="100" show-overflow-tooltip />
<el-table-column label="计划数量" width="90" align="center">
<template #default="{ row }">
<span style="color: #E6A23C; font-weight: bold;">{{ row.quantity ?? '-' }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="scan-section">
<div v-if="userStore.hasPermission('outbound_create:operation')" class="camera-placeholder" @click="showCamera = true">
@ -214,7 +276,7 @@ import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode, submitOutbound, getOutboundList } from '@/api/outbound'
import { getStockByBarcode, submitOutbound, getOutboundList, getApprovalRequestList } from '@/api/outbound'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
@ -228,6 +290,12 @@ const showCamera = ref(false)
const barcodeRef = ref()
const formRef = ref()
// ★ 双轨制模式
const outboundMode = ref<'by-request' | 'direct'>('by-request') // 'by-request' | 'direct'
const approvalRequests = ref<any[]>([])
const selectedRequest = ref<any>(null)
const requestsLoading = ref(false)
// 签名相关
const showSignatureDialog = ref(false)
const signaturePreviewUrl = ref('')
@ -258,8 +326,95 @@ const totalAmount = computed(() => {
return cartItems.value.reduce((sum, item) => sum + (item.price * item.out_quantity), 0)
})
// ★ 双轨制 computed
const selectedRequestId = computed({
get: () => selectedRequest.value?.id ?? null,
set: (val) => {
if (!val) {
selectedRequest.value = null
} else {
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
}
}
})
const plannedItems = computed(() => selectedRequest.value?.items ?? [])
// ★ 模式切换
const handleModeChange = () => {
selectedRequest.value = null
selectedRequestId.value = null
cartItems.value = []
form.consumer_name = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
}
// ★ 加载已审批通过的申请单
const loadApprovalRequests = async () => {
requestsLoading.value = true
try {
const res: any = await getApprovalRequestList({ status: 1, page: 1, pageSize: 100 })
approvalRequests.value = res.data?.items || []
} catch (e) {
console.error('加载审批单列表失败', e)
} finally {
requestsLoading.value = false
}
}
const handleRequestChange = (val: number | null) => {
if (!val) {
selectedRequest.value = null
} else {
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
}
// 切换申请单时清空购物车,防止已扫物品与新单据混淆
cartItems.value = []
signatureFile.value = null
signaturePreviewUrl.value = ''
}
// ★ 按单出库模式:校验扫码是否在计划内
const validateAgainstPlan = (scannedName: string, scannedSpec: string, scannedQty: number): string | null => {
const normalizedName = scannedName.trim()
const normalizedSpec = (scannedSpec || '').trim()
const matchedPlan = plannedItems.value.find(plan => {
const planName = (plan.name || '').trim()
const planSpec = (plan.spec_model || '').trim()
return planName === normalizedName && planSpec === normalizedSpec
})
if (!matchedPlan) {
return `该物料【${normalizedName} × ${normalizedSpec}】不在计划清单中,请检查`
}
const planQty = matchedPlan.quantity ?? 0
// 已扫数量(去重合并)
const alreadyScanned = cartItems.value
.filter(ci => {
const ciName = (ci.name || '').trim()
const ciSpec = (ci.spec_model || '').trim()
return ciName === normalizedName && ciSpec === normalizedSpec
})
.reduce((sum, ci) => sum + (ci.out_quantity || 0), 0)
if (alreadyScanned + scannedQty > planQty) {
return `${normalizedName} × ${normalizedSpec}】超出计划数量(计划: ${planQty},已扫: ${alreadyScanned},本次: ${scannedQty}`
}
return null // 通过
}
// --- 初始化 ---
onMounted(() => {
// 加载已审批通过的申请单列表
loadApprovalRequests()
if (userStore.username) {
form.operator_name = userStore.username
operatorOptions.value.push(userStore.username)
@ -313,15 +468,32 @@ const handleManualInput = async () => {
const code = barcodeInput.value.trim()
if (!code) return
// ★ 按单出库模式:必须先选择申请单
if (outboundMode.value === 'by-request' && !selectedRequest.value) {
ElMessage.warning('请先选择要出库的审批申请单')
return
}
try {
loading.value = true
// 1. 检查购物车重复
// 1. 检查购物车重复(直接模式走旧的追加逻辑,按单模式也复用但后续会校验)
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
if (existIndex > -1) {
const item = cartItems.value[existIndex]
const maxQty = parseFloat(item.available_quantity)
// ★ 按单模式:追加时仍需校验计划数量
if (outboundMode.value === 'by-request') {
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
}
const maxQty = parseFloat(item.available_quantity)
if (item.out_quantity < maxQty) {
item.out_quantity++
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
@ -343,16 +515,29 @@ const handleManualInput = async () => {
if (availQty <= 0) {
ElMessage.warning(`库存不足或已出库 (余: ${availQty})`)
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
} else {
// 加入购物车
cartItems.value.push({
...item,
out_quantity: 1,
price: parseFloat(item.price || 0)
})
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
barcodeInput.value = ''
return
}
// ★ 按单模式:扫码加入前校验是否在计划清单内
if (outboundMode.value === 'by-request') {
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
}
// 加入购物车
cartItems.value.push({
...item,
out_quantity: 1,
price: parseFloat(item.price || 0)
})
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
barcodeInput.value = ''
}
} catch (error: any) {
@ -393,6 +578,7 @@ const clearAll = () => {
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
// ★ 按单模式:仅清空购物车,保留申请单选择
})
}
@ -416,40 +602,67 @@ const submitForm = async () => {
try {
loading.value = true
// 上传签名
// 1. 上传签名
const uploadRes = await uploadFile(signatureFile.value)
const signatureUrl = uploadRes.data.url
const itemsPayload = cartItems.value.map(item => ({
stock_id: item.id,
source_table: item.source_table,
sku: item.sku,
barcode: item.barcode,
quantity: item.out_quantity,
price: item.price
}))
// 2. 核心保护:坚决杜绝 undefined、null 和 0
const itemsPayload = cartItems.value.map(item => {
// 强制确保出库数量是一个大于 0 的有效数字
let safeQuantity = Number(item.out_quantity)
if (isNaN(safeQuantity) || safeQuantity <= 0) {
safeQuantity = 1 // 兜底:只要扫了码,最少出 1 个
}
await submitOutbound({
items: itemsPayload,
return {
stock_id: item.id || 0,
source_table: item.source_table || '',
// 如果原数据 sku 是空,强制塞一个默认字符串,绝不传空值给后端引发 None 报错
sku: item.sku ? String(item.sku) : (item.barcode ? String(item.barcode) : 'NO_SKU'),
barcode: item.barcode ? String(item.barcode) : '',
quantity: safeQuantity,
price: item.price ? Number(item.price) : 0
}
})
if (itemsPayload.length === 0) {
ElMessage.warning('请至少扫描一件物料后再提交出库')
return
}
// 3. 组装发给后端的包
const submitPayload: any = {
outbound_type: form.outbound_type,
request_id: outboundMode.value === 'by-request' && selectedRequest.value ? selectedRequest.value.id : null,
consumer_name: form.consumer_name,
operator_name: form.operator_name,
remark: form.remark,
signature_path: signatureUrl
})
signature_path: signatureUrl,
items: itemsPayload
}
// 打印在前端控制台,你可以按 F12 在 Console 里核对这把"铁证"
console.log('准备提交给后端的最终数据:', JSON.parse(JSON.stringify(submitPayload)))
// 4. 发送请求
await submitOutbound(submitPayload)
ElMessage.success('出库成功')
// 重置
// 5. 成功后重置页面
cartItems.value = []
form.consumer_name = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
loadHistoryOperators()
// 根据你的项目实际变量重置签名组件,如果没有这句可以删掉
if (typeof signaturePreviewUrl !== 'undefined') {
signaturePreviewUrl.value = ''
}
} catch (error) {
console.error(error)
ElMessage.error('提交失败')
console.error('出库报错:', error)
ElMessage.error('提交失败,请检查数据')
} finally {
loading.value = false
}
@ -547,6 +760,39 @@ onUnmounted(() => {
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
.header-price { font-size: 18px; color: #F56C6C; font-weight: bold; }
/* ★ 双轨制模式切换 */
.mode-switch-bar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
padding: 12px 16px;
background: #f5f7fa;
border-radius: 8px;
border: 1px solid #e4e7ed;
}
.mode-hint { color: #909399; font-size: 13px; }
/* ★ 审批单选择 */
.approval-request-select { margin-bottom: 16px; }
.select-tip { margin: 6px 0 0 0; color: #909399; font-size: 12px; }
/* ★ 计划清单 */
.planned-items-section {
margin-bottom: 16px;
padding: 12px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 8px;
}
.planned-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.planned-title { font-weight: bold; font-size: 14px; color: #67C23A; }
/* 扫码区(卡片内触发器) */
.scan-section { margin-bottom: 20px; }
.camera-placeholder {

View File

@ -266,6 +266,16 @@
<div style="display: flex; align-items: center;">
<el-icon class="icon"><Box/></el-icon>
<span>1. 基础信息</span>
<el-link
v-if="form.base_id"
type="primary"
:underline="false"
style="margin-left: 15px; font-size: 13px;"
@click="openMaterialInNewTab"
>
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
</el-link>
</div>
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索锁定物料)</span>
</div>
@ -666,9 +676,12 @@
<script setup lang="ts">
import {ref, reactive, onMounted, watch, computed} from 'vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Delete, Picture} from '@element-plus/icons-vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Delete, Picture, EditPen} from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
const router = useRouter()
import {ElMessage, ElMessageBox, ElLoading} from 'element-plus'
import dayjs from 'dayjs'
import request from '@/utils/request'
import {
getBuyList,
createBuyInbound,
@ -959,31 +972,8 @@ const permissionMap: Record<string, string> = {
inspection_report: 'inbound_buy:inspection_report'
}
// 初始化列显示状态
// 初始化列显示状态(纯权限驱动,废除本地缓存)
const initColumnPermissions = () => {
// 生成存储键:基于用户 ID 进行隔离A/B 账号互不干扰
const userId = userStore.user?.id || userStore.username || 'anonymous'
const storageKey = `inbound_buy_columns_${userId}`
// 尝试从 localStorage 读取保存的列配置
const savedColumns = localStorage.getItem(storageKey)
if (savedColumns) {
try {
const parsed = JSON.parse(savedColumns)
// 【核心修复】权限二次交集:缓存的列必须同时满足"存在于 allColumns 且当前拥有该字段权限"
const permittedCols = parsed.filter((prop: string) =>
allColumns.some(col => col.prop === prop) && hasColumnPermission(prop)
)
if (permittedCols.length > 0) {
visibleColumnProps.value = permittedCols
return
}
} catch (e) {
console.warn('Failed to parse saved columns:', e)
}
}
// 【任务1】废除硬编码默认动态计算所有有权限的列默认展示
visibleColumnProps.value = allColumns
.filter(col => hasColumnPermission(col.prop))
.map(col => col.prop)
@ -1002,17 +992,6 @@ const allColumns = [...baseColumns, ...stockColumns]
const visibleColumnProps = ref<string[]>([])
// 监听列配置变化并保存到 localStorage
watch(visibleColumnProps, (newVal) => {
const userId = userStore.user?.id || userStore.username || 'anonymous'
const storageKey = `inbound_buy_columns_${userId}`
try {
localStorage.setItem(storageKey, JSON.stringify(newVal))
} catch (e) {
console.warn('Failed to save columns to localStorage:', e)
}
}, { deep: true })
const form = reactive({
id: undefined, base_id: undefined as number | undefined,
company_name: '',
@ -1132,7 +1111,7 @@ const loadMoreMaterials = async () => {
}
}
const onMaterialSelected = (val: number) => {
const onMaterialSelected = async (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (item) {
form.company_name = item.company_name
@ -1146,6 +1125,17 @@ const onMaterialSelected = (val: number) => {
// 更新表单校验规则
updateInspectionRules()
checkHistoryAndSetMode(item.id)
// 获取该物料历史入库库位(新增独立接口)
try {
const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: val } })
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
}
} catch (e) {
console.error('获取历史库位失败', e)
}
}
}
@ -1416,6 +1406,22 @@ const handleUpdate = (row: any) => {
visible.value = true
}
// 在新标签页打开基础信息编辑
const openMaterialInNewTab = () => {
if (!form.base_id) {
return ElMessage.warning('请先选择一个物料')
}
const routeUrl = router.resolve({
path: '/material',
query: {
edit_id: form.base_id,
// 【新增】:优先传递规格型号,如果没有则传名称,用于背景表格过滤
keyword: form.spec_model || form.material_name || ''
}
})
window.open(routeUrl.href, '_blank')
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {

View File

@ -250,9 +250,18 @@
<div class="form-card basic-card">
<div class="card-title">
<div style="display: flex; align-items: center;">
<div style="display: flex; align-items: center; gap: 8px;">
<el-icon class="icon"><Box /></el-icon>
<span>1. 基础信息</span>
<el-link
v-if="form.base_id"
type="primary"
:underline="false"
style="font-size: 13px;"
@click="openMaterialInNewTab"
>
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
</el-link>
</div>
</div>
<div class="card-content">
@ -421,7 +430,12 @@
</div>
<div class="form-card production-card">
<div class="card-title"><el-icon class="icon"><Setting /></el-icon><span>3. 生产与销售信息</span></div>
<div class="card-title">
<el-icon class="icon"><Setting /></el-icon><span>3. 生产与销售信息</span>
<el-link type="success" :underline="false" style="margin-left: 15px; font-size: 13px;" @click="createBomForMaterial">
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
</el-link>
</div>
<div class="card-content">
<el-row :gutter="24">
@ -556,9 +570,11 @@
<script setup lang="ts">
import { ref, reactive, onMounted, watch, computed } from 'vue'
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture } from '@element-plus/icons-vue'
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElLoading } from 'element-plus'
import dayjs from 'dayjs'
import request from '@/utils/request'
import {
getProductList,
createProductInbound,
@ -613,6 +629,21 @@ const vLoadmore = {
}
const userStore = useUserStore()
const router = useRouter()
// 在新标签页打开基础信息编辑
const openMaterialInNewTab = () => {
if (!form.base_id) return ElMessage.warning('请先选择物料')
const routeUrl = router.resolve({
path: '/material',
query: {
edit_id: form.base_id,
keyword: form.spec_model || form.material_name || ''
}
})
window.open(routeUrl.href, '_blank')
}
const loading = ref(false)
const submitting = ref(false)
const visible = ref(false)
@ -779,31 +810,8 @@ const permissionMap: Record<string, string> = {
}
// 根据用户权限初始化列显示状态
// 初始化列显示状态
// 初始化列显示状态(纯权限驱动,废除本地缓存)
const initColumnPermissions = () => {
// 生成存储键:基于用户 ID 进行隔离A/B 账号互不干扰
const userId = userStore.user?.id || userStore.username || 'anonymous'
const storageKey = `inbound_product_columns_${userId}`
// 尝试从 localStorage 读取保存的列配置
const savedColumns = localStorage.getItem(storageKey)
if (savedColumns) {
try {
const parsed = JSON.parse(savedColumns)
// 【核心修复】权限二次交集:缓存的列必须同时满足"存在于 allColumns 且当前拥有该字段权限"
const permittedCols = parsed.filter((prop: string) =>
allColumns.some(col => col.prop === prop) && hasColumnPermission(prop)
)
if (permittedCols.length > 0) {
visibleColumnProps.value = permittedCols
return
}
} catch (e) {
console.warn('Failed to parse saved columns:', e)
}
}
// 【任务1】废除硬编码默认动态计算所有有权限的列默认展示
visibleColumnProps.value = allColumns
.filter(col => hasColumnPermission(col.prop))
.map(col => col.prop)
@ -854,17 +862,6 @@ const displayData = computed(() => {
const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
const visibleColumnProps = ref<string[]>([])
// 监听列配置变化并保存到 localStorage
watch(visibleColumnProps, (newVal) => {
const userId = userStore.user?.id || userStore.username || 'anonymous'
const storageKey = `inbound_product_columns_${userId}`
try {
localStorage.setItem(storageKey, JSON.stringify(newVal))
} catch (e) {
console.warn('Failed to save columns to localStorage:', e)
}
}, { deep: true })
const form = reactive({
id: undefined, base_id: undefined as number | undefined,
company_name: '', // [新增]
@ -1029,7 +1026,7 @@ const loadMoreMaterials = async () => {
}
}
const onMaterialSelected = (val: number) => {
const onMaterialSelected = async (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (item) {
form.company_name = item.company_name // [新增]
@ -1038,6 +1035,17 @@ const onMaterialSelected = (val: number) => {
form.material_type = item.type
form.category = item.category
form.unit = item.unit
// 获取该物料历史入库库位(新增独立接口)
try {
const res = await request.get('/v1/inbound/product/last-location', { params: { base_id: val } })
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
}
} catch (e) {
console.error('获取历史库位失败', e)
}
}
}
@ -1285,6 +1293,20 @@ const handleScannerConfirm = (result: string) => {
ElMessage.success('序列号已提取')
}
// 快速基于此物料创建 BOM
const createBomForMaterial = () => {
if (!form.base_id) return ElMessage.warning('请先锁定物料基础信息')
const routeUrl = router.resolve({
path: '/bom',
query: {
create_for_id: form.base_id,
parent_name: form.material_name,
parent_spec: form.spec_model
}
})
window.open(routeUrl.href, '_blank')
}
const submitForm = async () => {
await formRef.value.validate(async (valid: boolean) => {
if(valid) {

View File

@ -0,0 +1,578 @@
<template>
<div class="repair-container">
<!-- 顶部搜索区 -->
<el-card class="search-card" shadow="never">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="维修单号">
<el-input v-model="searchForm.repair_no" placeholder="请输入维修单号" clearable style="width: 180px" />
</el-form-item>
<el-form-item label="SN序列号">
<el-input v-model="searchForm.serial_number" placeholder="请输入序列号" clearable style="width: 180px" />
</el-form-item>
<el-form-item label="物料名称">
<el-input v-model="searchForm.material_name" placeholder="请输入物料名称" clearable style="width: 180px" />
</el-form-item>
<el-form-item label="维修状态">
<el-select v-model="searchForm.repair_status" placeholder="请选择状态" clearable style="width: 150px">
<el-option label="待检测" value="待检测" />
<el-option label="维修中" value="维修中" />
<el-option label="等待配件" value="等待配件" />
<el-option label="已修复" value="已修复" />
<el-option label="报废转出" value="报废转出" />
<el-option label="已出库" value="已出库" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮区 + 快捷状态过滤 -->
<div class="action-bar">
<el-button v-if="userStore.hasPermission('inbound_repair:add')" type="primary" :icon="Plus" @click="handleCreate">新增维修</el-button>
<el-radio-group v-model="searchForm.repair_status" @change="handleSearch" class="status-filter-group">
<el-radio-button value="未出库">未出库</el-radio-button>
<el-radio-button value="全部">全部</el-radio-button>
<el-radio-button value="待检测">待检测</el-radio-button>
<el-radio-button value="维修中">维修中</el-radio-button>
<el-radio-button value="等待配件">等待配件</el-radio-button>
<el-radio-button value="已修复">已修复</el-radio-button>
<el-radio-button value="报废转出">报废转出</el-radio-button>
<el-radio-button value="已出库">已出库</el-radio-button>
</el-radio-group>
</div>
<!-- 数据表格 -->
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
<el-table-column prop="repair_no" label="维修单号" width="180" />
<el-table-column prop="sku" label="全局SKU(系统条码)" width="140" />
<el-table-column prop="material_name" label="物料名称" width="150" />
<el-table-column prop="serial_number" label="序列号(SN)" width="150" />
<el-table-column prop="customer_name" label="客户名称" width="120" />
<el-table-column prop="customer_location" label="所在地" width="150" show-overflow-tooltip />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row.repair_status)">{{ row.repair_status || '待检测' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="arrival_date" label="接收时间" width="120" />
<el-table-column prop="fault_description" label="故障描述" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="280" fixed="right" align="center">
<template #default="{ row }">
<el-button v-if="userStore.hasPermission('inbound_repair:edit')" type="warning" link size="small" @click="handlePrint(row)">
<el-icon><Printer /></el-icon> 打印
</el-button>
<el-button v-if="userStore.hasPermission('inbound_repair:edit')" type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button v-if="userStore.hasPermission('inbound_repair:edit') && row.repair_status !== '已出库'" type="success" link size="small" @click="handleUpdateStatus(row)">
更新状态
</el-button>
<el-button v-if="userStore.hasPermission('inbound_repair:delete')" type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 底部分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[20, 50, 100, 200]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
<!-- 新增/编辑维修单弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="650px" destroy-on-close :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="物料名称" prop="material_name">
<el-input v-model="form.material_name" placeholder="请输入物料名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="来源类型" prop="source_table">
<el-select v-model="form.source_table" placeholder="请选择来源类型" style="width: 100%">
<el-option label="采购入库" value="stock_buy" />
<el-option label="成品入库" value="stock_product" />
<el-option label="半成品入库" value="stock_semi" />
<el-option label="独立录入" value="independent" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="序列号SN" prop="serial_number">
<el-input v-model="form.serial_number" placeholder="请输入或扫描序列号">
<template #append>
<el-button :icon="Camera" @click="openScanner" title="智能扫码" />
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="接收时间" prop="arrival_date">
<el-date-picker
v-model="form.arrival_date"
type="date"
placeholder="选择接收时间"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="客户名称" prop="customer_name">
<el-input v-model="form.customer_name" placeholder="请输入客户名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="所在地" prop="customer_location">
<el-input v-model="form.customer_location" placeholder="请输入客户所在地" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="故障描述" prop="fault_description">
<el-input v-model="form.fault_description" type="textarea" :rows="3" placeholder="请输入客户反馈的故障描述" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="维修人" prop="repair_manager">
<el-input v-model="form.repair_manager" placeholder="请输入维修人" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="成本价" prop="cost_price">
<el-input-number v-model="form.cost_price" :precision="2" :min="0" :controls="false" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="销售价" prop="sale_price">
<el-input-number v-model="form.sale_price" :precision="2" :min="0" :controls="false" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 更新维修状态弹窗 -->
<el-dialog v-model="statusDialogVisible" title="更新维修状态" width="500px" destroy-on-close :close-on-click-modal="false">
<el-form ref="statusFormRef" :model="statusForm" :rules="statusFormRules" label-width="100px">
<el-form-item label="维修单号">
<el-input :value="statusForm.repair_no" disabled />
</el-form-item>
<el-form-item label="当前状态">
<el-tag :type="getStatusType(statusForm.repair_status)">{{ statusForm.repair_status }}</el-tag>
</el-form-item>
<el-form-item label="新状态" prop="status">
<el-select v-model="statusForm.status" placeholder="请选择新状态" style="width: 100%">
<el-option label="待检测" value="待检测" />
<el-option label="维修中" value="维修中" />
<el-option label="等待配件" value="等待配件" />
<el-option label="已修复" value="已修复" />
</el-select>
</el-form-item>
<el-form-item label="追加日志" prop="repair_log">
<el-input v-model="statusForm.repair_log" type="textarea" :rows="4" placeholder="请输入维修日志或备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="statusDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="statusSubmitLoading" @click="handleStatusSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 智能扫码弹窗 -->
<SmartScannerDialog v-model="scannerDialogVisible" @confirm="handleScannerConfirm" />
<!-- 打印预览弹窗 -->
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
<div v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="打印预览" style="width: 100%" />
</div>
<p>打印机 IP: 192.168.9.205</p>
<div style="margin: 15px 0;">
<span style="font-weight: bold; color: #303133;">打印份数:</span>
<el-input-number v-model="printCopies" :min="1" :max="100" size="default" style="width: 120px; margin-left: 10px;" />
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="printVisible = false">取消</el-button>
<el-button type="primary" :loading="printing" @click="confirmPrint">
<el-icon><Printer /></el-icon>确认打印
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import dayjs from 'dayjs'
import { Plus, Search, Refresh, Printer, Camera, Edit } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { getRepairList, createRepair, updateRepair, updateRepairStatus, deleteRepair } from '@/api/inbound/repair'
import { getLabelPreview, executePrint } from '@/api/common/print'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
const userStore = useUserStore()
// 搜索表单
const searchForm = reactive({
repair_no: '',
serial_number: '',
material_name: '',
repair_status: '未出库'
})
// 表格数据
const tableData = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 弹窗标题
const dialogTitle = computed(() => form.id ? '编辑维修单' : '新增维修单')
// 新增/编辑弹窗
const dialogVisible = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const form = reactive({
id: undefined as number | undefined,
material_name: '',
serial_number: '',
source_table: 'independent',
arrival_date: '',
fault_description: '',
customer_name: '',
customer_location: '',
repair_manager: '',
cost_price: undefined as number | undefined,
sale_price: undefined as number | undefined
})
// 表单校验规则
const formRules = reactive({
material_name: [{ required: true, message: '请输入物料名称', trigger: 'blur' }],
serial_number: [{ required: true, message: '请输入序列号', trigger: 'blur' }],
customer_name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }]
})
// 状态更新弹窗
const statusDialogVisible = ref(false)
const statusSubmitLoading = ref(false)
const statusFormRef = ref()
const statusForm = reactive({
id: 0,
repair_no: '',
repair_status: '',
status: '',
repair_log: ''
})
const statusFormRules: ElFormRules = [
{ required: true, message: '请选择新状态', trigger: 'change', field: 'status' }
]
// 智能扫码
const scannerDialogVisible = ref(false)
const openScanner = () => {
scannerDialogVisible.value = true
}
const handleScannerConfirm = (result: string) => {
form.serial_number = result
scannerDialogVisible.value = false
}
// 打印相关
const printVisible = ref(false)
const printLoading = ref(false)
const printing = ref(false)
const previewUrl = ref('')
const printCopies = ref(1)
const currentPrintData = ref<any>({})
// 状态颜色映射
const getStatusType = (status: string) => {
const map: Record<string, string> = {
'待检测': 'info',
'维修中': 'warning',
'等待配件': 'warning',
'已修复': 'success',
'报废转出': 'danger',
'已出库': 'success'
}
return map[status] || 'info'
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
...searchForm
}
const res = await getRepairList(params)
if (res.code === 200) {
tableData.value = res.data.list || []
pagination.total = res.data.total || 0
}
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
fetchData()
}
// 重置
const handleReset = () => {
searchForm.repair_no = ''
searchForm.serial_number = ''
searchForm.material_name = ''
searchForm.repair_status = ''
handleSearch()
}
// 获取默认时间
const getDefaultDate = () => {
return dayjs().format('YYYY-MM-DD')
}
// 新增 - 打开弹窗
const handleCreate = () => {
// 重置表单
form.id = undefined
form.material_name = ''
form.serial_number = ''
form.source_table = 'independent'
form.arrival_date = getDefaultDate()
form.fault_description = ''
form.customer_name = ''
form.customer_location = ''
form.repair_manager = ''
form.cost_price = undefined
form.sale_price = undefined
dialogVisible.value = true
}
// 编辑 - 打开弹窗
const handleEdit = (row: any) => {
form.id = row.id
form.material_name = row.material_name || ''
form.serial_number = row.serial_number || ''
form.source_table = row.source_table || 'independent'
form.arrival_date = row.arrival_date || getDefaultDate()
form.fault_description = row.fault_description || ''
form.customer_name = row.customer_name || ''
form.customer_location = row.customer_location || ''
form.repair_manager = row.repair_manager || ''
form.cost_price = row.cost_price ?? undefined
form.sale_price = row.sale_price ?? undefined
dialogVisible.value = true
}
// 提交新增/编辑
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch (error) {
return // 校验不通过,直接阻断提交
}
submitLoading.value = true
try {
if (form.id) {
// 编辑
const res = await updateRepair(form.id, form)
if (res.code === 200) {
ElMessage.success('编辑成功')
dialogVisible.value = false
fetchData()
} else {
ElMessage.error(res.msg || '编辑失败')
}
} else {
// 新增
const res = await createRepair(form)
if (res.code === 200) {
ElMessage.success('新增成功')
dialogVisible.value = false
fetchData()
} else {
ElMessage.error(res.msg || '新增失败')
}
}
} finally {
submitLoading.value = false
}
}
// 更新状态
const handleUpdateStatus = (row: any) => {
statusForm.id = row.id
statusForm.repair_no = row.repair_no
statusForm.repair_status = row.repair_status || '待检测'
statusForm.status = ''
statusForm.repair_log = ''
statusDialogVisible.value = true
}
// 提交状态更新
const handleStatusSubmit = async () => {
if (!statusFormRef.value) return
await statusFormRef.value.validate()
statusSubmitLoading.value = true
try {
const res = await updateRepairStatus({
id: statusForm.id,
status: statusForm.status,
repair_log: statusForm.repair_log
})
if (res.code === 200) {
ElMessage.success('状态更新成功')
statusDialogVisible.value = false
fetchData()
} else {
ElMessage.error(res.msg || '更新失败')
}
} finally {
statusSubmitLoading.value = false
}
}
// 删除
const handleDelete = (row: any) => {
ElMessageBox.confirm(`确定要删除维修单 ${row.repair_no} 吗?`, '警告', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
}).then(async () => {
const res = await deleteRepair(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchData()
} else {
ElMessage.error(res.msg || '删除失败')
}
}).catch(() => {})
}
// 打印标签
const handlePrint = async (row: any) => {
printVisible.value = true
printLoading.value = true
printCopies.value = 1
currentPrintData.value = {
sku: row.sku,
material_name: row.material_name,
serial_number: row.serial_number,
repair_no: row.repair_no
}
try {
const res: any = await getLabelPreview(currentPrintData.value)
previewUrl.value = res.data
} catch (e) {
ElMessage.error('预览失败')
} finally {
printLoading.value = false
}
}
// 确认打印
const confirmPrint = async () => {
printing.value = true
try {
await executePrint({ ...currentPrintData.value, copies: printCopies.value })
ElMessage.success(`打印指令已发送 (x${printCopies.value})`)
printVisible.value = false
} catch (e: any) {
ElMessage.error(e.msg || '打印失败')
} finally {
printing.value = false
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.repair-container {
padding: 20px;
}
.search-card {
margin-bottom: 16px;
}
.search-form {
margin-bottom: 0;
}
.action-bar {
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 20px;
}
.status-filter-group {
margin-left: auto;
}
.pagination-container {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.preview-box {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
margin-bottom: 15px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@ -287,9 +287,18 @@
<div class="form-card basic-card">
<div class="card-title">
<div style="display: flex; align-items: center;">
<div style="display: flex; align-items: center; gap: 8px;">
<el-icon class="icon"><Box/></el-icon>
<span>1. 基础信息</span>
<el-link
v-if="form.base_id"
type="primary"
:underline="false"
style="font-size: 13px;"
@click="openMaterialInNewTab"
>
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
</el-link>
</div>
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索选择半成品物料)</span>
</div>
@ -489,6 +498,9 @@
<div style="display: flex; align-items: center;">
<el-icon class="icon"><Setting/></el-icon>
<span>3. 生产与成本信息</span>
<el-link type="success" :underline="false" style="margin-left: 15px; font-size: 13px;" @click="createBomForMaterial">
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
</el-link>
</div>
</div>
<div class="card-content">
@ -613,9 +625,11 @@
<script setup lang="ts">
import {ref, reactive, onMounted, watch, computed} from 'vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Picture} from '@element-plus/icons-vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Picture, EditPen} from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import {ElMessage, ElLoading} from 'element-plus'
import dayjs from 'dayjs'
import request from '@/utils/request'
import {
getSemiList,
createSemiInbound,
@ -672,6 +686,21 @@ const vLoadmore = {
// 状态与变量
// ------------------------------------
const userStore = useUserStore()
const router = useRouter()
// 在新标签页打开基础信息编辑
const openMaterialInNewTab = () => {
if (!form.base_id) return ElMessage.warning('请先选择物料')
const routeUrl = router.resolve({
path: '/material',
query: {
edit_id: form.base_id,
keyword: form.spec_model || form.material_name || ''
}
})
window.open(routeUrl.href, '_blank')
}
const loading = ref(false)
const submitting = ref(false)
const visible = ref(false)
@ -810,31 +839,8 @@ const stockColumns = [
]
const allColumns = [...baseColumns, ...stockColumns]
// 初始化列显示状态
// 初始化列显示状态(纯权限驱动,废除本地缓存)
const initColumnPermissions = () => {
// 生成存储键:基于用户 ID 进行隔离A/B 账号互不干扰
const userId = userStore.user?.id || userStore.username || 'anonymous'
const storageKey = `inbound_semi_columns_${userId}`
// 尝试从 localStorage 读取保存的列配置
const savedColumns = localStorage.getItem(storageKey)
if (savedColumns) {
try {
const parsed = JSON.parse(savedColumns)
// 【核心修复】权限二次交集:缓存的列必须同时满足"存在于 allColumns 且当前拥有该字段权限"
const permittedCols = parsed.filter((prop: string) =>
allColumns.some(col => col.prop === prop) && hasColumnPermission(prop)
)
if (permittedCols.length > 0) {
visibleColumnProps.value = permittedCols
return
}
} catch (e) {
console.warn('Failed to parse saved columns:', e)
}
}
// 【任务1】废除硬编码默认动态计算所有有权限的列默认展示
visibleColumnProps.value = allColumns
.filter(col => hasColumnPermission(col.prop))
.map(col => col.prop)
@ -891,17 +897,6 @@ const hasColumnPermission = (prop: string) => {
const defaultColumns = ['company_name', 'material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'qty_stock', 'qty_available', 'unit_total_cost', 'arrival_photo', 'quality_report_link']
const visibleColumnProps = ref<string[]>([])
// 监听列配置变化并保存到 localStorage
watch(visibleColumnProps, (newVal) => {
const userId = userStore.user?.id || userStore.username || 'anonymous'
const storageKey = `inbound_semi_columns_${userId}`
try {
localStorage.setItem(storageKey, JSON.stringify(newVal))
} catch (e) {
console.warn('Failed to save columns to localStorage:', e)
}
}, { deep: true })
const form = reactive({
id: undefined, base_id: undefined as number | undefined,
company_name: '',
@ -1027,7 +1022,7 @@ const loadMoreMaterials = async () => {
}
}
const onMaterialSelected = (val: number) => {
const onMaterialSelected = async (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (item) {
form.company_name = item.company_name // [新增]
@ -1037,6 +1032,17 @@ const onMaterialSelected = (val: number) => {
form.unit = item.unit
form.material_type = item.type
checkHistoryAndSetMode(item.id)
// 获取该物料历史入库库位(新增独立接口)
try {
const res = await request.get('/v1/inbound/semi/last-location', { params: { base_id: val } })
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
}
} catch (e) {
console.error('获取历史库位失败', e)
}
}
}
@ -1368,6 +1374,20 @@ const handleScannerConfirm = (result: string) => {
ElMessage.success('序列号已提取')
}
// 快速基于此物料创建 BOM
const createBomForMaterial = () => {
if (!form.base_id) return ElMessage.warning('请先锁定物料基础信息')
const routeUrl = router.resolve({
path: '/bom',
query: {
create_for_id: form.base_id,
parent_name: form.material_name,
parent_spec: form.spec_model
}
})
window.open(routeUrl.href, '_blank')
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {

View File

@ -486,6 +486,7 @@ const listData = ref<any[]>([])
const listStatusFilter = ref<'all' | 'counted' | 'uncounted'>('all')
const allStockItems = ref<any[]>([]) // 全量应盘物资(盘点基数)
const totalStockCount = ref(0) // ★ 全量应盘物资总数不受limit限制
const totalScannedCount = ref(0) // ★ 后端去重的真实已盘数量
const allScannedDrafts = ref<any[]>([]) // 全量草稿记录(脱离分页和过滤)
const listTotalFiltered = ref(0) // 过滤后的总数
@ -515,18 +516,9 @@ const stats = computed(() => {
const total = allStockItems.value.length
if (total === 0) return { total: 0, scanned: 0, varianceItems: 0 }
// 使用完整的 allScannedDrafts 来计算"已盘"数量,绝对不依赖视图数据
const countedItems = new Set()
allScannedDrafts.value.forEach((d: any) => {
// 只要有实盘记录就算已盘
if (d.quantity !== undefined && d.quantity !== null) {
countedItems.add(`${d.source_table}-${d.stock_id}`)
}
})
return {
total,
scanned: countedItems.size,
scanned: totalScannedCount.value,
varianceItems: 0
}
})
@ -665,7 +657,7 @@ const resumeSession = async () => {
const res: any = await request({
url: '/v1/inbound/stock/draft/list',
method: 'get',
params: { page: 1, limit: 500 } // ★ 限制单次加载数量,防止内存溢出
params: { page: 1, limit: 99999 } // ★ 获取全量草稿数据
})
const drafts = res && res.items ? res.items : []
@ -722,6 +714,9 @@ const onScanSuccess = async (code: string) => {
if (!code || loading.value) return
const trimCode = code.trim()
// 将扫到的条码同步显示在输入框中
barcodeInput.value = trimCode
if (!/^[A-Za-z0-9\-\.]+$/.test(trimCode)) {
ElMessage.warning(`识别到异常字符:${trimCode}`)
return
@ -990,7 +985,7 @@ const fetchInventoryList = async (silent = false) => {
method: 'get',
params: {
page: 1,
limit: 500, // ★ 限制单次加载数量,防止内存溢出
limit: 99999, // ★ 获取全量草稿数据
keyword: listKeyword.value,
session_id: currentSessionId.value // ★ 必须传递 session_id 隔离会话
}
@ -1002,6 +997,8 @@ const fetchInventoryList = async (silent = false) => {
// 保存全量草稿记录用于全局统计
allScannedDrafts.value = scannedDrafts
// 直接读取后端算好的去重已盘数
totalScannedCount.value = res?.total_scanned || 0
// 2. 使用全量应盘物资列表
// 对于每个应盘物资,检查是否有对应的盘点记录
@ -1020,7 +1017,7 @@ const fetchInventoryList = async (silent = false) => {
material_name: item.material_name,
spec_model: item.spec_model,
stock_qty: item.stock_qty, // 账面数(盲盘时隐藏)
quantity: draft?.quantity || 0, // 实盘数
quantity: draft?.quantity ?? draft?.qty_actual ?? 0, // 兼容后端字段名
diff_qty: draft ? (draft.quantity - item.stock_qty) : -item.stock_qty, // 差异
remark: draft?.remark || '',
warehouse_location: item.warehouse_location

View File

@ -55,15 +55,25 @@
</el-table-column>
<el-table-column prop="action" label="操作类型" width="100">
<template #default="scope">
<el-tag :type="getActionType(scope.row.action)">{{ scope.row.action }}</el-tag>
<el-tag :type="getActionType(scope.row.action)">{{ actionMap[scope.row.action] || scope.row.action }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="target_name" label="操作对象" min-width="150" show-overflow-tooltip />
<el-table-column prop="ip_address" label="IP地址" width="130" />
<el-table-column prop="created_at" label="操作时间" width="170" />
<el-table-column prop="created_at" label="操作时间" width="170">
<template #default="scope">
{{ formatLocalTime(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button v-if="scope.row.details && Object.keys(scope.row.details).length > 0" link type="primary" size="small" @click="handleViewDetails(scope.row)">
<el-button
v-if="hasDetailContent(scope.row.details)"
link
type="primary"
size="small"
@click="handleViewDetails(scope.row)"
>
查看详情
</el-button>
<span v-else style="color: #909399; font-size: 12px;">无变更明细</span>
@ -84,36 +94,108 @@
/>
</el-card>
<!-- 详情弹窗 -->
<el-dialog v-model="detailDialogVisible" title="操作详情" width="700px" destroy-on-close>
<el-descriptions :column="2" border>
<!-- 重写的详情弹窗支持三种高级结构 -->
<el-dialog v-model="detailDialogVisible" title="操作详情" width="750px" destroy-on-close :close-on-click-modal="false">
<!-- 基本信息 -->
<el-descriptions :column="2" border class="base-info">
<el-descriptions-item label="ID">{{ currentLog.id }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ currentLog.username }} ({{ currentLog.display_name }})</el-descriptions-item>
<el-descriptions-item label="模块">{{ currentLog.module }}</el-descriptions-item>
<el-descriptions-item label="模块">
<el-tag>{{ currentLog.module }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="操作类型">
<el-tag :type="getActionType(currentLog.action)">{{ currentLog.action }}</el-tag>
<el-tag :type="getActionType(currentLog.action)">{{ actionMap[currentLog.action] || currentLog.action }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="操作对象" :span="2">{{ currentLog.target_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="IP地址">{{ currentLog.ip_address }}</el-descriptions-item>
<el-descriptions-item label="请求方式">{{ currentLog.method }}</el-descriptions-item>
<el-descriptions-item label="操作时间" :span="2">{{ currentLog.created_at }}</el-descriptions-item>
<el-descriptions-item label="请求URL" :span="2">
<el-text size="small">{{ currentLog.url }}</el-text>
</el-descriptions-item>
<el-descriptions-item label="操作时间" :span="2">{{ formatLocalTime(currentLog.created_at) }}</el-descriptions-item>
</el-descriptions>
<div v-if="currentLog.details" class="details-box">
<div class="details-title">变更内容 (JSON)</div>
<pre class="json-content">{{ formatDetails(currentLog.details) }}</pre>
<!-- 变更明细区域支持同时展示多种结构 -->
<div class="details-section">
<!-- 情况1UPDATE - 变更对比表 -->
<div v-if="hasChanges" class="changes-box">
<div class="section-title">
<el-icon><EditPen /></el-icon>
字段变更详情 {{ changesList.length }} 处变更
</div>
<el-table :data="changesList" border stripe size="small" max-height="350">
<el-table-column label="字段名" width="150">
<template #default="{ row }">
<span class="field-name">{{ fieldMap[row.field] || row.field }}</span>
</template>
</el-table-column>
<el-table-column label="修改前" min-width="200">
<template #default="{ row }">
<span class="old-value">{{ row.old ?? '空' }}</span>
</template>
</el-table-column>
<el-table-column label="修改后" min-width="200">
<template #default="{ row }">
<span class="new-value">{{ row.new ?? '空' }}</span>
</template>
</el-table-column>
</el-table>
</div>
<!-- 情况2DELETE - 删除快照 -->
<div v-if="hasDeletedSnapshot" class="snapshot-box">
<div class="section-title">
<el-icon><Delete /></el-icon>
删除前的数据快照
</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item
v-for="(value, key) in deletedSnapshot"
:key="String(key)"
:label="String(key)"
:span="isLongValue(value) ? 2 : 1"
>
<span class="snapshot-value">{{ formatValue(value) }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 情况3CREATE - 新增详情 -->
<div v-if="hasCreated" class="snapshot-box">
<div class="section-title">
<el-icon><Plus /></el-icon>
新增的数据详情
</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item
v-for="(value, key) in createdData"
:key="String(key)"
:label="String(key)"
:span="isLongValue(value) ? 2 : 1"
>
<span class="snapshot-value">{{ formatValue(value) }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 兜底原始 JSON仅在没有任何高级结构时显示 -->
<div v-if="showRawJson" class="raw-json-box">
<div class="section-title">
<el-icon><Document /></el-icon>
原始数据
</div>
<pre class="raw-json">{{ rawJson }}</pre>
</div>
</div>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { ref, reactive, onMounted, computed } from 'vue'
import { Search, Refresh, EditPen, Delete, Plus, Document } from '@element-plus/icons-vue'
import { getAuditLogs, getAuditModules } from '@/api/audit'
// 表格数据
@ -140,11 +222,139 @@ const dateRange = ref<[string, string] | null>(null)
const moduleOptions = ref<string[]>([])
const actionOptions = ref<string[]>(['create', 'update', 'delete', 'export', 'import'])
// ============================================================
// 中文化映射
// ============================================================
// 操作类型中文化映射
const actionMap: Record<string, string> = {
'UPDATE': '修改',
'CREATE': '新增',
'DELETE': '删除',
'LOGIN': '登录',
'LOGOUT': '登出'
};
// 字段名中文化映射 (常见业务字段)
const fieldMap: Record<string, string> = {
'available_quantity': '可用库存',
'in_quantity': '入库数量',
'stock_quantity': '总库存',
'out_quantity': '出库数量',
'name': '名称',
'material_name': '物料名称',
'spec_model': '规格型号',
'category': '类别',
'status': '状态',
'remark': '备注',
'is_active': '是否启用'
};
// 时间格式化:将后端的 UTC 时间字符串转换为本地时间 (UTC+8)
const formatLocalTime = (timeStr: string) => {
if (!timeStr) return '-'
// 补全 'Z' 让浏览器识别为 UTC 时间,自动转为当前系统的时区
const date = new Date(timeStr.replace(' ', 'T') + 'Z')
if (isNaN(date.getTime())) return timeStr
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}-${m}-${d} ${h}:${min}:${s}`
}
// 详情弹窗
const detailDialogVisible = ref(false)
const currentLog = ref<any>({})
// 获取操作类型对应的标签样式
// ============================================================
// 详情解析逻辑
// ============================================================
// 辅助函数:判断 details 是否有可显示内容
const hasDetailContent = (details: any): boolean => {
if (!details || typeof details !== 'object') return false
if (Object.keys(details).length === 0) return false
return !!(
details.changes ||
details.deleted_snapshot ||
details.created ||
details.payload
)
}
// 判断是否存在各高级结构
const hasChanges = computed(() => {
const details = currentLog.value.details
return !!(details?.changes && typeof details.changes === 'object')
})
const hasDeletedSnapshot = computed(() => {
const details = currentLog.value.details
return !!(details?.deleted_snapshot && typeof details.deleted_snapshot === 'object')
})
const hasCreated = computed(() => {
const details = currentLog.value.details
return !!(details?.created && typeof details.created === 'object')
})
const showRawJson = computed(() => {
return !hasChanges.value && !hasDeletedSnapshot.value && !hasCreated.value
})
// 解析 changes 为表格数据
const changesList = computed(() => {
const details = currentLog.value.details
if (!details?.changes) return []
return Object.entries(details.changes).map(([field, values]: [string, any]) => ({
field,
old: values?.old,
new: values?.new
}))
})
// 解析 deleted_snapshot
const deletedSnapshot = computed(() => {
const details = currentLog.value.details
return details?.deleted_snapshot || {}
})
// 解析 created
const createdData = computed(() => {
const details = currentLog.value.details
return details?.created || {}
})
// 原始 JSON
const rawJson = computed(() => {
const details = currentLog.value.details
if (!details) return ''
return JSON.stringify(details, null, 2)
})
// 辅助函数:格式化值
const formatValue = (value: any): string => {
if (value === null || value === undefined) return '-'
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
// 辅助函数:判断是否是长值
const isLongValue = (value: any): boolean => {
if (value === null || value === undefined) return false
const str = typeof value === 'object' ? JSON.stringify(value) : String(value)
return str.length > 50
}
// ============================================================
// 其他方法
// ============================================================
const getActionType = (action: string) => {
const typeMap: Record<string, string> = {
'create': 'success',
@ -158,11 +368,9 @@ const getActionType = (action: string) => {
return typeMap[action?.toLowerCase()] || 'info'
}
// 加载数据
const getList = async () => {
tableLoading.value = true
try {
// 处理日期范围
if (dateRange.value && dateRange.value.length === 2) {
queryParams.start_date = dateRange.value[0]
queryParams.end_date = dateRange.value[1]
@ -175,7 +383,6 @@ const getList = async () => {
if (res.code === 200) {
tableData.value = res.data.list
total.value = res.data.total
// 更新选项
if (res.data.modules) {
moduleOptions.value = res.data.modules
}
@ -190,13 +397,11 @@ const getList = async () => {
}
}
// 搜索
const handleQuery = () => {
queryParams.page = 1
getList()
}
// 重置
const handleReset = () => {
queryParams.page = 1
queryParams.username = ''
@ -209,29 +414,13 @@ const handleReset = () => {
getList()
}
// 查看详情
const handleViewDetails = (row: any) => {
currentLog.value = row
detailDialogVisible.value = true
}
// 格式化详情 JSON
const formatDetails = (details: any) => {
if (!details) return ''
if (typeof details === 'string') {
try {
return JSON.stringify(JSON.parse(details), null, 2)
} catch {
return details
}
}
return JSON.stringify(details, null, 2)
}
// 初始化
onMounted(() => {
getList()
// 获取可选模块
getAuditModules().then(res => {
if (res.code === 200 && res.data) {
moduleOptions.value = res.data
@ -251,26 +440,79 @@ onMounted(() => {
align-items: center;
}
.text-gray {
color: #999;
.base-info {
margin-bottom: 16px;
}
.details-box {
margin-top: 20px;
.details-section {
margin-top: 10px;
}
.details-title {
font-weight: bold;
margin-bottom: 10px;
.section-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 14px;
color: #303133;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.json-content {
background-color: #f5f7fa;
padding: 15px;
/* 变更表格样式 */
.changes-box {
background: #fef0f0;
padding: 12px;
border-radius: 6px;
border: 1px solid #fbc4c4;
}
.field-name {
font-weight: 600;
color: #303133;
}
.old-value {
color: #f56c6c;
text-decoration: line-through;
}
.new-value {
color: #67c23a;
font-weight: 500;
}
/* 快照样式 */
.snapshot-box {
background: #f5f7fa;
padding: 12px;
border-radius: 6px;
border: 1px solid #e4e7ed;
}
.snapshot-value {
word-break: break-all;
}
/* 原始 JSON */
.raw-json-box {
background: #f5f7fa;
padding: 12px;
border-radius: 6px;
}
.raw-json {
background: #2d2d2d;
color: #67c23a;
padding: 12px;
border-radius: 4px;
max-height: 400px;
overflow: auto;
font-size: 12px;
line-height: 1.5;
max-height: 300px;
overflow: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
</style>
</style>

View File

@ -62,6 +62,28 @@
<el-tag type="warning" effect="plain" round>当前配置: {{ getRoleLabel(currentRole) }}</el-tag>
</div>
<!-- 新增拉取其他角色权限的操作区 -->
<div class="pull-permission-bar">
<span class="pull-label">从其他角色复制权限</span>
<el-select
v-model="sourceRoleForClone"
placeholder="选择源角色..."
clearable
size="default"
@change="handleClonePermissions"
class="clone-select"
>
<el-option
v-for="role in availableSourceRoles"
:key="role.value"
:label="role.label"
:value="role.value"
:disabled="role.value === currentRole"
/>
</el-select>
<span class="pull-hint" v-if="!sourceRoleForClone">选择后将覆盖当前未保存的勾选状态</span>
</div>
<el-table
:data="tableData"
row-key="id"
@ -78,20 +100,25 @@
<el-table-column label="访问权限" width="150" align="center">
<template #default="{ row }">
<!-- 父级目录隐藏复选框仅叶子节点可操作 -->
<el-checkbox
v-if="row.type !== 'menu' || !row.children?.length"
v-model="row.hasRead"
@change="(val) => handleReadChange(val, row)"
class="custom-checkbox"
>
<span :class="{ 'text-active': row.hasRead }">可见 (Read)</span>
</el-checkbox>
<span v-else class="text-gray">-</span>
</template>
</el-table-column>
<el-table-column label="操作权限" width="180" align="center">
<template #default="{ row }">
<div v-if="row.operationCode">
<!-- 父级目录隐藏操作权限列 -->
<div v-if="row.type !== 'menu' || !row.children?.length">
<el-checkbox
v-if="row.operationCode"
v-model="row.hasWrite"
:disabled="!row.hasRead"
@change="(val) => handleWriteChange(val, row)"
@ -99,6 +126,7 @@
>
<span :class="{ 'text-active': row.hasWrite, 'text-disabled': !row.hasRead }">可编辑 (Write)</span>
</el-checkbox>
<span v-else class="text-gray">-</span>
</div>
<span v-else class="text-gray">-</span>
</template>
@ -153,8 +181,8 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { User, UserFilled, ArrowRight, Setting, Check, Avatar } from '@element-plus/icons-vue'
import { getAllPermissionTree, getRolePermissions, saveRolePermissions } from '@/api/system/permission'
@ -180,6 +208,7 @@ interface PermissionNode {
const loading = ref(false)
const saving = ref(false)
const currentRole = ref('')
const sourceRoleForClone = ref('') // 用于克隆权限的源角色
const roleList = [
{ label: '超级管理员', value: 'SUPER_ADMIN' },
{ label: '主管', value: 'SUPERVISOR' },
@ -203,9 +232,26 @@ const fetchTree = async () => {
try {
const res: any = await getAllPermissionTree()
if (res.code === 200) {
rawTreeData.value = res.data
// 初始化表格结构(此时没有勾选状态)
tableData.value = transformData(res.data)
// --- 注入全局特权虚拟节点 ---
const globalNode = {
id: 99999, // 虚拟ID
name: '🌍 全局系统特权',
code: 'global_privileges',
type: 'menu',
children: [
{
id: 999991,
name: '跨组织/跨区域数据查询',
code: 'global:cross_company_op', // 加上 _op 让它显示在操作权限列
type: 'element'
}
]
};
// 将虚拟节点放在最前面,然后交给 transformData 处理
const rawData = [globalNode, ...(res.data || [])];
rawTreeData.value = rawData;
tableData.value = transformData(rawData);
}
} catch (e) {
ElMessage.error('加载权限配置失败')
@ -252,6 +298,8 @@ const transformData = (nodes: any[]): PermissionNode[] => {
// 2. 切换角色:回显权限
const handleRoleSelect = async (roleCode: string) => {
currentRole.value = roleCode
// 切换角色时清空克隆选择
sourceRoleForClone.value = ''
loading.value = true
try {
@ -269,6 +317,53 @@ const handleRoleSelect = async (roleCode: string) => {
}
}
// 可用的源角色列表(排除当前已选角色)
const availableSourceRoles = computed(() => {
return roleList.filter(r => r.value !== currentRole.value)
})
// ★ 新增:从其他角色拉取权限
const handleClonePermissions = async (sourceRole: string) => {
if (!sourceRole) {
sourceRoleForClone.value = ''
return
}
// 确认提示
try {
await ElMessageBox.confirm(
`将从角色【${getRoleLabel(sourceRole)}】复制权限到【${getRoleLabel(currentRole.value)}】,覆盖当前未保存的勾选状态,是否继续?`,
'权限拉取确认',
{
confirmButtonText: '确认拉取',
cancelButtonText: '取消',
type: 'warning'
}
)
} catch {
// 用户取消
sourceRoleForClone.value = ''
return
}
loading.value = true
try {
// 调用后端 API 获取源角色的权限
const res: any = await getRolePermissions(sourceRole)
if (res.code === 200) {
const perms = new Set([...(res.data.menus || []), ...(res.data.elements || [])])
// 递归设置表格每一行的状态
setRowStatus(tableData.value, perms)
ElMessage.success(`已从【${getRoleLabel(sourceRole)}】拉取权限,请修改后点击保存`)
}
} catch (e) {
ElMessage.error('拉取权限失败')
} finally {
sourceRoleForClone.value = '' // 清空选择器
loading.value = false
}
}
// 递归回显状态
const setRowStatus = (rows: PermissionNode[], perms: Set<any>) => {
rows.forEach(row => {
@ -582,6 +677,33 @@ onMounted(() => {
overflow: auto;
}
/* 新增:拉取权限操作条 */
.pull-permission-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 15px;
margin-bottom: 15px;
background: #f0f7ff;
border-radius: 6px;
border: 1px solid #d9ecff;
}
.pull-label {
font-size: 14px;
color: #303133;
white-space: nowrap;
}
.clone-select {
width: 180px;
}
.pull-hint {
font-size: 12px;
color: #909399;
}
/* 表格内样式 */
.custom-checkbox {
height: auto;

View File

@ -4,9 +4,14 @@
<template #header>
<div class="card-header">
<span style="font-weight: bold;">员工账号管理</span>
<el-button v-if="userStore.hasPermission('system_user:operation')" type="primary" @click="handleCreate">
+ 新增员工
</el-button>
<div>
<el-button v-if="userStore.hasPermission('system_user:operation')" type="success" @click="batchDialogVisible = true">
批量新增
</el-button>
<el-button v-if="userStore.hasPermission('system_user:operation')" type="primary" @click="handleCreate">
+ 新增员工
</el-button>
</div>
</div>
</template>
@ -138,12 +143,52 @@
</div>
</template>
</el-dialog>
<!-- 批量新增弹窗 -->
<el-dialog v-model="batchDialogVisible" title="批量新增员工" width="600px" destroy-on-close @close="batchForm.namesText = ''">
<el-form :model="batchForm" label-width="100px">
<el-form-item label="所属部门" required>
<el-select v-model="batchForm.department" style="width: 100%" placeholder="请选择部门">
<el-option v-for="d in departmentOptions" :key="d" :label="d" :value="d" />
</el-select>
</el-form-item>
<el-form-item label="系统角色" required>
<el-select v-model="batchForm.role" style="width: 100%" placeholder="请选择角色">
<el-option v-for="r in roleOptions" :key="r.value" :label="r.label" :value="r.value" />
</el-select>
</el-form-item>
<el-form-item label="员工名单" required>
<el-input type="textarea" v-model="batchForm.namesText" :rows="8" placeholder="请输入真实姓名,每行一个。密码默认统一为 123456" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="batchSubmitting" @click="handleBatchSubmit">确认创建</el-button>
</template>
</el-dialog>
<!-- 批量创建结果弹窗 -->
<el-dialog v-model="batchResultVisible" title="批量创建结果" width="600px">
<div style="margin-bottom: 10px; color: #67C23A; font-weight: bold;">请复制以下账号分发给员工</div>
<el-table :data="batchResults" border height="400px">
<el-table-column prop="cn_name" label="姓名" width="150" />
<el-table-column label="生成账号">
<template #default="{ row }">
<span v-if="row.status === 'success'" style="color: #409EFF; font-weight: bold;">{{ row.account_id }}</span>
<span v-else style="color: #F56C6C">{{ row.error }}</span>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="batchResultVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, computed } from 'vue'
import { createUser, updateUser, getUserList, deleteUser } from '@/api/auth'
import { createUser, updateUser, getUserList, deleteUser, batchCreateUser } from '@/api/auth'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
import { pinyin } from 'pinyin-pro' // ★ 务必安装: npm install pinyin-pro
@ -199,6 +244,17 @@ const form = reactive({
email: ''
})
// 批量新增相关状态
const batchDialogVisible = ref(false)
const batchResultVisible = ref(false)
const batchSubmitting = ref(false)
const batchForm = reactive({
namesText: '',
department: '',
role: ''
})
const batchResults = ref<any[]>([])
// ★ 监听中文输入,自动转拼音
const handleNameInput = (val: string) => {
if (isEdit.value) return // 编辑模式下不联动
@ -246,10 +302,30 @@ const roleOptions = computed(() => {
return options
})
// 自定义校验:仅支持中英文、数字,禁止纯数字,禁止特殊字符
const validateNameStrict = (rule: any, value: string, callback: any) => {
if (!value) {
callback(new Error('该字段不能为空'));
return;
}
const reg = /^(?!\d+$)[a-zA-Z0-9\u4e00-\u9fa5]+$/;
if (!reg.test(value)) {
callback(new Error('仅支持中英文和数字,不能为纯数字,且不支持特殊字符'));
} else {
callback();
}
};
const rules = computed(() => {
const commonRules: any = {
cn_name: [{ required: true, message: '请输入真实姓名', trigger: 'blur' }],
username: [{ required: true, message: '账号不能为空', trigger: 'blur' }],
cn_name: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
{ validator: validateNameStrict, trigger: 'blur' }
],
username: [
{ required: true, message: '账号不能为空', trigger: 'blur' },
{ validator: validateNameStrict, trigger: 'blur' }
],
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
department: [{ required: true, message: '请输入或选择部门', trigger: ['blur', 'change'] }],
email: [
@ -370,6 +446,43 @@ const onSubmit = async () => {
})
}
// 批量提交逻辑
const handleBatchSubmit = async () => {
if (!batchForm.department || !batchForm.role || !batchForm.namesText.trim()) {
return ElMessage.warning('请填写完整部门、角色及员工名单')
}
const names = batchForm.namesText.split('\n').map(n => n.trim()).filter(n => n)
if (names.length === 0) return ElMessage.warning('未能识别到有效姓名')
const payload = names.map(name => {
const pinyinStr = pinyin(name, { toneType: 'none', type: 'array' }).join('').toLowerCase()
return {
cn_name: name,
username: pinyinStr, // 拼音基础串,后端会自动防重
password: '123456',
department: batchForm.department,
role: batchForm.role,
email: ''
}
})
batchSubmitting.value = true
try {
const res: any = await batchCreateUser(payload)
if (res.code === 200 || res.msg === '批量处理完成') {
batchResults.value = res.data
batchDialogVisible.value = false
batchResultVisible.value = true
getList() // 刷新底层列表
}
} catch (e) {
ElMessage.error('批量创建遇到错误')
} finally {
batchSubmitting.value = false
}
}
const resetForm = () => {
if (!formRef.value) return
formRef.value.resetFields()

View File

@ -17,10 +17,14 @@ export default defineConfig({
// 允许局域网访问前端页面
host: '0.0.0.0',
port: 5173,
strictPort: true,
watch: {
usePolling: true
},
https: true, // ★ [新增] 强制开启 HTTPS否则浏览器会拦截摄像头
hmr: {
protocol: 'wss',
clientPort: 5173
clientPort: 5175
},
proxy: {
// 拦截所有以 /api 开头的请求

35
query_audit.py Normal file
View File

@ -0,0 +1,35 @@
import psycopg2
import json
try:
conn = psycopg2.connect(
host='localhost',
port=5432,
database='inventory_system',
user='test',
password='1234'
)
cur = conn.cursor()
cur.execute('SELECT id, action, target_name, details FROM audit_logs ORDER BY id DESC LIMIT 3')
rows = cur.fetchall()
print('=== 最新3条审计日志 ===')
for row in rows:
print(f'ID: {row[0]}')
print(f'Action: {row[1]}')
print(f'Target: {row[2]}')
details = row[3]
if details:
# 格式化显示
if isinstance(details, str):
try:
details = json.loads(details)
except:
pass
print(f'Details: {json.dumps(details, indent=2, ensure_ascii=False)}')
else:
print(f'Details: None')
print('---')
cur.close()
conn.close()
except Exception as e:
print(f'Error: {e}')

54
query_permissions.py Normal file
View File

@ -0,0 +1,54 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import psycopg2
# 数据库连接配置 (从 docker-compose.yml 获取)
DB_CONFIG = {
'host': 'localhost',
'port': 5435,
'user': 'test',
'password': '1234',
'database': 'inventory_system'
}
def query_permissions():
conn = psycopg2.connect(**DB_CONFIG)
cursor = conn.cursor()
print('=' * 60)
print('查询: 角色为 PURCHASER 且 type=element 的所有权限记录')
print('=' * 60)
# 查询 PURCHASER 角色的元素权限
cursor.execute('''
SELECT role_code, target_code, type
FROM sys_role_permission
WHERE role_code = 'PURCHASER' AND type = 'element'
ORDER BY target_code
''')
rows = cursor.fetchall()
print(f'找到 {len(rows)} 条记录:\n')
for row in rows:
print(f' role_code: {row[0]}')
print(f' target_code: {row[1]}')
print(f' type: {row[2]}')
print('-' * 40)
# 如果没有结果,查询所有角色看看有什么
if not rows:
print('\n没有找到 PURCHASER 的记录,查询所有 element 权限...\n')
cursor.execute('''
SELECT DISTINCT role_code, type
FROM sys_role_permission
WHERE type = 'element'
ORDER BY role_code
''')
all_roles = cursor.fetchall()
print(f'数据库中有以下角色有 element 权限: {all_roles}')
conn.close()
if __name__ == '__main__':
query_permissions()

View File

@ -1,21 +1,22 @@
#!/bin/bash
# ==========================================
# 1. 本地 WSL 数据库配置 (根据你之前的数据)
# 1. 本地 WSL 数据库配置
# ==========================================
LOCAL_CONTAINER="inventory_db"
LOCAL_DB_USER="test"
LOCAL_DB_NAME="inventory_system"
# ==========================================
# 2. 远程服务器 SSH 配置 (根据你的截图)
# 2. 远程服务器 SSH 配置
# ==========================================
REMOTE_USER="dxc"
REMOTE_HOST="172.16.0.198"
REMOTE_PORT="22"
REMOTE_DIR="/opt/inventory-app" # 用于存放备份
# ==========================================
# 3. 远程服务器 Docker 配置 (根据你的 docker-compose.prod.yml)
# 3. 远程服务器 Docker 配置
# ==========================================
REMOTE_CONTAINER="inventory_db_prod"
REMOTE_DB_USER="prod_user"
@ -26,14 +27,28 @@ REMOTE_DB_NAME="inventory_system"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DUMP_FILE="db_sync_${TIMESTAMP}.sql.gz"
LOCAL_DUMP_PATH="/tmp/${DUMP_FILE}"
REMOTE_BACKUP_FILE="${REMOTE_DIR}/data_copy/DB_BACKUP_${TIMESTAMP}.sql.gz"
echo "========================================================"
echo " 🔄 开始同步 WSL 数据库到远程服务器 (${REMOTE_HOST})"
echo " ⚠️ 注意:线上旧数据将被完全覆盖!"
echo "========================================================"
# --- 新增:步骤 0: 远程服务器数据备份 ---
echo -e "\n[0/4] 🛡️ 正在备份线上服务器数据库..."
ssh -p ${REMOTE_PORT} ${REMOTE_USER}@${REMOTE_HOST} << EOF
mkdir -p ${REMOTE_DIR}/data_copy
# 导出线上数据作为备份
docker exec -e PGPASSWORD="${REMOTE_DB_PASS}" ${REMOTE_CONTAINER} pg_dump -U ${REMOTE_DB_USER} -d ${REMOTE_DB_NAME} -O -x | gzip > ${REMOTE_BACKUP_FILE}
echo " -> 线上备份已保存至: ${REMOTE_BACKUP_FILE}"
EOF
if [ $? -ne 0 ]; then
echo "❌ 线上备份失败!为保证数据安全,同步已终止!"
exit 1
fi
# --- 步骤 1: 本地导出 ---
echo -e "\n[1/4] 📦 正在本地打包数据库..."
# 注意:这里使用 pg_dump 导出,为了兼容性,排除可能引起冲突的权限所有者信息 (-O -x)
docker exec ${LOCAL_CONTAINER} pg_dump -U ${LOCAL_DB_USER} -d ${LOCAL_DB_NAME} -O -x | gzip > ${LOCAL_DUMP_PATH}
if [ $? -ne 0 ]; then
echo "❌ 本地数据库导出失败!请检查本地 inventory_db 容器是否正常运行。"
@ -57,7 +72,6 @@ ssh -p ${REMOTE_PORT} ${REMOTE_USER}@${REMOTE_HOST} << EOF
docker cp /tmp/${DUMP_FILE} ${REMOTE_CONTAINER}:/tmp/${DUMP_FILE}
echo " -> 危险操作:清空服务器旧数据环境..."
# 传入 PGPASSWORD 环境变量以防密码拦截
docker exec -e PGPASSWORD="${REMOTE_DB_PASS}" ${REMOTE_CONTAINER} psql -U ${REMOTE_DB_USER} -d ${REMOTE_DB_NAME} -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO ${REMOTE_DB_USER};"
echo " -> 正在导入最新数据..."
@ -74,4 +88,4 @@ rm ${LOCAL_DUMP_PATH}
echo -e "\n========================================================"
echo "🎉 数据库全量替换成功!快去刷新你的线上系统看看吧!"
echo "========================================================"
echo "========================================================"

38
upload_odoo_files.sh Executable file
View File

@ -0,0 +1,38 @@
#!/bin/bash
# === 配置项 ===
SERVER="dxc@172.16.0.198"
LOCAL_DIR="Odoo_Archive"
REMOTE_TARGET_DIR="/opt/inventory-app/uploads_prod"
ARCHIVE_NAME="odoo_images_upload.tar.gz"
echo "🚀 开始将本地图像及附件同步至线上存储目录..."
# 1. 检查本地文件夹
if [ ! -d "$LOCAL_DIR" ]; then
echo "❌ 找不到本地文件夹 $LOCAL_DIR,请确保脚本与该文件夹在同一层级!"
exit 1
fi
# 2. 本地打包 (使用 -C 保证解压后没有多余的外层文件夹)
echo "[1/4] 正在本地打包所有图片和文件..."
tar -czf $ARCHIVE_NAME -C $LOCAL_DIR .
# 3. 传输到生产环境的 /tmp 目录
echo "[2/4] 正在传输到服务器临时目录 /tmp (可能需要输入服务器密码)..."
scp $ARCHIVE_NAME $SERVER:/tmp/$ARCHIVE_NAME
# 4. 服务器解压并设置权限 (核心:纯文件覆盖/追加,不碰数据库)
echo "[3/4] 正在远端部署图像到目标文件夹 (可能需要输入 sudo 密码)..."
ssh -t $SERVER "sudo mkdir -p $REMOTE_TARGET_DIR && \
echo '>> 正在将图像释放到 $REMOTE_TARGET_DIR ...' && \
sudo tar -xzf /tmp/$ARCHIVE_NAME -C $REMOTE_TARGET_DIR && \
echo '>> 正在重置文件读写权限,确保线上服务可以正常显示图片...' && \
sudo chmod -R 755 $REMOTE_TARGET_DIR && \
sudo rm /tmp/$ARCHIVE_NAME"
# 5. 清理本地压缩包
echo "[4/4] 正在清理本地临时文件..."
rm $ARCHIVE_NAME
echo "✅ 图像及附件物理转移全部完成!线上存储内容已更新。"

219
全局系统体检报告.md Normal file
View File

@ -0,0 +1,219 @@
# IRIS 库存管理系统 - 全局系统体检报告
> 审查日期2026-04-02
> 审查范围inventory-web (Vue3 + Element Plus) + inventory-backend (Flask + SQLAlchemy)
> 审查模式:静态代码分析
---
## 一、前端状态与渲染漏洞 (Vue3 + Element Plus)
### [🚨 高危漏洞]
#### 1. el-table 缺少 reserve-selection 导致分页勾选丢失
- **模块/文件**: `inventory-web/src/views/materiel/list.vue` (及其他多个页面)
- **问题描述**: 大多数表格使用了 `type="selection"` 但未设置 `:reserve-selection="true"`
- **代码行数**: 该问题影响 30+ 个 el-table 组件
- **根因分析**:
- 分页后切换页面时,之前选中的行会丢失
- 只有 Selection.vue 的手动选择弹窗表格(第118行)添加了 `:reserve-selection="true"`
- **验证方法**: 进入物料列表勾选第1页的某几项切换到第2页再返回第1页确认勾选状态
#### 2. el-table 缺少 row-key 导致全选/渲染异常
- **模块/文件**: `inventory-web/src/views/stock/stocktake/index.vue`
- **代码行数**: 第240行
- **根因分析**: 该表格使用的 row-key="id"但如果存在跨表数据如物料、半成品、成品混排id 可能会冲突
#### 3. 盘点列表一次性加载 10000 条数据
- **模块/文件**: `inventory-web/src/views/stock/stocktake/index.vue`
- **代码行数**: 第662行、第985行
- **问题代码**:
```javascript
params: { page: 1, limit: 10000 } // 获取足够多的数据
limit: 10000, // 获取全部已盘点记录
```
- **风险**: 大量盘点记录时会导致前端内存溢出、页面卡死
---
### [⚠️ 交互/逻辑隐患]
#### 4. el-dialog 缺少 destroy-on-close 导致表单残留
- **模块/文件**: `inventory-web/src/views/outbound/create.vue`
- **代码行数**: 第174行
- **问题代码**: `<el-dialog v-model="showDialog" title="新建出库单" width="75%" :close-on-click-modal="false">`
- **根因分析**:
- 弹窗关闭后,数据未重置
- 再次打开弹窗会看到上一次填写的数据
- **建议**: 添加 `destroy-on-close` 属性或手动在关闭回调中重置表单
#### 5. formRef.resetFields() 调用不足
- **模块/文件**: 多个视图文件
- **根因分析**: 大多数弹窗表单没有调用 resetFields() 进行彻底重置
- **对比**:
- material/list.vue: 第1228行 ✅ 有 resetFields
- bom/BomManage.vue: 第656行 ✅ 有 resetFields
- system/UserCreate.vue: 第374行 ✅ 有 resetFields
- 其他大多数 ❌ 无重置逻辑
#### 6. 响应式解构潜在风险
- **模块/文件**: `inventory-web/src/App.vue`
- **代码行数**: 第80行
- **问题代码**: `const { new_password, confirm_password } = passwordForm.value`
- **说明**: 这种写法会失去响应性绑定,属于潜在风险(当前代码未直接修改解构后的变量,风险较低)
---
## 二、后端 ORM 与生命周期陷阱 (Flask + SQLAlchemy)
### [🚨 高危漏洞]
#### 7. @audit_log 装饰器中的 DetachedInstanceError 风险
- **模块/文件**: `inventory-backend/app/utils/decorators.py`
- **代码行数**: 第211-260行、第318行
- **问题代码**:
```python
# 第211-217行查询用户
user = SysUser.query.get(user_id)
if user:
user_info = user.to_dict() # 可能返回 DetachedInstanceError
display_name = user_info.get('display_name', username)
# 第248行添加审计日志
log_entry = AuditLog(...)
db.session.add(log_entry)
# 第318行在请求结束后 commit
db.session.commit()
```
- **根因分析**:
- 装饰器在 db.session.commit() 后访问已游离的对象属性
- 特别是在请求返回后session 可能已关闭,再次访问 user 对象会触发 DetachedInstanceError
#### 8. N+1 查询问题 - 库位树/库存列表
- **模块/文件**: `inventory-backend/app/api/v1/warehouse.py`, `inventory-backend/app/api/v1/inbound/stock.py`
- **代码行数**:
- warehouse.py 第118-132行循环查询每条库存记录
- stock.py 第1113-1138行全量查询后循环处理
- **问题代码**:
```python
# stock.py 第1113行
for item in StockBuy.query.filter(StockBuy.stock_quantity > 0).all():
# 每次循环访问 item.base 时会触发新的 SQL 查询 (Lazy Load)
'material_name': item.base.name
```
- **根因分析**: 没有使用 joinedload 或 eager loading 预加载关联关系
#### 9. 批量操作无最大数量限制
- **模块/文件**: `inventory-backend/app/api/v1/inbound/buy.py`
- **根因分析**:
- 批量创建物料/成品/半成品时没有限制最大条目数
- 前端子啊 stocktake/index.vue 设置了 limit=10000
- 后端如果接收大量数据会导致内存溢出
---
### [⚠️ 交互/逻辑隐患]
#### 10. 出库扣减逻辑中的可用库存竞态
- **模块/文件**: `inventory-backend/app/services/outbound_service.py`
- **代码行数**: 第169-173行
- **问题代码**:
```python
stock_record = ModelClass.query.with_for_update().get(stock_id) # 使用了悲观锁 ✅
if float(stock_record.available_quantity) < quantity:
raise ValueError(...)
stock_record.available_quantity = float(stock_record.available_quantity) - quantity
```
- **分析**: 已使用 `with_for_update()` 悲观锁,但需要确认数据库连接是否支持行级锁
- **建议**: 建议增加版本号字段实现乐观锁,作为双重保险
#### 11. 文件上传无大小限制
- **模块/文件**: `inventory-backend/app/api/v1/common/upload.py`
- **根因分析**:
- 没有检查文件大小
- 没有限制同时上传的文件数量
- **建议**: 添加 MAX_FILE_SIZE 和并发限制
---
## 三、业务并发与数据一致性
### [🚨 高危漏洞]
#### 12. 库存扣减无乐观锁
- **模块/文件**: 所有库存相关表 (StockBuy, StockSemi, StockProduct)
- **根因分析**:
- 库存表中没有 version 字段
- 仅依赖数据库行锁with_for_update()
- 高并发场景下可能出现库存扣为负数
- **影响范围**: 出库、报废、借用、盘点调整等所有减少库存的操作
#### 13. 盘点实盘数更新竞态
- **模块/文件**: `inventory-backend/app/api/v1/stock/adjustment.py`
- **代码行数**: 第226-250行
- **问题代码**:
```python
for stock in pagination.items:
new_qty = float(stock.stock_quantity)
# 读取和写入之间存在时间窗口,可能被其他请求修改
stock.stock_quantity = new_qty
db.session.add(stock)
db.session.commit()
```
- **根因分析**:
- 循环中的每条记录没有悲观锁
- 并发盘点可能导致数据覆盖
---
### [⚠️ 交互/逻辑隐患]
#### 14. 唯一键判断不严谨 - 购物车追加
- **模块/文件**: `inventory-web/src/views/outbound/Selection.vue`
- **根因分析**:
- 前端购物车基于 `type_id` 判断是否重复
- 如果 type 相同但 id 不同,仍会误判
- **当前状态**: ✅ 代码已修复,使用 `${item.type}_${item.id}` 格式
#### 15. BOM 批量导入无校验
- **模块/文件**: `inventory-backend/app/api/v1/bom.py`
- **代码行数**: 第278-340行
- **根因分析**:
- children 数据直接写入,不检查是否有重复子件
- 不验证子件是否存在
---
## 四、报告总结
### 漏洞统计
| 严重程度 | 数量 |
|---------|------|
| 🚨 高危漏洞 | 7 |
| ⚠️ 交互/逻辑隐患 | 8 |
| ✅ 状态良好 | 15+ |
### 优先修复建议(按优先级排序)
1. **[P0] 前端**:为所有分页表格添加 `:reserve-selection="true"` 和唯一 `row-key`
2. **[P0] 前端**:修复 stocktake <20><> 10000 条限制,改用分页或虚拟滚动
3. **[P0] 后端**:修复 @audit_log 的 DetachedInstanceError分两次 commit 或刷新对象)
4. **[P1] 后端**:为库存表添加 version 字段实现乐观锁
5. **[P1] 后端**:为盘点实盘更新添加 with_for_update()
6. **[P1] 后端**:添加批量操作最大限制(如 500 条/请求)
7. **[P2] 前端**:为所有表单弹窗添加 destroy-on-close 或手动重置
8. **[P2] 后端**:优化 N+1 查询,添加 joinedload
### ✅ 状态良好的核心链路
- ✅ 出库单创建使用了悲观锁 (with_for_update)
- ✅ 物料基础信息管理使用 visibilityLevel 控制
- ✅ 登录 Token 验证与 Redis 单设备登录互踢
- ✅ 文件上传使用 UUID 生成唯一文件名
- ✅ 出库选单唯一键已修复为 `${type}_${id}` 格式
- ✅ 库位路径 natural sorting 已实现
---
*本报告由 Qwen Code 全局静态扫描生成,仅供参考。实际修复请结合业务场景进行测试验证。*

108
图像信息导入.py Executable file
View File

@ -0,0 +1,108 @@
import pandas as pd
import psycopg2
import json
import os
# ================= 配置区 =================
DB_CONFIG = {
'dbname': 'inventory_system',
'user': 'test',
'password': '1234',
'host': 'localhost',
'port': '5435'
}
EXCEL_FILE = "Odoo_Archive/Odoo产品_终极大满贯版.xlsx"
# ================= 辅助函数 =================
def process_paths_only(json_str):
"""
将爬虫的绝对路径,转换为现有后端接口完美支持的纯文件名格式!
"""
if not json_str or str(json_str).strip() in ['[]', 'nan', 'None']:
return '[]'
try:
paths = json.loads(json_str)
new_paths = []
for path in paths:
if path.startswith('http://') or path.startswith('https://'):
new_paths.append(path)
else:
filename = os.path.basename(path)
# 【终极修复】去掉中间的子文件夹,直接请求文件名!
web_path = f"/api/v1/common/files/{filename}"
new_paths.append(web_path)
return json.dumps(new_paths, ensure_ascii=False)
except Exception as e:
return '[]'
# ================= 主程序 =================
def process_excel_to_db():
if not os.path.exists(EXCEL_FILE):
print(f"❌ 找不到 Excel 文件: {EXCEL_FILE}")
return
try:
df = pd.read_excel(EXCEL_FILE, dtype=str)
df = df.where(pd.notnull(df), None)
print(f"✅ 成功读取 Excel{len(df)} 行数据。")
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
success_count = 0
for index, row in df.iterrows():
internal_ref = row.get('内部参考')
barcode = row.get('条码')
spec_model = ""
if barcode and internal_ref:
spec_model = f"{barcode}/{internal_ref}"
elif barcode:
spec_model = f"{barcode}"
elif internal_ref:
spec_model = f"{internal_ref}"
else:
continue
raw_image_json = row.get('generalImage')
raw_manual_json = row.get('generalManual')
if (not raw_image_json or raw_image_json == '[]') and (not raw_manual_json or raw_manual_json == '[]'):
continue
product_image = process_paths_only(raw_image_json)
manual_link = process_paths_only(raw_manual_json)
update_query = """
UPDATE material_base
SET product_image = %s, \
manual_link = %s
WHERE spec_model = %s
"""
cur.execute(update_query, (product_image, manual_link, spec_model))
if cur.rowcount > 0:
success_count += 1
conn.commit()
print(f"\n🎉 导入完成!成功更新了 {success_count} 条数据的正确路径。")
print("💡 赶快去刷新前端看看吧!这次图片一定能刷出来!")
except Exception as e:
print(f"❌ 发生致命错误: {e}")
if 'conn' in locals() and conn: conn.rollback()
finally:
if 'cur' in locals() and cur: cur.close()
if 'conn' in locals() and conn: conn.close()
if __name__ == "__main__":
process_excel_to_db()

View File

@ -0,0 +1,102 @@
import pandas as pd
import psycopg2
# 1. 数据库配置
DB_CONFIG = {
'dbname': 'inventory_system',
'user': 'test',
'password': '1234',
'host': 'localhost',
'port': '5435'
}
# 2. Excel 文件路径
EXCEL_FILE = '筛选后的库存统计.xlsx'
def fix_category_data_no_nan():
try:
print("正在读取 Excel 文件...")
# 【修改点 1】明确限制只读取到第四级
possible_category_cols = ['类别一级', '类别二级', '类别三级', '类别四级']
df_header = pd.read_excel(EXCEL_FILE, nrows=0)
actual_category_cols = [col for col in possible_category_cols if col in df_header.columns]
needed_columns = ['资产名称', '规格型号'] + actual_category_cols
df = pd.read_excel(EXCEL_FILE, dtype=str, usecols=lambda x: x in needed_columns)
df = df.where(pd.notnull(df), None)
df = df.drop_duplicates(subset=['资产名称', '规格型号'])
print(f"发现了 {len(df)} 种独立物料,准备修复类别并清除 'nan'...")
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
update_count = 0
for index, row in df.iterrows():
name = row.get('资产名称')
spec_model = row.get('规格型号')
# 清理规格型号,防止它也被 pandas 变成了 'nan'
clean_spec = None if pd.isna(spec_model) or str(spec_model).lower() == 'nan' else str(spec_model).strip()
if not name or str(name).lower() == 'nan':
continue
# --- 核心逻辑只拼接前4级并且严格过滤 nan ---
category_parts = []
for col in actual_category_cols:
val = row.get(col)
if val is not None:
str_val = str(val).strip()
# 【修改点 2】增加对 'nan' 和 'None' 字符串的拦截
if str_val != '' and str_val.lower() != 'nan' and str_val.lower() != 'none':
category_parts.append(str_val)
full_category = "/".join(category_parts)
if not full_category:
continue
prefixed_name = f"库研*{name}"
prefixed_spec = f"KY*{clean_spec}" if clean_spec else None
# 执行更新操作
update_query = """
UPDATE material_base
SET category = %s
WHERE (name = %s OR name = %s)
AND (
(spec_model = %s OR spec_model = %s)
OR (spec_model IS NULL AND %s IS NULL)
) \
"""
cur.execute(update_query, (
full_category,
name, prefixed_name,
clean_spec, prefixed_spec, clean_spec
))
update_count += cur.rowcount
conn.commit()
print(f"✅ 完美修复!清除了讨厌的 'nan',共修正了 {update_count} 条记录。")
except Exception as e:
print(f"❌ 发生错误: {e}")
if 'conn' in locals() and conn:
conn.rollback()
finally:
if 'cur' in locals() and cur:
cur.close()
if 'conn' in locals() and conn:
conn.close()
if __name__ == "__main__":
fix_category_data_no_nan()

Binary file not shown.

29
库研操作/筛选.py Normal file
View File

@ -0,0 +1,29 @@
import pandas as pd
# 1. 读取您的Excel文件
file_path = '库存统计_20260413_094414.xlsx'
df = pd.read_excel(file_path)
# 指定要进行筛选的列名(根据您的截图,列名应为“仓库位置”)
col_name = '仓库位置'
# 2. 数据清洗:确保该列都是字符串格式,并处理可能存在的空值(NaN)
# 这一步是为了防止后续字符串操作报错
df[col_name] = df[col_name].astype(str)
# 3. 进行筛选
# 条件 A: str.count('/') == 2 (说明通过斜杠分割后只有3个部分即3层)
# 条件 B: str.endswith('/1') (说明最后是以 /1 结尾的即最后一层是1)
condition = (df[col_name].str.count('/') == 2) & (df[col_name].str.endswith('/1'))
# 将满足条件的数据提取出来
filtered_df = df[condition]
# 4. 打印查看筛选后的前几行结果
print("筛选出的符合要求的数据如下:")
print(filtered_df[[col_name]])
# 5. (可选)将筛选后的结果保存为新的 Excel 文件
output_path = '筛选后的库存统计.xlsx'
filtered_df.to_excel(output_path, index=False)
print(f"\n筛选完成,结果已保存至:{output_path}")

Binary file not shown.

View File

@ -12,7 +12,7 @@ DB_CONFIG = {
}
# 2. Excel 文件路径
EXCEL_FILE = 'product.template.xlsx'
EXCEL_FILE = '../product.template.xlsx'
def process_excel_to_db():