52 Commits

Author SHA1 Message Date
dxc
47fb8912a9 下一阶段将进行权限管理测试 2026-02-25 14:37:48 +08:00
dxc
82a9a4c2ba 修改登录,实现中文名称显示,以及修改登录逻辑 2026-02-25 11:18:01 +08:00
dxc
1c3f116c50 修改登录,添加超级管理员权限 2026-02-25 11:02:06 +08:00
dxc
948149cd44 修改登录验证,修改为7天 2026-02-25 10:01:03 +08:00
dxc
63a3cf269d 库存资产excel文件导出 2026-02-25 09:55:25 +08:00
dxc
447b1890ab 对于页面展示内容进行按照规格型号进行排序,同时修改类别下拉框大小 2026-02-24 17:15:43 +08:00
dxc
8a7e367d00 基础信息分页功能补回 2026-02-24 16:20:10 +08:00
dxc
31ddb1aafd 将半成品成品同样进行新增所属公司以及内容修改 2026-02-24 16:16:17 +08:00
dxc
42171ed612 对于采购件的税率添加以及所属公司添加 2026-02-24 15:43:14 +08:00
dxc
7e2fa8db8e 基础信息修改,新增所属公司,同时修正类别排序以及新增时候类别选择的功能 2026-02-24 15:13:37 +08:00
dxc
d1ab5f1100 新增打印多张标签纸的功能 2026-02-24 14:33:43 +08:00
dxc
853374de5d 将半成品和成品跟bom表进行相关联 2026-02-12 17:16:24 +08:00
dxc
b682d4b02f 对于出库选单和Bom库的逻辑完善以及功能美化 2026-02-12 16:54:26 +08:00
dxc
d61668bc4b 修改bom表逻辑和出库选单内容 2026-02-12 10:39:21 +08:00
dxc
b93a565c82 feat: add material spec to BOM responses and UI
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 10:10:37 +08:00
dxc
6e5df70ee6 fix: add typeLabel for display in outbound manual selection dialog
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 10:05:16 +08:00
dxc
fb536dad7f feat: add endpoint to delete BOM by bom_no
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 10:02:29 +08:00
dxc
d479b750d7 feat: add quantity input for manual stock selection
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 10:00:17 +08:00
dxc
05a108e96d refactor: replace outbound selection with shopping cart model
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 09:57:12 +08:00
dxc
ec7f20869a feat: refactor outbound selection to cart mode and update BomManage route
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 09:53:17 +08:00
dxc
fcebe70848 feat: add BOM management page
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 09:47:22 +08:00
dxc
bb8c07a465 feat: add BOM management view 2026-02-12 09:46:58 +08:00
dxc
5245ee2da3 feat: add BOM API and routing
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 09:43:51 +08:00
dxc
04dd6fb3fa feat: add bom api client 2026-02-12 09:43:12 +08:00
dxc
32f031b047 feat: add non-null and unique constraints to BOM model
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 09:41:40 +08:00
dxc
6d5d8a6aad feat: implement BOM versioning with bom_no and new management APIs
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 09:37:27 +08:00
dxc
e900326571 修改semi,product,service的搜索逻辑 2026-02-11 15:12:20 +08:00
dxc
5513e4cd81 fix: prevent 500 errors in service list endpoint
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 14:55:28 +08:00
dxc
83f040728f fix: add missing import and correct SQL query for service list
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 14:51:26 +08:00
dxc
d3d35e03cd feat: increase default page size and options for inbound semi list 2026-02-11 14:51:14 +08:00
dxc
9f0134b2e4 feat: add material search filters to semi, product, service modules
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 14:42:16 +08:00
dxc
b3fdc65d33 fix: import ref and reactive in dashboard component
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 14:29:56 +08:00
dxc
9c70d78d9f feat: add printer settings dialog to dashboard
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 14:26:43 +08:00
dxc
cfb36ebf0b feat: add printer config management API
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 14:23:43 +08:00
dxc
c6fd0aca90 feat: add dynamic printer configuration manager
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 14:14:59 +08:00
dxc
5f3ceef3fd 修改采购件对于两个搜索框的bug修复 2026-02-11 13:39:02 +08:00
dxc
5532c87684 基础信息展示以及搜索逻辑进行修复 2026-02-11 13:12:05 +08:00
dxc
d0a237625c feat: improve fuzzy search for buy inbound material
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-11 11:28:15 +08:00
dxc
b1e2836e4b feat: expand search fields and remove result limits in buy inbound service 2026-02-11 11:28:02 +08:00
dxc
706476d421 修改是基础信息内容展示库存数和可用数 2026-02-11 10:12:10 +08:00
dxc
64efbb97d6 修改出库选单页面以及打印内容 2026-02-11 09:53:46 +08:00
dxc
ec16ef8d20 对于采购件的内容进行修改,使其填写更加便利加上库位自动加载上一次的逻辑 2026-02-11 08:38:12 +08:00
dxc
d594ed7ef1 采购人根据历史上传记录来 2026-02-10 17:41:53 +08:00
dxc
8ee2a9a45b 采购人走用户的表 2026-02-10 17:20:06 +08:00
dxc
b5b0677b01 feat: align service inbound material search with buy/semi
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-10 15:16:03 +08:00
dxc
8d00e6783c feat: add provider autocomplete to service form
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-10 15:10:55 +08:00
dxc
695c78090a refactor: remove localStorage usage for column visibility and history
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-10 15:03:52 +08:00
dxc
af1a95017b 选择基础信息内容修改 2026-02-10 15:00:57 +08:00
dxc
8cae6ee7f6 refactor: remove local history caching and add API suggestions
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-10 14:38:15 +08:00
dxc
94ff7cecdc feat: add backend autocomplete for suppliers and users in inbound
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-10 13:51:19 +08:00
dxc
17a61b489c fix: prevent inbound of disabled materials 2026-02-10 13:50:26 +08:00
dxc
18da3979a9 删除废弃文件 2026-02-10 11:51:03 +08:00
48 changed files with 4800 additions and 1681 deletions

