diff --git a/inventory-web/src/views/bom/BomManage.vue b/inventory-web/src/views/bom/BomManage.vue index 89279ae..72ed5f7 100644 --- a/inventory-web/src/views/bom/BomManage.vue +++ b/inventory-web/src/views/bom/BomManage.vue @@ -17,6 +17,12 @@ + + + 全部 + 启用 + 停用 + 全部展开 全部折叠 导入BOM @@ -27,7 +33,7 @@ - + - + @@ -93,6 +99,7 @@
{{ item.name }} {{ item.spec }} + {{ item.company_name }}
@@ -112,7 +119,15 @@ - + + @@ -184,6 +199,7 @@
{{ item.name }} {{ item.spec }} + {{ item.company_name }}
@@ -196,10 +212,29 @@ style="font-size: 16px; padding: 4px;" /> - - - 自制{{ row.child_bom_version }} + + +
+ + + + + 自制 {{ row.child_bom_no ? row.child_bom_no + ' ' : '' }}{{ row.child_bom_version }} +
请选择该自制件的 BOM 版本
@@ -266,8 +301,9 @@ import { useRoute } from 'vue-router' import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus' import { Plus, Search, EditPen, Upload } from '@element-plus/icons-vue' import { useRouter } from 'vue-router' -import { getBomList, getBomSummary, getBomDetail, saveBom, deleteBom, getDraftDetail, saveDraft, publishDraft } from '@/api/bom' +import { getBomList, getBomSummary, getBomDetail, saveBom, deleteBom, getDraftDetail, saveDraft, publishDraft, getChildBomVersions, updateBomStatus } from '@/api/bom' import ImportDialog from '@/components/ImportDialog.vue' +import CompanySelector from '@/components/CompanySelector.vue' import { searchMaterialBase } from '@/api/inbound/buy' import { useUserStore } from '@/stores/user' @@ -294,7 +330,64 @@ interface ChildRow { material_spec: string dosage: number remark: string - child_bom_version?: string // 自制件子件对应的 BOM 版本(仅展示识别) + child_bom_no?: string // 子件引用的自制 BOM 编号(必选:该物料作父件的配方) + child_bom_version?: string // 子件引用的自制 BOM 版本(必选,见 bom_version_options) + child_bom_key?: string // 下拉选中项编码 `${bom_no}|${version}` + bom_version_options?: { bom_no: string; version: string }[] // 该物料作父件的启用配方候选 +} + +// ★ 自制件子件"版本"候选与选中项工具 +const BOM_KEY_SEP = '|' +const bomKeyOf = (no?: string, ver?: string) => (no || '') + BOM_KEY_SEP + (ver || '') +const bomKeyTo = (key?: string) => { + const s = String(key || '') + const i = s.indexOf(BOM_KEY_SEP) + if (i < 0) return ['', ''] + return [s.slice(0, i), s.slice(i + 1)] +} +const childIsSelfMade = (row: any) => + !!(row && ((row.bom_version_options && row.bom_version_options.length) || row.child_bom_no || row.child_bom_version)) + +// 拉取某子件物料作父件的启用配方列表,并维护选中项。 +// - 有候选: 若当前已有合法选中(编辑存量行)则保留,否则默认选"最新启用配方"(列表第一项) +// - 无候选(外购件/无现行BOM): 清空版本相关字段 +const loadChildBomVersions = async (row: any) => { + if (!row.child_id) { row.bom_version_options = []; return } + let list: { bom_no: string; version: string }[] = [] + try { + const res: any = await getChildBomVersions(row.child_id) + if (res?.code === 200 && Array.isArray(res.data)) list = res.data + } catch (e) { list = [] } + row.bom_version_options = list + if (!list.length) { + row.child_bom_no = '' + row.child_bom_version = '' + row.child_bom_key = '' + return + } + const existKey = bomKeyOf(row.child_bom_no, row.child_bom_version) + if (row.child_bom_no && row.child_bom_version && list.some(o => bomKeyOf(o.bom_no, o.version) === existKey)) { + row.child_bom_key = existKey + return + } + const first = list[0] + row.child_bom_no = first.bom_no + row.child_bom_version = first.version + row.child_bom_key = bomKeyOf(first.bom_no, first.version) +} + +const onChildBomChange = (row: any) => { + const [no, ver] = bomKeyTo(row.child_bom_key) + row.child_bom_no = no + row.child_bom_version = ver +} + +// 给现有 children(从详情/草稿/另存为载入的存量自制件子件)补齐版本候选, +// 使编辑态能渲染下拉;保留其已存储的引用版本。 +const hydrateChildBomVersions = async () => { + for (const c of form.children) { + if (c.child_id) await loadChildBomVersions(c) + } } const originalDraftHash = ref('') @@ -303,7 +396,9 @@ const getDraftHash = () => { const children = form.children.map(c => ({ child_id: c.child_id, dosage: Number(c.dosage) || 0, - remark: c.remark || '' + remark: c.remark || '', + child_bom_no: c.child_bom_no || '', + child_bom_version: c.child_bom_version || '' })) return JSON.stringify({ bom_no: form.bom_no, @@ -331,6 +426,11 @@ let pendingDraftVersion = '' const searchKeyword = ref('') const childSearchKeyword = ref('') +const bomStatusFilter = ref('enabled') // 默认只看"启用",可手动切全部/停用;enabled=启用 | disabled=停用 | ''=全部 +const bomCompany = ref('') // 跨域用户按公司(父件物料公司)过滤;普通用户由后端强制隔离,此控件不显示 +// ★ 手风琴开关:默认单开;"全部展开"时临时关闭手风琴以允许多开,"全部折叠"后恢复 +const isAccordion = ref(true) +const detailLoading = ref(false) // 弹窗查看详情 loading(防"假死"反馈) // ★ 懒加载分组架构 interface GroupSummary { category: string; count: number } @@ -378,7 +478,7 @@ 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) => { + searchMaterialBase(safeQuery, 1, bomCompany.value || '').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) @@ -404,7 +504,7 @@ const fetchChildSuggestions = (row: any, query: string, cb: (results: any[]) => 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) => { + searchMaterialBase(safeQuery, 1, bomCompany.value || '').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) @@ -416,6 +516,10 @@ const onChildClear = (row: any) => { row.material_name = '' row.material_spec = '' row.dosage = 0 + row.child_bom_no = '' + row.child_bom_version = '' + row.child_bom_key = '' + row.bom_version_options = [] } const onChildSelected = (row: any, item: any) => { @@ -425,12 +529,19 @@ const onChildSelected = (row: any, item: any) => { row.child_id = null row.material_name = '' row.dosage = 0 + row.bom_version_options = [] return } row.child_id = item.id row.material_name = item.name row.material_spec = item.spec row.dosage = 1 + row.child_bom_no = '' + row.child_bom_version = '' + row.child_bom_key = '' + row.bom_version_options = [] + // 选中物料后判定:若其为自制件(存在启用BOM),拉取候选版本并默认选最新,否则清空。 + loadChildBomVersions(row) } const getChildSpec = (rowKey: number): string => { const row = form.children.find(c => c.rowKey === rowKey) @@ -533,6 +644,7 @@ const dialogTitle = ref('新建 BOM') const handleSearch = () => { activeCategories.value = [] groupCache.value = new Map() + isAccordion.value = true // 搜索/筛选重置回默认手风琴模式 fetchBomSummary() } @@ -542,6 +654,8 @@ const fetchBomSummary = async () => { try { const params: any = {} if (searchKeyword.value) params.keyword = searchKeyword.value + if (bomStatusFilter.value) params.status = bomStatusFilter.value + if (bomCompany.value) params.company_name = bomCompany.value const res: any = await getBomSummary(params) if (res?.code === 200) { groupSummary.value = res.data ?? [] @@ -554,12 +668,17 @@ const fetchBomSummary = async () => { finally { loading.value = false } } -// ★ 默认全部展开(用户点击按钮时) +// ★ 全部展开:先临时关闭手风琴(允许多开),再一次性展开+懒加载各分类 const expandAllGroups = () => { + isAccordion.value = false activeCategories.value = groupSummary.value.map(g => g.category) groupSummary.value.forEach(g => loadGroupItems(g.category)) } -const collapseAllGroups = () => { activeCategories.value = [] } +// ★ 全部折叠:清空面板,并恢复手风琴模式 +const collapseAllGroups = () => { + activeCategories.value = [] + isAccordion.value = true +} // ★ 展开分组时懒加载该分类下的 BOM const loadGroupItems = async (category: string) => { @@ -574,6 +693,8 @@ const loadGroupItems = async (category: string) => { pageSize: 9999 // 单分类内全量加载(分类内数据量可控) } if (searchKeyword.value) params.keyword = searchKeyword.value + if (bomStatusFilter.value) params.status = bomStatusFilter.value + if (bomCompany.value) params.company_name = bomCompany.value const res: any = await getBomList(params) if (res?.code === 200) { groupCache.value.set(category, res.data?.items ?? []) @@ -655,7 +776,8 @@ const executeSaveDraftRequest = async (targetBomNo: string) => { child_id: child.child_id, dosage: child.dosage, remark: child.remark || '', - child_bom_version: child.child_bom_version || '' + child_bom_no: child.child_bom_no || '', + child_bom_version: child.child_bom_version || '' })) saving.value = true @@ -690,6 +812,7 @@ const restoreDraftToForm = (draftData: any) => { material_spec: child.child_spec || '', dosage: child.dosage, remark: child.remark || '', + child_bom_no: child.child_bom_no || '', child_bom_version: child.child_bom_version || '' })) @@ -705,6 +828,8 @@ const restoreDraftToForm = (draftData: any) => { pendingDraftVersion = draftData.version || pendingDraftVersion originalDraftHash.value = getDraftHash() + // 草稿中存量自制件子件也要补齐版本候选(保留其已存版本),便于继续编辑 + hydrateChildBomVersions() } const checkUserDraft = async (bomNo: string, version: string) => { @@ -773,12 +898,13 @@ const checkAndInterceptGlobalDraft = async (): Promise => { } const handleView = async (row: BomItem) => { - await loadDetail(row.bom_no, row.version) + resetForm() // 清空旧内容,避免加载期间闪现上一条数据 dialogTitle.value = '查看 BOM' isEditMode.value = false isSaveAsMode.value = false isReadOnlyMode.value = true - dialogVisible.value = true + dialogVisible.value = true // 先开窗,让 detailLoading 骨架屏可见 + await loadDetail(row.bom_no, row.version) } const handleSaveAs = async (row: any) => { @@ -808,6 +934,7 @@ const handleSaveAs = async (row: any) => { material_spec: child.child_spec || '', dosage: child.dosage, remark: child.remark || '', + child_bom_no: child.child_bom_no || '', child_bom_version: child.child_bom_version || '' })) if (raw.parent_id) { @@ -825,6 +952,9 @@ const handleSaveAs = async (row: any) => { originalChildren = JSON.parse(JSON.stringify(form.children)) originalDraftHash.value = getDraftHash() + // 另存/升版编辑:存量自制件子件补齐版本候选(保留其已存储的引用版本) + hydrateChildBomVersions() + dialogTitle.value = '新增 BOM (版本升级)' isEditMode.value = false isSaveAsMode.value = true @@ -833,6 +963,7 @@ const handleSaveAs = async (row: any) => { } const loadDetail = async (bomNo: string, version: string) => { + detailLoading.value = true try { const res = await getBomDetail(bomNo, version) if (res.code === 200) { @@ -845,6 +976,7 @@ const loadDetail = async (bomNo: string, version: string) => { material_spec: child.child_spec || '', dosage: child.dosage, remark: child.remark || '', + child_bom_no: child.child_bom_no || '', child_bom_version: child.child_bom_version || '' })) @@ -859,8 +991,45 @@ const loadDetail = async (bomNo: string, version: string) => { form.bom_no = bomNo.split('/')[0].trim() } form.remark = data.remark || '' + // 查看/只读视图的独立启停需要版本与当前状态 + form.version = version || data.version || 'V1.0' + form.is_enabled = data.is_enabled !== false } - } catch (e) {} + } catch (e) { + // 网络/数据异常:仅结束 loading,不弹全局错误,保持弹窗可操作 + } finally { + detailLoading.value = false + } +} + +// ★ 启用/停用开关:查看/只读态下独立更新状态(不触发整表单保存); +// 新建/编辑/另存模式则仅记录到 form,随 submitForm 一并保存。 +const onEnabledStatusChange = async () => { + if (!isReadOnlyMode.value) return + + if (!pureBomNo.value || !form.version) { + ElMessage.warning('缺少 BOM 编号/版本,无法更新状态') + return + } + const target = !!form.is_enabled + saving.value = true + try { + const res: any = await updateBomStatus({ bom_no: pureBomNo.value, version: form.version, is_enabled: target }) + if (res?.code === 200) { + ElMessage.success(target ? '已启用' : '已停用') + // 刷新左侧列表摘要(计数与状态列) + groupCache.value = new Map() + fetchBomSummary() + } else { + form.is_enabled = !target + ElMessage.error(res?.msg || '状态更新失败') + } + } catch (e) { + form.is_enabled = !target + ElMessage.error('状态更新失败') + } finally { + saving.value = false + } } const handleDelete = (row: BomItem) => { @@ -918,14 +1087,25 @@ const submitForm = async () => { } 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 childKey = (c: any) => `${c.child_id}-${c.dosage}-${c.child_bom_no || ''}-${c.child_bom_version || ''}` + const currentSet = new Set(form.children.map(childKey)) + const originalSet = new Set(originalChildren.map(childKey)) const isIdentical = currentSet.size === originalSet.size && [...currentSet].every(item => originalSet.has(item)) if (isIdentical) { return ElMessage.warning('您未修改任何子件,与原版本内容一致,请修改后再保存') } } + // 前置校验:自制件子件必须已选版本(后端仍会强校验兜底) + const missingVer = form.children.find( + (c: any) => c.child_id !== null && + (c.child_bom_no || c.child_bom_version || (c.bom_version_options && c.bom_version_options.length)) && + !(c.child_bom_no && c.child_bom_version) + ) + if (missingVer) { + return ElMessage.warning(`子件「${missingVer.material_name || missingVer.child_id}」为自制件,请先选择其 BOM 版本`) + } + const payload = { bom_no: pureBomNo.value, remark: form.remark, @@ -937,7 +1117,9 @@ const submitForm = async () => { .map((c: any) => ({ child_id: c.child_id, dosage: c.dosage, - remark: c.remark || '' + remark: c.remark || '', + child_bom_no: c.child_bom_no || '', + child_bom_version: c.child_bom_version || '' })) }