Compare commits

13 Commits

Author SHA1 Message Date
dxc
d61668bc4b 修改bom表逻辑和出库选单内容 2026-02-12 10:39:21 +08:00
dxc
b93a565c82 feat: add material spec to BOM responses and UI
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 10:10:37 +08:00
dxc
6e5df70ee6 fix: add typeLabel for display in outbound manual selection dialog
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 10:05:16 +08:00
dxc
fb536dad7f feat: add endpoint to delete BOM by bom_no
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 10:02:29 +08:00
dxc
d479b750d7 feat: add quantity input for manual stock selection
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 10:00:17 +08:00
dxc
05a108e96d refactor: replace outbound selection with shopping cart model
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 09:57:12 +08:00
dxc
ec7f20869a feat: refactor outbound selection to cart mode and update BomManage route
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 09:53:17 +08:00
dxc
fcebe70848 feat: add BOM management page
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 09:47:22 +08:00
dxc
bb8c07a465 feat: add BOM management view 2026-02-12 09:46:58 +08:00
dxc
5245ee2da3 feat: add BOM API and routing
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 09:43:51 +08:00
dxc
04dd6fb3fa feat: add bom api client 2026-02-12 09:43:12 +08:00
dxc
32f031b047 feat: add non-null and unique constraints to BOM model
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 09:41:40 +08:00
dxc
6d5d8a6aad feat: implement BOM versioning with bom_no and new management APIs
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-12 09:37:27 +08:00
11 changed files with 1230 additions and 453 deletions

View File

