6 Commits

11 changed files with 1007 additions and 8 deletions

View File

@ -127,6 +127,19 @@ def create_app():
except ImportError as e: except ImportError as e:
print(f"❌ 错误: BOM 模块导入失败: {e}") print(f"❌ 错误: BOM 模块导入失败: {e}")
# -----------------------------------------------------
# 2.8 注册用户偏好模块 (User Preferences)
# -----------------------------------------------------
try:
from app.api.v1.user import user_bp
# 标准: /api/v1/user/preferences
app.register_blueprint(user_bp, url_prefix='/api/v1/user')
# 兼容: /api/user/preferences
app.register_blueprint(user_bp, url_prefix='/api/user', name='user_legacy')
print("✅ User Preferences 模块注册成功")
except ImportError as e:
print(f"❌ 错误: User Preferences 模块导入失败: {e}")
# ----------------------------------------------------- # -----------------------------------------------------
# 2.7 注册权限管理模块 (Permission) - [新增] # 2.7 注册权限管理模块 (Permission) - [新增]
# ----------------------------------------------------- # -----------------------------------------------------

View File

@ -1,7 +1,9 @@
from flask import Blueprint from flask import Blueprint
from .inbound import inbound_bp from .inbound import inbound_bp
from .bom import bom_bp from .bom import bom_bp
from .user import user_bp
v1_bp = Blueprint('v1', __name__) v1_bp = Blueprint('v1', __name__)
v1_bp.register_blueprint(inbound_bp, url_prefix='/inbound') v1_bp.register_blueprint(inbound_bp, url_prefix='/inbound')
v1_bp.register_blueprint(bom_bp, url_prefix='/bom') v1_bp.register_blueprint(bom_bp, url_prefix='/bom')
v1_bp.register_blueprint(user_bp, url_prefix='/user')

View File

@ -348,3 +348,39 @@ def get_bom_parents():
except Exception as e: except Exception as e:
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
# ==============================================================================
# MRP 齐套模拟计算
# ==============================================================================
@bom_bp.route('/calculate-kitting', methods=['POST'])
@jwt_required()
def calculate_kitting():
"""
MRP 齐套模拟计算
入参:
[{"bom_no": "BOM-001", "target_qty": 10}, {"bom_no": "BOM-002", "target_qty": 5}]
算法:
1. 展开所有 BOM 的子件,按 child_id 合并需求量(含损耗)
2. 跨 StockBuy / StockSemi / StockProduct 聚合当前可用库存
3. 计算 shortage = available_quantity - required_quantity
出参:
[{base_id, material_name, spec, unit, required_qty, available_qty, shortage, bom_sources}]
"""
try:
entries = request.get_json()
if not entries or not isinstance(entries, list):
return jsonify({'code': 400, 'msg': '参数格式错误,需要数组'}), 400
result = BomService.calculate_kitting(entries)
return jsonify({
'code': 200,
'msg': '计算成功',
'data': result
})
except Exception as e:
current_app.logger.error(f'MRP齐套计算失败: {str(e)}')
return jsonify({'code': 500, 'msg': f'计算失败: {str(e)}'}), 500

View File

