- 自制件子件行内版本下拉(必选,默认最新启用,外购件不显示);保存/草稿带引用版本 - 查看/只读态“是否启用”开关解耦,切换走独立启停接口 - 状态筛选改 el-radio-group 按钮组(默认启用) - 顶部接入 CompanySelector,父件/子件物料搜索联动公司并标注公司 - 分类手风琴(全部展开临时关手风琴)与详情弹窗 v-loading;列表默认只显示启用
1205 lines
47 KiB
Vue
1205 lines
47 KiB
Vue
<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>
|
||
<company-selector v-model="bomCompany" @change="handleSearch" style="margin-right: 10px;" />
|
||
<el-radio-group v-model="bomStatusFilter" @change="handleSearch" style="margin-right: 15px;">
|
||
<el-radio-button :label="''">全部</el-radio-button>
|
||
<el-radio-button label="enabled">启用</el-radio-button>
|
||
<el-radio-button label="disabled">停用</el-radio-button>
|
||
</el-radio-group>
|
||
<el-button @click="expandAllGroups" size="small" style="margin-right: 6px;">全部展开</el-button>
|
||
<el-button @click="collapseAllGroups" size="small" style="margin-right: 10px;">全部折叠</el-button>
|
||
<el-button v-if="userStore.hasPermission('bom_manage:operation')" type="success" plain :icon="Upload" @click="showImportDialog = true" style="margin-right:10px">导入BOM</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 && groupSummary.length === 0" />
|
||
<el-empty v-else-if="!loading && groupSummary.length === 0" description="暂无 BOM 数据" />
|
||
<el-collapse v-else v-model="activeCategories" :accordion="isAccordion" class="bom-category-collapse" @change="handleCollapseChange">
|
||
<el-collapse-item
|
||
v-for="group in groupedData"
|
||
: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" show-overflow-tooltip>
|
||
<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="160" show-overflow-tooltip />
|
||
<el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="180" 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="160" 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>
|
||
<div v-if="groupLoadingMap.get(group.category)" style="text-align:center;padding:12px;">
|
||
<el-icon class="is-loading"><Loading /></el-icon> 加载中...
|
||
</div>
|
||
</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" v-loading="detailLoading">
|
||
|
||
<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>
|
||
<span v-if="item.company_name" style="font-size:12px;color:#c45656;background:#fef0f0;border-radius:2px;padding:0 4px;margin-left:auto;flex-shrink:0;">{{ item.company_name }}</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')">
|
||
<!-- ★ disabled 仅与操作权限绑定,不再随只读视图全局禁用:
|
||
查看/只读态下切换会走 updateBomStatus 单独更新状态(见 onEnabledStatusChange) -->
|
||
<el-switch
|
||
v-model="form.is_enabled"
|
||
active-text="启用"
|
||
inactive-text="禁用"
|
||
:disabled="!userStore.hasPermission('bom_manage:operation')"
|
||
@change="onEnabledStatusChange"
|
||
/>
|
||
</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>
|
||
<span v-if="item.company_name" style="font-size:12px;color:#c45656;background:#fef0f0;border-radius:2px;padding:0 4px;margin-left:auto;flex-shrink:0;">{{ item.company_name }}</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>
|
||
<!-- ★ 自制件子件:编辑态出现"版本"必选下拉;只读态显示其引用的 BOM 版本 -->
|
||
<div v-if="childIsSelfMade(row)" style="margin-top: 4px;">
|
||
<el-select
|
||
v-if="!isReadOnlyMode && row.bom_version_options && row.bom_version_options.length"
|
||
v-model="row.child_bom_key"
|
||
placeholder="请选择BOM版本"
|
||
style="width: 100%;"
|
||
size="small"
|
||
filterable
|
||
@change="() => onChildBomChange(row)"
|
||
>
|
||
<el-option
|
||
v-for="opt in row.bom_version_options"
|
||
:key="bomKeyOf(opt.bom_no, opt.version)"
|
||
:label="opt.bom_no + ' ' + opt.version"
|
||
:value="bomKeyOf(opt.bom_no, opt.version)"
|
||
/>
|
||
</el-select>
|
||
<el-tag v-else-if="row.child_bom_version" size="small" type="warning">
|
||
自制 {{ row.child_bom_no ? row.child_bom_no + ' ' : '' }}{{ row.child_bom_version }}
|
||
</el-tag>
|
||
<div v-else-if="!isReadOnlyMode" style="font-size: 12px; color: #E6A23C;">请选择该自制件的 BOM 版本</div>
|
||
</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>
|
||
|
||
<!-- 批量导入弹窗 -->
|
||
<ImportDialog v-model="showImportDialog" import-type="bom" @success="fetchBomSummary" />
|
||
|
||
</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, Upload } from '@element-plus/icons-vue'
|
||
import { useRouter } from 'vue-router'
|
||
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'
|
||
|
||
// ============================================================
|
||
// 类型定义
|
||
// ============================================================
|
||
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
|
||
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('')
|
||
|
||
const getDraftHash = () => {
|
||
const children = form.children.map(c => ({
|
||
child_id: c.child_id,
|
||
dosage: Number(c.dosage) || 0,
|
||
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,
|
||
version: form.version,
|
||
parent_id: form.parent_id,
|
||
children
|
||
})
|
||
}
|
||
|
||
const userStore = useUserStore()
|
||
const showImportDialog = ref(false)
|
||
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 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 }
|
||
const groupSummary = ref<GroupSummary[]>([])
|
||
const groupCache = ref<Map<string, any[]>>(new Map())
|
||
const groupLoadingMap = ref<Map<string, boolean>>(new Map())
|
||
const activeCategories = ref<string[]>([])
|
||
const lastKeyword = ref('')
|
||
|
||
const groupedData = computed(() => {
|
||
if (!groupSummary.value.length) return []
|
||
return groupSummary.value.map(s => ({
|
||
category: s.category,
|
||
count: s.count,
|
||
items: groupCache.value.get(s.category) ?? [],
|
||
loaded: groupCache.value.has(s.category)
|
||
}))
|
||
})
|
||
|
||
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, () => {
|
||
clearTimeout((window as any)._bomSearchTimer)
|
||
;(window as any)._bomSearchTimer = setTimeout(() => {
|
||
fetchBomSummary()
|
||
}, 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, 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)
|
||
}).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, 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)
|
||
}).catch(() => cb([])).finally(() => { searchLoading.value = false })
|
||
}
|
||
|
||
const onChildClear = (row: any) => {
|
||
row.child_id = null
|
||
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) => {
|
||
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
|
||
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)
|
||
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: any[] = []
|
||
groupCache.value.forEach((items) => allItems.push(...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 = []
|
||
groupCache.value = new Map()
|
||
isAccordion.value = true // 搜索/筛选重置回默认手风琴模式
|
||
fetchBomSummary()
|
||
}
|
||
|
||
// ★ 挂载+搜索时调用 — 获取分组摘要
|
||
const fetchBomSummary = async () => {
|
||
loading.value = true
|
||
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 ?? []
|
||
if (searchKeyword.value !== lastKeyword.value) {
|
||
activeCategories.value = []
|
||
lastKeyword.value = searchKeyword.value
|
||
}
|
||
}
|
||
} catch (e) { console.error('获取BOM摘要失败', e) }
|
||
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 = []
|
||
isAccordion.value = true
|
||
}
|
||
|
||
// ★ 展开分组时懒加载该分类下的 BOM
|
||
const loadGroupItems = async (category: string) => {
|
||
if (groupCache.value.has(category)) return
|
||
if (groupLoadingMap.value.get(category)) return
|
||
|
||
groupLoadingMap.value.set(category, true)
|
||
try {
|
||
const params: any = {
|
||
category: category,
|
||
page: 1,
|
||
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 ?? [])
|
||
}
|
||
} catch (e) { console.error(`加载BOM分类 [${category}] 失败`, e) }
|
||
finally { groupLoadingMap.value.set(category, false) }
|
||
}
|
||
|
||
// ★ collapse @change 事件 → 触发懒加载
|
||
const handleCollapseChange = (val: string | string[]) => {
|
||
const cats = Array.isArray(val) ? val : (val ? [val] : [])
|
||
cats.forEach(c => loadGroupItems(c))
|
||
}
|
||
|
||
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 || '',
|
||
child_bom_no: child.child_bom_no || '',
|
||
child_bom_version: child.child_bom_version || ''
|
||
}))
|
||
|
||
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 || '',
|
||
child_bom_no: child.child_bom_no || '',
|
||
child_bom_version: child.child_bom_version || ''
|
||
}))
|
||
|
||
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()
|
||
// 草稿中存量自制件子件也要补齐版本候选(保留其已存版本),便于继续编辑
|
||
hydrateChildBomVersions()
|
||
}
|
||
|
||
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) => {
|
||
resetForm() // 清空旧内容,避免加载期间闪现上一条数据
|
||
dialogTitle.value = '查看 BOM'
|
||
isEditMode.value = false
|
||
isSaveAsMode.value = false
|
||
isReadOnlyMode.value = true
|
||
dialogVisible.value = true // 先开窗,让 detailLoading 骨架屏可见
|
||
await loadDetail(row.bom_no, row.version)
|
||
}
|
||
|
||
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 || '',
|
||
child_bom_no: child.child_bom_no || '',
|
||
child_bom_version: child.child_bom_version || ''
|
||
}))
|
||
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()
|
||
|
||
// 另存/升版编辑:存量自制件子件补齐版本候选(保留其已存储的引用版本)
|
||
hydrateChildBomVersions()
|
||
|
||
dialogTitle.value = '新增 BOM (版本升级)'
|
||
isEditMode.value = false
|
||
isSaveAsMode.value = true
|
||
isReadOnlyMode.value = false
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
const loadDetail = async (bomNo: string, version: string) => {
|
||
detailLoading.value = true
|
||
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 || '',
|
||
child_bom_no: child.child_bom_no || '',
|
||
child_bom_version: child.child_bom_version || ''
|
||
}))
|
||
|
||
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 || ''
|
||
// 查看/只读视图的独立启停需要版本与当前状态
|
||
form.version = version || data.version || 'V1.0'
|
||
form.is_enabled = data.is_enabled !== false
|
||
}
|
||
} 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) => {
|
||
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('删除成功'); groupCache.value = new Map(); fetchBomSummary() }
|
||
} 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 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,
|
||
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 || '',
|
||
child_bom_no: c.child_bom_no || '',
|
||
child_bom_version: c.child_bom_version || ''
|
||
}))
|
||
}
|
||
|
||
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
|
||
groupCache.value = new Map()
|
||
fetchBomSummary()
|
||
} 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;
|
||
fetchBomSummary();
|
||
|
||
// 2. 延迟等待基础渲染后进行查重
|
||
setTimeout(() => {
|
||
getBomList({ keyword: parentName, page: 1, pageSize: 50 }).then((res: any) => {
|
||
// ★ 适配新 API 格式:{ items: [...], total, pages }
|
||
const flatItems = res.data?.items ?? [];
|
||
let existingBom = null;
|
||
|
||
for (const bom of flatItems) {
|
||
if (bom.parent_id === parentId) {
|
||
existingBom = bom;
|
||
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 {
|
||
// 如果不是从其他页面跳转过来的,直接正常加载摘要
|
||
fetchBomSummary();
|
||
}
|
||
})
|
||
</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>
|