Compare commits
91 Commits
04ee938cd1
...
1.0入库操作
| Author | SHA1 | Date | |
|---|---|---|---|
| 47fb8912a9 | |||
| 82a9a4c2ba | |||
| 1c3f116c50 | |||
| 948149cd44 | |||
| 63a3cf269d | |||
| 447b1890ab | |||
| 8a7e367d00 | |||
| 31ddb1aafd | |||
| 42171ed612 | |||
| 7e2fa8db8e | |||
| d1ab5f1100 | |||
| 853374de5d | |||
| b682d4b02f | |||
| d61668bc4b | |||
| b93a565c82 | |||
| 6e5df70ee6 | |||
| fb536dad7f | |||
| d479b750d7 | |||
| 05a108e96d | |||
| ec7f20869a | |||
| fcebe70848 | |||
| bb8c07a465 | |||
| 5245ee2da3 | |||
| 04dd6fb3fa | |||
| 32f031b047 | |||
| 6d5d8a6aad | |||
| e900326571 | |||
| 5513e4cd81 | |||
| 83f040728f | |||
| d3d35e03cd | |||
| 9f0134b2e4 | |||
| b3fdc65d33 | |||
| 9c70d78d9f | |||
| cfb36ebf0b | |||
| c6fd0aca90 | |||
| 5f3ceef3fd | |||
| 5532c87684 | |||
| d0a237625c | |||
| b1e2836e4b | |||
| 706476d421 | |||
| 64efbb97d6 | |||
| ec16ef8d20 | |||
| d594ed7ef1 | |||
| 8ee2a9a45b | |||
| b5b0677b01 | |||
| 8d00e6783c | |||
| 695c78090a | |||
| af1a95017b | |||
| 8cae6ee7f6 | |||
| 94ff7cecdc | |||
| 17a61b489c | |||
| 18da3979a9 | |||
| 88d32067ae | |||
| b98f89bfe4 | |||
| c4d2e703f1 | |||
| e876505a1b | |||
| bccbeaadce | |||
| 1edd9a95c6 | |||
| 2d0593078b | |||
| a0ed92319c | |||
| d4b23790a1 | |||
| aee0fc4380 | |||
| 107c311391 | |||
| 5c8cefdb69 | |||
| 50361dba9a | |||
| eb771ec4f1 | |||
| 58f0ce48e2 | |||
| 4be42ae5f5 | |||
| 170e80e2a5 | |||
| e535a2d99c | |||
| 81c0e93d46 | |||
| c0463cb7dc | |||
| 20e4329a44 | |||
| b61072eea0 | |||
| 40abb53721 | |||
| fdf22b9973 | |||
| bdee5fb27a | |||
| 70f75cc72b | |||
| 94d3149bd9 | |||
| 6131b474a1 | |||
| 164988ab62 | |||
| 8138e8cf5f | |||
| b57e9f5bba | |||
| c06b96f149 | |||
| 03aea51e9a | |||
| 592830c213 | |||
| 89a29f0b65 | |||
| 49453d47f6 | |||
| 1b88171985 | |||
| f234ca6793 | |||
| 3f398b74e5 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -15,3 +15,6 @@ inventory-web/*.local
|
||||
.vscode/
|
||||
.DS_Store
|
||||
*.log
|
||||
pgdata_docker/
|
||||
inventory-backend/uploads/
|
||||
.aider*
|
||||
|
||||
@ -106,6 +106,19 @@ def create_app():
|
||||
except ImportError as e:
|
||||
print(f"❌ 错误: Outbound 模块导入失败: {e}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 2.6 注册 BOM 模块
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
from app.api.v1.bom import bom_bp
|
||||
# 标准: /api/v1/bom
|
||||
app.register_blueprint(bom_bp, url_prefix='/api/v1/bom')
|
||||
# 兼容: /api/bom
|
||||
app.register_blueprint(bom_bp, url_prefix='/api/bom', name='bom_legacy')
|
||||
print("✅ BOM 模块注册成功")
|
||||
except ImportError as e:
|
||||
print(f"❌ 错误: BOM 模块导入失败: {e}")
|
||||
|
||||
# =========================================================
|
||||
# 3. 预加载数据模型
|
||||
# =========================================================
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
from flask import Blueprint
|
||||
from .inbound import inbound_bp
|
||||
from .bom import bom_bp
|
||||
|
||||
v1_bp = Blueprint('v1', __name__)
|
||||
v1_bp.register_blueprint(inbound_bp, url_prefix='/inbound')
|
||||
v1_bp.register_blueprint(bom_bp, url_prefix='/bom')
|
||||
|
||||
202
inventory-backend/app/api/v1/bom.py
Normal file
202
inventory-backend/app/api/v1/bom.py
Normal file
@ -0,0 +1,202 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from app.services.bom_service import BomService
|
||||
from app.models.base import MaterialBase
|
||||
from app.models.bom import BomTable
|
||||
from app.extensions import db
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
bom_bp = Blueprint('bom', __name__)
|
||||
|
||||
|
||||
# ==================== 新版 BOM 接口(基于 bom_no) ====================
|
||||
|
||||
@bom_bp.route('/list', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_bom_list():
|
||||
"""获取所有 BOM 配方列表,支持 keyword 搜索和 active_only 过滤"""
|
||||
try:
|
||||
keyword = request.args.get('keyword', '').strip()
|
||||
# 将字符串 'true' 转为布尔值
|
||||
active_only = request.args.get('active_only', 'false').lower() == 'true'
|
||||
|
||||
data = BomService.get_bom_list(keyword=keyword, active_only=active_only)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': data
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'获取BOM列表失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@bom_bp.route('/detail/<bom_no>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_bom_detail(bom_no):
|
||||
"""
|
||||
根据 BOM 编号获取配方详情
|
||||
Query参数: ?version=V1.0 (如果不传则取最新)
|
||||
"""
|
||||
try:
|
||||
version = request.args.get('version')
|
||||
data = BomService.get_bom_detail(bom_no, version=version)
|
||||
if not data:
|
||||
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': data
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'获取BOM详情失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@bom_bp.route('/save', methods=['POST'])
|
||||
@jwt_required()
|
||||
def save_bom():
|
||||
"""保存或更新 BOM 配方(支持自定义 bom_no 和 多版本)"""
|
||||
try:
|
||||
req_data = request.get_json()
|
||||
# 必需字段校验
|
||||
if 'parent_id' not in req_data or 'children' not in req_data:
|
||||
return jsonify({'code': 400, 'msg': '缺少 parent_id 或 children 字段'}), 400
|
||||
|
||||
# 校验 bom_no 不能为空
|
||||
if 'bom_no' in req_data and not req_data['bom_no']:
|
||||
return jsonify({'code': 400, 'msg': 'BOM编号不能为空'}), 400
|
||||
|
||||
bom_no = BomService.save_bom(req_data)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '保存成功',
|
||||
'data': {'bom_no': bom_no}
|
||||
})
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'保存BOM失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@bom_bp.route('/stock/<bom_no>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_bom_with_stock_by_no(bom_no):
|
||||
"""根据 BOM 编号获取配方详情及库存信息"""
|
||||
try:
|
||||
data = BomService.get_bom_with_stock_by_bom_no(bom_no)
|
||||
if not data:
|
||||
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': data
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'获取BOM库存信息失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
# ==================== 删除BOM接口 ====================
|
||||
|
||||
@bom_bp.route('/<bom_no>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_bom(bom_no):
|
||||
"""
|
||||
根据 BOM 编号删除
|
||||
Query参数: ?version=V1.0 (如果不传,删除该编号下所有版本)
|
||||
"""
|
||||
try:
|
||||
version = request.args.get('version')
|
||||
query = BomTable.query.filter_by(bom_no=bom_no)
|
||||
|
||||
if version:
|
||||
query = query.filter_by(version=version)
|
||||
|
||||
exist = query.first()
|
||||
if not exist:
|
||||
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
|
||||
|
||||
# 删除
|
||||
query.delete()
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '删除成功'
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'删除BOM失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
# ==================== 兼容旧接口 ====================
|
||||
|
||||
@bom_bp.route('/<int:parent_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_bom(parent_id):
|
||||
try:
|
||||
data = BomService.get_bom_with_stock(parent_id)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': data
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'获取BOM失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@bom_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def save_bom_legacy():
|
||||
try:
|
||||
req_data = request.get_json()
|
||||
parent_id = req_data.get('parent_id')
|
||||
child_list = req_data.get('children', [])
|
||||
if not parent_id or not isinstance(child_list, list):
|
||||
return jsonify({'code': 400, 'msg': '参数错误'}), 400
|
||||
BomService.create_or_update_bom(parent_id, child_list)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '保存成功'
|
||||
})
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'保存BOM失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@bom_bp.route('/base/list', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_material_base_list():
|
||||
"""获取所有基础物料列表,用于前端下拉框"""
|
||||
try:
|
||||
materials = MaterialBase.query.filter_by(is_enabled=True).order_by(MaterialBase.id.desc()).all()
|
||||
data = [item.to_dict() for item in materials]
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': data
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'获取基础物料列表失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@bom_bp.route('/parents', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_bom_parents():
|
||||
"""获取所有已定义BOM的父件物料列表(兼容旧版)"""
|
||||
try:
|
||||
subq = db.session.query(BomTable.parent_id).distinct().subquery()
|
||||
parents = MaterialBase.query.join(subq, MaterialBase.id == subq.c.parent_id).all()
|
||||
data = [item.to_dict() for item in parents]
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': data
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'获取BOM父件列表失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
@ -1,6 +1,7 @@
|
||||
# app/api/v1/common/print.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.print.label_service import LabelPrintService
|
||||
from app.services.print.print_config import PrintConfigManager
|
||||
from app.models.inbound.buy import StockBuy
|
||||
# 引入其他模型 StockSemi, StockProduct
|
||||
import traceback
|
||||
@ -25,3 +26,24 @@ def execute_print():
|
||||
return jsonify({"code": 200, "msg": "指令已发送至打印机"})
|
||||
except Exception as e:
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@print_bp.route('/config', methods=['GET'])
|
||||
def get_printer_config():
|
||||
try:
|
||||
label = PrintConfigManager.get_config('label_printer')
|
||||
network = PrintConfigManager.get_config('network_printer')
|
||||
config = {'label_printer': label, 'network_printer': network}
|
||||
return jsonify({"code": 200, "msg": "success", "data": config})
|
||||
except Exception as e:
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@print_bp.route('/config', methods=['POST'])
|
||||
def update_printer_config():
|
||||
try:
|
||||
data = request.get_json()
|
||||
PrintConfigManager.save_config(data)
|
||||
return jsonify({"code": 200, "msg": "配置保存成功"})
|
||||
except Exception as e:
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@ -5,21 +5,18 @@ import uuid
|
||||
from flask import Blueprint, request, jsonify, send_from_directory
|
||||
|
||||
# 定义蓝图
|
||||
# 注意:在 app/__init__.py 或类似入口文件中,注册此蓝图时 url_prefix 通常应为 '/api/v1/common'
|
||||
upload_bp = Blueprint('upload', __name__)
|
||||
|
||||
|
||||
# =========================================================
|
||||
# 配置上传路径 (核心修改:确保路径绝对准确)
|
||||
# 配置上传路径
|
||||
# =========================================================
|
||||
# 向上寻找直到找到 inventory-backend 目录,或者默认为当前文件的上级目录的...上级
|
||||
# 这种方式比数 dirname 层级更稳健
|
||||
|
||||
def get_project_root():
|
||||
"""获取项目根目录 inventory-backend"""
|
||||
current_path = os.path.abspath(__file__)
|
||||
# 循环向上查找,直到找到名为 inventory-backend 的目录
|
||||
# 如果你的根目录名字不是 inventory-backend,请修改这里的判断逻辑
|
||||
# 或者直接使用相对路径回退 5 层: api/v1/common -> app -> inventory-backend
|
||||
# 向上回退直到找到根目录,根据你的目录结构可能需要调整层级
|
||||
# 假设结构: inventory-backend/app/api/v1/common/upload.py (回退5层)
|
||||
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_path)))))
|
||||
return base
|
||||
|
||||
@ -28,7 +25,7 @@ BASE_DIR = get_project_root()
|
||||
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
|
||||
|
||||
# 允许上传的文件后缀
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'}
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'pdf', 'doc', 'docx', 'xls', 'xlsx'}
|
||||
|
||||
|
||||
def allowed_file(filename):
|
||||
@ -47,7 +44,7 @@ def ensure_upload_folder_exists():
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. 文件上传接口
|
||||
# URL: /api/v1/common/upload (POST)
|
||||
# 完整 URL: /api/v1/common/upload (POST)
|
||||
# ------------------------------------------------------------------
|
||||
@upload_bp.route('/upload', methods=['POST'])
|
||||
def upload_file():
|
||||
@ -63,6 +60,7 @@ def upload_file():
|
||||
|
||||
if file and allowed_file(file.filename):
|
||||
try:
|
||||
# 获取后缀并生成唯一文件名
|
||||
ext = file.filename.rsplit('.', 1)[1].lower()
|
||||
new_filename = f"{uuid.uuid4().hex}.{ext}"
|
||||
|
||||
@ -71,8 +69,9 @@ def upload_file():
|
||||
|
||||
print(f"💾 [Upload] 文件已保存: {save_path}")
|
||||
|
||||
# 生成访问 URL
|
||||
# 这里的路径必须与 __init__.py 中注册的 url_prefix + 路由匹配
|
||||
# 生成访问 URL (返回给前端的相对路径)
|
||||
# 前端展示时通常拼接 baseURL,或者直接使用此路径访问
|
||||
# 这里的 /api/v1/common 需与蓝图注册路径一致
|
||||
file_url = f"/api/v1/common/files/{new_filename}"
|
||||
|
||||
return jsonify({
|
||||
@ -92,13 +91,13 @@ def upload_file():
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. 静态文件访问接口 (回显)
|
||||
# URL: /api/v1/common/files/<filename>
|
||||
# 完整 URL: /api/v1/common/files/<filename> (GET)
|
||||
# ------------------------------------------------------------------
|
||||
@upload_bp.route('/files/<filename>')
|
||||
@upload_bp.route('/files/<filename>', methods=['GET'])
|
||||
def uploaded_file(filename):
|
||||
# 打印日志帮助调试 404 问题
|
||||
full_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||
if not os.path.exists(full_path):
|
||||
# 尝试调试路径问题
|
||||
print(f"❌ [File Access] 文件未找到: {full_path}")
|
||||
return jsonify({"code": 404, "msg": "文件不存在"}), 404
|
||||
|
||||
@ -107,12 +106,12 @@ def uploaded_file(filename):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. 文件删除接口 (同步删除物理文件)
|
||||
# URL: /api/v1/common/files/<filename> (DELETE)
|
||||
# 完整 URL: /api/v1/common/files/<filename> (DELETE)
|
||||
# ------------------------------------------------------------------
|
||||
@upload_bp.route('/files/<filename>', methods=['DELETE'])
|
||||
def delete_file(filename):
|
||||
try:
|
||||
# 安全处理文件名
|
||||
# 安全处理文件名 (防止路径遍历)
|
||||
safe_filename = os.path.basename(filename)
|
||||
file_path = os.path.join(UPLOAD_FOLDER, safe_filename)
|
||||
|
||||
@ -124,7 +123,7 @@ def delete_file(filename):
|
||||
return jsonify({"code": 200, "msg": "文件已删除"})
|
||||
else:
|
||||
print(f"⚠️ [Delete] 文件不存在,无需删除")
|
||||
# 即使文件不存在也返回成功,保证前端流程继续
|
||||
# 即使文件不存在也返回成功,保证前端逻辑闭环
|
||||
return jsonify({"code": 200, "msg": "文件不存在或已删除"})
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@ -1,20 +1,25 @@
|
||||
from flask import Blueprint
|
||||
|
||||
# 首先创建主蓝图,避免子模块导入时出现循环依赖
|
||||
inbound_bp = Blueprint('inbound', __name__)
|
||||
|
||||
# 导入各子模块的蓝图(此时 inbound_bp 已定义,子模块可以安全导入它)
|
||||
from .buy import inbound_buy_bp
|
||||
from .semi import inbound_semi_bp
|
||||
from .base import inbound_base_bp
|
||||
from .product import inbound_product_bp
|
||||
from .inbound_summary import bp as inbound_summary_bp
|
||||
# ★ [新增] 导入 stock 模块
|
||||
from .stock import bp as stock_bp
|
||||
from .stock import bp as inbound_stock_bp
|
||||
|
||||
inbound_bp = Blueprint('inbound', __name__)
|
||||
# 导入 service 模块,使其路由装饰器可以正常注册到 inbound_bp 上
|
||||
from . import service
|
||||
|
||||
# 注册子蓝图
|
||||
inbound_bp.register_blueprint(inbound_buy_bp, url_prefix='/buy')
|
||||
inbound_bp.register_blueprint(inbound_semi_bp, url_prefix='/semi')
|
||||
inbound_bp.register_blueprint(inbound_base_bp, url_prefix='/base')
|
||||
inbound_bp.register_blueprint(inbound_product_bp, url_prefix='/product')
|
||||
inbound_bp.register_blueprint(inbound_summary_bp, url_prefix='/summary')
|
||||
inbound_bp.register_blueprint(inbound_stock_bp, url_prefix='/stock')
|
||||
|
||||
# ★ [新增] 挂载 stock 模块,路径前缀为 /stock
|
||||
# 最终访问路径例:/api/v1/inbound/stock/all
|
||||
inbound_bp.register_blueprint(stock_bp, url_prefix='/stock')
|
||||
# service 模块的路由已经直接附加到 inbound_bp,无需再注册子蓝图
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
# 文件路径: app/api/v1/inbound/base.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from flask import Blueprint, request, jsonify, send_file
|
||||
from app.services.inbound.base_service import MaterialBaseService
|
||||
import traceback
|
||||
import datetime
|
||||
|
||||
inbound_base_bp = Blueprint('stock_base', __name__)
|
||||
|
||||
@ -26,12 +28,13 @@ def search_base():
|
||||
@inbound_base_bp.route('/list', methods=['GET'])
|
||||
def get_list():
|
||||
try:
|
||||
page = request.args.get('pageNum', 1, type=int) # 前端传的是 pageNum
|
||||
page = request.args.get('pageNum', 1, type=int)
|
||||
limit = request.args.get('pageSize', 10, type=int)
|
||||
|
||||
# 构造筛选条件
|
||||
filters = {
|
||||
'keyword': request.args.get('keyword', ''),
|
||||
'company': request.args.get('company', ''),
|
||||
'category': request.args.get('category', ''),
|
||||
'type': request.args.get('type', ''),
|
||||
'isEnabled': request.args.get('isEnabled', None)
|
||||
@ -44,9 +47,58 @@ def get_list():
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 2.1 选项接口 (GET /api/v1/inbound/base/options)
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/options', methods=['GET'])
|
||||
def get_options():
|
||||
try:
|
||||
data = MaterialBaseService.get_distinct_options()
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 2.2 导出接口 (GET /api/v1/inbound/base/export)
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/export', methods=['GET'])
|
||||
def export_data():
|
||||
try:
|
||||
# 获取筛选条件
|
||||
filters = {
|
||||
'keyword': request.args.get('keyword', ''),
|
||||
'company': request.args.get('company', ''),
|
||||
'category': request.args.get('category', ''),
|
||||
'type': request.args.get('type', ''),
|
||||
'isEnabled': request.args.get('isEnabled', None)
|
||||
}
|
||||
|
||||
# 生成 Excel 文件流
|
||||
file_stream = MaterialBaseService.export_excel(filters)
|
||||
|
||||
# 生成文件名:库存统计+年月日+时分秒 (北京时间 UTC+8)
|
||||
# 简单处理:UTC时间 + 8小时
|
||||
beijing_time = datetime.datetime.utcnow() + datetime.timedelta(hours=8)
|
||||
filename = f"库存统计_{beijing_time.strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
|
||||
# 发送文件
|
||||
# 注意:download_name 仅在较新 Flask 版本有效,旧版本可能需要手动 header,
|
||||
# 但通常浏览器下载名由前端 Blob 处理或 Content-Disposition 决定。
|
||||
return send_file(
|
||||
file_stream,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": f"导出失败: {str(e)}"}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 3. 新增接口 (POST /api/v1/inbound/base/)
|
||||
# 注意:前端 material_base.ts 可能会请求 / 或 /add,这里统一匹配
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/', methods=['POST'])
|
||||
def create():
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# app/api/v1/inbound/buy.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.inbound.buy_service import BuyInboundService
|
||||
import traceback
|
||||
@ -11,17 +10,19 @@ inbound_buy_bp = Blueprint('stock_buy', __name__)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/search-base', methods=['GET'])
|
||||
def search_base():
|
||||
"""
|
||||
供前端下拉框远程搜索使用
|
||||
Query Param: keyword (名称或规格)
|
||||
"""
|
||||
try:
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = BuyInboundService.search_base_material(keyword)
|
||||
page = request.args.get('page', 1, type=int)
|
||||
# 固定每次加载50条
|
||||
limit = 50
|
||||
|
||||
result = BuyInboundService.search_base_material(keyword, page, limit)
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": data
|
||||
"data": result['items'],
|
||||
"total": result['total'],
|
||||
"has_next": result['has_next']
|
||||
})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
@ -29,7 +30,7 @@ def search_base():
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. 获取列表 (修改:支持状态筛选)
|
||||
# 1. 获取列表 (修改:接收 category 和 material_type)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/list', methods=['GET'])
|
||||
def get_list():
|
||||
@ -38,11 +39,15 @@ def get_list():
|
||||
limit = request.args.get('pageSize', 15, type=int)
|
||||
keyword = request.args.get('keyword', '')
|
||||
|
||||
# 获取状态列表参数,前端传参格式: statuses=在库,借库
|
||||
# 新增筛选参数
|
||||
category = request.args.get('category', '')
|
||||
material_type = request.args.get('material_type', '')
|
||||
|
||||
# 状态参数处理
|
||||
statuses_str = request.args.get('statuses', '')
|
||||
statuses = statuses_str.split(',') if statuses_str else []
|
||||
|
||||
result = BuyInboundService.get_list(page, limit, keyword, statuses)
|
||||
result = BuyInboundService.get_list(page, limit, keyword, statuses, category, material_type)
|
||||
return jsonify({"code": 200, "msg": "success", "data": result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
@ -97,17 +102,68 @@ def delete_buy(id):
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. 获取关联的出库历史
|
||||
# 5. [新增] 获取筛选下拉选项 (修复404的关键)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/options', methods=['GET'])
|
||||
def get_options():
|
||||
try:
|
||||
data = BuyInboundService.get_filter_options()
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. 获取关联的出库历史 (如果有)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/<int:id>/history', methods=['GET'])
|
||||
def get_history(id):
|
||||
try:
|
||||
history = BuyInboundService.get_outbound_history(id)
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": history
|
||||
})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
# 如果没有出库模块,这个接口可能为空,但为保持兼容性保留
|
||||
return jsonify({"code": 200, "msg": "success", "data": []})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. 供应商建议
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/suggestions/suppliers', methods=['GET'])
|
||||
def get_supplier_suggestions():
|
||||
base_id = request.args.get('base_id', type=int)
|
||||
if not base_id:
|
||||
return jsonify({"code": 400, "msg": "base_id required"}), 400
|
||||
data = BuyInboundService.get_history_suppliers(base_id)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. 采购人建议 (全局)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/suggestions/users', methods=['GET'])
|
||||
def get_user_suggestions():
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = BuyInboundService.get_history_purchasers(keyword)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. 链接建议
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/suggestions/links', methods=['GET'])
|
||||
def get_link_suggestions():
|
||||
base_id = request.args.get('base_id', type=int)
|
||||
link_type = request.args.get('type', 'original') # original or detail
|
||||
if not base_id:
|
||||
return jsonify({"code": 400, "msg": "base_id required"}), 400
|
||||
data = BuyInboundService.get_history_links(base_id, link_type)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 10. 库位建议
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/suggestions/locations', methods=['GET'])
|
||||
def get_location_suggestions():
|
||||
base_id = request.args.get('base_id', type=int)
|
||||
if not base_id:
|
||||
return jsonify({"code": 400, "msg": "base_id required"}), 400
|
||||
data = BuyInboundService.get_history_locations(base_id)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
@ -1,4 +1,4 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify # .material -> .base refactor checked
|
||||
from app.services.inbound.inbound_summary_service import InboundSummaryService
|
||||
|
||||
# 定义蓝图
|
||||
|
||||
@ -25,6 +25,26 @@ def search_base():
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 0.5 [新增] BOM 搜索接口
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_product_bp.route('/search-bom', methods=['GET'])
|
||||
def search_bom():
|
||||
"""
|
||||
供前端下拉框远程搜索使用 (搜索BOM)
|
||||
"""
|
||||
try:
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = ProductInboundService.search_bom_options(keyword)
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": data
|
||||
})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. 获取列表 (支持 status 多选筛选)
|
||||
@ -104,3 +124,25 @@ def get_history(id):
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. 系统用户建议
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_product_bp.route('/suggestions/users', methods=['GET'])
|
||||
def get_user_suggestions():
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = ProductInboundService.search_system_users(keyword)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. 获取筛选选项
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_product_bp.route('/options', methods=['GET'])
|
||||
def get_options():
|
||||
try:
|
||||
data = ProductInboundService.get_filter_options()
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
@ -29,6 +29,27 @@ def search_base():
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 0.5 [新增] BOM 搜索接口
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_semi_bp.route('/search-bom', methods=['GET'])
|
||||
def search_bom():
|
||||
"""
|
||||
供前端下拉框远程搜索使用 (搜索BOM)
|
||||
Query Param: keyword (编号或父件规格)
|
||||
"""
|
||||
try:
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = SemiInboundService.search_bom_options(keyword)
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": data
|
||||
})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. 获取半成品列表
|
||||
@ -118,3 +139,25 @@ def get_history(id):
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. 系统用户建议
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_semi_bp.route('/suggestions/users', methods=['GET'])
|
||||
def get_user_suggestions():
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = SemiInboundService.search_system_users(keyword)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. 获取筛选选项
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_semi_bp.route('/options', methods=['GET'])
|
||||
def get_options():
|
||||
try:
|
||||
data = SemiInboundService.get_filter_options()
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
182
inventory-backend/app/api/v1/inbound/service.py
Normal file
182
inventory-backend/app/api/v1/inbound/service.py
Normal file
@ -0,0 +1,182 @@
|
||||
# inventory-backend/app/api/v1/inbound/service.py
|
||||
from flask import request, jsonify, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
from . import inbound_bp
|
||||
from app.services.inbound.service_service import ServiceService
|
||||
from app.utils.decorators import role_required
|
||||
import traceback
|
||||
|
||||
|
||||
@inbound_bp.route('/service/search-base', methods=['GET'])
|
||||
@jwt_required()
|
||||
def search_base():
|
||||
"""搜索基础物料"""
|
||||
keyword = request.args.get('keyword', '')
|
||||
try:
|
||||
data = ServiceService.search_base_material(keyword)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': data
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'搜索基础物料失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@inbound_bp.route('/service', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_service_list():
|
||||
"""获取服务权益列表"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int)
|
||||
keyword = request.args.get('keyword', None)
|
||||
start_date = request.args.get('start_date', None)
|
||||
end_date = request.args.get('end_date', None)
|
||||
provider_name = request.args.get('provider_name', None)
|
||||
|
||||
try:
|
||||
result = ServiceService.get_service_list(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
keyword=keyword,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
provider_name=provider_name
|
||||
)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'获取服务列表失败: {str(e)}')
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@inbound_bp.route('/service', methods=['POST'])
|
||||
@jwt_required()
|
||||
@role_required('admin,manager')
|
||||
def create_service():
|
||||
"""创建服务权益"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
|
||||
|
||||
# 基础校验
|
||||
if not data.get('base_id'):
|
||||
return jsonify({'code': 400, 'msg': '请选择基础物料'}), 400
|
||||
if data.get('sale_price') is None:
|
||||
return jsonify({'code': 400, 'msg': '请输入售价'}), 400
|
||||
|
||||
try:
|
||||
service = ServiceService.create_service(data)
|
||||
return jsonify({
|
||||
'code': 201,
|
||||
'msg': '创建成功',
|
||||
'data': service.to_dict()
|
||||
}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'创建服务权益失败: {str(e)}')
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@inbound_bp.route('/service/<int:service_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_service(service_id):
|
||||
"""获取单个服务权益详情"""
|
||||
try:
|
||||
service = ServiceService.get_service(service_id)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': service.to_dict()
|
||||
})
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 404, 'msg': str(e)}), 404
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'获取服务权益详情失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@inbound_bp.route('/service/<int:service_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@role_required('admin,manager')
|
||||
def update_service(service_id):
|
||||
"""更新服务权益"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
|
||||
|
||||
# 允许更新的字段
|
||||
allowed_fields = {
|
||||
'sale_price', 'provider_name', 'description',
|
||||
'cost_price', 'contract_id', 'contact_person', 'valid_period'
|
||||
}
|
||||
filtered_data = {k: v for k, v in data.items() if k in allowed_fields}
|
||||
|
||||
if not filtered_data:
|
||||
return jsonify({'code': 400, 'msg': '无有效更新字段'}), 400
|
||||
|
||||
try:
|
||||
service = ServiceService.update_service(service_id, filtered_data)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '更新成功',
|
||||
'data': service.to_dict()
|
||||
})
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 404, 'msg': str(e)}), 404
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'更新服务权益失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@inbound_bp.route('/service/<int:service_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@role_required('admin,manager')
|
||||
def delete_service(service_id):
|
||||
"""删除服务权益"""
|
||||
try:
|
||||
ServiceService.delete_service(service_id)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '删除成功'
|
||||
})
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 404, 'msg': str(e)}), 404
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'删除服务权益失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@inbound_bp.route('/service/suggestions/providers', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_provider_suggestions():
|
||||
base_id = request.args.get('base_id', type=int)
|
||||
if not base_id:
|
||||
return jsonify({'code': 400, 'msg': 'base_id required'}), 400
|
||||
data = ServiceService.get_history_providers(base_id)
|
||||
return jsonify({'code': 200, 'msg': 'success', 'data': data})
|
||||
|
||||
|
||||
@inbound_bp.route('/service/suggestions/users', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_user_suggestions():
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = ServiceService.search_system_users(keyword)
|
||||
return jsonify({'code': 200, 'msg': 'success', 'data': data})
|
||||
|
||||
|
||||
@inbound_bp.route('/service/options', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_options():
|
||||
try:
|
||||
data = ServiceService.get_filter_options()
|
||||
return jsonify({'code': 200, 'msg': 'success', 'data': data})
|
||||
except Exception as e:
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
@ -1,4 +1,4 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask import Blueprint, jsonify, request # .material -> .base refactor checked
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from app.services.trans_service import TransService
|
||||
import traceback
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
# app/models/base.py
|
||||
from app.extensions import db
|
||||
import json
|
||||
|
||||
|
||||
class MaterialBase(db.Model):
|
||||
"""
|
||||
@ -10,8 +12,11 @@ class MaterialBase(db.Model):
|
||||
|
||||
# 1. 基础字段
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
# [修改] 所属公司,去除了 default='IRIS'
|
||||
company_name = db.Column(db.String(255), comment='所属公司')
|
||||
|
||||
name = db.Column(db.String(255), nullable=False, comment='名称')
|
||||
common_name = db.Column(db.String(255), comment='俗名') # ✅ 新增字段
|
||||
common_name = db.Column(db.String(255), comment='俗名')
|
||||
category = db.Column(db.String(100), comment='类别')
|
||||
material_type = db.Column(db.String(100), comment='类型')
|
||||
spec_model = db.Column(db.String(255), comment='规格型号')
|
||||
@ -20,7 +25,7 @@ class MaterialBase(db.Model):
|
||||
# 可见等级
|
||||
visibility_level = db.Column(db.Integer, default=0, comment='信息可见等级')
|
||||
|
||||
# 链接与图片
|
||||
# 链接与图片 (现在存储 JSON 字符串)
|
||||
manual_link = db.Column(db.Text, comment='通用说明书')
|
||||
product_image = db.Column(db.Text, comment='通用产品图')
|
||||
|
||||
@ -32,28 +37,45 @@ class MaterialBase(db.Model):
|
||||
# ============================================================
|
||||
|
||||
# 1. 关联采购库存 (StockBuy)
|
||||
stock_buys = db.relationship('StockBuy', back_populates='material', lazy='dynamic')
|
||||
stock_buys = db.relationship('StockBuy', back_populates='base', lazy='dynamic')
|
||||
|
||||
# 2. 关联半成品库存 (StockSemi)
|
||||
stock_semis = db.relationship('StockSemi', back_populates='material', lazy='dynamic')
|
||||
stock_semis = db.relationship('StockSemi', back_populates='base', lazy='dynamic')
|
||||
|
||||
# 3. 关联成品库存 (StockProduct)
|
||||
stock_products = db.relationship('StockProduct', back_populates='material', lazy='dynamic')
|
||||
stock_products = db.relationship('StockProduct', back_populates='base', lazy='dynamic')
|
||||
|
||||
# 4. 关联服务库存 (StockService)
|
||||
stock_services = db.relationship('StockService', back_populates='base', lazy='dynamic')
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
序列化方法
|
||||
"""
|
||||
|
||||
# 辅助解析函数:将数据库存储的 JSON 字符串转为 List
|
||||
def parse_list(json_str):
|
||||
if not json_str:
|
||||
return []
|
||||
try:
|
||||
# 兼容旧数据:如果不是 JSON 格式(比如是单个 URL),则包装成 list
|
||||
if not json_str.startswith('['):
|
||||
return [json_str]
|
||||
return json.loads(json_str)
|
||||
except:
|
||||
return []
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'companyName': self.company_name,
|
||||
'name': self.name,
|
||||
'commonName': self.common_name, # ✅ 序列化新增字段
|
||||
'commonName': self.common_name,
|
||||
'category': self.category,
|
||||
'type': self.material_type, # 前端字段映射
|
||||
'spec': self.spec_model, # 前端字段映射
|
||||
'type': self.material_type,
|
||||
'spec': self.spec_model,
|
||||
'unit': self.unit,
|
||||
'visibilityLevel': self.visibility_level,
|
||||
'generalManual': self.manual_link,
|
||||
'generalImage': self.product_image,
|
||||
'generalManual': parse_list(self.manual_link),
|
||||
'generalImage': parse_list(self.product_image),
|
||||
'isEnabled': 1 if self.is_enabled else 0,
|
||||
}
|
||||
28
inventory-backend/app/models/bom.py
Normal file
28
inventory-backend/app/models/bom.py
Normal file
@ -0,0 +1,28 @@
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class BomTable(db.Model):
|
||||
__tablename__ = 'bom_table'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
child_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
|
||||
bom_no = db.Column(db.String(100), nullable=False, comment='BOM编号')
|
||||
version = db.Column(db.String(50), nullable=False, default='V1.0', comment='版本')
|
||||
|
||||
dosage = db.Column(db.Numeric(19, 4), comment='个数')
|
||||
loss_rate = db.Column(db.Numeric(5, 2), comment='损耗率%', default=0, nullable=True)
|
||||
remark = db.Column(db.Text, comment='备注')
|
||||
|
||||
# ★ 新增:启用状态
|
||||
is_enabled = db.Column(db.Boolean, default=True, comment='是否启用')
|
||||
|
||||
# 约束: 保证同一版本下的父子对唯一,允许不同版本存在
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('bom_no', 'version', 'parent_id', 'child_id', name='uniq_bom_pair_in_version'),
|
||||
)
|
||||
|
||||
# relationships
|
||||
parent = db.relationship('MaterialBase', foreign_keys=[parent_id], backref='bom_parents')
|
||||
child = db.relationship('MaterialBase', foreign_keys=[child_id], backref='bom_children')
|
||||
@ -1,5 +1,9 @@
|
||||
# inventory-backend/app/models/inbound/buy.py
|
||||
from app.extensions import db
|
||||
import json
|
||||
# 显式导入 MaterialBase 以防 relationship 找不到引用
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
|
||||
class StockBuy(db.Model):
|
||||
"""
|
||||
@ -29,36 +33,36 @@ class StockBuy(db.Model):
|
||||
available_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||
|
||||
# 财务与商务
|
||||
unit_price = db.Column(db.Numeric(19, 4), default=0)
|
||||
total_price = db.Column(db.Numeric(19, 4), default=0)
|
||||
unit_price = db.Column(db.Numeric(19, 4), default=0) # 现意为:不含税单价
|
||||
total_price = db.Column(db.Numeric(19, 4), default=0) # 总价
|
||||
# [新增] 税率
|
||||
tax_rate = db.Column(db.Numeric(5, 2), default=0)
|
||||
|
||||
currency = db.Column(db.String(20), default='CNY')
|
||||
exchange_rate = db.Column(db.Numeric(15, 6), default=1.0)
|
||||
|
||||
supplier_name = db.Column(db.String(255))
|
||||
buyer_name = db.Column(db.String(100)) # 对应 SQL: buyer_name
|
||||
buyer_email = db.Column(db.String(100)) # 对应 SQL: buyer_email
|
||||
original_link = db.Column(db.Text) # 对应 SQL: original_link
|
||||
buyer_name = db.Column(db.String(100))
|
||||
buyer_email = db.Column(db.String(100))
|
||||
original_link = db.Column(db.Text)
|
||||
detail_link = db.Column(db.Text)
|
||||
|
||||
# 图片字段 (存储 JSON 字符串)
|
||||
arrival_photo = db.Column(db.Text)
|
||||
# [新增] 检测报告图片路径 (存储 JSON 字符串)
|
||||
inspection_report = db.Column(db.Text)
|
||||
|
||||
# [新增] 全局打印流水号 (用于跨表连续编号,对应 Sequence: global_print_seq)
|
||||
# 全局打印流水号
|
||||
global_print_id = db.Column(db.Integer)
|
||||
|
||||
# 关系定义
|
||||
# 注意:这里使用字符串 'MaterialBase' 引用,避免了直接 import 导致的潜在循环依赖
|
||||
material = db.relationship('MaterialBase', back_populates='stock_buys')
|
||||
base = db.relationship('MaterialBase', back_populates='stock_buys')
|
||||
|
||||
def to_dict(self):
|
||||
# 辅助解析函数:将数据库存储的 JSON 字符串转为 List
|
||||
# 辅助解析函数
|
||||
def parse_img_list(json_str):
|
||||
if not json_str:
|
||||
return []
|
||||
try:
|
||||
# 兼容旧数据:如果不是 JSON 格式(比如是单个 URL),则包装成 list
|
||||
if not json_str.startswith('['):
|
||||
return [json_str]
|
||||
return json.loads(json_str)
|
||||
@ -68,11 +72,14 @@ class StockBuy(db.Model):
|
||||
return {
|
||||
'id': self.id,
|
||||
'base_id': self.base_id,
|
||||
'material_name': self.material.name if self.material else '',
|
||||
'spec_model': self.material.spec_model if self.material else '',
|
||||
'category': self.material.category if self.material else '',
|
||||
'unit': self.material.unit if self.material else '',
|
||||
'material_type': self.material.material_type if self.material else '',
|
||||
|
||||
# [修改] 增加公司名称
|
||||
'company_name': self.base.company_name if self.base else '',
|
||||
'material_name': self.base.name if self.base else '',
|
||||
'spec_model': self.base.spec_model if self.base else '',
|
||||
'category': self.base.category if self.base else '',
|
||||
'unit': self.base.unit if self.base else '',
|
||||
'material_type': self.base.material_type if self.base else '',
|
||||
|
||||
'sku': self.sku,
|
||||
'inbound_date': self.in_date.strftime('%Y-%m-%d') if self.in_date else '',
|
||||
@ -92,6 +99,9 @@ class StockBuy(db.Model):
|
||||
|
||||
'unit_price': float(self.unit_price or 0),
|
||||
'total_price': float(self.total_price or 0),
|
||||
# [新增] 税率
|
||||
'tax_rate': float(self.tax_rate or 0),
|
||||
|
||||
'currency': self.currency,
|
||||
'exchange_rate': float(self.exchange_rate or 1.0),
|
||||
|
||||
@ -101,11 +111,9 @@ class StockBuy(db.Model):
|
||||
'source_link': self.original_link,
|
||||
'detail_link': self.detail_link,
|
||||
|
||||
# [修改] 解析为数组返回给前端
|
||||
'arrival_photo': parse_img_list(self.arrival_photo),
|
||||
'inspection_report': parse_img_list(self.inspection_report),
|
||||
|
||||
# [新增] 返回全局打印ID及其格式化字符串
|
||||
'global_print_id': self.global_print_id,
|
||||
'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else ""
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
# app/models/inbound/product.py
|
||||
from app.extensions import db
|
||||
import json
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
|
||||
class StockProduct(db.Model):
|
||||
@ -44,7 +45,7 @@ class StockProduct(db.Model):
|
||||
quality_report_link = db.Column(db.Text) # 质量报告
|
||||
inspection_report_link = db.Column(db.Text) # 检测报告(旧字段升级为JSON)
|
||||
|
||||
# [新增] 成品实拍图 (JSON 存储)
|
||||
# 成品实拍图 (JSON 存储)
|
||||
product_photo = db.Column(db.Text)
|
||||
|
||||
detail_link = db.Column(db.Text)
|
||||
@ -58,7 +59,7 @@ class StockProduct(db.Model):
|
||||
global_print_id = db.Column(db.Integer)
|
||||
|
||||
# 关系定义
|
||||
material = db.relationship('MaterialBase', back_populates='stock_products')
|
||||
base = db.relationship('MaterialBase', back_populates='stock_products')
|
||||
|
||||
def to_dict(self):
|
||||
raw_val = float(self.raw_material_cost or 0)
|
||||
@ -79,11 +80,14 @@ class StockProduct(db.Model):
|
||||
return {
|
||||
'id': self.id,
|
||||
'base_id': self.base_id,
|
||||
'material_name': self.material.name if self.material else '',
|
||||
'spec_model': self.material.spec_model if self.material else '',
|
||||
'category': self.material.category if self.material else '',
|
||||
'unit': self.material.unit if self.material else '',
|
||||
'material_type': self.material.material_type if self.material else '',
|
||||
|
||||
# [新增] 公司名称
|
||||
'company_name': self.base.company_name if self.base else '',
|
||||
'material_name': self.base.name if self.base else '',
|
||||
'spec_model': self.base.spec_model if self.base else '',
|
||||
'category': self.base.category if self.base else '',
|
||||
'unit': self.base.unit if self.base else '',
|
||||
'material_type': self.base.material_type if self.base else '',
|
||||
|
||||
'sku': self.sku,
|
||||
'inbound_date': self.production_date.strftime('%Y-%m-%d') if self.production_date else '',
|
||||
@ -114,7 +118,6 @@ class StockProduct(db.Model):
|
||||
|
||||
'quality_status': self.quality_status,
|
||||
|
||||
# [核心修改] 三个图片/链接字段全部解析为数组
|
||||
'product_photo': parse_img_list(self.product_photo),
|
||||
'quality_report_link': parse_img_list(self.quality_report_link),
|
||||
'inspection_report_link': parse_img_list(self.inspection_report_link),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# app/models/inbound/semi.py
|
||||
from app.extensions import db
|
||||
import json
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
|
||||
class StockSemi(db.Model):
|
||||
@ -43,20 +44,20 @@ class StockSemi(db.Model):
|
||||
|
||||
quality_status = db.Column(db.String(50))
|
||||
|
||||
# [修改] 质量报告 (存储 JSON 字符串: 图片列表 + 链接)
|
||||
# 质量报告 (存储 JSON 字符串: 图片列表 + 链接)
|
||||
quality_report_link = db.Column(db.Text)
|
||||
|
||||
# [新增] 到货图片 (存储 JSON 字符串)
|
||||
# 到货图片 (存储 JSON 字符串)
|
||||
arrival_photo = db.Column(db.Text)
|
||||
|
||||
detail_link = db.Column(db.Text)
|
||||
remark = db.Column(db.Text)
|
||||
|
||||
# [新增] 全局打印流水号
|
||||
# 全局打印流水号
|
||||
global_print_id = db.Column(db.Integer)
|
||||
|
||||
# 关系定义
|
||||
material = db.relationship('MaterialBase', back_populates='stock_semis')
|
||||
base = db.relationship('MaterialBase', back_populates='stock_semis')
|
||||
|
||||
def to_dict(self):
|
||||
raw_val = float(self.raw_material_cost or 0)
|
||||
@ -78,11 +79,14 @@ class StockSemi(db.Model):
|
||||
return {
|
||||
'id': self.id,
|
||||
'base_id': self.base_id,
|
||||
'material_name': self.material.name if self.material else '',
|
||||
'spec_model': self.material.spec_model if self.material else '',
|
||||
'category': self.material.category if self.material else '',
|
||||
'unit': self.material.unit if self.material else '',
|
||||
'material_type': self.material.material_type if self.material else '',
|
||||
|
||||
# [新增] 公司名称
|
||||
'company_name': self.base.company_name if self.base else '',
|
||||
'material_name': self.base.name if self.base else '',
|
||||
'spec_model': self.base.spec_model if self.base else '',
|
||||
'category': self.base.category if self.base else '',
|
||||
'unit': self.base.unit if self.base else '',
|
||||
'material_type': self.base.material_type if self.base else '',
|
||||
|
||||
'sku': self.sku,
|
||||
'inbound_date': self.production_date.strftime('%Y-%m-%d') if self.production_date else '',
|
||||
@ -114,7 +118,6 @@ class StockSemi(db.Model):
|
||||
|
||||
'quality_status': self.quality_status,
|
||||
|
||||
# [修改] 解析 JSON 字符串为数组返回给前端
|
||||
'quality_report_link': parse_img_list(self.quality_report_link),
|
||||
'arrival_photo': parse_img_list(self.arrival_photo),
|
||||
|
||||
|
||||
73
inventory-backend/app/models/inbound/service.py
Normal file
73
inventory-backend/app/models/inbound/service.py
Normal file
@ -0,0 +1,73 @@
|
||||
# inventory-backend/app/models/inbound/service.py
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class StockService(db.Model):
|
||||
"""
|
||||
服务权益库存表
|
||||
对应数据库表: stock_service
|
||||
说明:服务权益通常为虚拟资产,不进行具体的库存数量(actual_quantity)管理
|
||||
"""
|
||||
__tablename__ = 'stock_service'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# 外键关联基础物料
|
||||
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
|
||||
# 核心业务字段
|
||||
sku = db.Column(db.String(100), unique=True, nullable=False)
|
||||
|
||||
# 扩展字段 (对应您的数据库建表脚本)
|
||||
service_category = db.Column(db.String(100), comment='服务类别')
|
||||
provider_name = db.Column(db.String(255), nullable=False, default='')
|
||||
contract_id = db.Column(db.String(100), comment='合同号')
|
||||
contact_person = db.Column(db.String(100), comment='联系人')
|
||||
|
||||
# 价格相关
|
||||
cost_price = db.Column(db.Numeric(19, 4), default=0)
|
||||
sale_price = db.Column(db.Numeric(19, 4), nullable=False, default=0)
|
||||
|
||||
# 描述与状态
|
||||
description = db.Column(db.Text, default='')
|
||||
valid_period = db.Column(db.String(100), comment='有效期')
|
||||
status = db.Column(db.String(20), default='active')
|
||||
|
||||
# 时间与系统字段
|
||||
created_at = db.Column(db.DateTime, default=datetime.now, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
is_deleted = db.Column(db.Boolean, default=False)
|
||||
|
||||
# ==========================================================================
|
||||
# 关联关系设置
|
||||
# MaterialBase 中定义了 back_populates='stock_services'
|
||||
# 因此这里必须定义 base 属性指向 'stock_services'
|
||||
# ==========================================================================
|
||||
base = db.relationship('MaterialBase', back_populates='stock_services')
|
||||
|
||||
def to_dict(self):
|
||||
"""序列化为字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'base_id': self.base_id,
|
||||
'sku': self.sku,
|
||||
'service_category': self.service_category,
|
||||
'provider_name': self.provider_name,
|
||||
'contract_id': self.contract_id,
|
||||
'contact_person': self.contact_person,
|
||||
'sale_price': float(self.sale_price) if self.sale_price is not None else 0,
|
||||
'cost_price': float(self.cost_price) if self.cost_price is not None else 0,
|
||||
'description': self.description,
|
||||
'valid_period': self.valid_period,
|
||||
'status': self.status,
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
|
||||
|
||||
# 关联的基础信息 (Flattened)
|
||||
'material_name': self.base.name if self.base else None,
|
||||
'spec_model': self.base.spec_model if self.base else None,
|
||||
'unit': self.base.unit if self.base else None,
|
||||
'category': self.base.category if self.base else None,
|
||||
'material_type': self.base.material_type if self.base else None,
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
from app.extensions import db
|
||||
from app.extensions import db # .material -> .base refactor checked
|
||||
from datetime import datetime
|
||||
|
||||
class StocktakeDraft(db.Model):
|
||||
|
||||
@ -3,18 +3,24 @@ from app.extensions import db
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SysUser(db.Model):
|
||||
"""
|
||||
系统用户表
|
||||
对应数据库: sys_user
|
||||
username 字段存储格式约定: "真实姓名/登录账号" (例如: 张三/zhangsan)
|
||||
"""
|
||||
__tablename__ = 'sys_user'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(100), nullable=False)
|
||||
# 注意:如果允许邮箱为空,建议去掉 unique=True 或者在数据库层面处理空字符串
|
||||
username = db.Column(db.String(100), nullable=False) # 存储 "张三/zhangsan"
|
||||
email = db.Column(db.String(100), unique=True)
|
||||
department = db.Column(db.String(100))
|
||||
role = db.Column(db.String(50))
|
||||
status = db.Column(db.String(20), default='active')
|
||||
password_hash = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.now) # 新增创建时间
|
||||
|
||||
# created_at 已在数据库脚本中移除,此处不再定义
|
||||
|
||||
def set_password(self, password):
|
||||
"""生成加密密码"""
|
||||
@ -25,17 +31,37 @@ class SysUser(db.Model):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def to_dict(self):
|
||||
"""序列化为字典,供接口返回使用"""
|
||||
"""
|
||||
序列化为字典
|
||||
数据库存的是 '张三/zhangsan'
|
||||
前端需要的是 '张三(zhangsan)'
|
||||
"""
|
||||
raw_name = self.username
|
||||
display_name = raw_name
|
||||
account_id = raw_name
|
||||
|
||||
# 解析存储格式: Name/ID
|
||||
if '/' in raw_name:
|
||||
parts = raw_name.split('/')
|
||||
real_name = parts[0]
|
||||
acc_id = parts[1]
|
||||
# 格式化为前端展示格式: 张三(zhangsan)
|
||||
display_name = f"{real_name}({acc_id})"
|
||||
# 单独提取账号ID (如果前端需要单独用)
|
||||
account_id = acc_id
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'username': self.username,
|
||||
'username': display_name, # 列表显示: 张三(zhangsan)
|
||||
'raw_username': self.username, # 原始数据
|
||||
'account_id': account_id, # 纯账号ID: zhangsan
|
||||
'email': self.email,
|
||||
'department': self.department,
|
||||
'role': self.role,
|
||||
'status': self.status,
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else ''
|
||||
'status': self.status
|
||||
}
|
||||
|
||||
|
||||
class SysLog(db.Model):
|
||||
"""
|
||||
系统操作日志表
|
||||
|
||||
@ -7,27 +7,20 @@ class TransBorrow(db.Model):
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
borrow_no = db.Column(db.String(100))
|
||||
|
||||
sku = db.Column(db.String(100))
|
||||
source_table = db.Column(db.String(50))
|
||||
stock_id = db.Column(db.Integer)
|
||||
barcode = db.Column(db.String(100))
|
||||
|
||||
quantity = db.Column(db.Numeric(19, 4))
|
||||
|
||||
# 借出信息
|
||||
borrower_name = db.Column(db.String(100))
|
||||
borrow_time = db.Column(db.DateTime, default=datetime.now)
|
||||
borrow_signature = db.Column(db.Text) # 借用人签字
|
||||
borrow_signature = db.Column(db.Text)
|
||||
expected_return_time = db.Column(db.DateTime)
|
||||
|
||||
# 归还信息
|
||||
is_returned = db.Column(db.Boolean, default=False)
|
||||
return_time = db.Column(db.DateTime)
|
||||
return_operator = db.Column(db.String(100)) # 库管
|
||||
return_signature = db.Column(db.Text) # 库管签字
|
||||
return_location = db.Column(db.String(100)) # 归还库位
|
||||
|
||||
return_operator = db.Column(db.String(100))
|
||||
return_signature = db.Column(db.Text)
|
||||
return_location = db.Column(db.String(100))
|
||||
status = db.Column(db.String(20), default='borrowed')
|
||||
remark = db.Column(db.Text)
|
||||
|
||||
@ -36,18 +29,91 @@ class TransBorrow(db.Model):
|
||||
'id': self.id,
|
||||
'borrow_no': self.borrow_no,
|
||||
'sku': self.sku,
|
||||
'source_table': self.source_table,
|
||||
'stock_id': self.stock_id,
|
||||
'barcode': self.barcode,
|
||||
'quantity': float(self.quantity) if self.quantity else 0,
|
||||
'quantity': float(self.quantity) if self.quantity is not None else None,
|
||||
'borrower_name': self.borrower_name,
|
||||
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else '',
|
||||
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else None,
|
||||
'borrow_signature': self.borrow_signature,
|
||||
'expected_return_time': self.expected_return_time.strftime(
|
||||
'%Y-%m-%d %H:%M') if self.expected_return_time else '',
|
||||
'expected_return_time': self.expected_return_time.strftime('%Y-%m-%d %H:%M') if self.expected_return_time else None,
|
||||
'is_returned': self.is_returned,
|
||||
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M') if self.return_time else '',
|
||||
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M') if self.return_time else None,
|
||||
'return_operator': self.return_operator,
|
||||
'return_signature': self.return_signature,
|
||||
'return_location': self.return_location,
|
||||
'status': self.status,
|
||||
'remark': self.remark
|
||||
'remark': self.remark,
|
||||
}
|
||||
|
||||
|
||||
class TransRepair(db.Model):
|
||||
__tablename__ = 'trans_repair'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
sku = db.Column(db.String(100))
|
||||
source_table = db.Column(db.String(50))
|
||||
stock_id = db.Column(db.Integer)
|
||||
arrival_date = db.Column(db.Date)
|
||||
expected_repair_time = db.Column(db.String(100))
|
||||
shipping_date = db.Column(db.Date)
|
||||
is_self_made = db.Column(db.Boolean, default=False)
|
||||
related_product_id = db.Column(db.Integer)
|
||||
related_contract_id = db.Column(db.String(100))
|
||||
repair_manager = db.Column(db.String(100))
|
||||
fault_description = db.Column(db.Text)
|
||||
repair_result = db.Column(db.Text)
|
||||
cost_price = db.Column(db.Numeric(19, 4))
|
||||
sale_price = db.Column(db.Numeric(19, 4))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'sku': self.sku,
|
||||
'source_table': self.source_table,
|
||||
'stock_id': self.stock_id,
|
||||
'arrival_date': self.arrival_date.strftime('%Y-%m-%d') if self.arrival_date else None,
|
||||
'expected_repair_time': self.expected_repair_time,
|
||||
'shipping_date': self.shipping_date.strftime('%Y-%m-%d') if self.shipping_date else None,
|
||||
'is_self_made': self.is_self_made,
|
||||
'related_product_id': self.related_product_id,
|
||||
'related_contract_id': self.related_contract_id,
|
||||
'repair_manager': self.repair_manager,
|
||||
'fault_description': self.fault_description,
|
||||
'repair_result': self.repair_result,
|
||||
'cost_price': float(self.cost_price) if self.cost_price is not None else None,
|
||||
'sale_price': float(self.sale_price) if self.sale_price is not None else None,
|
||||
}
|
||||
|
||||
|
||||
class TransScrap(db.Model):
|
||||
__tablename__ = 'trans_scrap'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
sku = db.Column(db.String(100))
|
||||
source_table = db.Column(db.String(50))
|
||||
stock_id = db.Column(db.Integer)
|
||||
quantity = db.Column(db.Numeric(19, 4))
|
||||
reason = db.Column(db.Text)
|
||||
operator_name = db.Column(db.String(100))
|
||||
operation_time = db.Column(db.DateTime, default=datetime.now)
|
||||
approver_name = db.Column(db.String(100))
|
||||
approval_status = db.Column(db.String(20), default='pending')
|
||||
cost_at_scrap = db.Column(db.Numeric(19, 4))
|
||||
total_loss = db.Column(db.Numeric(19, 4))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'sku': self.sku,
|
||||
'source_table': self.source_table,
|
||||
'stock_id': self.stock_id,
|
||||
'quantity': float(self.quantity) if self.quantity is not None else None,
|
||||
'reason': self.reason,
|
||||
'operator_name': self.operator_name,
|
||||
'operation_time': self.operation_time.strftime('%Y-%m-%d %H:%M:%S') if self.operation_time else None,
|
||||
'approver_name': self.approver_name,
|
||||
'approval_status': self.approval_status,
|
||||
'cost_at_scrap': float(self.cost_at_scrap) if self.cost_at_scrap is not None else None,
|
||||
'total_loss': float(self.total_loss) if self.total_loss is not None else None,
|
||||
}
|
||||
@ -38,5 +38,28 @@ class StockBuySchema(Schema):
|
||||
# 这里暂时不强制抛出错误,交给 Service 层处理 "SKU不存在且无名字" 的情况
|
||||
|
||||
|
||||
class StockServiceSchema(Schema):
|
||||
# 只用于输出的字段
|
||||
id = fields.Int(dump_only=True)
|
||||
sku = fields.Str(dump_only=True)
|
||||
created_at = fields.DateTime(format='%Y-%m-%d %H:%M:%S', dump_only=True)
|
||||
updated_at = fields.DateTime(format='%Y-%m-%d %H:%M:%S', dump_only=True)
|
||||
material_name = fields.Str(dump_only=True)
|
||||
spec_model = fields.Str(dump_only=True)
|
||||
unit = fields.Str(dump_only=True)
|
||||
|
||||
# 输入字段
|
||||
base_id = fields.Int(required=True, error_messages={"required": "必须选择基础物料"})
|
||||
sale_price = fields.Float(required=True, validate=validate.Range(min=0, error="售价不能为负数"))
|
||||
provider_name = fields.Str(required=True, error_messages={"required": "服务商名称不能为空"})
|
||||
description = fields.Str(missing='')
|
||||
|
||||
@validates_schema
|
||||
def validate_base_id(self, data, **kwargs):
|
||||
# 可以在这里添加对 base_id 是否存在的检查,但更建议在 Service 层进行
|
||||
pass
|
||||
|
||||
|
||||
# 实例化 Schema
|
||||
stock_buy_schema = StockBuySchema()
|
||||
stock_service_schema = StockServiceSchema()
|
||||
|
||||
@ -3,6 +3,7 @@ from app.models.system import SysUser
|
||||
from app.extensions import db
|
||||
from flask_jwt_extended import create_access_token
|
||||
from app.utils.constants import UserRole
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class AuthService:
|
||||
@ -12,29 +13,35 @@ class AuthService:
|
||||
|
||||
@staticmethod
|
||||
def login(data):
|
||||
username = data.get('username')
|
||||
# 用户登录时输入的只是账号ID (例如: zhangsan)
|
||||
login_input = data.get('username', '').strip()
|
||||
password = data.get('password')
|
||||
|
||||
user_role = None
|
||||
user_id = None
|
||||
user_info = {}
|
||||
|
||||
# 1. 优先检查硬编码的超级管理员
|
||||
if username == AuthService.SUPER_ADMIN_USER:
|
||||
# 1. 优先检查硬编码的超级管理员 (IRIS)
|
||||
if login_input == AuthService.SUPER_ADMIN_USER:
|
||||
if password == AuthService.SUPER_ADMIN_PASS:
|
||||
user_role = UserRole.SUPER_ADMIN
|
||||
user_id = 0 # 虚拟ID
|
||||
user_id = 0
|
||||
user_info = {
|
||||
'username': username,
|
||||
'username': '超级管理员(IRIS)',
|
||||
'account_id': 'IRIS',
|
||||
'role': user_role,
|
||||
'department': 'System'
|
||||
'department': 'System',
|
||||
'status': 'active'
|
||||
}
|
||||
else:
|
||||
raise ValueError("密码错误")
|
||||
|
||||
# 2. 如果不是 IRIS,检查数据库用户
|
||||
# 2. 检查数据库用户
|
||||
# 数据库存的是 "张三/zhangsan"
|
||||
# 登录匹配逻辑: 查找以 "/login_input" 结尾的记录
|
||||
else:
|
||||
user = SysUser.query.filter_by(username=username).first()
|
||||
# 使用 like 进行后缀匹配: '%/zhangsan'
|
||||
user = SysUser.query.filter(SysUser.username.like(f"%/{login_input}")).first()
|
||||
|
||||
if not user:
|
||||
raise ValueError("用户不存在")
|
||||
@ -50,9 +57,17 @@ class AuthService:
|
||||
user_info = user.to_dict()
|
||||
|
||||
# 3. 生成 Token
|
||||
# Token 中 identity 存数据库ID,claims 存登录账号ID
|
||||
account_id = user_info.get('account_id', login_input)
|
||||
|
||||
access_token = create_access_token(
|
||||
identity=user_id,
|
||||
additional_claims={'role': user_role, 'username': username}
|
||||
additional_claims={
|
||||
'role': user_role,
|
||||
'username': account_id, # 存纯账号ID
|
||||
'display_name': user_info.get('username') # 存显示名
|
||||
},
|
||||
expires_delta=timedelta(days=7)
|
||||
)
|
||||
|
||||
return {
|
||||
@ -63,25 +78,59 @@ class AuthService:
|
||||
@staticmethod
|
||||
def create_user(data, operator_role):
|
||||
"""
|
||||
创建新用户 (仅限管理员使用)
|
||||
创建新用户
|
||||
data 包含: cn_name(张三), username(zhangsan), ...
|
||||
"""
|
||||
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
|
||||
raise Exception("权限不足:只有超级管理员或主管可以创建新用户")
|
||||
|
||||
if SysUser.query.filter_by(username=data.get('username')).first():
|
||||
raise Exception("用户名已存在")
|
||||
cn_name = data.get('cn_name')
|
||||
pinyin_base = data.get('username') # 前端传来的基础拼音,如 zhangsan
|
||||
|
||||
if not cn_name or not pinyin_base:
|
||||
raise Exception("姓名和账号不能为空")
|
||||
|
||||
role = data.get('role')
|
||||
valid_roles = [v for k, v in UserRole.__dict__.items() if not k.startswith('__')]
|
||||
|
||||
# 验证角色合法性
|
||||
valid_roles = [
|
||||
v for k, v in UserRole.__dict__.items()
|
||||
if not k.startswith('__') and isinstance(v, str)
|
||||
]
|
||||
|
||||
if role not in valid_roles:
|
||||
raise Exception(f"角色无效,可选角色: {valid_roles}")
|
||||
raise Exception(f"角色无效")
|
||||
|
||||
if operator_role == UserRole.SUPERVISOR and role == UserRole.SUPER_ADMIN:
|
||||
raise Exception("权限不足:主管无法创建超级管理员")
|
||||
|
||||
email = data.get('email', '')
|
||||
if email and SysUser.query.filter_by(email=email).first():
|
||||
raise Exception("邮箱已被使用")
|
||||
|
||||
# === 核心逻辑: 自动处理账号重复 (zhangsan -> zhangsan1 -> zhangsan2) ===
|
||||
final_account_id = pinyin_base
|
||||
counter = 1 # 如果重复,从1开始累加
|
||||
|
||||
while True:
|
||||
# 检查数据库是否存在以 "/final_account_id" 结尾的记录
|
||||
existing = SysUser.query.filter(
|
||||
(SysUser.username.like(f"%/{final_account_id}")) |
|
||||
(SysUser.username == final_account_id)
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
break # 找到了可用的ID,跳出循环
|
||||
|
||||
# 如果存在,使用 base + counter
|
||||
final_account_id = f"{pinyin_base}{counter}"
|
||||
counter += 1
|
||||
|
||||
# 拼接最终存储格式: 张三/zhangsan1
|
||||
full_username_storage = f"{cn_name}/{final_account_id}"
|
||||
|
||||
new_user = SysUser(
|
||||
username=data.get('username'),
|
||||
username=full_username_storage, # 存组合串
|
||||
email=email,
|
||||
department=data.get('department', ''),
|
||||
role=role,
|
||||
@ -92,15 +141,17 @@ class AuthService:
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
# 返回时,最好把生成的ID告诉前端
|
||||
return new_user.to_dict()
|
||||
|
||||
@staticmethod
|
||||
def update_user(user_id, data, operator_role):
|
||||
"""
|
||||
[新增] 更新用户信息
|
||||
更新用户信息
|
||||
注意: 这里暂时不允许修改用户名/账号,因为涉及 split 逻辑较复杂,且通常账号不开通后不改
|
||||
"""
|
||||
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
|
||||
raise Exception("权限不足:只有超级管理员或主管可以修改用户信息")
|
||||
raise Exception("权限不足")
|
||||
|
||||
user = SysUser.query.get(user_id)
|
||||
if not user:
|
||||
@ -108,16 +159,21 @@ class AuthService:
|
||||
|
||||
# 1. 更新基本信息
|
||||
if 'role' in data:
|
||||
valid_roles = [v for k, v in UserRole.__dict__.items() if not k.startswith('__')]
|
||||
if data['role'] not in valid_roles:
|
||||
valid_roles = [
|
||||
v for k, v in UserRole.__dict__.items()
|
||||
if not k.startswith('__') and isinstance(v, str)
|
||||
]
|
||||
new_role = data['role']
|
||||
if new_role not in valid_roles:
|
||||
raise Exception(f"角色无效")
|
||||
user.role = data['role']
|
||||
if operator_role == UserRole.SUPERVISOR and new_role == UserRole.SUPER_ADMIN:
|
||||
raise Exception("权限不足")
|
||||
user.role = new_role
|
||||
|
||||
if 'department' in data:
|
||||
user.department = data['department']
|
||||
|
||||
if 'email' in data:
|
||||
# 如果修改了邮箱,且新邮箱已被其他人使用
|
||||
email = data['email']
|
||||
if email and email != user.email:
|
||||
existing = SysUser.query.filter_by(email=email).first()
|
||||
@ -125,7 +181,9 @@ class AuthService:
|
||||
raise Exception("该邮箱已被其他用户使用")
|
||||
user.email = email
|
||||
|
||||
# 2. 如果提供了密码,则重置密码;否则保持原密码
|
||||
if 'status' in data:
|
||||
user.status = data['status']
|
||||
|
||||
new_password = data.get('password')
|
||||
if new_password and str(new_password).strip():
|
||||
if len(new_password) < 6:
|
||||
@ -144,8 +202,8 @@ class AuthService:
|
||||
@staticmethod
|
||||
def delete_user(user_id, operator_role):
|
||||
"""删除用户"""
|
||||
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
|
||||
raise Exception("权限不足")
|
||||
if operator_role != UserRole.SUPER_ADMIN:
|
||||
raise Exception("权限不足:只有超级管理员可以删除用户")
|
||||
|
||||
user = SysUser.query.get(user_id)
|
||||
if not user:
|
||||
|
||||
246
inventory-backend/app/services/bom_service.py
Normal file
246
inventory-backend/app/services/bom_service.py
Normal file
@ -0,0 +1,246 @@
|
||||
from app.extensions import db
|
||||
from app.models.bom import BomTable
|
||||
from app.models.base import MaterialBase
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from sqlalchemy import func, distinct, or_, case
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class BomService:
|
||||
# ====================== 新版 BOM 逻辑(基于 bom_no) ======================
|
||||
|
||||
@staticmethod
|
||||
def generate_bom_no():
|
||||
"""生成唯一的 BOM 编号 (作为默认备选)"""
|
||||
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
unique = str(uuid.uuid4())[:8]
|
||||
return f'BOM-{timestamp}-{unique}'
|
||||
|
||||
@staticmethod
|
||||
def get_bom_list(keyword=None, active_only=False):
|
||||
"""
|
||||
获取所有 BOM 配方(按 bom_no + version 分组)
|
||||
支持模糊搜索:BOM编号、父件名称/规格、子件名称/规格
|
||||
"""
|
||||
# 1. 关键词过滤:先找出符合条件的 (bom_no, version) 组合
|
||||
query_base = db.session.query(
|
||||
BomTable.bom_no,
|
||||
BomTable.version
|
||||
).join(
|
||||
MaterialBase, BomTable.parent_id == MaterialBase.id
|
||||
)
|
||||
|
||||
# ★ 过滤禁用状态
|
||||
if active_only:
|
||||
query_base = query_base.filter(BomTable.is_enabled == True)
|
||||
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
# 关联子件表以支持子件搜索
|
||||
child_alias = db.aliased(MaterialBase)
|
||||
query_base = query_base.outerjoin(
|
||||
child_alias, BomTable.child_id == child_alias.id
|
||||
).filter(
|
||||
or_(
|
||||
BomTable.bom_no.ilike(kw),
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
child_alias.name.ilike(kw),
|
||||
child_alias.spec_model.ilike(kw)
|
||||
)
|
||||
)
|
||||
|
||||
# 获取符合条件的唯一组合
|
||||
target_pairs = query_base.distinct().all()
|
||||
|
||||
if not target_pairs:
|
||||
return []
|
||||
|
||||
# 2. 聚合查询详情
|
||||
results = []
|
||||
for bom_no, version in target_pairs:
|
||||
summary = db.session.query(
|
||||
BomTable.parent_id,
|
||||
MaterialBase.name.label('parent_name'),
|
||||
MaterialBase.spec_model.label('parent_spec'),
|
||||
BomTable.is_enabled,
|
||||
func.count(BomTable.child_id).label('child_count')
|
||||
).join(
|
||||
MaterialBase, BomTable.parent_id == MaterialBase.id
|
||||
).filter(
|
||||
BomTable.bom_no == bom_no,
|
||||
BomTable.version == version
|
||||
).group_by(
|
||||
BomTable.parent_id, MaterialBase.name, MaterialBase.spec_model, BomTable.is_enabled
|
||||
).first()
|
||||
|
||||
if summary:
|
||||
results.append({
|
||||
'bom_no': bom_no,
|
||||
'version': version,
|
||||
'parent_id': summary.parent_id,
|
||||
'parent_name': summary.parent_name,
|
||||
'parent_spec': summary.parent_spec or '',
|
||||
'is_enabled': summary.is_enabled,
|
||||
'child_count': summary.child_count
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: (x['bom_no'], x['version']), reverse=True)
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def get_bom_detail(bom_no, version=None):
|
||||
"""
|
||||
根据 bom_no (和 version) 获取配方详情
|
||||
"""
|
||||
query = db.session.query(
|
||||
BomTable,
|
||||
MaterialBase.name.label('child_name'),
|
||||
MaterialBase.spec_model.label('child_spec')
|
||||
).join(
|
||||
MaterialBase, BomTable.child_id == MaterialBase.id
|
||||
).filter(
|
||||
BomTable.bom_no == bom_no
|
||||
)
|
||||
|
||||
if version:
|
||||
query = query.filter(BomTable.version == version)
|
||||
else:
|
||||
latest_ver = db.session.query(BomTable.version).filter_by(bom_no=bom_no) \
|
||||
.order_by(BomTable.version.desc()).limit(1).scalar()
|
||||
if not latest_ver:
|
||||
return None
|
||||
query = query.filter(BomTable.version == latest_ver)
|
||||
|
||||
rows = query.all()
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
first = rows[0]
|
||||
parent_id = first.BomTable.parent_id
|
||||
parent_material = MaterialBase.query.get(parent_id)
|
||||
|
||||
children = []
|
||||
for bom, child_name, child_spec in rows:
|
||||
children.append({
|
||||
'child_id': bom.child_id,
|
||||
'child_name': child_name,
|
||||
'child_spec': child_spec or '',
|
||||
'dosage': float(bom.dosage) if bom.dosage else 0.0,
|
||||
'remark': bom.remark or ''
|
||||
})
|
||||
|
||||
return {
|
||||
'bom_no': bom_no,
|
||||
'version': first.BomTable.version,
|
||||
'parent_id': parent_id,
|
||||
'parent_name': parent_material.name if parent_material else '',
|
||||
'parent_spec': parent_material.spec_model if parent_material else '',
|
||||
'is_enabled': first.BomTable.is_enabled,
|
||||
'children': children
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def save_bom(data):
|
||||
"""保存 BOM (支持多版本)"""
|
||||
bom_no = data.get('bom_no')
|
||||
version = data.get('version', 'V1.0')
|
||||
parent_id = data['parent_id']
|
||||
children = data['children']
|
||||
is_enabled = data.get('is_enabled', True)
|
||||
|
||||
if not bom_no:
|
||||
raise ValueError('BOM编号不能为空')
|
||||
|
||||
for child in children:
|
||||
if child['child_id'] == parent_id:
|
||||
raise ValueError('父件与子件不能是同一物料')
|
||||
|
||||
# 仅删除当前版本的旧记录
|
||||
BomTable.query.filter_by(bom_no=bom_no, version=version).delete()
|
||||
|
||||
for child in children:
|
||||
bom = BomTable(
|
||||
bom_no=bom_no,
|
||||
version=version,
|
||||
parent_id=parent_id,
|
||||
child_id=child['child_id'],
|
||||
dosage=child.get('dosage', 0),
|
||||
remark=child.get('remark', ''),
|
||||
is_enabled=is_enabled
|
||||
)
|
||||
db.session.add(bom)
|
||||
|
||||
db.session.commit()
|
||||
return bom_no
|
||||
|
||||
@staticmethod
|
||||
def get_bom_with_stock_by_bom_no(bom_no):
|
||||
"""
|
||||
根据 bom_no 获取配方详情,并计算:
|
||||
1. 总可用库存
|
||||
2. 最大可生产套数
|
||||
3. ★ 聚合库位信息 (warehouse_locations)
|
||||
"""
|
||||
detail = BomService.get_bom_detail(bom_no)
|
||||
if not detail:
|
||||
return None
|
||||
|
||||
for child in detail['children']:
|
||||
# 1. 查询该子件的总库存
|
||||
stock_qty = db.session.query(
|
||||
func.coalesce(func.sum(StockBuy.available_quantity), 0)
|
||||
).filter(
|
||||
StockBuy.base_id == child['child_id']
|
||||
).scalar() or 0
|
||||
|
||||
# 2. ★ 查询该子件涉及的所有库位,并去重拼接 (PostgreSQL 使用 string_agg)
|
||||
# 注意:这里假设主要是 stock_buy 表,如果是成品或半成品也需要做类似 Union 查询
|
||||
# 为简化,这里演示只查 stock_buy 的库位
|
||||
locations = db.session.query(
|
||||
# 去除空值和重复值
|
||||
func.string_agg(distinct(StockBuy.warehouse_location), ', ')
|
||||
).filter(
|
||||
StockBuy.base_id == child['child_id'],
|
||||
StockBuy.available_quantity > 0, # 只看有货的库位
|
||||
StockBuy.warehouse_location != None,
|
||||
StockBuy.warehouse_location != ''
|
||||
).scalar()
|
||||
|
||||
child['current_stock'] = float(stock_qty)
|
||||
child['warehouse_location'] = locations or '' # 返回给前端
|
||||
|
||||
dosage = child['dosage']
|
||||
child['max_producible'] = int(stock_qty // dosage) if dosage > 0 else 0
|
||||
|
||||
return detail
|
||||
|
||||
# ====================== 兼容旧接口 ======================
|
||||
@staticmethod
|
||||
def get_bom_no_by_parent(parent_id):
|
||||
row = BomTable.query.filter_by(parent_id=parent_id).order_by(BomTable.version.desc()).first()
|
||||
return row.bom_no if row else None
|
||||
|
||||
@staticmethod
|
||||
def create_or_update_bom(parent_id, child_list, bom_no=None, version='V1.0'):
|
||||
if not bom_no:
|
||||
existing = BomTable.query.filter_by(parent_id=parent_id).first()
|
||||
bom_no = existing.bom_no if existing else BomService.generate_bom_no()
|
||||
|
||||
BomTable.query.filter_by(bom_no=bom_no, version=version).delete()
|
||||
for item in child_list:
|
||||
bom = BomTable(
|
||||
bom_no=bom_no, version=version, parent_id=parent_id,
|
||||
child_id=item['child_id'], dosage=item.get('dosage', 0), remark=item.get('remark', '')
|
||||
)
|
||||
db.session.add(bom)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_bom_with_stock(parent_id):
|
||||
bom_no = BomService.get_bom_no_by_parent(parent_id)
|
||||
if not bom_no: return []
|
||||
detail = BomService.get_bom_with_stock_by_bom_no(bom_no)
|
||||
return detail['children'] if detail else []
|
||||
@ -4,8 +4,16 @@ from app.extensions import db
|
||||
from app.models.base import MaterialBase
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from sqlalchemy import or_
|
||||
from app.models.inbound.product import StockProduct
|
||||
# from app.models.inbound.service import StockService
|
||||
from sqlalchemy import or_, and_
|
||||
import traceback
|
||||
import json
|
||||
import io
|
||||
import datetime
|
||||
# 需要 pip install openpyxl
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
||||
|
||||
|
||||
class MaterialBaseService:
|
||||
@ -24,22 +32,44 @@ class MaterialBaseService:
|
||||
if not keyword:
|
||||
return []
|
||||
|
||||
# ✅ 搜索范围增加 common_name (俗名)
|
||||
query = MaterialBase.query.filter(
|
||||
MaterialBase.is_enabled == True,
|
||||
or_(
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.common_name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%')
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%'),
|
||||
# 支持搜索公司名
|
||||
MaterialBase.company_name.ilike(f'%{keyword}%')
|
||||
)
|
||||
).limit(20)
|
||||
)
|
||||
|
||||
# [修改1] 增加返回数量限制
|
||||
# 原为 limit(20),现改为 1000,确保前端能获取所有(或足够多)的数据
|
||||
query = query.limit(1000)
|
||||
|
||||
# 获取查询结果对象列表
|
||||
db_items = query.all()
|
||||
|
||||
# [修改2] 规格型号排序逻辑
|
||||
# 要求:只考虑 '/' 前面的内容进行排序
|
||||
# 使用 Python 的 sort 方法,提取 spec_model 中 '/' 前的部分
|
||||
def get_sort_key(item):
|
||||
if not item.spec_model:
|
||||
return ""
|
||||
# 如果包含 '/',取前半部分;否则取整个字符串
|
||||
parts = item.spec_model.split('/')
|
||||
return parts[0] if len(parts) > 0 else item.spec_model
|
||||
|
||||
# 执行排序
|
||||
db_items.sort(key=get_sort_key)
|
||||
|
||||
results = []
|
||||
for item in query.all():
|
||||
for item in db_items:
|
||||
results.append({
|
||||
'id': item.id,
|
||||
'id': item.id, # 必须保留ID供前端逻辑使用,视觉上的隐藏请在前端处理
|
||||
'companyName': item.company_name,
|
||||
'name': item.name,
|
||||
'commonName': item.common_name, # ✅ 返回俗名
|
||||
'commonName': item.common_name,
|
||||
'spec': item.spec_model,
|
||||
'category': item.category,
|
||||
'unit': item.unit,
|
||||
@ -51,6 +81,33 @@ class MaterialBaseService:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _get_stock_counts(stock_query):
|
||||
"""
|
||||
辅助函数:安全计算库存列表的总数量
|
||||
"""
|
||||
total_inv = 0
|
||||
total_avail = 0
|
||||
|
||||
try:
|
||||
items = list(stock_query) # 触发查询
|
||||
except:
|
||||
items = []
|
||||
|
||||
for x in items:
|
||||
# 1. 获取库存数 (兼容不同字段名)
|
||||
q = getattr(x, 'stock_quantity', getattr(x, 'in_quantity', 0)) # 优先取库存,其次入库
|
||||
# 2. 获取可用数
|
||||
a = getattr(x, 'available_quantity', q)
|
||||
|
||||
try:
|
||||
total_inv += float(q if q is not None else 0)
|
||||
total_avail += float(a if a is not None else 0)
|
||||
except:
|
||||
pass
|
||||
|
||||
return total_inv, total_avail
|
||||
|
||||
@staticmethod
|
||||
def get_list(page, limit, filters=None):
|
||||
"""
|
||||
@ -60,10 +117,9 @@ class MaterialBaseService:
|
||||
query = MaterialBase.query
|
||||
|
||||
if filters:
|
||||
# 1. 关键词模糊搜索 (名称 或 俗名 或 规格型号)
|
||||
# 1. 关键词模糊搜索
|
||||
if filters.get('keyword'):
|
||||
kw = f"%{filters['keyword']}%"
|
||||
# ✅ 增加俗名搜索
|
||||
query = query.filter(or_(
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.common_name.ilike(kw),
|
||||
@ -71,6 +127,10 @@ class MaterialBaseService:
|
||||
))
|
||||
|
||||
# 2. 精确筛选
|
||||
# 公司筛选
|
||||
if filters.get('company'):
|
||||
query = query.filter_by(company_name=filters['company'])
|
||||
|
||||
if filters.get('category'):
|
||||
query = query.filter_by(category=filters['category'])
|
||||
|
||||
@ -78,30 +138,77 @@ class MaterialBaseService:
|
||||
query = query.filter_by(material_type=filters['type'])
|
||||
|
||||
if filters.get('isEnabled') is not None:
|
||||
# 前端传 1/0,转为 Boolean
|
||||
is_active = bool(int(filters['isEnabled']))
|
||||
query = query.filter_by(is_enabled=is_active)
|
||||
|
||||
# 按 ID 倒序排列
|
||||
pagination = query.order_by(MaterialBase.id.desc()).paginate(page=page, per_page=limit, error_out=False)
|
||||
# [修改3] 默认排序方式改为按 spec_model 排序
|
||||
pagination = query.order_by(MaterialBase.spec_model.asc()).paginate(page=page, per_page=limit,
|
||||
error_out=False)
|
||||
|
||||
items = [item.to_dict() for item in pagination.items]
|
||||
return {"total": pagination.total, "items": items}
|
||||
items_list = []
|
||||
for item in pagination.items:
|
||||
item_dict = item.to_dict()
|
||||
|
||||
# 聚合库存
|
||||
buy_inv, buy_avail = MaterialBaseService._get_stock_counts(item.stock_buys)
|
||||
semi_inv, semi_avail = MaterialBaseService._get_stock_counts(item.stock_semis)
|
||||
prod_inv, prod_avail = MaterialBaseService._get_stock_counts(item.stock_products)
|
||||
serv_inv, serv_avail = MaterialBaseService._get_stock_counts(getattr(item, 'stock_services', []))
|
||||
|
||||
item_dict['inventoryCount'] = buy_inv + semi_inv + prod_inv + serv_inv
|
||||
item_dict['availableCount'] = buy_avail + semi_avail + prod_avail + serv_avail
|
||||
|
||||
items_list.append(item_dict)
|
||||
|
||||
return {"total": pagination.total, "items": items_list}
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
print(f"查询基础信息列表失败: {e}")
|
||||
return {"total": 0, "items": []}
|
||||
|
||||
@staticmethod
|
||||
def get_distinct_options():
|
||||
"""
|
||||
获取所有已存在的类别、类型、公司 (去重且排序)
|
||||
"""
|
||||
try:
|
||||
# 1. 类别 (获取后在内存或前端做层级处理,这里先按字母序返回扁平列表)
|
||||
categories = db.session.query(MaterialBase.category) \
|
||||
.filter(MaterialBase.category != None, MaterialBase.category != '') \
|
||||
.distinct().all()
|
||||
|
||||
# 对类别进行排序
|
||||
sorted_categories = sorted([c[0] for c in categories])
|
||||
|
||||
# 2. 类型
|
||||
types = db.session.query(MaterialBase.material_type) \
|
||||
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
|
||||
.distinct().all()
|
||||
sorted_types = sorted([t[0] for t in types])
|
||||
|
||||
# 3. 公司
|
||||
companies = db.session.query(MaterialBase.company_name) \
|
||||
.filter(MaterialBase.company_name != None, MaterialBase.company_name != '') \
|
||||
.distinct().all()
|
||||
sorted_companies = sorted([c[0] for c in companies])
|
||||
|
||||
return {
|
||||
"categories": sorted_categories,
|
||||
"types": sorted_types,
|
||||
"companies": sorted_companies
|
||||
}
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return {"categories": [], "types": [], "companies": []}
|
||||
|
||||
@staticmethod
|
||||
def create_material(data):
|
||||
"""新增基础信息"""
|
||||
try:
|
||||
# 0. 基础校验
|
||||
if not data.get('name') or not data.get('spec'):
|
||||
raise ValueError("名称和规格型号不能为空")
|
||||
|
||||
# 1. 查重 (名称+规格型号 唯一)
|
||||
# 注意:俗名不参与唯一性校验,允许重复或为空
|
||||
exist = MaterialBase.query.filter_by(
|
||||
name=data['name'],
|
||||
spec_model=data['spec']
|
||||
@ -109,17 +216,18 @@ class MaterialBaseService:
|
||||
if exist:
|
||||
raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})")
|
||||
|
||||
# 2. 创建对象
|
||||
new_material = MaterialBase(
|
||||
# [修改] 移除了 'IRIS' 默认值
|
||||
company_name=data.get('companyName'),
|
||||
name=data['name'],
|
||||
common_name=data.get('commonName'), # ✅ 读取俗名
|
||||
common_name=data.get('commonName'),
|
||||
spec_model=data['spec'],
|
||||
category=data.get('category'),
|
||||
material_type=data.get('type'),
|
||||
unit=data.get('unit'),
|
||||
visibility_level=data.get('visibilityLevel'),
|
||||
manual_link=data.get('generalManual'),
|
||||
product_image=data.get('generalImage'),
|
||||
manual_link=json.dumps(data.get('generalManual', [])),
|
||||
product_image=json.dumps(data.get('generalImage', [])),
|
||||
is_enabled=True if data.get('isEnabled', 1) == 1 else False
|
||||
)
|
||||
|
||||
@ -140,15 +248,19 @@ class MaterialBaseService:
|
||||
raise ValueError("数据不存在")
|
||||
|
||||
# 更新字段
|
||||
if 'companyName' in data: material.company_name = data['companyName']
|
||||
if 'name' in data: material.name = data['name']
|
||||
if 'commonName' in data: material.common_name = data['commonName'] # ✅ 更新俗名
|
||||
if 'commonName' in data: material.common_name = data['commonName']
|
||||
if 'spec' in data: material.spec_model = data['spec']
|
||||
if 'category' in data: material.category = data['category']
|
||||
if 'type' in data: material.material_type = data['type']
|
||||
if 'unit' in data: material.unit = data['unit']
|
||||
if 'visibilityLevel' in data: material.visibility_level = data['visibilityLevel']
|
||||
if 'generalManual' in data: material.manual_link = data['generalManual']
|
||||
if 'generalImage' in data: material.product_image = data['generalImage']
|
||||
|
||||
if 'generalManual' in data:
|
||||
material.manual_link = json.dumps(data['generalManual'])
|
||||
if 'generalImage' in data:
|
||||
material.product_image = json.dumps(data['generalImage'])
|
||||
|
||||
if 'isEnabled' in data:
|
||||
material.is_enabled = bool(int(data['isEnabled']))
|
||||
@ -170,23 +282,21 @@ class MaterialBaseService:
|
||||
if not material:
|
||||
raise ValueError("数据不存在")
|
||||
|
||||
# 1. 依赖检查:采购入库引用
|
||||
buy_usage_count = StockBuy.query.filter_by(base_id=m_id).count()
|
||||
|
||||
# 2. 依赖检查:半成品入库引用
|
||||
semi_usage_count = StockSemi.query.filter_by(base_id=m_id).count()
|
||||
prod_usage_count = StockProduct.query.filter_by(base_id=m_id).count()
|
||||
|
||||
total_usage = buy_usage_count + semi_usage_count
|
||||
total_usage = buy_usage_count + semi_usage_count + prod_usage_count
|
||||
|
||||
if total_usage > 0:
|
||||
raise ValueError(
|
||||
f"无法删除:该基础物料正被使用中。\n"
|
||||
f"- 采购库存记录: {buy_usage_count} 条\n"
|
||||
f"- 半成品库存记录: {semi_usage_count} 条\n"
|
||||
f"- 成品库存记录: {prod_usage_count} 条\n"
|
||||
f"请先清理相关库存或仅‘禁用’此条目。"
|
||||
)
|
||||
|
||||
# 3. 执行删除
|
||||
db.session.delete(material)
|
||||
db.session.commit()
|
||||
return True
|
||||
@ -195,3 +305,234 @@ class MaterialBaseService:
|
||||
db.session.rollback()
|
||||
print(f"删除基础信息失败: {e}")
|
||||
raise e
|
||||
|
||||
# ==============================================================================
|
||||
# [核心修改] 统一资产统计导出
|
||||
# ==============================================================================
|
||||
@staticmethod
|
||||
def export_excel(filters=None):
|
||||
"""
|
||||
全口径资产统计报表:
|
||||
逻辑:先查库存表 (Buy, Semi, Product),关联基础信息,只导出实际存在的库存记录。
|
||||
"""
|
||||
try:
|
||||
# 1. 构造基础信息的筛选条件 (用于过滤库存)
|
||||
filter_conditions = []
|
||||
if filters:
|
||||
if filters.get('keyword'):
|
||||
kw = f"%{filters['keyword']}%"
|
||||
filter_conditions.append(or_(
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.common_name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
MaterialBase.company_name.ilike(kw)
|
||||
))
|
||||
if filters.get('company'):
|
||||
filter_conditions.append(MaterialBase.company_name == filters['company'])
|
||||
if filters.get('category'):
|
||||
filter_conditions.append(MaterialBase.category == filters['category'])
|
||||
if filters.get('type'):
|
||||
filter_conditions.append(MaterialBase.material_type == filters['type'])
|
||||
if filters.get('isEnabled') is not None:
|
||||
is_active = bool(int(filters['isEnabled']))
|
||||
filter_conditions.append(MaterialBase.is_enabled == is_active)
|
||||
|
||||
# 2. 分别查询三个库存表,并 Join MaterialBase 进行筛选
|
||||
# 2.1 采购库存 (StockBuy)
|
||||
query_buy = db.session.query(StockBuy, MaterialBase).join(
|
||||
MaterialBase, StockBuy.base_id == MaterialBase.id
|
||||
)
|
||||
for cond in filter_conditions:
|
||||
query_buy = query_buy.filter(cond)
|
||||
list_buy = query_buy.all()
|
||||
|
||||
# 2.2 半成品库存 (StockSemi)
|
||||
query_semi = db.session.query(StockSemi, MaterialBase).join(
|
||||
MaterialBase, StockSemi.base_id == MaterialBase.id
|
||||
)
|
||||
for cond in filter_conditions:
|
||||
query_semi = query_semi.filter(cond)
|
||||
list_semi = query_semi.all()
|
||||
|
||||
# 2.3 成品库存 (StockProduct)
|
||||
query_product = db.session.query(StockProduct, MaterialBase).join(
|
||||
MaterialBase, StockProduct.base_id == MaterialBase.id
|
||||
)
|
||||
for cond in filter_conditions:
|
||||
query_product = query_product.filter(cond)
|
||||
list_product = query_product.all()
|
||||
|
||||
# 3. 数据整合
|
||||
all_rows = []
|
||||
|
||||
# 处理采购件
|
||||
for stock, base in list_buy:
|
||||
# 价格计算
|
||||
unit_price = float(stock.unit_price or 0)
|
||||
tax_rate = float(stock.tax_rate or 0)
|
||||
price_incl = unit_price * (1 + tax_rate / 100.0)
|
||||
qty = float(stock.stock_quantity or 0)
|
||||
|
||||
# 计算不含税总价 = 数量 * 不含税单价
|
||||
total_val_excl = qty * unit_price
|
||||
# 计算含税总价 = 数量 * 含税单价
|
||||
total_val_incl = qty * price_incl
|
||||
|
||||
ident = stock.batch_number or stock.serial_number or stock.barcode or stock.sku
|
||||
|
||||
all_rows.append({
|
||||
"base": base,
|
||||
"type_name": "采购件",
|
||||
"ident": ident,
|
||||
"loc": stock.warehouse_location,
|
||||
"source": stock.supplier_name,
|
||||
"date": stock.in_date,
|
||||
"qty": qty,
|
||||
"avail": float(stock.available_quantity or 0),
|
||||
"price_excl": unit_price,
|
||||
"total_val_excl": total_val_excl, # [新增]
|
||||
"tax": tax_rate,
|
||||
"price_incl": price_incl,
|
||||
"total_val": total_val_incl
|
||||
})
|
||||
|
||||
# 处理半成品
|
||||
for stock, base in list_semi:
|
||||
cost = float(stock.raw_material_cost or 0) + float(stock.manual_cost or 0)
|
||||
qty = float(stock.stock_quantity or 0)
|
||||
|
||||
# 半成品不含税总价 = 数量 * 成本
|
||||
total_val_excl = qty * cost
|
||||
# 含税总价同上 (税率0)
|
||||
total_val_incl = qty * cost
|
||||
|
||||
ident = stock.batch_number or stock.serial_number or stock.barcode or stock.sku
|
||||
|
||||
all_rows.append({
|
||||
"base": base,
|
||||
"type_name": "半成品",
|
||||
"ident": ident,
|
||||
"loc": stock.warehouse_location,
|
||||
"source": stock.production_manager,
|
||||
"date": stock.production_date,
|
||||
"qty": qty,
|
||||
"avail": float(stock.available_quantity or 0),
|
||||
"price_excl": cost,
|
||||
"total_val_excl": total_val_excl, # [新增]
|
||||
"tax": 0.0,
|
||||
"price_incl": cost,
|
||||
"total_val": total_val_incl
|
||||
})
|
||||
|
||||
# 处理成品
|
||||
for stock, base in list_product:
|
||||
cost = float(stock.raw_material_cost or 0) + float(stock.manual_cost or 0)
|
||||
qty = float(stock.stock_quantity or 0)
|
||||
|
||||
total_val_excl = qty * cost
|
||||
total_val_incl = qty * cost
|
||||
|
||||
ident = stock.serial_number or stock.barcode or stock.sku
|
||||
|
||||
all_rows.append({
|
||||
"base": base,
|
||||
"type_name": "成品",
|
||||
"ident": ident,
|
||||
"loc": stock.warehouse_location,
|
||||
"source": stock.production_manager,
|
||||
"date": stock.production_date,
|
||||
"qty": qty,
|
||||
"avail": float(stock.available_quantity or 0),
|
||||
"price_excl": cost,
|
||||
"total_val_excl": total_val_excl, # [新增]
|
||||
"tax": 0.0,
|
||||
"price_incl": cost,
|
||||
"total_val": total_val_incl
|
||||
})
|
||||
|
||||
# 4. 排序:按公司 -> 规格型号 -> 基础ID -> 批号 排序
|
||||
all_rows.sort(key=lambda x: (
|
||||
x['base'].company_name or "",
|
||||
x['base'].spec_model or "",
|
||||
x['base'].id,
|
||||
x['ident'] or ""
|
||||
))
|
||||
|
||||
# 5. 生成 Excel
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "库存统计"
|
||||
|
||||
# 表头 [修改] 增加 "资产总额 (不含税)"
|
||||
headers = [
|
||||
"所属公司", "资产名称", "规格型号", "物料类型",
|
||||
"类别一级", "类别二级", "类别三级", "类别四级", "类别五级",
|
||||
"计量单位",
|
||||
"库存性质", "唯一标识码 (批号/SN)", "仓库位置",
|
||||
"资产来源", "入库/生产日期",
|
||||
"库存数量", "可用数量",
|
||||
"单价/成本 (不含税)", "资产总额 (不含税)", "税率 (%)", "单价/成本 (含税)", "资产总额 (含税)"
|
||||
]
|
||||
ws.append(headers)
|
||||
|
||||
# 样式
|
||||
header_fill = PatternFill(start_color="D7E4BC", end_color="D7E4BC", fill_type="solid")
|
||||
border_style = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'),
|
||||
bottom=Side(style='thin'))
|
||||
|
||||
for cell in ws[1]:
|
||||
cell.font = Font(bold=True, name='微软雅黑')
|
||||
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||||
cell.fill = header_fill
|
||||
cell.border = border_style
|
||||
|
||||
# 写入数据
|
||||
for r in all_rows:
|
||||
base = r['base']
|
||||
# 类别拆分
|
||||
cat_parts = (base.category or "").split('/')
|
||||
while len(cat_parts) < 5:
|
||||
cat_parts.append("")
|
||||
|
||||
# 日期格式化
|
||||
date_str = r['date'].strftime('%Y-%m-%d') if isinstance(r['date'], datetime.date) else ""
|
||||
|
||||
row_val = [
|
||||
base.company_name,
|
||||
base.name,
|
||||
base.spec_model,
|
||||
base.material_type,
|
||||
cat_parts[0], cat_parts[1], cat_parts[2], cat_parts[3], cat_parts[4],
|
||||
base.unit,
|
||||
r['type_name'],
|
||||
r['ident'],
|
||||
r['loc'],
|
||||
r['source'],
|
||||
date_str,
|
||||
r['qty'],
|
||||
r['avail'],
|
||||
r['price_excl'],
|
||||
r['total_val_excl'], # [新增] 对应列
|
||||
r['tax'],
|
||||
r['price_incl'],
|
||||
r['total_val']
|
||||
]
|
||||
ws.append(row_val)
|
||||
|
||||
# 列宽调整
|
||||
dims = {}
|
||||
for row in ws.rows:
|
||||
for cell in row:
|
||||
if cell.value:
|
||||
dims[cell.column_letter] = max((dims.get(cell.column_letter, 0), len(str(cell.value))))
|
||||
for col, value in dims.items():
|
||||
ws.column_dimensions[col].width = min(value + 2, 30)
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise e
|
||||
@ -1,13 +1,7 @@
|
||||
# inventory-backend/app/services/inbound/buy_service.py
|
||||
from app.extensions import db
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
# 尝试导入出库模型,如果不存在则忽略
|
||||
try:
|
||||
from app.models.outbound import TransOutbound
|
||||
except ImportError:
|
||||
TransOutbound = None
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import or_, func, text, and_
|
||||
import traceback
|
||||
@ -17,32 +11,19 @@ import json
|
||||
class BuyInboundService:
|
||||
|
||||
# ============================================================
|
||||
# 0. 辅助:唯一性校验 (核心修复)
|
||||
# 0. 辅助:唯一性校验
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
|
||||
"""
|
||||
校验序列号和批号的唯一性逻辑
|
||||
:param base_id: 当前物料的基础ID
|
||||
:param serial_number: 序列号
|
||||
:param batch_number: 批号
|
||||
:param exclude_id: 排除的ID (用于编辑模式)
|
||||
"""
|
||||
# 1. 序列号 (SN) 全局唯一校验
|
||||
# 解释: 不同规格的物料通常也不应该有相同的SN,防止扫码混淆
|
||||
if serial_number:
|
||||
query = StockBuy.query.filter(StockBuy.serial_number == serial_number)
|
||||
if exclude_id:
|
||||
query = query.filter(StockBuy.id != exclude_id)
|
||||
|
||||
exists = query.first()
|
||||
if exists:
|
||||
# 获取占用该SN的物料名称,提示更友好
|
||||
occupied_name = exists.material.name if exists.material else "未知物料"
|
||||
occupied_name = exists.base.name if exists.base else "未知物料"
|
||||
raise ValueError(f"序列号【{serial_number}】已存在!被物料 [{occupied_name}] 占用,请核查。")
|
||||
|
||||
# 2. 批号 (BN) 同物料唯一校验
|
||||
# 解释: 不同规格的物料可以有相同的批号(如都有 001 批次),但同一个物料不能重复建单
|
||||
if batch_number and base_id:
|
||||
query = StockBuy.query.filter(
|
||||
StockBuy.base_id == base_id,
|
||||
@ -50,7 +31,6 @@ class BuyInboundService:
|
||||
)
|
||||
if exclude_id:
|
||||
query = query.filter(StockBuy.id != exclude_id)
|
||||
|
||||
if query.first():
|
||||
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复录入,可直接在该批次下追加库存。")
|
||||
|
||||
@ -58,33 +38,49 @@ class BuyInboundService:
|
||||
# 1. 基础物料搜索
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def search_base_material(keyword):
|
||||
def search_base_material(keyword, page=1, limit=50):
|
||||
try:
|
||||
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
|
||||
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
k = keyword.strip()
|
||||
k_str = f'%{k}%'
|
||||
query = query.filter(and_(
|
||||
or_(
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%'),
|
||||
MaterialBase.pinyin.ilike(f'%{keyword}%') # 假设有拼音搜索
|
||||
MaterialBase.name.ilike(k_str),
|
||||
MaterialBase.spec_model.ilike(k_str),
|
||||
MaterialBase.company_name.ilike(k_str) # 支持搜公司
|
||||
)
|
||||
)
|
||||
query = query.order_by(MaterialBase.id.desc()).limit(20)
|
||||
results = []
|
||||
for item in query.all():
|
||||
results.append({
|
||||
))
|
||||
|
||||
query = query.order_by(MaterialBase.id.desc())
|
||||
pagination = query.paginate(page=page, per_page=limit, error_out=False)
|
||||
|
||||
items = []
|
||||
for item in pagination.items:
|
||||
items.append({
|
||||
'id': item.id,
|
||||
'company_name': item.company_name, # [新增]
|
||||
'name': item.name,
|
||||
'spec': item.spec_model, # 确保这里字段对应正确
|
||||
'spec': item.spec_model,
|
||||
'category': item.category,
|
||||
'unit': item.unit,
|
||||
'type': item.material_type,
|
||||
'brand': getattr(item, 'brand', ''),
|
||||
'manufacturer': getattr(item, 'manufacturer', ''),
|
||||
'pinyin': getattr(item, 'pinyin', ''),
|
||||
'status': '启用'
|
||||
})
|
||||
return results
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": pagination.total,
|
||||
"page": page,
|
||||
"has_next": pagination.has_next
|
||||
}
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
return {"items": [], "total": 0, "page": 1, "has_next": False}
|
||||
|
||||
# ============================================================
|
||||
# 2. 新增入库逻辑
|
||||
@ -93,25 +89,20 @@ class BuyInboundService:
|
||||
def handle_inbound(data):
|
||||
try:
|
||||
base_id = data.get('base_id')
|
||||
if not base_id:
|
||||
raise ValueError("必须选择基础物料")
|
||||
|
||||
if not base_id: raise ValueError("必须选择基础物料")
|
||||
material = MaterialBase.query.get(base_id)
|
||||
if not material:
|
||||
raise ValueError("所选物料不存在")
|
||||
if not material: raise ValueError("所选物料不存在")
|
||||
if not material.is_enabled: raise ValueError(f"物料【{material.name}】已停用")
|
||||
|
||||
# --- [修复点] 执行唯一性校验 ---
|
||||
BuyInboundService._check_unique(
|
||||
base_id=base_id,
|
||||
serial_number=data.get('serial_number'),
|
||||
batch_number=data.get('batch_number')
|
||||
)
|
||||
|
||||
# 时间处理 (强制北京时间)
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||
in_date_val = current_time
|
||||
|
||||
if data.get('in_date'):
|
||||
try:
|
||||
date_str = str(data['in_date'])
|
||||
@ -119,15 +110,15 @@ class BuyInboundService:
|
||||
in_date_val = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
d_temp = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day,
|
||||
current_time.hour, current_time.minute, current_time.second)
|
||||
in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day, current_time.hour,
|
||||
current_time.minute, current_time.second)
|
||||
except:
|
||||
in_date_val = current_time
|
||||
|
||||
in_qty = float(data.get('in_quantity') or 0)
|
||||
u_price = float(data.get('unit_price') or 0)
|
||||
tax_rate = float(data.get('tax_rate') or 0) # [新增]
|
||||
|
||||
# 获取全局打印ID
|
||||
try:
|
||||
seq_sql = text("SELECT nextval('global_print_seq')")
|
||||
result = db.session.execute(seq_sql)
|
||||
@ -135,42 +126,28 @@ class BuyInboundService:
|
||||
except:
|
||||
next_global_id = None
|
||||
|
||||
# SKU 生成
|
||||
if next_global_id:
|
||||
generated_sku = str(next_global_id).zfill(10)
|
||||
else:
|
||||
generated_sku = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
|
||||
generated_sku = str(next_global_id).zfill(10) if next_global_id else datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
final_barcode = data.get('barcode') or generated_sku
|
||||
|
||||
arrival_list = data.get('arrival_photo', [])
|
||||
report_list = data.get('inspection_report', [])
|
||||
|
||||
new_stock = StockBuy(
|
||||
base_id=material.id,
|
||||
global_print_id=next_global_id,
|
||||
sku=generated_sku,
|
||||
barcode=final_barcode,
|
||||
in_date=in_date_val,
|
||||
serial_number=data.get('serial_number'),
|
||||
batch_number=data.get('batch_number'),
|
||||
status=data.get('status', '在库'),
|
||||
in_quantity=in_qty,
|
||||
stock_quantity=in_qty, # 初始库存等于入库数
|
||||
available_quantity=in_qty,
|
||||
base_id=material.id, global_print_id=next_global_id, sku=generated_sku, barcode=final_barcode,
|
||||
in_date=in_date_val, serial_number=data.get('serial_number'), batch_number=data.get('batch_number'),
|
||||
status=data.get('status', '在库'), in_quantity=in_qty, stock_quantity=in_qty, available_quantity=in_qty,
|
||||
inspection_status=data.get('inspection_status', '未检'),
|
||||
warehouse_location=data.get('warehouse_location'),
|
||||
|
||||
# 价格信息
|
||||
unit_price=u_price,
|
||||
tax_rate=tax_rate, # [新增]
|
||||
total_price=in_qty * u_price,
|
||||
currency=data.get('currency', 'CNY'),
|
||||
exchange_rate=data.get('exchange_rate', 1.0),
|
||||
supplier_name=data.get('supplier_name'),
|
||||
buyer_name=data.get('purchaser'),
|
||||
|
||||
supplier_name=data.get('supplier_name'), buyer_name=data.get('purchaser'),
|
||||
buyer_email=data.get('purchaser_email'),
|
||||
original_link=data.get('source_link'),
|
||||
detail_link=data.get('detail_link'),
|
||||
arrival_photo=json.dumps(arrival_list),
|
||||
inspection_report=json.dumps(report_list)
|
||||
original_link=data.get('source_link'), detail_link=data.get('detail_link'),
|
||||
arrival_photo=json.dumps(data.get('arrival_photo', [])),
|
||||
inspection_report=json.dumps(data.get('inspection_report', []))
|
||||
)
|
||||
db.session.add(new_stock)
|
||||
db.session.commit()
|
||||
@ -180,58 +157,42 @@ class BuyInboundService:
|
||||
raise e
|
||||
|
||||
# ============================================================
|
||||
# 3. 更新入库逻辑
|
||||
# 3. 更新入库
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def update_inbound(stock_id, data):
|
||||
try:
|
||||
stock = StockBuy.query.get(stock_id)
|
||||
if not stock:
|
||||
raise ValueError("记录不存在")
|
||||
if not stock: raise ValueError("记录不存在")
|
||||
BuyInboundService._check_unique(base_id=data.get('base_id', stock.base_id),
|
||||
serial_number=data.get('serial_number', stock.serial_number),
|
||||
batch_number=data.get('batch_number', stock.batch_number),
|
||||
exclude_id=stock_id)
|
||||
|
||||
# --- [修复点] 编辑时也要校验唯一性 (排除自身ID) ---
|
||||
# 如果修改了物料(base_id),或者修改了SN/BN,都需要校验
|
||||
new_base_id = data.get('base_id', stock.base_id)
|
||||
new_sn = data.get('serial_number', stock.serial_number)
|
||||
new_bn = data.get('batch_number', stock.batch_number)
|
||||
|
||||
BuyInboundService._check_unique(
|
||||
base_id=new_base_id,
|
||||
serial_number=new_sn,
|
||||
batch_number=new_bn,
|
||||
exclude_id=stock_id
|
||||
)
|
||||
|
||||
# 更新字段
|
||||
field_mapping = {
|
||||
'sku': 'sku', 'barcode': 'barcode', 'base_id': 'base_id',
|
||||
'warehouse_location': 'warehouse_location',
|
||||
'serial_number': 'serial_number', 'batch_number': 'batch_number',
|
||||
'status': 'status', 'inspection_status': 'inspection_status',
|
||||
'supplier_name': 'supplier_name', 'detail_link': 'detail_link',
|
||||
'currency': 'currency', 'exchange_rate': 'exchange_rate',
|
||||
'purchaser': 'buyer_name', 'purchaser_email': 'buyer_email',
|
||||
'source_link': 'original_link'
|
||||
}
|
||||
field_mapping = {'sku': 'sku', 'barcode': 'barcode', 'base_id': 'base_id',
|
||||
'warehouse_location': 'warehouse_location', 'serial_number': 'serial_number',
|
||||
'batch_number': 'batch_number', 'status': 'status',
|
||||
'inspection_status': 'inspection_status', 'supplier_name': 'supplier_name',
|
||||
'detail_link': 'detail_link', 'currency': 'currency', 'exchange_rate': 'exchange_rate',
|
||||
'purchaser': 'buyer_name', 'purchaser_email': 'buyer_email',
|
||||
'source_link': 'original_link'}
|
||||
for k, v in field_mapping.items():
|
||||
if k in data: setattr(stock, v, data[k])
|
||||
|
||||
if 'arrival_photo' in data and isinstance(data['arrival_photo'], list):
|
||||
stock.arrival_photo = json.dumps(data['arrival_photo'])
|
||||
if 'inspection_report' in data and isinstance(data['inspection_report'], list):
|
||||
stock.inspection_report = json.dumps(data['inspection_report'])
|
||||
if 'arrival_photo' in data: stock.arrival_photo = json.dumps(data['arrival_photo'])
|
||||
if 'inspection_report' in data: stock.inspection_report = json.dumps(data['inspection_report'])
|
||||
|
||||
# [新增] 更新税率
|
||||
if 'tax_rate' in data: stock.tax_rate = float(data['tax_rate'])
|
||||
|
||||
# 库存数量变更逻辑
|
||||
if 'in_quantity' in data:
|
||||
new_qty = float(data['in_quantity'])
|
||||
diff = new_qty - float(stock.in_quantity)
|
||||
diff = float(data['in_quantity']) - float(stock.in_quantity)
|
||||
if diff != 0:
|
||||
stock.in_quantity = new_qty
|
||||
stock.in_quantity = float(data['in_quantity'])
|
||||
stock.stock_quantity = float(stock.stock_quantity) + diff
|
||||
stock.available_quantity = float(stock.available_quantity) + diff
|
||||
|
||||
if 'unit_price' in data:
|
||||
stock.unit_price = float(data['unit_price'])
|
||||
if 'unit_price' in data: stock.unit_price = float(data['unit_price'])
|
||||
|
||||
stock.total_price = float(stock.in_quantity) * float(stock.unit_price)
|
||||
db.session.commit()
|
||||
@ -241,7 +202,7 @@ class BuyInboundService:
|
||||
raise e
|
||||
|
||||
# ============================================================
|
||||
# 4. 删除逻辑
|
||||
# 4. 删除
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def delete_inbound(stock_id):
|
||||
@ -259,90 +220,106 @@ class BuyInboundService:
|
||||
# 5. 获取列表
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_list(page, limit, keyword=None, statuses=None):
|
||||
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None):
|
||||
try:
|
||||
query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
|
||||
|
||||
# 1. 通用关键词搜索
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(
|
||||
or_(
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
StockBuy.batch_number.ilike(kw),
|
||||
StockBuy.serial_number.ilike(kw),
|
||||
StockBuy.sku.ilike(kw),
|
||||
StockBuy.supplier_name.ilike(kw)
|
||||
)
|
||||
)
|
||||
k_str = f'%{keyword.strip()}%'
|
||||
conditions = [
|
||||
StockBuy.sku.ilike(k_str),
|
||||
StockBuy.barcode.ilike(k_str),
|
||||
StockBuy.batch_number.ilike(k_str),
|
||||
StockBuy.serial_number.ilike(k_str),
|
||||
StockBuy.supplier_name.ilike(k_str),
|
||||
StockBuy.buyer_name.ilike(k_str),
|
||||
MaterialBase.name.ilike(k_str),
|
||||
MaterialBase.spec_model.ilike(k_str),
|
||||
MaterialBase.company_name.ilike(k_str), # 关键词也支持搜公司
|
||||
]
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
if not statuses:
|
||||
statuses = ['在库', '借库']
|
||||
# 2. 类别独立搜索
|
||||
if category and category.strip():
|
||||
query = query.filter(MaterialBase.category == category.strip())
|
||||
|
||||
# 3. 类型独立搜索
|
||||
if material_type and material_type.strip():
|
||||
query = query.filter(MaterialBase.material_type == material_type.strip())
|
||||
|
||||
# 3.1 公司独立搜索 [新增]
|
||||
if company and company.strip():
|
||||
query = query.filter(MaterialBase.company_name == company.strip())
|
||||
|
||||
# 4. 状态筛选
|
||||
if not statuses: statuses = ['在库', '借库']
|
||||
if '已出库' in statuses:
|
||||
query = query.filter(StockBuy.status.in_(statuses))
|
||||
else:
|
||||
query = query.filter(and_(StockBuy.status.in_(statuses), StockBuy.stock_quantity > 0))
|
||||
|
||||
pagination = query.order_by(StockBuy.in_date.desc()).paginate(page=page, per_page=limit, error_out=False)
|
||||
current_items = pagination.items
|
||||
|
||||
def parse_img(json_str):
|
||||
if not json_str: return []
|
||||
try:
|
||||
return json.loads(json_str) if json_str.startswith('[') else [json_str]
|
||||
except:
|
||||
return []
|
||||
|
||||
items = []
|
||||
for item in current_items:
|
||||
qty_stock = float(item.stock_quantity or 0)
|
||||
qty_avail = float(item.available_quantity or 0)
|
||||
|
||||
date_display = ''
|
||||
if item.in_date:
|
||||
try:
|
||||
date_display = item.in_date.strftime('%Y-%m-%d')
|
||||
except:
|
||||
date_display = str(item.in_date)[:10]
|
||||
|
||||
d = {
|
||||
'id': item.id,
|
||||
'base_id': item.base_id,
|
||||
# 确保这里从关联的 MaterialBase 获取规格型号
|
||||
'material_name': item.material.name if item.material else '',
|
||||
'spec_model': item.material.spec_model if item.material else '',
|
||||
'category': item.material.category if item.material else '',
|
||||
'unit': item.material.unit if item.material else '',
|
||||
'material_type': item.material.material_type if item.material else '',
|
||||
|
||||
'sku': item.sku,
|
||||
'inbound_date': date_display,
|
||||
'barcode': item.barcode,
|
||||
'serial_number': item.serial_number,
|
||||
'batch_number': item.batch_number,
|
||||
'status': item.status,
|
||||
'inspection_status': item.inspection_status,
|
||||
'qty_inbound': float(item.in_quantity or 0),
|
||||
'qty_stock': qty_stock,
|
||||
'qty_available': qty_avail,
|
||||
'warehouse_loc': item.warehouse_location,
|
||||
'unit_price': float(item.unit_price or 0),
|
||||
'total_price': float(item.total_price or 0),
|
||||
'currency': item.currency,
|
||||
'exchange_rate': float(item.exchange_rate or 1),
|
||||
'supplier_name': item.supplier_name,
|
||||
'purchaser': item.buyer_name,
|
||||
'purchaser_email': item.buyer_email,
|
||||
'source_link': item.original_link,
|
||||
'detail_link': item.detail_link,
|
||||
'arrival_photo': parse_img(item.arrival_photo),
|
||||
'inspection_report': parse_img(item.inspection_report),
|
||||
'global_print_id': item.global_print_id
|
||||
}
|
||||
items.append(d)
|
||||
|
||||
for item in pagination.items:
|
||||
items.append(item.to_dict()) # 直接使用 model 的 to_dict
|
||||
return {"total": pagination.total, "items": items}
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return {"total": 0, "items": []}
|
||||
|
||||
# ============================================================
|
||||
# 6. 获取筛选选项(类别、类型、公司)并排序
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_filter_options():
|
||||
try:
|
||||
# 类别
|
||||
categories = db.session.query(MaterialBase.category) \
|
||||
.filter(MaterialBase.category != None, MaterialBase.category != '') \
|
||||
.distinct().all()
|
||||
sorted_categories = sorted([r[0] for r in categories])
|
||||
|
||||
# 类型
|
||||
types = db.session.query(MaterialBase.material_type) \
|
||||
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
|
||||
.distinct().all()
|
||||
sorted_types = sorted([r[0] for r in types])
|
||||
|
||||
# [新增] 公司
|
||||
companies = db.session.query(MaterialBase.company_name) \
|
||||
.filter(MaterialBase.company_name != None, MaterialBase.company_name != '') \
|
||||
.distinct().all()
|
||||
sorted_companies = sorted([r[0] for r in companies])
|
||||
|
||||
return {
|
||||
"categories": sorted_categories,
|
||||
"types": sorted_types,
|
||||
"companies": sorted_companies
|
||||
}
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return {"categories": [], "types": [], "companies": []}
|
||||
|
||||
# 7-10 建议类接口保持不变
|
||||
@staticmethod
|
||||
def get_history_suppliers(base_id):
|
||||
return [r[0] for r in db.session.query(StockBuy.supplier_name).filter(StockBuy.base_id == base_id,
|
||||
StockBuy.supplier_name != '').distinct().all()]
|
||||
|
||||
@staticmethod
|
||||
def get_history_purchasers(keyword):
|
||||
return [{'value': r.buyer_name, 'email': r.buyer_email} for r in
|
||||
db.session.query(StockBuy.buyer_name, StockBuy.buyer_email).filter(
|
||||
StockBuy.buyer_name != '').distinct().all()]
|
||||
|
||||
@staticmethod
|
||||
def get_history_links(base_id, type):
|
||||
return [r[0] for r in
|
||||
db.session.query(StockBuy.original_link if type == 'original' else StockBuy.detail_link).filter(
|
||||
StockBuy.base_id == base_id).distinct().all()]
|
||||
|
||||
@staticmethod
|
||||
def get_history_locations(base_id):
|
||||
return [r[0] for r in
|
||||
db.session.query(StockBuy.warehouse_location).filter(StockBuy.base_id == base_id).distinct().all()]
|
||||
@ -1,4 +1,4 @@
|
||||
from sqlalchemy import select, literal, union_all, desc, asc, func, or_, cast, String, Numeric, Date
|
||||
from sqlalchemy import select, literal, union_all, desc, asc, func, or_, cast, String, Numeric, Date # .material -> .base refactor checked
|
||||
from app.extensions import db
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
|
||||
@ -11,26 +11,18 @@ import json
|
||||
class ProductInboundService:
|
||||
|
||||
# ============================================================
|
||||
# 0. 辅助:唯一性校验 (新增核心逻辑)
|
||||
# 0. 辅助:唯一性校验
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def _check_unique(serial_number, exclude_id=None):
|
||||
"""
|
||||
校验成品的唯一性
|
||||
:param serial_number: 序列号
|
||||
:param exclude_id: 排除的ID (编辑模式用)
|
||||
"""
|
||||
from app.models.inbound.product import StockProduct
|
||||
|
||||
# 成品强校验序列号 (SN) - SN应该是全局唯一的
|
||||
if serial_number:
|
||||
query = StockProduct.query.filter(StockProduct.serial_number == serial_number)
|
||||
if exclude_id:
|
||||
query = query.filter(StockProduct.id != exclude_id)
|
||||
|
||||
exists = query.first()
|
||||
if exists:
|
||||
occupied_name = exists.material.name if (hasattr(exists, 'material') and exists.material) else "未知物料"
|
||||
occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料"
|
||||
raise ValueError(f"序列号【{serial_number}】已存在!被成品 [{occupied_name}] 占用,请核查。")
|
||||
|
||||
# ============================================================
|
||||
@ -39,26 +31,22 @@ class ProductInboundService:
|
||||
@staticmethod
|
||||
def search_base_material(keyword):
|
||||
try:
|
||||
# 1. 基础查询:必须是已启用的物料
|
||||
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
|
||||
|
||||
# 2. 动态条件:如果传入了关键词,则增加模糊匹配条件
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(
|
||||
or_(
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%')
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
MaterialBase.company_name.ilike(kw) # [新增]
|
||||
)
|
||||
)
|
||||
|
||||
# 3. 排序与限制:按ID倒序,取最新20条
|
||||
query = query.order_by(MaterialBase.id.desc()).limit(20)
|
||||
|
||||
# 4. 结果封装
|
||||
results = []
|
||||
for item in query.all():
|
||||
results.append({
|
||||
'id': item.id,
|
||||
'company_name': item.company_name, # [新增]
|
||||
'name': item.name,
|
||||
'spec': item.spec_model,
|
||||
'category': item.category,
|
||||
@ -72,29 +60,65 @@ class ProductInboundService:
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# 2. 新增入库逻辑 (强制北京时间 + 唯一性校验)
|
||||
# 1.5 BOM 搜索逻辑
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def search_bom_options(keyword):
|
||||
from app.models.bom import BomTable
|
||||
try:
|
||||
query = db.session.query(
|
||||
BomTable.bom_no,
|
||||
BomTable.version,
|
||||
MaterialBase.name.label('parent_name'),
|
||||
MaterialBase.spec_model.label('parent_spec')
|
||||
).join(MaterialBase, BomTable.parent_id == MaterialBase.id)
|
||||
|
||||
if hasattr(BomTable, 'is_enabled'):
|
||||
query = query.filter(BomTable.is_enabled == True)
|
||||
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(
|
||||
or_(
|
||||
BomTable.bom_no.ilike(kw),
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw)
|
||||
)
|
||||
)
|
||||
|
||||
results = query.distinct().limit(20).all()
|
||||
|
||||
return [{
|
||||
'bom_no': r.bom_no,
|
||||
'version': r.version,
|
||||
'parent_name': r.parent_name,
|
||||
'parent_spec': r.parent_spec or ''
|
||||
} for r in results]
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# 2. 新增入库逻辑
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def handle_inbound(data):
|
||||
from app.models.inbound.product import StockProduct
|
||||
|
||||
try:
|
||||
base_id = data.get('base_id')
|
||||
if not base_id: raise ValueError("必须选择基础物料")
|
||||
material = MaterialBase.query.get(base_id)
|
||||
if not material: raise ValueError("物料不存在")
|
||||
if not material.is_enabled:
|
||||
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
|
||||
|
||||
# --- [核心修改] 执行唯一性校验 ---
|
||||
ProductInboundService._check_unique(
|
||||
serial_number=data.get('serial_number')
|
||||
)
|
||||
|
||||
# [核心修改] 强制北京时间
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||
|
||||
in_date_val = current_time
|
||||
|
||||
if data.get('in_date'):
|
||||
try:
|
||||
date_str = str(data['in_date'])
|
||||
@ -108,12 +132,10 @@ class ProductInboundService:
|
||||
in_date_val = current_time
|
||||
|
||||
in_qty = float(data.get('in_quantity') or 0)
|
||||
|
||||
p_start = data.get('production_start_time', '')
|
||||
p_end = data.get('production_end_time', '')
|
||||
time_range = f"{p_start} ~ {p_end}" if p_start or p_end else None
|
||||
|
||||
# 全局流水号
|
||||
try:
|
||||
seq_sql = text("SELECT nextval('global_print_seq')")
|
||||
result = db.session.execute(seq_sql)
|
||||
@ -127,7 +149,6 @@ class ProductInboundService:
|
||||
photo_list = data.get('product_photo', [])
|
||||
quality_list = data.get('quality_report_link', [])
|
||||
inspection_list = data.get('inspection_report_link', [])
|
||||
|
||||
if not isinstance(photo_list, list): photo_list = []
|
||||
if not isinstance(quality_list, list): quality_list = []
|
||||
if not isinstance(inspection_list, list): inspection_list = []
|
||||
@ -136,39 +157,30 @@ class ProductInboundService:
|
||||
base_id=material.id,
|
||||
global_print_id=next_global_id,
|
||||
sku=generated_sku,
|
||||
production_date=in_date_val, # 存入 DateTime
|
||||
production_date=in_date_val,
|
||||
barcode=final_barcode,
|
||||
serial_number=data.get('serial_number'),
|
||||
|
||||
status=data.get('status', '在库'),
|
||||
warehouse_location=data.get('warehouse_location'),
|
||||
|
||||
in_quantity=in_qty,
|
||||
stock_quantity=in_qty,
|
||||
available_quantity=in_qty,
|
||||
|
||||
bom_code=data.get('bom_code'),
|
||||
bom_version=data.get('bom_version'),
|
||||
work_order_code=data.get('work_order_code'),
|
||||
production_manager=data.get('production_manager'),
|
||||
production_time_range=time_range,
|
||||
|
||||
raw_material_cost=float(data.get('raw_material_cost') or 0),
|
||||
manual_cost=float(data.get('manual_cost') or 0),
|
||||
|
||||
quality_status=data.get('quality_status', '合格'),
|
||||
|
||||
product_photo=json.dumps(photo_list),
|
||||
quality_report_link=json.dumps(quality_list),
|
||||
inspection_report_link=json.dumps(inspection_list),
|
||||
|
||||
detail_link=data.get('detail_link'),
|
||||
remark=data.get('remark'),
|
||||
|
||||
sale_price=float(data.get('sale_price') or 0),
|
||||
order_id=data.get('order_id')
|
||||
)
|
||||
|
||||
db.session.add(new_stock)
|
||||
db.session.commit()
|
||||
return new_stock
|
||||
@ -182,12 +194,10 @@ class ProductInboundService:
|
||||
@staticmethod
|
||||
def update_inbound(stock_id, data):
|
||||
from app.models.inbound.product import StockProduct
|
||||
|
||||
try:
|
||||
stock = StockProduct.query.get(stock_id)
|
||||
if not stock: raise ValueError("记录不存在")
|
||||
|
||||
# --- [核心修改] 编辑时也要校验唯一性 ---
|
||||
if 'serial_number' in data:
|
||||
ProductInboundService._check_unique(
|
||||
serial_number=data['serial_number'],
|
||||
@ -206,11 +216,9 @@ class ProductInboundService:
|
||||
if 'product_photo' in data:
|
||||
imgs = data['product_photo']
|
||||
if isinstance(imgs, list): stock.product_photo = json.dumps(imgs)
|
||||
|
||||
if 'quality_report_link' in data:
|
||||
imgs = data['quality_report_link']
|
||||
if isinstance(imgs, list): stock.quality_report_link = json.dumps(imgs)
|
||||
|
||||
if 'inspection_report_link' in data:
|
||||
imgs = data['inspection_report_link']
|
||||
if isinstance(imgs, list): stock.inspection_report_link = json.dumps(imgs)
|
||||
@ -262,7 +270,6 @@ class ProductInboundService:
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_outbound_history(stock_id):
|
||||
"""获取出库历史"""
|
||||
try:
|
||||
records = TransOutbound.query.filter_by(
|
||||
source_table='stock_product', stock_id=stock_id
|
||||
@ -275,24 +282,32 @@ class ProductInboundService:
|
||||
# 6. 获取列表
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_list(page, limit, keyword=None, statuses=None):
|
||||
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None):
|
||||
from app.models.inbound.product import StockProduct
|
||||
try:
|
||||
query = db.session.query(StockProduct).outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id)
|
||||
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(or_(
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%'),
|
||||
StockProduct.serial_number.ilike(f'%{keyword}%'),
|
||||
StockProduct.work_order_code.ilike(f'%{keyword}%'),
|
||||
StockProduct.order_id.ilike(f'%{keyword}%'),
|
||||
StockProduct.sku.ilike(f'%{keyword}%')
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
MaterialBase.company_name.ilike(kw), # [新增]
|
||||
StockProduct.serial_number.ilike(kw),
|
||||
StockProduct.work_order_code.ilike(kw),
|
||||
StockProduct.order_id.ilike(kw),
|
||||
StockProduct.sku.ilike(kw)
|
||||
))
|
||||
if category and category.strip():
|
||||
query = query.filter(MaterialBase.category == category.strip())
|
||||
if material_type and material_type.strip():
|
||||
query = query.filter(MaterialBase.material_type == material_type.strip())
|
||||
|
||||
# [新增]
|
||||
if company and company.strip():
|
||||
query = query.filter(MaterialBase.company_name == company.strip())
|
||||
|
||||
if not statuses:
|
||||
statuses = ['在库', '借库']
|
||||
|
||||
if '已出库' in statuses:
|
||||
query = query.filter(StockProduct.status.in_(statuses))
|
||||
else:
|
||||
@ -302,11 +317,8 @@ class ProductInboundService:
|
||||
StockProduct.stock_quantity > 0
|
||||
)
|
||||
)
|
||||
|
||||
# 按照 production_date (入库日期) 倒序排序
|
||||
pagination = query.order_by(StockProduct.production_date.desc()).paginate(page=page, per_page=limit,
|
||||
error_out=False)
|
||||
|
||||
current_items = pagination.items
|
||||
|
||||
def parse_img(json_str):
|
||||
@ -318,30 +330,65 @@ class ProductInboundService:
|
||||
|
||||
items = []
|
||||
for item in current_items:
|
||||
d = item.to_dict()
|
||||
|
||||
# 格式化日期
|
||||
date_display = ''
|
||||
if item.production_date:
|
||||
try:
|
||||
date_display = item.production_date.strftime('%Y-%m-%d')
|
||||
except:
|
||||
date_display = str(item.production_date)[:10]
|
||||
d['inbound_date'] = date_display
|
||||
|
||||
d['qty_stock'] = float(item.stock_quantity or 0)
|
||||
d['qty_available'] = float(item.available_quantity or 0)
|
||||
d['sum_stock'] = d['qty_stock']
|
||||
d['sum_available'] = d['qty_available']
|
||||
|
||||
d['product_photo'] = parse_img(item.product_photo)
|
||||
d['quality_report_link'] = parse_img(item.quality_report_link)
|
||||
d['inspection_report_link'] = parse_img(item.inspection_report_link)
|
||||
d['global_print_id'] = item.global_print_id
|
||||
|
||||
items.append(d)
|
||||
|
||||
items.append(item.to_dict()) # 使用 Model to_dict
|
||||
return {"total": pagination.total, "items": items}
|
||||
except:
|
||||
traceback.print_exc()
|
||||
return {"total": 0, "items": []}
|
||||
|
||||
@staticmethod
|
||||
def search_system_users(keyword):
|
||||
from app.models.system import SysUser
|
||||
try:
|
||||
query = SysUser.query.filter(SysUser.status == 'active')
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(db.or_(
|
||||
SysUser.username.ilike(kw),
|
||||
SysUser.email.ilike(kw)
|
||||
))
|
||||
query = query.order_by(SysUser.username)
|
||||
users = []
|
||||
for u in query.limit(20).all():
|
||||
users.append({
|
||||
'value': u.username,
|
||||
'email': u.email
|
||||
})
|
||||
return users
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# 7. 获取筛选项
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_filter_options():
|
||||
try:
|
||||
from app.models.base import MaterialBase
|
||||
# 类别
|
||||
categories = db.session.query(MaterialBase.category) \
|
||||
.filter(MaterialBase.category != None, MaterialBase.category != '') \
|
||||
.distinct().all()
|
||||
sorted_categories = sorted([r[0] for r in categories])
|
||||
|
||||
# 类型
|
||||
types = db.session.query(MaterialBase.material_type) \
|
||||
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
|
||||
.distinct().all()
|
||||
sorted_types = sorted([r[0] for r in types])
|
||||
|
||||
# [新增] 公司
|
||||
companies = db.session.query(MaterialBase.company_name) \
|
||||
.filter(MaterialBase.company_name != None, MaterialBase.company_name != '') \
|
||||
.distinct().all()
|
||||
sorted_companies = sorted([r[0] for r in companies])
|
||||
|
||||
return {
|
||||
"categories": sorted_categories,
|
||||
"types": sorted_types,
|
||||
"companies": sorted_companies
|
||||
}
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"categories": [], "types": [], "companies": []}
|
||||
@ -11,31 +11,20 @@ import json
|
||||
class SemiInboundService:
|
||||
|
||||
# ============================================================
|
||||
# 0. 辅助:唯一性校验 (新增核心逻辑)
|
||||
# 0. 辅助:唯一性校验
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
|
||||
"""
|
||||
校验半成品的唯一性
|
||||
:param base_id: 基础物料ID
|
||||
:param serial_number: 序列号
|
||||
:param batch_number: 批号
|
||||
:param exclude_id: 排除的ID
|
||||
"""
|
||||
from app.models.inbound.semi import StockSemi
|
||||
|
||||
# 1. 序列号 (SN) 校验 - 全局唯一
|
||||
if serial_number:
|
||||
query = StockSemi.query.filter(StockSemi.serial_number == serial_number)
|
||||
if exclude_id:
|
||||
query = query.filter(StockSemi.id != exclude_id)
|
||||
|
||||
exists = query.first()
|
||||
if exists:
|
||||
occupied_name = exists.material.name if (hasattr(exists, 'material') and exists.material) else "未知物料"
|
||||
occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料"
|
||||
raise ValueError(f"序列号【{serial_number}】已存在!被半成品 [{occupied_name}] 占用,请核查。")
|
||||
|
||||
# 2. 批号 (BN) 校验 - 同物料下不能重复开单
|
||||
if batch_number and base_id:
|
||||
query = StockSemi.query.filter(
|
||||
StockSemi.base_id == base_id,
|
||||
@ -43,7 +32,6 @@ class SemiInboundService:
|
||||
)
|
||||
if exclude_id:
|
||||
query = query.filter(StockSemi.id != exclude_id)
|
||||
|
||||
if query.first():
|
||||
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复建单,建议在原批次上追加库存。")
|
||||
|
||||
@ -53,30 +41,27 @@ class SemiInboundService:
|
||||
@staticmethod
|
||||
def search_base_material(keyword):
|
||||
try:
|
||||
# 基础查询:必须是已启用的物料
|
||||
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
|
||||
|
||||
# 如果有关键词,进行模糊匹配
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(
|
||||
or_(
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%')
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
MaterialBase.company_name.ilike(kw) # [新增] 支持搜公司
|
||||
)
|
||||
)
|
||||
|
||||
# 统一逻辑:按ID倒序,限制20条
|
||||
query = query.order_by(MaterialBase.id.desc()).limit(20)
|
||||
|
||||
results = []
|
||||
for item in query.all():
|
||||
results.append({
|
||||
'id': item.id,
|
||||
'company_name': item.company_name, # [新增]
|
||||
'name': item.name,
|
||||
'spec': item.spec_model, # 对应前端 item.spec
|
||||
'spec': item.spec_model,
|
||||
'category': item.category,
|
||||
'unit': item.unit,
|
||||
'type': item.material_type, # 对应前端 item.type
|
||||
'type': item.material_type,
|
||||
'status': '启用'
|
||||
})
|
||||
return results
|
||||
@ -84,35 +69,70 @@ class SemiInboundService:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# 1.5 BOM 搜索逻辑
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def search_bom_options(keyword):
|
||||
from app.models.bom import BomTable
|
||||
try:
|
||||
query = db.session.query(
|
||||
BomTable.bom_no,
|
||||
BomTable.version,
|
||||
MaterialBase.name.label('parent_name'),
|
||||
MaterialBase.spec_model.label('parent_spec')
|
||||
).join(MaterialBase, BomTable.parent_id == MaterialBase.id)
|
||||
|
||||
if hasattr(BomTable, 'is_enabled'):
|
||||
query = query.filter(BomTable.is_enabled == True)
|
||||
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(
|
||||
or_(
|
||||
BomTable.bom_no.ilike(kw),
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw)
|
||||
)
|
||||
)
|
||||
|
||||
results = query.distinct().limit(20).all()
|
||||
|
||||
return [{
|
||||
'bom_no': r.bom_no,
|
||||
'version': r.version,
|
||||
'parent_name': r.parent_name,
|
||||
'parent_spec': r.parent_spec or ''
|
||||
} for r in results]
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# 2. 新增入库逻辑
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def handle_inbound(data):
|
||||
from app.models.inbound.semi import StockSemi
|
||||
|
||||
try:
|
||||
base_id = data.get('base_id')
|
||||
if not base_id:
|
||||
raise ValueError("必须选择基础物料 (缺少 base_id)")
|
||||
|
||||
material = MaterialBase.query.get(base_id)
|
||||
if not material:
|
||||
raise ValueError(f"ID为 {base_id} 的基础物料不存在")
|
||||
if not material.is_enabled:
|
||||
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
|
||||
|
||||
# --- [核心修改] 执行唯一性校验 ---
|
||||
SemiInboundService._check_unique(
|
||||
base_id=base_id,
|
||||
serial_number=data.get('serial_number'),
|
||||
batch_number=data.get('batch_number')
|
||||
)
|
||||
|
||||
# [核心修改] 强制北京时间
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||
|
||||
in_date_val = current_time
|
||||
|
||||
if data.get('in_date'):
|
||||
try:
|
||||
date_str = str(data['in_date'])
|
||||
@ -125,7 +145,6 @@ class SemiInboundService:
|
||||
except ValueError:
|
||||
in_date_val = current_time
|
||||
|
||||
# 2. 处理生产时间
|
||||
p_start = None
|
||||
p_end = None
|
||||
if data.get('production_start_time'):
|
||||
@ -146,14 +165,12 @@ class SemiInboundService:
|
||||
elif isinstance(raw_range, str):
|
||||
time_range_str = raw_range
|
||||
|
||||
# 3. 处理数值和成本
|
||||
in_qty = float(data.get('in_quantity') or 0)
|
||||
raw_cost = float(data.get('raw_material_cost') or 0)
|
||||
manual_cost = float(data.get('manual_cost') or 0)
|
||||
unit_total_cost = raw_cost + manual_cost
|
||||
total_value = unit_total_cost * in_qty
|
||||
|
||||
# 4. 获取全局打印流水号
|
||||
next_global_id = 0
|
||||
try:
|
||||
seq_sql = text("SELECT nextval('global_print_seq')")
|
||||
@ -167,59 +184,47 @@ class SemiInboundService:
|
||||
final_sku = data.get('sku')
|
||||
if not final_sku:
|
||||
final_sku = generated_sku
|
||||
|
||||
final_barcode = data.get('barcode')
|
||||
if not final_barcode:
|
||||
final_barcode = final_sku
|
||||
|
||||
arrival_list = data.get('arrival_photo', [])
|
||||
quality_report_list = data.get('quality_report_link', [])
|
||||
|
||||
if not isinstance(arrival_list, list): arrival_list = []
|
||||
if not isinstance(quality_report_list, list): quality_report_list = []
|
||||
|
||||
# 8. 创建记录
|
||||
new_stock = StockSemi(
|
||||
base_id=material.id,
|
||||
global_print_id=next_global_id,
|
||||
sku=final_sku,
|
||||
production_date=in_date_val, # 存入 DateTime
|
||||
|
||||
production_date=in_date_val,
|
||||
serial_number=data.get('serial_number'),
|
||||
batch_number=data.get('batch_number'),
|
||||
barcode=final_barcode,
|
||||
|
||||
status='在库',
|
||||
quality_status=data.get('quality_status', '合格'),
|
||||
in_quantity=in_qty,
|
||||
stock_quantity=in_qty,
|
||||
available_quantity=in_qty,
|
||||
warehouse_location=data.get('warehouse_location'),
|
||||
|
||||
bom_code=data.get('bom_code'),
|
||||
bom_version=data.get('bom_version'),
|
||||
work_order_code=data.get('work_order_code'),
|
||||
production_manager=data.get('production_manager'),
|
||||
|
||||
production_start_time=p_start,
|
||||
production_end_time=p_end,
|
||||
production_time_range=time_range_str,
|
||||
|
||||
raw_material_cost=raw_cost,
|
||||
manual_cost=manual_cost,
|
||||
total_price=total_value,
|
||||
|
||||
arrival_photo=json.dumps(arrival_list),
|
||||
quality_report_link=json.dumps(quality_report_list),
|
||||
|
||||
detail_link=data.get('detail_link'),
|
||||
remark=data.get('remark')
|
||||
)
|
||||
|
||||
db.session.add(new_stock)
|
||||
db.session.commit()
|
||||
return new_stock
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print("----- SemiInboundService Error -----")
|
||||
@ -232,13 +237,11 @@ class SemiInboundService:
|
||||
@staticmethod
|
||||
def update_inbound(stock_id, data):
|
||||
from app.models.inbound.semi import StockSemi
|
||||
|
||||
try:
|
||||
stock = StockSemi.query.get(stock_id)
|
||||
if not stock:
|
||||
raise ValueError("记录不存在")
|
||||
|
||||
# --- [核心修改] 编辑时也要校验唯一性 ---
|
||||
new_base_id = data.get('base_id', stock.base_id)
|
||||
new_sn = data.get('serial_number', stock.serial_number)
|
||||
new_bn = data.get('batch_number', stock.batch_number)
|
||||
@ -265,7 +268,6 @@ class SemiInboundService:
|
||||
'detail_link': 'detail_link',
|
||||
'remark': 'remark'
|
||||
}
|
||||
|
||||
for frontend_key, db_attr in field_mapping.items():
|
||||
if frontend_key in data:
|
||||
setattr(stock, db_attr, data[frontend_key])
|
||||
@ -274,7 +276,6 @@ class SemiInboundService:
|
||||
imgs = data['arrival_photo']
|
||||
if isinstance(imgs, list):
|
||||
stock.arrival_photo = json.dumps(imgs)
|
||||
|
||||
if 'quality_report_link' in data:
|
||||
imgs = data['quality_report_link']
|
||||
if isinstance(imgs, list):
|
||||
@ -289,7 +290,6 @@ class SemiInboundService:
|
||||
stock.production_start_time = None
|
||||
except:
|
||||
pass
|
||||
|
||||
if 'production_end_time' in data:
|
||||
try:
|
||||
if data['production_end_time']:
|
||||
@ -299,7 +299,6 @@ class SemiInboundService:
|
||||
stock.production_end_time = None
|
||||
except:
|
||||
pass
|
||||
|
||||
if 'production_time_range' in data:
|
||||
raw_range = data['production_time_range']
|
||||
if isinstance(raw_range, list):
|
||||
@ -309,7 +308,6 @@ class SemiInboundService:
|
||||
|
||||
qty_changed = False
|
||||
cost_changed = False
|
||||
|
||||
if 'in_quantity' in data:
|
||||
new_qty = float(data['in_quantity'])
|
||||
diff = new_qty - float(stock.in_quantity)
|
||||
@ -318,22 +316,18 @@ class SemiInboundService:
|
||||
stock.stock_quantity = float(stock.stock_quantity) + diff
|
||||
stock.available_quantity = float(stock.available_quantity) + diff
|
||||
qty_changed = True
|
||||
|
||||
if 'raw_material_cost' in data:
|
||||
stock.raw_material_cost = float(data['raw_material_cost'])
|
||||
cost_changed = True
|
||||
|
||||
if 'manual_cost' in data:
|
||||
stock.manual_cost = float(data['manual_cost'])
|
||||
cost_changed = True
|
||||
|
||||
if cost_changed or qty_changed:
|
||||
unit_total = float(stock.raw_material_cost) + float(stock.manual_cost)
|
||||
stock.total_price = float(stock.in_quantity) * unit_total
|
||||
|
||||
db.session.commit()
|
||||
return stock
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
@ -360,7 +354,6 @@ class SemiInboundService:
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_outbound_history(stock_id):
|
||||
"""获取出库历史"""
|
||||
try:
|
||||
records = TransOutbound.query.filter_by(
|
||||
source_table='stock_semi', stock_id=stock_id
|
||||
@ -373,17 +366,17 @@ class SemiInboundService:
|
||||
# 6. 获取列表
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_list(page, limit, keyword=None, statuses=None):
|
||||
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None):
|
||||
from app.models.inbound.semi import StockSemi
|
||||
try:
|
||||
query = db.session.query(StockSemi).outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id)
|
||||
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(
|
||||
or_(
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
MaterialBase.company_name.ilike(kw), # [新增]
|
||||
StockSemi.batch_number.ilike(kw),
|
||||
StockSemi.serial_number.ilike(kw),
|
||||
StockSemi.sku.ilike(kw),
|
||||
@ -391,10 +384,17 @@ class SemiInboundService:
|
||||
StockSemi.bom_code.ilike(kw)
|
||||
)
|
||||
)
|
||||
if category and category.strip():
|
||||
query = query.filter(MaterialBase.category == category.strip())
|
||||
if material_type and material_type.strip():
|
||||
query = query.filter(MaterialBase.material_type == material_type.strip())
|
||||
|
||||
# [新增] 公司筛选
|
||||
if company and company.strip():
|
||||
query = query.filter(MaterialBase.company_name == company.strip())
|
||||
|
||||
if not statuses:
|
||||
statuses = ['在库', '借库']
|
||||
|
||||
if '已出库' in statuses:
|
||||
query = query.filter(StockSemi.status.in_(statuses))
|
||||
else:
|
||||
@ -404,11 +404,8 @@ class SemiInboundService:
|
||||
StockSemi.stock_quantity > 0
|
||||
)
|
||||
)
|
||||
|
||||
# 按照 production_date (入库日期) 倒序排序
|
||||
pagination = query.order_by(StockSemi.production_date.desc()).paginate(page=page, per_page=limit,
|
||||
error_out=False)
|
||||
|
||||
current_items = pagination.items
|
||||
|
||||
def parse_img(json_str):
|
||||
@ -420,30 +417,66 @@ class SemiInboundService:
|
||||
|
||||
items = []
|
||||
for item in current_items:
|
||||
d = item.to_dict()
|
||||
|
||||
# 格式化展示日期
|
||||
date_display = ''
|
||||
if item.production_date:
|
||||
try:
|
||||
date_display = item.production_date.strftime('%Y-%m-%d')
|
||||
except:
|
||||
date_display = str(item.production_date)[:10]
|
||||
d['inbound_date'] = date_display
|
||||
|
||||
d['qty_stock'] = float(item.stock_quantity or 0)
|
||||
d['qty_available'] = float(item.available_quantity or 0)
|
||||
d['sum_stock'] = d['qty_stock']
|
||||
d['sum_available'] = d['qty_available']
|
||||
|
||||
d['arrival_photo'] = parse_img(item.arrival_photo)
|
||||
d['quality_report_link'] = parse_img(item.quality_report_link)
|
||||
d['global_print_id'] = item.global_print_id
|
||||
|
||||
items.append(d)
|
||||
|
||||
items.append(item.to_dict()) # 直接使用 Model 的 to_dict (已包含 company_name)
|
||||
return {"total": pagination.total, "items": items}
|
||||
except Exception as e:
|
||||
print(f"List Error: {e}")
|
||||
traceback.print_exc()
|
||||
return {"total": 0, "items": []}
|
||||
|
||||
@staticmethod
|
||||
def search_system_users(keyword):
|
||||
from app.models.system import SysUser
|
||||
try:
|
||||
query = SysUser.query.filter(SysUser.status == 'active')
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(db.or_(
|
||||
SysUser.username.ilike(kw),
|
||||
SysUser.email.ilike(kw)
|
||||
))
|
||||
query = query.order_by(SysUser.username)
|
||||
users = []
|
||||
for u in query.limit(20).all():
|
||||
users.append({
|
||||
'value': u.username,
|
||||
'email': u.email
|
||||
})
|
||||
return users
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# 7. 获取筛选项 (排序)
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_filter_options():
|
||||
try:
|
||||
from app.models.base import MaterialBase
|
||||
# 类别
|
||||
categories = db.session.query(MaterialBase.category) \
|
||||
.filter(MaterialBase.category != None, MaterialBase.category != '') \
|
||||
.distinct().all()
|
||||
sorted_categories = sorted([r[0] for r in categories])
|
||||
|
||||
# 类型
|
||||
types = db.session.query(MaterialBase.material_type) \
|
||||
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
|
||||
.distinct().all()
|
||||
sorted_types = sorted([r[0] for r in types])
|
||||
|
||||
# [新增] 公司
|
||||
companies = db.session.query(MaterialBase.company_name) \
|
||||
.filter(MaterialBase.company_name != None, MaterialBase.company_name != '') \
|
||||
.distinct().all()
|
||||
sorted_companies = sorted([r[0] for r in companies])
|
||||
|
||||
return {
|
||||
"categories": sorted_categories,
|
||||
"types": sorted_types,
|
||||
"companies": sorted_companies
|
||||
}
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"categories": [], "types": [], "companies": []}
|
||||
239
inventory-backend/app/services/inbound/service_service.py
Normal file
239
inventory-backend/app/services/inbound/service_service.py
Normal file
@ -0,0 +1,239 @@
|
||||
# inventory-backend/app/services/inbound/service_service.py
|
||||
from app.extensions import db
|
||||
from app.models.inbound.service import StockService
|
||||
from app.models.base import MaterialBase
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
import traceback
|
||||
|
||||
|
||||
class ServiceService:
|
||||
"""服务权益库存业务逻辑"""
|
||||
|
||||
SKU_PREFIX = 'SRV'
|
||||
SKU_DATE_FORMAT = '%Y%m%d'
|
||||
SKU_SUFFIX_LEN = 4
|
||||
|
||||
@classmethod
|
||||
def _generate_sku(cls):
|
||||
"""生成唯一SKU,格式 SRV-YYYYMMDD-XXXX"""
|
||||
today_str = datetime.now().strftime(cls.SKU_DATE_FORMAT)
|
||||
prefix = f'{cls.SKU_PREFIX}-{today_str}-'
|
||||
|
||||
# 查找今天已有的最大后缀
|
||||
max_sku = db.session.query(db.func.max(StockService.sku)).filter(
|
||||
StockService.sku.like(f'{prefix}%')
|
||||
).scalar()
|
||||
|
||||
if not max_sku:
|
||||
suffix_num = 1
|
||||
else:
|
||||
# 提取后缀数字
|
||||
suffix_part = max_sku.replace(prefix, '')
|
||||
try:
|
||||
match = re.search(r'(\d+)$', suffix_part)
|
||||
suffix_num = int(match.group(1)) if match else 0
|
||||
except:
|
||||
suffix_num = 0
|
||||
suffix_num += 1
|
||||
|
||||
# 格式化为4位数字,左侧补零
|
||||
suffix = str(suffix_num).zfill(cls.SKU_SUFFIX_LEN)
|
||||
return f'{prefix}{suffix}'
|
||||
|
||||
@classmethod
|
||||
def search_base_material(cls, keyword):
|
||||
"""搜索基础物料,供前端远程选择"""
|
||||
try:
|
||||
# 只查询已启用的物料
|
||||
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
|
||||
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%'),
|
||||
)
|
||||
)
|
||||
query = query.order_by(MaterialBase.id.desc()).limit(20)
|
||||
|
||||
results = []
|
||||
for item in query.all():
|
||||
results.append({
|
||||
'id': item.id,
|
||||
'name': item.name,
|
||||
'spec': item.spec_model,
|
||||
'category': item.category,
|
||||
'unit': item.unit,
|
||||
'type': item.material_type,
|
||||
})
|
||||
return results
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def create_service(cls, data):
|
||||
"""创建服务权益记录"""
|
||||
# 1. 检查基础物料
|
||||
base_id = data.get('base_id')
|
||||
base = MaterialBase.query.get(base_id)
|
||||
if not base:
|
||||
raise ValueError('基础物料不存在')
|
||||
|
||||
if not base.is_enabled:
|
||||
raise ValueError(f"物料【{base.name}】已停用,无法创建新的服务权益。")
|
||||
|
||||
# 2. 生成SKU
|
||||
sku = cls._generate_sku()
|
||||
|
||||
# 3. 创建对象 (不包含库存数量字段)
|
||||
service = StockService(
|
||||
base_id=data['base_id'],
|
||||
sku=sku,
|
||||
sale_price=data.get('sale_price', 0),
|
||||
provider_name=data.get('provider_name', ''),
|
||||
description=data.get('description', ''),
|
||||
|
||||
# 可选字段映射
|
||||
service_category=data.get('service_category', ''),
|
||||
contract_id=data.get('contract_id', ''),
|
||||
contact_person=data.get('contact_person', ''),
|
||||
valid_period=data.get('valid_period', ''),
|
||||
cost_price=data.get('cost_price', 0)
|
||||
)
|
||||
|
||||
db.session.add(service)
|
||||
db.session.commit()
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
def get_service(cls, service_id):
|
||||
"""获取单个详情"""
|
||||
service = StockService.query.filter_by(id=service_id, is_deleted=False).first()
|
||||
if not service:
|
||||
raise ValueError('服务权益记录不存在')
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
def update_service(cls, service_id, data):
|
||||
"""更新服务权益"""
|
||||
service = cls.get_service(service_id)
|
||||
|
||||
# 允许更新的字段
|
||||
if 'sale_price' in data:
|
||||
service.sale_price = data['sale_price']
|
||||
if 'provider_name' in data:
|
||||
service.provider_name = data['provider_name']
|
||||
if 'description' in data:
|
||||
service.description = data.get('description', '')
|
||||
if 'cost_price' in data:
|
||||
service.cost_price = data.get('cost_price', 0)
|
||||
if 'contract_id' in data:
|
||||
service.contract_id = data.get('contract_id', '')
|
||||
if 'contact_person' in data:
|
||||
service.contact_person = data.get('contact_person', '')
|
||||
if 'valid_period' in data:
|
||||
service.valid_period = data.get('valid_period', '')
|
||||
|
||||
service.updated_at = datetime.now()
|
||||
db.session.commit()
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
def delete_service(cls, service_id):
|
||||
"""软删除"""
|
||||
service = cls.get_service(service_id)
|
||||
service.is_deleted = True
|
||||
service.updated_at = datetime.now()
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_service_list(cls, page=1, per_page=20, keyword=None,
|
||||
start_date=None, end_date=None, provider_name=None):
|
||||
"""分页查询列表"""
|
||||
try:
|
||||
query = StockService.query.filter_by(is_deleted=False)
|
||||
|
||||
# 关键词联表搜索
|
||||
if keyword:
|
||||
query = query.join(StockService.base).filter(
|
||||
db.or_(
|
||||
StockService.sku.ilike(f'%{keyword}%'),
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%')
|
||||
)
|
||||
)
|
||||
|
||||
# 日期过滤
|
||||
if start_date:
|
||||
try:
|
||||
start = datetime.strptime(start_date, '%Y-%m-%d')
|
||||
query = query.filter(StockService.created_at >= start)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d')
|
||||
# 包含当天结束
|
||||
end = end + timedelta(days=1) - timedelta(seconds=1)
|
||||
query = query.filter(StockService.created_at <= end)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 服务商过滤
|
||||
if provider_name:
|
||||
query = query.filter(StockService.provider_name.ilike(f'%{provider_name}%'))
|
||||
|
||||
total = query.count()
|
||||
|
||||
items = query.order_by(StockService.created_at.desc()) \
|
||||
.offset((page - 1) * per_page) \
|
||||
.limit(per_page).all()
|
||||
|
||||
return {
|
||||
'items': [item.to_dict() for item in items],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'per_page': per_page
|
||||
}
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise e
|
||||
|
||||
@classmethod
|
||||
def get_history_providers(cls, base_id):
|
||||
"""获取历史供应商"""
|
||||
try:
|
||||
query = db.session.query(StockService.provider_name).filter(
|
||||
StockService.base_id == base_id,
|
||||
StockService.provider_name.isnot(None),
|
||||
StockService.provider_name != ''
|
||||
).distinct().order_by(StockService.provider_name)
|
||||
return [row[0] for row in query.all()]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def search_system_users(cls, keyword):
|
||||
"""搜索系统用户(占位)"""
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_filter_options(cls):
|
||||
"""获取筛选下拉选项"""
|
||||
try:
|
||||
categories = db.session.query(MaterialBase.category) \
|
||||
.filter(MaterialBase.category != None, MaterialBase.category != '') \
|
||||
.distinct().all()
|
||||
types = db.session.query(MaterialBase.material_type) \
|
||||
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
|
||||
.distinct().all()
|
||||
return {
|
||||
"categories": [r[0] for r in categories],
|
||||
"types": [r[0] for r in types]
|
||||
}
|
||||
except Exception:
|
||||
return {"categories": [], "types": []}
|
||||
@ -1,4 +1,4 @@
|
||||
import uuid
|
||||
import uuid # .material -> .base refactor checked
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy import or_, func, desc
|
||||
from app.extensions import db
|
||||
|
||||
@ -1,20 +1,19 @@
|
||||
import socket
|
||||
import socket # .material -> .base refactor checked
|
||||
import base64
|
||||
import os
|
||||
from io import BytesIO
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from .print_config import PrintConfigManager
|
||||
|
||||
# 引入条形码生成库
|
||||
# 引入二维码生成库
|
||||
try:
|
||||
import barcode
|
||||
from barcode.writer import ImageWriter
|
||||
import qrcode
|
||||
except ImportError:
|
||||
print("❌ 警告: 未安装 python-barcode 库,无法生成真实条形码。请执行: pip install python-barcode")
|
||||
print("❌ 警告: 未安装 qrcode 库,无法生成二维码。请执行: pip install qrcode[pil]")
|
||||
|
||||
|
||||
class LabelPrintService:
|
||||
PRINTER_IP = "192.168.9.205"
|
||||
PRINTER_PORT = 9100
|
||||
# Printer IP and port now managed by PrintConfigManager
|
||||
|
||||
# ================= 1. 尺寸与分辨率配置 (300 DPI) =================
|
||||
DOTS_PER_MM = 12 # 300 DPI
|
||||
@ -25,53 +24,65 @@ class LabelPrintService:
|
||||
LABEL_WIDTH = int(LABEL_WIDTH_MM * DOTS_PER_MM)
|
||||
LABEL_HEIGHT = int(LABEL_HEIGHT_MM * DOTS_PER_MM)
|
||||
|
||||
# 顶部留白
|
||||
TOP_MARGIN_MM = 1.5
|
||||
TOP_MARGIN_PX = int(TOP_MARGIN_MM * DOTS_PER_MM)
|
||||
# ================= 2. 布局配置 =================
|
||||
MARGIN_LEFT = int(2 * DOTS_PER_MM) # 左边距 2mm
|
||||
MARGIN_RIGHT = int(1 * DOTS_PER_MM) # 右边距 1mm
|
||||
TOP_MARGIN = int(5 * DOTS_PER_MM) # 顶部边距 2mm
|
||||
|
||||
# 定义左边距 (3mm) - 用于正文左对齐
|
||||
MARGIN_LEFT = int(3 * DOTS_PER_MM)
|
||||
# 定义右边距 (防止文字贴边,留 2mm)
|
||||
MARGIN_RIGHT = int(2 * DOTS_PER_MM)
|
||||
# 二维码尺寸 15mm * 15mm
|
||||
QR_SIZE_MM = 15
|
||||
QR_SIZE_PX = int(QR_SIZE_MM * DOTS_PER_MM) # 180px
|
||||
|
||||
# 计算文字允许的最大像素宽度
|
||||
MAX_TEXT_WIDTH = LABEL_WIDTH - MARGIN_LEFT - MARGIN_RIGHT
|
||||
# 左右分栏的间距
|
||||
GAP_COLUMNS = int(2 * DOTS_PER_MM) # 2mm 间距
|
||||
|
||||
@staticmethod
|
||||
def _get_font(size):
|
||||
"""获取字体"""
|
||||
# 尝试加载中文字体,否则乱码
|
||||
font_names = ["simhei.ttf", "msyh.ttf", "SimHei.ttf", "arial.ttf"]
|
||||
base_dirs = [os.getcwd(), os.path.dirname(__file__)]
|
||||
"""获取字体 (优先使用黑体/微软雅黑)"""
|
||||
font_names = ["simhei.ttf", "msyh.ttf", "SimHei.ttf", "arial.ttf", "NotoSansCJK-Regular.ttc"]
|
||||
base_dirs = [os.getcwd(), os.path.dirname(__file__), "/usr/share/fonts", "C:\\Windows\\Fonts"]
|
||||
|
||||
for d in base_dirs:
|
||||
for name in font_names:
|
||||
path = os.path.join(d, name)
|
||||
if os.path.exists(path):
|
||||
return ImageFont.truetype(path, size)
|
||||
try:
|
||||
return ImageFont.truetype(path, size)
|
||||
except:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
@staticmethod
|
||||
def _generate_barcode_image(content, width_px, height_px):
|
||||
"""生成真实的条形码图片"""
|
||||
def _generate_qr_image(content, size_px):
|
||||
"""生成指定像素大小的二维码"""
|
||||
try:
|
||||
if not content: content = "0000000000"
|
||||
code128 = barcode.get('code128', content, writer=ImageWriter())
|
||||
buffer = BytesIO()
|
||||
code128.write(buffer, options={"write_text": False, "module_height": 10.0, "quiet_zone": 1.0})
|
||||
buffer.seek(0)
|
||||
bc_img = Image.open(buffer)
|
||||
return bc_img.resize((width_px, height_px), Image.Resampling.LANCZOS)
|
||||
if not content: content = "000000"
|
||||
|
||||
# 创建二维码对象
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=10,
|
||||
border=0,
|
||||
)
|
||||
qr.add_data(content)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# [重要] 必须转为 RGB 模式
|
||||
img = img.convert('RGB')
|
||||
|
||||
# 调整为指定像素大小
|
||||
return img.resize((size_px, size_px), Image.Resampling.LANCZOS)
|
||||
except Exception as e:
|
||||
print(f"条形码生成失败: {e}")
|
||||
return Image.new('RGB', (width_px, height_px), color='black')
|
||||
print(f"二维码生成失败: {e}")
|
||||
return Image.new('RGB', (size_px, size_px), color='gray')
|
||||
|
||||
@staticmethod
|
||||
def draw_text_wrap(draw, text, x, y, font, max_width, line_spacing=0):
|
||||
def draw_text_wrap(draw, text, x, y, font, max_width, line_spacing=0, stroke_width=1):
|
||||
"""
|
||||
[核心功能] 自动换行绘制文本
|
||||
:param line_spacing: 行与行之间的额外像素距离
|
||||
:return: 绘制结束后的 Y 坐标
|
||||
"""
|
||||
if not text:
|
||||
return y
|
||||
@ -79,36 +90,36 @@ class LabelPrintService:
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
# 1. 计算折行逻辑
|
||||
# 计算折行
|
||||
for char in text:
|
||||
# 预测加入新字符后的宽度
|
||||
test_line = current_line + char
|
||||
width = font.getlength(test_line)
|
||||
|
||||
if width <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
# 宽度超出,将当前行存入,新字符作为下一行开头
|
||||
lines.append(current_line)
|
||||
if current_line: lines.append(current_line)
|
||||
current_line = char
|
||||
|
||||
# 将最后剩余的内容加入
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
# 2. 绘制每一行
|
||||
# 绘制
|
||||
current_y = y
|
||||
font_height = font.size # 获取字号高度
|
||||
font_height = font.size
|
||||
|
||||
for line in lines:
|
||||
# 边界检查:如果超出图片高度,停止绘制
|
||||
if current_y + font_height > LabelPrintService.LABEL_HEIGHT:
|
||||
break
|
||||
|
||||
# 绘制文字 (stroke_width=1 加粗)
|
||||
draw.text((x, current_y), line, font=font, fill='black', stroke_width=1, stroke_fill='black')
|
||||
|
||||
# 更新 Y 坐标 (字高 + 行间距)
|
||||
draw.text(
|
||||
(x, current_y),
|
||||
line,
|
||||
font=font,
|
||||
fill='black',
|
||||
stroke_width=stroke_width, # 支持动态调整粗细
|
||||
stroke_fill='black'
|
||||
)
|
||||
current_y += font_height + line_spacing
|
||||
|
||||
return current_y
|
||||
@ -117,136 +128,169 @@ class LabelPrintService:
|
||||
def _create_image_object(data):
|
||||
"""
|
||||
[绘图层] 生成标签图片
|
||||
新布局逻辑:
|
||||
---------------------------------------
|
||||
| [QR Code] (15mm) | 名: XXXXXX |
|
||||
| | 规: XXXXXX |
|
||||
| SKU: XXXXX(大/粗)| 属: XXXXXX |
|
||||
| 库: XXXXX (中/粗)| SN: XXXXXX |
|
||||
---------------------------------------
|
||||
"""
|
||||
# 1. 创建画布
|
||||
img = Image.new('RGB', (LabelPrintService.LABEL_WIDTH, LabelPrintService.LABEL_HEIGHT), color='white')
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# 2. 字体配置
|
||||
# [保持] 正文内容维持 24号,以节省空间
|
||||
font_body = LabelPrintService._get_font(24)
|
||||
# [修改] 编码(SKU)字体设置为 30号
|
||||
font_code = LabelPrintService._get_font(30)
|
||||
# 2. 字体配置 (字号再次加大)
|
||||
# [修改] 通用字体加大到 28
|
||||
font_text = LabelPrintService._get_font(28)
|
||||
# [修改] SKU字体加大到 34 (特大)
|
||||
font_sku = LabelPrintService._get_font(34)
|
||||
|
||||
# 3. 数据准备
|
||||
sku_code = data.get('sku')
|
||||
if not sku_code:
|
||||
sku_code = data.get('serial_number') or str(data.get('global_print_id', '0000000000')).zfill(10)
|
||||
sku_code = str(data.get('sku') or data.get('serial_number') or '000000')
|
||||
|
||||
# ==================== 绘制布局 ====================
|
||||
|
||||
GLOBAL_OFFSET_X = LabelPrintService.MARGIN_LEFT
|
||||
CURRENT_Y = LabelPrintService.TOP_MARGIN_PX
|
||||
|
||||
# --- A. 绘制条形码 (居中) ---
|
||||
bc_w = int(37 * LabelPrintService.DOTS_PER_MM)
|
||||
bc_h = int(8 * LabelPrintService.DOTS_PER_MM) # 高度
|
||||
|
||||
bc_img = LabelPrintService._generate_barcode_image(sku_code, bc_w, bc_h)
|
||||
|
||||
# [修改核心] 计算条形码的居中 X 坐标
|
||||
# 公式:(标签总宽 - 条码宽) / 2
|
||||
bc_x_centered = (LabelPrintService.LABEL_WIDTH - bc_w) // 2
|
||||
|
||||
img.paste(bc_img, (bc_x_centered, CURRENT_Y))
|
||||
|
||||
# --- B. 绘制条形码下方数字 (居中 + 30号字) ---
|
||||
text_y_pos = CURRENT_Y + bc_h + 2
|
||||
|
||||
# [修改核心] 计算文字宽度 并 居中
|
||||
text_width = font_code.getlength(sku_code)
|
||||
text_x_centered = (LabelPrintService.LABEL_WIDTH - text_width) // 2
|
||||
|
||||
d.text(
|
||||
(text_x_centered, text_y_pos),
|
||||
sku_code,
|
||||
font=font_code, # 使用30号字体
|
||||
fill='black',
|
||||
stroke_width=1,
|
||||
stroke_fill='black'
|
||||
)
|
||||
|
||||
# 更新 Y 坐标,准备开始绘制正文
|
||||
# 30(字高) + 4(间距)
|
||||
CURRENT_Y = text_y_pos + 30 + 4
|
||||
|
||||
# --- C. 绘制其余信息 (保持左对齐 + 24号字 + 自动换行) ---
|
||||
|
||||
# 1. 准备完整文本
|
||||
name = str(data.get('material_name', '') or '-')
|
||||
spec = str(data.get('spec_model', '') or '-')
|
||||
loc = str(data.get('warehouse_loc', '') or '-')
|
||||
|
||||
cat = str(data.get('category', '') or '')
|
||||
typ = str(data.get('material_type', '') or '')
|
||||
attr = f"{cat}/{typ}"
|
||||
attr = f"{cat}/{typ}" if (cat or typ) else "-"
|
||||
|
||||
# 底部文字
|
||||
bottom_text = ""
|
||||
# 底部编号逻辑
|
||||
bottom_val = ""
|
||||
bottom_label = "NO"
|
||||
if data.get('print_no'):
|
||||
val = str(data.get('print_no'))
|
||||
label_type = data.get('print_label', '')
|
||||
bottom_text = f"{'SN' if label_type == '序' else 'BN' if label_type == '批' else 'NO'}: {val}"
|
||||
bottom_val = str(data.get('print_no'))
|
||||
l_type = data.get('print_label', '')
|
||||
bottom_label = 'SN' if l_type == '序' else 'BN' if l_type == '批' else 'NO'
|
||||
elif data.get('serial_number'):
|
||||
bottom_text = f"SN: {data.get('serial_number')}"
|
||||
bottom_label = "SN"
|
||||
bottom_val = str(data.get('serial_number'))
|
||||
elif data.get('batch_number'):
|
||||
bottom_text = f"BN: {data.get('batch_number')}"
|
||||
bottom_label = "BN"
|
||||
bottom_val = str(data.get('batch_number'))
|
||||
else:
|
||||
bottom_text = f"NO: {sku_code}"
|
||||
bottom_val = sku_code
|
||||
|
||||
# 2. 依次调用自动换行绘制函数 (使用正文字体 font_body,且坐标使用 GLOBAL_OFFSET_X)
|
||||
bottom_text_full = f"{bottom_label}:{bottom_val}"
|
||||
|
||||
# 绘制名称
|
||||
CURRENT_Y = LabelPrintService.draw_text_wrap(
|
||||
d, f"名: {name}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
|
||||
# ==================== 绘制区域划分 ====================
|
||||
|
||||
# --- A. 左侧区域 (二维码 + SKU + 库位) ---
|
||||
qr_x = LabelPrintService.MARGIN_LEFT
|
||||
qr_y = LabelPrintService.TOP_MARGIN
|
||||
|
||||
# 1. 绘制二维码
|
||||
qr_img = LabelPrintService._generate_qr_image(sku_code, LabelPrintService.QR_SIZE_PX)
|
||||
img.paste(qr_img, (qr_x, qr_y))
|
||||
|
||||
# 计算中心点,用于 SKU 和 库位 居中
|
||||
qr_center_x = qr_x + (LabelPrintService.QR_SIZE_PX // 2)
|
||||
|
||||
# 2. 绘制 SKU (特大 + 特粗)
|
||||
# 位于二维码下方,留 6px 间距
|
||||
current_left_y = qr_y + LabelPrintService.QR_SIZE_PX + 6
|
||||
|
||||
sku_w = font_sku.getlength(sku_code)
|
||||
sku_x = int(qr_center_x - (sku_w // 2))
|
||||
if sku_x < 2: sku_x = 2 # 边界保护
|
||||
|
||||
d.text(
|
||||
(sku_x, current_left_y),
|
||||
sku_code,
|
||||
font=font_sku,
|
||||
fill='black',
|
||||
stroke_width=2, # [修改] SKU 增加到 2px 描边,更粗
|
||||
stroke_fill='black'
|
||||
)
|
||||
CURRENT_Y += 2
|
||||
|
||||
# 绘制规格
|
||||
CURRENT_Y = LabelPrintService.draw_text_wrap(
|
||||
d, f"规: {spec}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
|
||||
# 3. 绘制 库位 (放在 SKU 下方)
|
||||
# 位于 SKU 下方,留 6px 间距
|
||||
current_left_y += 34 + 6 # 34是字号大致高度
|
||||
|
||||
loc_text = f"库:{loc}"
|
||||
loc_w = font_text.getlength(loc_text)
|
||||
loc_x = int(qr_center_x - (loc_w // 2))
|
||||
if loc_x < 2: loc_x = 2
|
||||
|
||||
d.text(
|
||||
(loc_x, current_left_y),
|
||||
loc_text,
|
||||
font=font_text,
|
||||
fill='black',
|
||||
stroke_width=1, # 普通加粗
|
||||
stroke_fill='black'
|
||||
)
|
||||
CURRENT_Y += 2
|
||||
|
||||
# 绘制库位
|
||||
CURRENT_Y = LabelPrintService.draw_text_wrap(
|
||||
d, f"库: {loc}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
|
||||
# --- B. 右侧区域 (名称、规格、属性、编号) ---
|
||||
|
||||
# 右侧起始 X
|
||||
right_start_x = LabelPrintService.MARGIN_LEFT + LabelPrintService.QR_SIZE_PX + LabelPrintService.GAP_COLUMNS
|
||||
# 右侧最大宽度
|
||||
right_max_width = LabelPrintService.LABEL_WIDTH - right_start_x - LabelPrintService.MARGIN_RIGHT
|
||||
|
||||
current_right_y = LabelPrintService.TOP_MARGIN
|
||||
|
||||
# [修改] 增大行间距 line_spacing=8
|
||||
LINE_SPACING = 8
|
||||
|
||||
# 1. 名称
|
||||
current_right_y = LabelPrintService.draw_text_wrap(
|
||||
d, f"名:{name}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
|
||||
)
|
||||
CURRENT_Y += 2
|
||||
current_right_y += LINE_SPACING
|
||||
|
||||
# 绘制属性
|
||||
CURRENT_Y = LabelPrintService.draw_text_wrap(
|
||||
d, f"属: {attr}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
|
||||
# 2. 规格
|
||||
current_right_y = LabelPrintService.draw_text_wrap(
|
||||
d, f"规:{spec}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
|
||||
)
|
||||
CURRENT_Y += 2
|
||||
current_right_y += LINE_SPACING
|
||||
|
||||
# 绘制底部编号
|
||||
CURRENT_Y = LabelPrintService.draw_text_wrap(
|
||||
d, bottom_text, GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
|
||||
# 3. 属性
|
||||
current_right_y = LabelPrintService.draw_text_wrap(
|
||||
d, f"属:{attr}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
|
||||
)
|
||||
current_right_y += LINE_SPACING
|
||||
|
||||
# 4. 序列号/批号
|
||||
LabelPrintService.draw_text_wrap(
|
||||
d, bottom_text_full, right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
|
||||
)
|
||||
|
||||
return img
|
||||
|
||||
@staticmethod
|
||||
def generate_preview_image(data):
|
||||
"""生成 Base64 预览图"""
|
||||
img = LabelPrintService._create_image_object(data)
|
||||
output_buffer = BytesIO()
|
||||
img.save(output_buffer, format='JPEG')
|
||||
img.save(output_buffer, format='JPEG', quality=95)
|
||||
base64_str = base64.b64encode(output_buffer.getvalue()).decode('utf-8')
|
||||
return f"data:image/jpeg;base64,{base64_str}"
|
||||
|
||||
@staticmethod
|
||||
def send_to_printer(data):
|
||||
ip = LabelPrintService.PRINTER_IP
|
||||
port = LabelPrintService.PRINTER_PORT
|
||||
config = PrintConfigManager.get_config('label_printer')
|
||||
ip = config['ip']
|
||||
port = config['port']
|
||||
|
||||
try:
|
||||
# 1. 获取 RGB 图像
|
||||
img_rgb = LabelPrintService._create_image_object(data)
|
||||
|
||||
# 2. 转换为灰度
|
||||
img_gray = img_rgb.convert('L')
|
||||
img_bw = img_gray.convert('1', dither=Image.Dither.NONE)
|
||||
|
||||
# 3. 二值化处理
|
||||
img_bw = img_gray.point(lambda x: 0 if x < 128 else 255, '1')
|
||||
|
||||
# 4. 生成打印指令
|
||||
bitmap_data = img_bw.tobytes()
|
||||
width_bytes = (img_bw.width + 7) // 8
|
||||
height_dots = img_bw.height
|
||||
|
||||
# TSPL 协议头
|
||||
header = (
|
||||
f"SIZE {LabelPrintService.LABEL_WIDTH_MM} mm, {LabelPrintService.LABEL_HEIGHT_MM} mm\r\n"
|
||||
"GAP 2 mm, 0 mm\r\n"
|
||||
@ -254,8 +298,12 @@ class LabelPrintService:
|
||||
"DIRECTION 1\r\n"
|
||||
"REFERENCE 0, 0\r\n"
|
||||
).encode('gbk')
|
||||
|
||||
# 位图指令
|
||||
bitmap_cmd = f"BITMAP 0,0,{width_bytes},{height_dots},0,".encode('gbk')
|
||||
footer = b"\r\nPRINT 1,1\r\n"
|
||||
|
||||
# 5. 发送 socket
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(5)
|
||||
s.connect((ip, port))
|
||||
@ -265,3 +313,7 @@ class LabelPrintService:
|
||||
except Exception as e:
|
||||
print(f"❌ 打印异常: {e}")
|
||||
raise Exception(f"打印机连接失败: {str(e)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pass
|
||||
|
||||
@ -1,114 +1,42 @@
|
||||
import socket
|
||||
import datetime
|
||||
from .print_config import PrintConfigManager
|
||||
|
||||
|
||||
class NetworkPrintService:
|
||||
def __init__(self, ip='192.168.9.205', port=9100):
|
||||
"""
|
||||
初始化网络打印机服务
|
||||
:param ip: 打印机IP,默认 192.168.9.205
|
||||
:param port: 端口,默认 9100
|
||||
"""
|
||||
self.ip = ip
|
||||
self.port = port
|
||||
def __init__(self, ip=None, port=None):
|
||||
config = PrintConfigManager.get_config('network_printer')
|
||||
self.ip = ip if ip is not None else config['ip']
|
||||
self.port = port if port is not None else config['port']
|
||||
|
||||
def _send_to_printer(self, content):
|
||||
"""底层发送方法"""
|
||||
try:
|
||||
# 建立 Socket 连接
|
||||
with socket.socket(socket.socket.AF_INET, socket.socket.SOCK_STREAM) as s:
|
||||
s.settimeout(5) # 设置5秒超时
|
||||
s.connect((self.ip, self.port))
|
||||
|
||||
# 发送内容,使用 GB18030 编码以支持中文
|
||||
s.sendall(content.encode('gb18030'))
|
||||
|
||||
# 发送切纸指令 (ESC/POS: GS V m)
|
||||
# 十六进制: 1D 56 42 00
|
||||
s.sendall(b'\x1d\x56\x42\x00')
|
||||
|
||||
return True, "打印成功"
|
||||
except Exception as e:
|
||||
print(f"[NetworkPrint Error] {str(e)}")
|
||||
return False, f"打印失败: {str(e)}"
|
||||
"""
|
||||
对于 A4 打印机,后端直接发送 Socket 指令通常无效或导致乱码。
|
||||
因此这里只做日志记录,实际打印由前端浏览器完成。
|
||||
"""
|
||||
print(f"--- [后端日志] 收到打印请求 (实际由前端处理) ---\n{content}\n----------------")
|
||||
return True, "记录成功"
|
||||
|
||||
def print_outbound_selection(self, items):
|
||||
"""
|
||||
打印出库选单 (拣货单)
|
||||
:param items: 选中的物品列表
|
||||
仅记录出库日志,不发送物理指令
|
||||
"""
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
try:
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
total_qty = sum([float(i.get('quantity', 0)) for i in items])
|
||||
|
||||
lines = []
|
||||
lines.append("\n")
|
||||
lines.append("********************************")
|
||||
lines.append(" 出库拣货确认单 ")
|
||||
lines.append("********************************")
|
||||
lines.append(f"打印时间: {timestamp}")
|
||||
lines.append(f"待出库总数: {len(items)} 件")
|
||||
lines.append("--------------------------------")
|
||||
lines.append(f"{'名称':<14}{'规格/批号':<10}")
|
||||
lines.append("--------------------------------")
|
||||
# 简单构造一个日志字符串
|
||||
log_content = f"出库时间: {timestamp}, 总数: {int(total_qty)}\n"
|
||||
for item in items:
|
||||
log_content += f"- {item.get('name')} (规格:{item.get('standard')}) x {item.get('quantity')}\n"
|
||||
|
||||
for item in items:
|
||||
# 获取名称,优先取 material_name, 其次 product_name
|
||||
name = item.get('material_name') or item.get('product_name') or "未知物品"
|
||||
if len(name) > 14: name = name[:13] + "." # 名称过长截断
|
||||
# 调用虚拟发送
|
||||
return self._send_to_printer(log_content)
|
||||
|
||||
standard = item.get('standard', '')
|
||||
batch = item.get('batch_no', '')
|
||||
uuid = item.get('uuid', '')[-6:] # 只显示UUID后6位
|
||||
|
||||
lines.append(f"{name:<14} {standard}")
|
||||
lines.append(f"批号: {batch} | 尾号: {uuid}")
|
||||
lines.append("- - - - - - - - - - - - - - - -")
|
||||
|
||||
lines.append("\n")
|
||||
lines.append("库管员签字: ______________")
|
||||
lines.append("领料人签字: ______________")
|
||||
lines.append("\n\n\n") # 走纸
|
||||
|
||||
content = "\n".join(lines)
|
||||
return self._send_to_printer(content)
|
||||
except Exception as e:
|
||||
print(f"日志记录失败: {e}")
|
||||
return True, "记录忽略" # 即使失败也不要在前端报错
|
||||
|
||||
def print_stocktake_report(self, data):
|
||||
"""
|
||||
打印盘点统计报告
|
||||
:param data: 包含 total, scanned, missing, missing_items
|
||||
"""
|
||||
total = data.get('total', 0)
|
||||
scanned = data.get('scanned', 0)
|
||||
missing = data.get('missing', 0)
|
||||
missing_items = data.get('missing_items', [])
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
lines = []
|
||||
lines.append("\n")
|
||||
lines.append("================================")
|
||||
lines.append(" 库存盘点统计报告 ")
|
||||
lines.append("================================")
|
||||
lines.append(f"盘点时间: {timestamp}")
|
||||
lines.append(f"应盘总数: {total}")
|
||||
lines.append(f"实盘(已扫): {scanned}")
|
||||
lines.append(f"差异(未扫): {missing}")
|
||||
lines.append("--------------------------------")
|
||||
|
||||
if missing == 0:
|
||||
lines.append("【结果】: 账实相符,库存完美!")
|
||||
else:
|
||||
lines.append("【差异明细 (未扫码物品)】:")
|
||||
for item in missing_items:
|
||||
name = item.get('material_name') or item.get('product_name') or "未知"
|
||||
batch = item.get('batch_no', '-')
|
||||
# 兼容不同模型的字段
|
||||
code = item.get('uuid', item.get('bar_code', 'N/A'))[-6:]
|
||||
|
||||
lines.append(f"[ ] {name}")
|
||||
lines.append(f" 批:{batch} 码:{code}")
|
||||
|
||||
lines.append("\n")
|
||||
lines.append("监盘人: ______________")
|
||||
lines.append("\n\n\n")
|
||||
|
||||
content = "\n".join(lines)
|
||||
return self._send_to_printer(content)
|
||||
# 同样处理
|
||||
return self._send_to_printer(f"盘点报告: 应盘{data.get('total')}, 实盘{data.get('scanned')}")
|
||||
|
||||
61
inventory-backend/app/services/print/print_config.py
Normal file
61
inventory-backend/app/services/print/print_config.py
Normal file
@ -0,0 +1,61 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
class PrintConfigManager:
|
||||
CONFIG_FILENAME = 'printer_config.json'
|
||||
DEFAULT_CONFIG = {
|
||||
'label_printer': {'ip': '192.168.9.221', 'port': 9100},
|
||||
'network_printer': {'ip': '192.168.9.250', 'port': 9100}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _get_config_path(cls):
|
||||
# Determine the path relative to this file's directory
|
||||
current_dir = Path(__file__).parent
|
||||
return current_dir / cls.CONFIG_FILENAME
|
||||
|
||||
@classmethod
|
||||
def get_config(cls, printer_type='label_printer'):
|
||||
"""
|
||||
Retrieve configuration for a given printer type.
|
||||
Returns a dict with 'ip' and 'port'.
|
||||
"""
|
||||
config_path = cls._get_config_path()
|
||||
if not config_path.exists():
|
||||
# Write default config if not exists
|
||||
cls.save_config(cls.DEFAULT_CONFIG)
|
||||
config = cls.DEFAULT_CONFIG
|
||||
else:
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error reading printer config: {e}")
|
||||
config = cls.DEFAULT_CONFIG
|
||||
# Return specific printer config, falling back to default for that type
|
||||
printer_config = config.get(printer_type, cls.DEFAULT_CONFIG.get(printer_type))
|
||||
# Ensure it's a dict with ip and port
|
||||
if not printer_config or 'ip' not in printer_config:
|
||||
printer_config = cls.DEFAULT_CONFIG.get(printer_type, {'ip': '127.0.0.1', 'port': 9100})
|
||||
return printer_config
|
||||
|
||||
@classmethod
|
||||
def save_config(cls, new_config):
|
||||
"""
|
||||
Save entire config dictionary to file.
|
||||
new_config should be a dict with keys 'label_printer' and/or 'network_printer'.
|
||||
"""
|
||||
config_path = cls._get_config_path()
|
||||
try:
|
||||
# If file exists, merge existing with new
|
||||
existing = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
existing = json.load(f)
|
||||
existing.update(new_config)
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(existing, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Error saving printer config: {e}")
|
||||
raise
|
||||
@ -1,4 +1,4 @@
|
||||
import uuid
|
||||
import uuid # .material -> .base refactor checked
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
from app.models.transaction import TransBorrow
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
# app/utils/constants.py
|
||||
|
||||
class UserRole:
|
||||
SUPER_ADMIN = 'super_admin' # 超级管理员 (IRIS)
|
||||
SUPERVISOR = 'supervisor' # 主管
|
||||
FINANCE = 'finance' # 财务
|
||||
WAREHOUSE_MGR = 'warehouse_manager' # 库管
|
||||
INBOUND = 'inbound' # 入库员
|
||||
OUTBOUND = 'outbound' # 出库员
|
||||
PURCHASER = 'purchaser' # 采购员
|
||||
SALES = 'sales' # 销售
|
||||
"""
|
||||
用户角色定义
|
||||
"""
|
||||
SUPER_ADMIN = 'SUPER_ADMIN' # 超级管理员 (IRIS)
|
||||
SUPERVISOR = 'SUPERVISOR' # 主管
|
||||
FINANCE = 'FINANCE' # 财务
|
||||
WAREHOUSE_MGR = 'WAREHOUSE_MGR' # 库管
|
||||
INBOUND = 'INBOUND' # 入库员
|
||||
OUTBOUND = 'OUTBOUND' # 出库员
|
||||
PURCHASER = 'PURCHASER' # 采购员
|
||||
SALES = 'SALES' # 销售
|
||||
|
||||
# 角色中文映射(用于前端展示或日志)
|
||||
# 注意:这个字典在 auth_service 遍历时需要被过滤掉
|
||||
ROLE_MAP = {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
SUPERVISOR: '主管',
|
||||
|
||||
@ -6,7 +6,13 @@ marshmallow-sqlalchemy==1.0.0
|
||||
psycopg2-binary==2.9.9
|
||||
python-dotenv==1.0.0
|
||||
flask-cors==4.0.0
|
||||
# 图片处理核心库
|
||||
Pillow>=10.0.0
|
||||
# [旧] 条形码生成库 (建议保留,防止旧代码报错)
|
||||
python-barcode>=0.14.0
|
||||
# [新增] 二维码生成库 (标签打印必需,包含PIL支持)
|
||||
qrcode[pil]>=7.4.2
|
||||
# [新增] 必须添加,用于处理 token 登录
|
||||
Flask-JWT-Extended==4.6.0
|
||||
# [新增] Excel 处理库 (解决 No module named 'openpyxl' 报错)
|
||||
openpyxl>=3.1.2
|
||||
@ -1,26 +1,58 @@
|
||||
# --- HTTP 重定向到 HTTPS ---
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
server_name _; # 匹配所有域名/IP
|
||||
|
||||
# 将所有 HTTP 请求强制跳转到 HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# --- HTTPS 服务器配置 ---
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
|
||||
# 1. SSL 证书配置
|
||||
ssl_certificate /etc/nginx/ssl/nginx.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/nginx.key;
|
||||
|
||||
# SSL 优化配置 (可选)
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# 允许上传大文件
|
||||
client_max_body_size 20M;
|
||||
|
||||
# 开启 Gzip
|
||||
gzip on;
|
||||
gzip_min_length 1k;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript;
|
||||
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript image/jpeg image/gif image/png;
|
||||
|
||||
# 1. 前端页面
|
||||
# 2. 前端 Vue 页面
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 2. 后端接口代理
|
||||
location /api {
|
||||
# 'backend' 对应 docker-compose 里的服务名
|
||||
# 3. 后端 API 接口代理
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme; # 告诉后端这是 HTTPS 请求
|
||||
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
# 4. 图片资源托管
|
||||
location /uploads/ {
|
||||
alias /usr/share/nginx/html/uploads/;
|
||||
expires 30d;
|
||||
}
|
||||
}
|
||||
@ -5,17 +5,19 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.13.3",
|
||||
"cropperjs": "^1.6.2",
|
||||
"element-plus": "^2.13.1",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"jspdf": "^2.5.1",
|
||||
"jspdf-autotable": "^3.8.2",
|
||||
"pinia": "^3.0.4",
|
||||
"pinyin-pro": "^3.19.0",
|
||||
"sass": "^1.97.3",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4",
|
||||
|
||||
@ -82,7 +82,7 @@ const handleLogout = () => {
|
||||
<footer v-if="!isLoginPage" class="app-footer">
|
||||
<span class="version-tag">
|
||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||
当前版本: 1.0 Beta (测试版)
|
||||
当前版本: 1.3 Beta (2.25权限管理版)
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
35
inventory-web/src/api/bom.ts
Normal file
35
inventory-web/src/api/bom.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取BOM列表
|
||||
export function getBomList(params?: any) {
|
||||
return request({
|
||||
url: '/v1/bom/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取BOM详情
|
||||
export function getBomDetail(bomNo: string) {
|
||||
return request({
|
||||
url: `/v1/bom/detail/${bomNo}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 保存BOM
|
||||
export function saveBom(data: any) {
|
||||
return request({
|
||||
url: '/v1/bom/save',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 删除BOM(暂未实现,预留)
|
||||
export function deleteBom(bomNo: string) {
|
||||
return request({
|
||||
url: `/v1/bom/${bomNo}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
@ -15,3 +15,18 @@ export function executePrint(data: any) {
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function getPrinterConfig() {
|
||||
return request({
|
||||
url: '/common/print/config',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePrinterConfig(data: any) {
|
||||
return request({
|
||||
url: '/common/print/config',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
@ -2,14 +2,23 @@ import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 上传文件通用接口
|
||||
* @param file File 对象
|
||||
* @param data File 对象 或 FormData 对象
|
||||
* 适配说明:list.vue 中 customUpload 已经封装了 FormData,所以这里支持直接传 FormData
|
||||
*/
|
||||
export function uploadFile(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
export function uploadFile(data: File | FormData) {
|
||||
let formData: FormData
|
||||
|
||||
if (data instanceof FormData) {
|
||||
formData = data
|
||||
} else {
|
||||
// 如果传入的是原始 File 对象,则手动封装
|
||||
formData = new FormData()
|
||||
// @ts-ignore
|
||||
formData.append('file', data)
|
||||
}
|
||||
|
||||
return request({
|
||||
// ★★★ [修改] 去掉开头的 /api,适配 request.ts 的 baseURL
|
||||
// 注意:这里 /v1/common/upload 需要与后端 BluePrint 注册的 url_prefix 对应
|
||||
url: '/v1/common/upload',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
@ -18,3 +27,15 @@ export function uploadFile(file: File) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件通用接口 (新增)
|
||||
* @param filename 文件名 (例如: a1b2c3d4.jpg)
|
||||
*/
|
||||
export function deleteFile(filename: string) {
|
||||
return request({
|
||||
// 对应后端路由: @upload_bp.route('/files/<filename>', methods=['DELETE'])
|
||||
url: `/v1/common/files/${filename}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
@ -35,29 +35,73 @@ export function deleteBuyInbound(id: number) {
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 搜索基础物料
|
||||
export function searchMaterialBase(keyword: string) {
|
||||
// 5. [新增] 获取筛选下拉选项(类别、类型)
|
||||
export function getFilterOptions() {
|
||||
return request({
|
||||
url: '/inbound/buy/search-base',
|
||||
method: 'get',
|
||||
params: { keyword }
|
||||
url: '/inbound/buy/options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 6. 文件上传 (用于图片/拍照)
|
||||
// 6. 搜索基础物料
|
||||
export function searchMaterialBase(keyword: string, page: number = 1) {
|
||||
return request({
|
||||
url: '/inbound/buy/search-base',
|
||||
method: 'get',
|
||||
params: { keyword, page }
|
||||
})
|
||||
}
|
||||
|
||||
// 7. 文件上传
|
||||
export function uploadFile(data: FormData) {
|
||||
return request({
|
||||
url: '/common/upload', // 对应后端 /api/v1/common/upload
|
||||
url: '/common/upload',
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
// 7. [新增] 文件删除
|
||||
// 8. 文件删除
|
||||
export function deleteFile(filename: string) {
|
||||
return request({
|
||||
url: `/common/files/${filename}`, // 对应后端 /api/v1/common/files/<filename>
|
||||
url: `/common/files/${filename}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 9. 供应商建议
|
||||
export function getSupplierSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/inbound/buy/suggestions/suppliers',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 10. 用户建议
|
||||
export function getUserSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/inbound/buy/suggestions/users',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 11. 链接建议
|
||||
export function getLinkSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/inbound/buy/suggestions/links',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 12. 库位建议
|
||||
export function getLocationSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/inbound/buy/suggestions/locations',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
@ -40,3 +40,29 @@ export function searchMaterialBase(keyword: string) {
|
||||
params: { keyword }
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索BOM (新增)
|
||||
export function searchBom(keyword: string) {
|
||||
return request({
|
||||
url: '/inbound/product/search-bom',
|
||||
method: 'get',
|
||||
params: { keyword }
|
||||
})
|
||||
}
|
||||
|
||||
// 用户建议
|
||||
export function getUserSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/inbound/product/suggestions/users',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 筛选选项
|
||||
export function getFilterOptions() {
|
||||
return request({
|
||||
url: '/inbound/product/options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
@ -43,3 +43,29 @@ export function searchMaterialBase(keyword: string) {
|
||||
params: { keyword }
|
||||
})
|
||||
}
|
||||
|
||||
// 5.5 搜索BOM (新增)
|
||||
export function searchBom(keyword: string) {
|
||||
return request({
|
||||
url: '/inbound/semi/search-bom',
|
||||
method: 'get',
|
||||
params: { keyword }
|
||||
})
|
||||
}
|
||||
|
||||
// 用户建议
|
||||
export function getUserSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/inbound/semi/suggestions/users',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 筛选选项
|
||||
export function getFilterOptions() {
|
||||
return request({
|
||||
url: '/inbound/semi/options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
139
inventory-web/src/api/inbound/service.ts
Normal file
139
inventory-web/src/api/inbound/service.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface ServiceItem {
|
||||
id: number
|
||||
base_id: number
|
||||
sku: string
|
||||
sale_price: number
|
||||
provider_name: string
|
||||
description: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
material_name?: string
|
||||
spec_model?: string
|
||||
unit?: string
|
||||
}
|
||||
|
||||
export interface ServiceListResponse {
|
||||
code: number
|
||||
msg: string
|
||||
data: {
|
||||
items: ServiceItem[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServiceQueryParams {
|
||||
page?: number
|
||||
per_page?: number
|
||||
keyword?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
provider_name?: string
|
||||
}
|
||||
|
||||
export interface ServiceCreateRequest {
|
||||
base_id: number
|
||||
sale_price: number
|
||||
provider_name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface ServiceUpdateRequest {
|
||||
sale_price?: number
|
||||
provider_name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface MaterialBaseItem {
|
||||
id: number
|
||||
name: string
|
||||
spec: string
|
||||
category: string
|
||||
unit: string
|
||||
type: string
|
||||
}
|
||||
|
||||
// 获取服务权益列表
|
||||
export function getServiceList(params: ServiceQueryParams) {
|
||||
return request<ServiceListResponse>({
|
||||
url: '/v1/inbound/service',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 创建服务权益
|
||||
export function createService(data: ServiceCreateRequest) {
|
||||
return request({
|
||||
url: '/v1/inbound/service',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 获取服务权益详情
|
||||
export function getServiceDetail(id: number) {
|
||||
return request<ServiceListResponse>({
|
||||
url: `/v1/inbound/service/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新服务权益
|
||||
export function updateService(id: number, data: ServiceUpdateRequest) {
|
||||
return request({
|
||||
url: `/v1/inbound/service/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索基础物料
|
||||
export function searchMaterialBase(keyword: string) {
|
||||
return request<{
|
||||
code: number
|
||||
msg: string
|
||||
data: MaterialBaseItem[]
|
||||
}>({
|
||||
url: '/v1/inbound/service/search-base',
|
||||
method: 'get',
|
||||
params: { keyword }
|
||||
})
|
||||
}
|
||||
|
||||
// 供应商建议
|
||||
export function getProviderSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/v1/inbound/service/suggestions/providers',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 用户建议
|
||||
export function getUserSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/v1/inbound/service/suggestions/users',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 筛选选项
|
||||
export function getFilterOptions() {
|
||||
return request({
|
||||
url: '/v1/inbound/service/options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 删除服务权益
|
||||
export function deleteService(id: number) {
|
||||
return request({
|
||||
url: `/v1/inbound/service/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
@ -29,3 +29,37 @@ export function printStocktakeReport(data: any) {
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 保存 BOM 结构
|
||||
export function saveBom(data: { parent_id: number; children: any[] }) {
|
||||
return request({
|
||||
url: '/v1/bom',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 获取基础物料列表
|
||||
export function getMaterialBaseList(params?: any) {
|
||||
return request({
|
||||
url: '/v1/bom/base/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取 BOM 父件列表
|
||||
export function getBomParents() {
|
||||
return request({
|
||||
url: '/v1/bom/parents',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取指定BOM详情
|
||||
export function getBom(parentId: number) {
|
||||
return request({
|
||||
url: `/v1/bom/${parentId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
@ -9,7 +9,25 @@ export function listMaterialBase(params: any) {
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 新增基础信息
|
||||
// 1.1 获取选项
|
||||
export function getMaterialBaseOptions() {
|
||||
return request({
|
||||
url: '/inbound/base/options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 1.2 [新增] 导出全口径资产统计表
|
||||
export function exportAssetStatistics(params: any) {
|
||||
return request({
|
||||
url: '/inbound/base/export',
|
||||
method: 'get',
|
||||
params,
|
||||
responseType: 'blob' // 关键:必须声明为 blob 处理文件流
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 新增
|
||||
export function addMaterialBase(data: any) {
|
||||
return request({
|
||||
url: '/inbound/base/',
|
||||
@ -18,8 +36,7 @@ export function addMaterialBase(data: any) {
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 修改基础信息 (包含状态启用/禁用)
|
||||
// 【修复点】: 必须在 URL 中拼接 data.id,否则后端会报 405 Method Not Allowed
|
||||
// 3. 修改
|
||||
export function updateMaterialBase(data: any) {
|
||||
return request({
|
||||
url: `/inbound/base/${data.id}`,
|
||||
@ -28,18 +45,10 @@ export function updateMaterialBase(data: any) {
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 删除基础信息
|
||||
// 4. 删除
|
||||
export function delMaterialBase(id: number) {
|
||||
return request({
|
||||
url: `/inbound/base/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 获取详情 (可选,用于编辑回显)
|
||||
export function getMaterialBase(id: number) {
|
||||
return request({
|
||||
url: `/inbound/base/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
553
inventory-web/src/components/Camera/WebRtcCamera.vue
Normal file
553
inventory-web/src/components/Camera/WebRtcCamera.vue
Normal file
@ -0,0 +1,553 @@
|
||||
<template>
|
||||
<div class="camera-container is-fullscreen">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
<el-button style="margin-top: 20px" @click="handleCancel">关闭</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="media-box">
|
||||
<video
|
||||
v-show="!imgSrc"
|
||||
ref="videoRef"
|
||||
autoplay
|
||||
playsinline
|
||||
class="media-content video-feed"
|
||||
:style="{ transform: `scale(${cameraZoom})` }"
|
||||
></video>
|
||||
|
||||
<div v-show="imgSrc" class="editor-container">
|
||||
<img
|
||||
ref="previewImgRef"
|
||||
:src="imgSrc"
|
||||
class="media-content preview-img"
|
||||
alt="Photo Preview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<canvas ref="canvasRef" style="display: none;"></canvas>
|
||||
</div>
|
||||
|
||||
<div v-if="!imgSrc && isCameraReady" class="zoom-slider-container">
|
||||
<el-icon class="zoom-icon"><Remove /></el-icon>
|
||||
<el-slider
|
||||
v-model="cameraZoom"
|
||||
:min="1"
|
||||
:max="3"
|
||||
:step="0.1"
|
||||
:show-tooltip="false"
|
||||
class="custom-slider"
|
||||
/>
|
||||
<el-icon class="zoom-icon"><CirclePlus /></el-icon>
|
||||
</div>
|
||||
|
||||
<div class="camera-actions">
|
||||
|
||||
<template v-if="!imgSrc">
|
||||
<el-button circle size="large" @click="handleCancel" class="action-btn icon-btn">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
circle
|
||||
type="danger"
|
||||
size="large"
|
||||
@click="capture"
|
||||
:disabled="!isCameraReady"
|
||||
class="shutter-btn"
|
||||
>
|
||||
<div class="shutter-inner"></div>
|
||||
</el-button>
|
||||
|
||||
<div class="placeholder-btn"></div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<div v-if="isEditing" class="edit-mode-bar">
|
||||
<div class="edit-tools">
|
||||
<el-tooltip content="切换移动图片/调整裁剪框" placement="top" :show-after="1000">
|
||||
<el-button
|
||||
circle
|
||||
@click="toggleDragMode"
|
||||
class="tool-btn"
|
||||
:class="{ 'is-active': isMoveMode }"
|
||||
>
|
||||
<el-icon><Rank /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-divider direction="vertical" border-style="dashed" />
|
||||
|
||||
<el-button circle @click="zoomCropper(0.1)" class="tool-btn"><el-icon><ZoomIn /></el-icon></el-button>
|
||||
<el-button circle @click="zoomCropper(-0.1)" class="tool-btn"><el-icon><ZoomOut /></el-icon></el-button>
|
||||
|
||||
<el-button circle @click="rotateLeft" class="tool-btn"><el-icon><RefreshLeft /></el-icon></el-button>
|
||||
<el-button circle @click="rotateRight" class="tool-btn"><el-icon><RefreshRight /></el-icon></el-button>
|
||||
<el-button circle @click="resetCrop" class="tool-btn"><el-icon><Refresh /></el-icon></el-button>
|
||||
</div>
|
||||
|
||||
<div class="edit-confirm">
|
||||
<el-button circle size="large" @click="handleCancel" class="action-btn icon-btn">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-button @click="stopEdit" class="text-btn">取消编辑</el-button>
|
||||
<el-button type="success" @click="confirmUse" class="confirm-btn">
|
||||
完成并上传
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="preview-mode-bar">
|
||||
<el-button circle size="large" @click="handleCancel" class="action-btn icon-btn">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-button @click="retake" size="large" class="text-btn" style="min-width: 80px;">重拍</el-button>
|
||||
|
||||
<el-button @click="startEdit" size="large" class="text-btn" style="min-width: 80px;">
|
||||
<el-icon style="margin-right: 4px"><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="success"
|
||||
@click="confirmUse"
|
||||
size="large"
|
||||
class="confirm-btn"
|
||||
>
|
||||
确认使用 <el-icon class="el-icon--right"><Check /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Camera, RefreshLeft, RefreshRight, Check, Close, Refresh, Edit,
|
||||
ZoomIn, ZoomOut, Remove, CirclePlus, Rank
|
||||
} from '@element-plus/icons-vue'
|
||||
import Cropper from 'cropperjs'
|
||||
import 'cropperjs/dist/cropper.css'
|
||||
|
||||
const emit = defineEmits(['photo-submit', 'cancel'])
|
||||
|
||||
const videoRef = ref<HTMLVideoElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const previewImgRef = ref<HTMLImageElement>()
|
||||
const mediaStream = ref<MediaStream>()
|
||||
const error = ref('')
|
||||
const isCameraReady = ref(false)
|
||||
|
||||
const imgSrc = ref('')
|
||||
const currentFile = ref<File | null>(null)
|
||||
|
||||
const isEditing = ref(false)
|
||||
const isMoveMode = ref(false)
|
||||
const cameraZoom = ref(1) // 控制拍摄时的变焦倍数
|
||||
let cropper: Cropper | null = null
|
||||
|
||||
const startCamera = async () => {
|
||||
stopCamera()
|
||||
error.value = ''
|
||||
imgSrc.value = ''
|
||||
isEditing.value = false
|
||||
currentFile.value = null
|
||||
cameraZoom.value = 1
|
||||
|
||||
try {
|
||||
const constraints = {
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 }
|
||||
}
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
mediaStream.value = stream
|
||||
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = stream
|
||||
await videoRef.value.play()
|
||||
isCameraReady.value = true
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
|
||||
error.value = '无法访问摄像头: 请使用 HTTPS 环境。'
|
||||
} else {
|
||||
error.value = '无法访问摄像头: ' + (err.message || '请检查权限')
|
||||
}
|
||||
ElMessage.error(error.value)
|
||||
}
|
||||
}
|
||||
|
||||
const stopCamera = () => {
|
||||
if (mediaStream.value) {
|
||||
mediaStream.value.getTracks().forEach(track => track.stop())
|
||||
mediaStream.value = undefined
|
||||
}
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = null
|
||||
}
|
||||
isCameraReady.value = false
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 核心修复:拍照时应用数码变焦(裁剪+拉伸)
|
||||
// ----------------------------------------------------
|
||||
const capture = () => {
|
||||
const video = videoRef.value
|
||||
const canvas = canvasRef.value
|
||||
if (!video || !canvas) return
|
||||
|
||||
// 1. 获取视频原始尺寸
|
||||
const vW = video.videoWidth
|
||||
const vH = video.videoHeight
|
||||
if (vW === 0 || vH === 0) return
|
||||
|
||||
// 2. 设置画布为全尺寸(保持清晰度)
|
||||
canvas.width = vW
|
||||
canvas.height = vH
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// 3. 计算基于 zoomLevel 的裁剪区域
|
||||
// zoom = 1: 裁剪宽 = vW
|
||||
// zoom = 2: 裁剪宽 = vW / 2
|
||||
const zoom = cameraZoom.value
|
||||
const cropW = vW / zoom
|
||||
const cropH = vH / zoom
|
||||
|
||||
// 4. 计算裁剪的起始点 (居中裁剪)
|
||||
const cropX = (vW - cropW) / 2
|
||||
const cropY = (vH - cropH) / 2
|
||||
|
||||
// 5. 将裁剪区域绘制到全尺寸画布上 (drawImage(source, sx, sy, sw, sh, dx, dy, dw, dh))
|
||||
ctx.drawImage(
|
||||
video,
|
||||
cropX, cropY, cropW, cropH, // 源:截取中心部分
|
||||
0, 0, vW, vH // 目标:铺满整个画布
|
||||
)
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
ElMessage.error('拍照失败,请重试')
|
||||
return
|
||||
}
|
||||
|
||||
const timestamp = new Date().getTime()
|
||||
const filename = `photo_${timestamp}.jpg`
|
||||
currentFile.value = new File([blob], filename, { type: 'image/jpeg' })
|
||||
|
||||
imgSrc.value = URL.createObjectURL(blob)
|
||||
stopCamera()
|
||||
}, 'image/jpeg', 0.95)
|
||||
}
|
||||
|
||||
const retake = () => {
|
||||
destroyCropper()
|
||||
isEditing.value = false
|
||||
if (imgSrc.value) URL.revokeObjectURL(imgSrc.value)
|
||||
imgSrc.value = ''
|
||||
currentFile.value = null
|
||||
startCamera()
|
||||
}
|
||||
|
||||
const startEdit = () => {
|
||||
if (!imgSrc.value || !previewImgRef.value) return
|
||||
isEditing.value = true
|
||||
isMoveMode.value = false
|
||||
|
||||
nextTick(() => {
|
||||
if (cropper) cropper.destroy()
|
||||
|
||||
cropper = new Cropper(previewImgRef.value!, {
|
||||
viewMode: 1,
|
||||
dragMode: 'none',
|
||||
autoCropArea: 0.8,
|
||||
background: false,
|
||||
modal: true,
|
||||
guides: true,
|
||||
highlight: false,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: false,
|
||||
movable: true,
|
||||
zoomable: true,
|
||||
rotatable: true,
|
||||
scalable: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const toggleDragMode = () => {
|
||||
if (!cropper) return
|
||||
isMoveMode.value = !isMoveMode.value
|
||||
cropper.setDragMode(isMoveMode.value ? 'move' : 'none')
|
||||
}
|
||||
|
||||
const stopEdit = () => {
|
||||
destroyCropper()
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const destroyCropper = () => {
|
||||
if (cropper) {
|
||||
cropper.destroy()
|
||||
cropper = null
|
||||
}
|
||||
}
|
||||
|
||||
const rotateLeft = () => cropper?.rotate(-90)
|
||||
const rotateRight = () => cropper?.rotate(90)
|
||||
const resetCrop = () => {
|
||||
cropper?.reset()
|
||||
isMoveMode.value = false
|
||||
cropper?.setDragMode('none')
|
||||
}
|
||||
const zoomCropper = (ratio: number) => cropper?.zoom(ratio)
|
||||
|
||||
const confirmUse = () => {
|
||||
console.log('👆 确认使用')
|
||||
|
||||
if (isEditing.value && cropper) {
|
||||
const croppedCanvas = cropper.getCroppedCanvas({
|
||||
imageSmoothingQuality: 'high'
|
||||
})
|
||||
|
||||
if (!croppedCanvas) {
|
||||
ElMessage.error('图片处理失败')
|
||||
return
|
||||
}
|
||||
|
||||
croppedCanvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
ElMessage.error('文件生成失败')
|
||||
return
|
||||
}
|
||||
const timestamp = new Date().getTime()
|
||||
const filename = `photo_crop_${timestamp}.jpg`
|
||||
const file = new File([blob], filename, { type: 'image/jpeg' })
|
||||
|
||||
emitFile(file)
|
||||
}, 'image/jpeg', 0.9)
|
||||
}
|
||||
else if (currentFile.value) {
|
||||
emitFile(currentFile.value)
|
||||
}
|
||||
else {
|
||||
ElMessage.warning('没有可用的照片')
|
||||
}
|
||||
}
|
||||
|
||||
const emitFile = (file: File) => {
|
||||
try {
|
||||
console.log('📤 提交文件:', file.name, (file.size/1024).toFixed(1)+'KB')
|
||||
emit('photo-submit', file)
|
||||
} catch (err) {
|
||||
console.error('父组件处理事件失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
destroyCropper()
|
||||
stopCamera()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
onMounted(() => startCamera())
|
||||
onBeforeUnmount(() => {
|
||||
destroyCropper()
|
||||
stopCamera()
|
||||
if (imgSrc.value) URL.revokeObjectURL(imgSrc.value)
|
||||
})
|
||||
|
||||
defineExpose({ startCamera, stopCamera })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.camera-container.is-fullscreen {
|
||||
position: fixed;
|
||||
top: 0; left: 0; width: 100vw; height: 100vh;
|
||||
background-color: #000;
|
||||
z-index: 9999;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #fff;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 媒体显示区 */
|
||||
.media-box {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.media-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.2s ease-out; /* 变焦平滑动画 */
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.editor-container { width: 100%; height: 100%; }
|
||||
.preview-img { display: block; max-width: 100%; max-height: 100%; }
|
||||
|
||||
/* 变焦滑块 */
|
||||
.zoom-slider-container {
|
||||
position: absolute;
|
||||
bottom: 150px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 70%;
|
||||
max-width: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.zoom-icon { color: #fff; font-size: 18px; }
|
||||
.custom-slider { flex: 1; }
|
||||
:deep(.el-slider__runway) { background-color: #555; }
|
||||
:deep(.el-slider__bar) { background-color: #fff; }
|
||||
:deep(.el-slider__button) { border-color: #fff; }
|
||||
|
||||
/* 底部操作栏 */
|
||||
.camera-actions {
|
||||
height: 140px;
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-bottom: 10px;
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* 拍照按钮布局 */
|
||||
.camera-actions:has(.shutter-btn) {
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.placeholder-btn { width: 40px; }
|
||||
|
||||
/* 按钮样式 */
|
||||
.action-btn { background: rgba(255, 255, 255, 0.15); border: none; color: #fff; }
|
||||
|
||||
.text-btn {
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
font-size: 14px;
|
||||
}
|
||||
.confirm-btn { min-width: 120px; font-weight: bold; }
|
||||
|
||||
/* 快门按钮 */
|
||||
.shutter-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border: 4px solid #fff;
|
||||
background: transparent !important;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.shutter-inner {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.shutter-btn:active .shutter-inner {
|
||||
transform: scale(0.9);
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
/* 预览模式操作栏 */
|
||||
.preview-mode-bar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 编辑模式操作栏 */
|
||||
.edit-mode-bar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
.edit-tools {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.tool-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.tool-btn.is-active {
|
||||
background-color: #409EFF;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.edit-confirm {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Cropper 样式定制 */
|
||||
:deep(.cropper-view-box) {
|
||||
outline: 3px solid #409EFF;
|
||||
outline-color: #409EFF;
|
||||
}
|
||||
:deep(.cropper-point) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #409EFF;
|
||||
opacity: 0.9;
|
||||
}
|
||||
:deep(.cropper-line) {
|
||||
background-color: rgba(64, 158, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
@ -6,34 +6,89 @@
|
||||
|
||||
<div class="focus-tip" v-if="!errorMsg && !isPaused">
|
||||
<div class="scan-line"></div>
|
||||
<div class="scan-text">请将条码横向填满红框</div>
|
||||
<div class="scan-text">将条码置于镜头范围内即可</div>
|
||||
</div>
|
||||
|
||||
<div class="focus-tip success" v-if="isPaused">
|
||||
<div class="scan-text-success">
|
||||
<el-icon><CircleCheckFilled /></el-icon>
|
||||
扫描成功,3秒后继续...
|
||||
扫描成功,2秒后继续...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasZoom" class="zoom-control">
|
||||
<span class="zoom-icon">-</span>
|
||||
<input
|
||||
type="range"
|
||||
:min="zoomMin"
|
||||
:max="zoomMax"
|
||||
step="0.1"
|
||||
v-model="currentZoom"
|
||||
@input="handleZoom"
|
||||
/>
|
||||
<span class="zoom-icon">+</span>
|
||||
<div class="zoom-value">{{ currentZoom }}x</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'
|
||||
import { CircleCheckFilled } from '@element-plus/icons-vue' // 引入图标用于成功提示
|
||||
import { CircleCheckFilled } from '@element-plus/icons-vue'
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['decode', 'error'])
|
||||
|
||||
const errorMsg = ref('')
|
||||
const isPaused = ref(false) // ★ 新增:控制暂停状态
|
||||
const isPaused = ref(false)
|
||||
let html5QrCode: Html5Qrcode | null = null
|
||||
const scannerElementId = "qr-reader"
|
||||
|
||||
// 变焦控制状态
|
||||
const hasZoom = ref(false)
|
||||
const zoomMin = ref(1)
|
||||
const zoomMax = ref(5)
|
||||
const currentZoom = ref(1)
|
||||
|
||||
// 音频上下文
|
||||
let audioCtx: AudioContext | null = null;
|
||||
|
||||
// 提示音播放函数
|
||||
const playBeep = () => {
|
||||
try {
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (!AudioContext) return;
|
||||
|
||||
if (!audioCtx) {
|
||||
audioCtx = new AudioContext();
|
||||
}
|
||||
|
||||
if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume();
|
||||
}
|
||||
|
||||
const oscillator = audioCtx.createOscillator();
|
||||
const gainNode = audioCtx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioCtx.destination);
|
||||
|
||||
// 三角波,清脆响亮
|
||||
oscillator.type = 'triangle';
|
||||
oscillator.frequency.value = 1500;
|
||||
|
||||
gainNode.gain.setValueAtTime(1.0, audioCtx.currentTime);
|
||||
|
||||
oscillator.start();
|
||||
oscillator.stop(audioCtx.currentTime + 0.1);
|
||||
|
||||
} catch (e) {
|
||||
console.error("播放提示音失败:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const startScanning = async () => {
|
||||
try {
|
||||
// 1. 实例化
|
||||
html5QrCode = new Html5Qrcode(scannerElementId, {
|
||||
useBarCodeDetectorIfSupported: true,
|
||||
formatsToSupport: [
|
||||
@ -43,59 +98,93 @@ const startScanning = async () => {
|
||||
verbose: false
|
||||
})
|
||||
|
||||
// 2. 启动配置
|
||||
const config = {
|
||||
fps: 20,
|
||||
qrbox: { width: 320, height: 60 },
|
||||
disableFlip: false,
|
||||
videoConstraints: {
|
||||
facingMode: "environment",
|
||||
width: { min: 1280, ideal: 1920, max: 3840 },
|
||||
height: { min: 720, ideal: 1080, max: 2160 },
|
||||
// ★★★ 核心修改:设置为 2K (QHD) 分辨率 ★★★
|
||||
// min: 1280x720 (保证低端机能启动)
|
||||
// ideal: 2560x1440 (2K QHD,清晰度与性能的平衡点)
|
||||
width: { min: 1280, ideal: 2560, max: 3840 },
|
||||
height: { min: 720, ideal: 1440, max: 2160 },
|
||||
// 16:9 的比例
|
||||
aspectRatio: { ideal: 1.7777777778 },
|
||||
focusMode: "continuous",
|
||||
advanced: [{ focusMode: "macro" }]
|
||||
advanced: [{ focusMode: "macro" }, { zoom: 2.0 }]
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 启动
|
||||
await html5QrCode.start(
|
||||
{ facingMode: "environment" },
|
||||
config,
|
||||
(decodedText) => {
|
||||
// ★ 核心修改:如果处于暂停冷却期,直接忽略后续扫描结果
|
||||
if (isPaused.value) return
|
||||
|
||||
console.log(`Scan: ${decodedText}`)
|
||||
|
||||
// 1. 锁定状态
|
||||
isPaused.value = true
|
||||
|
||||
// 2. 发送数据
|
||||
playBeep();
|
||||
|
||||
emit('decode', decodedText)
|
||||
|
||||
// 3. 开启 3 秒倒计时解锁
|
||||
if (navigator.vibrate) navigator.vibrate(200);
|
||||
|
||||
setTimeout(() => {
|
||||
isPaused.value = false
|
||||
}, 3000)
|
||||
}, 2000)
|
||||
},
|
||||
(errorMessage) => {
|
||||
// ignore
|
||||
}
|
||||
)
|
||||
|
||||
checkZoomCapability()
|
||||
|
||||
} catch (err: any) {
|
||||
let msg = '无法启动摄像头'
|
||||
const errStr = err.toString()
|
||||
if (errStr.includes('Permission')) msg = '请允许摄像头权限'
|
||||
else if (errStr.includes('Secure')) msg = '需要 HTTPS 或 localhost'
|
||||
else if (errStr.includes('NotFound')) msg = '未检测到后置摄像头'
|
||||
else if (errStr.includes('OverconstrainedError')) msg = '摄像头不支持高分辨率'
|
||||
|
||||
console.error("Scanner Error:", err)
|
||||
if (err.name === 'OverconstrainedError') {
|
||||
msg = '摄像头不支持 2K 分辨率,请尝试降低配置'
|
||||
}
|
||||
errorMsg.value = msg
|
||||
emit('error', msg)
|
||||
}
|
||||
}
|
||||
|
||||
const checkZoomCapability = () => {
|
||||
if (!html5QrCode) return
|
||||
|
||||
try {
|
||||
const videoTrack = html5QrCode.getRunningTrackCameraCapabilities() as MediaTrackCapabilities;
|
||||
|
||||
// @ts-ignore
|
||||
if (videoTrack && 'zoom' in videoTrack) {
|
||||
hasZoom.value = true
|
||||
// @ts-ignore
|
||||
zoomMin.value = videoTrack.zoom.min || 1
|
||||
// @ts-ignore
|
||||
zoomMax.value = videoTrack.zoom.max || 5
|
||||
// @ts-ignore
|
||||
currentZoom.value = videoTrack.zoom.min || 1
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("无法获取变焦能力", e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleZoom = () => {
|
||||
if (!html5QrCode) return
|
||||
|
||||
try {
|
||||
html5QrCode.applyVideoConstraints({
|
||||
advanced: [{ zoom: Number(currentZoom.value) }]
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("变焦失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
const stopScanning = async () => {
|
||||
if (html5QrCode) {
|
||||
try {
|
||||
@ -107,9 +196,19 @@ const stopScanning = async () => {
|
||||
console.error("Stop failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (audioCtx) {
|
||||
audioCtx.close();
|
||||
audioCtx = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (AudioContext) audioCtx = new AudioContext();
|
||||
} catch(e) {}
|
||||
|
||||
setTimeout(() => {
|
||||
startScanning()
|
||||
}, 500)
|
||||
@ -131,7 +230,7 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.scanner-box {
|
||||
@ -151,7 +250,6 @@ onUnmounted(() => {
|
||||
height: 100% !important;
|
||||
object-fit: cover !important;
|
||||
display: block !important;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
@ -167,62 +265,93 @@ onUnmounted(() => {
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
/* --- 视觉辅助线 --- */
|
||||
.focus-tip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 320px;
|
||||
height: 60px;
|
||||
border: 2px solid rgba(255, 0, 0, 0.6);
|
||||
border-radius: 6px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 0 2000px rgba(0, 0, 0, 0.6);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
/* ★ 扫描成功时的绿色框样式 */
|
||||
.focus-tip.success {
|
||||
border-color: #67c23a; /* 绿色边框 */
|
||||
background: rgba(103, 194, 58, 0.1);
|
||||
background: rgba(103, 194, 58, 0.2);
|
||||
}
|
||||
|
||||
.scan-line {
|
||||
width: 95%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #ff0000;
|
||||
box-shadow: 0 0 4px #ff0000;
|
||||
background: rgba(255, 0, 0, 0.5);
|
||||
box-shadow: 0 0 4px rgba(255, 0, 0, 0.8);
|
||||
position: absolute;
|
||||
animation: scan-move 1.5s infinite ease-in-out;
|
||||
animation: scan-move 2.5s infinite linear;
|
||||
}
|
||||
|
||||
.scan-text {
|
||||
position: absolute;
|
||||
bottom: -35px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 2px #000;
|
||||
bottom: 150px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scan-text-success {
|
||||
color: #67c23a;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||
gap: 10px;
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.8);
|
||||
background: rgba(103, 194, 58, 0.9);
|
||||
padding: 15px 30px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
@keyframes scan-move {
|
||||
0% { top: 10%; opacity: 0.5; }
|
||||
50% { top: 90%; opacity: 1; }
|
||||
100% { top: 10%; opacity: 0.5; }
|
||||
0% { top: 0%; opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { top: 100%; opacity: 0; }
|
||||
}
|
||||
|
||||
.zoom-control {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80%;
|
||||
max-width: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 10px 20px;
|
||||
border-radius: 30px;
|
||||
z-index: 50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.zoom-control input[type=range] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.zoom-icon {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.zoom-value {
|
||||
font-size: 14px;
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@ -22,7 +22,7 @@ import AppMain from './components/AppMain.vue'
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
width: 210px; /* 固定侧边栏宽度 */
|
||||
width: 180px; /* 固定侧边栏宽度 */
|
||||
height: 100%;
|
||||
background-color: #304156; /* 侧边栏背景色 */
|
||||
flex-shrink: 0; /* 防止被挤压 */
|
||||
@ -37,7 +37,7 @@ import AppMain from './components/AppMain.vue'
|
||||
flex-direction: column;
|
||||
overflow-y: auto; /* 关键:页面内容过多时,只在右侧区域滚动 */
|
||||
background-color: #f0f2f5; /* 右侧灰色背景,让白色卡片更明显 */
|
||||
padding: 20px; /* 给内部页面留出边距 */
|
||||
padding: 10px; /* 给内部页面留出边距 */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@ -2,6 +2,17 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import Layout from '@/layout/index.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import BomManage from '@/views/bom/BomManage.vue'
|
||||
|
||||
// [新增] 扩展 RouteMeta 类型定义,防止 TS 报错
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
icon?: string
|
||||
hidden?: boolean
|
||||
roles?: string[] // 允许的角色列表
|
||||
}
|
||||
}
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
// 1. 登录页
|
||||
@ -67,6 +78,13 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () => import('@/views/stock/inbound/product.vue'),
|
||||
meta: { title: '成品' }
|
||||
},
|
||||
|
||||
{
|
||||
path: 'service',
|
||||
name: 'InventoryService',
|
||||
component: () => import('@/views/stock/inbound/service.vue'),
|
||||
meta: { title: '服务权益' }
|
||||
},
|
||||
// [原有] 入库记录整合
|
||||
{
|
||||
path: 'summary',
|
||||
@ -74,12 +92,6 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () => import('@/views/stock/inbound/inbound_summary.vue'),
|
||||
meta: { title: '入库记录' }
|
||||
},
|
||||
{
|
||||
path: 'service',
|
||||
name: 'InventoryService',
|
||||
component: () => import('@/views/stock/inbound/service.vue'),
|
||||
meta: { title: '服务权益' }
|
||||
},
|
||||
// ★ [新增] 库存盘点页面 (查库/消除)
|
||||
{
|
||||
path: 'stocktake',
|
||||
@ -119,6 +131,22 @@ const routes: Array<RouteRecordRaw> = [
|
||||
]
|
||||
},
|
||||
|
||||
// 5. BOM 管理
|
||||
{
|
||||
path: '/bom',
|
||||
component: Layout,
|
||||
meta: { title: 'BOM管理', icon: 'Document' },
|
||||
redirect: '/bom/manage',
|
||||
children: [
|
||||
{
|
||||
path: 'manage',
|
||||
name: 'BomManage',
|
||||
component: BomManage,
|
||||
meta: { title: 'BOM配方管理', icon: 'list' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 6. 业务操作
|
||||
{
|
||||
path: '/operation',
|
||||
@ -151,17 +179,25 @@ const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/system',
|
||||
component: Layout,
|
||||
// [修复] 添加 redirect,点击父菜单时跳转到子页面
|
||||
redirect: '/system/user-create',
|
||||
meta: {
|
||||
title: '系统管理',
|
||||
icon: 'Setting',
|
||||
roles: ['super_admin', 'supervisor']
|
||||
// [修复] 使用大写角色名,匹配后端常量
|
||||
roles: ['SUPER_ADMIN', 'SUPERVISOR']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'user-create',
|
||||
name: 'UserCreate',
|
||||
component: () => import('@/views/system/UserCreate.vue'),
|
||||
meta: { title: '账号开通', icon: 'User' }
|
||||
meta: {
|
||||
title: '账号开通',
|
||||
icon: 'User',
|
||||
// 子路由也建议加上权限限制
|
||||
roles: ['SUPER_ADMIN', 'SUPERVISOR']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -186,7 +222,16 @@ router.beforeEach((to, from, next) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
const token = userStore.token || localStorage.getItem('token')
|
||||
const userRole = userStore.role || localStorage.getItem('role') || 'user'
|
||||
|
||||
// [修复] 优先从 user 对象获取,并统一转大写,防止大小写不一致导致权限失效
|
||||
// 注意:Store 中存储的可能是 user.role 或者直接是 role,根据你之前的 store 结构适配
|
||||
const rawRole = userStore.user?.role || userStore.role || localStorage.getItem('role') || 'user'
|
||||
const userRole = String(rawRole).toUpperCase()
|
||||
|
||||
// 调试日志:如果跳转有问题,请按 F12 查看控制台输出
|
||||
if (to.path.includes('/system')) {
|
||||
console.log(`路由守卫检查: Path=${to.path}, UserRole=${userRole}, Required=${to.meta.roles}`)
|
||||
}
|
||||
|
||||
if (to.path === '/login') {
|
||||
if (token) {
|
||||
@ -202,10 +247,13 @@ router.beforeEach((to, from, next) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 权限检查逻辑
|
||||
if (to.meta.roles && Array.isArray(to.meta.roles)) {
|
||||
// [修复] to.meta.roles 里已经是大写了,userRole 也转大写了,现在可以安全比对
|
||||
if (to.meta.roles.includes(userRole)) {
|
||||
next()
|
||||
} else {
|
||||
console.warn(`权限不足: 用户角色 ${userRole} 不在允许列表 ${to.meta.roles} 中`)
|
||||
next('/dashboard')
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -11,46 +11,40 @@ export const useUserStore = defineStore('user', () => {
|
||||
// 2. Actions
|
||||
// 登录逻辑
|
||||
const handleLogin = async (loginForm: any) => {
|
||||
try {
|
||||
const res = await login(loginForm)
|
||||
const res = await login(loginForm)
|
||||
|
||||
// [调试日志] 查看实际返回的数据结构
|
||||
console.log('Login API Response:', res)
|
||||
// [调试日志] 查看实际返回的数据结构
|
||||
console.log('Login API Response:', res)
|
||||
|
||||
// ============================================================
|
||||
// [关键修复] 兼容 Axios 拦截器的不同处理方式
|
||||
// 如果拦截器已经返回了 response.data,那么 res 本身就是数据对象
|
||||
// ============================================================
|
||||
const data = res.data || res
|
||||
// ============================================================
|
||||
// [关键修复] 兼容 Axios 拦截器的不同处理方式
|
||||
// 如果拦截器已经返回了 response.data,那么 res 本身就是数据对象
|
||||
// ============================================================
|
||||
const data = res.data || res
|
||||
|
||||
// 安全检查:确保 data 存在且包含 access_token
|
||||
if (!data || !data.access_token) {
|
||||
console.error('Login Error: 响应数据中缺少 access_token', data)
|
||||
return false
|
||||
}
|
||||
|
||||
// 更新 Pinia 状态 (内存)
|
||||
token.value = data.access_token
|
||||
|
||||
// 处理用户信息 (确保后端返回结构中有 user 字段)
|
||||
if (data.user) {
|
||||
role.value = data.user.role || 'user' // 默认给个 user 角色防止空
|
||||
username.value = data.user.username || '用户'
|
||||
|
||||
// 持久化存储用户信息
|
||||
localStorage.setItem('role', role.value)
|
||||
localStorage.setItem('username', username.value)
|
||||
}
|
||||
|
||||
// 持久化存储 Token
|
||||
localStorage.setItem('token', data.access_token)
|
||||
|
||||
return true // 返回 true 表示登录成功
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
// 返回 false 表示登录失败,Login 组件会据此停止跳转
|
||||
return false
|
||||
// 安全检查:确保 data 存在且包含 access_token
|
||||
if (!data || !data.access_token) {
|
||||
console.error('Login Error: 响应数据中缺少 access_token', data)
|
||||
throw new Error('登录失败: 响应数据异常')
|
||||
}
|
||||
|
||||
// 更新 Pinia 状态 (内存)
|
||||
token.value = data.access_token
|
||||
|
||||
// 处理用户信息 (确保后端返回结构中有 user 字段)
|
||||
if (data.user) {
|
||||
role.value = data.user.role || 'user' // 默认给个 user 角色防止空
|
||||
username.value = data.user.username || '用户'
|
||||
|
||||
// 持久化存储用户信息
|
||||
localStorage.setItem('role', role.value)
|
||||
localStorage.setItem('username', username.value)
|
||||
}
|
||||
|
||||
// 持久化存储 Token
|
||||
localStorage.setItem('token', data.access_token)
|
||||
|
||||
return true // 返回 true 表示登录成功
|
||||
}
|
||||
|
||||
// 退出逻辑
|
||||
|
||||
@ -52,15 +52,20 @@ service.interceptors.response.use(
|
||||
let message = error.message || '请求失败'
|
||||
|
||||
// 处理 HTTP 状态码错误
|
||||
const isLoginEndpoint = error.config && error.config.url.includes('/login')
|
||||
|
||||
if (error.response) {
|
||||
const status = error.response.status
|
||||
const data = error.response.data
|
||||
|
||||
if (status === 401) {
|
||||
message = '登录已过期,请重新登录'
|
||||
// 这里可以触发登出逻辑
|
||||
localStorage.clear()
|
||||
window.location.href = '/login'
|
||||
// 对于登录接口的401错误,不执行登出重定向,仅提示错误
|
||||
if (!isLoginEndpoint) {
|
||||
message = '登录已过期,请重新登录'
|
||||
localStorage.clear()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
// 如果是登录接口,message会被后面的data.msg覆盖
|
||||
} else if (status === 403) {
|
||||
message = '权限不足'
|
||||
} else if (status === 404) {
|
||||
@ -73,7 +78,10 @@ service.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.error(message)
|
||||
// 登录接口的错误由调用方单独处理,不再显示全局提示
|
||||
if (!isLoginEndpoint) {
|
||||
ElMessage.error(message)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
374
inventory-web/src/views/bom/BomManage.vue
Normal file
374
inventory-web/src/views/bom/BomManage.vue
Normal file
@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-card shadow="always">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="title">BOM 配方管理</span>
|
||||
<div class="header-right">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索 编号/名称/规格/子件..."
|
||||
style="width: 300px; margin-right: 15px;"
|
||||
clearable
|
||||
@clear="fetchBomList"
|
||||
@keyup.enter="fetchBomList"
|
||||
>
|
||||
<template #append>
|
||||
<el-button :icon="Search" @click="fetchBomList" />
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="bomList" border style="width: 100%">
|
||||
<el-table-column prop="bom_no" label="BOM编号" min-width="180" sortable />
|
||||
<el-table-column prop="parent_name" label="父件名称" min-width="150" />
|
||||
<el-table-column prop="parent_spec" label="父件规格" min-width="150" />
|
||||
<el-table-column prop="version" label="版本" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ row.version }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_enabled ? 'success' : 'danger'">
|
||||
{{ row.is_enabled ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="child_count" label="子件数" width="80" align="center" />
|
||||
<el-table-column label="操作" width="250" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false">
|
||||
<el-form :model="form" label-width="120px" ref="formRef" :rules="rules">
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="父件 (成品)" prop="parent_id">
|
||||
<el-select
|
||||
v-model="form.parent_id"
|
||||
placeholder="请搜索并选择父件"
|
||||
filterable
|
||||
style="width: 100%"
|
||||
:disabled="isEditMode"
|
||||
class="beautified-select"
|
||||
@change="onParentChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in materialOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.name} (${item.spec})`"
|
||||
:value="item.id"
|
||||
>
|
||||
<div class="option-row">
|
||||
<span class="option-name">{{ item.name }}</span>
|
||||
<span class="option-spec">{{ item.spec }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="是否启用" prop="is_enabled">
|
||||
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="14">
|
||||
<el-form-item label="BOM 编号" required>
|
||||
<el-input v-model="form.bom_suffix" placeholder="输入后缀 (如 -001)" :disabled="isEditMode">
|
||||
<template #prepend v-if="form.bom_prefix">{{ form.bom_prefix }}</template>
|
||||
</el-input>
|
||||
<div style="font-size: 12px; color: #909399; line-height: 1.2; margin-top: 4px;">
|
||||
最终编号: <span style="font-weight: bold">{{ fullBomNo }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="10">
|
||||
<el-form-item label="版本号" prop="version">
|
||||
<el-input v-model="form.version" placeholder="如: V1.0" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div style="font-weight: bold; margin: 15px 0 10px 0; border-left: 4px solid #409EFF; padding-left: 10px;">子件列表</div>
|
||||
|
||||
<el-table :data="form.children" border style="width: 100%; margin-bottom: 15px" max-height="300">
|
||||
<el-table-column label="子件物料" min-width="280">
|
||||
<template #default="{ row, $index }">
|
||||
<el-select
|
||||
v-model="row.child_id"
|
||||
placeholder="请搜索原料"
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in materialOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.name} (${item.spec})`"
|
||||
:value="item.id"
|
||||
>
|
||||
<div class="option-row">
|
||||
<span class="option-name">{{ item.name }}</span>
|
||||
<span class="option-spec">{{ item.spec }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="用量" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.dosage" :min="0" :precision="4" style="width: 100%" controls-position="right" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="备注" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.remark" placeholder="备注" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="60" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button type="danger" link @click="removeChild($index)">删</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top: 10px; text-align: center;">
|
||||
<el-button type="primary" plain :icon="Plus" @click="addChild" style="width: 100%">添加一行子件</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
|
||||
import { Plus, Search } from '@element-plus/icons-vue'
|
||||
import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom'
|
||||
import { getMaterialBaseList } from '@/api/inbound/stock'
|
||||
|
||||
// 类型定义
|
||||
interface BomItem {
|
||||
bom_no: string
|
||||
parent_id: number
|
||||
parent_name: string
|
||||
version: string
|
||||
is_enabled: boolean
|
||||
child_count: number
|
||||
}
|
||||
interface MaterialBase {
|
||||
id: number
|
||||
name: string
|
||||
spec: string
|
||||
}
|
||||
interface ChildRow {
|
||||
child_id: number | null
|
||||
dosage: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const isEditMode = ref(false)
|
||||
|
||||
const bomList = ref<BomItem[]>([])
|
||||
const materialOptions = ref<MaterialBase[]>([])
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive({
|
||||
bom_prefix: '', // 自动生成的父件规格前缀
|
||||
bom_suffix: '', // 用户输入的后缀
|
||||
parent_id: null as number | null,
|
||||
version: 'V1.0',
|
||||
is_enabled: true,
|
||||
children: [] as ChildRow[]
|
||||
})
|
||||
|
||||
// 计算最终的 BOM 编号
|
||||
const fullBomNo = computed(() => {
|
||||
if (form.bom_prefix) {
|
||||
return form.bom_prefix + (form.bom_suffix ? ('-' + form.bom_suffix) : '')
|
||||
}
|
||||
return form.bom_suffix
|
||||
})
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
parent_id: [{ required: true, message: '请选择父件', trigger: 'change' }],
|
||||
version: [{ required: true, message: '请输入版本号', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
const dialogTitle = ref('新建 BOM')
|
||||
|
||||
const fetchBomList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getBomList({ keyword: searchKeyword.value })
|
||||
if (res.code === 200) bomList.value = res.data
|
||||
} catch (error) { ElMessage.error('网络错误') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const fetchMaterialOptions = async () => {
|
||||
try {
|
||||
const res = await getMaterialBaseList()
|
||||
if (res.code === 200) materialOptions.value = res.data
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
// 监听父件变化,自动设置前缀
|
||||
const onParentChange = (val: number) => {
|
||||
const selected = materialOptions.value.find(m => m.id === val)
|
||||
if (selected && selected.spec) {
|
||||
form.bom_prefix = selected.spec
|
||||
} else {
|
||||
form.bom_prefix = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
resetForm()
|
||||
dialogTitle.value = '新建 BOM'
|
||||
isEditMode.value = false
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = async (row: BomItem) => {
|
||||
await loadDetail(row.bom_no, row.version)
|
||||
dialogTitle.value = '编辑 BOM'
|
||||
isEditMode.value = true // 编辑时不允许修改编号前缀/后缀
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSaveAs = async (row: BomItem) => {
|
||||
await loadDetail(row.bom_no, row.version)
|
||||
dialogTitle.value = '另存为新版/变体'
|
||||
isEditMode.value = true // 另存为时,编号部分依然锁定(因为我们是在同一个编号下新增版本)
|
||||
|
||||
// 提示用户修改版本号
|
||||
form.version = form.version + '_V2'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const loadDetail = async (bomNo: string, version: string) => {
|
||||
try {
|
||||
const res = await getBomDetail(bomNo, version)
|
||||
if (res.code === 200) {
|
||||
const data = res.data
|
||||
form.parent_id = data.parent_id
|
||||
form.version = data.version
|
||||
form.is_enabled = data.is_enabled
|
||||
form.children = data.children.map((child: any) => ({
|
||||
child_id: child.child_id,
|
||||
dosage: child.dosage,
|
||||
remark: child.remark || ''
|
||||
}))
|
||||
|
||||
// 解析编号到 前缀/后缀
|
||||
if (data.parent_spec && bomNo.startsWith(data.parent_spec)) {
|
||||
form.bom_prefix = data.parent_spec
|
||||
// 移除前缀和可能的分隔符
|
||||
let suffix = bomNo.substring(data.parent_spec.length)
|
||||
if (suffix.startsWith('-')) suffix = suffix.substring(1)
|
||||
form.bom_suffix = suffix
|
||||
} else {
|
||||
form.bom_prefix = ''
|
||||
form.bom_suffix = bomNo
|
||||
}
|
||||
}
|
||||
} catch (e) { ElMessage.error('获取详情失败') }
|
||||
}
|
||||
|
||||
const handleDelete = (row: BomItem) => {
|
||||
ElMessageBox.confirm(`确定删除 ${row.bom_no} (${row.version}) 吗?`, '警告', { type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const res = await deleteBom(row.bom_no, row.version)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchBomList()
|
||||
}
|
||||
} catch (e) {}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.bom_prefix = ''
|
||||
form.bom_suffix = ''
|
||||
form.parent_id = null
|
||||
form.version = 'V1.0'
|
||||
form.is_enabled = true
|
||||
form.children = []
|
||||
if (formRef.value) formRef.value.resetFields()
|
||||
}
|
||||
|
||||
const addChild = () => form.children.push({ child_id: null, dosage: 0, remark: '' })
|
||||
const removeChild = (idx: number) => form.children.splice(idx, 1)
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
if (!fullBomNo.value) return ElMessage.warning('BOM编号不能为空')
|
||||
if (form.children.length === 0) return ElMessage.warning('请至少添加一个子件')
|
||||
|
||||
const payload = {
|
||||
bom_no: fullBomNo.value,
|
||||
version: form.version,
|
||||
parent_id: form.parent_id,
|
||||
is_enabled: form.is_enabled,
|
||||
children: form.children
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await saveBom(payload)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
fetchBomList()
|
||||
} else { ElMessage.error(res.msg || '保存失败') }
|
||||
} catch (e) { ElMessage.error('网络错误') }
|
||||
finally { saving.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchBomList()
|
||||
fetchMaterialOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.header-right { display: flex; align-items: center; }
|
||||
.title { font-size: 18px; font-weight: bold; }
|
||||
.option-row { display: flex; justify-content: space-between; width: 100%; }
|
||||
.option-name { font-weight: bold; color: #303133; }
|
||||
.option-spec { font-size: 12px; color: #909399; margin-left: 15px; }
|
||||
</style>
|
||||
@ -4,7 +4,12 @@
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="title">👋 欢迎回来,{{ userStore.username }}</span>
|
||||
<el-tag type="success">系统运行正常</el-tag>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<el-tag type="success">系统运行正常</el-tag>
|
||||
<el-button type="info" plain size="small" @click="openPrinterDialog" :icon="Setting" class="printer-btn">
|
||||
打印设置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -30,20 +35,103 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="printerDialogVisible" title="打印机 IP 配置" width="500px">
|
||||
<el-form :model="printerForm" label-width="120px">
|
||||
<el-form-item label="标签打印机 IP">
|
||||
<el-input v-model="printerForm.label_ip" placeholder="例如 192.168.9.221" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标签打印机端口">
|
||||
<el-input v-model.number="printerForm.label_port" placeholder="例如 9100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网络打印机 IP">
|
||||
<el-input v-model="printerForm.network_ip" placeholder="例如 192.168.9.250" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网络打印机端口">
|
||||
<el-input v-model.number="printerForm.network_port" placeholder="例如 9100" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="printerDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="savePrinterConfig" :loading="loading">保存</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
// 1. 引入 User Store
|
||||
import { useUserStore } from '@/stores/user'
|
||||
// 引入需要的图标
|
||||
import { Box, TrendCharts, ShoppingCart, Operation } from '@element-plus/icons-vue'
|
||||
import { Box, TrendCharts, ShoppingCart, Operation, Setting } from '@element-plus/icons-vue'
|
||||
import { getPrinterConfig, updatePrinterConfig } from '@/api/common/print'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
// 2. 实例化 store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 打印机配置相关
|
||||
const printerDialogVisible = ref(false)
|
||||
const printerForm = reactive({
|
||||
label_ip: '',
|
||||
label_port: '',
|
||||
network_ip: '',
|
||||
network_port: ''
|
||||
})
|
||||
const loading = ref(false)
|
||||
|
||||
const openPrinterDialog = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await getPrinterConfig()
|
||||
if (res.code === 200) {
|
||||
const config = res.data
|
||||
printerForm.label_ip = config.label_printer?.ip || ''
|
||||
printerForm.label_port = config.label_printer?.port || ''
|
||||
printerForm.network_ip = config.network_printer?.ip || ''
|
||||
printerForm.network_port = config.network_printer?.port || ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
printerDialogVisible.value = true
|
||||
}
|
||||
|
||||
const savePrinterConfig = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const config = {
|
||||
label_printer: {
|
||||
ip: printerForm.label_ip,
|
||||
port: Number(printerForm.label_port)
|
||||
},
|
||||
network_printer: {
|
||||
ip: printerForm.network_ip,
|
||||
port: Number(printerForm.network_port)
|
||||
}
|
||||
}
|
||||
const res = await updatePrinterConfig(config)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('保存成功')
|
||||
printerDialogVisible.value = false
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('请求异常')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 统一跳转函数
|
||||
const handleNav = (path: string) => {
|
||||
router.push(path)
|
||||
|
||||
@ -11,6 +11,18 @@
|
||||
@input="handleInputSearch"
|
||||
/>
|
||||
|
||||
<el-select
|
||||
v-model="queryParams.company"
|
||||
placeholder="所属公司"
|
||||
clearable
|
||||
filterable
|
||||
default-first-option
|
||||
style="width: 120px; margin-right: 10px;"
|
||||
@change="handleQuery"
|
||||
>
|
||||
<el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-model="queryParams.category"
|
||||
placeholder="类别"
|
||||
@ -18,8 +30,9 @@
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
style="width: 140px; margin-right: 10px;"
|
||||
style="width: 240px; margin-right: 10px;"
|
||||
@change="handleQuery"
|
||||
popper-class="long-dropdown"
|
||||
>
|
||||
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
@ -33,6 +46,7 @@
|
||||
default-first-option
|
||||
style="width: 140px; margin-right: 10px;"
|
||||
@change="handleQuery"
|
||||
popper-class="long-dropdown"
|
||||
>
|
||||
<el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
@ -53,6 +67,10 @@
|
||||
</div>
|
||||
|
||||
<div class="right-toolbar">
|
||||
<el-button type="success" plain @click="handleExport" :loading="exportLoading" style="margin-right: 10px">
|
||||
<el-icon style="margin-right: 5px"><Download /></el-icon>导出库存统计
|
||||
</el-button>
|
||||
|
||||
<el-button type="primary" @click="handleAdd" style="margin-right: 10px">
|
||||
<el-icon style="margin-right: 5px"><Plus /></el-icon>新增
|
||||
</el-button>
|
||||
@ -81,13 +99,15 @@
|
||||
列展示设置
|
||||
</div>
|
||||
<el-checkbox v-model="columns.id.visible" label="ID" />
|
||||
<el-checkbox v-model="columns.companyName.visible" label="所属公司" />
|
||||
<el-checkbox v-model="columns.name.visible" label="名称" />
|
||||
<el-checkbox v-model="columns.commonName.visible" label="俗名" />
|
||||
<el-checkbox v-model="columns.category.visible" label="类别" />
|
||||
<el-checkbox v-model="columns.type.visible" label="类型" />
|
||||
<el-checkbox v-model="columns.spec.visible" label="规格型号" />
|
||||
<el-checkbox v-model="columns.unit.visible" label="单位" />
|
||||
<el-checkbox v-model="columns.visibilityLevel.visible" label="可见等级" />
|
||||
<el-checkbox v-model="columns.inventory.visible" label="库存数" />
|
||||
<el-checkbox v-model="columns.available.visible" label="可用数" />
|
||||
<el-checkbox v-model="columns.files.visible" label="资料" />
|
||||
<el-checkbox v-model="columns.isEnabled.visible" label="状态" />
|
||||
</div>
|
||||
@ -104,6 +124,13 @@
|
||||
style="width: 100%; margin-top: 15px"
|
||||
>
|
||||
<el-table-column v-if="columns.id.visible" prop="id" label="ID" min-width="80" align="center" fixed="left" />
|
||||
|
||||
<el-table-column v-if="columns.companyName.visible" prop="companyName" label="所属公司" min-width="100" align="center" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.companyName || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.name.visible" prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||
|
||||
<el-table-column v-if="columns.commonName.visible" prop="commonName" label="俗名" min-width="140" show-overflow-tooltip>
|
||||
@ -113,7 +140,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.category.visible" prop="category" label="类别" min-width="120" align="center" show-overflow-tooltip>
|
||||
<el-table-column v-if="columns.category.visible" prop="category" label="类别" min-width="140" show-overflow-tooltip>
|
||||
<template #default="scope">{{ scope.row.category || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.type.visible" prop="type" label="类型" min-width="120" align="center" show-overflow-tooltip>
|
||||
@ -121,15 +148,61 @@
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.spec.visible" prop="spec" label="规格型号" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column v-if="columns.unit.visible" prop="unit" label="单位" min-width="80" align="center" />
|
||||
<el-table-column v-if="columns.visibilityLevel.visible" prop="visibilityLevel" label="可见等级" min-width="100" align="center">
|
||||
<template #default="scope">L{{ scope.row.visibilityLevel }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.files.visible" label="资料" min-width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-button v-if="scope.row.generalImage" link type="primary" :icon="Picture" title="查看图片" @click="openLink(scope.row.generalImage)" />
|
||||
<el-button v-if="scope.row.generalManual" link type="primary" :icon="Document" title="查看说明书" @click="openLink(scope.row.generalManual)" />
|
||||
|
||||
<el-table-column v-if="columns.inventory.visible" prop="inventoryCount" label="库存数" min-width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ fontWeight: 'bold', color: row.inventoryCount > 0 ? '#67C23A' : '#909399' }">
|
||||
{{ row.inventoryCount }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.available.visible" prop="availableCount" label="可用数" min-width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ fontWeight: 'bold', color: row.availableCount > 0 ? '#409EFF' : '#909399' }">
|
||||
{{ row.availableCount }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.files.visible" label="资料" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; gap: 8px; justify-content: center;">
|
||||
<div v-if="getImagesOnly(row.generalImage).length > 0" class="file-preview-cell">
|
||||
<el-image
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
:src="getImageUrl(getImagesOnly(row.generalImage)[0])"
|
||||
:preview-src-list="getImagesOnly(row.generalImage).map(u => getImageUrl(u))"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
/>
|
||||
<span v-if="getImagesOnly(row.generalImage).length > 1" class="more-badge">+{{getImagesOnly(row.generalImage).length}}</span>
|
||||
</div>
|
||||
|
||||
<el-popover v-if="row.generalManual && row.generalManual.length > 0" placement="top" trigger="hover" width="200">
|
||||
<template #reference>
|
||||
<el-button link type="primary" :icon="Document" />
|
||||
</template>
|
||||
<div style="display: flex; flex-direction: column; gap: 5px;">
|
||||
<div v-for="(link, idx) in row.generalManual" :key="idx">
|
||||
<el-link v-if="isExternalLink(link)" :href="link" target="_blank" type="primary" :underline="false">
|
||||
说明书 {{idx+1}} <el-icon><Link /></el-icon>
|
||||
</el-link>
|
||||
<el-image v-else-if="isImageFile(link)"
|
||||
style="width: 100px; height: 100px"
|
||||
:src="getImageUrl(link)"
|
||||
:preview-src-list="[getImageUrl(link)]"
|
||||
fit="cover"
|
||||
preview-teleported
|
||||
/>
|
||||
<el-link v-else :href="getImageUrl(link)" target="_blank" type="info">PDF 文件 {{idx+1}}</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.isEnabled.visible" prop="isEnabled" label="是否启用" min-width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
@ -149,23 +222,23 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top: 20px; text-align: right;">
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
v-model:current-page="queryParams.pageNum"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@size-change="getList"
|
||||
@current-change="getList"
|
||||
:background="true"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
@size-change="handlePageSizeChange"
|
||||
@current-change="handlePageCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialog.visible"
|
||||
:title="dialog.title"
|
||||
width="600px"
|
||||
width="700px"
|
||||
append-to-body
|
||||
@close="cancel"
|
||||
>
|
||||
@ -186,16 +259,44 @@
|
||||
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="类别" prop="category">
|
||||
<el-form-item label="所属公司" prop="companyName">
|
||||
<el-autocomplete
|
||||
v-model="form.category"
|
||||
:fetch-suggestions="querySearchCategory"
|
||||
placeholder="可输入或选择"
|
||||
v-model="form.companyName"
|
||||
:fetch-suggestions="querySearchCompany"
|
||||
placeholder="请输入公司名称"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="类别" prop="category">
|
||||
<div style="display: flex; width: 100%; align-items: center;">
|
||||
<el-cascader
|
||||
v-model="tempCategoryPrefix"
|
||||
:options="categoryTreeOptions"
|
||||
:props="{ expandTrigger: 'hover', checkStrictly: true, emitPath: true }"
|
||||
placeholder="选择前缀层级"
|
||||
filterable
|
||||
clearable
|
||||
style="width: 50%;"
|
||||
/>
|
||||
<div style="padding: 0 8px; font-weight: bold; color: #909399;">/</div>
|
||||
<el-input
|
||||
v-model="tempCategorySuffix"
|
||||
placeholder="填写具体名称"
|
||||
clearable
|
||||
style="width: 50%;"
|
||||
/>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #E6A23C; margin-top: 4px; line-height: 1.2;">
|
||||
* 必须构成4层结构
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="类型" prop="type">
|
||||
<el-autocomplete
|
||||
@ -207,32 +308,81 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="规格型号" prop="spec">
|
||||
<el-input v-model="form.spec" placeholder="请输入规格型号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="计量单位" prop="unit">
|
||||
<el-input v-model="form.unit" placeholder="如: 个, 台, 米" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="可见等级" prop="visibilityLevel">
|
||||
<el-input-number v-model="form.visibilityLevel" :min="0" :max="9" label="等级" />
|
||||
<span style="margin-left: 10px; color: #999; font-size: 12px;">(0低-9高)</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="可见等级" prop="visibilityLevel">
|
||||
<el-input-number v-model="form.visibilityLevel" :min="0" :max="9" label="等级" />
|
||||
<span style="margin-left: 10px; color: #999; font-size: 12px;">(0为最低,9为最高)</span>
|
||||
<el-form-item label="产品图" prop="generalImage">
|
||||
<div class="upload-container">
|
||||
<el-upload
|
||||
v-model:file-list="fileListImage"
|
||||
action="#"
|
||||
list-type="picture-card"
|
||||
multiple
|
||||
:http-request="(opts) => customUpload(opts, 'generalImage')"
|
||||
:on-preview="handlePreviewPicture"
|
||||
:on-remove="(file) => handleRemoveImage(file, 'generalImage')"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
<div class="camera-card" @click="triggerCamera('generalImage')">
|
||||
<el-icon><Camera /></el-icon><span class="text">拍照</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="imageExternalUrl"
|
||||
placeholder="如有外部图片链接,请在此输入"
|
||||
style="margin-top: 8px;"
|
||||
clearable
|
||||
>
|
||||
<template #prefix><el-icon><Link /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="说明书链接" prop="generalManual">
|
||||
<el-input v-model="form.generalManual" placeholder="请输入说明书URL链接" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="产品图链接" prop="generalImage">
|
||||
<el-input v-model="form.generalImage" placeholder="请输入图片URL链接" />
|
||||
<el-form-item label="说明书" prop="generalManual">
|
||||
<div class="upload-container">
|
||||
<el-upload
|
||||
v-model:file-list="fileListManual"
|
||||
action="#"
|
||||
list-type="picture-card"
|
||||
multiple
|
||||
:http-request="(opts) => customUpload(opts, 'generalManual')"
|
||||
:on-preview="handlePreviewPicture"
|
||||
:on-remove="(file) => handleRemoveImage(file, 'generalManual')"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
<div class="camera-card" @click="triggerCamera('generalManual')">
|
||||
<el-icon><Camera /></el-icon><span class="text">拍照</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="manualExternalUrl"
|
||||
placeholder="如有外部说明书链接,请在此输入"
|
||||
style="margin-top: 8px;"
|
||||
clearable
|
||||
>
|
||||
<template #prefix><el-icon><Link /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="isEnabled">
|
||||
@ -251,37 +401,54 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%">
|
||||
<img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
|
||||
</el-dialog>
|
||||
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
|
||||
<WebRtcCamera
|
||||
ref="cameraRef"
|
||||
@photo-submit="handleCameraConfirm"
|
||||
@cancel="cameraDialogVisible = false"
|
||||
/>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue';
|
||||
import { Plus, Picture, Document, Refresh, Setting, Rank } from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download } from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
|
||||
import {
|
||||
listMaterialBase,
|
||||
addMaterialBase,
|
||||
updateMaterialBase,
|
||||
delMaterialBase
|
||||
delMaterialBase,
|
||||
getMaterialBaseOptions,
|
||||
exportAssetStatistics // 导入导出API
|
||||
} from '@/api/material_base';
|
||||
import { uploadFile, deleteFile } from '@/api/common/upload';
|
||||
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
|
||||
|
||||
// --- 类型定义 ---
|
||||
interface MaterialBaseVO {
|
||||
id: number;
|
||||
companyName: string;
|
||||
name: string;
|
||||
commonName?: string; // ✅ 新增类型定义
|
||||
commonName?: string;
|
||||
category: string;
|
||||
type: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
visibilityLevel: number;
|
||||
generalManual?: string;
|
||||
generalImage?: string;
|
||||
generalManual: string[];
|
||||
generalImage: string[];
|
||||
isEnabled: number;
|
||||
statusLoading?: boolean;
|
||||
inventoryCount?: number;
|
||||
availableCount?: number;
|
||||
}
|
||||
|
||||
interface QueryParams {
|
||||
@ -290,38 +457,65 @@ interface QueryParams {
|
||||
keyword: string;
|
||||
category: string;
|
||||
type: string;
|
||||
company: string;
|
||||
isEnabled?: number;
|
||||
}
|
||||
|
||||
interface CascaderOption {
|
||||
value: string;
|
||||
label: string;
|
||||
children?: CascaderOption[];
|
||||
}
|
||||
|
||||
// --- 响应式数据 ---
|
||||
const loading = ref(false);
|
||||
const exportLoading = ref(false); // 导出加载状态
|
||||
const total = ref(0);
|
||||
const tableData = ref<MaterialBaseVO[]>([]);
|
||||
const submitLoading = ref(false);
|
||||
const tableSize = ref<'large' | 'default' | 'small'>('large');
|
||||
|
||||
// 文件上传相关
|
||||
const fileListImage = ref<any[]>([]);
|
||||
const fileListManual = ref<any[]>([]);
|
||||
const imageExternalUrl = ref('');
|
||||
const manualExternalUrl = ref('');
|
||||
const dialogVisibleImage = ref(false);
|
||||
const dialogImageUrl = ref('');
|
||||
const cameraDialogVisible = ref(false);
|
||||
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null);
|
||||
const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage');
|
||||
|
||||
const columns = reactive({
|
||||
id: { visible: true },
|
||||
id: { visible: false },
|
||||
companyName: { visible: true },
|
||||
name: { visible: true },
|
||||
commonName: { visible: true }, // ✅ 新增列控制
|
||||
commonName: { visible: true },
|
||||
category: { visible: true },
|
||||
type: { visible: true },
|
||||
spec: { visible: true },
|
||||
unit: { visible: true },
|
||||
visibilityLevel: { visible: true },
|
||||
inventory: { visible: true },
|
||||
available: { visible: true },
|
||||
files: { visible: true },
|
||||
isEnabled: { visible: true }
|
||||
});
|
||||
|
||||
const companyOptions = ref<string[]>([]);
|
||||
const categoryOptions = ref<string[]>([]);
|
||||
const typeOptions = ref<string[]>([]);
|
||||
const categoryTreeOptions = ref<CascaderOption[]>([]);
|
||||
|
||||
const tempCategoryPrefix = ref<string[]>([]);
|
||||
const tempCategorySuffix = ref<string>('');
|
||||
|
||||
const queryParams = reactive<QueryParams>({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
pageSize: 100,
|
||||
keyword: '',
|
||||
category: '',
|
||||
type: '',
|
||||
company: '',
|
||||
isEnabled: undefined
|
||||
});
|
||||
|
||||
@ -335,23 +529,48 @@ const formRef = ref<FormInstance>();
|
||||
|
||||
const initForm = {
|
||||
id: undefined,
|
||||
companyName: '',
|
||||
name: '',
|
||||
commonName: '', // ✅ 初始化新增字段
|
||||
commonName: '',
|
||||
category: '',
|
||||
type: '',
|
||||
spec: '',
|
||||
unit: '',
|
||||
visibilityLevel: 0,
|
||||
generalManual: '',
|
||||
generalImage: '',
|
||||
generalManual: [] as string[],
|
||||
generalImage: [] as string[],
|
||||
isEnabled: 1
|
||||
};
|
||||
|
||||
const form = ref({...initForm});
|
||||
|
||||
const validateCategoryLevel = (rule: any, value: any, callback: any) => {
|
||||
const prefixStr = tempCategoryPrefix.value.join('/');
|
||||
const suffixStr = tempCategorySuffix.value.trim();
|
||||
|
||||
if (!prefixStr && !suffixStr) {
|
||||
callback(new Error('请填写或选择类别'));
|
||||
return;
|
||||
}
|
||||
|
||||
let fullPath = '';
|
||||
if (prefixStr && suffixStr) fullPath = prefixStr + '/' + suffixStr;
|
||||
else if (prefixStr) fullPath = prefixStr;
|
||||
else fullPath = suffixStr;
|
||||
|
||||
const levels = fullPath.split('/').filter(p => p.trim() !== '').length;
|
||||
|
||||
if (levels !== 4) {
|
||||
callback(new Error(`必须严格满足4层结构,当前为 ${levels} 层`));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
name: [{ required: true, message: '请输入基础信息名称', trigger: 'blur' }],
|
||||
category: [{ required: true, message: '请选择或输入类别', trigger: 'change' }],
|
||||
companyName: [{ required: true, message: '请输入公司名称', trigger: 'change' }],
|
||||
category: [{ required: true, validator: validateCategoryLevel, trigger: 'change' }],
|
||||
type: [{ required: true, message: '请选择或输入类型', trigger: 'change' }],
|
||||
spec: [{ required: true, message: '请输入规格型号', trigger: 'blur' }],
|
||||
unit: [{ required: true, message: '请输入单位', trigger: 'blur' }]
|
||||
@ -359,22 +578,46 @@ const rules = reactive<FormRules>({
|
||||
|
||||
// --- 业务逻辑方法 ---
|
||||
|
||||
const extractDynamicOptions = (items: MaterialBaseVO[]) => {
|
||||
if (!items || items.length === 0) return;
|
||||
const newCategories = new Set(categoryOptions.value);
|
||||
const newTypes = new Set(typeOptions.value);
|
||||
items.forEach(item => {
|
||||
if (item.category) newCategories.add(item.category);
|
||||
if (item.type) newTypes.add(item.type);
|
||||
const buildCategoryTree = (categories: string[]): CascaderOption[] => {
|
||||
const root: CascaderOption[] = [];
|
||||
categories.forEach(cat => {
|
||||
if (!cat) return;
|
||||
const parts = cat.split('/');
|
||||
let currentLevel = root;
|
||||
parts.forEach((part, index) => {
|
||||
let existingNode = currentLevel.find(n => n.value === part);
|
||||
if (!existingNode) {
|
||||
existingNode = { value: part, label: part };
|
||||
currentLevel.push(existingNode);
|
||||
}
|
||||
if (index < parts.length - 1) {
|
||||
if (!existingNode.children) {
|
||||
existingNode.children = [];
|
||||
}
|
||||
currentLevel = existingNode.children;
|
||||
}
|
||||
});
|
||||
});
|
||||
categoryOptions.value = Array.from(newCategories);
|
||||
typeOptions.value = Array.from(newTypes);
|
||||
return root;
|
||||
};
|
||||
|
||||
const querySearchCategory = (queryString: string, cb: any) => {
|
||||
const getOptionsList = () => {
|
||||
getMaterialBaseOptions().then((res: any) => {
|
||||
if (res.code === 200) {
|
||||
categoryOptions.value = res.data.categories || [];
|
||||
typeOptions.value = res.data.types || [];
|
||||
companyOptions.value = res.data.companies || [];
|
||||
categoryTreeOptions.value = buildCategoryTree(categoryOptions.value);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error("获取筛选项失败", err);
|
||||
});
|
||||
};
|
||||
|
||||
const querySearchCompany = (queryString: string, cb: any) => {
|
||||
const results = queryString
|
||||
? categoryOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
|
||||
: categoryOptions.value;
|
||||
? companyOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
|
||||
: companyOptions.value;
|
||||
const formattedResults = results.map(item => ({ value: item }));
|
||||
cb(formattedResults);
|
||||
};
|
||||
@ -394,7 +637,6 @@ const getList = () => {
|
||||
if (response && response.data) {
|
||||
tableData.value = response.data.items;
|
||||
total.value = response.data.total;
|
||||
extractDynamicOptions(tableData.value);
|
||||
} else {
|
||||
tableData.value = [];
|
||||
total.value = 0;
|
||||
@ -409,7 +651,51 @@ const getList = () => {
|
||||
});
|
||||
};
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// [修改] 导出处理函数:修正文件名格式
|
||||
const handleExport = () => {
|
||||
exportLoading.value = true;
|
||||
const params = {
|
||||
keyword: queryParams.keyword,
|
||||
company: queryParams.company,
|
||||
category: queryParams.category,
|
||||
type: queryParams.type,
|
||||
isEnabled: queryParams.isEnabled
|
||||
};
|
||||
|
||||
exportAssetStatistics(params)
|
||||
.then((response: any) => {
|
||||
const blob = new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// 构造文件名:库存统计_YYYYMMDD_HHMMSS
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hour = String(now.getHours()).padStart(2, '0');
|
||||
const minute = String(now.getMinutes()).padStart(2, '0');
|
||||
const second = String(now.getSeconds()).padStart(2, '0');
|
||||
const filename = `库存统计_${year}${month}${day}_${hour}${minute}${second}.xlsx`;
|
||||
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
ElMessage.success('导出成功');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("导出失败", err);
|
||||
ElMessage.error('导出失败');
|
||||
})
|
||||
.finally(() => {
|
||||
exportLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
let searchTimer: any = null;
|
||||
const handleInputSearch = () => {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
@ -427,6 +713,7 @@ const resetQuery = () => {
|
||||
queryParams.keyword = '';
|
||||
queryParams.category = '';
|
||||
queryParams.type = '';
|
||||
queryParams.company = '';
|
||||
queryParams.isEnabled = undefined;
|
||||
handleQuery();
|
||||
};
|
||||
@ -435,6 +722,17 @@ const handleSizeChange = (command: 'large' | 'default' | 'small') => {
|
||||
tableSize.value = command;
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (val: number) => {
|
||||
queryParams.pageSize = val;
|
||||
queryParams.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handlePageCurrentChange = (val: number) => {
|
||||
queryParams.pageNum = val;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
resetForm();
|
||||
dialog.title = '新增基础信息';
|
||||
@ -445,21 +743,51 @@ const handleEdit = (row: MaterialBaseVO) => {
|
||||
resetForm();
|
||||
dialog.title = '编辑基础信息';
|
||||
dialog.visible = true;
|
||||
|
||||
nextTick(() => {
|
||||
Object.assign(form.value, row);
|
||||
const data = JSON.parse(JSON.stringify(row));
|
||||
Object.assign(form.value, data);
|
||||
|
||||
if (data.category) {
|
||||
const parts = data.category.split('/');
|
||||
if (parts.length > 0) {
|
||||
tempCategorySuffix.value = parts.pop() || '';
|
||||
tempCategoryPrefix.value = parts;
|
||||
} else {
|
||||
tempCategoryPrefix.value = [];
|
||||
tempCategorySuffix.value = data.category;
|
||||
}
|
||||
} else {
|
||||
tempCategoryPrefix.value = [];
|
||||
tempCategorySuffix.value = '';
|
||||
}
|
||||
|
||||
const images = row.generalImage || [];
|
||||
const manuals = row.generalManual || [];
|
||||
|
||||
const imgFiles = images.filter(u => !isExternalLink(u));
|
||||
const imgLinks = images.filter(u => isExternalLink(u));
|
||||
const manualFiles = manuals.filter(u => !isExternalLink(u));
|
||||
const manualLinks = manuals.filter(u => isExternalLink(u));
|
||||
|
||||
fileListImage.value = imgFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }));
|
||||
imageExternalUrl.value = imgLinks.length > 0 ? imgLinks[0] : '';
|
||||
|
||||
fileListManual.value = manualFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }));
|
||||
manualExternalUrl.value = manualLinks.length > 0 ? manualLinks[0] : '';
|
||||
});
|
||||
};
|
||||
|
||||
const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
|
||||
try {
|
||||
const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name });
|
||||
if (nameRes.data?.items?.some((item: MaterialBaseVO) => item.name === name)) {
|
||||
ElMessage.error(`添加失败:已存在名称为 "${name}" 的基础信息!`);
|
||||
const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name, category: '', type: '', company: '' });
|
||||
if (nameRes.data?.items?.some((item: MaterialBaseVO) => item.name === name && item.id !== form.value.id)) {
|
||||
ElMessage.error(`已存在名称为 "${name}" 的基础信息!`);
|
||||
return true;
|
||||
}
|
||||
const specRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: spec });
|
||||
if (specRes.data?.items?.some((item: MaterialBaseVO) => item.spec === spec)) {
|
||||
ElMessage.error(`添加失败:已存在规格/编号为 "${spec}" 的基础信息!`);
|
||||
const specRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: spec, category: '', type: '', company: '' });
|
||||
if (specRes.data?.items?.some((item: MaterialBaseVO) => item.spec === spec && item.id !== form.value.id)) {
|
||||
ElMessage.error(`已存在规格/编号为 "${spec}" 的基础信息!`);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
@ -475,21 +803,41 @@ const submitForm = async () => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
if (!form.value.id) {
|
||||
const isDuplicate = await checkDuplicate(form.value.name, form.value.spec);
|
||||
if (isDuplicate) {
|
||||
submitLoading.value = false;
|
||||
return;
|
||||
}
|
||||
const isDuplicate = await checkDuplicate(form.value.name, form.value.spec);
|
||||
if (isDuplicate) {
|
||||
submitLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const finalImageList = form.value.generalImage.filter(item => !isExternalLink(item));
|
||||
if (imageExternalUrl.value) finalImageList.push(imageExternalUrl.value);
|
||||
|
||||
const finalManualList = form.value.generalManual.filter(item => !isExternalLink(item));
|
||||
if (manualExternalUrl.value) finalManualList.push(manualExternalUrl.value);
|
||||
|
||||
const prefixStr = tempCategoryPrefix.value.join('/');
|
||||
const suffixStr = tempCategorySuffix.value.trim();
|
||||
let fullCategory = '';
|
||||
if (prefixStr && suffixStr) fullCategory = prefixStr + '/' + suffixStr;
|
||||
else fullCategory = prefixStr || suffixStr;
|
||||
|
||||
const payload = {
|
||||
...form.value,
|
||||
category: fullCategory,
|
||||
generalImage: finalImageList,
|
||||
generalManual: finalManualList
|
||||
};
|
||||
|
||||
const requestApi = form.value.id ? updateMaterialBase : addMaterialBase;
|
||||
const actionText = form.value.id ? '修改' : '新增';
|
||||
await requestApi(form.value);
|
||||
await requestApi(payload);
|
||||
|
||||
ElMessage.success(`${actionText}成功`);
|
||||
dialog.visible = false;
|
||||
getList();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
getOptionsList();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.msg || '保存失败');
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
@ -503,7 +851,15 @@ const cancel = () => {
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = {...initForm};
|
||||
form.value = JSON.parse(JSON.stringify(initForm));
|
||||
fileListImage.value = [];
|
||||
fileListManual.value = [];
|
||||
|
||||
tempCategoryPrefix.value = [];
|
||||
tempCategorySuffix.value = '';
|
||||
|
||||
imageExternalUrl.value = '';
|
||||
manualExternalUrl.value = '';
|
||||
if (formRef.value) formRef.value.resetFields();
|
||||
};
|
||||
|
||||
@ -527,17 +883,113 @@ const handleDelete = (row: MaterialBaseVO) => {
|
||||
ElMessage.success("删除成功");
|
||||
if (tableData.value.length === 1 && queryParams.pageNum > 1) queryParams.pageNum--;
|
||||
getList();
|
||||
getOptionsList();
|
||||
});
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const openLink = (url: string) => {
|
||||
if (!url) return;
|
||||
window.open(url, '_blank');
|
||||
// --- 文件上传辅助函数 ---
|
||||
|
||||
const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) }
|
||||
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
|
||||
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item)) }
|
||||
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp)$/i.test(url) }
|
||||
|
||||
const beforeAvatarUpload = (rawFile: any) => {
|
||||
const isTypeValid = ['image/jpeg', 'image/png', 'application/pdf'].includes(rawFile.type);
|
||||
if (!isTypeValid) {
|
||||
ElMessage.error('仅支持 JPG/PNG/PDF');
|
||||
return false;
|
||||
}
|
||||
if (rawFile.size / 1024 / 1024 > 10) {
|
||||
ElMessage.error('文件不能超过 10MB');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const customUpload = async (options: any, targetField: 'generalImage' | 'generalManual') => {
|
||||
const { file, onSuccess, onError } = options
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
try {
|
||||
const res: any = await uploadFile(formData)
|
||||
if (res.code === 200) {
|
||||
const newUrl = res.data.url
|
||||
form.value[targetField].push(newUrl)
|
||||
ElMessage.success('上传成功')
|
||||
onSuccess(res)
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上传失败');
|
||||
onError(new Error(res.msg))
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('网络错误');
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' | 'generalManual') => {
|
||||
try {
|
||||
const urlToRemove = form.value[targetField].find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
|
||||
form.value[targetField] = form.value[targetField].filter(u => u !== urlToRemove)
|
||||
if (!isExternalLink(urlToRemove)) {
|
||||
const filename = urlToRemove.split('/').pop();
|
||||
if (filename) await deleteFile(filename)
|
||||
}
|
||||
ElMessage.success('已从列表移除')
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
const handlePreviewPicture = (uploadFile: any) => {
|
||||
dialogImageUrl.value = uploadFile.url!;
|
||||
dialogVisibleImage.value = true
|
||||
}
|
||||
|
||||
const triggerCamera = (field: 'generalImage' | 'generalManual') => {
|
||||
currentCameraField.value = field;
|
||||
cameraDialogVisible.value = true;
|
||||
}
|
||||
|
||||
const handleCameraConfirm = async (file: File) => {
|
||||
if (!beforeAvatarUpload(file)) {
|
||||
cameraDialogVisible.value = false;
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const loadingInstance = ElLoading.service({ text: '照片上传中...', background: 'rgba(0, 0, 0, 0.7)' });
|
||||
|
||||
try {
|
||||
const res: any = await uploadFile(formData);
|
||||
if (res.code === 200) {
|
||||
const newUrl = res.data.url;
|
||||
const field = currentCameraField.value;
|
||||
form.value[field].push(newUrl);
|
||||
|
||||
const fileObj = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) };
|
||||
if (field === 'generalImage') {
|
||||
fileListImage.value.push(fileObj);
|
||||
} else {
|
||||
fileListManual.value.push(fileObj);
|
||||
}
|
||||
|
||||
ElMessage.success('拍照上传成功');
|
||||
cameraDialogVisible.value = false;
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上传失败');
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('上传过程中发生异常');
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
getOptionsList();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -566,4 +1018,29 @@ onMounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.upload-container { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
:deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; }
|
||||
:deep(.el-upload-list--picture-card .el-upload-list__item) { width: 100px; height: 100px; }
|
||||
.camera-card { width: 100px; height: 100px; background-color: #fbfdff; border: 1px dashed #c0ccda; border-radius: 6px; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; align-items: center; cursor: pointer; transition: all 0.3s; color: #8c939d; }
|
||||
.camera-card:hover { border-color: #409EFF; color: #409EFF; }
|
||||
.camera-card .text { font-size: 12px; margin-top: 5px; }
|
||||
.camera-card .el-icon { font-size: 24px; }
|
||||
|
||||
.file-preview-cell { display: flex; align-items: center; justify-content: center; position: relative; }
|
||||
.more-badge { position: absolute; top: -5px; right: -5px; background: #909399; color: #fff; border-radius: 10px; padding: 0 4px; font-size: 10px; transform: scale(0.9); }
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 增加下拉框的最大高度,使其能容纳更多选项而不必频繁滚动 */
|
||||
.long-dropdown .el-select-dropdown__wrap {
|
||||
max-height: 600px !important; /* 可以根据屏幕大小适当调整 */
|
||||
}
|
||||
</style>
|
||||
@ -1,62 +1,45 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-card shadow="always">
|
||||
<el-card shadow="always" class="no-print-content">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<span class="title">出库拣货选单</span>
|
||||
<span class="subtitle">(打印目标: 192.168.9.205)</span>
|
||||
<span class="title">出库拣货车</span>
|
||||
<span class="subtitle">(请添加需要出库的物品)</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-button type="primary" :icon="Plus" @click="openManualSelect">
|
||||
手动添加库存
|
||||
</el-button>
|
||||
<el-button type="warning" :icon="List" @click="openBomSelect">
|
||||
按 BOM 套餐添加
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button type="success" :icon="Printer" :disabled="selectedItems.length === 0" @click="handlePreview">
|
||||
生成并预览出库单
|
||||
生成预览 & 打印
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="filter-container">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="请输入物料名称 或 规格型号 进行搜索"
|
||||
class="search-input"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12" style="text-align: right;">
|
||||
<el-button type="primary" plain :icon="Upload" @click="handleImportBom">
|
||||
导入 BOM 表
|
||||
</el-button>
|
||||
<el-button type="primary" plain :icon="Plus" @click="handleCreateBom">
|
||||
创建 BOM 表
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="selectedItems.length > 0"
|
||||
:title="`当前已选中 ${selectedItems.length} 项物品`"
|
||||
type="success"
|
||||
v-if="selectedItems.length === 0"
|
||||
title="清单为空,请点击右上角按钮添加物品"
|
||||
type="info"
|
||||
center
|
||||
show-icon
|
||||
style="margin-bottom: 15px"
|
||||
style="margin-bottom: 20px"
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="filteredTableData"
|
||||
style="width: 100%"
|
||||
@selection-change="handleSelectionChange"
|
||||
row-key="uuid"
|
||||
v-else
|
||||
:data="selectedItems"
|
||||
border
|
||||
height="600"
|
||||
style="width: 100%"
|
||||
row-key="uniqueKey"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column type="index" label="序号" width="50" align="center" />
|
||||
|
||||
<el-table-column label="类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
@ -64,198 +47,277 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip>
|
||||
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="standard" label="规格型号" min-width="150" show-overflow-tooltip />
|
||||
|
||||
<el-table-column prop="warehouse_location" label="库位" width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-html="highlightKeyword(row.name)"></span>
|
||||
<span style="color: #409EFF; font-weight: bold;">{{ row.warehouse_location || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="standard" label="规格型号" min-width="150" show-overflow-tooltip>
|
||||
<el-table-column prop="available_quantity" label="当前库存" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-html="highlightKeyword(row.standard)"></span>
|
||||
<span style="color: green; font-weight: bold;">{{ row.available_quantity }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="batch_no" label="批号" width="120" />
|
||||
<el-table-column prop="uuid" label="条码UUID" width="280" show-overflow-tooltip />
|
||||
<el-table-column prop="create_time" label="入库时间" width="170" />
|
||||
<el-table-column label="本次出库数" width="180" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.export_quantity"
|
||||
:min="0"
|
||||
:max="row.available_quantity"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
controls-position="right"
|
||||
@change="(val) => handleMainQuantityChange(val, row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||
<template #default="{ $index }">
|
||||
<el-button type="danger" link @click="removeRow($index)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="selectedItems.length > 0" style="margin-top: 15px; text-align: right; color: #606266; font-size: 14px;">
|
||||
共 <span style="color: red; font-weight: bold;">{{ selectedItems.length }}</span> 种物品,
|
||||
合计出库 <span style="color: red; font-weight: bold;">{{ totalExportCount }}</span> 件
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="manualDialogVisible" title="选择库存物品" width="85%" top="5vh" destroy-on-close :close-on-click-modal="false">
|
||||
<div class="filter-container">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="请输入物料名称 或 规格型号 进行搜索"
|
||||
style="width: 300px"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
@input="filterStock"
|
||||
/>
|
||||
<span style="margin-left: 15px; color: #909399; font-size: 12px;">
|
||||
提示:点击表格行可勾选;<span style="color: #F56C6C; font-weight: bold;">修改数量会自动勾选</span>
|
||||
</span>
|
||||
</div>
|
||||
<el-table
|
||||
ref="manualTableRef"
|
||||
:data="filteredStockData"
|
||||
height="500"
|
||||
border
|
||||
row-key="uniqueKey"
|
||||
@selection-change="handleStockSelection"
|
||||
@row-click="handleRowClick"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" :reserve-selection="true" />
|
||||
<el-table-column label="类型" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="getTypeTag(row.type)">{{ row.typeLabel }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="名称" show-overflow-tooltip />
|
||||
<el-table-column prop="standard" label="规格" show-overflow-tooltip />
|
||||
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="available_quantity" label="可用库存" width="100" align="right" />
|
||||
|
||||
<el-table-column label="本次出库" width="160" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.export_quantity"
|
||||
:min="0"
|
||||
:max="row.available_quantity"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
placeholder="0"
|
||||
@click.stop
|
||||
@change="(val) => handleManualQuantityChange(val, row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<span style="float: left; line-height: 32px; color: #909399;">
|
||||
已勾选 {{ tempSelection.length }} 项
|
||||
</span>
|
||||
<el-button @click="manualDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmManualAdd">确认添加</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="bomSelectVisible" title="按 BOM 套餐添加" width="600px" destroy-on-close :close-on-click-modal="false">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="选择产品">
|
||||
<el-select v-model="selectedBomNo" filterable placeholder="请选择启用状态的 BOM 配方" style="width: 100%">
|
||||
<el-option
|
||||
v-for="b in bomOptions"
|
||||
:key="`${b.bom_no}_${b.version}`"
|
||||
:label="`${b.parent_name} - ${b.version}`"
|
||||
:value="b.bom_no"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="生产套数">
|
||||
<el-input-number v-model="bomSets" :min="1" label="套" style="width: 200px;" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="margin-left: 100px; color: #909399; font-size: 12px;">
|
||||
注意:系统将自动计算所需原料数量 ( 配方用量 × 套数 )。
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="bomSelectVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmBomAdd">一键计算并添加</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="previewVisible"
|
||||
title="出库单打印预览"
|
||||
title="出库单核对与打印"
|
||||
width="800px"
|
||||
destroy-on-close
|
||||
class="no-print-content"
|
||||
>
|
||||
<div class="print-preview-content">
|
||||
<el-alert title="请核对以下清单,确认无误后点击下方【确认打印】按钮" type="warning" :closable="false" style="margin-bottom: 10px;" />
|
||||
<el-alert title="请核对以下清单,确认无误后点击【确认打印】" type="info" :closable="false" style="margin-bottom: 10px;" />
|
||||
|
||||
<el-table :data="selectedItems" border size="small" style="width: 100%">
|
||||
<el-table :data="validSelectedItems" border size="small" style="width: 100%">
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="typeLabel" label="类型" width="80" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="standard" label="规格" />
|
||||
<el-table-column prop="batch_no" label="批号" width="100" />
|
||||
<el-table-column prop="uuid" label="条码UUID" show-overflow-tooltip />
|
||||
<el-table-column prop="warehouse_location" label="库位" width="100" />
|
||||
<el-table-column prop="export_quantity" label="本次出库" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<span style="font-weight: bold; color: #F56C6C; font-size: 16px;">{{ row.export_quantity }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="summary-info" style="margin-top: 20px; text-align: right; font-weight: bold;">
|
||||
总计出库数量: <span style="color: red; font-size: 18px;">{{ selectedItems.length }}</span> 件
|
||||
总计出库: <span style="color: red; font-size: 18px;">{{ totalExportCount }}</span> 件
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="previewVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="printLoading" @click="confirmPrint">
|
||||
确认打印
|
||||
|
||||
<el-button type="warning" :icon="Download" :loading="exportLoading" @click="confirmExport">
|
||||
导出 Excel
|
||||
</el-button>
|
||||
|
||||
<el-button type="primary" :icon="Printer" :loading="printLoading" @click="confirmPrint">
|
||||
确认打印 (A4)
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<div id="print-area">
|
||||
<div class="print-header">
|
||||
<h1>IRIS出库拣货确认单</h1>
|
||||
<div class="print-meta-row">
|
||||
<span>打印时间: {{ currentTime }}</span>
|
||||
</div>
|
||||
<div class="header-line"></div>
|
||||
</div>
|
||||
|
||||
<table class="print-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50px;">序号</th>
|
||||
<th>物料名称</th>
|
||||
<th>规格型号</th>
|
||||
<th style="width: 80px;">库位</th>
|
||||
<th style="width: 50px;">单位</th>
|
||||
<th style="width: 90px;">出库数量</th>
|
||||
<th style="width: 60px;">备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in validSelectedItems" :key="index">
|
||||
<td style="text-align: center;">{{ index + 1 }}</td>
|
||||
<td class="cell-padding">{{ item.name }}</td>
|
||||
<td class="cell-padding">{{ item.standard }}</td>
|
||||
<td style="text-align: center;">{{ item.warehouse_location || '-' }}</td>
|
||||
<td style="text-align: center;">件</td>
|
||||
<td style="text-align: center; font-weight: bold; font-size: 16px;">{{ item.export_quantity }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr v-if="validSelectedItems.length === 0">
|
||||
<td colspan="7" style="text-align: center; padding: 20px;">无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: right; font-weight: bold; padding-right: 15px;">合计:</td>
|
||||
<td style="text-align: center; font-weight: bold; font-size: 18px;">{{ totalExportCount }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<div class="print-footer">
|
||||
<div class="signature-item">
|
||||
<span class="sig-label">库管员签字:</span>
|
||||
<span class="sig-line"></span>
|
||||
</div>
|
||||
<div class="signature-item">
|
||||
<span class="sig-label">领料人签字:</span>
|
||||
<span class="sig-line"></span>
|
||||
</div>
|
||||
<div class="signature-item">
|
||||
<span class="sig-label">日期:</span>
|
||||
<span class="sig-line"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Printer, Search, Upload, Plus } from '@element-plus/icons-vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
|
||||
import { getAllStock, printSelectionList } from '@/api/inbound/stock'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
// --- 类型定义 ---
|
||||
interface BaseStockItem {
|
||||
id: number | string;
|
||||
standard: string;
|
||||
batch_no: string;
|
||||
uuid: string;
|
||||
create_time: string;
|
||||
// 原始数据中可能存在的字段
|
||||
material_name?: string;
|
||||
product_name?: string;
|
||||
}
|
||||
|
||||
// 统一后的显示对象
|
||||
interface DisplayItem extends BaseStockItem {
|
||||
name: string; // 统一后的名称
|
||||
type: 'material' | 'semi' | 'product'; // 类型标识
|
||||
typeLabel: string; // 类型中文名
|
||||
}
|
||||
import { getBomList, getBomDetail } from '@/api/bom'
|
||||
|
||||
// --- 状态变量 ---
|
||||
const loading = ref(false)
|
||||
const selectedItems = ref<any[]>([])
|
||||
const manualDialogVisible = ref(false)
|
||||
const bomSelectVisible = ref(false)
|
||||
const previewVisible = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
const printLoading = ref(false)
|
||||
const searchKeyword = ref('') // 搜索关键词
|
||||
const previewVisible = ref(false) // 预览弹窗控制
|
||||
|
||||
// 原始扁平化数据
|
||||
const allStockData = ref<DisplayItem[]>([])
|
||||
const allStockData = ref<any[]>([])
|
||||
const filteredStockData = ref<any[]>([])
|
||||
const searchKeyword = ref('')
|
||||
const tempSelection = ref<any[]>([])
|
||||
|
||||
// 当前选中的行
|
||||
const selectedItems = ref<DisplayItem[]>([])
|
||||
// 表格引用
|
||||
const manualTableRef = ref<InstanceType<typeof ElTable>>()
|
||||
|
||||
// --- 计算属性:前端模糊搜索过滤 ---
|
||||
const filteredTableData = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return allStockData.value
|
||||
}
|
||||
return allStockData.value.filter(item => {
|
||||
const nameMatch = item.name && item.name.toLowerCase().includes(keyword)
|
||||
const stdMatch = item.standard && item.standard.toLowerCase().includes(keyword)
|
||||
// 也可以加上UUID搜索
|
||||
const uuidMatch = item.uuid && item.uuid.toLowerCase().includes(keyword)
|
||||
return nameMatch || stdMatch || uuidMatch
|
||||
})
|
||||
// BOM 相关
|
||||
const bomOptions = ref<any[]>([])
|
||||
const selectedBomNo = ref('')
|
||||
const bomSets = ref(1)
|
||||
|
||||
// 打印相关
|
||||
const currentTime = ref('')
|
||||
|
||||
// --- 计算属性 ---
|
||||
const validSelectedItems = computed(() => {
|
||||
return selectedItems.value.filter(item => item.export_quantity > 0)
|
||||
})
|
||||
|
||||
// --- 方法 ---
|
||||
const totalExportCount = computed(() => {
|
||||
return validSelectedItems.value.reduce((sum, item) => sum + (item.export_quantity || 0), 0)
|
||||
})
|
||||
|
||||
// 1. 获取并处理数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getAllStock()
|
||||
// 假设 res 结构为 { materials: [], semis: [], products: [] }
|
||||
const materials = (res.materials || []).map((item: any) => ({
|
||||
...item,
|
||||
name: item.material_name,
|
||||
type: 'material',
|
||||
typeLabel: '采购件'
|
||||
}))
|
||||
const semis = (res.semis || []).map((item: any) => ({
|
||||
...item,
|
||||
name: item.material_name || item.product_name, // 半成品字段名不确定,做个兼容
|
||||
type: 'semi',
|
||||
typeLabel: '半成品'
|
||||
}))
|
||||
const products = (res.products || []).map((item: any) => ({
|
||||
...item,
|
||||
name: item.product_name,
|
||||
type: 'product',
|
||||
typeLabel: '成品'
|
||||
}))
|
||||
|
||||
// 合并所有数据
|
||||
allStockData.value = [...materials, ...semis, ...products]
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ElMessage.error('无法获取库存数据')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 表格选择
|
||||
const handleSelectionChange = (val: DisplayItem[]) => {
|
||||
selectedItems.value = val
|
||||
}
|
||||
|
||||
// 3. 点击“生成并预览”
|
||||
const handlePreview = () => {
|
||||
if (selectedItems.value.length === 0) {
|
||||
ElMessage.warning('请先勾选需要出库的物品')
|
||||
return
|
||||
}
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
// 4. 确认打印 (在弹窗中触发)
|
||||
const confirmPrint = async () => {
|
||||
printLoading.value = true
|
||||
try {
|
||||
// 这里调用真实的打印接口
|
||||
await printSelectionList(selectedItems.value)
|
||||
ElMessage.success('指令已发送,请前往打印机(192.168.9.205)取单')
|
||||
previewVisible.value = false // 关闭弹窗
|
||||
// 可选:打印后是否清空选中?
|
||||
// selectedItems.value = []
|
||||
// 注意:el-table 需要调用 clearSelection 方法来清空UI选中状态
|
||||
} catch (err) {
|
||||
ElMessage.error('打印请求失败')
|
||||
} finally {
|
||||
printLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 5. BOM 操作占位函数
|
||||
const handleImportBom = () => {
|
||||
// TODO: 打开上传文件的 Dialog 或者跳转页面
|
||||
ElMessage.info('点击了导入BOM,请实现具体逻辑')
|
||||
}
|
||||
|
||||
const handleCreateBom = () => {
|
||||
// TODO: 打开新建 BOM 的表单
|
||||
ElMessage.info('点击了创建BOM,请实现具体逻辑')
|
||||
}
|
||||
|
||||
// 辅助函数:高亮关键词 (可选)
|
||||
const highlightKeyword = (text: string) => {
|
||||
if (!searchKeyword.value || !text) return text
|
||||
const reg = new RegExp(searchKeyword.value, 'gi')
|
||||
return text.replace(reg, (match) => `<span style="color: red; font-weight: bold;">${match}</span>`)
|
||||
}
|
||||
|
||||
// 辅助函数:标签颜色
|
||||
// --- 辅助方法 ---
|
||||
const getTypeTag = (type: string) => {
|
||||
switch (type) {
|
||||
case 'material': return 'info'
|
||||
@ -265,38 +327,275 @@ const getTypeTag = (type: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
// --- 核心逻辑 1:手动添加库存 ---
|
||||
|
||||
const openManualSelect = async () => {
|
||||
manualDialogVisible.value = true
|
||||
|
||||
if (allStockData.value.length === 0) {
|
||||
try {
|
||||
const res: any = await getAllStock()
|
||||
const rawMaterials = (res.materials || []).map((i: any) => ({ ...i, type: 'material', typeLabel: '采购件' }))
|
||||
const rawSemis = (res.semis || []).map((i: any) => ({ ...i, type: 'semi', typeLabel: '半成品' }))
|
||||
const rawProducts = (res.products || []).map((i: any) => ({ ...i, type: 'product', typeLabel: '成品' }))
|
||||
|
||||
const list = [...rawMaterials, ...rawSemis, ...rawProducts]
|
||||
|
||||
allStockData.value = list.map((i: any) => ({
|
||||
...i,
|
||||
name: i.name || i.material_name || i.product_name || '未知名称',
|
||||
standard: i.standard || i.spec_model || '',
|
||||
// ★ 确保读取库位字段,如果没有则为空
|
||||
warehouse_location: i.warehouse_location || (i.warehouse_loc) || '',
|
||||
uniqueKey: `${i.type}_${i.id}`,
|
||||
available_quantity: parseFloat(i.available_quantity) || 0,
|
||||
export_quantity: 0
|
||||
}))
|
||||
|
||||
filteredStockData.value = allStockData.value
|
||||
} catch (e) {
|
||||
ElMessage.error('加载库存数据失败')
|
||||
}
|
||||
} else {
|
||||
searchKeyword.value = ''
|
||||
allStockData.value.forEach(item => item.export_quantity = 0)
|
||||
filteredStockData.value = allStockData.value
|
||||
}
|
||||
}
|
||||
|
||||
const filterStock = () => {
|
||||
const kw = searchKeyword.value.trim().toLowerCase()
|
||||
if (!kw) {
|
||||
filteredStockData.value = allStockData.value
|
||||
return
|
||||
}
|
||||
filteredStockData.value = allStockData.value.filter(i =>
|
||||
(i.name && i.name.toLowerCase().includes(kw)) ||
|
||||
(i.standard && i.standard.toLowerCase().includes(kw))
|
||||
)
|
||||
}
|
||||
|
||||
const handleStockSelection = (val: any[]) => { tempSelection.value = val }
|
||||
|
||||
// 点击行任意位置切换勾选
|
||||
const handleRowClick = (row: any) => {
|
||||
if (manualTableRef.value) {
|
||||
manualTableRef.value.toggleRowSelection(row, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗内:当数量变化时,自动联动勾选状态
|
||||
const handleManualQuantityChange = (val: number | undefined, row: any) => {
|
||||
if (!manualTableRef.value) return
|
||||
if (val && val > 0) {
|
||||
manualTableRef.value.toggleRowSelection(row, true)
|
||||
} else {
|
||||
manualTableRef.value.toggleRowSelection(row, false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmManualAdd = () => {
|
||||
if (tempSelection.value.length === 0) return ElMessage.warning('请先勾选需要添加的物品')
|
||||
|
||||
const newItems = tempSelection.value.filter(item =>
|
||||
!selectedItems.value.find(existing => existing.uniqueKey === item.uniqueKey)
|
||||
)
|
||||
|
||||
if (newItems.length === 0) {
|
||||
manualDialogVisible.value = false
|
||||
return ElMessage.warning('选中的物品已全部在清单中')
|
||||
}
|
||||
|
||||
const itemsToAdd = newItems.map(item => {
|
||||
const copy = JSON.parse(JSON.stringify(item))
|
||||
copy.export_quantity = item.export_quantity || 0
|
||||
return copy
|
||||
})
|
||||
|
||||
selectedItems.value.push(...itemsToAdd)
|
||||
manualDialogVisible.value = false
|
||||
tempSelection.value = []
|
||||
ElMessage.success(`成功添加 ${itemsToAdd.length} 项物品`)
|
||||
}
|
||||
|
||||
// 主界面数量变更逻辑 (降为0时弹窗移除)
|
||||
const handleMainQuantityChange = (val: number | undefined, row: any) => {
|
||||
if (val === 0) {
|
||||
ElMessageBox.confirm(
|
||||
`物品【${row.name}】数量为0,确定要从清单中移除吗?`,
|
||||
'移除确认',
|
||||
{
|
||||
confirmButtonText: '移除',
|
||||
cancelButtonText: '保留 (恢复为1)',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
const index = selectedItems.value.findIndex(item => item.uniqueKey === row.uniqueKey)
|
||||
if (index > -1) {
|
||||
selectedItems.value.splice(index, 1)
|
||||
ElMessage.success('已移除')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
row.export_quantity = 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- 核心逻辑 2:按 BOM 添加 ---
|
||||
|
||||
const openBomSelect = async () => {
|
||||
bomSelectVisible.value = true
|
||||
bomSets.value = 1
|
||||
try {
|
||||
const res = await getBomList({ active_only: true })
|
||||
bomOptions.value = res.data || []
|
||||
} catch (e) {
|
||||
ElMessage.error('加载 BOM 列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmBomAdd = async () => {
|
||||
if(!selectedBomNo.value) return ElMessage.warning('请选择 BOM');
|
||||
|
||||
if (allStockData.value.length === 0) {
|
||||
await openManualSelect()
|
||||
manualDialogVisible.value = false
|
||||
}
|
||||
|
||||
try {
|
||||
const detailRes = await getBomDetail(selectedBomNo.value)
|
||||
const bomRows = detailRes.data?.children || []
|
||||
|
||||
let addedCount = 0;
|
||||
bomRows.forEach((bomItem: any) => {
|
||||
const needQty = (parseFloat(bomItem.dosage) || 0) * bomSets.value
|
||||
// ★ BOM 添加时,匹配本地库存数据,带入库位信息
|
||||
const stockCandidate = allStockData.value.find(s =>
|
||||
(s.base_id && s.base_id == bomItem.child_id)
|
||||
)
|
||||
|
||||
if (stockCandidate) {
|
||||
const existing = selectedItems.value.find(e => e.uniqueKey === stockCandidate.uniqueKey)
|
||||
if (existing) {
|
||||
existing.export_quantity += needQty
|
||||
} else {
|
||||
const newItem = JSON.parse(JSON.stringify(stockCandidate))
|
||||
// 如果后端 BomService 也返回了 warehouse_location (聚合的),这里优先使用
|
||||
if (bomItem.warehouse_location) {
|
||||
newItem.warehouse_location = bomItem.warehouse_location
|
||||
}
|
||||
newItem.export_quantity = needQty
|
||||
selectedItems.value.push(newItem)
|
||||
}
|
||||
addedCount++
|
||||
}
|
||||
})
|
||||
|
||||
if(addedCount > 0) {
|
||||
ElMessage.success(`成功添加 BOM 相关物料,共 ${addedCount} 类`)
|
||||
bomSelectVisible.value = false
|
||||
} else {
|
||||
ElMessage.warning('库存中未找到该 BOM 所需的任何原料')
|
||||
}
|
||||
} catch(e) {
|
||||
ElMessage.error('获取 BOM 详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// --- 通用逻辑 ---
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
selectedItems.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const handlePreview = () => {
|
||||
if (validSelectedItems.value.length === 0) {
|
||||
ElMessage.warning('请先添加物品并填写出库数量')
|
||||
return
|
||||
}
|
||||
const now = new Date();
|
||||
currentTime.value = `${now.getFullYear()}-${now.getMonth()+1}-${now.getDate()} ${now.getHours()}:${now.getMinutes()}`;
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const confirmPrint = async () => {
|
||||
previewVisible.value = false;
|
||||
// 记录日志
|
||||
try {
|
||||
const payload = validSelectedItems.value.map(item => ({
|
||||
name: item.name, standard: item.standard, quantity: item.export_quantity
|
||||
}));
|
||||
printSelectionList(JSON.parse(JSON.stringify(payload))).catch(() => {});
|
||||
} catch (e) {}
|
||||
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
const confirmExport = () => {
|
||||
if (validSelectedItems.value.length === 0) return;
|
||||
exportLoading.value = true;
|
||||
try {
|
||||
let csvContent = "\uFEFF";
|
||||
csvContent += "类型,名称,规格型号,库位,本次出库数量\n";
|
||||
validSelectedItems.value.forEach(item => {
|
||||
const safeName = (item.name || '').replace(/,/g, ' ');
|
||||
const safeStd = (item.standard || '').replace(/,/g, ' ');
|
||||
const safeLoc = (item.warehouse_location || '').replace(/,/g, ' ');
|
||||
csvContent += `${item.typeLabel},${safeName},${safeStd},${safeLoc},${item.export_quantity}\n`;
|
||||
});
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
const timestamp = new Date().toISOString().slice(0,19).replace(/[-T:]/g, "");
|
||||
link.download = `出库拣货单_${timestamp}.csv`;
|
||||
link.click();
|
||||
ElMessage.success('导出成功');
|
||||
previewVisible.value = false;
|
||||
} catch (err) {
|
||||
ElMessage.error('导出文件失败');
|
||||
} finally {
|
||||
exportLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
/* ================= 屏幕显示样式 ================= */
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.header-left .title { font-size: 18px; font-weight: bold; margin-right: 10px; }
|
||||
.header-left .subtitle { font-size: 12px; color: #909399; }
|
||||
.filter-container { margin-bottom: 20px; }
|
||||
.app-container { height: 100vh; overflow: hidden; display: flex; flex-direction: column; padding: 20px; box-sizing: border-box; }
|
||||
.app-container .el-card { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
::v-deep(.el-card__body) { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
.header-left .title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.header-left .subtitle {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
background-color: #f5f7fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
/* ================= ★★★ 打印专用样式 ★★★ ================= */
|
||||
#print-area { display: none; }
|
||||
@media print {
|
||||
@page { margin: 0; size: auto; }
|
||||
body * { visibility: hidden; }
|
||||
.el-dialog__wrapper, .v-modal, .el-message, .no-print-content { display: none !important; }
|
||||
#print-area, #print-area * { visibility: visible; }
|
||||
#print-area {
|
||||
position: fixed; left: 0; top: 0; width: 100%; height: 100%;
|
||||
margin: 0; padding: 20mm; background-color: white;
|
||||
display: block !important; z-index: 99999;
|
||||
}
|
||||
.print-header { text-align: center; margin-bottom: 20px; }
|
||||
.print-header h1 { font-size: 24px; margin: 0 0 10px 0; font-weight: bold; color: #000; }
|
||||
.print-meta-row { display: flex; justify-content: flex-start; font-size: 12px; margin-bottom: 5px; }
|
||||
.header-line { border-bottom: 2px solid #000; margin-top: 5px; }
|
||||
.print-table { width: 100%; border-collapse: collapse; margin-bottom: 40px; border: 1px solid #000; }
|
||||
.print-table th, .print-table td { border: 1px solid #000; padding: 12px 8px; text-align: left; font-size: 14px; color: #000; }
|
||||
.print-table th { text-align: center; font-weight: bold; }
|
||||
.cell-padding { padding-left: 10px; }
|
||||
.print-footer { display: flex; justify-content: space-between; margin-top: 60px; padding: 0 20px; }
|
||||
.signature-item { display: flex; flex-direction: column; align-items: center; width: 30%; }
|
||||
.sig-label { font-size: 14px; margin-bottom: 40px; text-align: left; width: 100%; }
|
||||
.sig-line { border-bottom: 1px solid #000; width: 100%; height: 1px; display: block; }
|
||||
}
|
||||
</style>
|
||||
@ -16,18 +16,10 @@
|
||||
</template>
|
||||
|
||||
<div class="scan-section">
|
||||
<div v-if="showCamera" class="camera-wrapper">
|
||||
<QrScanner @decode="onScanSuccess" />
|
||||
<div class="scan-overlay">
|
||||
<el-button type="info" size="small" bg text @click="showCamera = false" icon="Close">
|
||||
关闭摄像头
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="camera-placeholder" @click="showCamera = true">
|
||||
<div class="camera-placeholder" @click="showCamera = true">
|
||||
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
|
||||
<span class="text">点击开启扫码</span>
|
||||
<span class="text">点击开启全屏扫码</span>
|
||||
</div>
|
||||
|
||||
<div class="input-box">
|
||||
@ -150,6 +142,22 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-if="showCamera" class="fullscreen-scanner-overlay">
|
||||
<div class="scanner-header">
|
||||
<el-button circle icon="Close" @click="showCamera = false" class="close-btn" />
|
||||
<span class="scanner-title">扫码模式</span>
|
||||
<div class="scanner-placeholder"></div> </div>
|
||||
|
||||
<div class="scanner-body">
|
||||
<QrScanner @decode="onScanSuccess" />
|
||||
</div>
|
||||
|
||||
<div class="scanner-footer">
|
||||
<p>请对准条形码,识别成功后自动添加</p>
|
||||
<p v-if="cartItems.length > 0" class="current-count">当前已添加: {{ cartItems.length }} 件</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="showSignatureDialog"
|
||||
fullscreen
|
||||
@ -201,7 +209,7 @@ import { useUserStore } from '@/stores/user'
|
||||
const barcodeInput = ref('')
|
||||
const cartItems = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const showCamera = ref(false) // ★ 核心修改:默认改为 false
|
||||
const showCamera = ref(false)
|
||||
const barcodeRef = ref()
|
||||
const formRef = ref()
|
||||
const userStore = useUserStore()
|
||||
@ -266,26 +274,21 @@ const onScanSuccess = (code: string) => {
|
||||
if (!code) return
|
||||
const trimCode = code.trim()
|
||||
|
||||
// ★★★ 核心修改:防误触校验 ★★★
|
||||
// 1. 正则校验:只允许 数字、字母、横杠、点
|
||||
// 这样可以屏蔽掉条码解析错误产生的 { } $ # 等乱码
|
||||
const validPattern = /^[A-Za-z0-9\-\.]+$/
|
||||
if (!validPattern.test(trimCode)) {
|
||||
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 长度校验:避免误扫到环境中的短数字
|
||||
if (trimCode.length < 3) {
|
||||
ElMessage.warning('扫描结果过短,请对准重试')
|
||||
return
|
||||
}
|
||||
|
||||
// 防抖:防止同一条码连续触发
|
||||
if (loading.value) return
|
||||
|
||||
barcodeInput.value = trimCode
|
||||
handleManualInput() // 复用手动输入逻辑
|
||||
handleManualInput()
|
||||
}
|
||||
|
||||
const handleManualInput = async () => {
|
||||
@ -343,8 +346,11 @@ const handleManualInput = async () => {
|
||||
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||
} finally {
|
||||
loading.value = false
|
||||
// 聚焦输入框,方便连续扫
|
||||
nextTick(() => { barcodeRef.value?.focus() })
|
||||
// 注意:全屏扫码模式下,我们不需要 refocus input,因为用户还在看摄像头
|
||||
// 只有在非全屏模式下才 focus
|
||||
if (!showCamera.value) {
|
||||
nextTick(() => { barcodeRef.value?.focus() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -505,21 +511,73 @@ onUnmounted(() => {
|
||||
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
|
||||
.header-price { font-size: 18px; color: #F56C6C; font-weight: bold; }
|
||||
|
||||
/* 扫码区 */
|
||||
/* 扫码区(卡片内触发器) */
|
||||
.scan-section { margin-bottom: 20px; }
|
||||
.camera-wrapper {
|
||||
height: 25vh; background: #000; border-radius: 12px; overflow: hidden; position: relative; margin-bottom: 10px;
|
||||
}
|
||||
.scan-overlay {
|
||||
position: absolute; bottom: 10px; right: 10px; z-index: 10;
|
||||
}
|
||||
.camera-placeholder {
|
||||
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
||||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||
color: #909399; margin-bottom: 10px; cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.camera-placeholder:active { background: #e6e8eb; }
|
||||
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
|
||||
|
||||
/* ★ 全屏扫码层样式 */
|
||||
.fullscreen-scanner-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #000;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scanner-header {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 15px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
.scanner-title { font-size: 16px; font-weight: bold; }
|
||||
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
|
||||
|
||||
.scanner-body {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* 强制子组件(QrScanner)填满容器 */
|
||||
:deep(.qr-scanner-container) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.scanner-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; }
|
||||
|
||||
/* 表单与购物车 */
|
||||
.cart-section { margin-bottom: 20px; }
|
||||
.form-section { background: #fff; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,16 +2,28 @@
|
||||
<div class="product-module">
|
||||
<div class="header-tools">
|
||||
<div class="left-tools">
|
||||
<el-select
|
||||
v-model="queryParams.company"
|
||||
placeholder="所属公司"
|
||||
class="filter-item-select"
|
||||
clearable
|
||||
filterable
|
||||
@change="fetchData"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="🔍 搜索物料 / SN / 工单 / 订单号..."
|
||||
class="search-input"
|
||||
placeholder="🔍 搜索物料 / SN / 工单..."
|
||||
class="filter-item-input"
|
||||
clearable
|
||||
@clear="fetchData"
|
||||
@keyup.enter="fetchData"
|
||||
style="width: 300px; margin-right: 10px;"
|
||||
style="width: 260px;"
|
||||
>
|
||||
<template #append><el-button :icon="Search" @click="fetchData" /></template>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
|
||||
<el-select
|
||||
@ -66,6 +78,11 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #default="scope" v-else-if="col.prop === 'company_name'">
|
||||
<el-tag v-if="scope.row.company_name" type="info" effect="plain" size="small" style="font-weight: bold;">{{ scope.row.company_name }}</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<template #default="scope" v-else-if="['serial_number'].includes(col.prop)">
|
||||
<div v-if="scope.row.serial_number" class="id-cell">
|
||||
<span class="prefix-tag sn">SN</span>
|
||||
@ -133,21 +150,27 @@
|
||||
|
||||
<el-pagination class="pagination-bar" v-model:current-page="queryParams.page" v-model:page-size="queryParams.pageSize" :total="total" layout="total, sizes, prev, pager, next" background @change="fetchData" />
|
||||
|
||||
<el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="1100px" top="5vh" :close-on-click-modal="false" class="stylish-dialog">
|
||||
<el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="1100px" top="5vh" :close-on-click-modal="false" class="stylish-dialog compact-layout">
|
||||
<div class="dialog-scroll-container">
|
||||
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form">
|
||||
|
||||
<div class="form-card basic-card">
|
||||
<div class="card-title"><el-icon class="icon"><Box /></el-icon><span>1. 基础信息</span></div>
|
||||
<div class="card-title">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<el-icon class="icon"><Box /></el-icon>
|
||||
<span>1. 基础信息</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
|
||||
<el-col :span="10">
|
||||
<el-form-item label="物料搜索" prop="base_id">
|
||||
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
|
||||
<el-select
|
||||
v-model="form.base_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
clearable
|
||||
placeholder="搜名称/规格..."
|
||||
:remote-method="handleSearchMaterial"
|
||||
@visible-change="handleMaterialDropdownVisible"
|
||||
@ -155,15 +178,28 @@
|
||||
style="width: 100%"
|
||||
@change="onMaterialSelected"
|
||||
default-first-option
|
||||
v-loadmore="loadMoreMaterials"
|
||||
popper-class="long-dropdown"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
|
||||
<div class="option-item">
|
||||
<span class="opt-name">{{ item.name }}</span>
|
||||
<span class="opt-spec">{{ item.spec }}</span>
|
||||
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag>
|
||||
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
|
||||
<div class="opt-main">
|
||||
<span class="opt-name" :title="item.name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="opt-meta">
|
||||
<span class="opt-spec" :title="item.spec">{{ item.spec || '-' }}</span>
|
||||
</div>
|
||||
<div class="opt-tags">
|
||||
<el-tag size="small" type="info" effect="light" class="company-tag">{{ item.company_name }}</el-tag>
|
||||
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag>
|
||||
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-option>
|
||||
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
|
||||
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
|
||||
</div>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@ -175,11 +211,12 @@
|
||||
</el-row>
|
||||
<div class="read-only-grid">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="规格"><el-input v-model="form.spec_model" disabled class="is-text-view" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" disabled class="is-text-view" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="所属公司"><el-input v-model="form.company_name" readonly class="is-text-view" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" readonly class="is-text-view" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="规格"><el-input v-model="form.spec_model" readonly class="is-text-view" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" readonly class="is-text-view" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" readonly class="is-text-view" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" readonly class="is-text-view" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
@ -190,8 +227,8 @@
|
||||
<div class="card-content">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="6"><el-form-item label="SKU" prop="sku"><el-input v-model="form.sku" placeholder="自动生成" disabled /></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="自动生成" /></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" /></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="自动生成" clearable /></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" placeholder="例如: B-01-01" clearable /></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="入库日期"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled /></el-form-item></el-col>
|
||||
</el-row>
|
||||
|
||||
@ -276,8 +313,36 @@
|
||||
<div class="card-title"><el-icon class="icon"><Setting /></el-icon><span>3. 生产与销售信息</span></div>
|
||||
<div class="card-content">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8"><el-form-item label="BOM编号"><el-input v-model="form.bom_code" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="BOM版本"><el-input v-model="form.bom_version" /></el-form-item></el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-form-item label="BOM编号">
|
||||
<el-select
|
||||
v-model="form.bom_code"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
placeholder="搜规格/编号"
|
||||
:remote-method="handleSearchBom"
|
||||
:loading="bomSearchLoading"
|
||||
@change="handleBomSelect"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in bomOptions"
|
||||
:key="`${item.bom_no}_${item.version}`"
|
||||
:label="item.bom_no"
|
||||
:value="`${item.bom_no}###${item.version}`"
|
||||
>
|
||||
<span style="float: left; font-weight: bold;">{{ item.bom_no }}</span>
|
||||
<span style="float: right; color: #8492a6; font-size: 13px; margin-left: 10px;">
|
||||
{{ item.version }} <span v-if="item.parent_spec">({{ item.parent_spec }})</span>
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8"><el-form-item label="BOM版本"><el-input v-model="form.bom_version" placeholder="自动填充" readonly/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="工单号"><el-input v-model="form.work_order_code" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
@ -317,11 +382,17 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<input type="file" ref="cameraInputRef" accept="image/*" capture="environment" style="display: none" @change="handleCameraFile" />
|
||||
|
||||
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%">
|
||||
<img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
|
||||
</el-dialog>
|
||||
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
|
||||
<WebRtcCamera
|
||||
ref="cameraRef"
|
||||
@photo-submit="handleCameraConfirm"
|
||||
@cancel="cameraDialogVisible = false"
|
||||
/>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
|
||||
<div style="text-align: center;">
|
||||
@ -347,22 +418,65 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElLoading } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import { getProductList, createProductInbound, updateProductInbound, deleteProductInbound, searchMaterialBase } from '@/api/inbound/product'
|
||||
import {
|
||||
getProductList,
|
||||
createProductInbound,
|
||||
updateProductInbound,
|
||||
deleteProductInbound,
|
||||
searchMaterialBase,
|
||||
searchBom,
|
||||
getFilterOptions // [新增]
|
||||
} from '@/api/inbound/product'
|
||||
import { uploadFile, deleteFile } from '@/api/inbound/buy'
|
||||
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
|
||||
import { getLabelPreview, executePrint } from '@/api/common/print'
|
||||
|
||||
// ------------------------------------
|
||||
// v-loadmore
|
||||
// ------------------------------------
|
||||
const vLoadmore = {
|
||||
mounted(el: any, binding: any) {
|
||||
const checkAndBind = () => {
|
||||
const dropDownWrap = document.querySelector('.long-dropdown .el-select-dropdown__wrap')
|
||||
if (dropDownWrap && !dropDownWrap.getAttribute('data-loadmore-bound')) {
|
||||
dropDownWrap.setAttribute('data-loadmore-bound', 'true')
|
||||
dropDownWrap.addEventListener('scroll', function (this: any) {
|
||||
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 1
|
||||
if (condition) {
|
||||
binding.value()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
setTimeout(checkAndBind, 500)
|
||||
el.addEventListener('click', () => setTimeout(checkAndBind, 300))
|
||||
}
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const visible = ref(false)
|
||||
const searchLoading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const dialogStatus = ref<'create' | 'update'>('create')
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
const formRef = ref()
|
||||
const queryParams = reactive({ page: 1, pageSize: 15, keyword: '', statuses: ['在库', '借库'] })
|
||||
const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', statuses: ['在库', '借库'], company: '' })
|
||||
const categoryOptions = ref<string[]>([])
|
||||
const typeOptions = ref<string[]>([])
|
||||
const companyOptions = ref<string[]>([]) // [新增]
|
||||
const materialOptions = ref<any[]>([])
|
||||
const searchPage = ref(1)
|
||||
const searchKeyword = ref('')
|
||||
const hasNextPage = ref(true)
|
||||
let searchTimer: any = null
|
||||
|
||||
// BOM 搜索相关
|
||||
const bomSearchLoading = ref(false)
|
||||
const bomOptions = ref<any[]>([])
|
||||
|
||||
// 打印相关变量
|
||||
const printVisible = ref(false)
|
||||
@ -374,18 +488,19 @@ const currentPrintData = ref<any>({})
|
||||
// 图片/拍照相关
|
||||
const dialogImageUrl = ref('')
|
||||
const dialogVisibleImage = ref(false)
|
||||
// 3个独立的列表
|
||||
const productPhotoList = ref<any[]>([]) // 成品实拍
|
||||
const qualityFileList = ref<any[]>([]) // 质量报告
|
||||
const inspectionFileList = ref<any[]>([]) // 检测报告
|
||||
|
||||
const cameraInputRef = ref<HTMLInputElement | null>(null)
|
||||
const cameraDialogVisible = ref(false)
|
||||
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null)
|
||||
const currentCameraField = ref<'product_photo' | 'quality_report_link' | 'inspection_report_link'>('product_photo')
|
||||
const quality_url = ref('')
|
||||
const inspection_url = ref('')
|
||||
|
||||
// [核心优化] 所有列定义
|
||||
const allColumns = [
|
||||
{ prop: 'company_name', label: '所属公司', minWidth: '100' }, // [新增]
|
||||
{ prop: 'material_name', label: '名称', minWidth: '140' },
|
||||
{ prop: 'sku', label: 'SKU', minWidth: '110' },
|
||||
{ prop: 'serial_number', label: '序列号', minWidth: '130' },
|
||||
@ -408,14 +523,13 @@ const allColumns = [
|
||||
{ prop: 'detail_link', label: '详情', minWidth: '100' }
|
||||
]
|
||||
|
||||
const defaultVisibleCols = ['material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
|
||||
const STORAGE_KEY = 'stock_product_visible_columns_v2'
|
||||
const getSavedColumns = () => { try { const saved = localStorage.getItem(STORAGE_KEY); return saved ? JSON.parse(saved) : defaultVisibleCols } catch (e) { return defaultVisibleCols } }
|
||||
const visibleColumnProps = ref(getSavedColumns())
|
||||
watch(visibleColumnProps, (newVal) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(newVal)) }, { deep: true })
|
||||
const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
|
||||
const visibleColumnProps = ref(defaultVisibleCols)
|
||||
|
||||
const form = reactive({
|
||||
id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', unit: '',
|
||||
id: undefined, base_id: undefined,
|
||||
company_name: '', // [新增]
|
||||
material_name: '', spec_model: '', material_type: '', category: '', unit: '',
|
||||
sku: '', barcode: '', serial_number: '', in_date: '',
|
||||
in_quantity: 1, stock_quantity: 1, available_quantity: 1,
|
||||
warehouse_location: '', status: '在库', quality_status: '合格',
|
||||
@ -426,11 +540,31 @@ const form = reactive({
|
||||
})
|
||||
|
||||
// ------------------------------------
|
||||
// 校验规则 (前端 pre-check)
|
||||
// BOM Search Logic
|
||||
// ------------------------------------
|
||||
const handleSearchBom = async (query: string) => {
|
||||
bomSearchLoading.value = true
|
||||
try {
|
||||
const res: any = await searchBom(query)
|
||||
bomOptions.value = res.data || []
|
||||
} finally { bomSearchLoading.value = false }
|
||||
}
|
||||
const handleBomSelect = (val: string) => {
|
||||
if (!val) {
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
return
|
||||
}
|
||||
const [code, version] = val.split('###')
|
||||
form.bom_code = code
|
||||
form.bom_version = version
|
||||
}
|
||||
|
||||
// ------------------------------------
|
||||
// Validation Logic
|
||||
// ------------------------------------
|
||||
const validateUnique = (rule: any, value: string, callback: any) => {
|
||||
if (!value) return callback()
|
||||
// 简单的列表前端查重
|
||||
const isDuplicate = tableData.value.some((row: any) => {
|
||||
if (dialogStatus.value === 'update' && row.id === form.id) return false
|
||||
if (rule.field === 'serial_number' && row.serial_number === value) return true
|
||||
@ -446,34 +580,57 @@ const rules = {
|
||||
in_quantity: [{ required: true, message: '必填', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const HISTORY_KEYS = { PRODUCTION_MANAGER: 'history_product_managers', MATERIAL: 'history_product_materials' }
|
||||
const saveToHistory = (key: string, value: string) => { if (!value) return; try { const existing = localStorage.getItem(key); let list = existing ? JSON.parse(existing) : []; list = list.filter((i: string) => i !== value); list.unshift(value); if (list.length > 20) list = list.slice(0, 20); localStorage.setItem(key, JSON.stringify(list)) } catch (e) {} }
|
||||
const getHistoryList = (key: string): any[] => { try { return (JSON.parse(localStorage.getItem(key) || '[]')).map((v: string) => ({ value: v })) } catch (e) { return [] } }
|
||||
const saveMaterialHistory = (item: any) => { if (!item || !item.id) return; const key = HISTORY_KEYS.MATERIAL; try { let list = JSON.parse(localStorage.getItem(key) || '[]'); list = list.filter((i: any) => i.id !== item.id); list.unshift({ ...item, isHistory: true }); if (list.length > 10) list = list.slice(0, 10); localStorage.setItem(key, JSON.stringify(list)) } catch (e) {} }
|
||||
const getMaterialHistory = () => { try { return JSON.parse(localStorage.getItem(HISTORY_KEYS.MATERIAL) || '[]') } catch (e) { return [] } }
|
||||
|
||||
// ------------------------------------
|
||||
// Material Search & Population Logic
|
||||
// ------------------------------------
|
||||
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterial('') }
|
||||
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
|
||||
|
||||
const handleSearchMaterialDebounced = (query: string) => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
handleSearchMaterial(query)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleSearchMaterial = async (query: string) => {
|
||||
searchLoading.value = true
|
||||
searchKeyword.value = query
|
||||
searchPage.value = 1
|
||||
materialOptions.value = []
|
||||
|
||||
try {
|
||||
const res: any = await searchMaterialBase(query)
|
||||
const res: any = await searchMaterialBase(query, 1)
|
||||
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
|
||||
if (!query) {
|
||||
const history = getMaterialHistory()
|
||||
const historyIds = new Set(history.map((h: any) => h.id))
|
||||
const filteredApi = apiResults.filter((apiItem: any) => !historyIds.has(apiItem.id))
|
||||
materialOptions.value = [...history, ...filteredApi]
|
||||
} else { materialOptions.value = apiResults }
|
||||
materialOptions.value = apiResults
|
||||
hasNextPage.value = res.has_next
|
||||
} finally { searchLoading.value = false }
|
||||
}
|
||||
|
||||
const loadMoreMaterials = async () => {
|
||||
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
|
||||
loadingMore.value = true
|
||||
searchPage.value += 1
|
||||
try {
|
||||
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
|
||||
if (res.data && res.data.length > 0) {
|
||||
const newItems = res.data.map((i: any) => ({...i, isHistory: false}))
|
||||
materialOptions.value.push(...newItems)
|
||||
hasNextPage.value = res.has_next
|
||||
} else {
|
||||
hasNextPage.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
searchPage.value -= 1
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onMaterialSelected = (val: number) => {
|
||||
const item = materialOptions.value.find(i => i.id === val)
|
||||
if (item) {
|
||||
saveMaterialHistory(item)
|
||||
// Auto-populate readonly fields
|
||||
form.company_name = item.company_name // [新增]
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.material_type = item.type
|
||||
@ -483,13 +640,13 @@ const onMaterialSelected = (val: number) => {
|
||||
}
|
||||
|
||||
// ------------------------------------
|
||||
// Autocomplete (Manager)
|
||||
// Autocomplete (Manager) - 后端驱动
|
||||
// ------------------------------------
|
||||
const createFilter = (qs: string) => { return (item: any) => (item.value.toLowerCase().indexOf(qs.toLowerCase()) === 0) }
|
||||
const getTableDataUnique = (field: string) => { return Array.from(new Set(tableData.value.map((i: any) => i[field]).filter(Boolean))).map(i => ({ value: i })) }
|
||||
const mixedSearch = (qs: string, tableField: string, storageKey: string, cb: any) => { const tableList = getTableDataUnique(tableField); const historyList = getHistoryList(storageKey); const map = new Map(); historyList.forEach(i => map.set(i.value, i)); tableList.forEach(i => map.set(i.value, i)); const allList = Array.from(map.values()); const results = qs ? allList.filter(createFilter(qs)) : allList; cb(results) }
|
||||
const querySearchManager = (qs: string, cb: any) => mixedSearch(qs, 'production_manager', HISTORY_KEYS.PRODUCTION_MANAGER, cb)
|
||||
const handleManagerSelect = (item: any) => saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, item.value)
|
||||
const querySearchManager = async (query: string, cb: any) => {
|
||||
cb([])
|
||||
}
|
||||
const handleManagerSelect = (item: any) => {
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
@ -501,6 +658,28 @@ const fetchData = async () => {
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
const fetchOptions = async () => {
|
||||
try {
|
||||
const res: any = await getFilterOptions()
|
||||
if (res.code === 200) {
|
||||
categoryOptions.value = res.data.categories
|
||||
typeOptions.value = res.data.types
|
||||
companyOptions.value = res.data.companies // [新增]
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fetch options failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
const resetQuery = () => {
|
||||
queryParams.keyword = ''
|
||||
queryParams.category = ''
|
||||
queryParams.material_type = ''
|
||||
queryParams.company = ''
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
dialogStatus.value = 'create'
|
||||
resetForm()
|
||||
@ -531,7 +710,11 @@ const handleUpdate = (row: any) => {
|
||||
inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
|
||||
const iLinks = iReports.filter(r => isExternalLink(r))
|
||||
inspection_url.value = iLinks.length > 0 ? iLinks[0] : ''
|
||||
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, isHistory: false }]
|
||||
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }]
|
||||
// 回显BOM
|
||||
if (form.bom_code) {
|
||||
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
|
||||
}
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
@ -558,25 +741,55 @@ const handleRemoveImage = async (uploadFile: any, targetField: 'product_photo' |
|
||||
ElMessage.success('已删除')
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
const triggerCamera = (field: any) => { currentCameraField.value = field; if (cameraInputRef.value) cameraInputRef.value.click() }
|
||||
const handleCameraFile = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement; if (input.files && input.files[0]) {
|
||||
const file = input.files[0]; if (!beforeAvatarUpload(file)) { input.value = ''; return }
|
||||
const formData = new FormData(); formData.append('file', file); const loadingMsg = ElMessage.loading({ message: '上传中...', duration: 0 })
|
||||
try {
|
||||
const res: any = await uploadFile(formData)
|
||||
if (res.code === 200) {
|
||||
const newUrl = res.data.url; const field = currentCameraField.value; form[field].push(newUrl)
|
||||
if (field === 'product_photo') productPhotoList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
|
||||
else if (field === 'quality_report_link') qualityFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
|
||||
else inspectionFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
|
||||
ElMessage.success('拍照上传成功')
|
||||
} else { ElMessage.error(res.msg || '上传失败') }
|
||||
} catch (e) { ElMessage.error('网络错误') } finally { loadingMsg.close(); input.value = '' }
|
||||
}
|
||||
}
|
||||
const handlePreviewPicture = (uploadFile: any) => { dialogImageUrl.value = uploadFile.url!; dialogVisibleImage.value = true }
|
||||
|
||||
const triggerCamera = (field: any) => {
|
||||
currentCameraField.value = field;
|
||||
cameraDialogVisible.value = true;
|
||||
}
|
||||
|
||||
const handleCameraConfirm = async (file: File) => {
|
||||
if (!beforeAvatarUpload(file)) {
|
||||
cameraDialogVisible.value = false;
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const loadingMsg = ElLoading.service({
|
||||
lock: true,
|
||||
text: '照片处理中...',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
});
|
||||
let success = false;
|
||||
try {
|
||||
const res: any = await uploadFile(formData);
|
||||
if (res.code === 200) {
|
||||
const newUrl = res.data.url;
|
||||
const field = currentCameraField.value;
|
||||
form[field].push(newUrl);
|
||||
const previewItem = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) };
|
||||
if (field === 'product_photo') {
|
||||
productPhotoList.value.push(previewItem);
|
||||
} else if (field === 'quality_report_link') {
|
||||
qualityFileList.value.push(previewItem);
|
||||
} else if (field === 'inspection_report_link') {
|
||||
inspectionFileList.value.push(previewItem);
|
||||
}
|
||||
ElMessage.success('拍照上传成功');
|
||||
success = true;
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上传失败');
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('网络错误,上传失败');
|
||||
} finally {
|
||||
loadingMsg.close();
|
||||
if (success) {
|
||||
cameraDialogVisible.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if(valid) {
|
||||
@ -598,10 +811,8 @@ const submitForm = async () => {
|
||||
const newItem = res.data
|
||||
if (newItem) { ElMessage.info('发送打印...'); try { await executePrint(newItem); ElMessage.success('指令已发送') } catch (e: any) { ElMessage.warning('打印失败') } }
|
||||
} else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') }
|
||||
saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, form.production_manager)
|
||||
visible.value = false; fetchData()
|
||||
} catch(e:any) {
|
||||
// 捕获后端报错
|
||||
ElMessage.error(e.msg || '操作失败')
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
@ -616,13 +827,16 @@ const handlePrint = async (row: any) => {
|
||||
}
|
||||
const confirmPrint = async () => { printing.value = true; try { await executePrint(currentPrintData.value); ElMessage.success('已发送'); printVisible.value = false } catch (e: any) { ElMessage.error('打印失败') } finally { printing.value = false } }
|
||||
const resetForm = () => {
|
||||
materialOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = ''
|
||||
materialOptions.value = []; bomOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = ''
|
||||
Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', unit: '', sku: '', barcode: '', serial_number: '', in_date: '', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', status: '在库', quality_status: '合格', bom_code: '', bom_version: '', work_order_code: '', order_id: '', production_manager: '', production_time_range: [], raw_material_cost: 0, manual_cost: 0, sale_price: 0, quality_report_link: [], inspection_report_link: [], product_photo: [], detail_link: '' })
|
||||
}
|
||||
const getStatusType = (s:string) => ({'在库':'success','出库':'info','借库':'warning','损耗':'danger'}[s]||'warning')
|
||||
const getQualityType = (s:string) => ({'合格':'success','不合格':'danger','待检':'info'}[s]||'info')
|
||||
const formatMoney = (val:any) => isNaN(Number(val)) ? '-' : `¥ ${Number(val).toFixed(2)}`
|
||||
onMounted(() => fetchData())
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
fetchOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -664,4 +878,31 @@ onMounted(() => fetchData())
|
||||
.camera-card:hover { border-color: #409EFF; color: #409EFF; }
|
||||
.camera-card .text { font-size: 12px; margin-top: 5px; }
|
||||
.camera-card .el-icon { font-size: 24px; }
|
||||
|
||||
/* [重点] 下拉框 Flex 布局 */
|
||||
.option-item { display: flex; align-items: center; padding: 8px 0; width: 100%; }
|
||||
.opt-main { flex: 1; min-width: 0; margin-right: 10px; }
|
||||
.opt-name { font-weight: 600; font-size: 14px; color: #333; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.opt-meta { width: 100px; text-align: right; flex-shrink: 0; margin-right: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.opt-spec { color: #999; font-size: 12px; }
|
||||
.opt-tags { display: flex; gap: 5px; flex-shrink: 0; }
|
||||
.company-tag { font-weight: bold; }
|
||||
|
||||
/* [新增] 修复 filter-item-select/input 样式 */
|
||||
.filter-item-select { /* 宽度已在行内样式控制 */ }
|
||||
.filter-item-input { /* 宽度已在行内样式控制 */ }
|
||||
.action-btn { font-weight: 500; }
|
||||
|
||||
/* [新增] 修复弹窗最小高度 */
|
||||
.dialog-scroll-container { min-height: 450px; }
|
||||
|
||||
/* [新增] 纯文本样式 */
|
||||
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background-color: transparent !important; border-bottom: 1px dashed #dcdfe6; border-radius: 0; padding-left: 0; }
|
||||
.is-text-view :deep(.el-input__inner) { color: #303133; font-weight: 600; font-size: 14px; cursor: text; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.long-dropdown { width: 580px !important; }
|
||||
.long-dropdown .el-select-dropdown__wrap { max-height: 320px !important; }
|
||||
.long-dropdown .el-input__suffix { z-index: 10; }
|
||||
</style>
|
||||
@ -2,26 +2,64 @@
|
||||
<div class="semi-module">
|
||||
<div class="header-tools">
|
||||
<div class="left-tools">
|
||||
|
||||
<el-select
|
||||
v-model="queryParams.company"
|
||||
placeholder="所属公司"
|
||||
class="filter-item-select"
|
||||
clearable
|
||||
filterable
|
||||
@change="fetchData"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="🔍 搜索物料 / 批号 / SN / 工单号 / BOM..."
|
||||
class="search-input"
|
||||
placeholder="请输入名称或规格"
|
||||
class="filter-item-input"
|
||||
clearable
|
||||
@clear="fetchData"
|
||||
@keyup.enter="fetchData"
|
||||
style="width: 300px; margin-right: 10px;"
|
||||
style="width: 240px;"
|
||||
>
|
||||
<template #append>
|
||||
<el-button :icon="Search" @click="fetchData"/>
|
||||
</template>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
|
||||
<el-select
|
||||
v-model="queryParams.category"
|
||||
placeholder="类别"
|
||||
class="filter-item-select"
|
||||
clearable
|
||||
filterable
|
||||
@change="fetchData"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-model="queryParams.material_type"
|
||||
placeholder="类型"
|
||||
class="filter-item-select"
|
||||
clearable
|
||||
filterable
|
||||
@change="fetchData"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
|
||||
<el-button type="primary" plain class="search-btn" @click="fetchData">搜索</el-button>
|
||||
<el-button class="reset-btn" @click="resetQuery">重置</el-button>
|
||||
|
||||
<el-select
|
||||
v-model="queryParams.statuses"
|
||||
multiple
|
||||
collapse-tags
|
||||
placeholder="状态筛选"
|
||||
style="width: 220px;"
|
||||
style="width: 200px; margin-left: 10px;"
|
||||
@change="fetchData"
|
||||
>
|
||||
<el-option label="在库" value="在库" />
|
||||
@ -31,12 +69,12 @@
|
||||
</div>
|
||||
|
||||
<div class="right-tools">
|
||||
<el-button type="primary" :icon="Plus" @click="handleCreate" class="action-btn">半成品入库登记</el-button>
|
||||
<el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="handleCreate" class="add-btn">半成品入库</el-button>
|
||||
<el-button :icon="Refresh" circle @click="fetchData" class="circle-btn" />
|
||||
|
||||
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
|
||||
<template #reference>
|
||||
<el-button :icon="Setting" class="action-btn">表头</el-button>
|
||||
<el-button :icon="Setting" circle class="circle-btn" />
|
||||
</template>
|
||||
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
|
||||
<div class="col-group-title">基础信息</div>
|
||||
@ -80,6 +118,11 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #default="scope" v-else-if="col.prop === 'company_name'">
|
||||
<el-tag v-if="scope.row.company_name" type="info" effect="plain" size="small" style="font-weight: bold;">{{ scope.row.company_name }}</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<template #default="scope" v-else-if="col.prop === 'sn_bn'">
|
||||
<div v-if="scope.row.serial_number" class="id-cell">
|
||||
<span class="prefix-tag sn">SN</span>
|
||||
@ -136,10 +179,7 @@
|
||||
<template #default="scope" v-else-if="['detail_link'].includes(col.prop)">
|
||||
<el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank"
|
||||
:underline="false">
|
||||
<el-icon>
|
||||
<Link/>
|
||||
</el-icon>
|
||||
查看
|
||||
<el-icon><Link/></el-icon> 查看
|
||||
</el-link>
|
||||
</template>
|
||||
|
||||
@ -152,10 +192,7 @@
|
||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="warning" size="default" @click="handlePrint(row)">
|
||||
<el-icon>
|
||||
<Printer/>
|
||||
</el-icon>
|
||||
打印
|
||||
<el-icon><Printer/></el-icon> 打印
|
||||
</el-button>
|
||||
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
|
||||
<el-popconfirm title="确定删除该条记录吗?不可恢复。" @confirm="handleDelete(row)" width="220">
|
||||
@ -172,7 +209,7 @@
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[15, 30, 50, 100]"
|
||||
:page-sizes="[100, 200, 500, 1000]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@size-change="fetchData"
|
||||
@ -186,35 +223,41 @@
|
||||
top="5vh"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
class="stylish-dialog"
|
||||
class="stylish-dialog compact-layout"
|
||||
>
|
||||
<div class="dialog-scroll-container">
|
||||
<el-form :model="form" label-width="100px" ref="formRef" :rules="rules" size="default" class="stylish-form">
|
||||
|
||||
<div class="form-card basic-card">
|
||||
<div class="card-title">
|
||||
<el-icon class="icon"><Box/></el-icon>
|
||||
<span>1. 基础信息</span>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<el-icon class="icon"><Box/></el-icon>
|
||||
<span>1. 基础信息</span>
|
||||
</div>
|
||||
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索选择半成品物料)</span>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
|
||||
<el-col :span="10">
|
||||
<el-form-item label="物料搜索" prop="base_id">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
|
||||
<el-select
|
||||
v-model="form.base_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
placeholder="输入名称或规格..."
|
||||
clearable
|
||||
placeholder="请输入名称或规格进行检索..."
|
||||
:remote-method="handleSearchMaterial"
|
||||
@visible-change="handleMaterialDropdownVisible"
|
||||
:loading="searchLoading"
|
||||
style="width: 100%"
|
||||
@change="onMaterialSelected"
|
||||
default-first-option
|
||||
v-loadmore="loadMoreMaterials"
|
||||
popper-class="long-dropdown"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
<el-option
|
||||
v-for="item in materialOptions"
|
||||
:key="item.id"
|
||||
@ -222,29 +265,40 @@
|
||||
:value="item.id"
|
||||
>
|
||||
<div class="option-item">
|
||||
<span class="opt-name">{{ item.name }}</span>
|
||||
<span class="opt-spec">{{ item.spec }}</span>
|
||||
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag>
|
||||
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
|
||||
<div class="opt-main">
|
||||
<span class="opt-name" :title="item.name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="opt-meta">
|
||||
<span class="opt-spec" :title="item.spec">{{ item.spec || '-' }}</span>
|
||||
</div>
|
||||
<div class="opt-tags">
|
||||
<el-tag size="small" type="info" effect="light" class="company-tag">{{ item.company_name }}</el-tag>
|
||||
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag>
|
||||
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-option>
|
||||
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
|
||||
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
|
||||
</div>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="14" style="display: flex; align-items: center;">
|
||||
<span class="search-tip">
|
||||
<el-icon><InfoFilled/></el-icon> 未输入时展示最新物料;输入关键词进行精确搜索。
|
||||
</span>
|
||||
<el-col :span="12" style="display: flex; align-items: center;">
|
||||
<span class="search-tip">
|
||||
<el-icon><InfoFilled/></el-icon> 支持名称、规格型号、公司名称模糊搜索
|
||||
</span>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="read-only-grid">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" disabled class="is-text-view"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" disabled class="is-text-view"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="所属公司"><el-input v-model="form.company_name" readonly class="is-text-view"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" readonly class="is-text-view"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" readonly class="is-text-view"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" readonly class="is-text-view"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" readonly class="is-text-view"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" readonly class="is-text-view"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
@ -260,8 +314,8 @@
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="6"><el-form-item label="编码/SKU" prop="sku"><el-input v-model="form.sku" placeholder="系统自动生成" disabled/></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="入库日期" prop="in_date"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled/></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="扫描条码"/></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" placeholder="例如: B-01-01"/></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="扫描条码" clearable/></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" placeholder="例如: B-01-01" clearable/></el-form-item></el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="identity-panel">
|
||||
@ -357,15 +411,45 @@
|
||||
|
||||
<div class="form-card production-card">
|
||||
<div class="card-title">
|
||||
<el-icon class="icon"><Setting/></el-icon>
|
||||
<span>3. 生产与成本信息</span>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<el-icon class="icon"><Setting/></el-icon>
|
||||
<span>3. 生产与成本信息</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="divider-text">生产任务信息</div>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8"><el-form-item label="工单号"><el-input v-model="form.work_order_code" placeholder="WO-xxx"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="BOM编号"><el-input v-model="form.bom_code" placeholder="BOM-xxx"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="BOM版本"><el-input v-model="form.bom_version" placeholder="v1.0"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="工单号"><el-input v-model="form.work_order_code" placeholder="WO-xxx" clearable/></el-form-item></el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-form-item label="BOM编号">
|
||||
<el-select
|
||||
v-model="form.bom_code"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
placeholder="搜规格/编号"
|
||||
:remote-method="handleSearchBom"
|
||||
:loading="bomSearchLoading"
|
||||
@change="handleBomSelect"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in bomOptions"
|
||||
:key="`${item.bom_no}_${item.version}`"
|
||||
:label="item.bom_no"
|
||||
:value="`${item.bom_no}###${item.version}`"
|
||||
>
|
||||
<span style="float: left; font-weight: bold;">{{ item.bom_no }}</span>
|
||||
<span style="float: right; color: #8492a6; font-size: 13px; margin-left: 10px;">
|
||||
{{ item.version }} <span v-if="item.parent_spec">({{ item.parent_spec }})</span>
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8"><el-form-item label="BOM版本"><el-input v-model="form.bom_version" placeholder="自动填充" readonly/></el-form-item></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="24">
|
||||
@ -403,14 +487,20 @@
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="visible = false" size="large">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitForm" size="large" class="confirm-btn">
|
||||
{{ dialogStatus === 'create' ? '确认入库并打印' : '保存修改' }}
|
||||
{{ dialogStatus === 'create' ? '提交并打印' : '保存修改' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<input type="file" ref="cameraInputRef" accept="image/*" capture="environment" style="display: none" @change="handleCameraFile"/>
|
||||
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
|
||||
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
|
||||
<WebRtcCamera
|
||||
ref="cameraRef"
|
||||
@photo-submit="handleCameraConfirm"
|
||||
@cancel="cameraDialogVisible = false"
|
||||
/>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
|
||||
<div style="text-align: center;">
|
||||
<div v-loading="printLoading" class="preview-box">
|
||||
@ -429,18 +519,43 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive, onMounted, watch} from 'vue'
|
||||
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Picture} from '@element-plus/icons-vue'
|
||||
import {ElMessage} from 'element-plus'
|
||||
import {ElMessage, ElLoading} from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
getSemiList,
|
||||
createSemiInbound,
|
||||
updateSemiInbound,
|
||||
deleteSemiInbound,
|
||||
searchMaterialBase
|
||||
searchMaterialBase,
|
||||
searchBom,
|
||||
getFilterOptions
|
||||
} from '@/api/inbound/semi'
|
||||
import { uploadFile, deleteFile } from '@/api/inbound/buy'
|
||||
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
|
||||
import {getLabelPreview, executePrint} from '@/api/common/print'
|
||||
|
||||
// ------------------------------------
|
||||
// 自定义指令:v-loadmore (适配 Teleport 到 Body 的下拉框)
|
||||
// ------------------------------------
|
||||
const vLoadmore = {
|
||||
mounted(el: any, binding: any) {
|
||||
const checkAndBind = () => {
|
||||
const dropDownWrap = document.querySelector('.long-dropdown .el-select-dropdown__wrap')
|
||||
if (dropDownWrap && !dropDownWrap.getAttribute('data-loadmore-bound')) {
|
||||
dropDownWrap.setAttribute('data-loadmore-bound', 'true')
|
||||
dropDownWrap.addEventListener('scroll', function (this: any) {
|
||||
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 1
|
||||
if (condition) {
|
||||
binding.value()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
setTimeout(checkAndBind, 500)
|
||||
el.addEventListener('click', () => setTimeout(checkAndBind, 300))
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------
|
||||
// 状态与变量
|
||||
// ------------------------------------
|
||||
@ -448,12 +563,24 @@ const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const visible = ref(false)
|
||||
const searchLoading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const dialogStatus = ref<'create' | 'update'>('create')
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
const formRef = ref()
|
||||
const queryParams = reactive({ page: 1, pageSize: 15, keyword: '', statuses: ['在库', '借库'] })
|
||||
const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '' })
|
||||
const categoryOptions = ref<string[]>([])
|
||||
const typeOptions = ref<string[]>([])
|
||||
const companyOptions = ref<string[]>([]) // [新增]
|
||||
const materialOptions = ref<any[]>([])
|
||||
const searchPage = ref(1)
|
||||
const searchKeyword = ref('')
|
||||
const hasNextPage = ref(true)
|
||||
let searchTimer: any = null
|
||||
|
||||
// BOM 搜索相关
|
||||
const bomSearchLoading = ref(false)
|
||||
const bomOptions = ref<any[]>([])
|
||||
|
||||
// 打印相关变量
|
||||
const printVisible = ref(false)
|
||||
@ -467,7 +594,8 @@ const dialogImageUrl = ref('')
|
||||
const dialogVisibleImage = ref(false)
|
||||
const arrivalFileList = ref<any[]>([])
|
||||
const reportFileList = ref<any[]>([])
|
||||
const cameraInputRef = ref<HTMLInputElement | null>(null)
|
||||
const cameraDialogVisible = ref(false)
|
||||
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null)
|
||||
const currentCameraField = ref<'arrival_photo' | 'quality_report_link'>('arrival_photo')
|
||||
const quality_report_url = ref('')
|
||||
|
||||
@ -476,6 +604,7 @@ const modeLocked = ref(false)
|
||||
|
||||
// 列定义
|
||||
const baseColumns = [
|
||||
{prop: 'company_name', label: '所属公司'}, // [新增]
|
||||
{prop: 'material_name', label: '名称'},
|
||||
{prop: 'category', label: '类别'},
|
||||
{prop: 'material_type', label: '类型'},
|
||||
@ -511,94 +640,101 @@ const stockColumns = [
|
||||
]
|
||||
const allColumns = [...baseColumns, ...stockColumns]
|
||||
|
||||
const STORAGE_KEY = 'stock_semi_visible_columns_v2'
|
||||
const defaultColumns = ['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 getSavedColumns = () => { try { const saved = localStorage.getItem(STORAGE_KEY); return saved ? JSON.parse(saved) : defaultColumns } catch (e) { return defaultColumns } }
|
||||
const visibleColumnProps = ref(getSavedColumns())
|
||||
watch(visibleColumnProps, (newVal) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(newVal)) }, {deep: true})
|
||||
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(defaultColumns)
|
||||
|
||||
const form = reactive({
|
||||
id: undefined, base_id: undefined as number | undefined, material_name: '', spec_model: '', category: '', unit: '', material_type: '',
|
||||
sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格',
|
||||
in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '',
|
||||
bom_code: '', bom_version: '', work_order_code: '', raw_material_cost: 0, manual_cost: 0, unit_total_cost: 0,
|
||||
production_manager: '', production_time_range: [] as string[], arrival_photo: [] as string[], quality_report_link: [] as string[], detail_link: ''
|
||||
id: undefined, base_id: undefined as number | undefined,
|
||||
company_name: '', // [新增]
|
||||
material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', bom_code: '', bom_version: '', work_order_code: '', raw_material_cost: 0, manual_cost: 0, unit_total_cost: 0, production_manager: '', production_time_range: [] as string[], arrival_photo: [] as string[], quality_report_link: [] as string[], detail_link: ''
|
||||
})
|
||||
|
||||
// ------------------------------------
|
||||
// 历史记录管理器
|
||||
// BOM Search Logic
|
||||
// ------------------------------------
|
||||
const HISTORY_KEYS = { PRODUCTION_MANAGER: 'history_production_managers', MATERIAL: 'history_semi_materials' }
|
||||
const saveToHistory = (key: string, value: string) => {
|
||||
if (!value) return
|
||||
const handleSearchBom = async (query: string) => {
|
||||
bomSearchLoading.value = true
|
||||
try {
|
||||
const existing = localStorage.getItem(key)
|
||||
let list = existing ? JSON.parse(existing) : []
|
||||
list = list.filter((i: string) => i !== value)
|
||||
list.unshift(value)
|
||||
if (list.length > 20) list = list.slice(0, 20)
|
||||
localStorage.setItem(key, JSON.stringify(list))
|
||||
} catch (e) { console.error('save history failed', e) }
|
||||
const res: any = await searchBom(query)
|
||||
bomOptions.value = res.data || []
|
||||
} finally { bomSearchLoading.value = false }
|
||||
}
|
||||
const getHistoryList = (key: string): any[] => { try { return (JSON.parse(localStorage.getItem(key) || '[]')).map((v: string) => ({value: v})) } catch (e) { return [] } }
|
||||
const saveMaterialHistory = (item: any) => {
|
||||
if (!item || !item.id) return
|
||||
const key = HISTORY_KEYS.MATERIAL
|
||||
try {
|
||||
let list = JSON.parse(localStorage.getItem(key) || '[]')
|
||||
list = list.filter((i: any) => i.id !== item.id)
|
||||
list.unshift({...item, isHistory: true})
|
||||
if (list.length > 10) list = list.slice(0, 10)
|
||||
localStorage.setItem(key, JSON.stringify(list))
|
||||
} catch (e) {}
|
||||
const handleBomSelect = (val: string) => {
|
||||
// val 格式为 bom_no###version
|
||||
if (!val) {
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
return
|
||||
}
|
||||
const [code, version] = val.split('###')
|
||||
form.bom_code = code
|
||||
form.bom_version = version
|
||||
}
|
||||
const getMaterialHistory = () => { try { return JSON.parse(localStorage.getItem(HISTORY_KEYS.MATERIAL) || '[]') } catch (e) { return [] } }
|
||||
|
||||
// ------------------------------------
|
||||
// Autocomplete & Search Logic
|
||||
// Autocomplete & Search Logic (后端 API 驱动)
|
||||
// ------------------------------------
|
||||
const createFilter = (queryString: string) => { return (item: any) => (item.value.toLowerCase().indexOf(queryString.toLowerCase()) === 0) }
|
||||
const getTableDataUnique = (field: string) => { return Array.from(new Set(tableData.value.map((i: any) => i[field]).filter(Boolean))).map(i => ({value: i})) }
|
||||
const mixedSearch = (queryString: string, tableField: string, storageKey: string, cb: any) => {
|
||||
const tableList = getTableDataUnique(tableField)
|
||||
const historyList = getHistoryList(storageKey)
|
||||
const map = new Map()
|
||||
historyList.forEach(i => map.set(i.value, i))
|
||||
tableList.forEach(i => map.set(i.value, i))
|
||||
const allList = Array.from(map.values())
|
||||
const results = queryString ? allList.filter(createFilter(queryString)) : allList
|
||||
cb(results)
|
||||
const querySearchManager = async (query: string, cb: any) => {
|
||||
cb([])
|
||||
}
|
||||
const handleManagerSelect = (item: any) => {
|
||||
}
|
||||
const querySearchManager = (qs: string, cb: any) => mixedSearch(qs, 'production_manager', HISTORY_KEYS.PRODUCTION_MANAGER, cb)
|
||||
const handleManagerSelect = (item: any) => saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, item.value)
|
||||
|
||||
// ------------------------------------
|
||||
// Material Search (Matches Buy.vue)
|
||||
// ------------------------------------
|
||||
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterial('') }
|
||||
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
|
||||
|
||||
const handleSearchMaterialDebounced = (query: string) => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
handleSearchMaterial(query)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleSearchMaterial = async (query: string) => {
|
||||
searchLoading.value = true
|
||||
searchKeyword.value = query
|
||||
searchPage.value = 1
|
||||
materialOptions.value = []
|
||||
|
||||
try {
|
||||
const res: any = await searchMaterialBase(query)
|
||||
const res: any = await searchMaterialBase(query, 1)
|
||||
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
|
||||
if (!query) {
|
||||
const history = getMaterialHistory()
|
||||
const historyIds = new Set(history.map((h: any) => h.id))
|
||||
materialOptions.value = [...history, ...apiResults.filter((apiItem: any) => !historyIds.has(apiItem.id))]
|
||||
} else { materialOptions.value = apiResults }
|
||||
materialOptions.value = apiResults
|
||||
hasNextPage.value = res.has_next
|
||||
} finally { searchLoading.value = false }
|
||||
}
|
||||
|
||||
const loadMoreMaterials = async () => {
|
||||
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
|
||||
loadingMore.value = true
|
||||
searchPage.value += 1
|
||||
try {
|
||||
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
|
||||
if (res.data && res.data.length > 0) {
|
||||
const newItems = res.data.map((i: any) => ({...i, isHistory: false}))
|
||||
materialOptions.value.push(...newItems)
|
||||
hasNextPage.value = res.has_next
|
||||
} else {
|
||||
hasNextPage.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
searchPage.value -= 1
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onMaterialSelected = (val: number) => {
|
||||
const item = materialOptions.value.find(i => i.id === val)
|
||||
if (item) {
|
||||
saveMaterialHistory(item)
|
||||
// Populate form fields
|
||||
form.company_name = item.company_name // [新增]
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
form.material_type = item.type
|
||||
// Trigger batch/serial logic specific to Semi
|
||||
checkHistoryAndSetMode(item.id)
|
||||
}
|
||||
}
|
||||
@ -611,7 +747,6 @@ const validateUnique = (rule: any, value: string, callback: any) => {
|
||||
const isDuplicate = tableData.value.some((row: any) => {
|
||||
if (dialogStatus.value === 'update' && row.id === form.id) return false
|
||||
if (rule.field === 'serial_number' && row.serial_number === value) return true
|
||||
// 批号校验需要同时匹配物料
|
||||
if (rule.field === 'batch_number' && row.batch_number === value && row.base_id === form.base_id) return true
|
||||
return false
|
||||
})
|
||||
@ -667,6 +802,28 @@ const fetchData = async () => {
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
const fetchOptions = async () => {
|
||||
try {
|
||||
const res: any = await getFilterOptions()
|
||||
if (res.code === 200) {
|
||||
categoryOptions.value = res.data.categories
|
||||
typeOptions.value = res.data.types
|
||||
companyOptions.value = res.data.companies // [新增]
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fetch options failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
const resetQuery = () => {
|
||||
queryParams.keyword = ''
|
||||
queryParams.category = ''
|
||||
queryParams.material_type = ''
|
||||
queryParams.company = ''
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
dialogStatus.value = 'create'
|
||||
resetForm()
|
||||
@ -683,7 +840,9 @@ const handleUpdate = (row: any) => {
|
||||
resetForm()
|
||||
modeLocked.value = true
|
||||
Object.assign(form, {
|
||||
id: row.id, base_id: row.base_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category,
|
||||
id: row.id, base_id: row.base_id,
|
||||
company_name: row.company_name, // [新增]
|
||||
material_name: row.material_name, spec_model: row.spec_model, category: row.category,
|
||||
unit: row.unit, material_type: row.material_type, sku: row.sku, barcode: row.barcode, in_date: row.inbound_date,
|
||||
warehouse_location: row.warehouse_loc, status: row.status, quality_status: row.quality_status,
|
||||
in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available),
|
||||
@ -702,7 +861,11 @@ const handleUpdate = (row: any) => {
|
||||
quality_report_url.value = reportLinks.length > 0 ? reportLinks[0] : ''
|
||||
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
|
||||
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
|
||||
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, isHistory: false }]
|
||||
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }]
|
||||
// 回显BOM,如果存在
|
||||
if (form.bom_code) {
|
||||
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
|
||||
}
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
@ -734,26 +897,44 @@ const handleRemoveImage = async (uploadFile: any, targetField: 'arrival_photo' |
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
const handlePreviewPicture = (uploadFile: any) => { dialogImageUrl.value = uploadFile.url!; dialogVisibleImage.value = true }
|
||||
const triggerCamera = (field: 'arrival_photo' | 'quality_report_link') => { currentCameraField.value = field; if (cameraInputRef.value) cameraInputRef.value.click() }
|
||||
const handleCameraFile = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0]
|
||||
if (!beforeAvatarUpload(file)) { input.value = ''; return }
|
||||
const formData = new FormData(); formData.append('file', file)
|
||||
const loadingMsg = ElMessage.loading({ message: '照片上传中...', duration: 0 })
|
||||
try {
|
||||
const res: any = await uploadFile(formData)
|
||||
if (res.code === 200) {
|
||||
const newUrl = res.data.url; const field = currentCameraField.value
|
||||
form[field].push(newUrl)
|
||||
if (field === 'arrival_photo') arrivalFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
|
||||
else reportFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
|
||||
ElMessage.success('拍照上传成功')
|
||||
} else { ElMessage.error(res.msg || '上传失败') }
|
||||
} catch (e) { ElMessage.error('网络错误,上传失败') } finally { loadingMsg.close(); input.value = '' }
|
||||
}
|
||||
const triggerCamera = (field: 'arrival_photo' | 'quality_report_link') => {
|
||||
currentCameraField.value = field;
|
||||
cameraDialogVisible.value = true;
|
||||
}
|
||||
const handleCameraConfirm = async (file: File) => {
|
||||
if (!beforeAvatarUpload(file)) {
|
||||
cameraDialogVisible.value = false;
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const loadingMsg = ElLoading.service({ text: '照片上传中...', background: 'rgba(0, 0, 0, 0.7)' });
|
||||
let success = false;
|
||||
try {
|
||||
const res: any = await uploadFile(formData);
|
||||
if (res.code === 200) {
|
||||
const newUrl = res.data.url;
|
||||
const field = currentCameraField.value;
|
||||
form[field].push(newUrl);
|
||||
if (field === 'arrival_photo') {
|
||||
arrivalFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) });
|
||||
} else if (field === 'quality_report_link') {
|
||||
reportFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) });
|
||||
}
|
||||
ElMessage.success('拍照上传成功');
|
||||
success = true;
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上传失败');
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('网络错误,上传失败');
|
||||
} finally {
|
||||
loadingMsg.close();
|
||||
if (success) {
|
||||
cameraDialogVisible.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
@ -777,10 +958,8 @@ const submitForm = async () => {
|
||||
catch (printErr: any) { ElMessage.warning('入库成功,但自动打印失败:' + (printErr.msg || '未知错误')) }
|
||||
}
|
||||
} else { await updateSemiInbound(form.id!, payload); ElMessage.success('更新成功') }
|
||||
saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, form.production_manager)
|
||||
await fetchData(); visible.value = false
|
||||
} catch (e: any) {
|
||||
// 捕获后端报错
|
||||
ElMessage.error(e.msg || '操作失败')
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
@ -795,14 +974,20 @@ const handlePrint = async (row: any) => {
|
||||
}
|
||||
const confirmPrint = async () => { printing.value = true; try { await executePrint(currentPrintData.value); ElMessage.success('指令已发送'); printVisible.value = false } catch (e: any) { ElMessage.error(e.msg || '打印失败') } finally { printing.value = false } }
|
||||
const resetForm = () => {
|
||||
materialOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = ''
|
||||
Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', bom_code: '', bom_version: '', work_order_code: '', raw_material_cost: 0, manual_cost: 0, unit_total_cost: 0, production_manager: '', production_time_range: [], arrival_photo: [], quality_report_link: [], detail_link: '' })
|
||||
materialOptions.value = []; bomOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = ''
|
||||
Object.assign(form, {
|
||||
id: undefined, base_id: undefined,
|
||||
company_name: '', // [新增]
|
||||
material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', bom_code: '', bom_version: '', work_order_code: '', raw_material_cost: 0, manual_cost: 0, unit_total_cost: 0, production_manager: '', production_time_range: [], arrival_photo: [], quality_report_link: [], detail_link: '' })
|
||||
}
|
||||
const getStatusType = (status: string) => { const map: any = { '在库': 'success', '出库': 'info', '借库': 'warning', '损耗': 'danger' }; return map[status] || 'warning' }
|
||||
const getQualityType = (status: string) => { const map: any = { '合格': 'success', '不合格': 'danger', '待检': 'info', '返修中': 'warning' }; return map[status] || 'info' }
|
||||
const formatMoney = (val: any) => { const num = Number(val); return isNaN(num) ? '-' : `¥ ${num.toFixed(2)}` }
|
||||
|
||||
onMounted(() => fetchData())
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
fetchOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -820,7 +1005,10 @@ onMounted(() => fetchData())
|
||||
.avail-num { font-weight: bold; color: #67C23A; font-size: 15px; }
|
||||
.id-cell { display: flex; align-items: center; }
|
||||
.id-text { font-family: monospace; color: #606266; }
|
||||
.stylish-form .form-card { background: #fff; border-radius: 8px; border: 1px solid #e4e7ed; margin-bottom: 20px; overflow: hidden; }
|
||||
/* [修改] 增加 min-height */
|
||||
.dialog-scroll-container { padding: 20px; max-height: 70vh; overflow-y: auto; overflow-x: hidden; min-height: 450px; }
|
||||
|
||||
.stylish-form .form-card { background: #fff; border-radius: 8px; border: 1px solid #e4e7ed; margin-bottom: 20px; }
|
||||
.card-title { background: #fcfcfc; padding: 12px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; font-size: 15px; color: #303133; display: flex; align-items: center; }
|
||||
.card-title .icon { margin-right: 8px; font-size: 18px; color: #409EFF; }
|
||||
.card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; }
|
||||
@ -839,13 +1027,19 @@ onMounted(() => fetchData())
|
||||
.divider-text::before { margin-right: 15px; }
|
||||
.divider-text::after { margin-left: 15px; }
|
||||
.dialog-footer { display: flex; justify-content: flex-end; gap: 15px; margin-top: 20px; padding: 20px; border-top: 1px solid #ebeef5; }
|
||||
.option-item { display: flex; justify-content: space-between; width: 100%; align-items: center; }
|
||||
.opt-name { font-weight: bold; }
|
||||
.opt-spec { color: #8492a6; font-size: 13px; margin-right: 10px; }
|
||||
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background-color: #f5f7fa; border-bottom: 1px solid #dcdfe6; border-radius: 0; padding-left: 0; }
|
||||
.is-text-view :deep(.el-input__inner) { color: #606266; font-weight: 500; }
|
||||
|
||||
.filter-item-select { /* 宽度已在行内样式控制 */ }
|
||||
.filter-item-input { /* 宽度已在行内样式控制 */ }
|
||||
.search-btn { background-color: #E6F1FC; border-color: #A3D0FD; color: #409EFF; }
|
||||
.search-btn:hover { background-color: #409EFF; border-color: #409EFF; color: #fff; }
|
||||
.reset-btn { background-color: #fff; border: 1px solid #dcdfe6; }
|
||||
.reset-btn:hover { border-color: #c0c4cc; color: #606266; }
|
||||
|
||||
/* [优化] 纯文本样式 */
|
||||
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background-color: transparent !important; border-bottom: 1px dashed #dcdfe6; border-radius: 0; padding-left: 0; }
|
||||
.is-text-view :deep(.el-input__inner) { color: #303133; font-weight: 600; font-size: 14px; cursor: text; }
|
||||
|
||||
.search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; }
|
||||
.dialog-scroll-container { padding: 20px; max-height: 70vh; overflow-y: auto; }
|
||||
.upload-container { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
:deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; }
|
||||
:deep(.el-upload-list--picture-card .el-upload-list__item) { width: 100px; height: 100px; }
|
||||
@ -853,4 +1047,19 @@ onMounted(() => fetchData())
|
||||
.camera-card:hover { border-color: #409EFF; color: #409EFF; }
|
||||
.camera-card .text { font-size: 12px; margin-top: 5px; }
|
||||
.camera-card .el-icon { font-size: 24px; }
|
||||
|
||||
/* [重点] 下拉框 Flex 布局 */
|
||||
.option-item { display: flex; align-items: center; padding: 8px 0; width: 100%; }
|
||||
.opt-main { flex: 1; min-width: 0; margin-right: 10px; }
|
||||
.opt-name { font-weight: 600; font-size: 14px; color: #333; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.opt-meta { width: 100px; text-align: right; flex-shrink: 0; margin-right: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.opt-spec { color: #999; font-size: 12px; }
|
||||
.opt-tags { display: flex; gap: 5px; flex-shrink: 0; }
|
||||
.company-tag { font-weight: bold; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.long-dropdown { width: 580px !important; }
|
||||
.long-dropdown .el-select-dropdown__wrap { max-height: 320px !important; }
|
||||
.long-dropdown .el-input__suffix { z-index: 10; }
|
||||
</style>
|
||||
@ -1 +1,541 @@
|
||||
<template><div style="padding:20px;"><h2>服务权益管理</h2></div></template>
|
||||
<template>
|
||||
<div style="padding: 20px;">
|
||||
<h2 style="margin-bottom: 20px;">服务权益管理</h2>
|
||||
|
||||
<div class="header-toolbar">
|
||||
<el-form :inline="true" @submit.prevent>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="SKU/物料名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="服务商">
|
||||
<el-input
|
||||
v-model="searchForm.provider_name"
|
||||
placeholder="服务商名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="开始日期">
|
||||
<el-date-picker
|
||||
v-model="searchForm.start_date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结束日期">
|
||||
<el-date-picker
|
||||
v-model="searchForm.end_date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
<el-button type="success" @click="handleAdd">新增服务</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-table :data="tableData" border stripe style="width: 100%;" v-loading="loading">
|
||||
<el-table-column prop="sku" label="SKU" width="200" />
|
||||
<el-table-column prop="material_name" label="物料名称" />
|
||||
<el-table-column prop="provider_name" label="服务商" width="150" />
|
||||
<el-table-column prop="sale_price" label="售价" width="120">
|
||||
<template #default="{row}">¥{{ row.sale_price.toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="简介" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="创建时间" width="160" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{row}">
|
||||
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top: 20px; text-align: center;">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="perPage"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="700px"
|
||||
@close="resetDialog"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<div class="dialog-scroll-container">
|
||||
<div class="form-card basic-card">
|
||||
<div class="card-title">
|
||||
<el-icon class="icon"><Box /></el-icon>
|
||||
<span>1. 基础信息</span>
|
||||
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索锁定物料)</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 15px;">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
|
||||
<el-select
|
||||
v-model="form.base_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
placeholder="输入名称或规格..."
|
||||
:remote-method="handleSearchMaterial"
|
||||
@visible-change="handleMaterialDropdownVisible"
|
||||
:loading="searchLoading"
|
||||
style="width: 100%"
|
||||
@change="onMaterialSelected"
|
||||
default-first-option
|
||||
>
|
||||
<el-option
|
||||
v-for="item in materialOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
>
|
||||
<div class="option-item">
|
||||
<span class="opt-name">{{ item.name }}</span>
|
||||
<span class="opt-spec">{{ item.spec }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" style="margin-top: -10px; margin-bottom: 10px;">
|
||||
<span class="search-tip">
|
||||
<el-icon><InfoFilled/></el-icon> 未输入时展示最新物料;输入关键词进行精确搜索。
|
||||
</span>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="read-only-grid" v-if="form.base_id">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" disabled class="is-text-view"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" disabled class="is-text-view"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-card inbound-card">
|
||||
<div class="card-title">
|
||||
<el-icon class="icon"><House /></el-icon>
|
||||
<span>2. 服务详情</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<el-form-item label="售价" prop="sale_price">
|
||||
<el-input-number
|
||||
v-model="form.sale_price"
|
||||
placeholder="请输入售价"
|
||||
:controls="false"
|
||||
:precision="2"
|
||||
:min="0"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="服务商" prop="provider_name">
|
||||
<el-autocomplete
|
||||
v-model="form.provider_name"
|
||||
:fetch-suggestions="querySearchProvider"
|
||||
placeholder="输入或选择服务商"
|
||||
style="width: 100%"
|
||||
clearable
|
||||
:trigger-on-focus="true"
|
||||
@select="handleProviderSelect"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="简介" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入服务简介"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleDialogConfirm">确认</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { InfoFilled, Box, House } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getServiceList,
|
||||
createService,
|
||||
updateService,
|
||||
deleteService,
|
||||
searchMaterialBase,
|
||||
getProviderSuggestions,
|
||||
getUserSuggestions,
|
||||
type ServiceItem,
|
||||
type ServiceQueryParams,
|
||||
type ServiceCreateRequest,
|
||||
type MaterialBaseItem
|
||||
} from '@/api/inbound/service'
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref<ServiceItem[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const perPage = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const materialOptions = ref<any[]>([])
|
||||
const searchLoading = ref(false)
|
||||
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
provider_name: '',
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
})
|
||||
|
||||
// 加载列表
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: ServiceQueryParams = {
|
||||
page: page.value,
|
||||
per_page: perPage.value,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
provider_name: searchForm.provider_name || undefined,
|
||||
start_date: searchForm.start_date || undefined,
|
||||
end_date: searchForm.end_date || undefined
|
||||
}
|
||||
const res = await getServiceList(params)
|
||||
if (res.code === 200) {
|
||||
tableData.value = res.data.items
|
||||
total.value = res.data.total
|
||||
} else {
|
||||
ElMessage.error(res.msg || '加载失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('网络错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
const resetSearch = () => {
|
||||
Object.assign(searchForm, {
|
||||
keyword: '',
|
||||
provider_name: '',
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
})
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
const handleSizeChange = (val: number) => {
|
||||
perPage.value = val
|
||||
loadData()
|
||||
}
|
||||
const handlePageChange = (val: number) => {
|
||||
page.value = val
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||
if (visible && materialOptions.value.length === 0) {
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchMaterial = async (query: string) => {
|
||||
searchLoading.value = true
|
||||
try {
|
||||
const res = await searchMaterialBase(query)
|
||||
if (res.code === 200) {
|
||||
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
|
||||
materialOptions.value = apiResults
|
||||
} else {
|
||||
materialOptions.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
materialOptions.value = []
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onMaterialSelected = (val: number) => {
|
||||
const item = materialOptions.value.find(i => i.id === val)
|
||||
if (item) {
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
form.material_type = item.type
|
||||
}
|
||||
}
|
||||
|
||||
// 服务商建议
|
||||
const fetchProviderSuggestions = async (query: string, cb: any) => {
|
||||
if (!form.base_id) {
|
||||
cb([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: any = await getProviderSuggestions({ base_id: form.base_id })
|
||||
if (res.code === 200) {
|
||||
const providers = res.data.map((name: string) => ({ value: name }))
|
||||
const filtered = query ? providers.filter((item: any) => item.value.toLowerCase().includes(query.toLowerCase())) : providers
|
||||
cb(filtered)
|
||||
} else {
|
||||
cb([])
|
||||
}
|
||||
} catch (e) {
|
||||
cb([])
|
||||
}
|
||||
}
|
||||
const querySearchProvider = (qs: string, cb: any) => fetchProviderSuggestions(qs, cb)
|
||||
const handleProviderSelect = (item: any) => {
|
||||
form.provider_name = item.value
|
||||
}
|
||||
|
||||
// 弹窗相关
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const dialogStatus = ref<'create' | 'update'>('create')
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
base_id: undefined as number | undefined,
|
||||
material_name: '',
|
||||
spec_model: '',
|
||||
category: '',
|
||||
unit: '',
|
||||
material_type: '',
|
||||
sale_price: 0,
|
||||
provider_name: '',
|
||||
description: ''
|
||||
})
|
||||
const rules = reactive<FormRules>({
|
||||
base_id: [
|
||||
{ required: true, message: '请选择基础物料', trigger: 'change' }
|
||||
],
|
||||
sale_price: [
|
||||
{ required: true, message: '请输入售价', trigger: 'blur' },
|
||||
{ type: 'number', min: 0, message: '售价不能为负数', trigger: 'blur' }
|
||||
],
|
||||
provider_name: [
|
||||
{ required: true, message: '请输入服务商名称', trigger: 'blur' }
|
||||
]
|
||||
})
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogTitle.value = '新增服务'
|
||||
dialogStatus.value = 'create'
|
||||
Object.assign(form, {
|
||||
id: 0,
|
||||
base_id: undefined as number | undefined,
|
||||
material_name: '',
|
||||
spec_model: '',
|
||||
category: '',
|
||||
unit: '',
|
||||
material_type: '',
|
||||
sale_price: 0,
|
||||
provider_name: '',
|
||||
description: ''
|
||||
})
|
||||
materialOptions.value = []
|
||||
dialogVisible.value = true
|
||||
}
|
||||
const handleEdit = (row: ServiceItem) => {
|
||||
dialogTitle.value = '编辑服务'
|
||||
dialogStatus.value = 'update'
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
base_id: row.base_id,
|
||||
material_name: row.material_name || '',
|
||||
spec_model: row.spec_model || '',
|
||||
category: row.category || '',
|
||||
unit: row.unit || '',
|
||||
material_type: row.material_type || '',
|
||||
sale_price: row.sale_price,
|
||||
provider_name: row.provider_name,
|
||||
description: row.description
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
const handleDialogConfirm = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
const reqData: ServiceCreateRequest = {
|
||||
base_id: form.base_id,
|
||||
sale_price: form.sale_price,
|
||||
provider_name: form.provider_name,
|
||||
description: form.description
|
||||
}
|
||||
if (form.id === 0) {
|
||||
await createService(reqData)
|
||||
ElMessage.success('创建成功')
|
||||
} else {
|
||||
await updateService(form.id, reqData)
|
||||
ElMessage.success('更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.msg || '操作失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
const resetDialog = () => {
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
const handleDelete = (row: ServiceItem) => {
|
||||
ElMessageBox.confirm(`确定删除服务权益 "${row.sku}" 吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
await deleteService(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.msg || '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-scroll-container {
|
||||
padding: 15px 20px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.form-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
margin-bottom: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-title {
|
||||
background: #fcfcfc;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.card-title .icon {
|
||||
margin-right: 8px;
|
||||
font-size: 18px;
|
||||
color: #409EFF;
|
||||
}
|
||||
.card-title .sub-title {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-weight: normal;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.card-content {
|
||||
padding: 15px 20px;
|
||||
}
|
||||
.basic-card {
|
||||
border-left: 4px solid #409EFF;
|
||||
}
|
||||
.inbound-card {
|
||||
border-left: 4px solid #67C23A;
|
||||
}
|
||||
.is-text-view :deep(.el-input__wrapper) {
|
||||
box-shadow: none !important;
|
||||
background-color: #f5f7fa;
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
border-radius: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
.is-text-view :deep(.el-input__inner) {
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
.read-only-grid {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.search-tip {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.highlight-label :deep(.el-form-item__label) {
|
||||
font-weight: 600;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
/* Material search options matching buy/semi style */
|
||||
.option-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
.opt-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
.opt-spec {
|
||||
color: #8492a6;
|
||||
font-size: 13px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
border
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="username" label="用户名" width="150" />
|
||||
<el-table-column prop="username" label="用户标识" min-width="180" />
|
||||
|
||||
<el-table-column prop="department" label="所属部门" width="150">
|
||||
<template #default="scope">
|
||||
@ -32,27 +32,10 @@
|
||||
|
||||
<el-table-column prop="email" label="邮箱" min-width="200" />
|
||||
|
||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleEdit(scope.row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
|
||||
<el-popconfirm
|
||||
title="确定要删除该用户吗?此操作无法撤销。"
|
||||
@confirm="handleDelete(scope.row)"
|
||||
>
|
||||
<el-button link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
||||
<el-popconfirm title="确定要删除该用户吗?" @confirm="handleDelete(scope.row)">
|
||||
<template #reference>
|
||||
<el-button link type="danger" size="small">删除</el-button>
|
||||
</template>
|
||||
@ -74,18 +57,28 @@
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-form-item label="真实姓名" prop="cn_name">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="登录账号 (英文)"
|
||||
v-model="form.cn_name"
|
||||
placeholder="请输入中文姓名 (如: 张三)"
|
||||
:disabled="isEdit"
|
||||
@input="handleNameInput"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="密码"
|
||||
prop="password"
|
||||
>
|
||||
<el-form-item label="登录账号" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="自动生成,可修改 (如: zhangsan)"
|
||||
:disabled="isEdit"
|
||||
>
|
||||
<template #append>
|
||||
<span v-if="!isEdit" style="font-size: 12px; color: #999;">重复自动+1</span>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
@ -103,29 +96,23 @@
|
||||
allow-create
|
||||
default-first-option
|
||||
>
|
||||
<el-option
|
||||
v-for="item in departmentOptions"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
<el-option v-for="item in departmentOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="系统角色" prop="role">
|
||||
<el-select v-model="form.role" placeholder="授予权限" style="width: 100%">
|
||||
<el-option label="主管" value="supervisor" />
|
||||
<el-option label="财务" value="finance" />
|
||||
<el-option label="库管" value="warehouse_manager" />
|
||||
<el-option label="入库员" value="inbound" />
|
||||
<el-option label="出库员" value="outbound" />
|
||||
<el-option label="采购员" value="purchaser" />
|
||||
<el-option label="销售" value="sales" />
|
||||
<el-option
|
||||
v-for="option in roleOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="可选填" />
|
||||
<el-input v-model="form.email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@ -144,9 +131,11 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, computed } from 'vue'
|
||||
import { createUser, updateUser, getUserList, deleteUser } from '@/api/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { pinyin } from 'pinyin-pro' // ★ 务必安装: npm install pinyin-pro
|
||||
|
||||
// --- 状态定义 ---
|
||||
const userStore = useUserStore()
|
||||
const tableLoading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
@ -154,27 +143,77 @@ const tableData = ref<any[]>([])
|
||||
const departmentOptions = ref<string[]>([])
|
||||
const formRef = ref()
|
||||
|
||||
// [新增] 区分编辑/创建模式
|
||||
const isEdit = ref(false)
|
||||
const currentId = ref<number | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
cn_name: '', // 真实姓名 (前端输入)
|
||||
username: '', // 登录账号 (拼音)
|
||||
password: '',
|
||||
department: '',
|
||||
role: '',
|
||||
email: ''
|
||||
})
|
||||
|
||||
// [关键] 动态规则:编辑模式下密码不是必填项
|
||||
const rules = computed(() => {
|
||||
const commonRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||
department: [{ required: true, message: '请输入或选择部门', trigger: ['blur', 'change'] }]
|
||||
// ★ 监听中文输入,自动转拼音
|
||||
const handleNameInput = (val: string) => {
|
||||
if (isEdit.value) return // 编辑模式下不联动
|
||||
if (!val) {
|
||||
form.username = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// toneType: 'none' 去除声调, type: 'array' 转数组后拼接
|
||||
const pinyinStr = pinyin(val, { toneType: 'none', type: 'array' }).join('')
|
||||
// 将拼音转小写
|
||||
form.username = pinyinStr.toLowerCase()
|
||||
} catch (e) {
|
||||
// 如果转换失败(比如输入符号),不做处理
|
||||
}
|
||||
}
|
||||
|
||||
const roleOptions = computed(() => {
|
||||
const options = [
|
||||
{ label: '主管', value: 'SUPERVISOR' },
|
||||
{ label: '财务', value: 'FINANCE' },
|
||||
{ label: '库管', value: 'WAREHOUSE_MGR' },
|
||||
{ label: '入库员', value: 'INBOUND' },
|
||||
{ label: '出库员', value: 'OUTBOUND' },
|
||||
{ label: '采购员', value: 'PURCHASER' },
|
||||
{ label: '销售', value: 'SALES' }
|
||||
]
|
||||
|
||||
let role = userStore.user?.role || userStore.role
|
||||
let username = userStore.user?.username || userStore.name
|
||||
|
||||
if (!role) role = localStorage.getItem('role')
|
||||
if (!username) username = localStorage.getItem('username')
|
||||
|
||||
const safeRole = role ? String(role).toUpperCase() : ''
|
||||
const safeUsername = username ? String(username).toUpperCase() : ''
|
||||
|
||||
const isSuperAdmin = (safeRole === 'SUPER_ADMIN') || (safeUsername === 'IRIS')
|
||||
|
||||
if (isSuperAdmin) {
|
||||
options.unshift({ label: '超级管理员', value: 'SUPER_ADMIN' })
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const rules = computed(() => {
|
||||
const commonRules: any = {
|
||||
cn_name: [{ required: true, message: '请输入真实姓名', trigger: 'blur' }],
|
||||
username: [{ required: true, message: '账号不能为空', trigger: 'blur' }],
|
||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||
department: [{ required: true, message: '请输入或选择部门', trigger: ['blur', 'change'] }],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: ['blur', 'change'] }
|
||||
]
|
||||
}
|
||||
|
||||
// 如果是创建模式,密码必填
|
||||
if (!isEdit.value) {
|
||||
return {
|
||||
...commonRules,
|
||||
@ -184,7 +223,6 @@ const rules = computed(() => {
|
||||
]
|
||||
}
|
||||
} else {
|
||||
// 如果是编辑模式,密码选填(只有输入了才校验长度)
|
||||
return {
|
||||
...commonRules,
|
||||
password: [
|
||||
@ -221,51 +259,65 @@ const extractDepartments = (data: any[]) => {
|
||||
departmentOptions.value = Array.from(deptSet)
|
||||
}
|
||||
|
||||
// 2. 打开新增弹窗
|
||||
const handleCreate = () => {
|
||||
isEdit.value = false
|
||||
currentId.value = null
|
||||
resetFormState() // 清空数据
|
||||
// 重置表单
|
||||
form.cn_name = ''
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.department = ''
|
||||
form.role = ''
|
||||
form.email = ''
|
||||
if (formRef.value) formRef.value.clearValidate()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// [新增] 打开编辑弹窗
|
||||
const handleEdit = (row: any) => {
|
||||
isEdit.value = true
|
||||
currentId.value = row.id
|
||||
|
||||
// 回填数据
|
||||
form.username = row.username
|
||||
// 回显数据
|
||||
// 注意:后端返回的 row.username 已经是 "张三(zhangsan01)" 格式
|
||||
const displayStr = row.username || ''
|
||||
|
||||
if (displayStr.includes('(') && displayStr.includes(')')) {
|
||||
const parts = displayStr.split('(')
|
||||
form.cn_name = parts[0]
|
||||
form.username = parts[1].replace(')', '')
|
||||
} else {
|
||||
// 兼容旧数据
|
||||
form.cn_name = displayStr
|
||||
form.username = displayStr
|
||||
}
|
||||
|
||||
form.department = row.department
|
||||
form.role = row.role
|
||||
form.email = row.email || ''
|
||||
form.password = '' // 编辑时不回显密码,留空表示不修改
|
||||
|
||||
form.password = ''
|
||||
if (formRef.value) formRef.value.clearValidate()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 3. 提交 (兼容创建和修改)
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value && currentId.value) {
|
||||
// 编辑逻辑
|
||||
await updateUser(currentId.value, form)
|
||||
ElMessage.success(`用户 ${form.username} 修改成功!`)
|
||||
ElMessage.success(`用户 ${form.cn_name} 修改成功!`)
|
||||
} else {
|
||||
// 创建逻辑
|
||||
await createUser(form)
|
||||
ElMessage.success(`用户 ${form.username} 创建成功!`)
|
||||
// 创建时,后端会自动处理重复逻辑
|
||||
const res: any = await createUser(form)
|
||||
// 这里的 res.data.account_id 会返回最终生成的账号 (如 zhangsan1)
|
||||
ElMessage.success(`创建成功!登录账号为: ${res.data.account_id} (已自动防重)`)
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
getList()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
// request 拦截器会处理错误
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
@ -273,14 +325,10 @@ const onSubmit = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 重置表单
|
||||
const resetForm = () => {
|
||||
if (!formRef.value) return
|
||||
formRef.value.resetFields()
|
||||
resetFormState()
|
||||
}
|
||||
|
||||
const resetFormState = () => {
|
||||
form.cn_name = ''
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.department = ''
|
||||
@ -288,39 +336,30 @@ const resetFormState = () => {
|
||||
form.email = ''
|
||||
}
|
||||
|
||||
// 5. 删除用户
|
||||
const handleDelete = async (row: any) => {
|
||||
try {
|
||||
await deleteUser(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
getList()
|
||||
} catch (error) {
|
||||
// 错误处理
|
||||
}
|
||||
}
|
||||
|
||||
// --- 辅助显示方法 ---
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '-'
|
||||
return dateStr.replace('T', ' ').substring(0, 19)
|
||||
}
|
||||
|
||||
const formatRole = (val: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'supervisor': '主管',
|
||||
'finance': '财务',
|
||||
'warehouse_manager': '库管',
|
||||
'inbound': '入库员',
|
||||
'outbound': '出库员',
|
||||
'purchaser': '采购员',
|
||||
'sales': '销售',
|
||||
'super_admin': '超级管理员'
|
||||
'SUPER_ADMIN': '超级管理员',
|
||||
'SUPERVISOR': '主管',
|
||||
'FINANCE': '财务',
|
||||
'WAREHOUSE_MGR': '库管',
|
||||
'INBOUND': '入库员',
|
||||
'OUTBOUND': '出库员',
|
||||
'PURCHASER': '采购员',
|
||||
'SALES': '销售'
|
||||
}
|
||||
return map[val] || val
|
||||
const key = val ? val.toUpperCase() : ''
|
||||
return map[key] || val
|
||||
}
|
||||
|
||||
// --- 初始化 ---
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
|
||||
@ -13,18 +13,9 @@
|
||||
</template>
|
||||
|
||||
<div class="scan-section">
|
||||
<div v-if="showCamera" class="camera-wrapper">
|
||||
<QrScanner @decode="onScanSuccess" />
|
||||
<div class="scan-overlay">
|
||||
<el-button type="info" size="small" bg text @click="showCamera = false" icon="Close">
|
||||
关闭摄像头
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="camera-placeholder" @click="showCamera = true">
|
||||
<div class="camera-placeholder" @click="showCamera = true">
|
||||
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
|
||||
<span class="text">点击开启扫码</span>
|
||||
<span class="text">点击开启全屏扫码</span>
|
||||
</div>
|
||||
|
||||
<div class="input-box">
|
||||
@ -133,6 +124,23 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-if="showCamera" class="fullscreen-scanner-overlay">
|
||||
<div class="scanner-header">
|
||||
<el-button circle icon="Close" @click="showCamera = false" class="close-btn" />
|
||||
<span class="scanner-title">扫码模式</span>
|
||||
<div class="scanner-placeholder"></div>
|
||||
</div>
|
||||
|
||||
<div class="scanner-body">
|
||||
<QrScanner @decode="onScanSuccess" />
|
||||
</div>
|
||||
|
||||
<div class="scanner-footer">
|
||||
<p>请将条码/二维码放入镜头范围</p>
|
||||
<p v-if="cartItems.length > 0" class="current-count">已添加: {{ cartItems.length }} 项</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="showSignatureDialog"
|
||||
fullscreen
|
||||
@ -205,7 +213,6 @@ const form = reactive({
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// ★ 修改点:增强校验规则
|
||||
const rules = {
|
||||
borrower_name: [
|
||||
{ required: true, message: '请输入借用人姓名', trigger: 'blur' }
|
||||
@ -215,9 +222,8 @@ const rules = {
|
||||
]
|
||||
}
|
||||
|
||||
// ★ 新增:禁止选择今天之前的日期
|
||||
const disabledDate = (time: Date) => {
|
||||
return time.getTime() < Date.now() - 8.64e7 // 禁止选择昨天及之前
|
||||
return time.getTime() < Date.now() - 8.64e7
|
||||
}
|
||||
|
||||
// --- 核心扫码逻辑 ---
|
||||
@ -292,7 +298,10 @@ const handleManualInput = async () => {
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
nextTick(() => { barcodeRef.value?.focus() })
|
||||
// ★ 核心修改:只有当非全屏模式时,才自动聚焦输入框
|
||||
if (!showCamera.value) {
|
||||
nextTick(() => { barcodeRef.value?.focus() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -318,7 +327,6 @@ const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
|
||||
|
||||
// ★ 核心修改:等待校验通过后再提交,否则报错会被拦截在前端
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) {
|
||||
ElMessage.error('请填写完整的必填项(姓名、归还日期)')
|
||||
@ -342,7 +350,7 @@ const submitForm = async () => {
|
||||
method: 'post',
|
||||
data: {
|
||||
items: cartItems.value,
|
||||
...form, // 此时 form.expected_return_time 已经是 YYYY-MM-DD 格式
|
||||
...form,
|
||||
signature_path: signatureUrl
|
||||
}
|
||||
})
|
||||
@ -449,21 +457,73 @@ onUnmounted(() => {
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* 扫码区 */
|
||||
/* 扫码区(卡片内触发器) */
|
||||
.scan-section { margin-bottom: 20px; }
|
||||
.camera-wrapper {
|
||||
height: 25vh; background: #000; border-radius: 12px; overflow: hidden; position: relative; margin-bottom: 10px;
|
||||
}
|
||||
.scan-overlay {
|
||||
position: absolute; bottom: 10px; right: 10px; z-index: 10;
|
||||
}
|
||||
.camera-placeholder {
|
||||
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
||||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||
color: #909399; margin-bottom: 10px; cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.camera-placeholder:active { background: #e6e8eb; }
|
||||
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
|
||||
|
||||
/* ★ 全屏扫码层样式 */
|
||||
.fullscreen-scanner-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #000;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scanner-header {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 15px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
.scanner-title { font-size: 16px; font-weight: bold; }
|
||||
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
|
||||
|
||||
.scanner-body {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* 强制子组件(QrScanner)填满容器 */
|
||||
:deep(.qr-scanner-container) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.scanner-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; }
|
||||
|
||||
/* 表单与购物车 */
|
||||
.cart-section { margin-bottom: 20px; }
|
||||
.form-section { background: #fff; }
|
||||
|
||||
@ -13,18 +13,9 @@
|
||||
</template>
|
||||
|
||||
<div class="scan-section">
|
||||
<div v-if="showCamera" class="camera-wrapper">
|
||||
<QrScanner @decode="onScanSuccess" />
|
||||
<div class="scan-overlay">
|
||||
<el-button type="info" size="small" bg text @click="showCamera = false" icon="Close">
|
||||
关闭摄像头
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="camera-placeholder" @click="showCamera = true">
|
||||
<div class="camera-placeholder" @click="showCamera = true">
|
||||
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
|
||||
<span class="text">点击开启扫码</span>
|
||||
<span class="text">点击开启全屏扫码</span>
|
||||
</div>
|
||||
|
||||
<div class="input-box">
|
||||
@ -108,6 +99,23 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-if="showCamera" class="fullscreen-scanner-overlay">
|
||||
<div class="scanner-header">
|
||||
<el-button circle icon="Close" @click="showCamera = false" class="close-btn" />
|
||||
<span class="scanner-title">扫码模式</span>
|
||||
<div class="scanner-placeholder"></div>
|
||||
</div>
|
||||
|
||||
<div class="scanner-body">
|
||||
<QrScanner @decode="onScanSuccess" />
|
||||
</div>
|
||||
|
||||
<div class="scanner-footer">
|
||||
<p>扫描二维码/条形码进行归还</p>
|
||||
<p v-if="returnList.length > 0" class="current-count">待还: {{ returnList.length }} 项</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="showSignatureDialog"
|
||||
fullscreen
|
||||
@ -222,7 +230,10 @@ const scanItem = async () => {
|
||||
ElMessage.error('未找到该物品的未还记录')
|
||||
} finally {
|
||||
loading.value = false
|
||||
nextTick(() => { barcodeRef.value?.focus() })
|
||||
// ★ 核心修改:仅在非全屏模式聚焦
|
||||
if (!showCamera.value) {
|
||||
nextTick(() => { barcodeRef.value?.focus() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -297,7 +308,7 @@ const submitReturn = async () => {
|
||||
returnList.value = []
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
showCamera.value = false // 关闭摄像头
|
||||
showCamera.value = false
|
||||
} catch(e: any) {
|
||||
ElMessage.error(e.response?.data?.msg || '提交失败')
|
||||
} finally {
|
||||
@ -389,21 +400,73 @@ onUnmounted(() => {
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* 扫码区 */
|
||||
/* 扫码区(卡片内触发器) */
|
||||
.scan-section { margin-bottom: 20px; }
|
||||
.camera-wrapper {
|
||||
height: 25vh; background: #000; border-radius: 12px; overflow: hidden; position: relative; margin-bottom: 10px;
|
||||
}
|
||||
.scan-overlay {
|
||||
position: absolute; bottom: 10px; right: 10px; z-index: 10;
|
||||
}
|
||||
.camera-placeholder {
|
||||
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
||||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||
color: #909399; margin-bottom: 10px; cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.camera-placeholder:active { background: #e6e8eb; }
|
||||
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
|
||||
|
||||
/* ★ 全屏扫码层样式 */
|
||||
.fullscreen-scanner-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #000;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scanner-header {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 15px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
.scanner-title { font-size: 16px; font-weight: bold; }
|
||||
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
|
||||
|
||||
.scanner-body {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* 强制子组件(QrScanner)填满容器 */
|
||||
:deep(.qr-scanner-container) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.scanner-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; }
|
||||
|
||||
/* 表单与购物车 */
|
||||
.cart-section { margin-bottom: 20px; }
|
||||
.form-section { background: #fff; }
|
||||
|
||||
@ -14,10 +14,10 @@
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
/* Linting - Relaxed for production build */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
|
||||
Reference in New Issue
Block a user