@ -0,0 +1,66 @@
from flask import Blueprint, request, jsonify, current_app
from app.models.system import SysUser
from app.extensions import db
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.utils.decorators import audit_log
user_bp = Blueprint('user', __name__)
# ==============================================================================
# 用户偏好配置 API
# ==============================================================================
@user_bp.route('/preferences', methods=['GET'])
@audit_log(module='系统', action='读取偏好配置')
@jwt_required()
def get_preferences():
"""
读取当前用户的 preferences 字段
GET /api/v1/user/preferences
"""
try:
user_id = get_jwt_identity()
user = SysUser.query.get(user_id)
if not user:
return jsonify({'code': 404, 'msg': '用户不存在'}), 404
return jsonify({
'code': 200,
'msg': 'success',
'data': user.preferences or {}
})
except Exception as e:
current_app.logger.error(f'读取用户偏好失败: {str(e)}')
return jsonify({'code': 500, 'msg': f'读取失败: {str(e)}'}), 500
@user_bp.route('/preferences', methods=['PUT'])
@audit_log(module='系统', action='修改偏好配置')
@jwt_required()
def save_preferences():
"""
保存/更新当前用户的 preferences 字段
PUT /api/v1/user/preferences
入参: 任意 JSON 字典
"""
try:
user_id = get_jwt_identity()
user = SysUser.query.get(user_id)
if not user:
return jsonify({'code': 404, 'msg': '用户不存在'}), 404
new_prefs = request.get_json()
if new_prefs is None or not isinstance(new_prefs, dict):
return jsonify({'code': 400, 'msg': '参数必须是 JSON 对象'}), 400
user.preferences = new_prefs
db.session.commit()
return jsonify({
'code': 200,
'msg': '保存成功',
'data': user.preferences
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f'保存用户偏好失败: {str(e)}')
return jsonify({'code': 500, 'msg': f'保存失败: {str(e)}'}), 500

View File

@ -22,6 +22,7 @@ class SysUser(db.Model):
role = db.Column(db.String(50)) role = db.Column(db.String(50))
status = db.Column(db.String(20), default='active') status = db.Column(db.String(20), default='active')
password_hash = db.Column(db.Text) password_hash = db.Column(db.Text)
preferences = db.Column(db.JSON, nullable=True) # 用户偏好如齐套监控列表Python层用 or {} 兜底
created_at = db.Column(db.DateTime, default=beijing_time) created_at = db.Column(db.DateTime, default=beijing_time)
def set_password(self, password): def set_password(self, password):
@ -61,6 +62,7 @@ class SysUser(db.Model):
'department': self.department, 'department': self.department,
'role': self.role, 'role': self.role,
'status': self.status, 'status': self.status,
'preferences': self.preferences or {},
'created_at': self.created_at.isoformat() if self.created_at else None 'created_at': self.created_at.isoformat() if self.created_at else None
} }

View File

