Files
KCGL/inventory-web/src/views/bom/BomManage.vue
yueli 68fd93b457 fix: BOM跳转恢复 + 采购单导入优化 + 基础信息表单禁用守卫
- BOM: 移除父件/子件跳转按钮的 isReadOnlyMode 限制, 任何模式均可跳转
- BOM: 补全缺失的5个权限码(sys_element + sys_role_permission)
- 采购单导入: supplier_link→detail_link, 新增采购人/邮箱自动填充
- 采购单导入: 实时搜索(300ms防抖)+模糊匹配, 保留搜索按钮
- 采购单导入: 移除原始/详情链接的历史 autocomplete
- 基础信息: el-form 增加 formDisabled, 无 material_list:operation 时全局禁用
- /approved-unstocked API 增加 permission_required(inbound_buy)
- PurchaseRequest.to_dict() 增加 requester_email
2026-07-14 17:02:07 +08:00

947 lines
35 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="always">
<template #header>
<div class="card-header">
<span class="title">BOM 配方管理</span>
<div class="header-right">
<el-input
v-model="searchKeyword"
placeholder="搜索 编号/名称/规格/子件..."
style="width: 300px; margin-right: 10px;"
clearable
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #append>
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
<el-button @click="activeCategories = bomGroups.map((g: any) => g.category)" size="small" style="margin-right: 6px;">全部展开</el-button>
<el-button @click="activeCategories = []" size="small" style="margin-right: 10px;">全部折叠</el-button>
<el-button v-if="userStore.hasPermission('bom_manage:operation')" type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button>
</div>
</div>
</template>
<el-skeleton :rows="8" animated v-if="loading && bomGroups.length === 0" />
<el-empty v-else-if="!loading && bomGroups.length === 0" description="暂无 BOM 数据" />
<el-collapse v-else v-model="activeCategories" class="bom-category-collapse">
<el-collapse-item
v-for="group in bomGroups"
:key="group.category"
:title="group.category + ' (' + group.count + ')'"
:name="group.category"
>
<el-table v-if="activeCategories.includes(group.category)" :data="group.items" border style="width: 100%">
<el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable>
<template #default="{ row }">
<span style="cursor: pointer; color: #409EFF;" @click="handleView(row)">{{ row.bom_no }}</span>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="150" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('version')" label="版本" width="100" align="center">
<template #default="{ row }">
<el-tag>{{ row.version }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'danger'">{{ row.is_enabled ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-collapse-item>
</el-collapse>
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="form" label-width="120px" ref="formRef" :rules="rules">
<el-row :gutter="20">
<el-col :span="16">
<el-form-item label="父件 (成品)" prop="parent_id" v-if="hasFormFieldPermission('parent_id')">
<!-- ====== 父件搜索 - el-autocomplete ====== -->
<el-autocomplete
v-model="parentNameInput"
:fetch-suggestions="fetchParentSuggestions"
:value-key="'name'"
:validate-event="false"
clearable
placeholder="请搜索并选择父件"
:loading="searchLoading"
:trigger-on-focus="true"
style="width: 100%"
:disabled="isReadOnlyMode || isEditMode"
@select="onParentSelected"
@clear="onParentClear"
popper-class="bom-parent-popper"
>
<template #default="{ item }">
<div class="option-row">
<span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span>
</div>
</template>
</el-autocomplete>
<el-link
v-if="form.parent_id"
type="primary"
:underline="false"
style="margin-left: 12px; font-size: 13px;"
@click="openParentMaterial"
>
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
</el-link>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')">
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="isReadOnlyMode || !userStore.hasPermission('bom_manage:operation')" />
</el-form-item>
</el-col>
<el-col :span="16"></el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="10">
<el-form-item label="BOM 编号" required v-if="hasFormFieldPermission('bom_no')">
<el-input v-model="form.bom_no" placeholder="选择父件后自动生成" disabled />
<div style="font-size: 12px; color: #909399; line-height: 1.2; margin-top: 4px;">
编号预览: <span style="font-weight: bold">{{ form.bom_no || '请选择父件' }}</span>
</div>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="备注" v-if="hasFormFieldPermission('remark')">
<el-input v-model="form.remark" placeholder="备注信息(可选)" :disabled="isReadOnlyMode" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="版本号" prop="version" v-if="hasFormFieldPermission('version')">
<template v-if="isSaveAsMode">
<el-radio-group v-model="form.versionUpgradeType" :disabled="isReadOnlyMode" @change="onVersionUpgradeTypeChange">
<el-radio-button label="minor">升级次版本 ({{ versionOptions.minor }})</el-radio-button>
<el-radio-button label="major">升级主版本 ({{ versionOptions.major }})</el-radio-button>
</el-radio-group>
</template>
<template v-else>
<el-input v-model="form.version" placeholder="如: V1.0" :disabled="isReadOnlyMode" />
</template>
</el-form-item>
</el-col>
</el-row>
<div style="font-weight: bold; margin: 15px 0 10px 0; border-left: 4px solid #409EFF; padding-left: 10px;">
子件列表
<span style="font-weight: normal; font-size: 12px; color: #909399; margin-left: 10px;">(已选 {{ filteredChildren.length }} 条)</span>
</div>
<el-input
v-model="childSearchKeyword"
placeholder="请输入子件名称或规格型号搜索"
clearable
style="width: 300px; margin-bottom: 10px;"
:prefix-icon="Search"
:disabled="isReadOnlyMode"
/>
<el-table :data="filteredChildren" border style="width: 100%; margin-bottom: 15px" max-height="300">
<el-table-column label="子件物料" min-width="250" v-if="hasFormFieldPermission('child_id')">
<template #default="{ row }">
<!-- ====== 子件搜索 - el-autocomplete,行级绑定 ====== -->
<div style="display: flex; align-items: center; gap: 8px;">
<el-autocomplete
v-model="row.material_name"
:fetch-suggestions="(q: string, cb: (results: any[]) => void) => fetchChildSuggestions(row, q, cb)"
:value-key="'name'"
:validate-event="false"
clearable
placeholder="请搜索原料"
:loading="searchLoading"
:trigger-on-focus="true"
style="flex: 1;"
:disabled="isReadOnlyMode"
@select="(item: any) => onChildSelected(row, item)"
@clear="() => onChildClear(row)"
>
<template #default="{ item }">
<div class="option-row">
<span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span>
</div>
</template>
</el-autocomplete>
<el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id">
<el-button
type="primary"
link
:icon="EditPen"
@click.stop="openMaterialInNewTab(row.child_id, row.material_spec)"
style="font-size: 16px; padding: 4px;"
/>
</el-tooltip>
</div>
</template>
</el-table-column>
<el-table-column label="用量" width="140" v-if="hasFormFieldPermission('dosage')">
<template #default="{ row }">
<el-input-number v-model="row.dosage" :min="0" :precision="0" style="width: 100%" :controls="false" :disabled="isReadOnlyMode" />
</template>
</el-table-column>
<el-table-column label="备注" width="150" v-if="hasFormFieldPermission('remark')">
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" :disabled="isReadOnlyMode" />
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center" v-if="userStore.hasPermission('bom_manage:operation')">
<template #default="{ row }">
<el-button v-if="!isReadOnlyMode" type="danger" link @click="removeChild(row.rowKey)">删</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 10px; text-align: center;" v-if="hasFormFieldPermission('child_id') && !isReadOnlyMode">
<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">{{ isReadOnlyMode ? '关闭' : '取消' }}</el-button>
<el-button
v-if="!isReadOnlyMode"
type="warning"
plain
:loading="saving"
@click="saveDraftData"
>
暂存草稿
</el-button>
<el-button
v-if="!isReadOnlyMode"
type="primary"
:loading="saving"
@click="submitForm"
>
保 存
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { getBomList, getBomDetail, saveBom, deleteBom, getDraftDetail, saveDraft, publishDraft } from '@/api/bom'
import { searchMaterialBase } from '@/api/inbound/buy'
import { useUserStore } from '@/stores/user'
// ============================================================
// 类型定义
// ============================================================
interface BomItem {
bom_no: string
parent_id: number
parent_name: string
version: string
is_enabled: boolean
child_count: number
}
interface MaterialBase {
id: number
name: string
spec: string
}
interface ChildRow {
rowKey: number // 唯一标识,替代 $index 作为 Map key
child_id: number | null
material_name: string
material_spec: string
dosage: number
remark: string
}
const originalDraftHash = ref('')
const getDraftHash = () => {
const children = form.children.map(c => ({
child_id: c.child_id,
dosage: Number(c.dosage) || 0,
remark: c.remark || ''
}))
return JSON.stringify({
bom_no: form.bom_no,
version: form.version,
parent_id: form.parent_id,
children
})
}
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
const isEditMode = ref(false)
const isSaveAsMode = ref(false)
const isReadOnlyMode = ref(false)
let originalVersion = ''
let currentBomNo = ''
let originalChildren: ChildRow[] = []
let pendingDraftBomNo = ''
let pendingDraftVersion = ''
const bomGroups = ref([]) // 分组结构: [{category, count, items[]}]
const activeCategories = ref([]) // 默认全部展开
const searchKeyword = ref('')
const childSearchKeyword = ref('')
const filteredChildren = computed(() => {
if (!childSearchKeyword.value) return form.children
const kw = childSearchKeyword.value.toLowerCase()
return form.children.filter(child => {
const name = (child.material_name || '').toLowerCase()
const spec = (child.material_spec || '').toLowerCase()
return name.includes(kw) || spec.includes(kw)
})
})
// 自动搜索:输入后 500ms 防抖触发搜索(无需回车)
watch(searchKeyword, (val) => {
// 防抖:延迟 500ms 执行,避免频繁请求
clearTimeout((window as any)._bomSearchTimer)
;(window as any)._bomSearchTimer = setTimeout(() => {
fetchBomList()
}, 500)
})
// ============================================================
// Material Search - el-autocomplete
// ============================================================
const searchLoading = ref(false)
const parentNameInput = ref('')
const fetchParentSuggestions = (query: string, cb: (results: any[]) => void) => {
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true
searchMaterialBase(safeQuery).then((res: any) => {
const items = res.data?.items || res.data || []
const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name }))
cb(formatted)
}).catch(() => cb([])).finally(() => { searchLoading.value = false })
}
const onParentClear = () => {
form.parent_id = null
form.bom_no = ''
}
const onParentSelected = (item: any) => {
form.parent_id = item.id
parentNameInput.value = item.name || item.material_name || ''
if (item.spec) {
form.bom_no = item.spec.split('/')[0].trim()
} else {
form.bom_no = ''
}
}
const fetchChildSuggestions = (row: any, query: string, cb: (results: any[]) => void) => {
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true
searchMaterialBase(safeQuery).then((res: any) => {
const items = res.data?.items || res.data || []
const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name }))
cb(formatted)
}).catch(() => cb([])).finally(() => { searchLoading.value = false })
}
const onChildClear = (row: any) => {
row.child_id = null
row.material_name = ''
row.material_spec = ''
row.dosage = 0
}
const onChildSelected = (row: any, item: any) => {
const existingIndex = form.children.findIndex((child, idx) => idx !== form.children.indexOf(row) && child.child_id === item.id)
if (existingIndex !== -1) {
ElMessage.warning(`该物料已在第 ${existingIndex + 1} 行存在,请合并用量或删除重复项`)
row.child_id = null
row.material_name = ''
row.dosage = 0
return
}
row.child_id = item.id
row.material_name = item.name
row.material_spec = item.spec
row.dosage = 1
}
const getChildSpec = (rowKey: number): string => {
const row = form.children.find(c => c.rowKey === rowKey)
return row?.material_spec || ''
}
// 在新标签页打开基础信息编辑
const openMaterialInNewTab = (targetId: number | null, keyword: string = '') => {
if (!targetId) return ElMessage.warning('请先选择物料')
const routeUrl = router.resolve({
path: '/material',
query: { edit_id: targetId, keyword }
})
window.open(routeUrl.href, '_blank')
}
const openParentMaterial = () => {
if (!form.parent_id) return ElMessage.warning('请先选择父件')
const keyword = parentNameInput.value || ''
openMaterialInNewTab(form.parent_id, keyword)
}
// 列与权限Code的映射关系(数据库中的code)
const permissionMap: Record<string, string> = {
bom_no: 'bom_manage:bom_no',
parent_name: 'bom_manage:parent_name',
parent_spec: 'bom_manage:parent_spec',
version: 'bom_manage:version',
status: 'bom_manage:status',
child_count: 'bom_manage:child_count',
parent_id: 'bom_manage:parent_id',
is_enabled: 'bom_manage:status',
child_id: 'bom_manage:child_id',
dosage: 'bom_manage:dosage',
remark: 'bom_manage:remark',
}
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
const hasFormFieldPermission = (fieldName: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
const code = permissionMap[fieldName]
return code ? userStore.hasPermission(code) : false
}
const formRef = ref<FormInstance>()
const form = reactive({
bom_no: '',
remark: '',
parent_id: null as number | null,
version: 'V1.0',
versionUpgradeType: 'minor' as 'minor' | 'major',
is_enabled: true,
children: [] as ChildRow[]
})
const pureBomNo = computed(() => form.bom_no)
const versionOptions = computed(() => {
const ver = originalVersion || 'V1.0'
const allItems = bomGroups.value.flatMap((g: any) => g.items)
const occupiedVersions = new Set(
allItems.filter((item: any) => item.bom_no === currentBomNo).map((item: any) => item.version)
)
const getNextMinor = (baseMajor: number, baseMinor: number): string => {
let minor = baseMinor + 1
let candidate = `V${baseMajor}.${minor}`
while (occupiedVersions.has(candidate)) { minor++; candidate = `V${baseMajor}.${minor}` }
return candidate
}
const getNextMajor = (baseMajor: number): string => {
let major = baseMajor + 1
let candidate = `V${major}.0`
while (occupiedVersions.has(candidate)) { major++; candidate = `V${major}.0` }
return candidate
}
const match = ver.match(/^V(\d+)\.(\d+)$/)
if (match) {
return { minor: getNextMinor(parseInt(match[1]), parseInt(match[2])), major: getNextMajor(parseInt(match[1])) }
}
return { minor: getNextMinor(1, 0), major: getNextMajor(1) }
})
const onVersionUpgradeTypeChange = (type: 'minor' | 'major') => {
form.version = versionOptions.value[type]
}
const rules = reactive<FormRules>({
parentNameInput: [{ required: true, message: '请选择父件', trigger: 'change' }],
version: [{ required: true, message: '请输入版本号', trigger: 'blur' }]
})
const dialogTitle = ref('新建 BOM')
const handleSearch = () => {
activeCategories.value = [] // 用户主动搜索时重置折叠状态
fetchBomList()
}
const fetchBomList = async () => {
loading.value = true
try {
const res = await getBomList({ keyword: searchKeyword.value })
if (res.code === 200) {
bomGroups.value = res.data
}
} finally { loading.value = false }
}
const onParentChange = (val: number) => {}
const onChildChange = (val: number | null, index: number) => {
if (val !== null) {
const existingIndex = form.children.findIndex((child, idx) => idx !== index && child.child_id === val)
if (existingIndex !== -1) {
ElMessage.warning(`该物料已在第 ${existingIndex + 1} 行存在,请合并用量或删除重复项`)
form.children[index].child_id = null
form.children[index].dosage = 0
return
}
form.children[index].dosage = 1
}
}
const getOrGenerateTempBomNo = (): string => {
let storedBomNo = localStorage.getItem('pending_bom_draft_no')
if (storedBomNo) {
return storedBomNo
}
const ts = new Date().toISOString().replace(/[-T:.Z]/g, '').slice(0, 14)
const uid = Math.random().toString(36).slice(2, 10)
const newBomNo = `DRAFT-TEMP-${ts}-${uid}`
localStorage.setItem('pending_bom_draft_no', newBomNo)
return newBomNo
}
const handleCreate = async () => {
// 1. 全局拦截
const isIntercepted = await checkAndInterceptGlobalDraft()
if (isIntercepted) return
// 2. 正常的纯新建逻辑
resetForm()
const tempBomNo = getOrGenerateTempBomNo()
form.bom_no = tempBomNo
originalDraftHash.value = getDraftHash()
dialogTitle.value = '新建 BOM'
isEditMode.value = false
isSaveAsMode.value = false
isReadOnlyMode.value = false
dialogVisible.value = true
}
const saveDraftData = async () => {
// 获取当前正在编辑的单号
const draftBomNo = form.bom_no || getOrGenerateTempBomNo()
const currentHash = getDraftHash()
// 场景 A:防呆拦截。如果数据没有任何改动,直接提示并阻断多余的网络请求
if (originalDraftHash.value && currentHash === originalDraftHash.value) {
return ElMessage.warning('草稿数据无变动,无需重复暂存')
}
// 场景 B:执行保存。在"全局唯一草稿"架构下,不再询问,直接实时同步最新状态到数据库
await executeSaveDraftRequest(draftBomNo)
}
const executeSaveDraftRequest = async (targetBomNo: string) => {
const draftVersion = form.version || 'V1.0'
const children = form.children
.filter(child => child.child_id !== null || child.dosage > 0)
.map(child => ({
child_id: child.child_id,
dosage: child.dosage,
remark: child.remark || ''
}))
saving.value = true
try {
const res = await saveDraft({
bom_no: targetBomNo,
version: draftVersion,
parent_id: form.parent_id,
children
})
if (res.code === 200) {
ElMessage.success('草稿暂存成功')
form.bom_no = targetBomNo
localStorage.setItem('pending_bom_draft_no', targetBomNo)
localStorage.setItem('pending_bom_draft_version', draftVersion)
originalDraftHash.value = getDraftHash()
} else {
ElMessage.error(res.msg || '暂存失败')
}
} catch (e) {
ElMessage.error('暂存异常')
} finally {
saving.value = false
}
}
const restoreDraftToForm = (draftData: any) => {
form.children = (draftData.children || []).map((child: any, idx: number) => ({
rowKey: Date.now() + idx,
child_id: child.child_id,
material_name: child.child_name || '',
material_spec: child.child_spec || '',
dosage: child.dosage,
remark: child.remark || ''
}))
if (draftData.parent_id) {
form.parent_id = draftData.parent_id
parentNameInput.value = draftData.parent_name || ''
}
form.bom_no = draftData.bom_no || ''
form.version = draftData.version || 'V1.0'
form.remark = draftData.remark || ''
pendingDraftBomNo = draftData.bom_no || pendingDraftBomNo
pendingDraftVersion = draftData.version || pendingDraftVersion
originalDraftHash.value = getDraftHash()
}
const checkUserDraft = async (bomNo: string, version: string) => {
try {
const res = await getDraftDetail({ bom_no: bomNo, version: version })
if (res.code === 200 && res.data) {
const confirm = await ElMessageBox.confirm(
`检测到未发布的草稿数据,是否恢复继续编辑?`,
'草稿恢复',
{ confirmButtonText: '恢复草稿', cancelButtonText: '放弃草稿', type: 'info' }
).catch(() => 'cancel')
if (confirm === 'confirm') {
return res.data
}
}
} catch (e) {
// 网络异常或查不到均视为无草稿
}
return null
}
const checkAndInterceptGlobalDraft = async (): Promise<boolean> => {
const pendingNo = localStorage.getItem('pending_bom_draft_no')
const pendingVer = localStorage.getItem('pending_bom_draft_version') || 'V1.0'
if (pendingNo) {
try {
const res = await getDraftDetail({ bom_no: pendingNo, version: pendingVer })
if (res.code === 200 && res.data) {
const action = await ElMessageBox.confirm(
`系统检测到您有一个未发布的草稿 [${pendingNo}]。\n\n为防止数据混乱,同一时间只允许保留一份草稿。\n• 点击【恢复草稿】继续编辑\n• 点击【放弃旧草稿】彻底销毁它并开启新任务`,
'全局草稿拦截',
{
confirmButtonText: '恢复草稿',
cancelButtonText: '放弃旧草稿',
distinguishCancelAndClose: true,
type: 'warning'
}
).catch(action => action)
if (action === 'confirm') {
resetForm()
restoreDraftToForm(res.data)
originalDraftHash.value = getDraftHash()
dialogTitle.value = '继续编辑草稿'
isEditMode.value = false
isSaveAsMode.value = false
isReadOnlyMode.value = false
dialogVisible.value = true
return true
} else if (action === 'cancel') {
localStorage.removeItem('pending_bom_draft_no')
localStorage.removeItem('pending_bom_draft_version')
return false
} else {
return true
}
}
} catch (e) {
localStorage.removeItem('pending_bom_draft_no')
localStorage.removeItem('pending_bom_draft_version')
}
}
return false
}
const handleView = async (row: BomItem) => {
await loadDetail(row.bom_no, row.version)
dialogTitle.value = '查看 BOM'
isEditMode.value = false
isSaveAsMode.value = false
isReadOnlyMode.value = true
dialogVisible.value = true
}
const handleSaveAs = async (row: any) => {
// 1. 全局拦截
const isIntercepted = await checkAndInterceptGlobalDraft()
if (isIntercepted) return
// 2. 正常的纯另存为逻辑
resetForm()
const res = await getBomDetail(row.bom_no, row.version)
if (res.code !== 200) return
const raw = JSON.parse(JSON.stringify(res.data))
if ('id' in raw) delete raw.id
if ('bom_id' in raw) delete raw.bom_id
if (Array.isArray(raw.children)) {
raw.children.forEach((c: any) => {
if ('id' in c) delete c.id
if ('bom_id' in c) delete c.bom_id
})
}
form.children = raw.children.map((child: any, idx: number) => ({
rowKey: idx,
child_id: child.child_id,
material_name: child.child_name || '未知物料',
material_spec: child.child_spec || '',
dosage: child.dosage,
remark: child.remark || ''
}))
if (raw.parent_id) {
form.parent_id = raw.parent_id
parentNameInput.value = raw.parent_name || '未知产品'
}
form.bom_no = (raw.parent_spec || row.bom_no).split('/')[0].trim()
form.remark = raw.remark || ''
form.versionUpgradeType = 'minor'
form.version = versionOptions.value?.minor || 'V1.1'
originalVersion = raw.version || ''
currentBomNo = row.bom_no
originalChildren = JSON.parse(JSON.stringify(form.children))
originalDraftHash.value = getDraftHash()
dialogTitle.value = '新增 BOM (版本升级)'
isEditMode.value = false
isSaveAsMode.value = true
isReadOnlyMode.value = false
dialogVisible.value = true
}
const loadDetail = async (bomNo: string, version: string) => {
try {
const res = await getBomDetail(bomNo, version)
if (res.code === 200) {
const data = res.data
// 1. 映射子件基本数据(使用 idx 生成唯一 rowKey)
form.children = data.children.map((child: any, idx: number) => ({
rowKey: idx,
child_id: child.child_id,
material_name: child.child_name || '未知物料',
material_spec: child.child_spec || '',
dosage: child.dosage,
remark: child.remark || ''
}))
if (data.parent_id) {
form.parent_id = data.parent_id
parentNameInput.value = data.parent_name || '未知产品'
}
if (data.parent_spec) {
form.bom_no = (data.parent_spec || '').split('/')[0].trim()
} else {
form.bom_no = bomNo.split('/')[0].trim()
}
form.remark = data.remark || ''
}
} catch (e) {}
}
const handleDelete = (row: BomItem) => {
ElMessageBox.confirm(`确定删除 ${row.bom_no} (${row.version}) 吗?`, '警告', { type: 'warning' })
.then(async () => {
try {
const res = await deleteBom(row.bom_no, row.version)
if (res.code === 200) { ElMessage.success('删除成功'); fetchBomList() }
} catch (e) {}
})
.catch(() => {})
}
const resetForm = () => {
form.bom_no = ''
form.remark = ''
form.parent_id = null
form.version = 'V1.0'
form.versionUpgradeType = 'minor'
form.is_enabled = true
form.children = []
isSaveAsMode.value = false
isReadOnlyMode.value = false
originalVersion = ''
currentBomNo = ''
childSearchKeyword.value = ''
parentNameInput.value = ''
originalDraftHash.value = ''
pendingDraftBomNo = ''
pendingDraftVersion = ''
if (formRef.value) formRef.value.resetFields()
}
const addChild = () => {
const rowKey = Date.now()
form.children.push({ rowKey, child_id: null, material_name: '', material_spec: '', dosage: 0, remark: '' })
}
const removeChild = (rowKey: number) => {
const idx = form.children.findIndex(c => c.rowKey === rowKey)
if (idx !== -1) form.children.splice(idx, 1)
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
if (!pureBomNo.value) return ElMessage.warning('BOM编号不能为空')
if (form.children.length === 0) return ElMessage.warning('请至少添加一个子件')
const childIds = form.children.map(c => c.child_id).filter(id => id !== null)
const uniqueIds = new Set(childIds)
if (childIds.length !== uniqueIds.size) {
return ElMessage.warning('子件列表中存在重复物料,请合并用量或删除重复项')
}
if (isSaveAsMode.value && originalChildren.length > 0) {
const currentSet = new Set(form.children.map(c => `${c.child_id}-${c.dosage}`))
const originalSet = new Set(originalChildren.map(c => `${c.child_id}-${c.dosage}`))
const isIdentical = currentSet.size === originalSet.size && [...currentSet].every(item => originalSet.has(item))
if (isIdentical) {
return ElMessage.warning('您未修改任何子件,与原版本内容一致,请修改后再保存')
}
}
const payload = {
bom_no: pureBomNo.value,
remark: form.remark,
version: form.version,
parent_id: form.parent_id,
is_enabled: form.is_enabled,
children: form.children
.filter((c: any) => c.child_id !== null)
.map((c: any) => ({
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('保存成功')
localStorage.removeItem('pending_bom_draft_no')
localStorage.removeItem('pending_bom_draft_version')
originalDraftHash.value = ''
dialogVisible.value = false
fetchBomList()
} else {
ElMessage.error(res.msg || '保存失败')
}
} finally {
saving.value = false
}
})
}
onMounted(() => {
// 处理外部跳转自动打开 BOM(带查重保护)
if (route.query.create_for_id) {
const parentId = Number(route.query.create_for_id);
const parentName = (route.query.parent_name as string) || '';
const parentSpec = (route.query.parent_spec as string) || '';
// 1. 把名称填入背景搜索框,并真正触发一次列表搜索,让背景列表也只显示该物料
searchKeyword.value = parentName;
fetchBomList();
// 2. 延迟等待基础渲染后进行查重
setTimeout(() => {
getBomList({ keyword: parentName }).then((res: any) => {
const groups = res.data || [];
let existingBom = null;
// ★ 修复点:遍历分组 (groups) 里的 items 来查找正确的 parent_id
for (const group of groups) {
if (group.items && group.items.length > 0) {
const found = group.items.find((b: any) => b.parent_id === parentId);
if (found) {
existingBom = found;
break; // 找到了就跳出循环
}
}
}
if (existingBom) {
// ★ 情况 A:已经有BOM了,直接打开只读查看弹窗
ElMessage.success('检测到该物料已有 BOM,已自动为您打开');
handleView(existingBom);
} else {
// ★ 情况 B:还没建过BOM,打开新建并注入父件
handleCreate();
parentNameInput.value = parentName;
// 给表单赋值
form.parent_id = parentId;
// 自动带出版本和生成编号
if (parentSpec) {
form.bom_no = parentSpec.split('/')[0].trim()
}
}
});
}, 300);
} else {
// 如果不是从其他页面跳转过来的,直接正常加载全部列表
fetchBomList();
}
})
</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; width: 100%; }
.option-name { font-weight: bold; color: #303133; }
.option-spec { font-size: 12px; color: #909399; margin-left: 15px; }
</style>