Files
KCGL/inventory-web/src/views/basic/kitting/index.vue

644 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 =>
(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>