Compare commits

27 Commits

Author SHA1 Message Date
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
37 changed files with 2306 additions and 166 deletions

Binary file not shown.

0
deploy_code.sh Executable file → Normal file
View File

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

View File

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

View File

@ -455,3 +455,21 @@ def batch_set_inspection():
db.session.rollback() db.session.rollback()
current_app.logger.error(f"批量设置强制质检失败: {str(e)}") current_app.logger.error(f"批量设置强制质检失败: {str(e)}")
return jsonify({"code": 500, "msg": f"批量设置强制质检失败: {str(e)}"}), 500 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', 'sku': 'inbound_buy:sku',
'barcode': 'inbound_buy:barcode', 'barcode': 'inbound_buy:barcode',
'in_date': 'inbound_buy:in_date', 'in_date': 'inbound_buy:in_date',
'serial_number': 'inbound_buy:serial_number', 'serial_number': 'inbound_buy:sn_bn',
'batch_number': 'inbound_buy:batch_number', 'batch_number': 'inbound_buy:sn_bn',
'status': 'inbound_buy:status', 'status': 'inbound_buy:status',
'in_quantity': 'inbound_buy:in_quantity', 'in_quantity': 'inbound_buy:in_quantity',
'stock_quantity': 'inbound_buy:stock_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: if 'inbound_buy:*' in user_permissions:
return item_dict return item_dict
for field, perm_code in field_to_perm.items(): 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 item_dict[field] = None
return item_dict return item_dict
@ -210,7 +213,10 @@ def submit():
# 复制一份,避免遍历时修改字典 # 复制一份,避免遍历时修改字典
for field in list(data.keys()): for field in list(data.keys()):
perm_code = field_to_perm.get(field) 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) data.pop(field, None)
# 库位必填校验(安全兜底) # 库位必填校验(安全兜底)
@ -286,7 +292,10 @@ def update_buy(id):
# 复制一份,避免遍历时修改字典 # 复制一份,避免遍历时修改字典
for field in list(data.keys()): for field in list(data.keys()):
perm_code = field_to_perm.get(field) 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) data.pop(field, None)
BuyInboundService.update_inbound(id, data) BuyInboundService.update_inbound(id, data)

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

@ -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.utils.decorators import permission_required, audit_log
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.extensions import db 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.buy import StockBuy
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase
from app.models.system import SysUser
import traceback import traceback
import math import math
@ -172,6 +174,28 @@ class ScrapService:
res['price'] = get_price(buy, 'stock_buy') res['price'] = get_price(buy, 'stock_buy')
return res 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 return None
@staticmethod @staticmethod
@ -210,6 +234,31 @@ class ScrapService:
if not stock_id or not source_table or scrap_qty <= 0: if not stock_id or not source_table or scrap_qty <= 0:
continue 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 stock_record = None
if source_table == 'stock_product': if source_table == 'stock_product':
@ -277,8 +326,63 @@ class ScrapService:
total = query.count() total = query.count()
records = query.offset((page - 1) * page_size).limit(page_size).all() 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 { return {
'list': [r.to_dict() for r in records], 'list': result_list,
'total': total, 'total': total,
'page': page, 'page': page,
'pageSize': page_size 'pageSize': page_size

View File

@ -70,39 +70,94 @@ class TransBorrow(db.Model):
class TransRepair(db.Model): class TransRepair(db.Model):
__tablename__ = 'trans_repair' __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)) 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)) source_table = db.Column(db.String(50))
stock_id = db.Column(db.Integer) 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) is_self_made = db.Column(db.Boolean, default=False)
related_product_id = db.Column(db.Integer) 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) fault_description = db.Column(db.Text)
# 预计修复时间
expected_repair_time = db.Column(db.String(100))
# 维修日志/结果
repair_result = db.Column(db.Text) 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)) cost_price = db.Column(db.Numeric(19, 4))
sale_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): def to_dict(self):
return { return {
'id': self.id, 'id': self.id,
'repair_no': self.repair_no,
'base_id': self.base_id,
'sku': self.sku, 'sku': self.sku,
'material_name': self.material_name,
'serial_number': self.serial_number,
'source_table': self.source_table, 'source_table': self.source_table,
'stock_id': self.stock_id, 'stock_id': self.stock_id,
'arrival_date': self.arrival_date.strftime('%Y-%m-%d') if self.arrival_date else None, '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, 'expected_repair_time': self.expected_repair_time,
'shipping_date': self.shipping_date.strftime('%Y-%m-%d') if self.shipping_date else None, 'shipping_date': self.shipping_date.strftime('%Y-%m-%d') if self.shipping_date else None,
'is_self_made': self.is_self_made, 'is_self_made': self.is_self_made,
'related_product_id': self.related_product_id, 'related_product_id': self.related_product_id,
'related_contract_id': self.related_contract_id, 'related_contract_id': self.related_contract_id,
'customer_name': self.customer_name,
'customer_location': self.customer_location,
'repair_manager': self.repair_manager, 'repair_manager': self.repair_manager,
'fault_description': self.fault_description, 'fault_description': self.fault_description,
'repair_result': self.repair_result, 'repair_result': self.repair_result,
'cost_price': float(self.cost_price) if self.cost_price is not None else None, '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, 'sale_price': float(self.sale_price) if self.sale_price is not None else None,
'company_id': self.company_id,
} }