@ -2,6 +2,8 @@ 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 app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from sqlalchemy import func, distinct, or_, case from sqlalchemy import func, distinct, or_, case
import uuid import uuid
from datetime import datetime from datetime import datetime
@ -248,6 +250,172 @@ class BomService:
return detail return detail
# ====================== MRP 齐套模拟计算 ======================
@staticmethod
def calculate_kitting(entries: list) -> dict:
"""
MRP 齐套模拟计算
算法步骤:
1. 遍历传入的 BOM取每个 BOM 最新版本的子件
2. 按子件 base_id 合并需求量:需求量 = dosage * (1 + loss_rate/100) * target_qty
3. 跨 StockBuy / StockSemi / StockProduct 聚合可用库存
4. 计算缺口shortage = available_quantity - required_quantity
5. 计算每个 BOM 的"当前库存可生产最大套数"(木桶效应)
:param entries: [{"bom_no": "BOM-001", "target_qty": 10}, ...]
:return: {
"bom_summary": [{bom_no, parent_name, max_producible}],
"materials": [{base_id, material_name, spec, unit, required_qty, available_qty, shortage, bom_sources}]
}
"""
# Step 1: 展开所有 BOM 的子件,聚合需求量
demand_map = {} # child_id -> {base_id, material_name, spec, unit, required_qty, bom_sources: []}
# 记录每个 entry 的元信息(用于后续 per-BOM 产量计算)
entry_meta = {} # bom_no -> {parent_name, version, children: {child_id: {dosage, loss_rate}}}
for entry in entries:
bom_no = entry.get('bom_no')
target_qty = float(entry.get('target_qty', 0) or 0)
if not bom_no or target_qty <= 0:
continue
# 取最新版本
latest_version = db.session.query(
BomTable.version
).filter_by(
bom_no=bom_no
).order_by(
BomTable.version.desc()
).limit(1).scalar()
if not latest_version:
continue
# 获取父件名称
parent_row = db.session.query(
BomTable.parent_id, MaterialBase.name
).join(
MaterialBase, BomTable.parent_id == MaterialBase.id
).filter(
BomTable.bom_no == bom_no,
BomTable.version == latest_version
).first()
parent_name = parent_row.name if parent_row else ''
# 查询该 BOM 所有子件
rows = db.session.query(
BomTable, MaterialBase.name, MaterialBase.spec_model, MaterialBase.unit
).join(
MaterialBase, BomTable.child_id == MaterialBase.id
).filter(
BomTable.bom_no == bom_no,
BomTable.version == latest_version,
BomTable.is_enabled == True
).all()
entry_meta[bom_no] = {
'parent_name': parent_name,
'version': latest_version,
'children': {}
}
for bom, child_name, child_spec, child_unit in rows:
dosage = float(bom.dosage or 0)
loss_rate = float(bom.loss_rate or 0)
adj_dosage = dosage * (1 + loss_rate / 100.0)
qty_needed = adj_dosage * target_qty
# 记录 per-unit 用量(用于 max_producible 计算)
entry_meta[bom_no]['children'][bom.child_id] = {
'dosage': dosage,
'loss_rate': loss_rate,
'adj_dosage': adj_dosage,
'per_unit': adj_dosage # 每生产1套该BOM所需的此子件数量
}
if bom.child_id not in demand_map:
demand_map[bom.child_id] = {
'base_id': bom.child_id,
'material_name': child_name or '',
'spec': child_spec or '',
'unit': child_unit or '',
'required_qty': 0.0,
'bom_sources': []
}
demand_map[bom.child_id]['required_qty'] += qty_needed
demand_map[bom.child_id]['bom_sources'].append({
'bom_no': bom_no,
'dosage': dosage,
'loss_rate': loss_rate,
'target_qty': target_qty
})
# Step 2: 批量查询三张库存表的可用库存
child_ids = list(demand_map.keys())
if not child_ids:
return {'bom_summary': [], 'materials': []}
available_map = {cid: 0.0 for cid in child_ids}
for model_cls in (StockBuy, StockSemi, StockProduct):
rows = db.session.query(
model_cls.base_id,
func.coalesce(model_cls.available_quantity, 0)
).filter(
model_cls.base_id.in_(child_ids)
).all()
for base_id, qty in rows:
if base_id in available_map:
available_map[base_id] += float(qty)
# Step 3: 构造物料结果,计算缺口
materials = []
for base_id, info in demand_map.items():
avail = available_map.get(base_id, 0.0)
shortage = avail - info['required_qty']
materials.append({
'base_id': base_id,
'material_name': info['material_name'],
'spec': info['spec'],
'unit': info['unit'],
'required_qty': round(info['required_qty'], 4),
'available_qty': round(avail, 4),
'shortage': round(shortage, 4),
'bom_sources': info['bom_sources']
})
# 按缺件数量升序(最缺的排前面)
materials.sort(key=lambda x: x['shortage'])
# Step 4: 计算每个 BOM 的"当前库存可生产最大套数"(木桶效应)
# 算法:对每个 BOM 的所有子件,计算 Floor(available_qty / per_unit_demand)
# 取最小值 = 该 BOM 的最大可生产套数
bom_summary = []
for bom_no, meta in entry_meta.items():
min_producible = float('inf')
for child_id, child_info in meta['children'].items():
avail = available_map.get(child_id, 0.0)
per_unit = child_info['adj_dosage']
if per_unit > 0:
producible = int(avail // per_unit)
if producible < min_producible:
min_producible = producible
max_prod = int(min_producible) if min_producible != float('inf') else 0
bom_summary.append({
'bom_no': bom_no,
'parent_name': meta['parent_name'],
'version': meta['version'],
'max_producible': max_prod
})
return {
'bom_summary': bom_summary,
'materials': materials
}
# ====================== 兼容旧接口 ====================== # ====================== 兼容旧接口 ======================
@staticmethod @staticmethod
def get_bom_no_by_parent(parent_id): def get_bom_no_by_parent(parent_id):

View File

@ -1,5 +1,6 @@
from app.models.system import SysMenu, SysElement, SysRolePermission from app.models.system import SysMenu, SysElement, SysRolePermission
from app.extensions import db from app.extensions import db
from sqlalchemy import func
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
@ -401,7 +402,7 @@ class PermissionService:
# 定义菜单结构 (code, name, path, parent_code, sort_order) # 定义菜单结构 (code, name, path, parent_code, sort_order)
menu_defs = [ menu_defs = [
# 顶级菜单 (按侧边栏顺序) # 顶级菜单 (按侧边栏顺序)
('material_mgmt', '基础信息管理', '/material', None, 10), ('material_mgmt', '基础信息管理', '/basic', None, 10),
('inventory_mgmt', '入库管理', '/inventory', None, 20), ('inventory_mgmt', '入库管理', '/inventory', None, 20),
('stocktake_mgmt', '盘点管理', '/stocktake', None, 30), ('stocktake_mgmt', '盘点管理', '/stocktake', None, 30),
('outbound_mgmt', '出库管理', '/outbound', None, 40), ('outbound_mgmt', '出库管理', '/outbound', None, 40),
@ -410,8 +411,9 @@ class PermissionService:
('scrap_mgmt', '报废管理', '/scrap', None, 70), ('scrap_mgmt', '报废管理', '/scrap', None, 70),
('system_mgmt', '系统管理', '/system', None, 80), ('system_mgmt', '系统管理', '/system', None, 80),
# 基础信息子菜单 # 基础信息子菜单/basic 父级下的两个子路由)
('material_base', '基础信息', '/material/index', 'material_mgmt', 1), ('material_base', '物料基础信息', '/basic/material', 'material_mgmt', 1),
('basic_kitting', '产能与齐套分析', '/basic/kitting', 'material_mgmt', 2),
# 入库管理子菜单 # 入库管理子菜单
('inbound_buy', '采购入库', '/inventory/buy', 'inventory_mgmt', 1), ('inbound_buy', '采购入库', '/inventory/buy', 'inventory_mgmt', 1),

View File

@ -42,3 +42,45 @@ export function deleteBom(bomNo: string, version: string) {
method: 'delete' method: 'delete'
}) })
} }
// MRP 齐套模拟计算
export interface BomKittingEntry {
bom_no: string
target_qty: number
}
export interface BomSummary {
bom_no: string
parent_name: string
version: string
max_producible: number
}
export interface KittingMaterial {
base_id: number
material_name: string
spec: string
unit: string
required_qty: number
available_qty: number
shortage: number
bom_sources: Array<{
bom_no: string
dosage: number
loss_rate: number
target_qty: number
}>
}
export interface KittingResult {
bom_summary: BomSummary[]
materials: KittingMaterial[]
}
export function calculateKitting(entries: BomKittingEntry[]) {
return request<KittingResult>({
url: '/v1/bom/calculate-kitting',
method: 'post',
data: entries
})
}