@ -8,6 +8,115 @@ from sqlalchemy import distinct
bom_bp = Blueprint('bom', __name__) bom_bp = Blueprint('bom', __name__)
# ==================== 新版 BOM 接口(基于 bom_no ====================
@bom_bp.route('/list', methods=['GET'])
@jwt_required()
def get_bom_list():
"""获取所有 BOM 配方列表(按 bom_no 分组),支持 keyword 搜索"""
try:
keyword = request.args.get('keyword', '').strip()
data = BomService.get_bom_list(keyword=keyword)
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'获取BOM列表失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/detail/<bom_no>', methods=['GET'])
@jwt_required()
def get_bom_detail(bom_no):
"""根据 BOM 编号获取配方详情"""
try:
data = BomService.get_bom_detail(bom_no)
if not data:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'获取BOM详情失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/save', methods=['POST'])
@jwt_required()
def save_bom():
"""保存或更新 BOM 配方(支持自定义 bom_no"""
try:
req_data = request.get_json()
# 必需字段校验
if 'parent_id' not in req_data or 'children' not in req_data:
return jsonify({'code': 400, 'msg': '缺少 parent_id 或 children 字段'}), 400
# 校验 bom_no 不能为空(如果前端要求必须填)
if 'bom_no' in req_data and not req_data['bom_no']:
return jsonify({'code': 400, 'msg': 'BOM编号不能为空'}), 400
bom_no = BomService.save_bom(req_data)
return jsonify({
'code': 200,
'msg': '保存成功',
'data': {'bom_no': bom_no}
})
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
current_app.logger.error(f'保存BOM失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/stock/<bom_no>', methods=['GET'])
@jwt_required()
def get_bom_with_stock_by_no(bom_no):
"""根据 BOM 编号获取配方详情及库存信息"""
try:
data = BomService.get_bom_with_stock_by_bom_no(bom_no)
if not data:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'获取BOM库存信息失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
# ==================== 删除BOM接口根据bom_no删除整个配方 ====================
@bom_bp.route('/<bom_no>', methods=['DELETE'])
@jwt_required()
def delete_bom(bom_no):
"""根据 BOM 编号删除整个配方(包括所有子件记录)"""
try:
# 先检查是否存在
exist = BomTable.query.filter_by(bom_no=bom_no).first()
if not exist:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 删除该 bom_no 下所有记录
BomTable.query.filter_by(bom_no=bom_no).delete()
db.session.commit()
return jsonify({
'code': 200,
'msg': '删除成功'
})
except Exception as e:
current_app.logger.error(f'删除BOM失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
# ==================== 兼容旧接口(保留不改动现有前端) ====================
@bom_bp.route('/<int:parent_id>', methods=['GET']) @bom_bp.route('/<int:parent_id>', methods=['GET'])
@jwt_required() @jwt_required()
def get_bom(parent_id): def get_bom(parent_id):
@ -22,9 +131,10 @@ def get_bom(parent_id):
current_app.logger.error(f'获取BOM失败: {str(e)}') current_app.logger.error(f'获取BOM失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('', methods=['POST']) @bom_bp.route('', methods=['POST'])
@jwt_required() @jwt_required()
def save_bom(): def save_bom_legacy():
try: try:
req_data = request.get_json() req_data = request.get_json()
parent_id = req_data.get('parent_id') parent_id = req_data.get('parent_id')
@ -42,6 +152,7 @@ def save_bom():
current_app.logger.error(f'保存BOM失败: {str(e)}') current_app.logger.error(f'保存BOM失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/base/list', methods=['GET']) @bom_bp.route('/base/list', methods=['GET'])
@jwt_required() @jwt_required()
def get_material_base_list(): def get_material_base_list():
@ -58,10 +169,11 @@ def get_material_base_list():
current_app.logger.error(f'获取基础物料列表失败: {str(e)}') current_app.logger.error(f'获取基础物料列表失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/parents', methods=['GET']) @bom_bp.route('/parents', methods=['GET'])
@jwt_required() @jwt_required()
def get_bom_parents(): def get_bom_parents():
"""获取所有已定义BOM的父件物料列表""" """获取所有已定义BOM的父件物料列表(兼容旧版)"""
try: try:
subq = db.session.query(BomTable.parent_id).distinct().subquery() subq = db.session.query(BomTable.parent_id).distinct().subquery()
parents = MaterialBase.query.join(subq, MaterialBase.id == subq.c.parent_id).all() parents = MaterialBase.query.join(subq, MaterialBase.id == subq.c.parent_id).all()

View File

@ -6,12 +6,16 @@ class BomTable(db.Model):
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), comment='BOM编号') bom_no = db.Column(db.String(100), nullable=False, comment='BOM编号')
version = db.Column(db.String(50), comment='版本') version = db.Column(db.String(50), nullable=False, default='v1', 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='备注')
__table_args__ = (
db.UniqueConstraint('bom_no', 'parent_id', 'child_id', name='uniq_bom_no_parent_child'),
)
# 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,66 +2,272 @@ 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 from sqlalchemy import func, distinct, or_
import uuid
from datetime import datetime
class BomService: class BomService:
# ====================== 新版 BOM 逻辑(基于 bom_no ======================
@staticmethod @staticmethod
def create_or_update_bom(parent_id, child_list): def generate_bom_no():
"""生成唯一的 BOM 编号 (作为默认备选)"""
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
unique = str(uuid.uuid4())[:8]
return f'BOM-{timestamp}-{unique}'
@staticmethod
def get_bom_list(keyword=None):
""" """
保存/更新父件的BOM子件关系 获取所有 BOM 配方(按 bom_no 分组)
child_list: [{"child_id": int, "dosage": float, "remark": str}, ...] 支持模糊搜索BOM编号、父件名称/规格、子件名称/规格
"""
# 1. 如果有搜索关键词,先筛选出符合条件的 bom_no 集合
filtered_bom_nos = None
if keyword:
kw = f'%{keyword}%'
# 条件A: 匹配 BOM编号 或 父件信息
# 需要 join 父件表
q1 = db.session.query(BomTable.bom_no).join(
MaterialBase, BomTable.parent_id == MaterialBase.id
).filter(
or_(
BomTable.bom_no.ilike(kw),
MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw)
)
)
# 条件B: 匹配 子件信息
# 需要 join 子件表
q2 = db.session.query(BomTable.bom_no).join(
MaterialBase, BomTable.child_id == MaterialBase.id
).filter(
or_(
MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw)
)
)
# 取并集 (Union)
filtered_bom_nos = q1.union(q2).distinct().all()
filtered_bom_nos = [row[0] for row in filtered_bom_nos]
# 如果搜不到任何结果,直接返回空
if not filtered_bom_nos:
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
def get_bom_detail(bom_no):
"""
根据 bom_no 获取配方详情
返回包含父件信息和子件列表的对象
"""
rows = db.session.query(
BomTable,
MaterialBase.name.label('child_name'),
MaterialBase.spec_model.label('child_spec')
).join(
MaterialBase, BomTable.child_id == MaterialBase.id
).filter(
BomTable.bom_no == bom_no
).all()
if not rows:
return None
first = rows[0]
parent_id = first.BomTable.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 = []
for bom, child_name, child_spec in rows:
children.append({
'child_id': bom.child_id,
'child_name': child_name,
'child_spec': child_spec or '',
'dosage': float(bom.dosage) if bom.dosage else 0.0,
'remark': bom.remark or ''
})
return {
'bom_no': bom_no,
'parent_id': parent_id,
'parent_name': parent_name,
'parent_spec': parent_spec,
'version': first.BomTable.version,
'children': children
}
@staticmethod
def save_bom(data):
"""
保存或更新一个 BOM 配方
data 结构:
{
"bom_no": "用户输入或自动生成",
"version": "版本号默认v1",
"parent_id": 父件ID,
"children": [...]
}
"""
bom_no = data.get('bom_no')
version = data.get('version', 'v1')
parent_id = data['parent_id']
children = data['children']
# 校验父件不能与子件相同
for child in children:
if child['child_id'] == parent_id:
raise ValueError('父件与子件不能是同一物料')
# 如果未提供 bom_no则生成一个新的 (兼容旧逻辑,但现在前端会传值)
if not bom_no or not bom_no.strip():
# 如果前端没传,抛出异常要求用户填写,或者自动生成
# 这里选择自动生成作为兜底,但推荐前端校验必填
bom_no = BomService.generate_bom_no()
# 删除该 bom_no 下所有现有记录 (全量更新模式)
BomTable.query.filter_by(bom_no=bom_no).delete()
# 插入新记录
for child in children:
bom = BomTable(
bom_no=bom_no,
version=version,
parent_id=parent_id,
child_id=child['child_id'],
dosage=child.get('dosage', 0),
remark=child.get('remark', '')
)
db.session.add(bom)
db.session.commit()
return bom_no
@staticmethod
def get_bom_with_stock_by_bom_no(bom_no):
"""
根据 bom_no 获取配方详情,并计算每个子件的库存和最大可生产数量
"""
detail = BomService.get_bom_detail(bom_no)
if not detail:
return None
for child in detail['children']:
# 查询该子件在 StockBuy 中的可用库存总量
stock_qty = db.session.query(
func.coalesce(func.sum(StockBuy.available_quantity), 0)
).filter(
StockBuy.base_id == child['child_id']
).scalar() or 0
child['current_stock'] = float(stock_qty)
dosage = child['dosage']
child['max_producible'] = int(stock_qty // dosage) if dosage > 0 else 0
return detail
# ====================== 兼容旧接口(基于 parent_id ======================
@staticmethod
def get_bom_no_by_parent(parent_id):
"""
根据父件 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
@staticmethod
def create_or_update_bom(parent_id, child_list, bom_no=None, version='v1'):
"""
兼容旧接口的保存方法(保留原有调用方式)
如果提供了 bom_no则更新该 bom_no否则为父件创建新 BOM。
""" """
# 校验父件不能与子件相同 # 校验父件不能与子件相同
for item in child_list: for item in child_list:
if item['child_id'] == parent_id: if item['child_id'] == parent_id:
raise ValueError('父件与子件不能是同一物料') raise ValueError('父件与子件不能是同一物料')
# 删除该父件原有的BOM记录
BomTable.query.filter_by(parent_id=parent_id).delete() # 如果未提供 bom_no尝试查找现有的否则新建
if not bom_no:
existing = BomTable.query.filter_by(parent_id=parent_id).first()
bom_no = existing.bom_no if existing else BomService.generate_bom_no()
# 删除该 bom_no 下所有记录
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,
version=version,
parent_id=parent_id, parent_id=parent_id,
child_id=item['child_id'], child_id=item['child_id'],
dosage=item.get('dosage', 0), dosage=item.get('dosage', 0),
remark=item.get('remark', '') 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):
""" """
查询父件的BOM结构及库存信息 兼容旧接口:根据父件 ID 获取 BOM 及库存信息
(实际会找到对应的 bom_no 再调用新方法)
""" """
bom_items = db.session.query( bom_no = BomService.get_bom_no_by_parent(parent_id)
BomTable, if not bom_no:
MaterialBase.name.label('child_name') return []
).join( detail = BomService.get_bom_with_stock_by_bom_no(bom_no)
MaterialBase, BomTable.child_id == MaterialBase.id if not detail:
).filter( return []
BomTable.parent_id == parent_id return detail['children']
).all()
result = []
for bom, child_name in bom_items:
# 查询该子件在 StockBuy 中的可用库存总量
stock_qty = db.session.query(
func.coalesce(func.sum(StockBuy.available_quantity), 0)
).filter(
StockBuy.base_id == bom.child_id
).scalar() or 0
# 计算最大可生产数量
dosage = float(bom.dosage) if bom.dosage else 0
max_producible = int(stock_qty // dosage) if dosage > 0 else 0
result.append({
'child_id': bom.child_id,
'child_name': child_name,
'dosage': dosage,
'current_stock': float(stock_qty),
'max_producible': max_producible,
'remark': bom.remark or ''
})
return result

View File

@ -1,26 +1,58 @@
# --- HTTP 重定向到 HTTPS ---
server { server {
listen 80; listen 80;
server_name localhost; server_name _; # 匹配所有域名/IP
# 将所有 HTTP 请求强制跳转到 HTTPS
return 301 https://$host$request_uri;
}
# --- HTTPS 服务器配置 ---
server {
listen 443 ssl;
server_name _;
# 1. SSL 证书配置
ssl_certificate /etc/nginx/ssl/nginx.crt;
ssl_certificate_key /etc/nginx/ssl/nginx.key;
# SSL 优化配置 (可选)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# 允许上传大文件
client_max_body_size 20M;
# 开启 Gzip
gzip on; gzip on;
gzip_min_length 1k; gzip_min_length 1k;
gzip_comp_level 6; gzip_comp_level 6;
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript; gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript image/jpeg image/gif image/png;
# 1. 前端页面 # 2. 前端 Vue 页面
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm; index index.html index.htm;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# 2. 后端接口代理 # 3. 后端 API 接口代理
location /api { location /api/ {
# 'backend' 对应 docker-compose 里的服务名
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; # 告诉后端这是 HTTPS 请求
proxy_connect_timeout 300s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# 4. 图片资源托管
location /uploads/ {
alias /usr/share/nginx/html/uploads/;
expires 30d;
} }
} }

View File

@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {

View File

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

View File

@ -0,0 +1,35 @@
import request from '@/utils/request'
// 获取BOM列表
export function getBomList(params?: any) {
return request({
url: '/v1/bom/list',
method: 'get',
params
})
}
// 获取BOM详情
export function getBomDetail(bomNo: string) {
return request({
url: `/v1/bom/detail/${bomNo}`,
method: 'get'
})
}
// 保存BOM
export function saveBom(data: any) {
return request({
url: '/v1/bom/save',
method: 'post',
data
})
}
// 删除BOM暂未实现预留
export function deleteBom(bomNo: string) {
return request({
url: `/v1/bom/${bomNo}`,
method: 'delete'
})
}

View File

@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/layout/index.vue' import Layout from '@/layout/index.vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import BomManage from '@/views/bom/BomManage.vue'
const routes: Array<RouteRecordRaw> = [ const routes: Array<RouteRecordRaw> = [
// 1. 登录页 // 1. 登录页
@ -120,6 +121,22 @@ const routes: Array<RouteRecordRaw> = [
] ]
}, },
// 5. BOM 管理
{
path: '/bom',
component: Layout,
meta: { title: 'BOM管理', icon: 'Document' },
redirect: '/bom/manage',
children: [
{
path: 'manage',
name: 'BomManage',
component: BomManage,
meta: { title: 'BOM配方管理', icon: 'list' }
}
]
},
// 6. 业务操作 // 6. 业务操作
{ {
path: '/operation', path: '/operation',

View File

@ -0,0 +1,428 @@
<template>
<div class="app-container">
<el-card shadow="always">
<template #header>
<div class="card-header">
<span class="title">BOM 配方管理</span>
<div class="header-right">
<el-input
v-model="searchKeyword"
placeholder="搜索 BOM编号/父子件名称/规格"
style="width: 300px; margin-right: 15px;"
clearable
@clear="fetchBomList"
@keyup.enter="fetchBomList"
>
<template #append>
<el-button :icon="Search" @click="fetchBomList" />
</template>
</el-input>
<el-button type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" :data="bomList" border style="width: 100%">
<el-table-column prop="bom_no" label="BOM编号" min-width="180" sortable />
<el-table-column prop="parent_name" label="父件名称" min-width="150" />
<el-table-column prop="parent_spec" label="父件规格" min-width="150" />
<el-table-column prop="version" label="版本" width="100" align="center">
<template #default="{ row }">
<el-tag>{{ row.version }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="child_count" label="子件数" width="100" align="center" />
<el-table-column label="操作" width="280" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row.bom_no)">编辑</el-button>
<el-button type="success" link @click="handleSaveAs(row.bom_no)">另存为</el-button>
<el-button type="danger" link @click="handleDelete(row.bom_no)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false">
<el-form :model="form" label-width="120px" ref="formRef" :rules="rules">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="BOM 编号" prop="bom_no">
<el-input
v-model="form.bom_no"
placeholder="请输入BOM编号"
:disabled="isEditMode"
clearable
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="版本" prop="version">
<el-input v-model="form.version" placeholder="例如V1.0" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="父件 (成品)" prop="parent_id">
<el-select
v-model="form.parent_id"
placeholder="请搜索并选择父件"
filterable
style="width: 100%"
:disabled="isEditMode"
class="beautified-select"
>
<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>
<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-column label="子件物料" min-width="300">
<template #default="{ row, $index }">
<el-select
v-model="row.child_id"
placeholder="请搜索原料"
filterable
style="width: 100%"
@change="(val) => onChildChange(val, $index)"
>
<el-option
v-for="item in materialOptions"
:key="item.id"
:label="`${item.name} (${item.spec})`"
:value="item.id"
>
<div class="option-row">
<span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span>
</div>
</el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="用量" width="150">
<template #default="{ row }">
<el-input-number
v-model="row.dosage"
:min="0"
:precision="4"
style="width: 100%"
controls-position="right"
/>
</template>
</el-table-column>
<el-table-column label="备注" width="180">
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button type="danger" link @click="removeChild($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 10px; text-align: center;">
<el-button type="primary" plain :icon="Plus" @click="addChild" style="width: 100%">添加一行子件</el-button>
</div>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom'
import { getMaterialBaseList } from '@/api/inbound/stock'
interface BomItem {
bom_no: string
parent_id: number
parent_name: string
version: string
child_count: number
}
interface MaterialBase {
id: number
name: string
spec: string
}
interface ChildRow {
child_id: number | null
dosage: number
remark: string
}
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
// isEditMode: true表示编辑现有BOMfalse表示新建或另存为
const isEditMode = ref(false)
const bomList = ref<BomItem[]>([])
const materialOptions = ref<MaterialBase[]>([])
const searchKeyword = ref('')
const formRef = ref<FormInstance>()
const form = reactive({
bom_no: '',
parent_id: null as number | null,
version: 'V1.0',
children: [] as ChildRow[]
})
const rules = reactive<FormRules>({
bom_no: [{ required: true, message: '请输入BOM编号', trigger: 'blur' }],
parent_id: [{ required: true, message: '请选择父件', trigger: 'change' }],
version: [{ required: true, message: '请输入版本号', trigger: 'blur' }]
})
const dialogTitle = ref('新建 BOM')
const fetchBomList = async () => {
loading.value = true
try {
const res = await getBomList({ keyword: searchKeyword.value })
if (res.code === 200) {
bomList.value = res.data
} else {
ElMessage.error(res.msg || '获取列表失败')
}
} catch (error) {
ElMessage.error('网络错误')
} finally {
loading.value = false
}
}
const fetchMaterialOptions = async () => {
try {
const res = await getMaterialBaseList()
if (res.code === 200) {
materialOptions.value = res.data
}
} catch (error) {
console.error('获取物料列表失败', error)
}
}
const handleCreate = () => {
resetForm()
dialogTitle.value = '新建 BOM'
dialogVisible.value = true
isEditMode.value = false
}
const handleEdit = async (bomNo: string) => {
try {
const res = await getBomDetail(bomNo)
if (res.code === 200) {
const data = res.data
form.bom_no = data.bom_no
form.parent_id = data.parent_id
form.version = data.version
form.children = data.children.map((child: any) => ({
child_id: child.child_id,
dosage: child.dosage,
remark: child.remark || ''
}))
dialogTitle.value = '编辑 BOM'
dialogVisible.value = true
// 编辑模式下BOM编号不可改
isEditMode.value = true
} else {
ElMessage.error(res.msg || '获取详情失败')
}
} catch (error) {
ElMessage.error('网络错误')
}
}
const handleSaveAs = async (bomNo: string) => {
try {
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 () => {
try {
const res = await deleteBom(bomNo)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchBomList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
ElMessage.error('网络错误')
}
})
.catch(() => {})
}
const resetForm = () => {
form.bom_no = ''
form.parent_id = null
form.version = 'V1.0'
form.children = []
if (formRef.value) formRef.value.resetFields()
}
const addChild = () => {
form.children.push({
child_id: null,
dosage: 0,
remark: ''
})
}
const removeChild = (index: number) => {
form.children.splice(index, 1)
}
const onChildChange = (val: number, index: number) => {
// 可扩展逻辑
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
if (form.children.length === 0) {
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 = {
bom_no: form.bom_no, // 必填
version: form.version,
parent_id: form.parent_id,
children: form.children.map(c => ({
child_id: c.child_id,
dosage: c.dosage,
remark: c.remark
}))
}
saving.value = true
try {
const res = await saveBom(payload)
if (res.code === 200) {
ElMessage.success('保存成功')
dialogVisible.value = false
fetchBomList()
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (error) {
ElMessage.error('网络错误')
} finally {
saving.value = false
}
})
}
onMounted(() => {
fetchBomList()
fetchMaterialOptions()
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-right {
display: flex;
align-items: center;
}
.title {
font-size: 18px;
font-weight: bold;
}
/* 下拉框选项样式优化 */
.option-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.option-name {
font-weight: bold;
color: #303133;
}
.option-spec {
font-size: 12px;
color: #909399;
margin-left: 15px;
}
</style>

View File

@ -4,120 +4,161 @@
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<div class="header-left"> <div class="header-left">
<span class="title">出库拣货选单</span> <span class="title">出库拣货</span>
<span class="subtitle">(A4 打印模式)</span> <span class="subtitle">(请添加需要出库的物品)</span>
</div> </div>
<div> <div>
<el-button type="success" :icon="Printer" :disabled="!canSubmit" @click="handlePreview"> <el-button type="primary" :icon="Plus" @click="openManualSelect">
生成并预览出库单 手动添加库存
</el-button>
<el-button type="warning" :icon="List" @click="openBomSelect">
BOM 套餐添加
</el-button>
<el-divider direction="vertical" />
<el-button type="success" :icon="Printer" :disabled="selectedItems.length === 0" @click="handlePreview">
生成预览 & 打印
</el-button> </el-button>
</div> </div>
</div> </div>
</template> </template>
<div class="filter-container">
<el-row :gutter="20">
<el-col :span="12">
<el-input
v-model="searchKeyword"
placeholder="请输入物料名称 或 规格型号 进行搜索"
class="search-input"
clearable
:prefix-icon="Search"
/>
</el-col>
<el-col :span="12" style="text-align: right;">
<el-button type="primary" plain :icon="Plus" @click="handleCreateBom">
创建 BOM
</el-button>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top:15px">
<el-col :span="24">
<el-select v-model="selectedParentId" placeholder="选择 BOM 表" filterable style="width:100%" @change="onBomParentChange">
<el-option v-for="item in bomParents" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-col>
</el-row>
</div>
<el-table v-if="bomChildren.length > 0" :data="bomChildren" border style="width:100%; margin-bottom:15px">
<el-table-column prop="child_name" label="子件名称" />
<el-table-column prop="dosage" label="所需个数" />
<el-table-column prop="current_stock" label="当前库存" />
<el-table-column prop="max_producible" label="最大可生产" />
</el-table>
<el-alert <el-alert
v-if="selectionCount > 0" v-if="selectedItems.length === 0"
:title="`当前已勾选 ${selectionCount} 种物品,请在表格右侧填写【本次出库数】`" title="清单为空,请点击右上角按钮添加物品"
type="success" type="info"
center
show-icon show-icon
style="margin-bottom: 15px" style="margin-bottom: 20px"
:closable="false"
/> />
<div class="table-wrapper"> <el-table
<el-table v-else
v-loading="loading" :data="selectedItems"
:data="filteredTableData" border
style="width: 100%" style="width: 100%"
@selection-change="handleSelectionChange" row-key="uniqueKey"
row-key="uniqueKey" >
border <el-table-column type="index" label="序号" width="50" align="center" />
height="100%"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="类型" width="100" align="center"> <el-table-column label="类型" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="getTypeTag(row.type)">{{ row.typeLabel }}</el-tag> <el-tag :type="getTypeTag(row.type)">{{ row.typeLabel }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip> <el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
<template #default="{ row }"> <el-table-column prop="standard" label="规格型号" min-width="150" show-overflow-tooltip />
<span v-html="highlightKeyword(row.name)"></span>
</template>
</el-table-column>
<el-table-column prop="standard" label="规格型号" min-width="150" show-overflow-tooltip> <el-table-column prop="available_quantity" label="当前库存" width="120" align="right">
<template #default="{ row }"> <template #default="{ row }">
<span v-html="highlightKeyword(row.standard)"></span> <span style="color: green; font-weight: bold;">{{ row.available_quantity }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="stock_quantity" label="库存总数" width="120" align="right"> <el-table-column label="本次出库数" width="180" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<span style="font-weight: bold; font-size: 14px; color: #909399;">{{ row.stock_quantity }}</span> <el-input-number
</template> v-model="row.export_quantity"
</el-table-column> :min="0"
:max="row.available_quantity"
size="small"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column prop="available_quantity" label="可用数量" width="120" align="right"> <el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ $index }">
<span style="color: green; font-weight: bold; font-size: 14px;">{{ row.available_quantity }}</span> <el-button type="danger" link @click="removeRow($index)">移除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table>
<el-table-column label="本次出库数" width="160" align="center" fixed="right"> <div v-if="selectedItems.length > 0" style="margin-top: 15px; text-align: right; color: #606266;">
<template #default="{ row }"> <span style="color: red; font-weight: bold;">{{ selectedItems.length }}</span> 种物品
<el-input-number 合计出库 <span style="color: red; font-weight: bold;">{{ totalExportCount }}</span>
v-model="row.export_quantity"
:min="0"
:max="row.available_quantity"
size="small"
style="width: 100%"
controls-position="right"
placeholder="0"
/>
</template>
</el-table-column>
</el-table>
</div> </div>
</el-card> </el-card>
<el-dialog v-model="manualDialogVisible" title="选择库存物品" width="85%" top="5vh" destroy-on-close>
<div class="filter-container">
<el-input
v-model="searchKeyword"
placeholder="请输入物料名称 或 规格型号 进行搜索"
style="width: 300px"
:prefix-icon="Search"
clearable
@input="filterStock"
/>
<span style="margin-left: 15px; color: #909399; font-size: 12px;">
提示勾选物品后可直接在表格中修改本次出库数量
</span>
</div>
<el-table
:data="filteredStockData"
height="500"
border
row-key="uniqueKey"
@selection-change="handleStockSelection"
>
<el-table-column type="selection" width="55" align="center" :reserve-selection="true" />
<el-table-column label="类型" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="getTypeTag(row.type)">{{ row.typeLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" show-overflow-tooltip />
<el-table-column prop="standard" label="规格" show-overflow-tooltip />
<el-table-column prop="available_quantity" label="可用库存" width="100" align="right" />
<el-table-column label="本次出库" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-input-number
v-model="row.export_quantity"
:min="1"
:max="row.available_quantity"
size="small"
style="width: 100%"
placeholder="数量"
@click.stop
/>
</template>
</el-table-column>
</el-table>
<template #footer>
<span style="float: left; line-height: 32px; color: #909399;">
已勾选 {{ tempSelection.length }}
</span>
<el-button @click="manualDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmManualAdd">确认添加</el-button>
</template>
</el-dialog>
<el-dialog v-model="bomSelectVisible" title="按 BOM 套餐添加" width="600px">
<el-form label-width="100px">
<el-form-item label="选择产品">
<el-select v-model="selectedBomNo" filterable placeholder="请选择产品BOM配方" style="width: 100%">
<el-option
v-for="b in bomOptions"
:key="b.bom_no"
:label="`${b.parent_name} - ${b.version}`"
:value="b.bom_no"
/>
</el-select>
</el-form-item>
<el-form-item label="生产套数">
<el-input-number v-model="bomSets" :min="1" label="套" style="width: 200px;" />
</el-form-item>
</el-form>
<div style="margin-left: 100px; color: #909399; font-size: 12px;">
注意系统将自动计算所需原料数量 ( 配方用量 × 套数 )
</div>
<template #footer>
<el-button @click="bomSelectVisible = false">取消</el-button>
<el-button type="primary" @click="confirmBomAdd">一键计算并添加</el-button>
</template>
</el-dialog>
<el-dialog <el-dialog
v-model="previewVisible" v-model="previewVisible"
title="出库单核对与打印" title="出库单核对与打印"
@ -164,7 +205,7 @@
<h1>IRIS出库拣货确认单</h1> <h1>IRIS出库拣货确认单</h1>
<div class="print-meta-row"> <div class="print-meta-row">
<span>打印时间: {{ currentTime }}</span> <span>打印时间: {{ currentTime }}</span>
<span>单据编号: {{ generateOrderNo() }}</span> <span>单据编号: {{ currentOrderNo }}</span>
</div> </div>
<div class="header-line"></div> <div class="header-line"></div>
</div> </div>
@ -217,222 +258,64 @@
</div> </div>
</div> </div>
</div> </div>
<el-dialog v-model="bomDialogVisible" title="创建/编辑 BOM" width="800px" class="no-print-content">
<el-form :model="bomForm" label-width="120px">
<el-form-item label="父件 (成品)">
<el-select v-model="bomForm.parent_id" placeholder="请选择" filterable style="width:100%">
<el-option
v-for="item in materialBaseOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<div style="font-weight: bold; margin-bottom: 10px;">子件列表</div>
<el-table :data="bomForm.children" border style="width:100%; margin-top:10px">
<el-table-column label="子件物料" width="300">
<template #default="{ row, $index }">
<el-select v-model="row.child_id" placeholder="请选择" filterable style="width:100%">
<el-option
v-for="item in materialBaseOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="个数" width="150">
<template #default="{ row }">
<el-input-number v-model="row.dosage" :min="0" :precision="4" style="width:100%" />
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ $index }">
<el-button type="danger" size="small" @click="removeChildRow($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top:10px">
<el-button type="primary" @click="addChildRow">添加子件</el-button>
</div>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="bomDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveBom">保存 BOM</el-button>
</span>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed } from 'vue'
import { Printer, Search, Plus, Download } from '@element-plus/icons-vue' import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue'
import { getAllStock, printSelectionList, getMaterialBaseList, saveBom as saveBomApi, getBomParents, getBom } from '@/api/inbound/stock'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getAllStock, printSelectionList } from '@/api/inbound/stock'
// --- 类型定义 --- import { getBomList, getBomDetail } from '@/api/bom'
interface StandardStockItem {
id: number | string;
name: string;
type: 'material' | 'semi' | 'product';
typeLabel: string;
standard: string;
batch_no: string;
uuid: string;
create_time: string;
stock_quantity: number;
available_quantity: number;
base_id?: number | string;
[key: string]: any;
}
interface GroupedItem extends StandardStockItem {
uniqueKey: string;
itemsDetail: StandardStockItem[];
export_quantity: number; // 用户输入数量
}
// --- 状态变量 --- // --- 状态变量 ---
const loading = ref(false) // 核心:购物车数据
const printLoading = ref(false) const selectedItems = ref<any[]>([])
const exportLoading = ref(false)
const searchKeyword = ref('')
const previewVisible = ref(false)
const currentTime = ref('')
const allStockData = ref<GroupedItem[]>([]) // 弹窗与加载状态
const selectedItems = ref<GroupedItem[]>([]) const manualDialogVisible = ref(false)
const bomSelectVisible = ref(false)
const previewVisible = ref(false)
const exportLoading = ref(false)
const printLoading = ref(false)
// 数据缓存
const allStockData = ref<any[]>([])
const filteredStockData = ref<any[]>([])
const searchKeyword = ref('')
const tempSelection = ref<any[]>([]) // 手动添加时的临时勾选
// BOM 相关 // BOM 相关
const bomDialogVisible = ref(false) const bomOptions = ref<any[]>([])
const materialBaseOptions = ref<any[]>([]) const selectedBomNo = ref('')
const bomParents = ref<any[]>([]) const bomSets = ref(1)
const selectedParentId = ref<number|null>(null)
const bomChildren = ref<any[]>([])
const bomForm = ref({ // 打印相关
parent_id: null as number | null, const currentTime = ref('')
children: [] as any[] const currentOrderNo = ref('')
})
// --- 计算属性 --- // --- 计算属性 ---
const filteredTableData = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
if (!keyword) {
return allStockData.value
}
return allStockData.value.filter(item => {
const nameMatch = item.name && item.name.toLowerCase().includes(keyword)
const stdMatch = item.standard && item.standard.toLowerCase().includes(keyword)
return nameMatch || stdMatch
})
})
const selectionCount = computed(() => selectedItems.value.length) // 有效的出库项 (数量 > 0)
// 计算有效的出库项(勾选了 且 数量>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)
}) })
const canSubmit = computed(() => validSelectedItems.value.length > 0)
const totalExportCount = computed(() => { const totalExportCount = computed(() => {
return validSelectedItems.value.reduce((sum, item) => sum + (item.export_quantity || 0), 0) return validSelectedItems.value.reduce((sum, item) => sum + (item.export_quantity || 0), 0)
}) })
// --- 辅助函数 --- // --- 辅助方法 ---
const getStandard = (item: any) => { const getTypeTag = (type: string) => {
if (item.standard) return item.standard; switch (type) {
if (item.spec_model) return item.spec_model; case 'material': return 'info'
if (item.base && item.base.spec_model) return item.base.spec_model; case 'semi': return 'warning'
return ''; case 'product': return 'success'
} default: return ''
const normalizeItem = (item: any, type: 'material' | 'semi' | 'product', typeLabel: string): StandardStockItem => {
const name = item.material_name || item.product_name || item.name || '未知名称';
return {
...item,
name: name.trim(),
type: type,
typeLabel: typeLabel,
standard: getStandard(item),
stock_quantity: parseFloat(item.stock_quantity) || 0,
available_quantity: parseFloat(item.available_quantity) || 0,
base_id: item.base_id || 'unknown'
} }
} }
const groupItems = (items: StandardStockItem[]): GroupedItem[] => {
const map = new Map<string, GroupedItem>();
items.forEach(item => {
const safeName = item.name || '';
const safeStd = item.standard || '';
const key = `${item.type}_${safeName}_${safeStd}`;
if (!map.has(key)) {
map.set(key, {
...item,
uniqueKey: key,
stock_quantity: 0,
available_quantity: 0,
itemsDetail: [],
export_quantity: 0
});
}
const group = map.get(key)!;
if (!group.itemsDetail) group.itemsDetail = [];
group.stock_quantity += item.stock_quantity;
group.available_quantity += item.available_quantity;
group.itemsDetail.push(item);
});
// 精度修正
map.forEach(group => {
group.stock_quantity = parseFloat(group.stock_quantity.toFixed(4));
group.available_quantity = parseFloat(group.available_quantity.toFixed(4));
});
return Array.from(map.values());
}
// --- 主要方法 ---
const fetchData = async () => {
loading.value = true
try {
const res: any = await getAllStock()
const rawMaterials = (res.materials || []).map((i: any) => normalizeItem(i, 'material', '采购件'));
const rawSemis = (res.semis || []).map((i: any) => normalizeItem(i, 'semi', '半成品'));
const rawProducts = (res.products || []).map((i: any) => normalizeItem(i, 'product', '成品'));
const groupedMaterials = groupItems(rawMaterials);
const groupedSemis = groupItems(rawSemis);
const groupedProducts = groupItems(rawProducts);
allStockData.value = [...groupedMaterials, ...groupedSemis, ...groupedProducts]
} catch (error) {
ElMessage.error('无法获取库存数据')
} finally {
loading.value = false
}
}
const handleSelectionChange = (val: GroupedItem[]) => {
selectedItems.value = val
}
const generateOrderNo = () => { const generateOrderNo = () => {
const now = new Date(); const now = new Date();
const dateStr = now.getFullYear() + (now.getMonth()+1).toString().padStart(2,'0') + now.getDate().toString().padStart(2,'0'); const dateStr = now.getFullYear() + (now.getMonth()+1).toString().padStart(2,'0') + now.getDate().toString().padStart(2,'0');
@ -440,37 +323,190 @@ const generateOrderNo = () => {
return 'OUT' + dateStr + '-' + random; return 'OUT' + dateStr + '-' + random;
} }
// 打开预览弹窗 // --- 核心逻辑 1手动添加库存 (支持弹窗内修改数量) ---
const openManualSelect = async () => {
manualDialogVisible.value = true
if (allStockData.value.length === 0) {
try {
const res: any = await getAllStock()
// 1. 分类处理并打上标记
const rawMaterials = (res.materials || []).map((i: any) => ({ ...i, type: 'material', typeLabel: '采购件' }))
const rawSemis = (res.semis || []).map((i: any) => ({ ...i, type: 'semi', typeLabel: '半成品' }))
const rawProducts = (res.products || []).map((i: any) => ({ ...i, type: 'product', typeLabel: '成品' }))
// 2. 合并
const list = [...rawMaterials, ...rawSemis, ...rawProducts]
// 3. 规范化并生成 UniqueKey
allStockData.value = list.map((i: any) => ({
...i,
name: i.name || i.material_name || i.product_name || '未知名称',
standard: i.standard || i.spec_model || '',
// ★ 确保 Key 唯一格式类型_ID
uniqueKey: `${i.type}_${i.id}`,
available_quantity: parseFloat(i.available_quantity) || 0,
export_quantity: 1 // ★ 默认初始化为1方便用户在弹窗里看到并修改
}))
filteredStockData.value = allStockData.value
} catch (e) {
ElMessage.error('加载库存数据失败')
}
} else {
// 每次打开时重置筛选并将所有项的“本次出库”重置为1或保留上次视需求而定这里重置为1以防混淆
searchKeyword.value = ''
allStockData.value.forEach(item => item.export_quantity = 1)
filteredStockData.value = allStockData.value
}
}
const filterStock = () => {
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) {
filteredStockData.value = allStockData.value
return
}
filteredStockData.value = allStockData.value.filter(i =>
(i.name && i.name.toLowerCase().includes(kw)) ||
(i.standard && i.standard.toLowerCase().includes(kw))
)
}
const handleStockSelection = (val: any[]) => {
tempSelection.value = val
}
const confirmManualAdd = () => {
if (tempSelection.value.length === 0) {
return ElMessage.warning('请先勾选需要添加的物品')
}
// 1. 过滤已存在的 (避免重复)
const newItems = tempSelection.value.filter(item =>
!selectedItems.value.find(existing => existing.uniqueKey === item.uniqueKey)
)
if (newItems.length === 0) {
manualDialogVisible.value = false
return ElMessage.warning('选中的物品已全部在清单中')
}
// 2. 深拷贝加入购物车 (防止引用关联)
const itemsToAdd = newItems.map(item => {
const copy = JSON.parse(JSON.stringify(item))
// ★ 关键修改:直接使用用户在弹窗里输入的 export_quantity
// 如果用户把输入框清空了(undefined)则默认为1
copy.export_quantity = (item.export_quantity && item.export_quantity > 0) ? item.export_quantity : 1
return copy
})
selectedItems.value.push(...itemsToAdd)
manualDialogVisible.value = false
tempSelection.value = [] // 清空临时勾选
ElMessage.success(`成功添加 ${itemsToAdd.length} 项物品`)
}
// --- 核心逻辑 2按 BOM 添加 ---
const openBomSelect = async () => {
bomSelectVisible.value = true
// 每次打开 BOM 弹窗重置套数为 1
bomSets.value = 1
try {
const res = await getBomList()
bomOptions.value = res.data || []
} catch (e) {
ElMessage.error('加载 BOM 列表失败')
}
}
const confirmBomAdd = async () => {
if(!selectedBomNo.value) return ElMessage.warning('请选择 BOM');
// 确保库存数据已加载,用于匹配
if (allStockData.value.length === 0) {
await openManualSelect()
manualDialogVisible.value = false // 仅加载数据,不显示弹窗
}
try {
const detailRes = await getBomDetail(selectedBomNo.value)
const bomRows = detailRes.data || []
let addedCount = 0;
// 遍历 BOM 子件
bomRows.forEach((bomItem: any) => {
// ★ 这里本身就是“选择数量”的逻辑:用量 * 套数
const needQty = (parseFloat(bomItem.dosage) || 0) * bomSets.value
// 简单匹配逻辑:匹配 base_id (最准确)
const stockCandidate = allStockData.value.find(s =>
(s.base_id && s.base_id == bomItem.child_id)
)
if (stockCandidate) {
const existing = selectedItems.value.find(e => e.uniqueKey === stockCandidate.uniqueKey)
if (existing) {
// 如果已存在,累加数量
existing.export_quantity += needQty
} else {
const newItem = JSON.parse(JSON.stringify(stockCandidate))
newItem.export_quantity = needQty
selectedItems.value.push(newItem)
}
addedCount++
}
})
if(addedCount > 0) {
ElMessage.success(`成功添加 BOM 相关物料,共 ${addedCount}`)
bomSelectVisible.value = false
} else {
ElMessage.warning('库存中未找到该 BOM 所需的任何原料')
}
} catch(e) {
ElMessage.error('获取 BOM 详情失败')
}
}
// --- 通用逻辑 ---
const removeRow = (index: number) => {
selectedItems.value.splice(index, 1)
}
const handlePreview = () => { const handlePreview = () => {
if (validSelectedItems.value.length === 0) { if (validSelectedItems.value.length === 0) {
ElMessage.warning('请先勾选物品并填写有效的出库数量') ElMessage.warning('请先添加物品并填写出库数量')
return return
} }
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
} }
// ★★★ 核心打印:浏览器原生打印 (Window.print) ★★★
const confirmPrint = async () => { const confirmPrint = async () => {
// 1. 关闭预览弹窗,展示底层页面(其中包含隐藏的 #print-area
previewVisible.value = false; previewVisible.value = false;
// 2. 为了日志记录,还是异步调一下后端接口 (不阻塞打印) // 记录日志
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) {}
// 3. 延时唤起系统打印预览 (预留时间给 DOM 渲染)
setTimeout(() => { setTimeout(() => {
window.print(); window.print();
}, 300); }, 300);
} }
// 导出 Excel
const confirmExport = () => { const confirmExport = () => {
if (validSelectedItems.value.length === 0) return; if (validSelectedItems.value.length === 0) return;
exportLoading.value = true; exportLoading.value = true;
@ -498,116 +534,32 @@ const confirmExport = () => {
exportLoading.value = false; exportLoading.value = false;
} }
} }
// --- BOM 逻辑 (保持原有功能) ---
const handleCreateBom = async () => {
bomDialogVisible.value = true
if (materialBaseOptions.value.length === 0) {
try {
const res = await getMaterialBaseList({})
if (res.code === 200) materialBaseOptions.value = res.data
} catch (err) {}
}
}
const addChildRow = () => bomForm.value.children.push({ child_id: null, dosage: 0, remark: '' })
const removeChildRow = (index: number) => bomForm.value.children.splice(index, 1)
const saveBom = async () => {
if (!bomForm.value.parent_id) return ElMessage.warning('请选择父件')
if (bomForm.value.children.length === 0) return ElMessage.warning('请至少添加一个子件')
const payload = {
parent_id: bomForm.value.parent_id,
children: bomForm.value.children.map(c => ({
child_id: c.child_id, dosage: c.dosage, remark: c.remark || ''
}))
}
try {
const res = await saveBomApi(payload)
if (res.code === 200) {
ElMessage.success('BOM保存成功')
bomDialogVisible.value = false
bomForm.value = { parent_id: null, children: [] }
} else { ElMessage.error(res.msg || '保存失败') }
} catch (err) { ElMessage.error('网络错误') }
}
const highlightKeyword = (text: string) => {
if (!searchKeyword.value || !text) return text
const reg = new RegExp(searchKeyword.value, 'gi')
return text.replace(reg, (match) => `<span style="color: red; font-weight: bold;">${match}</span>`)
}
const getTypeTag = (type: string) => {
switch (type) {
case 'material': return 'info'
case 'semi': return 'warning'
case 'product': return 'success'
default: return ''
}
}
const onBomParentChange = async (val: number) => {
selectedParentId.value = val
if (val) {
try {
const res = await getBom(val)
if (res.code === 200) bomChildren.value = res.data
} catch (err) {}
} else { bomChildren.value = [] }
}
onMounted(async () => {
fetchData()
try {
const res = await getBomParents()
if (res.code === 200) bomParents.value = res.data
} catch (err) {}
})
</script> </script>
<style scoped> <style scoped>
/* ================= 屏幕显示样式 (普通操作界面) ================= */ /* ================= 屏幕显示样式 ================= */
.card-header { display: flex; justify-content: space-between; align-items: center; } .card-header { display: flex; justify-content: space-between; align-items: center; }
.header-left .title { font-size: 18px; font-weight: bold; margin-right: 10px; } .header-left .title { font-size: 18px; font-weight: bold; margin-right: 10px; }
.header-left .subtitle { font-size: 12px; color: #909399; } .header-left .subtitle { font-size: 12px; color: #909399; }
.filter-container { margin-bottom: 20px; background-color: #f5f7fa; padding: 15px; border-radius: 4px; } .filter-container { margin-bottom: 20px; }
.search-input { width: 100%; max-width: 400px; }
.app-container { height: 100vh; overflow: hidden; display: flex; flex-direction: column; padding: 20px; box-sizing: border-box; } .app-container { height: 100vh; overflow: hidden; display: flex; flex-direction: column; padding: 20px; box-sizing: border-box; }
.app-container .el-card { flex: 1; display: flex; flex-direction: column; overflow: hidden; } .app-container .el-card { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
::v-deep(.el-card__body) { flex: 1; display: flex; flex-direction: column; overflow: hidden; } ::v-deep(.el-card__body) { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.table-wrapper { flex: 1; overflow: auto; }
::v-deep(.el-dialog__body) { max-height: 70vh; overflow-y: auto; }
/* ================= ★★★ 打印专用样式 (CSS 强力隔离) ★★★ ================= */ /* ================= ★★★ 打印专用样式 ★★★ ================= */
/* 1. 默认状态:屏幕上隐藏打印区域 */ /* 1. 默认状态:屏幕上隐藏打印区域 */
#print-area { display: none; } #print-area { display: none; }
/* 2. 打印状态:隐藏所有非打印内容,独显 #print-area */ /* 2. 打印状态:隐藏所有非打印内容,独显 #print-area */
@media print { @media print {
/* ★★★ 关键配置:去除浏览器默认页眉页脚 (时间、URL等) ★★★ */ @page { margin: 0; size: auto; }
@page {
margin: 0; /* 设置页边距为0这会挤掉浏览器自带的页眉页脚 */
size: auto;
}
/* 隐藏所有内容Body下的所有直接子元素全部隐藏 */ body * { visibility: hidden; }
body * { .el-dialog__wrapper, .v-modal, .el-message, .no-print-content { display: none !important; }
visibility: hidden;
}
/* 隐藏 Element UI 的弹窗层、遮罩层等干扰元素 */ #print-area, #print-area * { visibility: visible; }
.el-dialog__wrapper, .v-modal, .el-message, .no-print-content {
display: none !important;
}
/* 显式显示打印区域及其子元素 */
#print-area, #print-area * {
visibility: visible;
}
/* 将打印区域定位到页面绝对左上角,覆盖所有内容,背景设为白色 */
#print-area { #print-area {
position: fixed; position: fixed;
left: 0; left: 0;
@ -615,15 +567,12 @@ onMounted(async () => {
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0; margin: 0;
/* ★★★ 因为去掉了默认页边距这里需要手动增加内边距来模拟A4纸边距 ★★★ */
padding: 20mm; padding: 20mm;
background-color: white; background-color: white;
display: block !important; display: block !important;
z-index: 99999; /* 最高层级 */ z-index: 99999;
} }
/* ================= 打印表格排版优化 (A4 风格) ================= */
.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: space-between; font-size: 12px; margin-bottom: 5px; }
@ -633,26 +582,20 @@ onMounted(async () => {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-bottom: 40px; margin-bottom: 40px;
border: 1px solid #000; /* 外边框 */ border: 1px solid #000;
} }
.print-table th, .print-table td { .print-table th, .print-table td {
border: 1px solid #000; /* 单元格边框 */ border: 1px solid #000;
padding: 12px 8px; /* 增加内边距 */ padding: 12px 8px;
text-align: left; text-align: left;
font-size: 14px; font-size: 14px;
color: #000; color: #000;
} }
.print-table th { .print-table th { text-align: center; font-weight: bold; }
text-align: center;
font-weight: bold;
background-color: transparent !important; /* 移除背景色以省墨 */
}
.cell-padding { padding-left: 10px; } .cell-padding { padding-left: 10px; }
/* 底部签字区域 */
.print-footer { .print-footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@ -14,10 +14,10 @@
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
/* Linting */ /* Linting - Relaxed for production build */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": false,
"noUnusedParameters": true, "noUnusedParameters": false,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true