View File

@ -1,15 +0,0 @@
# Files that need to be checked for .material -> .base attribute rename
Based on the refactoring, the following modules likely contain references to `.material` that should be updated to `.base`:
1. `app/services/stocktake/stocktake_service.py`
2. `app/services/check/check_service.py`
3. `app/services/dashboard/dashboard_service.py`
4. `app/controllers/stocktake_controller.py`
5. `app/controllers/check_controller.py`
6. `app/controllers/dashboard_controller.py`
7. `app/services/report/stock_report_service.py`
8. `app/services/alert/stock_alert_service.py`
9. `app/services/outbound/outbound_service.py`
Please add these files to the chat so we can inspect and apply the required fixes.

View File

@ -4,10 +4,133 @@ from app.models.base import MaterialBase
from app.models.bom import BomTable
from app.extensions import db
from flask_jwt_extended import jwt_required
from sqlalchemy import distinct
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):
@ -22,9 +145,10 @@ def get_bom(parent_id):
current_app.logger.error(f'获取BOM失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('', methods=['POST'])
@jwt_required()
def save_bom():
def save_bom_legacy():
try:
req_data = request.get_json()
parent_id = req_data.get('parent_id')
@ -42,6 +166,7 @@ def save_bom():
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():
@ -58,10 +183,11 @@ def get_material_base_list():
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的父件物料列表"""
"""获取所有已定义BOM的父件物料列表(兼容旧版)"""
try:
subq = db.session.query(BomTable.parent_id).distinct().subquery()
parents = MaterialBase.query.join(subq, MaterialBase.id == subq.c.parent_id).all()
@ -73,4 +199,4 @@ def get_bom_parents():
})
except Exception as e:
current_app.logger.error(f'获取BOM父件列表失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500

View File

@ -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
@ -24,4 +25,25 @@ def execute_print():
LabelPrintService.send_to_printer(data)
return jsonify({"code": 200, "msg": "指令已发送至打印机"})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
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

View File

@ -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():

View File

@ -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})

View File

@ -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 多选筛选)
@ -103,4 +123,26 @@ def get_history(id):
return jsonify({"code": 200, "msg": "success", "data": data})
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

View File

@ -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. 获取半成品列表
@ -117,4 +138,26 @@ 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

View File

@ -1,9 +1,10 @@
# 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.schemas.stock_schema import stock_service_schema
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'])
@ -22,6 +23,7 @@ def search_base():
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():
@ -49,6 +51,7 @@ def get_service_list():
})
except Exception as e:
current_app.logger.error(f'获取服务列表失败: {str(e)}')
traceback.print_exc()
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@ -60,20 +63,25 @@ def create_service():
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
errors = stock_service_schema.validate(data)
if errors:
return jsonify({'code': 400, 'msg': '数据校验失败', 'errors': errors}), 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': stock_service_schema.dump(service)
'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
@ -86,7 +94,7 @@ def get_service(service_id):
return jsonify({
'code': 200,
'msg': 'success',
'data': stock_service_schema.dump(service)
'data': service.to_dict()
})
except ValueError as e:
return jsonify({'code': 404, 'msg': str(e)}), 404
@ -103,17 +111,23 @@ 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'}
# 允许更新的字段
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': stock_service_schema.dump(service)
'data': service.to_dict()
})
except ValueError as e:
return jsonify({'code': 404, 'msg': str(e)}), 404
@ -138,3 +152,31 @@ def delete_service(service_id):
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

View File

@ -2,6 +2,7 @@
from app.extensions import db
import json
class MaterialBase(db.Model):
"""
基础信息表模型
@ -11,6 +12,9 @@ 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='俗名')
category = db.Column(db.String(100), comment='类别')
@ -32,19 +36,23 @@ class MaterialBase(db.Model):
# 关联关系区域
# ============================================================
# 1. 关联采购库存 (StockBuy) - 修改 back_populates 为 'base'
# 1. 关联采购库存 (StockBuy)
stock_buys = db.relationship('StockBuy', back_populates='base', lazy='dynamic')
# 2. 关联半成品库存 (StockSemi) - 修改 back_populates 为 'base'
# 2. 关联半成品库存 (StockSemi)
stock_semis = db.relationship('StockSemi', back_populates='base', lazy='dynamic')
# 3. 关联成品库存 (StockProduct) - 修改 back_populates 为 'base'
# 3. 关联成品库存 (StockProduct)
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:
@ -59,6 +67,7 @@ class MaterialBase(db.Model):
return {
'id': self.id,
'companyName': self.company_name,
'name': self.name,
'commonName': self.common_name,
'category': self.category,
@ -66,7 +75,6 @@ class MaterialBase(db.Model):
'spec': self.spec_model,
'unit': self.unit,
'visibilityLevel': self.visibility_level,
# 修改:解析为列表返回
'generalManual': parse_list(self.manual_link),
'generalImage': parse_list(self.product_image),
'isEnabled': 1 if self.is_enabled else 0,

View File

@ -1,17 +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), comment='BOM编号')
version = db.Column(db.String(50), comment='版本')
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)
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')
child = db.relationship('MaterialBase', foreign_keys=[child_id], backref='bom_children')

