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'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# --- 数据库服务 ---
|
|
||||||
db:
|
db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: inventory_db
|
container_name: inventory_db
|
||||||
@ -11,42 +10,35 @@ services:
|
|||||||
POSTGRES_PASSWORD: 1234
|
POSTGRES_PASSWORD: 1234
|
||||||
POSTGRES_DB: inventory_system
|
POSTGRES_DB: inventory_system
|
||||||
volumes:
|
volumes:
|
||||||
# 数据持久化
|
|
||||||
- ./pgdata_docker:/var/lib/postgresql/data
|
- ./pgdata_docker:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "5434:5432"
|
- "5435:5432"
|
||||||
|
|
||||||
# --- 后端 Flask 服务 ---
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./inventory-backend # 指向你的新后端目录
|
context: ./inventory-backend
|
||||||
container_name: inventory_api
|
container_name: inventory_api
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./inventory-backend:/app # 挂载代码,实现热更新
|
- ./inventory-backend:/app
|
||||||
# 【核心修改】显式挂载 uploads 目录,确保图片持久化且宿主机可见
|
|
||||||
- ./inventory-backend/uploads:/app/uploads
|
- ./inventory-backend/uploads:/app/uploads
|
||||||
command: gunicorn -c gunicorn.conf.py run:app --reload
|
command: gunicorn -c gunicorn.conf.py run:app --reload
|
||||||
environment:
|
environment:
|
||||||
# Host 必须写 'db'
|
|
||||||
DATABASE_URL: postgresql://test:1234@db:5432/inventory_system
|
DATABASE_URL: postgresql://test:1234@db:5432/inventory_system
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|
||||||
# --- 前端 Vue 开发服务 ---
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./inventory-web
|
context: ./inventory-web
|
||||||
container_name: inventory_ui
|
container_name: inventory_ui
|
||||||
restart: always
|
restart: always
|
||||||
# 把本地代码挂载进去,实现“热更新”
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./inventory-web:/app
|
- ./inventory-web:/app
|
||||||
- /app/node_modules # 排除 node_modules,防止冲突
|
- /app/node_modules
|
||||||
# 开发模式端口通常是 5173
|
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5175:5173"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from .base import inbound_base_bp
|
|||||||
from .product import inbound_product_bp
|
from .product import inbound_product_bp
|
||||||
from .inbound_summary import bp as inbound_summary_bp
|
from .inbound_summary import bp as inbound_summary_bp
|
||||||
from .stock import bp as inbound_stock_bp
|
from .stock import bp as inbound_stock_bp
|
||||||
|
from .repair import inbound_repair_bp
|
||||||
|
|
||||||
# 导入 service 模块,使其路由装饰器可以正常注册到 inbound_bp 上
|
# 导入 service 模块,使其路由装饰器可以正常注册到 inbound_bp 上
|
||||||
from . import service
|
from . import service
|
||||||
@ -21,5 +22,6 @@ inbound_bp.register_blueprint(inbound_base_bp, url_prefix='/base')
|
|||||||
inbound_bp.register_blueprint(inbound_product_bp, url_prefix='/product')
|
inbound_bp.register_blueprint(inbound_product_bp, url_prefix='/product')
|
||||||
inbound_bp.register_blueprint(inbound_summary_bp, url_prefix='/summary')
|
inbound_bp.register_blueprint(inbound_summary_bp, url_prefix='/summary')
|
||||||
inbound_bp.register_blueprint(inbound_stock_bp, url_prefix='/stock')
|
inbound_bp.register_blueprint(inbound_stock_bp, url_prefix='/stock')
|
||||||
|
inbound_bp.register_blueprint(inbound_repair_bp, url_prefix='/repair')
|
||||||
|
|
||||||
# service 模块的路由已经直接附加到 inbound_bp,无需再注册子蓝图
|
# service 模块的路由已经直接附加到 inbound_bp,无需再注册子蓝图
|
||||||
|
|||||||
@ -455,3 +455,21 @@ def batch_set_inspection():
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
current_app.logger.error(f"批量设置强制质检失败: {str(e)}")
|
current_app.logger.error(f"批量设置强制质检失败: {str(e)}")
|
||||||
return jsonify({"code": 500, "msg": f"批量设置强制质检失败: {str(e)}"}), 500
|
return jsonify({"code": 500, "msg": f"批量设置强制质检失败: {str(e)}"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 2.7 智能分组求最大连号 API (GET /api/v1/inbound/base/spec-latest)
|
||||||
|
# ==============================================================================
|
||||||
|
@inbound_base_bp.route('/spec-latest', methods=['GET'])
|
||||||
|
@permission_required('material_list')
|
||||||
|
def get_spec_latest():
|
||||||
|
"""
|
||||||
|
获取所有规格型号的最大连号,按智能分组返回
|
||||||
|
返回格式: [{"group": "S", "latest": "S0115/S0115"}, {"group": "Opt4xxx", "latest": "Opt4018/Opt4018"}, ...]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = MaterialBaseService.get_latest_specs()
|
||||||
|
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
|
|||||||
@ -43,8 +43,8 @@ def filter_item_by_permissions(item_dict, user_permissions):
|
|||||||
'sku': 'inbound_buy:sku',
|
'sku': 'inbound_buy:sku',
|
||||||
'barcode': 'inbound_buy:barcode',
|
'barcode': 'inbound_buy:barcode',
|
||||||
'in_date': 'inbound_buy:in_date',
|
'in_date': 'inbound_buy:in_date',
|
||||||
'serial_number': 'inbound_buy:serial_number',
|
'serial_number': 'inbound_buy:sn_bn',
|
||||||
'batch_number': 'inbound_buy:batch_number',
|
'batch_number': 'inbound_buy:sn_bn',
|
||||||
'status': 'inbound_buy:status',
|
'status': 'inbound_buy:status',
|
||||||
'in_quantity': 'inbound_buy:in_quantity',
|
'in_quantity': 'inbound_buy:in_quantity',
|
||||||
'stock_quantity': 'inbound_buy:stock_quantity',
|
'stock_quantity': 'inbound_buy:stock_quantity',
|
||||||
@ -75,7 +75,10 @@ def filter_item_by_permissions(item_dict, user_permissions):
|
|||||||
if 'inbound_buy:*' in user_permissions:
|
if 'inbound_buy:*' in user_permissions:
|
||||||
return item_dict
|
return item_dict
|
||||||
for field, perm_code in field_to_perm.items():
|
for field, perm_code in field_to_perm.items():
|
||||||
if field in item_dict and perm_code not in user_permissions:
|
# 提取不带前缀的基础权限码(如 'serial_number')
|
||||||
|
base_perm_code = perm_code.split(':')[-1] if ':' in perm_code else perm_code
|
||||||
|
# 如果用户的权限列表中,既没有长格式,也没有短格式,才将字段设为 None
|
||||||
|
if field in item_dict and perm_code not in user_permissions and base_perm_code not in user_permissions:
|
||||||
item_dict[field] = None
|
item_dict[field] = None
|
||||||
return item_dict
|
return item_dict
|
||||||
|
|
||||||
@ -210,7 +213,10 @@ def submit():
|
|||||||
# 复制一份,避免遍历时修改字典
|
# 复制一份,避免遍历时修改字典
|
||||||
for field in list(data.keys()):
|
for field in list(data.keys()):
|
||||||
perm_code = field_to_perm.get(field)
|
perm_code = field_to_perm.get(field)
|
||||||
if perm_code and perm_code not in user_permissions:
|
# 提取不带前缀的基础权限码(如 'serial_number')
|
||||||
|
base_perm_code = perm_code.split(':')[-1] if ':' in perm_code else perm_code
|
||||||
|
# 如果用户的权限列表中,既没有长格式,也没有短格式,才移除该字段
|
||||||
|
if perm_code and perm_code not in user_permissions and base_perm_code not in user_permissions:
|
||||||
data.pop(field, None)
|
data.pop(field, None)
|
||||||
|
|
||||||
# 库位必填校验(安全兜底)
|
# 库位必填校验(安全兜底)
|
||||||
@ -286,7 +292,10 @@ def update_buy(id):
|
|||||||
# 复制一份,避免遍历时修改字典
|
# 复制一份,避免遍历时修改字典
|
||||||
for field in list(data.keys()):
|
for field in list(data.keys()):
|
||||||
perm_code = field_to_perm.get(field)
|
perm_code = field_to_perm.get(field)
|
||||||
if perm_code and perm_code not in user_permissions:
|
# 提取不带前缀的基础权限码(如 'serial_number')
|
||||||
|
base_perm_code = perm_code.split(':')[-1] if ':' in perm_code else perm_code
|
||||||
|
# 如果用户的权限列表中,既没有长格式,也没有短格式,才移除该字段
|
||||||
|
if perm_code and perm_code not in user_permissions and base_perm_code not in user_permissions:
|
||||||
data.pop(field, None)
|
data.pop(field, None)
|
||||||
|
|
||||||
BuyInboundService.update_inbound(id, data)
|
BuyInboundService.update_inbound(id, data)
|
||||||
|
|||||||
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.utils.decorators import permission_required, audit_log
|
||||||
from app.services.auth_service import AuthService
|
from app.services.auth_service import AuthService
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.transaction import TransScrap
|
from app.models.transaction import TransScrap, TransRepair
|
||||||
from app.models.inbound.buy import StockBuy
|
from app.models.inbound.buy import StockBuy
|
||||||
from app.models.inbound.semi import StockSemi
|
from app.models.inbound.semi import StockSemi
|
||||||
from app.models.inbound.product import StockProduct
|
from app.models.inbound.product import StockProduct
|
||||||
|
from app.models.base import MaterialBase
|
||||||
|
from app.models.system import SysUser
|
||||||
import traceback
|
import traceback
|
||||||
import math
|
import math
|
||||||
|
|
||||||
@ -172,6 +174,28 @@ class ScrapService:
|
|||||||
res['price'] = get_price(buy, 'stock_buy')
|
res['price'] = get_price(buy, 'stock_buy')
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
# 4. 查询维修单 (TransRepair)
|
||||||
|
repair = TransRepair.query.filter(
|
||||||
|
db.or_(TransRepair.sku == clean_code, TransRepair.serial_number == clean_code)
|
||||||
|
).filter(
|
||||||
|
TransRepair.repair_status.notin_(['已出库', '报废转出'])
|
||||||
|
).first()
|
||||||
|
if repair:
|
||||||
|
return {
|
||||||
|
'id': repair.id,
|
||||||
|
'sku': repair.sku,
|
||||||
|
'barcode': repair.sku,
|
||||||
|
'name': repair.material_name or '维修件',
|
||||||
|
'spec': '',
|
||||||
|
'category': '',
|
||||||
|
'material_type': '',
|
||||||
|
'warehouse_loc': repair.customer_location or '',
|
||||||
|
'stock_quantity': 1,
|
||||||
|
'available_quantity': 1,
|
||||||
|
'source_table': 'trans_repair',
|
||||||
|
'price': float(repair.sale_price) if repair.sale_price else 0
|
||||||
|
}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -210,6 +234,31 @@ class ScrapService:
|
|||||||
if not stock_id or not source_table or scrap_qty <= 0:
|
if not stock_id or not source_table or scrap_qty <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 处理维修单报废
|
||||||
|
if source_table == 'trans_repair':
|
||||||
|
repair = TransRepair.query.get(stock_id)
|
||||||
|
if not repair:
|
||||||
|
raise ValueError(f'维修单不存在: ID={stock_id}')
|
||||||
|
|
||||||
|
# 更新维修单状态为报废转出
|
||||||
|
repair.repair_status = '报废转出'
|
||||||
|
|
||||||
|
# 创建报废记录
|
||||||
|
scrap_record = TransScrap(
|
||||||
|
sku=repair.sku,
|
||||||
|
source_table='trans_repair',
|
||||||
|
stock_id=stock_id,
|
||||||
|
quantity=1,
|
||||||
|
reason=reason,
|
||||||
|
operator_name=operator_name,
|
||||||
|
approval_status='approved',
|
||||||
|
cost_at_scrap=float(repair.cost_price) if repair.cost_price else 0,
|
||||||
|
total_loss=float(repair.cost_price) if repair.cost_price else 0
|
||||||
|
)
|
||||||
|
db.session.add(scrap_record)
|
||||||
|
created_records.append(scrap_record)
|
||||||
|
continue
|
||||||
|
|
||||||
# 获取库存记录
|
# 获取库存记录
|
||||||
stock_record = None
|
stock_record = None
|
||||||
if source_table == 'stock_product':
|
if source_table == 'stock_product':
|
||||||
@ -277,8 +326,63 @@ class ScrapService:
|
|||||||
total = query.count()
|
total = query.count()
|
||||||
records = query.offset((page - 1) * page_size).limit(page_size).all()
|
records = query.offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
|
||||||
|
# 遍历结果,补充操作人姓名、物料名称、规格
|
||||||
|
result_list = []
|
||||||
|
for r in records:
|
||||||
|
item = r.to_dict()
|
||||||
|
|
||||||
|
# 1. 解析操作人姓名
|
||||||
|
if r.operator_name:
|
||||||
|
# operator_name 可能是用户ID或用户名,尝试解析为真实姓名
|
||||||
|
try:
|
||||||
|
# 尝试将 operator_name 当作用户ID查询
|
||||||
|
user_id = int(r.operator_name)
|
||||||
|
user = SysUser.query.get(user_id)
|
||||||
|
if user:
|
||||||
|
# 解析存储格式: "张三/zhangsan"
|
||||||
|
raw_name = user.username
|
||||||
|
if '/' in raw_name:
|
||||||
|
item['operator_name'] = raw_name.split('/')[0]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# 如果不是数字ID,保持原值
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. 多态解析物料名称与规格
|
||||||
|
material_name = ''
|
||||||
|
spec_model = ''
|
||||||
|
|
||||||
|
if r.source_table == 'trans_repair':
|
||||||
|
# 维修单
|
||||||
|
repair = TransRepair.query.get(r.stock_id)
|
||||||
|
if repair:
|
||||||
|
material_name = repair.material_name or ''
|
||||||
|
spec_model = ''
|
||||||
|
elif r.source_table in ['stock_buy', 'stock_semi', 'stock_product']:
|
||||||
|
# 常规库存表
|
||||||
|
stock_model = None
|
||||||
|
if r.source_table == 'stock_buy':
|
||||||
|
stock_model = StockBuy.query.get(r.stock_id)
|
||||||
|
elif r.source_table == 'stock_semi':
|
||||||
|
stock_model = StockSemi.query.get(r.stock_id)
|
||||||
|
elif r.source_table == 'stock_product':
|
||||||
|
stock_model = StockProduct.query.get(r.stock_id)
|
||||||
|
|
||||||
|
if stock_model and hasattr(stock_model, 'base_id') and stock_model.base_id:
|
||||||
|
base = MaterialBase.query.get(stock_model.base_id)
|
||||||
|
if base:
|
||||||
|
material_name = base.name or ''
|
||||||
|
spec_model = base.spec_model or ''
|
||||||
|
elif stock_model and hasattr(stock_model, 'base') and stock_model.base:
|
||||||
|
material_name = stock_model.base.name or ''
|
||||||
|
spec_model = stock_model.base.spec_model or ''
|
||||||
|
|
||||||
|
item['material_name'] = material_name
|
||||||
|
item['spec_model'] = spec_model
|
||||||
|
|
||||||
|
result_list.append(item)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'list': [r.to_dict() for r in records],
|
'list': result_list,
|
||||||
'total': total,
|
'total': total,
|
||||||
'page': page,
|
'page': page,
|
||||||
'pageSize': page_size
|
'pageSize': page_size
|
||||||
|
|||||||
@ -70,39 +70,94 @@ class TransBorrow(db.Model):
|
|||||||
class TransRepair(db.Model):
|
class TransRepair(db.Model):
|
||||||
__tablename__ = 'trans_repair'
|
__tablename__ = 'trans_repair'
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# 维修单号 (新增)
|
||||||
|
repair_no = db.Column(db.String(50), nullable=True, unique=True)
|
||||||
|
|
||||||
|
# 关联基础信息 (新增)
|
||||||
|
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=True)
|
||||||
|
|
||||||
|
# SKU 保留
|
||||||
sku = db.Column(db.String(100))
|
sku = db.Column(db.String(100))
|
||||||
|
|
||||||
|
# 物料名称 (独立录入时使用,非关联base_id)
|
||||||
|
material_name = db.Column(db.String(200))
|
||||||
|
|
||||||
|
# 序列号SN (新增,用于单台追溯)
|
||||||
|
serial_number = db.Column(db.String(100), nullable=True)
|
||||||
|
|
||||||
|
# 来源追溯 (兼容旧数据)
|
||||||
source_table = db.Column(db.String(50))
|
source_table = db.Column(db.String(50))
|
||||||
stock_id = db.Column(db.Integer)
|
stock_id = db.Column(db.Integer)
|
||||||
arrival_date = db.Column(db.Date)
|
|
||||||
expected_repair_time = db.Column(db.String(100))
|
|
||||||
shipping_date = db.Column(db.Date)
|
|
||||||
is_self_made = db.Column(db.Boolean, default=False)
|
is_self_made = db.Column(db.Boolean, default=False)
|
||||||
related_product_id = db.Column(db.Integer)
|
related_product_id = db.Column(db.Integer)
|
||||||
related_contract_id = db.Column(db.String(100))
|
|
||||||
repair_manager = db.Column(db.String(100))
|
# 入库/接收时间
|
||||||
|
arrival_date = db.Column(db.Date)
|
||||||
|
|
||||||
|
# 维修状态 (新增)
|
||||||
|
repair_status = db.Column(db.String(50), default='待检测')
|
||||||
|
|
||||||
|
# 客户反馈
|
||||||
fault_description = db.Column(db.Text)
|
fault_description = db.Column(db.Text)
|
||||||
|
|
||||||
|
# 预计修复时间
|
||||||
|
expected_repair_time = db.Column(db.String(100))
|
||||||
|
|
||||||
|
# 维修日志/结果
|
||||||
repair_result = db.Column(db.Text)
|
repair_result = db.Column(db.Text)
|
||||||
|
|
||||||
|
# 维修人
|
||||||
|
repair_manager = db.Column(db.String(100))
|
||||||
|
|
||||||
|
# 出库交付时间
|
||||||
|
shipping_date = db.Column(db.Date)
|
||||||
|
|
||||||
|
# 客户名/来源
|
||||||
|
related_contract_id = db.Column(db.String(100))
|
||||||
|
|
||||||
|
# 客户名称 (新增)
|
||||||
|
customer_name = db.Column(db.String(100))
|
||||||
|
|
||||||
|
# 客户所在地 (新增)
|
||||||
|
customer_location = db.Column(db.String(255))
|
||||||
|
|
||||||
|
# 成本与售价
|
||||||
cost_price = db.Column(db.Numeric(19, 4))
|
cost_price = db.Column(db.Numeric(19, 4))
|
||||||
sale_price = db.Column(db.Numeric(19, 4))
|
sale_price = db.Column(db.Numeric(19, 4))
|
||||||
|
|
||||||
|
# 数据隔离 (新增)
|
||||||
|
company_id = db.Column(db.Integer, nullable=True)
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
base = db.relationship('MaterialBase', backref='repairs')
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
|
'repair_no': self.repair_no,
|
||||||
|
'base_id': self.base_id,
|
||||||
'sku': self.sku,
|
'sku': self.sku,
|
||||||
|
'material_name': self.material_name,
|
||||||
|
'serial_number': self.serial_number,
|
||||||
'source_table': self.source_table,
|
'source_table': self.source_table,
|
||||||
'stock_id': self.stock_id,
|
'stock_id': self.stock_id,
|
||||||
'arrival_date': self.arrival_date.strftime('%Y-%m-%d') if self.arrival_date else None,
|
'arrival_date': self.arrival_date.strftime('%Y-%m-%d') if self.arrival_date else None,
|
||||||
|
'repair_status': self.repair_status,
|
||||||
'expected_repair_time': self.expected_repair_time,
|
'expected_repair_time': self.expected_repair_time,
|
||||||
'shipping_date': self.shipping_date.strftime('%Y-%m-%d') if self.shipping_date else None,
|
'shipping_date': self.shipping_date.strftime('%Y-%m-%d') if self.shipping_date else None,
|
||||||
'is_self_made': self.is_self_made,
|
'is_self_made': self.is_self_made,
|
||||||
'related_product_id': self.related_product_id,
|
'related_product_id': self.related_product_id,
|
||||||
'related_contract_id': self.related_contract_id,
|
'related_contract_id': self.related_contract_id,
|
||||||
|
'customer_name': self.customer_name,
|
||||||
|
'customer_location': self.customer_location,
|
||||||
'repair_manager': self.repair_manager,
|
'repair_manager': self.repair_manager,
|
||||||
'fault_description': self.fault_description,
|
'fault_description': self.fault_description,
|
||||||
'repair_result': self.repair_result,
|
'repair_result': self.repair_result,
|
||||||
'cost_price': float(self.cost_price) if self.cost_price is not None else None,
|
'cost_price': float(self.cost_price) if self.cost_price is not None else None,
|
||||||
'sale_price': float(self.sale_price) if self.sale_price is not None else None,
|
'sale_price': float(self.sale_price) if self.sale_price is not None else None,
|
||||||
|
'company_id': self.company_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -104,6 +104,8 @@ class AuthService:
|
|||||||
user_id = user.id
|
user_id = user.id
|
||||||
user_info = user.to_dict()
|
user_info = user.to_dict()
|
||||||
user_info['role'] = user_role
|
user_info['role'] = user_role
|
||||||
|
# 获取用户所属公司(存于 department 字段)
|
||||||
|
user_company = user.department or ''
|
||||||
|
|
||||||
# 3. 生成 Token
|
# 3. 生成 Token
|
||||||
# Token 中 identity 存数据库ID,claims 存登录账号ID
|
# Token 中 identity 存数据库ID,claims 存登录账号ID
|
||||||
@ -115,7 +117,8 @@ class AuthService:
|
|||||||
additional_claims={
|
additional_claims={
|
||||||
'role': user_role,
|
'role': user_role,
|
||||||
'username': account_id, # 存纯账号ID
|
'username': account_id, # 存纯账号ID
|
||||||
'display_name': user_info.get('username') # 存显示名
|
'display_name': user_info.get('username'), # 存显示名
|
||||||
|
'company_name': user_company # 存所属公司
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -125,7 +128,8 @@ class AuthService:
|
|||||||
additional_claims={
|
additional_claims={
|
||||||
'role': user_role,
|
'role': user_role,
|
||||||
'username': account_id,
|
'username': account_id,
|
||||||
'display_name': user_info.get('display_name', account_id)
|
'display_name': user_info.get('display_name', account_id),
|
||||||
|
'company_name': user_company
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -341,9 +345,13 @@ class AuthService:
|
|||||||
'elements': ['inbound_buy:unit_price', ...]
|
'elements': ['inbound_buy:unit_price', ...]
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
# 防御性编程:role_code 为空时直接返回空权限,避免后续 SQL 崩溃
|
||||||
|
if not role_code:
|
||||||
|
return {'menus': [], 'elements': []}
|
||||||
|
|
||||||
# 超级管理员返回所有权限(通配符)
|
# 超级管理员返回所有权限(通配符)
|
||||||
from app.utils.constants import UserRole
|
from app.utils.constants import UserRole
|
||||||
if role_code and role_code.upper() == UserRole.SUPER_ADMIN:
|
if role_code.upper() == UserRole.SUPER_ADMIN:
|
||||||
# 返回通配符,表示拥有所有菜单和元素权限
|
# 返回通配符,表示拥有所有菜单和元素权限
|
||||||
return {
|
return {
|
||||||
'menus': ['*'],
|
'menus': ['*'],
|
||||||
@ -351,6 +359,7 @@ class AuthService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 1. 查菜单权限
|
# 1. 查菜单权限
|
||||||
|
# 使用 func.upper() 处理数据库字段的大小写
|
||||||
menu_perms = SysRolePermission.query.filter(
|
menu_perms = SysRolePermission.query.filter(
|
||||||
func.upper(SysRolePermission.role_code) == role_code.upper(),
|
func.upper(SysRolePermission.role_code) == role_code.upper(),
|
||||||
SysRolePermission.type == 'menu'
|
SysRolePermission.type == 'menu'
|
||||||
@ -363,12 +372,14 @@ class AuthService:
|
|||||||
func.upper(SysRolePermission.role_code) == role_code.upper(),
|
func.upper(SysRolePermission.role_code) == role_code.upper(),
|
||||||
SysRolePermission.type == 'element'
|
SysRolePermission.type == 'element'
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
# 这里的 target_code 就是列的 code (如 unit_price)
|
|
||||||
# 为了防止不同页面有相同列名导致的混淆,我们之前数据库设计是做了隔离的
|
|
||||||
# 但为了前端处理方便,我们直接返回列的 code 集合
|
|
||||||
element_codes = [p.target_code for p in element_perms]
|
element_codes = [p.target_code for p in element_perms]
|
||||||
|
|
||||||
|
# 调试日志:输出查询结果便于排查字段权限问题
|
||||||
|
from flask import current_app
|
||||||
|
current_app.logger.info(
|
||||||
|
f"[权限查询] role={role_code}, 查询到菜单权限={menu_codes}, 元素权限={element_codes}"
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'menus': menu_codes,
|
'menus': menu_codes,
|
||||||
'elements': element_codes
|
'elements': element_codes
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
# 文件路径: app/services/inbound/base_service.py
|
# 文件路径: app/services/inbound/base_service.py
|
||||||
|
|
||||||
|
from flask_jwt_extended import get_jwt
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.base import MaterialBase, MaterialWarningSetting
|
from app.models.base import MaterialBase, MaterialWarningSetting
|
||||||
from app.models.inbound.buy import StockBuy
|
from app.models.inbound.buy import StockBuy
|
||||||
@ -209,9 +210,40 @@ class MaterialBaseService:
|
|||||||
MaterialBase.spec_model.ilike(kw)
|
MaterialBase.spec_model.ilike(kw)
|
||||||
))
|
))
|
||||||
|
|
||||||
company = filters.get('company')
|
# ============================================================
|
||||||
if company is not None and company != '':
|
# 【行级数据隔离】基于 JWT 中的 company_name 进行过滤
|
||||||
query = query.filter(MaterialBase.company_name.ilike(company.strip()))
|
# ============================================================
|
||||||
|
from flask_jwt_extended import get_jwt
|
||||||
|
|
||||||
|
claims = get_jwt()
|
||||||
|
user_role = claims.get('role', '').upper() if claims.get('role') else ''
|
||||||
|
user_company = claims.get('company_name', '')
|
||||||
|
|
||||||
|
# 获取用户权限列表(用于检查 global:cross_company_op 特权)
|
||||||
|
from app.api.v1.inbound.base import get_current_user_permissions
|
||||||
|
user_perms = get_current_user_permissions() or []
|
||||||
|
normalized_perms = set(p.lower().replace('_', '').replace(':', '') for p in user_perms)
|
||||||
|
|
||||||
|
# 检查是否拥有全局特权或超管角色
|
||||||
|
has_cross_company = 'globalcrosscompanyop' in normalized_perms
|
||||||
|
|
||||||
|
# 获取前端传的查询参数
|
||||||
|
req_company = filters.get('company') if filters else None
|
||||||
|
|
||||||
|
if user_role != 'SUPER_ADMIN' and not has_cross_company:
|
||||||
|
# 【显式拒绝越权】如果前端传了公司参数,且不是当前用户的公司,返回403
|
||||||
|
if req_company and req_company != user_company:
|
||||||
|
from flask import abort
|
||||||
|
abort(403, description=f'越权访问:您无权查询 {req_company} 的数据')
|
||||||
|
# 正常查询本公司数据
|
||||||
|
if user_company:
|
||||||
|
query = query.filter(MaterialBase.company_name == user_company)
|
||||||
|
# 如果用户没有所属公司字段,则只显示公司为空的记录(或不允许查看)
|
||||||
|
elif user_role == 'SUPER_ADMIN' or has_cross_company:
|
||||||
|
# 超级管理员或有跨域特权:允许跨公司视角
|
||||||
|
if req_company:
|
||||||
|
query = query.filter(MaterialBase.company_name == req_company)
|
||||||
|
# 没选公司则不加过滤,看到全量
|
||||||
|
|
||||||
category = filters.get('category')
|
category = filters.get('category')
|
||||||
if category is not None and category != '':
|
if category is not None and category != '':
|
||||||
@ -618,9 +650,34 @@ class MaterialBaseService:
|
|||||||
MaterialBase.spec_model.ilike(kw),
|
MaterialBase.spec_model.ilike(kw),
|
||||||
MaterialBase.company_name.ilike(kw)
|
MaterialBase.company_name.ilike(kw)
|
||||||
))
|
))
|
||||||
company = filters.get('company')
|
# ============================================================
|
||||||
if company is not None and company != '':
|
# 【行级数据隔离】基于 JWT 中的 company_name 进行过滤(高级筛选)
|
||||||
filter_conditions.append(MaterialBase.company_name.ilike(company.strip()))
|
# ============================================================
|
||||||
|
from flask_jwt_extended import get_jwt
|
||||||
|
|
||||||
|
claims = get_jwt()
|
||||||
|
user_role = claims.get('role', '').upper() if claims.get('role') else ''
|
||||||
|
user_company = claims.get('company_name', '')
|
||||||
|
|
||||||
|
# 获取用户权限列表(用于检查 global:cross_company_op 特权)
|
||||||
|
from app.api.v1.inbound.base import get_current_user_permissions
|
||||||
|
user_perms = get_current_user_permissions() or []
|
||||||
|
normalized_perms = set(p.lower().replace('_', '').replace(':', '') for p in user_perms)
|
||||||
|
|
||||||
|
# 检查是否拥有全局特权或超管角色
|
||||||
|
has_cross_company = 'globalcrosscompanyop' in normalized_perms
|
||||||
|
|
||||||
|
req_company = filters.get('company') if filters else None
|
||||||
|
|
||||||
|
if user_role != 'SUPER_ADMIN' and not has_cross_company:
|
||||||
|
# 普通用户:强制隔离
|
||||||
|
if user_company:
|
||||||
|
filter_conditions.append(MaterialBase.company_name == user_company)
|
||||||
|
elif user_role == 'SUPER_ADMIN' or has_cross_company:
|
||||||
|
# 超级管理员或有跨域特权:允许跨公司视角
|
||||||
|
if req_company:
|
||||||
|
filter_conditions.append(MaterialBase.company_name == req_company)
|
||||||
|
|
||||||
category = filters.get('category')
|
category = filters.get('category')
|
||||||
if category is not None and category != '':
|
if category is not None and category != '':
|
||||||
filter_conditions.append(MaterialBase.category.ilike(category.strip()))
|
filter_conditions.append(MaterialBase.category.ilike(category.strip()))
|
||||||
@ -954,4 +1011,101 @@ class MaterialBaseService:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_latest_specs():
|
||||||
|
"""
|
||||||
|
获取所有规格型号的最大连号,按连续区间分组返回
|
||||||
|
- 前缀统一大写处理
|
||||||
|
- 只有数字完全连续(N, N+1, N+2...)才认定为同一组
|
||||||
|
- 数字不连续时断开,形成新组
|
||||||
|
- 按每组数量降序排列
|
||||||
|
- 返回每个连续区间的最大值
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# 1. 查询所有不为空的规格型号
|
||||||
|
specs = MaterialBase.query.filter(
|
||||||
|
MaterialBase.spec_model.isnot(None),
|
||||||
|
MaterialBase.spec_model != ''
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 2. 解析并收集所有有效的 (prefix, num, original_spec)
|
||||||
|
parsed = []
|
||||||
|
|
||||||
|
for material in specs:
|
||||||
|
spec = material.spec_model
|
||||||
|
if not spec:
|
||||||
|
continue
|
||||||
|
|
||||||
|
base_spec = spec.split('/')[0]
|
||||||
|
|
||||||
|
match = re.match(r'^([A-Za-z]+)(\d+)$', base_spec)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
prefix, num_str = match.groups()
|
||||||
|
prefix = prefix.upper()
|
||||||
|
num = int(num_str)
|
||||||
|
|
||||||
|
parsed.append((prefix, num, spec))
|
||||||
|
|
||||||
|
# 3. 先按 prefix 升序,再按 num 升序排序
|
||||||
|
parsed.sort(key=lambda x: (x[0], x[1]))
|
||||||
|
|
||||||
|
# 4. 遍历切分连续区间
|
||||||
|
# 核心逻辑:当 current_num != prev_num + 1 时,断开形成新组
|
||||||
|
intervals = []
|
||||||
|
current_prefix = None
|
||||||
|
current_start = None
|
||||||
|
current_end = None
|
||||||
|
current_last_spec = None
|
||||||
|
|
||||||
|
for prefix, num, spec in parsed:
|
||||||
|
if current_prefix is None:
|
||||||
|
current_prefix = prefix
|
||||||
|
current_start = num
|
||||||
|
current_end = num
|
||||||
|
current_last_spec = spec
|
||||||
|
elif prefix == current_prefix and num == current_end + 1:
|
||||||
|
current_end = num
|
||||||
|
current_last_spec = spec
|
||||||
|
else:
|
||||||
|
intervals.append({
|
||||||
|
'prefix': current_prefix,
|
||||||
|
'start': current_start,
|
||||||
|
'end': current_end,
|
||||||
|
'count': current_end - current_start + 1,
|
||||||
|
'latest': current_last_spec
|
||||||
|
})
|
||||||
|
current_prefix = prefix
|
||||||
|
current_start = num
|
||||||
|
current_end = num
|
||||||
|
current_last_spec = spec
|
||||||
|
|
||||||
|
if current_prefix is not None:
|
||||||
|
intervals.append({
|
||||||
|
'prefix': current_prefix,
|
||||||
|
'start': current_start,
|
||||||
|
'end': current_end,
|
||||||
|
'count': current_end - current_start + 1,
|
||||||
|
'latest': current_last_spec
|
||||||
|
})
|
||||||
|
|
||||||
|
# 5. 按每组数量降序排列,再按前缀升序
|
||||||
|
intervals.sort(key=lambda x: (-x['count'], x['prefix']))
|
||||||
|
|
||||||
|
# 6. 构建返回结果
|
||||||
|
result = []
|
||||||
|
for item in intervals:
|
||||||
|
prefix = item['prefix']
|
||||||
|
start = item['start']
|
||||||
|
end = item['end']
|
||||||
|
result.append({
|
||||||
|
"group": f"{prefix}({start}-{end})",
|
||||||
|
"count": item['count'],
|
||||||
|
"latest": item['latest']
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
@ -1,4 +1,5 @@
|
|||||||
# inventory-backend/app/services/inbound/buy_service.py
|
# inventory-backend/app/services/inbound/buy_service.py
|
||||||
|
from flask_jwt_extended import get_jwt
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.inbound.buy import StockBuy
|
from app.models.inbound.buy import StockBuy
|
||||||
from app.models.inbound.product import StockProduct
|
from app.models.inbound.product import StockProduct
|
||||||
@ -347,9 +348,34 @@ class BuyInboundService:
|
|||||||
if material_type and material_type.strip():
|
if material_type and material_type.strip():
|
||||||
query = query.filter(MaterialBase.material_type == material_type.strip())
|
query = query.filter(MaterialBase.material_type == material_type.strip())
|
||||||
|
|
||||||
# 3.1 公司独立搜索 [新增]
|
# ============================================================
|
||||||
if company and company.strip():
|
# 【行级数据隔离】基于 JWT 中的 company_name 进行过滤
|
||||||
query = query.filter(MaterialBase.company_name == company.strip())
|
# ============================================================
|
||||||
|
from flask_jwt_extended import get_jwt
|
||||||
|
|
||||||
|
claims = get_jwt()
|
||||||
|
user_role = claims.get('role', '').upper() if claims.get('role') else ''
|
||||||
|
user_company = claims.get('company_name', '')
|
||||||
|
|
||||||
|
# 获取用户权限列表(用于检查 global:cross_company_op 特权)
|
||||||
|
from app.api.v1.inbound.base import get_current_user_permissions
|
||||||
|
user_perms = get_current_user_permissions() or []
|
||||||
|
normalized_perms = set(p.lower().replace('_', '').replace(':', '') for p in user_perms)
|
||||||
|
|
||||||
|
# 检查是否拥有全局特权或超管角色
|
||||||
|
has_cross_company = 'globalcrosscompanyop' in normalized_perms
|
||||||
|
|
||||||
|
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. 状态筛选
|
# 4. 状态筛选
|
||||||
if not statuses: statuses = ['在库', '借库']
|
if not statuses: statuses = ['在库', '借库']
|
||||||
|
|||||||
@ -317,8 +317,34 @@ class ProductInboundService:
|
|||||||
if material_type and material_type.strip():
|
if material_type and material_type.strip():
|
||||||
query = query.filter(MaterialBase.material_type == material_type.strip())
|
query = query.filter(MaterialBase.material_type == material_type.strip())
|
||||||
|
|
||||||
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:
|
if not statuses:
|
||||||
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():
|
if material_type and material_type.strip():
|
||||||
query = query.filter(MaterialBase.material_type == material_type.strip())
|
query = query.filter(MaterialBase.material_type == material_type.strip())
|
||||||
|
|
||||||
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:
|
if not statuses:
|
||||||
statuses = ['在库', '借库']
|
statuses = ['在库', '借库']
|
||||||
|
|||||||
@ -10,6 +10,8 @@ from app.models.inbound.semi import StockSemi
|
|||||||
from app.models.inbound.product import StockProduct
|
from app.models.inbound.product import StockProduct
|
||||||
# 引入基础信息表
|
# 引入基础信息表
|
||||||
from app.models.base import MaterialBase
|
from app.models.base import MaterialBase
|
||||||
|
# 引入维修单表
|
||||||
|
from app.models.transaction import TransRepair
|
||||||
|
|
||||||
|
|
||||||
class OutboundService:
|
class OutboundService:
|
||||||
@ -75,6 +77,31 @@ class OutboundService:
|
|||||||
res['price'] = get_price(buy, 'stock_buy')
|
res['price'] = get_price(buy, 'stock_buy')
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
# 查询维修单表 (按SKU或序列号查询,排除已出库状态)
|
||||||
|
repair = TransRepair.query.filter(
|
||||||
|
or_(TransRepair.sku == clean_code, TransRepair.serial_number == clean_code)
|
||||||
|
).filter(
|
||||||
|
TransRepair.repair_status != '已出库'
|
||||||
|
).first()
|
||||||
|
if repair:
|
||||||
|
res = {
|
||||||
|
'id': repair.id,
|
||||||
|
'sku': repair.sku,
|
||||||
|
'name': repair.material_name or "维修件",
|
||||||
|
'spec_model': "",
|
||||||
|
'category': "",
|
||||||
|
'material_type': "",
|
||||||
|
'source_table': 'trans_repair',
|
||||||
|
'stock_quantity': 1,
|
||||||
|
'available_quantity': 1,
|
||||||
|
'batch_number': repair.serial_number or '',
|
||||||
|
'serial_number': repair.serial_number or '',
|
||||||
|
'warehouse_location': repair.customer_location or '',
|
||||||
|
'barcode': repair.sku,
|
||||||
|
'price': float(repair.sale_price) if repair.sale_price else 0
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -158,6 +185,30 @@ class OutboundService:
|
|||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
raise ValueError(f"SKU {item.get('sku')} 的出库数量必须大于0")
|
raise ValueError(f"SKU {item.get('sku')} 的出库数量必须大于0")
|
||||||
|
|
||||||
|
# 处理维修单出库
|
||||||
|
if source_table == 'trans_repair':
|
||||||
|
repair = TransRepair.query.with_for_update().get(stock_id)
|
||||||
|
if not repair:
|
||||||
|
raise ValueError(f"维修单不存在 (ID: {stock_id})")
|
||||||
|
|
||||||
|
# 更新维修单状态为已出库
|
||||||
|
repair.repair_status = '已出库'
|
||||||
|
repair.shipping_date = current_time
|
||||||
|
|
||||||
|
# 创建出库记录
|
||||||
|
new_record = TransOutbound(
|
||||||
|
sku=item.get('sku'),
|
||||||
|
source_table=source_table,
|
||||||
|
stock_id=stock_id,
|
||||||
|
barcode=item.get('barcode'),
|
||||||
|
quantity=quantity,
|
||||||
|
unit_price=unit_price,
|
||||||
|
outbound_time=current_time,
|
||||||
|
**common_data
|
||||||
|
)
|
||||||
|
db.session.add(new_record)
|
||||||
|
continue
|
||||||
|
|
||||||
ModelClass = model_map.get(source_table)
|
ModelClass = model_map.get(source_table)
|
||||||
if not ModelClass:
|
if not ModelClass:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
@ -35,4 +35,4 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "npm:rolldown-vite@7.2.5"
|
"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',
|
method: 'post',
|
||||||
data
|
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">
|
<div class="main-container">
|
||||||
<AppMain />
|
<AppMain />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 全局规格连号助手 -->
|
||||||
|
<SpecHelper />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Sidebar from './components/Sidebar/index.vue'
|
import Sidebar from './components/Sidebar/index.vue'
|
||||||
import AppMain from './components/AppMain.vue'
|
import AppMain from './components/AppMain.vue'
|
||||||
|
import SpecHelper from '@/components/SpecHelper/index.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -92,6 +92,13 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
name: 'InventorySummary',
|
name: 'InventorySummary',
|
||||||
component: () => import('@/views/stock/inbound/inbound_summary.vue'),
|
component: () => import('@/views/stock/inbound/inbound_summary.vue'),
|
||||||
meta: { title: '入库记录' }
|
meta: { title: '入库记录' }
|
||||||
|
},
|
||||||
|
// 维修管理
|
||||||
|
{
|
||||||
|
path: 'repair',
|
||||||
|
name: 'RepairManagement',
|
||||||
|
component: () => import('@/views/stock/inbound/repair.vue'),
|
||||||
|
meta: { title: '维修管理', permission: 'inbound_repair' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -30,7 +30,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="规格型号" min-width="140">
|
<el-table-column label="规格型号" min-width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.material_spec || '-' }}
|
{{ row.spec_model || '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="quantity" label="报废数量" width="100" align="right" />
|
<el-table-column prop="quantity" label="报废数量" width="100" align="right" />
|
||||||
|
|||||||
@ -960,31 +960,8 @@ const permissionMap: Record<string, string> = {
|
|||||||
inspection_report: 'inbound_buy:inspection_report'
|
inspection_report: 'inbound_buy:inspection_report'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化列显示状态
|
// 初始化列显示状态(纯权限驱动,废除本地缓存)
|
||||||
const initColumnPermissions = () => {
|
const initColumnPermissions = () => {
|
||||||
// 生成存储键:基于用户 ID 进行隔离,A/B 账号互不干扰
|
|
||||||
const userId = userStore.user?.id || userStore.username || 'anonymous'
|
|
||||||
const storageKey = `inbound_buy_columns_${userId}`
|
|
||||||
|
|
||||||
// 尝试从 localStorage 读取保存的列配置
|
|
||||||
const savedColumns = localStorage.getItem(storageKey)
|
|
||||||
if (savedColumns) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(savedColumns)
|
|
||||||
// 【核心修复】权限二次交集:缓存的列必须同时满足"存在于 allColumns 且当前拥有该字段权限"
|
|
||||||
const permittedCols = parsed.filter((prop: string) =>
|
|
||||||
allColumns.some(col => col.prop === prop) && hasColumnPermission(prop)
|
|
||||||
)
|
|
||||||
if (permittedCols.length > 0) {
|
|
||||||
visibleColumnProps.value = permittedCols
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to parse saved columns:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 【任务1】废除硬编码默认,动态计算:所有有权限的列默认展示
|
|
||||||
visibleColumnProps.value = allColumns
|
visibleColumnProps.value = allColumns
|
||||||
.filter(col => hasColumnPermission(col.prop))
|
.filter(col => hasColumnPermission(col.prop))
|
||||||
.map(col => col.prop)
|
.map(col => col.prop)
|
||||||
@ -1003,17 +980,6 @@ const allColumns = [...baseColumns, ...stockColumns]
|
|||||||
|
|
||||||
const visibleColumnProps = ref<string[]>([])
|
const visibleColumnProps = ref<string[]>([])
|
||||||
|
|
||||||
// 监听列配置变化并保存到 localStorage
|
|
||||||
watch(visibleColumnProps, (newVal) => {
|
|
||||||
const userId = userStore.user?.id || userStore.username || 'anonymous'
|
|
||||||
const storageKey = `inbound_buy_columns_${userId}`
|
|
||||||
try {
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(newVal))
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to save columns to localStorage:', e)
|
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
id: undefined, base_id: undefined as number | undefined,
|
id: undefined, base_id: undefined as number | undefined,
|
||||||
company_name: '',
|
company_name: '',
|
||||||
|
|||||||
@ -780,31 +780,8 @@ const permissionMap: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 根据用户权限初始化列显示状态
|
// 根据用户权限初始化列显示状态
|
||||||
// 初始化列显示状态
|
// 初始化列显示状态(纯权限驱动,废除本地缓存)
|
||||||
const initColumnPermissions = () => {
|
const initColumnPermissions = () => {
|
||||||
// 生成存储键:基于用户 ID 进行隔离,A/B 账号互不干扰
|
|
||||||
const userId = userStore.user?.id || userStore.username || 'anonymous'
|
|
||||||
const storageKey = `inbound_product_columns_${userId}`
|
|
||||||
|
|
||||||
// 尝试从 localStorage 读取保存的列配置
|
|
||||||
const savedColumns = localStorage.getItem(storageKey)
|
|
||||||
if (savedColumns) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(savedColumns)
|
|
||||||
// 【核心修复】权限二次交集:缓存的列必须同时满足"存在于 allColumns 且当前拥有该字段权限"
|
|
||||||
const permittedCols = parsed.filter((prop: string) =>
|
|
||||||
allColumns.some(col => col.prop === prop) && hasColumnPermission(prop)
|
|
||||||
)
|
|
||||||
if (permittedCols.length > 0) {
|
|
||||||
visibleColumnProps.value = permittedCols
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to parse saved columns:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 【任务1】废除硬编码默认,动态计算:所有有权限的列默认展示
|
|
||||||
visibleColumnProps.value = allColumns
|
visibleColumnProps.value = allColumns
|
||||||
.filter(col => hasColumnPermission(col.prop))
|
.filter(col => hasColumnPermission(col.prop))
|
||||||
.map(col => col.prop)
|
.map(col => col.prop)
|
||||||
@ -855,17 +832,6 @@ const displayData = computed(() => {
|
|||||||
const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
|
const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
|
||||||
const visibleColumnProps = ref<string[]>([])
|
const visibleColumnProps = ref<string[]>([])
|
||||||
|
|
||||||
// 监听列配置变化并保存到 localStorage
|
|
||||||
watch(visibleColumnProps, (newVal) => {
|
|
||||||
const userId = userStore.user?.id || userStore.username || 'anonymous'
|
|
||||||
const storageKey = `inbound_product_columns_${userId}`
|
|
||||||
try {
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(newVal))
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to save columns to localStorage:', e)
|
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
id: undefined, base_id: undefined as number | undefined,
|
id: undefined, base_id: undefined as number | undefined,
|
||||||
company_name: '', // [新增]
|
company_name: '', // [新增]
|
||||||
|
|||||||
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 allColumns = [...baseColumns, ...stockColumns]
|
||||||
|
|
||||||
// 初始化列显示状态
|
// 初始化列显示状态(纯权限驱动,废除本地缓存)
|
||||||
const initColumnPermissions = () => {
|
const initColumnPermissions = () => {
|
||||||
// 生成存储键:基于用户 ID 进行隔离,A/B 账号互不干扰
|
|
||||||
const userId = userStore.user?.id || userStore.username || 'anonymous'
|
|
||||||
const storageKey = `inbound_semi_columns_${userId}`
|
|
||||||
|
|
||||||
// 尝试从 localStorage 读取保存的列配置
|
|
||||||
const savedColumns = localStorage.getItem(storageKey)
|
|
||||||
if (savedColumns) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(savedColumns)
|
|
||||||
// 【核心修复】权限二次交集:缓存的列必须同时满足"存在于 allColumns 且当前拥有该字段权限"
|
|
||||||
const permittedCols = parsed.filter((prop: string) =>
|
|
||||||
allColumns.some(col => col.prop === prop) && hasColumnPermission(prop)
|
|
||||||
)
|
|
||||||
if (permittedCols.length > 0) {
|
|
||||||
visibleColumnProps.value = permittedCols
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to parse saved columns:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 【任务1】废除硬编码默认,动态计算:所有有权限的列默认展示
|
|
||||||
visibleColumnProps.value = allColumns
|
visibleColumnProps.value = allColumns
|
||||||
.filter(col => hasColumnPermission(col.prop))
|
.filter(col => hasColumnPermission(col.prop))
|
||||||
.map(col => col.prop)
|
.map(col => col.prop)
|
||||||
@ -892,17 +869,6 @@ const hasColumnPermission = (prop: string) => {
|
|||||||
const defaultColumns = ['company_name', 'material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'qty_stock', 'qty_available', 'unit_total_cost', 'arrival_photo', 'quality_report_link']
|
const defaultColumns = ['company_name', 'material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'qty_stock', 'qty_available', 'unit_total_cost', 'arrival_photo', 'quality_report_link']
|
||||||
const visibleColumnProps = ref<string[]>([])
|
const visibleColumnProps = ref<string[]>([])
|
||||||
|
|
||||||
// 监听列配置变化并保存到 localStorage
|
|
||||||
watch(visibleColumnProps, (newVal) => {
|
|
||||||
const userId = userStore.user?.id || userStore.username || 'anonymous'
|
|
||||||
const storageKey = `inbound_semi_columns_${userId}`
|
|
||||||
try {
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(newVal))
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to save columns to localStorage:', e)
|
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
id: undefined, base_id: undefined as number | undefined,
|
id: undefined, base_id: undefined as number | undefined,
|
||||||
company_name: '',
|
company_name: '',
|
||||||
|
|||||||
@ -203,9 +203,26 @@ const fetchTree = async () => {
|
|||||||
try {
|
try {
|
||||||
const res: any = await getAllPermissionTree()
|
const res: any = await getAllPermissionTree()
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
rawTreeData.value = res.data
|
// --- 注入全局特权虚拟节点 ---
|
||||||
// 初始化表格结构(此时没有勾选状态)
|
const globalNode = {
|
||||||
tableData.value = transformData(res.data)
|
id: 99999, // 虚拟ID
|
||||||
|
name: '🌍 全局系统特权',
|
||||||
|
code: 'global_privileges',
|
||||||
|
type: 'menu',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 999991,
|
||||||
|
name: '跨组织/跨区域数据查询',
|
||||||
|
code: 'global:cross_company_op', // 加上 _op 让它显示在操作权限列
|
||||||
|
type: 'element'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
// 将虚拟节点放在最前面,然后交给 transformData 处理
|
||||||
|
const rawData = [globalNode, ...(res.data || [])];
|
||||||
|
|
||||||
|
rawTreeData.value = rawData;
|
||||||
|
tableData.value = transformData(rawData);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('加载权限配置失败')
|
ElMessage.error('加载权限配置失败')
|
||||||
|
|||||||
@ -17,10 +17,14 @@ export default defineConfig({
|
|||||||
// 允许局域网访问前端页面
|
// 允许局域网访问前端页面
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
watch: {
|
||||||
|
usePolling: true
|
||||||
|
},
|
||||||
https: true, // ★ [新增] 强制开启 HTTPS,否则浏览器会拦截摄像头
|
https: true, // ★ [新增] 强制开启 HTTPS,否则浏览器会拦截摄像头
|
||||||
hmr: {
|
hmr: {
|
||||||
protocol: 'wss',
|
protocol: 'wss',
|
||||||
clientPort: 5173
|
clientPort: 5175
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
// 拦截所有以 /api 开头的请求
|
// 拦截所有以 /api 开头的请求
|
||||||
|
|||||||
54
query_permissions.py
Normal file
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
|
#!/bin/bash
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 1. 本地 WSL 数据库配置 (根据你之前的数据)
|
# 1. 本地 WSL 数据库配置
|
||||||
# ==========================================
|
# ==========================================
|
||||||
LOCAL_CONTAINER="inventory_db"
|
LOCAL_CONTAINER="inventory_db"
|
||||||
LOCAL_DB_USER="test"
|
LOCAL_DB_USER="test"
|
||||||
LOCAL_DB_NAME="inventory_system"
|
LOCAL_DB_NAME="inventory_system"
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 2. 远程服务器 SSH 配置 (根据你的截图)
|
# 2. 远程服务器 SSH 配置
|
||||||
# ==========================================
|
# ==========================================
|
||||||
REMOTE_USER="dxc"
|
REMOTE_USER="dxc"
|
||||||
REMOTE_HOST="172.16.0.198"
|
REMOTE_HOST="172.16.0.198"
|
||||||
REMOTE_PORT="22"
|
REMOTE_PORT="22"
|
||||||
|
REMOTE_DIR="/opt/inventory-app" # 用于存放备份
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 3. 远程服务器 Docker 配置 (根据你的 docker-compose.prod.yml)
|
# 3. 远程服务器 Docker 配置
|
||||||
# ==========================================
|
# ==========================================
|
||||||
REMOTE_CONTAINER="inventory_db_prod"
|
REMOTE_CONTAINER="inventory_db_prod"
|
||||||
REMOTE_DB_USER="prod_user"
|
REMOTE_DB_USER="prod_user"
|
||||||
@ -26,14 +27,28 @@ REMOTE_DB_NAME="inventory_system"
|
|||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
DUMP_FILE="db_sync_${TIMESTAMP}.sql.gz"
|
DUMP_FILE="db_sync_${TIMESTAMP}.sql.gz"
|
||||||
LOCAL_DUMP_PATH="/tmp/${DUMP_FILE}"
|
LOCAL_DUMP_PATH="/tmp/${DUMP_FILE}"
|
||||||
|
REMOTE_BACKUP_FILE="${REMOTE_DIR}/data_copy/DB_BACKUP_${TIMESTAMP}.sql.gz"
|
||||||
|
|
||||||
echo "========================================================"
|
echo "========================================================"
|
||||||
echo " 🔄 开始同步 WSL 数据库到远程服务器 (${REMOTE_HOST})"
|
echo " 🔄 开始同步 WSL 数据库到远程服务器 (${REMOTE_HOST})"
|
||||||
|
echo " ⚠️ 注意:线上旧数据将被完全覆盖!"
|
||||||
echo "========================================================"
|
echo "========================================================"
|
||||||
|
|
||||||
|
# --- 新增:步骤 0: 远程服务器数据备份 ---
|
||||||
|
echo -e "\n[0/4] 🛡️ 正在备份线上服务器数据库..."
|
||||||
|
ssh -p ${REMOTE_PORT} ${REMOTE_USER}@${REMOTE_HOST} << EOF
|
||||||
|
mkdir -p ${REMOTE_DIR}/data_copy
|
||||||
|
# 导出线上数据作为备份
|
||||||
|
docker exec -e PGPASSWORD="${REMOTE_DB_PASS}" ${REMOTE_CONTAINER} pg_dump -U ${REMOTE_DB_USER} -d ${REMOTE_DB_NAME} -O -x | gzip > ${REMOTE_BACKUP_FILE}
|
||||||
|
echo " -> 线上备份已保存至: ${REMOTE_BACKUP_FILE}"
|
||||||
|
EOF
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ 线上备份失败!为保证数据安全,同步已终止!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# --- 步骤 1: 本地导出 ---
|
# --- 步骤 1: 本地导出 ---
|
||||||
echo -e "\n[1/4] 📦 正在本地打包数据库..."
|
echo -e "\n[1/4] 📦 正在本地打包数据库..."
|
||||||
# 注意:这里使用 pg_dump 导出,为了兼容性,排除可能引起冲突的权限所有者信息 (-O -x)
|
|
||||||
docker exec ${LOCAL_CONTAINER} pg_dump -U ${LOCAL_DB_USER} -d ${LOCAL_DB_NAME} -O -x | gzip > ${LOCAL_DUMP_PATH}
|
docker exec ${LOCAL_CONTAINER} pg_dump -U ${LOCAL_DB_USER} -d ${LOCAL_DB_NAME} -O -x | gzip > ${LOCAL_DUMP_PATH}
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo "❌ 本地数据库导出失败!请检查本地 inventory_db 容器是否正常运行。"
|
echo "❌ 本地数据库导出失败!请检查本地 inventory_db 容器是否正常运行。"
|
||||||
@ -57,7 +72,6 @@ ssh -p ${REMOTE_PORT} ${REMOTE_USER}@${REMOTE_HOST} << EOF
|
|||||||
docker cp /tmp/${DUMP_FILE} ${REMOTE_CONTAINER}:/tmp/${DUMP_FILE}
|
docker cp /tmp/${DUMP_FILE} ${REMOTE_CONTAINER}:/tmp/${DUMP_FILE}
|
||||||
|
|
||||||
echo " -> 危险操作:清空服务器旧数据环境..."
|
echo " -> 危险操作:清空服务器旧数据环境..."
|
||||||
# 传入 PGPASSWORD 环境变量以防密码拦截
|
|
||||||
docker exec -e PGPASSWORD="${REMOTE_DB_PASS}" ${REMOTE_CONTAINER} psql -U ${REMOTE_DB_USER} -d ${REMOTE_DB_NAME} -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO ${REMOTE_DB_USER};"
|
docker exec -e PGPASSWORD="${REMOTE_DB_PASS}" ${REMOTE_CONTAINER} psql -U ${REMOTE_DB_USER} -d ${REMOTE_DB_NAME} -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO ${REMOTE_DB_USER};"
|
||||||
|
|
||||||
echo " -> 正在导入最新数据..."
|
echo " -> 正在导入最新数据..."
|
||||||
@ -74,4 +88,4 @@ rm ${LOCAL_DUMP_PATH}
|
|||||||
|
|
||||||
echo -e "\n========================================================"
|
echo -e "\n========================================================"
|
||||||
echo "🎉 数据库全量替换成功!快去刷新你的线上系统看看吧!"
|
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