13 Commits

31 changed files with 2346 additions and 1152 deletions

View File

@ -4,7 +4,6 @@ from app.models.base import MaterialBase
from app.models.bom import BomTable from app.models.bom import BomTable
from app.extensions import db from app.extensions import db
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from sqlalchemy import distinct
bom_bp = Blueprint('bom', __name__) bom_bp = Blueprint('bom', __name__)
@ -14,10 +13,13 @@ bom_bp = Blueprint('bom', __name__)
@bom_bp.route('/list', methods=['GET']) @bom_bp.route('/list', methods=['GET'])
@jwt_required() @jwt_required()
def get_bom_list(): def get_bom_list():
"""获取所有 BOM 配方列表(按 bom_no 分组),支持 keyword 搜索""" """获取所有 BOM 配方列表,支持 keyword 搜索和 active_only 过滤"""
try: try:
keyword = request.args.get('keyword', '').strip() keyword = request.args.get('keyword', '').strip()
data = BomService.get_bom_list(keyword=keyword) # 将字符串 'true' 转为布尔值
active_only = request.args.get('active_only', 'false').lower() == 'true'
data = BomService.get_bom_list(keyword=keyword, active_only=active_only)
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
@ -31,9 +33,13 @@ def get_bom_list():
@bom_bp.route('/detail/<bom_no>', methods=['GET']) @bom_bp.route('/detail/<bom_no>', methods=['GET'])
@jwt_required() @jwt_required()
def get_bom_detail(bom_no): def get_bom_detail(bom_no):
"""根据 BOM 编号获取配方详情""" """
根据 BOM 编号获取配方详情
Query参数: ?version=V1.0 (如果不传则取最新)
"""
try: try:
data = BomService.get_bom_detail(bom_no) version = request.args.get('version')
data = BomService.get_bom_detail(bom_no, version=version)
if not data: if not data:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404 return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
return jsonify({ return jsonify({
@ -49,14 +55,14 @@ def get_bom_detail(bom_no):
@bom_bp.route('/save', methods=['POST']) @bom_bp.route('/save', methods=['POST'])
@jwt_required() @jwt_required()
def save_bom(): def save_bom():
"""保存或更新 BOM 配方(支持自定义 bom_no""" """保存或更新 BOM 配方(支持自定义 bom_no 和 多版本"""
try: try:
req_data = request.get_json() req_data = request.get_json()
# 必需字段校验 # 必需字段校验
if 'parent_id' not in req_data or 'children' not in req_data: if 'parent_id' not in req_data or 'children' not in req_data:
return jsonify({'code': 400, 'msg': '缺少 parent_id 或 children 字段'}), 400 return jsonify({'code': 400, 'msg': '缺少 parent_id 或 children 字段'}), 400
# 校验 bom_no 不能为空(如果前端要求必须填) # 校验 bom_no 不能为空
if 'bom_no' in req_data and not req_data['bom_no']: if 'bom_no' in req_data and not req_data['bom_no']:
return jsonify({'code': 400, 'msg': 'BOM编号不能为空'}), 400 return jsonify({'code': 400, 'msg': 'BOM编号不能为空'}), 400
@ -91,20 +97,28 @@ def get_bom_with_stock_by_no(bom_no):
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
# ==================== 删除BOM接口根据bom_no删除整个配方 ==================== # ==================== 删除BOM接口 ====================
@bom_bp.route('/<bom_no>', methods=['DELETE']) @bom_bp.route('/<bom_no>', methods=['DELETE'])
@jwt_required() @jwt_required()
def delete_bom(bom_no): def delete_bom(bom_no):
"""根据 BOM 编号删除整个配方(包括所有子件记录)""" """
根据 BOM 编号删除
Query参数: ?version=V1.0 (如果不传,删除该编号下所有版本)
"""
try: try:
# 先检查是否存在 version = request.args.get('version')
exist = BomTable.query.filter_by(bom_no=bom_no).first() query = BomTable.query.filter_by(bom_no=bom_no)
if version:
query = query.filter_by(version=version)
exist = query.first()
if not exist: if not exist:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404 return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 删除该 bom_no 下所有记录 # 删除
BomTable.query.filter_by(bom_no=bom_no).delete() query.delete()
db.session.commit() db.session.commit()
return jsonify({ return jsonify({
'code': 200, 'code': 200,
@ -115,7 +129,7 @@ def delete_bom(bom_no):
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
# ==================== 兼容旧接口(保留不改动现有前端) ==================== # ==================== 兼容旧接口 ====================
@bom_bp.route('/<int:parent_id>', methods=['GET']) @bom_bp.route('/<int:parent_id>', methods=['GET'])
@jwt_required() @jwt_required()

View File

@ -1,7 +1,9 @@
# 文件路径: app/api/v1/inbound/base.py # 文件路径: 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 from app.services.inbound.base_service import MaterialBaseService
import traceback import traceback
import datetime
inbound_base_bp = Blueprint('stock_base', __name__) inbound_base_bp = Blueprint('stock_base', __name__)
@ -26,12 +28,13 @@ def search_base():
@inbound_base_bp.route('/list', methods=['GET']) @inbound_base_bp.route('/list', methods=['GET'])
def get_list(): def get_list():
try: 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) limit = request.args.get('pageSize', 10, type=int)
# 构造筛选条件 # 构造筛选条件
filters = { filters = {
'keyword': request.args.get('keyword', ''), 'keyword': request.args.get('keyword', ''),
'company': request.args.get('company', ''),
'category': request.args.get('category', ''), 'category': request.args.get('category', ''),
'type': request.args.get('type', ''), 'type': request.args.get('type', ''),
'isEnabled': request.args.get('isEnabled', None) 'isEnabled': request.args.get('isEnabled', None)
@ -45,7 +48,7 @@ def get_list():
# ============================================================================== # ==============================================================================
# 2.1 选项接口 (GET /api/v1/inbound/base/options) [新增] # 2.1 选项接口 (GET /api/v1/inbound/base/options)
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/options', methods=['GET']) @inbound_base_bp.route('/options', methods=['GET'])
def get_options(): def get_options():
@ -57,9 +60,45 @@ def get_options():
return jsonify({"code": 500, "msg": str(e)}), 500 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/) # 3. 新增接口 (POST /api/v1/inbound/base/)
# 注意:前端 material_base.ts 可能会请求 / 或 /add这里统一匹配
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/', methods=['POST']) @inbound_base_bp.route('/', methods=['POST'])
def create(): def create():

View File

@ -25,6 +25,26 @@ def search_base():
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 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 多选筛选) # 1. 获取列表 (支持 status 多选筛选)
@ -125,4 +145,4 @@ def get_options():
data = ProductInboundService.get_filter_options() data = ProductInboundService.get_filter_options()
return jsonify({"code": 200, "msg": "success", "data": data}) return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e: except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -29,6 +29,27 @@ def search_base():
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 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. 获取半成品列表 # 1. 获取半成品列表
@ -139,4 +160,4 @@ def get_options():
data = SemiInboundService.get_filter_options() data = SemiInboundService.get_filter_options()
return jsonify({"code": 200, "msg": "success", "data": data}) return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e: except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -12,6 +12,9 @@ class MaterialBase(db.Model):
# 1. 基础字段 # 1. 基础字段
id = db.Column(db.Integer, primary_key=True) 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='名称') name = db.Column(db.String(255), nullable=False, comment='名称')
common_name = db.Column(db.String(255), comment='俗名') common_name = db.Column(db.String(255), comment='俗名')
category = db.Column(db.String(100), comment='类别') category = db.Column(db.String(100), comment='类别')
@ -42,8 +45,7 @@ class MaterialBase(db.Model):
# 3. 关联成品库存 (StockProduct) # 3. 关联成品库存 (StockProduct)
stock_products = db.relationship('StockProduct', back_populates='base', lazy='dynamic') stock_products = db.relationship('StockProduct', back_populates='base', lazy='dynamic')
# 4. 关联服务库存 (StockService) - [新增] # 4. 关联服务库存 (StockService)
# 假设您的服务库存模型类名为 StockService且有 base_id 外键
stock_services = db.relationship('StockService', back_populates='base', lazy='dynamic') stock_services = db.relationship('StockService', back_populates='base', lazy='dynamic')
def to_dict(self): def to_dict(self):
@ -65,6 +67,7 @@ class MaterialBase(db.Model):
return { return {
'id': self.id, 'id': self.id,
'companyName': self.company_name,
'name': self.name, 'name': self.name,
'commonName': self.common_name, 'commonName': self.common_name,
'category': self.category, 'category': self.category,
@ -72,7 +75,6 @@ class MaterialBase(db.Model):
'spec': self.spec_model, 'spec': self.spec_model,
'unit': self.unit, 'unit': self.unit,
'visibilityLevel': self.visibility_level, 'visibilityLevel': self.visibility_level,
# 修改:解析为列表返回
'generalManual': parse_list(self.manual_link), 'generalManual': parse_list(self.manual_link),
'generalImage': parse_list(self.product_image), 'generalImage': parse_list(self.product_image),
'isEnabled': 1 if self.is_enabled else 0, 'isEnabled': 1 if self.is_enabled else 0,

View File

@ -1,21 +1,28 @@
from app.extensions import db from app.extensions import db
class BomTable(db.Model): class BomTable(db.Model):
__tablename__ = 'bom_table' __tablename__ = 'bom_table'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
parent_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False) 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) child_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
bom_no = db.Column(db.String(100), nullable=False, comment='BOM编号') bom_no = db.Column(db.String(100), nullable=False, comment='BOM编号')
version = db.Column(db.String(50), nullable=False, default='v1', comment='版本') version = db.Column(db.String(50), nullable=False, default='V1.0', comment='版本')
dosage = db.Column(db.Numeric(19, 4), 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='备注') remark = db.Column(db.Text, comment='备注')
# ★ 新增:启用状态
is_enabled = db.Column(db.Boolean, default=True, comment='是否启用')
# 约束: 保证同一版本下的父子对唯一,允许不同版本存在
__table_args__ = ( __table_args__ = (
db.UniqueConstraint('bom_no', 'parent_id', 'child_id', name='uniq_bom_no_parent_child'), db.UniqueConstraint('bom_no', 'version', 'parent_id', 'child_id', name='uniq_bom_pair_in_version'),
) )
# relationships # relationships
parent = db.relationship('MaterialBase', foreign_keys=[parent_id], backref='bom_parents') 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 找不到引用 # 显式导入 MaterialBase 以防 relationship 找不到引用
from app.models.base import MaterialBase from app.models.base import MaterialBase
class StockBuy(db.Model): class StockBuy(db.Model):
""" """
采购入库库存表 采购入库库存表
@ -32,35 +33,36 @@ class StockBuy(db.Model):
available_quantity = db.Column(db.Numeric(19, 4), default=0) available_quantity = db.Column(db.Numeric(19, 4), default=0)
# 财务与商务 # 财务与商务
unit_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) 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') currency = db.Column(db.String(20), default='CNY')
exchange_rate = db.Column(db.Numeric(15, 6), default=1.0) exchange_rate = db.Column(db.Numeric(15, 6), default=1.0)
supplier_name = db.Column(db.String(255)) supplier_name = db.Column(db.String(255))
buyer_name = db.Column(db.String(100)) # 对应 SQL: buyer_name buyer_name = db.Column(db.String(100))
buyer_email = db.Column(db.String(100)) # 对应 SQL: buyer_email buyer_email = db.Column(db.String(100))
original_link = db.Column(db.Text) # 对应 SQL: original_link original_link = db.Column(db.Text)
detail_link = db.Column(db.Text) detail_link = db.Column(db.Text)
# 图片字段 (存储 JSON 字符串) # 图片字段 (存储 JSON 字符串)
arrival_photo = db.Column(db.Text) arrival_photo = db.Column(db.Text)
# [新增] 检测报告图片路径 (存储 JSON 字符串)
inspection_report = db.Column(db.Text) inspection_report = db.Column(db.Text)
# [新增] 全局打印流水号 (用于跨表连续编号,对应 Sequence: global_print_seq) # 全局打印流水号
global_print_id = db.Column(db.Integer) global_print_id = db.Column(db.Integer)
# 关系定义 [已修改] # 关系定义
base = db.relationship('MaterialBase', back_populates='stock_buys') base = db.relationship('MaterialBase', back_populates='stock_buys')
def to_dict(self): def to_dict(self):
# 辅助解析函数:将数据库存储的 JSON 字符串转为 List # 辅助解析函数
def parse_img_list(json_str): def parse_img_list(json_str):
if not json_str: if not json_str:
return [] return []
try: try:
# 兼容旧数据:如果不是 JSON 格式(比如是单个 URL则包装成 list
if not json_str.startswith('['): if not json_str.startswith('['):
return [json_str] return [json_str]
return json.loads(json_str) return json.loads(json_str)
@ -70,7 +72,9 @@ class StockBuy(db.Model):
return { return {
'id': self.id, 'id': self.id,
'base_id': self.base_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 '', 'material_name': self.base.name if self.base else '',
'spec_model': self.base.spec_model if self.base else '', 'spec_model': self.base.spec_model if self.base else '',
'category': self.base.category 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), 'unit_price': float(self.unit_price or 0),
'total_price': float(self.total_price or 0), 'total_price': float(self.total_price or 0),
# [新增] 税率
'tax_rate': float(self.tax_rate or 0),
'currency': self.currency, 'currency': self.currency,
'exchange_rate': float(self.exchange_rate or 1.0), 'exchange_rate': float(self.exchange_rate or 1.0),
@ -104,11 +111,9 @@ class StockBuy(db.Model):
'source_link': self.original_link, 'source_link': self.original_link,
'detail_link': self.detail_link, 'detail_link': self.detail_link,
# [修改] 解析为数组返回给前端
'arrival_photo': parse_img_list(self.arrival_photo), 'arrival_photo': parse_img_list(self.arrival_photo),
'inspection_report': parse_img_list(self.inspection_report), 'inspection_report': parse_img_list(self.inspection_report),
# [新增] 返回全局打印ID及其格式化字符串
'global_print_id': self.global_print_id, 'global_print_id': self.global_print_id,
'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else "" '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 import json
from app.models.base import MaterialBase from app.models.base import MaterialBase
class StockProduct(db.Model): class StockProduct(db.Model):
""" """
成品入库库存表 成品入库库存表
@ -44,7 +45,7 @@ class StockProduct(db.Model):
quality_report_link = db.Column(db.Text) # 质量报告 quality_report_link = db.Column(db.Text) # 质量报告
inspection_report_link = db.Column(db.Text) # 检测报告(旧字段升级为JSON) inspection_report_link = db.Column(db.Text) # 检测报告(旧字段升级为JSON)
# [新增] 成品实拍图 (JSON 存储) # 成品实拍图 (JSON 存储)
product_photo = db.Column(db.Text) product_photo = db.Column(db.Text)
detail_link = 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) global_print_id = db.Column(db.Integer)
# 关系定义 [已修改] # 关系定义
base = db.relationship('MaterialBase', back_populates='stock_products') base = db.relationship('MaterialBase', back_populates='stock_products')
def to_dict(self): def to_dict(self):
@ -79,7 +80,9 @@ class StockProduct(db.Model):
return { return {
'id': self.id, 'id': self.id,
'base_id': self.base_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 '', 'material_name': self.base.name if self.base else '',
'spec_model': self.base.spec_model if self.base else '', 'spec_model': self.base.spec_model if self.base else '',
'category': self.base.category 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, 'quality_status': self.quality_status,
# [核心修改] 三个图片/链接字段全部解析为数组
'product_photo': parse_img_list(self.product_photo), 'product_photo': parse_img_list(self.product_photo),
'quality_report_link': parse_img_list(self.quality_report_link), 'quality_report_link': parse_img_list(self.quality_report_link),
'inspection_report_link': parse_img_list(self.inspection_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 import json
from app.models.base import MaterialBase from app.models.base import MaterialBase
class StockSemi(db.Model): class StockSemi(db.Model):
""" """
半成品入库库存表 半成品入库库存表
@ -43,19 +44,19 @@ class StockSemi(db.Model):
quality_status = db.Column(db.String(50)) quality_status = db.Column(db.String(50))
# [修改] 质量报告 (存储 JSON 字符串: 图片列表 + 链接) # 质量报告 (存储 JSON 字符串: 图片列表 + 链接)
quality_report_link = db.Column(db.Text) quality_report_link = db.Column(db.Text)
# [新增] 到货图片 (存储 JSON 字符串) # 到货图片 (存储 JSON 字符串)
arrival_photo = db.Column(db.Text) arrival_photo = db.Column(db.Text)
detail_link = db.Column(db.Text) detail_link = db.Column(db.Text)
remark = db.Column(db.Text) remark = db.Column(db.Text)
# [新增] 全局打印流水号 # 全局打印流水号
global_print_id = db.Column(db.Integer) global_print_id = db.Column(db.Integer)
# 关系定义 [已修改] # 关系定义
base = db.relationship('MaterialBase', back_populates='stock_semis') base = db.relationship('MaterialBase', back_populates='stock_semis')
def to_dict(self): def to_dict(self):
@ -78,7 +79,9 @@ class StockSemi(db.Model):
return { return {
'id': self.id, 'id': self.id,
'base_id': self.base_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 '', 'material_name': self.base.name if self.base else '',
'spec_model': self.base.spec_model if self.base else '', 'spec_model': self.base.spec_model if self.base else '',
'category': self.base.category 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, 'quality_status': self.quality_status,
# [修改] 解析 JSON 字符串为数组返回给前端
'quality_report_link': parse_img_list(self.quality_report_link), 'quality_report_link': parse_img_list(self.quality_report_link),
'arrival_photo': parse_img_list(self.arrival_photo), 'arrival_photo': parse_img_list(self.arrival_photo),

View File

@ -3,18 +3,24 @@ from app.extensions import db
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime from datetime import datetime
class SysUser(db.Model): class SysUser(db.Model):
"""
系统用户表
对应数据库: sys_user
username 字段存储格式约定: "真实姓名/登录账号" (例如: 张三/zhangsan)
"""
__tablename__ = 'sys_user' __tablename__ = 'sys_user'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), nullable=False) username = db.Column(db.String(100), nullable=False) # 存储 "张三/zhangsan"
# 注意:如果允许邮箱为空,建议去掉 unique=True 或者在数据库层面处理空字符串
email = db.Column(db.String(100), unique=True) email = db.Column(db.String(100), unique=True)
department = db.Column(db.String(100)) department = db.Column(db.String(100))
role = db.Column(db.String(50)) role = db.Column(db.String(50))
status = db.Column(db.String(20), default='active') status = db.Column(db.String(20), default='active')
password_hash = db.Column(db.Text) password_hash = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.now) # 新增创建时间
# created_at 已在数据库脚本中移除,此处不再定义
def set_password(self, password): def set_password(self, password):
"""生成加密密码""" """生成加密密码"""
@ -25,17 +31,37 @@ class SysUser(db.Model):
return check_password_hash(self.password_hash, password) return check_password_hash(self.password_hash, password)
def to_dict(self): 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 { return {
'id': self.id, 'id': self.id,
'username': self.username, 'username': display_name, # 列表显示: 张三(zhangsan)
'raw_username': self.username, # 原始数据
'account_id': account_id, # 纯账号ID: zhangsan
'email': self.email, 'email': self.email,
'department': self.department, 'department': self.department,
'role': self.role, 'role': self.role,
'status': self.status, 'status': self.status
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else ''
} }
class SysLog(db.Model): class SysLog(db.Model):
""" """
系统操作日志表 系统操作日志表

View File

@ -3,6 +3,7 @@ from app.models.system import SysUser
from app.extensions import db from app.extensions import db
from flask_jwt_extended import create_access_token from flask_jwt_extended import create_access_token
from app.utils.constants import UserRole from app.utils.constants import UserRole
from datetime import timedelta
class AuthService: class AuthService:
@ -12,29 +13,35 @@ class AuthService:
@staticmethod @staticmethod
def login(data): def login(data):
username = data.get('username') # 用户登录时输入的只是账号ID (例如: zhangsan)
login_input = data.get('username', '').strip()
password = data.get('password') password = data.get('password')
user_role = None user_role = None
user_id = None user_id = None
user_info = {} user_info = {}
# 1. 优先检查硬编码的超级管理员 # 1. 优先检查硬编码的超级管理员 (IRIS)
if username == AuthService.SUPER_ADMIN_USER: if login_input == AuthService.SUPER_ADMIN_USER:
if password == AuthService.SUPER_ADMIN_PASS: if password == AuthService.SUPER_ADMIN_PASS:
user_role = UserRole.SUPER_ADMIN user_role = UserRole.SUPER_ADMIN
user_id = 0 # 虚拟ID user_id = 0
user_info = { user_info = {
'username': username, 'username': '超级管理员(IRIS)',
'account_id': 'IRIS',
'role': user_role, 'role': user_role,
'department': 'System' 'department': 'System',
'status': 'active'
} }
else: else:
raise ValueError("密码错误") raise ValueError("密码错误")
# 2. 如果不是 IRIS检查数据库用户 # 2. 检查数据库用户
# 数据库存的是 "张三/zhangsan"
# 登录匹配逻辑: 查找以 "/login_input" 结尾的记录
else: 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: if not user:
raise ValueError("用户不存在") raise ValueError("用户不存在")
@ -50,9 +57,17 @@ class AuthService:
user_info = user.to_dict() user_info = user.to_dict()
# 3. 生成 Token # 3. 生成 Token
# Token 中 identity 存数据库IDclaims 存登录账号ID
account_id = user_info.get('account_id', login_input)
access_token = create_access_token( access_token = create_access_token(
identity=user_id, 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 { return {
@ -63,25 +78,59 @@ class AuthService:
@staticmethod @staticmethod
def create_user(data, operator_role): def create_user(data, operator_role):
""" """
创建新用户 (仅限管理员使用) 创建新用户
data 包含: cn_name(张三), username(zhangsan), ...
""" """
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]: if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
raise Exception("权限不足:只有超级管理员或主管可以创建新用户") raise Exception("权限不足:只有超级管理员或主管可以创建新用户")
if SysUser.query.filter_by(username=data.get('username')).first(): cn_name = data.get('cn_name')
raise Exception("用户名已存在") pinyin_base = data.get('username') # 前端传来的基础拼音,如 zhangsan
if not cn_name or not pinyin_base:
raise Exception("姓名和账号不能为空")
role = data.get('role') 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: 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', '') email = data.get('email', '')
if email and SysUser.query.filter_by(email=email).first(): if email and SysUser.query.filter_by(email=email).first():
raise Exception("邮箱已被使用") 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( new_user = SysUser(
username=data.get('username'), username=full_username_storage, # 存组合串
email=email, email=email,
department=data.get('department', ''), department=data.get('department', ''),
role=role, role=role,
@ -92,15 +141,17 @@ class AuthService:
db.session.add(new_user) db.session.add(new_user)
db.session.commit() db.session.commit()
# 返回时最好把生成的ID告诉前端
return new_user.to_dict() return new_user.to_dict()
@staticmethod @staticmethod
def update_user(user_id, data, operator_role): def update_user(user_id, data, operator_role):
""" """
[新增] 更新用户信息 更新用户信息
注意: 这里暂时不允许修改用户名/账号,因为涉及 split 逻辑较复杂,且通常账号不开通后不改
""" """
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]: if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
raise Exception("权限不足:只有超级管理员或主管可以修改用户信息") raise Exception("权限不足")
user = SysUser.query.get(user_id) user = SysUser.query.get(user_id)
if not user: if not user:
@ -108,16 +159,21 @@ class AuthService:
# 1. 更新基本信息 # 1. 更新基本信息
if 'role' in data: if 'role' in data:
valid_roles = [v for k, v in UserRole.__dict__.items() if not k.startswith('__')] valid_roles = [
if data['role'] not in 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"角色无效") 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: if 'department' in data:
user.department = data['department'] user.department = data['department']
if 'email' in data: if 'email' in data:
# 如果修改了邮箱,且新邮箱已被其他人使用
email = data['email'] email = data['email']
if email and email != user.email: if email and email != user.email:
existing = SysUser.query.filter_by(email=email).first() existing = SysUser.query.filter_by(email=email).first()
@ -125,7 +181,9 @@ class AuthService:
raise Exception("该邮箱已被其他用户使用") raise Exception("该邮箱已被其他用户使用")
user.email = email user.email = email
# 2. 如果提供了密码,则重置密码;否则保持原密码 if 'status' in data:
user.status = data['status']
new_password = data.get('password') new_password = data.get('password')
if new_password and str(new_password).strip(): if new_password and str(new_password).strip():
if len(new_password) < 6: if len(new_password) < 6:
@ -144,8 +202,8 @@ class AuthService:
@staticmethod @staticmethod
def delete_user(user_id, operator_role): def delete_user(user_id, operator_role):
"""删除用户""" """删除用户"""
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]: if operator_role != UserRole.SUPER_ADMIN:
raise Exception("权限不足") raise Exception("权限不足:只有超级管理员可以删除用户")
user = SysUser.query.get(user_id) user = SysUser.query.get(user_id)
if not user: if not user:

View File

@ -2,7 +2,7 @@ from app.extensions import db
from app.models.bom import BomTable from app.models.bom import BomTable
from app.models.base import MaterialBase from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
from sqlalchemy import func, distinct, or_ from sqlalchemy import func, distinct, or_, case
import uuid import uuid
from datetime import datetime from datetime import datetime
@ -18,90 +18,83 @@ class BomService:
return f'BOM-{timestamp}-{unique}' return f'BOM-{timestamp}-{unique}'
@staticmethod @staticmethod
def get_bom_list(keyword=None): def get_bom_list(keyword=None, active_only=False):
""" """
获取所有 BOM 配方(按 bom_no 分组) 获取所有 BOM 配方(按 bom_no + version 分组)
支持模糊搜索BOM编号、父件名称/规格、子件名称/规格 支持模糊搜索BOM编号、父件名称/规格、子件名称/规格
""" """
# 1. 如果有搜索关键词,先筛选出符合条件的 bom_no # 1. 关键词过滤:先找出符合条件的 (bom_no, version) 组
filtered_bom_nos = None 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: if keyword:
kw = f'%{keyword}%' kw = f'%{keyword}%'
# 关联子件表以支持子件搜索
# 条件A: 匹配 BOM编号 或 父件信息 child_alias = db.aliased(MaterialBase)
# 需要 join 父件表 query_base = query_base.outerjoin(
q1 = db.session.query(BomTable.bom_no).join( child_alias, BomTable.child_id == child_alias.id
MaterialBase, BomTable.parent_id == MaterialBase.id
).filter( ).filter(
or_( or_(
BomTable.bom_no.ilike(kw), BomTable.bom_no.ilike(kw),
MaterialBase.name.ilike(kw), MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw) MaterialBase.spec_model.ilike(kw),
child_alias.name.ilike(kw),
child_alias.spec_model.ilike(kw)
) )
) )
# 条件B: 匹配 子件信息 # 获取符合条件的唯一组合
# 需要 join 子件表 target_pairs = query_base.distinct().all()
q2 = db.session.query(BomTable.bom_no).join(
MaterialBase, BomTable.child_id == MaterialBase.id 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( ).filter(
or_( BomTable.bom_no == bom_no,
MaterialBase.name.ilike(kw), BomTable.version == version
MaterialBase.spec_model.ilike(kw) ).group_by(
) BomTable.parent_id, MaterialBase.name, MaterialBase.spec_model, BomTable.is_enabled
) ).first()
# 取并集 (Union) if summary:
filtered_bom_nos = q1.union(q2).distinct().all() results.append({
filtered_bom_nos = [row[0] for row in filtered_bom_nos] '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)
if not filtered_bom_nos: return results
return []
# 2. 原有的分组聚合查询
subq_query = db.session.query(
BomTable.bom_no,
BomTable.parent_id,
BomTable.version,
func.count(BomTable.child_id).label('child_count')
)
# 应用筛选
if filtered_bom_nos is not None:
subq_query = subq_query.filter(BomTable.bom_no.in_(filtered_bom_nos))
subq = subq_query.group_by(BomTable.bom_no, BomTable.parent_id, BomTable.version).subquery()
query = db.session.query(
subq.c.bom_no,
subq.c.parent_id,
subq.c.version,
subq.c.child_count,
MaterialBase.name.label('parent_name'),
MaterialBase.spec_model.label('parent_spec')
).join(MaterialBase, subq.c.parent_id == MaterialBase.id)
# 按 bom_no 倒序排列
query = query.order_by(subq.c.bom_no.desc())
results = query.all()
return [{
'bom_no': row.bom_no,
'parent_id': row.parent_id,
'parent_name': row.parent_name,
'parent_spec': row.parent_spec or '',
'version': row.version,
'child_count': row.child_count
} for row in results]
@staticmethod @staticmethod
def get_bom_detail(bom_no): def get_bom_detail(bom_no, version=None):
""" """
根据 bom_no 获取配方详情 根据 bom_no (和 version) 获取配方详情
返回包含父件信息和子件列表的对象
""" """
rows = db.session.query( query = db.session.query(
BomTable, BomTable,
MaterialBase.name.label('child_name'), MaterialBase.name.label('child_name'),
MaterialBase.spec_model.label('child_spec') MaterialBase.spec_model.label('child_spec')
@ -109,21 +102,24 @@ class BomService:
MaterialBase, BomTable.child_id == MaterialBase.id MaterialBase, BomTable.child_id == MaterialBase.id
).filter( ).filter(
BomTable.bom_no == bom_no BomTable.bom_no == bom_no
).all() )
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: if not rows:
return None return None
first = rows[0] first = rows[0]
parent_id = first.BomTable.parent_id parent_id = first.BomTable.parent_id
# 获取父件的名称和规格 parent_material = MaterialBase.query.get(parent_id)
parent_material = MaterialBase.query.filter(MaterialBase.id == parent_id).first()
if parent_material:
parent_name = parent_material.name
parent_spec = parent_material.spec_model or ''
else:
parent_name = ''
parent_spec = ''
children = [] children = []
for bom, child_name, child_spec in rows: for bom, child_name, child_spec in rows:
@ -137,45 +133,33 @@ class BomService:
return { return {
'bom_no': bom_no, 'bom_no': bom_no,
'parent_id': parent_id,
'parent_name': parent_name,
'parent_spec': parent_spec,
'version': first.BomTable.version, '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 'children': children
} }
@staticmethod @staticmethod
def save_bom(data): def save_bom(data):
""" """保存 BOM (支持多版本)"""
保存或更新一个 BOM 配方
data 结构:
{
"bom_no": "用户输入或自动生成",
"version": "版本号默认v1",
"parent_id": 父件ID,
"children": [...]
}
"""
bom_no = data.get('bom_no') bom_no = data.get('bom_no')
version = data.get('version', 'v1') version = data.get('version', 'V1.0')
parent_id = data['parent_id'] parent_id = data['parent_id']
children = data['children'] children = data['children']
is_enabled = data.get('is_enabled', True)
if not bom_no:
raise ValueError('BOM编号不能为空')
# 校验父件不能与子件相同
for child in children: for child in children:
if child['child_id'] == parent_id: if child['child_id'] == parent_id:
raise ValueError('父件与子件不能是同一物料') raise ValueError('父件与子件不能是同一物料')
# 如果未提供 bom_no则生成一个新的 (兼容旧逻辑,但现在前端会传值) # 仅删除当前版本的旧记录
if not bom_no or not bom_no.strip(): BomTable.query.filter_by(bom_no=bom_no, version=version).delete()
# 如果前端没传,抛出异常要求用户填写,或者自动生成
# 这里选择自动生成作为兜底,但推荐前端校验必填
bom_no = BomService.generate_bom_no()
# 删除该 bom_no 下所有现有记录 (全量更新模式)
BomTable.query.filter_by(bom_no=bom_no).delete()
# 插入新记录
for child in children: for child in children:
bom = BomTable( bom = BomTable(
bom_no=bom_no, bom_no=bom_no,
@ -183,7 +167,8 @@ class BomService:
parent_id=parent_id, parent_id=parent_id,
child_id=child['child_id'], child_id=child['child_id'],
dosage=child.get('dosage', 0), dosage=child.get('dosage', 0),
remark=child.get('remark', '') remark=child.get('remark', ''),
is_enabled=is_enabled
) )
db.session.add(bom) db.session.add(bom)
@ -193,81 +178,69 @@ class BomService:
@staticmethod @staticmethod
def get_bom_with_stock_by_bom_no(bom_no): def get_bom_with_stock_by_bom_no(bom_no):
""" """
根据 bom_no 获取配方详情,并计算每个子件的库存和最大可生产数量 根据 bom_no 获取配方详情,并计算
1. 总可用库存
2. 最大可生产套数
3. ★ 聚合库位信息 (warehouse_locations)
""" """
detail = BomService.get_bom_detail(bom_no) detail = BomService.get_bom_detail(bom_no)
if not detail: if not detail:
return None return None
for child in detail['children']: for child in detail['children']:
# 查询该子件在 StockBuy 中的可用库存总量 # 1. 查询该子件的总库存
stock_qty = db.session.query( stock_qty = db.session.query(
func.coalesce(func.sum(StockBuy.available_quantity), 0) func.coalesce(func.sum(StockBuy.available_quantity), 0)
).filter( ).filter(
StockBuy.base_id == child['child_id'] StockBuy.base_id == child['child_id']
).scalar() or 0 ).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['current_stock'] = float(stock_qty)
child['warehouse_location'] = locations or '' # 返回给前端
dosage = child['dosage'] dosage = child['dosage']
child['max_producible'] = int(stock_qty // dosage) if dosage > 0 else 0 child['max_producible'] = int(stock_qty // dosage) if dosage > 0 else 0
return detail return detail
# ====================== 兼容旧接口(基于 parent_id ====================== # ====================== 兼容旧接口 ======================
@staticmethod @staticmethod
def get_bom_no_by_parent(parent_id): def get_bom_no_by_parent(parent_id):
""" row = BomTable.query.filter_by(parent_id=parent_id).order_by(BomTable.version.desc()).first()
根据父件 ID 获取其最新的 BOM 编号(用于兼容旧接口)
"""
row = BomTable.query.filter_by(parent_id=parent_id) \
.order_by(BomTable.version.desc()).first()
return row.bom_no if row else None return row.bom_no if row else None
@staticmethod @staticmethod
def create_or_update_bom(parent_id, child_list, bom_no=None, version='v1'): def create_or_update_bom(parent_id, child_list, bom_no=None, version='V1.0'):
"""
兼容旧接口的保存方法(保留原有调用方式)
如果提供了 bom_no则更新该 bom_no否则为父件创建新 BOM。
"""
# 校验父件不能与子件相同
for item in child_list:
if item['child_id'] == parent_id:
raise ValueError('父件与子件不能是同一物料')
# 如果未提供 bom_no尝试查找现有的否则新建
if not bom_no: if not bom_no:
existing = BomTable.query.filter_by(parent_id=parent_id).first() existing = BomTable.query.filter_by(parent_id=parent_id).first()
bom_no = existing.bom_no if existing else BomService.generate_bom_no() bom_no = existing.bom_no if existing else BomService.generate_bom_no()
# 删除该 bom_no 下所有记录 BomTable.query.filter_by(bom_no=bom_no, version=version).delete()
BomTable.query.filter_by(bom_no=bom_no).delete()
# 插入新的
for item in child_list: for item in child_list:
bom = BomTable( bom = BomTable(
bom_no=bom_no, bom_no=bom_no, version=version, parent_id=parent_id,
version=version, child_id=item['child_id'], dosage=item.get('dosage', 0), remark=item.get('remark', '')
parent_id=parent_id,
child_id=item['child_id'],
dosage=item.get('dosage', 0),
remark=item.get('remark', '')
) )
db.session.add(bom) db.session.add(bom)
db.session.commit() db.session.commit()
return True return True
@staticmethod @staticmethod
def get_bom_with_stock(parent_id): def get_bom_with_stock(parent_id):
"""
兼容旧接口:根据父件 ID 获取 BOM 及库存信息
(实际会找到对应的 bom_no 再调用新方法)
"""
bom_no = BomService.get_bom_no_by_parent(parent_id) bom_no = BomService.get_bom_no_by_parent(parent_id)
if not bom_no: if not bom_no: return []
return []
detail = BomService.get_bom_with_stock_by_bom_no(bom_no) detail = BomService.get_bom_with_stock_by_bom_no(bom_no)
if not detail: return detail['children'] if detail else []
return []
return detail['children']

View File

@ -4,12 +4,16 @@ from app.extensions import db
from app.models.base import MaterialBase from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
# 假设您有 StockProduct 和 StockService 的模型定义 from app.models.inbound.product import StockProduct
# from app.models.inbound.product import StockProduct
# from app.models.inbound.service import StockService # from app.models.inbound.service import StockService
from sqlalchemy import or_ from sqlalchemy import or_, and_
import traceback import traceback
import json import json
import io
import datetime
# 需要 pip install openpyxl
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
class MaterialBaseService: class MaterialBaseService:
@ -33,14 +37,37 @@ class MaterialBaseService:
or_( or_(
MaterialBase.name.ilike(f'%{keyword}%'), MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.common_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 = [] results = []
for item in query.all(): for item in db_items:
results.append({ results.append({
'id': item.id, 'id': item.id, # 必须保留ID供前端逻辑使用视觉上的隐藏请在前端处理
'companyName': item.company_name,
'name': item.name, 'name': item.name,
'commonName': item.common_name, 'commonName': item.common_name,
'spec': item.spec_model, 'spec': item.spec_model,
@ -58,29 +85,21 @@ class MaterialBaseService:
def _get_stock_counts(stock_query): def _get_stock_counts(stock_query):
""" """
辅助函数:安全计算库存列表的总数量 辅助函数:安全计算库存列表的总数量
修复逻辑:优先查找 'stock_quantity' (Buy/Semi/Product表中实际使用的字段)
""" """
total_inv = 0 total_inv = 0
total_avail = 0 total_avail = 0
# 如果 stock_query 是动态加载的查询对象 (AppenderQuery),需要迭代它
# 如果是列表,直接迭代
try: try:
items = list(stock_query) # 触发查询 items = list(stock_query) # 触发查询
except: except:
items = [] items = []
for x in items: for x in items:
# 1. 获取库存数 # 1. 获取库存数 (兼容不同字段名)
# 【修复点】根据你提供的 Service 代码Buy/Semi/Product 均使用 stock_quantity q = getattr(x, 'stock_quantity', getattr(x, 'in_quantity', 0)) # 优先取库存,其次入库
# Service 使用 actual_quantity这里做兼容查找
q = getattr(x, 'stock_quantity', getattr(x, 'actual_quantity', getattr(x, 'quantity', 0)))
# 2. 获取可用数 # 2. 获取可用数
# 这里的字段名通常都是 available_quantity
a = getattr(x, 'available_quantity', q) a = getattr(x, 'available_quantity', q)
# 累加 (转 float 防止 None 或 Decimal 计算报错)
try: try:
total_inv += float(q if q is not None else 0) total_inv += float(q if q is not None else 0)
total_avail += float(a if a is not None else 0) total_avail += float(a if a is not None else 0)
@ -93,13 +112,12 @@ class MaterialBaseService:
def get_list(page, limit, filters=None): def get_list(page, limit, filters=None):
""" """
获取基础信息列表 (带分页和筛选) 获取基础信息列表 (带分页和筛选)
并聚合库存总数和可用总数
""" """
try: try:
query = MaterialBase.query query = MaterialBase.query
if filters: if filters:
# 1. 关键词模糊搜索 (名称 或 俗名 或 规格型号) # 1. 关键词模糊搜索
if filters.get('keyword'): if filters.get('keyword'):
kw = f"%{filters['keyword']}%" kw = f"%{filters['keyword']}%"
query = query.filter(or_( query = query.filter(or_(
@ -109,6 +127,10 @@ class MaterialBaseService:
)) ))
# 2. 精确筛选 # 2. 精确筛选
# 公司筛选
if filters.get('company'):
query = query.filter_by(company_name=filters['company'])
if filters.get('category'): if filters.get('category'):
query = query.filter_by(category=filters['category']) query = query.filter_by(category=filters['category'])
@ -116,34 +138,23 @@ class MaterialBaseService:
query = query.filter_by(material_type=filters['type']) query = query.filter_by(material_type=filters['type'])
if filters.get('isEnabled') is not None: if filters.get('isEnabled') is not None:
# 前端传 1/0转为 Boolean
is_active = bool(int(filters['isEnabled'])) is_active = bool(int(filters['isEnabled']))
query = query.filter_by(is_enabled=is_active) query = query.filter_by(is_enabled=is_active)
# 按 ID 倒序排列 # [修改3] 默认排序方式改为按 spec_model 排序
pagination = query.order_by(MaterialBase.id.desc()).paginate(page=page, per_page=limit, error_out=False) pagination = query.order_by(MaterialBase.spec_model.asc()).paginate(page=page, per_page=limit,
error_out=False)
items_list = [] items_list = []
for item in pagination.items: for item in pagination.items:
# 获取基础字典
item_dict = item.to_dict() item_dict = item.to_dict()
# [调用修复后的辅助函数] # 聚合库存
# 1. 采购库存 (StockBuy)
buy_inv, buy_avail = MaterialBaseService._get_stock_counts(item.stock_buys) buy_inv, buy_avail = MaterialBaseService._get_stock_counts(item.stock_buys)
# 2. 半成品库存 (StockSemi)
semi_inv, semi_avail = MaterialBaseService._get_stock_counts(item.stock_semis) semi_inv, semi_avail = MaterialBaseService._get_stock_counts(item.stock_semis)
# 3. 成品库存 (StockProduct)
prod_inv, prod_avail = MaterialBaseService._get_stock_counts(item.stock_products) prod_inv, prod_avail = MaterialBaseService._get_stock_counts(item.stock_products)
# 4. 服务库存 (StockService)
# 使用 getattr 防止关联不存在时报错
serv_inv, serv_avail = MaterialBaseService._get_stock_counts(getattr(item, 'stock_services', [])) 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['inventoryCount'] = buy_inv + semi_inv + prod_inv + serv_inv
item_dict['availableCount'] = buy_avail + semi_avail + prod_avail + serv_avail item_dict['availableCount'] = buy_avail + semi_avail + prod_avail + serv_avail
@ -159,37 +170,45 @@ class MaterialBaseService:
@staticmethod @staticmethod
def get_distinct_options(): def get_distinct_options():
""" """
获取所有已存在的类别类型 (去重) 获取所有已存在的类别类型、公司 (去重且排序)
用于前端下拉筛选
""" """
try: try:
# 查询所有不为空的类别并去重 # 1. 类别 (获取后在内存或前端做层级处理,这里先按字母序返回扁平列表)
categories = db.session.query(MaterialBase.category) \ categories = db.session.query(MaterialBase.category) \
.filter(MaterialBase.category != None, MaterialBase.category != '') \ .filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all() .distinct().all()
# 查询所有不为空的类型并去重 # 对类别进行排序
sorted_categories = sorted([c[0] for c in categories])
# 2. 类型
types = db.session.query(MaterialBase.material_type) \ types = db.session.query(MaterialBase.material_type) \
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \ .filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
.distinct().all() .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 { return {
"categories": [c[0] for c in categories], "categories": sorted_categories,
"types": [t[0] for t in types] "types": sorted_types,
"companies": sorted_companies
} }
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return {"categories": [], "types": []} return {"categories": [], "types": [], "companies": []}
@staticmethod @staticmethod
def create_material(data): def create_material(data):
"""新增基础信息""" """新增基础信息"""
try: try:
# 0. 基础校验
if not data.get('name') or not data.get('spec'): if not data.get('name') or not data.get('spec'):
raise ValueError("名称和规格型号不能为空") raise ValueError("名称和规格型号不能为空")
# 1. 查重
exist = MaterialBase.query.filter_by( exist = MaterialBase.query.filter_by(
name=data['name'], name=data['name'],
spec_model=data['spec'] spec_model=data['spec']
@ -197,8 +216,9 @@ class MaterialBaseService:
if exist: if exist:
raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})") raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})")
# 2. 创建对象 (列表转JSON字符串)
new_material = MaterialBase( new_material = MaterialBase(
# [修改] 移除了 'IRIS' 默认值
company_name=data.get('companyName'),
name=data['name'], name=data['name'],
common_name=data.get('commonName'), common_name=data.get('commonName'),
spec_model=data['spec'], spec_model=data['spec'],
@ -206,7 +226,6 @@ class MaterialBaseService:
material_type=data.get('type'), material_type=data.get('type'),
unit=data.get('unit'), unit=data.get('unit'),
visibility_level=data.get('visibilityLevel'), visibility_level=data.get('visibilityLevel'),
# 修改:将列表 dumps 为字符串
manual_link=json.dumps(data.get('generalManual', [])), manual_link=json.dumps(data.get('generalManual', [])),
product_image=json.dumps(data.get('generalImage', [])), product_image=json.dumps(data.get('generalImage', [])),
is_enabled=True if data.get('isEnabled', 1) == 1 else False is_enabled=True if data.get('isEnabled', 1) == 1 else False
@ -229,6 +248,7 @@ class MaterialBaseService:
raise ValueError("数据不存在") raise ValueError("数据不存在")
# 更新字段 # 更新字段
if 'companyName' in data: material.company_name = data['companyName']
if 'name' in data: material.name = data['name'] if 'name' in data: material.name = data['name']
if 'commonName' in data: material.common_name = data['commonName'] if 'commonName' in data: material.common_name = data['commonName']
if 'spec' in data: material.spec_model = data['spec'] if 'spec' in data: material.spec_model = data['spec']
@ -237,7 +257,6 @@ class MaterialBaseService:
if 'unit' in data: material.unit = data['unit'] if 'unit' in data: material.unit = data['unit']
if 'visibilityLevel' in data: material.visibility_level = data['visibilityLevel'] if 'visibilityLevel' in data: material.visibility_level = data['visibilityLevel']
# 修改:将列表 dumps 为字符串
if 'generalManual' in data: if 'generalManual' in data:
material.manual_link = json.dumps(data['generalManual']) material.manual_link = json.dumps(data['generalManual'])
if 'generalImage' in data: if 'generalImage' in data:
@ -265,18 +284,16 @@ class MaterialBaseService:
buy_usage_count = StockBuy.query.filter_by(base_id=m_id).count() buy_usage_count = StockBuy.query.filter_by(base_id=m_id).count()
semi_usage_count = StockSemi.query.filter_by(base_id=m_id).count() semi_usage_count = StockSemi.query.filter_by(base_id=m_id).count()
prod_usage_count = StockProduct.query.filter_by(base_id=m_id).count()
# 如果需要检查成品和服务,可以解开注释 total_usage = buy_usage_count + semi_usage_count + prod_usage_count
# from app.models.inbound.product import StockProduct
# prod_usage_count = StockProduct.query.filter_by(base_id=m_id).count()
total_usage = buy_usage_count + semi_usage_count
if total_usage > 0: if total_usage > 0:
raise ValueError( raise ValueError(
f"无法删除:该基础物料正被使用中。\n" f"无法删除:该基础物料正被使用中。\n"
f"- 采购库存记录: {buy_usage_count}\n" f"- 采购库存记录: {buy_usage_count}\n"
f"- 半成品库存记录: {semi_usage_count}\n" f"- 半成品库存记录: {semi_usage_count}\n"
f"- 成品库存记录: {prod_usage_count}\n"
f"请先清理相关库存或仅‘禁用’此条目。" f"请先清理相关库存或仅‘禁用’此条目。"
) )
@ -287,4 +304,235 @@ class MaterialBaseService:
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
print(f"删除基础信息失败: {e}") 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 raise e

View File

@ -1,3 +1,4 @@
# inventory-backend/app/services/inbound/buy_service.py
from app.extensions import db from app.extensions import db
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
from app.models.base import MaterialBase from app.models.base import MaterialBase
@ -47,7 +48,8 @@ class BuyInboundService:
query = query.filter(and_( query = query.filter(and_(
or_( or_(
MaterialBase.name.ilike(k_str), MaterialBase.name.ilike(k_str),
MaterialBase.spec_model.ilike(k_str) MaterialBase.spec_model.ilike(k_str),
MaterialBase.company_name.ilike(k_str) # 支持搜公司
) )
)) ))
@ -58,6 +60,7 @@ class BuyInboundService:
for item in pagination.items: for item in pagination.items:
items.append({ items.append({
'id': item.id, 'id': item.id,
'company_name': item.company_name, # [新增]
'name': item.name, 'name': item.name,
'spec': item.spec_model, 'spec': item.spec_model,
'category': item.category, 'category': item.category,
@ -114,6 +117,7 @@ class BuyInboundService:
in_qty = float(data.get('in_quantity') or 0) in_qty = float(data.get('in_quantity') or 0)
u_price = float(data.get('unit_price') or 0) u_price = float(data.get('unit_price') or 0)
tax_rate = float(data.get('tax_rate') or 0) # [新增]
try: try:
seq_sql = text("SELECT nextval('global_print_seq')") seq_sql = text("SELECT nextval('global_print_seq')")
@ -131,8 +135,14 @@ class BuyInboundService:
status=data.get('status', '在库'), in_quantity=in_qty, stock_quantity=in_qty, available_quantity=in_qty, status=data.get('status', '在库'), in_quantity=in_qty, stock_quantity=in_qty, available_quantity=in_qty,
inspection_status=data.get('inspection_status', '未检'), inspection_status=data.get('inspection_status', '未检'),
warehouse_location=data.get('warehouse_location'), warehouse_location=data.get('warehouse_location'),
unit_price=u_price, total_price=in_qty * u_price, currency=data.get('currency', 'CNY'),
# 价格信息
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), 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'), buyer_email=data.get('purchaser_email'),
original_link=data.get('source_link'), detail_link=data.get('detail_link'), original_link=data.get('source_link'), detail_link=data.get('detail_link'),
@ -172,13 +182,18 @@ class BuyInboundService:
if 'arrival_photo' in data: stock.arrival_photo = json.dumps(data['arrival_photo']) 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 '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: if 'in_quantity' in data:
diff = float(data['in_quantity']) - float(stock.in_quantity) diff = float(data['in_quantity']) - float(stock.in_quantity)
if diff != 0: if diff != 0:
stock.in_quantity = float(data['in_quantity']) stock.in_quantity = float(data['in_quantity'])
stock.stock_quantity = float(stock.stock_quantity) + diff stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_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) stock.total_price = float(stock.in_quantity) * float(stock.unit_price)
db.session.commit() db.session.commit()
return stock return stock
@ -205,7 +220,7 @@ class BuyInboundService:
# 5. 获取列表 # 5. 获取列表
# ============================================================ # ============================================================
@staticmethod @staticmethod
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None): def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None):
try: try:
query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id) query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
@ -219,18 +234,23 @@ class BuyInboundService:
StockBuy.serial_number.ilike(k_str), StockBuy.serial_number.ilike(k_str),
StockBuy.supplier_name.ilike(k_str), StockBuy.supplier_name.ilike(k_str),
StockBuy.buyer_name.ilike(k_str), StockBuy.buyer_name.ilike(k_str),
MaterialBase.name.ilike(k_str), # 名称 MaterialBase.name.ilike(k_str),
MaterialBase.spec_model.ilike(k_str), # 规格 MaterialBase.spec_model.ilike(k_str),
MaterialBase.company_name.ilike(k_str), # 关键词也支持搜公司
] ]
query = query.filter(or_(*conditions)) query = query.filter(or_(*conditions))
# 2. 类别独立搜索 # 2. 类别独立搜索
if category and category.strip(): if category and category.strip():
query = query.filter(MaterialBase.category == category.strip()) # 下拉框通常是精确匹配 query = query.filter(MaterialBase.category == category.strip())
# 3. 类型独立搜索 # 3. 类型独立搜索
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) # 精确匹配 query = query.filter(MaterialBase.material_type == material_type.strip())
# 3.1 公司独立搜索 [新增]
if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip())
# 4. 状态筛选 # 4. 状态筛选
if not statuses: statuses = ['在库', '借库'] if not statuses: statuses = ['在库', '借库']
@ -242,52 +262,44 @@ class BuyInboundService:
pagination = query.order_by(StockBuy.in_date.desc()).paginate(page=page, per_page=limit, error_out=False) pagination = query.order_by(StockBuy.in_date.desc()).paginate(page=page, per_page=limit, error_out=False)
items = [] items = []
for item in pagination.items: for item in pagination.items:
items.append({ items.append(item.to_dict()) # 直接使用 model 的 to_dict
'id': item.id, 'base_id': item.base_id, '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': str(item.in_date)[:10] if item.in_date else '', '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': float(item.stock_quantity or 0), 'qty_available': float(item.available_quantity or 0),
'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': json.loads(item.arrival_photo) if item.arrival_photo else [],
'inspection_report': json.loads(item.inspection_report) if item.inspection_report else [],
'global_print_id': item.global_print_id
})
return {"total": pagination.total, "items": items} return {"total": pagination.total, "items": items}
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
return {"total": 0, "items": []} return {"total": 0, "items": []}
# ============================================================ # ============================================================
# 6. [新增] 获取筛选选项(类别、类型 # 6. 获取筛选选项(类别、类型、公司)并排序
# ============================================================ # ============================================================
@staticmethod @staticmethod
def get_filter_options(): def get_filter_options():
try: try:
# 获取所有非空的类别 # 类别
categories = db.session.query(MaterialBase.category) \ categories = db.session.query(MaterialBase.category) \
.filter(MaterialBase.category != None, MaterialBase.category != '') \ .filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all() .distinct().all()
sorted_categories = sorted([r[0] for r in categories])
# 获取所有非空的类型 # 类型
types = db.session.query(MaterialBase.material_type) \ types = db.session.query(MaterialBase.material_type) \
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \ .filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
.distinct().all() .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 { return {
"categories": [r[0] for r in categories], "categories": sorted_categories,
"types": [r[0] for r in types] "types": sorted_types,
"companies": sorted_companies
} }
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
return {"categories": [], "types": []} return {"categories": [], "types": [], "companies": []}
# 7-10 建议类接口保持不变 # 7-10 建议类接口保持不变
@staticmethod @staticmethod

View File

@ -11,26 +11,17 @@ import json
class ProductInboundService: class ProductInboundService:
# ============================================================ # ============================================================
# 0. 辅助:唯一性校验 (新增核心逻辑) # 0. 辅助:唯一性校验
# ============================================================ # ============================================================
@staticmethod @staticmethod
def _check_unique(serial_number, exclude_id=None): def _check_unique(serial_number, exclude_id=None):
"""
校验成品的唯一性
:param serial_number: 序列号
:param exclude_id: 排除的ID (编辑模式用)
"""
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
# 成品强校验序列号 (SN) - SN应该是全局唯一的
if serial_number: if serial_number:
query = StockProduct.query.filter(StockProduct.serial_number == serial_number) query = StockProduct.query.filter(StockProduct.serial_number == serial_number)
if exclude_id: if exclude_id:
query = query.filter(StockProduct.id != exclude_id) query = query.filter(StockProduct.id != exclude_id)
exists = query.first() exists = query.first()
if exists: if exists:
# [修改] material -> base
occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料" occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料"
raise ValueError(f"序列号【{serial_number}】已存在!被成品 [{occupied_name}] 占用,请核查。") raise ValueError(f"序列号【{serial_number}】已存在!被成品 [{occupied_name}] 占用,请核查。")
@ -40,26 +31,22 @@ class ProductInboundService:
@staticmethod @staticmethod
def search_base_material(keyword): def search_base_material(keyword):
try: try:
# [核心修改] 只查询已启用的物料
query = MaterialBase.query.filter(MaterialBase.is_enabled == True) query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
# 2. 动态条件:如果传入了关键词,则增加模糊匹配条件
if keyword: if keyword:
kw = f'%{keyword}%'
query = query.filter( query = query.filter(
or_( or_(
MaterialBase.name.ilike(f'%{keyword}%'), MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(f'%{keyword}%') MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw) # [新增]
) )
) )
# 3. 排序与限制按ID倒序取最新20条
query = query.order_by(MaterialBase.id.desc()).limit(20) query = query.order_by(MaterialBase.id.desc()).limit(20)
# 4. 结果封装
results = [] results = []
for item in query.all(): for item in query.all():
results.append({ results.append({
'id': item.id, 'id': item.id,
'company_name': item.company_name, # [新增]
'name': item.name, 'name': item.name,
'spec': item.spec_model, 'spec': item.spec_model,
'category': item.category, 'category': item.category,
@ -73,33 +60,65 @@ class ProductInboundService:
return [] 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 @staticmethod
def handle_inbound(data): def handle_inbound(data):
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
try: try:
base_id = data.get('base_id') base_id = data.get('base_id')
if not base_id: raise ValueError("必须选择基础物料") if not base_id: raise ValueError("必须选择基础物料")
material = MaterialBase.query.get(base_id) material = MaterialBase.query.get(base_id)
if not material: raise ValueError("物料不存在") if not material: raise ValueError("物料不存在")
# [核心修改] 后端二次校验:如果物料已停用,禁止入库
if not material.is_enabled: if not material.is_enabled:
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。") raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
# --- [核心修改] 执行唯一性校验 ---
ProductInboundService._check_unique( ProductInboundService._check_unique(
serial_number=data.get('serial_number') serial_number=data.get('serial_number')
) )
# [核心修改] 强制北京时间
beijing_tz = timezone(timedelta(hours=8)) beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None) current_time = datetime.now(beijing_tz).replace(tzinfo=None)
in_date_val = current_time in_date_val = current_time
if data.get('in_date'): if data.get('in_date'):
try: try:
date_str = str(data['in_date']) date_str = str(data['in_date'])
@ -113,12 +132,10 @@ class ProductInboundService:
in_date_val = current_time in_date_val = current_time
in_qty = float(data.get('in_quantity') or 0) in_qty = float(data.get('in_quantity') or 0)
p_start = data.get('production_start_time', '') p_start = data.get('production_start_time', '')
p_end = data.get('production_end_time', '') p_end = data.get('production_end_time', '')
time_range = f"{p_start} ~ {p_end}" if p_start or p_end else None time_range = f"{p_start} ~ {p_end}" if p_start or p_end else None
# 全局流水号
try: try:
seq_sql = text("SELECT nextval('global_print_seq')") seq_sql = text("SELECT nextval('global_print_seq')")
result = db.session.execute(seq_sql) result = db.session.execute(seq_sql)
@ -132,7 +149,6 @@ class ProductInboundService:
photo_list = data.get('product_photo', []) photo_list = data.get('product_photo', [])
quality_list = data.get('quality_report_link', []) quality_list = data.get('quality_report_link', [])
inspection_list = data.get('inspection_report_link', []) inspection_list = data.get('inspection_report_link', [])
if not isinstance(photo_list, list): photo_list = [] if not isinstance(photo_list, list): photo_list = []
if not isinstance(quality_list, list): quality_list = [] if not isinstance(quality_list, list): quality_list = []
if not isinstance(inspection_list, list): inspection_list = [] if not isinstance(inspection_list, list): inspection_list = []
@ -141,39 +157,30 @@ class ProductInboundService:
base_id=material.id, base_id=material.id,
global_print_id=next_global_id, global_print_id=next_global_id,
sku=generated_sku, sku=generated_sku,
production_date=in_date_val, # 存入 DateTime production_date=in_date_val,
barcode=final_barcode, barcode=final_barcode,
serial_number=data.get('serial_number'), serial_number=data.get('serial_number'),
status=data.get('status', '在库'), status=data.get('status', '在库'),
warehouse_location=data.get('warehouse_location'), warehouse_location=data.get('warehouse_location'),
in_quantity=in_qty, in_quantity=in_qty,
stock_quantity=in_qty, stock_quantity=in_qty,
available_quantity=in_qty, available_quantity=in_qty,
bom_code=data.get('bom_code'), bom_code=data.get('bom_code'),
bom_version=data.get('bom_version'), bom_version=data.get('bom_version'),
work_order_code=data.get('work_order_code'), work_order_code=data.get('work_order_code'),
production_manager=data.get('production_manager'), production_manager=data.get('production_manager'),
production_time_range=time_range, production_time_range=time_range,
raw_material_cost=float(data.get('raw_material_cost') or 0), raw_material_cost=float(data.get('raw_material_cost') or 0),
manual_cost=float(data.get('manual_cost') or 0), manual_cost=float(data.get('manual_cost') or 0),
quality_status=data.get('quality_status', '合格'), quality_status=data.get('quality_status', '合格'),
product_photo=json.dumps(photo_list), product_photo=json.dumps(photo_list),
quality_report_link=json.dumps(quality_list), quality_report_link=json.dumps(quality_list),
inspection_report_link=json.dumps(inspection_list), inspection_report_link=json.dumps(inspection_list),
detail_link=data.get('detail_link'), detail_link=data.get('detail_link'),
remark=data.get('remark'), remark=data.get('remark'),
sale_price=float(data.get('sale_price') or 0), sale_price=float(data.get('sale_price') or 0),
order_id=data.get('order_id') order_id=data.get('order_id')
) )
db.session.add(new_stock) db.session.add(new_stock)
db.session.commit() db.session.commit()
return new_stock return new_stock
@ -187,12 +194,10 @@ class ProductInboundService:
@staticmethod @staticmethod
def update_inbound(stock_id, data): def update_inbound(stock_id, data):
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
try: try:
stock = StockProduct.query.get(stock_id) stock = StockProduct.query.get(stock_id)
if not stock: raise ValueError("记录不存在") if not stock: raise ValueError("记录不存在")
# --- [核心修改] 编辑时也要校验唯一性 ---
if 'serial_number' in data: if 'serial_number' in data:
ProductInboundService._check_unique( ProductInboundService._check_unique(
serial_number=data['serial_number'], serial_number=data['serial_number'],
@ -211,11 +216,9 @@ class ProductInboundService:
if 'product_photo' in data: if 'product_photo' in data:
imgs = data['product_photo'] imgs = data['product_photo']
if isinstance(imgs, list): stock.product_photo = json.dumps(imgs) if isinstance(imgs, list): stock.product_photo = json.dumps(imgs)
if 'quality_report_link' in data: if 'quality_report_link' in data:
imgs = data['quality_report_link'] imgs = data['quality_report_link']
if isinstance(imgs, list): stock.quality_report_link = json.dumps(imgs) if isinstance(imgs, list): stock.quality_report_link = json.dumps(imgs)
if 'inspection_report_link' in data: if 'inspection_report_link' in data:
imgs = data['inspection_report_link'] imgs = data['inspection_report_link']
if isinstance(imgs, list): stock.inspection_report_link = json.dumps(imgs) if isinstance(imgs, list): stock.inspection_report_link = json.dumps(imgs)
@ -267,7 +270,6 @@ class ProductInboundService:
# ============================================================ # ============================================================
@staticmethod @staticmethod
def get_outbound_history(stock_id): def get_outbound_history(stock_id):
"""获取出库历史"""
try: try:
records = TransOutbound.query.filter_by( records = TransOutbound.query.filter_by(
source_table='stock_product', stock_id=stock_id source_table='stock_product', stock_id=stock_id
@ -280,31 +282,32 @@ class ProductInboundService:
# 6. 获取列表 # 6. 获取列表
# ============================================================ # ============================================================
@staticmethod @staticmethod
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None): def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None):
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
try: try:
query = db.session.query(StockProduct).outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id) query = db.session.query(StockProduct).outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id)
if keyword: if keyword:
kw = f'%{keyword}%'
query = query.filter(or_( query = query.filter(or_(
MaterialBase.name.ilike(f'%{keyword}%'), MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(f'%{keyword}%'), MaterialBase.spec_model.ilike(kw),
StockProduct.serial_number.ilike(f'%{keyword}%'), MaterialBase.company_name.ilike(kw), # [新增]
StockProduct.work_order_code.ilike(f'%{keyword}%'), StockProduct.serial_number.ilike(kw),
StockProduct.order_id.ilike(f'%{keyword}%'), StockProduct.work_order_code.ilike(kw),
StockProduct.sku.ilike(f'%{keyword}%') StockProduct.order_id.ilike(kw),
StockProduct.sku.ilike(kw)
)) ))
# 类别筛选
if category and category.strip(): if category and category.strip():
query = query.filter(MaterialBase.category == category.strip()) query = query.filter(MaterialBase.category == category.strip())
# 类型筛选
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) query = query.filter(MaterialBase.material_type == material_type.strip())
# [新增]
if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip())
if not statuses: if not statuses:
statuses = ['在库', '借库'] statuses = ['在库', '借库']
if '已出库' in statuses: if '已出库' in statuses:
query = query.filter(StockProduct.status.in_(statuses)) query = query.filter(StockProduct.status.in_(statuses))
else: else:
@ -314,11 +317,8 @@ class ProductInboundService:
StockProduct.stock_quantity > 0 StockProduct.stock_quantity > 0
) )
) )
# 按照 production_date (入库日期) 倒序排序
pagination = query.order_by(StockProduct.production_date.desc()).paginate(page=page, per_page=limit, pagination = query.order_by(StockProduct.production_date.desc()).paginate(page=page, per_page=limit,
error_out=False) error_out=False)
current_items = pagination.items current_items = pagination.items
def parse_img(json_str): def parse_img(json_str):
@ -330,41 +330,14 @@ class ProductInboundService:
items = [] items = []
for item in current_items: for item in current_items:
# [注意] 因为Model层已经修改了 to_dict 内部的 material -> base所以这里直接调 to_dict 即可 items.append(item.to_dict()) # 使用 Model 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)
return {"total": pagination.total, "items": items} return {"total": pagination.total, "items": items}
except: except:
traceback.print_exc() traceback.print_exc()
return {"total": 0, "items": []} return {"total": 0, "items": []}
# ============================================================
# 7. 系统用户搜索
# ============================================================
@staticmethod @staticmethod
def search_system_users(keyword): def search_system_users(keyword):
"""搜索系统用户(活跃状态)"""
from app.models.system import SysUser from app.models.system import SysUser
try: try:
query = SysUser.query.filter(SysUser.status == 'active') query = SysUser.query.filter(SysUser.status == 'active')
@ -386,23 +359,36 @@ class ProductInboundService:
return [] return []
# ============================================================ # ============================================================
# 8. 获取筛选选项(类别、类型) # 7. 获取筛选
# ============================================================ # ============================================================
@staticmethod @staticmethod
def get_filter_options(): def get_filter_options():
try: try:
from app.models.base import MaterialBase from app.models.base import MaterialBase
# 类别
categories = db.session.query(MaterialBase.category) \ categories = db.session.query(MaterialBase.category) \
.filter(MaterialBase.category != None, MaterialBase.category != '') \ .filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all() .distinct().all()
sorted_categories = sorted([r[0] for r in categories])
# 类型
types = db.session.query(MaterialBase.material_type) \ types = db.session.query(MaterialBase.material_type) \
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \ .filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
.distinct().all() .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 { return {
"categories": [r[0] for r in categories], "categories": sorted_categories,
"types": [r[0] for r in types] "types": sorted_types,
"companies": sorted_companies
} }
except Exception: except Exception:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return {"categories": [], "types": []} return {"categories": [], "types": [], "companies": []}

View File

@ -11,32 +11,20 @@ import json
class SemiInboundService: class SemiInboundService:
# ============================================================ # ============================================================
# 0. 辅助:唯一性校验 (新增核心逻辑) # 0. 辅助:唯一性校验
# ============================================================ # ============================================================
@staticmethod @staticmethod
def _check_unique(base_id, serial_number, batch_number, exclude_id=None): 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 from app.models.inbound.semi import StockSemi
# 1. 序列号 (SN) 校验 - 全局唯一
if serial_number: if serial_number:
query = StockSemi.query.filter(StockSemi.serial_number == serial_number) query = StockSemi.query.filter(StockSemi.serial_number == serial_number)
if exclude_id: if exclude_id:
query = query.filter(StockSemi.id != exclude_id) query = query.filter(StockSemi.id != exclude_id)
exists = query.first() exists = query.first()
if exists: if exists:
# [修改] material -> base
occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料" occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料"
raise ValueError(f"序列号【{serial_number}】已存在!被半成品 [{occupied_name}] 占用,请核查。") raise ValueError(f"序列号【{serial_number}】已存在!被半成品 [{occupied_name}] 占用,请核查。")
# 2. 批号 (BN) 校验 - 同物料下不能重复开单
if batch_number and base_id: if batch_number and base_id:
query = StockSemi.query.filter( query = StockSemi.query.filter(
StockSemi.base_id == base_id, StockSemi.base_id == base_id,
@ -44,7 +32,6 @@ class SemiInboundService:
) )
if exclude_id: if exclude_id:
query = query.filter(StockSemi.id != exclude_id) query = query.filter(StockSemi.id != exclude_id)
if query.first(): if query.first():
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复建单,建议在原批次上追加库存。") raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复建单,建议在原批次上追加库存。")
@ -54,30 +41,27 @@ class SemiInboundService:
@staticmethod @staticmethod
def search_base_material(keyword): def search_base_material(keyword):
try: try:
# [核心修改] 只查询已启用的物料
query = MaterialBase.query.filter(MaterialBase.is_enabled == True) query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
# 如果有关键词,进行模糊匹配
if keyword: if keyword:
kw = f'%{keyword}%'
query = query.filter( query = query.filter(
or_( or_(
MaterialBase.name.ilike(f'%{keyword}%'), MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(f'%{keyword}%') MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw) # [新增] 支持搜公司
) )
) )
# 统一逻辑按ID倒序限制20条
query = query.order_by(MaterialBase.id.desc()).limit(20) query = query.order_by(MaterialBase.id.desc()).limit(20)
results = [] results = []
for item in query.all(): for item in query.all():
results.append({ results.append({
'id': item.id, 'id': item.id,
'company_name': item.company_name, # [新增]
'name': item.name, 'name': item.name,
'spec': item.spec_model, # 对应前端 item.spec 'spec': item.spec_model,
'category': item.category, 'category': item.category,
'unit': item.unit, 'unit': item.unit,
'type': item.material_type, # 对应前端 item.type 'type': item.material_type,
'status': '启用' 'status': '启用'
}) })
return results return results
@ -85,39 +69,70 @@ class SemiInboundService:
traceback.print_exc() traceback.print_exc()
return [] 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. 新增入库逻辑 # 2. 新增入库逻辑
# ============================================================ # ============================================================
@staticmethod @staticmethod
def handle_inbound(data): def handle_inbound(data):
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
try: try:
base_id = data.get('base_id') base_id = data.get('base_id')
if not base_id: if not base_id:
raise ValueError("必须选择基础物料 (缺少 base_id)") raise ValueError("必须选择基础物料 (缺少 base_id)")
material = MaterialBase.query.get(base_id) material = MaterialBase.query.get(base_id)
if not material: if not material:
raise ValueError(f"ID为 {base_id} 的基础物料不存在") raise ValueError(f"ID为 {base_id} 的基础物料不存在")
# [核心修改] 后端二次校验:如果物料已停用,禁止入库
if not material.is_enabled: if not material.is_enabled:
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。") raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
# --- [核心修改] 执行唯一性校验 ---
SemiInboundService._check_unique( SemiInboundService._check_unique(
base_id=base_id, base_id=base_id,
serial_number=data.get('serial_number'), serial_number=data.get('serial_number'),
batch_number=data.get('batch_number') batch_number=data.get('batch_number')
) )
# [核心修改] 强制北京时间
beijing_tz = timezone(timedelta(hours=8)) beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None) current_time = datetime.now(beijing_tz).replace(tzinfo=None)
in_date_val = current_time in_date_val = current_time
if data.get('in_date'): if data.get('in_date'):
try: try:
date_str = str(data['in_date']) date_str = str(data['in_date'])
@ -130,7 +145,6 @@ class SemiInboundService:
except ValueError: except ValueError:
in_date_val = current_time in_date_val = current_time
# 2. 处理生产时间
p_start = None p_start = None
p_end = None p_end = None
if data.get('production_start_time'): if data.get('production_start_time'):
@ -151,14 +165,12 @@ class SemiInboundService:
elif isinstance(raw_range, str): elif isinstance(raw_range, str):
time_range_str = raw_range time_range_str = raw_range
# 3. 处理数值和成本
in_qty = float(data.get('in_quantity') or 0) in_qty = float(data.get('in_quantity') or 0)
raw_cost = float(data.get('raw_material_cost') or 0) raw_cost = float(data.get('raw_material_cost') or 0)
manual_cost = float(data.get('manual_cost') or 0) manual_cost = float(data.get('manual_cost') or 0)
unit_total_cost = raw_cost + manual_cost unit_total_cost = raw_cost + manual_cost
total_value = unit_total_cost * in_qty total_value = unit_total_cost * in_qty
# 4. 获取全局打印流水号
next_global_id = 0 next_global_id = 0
try: try:
seq_sql = text("SELECT nextval('global_print_seq')") seq_sql = text("SELECT nextval('global_print_seq')")
@ -172,59 +184,47 @@ class SemiInboundService:
final_sku = data.get('sku') final_sku = data.get('sku')
if not final_sku: if not final_sku:
final_sku = generated_sku final_sku = generated_sku
final_barcode = data.get('barcode') final_barcode = data.get('barcode')
if not final_barcode: if not final_barcode:
final_barcode = final_sku final_barcode = final_sku
arrival_list = data.get('arrival_photo', []) arrival_list = data.get('arrival_photo', [])
quality_report_list = data.get('quality_report_link', []) quality_report_list = data.get('quality_report_link', [])
if not isinstance(arrival_list, list): arrival_list = [] if not isinstance(arrival_list, list): arrival_list = []
if not isinstance(quality_report_list, list): quality_report_list = [] if not isinstance(quality_report_list, list): quality_report_list = []
# 8. 创建记录
new_stock = StockSemi( new_stock = StockSemi(
base_id=material.id, base_id=material.id,
global_print_id=next_global_id, global_print_id=next_global_id,
sku=final_sku, sku=final_sku,
production_date=in_date_val, # 存入 DateTime production_date=in_date_val,
serial_number=data.get('serial_number'), serial_number=data.get('serial_number'),
batch_number=data.get('batch_number'), batch_number=data.get('batch_number'),
barcode=final_barcode, barcode=final_barcode,
status='在库', status='在库',
quality_status=data.get('quality_status', '合格'), quality_status=data.get('quality_status', '合格'),
in_quantity=in_qty, in_quantity=in_qty,
stock_quantity=in_qty, stock_quantity=in_qty,
available_quantity=in_qty, available_quantity=in_qty,
warehouse_location=data.get('warehouse_location'), warehouse_location=data.get('warehouse_location'),
bom_code=data.get('bom_code'), bom_code=data.get('bom_code'),
bom_version=data.get('bom_version'), bom_version=data.get('bom_version'),
work_order_code=data.get('work_order_code'), work_order_code=data.get('work_order_code'),
production_manager=data.get('production_manager'), production_manager=data.get('production_manager'),
production_start_time=p_start, production_start_time=p_start,
production_end_time=p_end, production_end_time=p_end,
production_time_range=time_range_str, production_time_range=time_range_str,
raw_material_cost=raw_cost, raw_material_cost=raw_cost,
manual_cost=manual_cost, manual_cost=manual_cost,
total_price=total_value, total_price=total_value,
arrival_photo=json.dumps(arrival_list), arrival_photo=json.dumps(arrival_list),
quality_report_link=json.dumps(quality_report_list), quality_report_link=json.dumps(quality_report_list),
detail_link=data.get('detail_link'), detail_link=data.get('detail_link'),
remark=data.get('remark') remark=data.get('remark')
) )
db.session.add(new_stock) db.session.add(new_stock)
db.session.commit() db.session.commit()
return new_stock return new_stock
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
print("----- SemiInboundService Error -----") print("----- SemiInboundService Error -----")
@ -237,13 +237,11 @@ class SemiInboundService:
@staticmethod @staticmethod
def update_inbound(stock_id, data): def update_inbound(stock_id, data):
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
try: try:
stock = StockSemi.query.get(stock_id) stock = StockSemi.query.get(stock_id)
if not stock: if not stock:
raise ValueError("记录不存在") raise ValueError("记录不存在")
# --- [核心修改] 编辑时也要校验唯一性 ---
new_base_id = data.get('base_id', stock.base_id) new_base_id = data.get('base_id', stock.base_id)
new_sn = data.get('serial_number', stock.serial_number) new_sn = data.get('serial_number', stock.serial_number)
new_bn = data.get('batch_number', stock.batch_number) new_bn = data.get('batch_number', stock.batch_number)
@ -270,7 +268,6 @@ class SemiInboundService:
'detail_link': 'detail_link', 'detail_link': 'detail_link',
'remark': 'remark' 'remark': 'remark'
} }
for frontend_key, db_attr in field_mapping.items(): for frontend_key, db_attr in field_mapping.items():
if frontend_key in data: if frontend_key in data:
setattr(stock, db_attr, data[frontend_key]) setattr(stock, db_attr, data[frontend_key])
@ -279,7 +276,6 @@ class SemiInboundService:
imgs = data['arrival_photo'] imgs = data['arrival_photo']
if isinstance(imgs, list): if isinstance(imgs, list):
stock.arrival_photo = json.dumps(imgs) stock.arrival_photo = json.dumps(imgs)
if 'quality_report_link' in data: if 'quality_report_link' in data:
imgs = data['quality_report_link'] imgs = data['quality_report_link']
if isinstance(imgs, list): if isinstance(imgs, list):
@ -294,7 +290,6 @@ class SemiInboundService:
stock.production_start_time = None stock.production_start_time = None
except: except:
pass pass
if 'production_end_time' in data: if 'production_end_time' in data:
try: try:
if data['production_end_time']: if data['production_end_time']:
@ -304,7 +299,6 @@ class SemiInboundService:
stock.production_end_time = None stock.production_end_time = None
except: except:
pass pass
if 'production_time_range' in data: if 'production_time_range' in data:
raw_range = data['production_time_range'] raw_range = data['production_time_range']
if isinstance(raw_range, list): if isinstance(raw_range, list):
@ -314,7 +308,6 @@ class SemiInboundService:
qty_changed = False qty_changed = False
cost_changed = False cost_changed = False
if 'in_quantity' in data: if 'in_quantity' in data:
new_qty = float(data['in_quantity']) new_qty = float(data['in_quantity'])
diff = new_qty - float(stock.in_quantity) diff = new_qty - float(stock.in_quantity)
@ -323,22 +316,18 @@ class SemiInboundService:
stock.stock_quantity = float(stock.stock_quantity) + diff stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff stock.available_quantity = float(stock.available_quantity) + diff
qty_changed = True qty_changed = True
if 'raw_material_cost' in data: if 'raw_material_cost' in data:
stock.raw_material_cost = float(data['raw_material_cost']) stock.raw_material_cost = float(data['raw_material_cost'])
cost_changed = True cost_changed = True
if 'manual_cost' in data: if 'manual_cost' in data:
stock.manual_cost = float(data['manual_cost']) stock.manual_cost = float(data['manual_cost'])
cost_changed = True cost_changed = True
if cost_changed or qty_changed: if cost_changed or qty_changed:
unit_total = float(stock.raw_material_cost) + float(stock.manual_cost) unit_total = float(stock.raw_material_cost) + float(stock.manual_cost)
stock.total_price = float(stock.in_quantity) * unit_total stock.total_price = float(stock.in_quantity) * unit_total
db.session.commit() db.session.commit()
return stock return stock
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
raise e raise e
@ -365,7 +354,6 @@ class SemiInboundService:
# ============================================================ # ============================================================
@staticmethod @staticmethod
def get_outbound_history(stock_id): def get_outbound_history(stock_id):
"""获取出库历史"""
try: try:
records = TransOutbound.query.filter_by( records = TransOutbound.query.filter_by(
source_table='stock_semi', stock_id=stock_id source_table='stock_semi', stock_id=stock_id
@ -378,17 +366,17 @@ class SemiInboundService:
# 6. 获取列表 # 6. 获取列表
# ============================================================ # ============================================================
@staticmethod @staticmethod
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None): def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None):
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
try: try:
query = db.session.query(StockSemi).outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id) query = db.session.query(StockSemi).outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id)
if keyword: if keyword:
kw = f'%{keyword}%' kw = f'%{keyword}%'
query = query.filter( query = query.filter(
or_( or_(
MaterialBase.name.ilike(kw), MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw), MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw), # [新增]
StockSemi.batch_number.ilike(kw), StockSemi.batch_number.ilike(kw),
StockSemi.serial_number.ilike(kw), StockSemi.serial_number.ilike(kw),
StockSemi.sku.ilike(kw), StockSemi.sku.ilike(kw),
@ -396,17 +384,17 @@ class SemiInboundService:
StockSemi.bom_code.ilike(kw) StockSemi.bom_code.ilike(kw)
) )
) )
# 类别筛选
if category and category.strip(): if category and category.strip():
query = query.filter(MaterialBase.category == category.strip()) query = query.filter(MaterialBase.category == category.strip())
# 类型筛选
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) query = query.filter(MaterialBase.material_type == material_type.strip())
# [新增] 公司筛选
if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip())
if not statuses: if not statuses:
statuses = ['在库', '借库'] statuses = ['在库', '借库']
if '已出库' in statuses: if '已出库' in statuses:
query = query.filter(StockSemi.status.in_(statuses)) query = query.filter(StockSemi.status.in_(statuses))
else: else:
@ -416,11 +404,8 @@ class SemiInboundService:
StockSemi.stock_quantity > 0 StockSemi.stock_quantity > 0
) )
) )
# 按照 production_date (入库日期) 倒序排序
pagination = query.order_by(StockSemi.production_date.desc()).paginate(page=page, per_page=limit, pagination = query.order_by(StockSemi.production_date.desc()).paginate(page=page, per_page=limit,
error_out=False) error_out=False)
current_items = pagination.items current_items = pagination.items
def parse_img(json_str): def parse_img(json_str):
@ -432,41 +417,15 @@ class SemiInboundService:
items = [] items = []
for item in current_items: for item in current_items:
# [注意] 因为Model层已经修改了 to_dict 内部的 material -> base所以这里直接调 to_dict 即可 items.append(item.to_dict()) # 直接使用 Model 的 to_dict (已包含 company_name)
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)
return {"total": pagination.total, "items": items} return {"total": pagination.total, "items": items}
except Exception as e: except Exception as e:
print(f"List Error: {e}") print(f"List Error: {e}")
traceback.print_exc() traceback.print_exc()
return {"total": 0, "items": []} return {"total": 0, "items": []}
# ============================================================
# 7. 系统用户搜索
# ============================================================
@staticmethod @staticmethod
def search_system_users(keyword): def search_system_users(keyword):
"""搜索系统用户(活跃状态)"""
from app.models.system import SysUser from app.models.system import SysUser
try: try:
query = SysUser.query.filter(SysUser.status == 'active') query = SysUser.query.filter(SysUser.status == 'active')
@ -488,23 +447,36 @@ class SemiInboundService:
return [] return []
# ============================================================ # ============================================================
# 8. 获取筛选选项(类别、类型) # 7. 获取筛选项 (排序)
# ============================================================ # ============================================================
@staticmethod @staticmethod
def get_filter_options(): def get_filter_options():
try: try:
from app.models.base import MaterialBase from app.models.base import MaterialBase
# 类别
categories = db.session.query(MaterialBase.category) \ categories = db.session.query(MaterialBase.category) \
.filter(MaterialBase.category != None, MaterialBase.category != '') \ .filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all() .distinct().all()
sorted_categories = sorted([r[0] for r in categories])
# 类型
types = db.session.query(MaterialBase.material_type) \ types = db.session.query(MaterialBase.material_type) \
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \ .filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
.distinct().all() .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 { return {
"categories": [r[0] for r in categories], "categories": sorted_categories,
"types": [r[0] for r in types] "types": sorted_types,
"companies": sorted_companies
} }
except Exception: except Exception:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return {"categories": [], "types": []} return {"categories": [], "types": [], "companies": []}

