对于出库选单和Bom库的逻辑完善以及功能美化

This commit is contained in:
dxc
2026-02-12 16:54:26 +08:00
parent d61668bc4b
commit b682d4b02f
5 changed files with 402 additions and 477 deletions

View File

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

View File

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

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