View File

@ -104,6 +104,8 @@ class AuthService:
user_id = user.id user_id = user.id
user_info = user.to_dict() user_info = user.to_dict()
user_info['role'] = user_role user_info['role'] = user_role
# 获取用户所属公司(存于 department 字段)
user_company = user.department or ''
# 3. 生成 Token # 3. 生成 Token
# Token 中 identity 存数据库IDclaims 存登录账号ID # Token 中 identity 存数据库IDclaims 存登录账号ID
@ -115,7 +117,8 @@ class AuthService:
additional_claims={ additional_claims={
'role': user_role, 'role': user_role,
'username': account_id, # 存纯账号ID '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={ additional_claims={
'role': user_role, 'role': user_role,
'username': account_id, '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
} }
) )
@ -341,9 +345,13 @@ class AuthService:
'elements': ['inbound_buy:unit_price', ...] 'elements': ['inbound_buy:unit_price', ...]
} }
""" """
# 防御性编程role_code 为空时直接返回空权限,避免后续 SQL 崩溃
if not role_code:
return {'menus': [], 'elements': []}
# 超级管理员返回所有权限(通配符) # 超级管理员返回所有权限(通配符)
from app.utils.constants import UserRole from app.utils.constants import UserRole
if role_code and role_code.upper() == UserRole.SUPER_ADMIN: if role_code.upper() == UserRole.SUPER_ADMIN:
# 返回通配符,表示拥有所有菜单和元素权限 # 返回通配符,表示拥有所有菜单和元素权限
return { return {
'menus': ['*'], 'menus': ['*'],
@ -351,6 +359,7 @@ class AuthService:
} }
# 1. 查菜单权限 # 1. 查菜单权限
# 使用 func.upper() 处理数据库字段的大小写
menu_perms = SysRolePermission.query.filter( menu_perms = SysRolePermission.query.filter(
func.upper(SysRolePermission.role_code) == role_code.upper(), func.upper(SysRolePermission.role_code) == role_code.upper(),
SysRolePermission.type == 'menu' SysRolePermission.type == 'menu'
@ -363,12 +372,14 @@ class AuthService:
func.upper(SysRolePermission.role_code) == role_code.upper(), func.upper(SysRolePermission.role_code) == role_code.upper(),
SysRolePermission.type == 'element' SysRolePermission.type == 'element'
).all() ).all()
# 这里的 target_code 就是列的 code (如 unit_price)
# 为了防止不同页面有相同列名导致的混淆,我们之前数据库设计是做了隔离的
# 但为了前端处理方便,我们直接返回列的 code 集合
element_codes = [p.target_code for p in element_perms] 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 { return {
'menus': menu_codes, 'menus': menu_codes,
'elements': element_codes 'elements': element_codes

View File

@ -1,5 +1,6 @@
# 文件路径: app/services/inbound/base_service.py # 文件路径: app/services/inbound/base_service.py
from flask_jwt_extended import get_jwt
from app.extensions import db from app.extensions import db
from app.models.base import MaterialBase, MaterialWarningSetting from app.models.base import MaterialBase, MaterialWarningSetting
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
@ -209,9 +210,40 @@ class MaterialBaseService:
MaterialBase.spec_model.ilike(kw) MaterialBase.spec_model.ilike(kw)
)) ))
company = filters.get('company') # ============================================================
if company is not None and company != '': # 【行级数据隔离】基于 JWT 中的 company_name 进行过滤
query = query.filter(MaterialBase.company_name.ilike(company.strip())) # ============================================================
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') category = filters.get('category')
if category is not None and category != '': if category is not None and category != '':
@ -618,9 +650,34 @@ class MaterialBaseService:
MaterialBase.spec_model.ilike(kw), MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw) MaterialBase.company_name.ilike(kw)
)) ))
company = filters.get('company') # ============================================================
if company is not None and company != '': # 【行级数据隔离】基于 JWT 中的 company_name 进行过滤(高级筛选)
filter_conditions.append(MaterialBase.company_name.ilike(company.strip())) # ============================================================
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') category = filters.get('category')
if category is not None and category != '': if category is not None and category != '':
filter_conditions.append(MaterialBase.category.ilike(category.strip())) filter_conditions.append(MaterialBase.category.ilike(category.strip()))
@ -955,3 +1012,100 @@ class MaterialBaseService:
except Exception as e: except Exception as e:
traceback.print_exc() 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,4 +1,5 @@
# inventory-backend/app/services/inbound/buy_service.py # inventory-backend/app/services/inbound/buy_service.py
from flask_jwt_extended import get_jwt
from app.extensions import db from app.extensions import db
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
@ -347,7 +348,32 @@ class BuyInboundService:
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) query = query.filter(MaterialBase.material_type == material_type.strip())
# 3.1 公司独立搜索 [新增] # ============================================================
# 【行级数据隔离】基于 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(): if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip()) query = query.filter(MaterialBase.company_name == company.strip())