View File

@ -4,6 +4,7 @@ import json
# 显式导入 MaterialBase 以防 relationship 找不到引用
from app.models.base import MaterialBase
class StockBuy(db.Model):
"""
采购入库库存表
@ -32,35 +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)
# 关系定义 [已修改]
# 关系定义
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)
@ -70,7 +72,9 @@ class StockBuy(db.Model):
return {
'id': self.id,
'base_id': self.base_id,
# [已修改] 使用 self.base
# [修改] 增加公司名称
'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 '',
@ -95,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),
@ -104,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 ""
}

View File

@ -3,6 +3,7 @@ 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)
@ -57,7 +58,7 @@ class StockProduct(db.Model):
# 全局打印流水号
global_print_id = db.Column(db.Integer)
# 关系定义 [已修改]
# 关系定义
base = db.relationship('MaterialBase', back_populates='stock_products')
def to_dict(self):
@ -79,7 +80,9 @@ class StockProduct(db.Model):
return {
'id': self.id,
'base_id': self.base_id,
# [已修改] 使用 self.base
# [新增] 公司名称
'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 '',
@ -115,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),

View File

@ -3,6 +3,7 @@ from app.extensions import db
import json
from app.models.base import MaterialBase
class StockSemi(db.Model):
"""
半成品入库库存表
@ -43,19 +44,19 @@ 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)
# 关系定义 [已修改]
# 关系定义
base = db.relationship('MaterialBase', back_populates='stock_semis')
def to_dict(self):
@ -78,7 +79,9 @@ class StockSemi(db.Model):
return {
'id': self.id,
'base_id': self.base_id,
# [已修改] 使用 self.base
# [新增] 公司名称
'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 '',
@ -115,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),

