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

@ -42,3 +42,12 @@ export function deleteBom(bomNo: string, version: string) {
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',
component: BomManage,
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>