View File

@ -317,6 +317,32 @@ class ProductInboundService:
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) query = query.filter(MaterialBase.material_type == material_type.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(): if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip()) query = query.filter(MaterialBase.company_name == company.strip())

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

@ -408,6 +408,32 @@ class SemiInboundService:
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) query = query.filter(MaterialBase.material_type == material_type.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(): if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip()) query = query.filter(MaterialBase.company_name == company.strip())

View File

@ -10,6 +10,8 @@ from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
# 引入基础信息表 # 引入基础信息表
from app.models.base import MaterialBase from app.models.base import MaterialBase
# 引入维修单表
from app.models.transaction import TransRepair
class OutboundService: class OutboundService:
@ -75,6 +77,31 @@ class OutboundService:
res['price'] = get_price(buy, 'stock_buy') res['price'] = get_price(buy, 'stock_buy')
return res 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 return None
@staticmethod @staticmethod
@ -158,6 +185,30 @@ class OutboundService:
if quantity <= 0: if quantity <= 0:
raise ValueError(f"SKU {item.get('sku')} 的出库数量必须大于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) ModelClass = model_map.get(source_table)
if not ModelClass: if not ModelClass:
continue continue

View File

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

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

@ -70,3 +70,11 @@ export function batchSetInspection(data: { ids: number[], isInspectionRequired:
data data
}) })
} }
// 7. 获取智能分组规格最大连号
export function getLatestSpecs() {
return request({
url: '/inbound/base/spec-latest',
method: 'get'
})
}

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"> <div class="main-container">
<AppMain /> <AppMain />
</div> </div>
<!-- 全局规格连号助手 -->
<SpecHelper />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Sidebar from './components/Sidebar/index.vue' import Sidebar from './components/Sidebar/index.vue'
import AppMain from './components/AppMain.vue' import AppMain from './components/AppMain.vue'
import SpecHelper from '@/components/SpecHelper/index.vue'
</script> </script>
<style scoped> <style scoped>

View File

@ -92,6 +92,13 @@ const routes: Array<RouteRecordRaw> = [
name: 'InventorySummary', name: 'InventorySummary',
component: () => import('@/views/stock/inbound/inbound_summary.vue'), component: () => import('@/views/stock/inbound/inbound_summary.vue'),
meta: { title: '入库记录' } meta: { title: '入库记录' }
},
// 维修管理
{
path: 'repair',
name: 'RepairManagement',
component: () => import('@/views/stock/inbound/repair.vue'),
meta: { title: '维修管理', permission: 'inbound_repair' }
} }
] ]
}, },

View File

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

View File

