Compare commits

2 Commits

13 changed files with 694 additions and 669 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

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

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

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

@ -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,10 +31,7 @@ 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:
query = query.filter( query = query.filter(
or_( or_(
@ -51,11 +39,7 @@ class ProductInboundService:
MaterialBase.spec_model.ilike(f'%{keyword}%') MaterialBase.spec_model.ilike(f'%{keyword}%')
) )
) )
# 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({
@ -73,33 +57,69 @@ class ProductInboundService:
return [] return []
# ============================================================ # ============================================================
# 2. 新增入库逻辑 (强制北京时间 + 唯一性校验) # 1.5 [新增] BOM 搜索逻辑
# ============================================================
@staticmethod
def search_bom_options(keyword):
from app.models.bom import BomTable
try:
# 关联查询BOM表 + 父件基础信息表
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)
# 只查询启用的BOM
if hasattr(BomTable, 'is_enabled'):
query = query.filter(BomTable.is_enabled == True)
if keyword:
kw = f'%{keyword}%'
# 支持搜索BOM编号、父件名称、父件规格
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 +133,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 +150,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 +158,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 +195,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 +217,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 +271,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
@ -284,7 +287,6 @@ class ProductInboundService:
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:
query = query.filter(or_( query = query.filter(or_(
MaterialBase.name.ilike(f'%{keyword}%'), MaterialBase.name.ilike(f'%{keyword}%'),
@ -294,17 +296,12 @@ class ProductInboundService:
StockProduct.order_id.ilike(f'%{keyword}%'), StockProduct.order_id.ilike(f'%{keyword}%'),
StockProduct.sku.ilike(f'%{keyword}%') StockProduct.sku.ilike(f'%{keyword}%')
)) ))
# 类别筛选
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 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 +311,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,10 +324,7 @@ class ProductInboundService:
items = [] items = []
for item in current_items: for item in current_items:
# [注意] 因为Model层已经修改了 to_dict 内部的 material -> base所以这里直接调 to_dict 即可
d = item.to_dict() d = item.to_dict()
# 格式化日期
date_display = '' date_display = ''
if item.production_date: if item.production_date:
try: try:
@ -341,30 +332,22 @@ class ProductInboundService:
except: except:
date_display = str(item.production_date)[:10] date_display = str(item.production_date)[:10]
d['inbound_date'] = date_display d['inbound_date'] = date_display
d['qty_stock'] = float(item.stock_quantity or 0) d['qty_stock'] = float(item.stock_quantity or 0)
d['qty_available'] = float(item.available_quantity or 0) d['qty_available'] = float(item.available_quantity or 0)
d['sum_stock'] = d['qty_stock'] d['sum_stock'] = d['qty_stock']
d['sum_available'] = d['qty_available'] d['sum_available'] = d['qty_available']
d['product_photo'] = parse_img(item.product_photo) d['product_photo'] = parse_img(item.product_photo)
d['quality_report_link'] = parse_img(item.quality_report_link) d['quality_report_link'] = parse_img(item.quality_report_link)
d['inspection_report_link'] = parse_img(item.inspection_report_link) d['inspection_report_link'] = parse_img(item.inspection_report_link)
d['global_print_id'] = item.global_print_id d['global_print_id'] = item.global_print_id
items.append(d) 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')
@ -385,9 +368,6 @@ class ProductInboundService:
except Exception: except Exception:
return [] return []
# ============================================================
# 8. 获取筛选选项(类别、类型)
# ============================================================
@staticmethod @staticmethod
def get_filter_options(): def get_filter_options():
try: try:
@ -405,4 +385,4 @@ class ProductInboundService:
except Exception: except Exception:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return {"categories": [], "types": []} return {"categories": [], "types": []}

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,10 +41,7 @@ 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:
query = query.filter( query = query.filter(
or_( or_(
@ -65,19 +49,16 @@ class SemiInboundService:
MaterialBase.spec_model.ilike(f'%{keyword}%') MaterialBase.spec_model.ilike(f'%{keyword}%')
) )
) )
# 统一逻辑按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,
'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 +66,74 @@ 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:
# 关联查询BOM表 + 父件基础信息表
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)
# 只查询启用的BOM
if hasattr(BomTable, 'is_enabled'):
query = query.filter(BomTable.is_enabled == True)
if keyword:
kw = f'%{keyword}%'
# 支持搜索BOM编号、父件名称、父件规格
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 +146,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 +166,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 +185,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 +238,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 +269,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 +277,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 +291,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 +300,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 +309,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 +317,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 +355,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
@ -382,7 +371,6 @@ class SemiInboundService:
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(
@ -396,17 +384,12 @@ 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 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 +399,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,10 +412,7 @@ class SemiInboundService:
items = [] items = []
for item in current_items: for item in current_items:
# [注意] 因为Model层已经修改了 to_dict 内部的 material -> base所以这里直接调 to_dict 即可
d = item.to_dict() d = item.to_dict()
# 格式化展示日期
date_display = '' date_display = ''
if item.production_date: if item.production_date:
try: try:
@ -443,30 +420,22 @@ class SemiInboundService:
except: except:
date_display = str(item.production_date)[:10] date_display = str(item.production_date)[:10]
d['inbound_date'] = date_display d['inbound_date'] = date_display
d['qty_stock'] = float(item.stock_quantity or 0) d['qty_stock'] = float(item.stock_quantity or 0)
d['qty_available'] = float(item.available_quantity or 0) d['qty_available'] = float(item.available_quantity or 0)
d['sum_stock'] = d['qty_stock'] d['sum_stock'] = d['qty_stock']
d['sum_available'] = d['qty_available'] d['sum_available'] = d['qty_available']
d['arrival_photo'] = parse_img(item.arrival_photo) d['arrival_photo'] = parse_img(item.arrival_photo)
d['quality_report_link'] = parse_img(item.quality_report_link) d['quality_report_link'] = parse_img(item.quality_report_link)
d['global_print_id'] = item.global_print_id d['global_print_id'] = item.global_print_id
items.append(d) 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')
@ -487,9 +456,6 @@ class SemiInboundService:
except Exception: except Exception:
return [] return []
# ============================================================
# 8. 获取筛选选项(类别、类型)
# ============================================================
@staticmethod @staticmethod
def get_filter_options(): def get_filter_options():
try: try:
@ -507,4 +473,4 @@ class SemiInboundService:
except Exception: except Exception:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return {"categories": [], "types": []} return {"categories": [], "types": []}

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

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

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

@ -276,8 +276,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,10 +381,16 @@
<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 // [新增]
} 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'
@ -372,6 +406,10 @@ const formRef = ref()
const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', statuses: ['在库', '借库'] }) const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', statuses: ['在库', '借库'] })
const materialOptions = ref<any[]>([]) const materialOptions = ref<any[]>([])
// BOM 搜索相关
const bomSearchLoading = ref(false)
const bomOptions = ref<any[]>([])
// 打印相关变量 // 打印相关变量
const printVisible = ref(false) const printVisible = ref(false)
const printLoading = ref(false) const printLoading = ref(false)
@ -382,14 +420,12 @@ 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('')
@ -433,11 +469,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
@ -469,7 +525,6 @@ const handleSearchMaterial = async (query: string) => {
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.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 +537,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 () => {
@ -530,6 +583,10 @@ const handleUpdate = (row: any) => {
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, 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 +620,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 +627,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 +647,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 +685,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,7 +699,7 @@ 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')
@ -702,4 +747,4 @@ 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; }
</style> </style>

View File

@ -373,8 +373,36 @@
<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"/></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">
@ -452,6 +480,7 @@ 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'
@ -474,6 +503,10 @@ const categoryOptions = ref<string[]>([])
const typeOptions = ref<string[]>([]) const typeOptions = ref<string[]>([])
const materialOptions = ref<any[]>([]) const materialOptions = ref<any[]>([])
// BOM 搜索相关
const bomSearchLoading = ref(false)
const bomOptions = ref<any[]>([])
// 打印相关变量 // 打印相关变量
const printVisible = ref(false) const printVisible = ref(false)
const printLoading = ref(false) const printLoading = ref(false)
@ -542,15 +575,35 @@ const form = reactive({
production_manager: '', production_time_range: [] as string[], arrival_photo: [] as string[], quality_report_link: [] as string[], detail_link: '' 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) => {
// 无需保存历史
} }
// ------------------------------------ // ------------------------------------
@ -568,13 +621,11 @@ const handleSearchMaterial = async (query: string) => {
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.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 +638,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
}) })
@ -699,6 +749,10 @@ const handleUpdate = (row: any) => {
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, 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 +795,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 +847,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,7 +861,7 @@ 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, 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' }
@ -872,4 +922,4 @@ 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>