View File

@ -1,4 +1,5 @@
from app import db
# inventory-backend/app/models/inbound/service.py
from app.extensions import db
from datetime import datetime
@ -6,41 +7,67 @@ 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格式 SRV-YYYYMMDD-XXXX
sku = db.Column(db.String(64), unique=True, nullable=False)
# 售价
sale_price = db.Column(db.Numeric(10, 2), nullable=False)
# 服务商名称
provider_name = db.Column(db.String(255), nullable=False, default='')
# 服务详情/简介
description = db.Column(db.Text, default='')
# 创建时间与更新时间
created_at = db.Column(db.DateTime, default=datetime.now, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now, nullable=False)
# 软删除标志
is_deleted = db.Column(db.Boolean, default=False, nullable=False)
# 关系(可选)
material_base = db.relationship('MaterialBase', backref='service_stocks', lazy='joined')
# 外键关联基础物料
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):
"""转为字典,用于 API 响应"""
"""序列化为字典"""
return {
'id': self.id,
'base_id': self.base_id,
'sku': self.sku,
'sale_price': float(self.sale_price) if self.sale_price is not None else 0,
'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,
'material_name': self.material_base.name if self.material_base else None,
'spec_model': self.material_base.spec_model if self.material_base else None,
'unit': self.material_base.unit if self.material_base 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,
}

View File