View File

@ -0,0 +1,18 @@
import request from '@/utils/request'
// 读取当前用户的 preferences
export function getUserPreferences() {
return request({
url: '/v1/user/preferences',
method: 'get'
})
}
// 保存/更新当前用户的 preferences
export function saveUserPreferences(data: Record<string, any>) {
return request({
url: '/v1/user/preferences',
method: 'put',
data
})
}

View File

@ -39,17 +39,24 @@ const routes: Array<RouteRecordRaw> = [
] ]
}, },
// 3. 基础信息 // 3. 基础信息(父级菜单)
{ {
path: '/material', path: '/basic',
component: Layout, component: Layout,
redirect: '/material/index', meta: { title: '基础信息', icon: 'Box' },
redirect: '/basic/material',
children: [ children: [
{ {
path: 'index', path: 'material',
name: 'MaterialBase', name: 'MaterialBase',
component: () => import('@/views/material/list.vue'), component: () => import('@/views/material/list.vue'),
meta: { title: '基础信息', icon: 'Box' } meta: { title: '物料基础信息', icon: 'Box' }
},
{
path: 'kitting',
name: 'BasicKitting',
component: () => import('@/views/basic/kitting/index.vue'),
meta: { title: '产能与齐套分析', icon: 'Cpu' }
} }
] ]
}, },

View File