@ -960,31 +960,8 @@ const permissionMap: Record<string, string> = {
inspection_report: 'inbound_buy:inspection_report' inspection_report: 'inbound_buy:inspection_report'
} }
// 初始化列显示状态 // 初始化列显示状态(纯权限驱动,废除本地缓存)
const initColumnPermissions = () => { 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 visibleColumnProps.value = allColumns
.filter(col => hasColumnPermission(col.prop)) .filter(col => hasColumnPermission(col.prop))
.map(col => col.prop) .map(col => col.prop)
@ -1003,17 +980,6 @@ const allColumns = [...baseColumns, ...stockColumns]
const visibleColumnProps = ref<string[]>([]) 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({ const form = reactive({
id: undefined, base_id: undefined as number | undefined, id: undefined, base_id: undefined as number | undefined,
company_name: '', company_name: '',

View File

@ -780,31 +780,8 @@ const permissionMap: Record<string, string> = {
} }
// 根据用户权限初始化列显示状态 // 根据用户权限初始化列显示状态
// 初始化列显示状态 // 初始化列显示状态(纯权限驱动,废除本地缓存)
const initColumnPermissions = () => { 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 visibleColumnProps.value = allColumns
.filter(col => hasColumnPermission(col.prop)) .filter(col => hasColumnPermission(col.prop))
.map(col => col.prop) .map(col => col.prop)
@ -855,17 +832,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 defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
const visibleColumnProps = ref<string[]>([]) 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({ const form = reactive({
id: undefined, base_id: undefined as number | undefined, id: undefined, base_id: undefined as number | undefined,
company_name: '', // [新增] company_name: '', // [新增]

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

@ -811,31 +811,8 @@ const stockColumns = [
] ]
const allColumns = [...baseColumns, ...stockColumns] const allColumns = [...baseColumns, ...stockColumns]
// 初始化列显示状态 // 初始化列显示状态(纯权限驱动,废除本地缓存)
const initColumnPermissions = () => { 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 visibleColumnProps.value = allColumns
.filter(col => hasColumnPermission(col.prop)) .filter(col => hasColumnPermission(col.prop))
.map(col => col.prop) .map(col => col.prop)
@ -892,17 +869,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 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[]>([]) 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({ const form = reactive({
id: undefined, base_id: undefined as number | undefined, id: undefined, base_id: undefined as number | undefined,
company_name: '', company_name: '',

View File

@ -203,9 +203,26 @@ const fetchTree = async () => {
try { try {
const res: any = await getAllPermissionTree() const res: any = await getAllPermissionTree()
if (res.code === 200) { if (res.code === 200) {
rawTreeData.value = res.data // --- 注入全局特权虚拟节点 ---
// 初始化表格结构(此时没有勾选状态) const globalNode = {
tableData.value = transformData(res.data) 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) { } catch (e) {
ElMessage.error('加载权限配置失败') ElMessage.error('加载权限配置失败')

View File

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

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 #!/bin/bash
# ========================================== # ==========================================
# 1. 本地 WSL 数据库配置 (根据你之前的数据) # 1. 本地 WSL 数据库配置
# ========================================== # ==========================================
LOCAL_CONTAINER="inventory_db" LOCAL_CONTAINER="inventory_db"
LOCAL_DB_USER="test" LOCAL_DB_USER="test"
LOCAL_DB_NAME="inventory_system" LOCAL_DB_NAME="inventory_system"
# ========================================== # ==========================================
# 2. 远程服务器 SSH 配置 (根据你的截图) # 2. 远程服务器 SSH 配置
# ========================================== # ==========================================
REMOTE_USER="dxc" REMOTE_USER="dxc"
REMOTE_HOST="172.16.0.198" REMOTE_HOST="172.16.0.198"
REMOTE_PORT="22" REMOTE_PORT="22"
REMOTE_DIR="/opt/inventory-app" # 用于存放备份
# ========================================== # ==========================================
# 3. 远程服务器 Docker 配置 (根据你的 docker-compose.prod.yml) # 3. 远程服务器 Docker 配置
# ========================================== # ==========================================
REMOTE_CONTAINER="inventory_db_prod" REMOTE_CONTAINER="inventory_db_prod"
REMOTE_DB_USER="prod_user" REMOTE_DB_USER="prod_user"
@ -26,14 +27,28 @@ REMOTE_DB_NAME="inventory_system"
TIMESTAMP=$(date +%Y%m%d_%H%M%S) TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DUMP_FILE="db_sync_${TIMESTAMP}.sql.gz" DUMP_FILE="db_sync_${TIMESTAMP}.sql.gz"
LOCAL_DUMP_PATH="/tmp/${DUMP_FILE}" LOCAL_DUMP_PATH="/tmp/${DUMP_FILE}"
REMOTE_BACKUP_FILE="${REMOTE_DIR}/data_copy/DB_BACKUP_${TIMESTAMP}.sql.gz"
echo "========================================================" echo "========================================================"
echo " 🔄 开始同步 WSL 数据库到远程服务器 (${REMOTE_HOST})" echo " 🔄 开始同步 WSL 数据库到远程服务器 (${REMOTE_HOST})"
echo " ⚠️ 注意:线上旧数据将被完全覆盖!"
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: 本地导出 --- # --- 步骤 1: 本地导出 ---
echo -e "\n[1/4] 📦 正在本地打包数据库..." 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} docker exec ${LOCAL_CONTAINER} pg_dump -U ${LOCAL_DB_USER} -d ${LOCAL_DB_NAME} -O -x | gzip > ${LOCAL_DUMP_PATH}
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "❌ 本地数据库导出失败!请检查本地 inventory_db 容器是否正常运行。" 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} docker cp /tmp/${DUMP_FILE} ${REMOTE_CONTAINER}:/tmp/${DUMP_FILE}
echo " -> 危险操作:清空服务器旧数据环境..." 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};" 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 " -> 正在导入最新数据..." 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 全局静态扫描生成,仅供参考。实际修复请结合业务场景进行测试验证。*

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.