@ -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):
"""
系统操作日志表

View File

@ -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 存数据库IDclaims 存登录账号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:

View File

@ -2,28 +2,237 @@ 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
from sqlalchemy import func, distinct, or_, case
import uuid
from datetime import datetime
class BomService:
# ====================== 新版 BOM 逻辑(基于 bom_no ======================
@staticmethod
def create_or_update_bom(parent_id, child_list):
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子件关系
child_list: [{"child_id": int, "dosage": float, "remark": str}, ...]
获取所有 BOM 配方(按 bom_no + version 分组)
支持模糊搜索BOM编号、父件名称/规格、子件名称/规格
"""
# 校验父件不能与子件相同
for item in child_list:
if item['child_id'] == parent_id:
# 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('父件与子件不能是同一物料')
# 删除该父件原有的BOM记录
BomTable.query.filter_by(parent_id=parent_id).delete()
# 插入新的
# 仅删除当前版本的旧记录
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(
parent_id=parent_id,
child_id=item['child_id'],
dosage=item.get('dosage', 0),
remark=item.get('remark', '')
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()
@ -31,37 +240,7 @@ class BomService:
@staticmethod
def get_bom_with_stock(parent_id):
"""
查询父件的BOM结构及库存信息
"""
bom_items = db.session.query(
BomTable,
MaterialBase.name.label('child_name')
).join(
MaterialBase, BomTable.child_id == MaterialBase.id
).filter(
BomTable.parent_id == parent_id
).all()
result = []
for bom, child_name in bom_items:
# 查询该子件在 StockBuy 中的可用库存总量
stock_qty = db.session.query(
func.coalesce(func.sum(StockBuy.available_quantity), 0)
).filter(
StockBuy.base_id == bom.child_id
).scalar() or 0
# 计算最大可生产数量
dosage = float(bom.dosage) if bom.dosage else 0
max_producible = int(stock_qty // dosage) if dosage > 0 else 0
result.append({
'child_id': bom.child_id,
'child_name': child_name,
'dosage': dosage,
'current_stock': float(stock_qty),
'max_producible': max_producible,
'remark': bom.remark or ''
})
return result
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 []

View File

@ -4,9 +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:
@ -30,14 +37,37 @@ class MaterialBaseService:
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,
'spec': item.spec_model,
@ -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,7 +117,7 @@ class MaterialBaseService:
query = MaterialBase.query
if filters:
# 1. 关键词模糊搜索 (名称 或 俗名 或 规格型号)
# 1. 关键词模糊搜索
if filters.get('keyword'):
kw = f"%{filters['keyword']}%"
query = query.filter(or_(
@ -70,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'])
@ -77,29 +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']
@ -107,8 +216,9 @@ class MaterialBaseService:
if exist:
raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})")
# 2. 创建对象 (列表转JSON字符串)
new_material = MaterialBase(
# [修改] 移除了 'IRIS' 默认值
company_name=data.get('companyName'),
name=data['name'],
common_name=data.get('commonName'),
spec_model=data['spec'],
@ -116,7 +226,6 @@ class MaterialBaseService:
material_type=data.get('type'),
unit=data.get('unit'),
visibility_level=data.get('visibilityLevel'),
# 修改:将列表 dumps 为字符串
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
@ -139,6 +248,7 @@ 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 'spec' in data: material.spec_model = data['spec']
@ -147,7 +257,6 @@ class MaterialBaseService:
if 'unit' in data: material.unit = data['unit']
if 'visibilityLevel' in data: material.visibility_level = data['visibilityLevel']
# 修改:将列表 dumps 为字符串
if 'generalManual' in data:
material.manual_link = json.dumps(data['generalManual'])
if 'generalImage' in data:
@ -175,13 +284,16 @@ class MaterialBaseService:
buy_usage_count = StockBuy.query.filter_by(base_id=m_id).count()
semi_usage_count = StockSemi.query.filter_by(base_id=m_id).count()
total_usage = buy_usage_count + semi_usage_count
prod_usage_count = StockProduct.query.filter_by(base_id=m_id).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"请先清理相关库存或仅‘禁用’此条目。"
)
@ -192,4 +304,235 @@ class MaterialBaseService:
except Exception as e:
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

View File

@ -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的物料名称 (material -> base)
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,
# [核心修改] 确保这里从关联的 .base 获取信息
'material_name': item.base.name if item.base else '',
'spec_model': item.base.spec_model if item.base else '',
'category': item.base.category if item.base else '',
'unit': item.base.unit if item.base else '',
'material_type': item.base.material_type if item.base 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": []}
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()]

View File

@ -11,26 +11,17 @@ 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:
# [修改] material -> base
occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料"
raise ValueError(f"序列号【{serial_number}】已存在!被成品 [{occupied_name}] 占用,请核查。")
@ -40,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,
@ -73,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'])
@ -109,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)
@ -128,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 = []
@ -137,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
@ -183,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'],
@ -207,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)
@ -263,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
@ -276,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:
@ -303,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):
@ -319,31 +330,65 @@ class ProductInboundService:
items = []
for item in current_items:
# [注意] 因为Model层已经修改了 to_dict 内部的 material -> base所以这里直接调 to_dict 即可
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": []}
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": []}

View File

@ -11,32 +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:
# [修改] material -> base
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,
@ -44,7 +32,6 @@ class SemiInboundService:
)
if exclude_id:
query = query.filter(StockSemi.id != exclude_id)
if query.first():
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复建单,建议在原批次上追加库存。")
@ -54,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
@ -85,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'])
@ -126,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'):
@ -147,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')")
@ -168,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 -----")
@ -233,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)
@ -266,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])
@ -275,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):
@ -290,7 +290,6 @@ class SemiInboundService:
stock.production_start_time = None
except:
pass
if 'production_end_time' in data:
try:
if data['production_end_time']:
@ -300,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):
@ -310,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)
@ -319,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
@ -361,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
@ -374,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),
@ -392,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:
@ -405,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):
@ -421,31 +417,66 @@ class SemiInboundService:
items = []
for item in current_items:
# [注意] 因为Model层已经修改了 to_dict 内部的 material -> base所以这里直接调 to_dict 即可
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": []}
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": []}

View File