@ -0,0 +1,643 @@
<template>
<div class="app-container">
<el-card shadow="never">
<!-- ==================== 顶部工具栏 ==================== -->
<div class="toolbar">
<div class="toolbar-left">
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
添加监控设备
</el-button>
<el-button type="default" @click="openCalculatorDialog">
<el-icon><Cpu /></el-icon>
齐套推演计算器
</el-button>
</div>
<div class="toolbar-right">
<el-input
v-model="searchKeyword"
placeholder="搜索 BOM 编号或产品名称..."
style="width: 240px;"
clearable
@input="handleFilter"
prefix-icon="Search"
/>
<el-button :icon="Refresh" circle @click="loadData" />
</div>
</div>
<!-- ==================== 监控表格 ==================== -->
<el-table
v-loading="loading"
:data="filteredTableData"
border
stripe
:row-class-name="tableRowClassName"
height="calc(100vh - 240px)"
>
<el-table-column label="BOM编号" prop="bom_no" width="180" show-overflow-tooltip />
<el-table-column label="产品名称" prop="parent_name" min-width="160" show-overflow-tooltip />
<el-table-column label="规格" prop="spec" min-width="140" show-overflow-tooltip />
<el-table-column label="版本" prop="version" width="100" align="center" />
<el-table-column label="预警底线套数" prop="alert_threshold" width="130" align="center">
<template #default="{ row }">
<span v-if="!row._editing">{{ row.alert_threshold }}</span>
<el-input-number
v-else
v-model="row.alert_threshold"
:min="0"
:precision="0"
size="small"
style="width: 90px;"
/>
</template>
</el-table-column>
<el-table-column label="库存可生产个数" prop="max_producible" width="150" align="center" sortable>
<template #default="{ row }">
<span :class="getProducibleClass(row)" style="font-weight: 700; font-size: 15px;">
{{ row.max_producible }}
</span>
</template>
</el-table-column>
<el-table-column label="缺件物料数" prop="shortage_count" width="120" align="center" sortable>
<template #default="{ row }">
<span v-if="row.shortage_count > 0" style="color: #f56c6c; font-weight: 600;">
{{ row.shortage_count }}
</span>
<span v-else style="color: #67c23a;">无缺件</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.max_producible < row.alert_threshold" type="danger" effect="dark">预警</el-tag>
<el-tag v-else type="success" effect="dark">正常</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<template v-if="!row._editing">
<el-button type="primary" link size="small" @click="editThreshold(row)">改阈值</el-button>
<el-button type="danger" link size="small" @click="removeWatch(row)">删除</el-button>
</template>
<template v-else>
<el-button type="success" link size="small" @click="saveThreshold(row)">保存</el-button>
<el-button type="info" link size="small" @click="cancelEdit(row)">取消</el-button>
</template>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top: 12px;"
v-model:current-page="page"
v-model:page-size="pageSize"
:total="filteredTableData.length"
:page-sizes="[10, 20, 50, 100]"
layout="total, prev, pager, next"
background
/>
<!-- ==================== 添加监控设备对话框 ==================== -->
<el-dialog v-model="addDialogVisible" title="添加监控设备" width="680px">
<div class="add-dialog-body">
<el-form :model="addForm" label-width="100px">
<el-form-item label="选择 BOM">
<el-select
v-model="addForm.bom_no"
filterable
remote
reserve-keyword
placeholder="搜索 BOM 编号或成品名称..."
:remote-method="searchBomOptions"
:loading="bomSearchLoading"
style="width: 100%;"
@change="onAddBomSelected"
clearable
>
<el-option
v-for="item in bomOptions"
:key="item.bom_no"
:label="`${item.bom_no} (${item.parent_name})`"
:value="item.bom_no"
>
<div style="display: flex; justify-content: space-between;">
<span>{{ item.bom_no }}</span>
<span style="color: #999; font-size: 12px;">{{ item.parent_name }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="产品名称">
<el-input v-model="addForm.parent_name" disabled />
</el-form-item>
<el-form-item label="版本">
<el-input v-model="addForm.version" disabled />
</el-form-item>
<el-form-item label="预警底线套数">
<el-input-number v-model="addForm.alert_threshold" :min="0" :precision="0" style="width: 100%;" />
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" :disabled="!addForm.bom_no" @click="confirmAdd">确认添加</el-button>
</template>
</el-dialog>
<!-- ==================== 齐套推演计算器对话框 ==================== -->
<el-dialog v-model="calcDialogVisible" title="齐套推演计算器" width="900px">
<div class="calc-dialog-body">
<el-alert type="info" :closable="false" style="margin-bottom: 16px;">
选择 BOM 并设置计划生产数量系统将计算整体缺件情况及各产品的库存可生产最大套数木桶效应
</el-alert>
<!-- BOM 搜索 + 添加 -->
<el-row :gutter="12" style="margin-bottom: 16px;">
<el-col :span="12">
<el-select
v-model="calcPendingBomNo"
filterable
remote
reserve-keyword
placeholder="搜索 BOM 编号..."
:remote-method="searchBomOptions"
:loading="bomSearchLoading"
style="width: 100%;"
clearable
@change="onCalcBomSelected"
>
<el-option
v-for="item in bomOptions"
:key="item.bom_no"
:label="`${item.bom_no} (${item.parent_name})`"
:value="item.bom_no"
/>
</el-select>
</el-col>
<el-col :span="6">
<el-input-number v-model="calcPendingQty" :min="1" :max="999999" style="width: 100%;" />
</el-col>
<el-col :span="6">
<el-button type="primary" :disabled="!calcPendingBomNo" @click="addCalcBom" style="width: 100%;">
<el-icon><Plus /></el-icon> 添加
</el-button>
</el-col>
</el-row>
<!-- 计算中的 BOM 列表 -->
<el-table v-if="calcTargets.length" :data="calcTargets" border size="small" style="margin-bottom: 16px;">
<el-table-column label="BOM编号" prop="bom_no" width="200" />
<el-table-column label="产品名称" prop="parent_name" show-overflow-tooltip />
<el-table-column label="版本" prop="version" width="100" align="center" />
<el-table-column label="计划数量" width="130" align="center">
<template #default="{ row }">
<el-input-number v-model="row.target_qty" :min="1" :max="999999" size="small" style="width: 100px;" />
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center">
<template #default="{ $index }">
<el-button type="danger" link size="small" @click="removeCalcBom($index)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="请在上方添加 BOM" style="padding: 12px 0;" />
</div>
<!-- 计算结果 -->
<div v-if="calcResult" class="calc-result">
<div class="result-summary">
<el-tag type="success" effect="dark">参与计算{{ calcTargets.length }} BOM</el-tag>
<el-tag type="danger" effect="dark">缺件物料{{ calcResult.materials.filter((m: any) => m.shortage < 0).length }} </el-tag>
</div>
<!-- BOM 可生产套数汇总 -->
<div class="bom-summary-title"> BOM 库存可生产最大套数木桶效应</div>
<el-table :data="calcResult.bom_summary" border size="small" style="margin-bottom: 16px;">
<el-table-column label="BOM编号" prop="bom_no" width="160" />
<el-table-column label="产品名称" prop="parent_name" show-overflow-tooltip />
<el-table-column label="版本" prop="version" width="100" align="center" />
<el-table-column label="库存可生产最大套数" prop="max_producible" width="180" align="center" sortable>
<template #default="{ row }">
<span :class="row.max_producible > 0 ? 'shortage-green' : 'shortage-red'" style="font-weight: 700;">
{{ row.max_producible }}
</span>
</template>
</el-table-column>
</el-table>
<!-- 缺件物料明细 -->
<div class="shortage-title">缺件物料明细</div>
<el-table
v-if="shortageMaterials.length"
:data="shortageMaterials"
border
size="small"
max-height="280"
>
<el-table-column label="物料名称" prop="material_name" min-width="160" show-overflow-tooltip />
<el-table-column label="规格" prop="spec" min-width="120" show-overflow-tooltip />
<el-table-column label="单位" prop="unit" width="70" align="center" />
<el-table-column label="所需总数" prop="required_qty" width="110" align="right">
<template #default="{ row }">{{ row.required_qty.toFixed(4) }}</template>
</el-table-column>
<el-table-column label="当前库存" prop="available_qty" width="110" align="right">
<template #default="{ row }">{{ row.available_qty.toFixed(4) }}</template>
</el-table-column>
<el-table-column label="缺口" prop="shortage" width="110" align="right">
<template #default="{ row }">
<span class="shortage-red" style="font-weight: 600;">{{ row.shortage.toFixed(4) }}</span>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="各 BOM 所需物料均库存充足" />
</div>
<template #footer>
<el-button @click="calcDialogVisible = false">关闭</el-button>
<el-button
type="success"
:disabled="!calcTargets.length"
:loading="calculating"
@click="runCalculation"
>
<el-icon><Cpu /></el-icon>
开始模拟计算
</el-button>
</template>
</el-dialog>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Cpu, Delete, Refresh } from '@element-plus/icons-vue'
import { getUserPreferences, saveUserPreferences } from '@/api/user'
import { getBomList, calculateKitting } from '@/api/bom'
import type { KittingResult } from '@/api/bom'
// ---- 表格数据 ----
interface WatchItem {
bom_no: string
parent_name: string
spec: string
version: string
alert_threshold: number
max_producible: number
shortage_count: number
shortage_materials: any[]
_editing?: boolean
_original_threshold?: number
}
const loading = ref(false)
const tableData = ref<WatchItem[]>([])
const searchKeyword = ref('')
const page = ref(1)
const pageSize = ref(20)
const filteredTableData = computed(() => {
if (!searchKeyword.value.trim()) return paginatedData.value
const kw = searchKeyword.value.trim().toLowerCase()
return (tableData.value || []).filter((item: any) =>
(item.bom_no || '').toLowerCase().includes(kw) ||
(item.parent_name || '').toLowerCase().includes(kw)
)
})
const paginatedData = computed(() => {
const start = (page.value - 1) * pageSize.value
return (filteredTableData.value || []).slice(start, start + pageSize.value)
})
const handleFilter = () => {
page.value = 1
}
// ---- 表格行样式 ----
const getProducibleClass = (row: WatchItem) =>
row.max_producible < row.alert_threshold ? 'shortage-red' : 'shortage-green'
const tableRowClassName = ({ row }: { row: WatchItem }) =>
row.max_producible < row.alert_threshold ? 'danger-row' : ''
// ---- 数据加载 ----
const loadData = async () => {
loading.value = true
try {
// 1. 读取用户监控列表
const prefRes: any = await getUserPreferences()
const watchlist: any[] = (prefRes.data?.bom_kitting_watchlist) || []
if (!watchlist.length) {
tableData.value = []
loading.value = false
return
}
// 2. 调用齐套算法(以 target_qty=1 计算当前库存可生产套数)
const entries = watchlist.map(item => ({
bom_no: item.bom_no,
target_qty: item.alert_threshold > 0 ? item.alert_threshold : 1
}))
const kittingRes: any = await calculateKitting(entries)
if (kittingRes.code !== 200) {
ElMessage.error(kittingRes.msg || '计算失败')
tableData.value = []
loading.value = false
return
}
const result: KittingResult = kittingRes.data || { bom_summary: [], materials: [] }
// 3. 合并 watchlist 和计算结果
tableData.value = watchlist.map(item => {
const summary = result.bom_summary.find(s => s.bom_no === item.bom_no) || {
max_producible: 0
}
const shortageMaterials = result.materials.filter(m =>
m.shortage < 0 && m.bom_sources.some((s: any) => s.bom_no === item.bom_no)
)
return {
bom_no: item.bom_no,
parent_name: item.parent_name || '',
spec: item.spec || '',
version: item.version || '',
alert_threshold: item.alert_threshold || 0,
max_producible: summary.max_producible || 0,
shortage_count: shortageMaterials.length,
shortage_materials: shortageMaterials
}
})
} catch (e: any) {
ElMessage.error(e?.message || '加载失败')
} finally {
loading.value = false
}
}
// ---- 添加监控设备 ----
const addDialogVisible = ref(false)
const bomOptions = ref<any[]>([])
const bomSearchLoading = ref(false)
let bomSearchTimer: ReturnType<typeof setTimeout> | null = null
const addForm = ref({
bom_no: '',
parent_name: '',
version: '',
spec: '',
alert_threshold: 10
})
const searchBomOptions = async (query: string) => {
if (bomSearchTimer) clearTimeout(bomSearchTimer)
bomSearchTimer = setTimeout(async () => {
bomSearchLoading.value = true
try {
const res: any = await getBomList({ keyword: query, active_only: true })
bomOptions.value = res.data || []
} catch {
bomOptions.value = []
} finally {
bomSearchLoading.value = false
}
}, 300)
}
const onAddBomSelected = async (bomNo: string) => {
if (!bomNo) { addForm.value = { bom_no: '', parent_name: '', version: '', spec: '', alert_threshold: 10 }; return }
const found = bomOptions.value.find(b => b.bom_no === bomNo)
if (found) {
addForm.value.parent_name = found.parent_name || ''
addForm.value.version = found.version || ''
addForm.value.spec = found.parent_spec || ''
}
}
const openAddDialog = () => {
addForm.value = { bom_no: '', parent_name: '', version: '', spec: '', alert_threshold: 10 }
bomOptions.value = []
addDialogVisible.value = true
}
const confirmAdd = async () => {
if (!addForm.value.bom_no) {
ElMessage.warning('请选择 BOM')
return
}
if (tableData.value.find(t => t.bom_no === addForm.value.bom_no)) {
ElMessage.warning('该 BOM 已在监控列表中')
return
}
// 追加到 preferences
const prefRes: any = await getUserPreferences().catch(() => ({ data: {} }))
const prefs = prefRes.data || {}
const watchlist: any[] = prefs.bom_kitting_watchlist || []
watchlist.push({
bom_no: addForm.value.bom_no,
parent_name: addForm.value.parent_name,
spec: addForm.value.spec,
version: addForm.value.version,
alert_threshold: addForm.value.alert_threshold
})
await saveUserPreferences({ ...prefs, bom_kitting_watchlist: watchlist })
ElMessage.success('添加成功')
addDialogVisible.value = false
await loadData()
}
// ---- 编辑预警阈值 ----
const editThreshold = (row: WatchItem) => {
row._editing = true
row._original_threshold = row.alert_threshold
}
const cancelEdit = (row: WatchItem) => {
row.alert_threshold = row._original_threshold ?? row.alert_threshold
row._editing = false
}
const saveThreshold = async (row: WatchItem) => {
try {
const prefRes: any = await getUserPreferences().catch(() => ({ data: {} }))
const prefs = prefRes.data || {}
const watchlist: any[] = (prefs.bom_kitting_watchlist || []).map((item: any) =>
item.bom_no === row.bom_no ? { ...item, alert_threshold: row.alert_threshold } : item
)
await saveUserPreferences({ ...prefs, bom_kitting_watchlist: watchlist })
row._editing = false
ElMessage.success('阈值已更新')
await loadData()
} catch {
ElMessage.error('保存失败')
}
}
// ---- 删除监控 ----
const removeWatch = async (row: WatchItem) => {
await ElMessageBox.confirm(`确定移除「${row.bom_no}」的监控?`, '确认删除', { type: 'warning' })
try {
const prefRes: any = await getUserPreferences().catch(() => ({ data: {} }))
const prefs = prefRes.data || {}
const watchlist = (prefs.bom_kitting_watchlist || []).filter(
(item: any) => item.bom_no !== row.bom_no
)
await saveUserPreferences({ ...prefs, bom_kitting_watchlist: watchlist })
ElMessage.success('已移除')
await loadData()
} catch {
ElMessage.error('移除失败')
}
}
// ---- 齐套推演计算器 ----
const calcDialogVisible = ref(false)
const calcPendingBomNo = ref('')
const calcPendingQty = ref(1)
const calcTargets = ref<any[]>([])
const calculating = ref(false)
const calcResult = ref<KittingResult | null>(null)
const openCalculatorDialog = () => {
calcPendingBomNo.value = ''
calcPendingQty.value = 1
calcTargets.value = []
calcResult.value = null
bomOptions.value = []
calcDialogVisible.value = true
}
const onCalcBomSelected = async (bomNo: string) => {
if (!bomNo) return
const found = bomOptions.value.find(b => b.bom_no === bomNo)
if (found && !calcTargets.value.find(t => t.bom_no === bomNo)) {
calcTargets.value.push({
bom_no: found.bom_no,
parent_name: found.parent_name || '',
version: found.version || '',
target_qty: calcPendingQty.value || 1
})
}
calcPendingBomNo.value = ''
calcPendingQty.value = 1
}
const addCalcBom = () => {
onCalcBomSelected(calcPendingBomNo.value)
}
const removeCalcBom = (index: number) => {
calcTargets.value.splice(index, 1)
}
const runCalculation = async () => {
if (!calcTargets.value.length) {
ElMessage.warning('请先添加 BOM')
return
}
calculating.value = true
calcResult.value = null
try {
const entries = calcTargets.value.map(t => ({
bom_no: t.bom_no,
target_qty: t.target_qty
}))
const res: any = await calculateKitting(entries)
if (res.code === 200) {
calcResult.value = res.data as KittingResult
ElMessage.success('计算完成')
} else {
ElMessage.error(res.msg || '计算失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '网络错误')
} finally {
calculating.value = false
}
}
const shortageMaterials = computed(() =>
(calcResult.value?.materials || []).filter((m: any) => m.shortage < 0)
)
// ---- 挂载加载 ----
onMounted(() => {
loadData()
})
</script>
<style scoped>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
flex-wrap: wrap;
gap: 10px;
}
.toolbar-left {
display: flex;
gap: 10px;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.add-dialog-body {
padding: 8px 0;
}
.calc-dialog-body {
max-height: 420px;
overflow-y: auto;
}
.calc-result {
margin-top: 16px;
border-top: 1px solid #ebeef5;
padding-top: 16px;
}
.result-summary {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.bom-summary-title {
font-size: 13px;
font-weight: 600;
color: #606266;
margin-bottom: 8px;
}
.shortage-title {
font-size: 13px;
font-weight: 600;
color: #f56c6c;
margin-bottom: 8px;
}
.shortage-red {
color: #f56c6c;
}
.shortage-green {
color: #67c23a;
}
:deep(.danger-row) {
background-color: #fef0f0 !important;
}
:deep(.danger-row:hover > td) {
background-color: #fee !important;
}
</style>