View File

@ -1,16 +1,20 @@
# app/utils/constants.py # app/utils/constants.py
class UserRole: class UserRole:
SUPER_ADMIN = 'super_admin' # 超级管理员 (IRIS) """
SUPERVISOR = 'supervisor' # 主管 用户角色定义
FINANCE = 'finance' # 财务 """
WAREHOUSE_MGR = 'warehouse_manager' # 库管 SUPER_ADMIN = 'SUPER_ADMIN' # 超级管理员 (IRIS)
INBOUND = 'inbound' # 入库员 SUPERVISOR = 'SUPERVISOR' # 主管
OUTBOUND = 'outbound' # 出库员 FINANCE = 'FINANCE' # 财务
PURCHASER = 'purchaser' # 采购员 WAREHOUSE_MGR = 'WAREHOUSE_MGR' # 库管
SALES = 'sales' # 销售 INBOUND = 'INBOUND' # 入库员
OUTBOUND = 'OUTBOUND' # 出库员
PURCHASER = 'PURCHASER' # 采购员
SALES = 'SALES' # 销售
# 角色中文映射(用于前端展示或日志) # 角色中文映射(用于前端展示或日志)
# 注意:这个字典在 auth_service 遍历时需要被过滤掉
ROLE_MAP = { ROLE_MAP = {
SUPER_ADMIN: '超级管理员', SUPER_ADMIN: '超级管理员',
SUPERVISOR: '主管', SUPERVISOR: '主管',

View File

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

View File

@ -17,6 +17,7 @@
"jspdf": "^2.5.1", "jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2", "jspdf-autotable": "^3.8.2",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"pinyin-pro": "^3.19.0",
"sass": "^1.97.3", "sass": "^1.97.3",
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-router": "^4.6.4", "vue-router": "^4.6.4",

View File

@ -82,7 +82,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer"> <footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag"> <span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon> <el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本: 1.1 Beta (测试) 当前版本: 1.3 Beta (2.25权限管理)
</span> </span>
</footer> </footer>
</div> </div>

View File

@ -41,6 +41,15 @@ export function searchMaterialBase(keyword: string) {
}) })
} }
// 搜索BOM (新增)
export function searchBom(keyword: string) {
return request({
url: '/inbound/product/search-bom',
method: 'get',
params: { keyword }
})
}
// 用户建议 // 用户建议
export function getUserSuggestions(params: any) { export function getUserSuggestions(params: any) {
return request({ return request({
@ -56,4 +65,4 @@ export function getFilterOptions() {
url: '/inbound/product/options', url: '/inbound/product/options',
method: 'get' method: 'get'
}) })
} }

View File

@ -44,6 +44,15 @@ export function searchMaterialBase(keyword: string) {
}) })
} }
// 5.5 搜索BOM (新增)
export function searchBom(keyword: string) {
return request({
url: '/inbound/semi/search-bom',
method: 'get',
params: { keyword }
})
}
// 用户建议 // 用户建议
export function getUserSuggestions(params: any) { export function getUserSuggestions(params: any) {
return request({ return request({
@ -59,4 +68,4 @@ export function getFilterOptions() {
url: '/inbound/semi/options', url: '/inbound/semi/options',
method: 'get' method: 'get'
}) })
} }

View File

@ -9,9 +9,7 @@ export function listMaterialBase(params: any) {
}) })
} }
// ========================================== // 1.1 获取选项
// 1.1 获取基础信息筛选选项 (所有类别/类型) [新增]
// ==========================================
export function getMaterialBaseOptions() { export function getMaterialBaseOptions() {
return request({ return request({
url: '/inbound/base/options', url: '/inbound/base/options',
@ -19,7 +17,17 @@ export function getMaterialBaseOptions() {
}) })
} }
// 2. 新增基础信息 // 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) { export function addMaterialBase(data: any) {
return request({ return request({
url: '/inbound/base/', url: '/inbound/base/',
@ -28,7 +36,7 @@ export function addMaterialBase(data: any) {
}) })
} }
// 3. 修改基础信息 (包含状态启用/禁用) // 3. 修改
export function updateMaterialBase(data: any) { export function updateMaterialBase(data: any) {
return request({ return request({
url: `/inbound/base/${data.id}`, url: `/inbound/base/${data.id}`,
@ -37,18 +45,10 @@ export function updateMaterialBase(data: any) {
}) })
} }
// 4. 删除基础信息 // 4. 删除
export function delMaterialBase(id: number) { export function delMaterialBase(id: number) {
return request({ return request({
url: `/inbound/base/${id}`, url: `/inbound/base/${id}`,
method: 'delete' method: 'delete'
}) })
}
// 5. 获取详情 (可选,用于编辑回显)
export function getMaterialBase(id: number) {
return request({
url: `/inbound/base/${id}`,
method: 'get'
})
} }

View File

@ -4,6 +4,16 @@ import Layout from '@/layout/index.vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import BomManage from '@/views/bom/BomManage.vue' 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> = [ const routes: Array<RouteRecordRaw> = [
// 1. 登录页 // 1. 登录页
{ {
@ -169,17 +179,25 @@ const routes: Array<RouteRecordRaw> = [
{ {
path: '/system', path: '/system',
component: Layout, component: Layout,
// [修复] 添加 redirect点击父菜单时跳转到子页面
redirect: '/system/user-create',
meta: { meta: {
title: '系统管理', title: '系统管理',
icon: 'Setting', icon: 'Setting',
roles: ['super_admin', 'supervisor'] // [修复] 使用大写角色名,匹配后端常量
roles: ['SUPER_ADMIN', 'SUPERVISOR']
}, },
children: [ children: [
{ {
path: 'user-create', path: 'user-create',
name: 'UserCreate', name: 'UserCreate',
component: () => import('@/views/system/UserCreate.vue'), component: () => import('@/views/system/UserCreate.vue'),
meta: { title: '账号开通', icon: 'User' } meta: {
title: '账号开通',
icon: 'User',
// 子路由也建议加上权限限制
roles: ['SUPER_ADMIN', 'SUPERVISOR']
}
} }
] ]
}, },
@ -204,7 +222,16 @@ router.beforeEach((to, from, next) => {
const userStore = useUserStore() const userStore = useUserStore()
const token = userStore.token || localStorage.getItem('token') 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 (to.path === '/login') {
if (token) { if (token) {
@ -220,10 +247,13 @@ router.beforeEach((to, from, next) => {
return return
} }
// 权限检查逻辑
if (to.meta.roles && Array.isArray(to.meta.roles)) { if (to.meta.roles && Array.isArray(to.meta.roles)) {
// [修复] to.meta.roles 里已经是大写了userRole 也转大写了,现在可以安全比对
if (to.meta.roles.includes(userRole)) { if (to.meta.roles.includes(userRole)) {
next() next()
} else { } else {
console.warn(`权限不足: 用户角色 ${userRole} 不在允许列表 ${to.meta.roles}`)
next('/dashboard') next('/dashboard')
} }
} else { } else {
@ -231,4 +261,4 @@ router.beforeEach((to, from, next) => {
} }
}) })
export default router export default router

View File

@ -7,7 +7,7 @@
<div class="header-right"> <div class="header-right">
<el-input <el-input
v-model="searchKeyword" v-model="searchKeyword"
placeholder="搜索 BOM编号/父子件名称/规格" placeholder="搜索 编号/名称/规格/子件..."
style="width: 300px; margin-right: 15px;" style="width: 300px; margin-right: 15px;"
clearable clearable
@clear="fetchBomList" @clear="fetchBomList"
@ -31,12 +31,19 @@
<el-tag>{{ row.version }}</el-tag> <el-tag>{{ row.version }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="child_count" label="子件数" width="100" align="center" /> <el-table-column label="状态" width="100" align="center">
<el-table-column label="操作" width="280" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row.bom_no)">编辑</el-button> <el-tag :type="row.is_enabled ? 'success' : 'danger'">
<el-button type="success" link @click="handleSaveAs(row.bom_no)">另存为</el-button> {{ row.is_enabled ? '启用' : '禁用' }}
<el-button type="danger" link @click="handleDelete(row.bom_no)">删除</el-button> </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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -46,57 +53,66 @@
<el-form :model="form" label-width="120px" ref="formRef" :rules="rules"> <el-form :model="form" label-width="120px" ref="formRef" :rules="rules">
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="12"> <el-col :span="16">
<el-form-item label="BOM 编号" prop="bom_no"> <el-form-item label="父件 (成品)" prop="parent_id">
<el-input <el-select
v-model="form.bom_no" v-model="form.parent_id"
placeholder="请输入BOM编号" placeholder="请搜索并选择父件"
filterable
style="width: 100%"
:disabled="isEditMode" :disabled="isEditMode"
clearable 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-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="8">
<el-form-item label="版本" prop="version"> <el-form-item label="是否启用" prop="is_enabled">
<el-input v-model="form.version" placeholder="例如V1.0" /> <el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-form-item label="父件 (成品)" prop="parent_id"> <el-row :gutter="20">
<el-select <el-col :span="14">
v-model="form.parent_id" <el-form-item label="BOM 编号" required>
placeholder="请搜索并选择父件" <el-input v-model="form.bom_suffix" placeholder="输入后缀 (如 -001)" :disabled="isEditMode">
filterable <template #prepend v-if="form.bom_prefix">{{ form.bom_prefix }}</template>
style="width: 100%" </el-input>
:disabled="isEditMode" <div style="font-size: 12px; color: #909399; line-height: 1.2; margin-top: 4px;">
class="beautified-select" 最终编号: <span style="font-weight: bold">{{ fullBomNo }}</span>
>
<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> </div>
</el-option> </el-form-item>
</el-select> </el-col>
</el-form-item> <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> <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"> <el-table :data="form.children" border style="width: 100%; margin-bottom: 15px" max-height="300">
<el-table-column label="子件物料" min-width="300"> <el-table-column label="子件物料" min-width="280">
<template #default="{ row, $index }"> <template #default="{ row, $index }">
<el-select <el-select
v-model="row.child_id" v-model="row.child_id"
placeholder="请搜索原料" placeholder="请搜索原料"
filterable filterable
style="width: 100%" style="width: 100%"
@change="(val) => onChildChange(val, $index)"
> >
<el-option <el-option
v-for="item in materialOptions" v-for="item in materialOptions"
@ -113,27 +129,21 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="用量" width="150"> <el-table-column label="用量" width="140">
<template #default="{ row }"> <template #default="{ row }">
<el-input-number <el-input-number v-model="row.dosage" :min="0" :precision="4" style="width: 100%" controls-position="right" />
v-model="row.dosage"
:min="0"
:precision="4"
style="width: 100%"
controls-position="right"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="备注" width="180"> <el-table-column label="备注" width="150">
<template #default="{ row }"> <template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" /> <el-input v-model="row.remark" placeholder="备注" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="80" align="center"> <el-table-column label="操作" width="60" align="center">
<template #default="{ $index }"> <template #default="{ $index }">
<el-button type="danger" link @click="removeChild($index)"></el-button> <el-button type="danger" link @click="removeChild($index)"></el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -160,20 +170,20 @@ import { Plus, Search } from '@element-plus/icons-vue'
import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom' import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom'
import { getMaterialBaseList } from '@/api/inbound/stock' import { getMaterialBaseList } from '@/api/inbound/stock'
// 类型定义
interface BomItem { interface BomItem {
bom_no: string bom_no: string
parent_id: number parent_id: number
parent_name: string parent_name: string
version: string version: string
is_enabled: boolean
child_count: number child_count: number
} }
interface MaterialBase { interface MaterialBase {
id: number id: number
name: string name: string
spec: string spec: string
} }
interface ChildRow { interface ChildRow {
child_id: number | null child_id: number | null
dosage: number dosage: number
@ -183,7 +193,6 @@ interface ChildRow {
const loading = ref(false) const loading = ref(false)
const dialogVisible = ref(false) const dialogVisible = ref(false)
const saving = ref(false) const saving = ref(false)
// isEditMode: true表示编辑现有BOMfalse表示新建或另存为
const isEditMode = ref(false) const isEditMode = ref(false)
const bomList = ref<BomItem[]>([]) const bomList = ref<BomItem[]>([])
@ -192,14 +201,23 @@ const searchKeyword = ref('')
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const form = reactive({ const form = reactive({
bom_no: '', bom_prefix: '', // 自动生成的父件规格前缀
bom_suffix: '', // 用户输入的后缀
parent_id: null as number | null, parent_id: null as number | null,
version: 'V1.0', version: 'V1.0',
is_enabled: true,
children: [] as ChildRow[] 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>({ const rules = reactive<FormRules>({
bom_no: [{ required: true, message: '请输入BOM编号', trigger: 'blur' }],
parent_id: [{ required: true, message: '请选择父件', trigger: 'change' }], parent_id: [{ required: true, message: '请选择父件', trigger: 'change' }],
version: [{ required: true, message: '请输入版本号', trigger: 'blur' }] version: [{ required: true, message: '请输入版本号', trigger: 'blur' }]
}) })
@ -210,164 +228,121 @@ const fetchBomList = async () => {
loading.value = true loading.value = true
try { try {
const res = await getBomList({ keyword: searchKeyword.value }) const res = await getBomList({ keyword: searchKeyword.value })
if (res.code === 200) { if (res.code === 200) bomList.value = res.data
bomList.value = res.data } catch (error) { ElMessage.error('网络错误') }
} else { finally { loading.value = false }
ElMessage.error(res.msg || '获取列表失败')
}
} catch (error) {
ElMessage.error('网络错误')
} finally {
loading.value = false
}
} }
const fetchMaterialOptions = async () => { const fetchMaterialOptions = async () => {
try { try {
const res = await getMaterialBaseList() const res = await getMaterialBaseList()
if (res.code === 200) { if (res.code === 200) materialOptions.value = res.data
materialOptions.value = res.data } catch (error) {}
} }
} catch (error) {
console.error('获取物料列表失败', 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 = () => { const handleCreate = () => {
resetForm() resetForm()
dialogTitle.value = '新建 BOM' dialogTitle.value = '新建 BOM'
dialogVisible.value = true
isEditMode.value = false isEditMode.value = false
dialogVisible.value = true
} }
const handleEdit = async (bomNo: string) => { 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 { try {
const res = await getBomDetail(bomNo) const res = await getBomDetail(bomNo, version)
if (res.code === 200) { if (res.code === 200) {
const data = res.data const data = res.data
form.bom_no = data.bom_no
form.parent_id = data.parent_id form.parent_id = data.parent_id
form.version = data.version form.version = data.version
form.is_enabled = data.is_enabled
form.children = data.children.map((child: any) => ({ form.children = data.children.map((child: any) => ({
child_id: child.child_id, child_id: child.child_id,
dosage: child.dosage, dosage: child.dosage,
remark: child.remark || '' remark: child.remark || ''
})) }))
dialogTitle.value = '编辑 BOM'
dialogVisible.value = true // 解析编号到 前缀/后缀
// 编辑模式下BOM编号不可改 if (data.parent_spec && bomNo.startsWith(data.parent_spec)) {
isEditMode.value = true form.bom_prefix = data.parent_spec
} else { // 移除前缀和可能的分隔符
ElMessage.error(res.msg || '获取详情失败') 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 (error) { } catch (e) { ElMessage.error('获取详情失败') }
ElMessage.error('网络错误')
}
} }
const handleSaveAs = async (bomNo: string) => { const handleDelete = (row: BomItem) => {
try { ElMessageBox.confirm(`确定删除 ${row.bom_no} (${row.version}) 吗?`, '警告', { type: 'warning' })
const res = await getBomDetail(bomNo)
if (res.code === 200) {
const data = res.data
// 清空 bom_no要求用户输入新的
form.bom_no = ''
form.parent_id = data.parent_id
form.version = data.version + '_copy'
form.children = data.children.map((child: any) => ({
child_id: child.child_id,
dosage: child.dosage,
remark: child.remark || ''
}))
dialogTitle.value = '另存为新版'
dialogVisible.value = true
// 另存为模式BOM编号可编辑
isEditMode.value = false
} else {
ElMessage.error(res.msg || '获取详情失败')
}
} catch (error) {
ElMessage.error('网络错误')
}
}
const handleDelete = (bomNo: string) => {
ElMessageBox.confirm('确定删除该 BOM 吗?此操作不可恢复', '警告', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消'
})
.then(async () => { .then(async () => {
try { try {
const res = await deleteBom(bomNo) const res = await deleteBom(row.bom_no, row.version)
if (res.code === 200) { if (res.code === 200) {
ElMessage.success('删除成功') ElMessage.success('删除成功')
fetchBomList() fetchBomList()
} else {
ElMessage.error(res.msg || '删除失败')
} }
} catch (error) { } catch (e) {}
ElMessage.error('网络错误')
}
}) })
.catch(() => {}) .catch(() => {})
} }
const resetForm = () => { const resetForm = () => {
form.bom_no = '' form.bom_prefix = ''
form.bom_suffix = ''
form.parent_id = null form.parent_id = null
form.version = 'V1.0' form.version = 'V1.0'
form.is_enabled = true
form.children = [] form.children = []
if (formRef.value) formRef.value.resetFields() if (formRef.value) formRef.value.resetFields()
} }
const addChild = () => { const addChild = () => form.children.push({ child_id: null, dosage: 0, remark: '' })
form.children.push({ const removeChild = (idx: number) => form.children.splice(idx, 1)
child_id: null,
dosage: 0,
remark: ''
})
}
const removeChild = (index: number) => {
form.children.splice(index, 1)
}
const onChildChange = (val: number, index: number) => {
// 可扩展逻辑
}
const submitForm = async () => { const submitForm = async () => {
if (!formRef.value) return if (!formRef.value) return
await formRef.value.validate(async (valid) => { await formRef.value.validate(async (valid) => {
if (!valid) return if (!valid) return
if (!fullBomNo.value) return ElMessage.warning('BOM编号不能为空')
if (form.children.length === 0) { if (form.children.length === 0) return ElMessage.warning('请至少添加一个子件')
ElMessage.warning('请至少添加一个子件')
return
}
for (const child of form.children) {
if (!child.child_id) {
ElMessage.warning('请为每个子件选择物料')
return
}
if (child.child_id === form.parent_id) {
ElMessage.warning('子件不能与父件相同')
return
}
}
const payload = { const payload = {
bom_no: form.bom_no, // 必填 bom_no: fullBomNo.value,
version: form.version, version: form.version,
parent_id: form.parent_id, parent_id: form.parent_id,
children: form.children.map(c => ({ is_enabled: form.is_enabled,
child_id: c.child_id, children: form.children
dosage: c.dosage,
remark: c.remark
}))
} }
saving.value = true saving.value = true
@ -377,14 +352,9 @@ const submitForm = async () => {
ElMessage.success('保存成功') ElMessage.success('保存成功')
dialogVisible.value = false dialogVisible.value = false
fetchBomList() fetchBomList()
} else { } else { ElMessage.error(res.msg || '保存失败') }
ElMessage.error(res.msg || '保存失败') } catch (e) { ElMessage.error('网络错误') }
} finally { saving.value = false }
} catch (error) {
ElMessage.error('网络错误')
} finally {
saving.value = false
}
}) })
} }
@ -395,34 +365,10 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.card-header { .card-header { display: flex; justify-content: space-between; align-items: center; }
display: flex; .header-right { display: flex; align-items: center; }
justify-content: space-between; .title { font-size: 18px; font-weight: bold; }
align-items: center; .option-row { display: flex; justify-content: space-between; width: 100%; }
} .option-name { font-weight: bold; color: #303133; }
.header-right { .option-spec { font-size: 12px; color: #909399; margin-left: 15px; }
display: flex;
align-items: center;
}
.title {
font-size: 18px;
font-weight: bold;
}
/* 下拉框选项样式优化 */
.option-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.option-name {
font-weight: bold;
color: #303133;
}
.option-spec {
font-size: 12px;
color: #909399;
margin-left: 15px;
}
</style> </style>

View File

@ -11,6 +11,18 @@
@input="handleInputSearch" @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 <el-select
v-model="queryParams.category" v-model="queryParams.category"
placeholder="类别" placeholder="类别"
@ -18,8 +30,9 @@
filterable filterable
allow-create allow-create
default-first-option default-first-option
style="width: 140px; margin-right: 10px;" style="width: 240px; margin-right: 10px;"
@change="handleQuery" @change="handleQuery"
popper-class="long-dropdown"
> >
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" /> <el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select> </el-select>
@ -33,6 +46,7 @@
default-first-option default-first-option
style="width: 140px; margin-right: 10px;" style="width: 140px; margin-right: 10px;"
@change="handleQuery" @change="handleQuery"
popper-class="long-dropdown"
> >
<el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" /> <el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" />
</el-select> </el-select>
@ -53,6 +67,10 @@
</div> </div>
<div class="right-toolbar"> <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-button type="primary" @click="handleAdd" style="margin-right: 10px">
<el-icon style="margin-right: 5px"><Plus /></el-icon>新增 <el-icon style="margin-right: 5px"><Plus /></el-icon>新增
</el-button> </el-button>
@ -81,6 +99,7 @@
列展示设置 列展示设置
</div> </div>
<el-checkbox v-model="columns.id.visible" label="ID" /> <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.name.visible" label="名称" />
<el-checkbox v-model="columns.commonName.visible" label="俗名" /> <el-checkbox v-model="columns.commonName.visible" label="俗名" />
<el-checkbox v-model="columns.category.visible" label="类别" /> <el-checkbox v-model="columns.category.visible" label="类别" />
@ -105,6 +124,13 @@
style="width: 100%; margin-top: 15px" 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.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.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> <el-table-column v-if="columns.commonName.visible" prop="commonName" label="俗名" min-width="140" show-overflow-tooltip>
@ -114,7 +140,7 @@
</template> </template>
</el-table-column> </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> <template #default="scope">{{ scope.row.category || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column v-if="columns.type.visible" prop="type" label="类型" min-width="120" align="center" show-overflow-tooltip> <el-table-column v-if="columns.type.visible" prop="type" label="类型" min-width="120" align="center" show-overflow-tooltip>
@ -196,16 +222,16 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<div style="margin-top: 20px; text-align: right;"> <div class="pagination-container">
<el-pagination <el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
v-model:current-page="queryParams.pageNum" v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize" v-model:page-size="queryParams.pageSize"
:page-sizes="[100, 200, 500, 1000]" :page-sizes="[10, 20, 50, 100]"
@size-change="getList" :background="true"
@current-change="getList" layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handlePageSizeChange"
@current-change="handlePageCurrentChange"
/> />
</div> </div>
@ -233,16 +259,44 @@
<el-row> <el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="类别" prop="category"> <el-form-item label="所属公司" prop="companyName">
<el-autocomplete <el-autocomplete
v-model="form.category" v-model="form.companyName"
:fetch-suggestions="querySearchCategory" :fetch-suggestions="querySearchCompany"
placeholder="输入或选择" placeholder="输入公司名称"
clearable clearable
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
</el-col> </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-col :span="12">
<el-form-item label="类型" prop="type"> <el-form-item label="类型" prop="type">
<el-autocomplete <el-autocomplete
@ -254,26 +308,27 @@
/> />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row>
<el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="规格型号" prop="spec"> <el-form-item label="规格型号" prop="spec">
<el-input v-model="form.spec" placeholder="请输入规格型号" /> <el-input v-model="form.spec" placeholder="请输入规格型号" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row>
<el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="计量单位" prop="unit"> <el-form-item label="计量单位" prop="unit">
<el-input v-model="form.unit" placeholder=": , , " /> <el-input v-model="form.unit" placeholder=": , , " />
</el-form-item> </el-form-item>
</el-col> </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-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"> <el-form-item label="产品图" prop="generalImage">
<div class="upload-container"> <div class="upload-container">
<el-upload <el-upload
@ -362,8 +417,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'; import { ref, reactive, onMounted, nextTick } from 'vue';
import { Plus, Picture, Document, Refresh, Setting, Rank, Camera, Link } from '@element-plus/icons-vue'; import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download } from '@element-plus/icons-vue';
// 修复:引入 ElLoading
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'; import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus'; import type { FormInstance, FormRules } from 'element-plus';
@ -372,7 +426,8 @@ import {
addMaterialBase, addMaterialBase,
updateMaterialBase, updateMaterialBase,
delMaterialBase, delMaterialBase,
getMaterialBaseOptions // 新增引入 getMaterialBaseOptions,
exportAssetStatistics // 导入导出API
} from '@/api/material_base'; } from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload'; import { uploadFile, deleteFile } from '@/api/common/upload';
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'; import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
@ -380,6 +435,7 @@ import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
// --- 类型定义 --- // --- 类型定义 ---
interface MaterialBaseVO { interface MaterialBaseVO {
id: number; id: number;
companyName: string;
name: string; name: string;
commonName?: string; commonName?: string;
category: string; category: string;
@ -391,7 +447,6 @@ interface MaterialBaseVO {
generalImage: string[]; generalImage: string[];
isEnabled: number; isEnabled: number;
statusLoading?: boolean; statusLoading?: boolean;
// 新增字段
inventoryCount?: number; inventoryCount?: number;
availableCount?: number; availableCount?: number;
} }
@ -402,11 +457,19 @@ interface QueryParams {
keyword: string; keyword: string;
category: string; category: string;
type: string; type: string;
company: string;
isEnabled?: number; isEnabled?: number;
} }
interface CascaderOption {
value: string;
label: string;
children?: CascaderOption[];
}
// --- 响应式数据 --- // --- 响应式数据 ---
const loading = ref(false); const loading = ref(false);
const exportLoading = ref(false); // 导出加载状态
const total = ref(0); const total = ref(0);
const tableData = ref<MaterialBaseVO[]>([]); const tableData = ref<MaterialBaseVO[]>([]);
const submitLoading = ref(false); const submitLoading = ref(false);
@ -424,29 +487,35 @@ const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null);
const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage'); const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage');
const columns = reactive({ const columns = reactive({
id: { visible: true }, id: { visible: false },
companyName: { visible: true },
name: { visible: true }, name: { visible: true },
commonName: { visible: true }, commonName: { visible: true },
category: { visible: true }, category: { visible: true },
type: { visible: true }, type: { visible: true },
spec: { visible: true }, spec: { visible: true },
unit: { visible: true }, unit: { visible: true },
// visibilityLevel: { visible: false }, // 不再使用 inventory: { visible: true },
inventory: { visible: true }, // 新增 available: { visible: true },
available: { visible: true }, // 新增
files: { visible: true }, files: { visible: true },
isEnabled: { visible: true } isEnabled: { visible: true }
}); });
const companyOptions = ref<string[]>([]);
const categoryOptions = ref<string[]>([]); const categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]); const typeOptions = ref<string[]>([]);
const categoryTreeOptions = ref<CascaderOption[]>([]);
const tempCategoryPrefix = ref<string[]>([]);
const tempCategorySuffix = ref<string>('');
const queryParams = reactive<QueryParams>({ const queryParams = reactive<QueryParams>({
pageNum: 1, pageNum: 1,
pageSize: 100, // 修改默认显示条数为 100 pageSize: 100,
keyword: '', keyword: '',
category: '', category: '',
type: '', type: '',
company: '',
isEnabled: undefined isEnabled: undefined
}); });
@ -460,6 +529,7 @@ const formRef = ref<FormInstance>();
const initForm = { const initForm = {
id: undefined, id: undefined,
companyName: '',
name: '', name: '',
commonName: '', commonName: '',
category: '', category: '',
@ -474,9 +544,33 @@ const initForm = {
const form = ref({...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>({ const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入基础信息名称', trigger: 'blur' }], 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' }], type: [{ required: true, message: '请选择或输入类型', trigger: 'change' }],
spec: [{ required: true, message: '请输入规格型号', trigger: 'blur' }], spec: [{ required: true, message: '请输入规格型号', trigger: 'blur' }],
unit: [{ required: true, message: '请输入单位', trigger: 'blur' }] unit: [{ required: true, message: '请输入单位', trigger: 'blur' }]
@ -484,22 +578,46 @@ const rules = reactive<FormRules>({
// --- 业务逻辑方法 --- // --- 业务逻辑方法 ---
// 获取所有选项(不再依赖当前页数据) 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;
}
});
});
return root;
};
const getOptionsList = () => { const getOptionsList = () => {
getMaterialBaseOptions().then((res: any) => { getMaterialBaseOptions().then((res: any) => {
if (res.code === 200) { if (res.code === 200) {
categoryOptions.value = res.data.categories || []; categoryOptions.value = res.data.categories || [];
typeOptions.value = res.data.types || []; typeOptions.value = res.data.types || [];
companyOptions.value = res.data.companies || [];
categoryTreeOptions.value = buildCategoryTree(categoryOptions.value);
} }
}).catch(err => { }).catch(err => {
console.error("获取筛选项失败", err); console.error("获取筛选项失败", err);
}); });
}; };
const querySearchCategory = (queryString: string, cb: any) => { const querySearchCompany = (queryString: string, cb: any) => {
const results = queryString const results = queryString
? categoryOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase())) ? companyOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
: categoryOptions.value; : companyOptions.value;
const formattedResults = results.map(item => ({ value: item })); const formattedResults = results.map(item => ({ value: item }));
cb(formattedResults); cb(formattedResults);
}; };
@ -519,7 +637,6 @@ const getList = () => {
if (response && response.data) { if (response && response.data) {
tableData.value = response.data.items; tableData.value = response.data.items;
total.value = response.data.total; total.value = response.data.total;
// 移除 extractDynamicOptions 调用
} else { } else {
tableData.value = []; tableData.value = [];
total.value = 0; total.value = 0;
@ -534,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 = () => { const handleInputSearch = () => {
if (searchTimer) clearTimeout(searchTimer); if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => { searchTimer = setTimeout(() => {
@ -552,6 +713,7 @@ const resetQuery = () => {
queryParams.keyword = ''; queryParams.keyword = '';
queryParams.category = ''; queryParams.category = '';
queryParams.type = ''; queryParams.type = '';
queryParams.company = '';
queryParams.isEnabled = undefined; queryParams.isEnabled = undefined;
handleQuery(); handleQuery();
}; };
@ -560,6 +722,17 @@ const handleSizeChange = (command: 'large' | 'default' | 'small') => {
tableSize.value = command; tableSize.value = command;
}; };
const handlePageSizeChange = (val: number) => {
queryParams.pageSize = val;
queryParams.pageNum = 1;
getList();
};
const handlePageCurrentChange = (val: number) => {
queryParams.pageNum = val;
getList();
};
const handleAdd = () => { const handleAdd = () => {
resetForm(); resetForm();
dialog.title = '新增基础信息'; dialog.title = '新增基础信息';
@ -572,17 +745,28 @@ const handleEdit = (row: MaterialBaseVO) => {
dialog.visible = true; dialog.visible = true;
nextTick(() => { nextTick(() => {
// 基础字段赋值 - 深拷贝防止引用 const data = JSON.parse(JSON.stringify(row));
Object.assign(form.value, 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 images = row.generalImage || [];
const manuals = row.generalManual || []; const manuals = row.generalManual || [];
// 分离图片文件和外部链接
const imgFiles = images.filter(u => !isExternalLink(u)); const imgFiles = images.filter(u => !isExternalLink(u));
const imgLinks = images.filter(u => isExternalLink(u)); const imgLinks = images.filter(u => isExternalLink(u));
const manualFiles = manuals.filter(u => !isExternalLink(u)); const manualFiles = manuals.filter(u => !isExternalLink(u));
const manualLinks = manuals.filter(u => isExternalLink(u)); const manualLinks = manuals.filter(u => isExternalLink(u));
@ -596,12 +780,12 @@ const handleEdit = (row: MaterialBaseVO) => {
const checkDuplicate = async (name: string, spec: string): Promise<boolean> => { const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
try { 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)) { if (nameRes.data?.items?.some((item: MaterialBaseVO) => item.name === name && item.id !== form.value.id)) {
ElMessage.error(`已存在名称为 "${name}" 的基础信息!`); ElMessage.error(`已存在名称为 "${name}" 的基础信息!`);
return true; 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)) { if (specRes.data?.items?.some((item: MaterialBaseVO) => item.spec === spec && item.id !== form.value.id)) {
ElMessage.error(`已存在规格/编号为 "${spec}" 的基础信息!`); ElMessage.error(`已存在规格/编号为 "${spec}" 的基础信息!`);
return true; return true;
@ -619,23 +803,27 @@ const submitForm = async () => {
if (valid) { if (valid) {
submitLoading.value = true; submitLoading.value = true;
try { try {
// 重复校验
const isDuplicate = await checkDuplicate(form.value.name, form.value.spec); const isDuplicate = await checkDuplicate(form.value.name, form.value.spec);
if (isDuplicate) { if (isDuplicate) {
submitLoading.value = false; submitLoading.value = false;
return; return;
} }
// 整理文件数据:本地上传的路径已经在 form.value 中
// 我们需要重新合并:已有的非外链 + 新的输入框外链
const finalImageList = form.value.generalImage.filter(item => !isExternalLink(item)); const finalImageList = form.value.generalImage.filter(item => !isExternalLink(item));
if (imageExternalUrl.value) finalImageList.push(imageExternalUrl.value); if (imageExternalUrl.value) finalImageList.push(imageExternalUrl.value);
const finalManualList = form.value.generalManual.filter(item => !isExternalLink(item)); const finalManualList = form.value.generalManual.filter(item => !isExternalLink(item));
if (manualExternalUrl.value) finalManualList.push(manualExternalUrl.value); 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 = { const payload = {
...form.value, ...form.value,
category: fullCategory,
generalImage: finalImageList, generalImage: finalImageList,
generalManual: finalManualList generalManual: finalManualList
}; };
@ -647,7 +835,6 @@ const submitForm = async () => {
ElMessage.success(`${actionText}成功`); ElMessage.success(`${actionText}成功`);
dialog.visible = false; dialog.visible = false;
getList(); getList();
// 提交成功后,刷新选项,以防有新的类别/类型被创建
getOptionsList(); getOptionsList();
} catch (error: any) { } catch (error: any) {
ElMessage.error(error.msg || '保存失败'); ElMessage.error(error.msg || '保存失败');
@ -667,6 +854,10 @@ const resetForm = () => {
form.value = JSON.parse(JSON.stringify(initForm)); form.value = JSON.parse(JSON.stringify(initForm));
fileListImage.value = []; fileListImage.value = [];
fileListManual.value = []; fileListManual.value = [];
tempCategoryPrefix.value = [];
tempCategorySuffix.value = '';
imageExternalUrl.value = ''; imageExternalUrl.value = '';
manualExternalUrl.value = ''; manualExternalUrl.value = '';
if (formRef.value) formRef.value.resetFields(); if (formRef.value) formRef.value.resetFields();
@ -692,7 +883,6 @@ const handleDelete = (row: MaterialBaseVO) => {
ElMessage.success("删除成功"); ElMessage.success("删除成功");
if (tableData.value.length === 1 && queryParams.pageNum > 1) queryParams.pageNum--; if (tableData.value.length === 1 && queryParams.pageNum > 1) queryParams.pageNum--;
getList(); getList();
// 删除后也刷新选项
getOptionsList(); getOptionsList();
}); });
}).catch(() => {}); }).catch(() => {});
@ -762,7 +952,6 @@ const triggerCamera = (field: 'generalImage' | 'generalManual') => {
} }
const handleCameraConfirm = async (file: File) => { const handleCameraConfirm = async (file: File) => {
console.log('✅ 父组件收到照片:', file.name)
if (!beforeAvatarUpload(file)) { if (!beforeAvatarUpload(file)) {
cameraDialogVisible.value = false; cameraDialogVisible.value = false;
return; return;
@ -770,7 +959,6 @@ const handleCameraConfirm = async (file: File) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
// 修复点:使用 ElLoading
const loadingInstance = ElLoading.service({ text: '照片上传中...', background: 'rgba(0, 0, 0, 0.7)' }); const loadingInstance = ElLoading.service({ text: '照片上传中...', background: 'rgba(0, 0, 0, 0.7)' });
try { try {
@ -801,7 +989,7 @@ const handleCameraConfirm = async (file: File) => {
onMounted(() => { onMounted(() => {
getList(); getList();
getOptionsList(); // 初始化下拉选项 getOptionsList();
}); });
</script> </script>
@ -831,7 +1019,12 @@ onMounted(() => {
flex-direction: column; flex-direction: column;
} }
/* 上传相关样式 */ .pagination-container {
margin-top: 15px;
display: flex;
justify-content: flex-start;
}
.upload-container { display: flex; flex-wrap: wrap; gap: 8px; } .upload-container { display: flex; flex-wrap: wrap; gap: 8px; }
:deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; } :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; } :deep(.el-upload-list--picture-card .el-upload-list__item) { width: 100px; height: 100px; }
@ -840,7 +1033,14 @@ onMounted(() => {
.camera-card .text { font-size: 12px; margin-top: 5px; } .camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; } .camera-card .el-icon { font-size: 24px; }
/* 表格缩略图样式 */
.file-preview-cell { display: flex; align-items: center; justify-content: center; position: relative; } .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); } .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> </style>

View File

@ -29,6 +29,7 @@
center center
show-icon show-icon
style="margin-bottom: 20px" style="margin-bottom: 20px"
:closable="false"
/> />
<el-table <el-table
@ -49,6 +50,12 @@
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip /> <el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="standard" label="规格型号" min-width="150" show-overflow-tooltip /> <el-table-column prop="standard" label="规格型号" min-width="150" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="150" show-overflow-tooltip>
<template #default="{ row }">
<span style="color: #409EFF; font-weight: bold;">{{ row.warehouse_location || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="available_quantity" label="当前库存" width="120" align="right"> <el-table-column prop="available_quantity" label="当前库存" width="120" align="right">
<template #default="{ row }"> <template #default="{ row }">
<span style="color: green; font-weight: bold;">{{ row.available_quantity }}</span> <span style="color: green; font-weight: bold;">{{ row.available_quantity }}</span>
@ -63,6 +70,8 @@
:max="row.available_quantity" :max="row.available_quantity"
size="small" size="small"
style="width: 100%" style="width: 100%"
controls-position="right"
@change="(val) => handleMainQuantityChange(val, row)"
/> />
</template> </template>
</el-table-column> </el-table-column>
@ -74,13 +83,13 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<div v-if="selectedItems.length > 0" style="margin-top: 15px; text-align: right; color: #606266;"> <div v-if="selectedItems.length > 0" style="margin-top: 15px; text-align: right; color: #606266; font-size: 14px;">
<span style="color: red; font-weight: bold;">{{ selectedItems.length }}</span> 种物品 <span style="color: red; font-weight: bold;">{{ selectedItems.length }}</span> 种物品
合计出库 <span style="color: red; font-weight: bold;">{{ totalExportCount }}</span> 合计出库 <span style="color: red; font-weight: bold;">{{ totalExportCount }}</span>
</div> </div>
</el-card> </el-card>
<el-dialog v-model="manualDialogVisible" title="选择库存物品" width="85%" top="5vh" destroy-on-close> <el-dialog v-model="manualDialogVisible" title="选择库存物品" width="85%" top="5vh" destroy-on-close :close-on-click-modal="false">
<div class="filter-container"> <div class="filter-container">
<el-input <el-input
v-model="searchKeyword" v-model="searchKeyword"
@ -91,15 +100,18 @@
@input="filterStock" @input="filterStock"
/> />
<span style="margin-left: 15px; color: #909399; font-size: 12px;"> <span style="margin-left: 15px; color: #909399; font-size: 12px;">
提示勾选物品后可直接在表格中修改本次出库数量 提示点击表格行可勾选<span style="color: #F56C6C; font-weight: bold;">修改数量会自动勾选</span>
</span> </span>
</div> </div>
<el-table <el-table
ref="manualTableRef"
:data="filteredStockData" :data="filteredStockData"
height="500" height="500"
border border
row-key="uniqueKey" row-key="uniqueKey"
@selection-change="handleStockSelection" @selection-change="handleStockSelection"
@row-click="handleRowClick"
style="cursor: pointer"
> >
<el-table-column type="selection" width="55" align="center" :reserve-selection="true" /> <el-table-column type="selection" width="55" align="center" :reserve-selection="true" />
<el-table-column label="类型" width="90" align="center"> <el-table-column label="类型" width="90" align="center">
@ -109,18 +121,20 @@
</el-table-column> </el-table-column>
<el-table-column prop="name" label="名称" show-overflow-tooltip /> <el-table-column prop="name" label="名称" show-overflow-tooltip />
<el-table-column prop="standard" label="规格" show-overflow-tooltip /> <el-table-column prop="standard" label="规格" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
<el-table-column prop="available_quantity" label="可用库存" width="100" align="right" /> <el-table-column prop="available_quantity" label="可用库存" width="100" align="right" />
<el-table-column label="本次出库" width="160" align="center" fixed="right"> <el-table-column label="本次出库" width="160" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-input-number <el-input-number
v-model="row.export_quantity" v-model="row.export_quantity"
:min="1" :min="0"
:max="row.available_quantity" :max="row.available_quantity"
size="small" size="small"
style="width: 100%" style="width: 100%"
placeholder="数量" placeholder="0"
@click.stop @click.stop
@change="(val) => handleManualQuantityChange(val, row)"
/> />
</template> </template>
</el-table-column> </el-table-column>
@ -134,13 +148,13 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="bomSelectVisible" title="按 BOM 套餐添加" width="600px"> <el-dialog v-model="bomSelectVisible" title="按 BOM 套餐添加" width="600px" destroy-on-close :close-on-click-modal="false">
<el-form label-width="100px"> <el-form label-width="100px">
<el-form-item label="选择产品"> <el-form-item label="选择产品">
<el-select v-model="selectedBomNo" filterable placeholder="请选择产品BOM配方" style="width: 100%"> <el-select v-model="selectedBomNo" filterable placeholder="请选择启用状态的 BOM 配方" style="width: 100%">
<el-option <el-option
v-for="b in bomOptions" v-for="b in bomOptions"
:key="b.bom_no" :key="`${b.bom_no}_${b.version}`"
:label="`${b.parent_name} - ${b.version}`" :label="`${b.parent_name} - ${b.version}`"
:value="b.bom_no" :value="b.bom_no"
/> />
@ -174,6 +188,7 @@
<el-table-column prop="typeLabel" label="类型" width="80" /> <el-table-column prop="typeLabel" label="类型" width="80" />
<el-table-column prop="name" label="名称" /> <el-table-column prop="name" label="名称" />
<el-table-column prop="standard" label="规格" /> <el-table-column prop="standard" label="规格" />
<el-table-column prop="warehouse_location" label="库位" width="100" />
<el-table-column prop="export_quantity" label="本次出库" width="120" align="center"> <el-table-column prop="export_quantity" label="本次出库" width="120" align="center">
<template #default="{ row }"> <template #default="{ row }">
<span style="font-weight: bold; color: #F56C6C; font-size: 16px;">{{ row.export_quantity }}</span> <span style="font-weight: bold; color: #F56C6C; font-size: 16px;">{{ row.export_quantity }}</span>
@ -205,7 +220,6 @@
<h1>IRIS出库拣货确认单</h1> <h1>IRIS出库拣货确认单</h1>
<div class="print-meta-row"> <div class="print-meta-row">
<span>打印时间: {{ currentTime }}</span> <span>打印时间: {{ currentTime }}</span>
<span>单据编号: {{ currentOrderNo }}</span>
</div> </div>
<div class="header-line"></div> <div class="header-line"></div>
</div> </div>
@ -213,11 +227,12 @@
<table class="print-table"> <table class="print-table">
<thead> <thead>
<tr> <tr>
<th style="width: 60px;">序号</th> <th style="width: 50px;">序号</th>
<th>物料名称</th> <th>物料名称</th>
<th>规格型号</th> <th>规格型号</th>
<th style="width: 60px;"></th> <th style="width: 80px;"></th>
<th style="width: 100px;">出库数量</th> <th style="width: 50px;">单位</th>
<th style="width: 90px;">出库数量</th>
<th style="width: 60px;">备注</th> <th style="width: 60px;">备注</th>
</tr> </tr>
</thead> </thead>
@ -226,17 +241,18 @@
<td style="text-align: center;">{{ index + 1 }}</td> <td style="text-align: center;">{{ index + 1 }}</td>
<td class="cell-padding">{{ item.name }}</td> <td class="cell-padding">{{ item.name }}</td>
<td class="cell-padding">{{ item.standard }}</td> <td class="cell-padding">{{ item.standard }}</td>
<td style="text-align: center;">{{ item.warehouse_location || '-' }}</td>
<td style="text-align: center;"></td> <td style="text-align: center;"></td>
<td style="text-align: center; font-weight: bold; font-size: 16px;">{{ item.export_quantity }}</td> <td style="text-align: center; font-weight: bold; font-size: 16px;">{{ item.export_quantity }}</td>
<td></td> <td></td>
</tr> </tr>
<tr v-if="validSelectedItems.length === 0"> <tr v-if="validSelectedItems.length === 0">
<td colspan="6" style="text-align: center; padding: 20px;">无数据</td> <td colspan="7" style="text-align: center; padding: 20px;">无数据</td>
</tr> </tr>
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td colspan="4" style="text-align: right; font-weight: bold; padding-right: 15px;">合计:</td> <td colspan="5" style="text-align: right; font-weight: bold; padding-right: 15px;">合计:</td>
<td style="text-align: center; font-weight: bold; font-size: 18px;">{{ totalExportCount }}</td> <td style="text-align: center; font-weight: bold; font-size: 18px;">{{ totalExportCount }}</td>
<td></td> <td></td>
</tr> </tr>
@ -264,26 +280,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue' import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
import { getAllStock, printSelectionList } from '@/api/inbound/stock' import { getAllStock, printSelectionList } from '@/api/inbound/stock'
import { getBomList, getBomDetail } from '@/api/bom' import { getBomList, getBomDetail } from '@/api/bom'
// --- 状态变量 --- // --- 状态变量 ---
// 核心:购物车数据
const selectedItems = ref<any[]>([]) const selectedItems = ref<any[]>([])
// 弹窗与加载状态
const manualDialogVisible = ref(false) const manualDialogVisible = ref(false)
const bomSelectVisible = ref(false) const bomSelectVisible = ref(false)
const previewVisible = ref(false) const previewVisible = ref(false)
const exportLoading = ref(false) const exportLoading = ref(false)
const printLoading = ref(false) const printLoading = ref(false)
// 数据缓存
const allStockData = ref<any[]>([]) const allStockData = ref<any[]>([])
const filteredStockData = ref<any[]>([]) const filteredStockData = ref<any[]>([])
const searchKeyword = ref('') const searchKeyword = ref('')
const tempSelection = ref<any[]>([]) // 手动添加时的临时勾选 const tempSelection = ref<any[]>([])
// 表格引用
const manualTableRef = ref<InstanceType<typeof ElTable>>()
// BOM 相关 // BOM 相关
const bomOptions = ref<any[]>([]) const bomOptions = ref<any[]>([])
@ -292,11 +307,8 @@ const bomSets = ref(1)
// 打印相关 // 打印相关
const currentTime = ref('') const currentTime = ref('')
const currentOrderNo = ref('')
// --- 计算属性 --- // --- 计算属性 ---
// 有效的出库项 (数量 > 0)
const validSelectedItems = computed(() => { const validSelectedItems = computed(() => {
return selectedItems.value.filter(item => item.export_quantity > 0) return selectedItems.value.filter(item => item.export_quantity > 0)
}) })
@ -306,7 +318,6 @@ const totalExportCount = computed(() => {
}) })
// --- 辅助方法 --- // --- 辅助方法 ---
const getTypeTag = (type: string) => { const getTypeTag = (type: string) => {
switch (type) { switch (type) {
case 'material': return 'info' case 'material': return 'info'
@ -316,14 +327,7 @@ const getTypeTag = (type: string) => {
} }
} }
const generateOrderNo = () => { // --- 核心逻辑 1手动添加库存 ---
const now = new Date();
const dateStr = now.getFullYear() + (now.getMonth()+1).toString().padStart(2,'0') + now.getDate().toString().padStart(2,'0');
const random = Math.floor(Math.random()*1000).toString().padStart(3, '0');
return 'OUT' + dateStr + '-' + random;
}
// --- 核心逻辑 1手动添加库存 (支持弹窗内修改数量) ---
const openManualSelect = async () => { const openManualSelect = async () => {
manualDialogVisible.value = true manualDialogVisible.value = true
@ -331,24 +335,21 @@ const openManualSelect = async () => {
if (allStockData.value.length === 0) { if (allStockData.value.length === 0) {
try { try {
const res: any = await getAllStock() const res: any = await getAllStock()
// 1. 分类处理并打上标记
const rawMaterials = (res.materials || []).map((i: any) => ({ ...i, type: 'material', typeLabel: '采购件' })) const rawMaterials = (res.materials || []).map((i: any) => ({ ...i, type: 'material', typeLabel: '采购件' }))
const rawSemis = (res.semis || []).map((i: any) => ({ ...i, type: 'semi', typeLabel: '半成品' })) const rawSemis = (res.semis || []).map((i: any) => ({ ...i, type: 'semi', typeLabel: '半成品' }))
const rawProducts = (res.products || []).map((i: any) => ({ ...i, type: 'product', typeLabel: '成品' })) const rawProducts = (res.products || []).map((i: any) => ({ ...i, type: 'product', typeLabel: '成品' }))
// 2. 合并
const list = [...rawMaterials, ...rawSemis, ...rawProducts] const list = [...rawMaterials, ...rawSemis, ...rawProducts]
// 3. 规范化并生成 UniqueKey
allStockData.value = list.map((i: any) => ({ allStockData.value = list.map((i: any) => ({
...i, ...i,
name: i.name || i.material_name || i.product_name || '未知名称', name: i.name || i.material_name || i.product_name || '未知名称',
standard: i.standard || i.spec_model || '', standard: i.standard || i.spec_model || '',
// ★ 确保 Key 唯一格式类型_ID // ★ 确保读取库位字段,如果没有则为空
warehouse_location: i.warehouse_location || (i.warehouse_loc) || '',
uniqueKey: `${i.type}_${i.id}`, uniqueKey: `${i.type}_${i.id}`,
available_quantity: parseFloat(i.available_quantity) || 0, available_quantity: parseFloat(i.available_quantity) || 0,
export_quantity: 1 // ★ 默认初始化为1方便用户在弹窗里看到并修改 export_quantity: 0
})) }))
filteredStockData.value = allStockData.value filteredStockData.value = allStockData.value
@ -356,9 +357,8 @@ const openManualSelect = async () => {
ElMessage.error('加载库存数据失败') ElMessage.error('加载库存数据失败')
} }
} else { } else {
// 每次打开时重置筛选并将所有项的“本次出库”重置为1或保留上次视需求而定这里重置为1以防混淆
searchKeyword.value = '' searchKeyword.value = ''
allStockData.value.forEach(item => item.export_quantity = 1) allStockData.value.forEach(item => item.export_quantity = 0)
filteredStockData.value = allStockData.value filteredStockData.value = allStockData.value
} }
} }
@ -375,16 +375,28 @@ const filterStock = () => {
) )
} }
const handleStockSelection = (val: any[]) => { const handleStockSelection = (val: any[]) => { tempSelection.value = val }
tempSelection.value = val
// 点击行任意位置切换勾选
const handleRowClick = (row: any) => {
if (manualTableRef.value) {
manualTableRef.value.toggleRowSelection(row, undefined)
}
}
// 弹窗内:当数量变化时,自动联动勾选状态
const handleManualQuantityChange = (val: number | undefined, row: any) => {
if (!manualTableRef.value) return
if (val && val > 0) {
manualTableRef.value.toggleRowSelection(row, true)
} else {
manualTableRef.value.toggleRowSelection(row, false)
}
} }
const confirmManualAdd = () => { const confirmManualAdd = () => {
if (tempSelection.value.length === 0) { if (tempSelection.value.length === 0) return ElMessage.warning('请先勾选需要添加的物品')
return ElMessage.warning('请先勾选需要添加的物品')
}
// 1. 过滤已存在的 (避免重复)
const newItems = tempSelection.value.filter(item => const newItems = tempSelection.value.filter(item =>
!selectedItems.value.find(existing => existing.uniqueKey === item.uniqueKey) !selectedItems.value.find(existing => existing.uniqueKey === item.uniqueKey)
) )
@ -394,29 +406,50 @@ const confirmManualAdd = () => {
return ElMessage.warning('选中的物品已全部在清单中') return ElMessage.warning('选中的物品已全部在清单中')
} }
// 2. 深拷贝加入购物车 (防止引用关联)
const itemsToAdd = newItems.map(item => { const itemsToAdd = newItems.map(item => {
const copy = JSON.parse(JSON.stringify(item)) const copy = JSON.parse(JSON.stringify(item))
// ★ 关键修改:直接使用用户在弹窗里输入的 export_quantity copy.export_quantity = item.export_quantity || 0
// 如果用户把输入框清空了(undefined)则默认为1
copy.export_quantity = (item.export_quantity && item.export_quantity > 0) ? item.export_quantity : 1
return copy return copy
}) })
selectedItems.value.push(...itemsToAdd) selectedItems.value.push(...itemsToAdd)
manualDialogVisible.value = false manualDialogVisible.value = false
tempSelection.value = [] // 清空临时勾选 tempSelection.value = []
ElMessage.success(`成功添加 ${itemsToAdd.length} 项物品`) ElMessage.success(`成功添加 ${itemsToAdd.length} 项物品`)
} }
// 主界面数量变更逻辑 (降为0时弹窗移除)
const handleMainQuantityChange = (val: number | undefined, row: any) => {
if (val === 0) {
ElMessageBox.confirm(
`物品【${row.name}】数量为0确定要从清单中移除吗`,
'移除确认',
{
confirmButtonText: '移除',
cancelButtonText: '保留 (恢复为1)',
type: 'warning',
}
)
.then(() => {
const index = selectedItems.value.findIndex(item => item.uniqueKey === row.uniqueKey)
if (index > -1) {
selectedItems.value.splice(index, 1)
ElMessage.success('已移除')
}
})
.catch(() => {
row.export_quantity = 1
})
}
}
// --- 核心逻辑 2按 BOM 添加 --- // --- 核心逻辑 2按 BOM 添加 ---
const openBomSelect = async () => { const openBomSelect = async () => {
bomSelectVisible.value = true bomSelectVisible.value = true
// 每次打开 BOM 弹窗重置套数为 1
bomSets.value = 1 bomSets.value = 1
try { try {
const res = await getBomList() const res = await getBomList({ active_only: true })
bomOptions.value = res.data || [] bomOptions.value = res.data || []
} catch (e) { } catch (e) {
ElMessage.error('加载 BOM 列表失败') ElMessage.error('加载 BOM 列表失败')
@ -426,24 +459,19 @@ const openBomSelect = async () => {
const confirmBomAdd = async () => { const confirmBomAdd = async () => {
if(!selectedBomNo.value) return ElMessage.warning('请选择 BOM'); if(!selectedBomNo.value) return ElMessage.warning('请选择 BOM');
// 确保库存数据已加载,用于匹配
if (allStockData.value.length === 0) { if (allStockData.value.length === 0) {
await openManualSelect() await openManualSelect()
manualDialogVisible.value = false // 仅加载数据,不显示弹窗 manualDialogVisible.value = false
} }
try { try {
const detailRes = await getBomDetail(selectedBomNo.value) const detailRes = await getBomDetail(selectedBomNo.value)
const bomRows = detailRes.data || [] const bomRows = detailRes.data?.children || []
let addedCount = 0; let addedCount = 0;
// 遍历 BOM 子件
bomRows.forEach((bomItem: any) => { bomRows.forEach((bomItem: any) => {
// ★ 这里本身就是“选择数量”的逻辑:用量 * 套数
const needQty = (parseFloat(bomItem.dosage) || 0) * bomSets.value const needQty = (parseFloat(bomItem.dosage) || 0) * bomSets.value
// ★ BOM 添加时,匹配本地库存数据,带入库位信息
// 简单匹配逻辑:匹配 base_id (最准确)
const stockCandidate = allStockData.value.find(s => const stockCandidate = allStockData.value.find(s =>
(s.base_id && s.base_id == bomItem.child_id) (s.base_id && s.base_id == bomItem.child_id)
) )
@ -451,10 +479,13 @@ const confirmBomAdd = async () => {
if (stockCandidate) { if (stockCandidate) {
const existing = selectedItems.value.find(e => e.uniqueKey === stockCandidate.uniqueKey) const existing = selectedItems.value.find(e => e.uniqueKey === stockCandidate.uniqueKey)
if (existing) { if (existing) {
// 如果已存在,累加数量
existing.export_quantity += needQty existing.export_quantity += needQty
} else { } else {
const newItem = JSON.parse(JSON.stringify(stockCandidate)) const newItem = JSON.parse(JSON.stringify(stockCandidate))
// 如果后端 BomService 也返回了 warehouse_location (聚合的),这里优先使用
if (bomItem.warehouse_location) {
newItem.warehouse_location = bomItem.warehouse_location
}
newItem.export_quantity = needQty newItem.export_quantity = needQty
selectedItems.value.push(newItem) selectedItems.value.push(newItem)
} }
@ -486,19 +517,16 @@ const handlePreview = () => {
} }
const now = new Date(); const now = new Date();
currentTime.value = `${now.getFullYear()}-${now.getMonth()+1}-${now.getDate()} ${now.getHours()}:${now.getMinutes()}`; currentTime.value = `${now.getFullYear()}-${now.getMonth()+1}-${now.getDate()} ${now.getHours()}:${now.getMinutes()}`;
currentOrderNo.value = generateOrderNo();
previewVisible.value = true previewVisible.value = true
} }
const confirmPrint = async () => { const confirmPrint = async () => {
previewVisible.value = false; previewVisible.value = false;
// 记录日志 // 记录日志
try { try {
const payload = validSelectedItems.value.map(item => ({ const payload = validSelectedItems.value.map(item => ({
name: item.name, standard: item.standard, quantity: item.export_quantity name: item.name, standard: item.standard, quantity: item.export_quantity
})); }));
// 不阻塞打印
printSelectionList(JSON.parse(JSON.stringify(payload))).catch(() => {}); printSelectionList(JSON.parse(JSON.stringify(payload))).catch(() => {});
} catch (e) {} } catch (e) {}
@ -512,19 +540,18 @@ const confirmExport = () => {
exportLoading.value = true; exportLoading.value = true;
try { try {
let csvContent = "\uFEFF"; let csvContent = "\uFEFF";
csvContent += "类型,名称,规格型号,本次出库数量\n"; csvContent += "类型,名称,规格型号,库位,本次出库数量\n";
validSelectedItems.value.forEach(item => { validSelectedItems.value.forEach(item => {
const safeName = (item.name || '').replace(/,/g, ' '); const safeName = (item.name || '').replace(/,/g, ' ');
const safeStd = (item.standard || '').replace(/,/g, ' '); const safeStd = (item.standard || '').replace(/,/g, ' ');
csvContent += `${item.typeLabel},${safeName},${safeStd},${item.export_quantity}\n`; const safeLoc = (item.warehouse_location || '').replace(/,/g, ' ');
csvContent += `${item.typeLabel},${safeName},${safeStd},${safeLoc},${item.export_quantity}\n`;
}); });
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement("a"); const link = document.createElement("a");
link.href = URL.createObjectURL(blob); link.href = URL.createObjectURL(blob);
const timestamp = new Date().toISOString().slice(0,19).replace(/[-T:]/g, ""); const timestamp = new Date().toISOString().slice(0,19).replace(/[-T:]/g, "");
link.download = `出库单_${timestamp}.csv`; link.download = `出库拣货单_${timestamp}.csv`;
link.click(); link.click();
ElMessage.success('导出成功'); ElMessage.success('导出成功');
previewVisible.value = false; previewVisible.value = false;
@ -547,69 +574,27 @@ const confirmExport = () => {
::v-deep(.el-card__body) { flex: 1; display: flex; flex-direction: column; overflow: hidden; } ::v-deep(.el-card__body) { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* ================= ★★★ 打印专用样式 ★★★ ================= */ /* ================= ★★★ 打印专用样式 ★★★ ================= */
/* 1. 默认状态:屏幕上隐藏打印区域 */
#print-area { display: none; } #print-area { display: none; }
/* 2. 打印状态:隐藏所有非打印内容,独显 #print-area */
@media print { @media print {
@page { margin: 0; size: auto; } @page { margin: 0; size: auto; }
body * { visibility: hidden; } body * { visibility: hidden; }
.el-dialog__wrapper, .v-modal, .el-message, .no-print-content { display: none !important; } .el-dialog__wrapper, .v-modal, .el-message, .no-print-content { display: none !important; }
#print-area, #print-area * { visibility: visible; } #print-area, #print-area * { visibility: visible; }
#print-area { #print-area {
position: fixed; position: fixed; left: 0; top: 0; width: 100%; height: 100%;
left: 0; margin: 0; padding: 20mm; background-color: white;
top: 0; display: block !important; z-index: 99999;
width: 100%;
height: 100%;
margin: 0;
padding: 20mm;
background-color: white;
display: block !important;
z-index: 99999;
} }
.print-header { text-align: center; margin-bottom: 20px; } .print-header { text-align: center; margin-bottom: 20px; }
.print-header h1 { font-size: 24px; margin: 0 0 10px 0; font-weight: bold; color: #000; } .print-header h1 { font-size: 24px; margin: 0 0 10px 0; font-weight: bold; color: #000; }
.print-meta-row { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 5px; } .print-meta-row { display: flex; justify-content: flex-start; font-size: 12px; margin-bottom: 5px; }
.header-line { border-bottom: 2px solid #000; margin-top: 5px; } .header-line { border-bottom: 2px solid #000; margin-top: 5px; }
.print-table { width: 100%; border-collapse: collapse; margin-bottom: 40px; border: 1px solid #000; }
.print-table { .print-table th, .print-table td { border: 1px solid #000; padding: 12px 8px; text-align: left; font-size: 14px; color: #000; }
width: 100%;
border-collapse: collapse;
margin-bottom: 40px;
border: 1px solid #000;
}
.print-table th, .print-table td {
border: 1px solid #000;
padding: 12px 8px;
text-align: left;
font-size: 14px;
color: #000;
}
.print-table th { text-align: center; font-weight: bold; } .print-table th { text-align: center; font-weight: bold; }
.cell-padding { padding-left: 10px; } .cell-padding { padding-left: 10px; }
.print-footer { display: flex; justify-content: space-between; margin-top: 60px; padding: 0 20px; }
.print-footer { .signature-item { display: flex; flex-direction: column; align-items: center; width: 30%; }
display: flex;
justify-content: space-between;
margin-top: 60px;
padding: 0 20px;
}
.signature-item {
display: flex;
flex-direction: column;
align-items: center;
width: 30%;
}
.sig-label { font-size: 14px; margin-bottom: 40px; text-align: left; width: 100%; } .sig-label { font-size: 14px; margin-bottom: 40px; text-align: left; width: 100%; }
.sig-line { border-bottom: 1px solid #000; width: 100%; height: 1px; display: block; } .sig-line { border-bottom: 1px solid #000; width: 100%; height: 1px; display: block; }
} }

View File

@ -2,14 +2,30 @@
<div class="buy-module"> <div class="buy-module">
<div class="header-container"> <div class="header-container">
<div class="search-form-area"> <div class="search-form-area">
<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 <el-input
v-model="queryParams.keyword" v-model="queryParams.keyword"
placeholder="请输入名称或规格" placeholder="请输入名称或规格"
class="filter-item-input" class="filter-item-input"
clearable clearable
@clear="fetchData"
@keyup.enter="fetchData" @keyup.enter="fetchData"
style="width: 240px;" style="width: 240px;"
/> >
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select <el-select
v-model="queryParams.category" v-model="queryParams.category"
@ -114,6 +130,10 @@
<span class="avail-num">{{ scope.row.qty_available }}</span> <span class="avail-num">{{ scope.row.qty_available }}</span>
</template> </template>
<template #default="scope" v-else-if="col.prop === 'tax_rate'">
<span style="color: #909399;">{{ scope.row.tax_rate }}%</span>
</template>
<template #default="scope" v-else-if="['arrival_photo', 'inspection_report'].includes(col.prop)"> <template #default="scope" v-else-if="['arrival_photo', 'inspection_report'].includes(col.prop)">
<div v-if="getImagesOnly(scope.row[col.prop]).length > 0" style="display: flex; align-items: center; justify-content: center;"> <div v-if="getImagesOnly(scope.row[col.prop]).length > 0" style="display: flex; align-items: center; justify-content: center;">
<el-image <el-image
@ -188,20 +208,23 @@
<div class="form-card basic-card"> <div class="form-card basic-card">
<div class="card-title"> <div class="card-title">
<el-icon class="icon"><Box/></el-icon> <div style="display: flex; align-items: center;">
<span>1. 基础信息</span> <el-icon class="icon"><Box/></el-icon>
<span>1. 基础信息</span>
</div>
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索锁定物料)</span> <span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索锁定物料)</span>
</div> </div>
<div class="card-content"> <div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 15px;"> <el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="10"> <el-col :span="12">
<el-form-item label="物料搜索" prop="base_id" class="highlight-label"> <el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select <el-select
v-model="form.base_id" v-model="form.base_id"
filterable filterable
remote remote
reserve-keyword reserve-keyword
placeholder="输入名称或规格..." clearable
placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterialDebounced" :remote-method="handleSearchMaterialDebounced"
@visible-change="handleMaterialDropdownVisible" @visible-change="handleMaterialDropdownVisible"
:loading="searchLoading" :loading="searchLoading"
@ -209,8 +232,11 @@
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option default-first-option
v-loadmore="loadMoreMaterials" v-loadmore="loadMoreMaterials"
:teleported="false" popper-class="long-dropdown"
> >
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<el-option <el-option
v-for="item in materialOptions" v-for="item in materialOptions"
:key="item.id" :key="item.id"
@ -218,32 +244,40 @@
:value="item.id" :value="item.id"
> >
<div class="option-item"> <div class="option-item">
<span class="opt-name">{{ item.name }}</span> <div class="opt-main">
<span class="opt-spec">{{ item.spec }}</span> <span class="opt-name" :title="item.name">{{ item.name }}</span>
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag> </div>
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag> <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="warning" effect="plain">历史</el-tag>
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div>
</div> </div>
</el-option> </el-option>
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 5px; background: #fff;"> <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> 加载更多中... <el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
</div> </div>
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="14" style="display: flex; align-items: center;"> <el-col :span="12" style="display: flex; align-items: center;">
<span class="search-tip"> <span class="search-tip">
<el-icon><InfoFilled/></el-icon> 支持名称/规格型号模糊搜索;滚动可加载更多。 <el-icon><InfoFilled/></el-icon> 支持名称规格型号、公司名称模糊搜索
</span> </span>
</el-col> </el-col>
</el-row> </el-row>
<div class="read-only-grid"> <div class="read-only-grid">
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view"/></el-form-item></el-col> <el-col :span="8"><el-form-item label="所属公司"><el-input v-model="form.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_type" disabled 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.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" 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" disabled 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.unit" 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" 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-row> </el-row>
</div> </div>
</div> </div>
@ -263,7 +297,7 @@
<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-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>
<el-col :span="6"> <el-col :span="6">
<el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="扫描条码"/></el-form-item> <el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="扫描条码" clearable/></el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :span="6">
<el-form-item label="库位" prop="warehouse_location"> <el-form-item label="库位" prop="warehouse_location">
@ -315,6 +349,13 @@
<el-input-number v-model="form.in_quantity" :min="1" controls-position="right" style="width:100%" class="strong-input"/> <el-input-number v-model="form.in_quantity" :min="1" controls-position="right" style="width:100%" class="strong-input"/>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6" v-if="dialogStatus === 'create'">
<el-form-item label="打印份数" prop="print_copies">
<el-input-number v-model="form.print_copies" :min="1" :max="999" controls-position="right" style="width:100%"/>
</el-form-item>
</el-col>
<template v-if="dialogStatus === 'update'"> <template v-if="dialogStatus === 'update'">
<el-col :span="6"><el-form-item label="当前库存" prop="stock_quantity"><el-input-number v-model="form.stock_quantity" disabled style="width:100%" :controls="false"/></el-form-item></el-col> <el-col :span="6"><el-form-item label="当前库存" prop="stock_quantity"><el-input-number v-model="form.stock_quantity" disabled style="width:100%" :controls="false"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="当前可用" prop="available_quantity"><el-input-number v-model="form.available_quantity" disabled style="width:100%" :controls="false"/></el-form-item></el-col> <el-col :span="6"><el-form-item label="当前可用" prop="available_quantity"><el-input-number v-model="form.available_quantity" disabled style="width:100%" :controls="false"/></el-form-item></el-col>
@ -391,7 +432,19 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"><el-form-item label="汇率"><el-input-number v-model="form.exchange_rate" :precision="2" controls-position="right" style="width:100%"/></el-form-item></el-col> <el-col :span="6"><el-form-item label="汇率"><el-input-number v-model="form.exchange_rate" :precision="2" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="含税单价" prop="unit_price"><el-input-number v-model="form.unit_price" :precision="4" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="6">
<el-form-item label="税率">
<el-select v-model="form.tax_rate" style="width:100%">
<el-option label="0%" :value="0" />
<el-option label="1%" :value="1" />
<el-option label="13%" :value="13" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6"><el-form-item label="不含税单价" prop="unit_price"><el-input-number v-model="form.unit_price" :precision="4" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="总价"><el-input-number v-model="form.total_price" :precision="2" disabled :controls="false" style="width:100%" class="total-price-input"/></el-form-item></el-col> <el-col :span="6"><el-form-item label="总价"><el-input-number v-model="form.total_price" :precision="2" disabled :controls="false" style="width:100%" class="total-price-input"/></el-form-item></el-col>
</el-row> </el-row>
<el-row :gutter="20"> <el-row :gutter="20">
@ -495,7 +548,16 @@
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/> <img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
<div v-else class="empty-preview">正在生成预览...</div> <div v-else class="empty-preview">正在生成预览...</div>
</div> </div>
<div style="margin-top: 20px; font-size: 14px; color: #666;"><p>打印机 IP: 192.168.9.205</p><p>尺寸: 40mm x 30mm</p></div> <div style="margin-top: 20px; font-size: 14px; color: #666;">
<p>打印机 IP: 192.168.9.205</p>
<p>尺寸: 40mm x 30mm</p>
<div style="margin-top: 15px; display: flex; align-items: center; justify-content: center; gap: 10px;">
<span style="font-weight: bold; color: #303133;">打印份数:</span>
<el-input-number v-model="printCopies" :min="1" :max="100" size="default" style="width: 120px;" />
</div>
</div>
</div> </div>
<template #footer> <template #footer>
<div class="dialog-footer"><el-button @click="printVisible = false">取消</el-button><el-button type="primary" :loading="printing" @click="confirmPrint"><el-icon><Printer/></el-icon>确认打印</el-button></div> <div class="dialog-footer"><el-button @click="printVisible = false">取消</el-button><el-button type="primary" :loading="printing" @click="confirmPrint"><el-icon><Printer/></el-icon>确认打印</el-button></div>
@ -521,27 +583,30 @@ import {
getUserSuggestions, getUserSuggestions,
getLinkSuggestions, getLinkSuggestions,
getLocationSuggestions, getLocationSuggestions,
getFilterOptions // 新增引入 getFilterOptions
} from '@/api/inbound/buy' } from '@/api/inbound/buy'
import {getLabelPreview, executePrint} from '@/api/common/print' import {getLabelPreview, executePrint} from '@/api/common/print'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
// ------------------------------------ // ------------------------------------
// 自定义指令v-loadmore // 自定义指令v-loadmore (适配 Teleport 到 Body 的下拉框)
// ------------------------------------ // ------------------------------------
const vLoadmore = { const vLoadmore = {
mounted(el: any, binding: any) { mounted(el: any, binding: any) {
setTimeout(() => { const checkAndBind = () => {
const SELECT_DOM = el.querySelector('.el-select-dropdown .el-scrollbar__wrap') const dropDownWrap = document.querySelector('.long-dropdown .el-select-dropdown__wrap')
if (SELECT_DOM) { if (dropDownWrap && !dropDownWrap.getAttribute('data-loadmore-bound')) {
SELECT_DOM.addEventListener('scroll', function (this: any) { dropDownWrap.setAttribute('data-loadmore-bound', 'true')
dropDownWrap.addEventListener('scroll', function (this: any) {
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 1 const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 1
if (condition) { if (condition) {
binding.value() binding.value()
} }
}) })
} }
}, 200) }
setTimeout(checkAndBind, 500)
el.addEventListener('click', () => setTimeout(checkAndBind, 300))
} }
} }
@ -558,9 +623,9 @@ const tableData = ref([])
const total = ref(0) const total = ref(0)
const formRef = ref() const formRef = ref()
// 存储下拉选项
const categoryOptions = ref<string[]>([]) const categoryOptions = ref<string[]>([])
const typeOptions = ref<string[]>([]) const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([])
const queryParams = reactive({ const queryParams = reactive({
page: 1, page: 1,
@ -568,6 +633,7 @@ const queryParams = reactive({
keyword: '', keyword: '',
category: '', category: '',
material_type: '', material_type: '',
company: '',
statuses: ['在库', '借库'] statuses: ['在库', '借库']
}) })
@ -582,6 +648,8 @@ const printLoading = ref(false)
const printing = ref(false) const printing = ref(false)
const previewUrl = ref('') const previewUrl = ref('')
const currentPrintData = ref<any>({}) const currentPrintData = ref<any>({})
const printCopies = ref(1)
const entryMode = ref('batch') const entryMode = ref('batch')
const modeLocked = ref(false) const modeLocked = ref(false)
const dialogImageUrl = ref('') const dialogImageUrl = ref('')
@ -595,6 +663,7 @@ const inspection_report_url = ref('')
// 基础列 // 基础列
const baseColumns = [ const baseColumns = [
{prop: 'company_name', label: '所属公司'},
{prop: 'material_name', label: '名称'}, {prop: 'material_name', label: '名称'},
{prop: 'material_type', label: '类型'}, {prop: 'material_type', label: '类型'},
{prop: 'category', label: '类别'}, {prop: 'category', label: '类别'},
@ -616,7 +685,10 @@ const stockColumns = [
{prop: 'qty_stock', label: '库存数', minWidth: '100'}, {prop: 'qty_stock', label: '库存数', minWidth: '100'},
{prop: 'qty_available', label: '可用数', minWidth: '100'}, {prop: 'qty_available', label: '可用数', minWidth: '100'},
{prop: 'warehouse_loc', label: '库位', minWidth: '120'}, {prop: 'warehouse_loc', label: '库位', minWidth: '120'},
{prop: 'unit_price', label: '单价', minWidth: '120'},
{prop: 'tax_rate', label: '税率', minWidth: '80'},
{prop: 'unit_price', label: '不含税单价', minWidth: '120'},
{prop: 'total_price', label: '总价', minWidth: '120'}, {prop: 'total_price', label: '总价', minWidth: '120'},
{prop: 'currency', label: '币种', minWidth: '80'}, {prop: 'currency', label: '币种', minWidth: '80'},
{prop: 'exchange_rate', label: '汇率', minWidth: '80'}, {prop: 'exchange_rate', label: '汇率', minWidth: '80'},
@ -632,20 +704,27 @@ const stockColumns = [
const allColumns = [...baseColumns, ...stockColumns] const allColumns = [...baseColumns, ...stockColumns]
const defaultColumns = [ const defaultColumns = [
'company_name',
'material_name', 'material_type', 'category', 'spec_model', 'unit', 'material_name', 'material_type', 'category', 'spec_model', 'unit',
'inbound_date', 'sn_bn', 'warehouse_loc', 'status', 'inspection_status', 'inbound_date', 'sn_bn', 'warehouse_loc', 'status', 'inspection_status',
'unit_price', 'total_price', 'supplier_name', 'purchaser', 'qty_stock', 'qty_available', 'arrival_photo', 'inspection_report' 'tax_rate', 'unit_price', 'total_price',
'supplier_name', 'purchaser', 'qty_stock', 'qty_available', 'arrival_photo', 'inspection_report'
] ]
const visibleColumnProps = ref(defaultColumns) const visibleColumnProps = ref(defaultColumns)
const form = reactive({ const form = reactive({
id: undefined, base_id: undefined as number | undefined, material_name: '', spec_model: '', category: '', unit: '', material_type: '', 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: '在库', inspection_status: '未检', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', inspection_status: '未检',
in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '',
unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00, unit_price: 0, total_price: 0,
tax_rate: 0,
currency: 'CNY', exchange_rate: 1.00,
supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '', supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '',
arrival_photo: [] as string[], inspection_report: [] as string[] arrival_photo: [] as string[], inspection_report: [] as string[],
print_copies: 1
}) })
@ -755,6 +834,7 @@ const loadMoreMaterials = async () => {
const onMaterialSelected = (val: number) => { const onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val) const item = materialOptions.value.find(i => i.id === val)
if (item) { if (item) {
form.company_name = item.company_name
form.material_name = item.name form.material_name = item.name
form.spec_model = item.spec form.spec_model = item.spec
form.category = item.category form.category = item.category
@ -857,6 +937,7 @@ const fetchOptions = async () => {
if (res.code === 200) { if (res.code === 200) {
categoryOptions.value = res.data.categories categoryOptions.value = res.data.categories
typeOptions.value = res.data.types typeOptions.value = res.data.types
companyOptions.value = res.data.companies
} }
} catch (e) { } catch (e) {
console.error("Fetch options failed", e) console.error("Fetch options failed", e)
@ -867,6 +948,7 @@ const resetQuery = () => {
queryParams.keyword = '' queryParams.keyword = ''
queryParams.category = '' queryParams.category = ''
queryParams.material_type = '' queryParams.material_type = ''
queryParams.company = ''
queryParams.page = 1 queryParams.page = 1
fetchData() fetchData()
} }
@ -887,11 +969,15 @@ const handleUpdate = (row: any) => {
resetForm() resetForm()
modeLocked.value = true modeLocked.value = true
Object.assign(form, { 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, 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, inspection_status: row.inspection_status, warehouse_location: row.warehouse_loc, status: row.status, inspection_status: row.inspection_status,
in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available), in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available),
unit_price: Number(row.unit_price), total_price: Number(row.total_price), currency: row.currency, exchange_rate: Number(row.exchange_rate), unit_price: Number(row.unit_price), total_price: Number(row.total_price),
tax_rate: Number(row.tax_rate),
currency: row.currency, exchange_rate: Number(row.exchange_rate),
supplier_name: row.supplier_name, purchaser: row.purchaser, purchaser_email: row.purchaser_email, supplier_name: row.supplier_name, purchaser: row.purchaser, purchaser_email: row.purchaser_email,
source_link: row.source_link, detail_link: row.detail_link, source_link: row.source_link, detail_link: row.detail_link,
arrival_photo: row.arrival_photo || [], inspection_report: row.inspection_report || [] arrival_photo: row.arrival_photo || [], inspection_report: row.inspection_report || []
@ -904,7 +990,7 @@ const handleUpdate = (row: any) => {
inspection_report_url.value = reportLinks.length > 0 ? reportLinks[0] : '' inspection_report_url.value = reportLinks.length > 0 ? reportLinks[0] : ''
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' } 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 = '' } 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 }] materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name }]
visible.value = true visible.value = true
} }
@ -917,6 +1003,7 @@ const submitForm = async () => {
if (inspection_report_url.value && !finalReportList.includes(inspection_report_url.value)) finalReportList.push(inspection_report_url.value) if (inspection_report_url.value && !finalReportList.includes(inspection_report_url.value)) finalReportList.push(inspection_report_url.value)
const onlyImages = finalReportList.filter(item => !isExternalLink(item)) const onlyImages = finalReportList.filter(item => !isExternalLink(item))
if (inspection_report_url.value) onlyImages.push(inspection_report_url.value) if (inspection_report_url.value) onlyImages.push(inspection_report_url.value)
const payload = { ...form, inspection_report: onlyImages, in_quantity: Number(form.in_quantity), unit_price: Number(form.unit_price) } const payload = { ...form, inspection_report: onlyImages, in_quantity: Number(form.in_quantity), unit_price: Number(form.unit_price) }
try { try {
if (dialogStatus.value === 'create') { if (dialogStatus.value === 'create') {
@ -924,7 +1011,11 @@ const submitForm = async () => {
ElMessage.success('入库成功') ElMessage.success('入库成功')
if (res.data) { if (res.data) {
ElMessage.info('发送打印指令...') ElMessage.info('发送打印指令...')
try { await executePrint(res.data); ElMessage.success('打印指令已发送') } try {
// [已修改] 传递用户选择的打印份数
await executePrint({ ...res.data, copies: form.print_copies });
ElMessage.success(`打印指令已发送 (x${form.print_copies})`)
}
catch (printErr: any) { ElMessage.warning('打印失败:' + (printErr.msg || '未知错误')) } catch (printErr: any) { ElMessage.warning('打印失败:' + (printErr.msg || '未知错误')) }
} }
} else { await updateBuyInbound(form.id!, payload); ElMessage.success('更新成功') } } else { await updateBuyInbound(form.id!, payload); ElMessage.success('更新成功') }
@ -1044,24 +1135,53 @@ const handleCameraConfirm = async (file: File) => {
} }
const handleDelete = async (row: any) => { try { await deleteBuyInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } } const handleDelete = async (row: any) => { try { await deleteBuyInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } }
// ------------------------------------
// 打印逻辑
// ------------------------------------
const handlePrint = async (row: any) => { const handlePrint = async (row: any) => {
printVisible.value = true; printLoading.value = true; previewUrl.value = '' printVisible.value = true;
printLoading.value = true;
previewUrl.value = '';
printCopies.value = 1;
currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, batch_number: row.batch_number, sku: row.sku } currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, batch_number: row.batch_number, sku: row.sku }
try { const res: any = await getLabelPreview(currentPrintData.value); previewUrl.value = res.data } try { const res: any = await getLabelPreview(currentPrintData.value); previewUrl.value = res.data }
catch (e) { ElMessage.error('预览失败') } finally { printLoading.value = false } catch (e) { ElMessage.error('预览失败') } finally { printLoading.value = false }
} }
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 confirmPrint = async () => {
printing.value = true;
try {
await executePrint({ ...currentPrintData.value, copies: printCopies.value });
ElMessage.success('指令已发送');
printVisible.value = false
} catch (e: any) {
ElMessage.error(e.msg || '打印失败')
} finally {
printing.value = false
}
}
const resetForm = () => { const resetForm = () => {
materialOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = '' materialOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = ''
searchPage.value = 1; hasNextPage.value = true; searchKeyword.value = ''; searchPage.value = 1; hasNextPage.value = true; searchKeyword.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: '在库', inspection_status: '未检', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00, supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '', arrival_photo: [], inspection_report: [] }) 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: '在库', inspection_status: '未检', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '',
unit_price: 0, total_price: 0,
tax_rate: 0,
currency: 'CNY', exchange_rate: 1.00, supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '', arrival_photo: [], inspection_report: [],
print_copies: 1
})
} }
const getStatusType = (status: string) => { const map: any = {'在库': 'success', '出库': 'info', '损耗': 'danger'}; return map[status] || 'warning' } const getStatusType = (status: string) => { const map: any = {'在库': 'success', '出库': 'info', '损耗': 'danger'}; return map[status] || 'warning' }
const formatMoney = (val: any, currency = '¥') => { const num = Number(val); return isNaN(num) ? '-' : `${currency} ${num.toFixed(2)}` } const formatMoney = (val: any, currency = '¥') => { const num = Number(val); return isNaN(num) ? '-' : `${currency} ${num.toFixed(2)}` }
onMounted(() => { onMounted(() => {
fetchData() fetchData()
fetchOptions() // 加载下拉框数据 fetchOptions()
}) })
</script> </script>
@ -1089,7 +1209,6 @@ onMounted(() => {
/* 输入框样式 */ /* 输入框样式 */
} }
/* 调整下拉框样式以匹配截图 */
.filter-item-select { .filter-item-select {
/* 确保下拉框高度和输入框一致 */ /* 确保下拉框高度和输入框一致 */
} }
@ -1142,16 +1261,33 @@ onMounted(() => {
.avail-num { font-weight: bold; color: #67C23A; font-size: 15px; } .avail-num { font-weight: bold; color: #67C23A; font-size: 15px; }
.sum-tag { margin-left: 4px; transform: scale(0.9); } .sum-tag { margin-left: 4px; transform: scale(0.9); }
:deep(.el-dialog__body) { padding: 0; overflow: hidden; } :deep(.el-dialog__body) { padding: 0; overflow: hidden; }
.dialog-scroll-container { padding: 15px 20px; max-height: 70vh; overflow-y: auto; overflow-x: hidden; }
.stylish-form .form-card { background: #fff; border-radius: 8px; border: 1px solid #e4e7ed; margin-bottom: 15px; overflow: hidden; } /* [已修改] 增加 min-height 确保弹窗即使内容少时也保持美观高度,防止被下拉框“压垮” */
.dialog-scroll-container { padding: 15px 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: 15px; }
.card-title { background: #fcfcfc; padding: 10px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; font-size: 14px; color: #303133; display: flex; align-items: center; } .card-title { background: #fcfcfc; padding: 10px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; font-size: 14px; color: #303133; display: flex; align-items: center; }
.card-title .icon { margin-right: 8px; font-size: 18px; color: #409EFF; } .card-title .icon { margin-right: 8px; font-size: 18px; color: #409EFF; }
.card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; } .card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; }
.card-content { padding: 15px 20px; } .card-content { padding: 15px 20px; }
.basic-card { border-left: 4px solid #409EFF; } .basic-card { border-left: 4px solid #409EFF; }
.search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; } .search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; }
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background-color: #f5f7fa; border-bottom: 1px solid #dcdfe6; border-radius: 0; padding-left: 0; }
.is-text-view :deep(.el-input__inner) { color: #606266; font-weight: 500; font-size: 13px; } /* 只读输入框样式:纯文本风格 */
.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;
}
.inbound-card { border-left: 4px solid #67C23A; } .inbound-card { border-left: 4px solid #67C23A; }
.identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; border-radius: 6px; padding: 12px; margin-bottom: 15px; } .identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; border-radius: 6px; padding: 12px; margin-bottom: 15px; }
.custom-radio-group { margin-bottom: 10px; } .custom-radio-group { margin-bottom: 10px; }
@ -1164,9 +1300,53 @@ onMounted(() => {
.divider-text::before { margin-right: 15px; } .divider-text::before { margin-right: 15px; }
.divider-text::after { margin-left: 15px; } .divider-text::after { margin-left: 15px; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 15px; padding: 15px 20px; background: #fff; border-top: 1px solid #ebeef5; } .dialog-footer { display: flex; justify-content: flex-end; gap: 15px; padding: 15px 20px; background: #fff; 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; } .option-item {
display: flex;
align-items: center;
padding: 8px 0;
width: 100%;
}
/* 名称区域:占据剩余空间,但必须有 min-width: 0 以触发 ellipsis */
.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;
}
.total-price-input :deep(.el-input__inner) { color: #F56C6C; font-weight: bold; } .total-price-input :deep(.el-input__inner) { color: #F56C6C; font-weight: bold; }
.preview-box { min-height: 150px; display: flex; justify-content: center; align-items: center; background: #f5f7fa; border-radius: 4px; } .preview-box { min-height: 150px; display: flex; justify-content: center; align-items: center; background: #f5f7fa; border-radius: 4px; }
.empty-preview { color: #909399; } .empty-preview { color: #909399; }
@ -1180,4 +1360,18 @@ onMounted(() => {
.camera-card:hover { border-color: #409EFF; color: #409EFF; } .camera-card:hover { border-color: #409EFF; color: #409EFF; }
.camera-card .text { font-size: 12px; margin-top: 5px; } .camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; } .camera-card .el-icon { font-size: 24px; }
</style>
<style>
/* 针对开启 teleport 后,挂载在 body 下的 dropdown */
.long-dropdown {
width: 580px !important; /* 固定宽度,比输入框稍宽以展示更多信息 */
}
.long-dropdown .el-select-dropdown__wrap {
max-height: 320px !important; /* 限制高度,避免遮挡整个弹窗 */
}
/* [新增] 修复清除按钮被内容遮挡的问题 (Element Plus 偶发 Bug) */
.long-dropdown .el-input__suffix {
z-index: 10;
}
</style> </style>

View File

@ -2,16 +2,28 @@
<div class="product-module"> <div class="product-module">
<div class="header-tools"> <div class="header-tools">
<div class="left-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 <el-input
v-model="queryParams.keyword" v-model="queryParams.keyword"
placeholder="🔍 搜索物料 / SN / 工单 / 订单号..." placeholder="🔍 搜索物料 / SN / 工单..."
class="search-input" class="filter-item-input"
clearable clearable
@clear="fetchData" @clear="fetchData"
@keyup.enter="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-input>
<el-select <el-select
@ -66,6 +78,11 @@
</span> </span>
</template> </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)"> <template #default="scope" v-else-if="['serial_number'].includes(col.prop)">
<div v-if="scope.row.serial_number" class="id-cell"> <div v-if="scope.row.serial_number" class="id-cell">
<span class="prefix-tag sn">SN</span> <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-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"> <div class="dialog-scroll-container">
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form"> <el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form">
<div class="form-card basic-card"> <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"> <div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;"> <el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="10"> <el-col :span="10">
<el-form-item label="物料搜索" prop="base_id"> <el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select <el-select
v-model="form.base_id" v-model="form.base_id"
filterable filterable
remote remote
reserve-keyword reserve-keyword
clearable
placeholder="搜名称/规格..." placeholder="搜名称/规格..."
:remote-method="handleSearchMaterial" :remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible" @visible-change="handleMaterialDropdownVisible"
@ -155,15 +178,28 @@
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option 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"> <el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
<div class="option-item"> <div class="option-item">
<span class="opt-name">{{ item.name }}</span> <div class="opt-main">
<span class="opt-spec">{{ item.spec }}</span> <span class="opt-name" :title="item.name">{{ item.name }}</span>
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag> </div>
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag> <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> </div>
</el-option> </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-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
@ -175,11 +211,12 @@
</el-row> </el-row>
<div class="read-only-grid"> <div class="read-only-grid">
<el-row :gutter="24"> <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.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.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.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.unit" 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" 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" disabled 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" 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" 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> </el-row>
</div> </div>
</div> </div>
@ -190,8 +227,8 @@
<div class="card-content"> <div class="card-content">
<el-row :gutter="24"> <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="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="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" /></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-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> </el-row>
@ -276,8 +313,36 @@
<div class="card-title"><el-icon class="icon"><Setting /></el-icon><span>3. 生产与销售信息</span></div> <div class="card-title"><el-icon class="icon"><Setting /></el-icon><span>3. 生产与销售信息</span></div>
<div class="card-content"> <div class="card-content">
<el-row :gutter="24"> <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-col :span="8"><el-form-item label="工单号"><el-input v-model="form.work_order_code" /></el-form-item></el-col>
</el-row> </el-row>
<el-row :gutter="24"> <el-row :gutter="24">
@ -353,24 +418,65 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue' import { ref, reactive, onMounted, watch } from 'vue'
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture } from '@element-plus/icons-vue' import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture } from '@element-plus/icons-vue'
// 修复点:引入 ElLoading
import { ElMessage, ElLoading } from 'element-plus' import { ElMessage, ElLoading } from 'element-plus'
import dayjs from 'dayjs' 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 { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import { getLabelPreview, executePrint } from '@/api/common/print' 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 loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const visible = ref(false) const visible = ref(false)
const searchLoading = ref(false) const searchLoading = ref(false)
const loadingMore = ref(false)
const dialogStatus = ref<'create' | 'update'>('create') const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([]) const tableData = ref([])
const total = ref(0) const total = ref(0)
const formRef = ref() const formRef = ref()
const queryParams = reactive({ page: 1, pageSize: 100, 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 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) const printVisible = ref(false)
@ -382,20 +488,19 @@ const currentPrintData = ref<any>({})
// 图片/拍照相关 // 图片/拍照相关
const dialogImageUrl = ref('') const dialogImageUrl = ref('')
const dialogVisibleImage = ref(false) const dialogVisibleImage = ref(false)
// 3个独立的列表
const productPhotoList = ref<any[]>([]) // 成品实拍 const productPhotoList = ref<any[]>([]) // 成品实拍
const qualityFileList = ref<any[]>([]) // 质量报告 const qualityFileList = ref<any[]>([]) // 质量报告
const inspectionFileList = ref<any[]>([]) // 检测报告 const inspectionFileList = ref<any[]>([]) // 检测报告
const cameraDialogVisible = ref(false) const cameraDialogVisible = ref(false)
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null) const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null)
// 定义当前触发拍照的字段
const currentCameraField = ref<'product_photo' | 'quality_report_link' | 'inspection_report_link'>('product_photo') const currentCameraField = ref<'product_photo' | 'quality_report_link' | 'inspection_report_link'>('product_photo')
const quality_url = ref('') const quality_url = ref('')
const inspection_url = ref('') const inspection_url = ref('')
// [核心优化] 所有列定义 // [核心优化] 所有列定义
const allColumns = [ const allColumns = [
{ prop: 'company_name', label: '所属公司', minWidth: '100' }, // [新增]
{ prop: 'material_name', label: '名称', minWidth: '140' }, { prop: 'material_name', label: '名称', minWidth: '140' },
{ prop: 'sku', label: 'SKU', minWidth: '110' }, { prop: 'sku', label: 'SKU', minWidth: '110' },
{ prop: 'serial_number', label: '序列号', minWidth: '130' }, { prop: 'serial_number', label: '序列号', minWidth: '130' },
@ -418,11 +523,13 @@ const allColumns = [
{ prop: 'detail_link', label: '详情', minWidth: '100' } { 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 defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
const visibleColumnProps = ref(defaultVisibleCols) const visibleColumnProps = ref(defaultVisibleCols)
const form = reactive({ 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: '', sku: '', barcode: '', serial_number: '', in_date: '',
in_quantity: 1, stock_quantity: 1, available_quantity: 1, in_quantity: 1, stock_quantity: 1, available_quantity: 1,
warehouse_location: '', status: '在库', quality_status: '合格', warehouse_location: '', status: '在库', quality_status: '合格',
@ -433,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) => { const validateUnique = (rule: any, value: string, callback: any) => {
if (!value) return callback() if (!value) return callback()
// 简单的列表前端查重
const isDuplicate = tableData.value.some((row: any) => { const isDuplicate = tableData.value.some((row: any) => {
if (dialogStatus.value === 'update' && row.id === form.id) return false if (dialogStatus.value === 'update' && row.id === form.id) return false
if (rule.field === 'serial_number' && row.serial_number === value) return true if (rule.field === 'serial_number' && row.serial_number === value) return true
@ -457,19 +584,53 @@ const rules = {
// ------------------------------------ // ------------------------------------
// Material Search & Population Logic // 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) => { const handleSearchMaterial = async (query: string) => {
searchLoading.value = true searchLoading.value = true
searchKeyword.value = query
searchPage.value = 1
materialOptions.value = []
try { try {
const res: any = await searchMaterialBase(query) const res: any = await searchMaterialBase(query, 1)
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false })) const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults materialOptions.value = apiResults
hasNextPage.value = res.has_next
} finally { searchLoading.value = false } } 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 onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val) const item = materialOptions.value.find(i => i.id === val)
if (item) { if (item) {
// Auto-populate readonly fields form.company_name = item.company_name // [新增]
form.material_name = item.name form.material_name = item.name
form.spec_model = item.spec form.spec_model = item.spec
form.material_type = item.type form.material_type = item.type
@ -482,11 +643,9 @@ const onMaterialSelected = (val: number) => {
// Autocomplete (Manager) - 后端驱动 // Autocomplete (Manager) - 后端驱动
// ------------------------------------ // ------------------------------------
const querySearchManager = async (query: string, cb: any) => { const querySearchManager = async (query: string, cb: any) => {
// 后续从后端获取用户建议
cb([]) cb([])
} }
const handleManagerSelect = (item: any) => { const handleManagerSelect = (item: any) => {
// 无需保存历史
} }
const fetchData = async () => { const fetchData = async () => {
@ -499,6 +658,28 @@ const fetchData = async () => {
} finally { loading.value = false } } 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 = () => { const handleCreate = () => {
dialogStatus.value = 'create' dialogStatus.value = 'create'
resetForm() resetForm()
@ -529,7 +710,11 @@ const handleUpdate = (row: any) => {
inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) })) inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const iLinks = iReports.filter(r => isExternalLink(r)) const iLinks = iReports.filter(r => isExternalLink(r))
inspection_url.value = iLinks.length > 0 ? iLinks[0] : '' 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 visible.value = true
} }
@ -563,9 +748,6 @@ const triggerCamera = (field: any) => {
cameraDialogVisible.value = true; cameraDialogVisible.value = true;
} }
// ------------------------------------------------------------------------
// 修复核心:拍照上传回调逻辑
// ------------------------------------------------------------------------
const handleCameraConfirm = async (file: File) => { const handleCameraConfirm = async (file: File) => {
if (!beforeAvatarUpload(file)) { if (!beforeAvatarUpload(file)) {
cameraDialogVisible.value = false; cameraDialogVisible.value = false;
@ -573,25 +755,18 @@ const handleCameraConfirm = async (file: File) => {
} }
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
// 使用 ElLoading.service 替代报错的 ElMessage.loading
const loadingMsg = ElLoading.service({ const loadingMsg = ElLoading.service({
lock: true, lock: true,
text: '照片处理中...', text: '照片处理中...',
background: 'rgba(0, 0, 0, 0.7)' background: 'rgba(0, 0, 0, 0.7)'
}); });
let success = false; let success = false;
try { try {
const res: any = await uploadFile(formData); const res: any = await uploadFile(formData);
if (res.code === 200) { if (res.code === 200) {
const newUrl = res.data.url; const newUrl = res.data.url;
const field = currentCameraField.value; // 根据触发时记录的字段 const field = currentCameraField.value;
// 添加到表单数据
form[field].push(newUrl); form[field].push(newUrl);
// 更新对应的显示列表
const previewItem = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) }; const previewItem = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) };
if (field === 'product_photo') { if (field === 'product_photo') {
productPhotoList.value.push(previewItem); productPhotoList.value.push(previewItem);
@ -600,7 +775,6 @@ const handleCameraConfirm = async (file: File) => {
} else if (field === 'inspection_report_link') { } else if (field === 'inspection_report_link') {
inspectionFileList.value.push(previewItem); inspectionFileList.value.push(previewItem);
} }
ElMessage.success('拍照上传成功'); ElMessage.success('拍照上传成功');
success = true; success = true;
} else { } else {
@ -639,7 +813,6 @@ const submitForm = async () => {
} else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') } } else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') }
visible.value = false; fetchData() visible.value = false; fetchData()
} catch(e:any) { } catch(e:any) {
// 捕获后端报错
ElMessage.error(e.msg || '操作失败') ElMessage.error(e.msg || '操作失败')
} finally { submitting.value = false } } finally { submitting.value = false }
} }
@ -654,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 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 = () => { 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: '' }) 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 getStatusType = (s:string) => ({'在库':'success','出库':'info','借库':'warning','损耗':'danger'}[s]||'warning')
const getQualityType = (s:string) => ({'合格':'success','不合格':'danger','待检':'info'}[s]||'info') const getQualityType = (s:string) => ({'合格':'success','不合格':'danger','待检':'info'}[s]||'info')
const formatMoney = (val:any) => isNaN(Number(val)) ? '-' : `¥ ${Number(val).toFixed(2)}` const formatMoney = (val:any) => isNaN(Number(val)) ? '-' : `¥ ${Number(val).toFixed(2)}`
onMounted(() => fetchData()) onMounted(() => {
fetchData()
fetchOptions()
})
</script> </script>
<style scoped> <style scoped>
@ -702,4 +878,31 @@ onMounted(() => fetchData())
.camera-card:hover { border-color: #409EFF; color: #409EFF; } .camera-card:hover { border-color: #409EFF; color: #409EFF; }
.camera-card .text { font-size: 12px; margin-top: 5px; } .camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; } .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>
<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

@ -1,17 +1,36 @@
<template> <template>
<div class="semi-module"> <div class="semi-module">
<div class="header-tools"> <div class="header-tools">
<div class="left-tools" style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;"> <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 <el-input
v-model="queryParams.keyword" v-model="queryParams.keyword"
placeholder="请输入名称或规格" placeholder="请输入名称或规格"
class="filter-item-input"
clearable clearable
@clear="fetchData"
@keyup.enter="fetchData" @keyup.enter="fetchData"
style="width: 240px;" style="width: 240px;"
/> >
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select <el-select
v-model="queryParams.category" v-model="queryParams.category"
placeholder="类别" placeholder="类别"
class="filter-item-select"
clearable clearable
filterable filterable
@change="fetchData" @change="fetchData"
@ -19,9 +38,11 @@
> >
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" /> <el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select> </el-select>
<el-select <el-select
v-model="queryParams.material_type" v-model="queryParams.material_type"
placeholder="类型" placeholder="类型"
class="filter-item-select"
clearable clearable
filterable filterable
@change="fetchData" @change="fetchData"
@ -29,14 +50,16 @@
> >
<el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" /> <el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" />
</el-select> </el-select>
<el-button type="primary" plain @click="fetchData">搜索</el-button>
<el-button @click="resetQuery">重置</el-button> <el-button type="primary" plain class="search-btn" @click="fetchData">搜索</el-button>
<el-button class="reset-btn" @click="resetQuery">重置</el-button>
<el-select <el-select
v-model="queryParams.statuses" v-model="queryParams.statuses"
multiple multiple
collapse-tags collapse-tags
placeholder="状态筛选" placeholder="状态筛选"
style="width: 220px;" style="width: 200px; margin-left: 10px;"
@change="fetchData" @change="fetchData"
> >
<el-option label="在库" value="在库" /> <el-option label="在库" value="在库" />
@ -46,12 +69,12 @@
</div> </div>
<div class="right-tools"> <div class="right-tools">
<el-button type="primary" :icon="Plus" @click="handleCreate" class="action-btn">半成品入库登记</el-button> <el-button type="primary" :icon="Plus" @click="handleCreate" class="add-btn">半成品入库</el-button>
<el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button> <el-button :icon="Refresh" circle @click="fetchData" class="circle-btn" />
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click"> <el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
<template #reference> <template #reference>
<el-button :icon="Setting" class="action-btn">表头</el-button> <el-button :icon="Setting" circle class="circle-btn" />
</template> </template>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector"> <el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<div class="col-group-title">基础信息</div> <div class="col-group-title">基础信息</div>
@ -95,6 +118,11 @@
</span> </span>
</template> </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'"> <template #default="scope" v-else-if="col.prop === 'sn_bn'">
<div v-if="scope.row.serial_number" class="id-cell"> <div v-if="scope.row.serial_number" class="id-cell">
<span class="prefix-tag sn">SN</span> <span class="prefix-tag sn">SN</span>
@ -195,35 +223,41 @@
top="5vh" top="5vh"
destroy-on-close destroy-on-close
:close-on-click-modal="false" :close-on-click-modal="false"
class="stylish-dialog" class="stylish-dialog compact-layout"
> >
<div class="dialog-scroll-container"> <div class="dialog-scroll-container">
<el-form :model="form" label-width="100px" ref="formRef" :rules="rules" size="default" class="stylish-form"> <el-form :model="form" label-width="100px" ref="formRef" :rules="rules" size="default" class="stylish-form">
<div class="form-card basic-card"> <div class="form-card basic-card">
<div class="card-title"> <div class="card-title">
<el-icon class="icon"><Box/></el-icon> <div style="display: flex; align-items: center;">
<span>1. 基础信息</span> <el-icon class="icon"><Box/></el-icon>
<span>1. 基础信息</span>
</div>
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索选择半成品物料)</span> <span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索选择半成品物料)</span>
</div> </div>
<div class="card-content"> <div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;"> <el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="10"> <el-col :span="12">
<el-form-item label="物料搜索" prop="base_id"> <el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select <el-select
v-model="form.base_id" v-model="form.base_id"
filterable filterable
remote remote
reserve-keyword reserve-keyword
placeholder="输入名称或规格..." clearable
placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial" :remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible" @visible-change="handleMaterialDropdownVisible"
:loading="searchLoading" :loading="searchLoading"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option default-first-option
v-loadmore="loadMoreMaterials"
popper-class="long-dropdown"
> >
<template #prefix><el-icon><Search /></el-icon></template>
<el-option <el-option
v-for="item in materialOptions" v-for="item in materialOptions"
:key="item.id" :key="item.id"
@ -231,29 +265,40 @@
:value="item.id" :value="item.id"
> >
<div class="option-item"> <div class="option-item">
<span class="opt-name">{{ item.name }}</span> <div class="opt-main">
<span class="opt-spec">{{ item.spec }}</span> <span class="opt-name" :title="item.name">{{ item.name }}</span>
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag> </div>
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag> <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> </div>
</el-option> </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-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="14" style="display: flex; align-items: center;"> <el-col :span="12" style="display: flex; align-items: center;">
<span class="search-tip"> <span class="search-tip">
<el-icon><InfoFilled/></el-icon> 未输入时展示最新物料;输入关键词进行精确搜索 <el-icon><InfoFilled/></el-icon> 支持名称、规格型号、公司名称模糊搜索
</span> </span>
</el-col> </el-col>
</el-row> </el-row>
<div class="read-only-grid"> <div class="read-only-grid">
<el-row :gutter="24"> <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.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.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.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.unit" 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" readonly 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.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" disabled 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> </el-row>
</div> </div>
</div> </div>
@ -269,8 +314,8 @@
<el-row :gutter="24"> <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="编码/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="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="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"/></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> </el-row>
<div class="identity-panel"> <div class="identity-panel">
@ -366,15 +411,45 @@
<div class="form-card production-card"> <div class="form-card production-card">
<div class="card-title"> <div class="card-title">
<el-icon class="icon"><Setting/></el-icon> <div style="display: flex; align-items: center;">
<span>3. 生产与成本信息</span> <el-icon class="icon"><Setting/></el-icon>
<span>3. 生产与成本信息</span>
</div>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="divider-text">生产任务信息</div> <div class="divider-text">生产任务信息</div>
<el-row :gutter="24"> <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="工单号"><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-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="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>
<el-row :gutter="24"> <el-row :gutter="24">
@ -412,7 +487,7 @@
<div class="dialog-footer"> <div class="dialog-footer">
<el-button @click="visible = false" size="large">取消</el-button> <el-button @click="visible = false" size="large">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitForm" size="large" class="confirm-btn"> <el-button type="primary" :loading="submitting" @click="submitForm" size="large" class="confirm-btn">
{{ dialogStatus === 'create' ? '确认入库并打印' : '保存修改' }} {{ dialogStatus === 'create' ? '提交并打印' : '保存修改' }}
</el-button> </el-button>
</div> </div>
</template> </template>
@ -452,12 +527,35 @@ import {
updateSemiInbound, updateSemiInbound,
deleteSemiInbound, deleteSemiInbound,
searchMaterialBase, searchMaterialBase,
searchBom,
getFilterOptions getFilterOptions
} from '@/api/inbound/semi' } from '@/api/inbound/semi'
import { uploadFile, deleteFile } from '@/api/inbound/buy' import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import {getLabelPreview, executePrint} from '@/api/common/print' 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))
}
}
// ------------------------------------ // ------------------------------------
// 状态与变量 // 状态与变量
// ------------------------------------ // ------------------------------------
@ -465,14 +563,24 @@ const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const visible = ref(false) const visible = ref(false)
const searchLoading = ref(false) const searchLoading = ref(false)
const loadingMore = ref(false)
const dialogStatus = ref<'create' | 'update'>('create') const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([]) const tableData = ref([])
const total = ref(0) const total = ref(0)
const formRef = ref() const formRef = ref()
const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', category: '', material_type: '', statuses: ['在库', '借库'] }) const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '' })
const categoryOptions = ref<string[]>([]) const categoryOptions = ref<string[]>([])
const typeOptions = ref<string[]>([]) const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([]) // [新增]
const materialOptions = ref<any[]>([]) 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) const printVisible = ref(false)
@ -496,6 +604,7 @@ const modeLocked = ref(false)
// 列定义 // 列定义
const baseColumns = [ const baseColumns = [
{prop: 'company_name', label: '所属公司'}, // [新增]
{prop: 'material_name', label: '名称'}, {prop: 'material_name', label: '名称'},
{prop: 'category', label: '类别'}, {prop: 'category', label: '类别'},
{prop: 'material_type', label: '类型'}, {prop: 'material_type', label: '类型'},
@ -531,50 +640,101 @@ const stockColumns = [
] ]
const allColumns = [...baseColumns, ...stockColumns] const allColumns = [...baseColumns, ...stockColumns]
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 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 visibleColumnProps = ref(defaultColumns)
const form = reactive({ const form = reactive({
id: undefined, base_id: undefined as number | undefined, material_name: '', spec_model: '', category: '', unit: '', material_type: '', id: undefined, base_id: undefined as number | undefined,
sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', company_name: '', // [新增]
in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', 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_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 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) => {
// 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
}
// ------------------------------------ // ------------------------------------
// Autocomplete & Search Logic (后端 API 驱动) // Autocomplete & Search Logic (后端 API 驱动)
// ------------------------------------ // ------------------------------------
const querySearchManager = async (query: string, cb: any) => { const querySearchManager = async (query: string, cb: any) => {
// 后续会从后端获取用户建议,暂时先返回空列表
cb([]) cb([])
} }
const handleManagerSelect = (item: any) => { const handleManagerSelect = (item: any) => {
// 无需保存历史
} }
// ------------------------------------ // ------------------------------------
// Material Search (Matches Buy.vue) // 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) => { const handleSearchMaterial = async (query: string) => {
searchLoading.value = true searchLoading.value = true
searchKeyword.value = query
searchPage.value = 1
materialOptions.value = []
try { try {
const res: any = await searchMaterialBase(query) const res: any = await searchMaterialBase(query, 1)
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false})) const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
materialOptions.value = apiResults materialOptions.value = apiResults
hasNextPage.value = res.has_next
} finally { searchLoading.value = false } } 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 onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val) const item = materialOptions.value.find(i => i.id === val)
if (item) { if (item) {
// Populate form fields form.company_name = item.company_name // [新增]
form.material_name = item.name form.material_name = item.name
form.spec_model = item.spec form.spec_model = item.spec
form.category = item.category form.category = item.category
form.unit = item.unit form.unit = item.unit
form.material_type = item.type form.material_type = item.type
// Trigger batch/serial logic specific to Semi
checkHistoryAndSetMode(item.id) checkHistoryAndSetMode(item.id)
} }
} }
@ -587,7 +747,6 @@ const validateUnique = (rule: any, value: string, callback: any) => {
const isDuplicate = tableData.value.some((row: any) => { const isDuplicate = tableData.value.some((row: any) => {
if (dialogStatus.value === 'update' && row.id === form.id) return false if (dialogStatus.value === 'update' && row.id === form.id) return false
if (rule.field === 'serial_number' && row.serial_number === value) return true 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 if (rule.field === 'batch_number' && row.batch_number === value && row.base_id === form.base_id) return true
return false return false
}) })
@ -649,6 +808,7 @@ const fetchOptions = async () => {
if (res.code === 200) { if (res.code === 200) {
categoryOptions.value = res.data.categories categoryOptions.value = res.data.categories
typeOptions.value = res.data.types typeOptions.value = res.data.types
companyOptions.value = res.data.companies // [新增]
} }
} catch (e) { } catch (e) {
console.error('Fetch options failed', e) console.error('Fetch options failed', e)
@ -659,6 +819,7 @@ const resetQuery = () => {
queryParams.keyword = '' queryParams.keyword = ''
queryParams.category = '' queryParams.category = ''
queryParams.material_type = '' queryParams.material_type = ''
queryParams.company = ''
queryParams.page = 1 queryParams.page = 1
fetchData() fetchData()
} }
@ -679,7 +840,9 @@ const handleUpdate = (row: any) => {
resetForm() resetForm()
modeLocked.value = true modeLocked.value = true
Object.assign(form, { 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, 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, 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), in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available),
@ -698,7 +861,11 @@ const handleUpdate = (row: any) => {
quality_report_url.value = reportLinks.length > 0 ? reportLinks[0] : '' 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 = '' } 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 = '' } 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 visible.value = true
} }
@ -741,10 +908,7 @@ const handleCameraConfirm = async (file: File) => {
} }
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
// 修复点:使用 ElLoading
const loadingMsg = ElLoading.service({ text: '照片上传中...', background: 'rgba(0, 0, 0, 0.7)' }); const loadingMsg = ElLoading.service({ text: '照片上传中...', background: 'rgba(0, 0, 0, 0.7)' });
let success = false; let success = false;
try { try {
const res: any = await uploadFile(formData); const res: any = await uploadFile(formData);
@ -796,7 +960,6 @@ const submitForm = async () => {
} else { await updateSemiInbound(form.id!, payload); ElMessage.success('更新成功') } } else { await updateSemiInbound(form.id!, payload); ElMessage.success('更新成功') }
await fetchData(); visible.value = false await fetchData(); visible.value = false
} catch (e: any) { } catch (e: any) {
// 捕获后端报错
ElMessage.error(e.msg || '操作失败') ElMessage.error(e.msg || '操作失败')
} finally { submitting.value = false } } finally { submitting.value = false }
} }
@ -811,8 +974,11 @@ 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 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 = () => { const resetForm = () => {
materialOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = '' materialOptions.value = []; bomOptions.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: '' }) 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 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 getQualityType = (status: string) => { const map: any = { '合格': 'success', '不合格': 'danger', '待检': 'info', '返修中': 'warning' }; return map[status] || 'info' }
@ -839,7 +1005,10 @@ onMounted(() => {
.avail-num { font-weight: bold; color: #67C23A; font-size: 15px; } .avail-num { font-weight: bold; color: #67C23A; font-size: 15px; }
.id-cell { display: flex; align-items: center; } .id-cell { display: flex; align-items: center; }
.id-text { font-family: monospace; color: #606266; } .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 { 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 .icon { margin-right: 8px; font-size: 18px; color: #409EFF; }
.card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; } .card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; }
@ -858,13 +1027,19 @@ onMounted(() => {
.divider-text::before { margin-right: 15px; } .divider-text::before { margin-right: 15px; }
.divider-text::after { margin-left: 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; } .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; } .filter-item-select { /* 宽度已在行内样式控制 */ }
.opt-spec { color: #8492a6; font-size: 13px; margin-right: 10px; } .filter-item-input { /* 宽度已在行内样式控制 */ }
.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; } .search-btn { background-color: #E6F1FC; border-color: #A3D0FD; color: #409EFF; }
.is-text-view :deep(.el-input__inner) { color: #606266; font-weight: 500; } .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; } .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; } .upload-container { display: flex; flex-wrap: wrap; gap: 8px; }
:deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; } :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; } :deep(.el-upload-list--picture-card .el-upload-list__item) { width: 100px; height: 100px; }
@ -872,4 +1047,19 @@ onMounted(() => {
.camera-card:hover { border-color: #409EFF; color: #409EFF; } .camera-card:hover { border-color: #409EFF; color: #409EFF; }
.camera-card .text { font-size: 12px; margin-top: 5px; } .camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; } .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>
<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

@ -16,7 +16,7 @@
border border
style="width: 100%" 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"> <el-table-column prop="department" label="所属部门" width="150">
<template #default="scope"> <template #default="scope">
@ -32,27 +32,10 @@
<el-table-column prop="email" label="邮箱" min-width="200" /> <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"> <el-table-column label="操作" width="180" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button <el-button link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
link <el-popconfirm title="确定要删除该用户吗?" @confirm="handleDelete(scope.row)">
type="primary"
size="small"
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-popconfirm
title="确定要删除该用户吗?此操作无法撤销。"
@confirm="handleDelete(scope.row)"
>
<template #reference> <template #reference>
<el-button link type="danger" size="small">删除</el-button> <el-button link type="danger" size="small">删除</el-button>
</template> </template>
@ -74,18 +57,28 @@
:rules="rules" :rules="rules"
label-width="100px" label-width="100px"
> >
<el-form-item label="用户名" prop="username"> <el-form-item label="真实姓名" prop="cn_name">
<el-input <el-input
v-model="form.username" v-model="form.cn_name"
placeholder="登录账号 (英文)" placeholder="请输入中文姓名 (如: 张三)"
:disabled="isEdit" :disabled="isEdit"
@input="handleNameInput"
/> />
</el-form-item> </el-form-item>
<el-form-item <el-form-item label="登录账号" prop="username">
label="密码" <el-input
prop="password" 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 <el-input
v-model="form.password" v-model="form.password"
type="password" type="password"
@ -103,24 +96,18 @@
allow-create allow-create
default-first-option default-first-option
> >
<el-option <el-option v-for="item in departmentOptions" :key="item" :label="item" :value="item" />
v-for="item in departmentOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="系统角色" prop="role"> <el-form-item label="系统角色" prop="role">
<el-select v-model="form.role" placeholder="授予权限" style="width: 100%"> <el-select v-model="form.role" placeholder="授予权限" style="width: 100%">
<el-option label="主管" value="supervisor" /> <el-option
<el-option label="财务" value="finance" /> v-for="option in roleOptions"
<el-option label="库管" value="warehouse_manager" /> :key="option.value"
<el-option label="入库员" value="inbound" /> :label="option.label"
<el-option label="出库员" value="outbound" /> :value="option.value"
<el-option label="采购员" value="purchaser" /> />
<el-option label="销售" value="sales" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@ -144,9 +131,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, onMounted, computed } from 'vue' import { reactive, ref, onMounted, computed } from 'vue'
import { createUser, updateUser, getUserList, deleteUser } from '@/api/auth' import { createUser, updateUser, getUserList, deleteUser } from '@/api/auth'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { pinyin } from 'pinyin-pro' // ★ 务必安装: npm install pinyin-pro
// --- 状态定义 --- const userStore = useUserStore()
const tableLoading = ref(false) const tableLoading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
const dialogVisible = ref(false) const dialogVisible = ref(false)
@ -154,32 +143,77 @@ const tableData = ref<any[]>([])
const departmentOptions = ref<string[]>([]) const departmentOptions = ref<string[]>([])
const formRef = ref() const formRef = ref()
// [新增] 区分编辑/创建模式
const isEdit = ref(false) const isEdit = ref(false)
const currentId = ref<number | null>(null) const currentId = ref<number | null>(null)
const form = reactive({ const form = reactive({
username: '', cn_name: '', // 真实姓名 (前端输入)
username: '', // 登录账号 (拼音)
password: '', password: '',
department: '', department: '',
role: '', role: '',
email: '' 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 rules = computed(() => {
const commonRules = { const commonRules: any = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], cn_name: [{ required: true, message: '请输入真实姓名', trigger: 'blur' }],
username: [{ required: true, message: '账号不能为空', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }], role: [{ required: true, message: '请选择角色', trigger: 'change' }],
department: [{ required: true, message: '请输入或选择部门', trigger: ['blur', 'change'] }], department: [{ required: true, message: '请输入或选择部门', trigger: ['blur', 'change'] }],
// [新增] 邮箱必填校验规则
email: [ email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' }, { required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: ['blur', 'change'] } { type: 'email', message: '请输入正确的邮箱格式', trigger: ['blur', 'change'] }
] ]
} }
// 如果是创建模式,密码必填
if (!isEdit.value) { if (!isEdit.value) {
return { return {
...commonRules, ...commonRules,
@ -189,7 +223,6 @@ const rules = computed(() => {
] ]
} }
} else { } else {
// 如果是编辑模式,密码选填(只有输入了才校验长度)
return { return {
...commonRules, ...commonRules,
password: [ password: [
@ -226,51 +259,65 @@ const extractDepartments = (data: any[]) => {
departmentOptions.value = Array.from(deptSet) departmentOptions.value = Array.from(deptSet)
} }
// 2. 打开新增弹窗
const handleCreate = () => { const handleCreate = () => {
isEdit.value = false isEdit.value = false
currentId.value = null 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 dialogVisible.value = true
} }
// [新增] 打开编辑弹窗
const handleEdit = (row: any) => { const handleEdit = (row: any) => {
isEdit.value = true isEdit.value = true
currentId.value = row.id 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.department = row.department
form.role = row.role form.role = row.role
form.email = row.email || '' form.email = row.email || ''
form.password = '' // 编辑时不回显密码,留空表示不修改 form.password = ''
if (formRef.value) formRef.value.clearValidate()
dialogVisible.value = true dialogVisible.value = true
} }
// 3. 提交 (兼容创建和修改)
const onSubmit = async () => { const onSubmit = async () => {
if (!formRef.value) return if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => { await formRef.value.validate(async (valid: boolean) => {
if (valid) { if (valid) {
submitLoading.value = true submitLoading.value = true
try { try {
if (isEdit.value && currentId.value) { if (isEdit.value && currentId.value) {
// 编辑逻辑
await updateUser(currentId.value, form) await updateUser(currentId.value, form)
ElMessage.success(`用户 ${form.username} 修改成功!`) ElMessage.success(`用户 ${form.cn_name} 修改成功!`)
} else { } else {
// 创建逻辑 // 创建时,后端会自动处理重复逻辑
await createUser(form) const res: any = await createUser(form)
ElMessage.success(`用户 ${form.username} 创建成功!`) // 这里的 res.data.account_id 会返回最终生成的账号 (如 zhangsan1)
ElMessage.success(`创建成功!登录账号为: ${res.data.account_id} (已自动防重)`)
} }
dialogVisible.value = false dialogVisible.value = false
getList() getList()
} catch (error) { } catch (error) {
// 错误已处理 // request 拦截器会处理错误
} finally { } finally {
submitLoading.value = false submitLoading.value = false
} }
@ -278,14 +325,10 @@ const onSubmit = async () => {
}) })
} }
// 4. 重置表单
const resetForm = () => { const resetForm = () => {
if (!formRef.value) return if (!formRef.value) return
formRef.value.resetFields() formRef.value.resetFields()
resetFormState() form.cn_name = ''
}
const resetFormState = () => {
form.username = '' form.username = ''
form.password = '' form.password = ''
form.department = '' form.department = ''
@ -293,39 +336,30 @@ const resetFormState = () => {
form.email = '' form.email = ''
} }
// 5. 删除用户
const handleDelete = async (row: any) => { const handleDelete = async (row: any) => {
try { try {
await deleteUser(row.id) await deleteUser(row.id)
ElMessage.success('删除成功') ElMessage.success('删除成功')
getList() getList()
} catch (error) { } catch (error) {
// 错误处理
} }
} }
// --- 辅助显示方法 ---
const formatDate = (dateStr: string) => {
if (!dateStr) return '-'
return dateStr.replace('T', ' ').substring(0, 19)
}
const formatRole = (val: string) => { const formatRole = (val: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
'supervisor': '主管', 'SUPER_ADMIN': '超级管理员',
'finance': '财务', 'SUPERVISOR': '主管',
'warehouse_manager': '库管', 'FINANCE': '财务',
'inbound': '入库员', 'WAREHOUSE_MGR': '库管',
'outbound': '库员', 'INBOUND': '库员',
'purchaser': '采购员', 'OUTBOUND': '出库员',
'sales': '销售', 'PURCHASER': '采购员',
'super_admin': '超级管理员' 'SALES': '销售'
} }
return map[val] || val const key = val ? val.toUpperCase() : ''
return map[key] || val
} }
// --- 初始化 ---
onMounted(() => { onMounted(() => {
getList() getList()
}) })