@ -1,8 +1,10 @@
from app import db
# 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:
@ -17,18 +19,24 @@ class ServiceService:
"""生成唯一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, '')
match = re.match(r'^(\d+)', suffix_part)
suffix_num = int(match.group(1)) if match else 0
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}'
@ -37,7 +45,9 @@ class ServiceService:
def search_base_material(cls, keyword):
"""搜索基础物料,供前端远程选择"""
try:
# 只查询已启用的物料
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword:
query = query.filter(
db.or_(
@ -46,6 +56,7 @@ class ServiceService:
)
)
query = query.order_by(MaterialBase.id.desc()).limit(20)
results = []
for item in query.all():
results.append({
@ -57,34 +68,48 @@ class ServiceService:
'type': item.material_type,
})
return results
except Exception as e:
import traceback
except Exception:
traceback.print_exc()
return []
@classmethod
def create_service(cls, data):
"""创建服务权益记录"""
# 检查基础物料是否存在
base = MaterialBase.query.get(data.get('base_id'))
# 1. 检查基础物料
base_id = data.get('base_id')
base = MaterialBase.query.get(base_id)
if not base:
raise ValueError('基础物料不存在')
# 生成SKU
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['sale_price'],
provider_name=data['provider_name'],
description=data.get('description', '')
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('服务权益记录不存在')
@ -92,22 +117,32 @@ class ServiceService:
@classmethod
def update_service(cls, service_id, data):
"""更新服务权益记录"""
"""更新服务权益"""
service = cls.get_service(service_id)
# 不允许修改 base_id 和 sku业务上不允许变更基础物料
# 允许更新的字段
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()
@ -117,39 +152,88 @@ class ServiceService:
@classmethod
def get_service_list(cls, page=1, per_page=20, keyword=None,
start_date=None, end_date=None, provider_name=None):
"""分页查询服务权益列表"""
query = StockService.query.filter_by(is_deleted=False)
# 关键词搜索:可搜索 SKU 或 关联物料名称
if keyword:
# 子查询查找物料名称匹配的 base_id
subquery = MaterialBase.query.filter(
MaterialBase.name.ilike(f'%{keyword}%')
).subquery()
query = query.filter(
db.or_(
StockService.sku.ilike(f'%{keyword}%'),
StockService.base_id.in_([row.id for row in db.session.query(subquery.c.id)])
"""分页查询列表"""
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:
start = datetime.strptime(start_date, '%Y-%m-%d')
query = query.filter(StockService.created_at >= start)
if end_date:
end = datetime.strptime(end_date, '%Y-%m-%d')
# 包含当天
end = end + timedelta(days=1) - timedelta(seconds=1)
query = query.filter(StockService.created_at <= end)
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
}
# 日期过滤
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": []}

View File

@ -3,6 +3,7 @@ import base64
import os
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont
from .print_config import PrintConfigManager
# 引入二维码生成库
try:
@ -12,8 +13,7 @@ except ImportError:
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
@ -271,8 +271,9 @@ class LabelPrintService:
@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 图像

View File

@ -1,114 +1,42 @@
import socket # .material -> .base refactor checked
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')}")

View 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

View File

@ -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: '主管',

View File

@ -13,4 +13,6 @@ python-barcode>=0.14.0
# [新增] 二维码生成库 (标签打印必需包含PIL支持)
qrcode[pil]>=7.4.2
# [新增] 必须添加,用于处理 token 登录
Flask-JWT-Extended==4.6.0
Flask-JWT-Extended==4.6.0
# [新增] Excel 处理库 (解决 No module named 'openpyxl' 报错)
openpyxl>=3.1.2

View File

@ -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;
}
}

View File

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
@ -17,6 +17,7 @@
"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",

View File

@ -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>

View 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'
})
}

View File

@ -14,4 +14,19 @@ export function executePrint(data: any) {
method: 'post',
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
})
}

View File

@ -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
})
}

View File

@ -39,4 +39,30 @@ export function searchMaterialBase(keyword: string) {
method: 'get',
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'
})
}

View File

@ -42,4 +42,30 @@ export function searchMaterialBase(keyword: string) {
method: 'get',
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'
})
}

View File

@ -104,6 +104,32 @@ export function searchMaterialBase(keyword: string) {
})
}
// 供应商建议
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({

View File

@ -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'
})
}

View File

@ -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. 登录页
@ -120,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',
@ -152,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']
}
}
]
},
@ -187,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) {
@ -203,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 {

View 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>

View File

@ -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)
@ -109,4 +197,4 @@ const handleNav = (path: string) => {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>
</style>

View File

@ -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,8 +148,21 @@
</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 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">
@ -182,16 +222,16 @@
</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>
@ -219,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
@ -240,26 +308,27 @@
/>
</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>
<el-form-item label="产品图" prop="generalImage">
<div class="upload-container">
<el-upload
@ -348,8 +417,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue';
import { Plus, Picture, Document, Refresh, Setting, Rank, Camera, Link } from '@element-plus/icons-vue';
// 修复:引入 ElLoading
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';
@ -357,7 +425,9 @@ 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';
@ -365,6 +435,7 @@ import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
// --- 类型定义 ---
interface MaterialBaseVO {
id: number;
companyName: string;
name: string;
commonName?: string;
category: string;
@ -376,6 +447,8 @@ interface MaterialBaseVO {
generalImage: string[];
isEnabled: number;
statusLoading?: boolean;
inventoryCount?: number;
availableCount?: number;
}
interface QueryParams {
@ -384,11 +457,19 @@ 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);
@ -406,27 +487,35 @@ 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 },
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
});
@ -440,6 +529,7 @@ const formRef = ref<FormInstance>();
const initForm = {
id: undefined,
companyName: '',
name: '',
commonName: '',
category: '',
@ -454,9 +544,33 @@ const initForm = {
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' }]
@ -464,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);
};
@ -499,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;
@ -514,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(() => {
@ -532,6 +713,7 @@ const resetQuery = () => {
queryParams.keyword = '';
queryParams.category = '';
queryParams.type = '';
queryParams.company = '';
queryParams.isEnabled = undefined;
handleQuery();
};
@ -540,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 = '新增基础信息';
@ -552,17 +745,28 @@ const handleEdit = (row: MaterialBaseVO) => {
dialog.visible = true;
nextTick(() => {
// 基础字段赋值 - 深拷贝防止引用
Object.assign(form.value, JSON.parse(JSON.stringify(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));
@ -576,12 +780,12 @@ const handleEdit = (row: MaterialBaseVO) => {
const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
try {
const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: 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 });
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;
@ -599,23 +803,27 @@ const submitForm = async () => {
if (valid) {
submitLoading.value = true;
try {
// 重复校验
const isDuplicate = await checkDuplicate(form.value.name, form.value.spec);
if (isDuplicate) {
submitLoading.value = false;
return;
}
// 整理文件数据:本地上传的路径已经在 form.value 中
// 我们需要重新合并:已有的非外链 + 新的输入框外链
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
};
@ -627,6 +835,7 @@ const submitForm = async () => {
ElMessage.success(`${actionText}成功`);
dialog.visible = false;
getList();
getOptionsList();
} catch (error: any) {
ElMessage.error(error.msg || '保存失败');
} finally {
@ -645,6 +854,10 @@ const resetForm = () => {
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();
@ -670,6 +883,7 @@ const handleDelete = (row: MaterialBaseVO) => {
ElMessage.success("删除成功");
if (tableData.value.length === 1 && queryParams.pageNum > 1) queryParams.pageNum--;
getList();
getOptionsList();
});
}).catch(() => {});
};
@ -738,7 +952,6 @@ const triggerCamera = (field: 'generalImage' | 'generalManual') => {
}
const handleCameraConfirm = async (file: File) => {
console.log('✅ 父组件收到照片:', file.name)
if (!beforeAvatarUpload(file)) {
cameraDialogVisible.value = false;
return;
@ -746,7 +959,6 @@ const handleCameraConfirm = async (file: File) => {
const formData = new FormData();
formData.append('file', file);
// 修复点:使用 ElLoading
const loadingInstance = ElLoading.service({ text: '照片上传中...', background: 'rgba(0, 0, 0, 0.7)' });
try {
@ -777,6 +989,7 @@ const handleCameraConfirm = async (file: File) => {
onMounted(() => {
getList();
getOptionsList();
});
</script>
@ -806,7 +1019,12 @@ onMounted(() => {
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; }
@ -815,7 +1033,14 @@ onMounted(() => {
.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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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">
@ -353,24 +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'
// 修复点:引入 ElLoading
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)
@ -382,20 +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 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' },
@ -418,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: '合格',
@ -436,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
@ -456,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
@ -493,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;
@ -511,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()
@ -541,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
}
@ -575,9 +748,6 @@ const triggerCamera = (field: any) => {
cameraDialogVisible.value = true;
}
// ------------------------------------------------------------------------
// 修复核心:拍照上传回调逻辑
// ------------------------------------------------------------------------
const handleCameraConfirm = async (file: File) => {
if (!beforeAvatarUpload(file)) {
cameraDialogVisible.value = false;
@ -585,25 +755,18 @@ const handleCameraConfirm = async (file: File) => {
}
const formData = new FormData();
formData.append('file', file);
// 使用 ElLoading.service 替代报错的 ElMessage.loading
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; // 根据触发时记录的字段
// 添加到表单数据
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);
@ -612,7 +775,6 @@ const handleCameraConfirm = async (file: File) => {
} else if (field === 'inspection_report_link') {
inspectionFileList.value.push(previewItem);
}
ElMessage.success('拍照上传成功');
success = true;
} else {
@ -649,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 }
}
@ -667,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>
@ -715,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>

View File

@ -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>
@ -166,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"
@ -180,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"
@ -216,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>
@ -254,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">
@ -351,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">
@ -397,7 +487,7 @@
<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>
@ -436,12 +526,36 @@ import {
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))
}
}
// ------------------------------------
// 状态与变量
// ------------------------------------
@ -449,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)
@ -478,6 +604,7 @@ const modeLocked = ref(false)
// 列定义
const baseColumns = [
{prop: 'company_name', label: '所属公司'}, // [新增]
{prop: 'material_name', label: '名称'},
{prop: 'category', label: '类别'},
{prop: 'material_type', label: '类型'},
@ -513,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)
}
}
@ -613,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
})
@ -669,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()
@ -685,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),
@ -704,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
}
@ -747,10 +908,7 @@ const handleCameraConfirm = async (file: File) => {
}
const formData = new FormData();
formData.append('file', file);
// 修复点:使用 ElLoading
const loadingMsg = ElLoading.service({ text: '照片上传中...', background: 'rgba(0, 0, 0, 0.7)' });
let success = false;
try {
const res: any = await uploadFile(formData);
@ -800,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 }
}
@ -818,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>
@ -843,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; }
@ -862,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; }
@ -876,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>

View File

@ -161,9 +161,14 @@
/>
</el-form-item>
<el-form-item label="服务商" prop="provider_name">
<el-input
<el-autocomplete
v-model="form.provider_name"
placeholder="请输入服务商名称"
:fetch-suggestions="querySearchProvider"
placeholder="输入或选择服务商"
style="width: 100%"
clearable
:trigger-on-focus="true"
@select="handleProviderSelect"
/>
</el-form-item>
<el-form-item label="简介" prop="description">
@ -199,6 +204,8 @@ import {
updateService,
deleteService,
searchMaterialBase,
getProviderSuggestions,
getUserSuggestions,
type ServiceItem,
type ServiceQueryParams,
type ServiceCreateRequest,
@ -283,7 +290,8 @@ const handleSearchMaterial = async (query: string) => {
try {
const res = await searchMaterialBase(query)
if (res.code === 200) {
materialOptions.value = res.data
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults
} else {
materialOptions.value = []
}
@ -305,6 +313,30 @@ const onMaterialSelected = (val: number) => {
}
}
// 服务商建议
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('')
@ -490,4 +522,20 @@ onMounted(() => {
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>

View File

@ -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,24 +96,18 @@
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>
@ -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,32 +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 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 = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
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,
@ -189,7 +223,6 @@ const rules = computed(() => {
]
}
} else {
// 如果是编辑模式,密码选填(只有输入了才校验长度)
return {
...commonRules,
password: [
@ -226,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
}
@ -278,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 = ''
@ -293,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()
})

View File

@ -14,13 +14,13 @@
"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
},
"include": ["vite.config.ts"]
}
}