Compare commits
27 Commits
4eb6bd792b
...
8291a89898
| Author | SHA1 | Date | |
|---|---|---|---|
| 8291a89898 | |||
| 6c0e13e52d | |||
| bd93a3d70b | |||
| 477da7c434 | |||
| ae05f3bb75 | |||
| ae1fd1afd4 | |||
| db077a6033 | |||
| c91f8ec693 | |||
| 0e8ddd0851 | |||
| 81bfb29b50 | |||
| f7a789a196 | |||
| 6aa2142f01 | |||
| 4728f91cc7 | |||
| 14eedaa57a | |||
| c7ac092be4 | |||
| e23e8c6a9e | |||
| 454f9b1184 | |||
| d3a143288b | |||
| f4d14f718d | |||
| 48efbed46b | |||
| 0a9c8cd39c | |||
| 09936cb045 | |||
| 3085d9f447 | |||
| cf7dc04db7 | |||
| 7f2b9bc7ce | |||
| 41b5118ecd | |||
| ec468b266d |
BIN
deploy.tar.gz
BIN
deploy.tar.gz
Binary file not shown.
0
deploy_code.sh
Executable file → Normal file
0
deploy_code.sh
Executable file → Normal file
45
deploy_patch.sh
Normal file
45
deploy_patch.sh
Normal 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 "==================================================="
|
||||
@ -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
|
||||
|
||||
@ -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,无需再注册子蓝图
|
||||
|
||||
@ -455,3 +455,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
|
||||
|
||||
@ -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)
|
||||
|
||||
137
inventory-backend/app/api/v1/inbound/repair.py
Normal file
137
inventory-backend/app/api/v1/inbound/repair.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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 存数据库ID,claims 存登录账号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
|
||||
}
|
||||
)
|
||||
|
||||
@ -341,9 +345,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 +359,7 @@ class AuthService:
|
||||
}
|
||||
|
||||
# 1. 查菜单权限
|
||||
# 使用 func.upper() 处理数据库字段的大小写
|
||||
menu_perms = SysRolePermission.query.filter(
|
||||
func.upper(SysRolePermission.role_code) == role_code.upper(),
|
||||
SysRolePermission.type == 'menu'
|
||||
@ -363,12 +372,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
|
||||
|
||||
@ -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 != '':
|
||||
@ -618,9 +650,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 +1011,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
|
||||
@ -1,4 +1,5 @@
|
||||
# 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
|
||||
@ -347,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 = ['在库', '借库']
|
||||
|
||||
@ -317,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 = ['在库', '借库']
|
||||
|
||||
284
inventory-backend/app/services/inbound/repair_service.py
Normal file
284
inventory-backend/app/services/inbound/repair_service.py
Normal 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
|
||||
@ -408,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 = ['在库', '借库']
|
||||
|
||||
@ -10,6 +10,8 @@ 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
|
||||
|
||||
|
||||
class OutboundService:
|
||||
@ -75,6 +77,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
|
||||
@ -158,6 +185,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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
53
inventory-web/src/api/inbound/repair.ts
Normal file
53
inventory-web/src/api/inbound/repair.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
@ -69,4 +69,12 @@ export function batchSetInspection(data: { ids: number[], isInspectionRequired:
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 7. 获取智能分组规格最大连号
|
||||
export function getLatestSpecs() {
|
||||
return request({
|
||||
url: '/inbound/base/spec-latest',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
213
inventory-web/src/components/SpecHelper/index.vue
Normal file
213
inventory-web/src/components/SpecHelper/index.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -960,31 +960,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)
|
||||
@ -1003,17 +980,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: '',
|
||||
|
||||
@ -780,31 +780,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)
|
||||
@ -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 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: '', // [新增]
|
||||
|
||||
578
inventory-web/src/views/stock/inbound/repair.vue
Normal file
578
inventory-web/src/views/stock/inbound/repair.vue
Normal 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>
|
||||
@ -811,31 +811,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)
|
||||
@ -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 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: '',
|
||||
|
||||
@ -203,9 +203,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('加载权限配置失败')
|
||||
|
||||
@ -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 开头的请求
|
||||
|
||||
54
query_permissions.py
Normal file
54
query_permissions.py
Normal 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()
|
||||
26
sync_db.sh
26
sync_db.sh
@ -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 "========================================================"
|
||||
219
全局系统体检报告.md
Normal file
219
全局系统体检报告.md
Normal 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 全局静态扫描生成,仅供参考。实际修复请结合业务场景进行测试验证。*
|
||||
102
库研操作/导入数据库.py
Normal file
102
库研操作/导入数据库.py
Normal 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()
|
||||
BIN
库研操作/库存统计_20260413_094414.xlsx
Normal file
BIN
库研操作/库存统计_20260413_094414.xlsx
Normal file
Binary file not shown.
29
库研操作/筛选.py
Normal file
29
库研操作/筛选.py
Normal 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}")
|
||||
BIN
库研操作/筛选后的库存统计.xlsx
Normal file
BIN
库研操作/筛选后的库存统计.xlsx
Normal file
Binary file not shown.
Reference in New Issue
Block a user