feat: implement MRP kitting calculator for production simulation and shared component analysis

This commit is contained in:
DXC
2026-03-24 09:10:56 +08:00
parent 5fe645dc0b
commit 706d7e551c
5 changed files with 665 additions and 0 deletions

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
results = BomService.calculate_kitting(entries)
return jsonify({
'code': 200,
'msg': '计算成功',
'data': results
})
except Exception as e:
current_app.logger.error(f'MRP齐套计算失败: {str(e)}')
return jsonify({'code': 500, 'msg': f'计算失败: {str(e)}'}), 500

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,117 @@ class BomService:
return detail return detail
# ====================== MRP 齐套模拟计算 ======================
@staticmethod
def calculate_kitting(entries: list) -> list:
"""
MRP 齐套模拟计算
算法步骤:
1. 遍历传入的 BOM取每个 BOM 最新版本的子件
2. 按子件 base_id 合并需求量:需求量 = dosage * (1 + loss_rate/100) * target_qty
3. 跨 StockBuy / StockSemi / StockProduct 聚合可用库存
4. 计算缺口shortage = available_quantity - required_quantity
:param entries: [{"bom_no": "BOM-001", "target_qty": 10}, ...]
:return: [{base_id, name, spec, unit, required_qty, available_qty, shortage, children: [...]}, ...]
"""
# Step 1: 展开所有 BOM 的子件,聚合需求量
demand_map = {} # child_id -> {base_id, material_name, spec, unit, required_qty, bom_sources: []}
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
# 查询该 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()
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
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 []
# StockBuy.available_quantity, StockSemi.available_quantity, StockProduct.available_quantity
available_map = {cid: 0.0 for cid in child_ids}
for model_cls in (StockBuy, StockSemi, StockProduct):
if model_cls is None:
continue
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: 构造结果,计算缺口
results = []
for base_id, info in demand_map.items():
avail = available_map.get(base_id, 0.0)
shortage = avail - info['required_qty']
results.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']
})
# 按缺件数量降序排列(最缺的排前面)
results.sort(key=lambda x: x['shortage'])
return results
# ====================== 兼容旧接口 ====================== # ====================== 兼容旧接口 ======================
@staticmethod @staticmethod
def get_bom_no_by_parent(parent_id): def get_bom_no_by_parent(parent_id):

View File

@ -42,3 +42,12 @@ export function deleteBom(bomNo: string, version: string) {
method: 'delete' method: 'delete'
}) })
} }
// MRP 齐套模拟计算
export function calculateKitting(entries: { bom_no: string; target_qty: number }[]) {
return request({
url: '/v1/bom/calculate-kitting',
method: 'post',
data: entries
})
}

View File

@ -159,6 +159,12 @@ const routes: Array<RouteRecordRaw> = [
name: 'BomManage', name: 'BomManage',
component: BomManage, component: BomManage,
meta: { title: 'BOM配方管理', icon: 'list' } meta: { title: 'BOM配方管理', icon: 'list' }
},
{
path: 'kitting',
name: 'BomKitting',
component: () => import('@/views/basic/kitting/index.vue'),
meta: { title: '齐套计算器', icon: 'Cpu' }
} }
] ]
}, },

View File

@ -0,0 +1,501 @@
<template>
<div class="app-container">
<!-- ==================== 排产目标区 ==================== -->
<el-card shadow="always" style="margin-bottom: 16px;">
<template #header>
<div class="card-header">
<span class="title">排产目标</span>
<span class="sub-title">选择 BOM 并设置计划生产数量</span>
</div>
</template>
<el-row :gutter="20" align="middle">
<!-- BOM 搜索选择器 -->
<el-col :span="10">
<el-select
v-model="pendingBomNo"
filterable
remote
reserve-keyword
placeholder="搜索 BOM 编号或成品名称..."
:remote-method="searchBomOptions"
:loading="bomSearchLoading"
style="width: 100%"
@change="onBomSelected"
clearable
default-first-option
>
<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-col>
<!-- 计划数量 -->
<el-col :span="4">
<el-input-number
v-model="pendingQty"
:min="1"
:max="999999"
placeholder="计划数量"
style="width: 100%"
/>
</el-col>
<!-- 添加按钮 -->
<el-col :span="4">
<el-button
type="primary"
:disabled="!pendingBomNo || !pendingQty"
@click="addBomTarget"
>
<el-icon><Plus /></el-icon>
添加到排产
</el-button>
</el-col>
<!-- 模拟计算按钮 -->
<el-col :span="6" style="text-align: right;">
<el-button
type="success"
size="large"
:disabled="!bomTargets.length"
:loading="calculating"
@click="runSimulation"
>
<el-icon><Cpu /></el-icon>
开始模拟计算
</el-button>
</el-col>
</el-row>
<!-- 已选择的 BOM 列表 -->
<el-table
v-if="bomTargets.length"
:data="bomTargets"
border
style="margin-top: 16px;"
size="small"
>
<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="150" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.target_qty"
:min="1"
:max="999999"
size="small"
@change="() => {}"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button type="danger" size="small" text @click="removeBomTarget($index)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="请在上方搜索并添加 BOM 目标" style="padding: 20px 0;" />
</el-card>
<!-- ==================== 齐套分析结果区 ==================== -->
<el-card shadow="always" v-loading="calculating">
<template #header>
<div class="card-header">
<span class="title">齐套分析结果</span>
<div class="header-right">
<!-- 搜索过滤 -->
<el-input
v-model="resultKeyword"
placeholder="搜索物料名称/规格..."
style="width: 220px; margin-right: 10px;"
clearable
@input="filterResults"
prefix-icon="Search"
/>
<!-- 批量设置预警 -->
<el-button
type="warning"
:disabled="!shortageItems.length"
:loading="batchWarningLoading"
@click="handleBatchWarning"
>
<el-icon><Bell /></el-icon>
批量设置预警
</el-button>
</div>
</div>
</template>
<!-- 结果统计 -->
<div v-if="resultData.length" class="summary-bar">
<el-tag type="success" effect="dark">总物料数{{ resultData.length }}</el-tag>
<el-tag type="danger" effect="dark">缺件数量{{ shortageCount }}</el-tag>
<el-tag type="warning" effect="dark">库存不足{{ shortagePercent }}%</el-tag>
</div>
<!-- 结果表格 -->
<el-table
v-if="filteredData.length"
:data="paginatedData"
border
:row-class-name="tableRowClassName"
height="calc(100vh - 560px)"
:expand-row-keys="expandedRows"
@expand-change="toggleExpand"
>
<el-table-column type="expand" width="50">
<template #default="{ row }">
<div style="padding: 8px 16px; background: #f5f7fa; border-radius: 4px;">
<p style="margin: 0 0 8px; font-size: 12px; color: #909399; font-weight: bold;">BOM 来源明细</p>
<el-table :data="row.bom_sources" size="small" border>
<el-table-column prop="bom_no" label="BOM编号" />
<el-table-column prop="dosage" label="单位用量" align="right" />
<el-table-column prop="loss_rate" label="损耗率%" align="right" />
<el-table-column prop="target_qty" label="目标数量" align="right" />
<el-table-column label="小计" align="right">
<template #default="{ row: src }">
{{ ((src.dosage || 0) * (1 + (src.loss_rate || 0) / 100) * (src.target_qty || 0)).toFixed(4) }}
</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column label="物料名称" prop="material_name" show-overflow-tooltip min-width="160" />
<el-table-column label="规格" prop="spec" show-overflow-tooltip min-width="140" />
<el-table-column label="单位" prop="unit" width="70" align="center" />
<el-table-column label="所需总数" prop="required_qty" width="120" align="right" sortable>
<template #default="{ row }">{{ row.required_qty.toFixed(4) }}</template>
</el-table-column>
<el-table-column label="当前可用库存" prop="available_qty" width="130" align="right" sortable>
<template #default="{ row }">{{ row.available_qty.toFixed(4) }}</template>
</el-table-column>
<el-table-column label="缺口" prop="shortage" width="120" align="right" sortable>
<template #default="{ row }">
<span :class="row.shortage < 0 ? 'shortage-red' : 'shortage-green'">
{{ row.shortage.toFixed(4) }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.shortage < 0" type="danger" size="small">缺件</el-tag>
<el-tag v-else type="success" size="small">充足</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-else-if="!calculating && hasCalculated" description="暂无分析结果" style="padding: 20px 0;" />
<el-empty v-else description="请先在上方设置排产目标并点击「开始模拟计算」" style="padding: 40px 0;" />
<!-- 分页 -->
<el-pagination
v-if="filteredData.length"
style="margin-top: 12px;"
v-model:current-page="resultPage"
v-model:page-size="resultPageSize"
:total="filteredData.length"
:page-sizes="[20, 50, 100, 200]"
layout="total, prev, pager, next"
background
@size-change="resultPage = 1"
/>
</el-card>
<!-- 批量预警设置对话框 -->
<el-dialog v-model="warningDialogVisible" title="批量设置预警阈值" width="500px">
<el-form :model="warningForm" label-width="100px">
<el-form-item label="黄线预警阈值">
<el-input-number
v-model="warningForm.yellowThreshold"
:min="0"
:precision="2"
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="红线预警阈值">
<el-input-number
v-model="warningForm.redThreshold"
:min="0"
:precision="2"
style="width: 100%;"
/>
</el-form-item>
<el-form-item>
<el-alert type="info" :closable="false">
将为以下 {{ shortageItems.length }} 种缺件物料统一设置预警阈值
</el-alert>
</el-form-item>
<el-form-item>
<div class="shortage-preview">
<el-tag v-for="item in shortageItems" :key="item.base_id" type="danger" size="small" style="margin: 2px;">
{{ item.material_name }}
</el-tag>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="warningDialogVisible = false">取消</el-button>
<el-button type="warning" :loading="submitting" @click="submitBatchWarning">确认设置</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Cpu, Delete, Bell } from '@element-plus/icons-vue'
import { getBomList, calculateKitting } from '@/api/bom'
import { batchSetWarning } from '@/api/material_base'
// ---- BOM 搜索相关 ----
const pendingBomNo = ref('')
const pendingQty = ref(1)
const bomOptions = ref<any[]>([])
const bomSearchLoading = ref(false)
let bomSearchTimer: ReturnType<typeof setTimeout> | null = null
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)
}
// ---- 排产目标列表 ----
interface BomTarget {
bom_no: string
parent_name: string
version: string
target_qty: number
}
const bomTargets = ref<BomTarget[]>([])
const onBomSelected = async (bomNo: string) => {
if (!bomNo) return
const found = bomOptions.value.find(b => b.bom_no === bomNo)
if (found && !bomTargets.value.find(t => t.bom_no === bomNo)) {
bomTargets.value.push({
bom_no: found.bom_no,
parent_name: found.parent_name,
version: found.version,
target_qty: pendingQty.value || 1
})
}
pendingBomNo.value = ''
pendingQty.value = 1
}
const addBomTarget = () => {
onBomSelected(pendingBomNo.value)
}
const removeBomTarget = (index: number) => {
bomTargets.value.splice(index, 1)
}
// ---- 模拟计算 ----
const calculating = ref(false)
const resultData = ref<any[]>([])
const hasCalculated = ref(false)
const runSimulation = async () => {
if (!bomTargets.value.length) {
ElMessage.warning('请先添加 BOM 排产目标')
return
}
calculating.value = true
hasCalculated.value = false
try {
const entries = bomTargets.value.map(t => ({
bom_no: t.bom_no,
target_qty: t.target_qty
}))
const res: any = await calculateKitting(entries)
if (res.code === 200) {
resultData.value = res.data || []
hasCalculated.value = true
resultPage.value = 1
ElMessage.success(`齐套分析完成,共 ${resultData.value.length} 种物料`)
} else {
ElMessage.error(res.msg || '计算失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '网络错误,计算失败')
} finally {
calculating.value = false
}
}
// ---- 结果过滤与分页 ----
const resultKeyword = ref('')
const resultPage = ref(1)
const resultPageSize = ref(20)
const expandedRows = ref<number[]>([])
const filteredData = computed(() => {
if (!resultKeyword.value.trim()) return resultData.value
const kw = resultKeyword.value.trim().toLowerCase()
return resultData.value.filter(item =>
(item.material_name || '').toLowerCase().includes(kw) ||
(item.spec || '').toLowerCase().includes(kw)
)
})
const paginatedData = computed(() => {
const start = (resultPage.value - 1) * resultPageSize.value
return filteredData.value.slice(start, start + resultPageSize.value)
})
const filterResults = () => {
resultPage.value = 1
}
const shortageCount = computed(() =>
resultData.value.filter(r => r.shortage < 0).length
)
const shortagePercent = computed(() =>
resultData.value.length
? Math.round((shortageCount.value / resultData.value.length) * 100)
: 0
)
const shortageItems = computed(() =>
resultData.value.filter(r => r.shortage < 0)
)
// ---- 行展开 ----
const toggleExpand = (row: any) => {
const idx = expandedRows.value.indexOf(row.base_id)
if (idx >= 0) {
expandedRows.value.splice(idx, 1)
} else {
expandedRows.value.push(row.base_id)
}
}
// ---- 表格行样式 ----
const tableRowClassName = ({ row }: { row: any }) =>
row.shortage < 0 ? 'shortage-row' : ''
// ---- 批量设置预警 ----
const warningDialogVisible = ref(false)
const batchWarningLoading = ref(false)
const submitting = ref(false)
const warningForm = ref({ yellowThreshold: 10, redThreshold: 5 })
const handleBatchWarning = () => {
warningForm.value.yellowThreshold = 10
warningForm.value.redThreshold = 5
warningDialogVisible.value = true
}
const submitBatchWarning = async () => {
if (!shortageItems.value.length) {
ElMessage.warning('没有缺件物料,无需设置预警')
return
}
submitting.value = true
try {
const payload = shortageItems.value.map(item => ({
baseId: item.base_id,
isEnabled: true,
yellowThreshold: warningForm.value.yellowThreshold,
redThreshold: warningForm.value.redThreshold
}))
const res: any = await batchSetWarning(payload)
if (res.code === 200) {
ElMessage.success(`批量设置成功:新建 ${res.data?.created || 0} 条,更新 ${res.data?.updated || 0}`)
warningDialogVisible.value = false
} else {
ElMessage.error(res.msg || '设置失败')
}
} catch {
ElMessage.error('批量设置预警失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.card-header {
display: flex;
align-items: center;
gap: 12px;
}
.title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.sub-title {
font-size: 13px;
color: #909399;
}
.header-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
.summary-bar {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.shortage-red {
color: #f56c6c;
font-weight: 600;
}
.shortage-green {
color: #67c23a;
}
.shortage-preview {
max-height: 160px;
overflow-y: auto;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* 缺件行高亮 */
:deep(.shortage-row) {
background-color: #fef0f0 !important;
}
:deep(.shortage-row:hover > td) {
background-color: #fee !important;
}
</style>