Compare commits
10 Commits
8bb3e58b44
...
93b9846fc6
| Author | SHA1 | Date | |
|---|---|---|---|
| 93b9846fc6 | |||
| 1def8c7747 | |||
| 907c083107 | |||
| afe0f25415 | |||
| ffc482bd9e | |||
| 7087769a33 | |||
| 3d30cbc5c2 | |||
| 355a21e94c | |||
| ff5418afa3 | |||
| d94b52bf73 |
@ -1,12 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git *)",
|
||||
"Bash(del *)",
|
||||
"Bash(rm *)"
|
||||
]
|
||||
},
|
||||
"$version": 3
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -100,6 +100,12 @@ class BuyInboundService:
|
||||
if not material: raise ValueError("所选物料不存在")
|
||||
if not material.is_enabled: raise ValueError(f"物料【{material.name}】已停用")
|
||||
|
||||
# ============================================================
|
||||
# 物料类别隔离校验:采购入库禁止【半成品】和【成品】(黑名单拦截制)
|
||||
# ============================================================
|
||||
if material.category and ('/半成品' in material.category or '/成品' in material.category):
|
||||
raise ValueError(f"物料【{material.name}】属于【{material.category}】,【半成品】和【成品】不允许直接采购入库!")
|
||||
|
||||
# ============================================================
|
||||
# 强制质检校验:如果物料标记为强制质检,则必须提供到检状态和检测报告
|
||||
# ============================================================
|
||||
|
||||
@ -115,6 +115,12 @@ class ProductInboundService:
|
||||
if not material.is_enabled:
|
||||
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
|
||||
|
||||
# ============================================================
|
||||
# 物料类别隔离校验:成品入库必须为【成品】类目(精确白名单准入制)
|
||||
# ============================================================
|
||||
if not material.category or '/成品' not in material.category:
|
||||
raise ValueError(f"物料【{material.name}】属于【{material.category or '未分类'}】,只有【成品】才允许进行成品入库!")
|
||||
|
||||
ProductInboundService._check_unique(
|
||||
serial_number=data.get('serial_number')
|
||||
)
|
||||
|
||||
@ -122,6 +122,12 @@ class SemiInboundService:
|
||||
if not material.is_enabled:
|
||||
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
|
||||
|
||||
# ============================================================
|
||||
# 物料类别隔离校验:半成品入库必须为【半成品】类目(精确白名单准入制)
|
||||
# ============================================================
|
||||
if not material.category or '/半成品' not in material.category:
|
||||
raise ValueError(f"物料【{material.name}】属于【{material.category or '未分类'}】,只有【半成品】才允许进行半成品入库!")
|
||||
|
||||
SemiInboundService._check_unique(
|
||||
base_id=base_id,
|
||||
serial_number=data.get('serial_number'),
|
||||
|
||||
@ -33,6 +33,17 @@
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<!-- 拍照按钮 -->
|
||||
<el-button
|
||||
v-if="!previewUrl"
|
||||
type="primary"
|
||||
class="camera-btn"
|
||||
@click="openCamera"
|
||||
>
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
调起摄像头拍照
|
||||
</el-button>
|
||||
|
||||
<div v-if="searching" class="loading-tip">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>正在识别图片并检索...</span>
|
||||
@ -94,15 +105,33 @@
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 拍照弹窗 -->
|
||||
<el-dialog
|
||||
v-model="cameraVisible"
|
||||
title="拍照"
|
||||
width="95%"
|
||||
style="max-width: 480px; height: 80vh; padding: 0;"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
@close="closeCamera"
|
||||
>
|
||||
<WebRtcCamera
|
||||
@cancel="closeCamera"
|
||||
@photo-submit="handleCameraSubmit"
|
||||
/>
|
||||
</el-dialog>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Camera, Loading, Picture, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { Camera, Loading, Picture, WarningFilled, VideoCamera } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { imageSearch, type ImageSearchItem } from '@/api/common/upload'
|
||||
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@ -126,6 +155,9 @@ const searching = ref(false)
|
||||
const searched = ref(false)
|
||||
const results = ref<ImageSearchItem[]>([])
|
||||
|
||||
// 拍照相关
|
||||
const cameraVisible = ref(false)
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (!val) {
|
||||
@ -137,6 +169,27 @@ watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// 拍照相关方法
|
||||
const openCamera = () => {
|
||||
cameraVisible.value = true
|
||||
}
|
||||
|
||||
const closeCamera = () => {
|
||||
cameraVisible.value = false
|
||||
}
|
||||
|
||||
const handleCameraSubmit = (file: File) => {
|
||||
// 关闭拍照弹窗
|
||||
closeCamera()
|
||||
|
||||
// 生成预览
|
||||
currentFile.value = file
|
||||
previewUrl.value = URL.createObjectURL(file)
|
||||
|
||||
// 立即触发搜图
|
||||
doSearch(file)
|
||||
}
|
||||
|
||||
const handleFileChange = (uploadFile: any) => {
|
||||
const file = uploadFile.raw
|
||||
if (!file) return
|
||||
@ -179,6 +232,9 @@ const doSearch = async (file: File) => {
|
||||
}
|
||||
|
||||
const clearImage = () => {
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
}
|
||||
previewUrl.value = ''
|
||||
currentFile.value = null
|
||||
results.value = []
|
||||
@ -188,7 +244,6 @@ const clearImage = () => {
|
||||
|
||||
const fullImageUrl = (path: string) => {
|
||||
if (!path) return '';
|
||||
// 直接原样返回,完全信任后端传过来的 image_url
|
||||
return path.startsWith('http') ? path : path;
|
||||
}
|
||||
|
||||
@ -219,6 +274,9 @@ const handleClose = () => {
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
}
|
||||
previewUrl.value = ''
|
||||
currentFile.value = null
|
||||
searching.value = false
|
||||
@ -234,6 +292,12 @@ const resetState = () => {
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
/* 拍照按钮 */
|
||||
.camera-btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ── 左侧上传区 ── */
|
||||
.upload-section {
|
||||
flex: 0 0 220px;
|
||||
|
||||
@ -39,17 +39,24 @@ const routes: Array<RouteRecordRaw> = [
|
||||
]
|
||||
},
|
||||
|
||||
// 3. 基础信息
|
||||
// 3. 物料管理
|
||||
{
|
||||
path: '/material',
|
||||
component: Layout,
|
||||
redirect: '/material/index',
|
||||
meta: { title: '物料管理', icon: 'Box' },
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
name: 'MaterialBase',
|
||||
component: () => import('@/views/material/list.vue'),
|
||||
meta: { title: '基础信息', icon: 'Box' }
|
||||
},
|
||||
{
|
||||
path: 'buyOdoo',
|
||||
name: 'BuyOdoo',
|
||||
component: () => import('@/views/material/buyOdoo.vue'),
|
||||
meta: { title: '基础信息(Odoo)', icon: 'Grid' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -10,11 +10,11 @@
|
||||
placeholder="搜索 编号/名称/规格/子件..."
|
||||
style="width: 300px; margin-right: 10px;"
|
||||
clearable
|
||||
@clear="fetchBomList"
|
||||
@keyup.enter="fetchBomList"
|
||||
@clear="handleSearch"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #append>
|
||||
<el-button :icon="Search" @click="fetchBomList" />
|
||||
<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>
|
||||
@ -24,15 +24,16 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-loading="loading">
|
||||
<el-collapse 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 :data="group.items" border style="width: 100%">
|
||||
<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>
|
||||
@ -60,7 +61,6 @@
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
||||
@ -69,35 +69,29 @@
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="父件 (成品)" prop="parent_id" v-if="hasFormFieldPermission('parent_id')">
|
||||
<!-- ====== 改造:父件下拉 - 远程搜索 + 懒加载 ====== -->
|
||||
<el-select
|
||||
v-model="form.parent_id"
|
||||
<!-- ====== 父件搜索 - el-autocomplete ====== -->
|
||||
<el-autocomplete
|
||||
v-model="parentNameInput"
|
||||
:fetch-suggestions="fetchParentSuggestions"
|
||||
:value-key="'name'"
|
||||
:validate-event="false"
|
||||
clearable
|
||||
placeholder="请搜索并选择父件"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword="true"
|
||||
:remote-method="(q: string) => handleRemoteSearch(q, 'parent')"
|
||||
:loading="selectLoading"
|
||||
:loading="searchLoading"
|
||||
:trigger-on-focus="true"
|
||||
style="width: 100%"
|
||||
:disabled="isReadOnlyMode || isEditMode"
|
||||
class="beautified-select"
|
||||
popper-class="bom-loadmore-popper parent-popper"
|
||||
default-first-option="true"
|
||||
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
|
||||
@change="onParentChange"
|
||||
@select="onParentSelected"
|
||||
@clear="onParentClear"
|
||||
popper-class="bom-parent-popper"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in parentOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.name} (${item.spec})`"
|
||||
:value="item.id"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="option-row">
|
||||
<span class="option-name">{{ item.name }}</span>
|
||||
<span class="option-spec">{{ item.spec }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
<el-link
|
||||
v-if="form.parent_id && !isReadOnlyMode"
|
||||
type="primary"
|
||||
@ -166,41 +160,35 @@
|
||||
<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-select
|
||||
v-model="row.child_id"
|
||||
<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="请搜索原料"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword="true"
|
||||
:loading="searchLoading"
|
||||
:trigger-on-focus="true"
|
||||
style="flex: 1;"
|
||||
:remote-method="(q: string) => handleRemoteSearch(q, 'child', row.rowKey)"
|
||||
:loading="selectLoading"
|
||||
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
|
||||
:popper-class="`bom-loadmore-popper child-popper-${row.rowKey}`"
|
||||
:disabled="isReadOnlyMode"
|
||||
default-first-option="true"
|
||||
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', row.rowKey)"
|
||||
@select="(item: any) => onChildSelected(row, item)"
|
||||
@clear="() => onChildClear(row)"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in getChildOptions(row.rowKey)"
|
||||
:key="item.id"
|
||||
:label="`${item.name} (${item.spec})`"
|
||||
:value="item.id"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="option-row">
|
||||
<span class="option-name">{{ item.name }}</span>
|
||||
<span class="option-spec">{{ item.spec }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
<el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id && !isReadOnlyMode">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
:icon="EditPen"
|
||||
@click.stop="openMaterialInNewTab(row.child_id, getChildSpec(row.rowKey))"
|
||||
@click.stop="openMaterialInNewTab(row.child_id, row.material_spec)"
|
||||
style="font-size: 16px; padding: 4px;"
|
||||
/>
|
||||
</el-tooltip>
|
||||
@ -249,7 +237,7 @@ 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 } from '@/api/bom'
|
||||
import { getMaterialBaseList } from '@/api/inbound/stock'
|
||||
import { searchMaterialBase } from '@/api/inbound/buy'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// ============================================================
|
||||
@ -271,6 +259,8 @@ interface MaterialBase {
|
||||
interface ChildRow {
|
||||
rowKey: number // 唯一标识,替代 $index 作为 Map key
|
||||
child_id: number | null
|
||||
material_name: string
|
||||
material_spec: string
|
||||
dosage: number
|
||||
remark: string
|
||||
}
|
||||
@ -293,7 +283,17 @@ const activeCategories = ref([]) // 默认全部展开
|
||||
const searchKeyword = ref('')
|
||||
const childSearchKeyword = ref('')
|
||||
|
||||
// ★ 自动搜索:输入后 500ms 防抖触发搜索(无需回车)
|
||||
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)
|
||||
@ -303,257 +303,72 @@ watch(searchKeyword, (val) => {
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 【改造】分页 + 远程搜索相关状态
|
||||
// Material Search - el-autocomplete
|
||||
// ============================================================
|
||||
const PAGE_SIZE = 20
|
||||
const searchLoading = ref(false)
|
||||
const parentNameInput = ref('')
|
||||
|
||||
// 父件下拉
|
||||
const parentOptions = ref<MaterialBase[]>([])
|
||||
const parentQueryParams = reactive({ page: 1, limit: PAGE_SIZE, keyword: '' })
|
||||
const parentHasMore = ref(true)
|
||||
|
||||
// 子件下拉(每行独立状态)
|
||||
interface ChildDropdownState {
|
||||
options: MaterialBase[]
|
||||
queryParams: { page: number; limit: number; keyword: string }
|
||||
hasMore: boolean
|
||||
}
|
||||
const childDropdownStates = ref<Map<number, ChildDropdownState>>(new Map())
|
||||
|
||||
// 子件下拉专用的查询参数(用于 loading-text 显示)
|
||||
const childQueryParams = reactive({ page: 1 })
|
||||
|
||||
// 加载状态(父件和子件共用一个 loading,避免闪烁)
|
||||
const selectLoading = ref(false)
|
||||
|
||||
const getChildOptions = (index: number): MaterialBase[] => {
|
||||
return childDropdownStates.value.get(index)?.options ?? []
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 【改造】获取物料列表(分页版本)
|
||||
// ============================================================
|
||||
const fetchMaterialOptions = async (
|
||||
type: 'parent' | 'child',
|
||||
rowKey?: number,
|
||||
isLoadMore = false
|
||||
) => {
|
||||
// 子件行需要 rowKey(唯一标识,不再依赖数组索引)
|
||||
if (type === 'child' && rowKey === undefined) return
|
||||
|
||||
const params = type === 'parent'
|
||||
? parentQueryParams
|
||||
: childDropdownStates.value.get(rowKey!)?.queryParams
|
||||
|
||||
if (!params) return
|
||||
|
||||
selectLoading.value = true
|
||||
|
||||
try {
|
||||
const res = await getMaterialBaseList({
|
||||
page: params.page,
|
||||
limit: params.limit,
|
||||
keyword: params.keyword
|
||||
})
|
||||
|
||||
if (res.code === 200) {
|
||||
const list: MaterialBase[] = res.data?.list ?? res.data ?? []
|
||||
const total = res.data?.total ?? list.length
|
||||
|
||||
if (type === 'parent') {
|
||||
if (isLoadMore) {
|
||||
// 去重追加
|
||||
const existingIds = new Set(parentOptions.value.map(m => m.id))
|
||||
const newItems = list.filter(m => !existingIds.has(m.id))
|
||||
parentOptions.value.push(...newItems)
|
||||
} else {
|
||||
// ★ 修复回显丢失:先检查当前选中项是否在列表中,不在则从原 options 保留
|
||||
const selectedId = form.parent_id
|
||||
let finalList = [...list]
|
||||
if (selectedId && !list.find(m => m.id === selectedId)) {
|
||||
const existing = parentOptions.value.find(m => m.id === selectedId)
|
||||
if (existing) finalList.unshift(existing)
|
||||
}
|
||||
parentOptions.value = finalList
|
||||
}
|
||||
// 判断是否还有更多数据
|
||||
parentHasMore.value = parentOptions.value.length < total
|
||||
} else {
|
||||
const state = childDropdownStates.value.get(rowKey!)
|
||||
if (!state) return
|
||||
|
||||
if (isLoadMore) {
|
||||
const existingIds = new Set(state.options.map(m => m.id))
|
||||
const newItems = list.filter(m => !existingIds.has(m.id))
|
||||
state.options.push(...newItems)
|
||||
} else {
|
||||
// ★ 修复回显丢失:先通过 rowKey 精准找到当前行,再检查选中项是否在列表中
|
||||
const currentRow = form.children.find(c => c.rowKey === rowKey)
|
||||
const currentSelectedId = currentRow?.child_id
|
||||
let finalList = [...list]
|
||||
if (currentSelectedId && !list.find(m => m.id === currentSelectedId)) {
|
||||
const existing = state.options.find(m => m.id === currentSelectedId)
|
||||
if (existing) finalList.unshift(existing)
|
||||
}
|
||||
state.options = finalList
|
||||
}
|
||||
state.hasMore = state.options.length < total
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 错误已由全局拦截器统一处理
|
||||
} finally {
|
||||
selectLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 【改造】远程搜索处理函数
|
||||
// ============================================================
|
||||
const handleRemoteSearch = (
|
||||
query: string,
|
||||
type: 'parent' | 'child',
|
||||
rowKey?: number
|
||||
) => {
|
||||
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||
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()
|
||||
if (type === 'parent') {
|
||||
parentQueryParams.keyword = safeQuery
|
||||
parentQueryParams.page = 1
|
||||
parentHasMore.value = true
|
||||
fetchMaterialOptions('parent')
|
||||
} else if (type === 'child' && rowKey !== undefined) {
|
||||
const state = childDropdownStates.value.get(rowKey)
|
||||
if (!state) return
|
||||
state.queryParams.keyword = safeQuery
|
||||
state.queryParams.page = 1
|
||||
state.hasMore = true
|
||||
fetchMaterialOptions('child', rowKey)
|
||||
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 handleVisibleChange = (visible: boolean, type: 'parent' | 'child', rowKey?: number) => {
|
||||
if (!visible) return
|
||||
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 })
|
||||
}
|
||||
|
||||
if (type === 'parent') {
|
||||
// 防御性拦截:竞态条件守卫
|
||||
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 keyword),
|
||||
// 绝对不要去请求默认列表,否则会清空 keyword、覆盖正确结果。
|
||||
if (parentQueryParams.keyword || parentOptions.value.length > 0) return
|
||||
parentQueryParams.page = 1
|
||||
parentQueryParams.keyword = ''
|
||||
parentHasMore.value = true
|
||||
fetchMaterialOptions('parent')
|
||||
} else if (type === 'child' && rowKey !== undefined) {
|
||||
// 确保该行下拉状态已初始化
|
||||
if (!childDropdownStates.value.has(rowKey)) {
|
||||
childDropdownStates.value.set(rowKey, {
|
||||
options: [],
|
||||
queryParams: { page: 1, limit: PAGE_SIZE, keyword: '' },
|
||||
hasMore: true
|
||||
})
|
||||
}
|
||||
const state = childDropdownStates.value.get(rowKey)!
|
||||
// 防御性拦截:竞态条件守卫(同上)
|
||||
if (state.queryParams.keyword || state.options.length > 0) return
|
||||
state.queryParams.page = 1
|
||||
state.queryParams.keyword = ''
|
||||
state.hasMore = true
|
||||
fetchMaterialOptions('child', rowKey)
|
||||
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
|
||||
}
|
||||
|
||||
// 延迟 50ms 等待弹窗 DOM 完全渲染
|
||||
setTimeout(() => {
|
||||
// 动态拼接精确的选择器
|
||||
const exactSelector = type === 'parent'
|
||||
? '.parent-popper .el-select-dropdown__wrap'
|
||||
: `.child-popper-${rowKey} .el-select-dropdown__wrap`;
|
||||
|
||||
const popperWrap = document.querySelector(exactSelector) as HTMLElement;
|
||||
|
||||
if (popperWrap) {
|
||||
// 解绑旧事件,防止重复触发
|
||||
if ((popperWrap as any)._scrollHandler) {
|
||||
popperWrap.removeEventListener('scroll', (popperWrap as any)._scrollHandler);
|
||||
}
|
||||
|
||||
// 定义滚动触发逻辑
|
||||
(popperWrap as any)._scrollHandler = function() {
|
||||
const { scrollTop, scrollHeight, clientHeight } = this;
|
||||
// 距离底部 10px 触发
|
||||
if (scrollHeight - scrollTop - clientHeight <= 10) {
|
||||
if (type === 'parent') {
|
||||
loadMoreParent();
|
||||
} else if (type === 'child' && rowKey !== undefined) {
|
||||
// 触发子件加载
|
||||
loadMoreChild(popperWrap, rowKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
popperWrap.addEventListener('scroll', (popperWrap as any)._scrollHandler);
|
||||
}
|
||||
}, 50);
|
||||
row.child_id = item.id
|
||||
row.material_name = item.name
|
||||
row.material_spec = item.spec
|
||||
row.dosage = 1
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 【改造】滚动触底加载更多
|
||||
// ============================================================
|
||||
const loadMoreParent = () => {
|
||||
if (selectLoading.value || !parentHasMore.value) return
|
||||
parentQueryParams.page++
|
||||
fetchMaterialOptions('parent', undefined, true)
|
||||
}
|
||||
|
||||
const loadMoreChild = (_el: HTMLElement, rowKey: number) => {
|
||||
const state = childDropdownStates.value.get(rowKey)
|
||||
if (!state) return
|
||||
if (selectLoading.value || !state.hasMore) return
|
||||
state.queryParams.page++
|
||||
fetchMaterialOptions('child', rowKey, true)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 【改造】初始化子件行下拉状态
|
||||
// ============================================================
|
||||
const initChildDropdownState = (rowKey: number) => {
|
||||
if (!childDropdownStates.value.has(rowKey)) {
|
||||
childDropdownStates.value.set(rowKey, {
|
||||
options: [],
|
||||
queryParams: { page: 1, limit: PAGE_SIZE, keyword: '' },
|
||||
hasMore: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 原有逻辑保留
|
||||
// ============================================================
|
||||
const filteredChildren = computed(() => {
|
||||
if (!childSearchKeyword.value) {
|
||||
return form.children
|
||||
}
|
||||
const kw = childSearchKeyword.value.toLowerCase()
|
||||
return form.children.filter(child => {
|
||||
const state = childDropdownStates.value.get(child.rowKey)
|
||||
const material = state?.options.find(m => m.id === child.child_id)
|
||||
if (!material) return false
|
||||
const name = (material.name || '').toLowerCase()
|
||||
const spec = (material.spec || '').toLowerCase()
|
||||
return name.includes(kw) || spec.includes(kw)
|
||||
})
|
||||
})
|
||||
|
||||
// 获取子件规格(从 childDropdownStates 缓存中查找)
|
||||
const getChildSpec = (rowKey: number): string => {
|
||||
const state = childDropdownStates.value.get(rowKey)
|
||||
const row = form.children.find(c => c.rowKey === rowKey)
|
||||
if (!state || !row?.child_id) return ''
|
||||
const material = state.options.find((m: MaterialBase) => m.id === row.child_id)
|
||||
return material?.spec || ''
|
||||
return row?.material_spec || ''
|
||||
}
|
||||
|
||||
// 在新标签页打开基础信息编辑
|
||||
@ -568,8 +383,7 @@ const openMaterialInNewTab = (targetId: number | null, keyword: string = '') =>
|
||||
|
||||
const openParentMaterial = () => {
|
||||
if (!form.parent_id) return ElMessage.warning('请先选择父件')
|
||||
const parent = parentOptions.value.find((p: MaterialBase) => p.id === form.parent_id)
|
||||
const keyword = parent?.spec || parent?.name || ''
|
||||
const keyword = parentNameInput.value || ''
|
||||
openMaterialInNewTab(form.parent_id, keyword)
|
||||
}
|
||||
|
||||
@ -643,31 +457,28 @@ const onVersionUpgradeTypeChange = (type: 'minor' | 'major') => {
|
||||
}
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
parent_id: [{ required: true, message: '请选择父件', trigger: 'change' }],
|
||||
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
|
||||
activeCategories.value = []
|
||||
}
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
const onParentChange = (val: number) => {
|
||||
const selected = parentOptions.value.find(m => m.id === val)
|
||||
if (selected && selected.spec) {
|
||||
form.bom_no = selected.spec.split('/')[0].trim()
|
||||
} else {
|
||||
form.bom_no = ''
|
||||
}
|
||||
}
|
||||
const onParentChange = (val: number) => {}
|
||||
|
||||
const onChildChange = (val: number | null, index: number) => {
|
||||
if (val !== null) {
|
||||
@ -719,32 +530,18 @@ const handleSaveAs = async (row: BomItem) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 把"已清除 ID 的纯净数据"写入 form(保留子件下拉回显 + 父件下拉回显)
|
||||
// 4. 把"已清除 ID 的纯净数据"写入 form
|
||||
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 || ''
|
||||
}))
|
||||
form.children.forEach((child, idx) => {
|
||||
initChildDropdownState(idx)
|
||||
if (child.child_id) {
|
||||
const state = childDropdownStates.value.get(idx)!
|
||||
state.options = [{
|
||||
id: raw.children[idx].child_id,
|
||||
name: raw.children[idx].child_name || '未知物料',
|
||||
spec: raw.children[idx].child_spec || ''
|
||||
}]
|
||||
state.hasMore = false
|
||||
}
|
||||
})
|
||||
if (raw.parent_id) {
|
||||
form.parent_id = raw.parent_id
|
||||
parentOptions.value = [{
|
||||
id: raw.parent_id,
|
||||
name: raw.parent_name || '未知产品',
|
||||
spec: raw.parent_spec || ''
|
||||
}]
|
||||
parentNameInput.value = raw.parent_name || '未知产品'
|
||||
}
|
||||
form.bom_no = (raw.parent_spec || row.bom_no).split('/')[0].trim()
|
||||
form.remark = raw.remark || ''
|
||||
@ -771,37 +568,17 @@ const loadDetail = async (bomNo: string, version: string) => {
|
||||
const data = res.data
|
||||
// 1. 映射子件基本数据(使用 idx 生成唯一 rowKey)
|
||||
form.children = data.children.map((child: any, idx: number) => ({
|
||||
rowKey: idx, // 用数组索引作为唯一标识(编辑场景下不会增删行)
|
||||
rowKey: idx,
|
||||
child_id: child.child_id,
|
||||
material_name: child.child_name || '未知物料',
|
||||
material_spec: child.child_spec || '',
|
||||
dosage: child.dosage,
|
||||
remark: child.remark || ''
|
||||
}))
|
||||
|
||||
// 2. 初始化子件下拉状态,并预填充 options 解决回显显示 ID 的问题
|
||||
form.children.forEach((child, idx) => {
|
||||
initChildDropdownState(idx) // rowKey === idx(编辑场景下唯一)
|
||||
|
||||
if (child.child_id) {
|
||||
const state = childDropdownStates.value.get(idx)!
|
||||
// 从原始 data.children 中取对应的名称和规格注入 options
|
||||
const rawChildData = data.children[idx]
|
||||
state.options = [{
|
||||
id: rawChildData.child_id,
|
||||
name: rawChildData.child_name || '未知物料', // 依赖后端返回 child_name
|
||||
spec: rawChildData.child_spec || '' // 依赖后端返回 child_spec
|
||||
}]
|
||||
state.hasMore = false
|
||||
}
|
||||
})
|
||||
|
||||
// 3. 处理父件回显,预填充 parentOptions
|
||||
if (data.parent_id) {
|
||||
form.parent_id = data.parent_id
|
||||
parentOptions.value = [{
|
||||
id: data.parent_id,
|
||||
name: data.parent_name || '未知产品', // 依赖后端返回 parent_name
|
||||
spec: data.parent_spec || '' // 依赖后端返回 parent_spec
|
||||
}]
|
||||
parentNameInput.value = data.parent_name || '未知产品'
|
||||
}
|
||||
|
||||
if (data.parent_spec) {
|
||||
@ -838,28 +615,18 @@ const resetForm = () => {
|
||||
originalVersion = ''
|
||||
currentBomNo = ''
|
||||
childSearchKeyword.value = ''
|
||||
// 重置子件下拉状态
|
||||
childDropdownStates.value.clear()
|
||||
// 重置父件下拉状态
|
||||
parentOptions.value = []
|
||||
parentQueryParams.page = 1
|
||||
parentQueryParams.keyword = ''
|
||||
parentHasMore.value = true
|
||||
parentNameInput.value = ''
|
||||
if (formRef.value) formRef.value.resetFields()
|
||||
}
|
||||
|
||||
const addChild = () => {
|
||||
const rowKey = Date.now() // 生成唯一标识,不再使用数组长度
|
||||
form.children.push({ rowKey, child_id: null, dosage: 0, remark: '' })
|
||||
initChildDropdownState(rowKey)
|
||||
const rowKey = Date.now()
|
||||
form.children.push({ rowKey, child_id: null, material_name: '', material_spec: '', dosage: 0, remark: '' })
|
||||
}
|
||||
|
||||
const removeChild = (rowKey: number) => {
|
||||
// 通过 rowKey 找到并删除该行(不再依赖数组索引)
|
||||
const idx = form.children.findIndex(c => c.rowKey === rowKey)
|
||||
if (idx !== -1) form.children.splice(idx, 1)
|
||||
// 直接删除该行的下拉状态(无需重建索引)
|
||||
childDropdownStates.value.delete(rowKey)
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
@ -938,21 +705,13 @@ onMounted(() => {
|
||||
// ★ 情况 B:还没建过BOM,打开新建并注入父件
|
||||
handleCreate();
|
||||
|
||||
// 强行注入父件远程搜索选项
|
||||
parentOptions.value = [{
|
||||
id: parentId,
|
||||
name: parentName,
|
||||
spec: parentSpec
|
||||
}];
|
||||
parentNameInput.value = parentName;
|
||||
|
||||
// 给表单赋值
|
||||
form.parent_id = parentId;
|
||||
|
||||
// 触发联动逻辑(自动带出版本和生成编号)
|
||||
if (typeof onParentChange === 'function') {
|
||||
setTimeout(() => {
|
||||
onParentChange(parentId);
|
||||
}, 100);
|
||||
// 自动带出版本和生成编号
|
||||
if (parentSpec) {
|
||||
form.bom_no = parentSpec.split('/')[0].trim()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
1548
inventory-web/src/views/material/buyOdoo.vue
Normal file
1548
inventory-web/src/views/material/buyOdoo.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -294,31 +294,21 @@
|
||||
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
|
||||
<el-select
|
||||
v-model="form.base_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword="true"
|
||||
clearable
|
||||
<el-autocomplete
|
||||
v-model="materialNameInput"
|
||||
:fetch-suggestions="fetchMaterialSuggestions"
|
||||
:value-key="'name'"
|
||||
placeholder="请输入名称或规格进行检索..."
|
||||
:remote-method="handleSearchMaterialDebounced"
|
||||
@visible-change="handleMaterialDropdownVisible"
|
||||
:loading="searchLoading"
|
||||
:trigger-on-focus="true"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="onMaterialSelected"
|
||||
default-first-option="true"
|
||||
v-loadmore="loadMoreMaterials"
|
||||
popper-class="long-dropdown"
|
||||
@select="onMaterialSelected"
|
||||
@clear="onMaterialClear"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
<el-option
|
||||
v-for="item in materialOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="option-item">
|
||||
<div class="opt-main">
|
||||
<span class="opt-name" :title="item.name">{{ item.name }}</span>
|
||||
@ -332,11 +322,8 @@
|
||||
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-option>
|
||||
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
|
||||
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
|
||||
</div>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" style="display: flex; align-items: center;">
|
||||
@ -846,11 +833,8 @@ const queryParams = reactive({
|
||||
advancedFilters: [] as any[]
|
||||
})
|
||||
|
||||
const materialNameInput = ref('')
|
||||
const materialOptions = ref<any[]>([])
|
||||
const searchPage = ref(1)
|
||||
const searchKeyword = ref('')
|
||||
const hasNextPage = ref(true)
|
||||
let searchTimer: any = null
|
||||
|
||||
const printVisible = ref(false)
|
||||
const printLoading = ref(false)
|
||||
@ -1136,88 +1120,56 @@ const querySearchCurrency = (queryString: string, cb: any) => {
|
||||
cb(filtered)
|
||||
}
|
||||
|
||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||
if (!visible) return
|
||||
// 防御性拦截:竞态条件守卫
|
||||
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
||||
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
||||
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
||||
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
|
||||
const handleSearchMaterialDebounced = (query: string) => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
handleSearchMaterial(query)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleSearchMaterial = async (query: string) => {
|
||||
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||
const rawQuery = String(query || '')
|
||||
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||
const fetchMaterialSuggestions = async (query: string, cb: (results: any[]) => void) => {
|
||||
const safeQuery = String(query || '').replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||
searchLoading.value = true
|
||||
searchKeyword.value = safeQuery
|
||||
searchPage.value = 1
|
||||
materialOptions.value = []
|
||||
|
||||
try {
|
||||
const res: any = await searchMaterialBase(safeQuery, 1)
|
||||
if (res.data) {
|
||||
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
|
||||
materialOptions.value = apiResults
|
||||
hasNextPage.value = res.has_next
|
||||
}
|
||||
} finally { searchLoading.value = false }
|
||||
}
|
||||
|
||||
const loadMoreMaterials = async () => {
|
||||
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
|
||||
loadingMore.value = true
|
||||
searchPage.value += 1
|
||||
try {
|
||||
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
|
||||
if (res.data && res.data.length > 0) {
|
||||
const newItems = res.data.map((i: any) => ({...i, isHistory: false}))
|
||||
materialOptions.value.push(...newItems)
|
||||
hasNextPage.value = res.has_next
|
||||
const res: any = await searchMaterialBase(safeQuery)
|
||||
if (res.code === 200 && res.data) {
|
||||
cb((res.data || []).map((i: any) => ({ ...i, isHistory: false })))
|
||||
} else {
|
||||
hasNextPage.value = false
|
||||
cb([])
|
||||
}
|
||||
} catch (e) {
|
||||
searchPage.value -= 1
|
||||
cb([])
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onMaterialSelected = async (val: number) => {
|
||||
const item = materialOptions.value.find(i => i.id === val)
|
||||
if (item) {
|
||||
form.company_name = item.company_name
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
form.material_type = item.type
|
||||
// 保存强制质检标记
|
||||
isCurrentMaterialInspectionRequired.value = item.isInspectionRequired || false
|
||||
// 更新表单校验规则
|
||||
updateInspectionRules()
|
||||
checkHistoryAndSetMode(item.id)
|
||||
const onMaterialClear = () => {
|
||||
form.base_id = undefined
|
||||
form.company_name = ''
|
||||
form.material_name = ''
|
||||
form.spec_model = ''
|
||||
form.category = ''
|
||||
form.unit = ''
|
||||
form.material_type = ''
|
||||
isCurrentMaterialInspectionRequired.value = false
|
||||
updateInspectionRules()
|
||||
}
|
||||
|
||||
// 获取该物料历史入库库位(新增独立接口)
|
||||
try {
|
||||
const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: val } })
|
||||
if (res.code === 200 && res.data.location) {
|
||||
form.warehouse_location = res.data.location
|
||||
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取历史库位失败', e)
|
||||
const onMaterialSelected = async (item: any) => {
|
||||
form.base_id = item.id
|
||||
form.company_name = item.company_name
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
form.material_type = item.type
|
||||
materialNameInput.value = item.name
|
||||
isCurrentMaterialInspectionRequired.value = item.isInspectionRequired || false
|
||||
updateInspectionRules()
|
||||
checkHistoryAndSetMode(item.id)
|
||||
|
||||
try {
|
||||
const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: item.id } })
|
||||
if (res.code === 200 && res.data.location) {
|
||||
form.warehouse_location = res.data.location
|
||||
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取历史库位失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1507,6 +1459,7 @@ const handleUpdate = (row: any) => {
|
||||
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
|
||||
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
|
||||
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isInspectionRequired: row.isInspectionRequired }]
|
||||
materialNameInput.value = row.material_name
|
||||
// 设置强制质检标记
|
||||
isCurrentMaterialInspectionRequired.value = row.isInspectionRequired || false
|
||||
updateInspectionRules()
|
||||
@ -1563,8 +1516,10 @@ const submitForm = async () => {
|
||||
|
||||
await fetchData()
|
||||
visible.value = false
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.msg || '操作失败')
|
||||
} catch (error: any) {
|
||||
// 后端返回 HTTP 500 时(如物料类别隔离校验),从 axios 错误的 response.data.msg 提取具体报错
|
||||
const errorMsg = error.response?.data?.msg || error.message || '系统内部错误,入库失败'
|
||||
ElMessage.error(errorMsg)
|
||||
} finally { submitting.value = false }
|
||||
} else {
|
||||
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
|
||||
@ -1799,8 +1754,7 @@ const confirmPrint = async () => {
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
materialOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = ''
|
||||
searchPage.value = 1; hasNextPage.value = true; searchKeyword.value = '';
|
||||
materialOptions.value = []; materialNameInput.value = ''; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = ''
|
||||
// 重置强制质检标记
|
||||
isCurrentMaterialInspectionRequired.value = false
|
||||
Object.assign(form, {
|
||||
|
||||
@ -283,24 +283,21 @@
|
||||
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
|
||||
<el-select
|
||||
v-model="form.base_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword="true"
|
||||
<el-autocomplete
|
||||
v-model="materialNameInput"
|
||||
:fetch-suggestions="fetchMaterialSuggestions"
|
||||
:value-key="'name'"
|
||||
clearable
|
||||
placeholder="请输入名称或规格进行检索..."
|
||||
:remote-method="handleSearchMaterial"
|
||||
@visible-change="handleMaterialDropdownVisible"
|
||||
:loading="searchLoading"
|
||||
:trigger-on-focus="true"
|
||||
style="width: 100%"
|
||||
@change="onMaterialSelected"
|
||||
default-first-option="true"
|
||||
v-loadmore="loadMoreMaterials"
|
||||
@select="onMaterialSelected"
|
||||
@clear="onMaterialClear"
|
||||
popper-class="product-dropdown"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
|
||||
<template #default="{ item }">
|
||||
<div class="option-item">
|
||||
<div class="opt-main">
|
||||
<span class="opt-name" :title="item.name">{{ item.name }}</span>
|
||||
@ -314,11 +311,8 @@
|
||||
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-option>
|
||||
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
|
||||
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
|
||||
</div>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" style="display: flex; align-items: center;">
|
||||
@ -625,28 +619,6 @@ const debounce = (fn: Function, delay: number = 500) => {
|
||||
}
|
||||
|
||||
// ------------------------------------
|
||||
// v-loadmore
|
||||
// ------------------------------------
|
||||
const vLoadmore = {
|
||||
mounted(el: any, binding: any) {
|
||||
const checkAndBind = () => {
|
||||
// 这里的 .product-dropdown 是唯一标识,防止和采购/半成品页面冲突
|
||||
const dropDownWrap = document.querySelector('.product-dropdown .el-select-dropdown__wrap')
|
||||
if (dropDownWrap && !dropDownWrap.getAttribute('data-loadmore-bound')) {
|
||||
dropDownWrap.setAttribute('data-loadmore-bound', 'true')
|
||||
dropDownWrap.addEventListener('scroll', function (this: any) {
|
||||
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 1
|
||||
if (condition) {
|
||||
binding.value()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
setTimeout(checkAndBind, 500)
|
||||
el.addEventListener('click', () => setTimeout(checkAndBind, 300))
|
||||
}
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
@ -667,7 +639,6 @@ const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const visible = ref(false)
|
||||
const searchLoading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const dialogStatus = ref<'create' | 'update'>('create')
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
@ -738,11 +709,7 @@ const operatorOptions = ref([
|
||||
{ label: '大于等于', value: '>=' },
|
||||
{ label: '小于等于', value: '<=' },
|
||||
])
|
||||
const materialOptions = ref<any[]>([])
|
||||
const searchPage = ref(1)
|
||||
const searchKeyword = ref('')
|
||||
const hasNextPage = ref(true)
|
||||
let searchTimer: any = null
|
||||
const materialNameInput = ref('')
|
||||
|
||||
// BOM 搜索相关
|
||||
const bomSearchLoading = ref(false)
|
||||
@ -1062,93 +1029,52 @@ const rules = {
|
||||
}
|
||||
|
||||
|
||||
// Material Search & Population Logic
|
||||
// ------------------------------------
|
||||
// Material Search & Population Logic (已修改)
|
||||
// ------------------------------------
|
||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||
if (!visible) return
|
||||
// 防御性拦截:竞态条件守卫
|
||||
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
||||
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
||||
// 只有当没有搜索关键字、且下拉列表为空时,才加载默认数据。
|
||||
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
||||
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
|
||||
const handleSearchMaterialDebounced = (query: string) => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
handleSearchMaterial(query)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleSearchMaterial = async (query: string) => {
|
||||
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||
// 1) 强制转字符串,防 ClipboardEvent 对象
|
||||
// 2) 深度净化:剔除所有控制字符、零宽字符、BOM
|
||||
// 3) 常规 trim
|
||||
const fetchMaterialSuggestions = (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
|
||||
searchKeyword.value = safeQuery
|
||||
searchPage.value = 1
|
||||
materialOptions.value = []
|
||||
|
||||
try {
|
||||
const res: any = await searchMaterialBase(safeQuery, 1)
|
||||
const apiResults = (res.data?.items || []).map((i: any) => ({ ...i, isHistory: false }))
|
||||
materialOptions.value = apiResults
|
||||
hasNextPage.value = res.data?.has_next ?? false
|
||||
} finally { searchLoading.value = false }
|
||||
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, isHistory: false }))
|
||||
cb(formatted)
|
||||
}).catch(() => cb([])).finally(() => { searchLoading.value = false })
|
||||
}
|
||||
|
||||
const loadMoreMaterials = async () => {
|
||||
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
|
||||
loadingMore.value = true
|
||||
searchPage.value += 1
|
||||
try {
|
||||
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
|
||||
if (res.data && res.data.items && res.data.items.length > 0) {
|
||||
const newItems = res.data.items.map((i: any) => ({...i, isHistory: false}))
|
||||
materialOptions.value.push(...newItems)
|
||||
hasNextPage.value = res.data.has_next
|
||||
} else {
|
||||
hasNextPage.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
searchPage.value -= 1
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
const onMaterialClear = () => {
|
||||
form.base_id = undefined
|
||||
form.company_name = ''
|
||||
form.material_name = ''
|
||||
form.spec_model = ''
|
||||
form.material_type = ''
|
||||
form.category = ''
|
||||
form.unit = ''
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
bomOptions.value = []
|
||||
}
|
||||
|
||||
const onMaterialSelected = async (val: number) => {
|
||||
const item = materialOptions.value.find(i => i.id === val)
|
||||
if (item) {
|
||||
form.company_name = item.company_name // [新增]
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.material_type = item.type
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
// 切换物料时清空已选 BOM,防止脏数据
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
bomOptions.value = []
|
||||
|
||||
// 获取该物料历史入库库位(新增独立接口)
|
||||
try {
|
||||
const res = await request.get('/v1/inbound/product/last-location', { params: { base_id: val } })
|
||||
if (res.code === 200 && res.data.location) {
|
||||
form.warehouse_location = res.data.location
|
||||
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取历史库位失败', e)
|
||||
const onMaterialSelected = (item: any) => {
|
||||
form.base_id = item.id
|
||||
form.company_name = item.company_name
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.material_type = item.type
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
materialNameInput.value = item.name
|
||||
// 切换物料时清空已选 BOM,防止脏数据
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
bomOptions.value = []
|
||||
// 获取该物料历史入库库位
|
||||
request.get('/v1/inbound/product/last-location', { params: { base_id: item.id } }).then((res: any) => {
|
||||
if (res.code === 200 && res.data?.location) {
|
||||
form.warehouse_location = res.data.location
|
||||
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
|
||||
}
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ------------------------------------
|
||||
@ -1298,7 +1224,6 @@ const handleCreate = () => {
|
||||
resetForm()
|
||||
form.in_date = dayjs().format('YYYY-MM-DD')
|
||||
visible.value = true
|
||||
materialOptions.value = []
|
||||
}
|
||||
|
||||
const handleUpdate = (row: any) => {
|
||||
@ -1327,7 +1252,7 @@ const handleUpdate = (row: any) => {
|
||||
inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
|
||||
const iLinks = iReports.filter(r => isExternalLink(r))
|
||||
inspection_url.value = iLinks.length > 0 ? iLinks[0] : ''
|
||||
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }]
|
||||
materialNameInput.value = row.material_name
|
||||
// 回显BOM
|
||||
if (form.bom_code) {
|
||||
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
|
||||
@ -1475,8 +1400,10 @@ const submitForm = async () => {
|
||||
if (newItem) { ElMessage.info('发送打印...'); try { await executePrint({ ...newItem, copies: form.print_copies }); ElMessage.success(`指令已发送 (x${form.print_copies})`) } catch (e: any) { ElMessage.warning('打印失败') } }
|
||||
} else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') }
|
||||
visible.value = false; fetchData()
|
||||
} catch(e:any) {
|
||||
ElMessage.error(e.msg || '操作失败')
|
||||
} catch(error:any) {
|
||||
// 后端返回 HTTP 500 时(如物料类别隔离校验),从 axios 错误的 response.data.msg 提取具体报错
|
||||
const errorMsg = error.response?.data?.msg || error.message || '系统内部错误,入库失败'
|
||||
ElMessage.error(errorMsg)
|
||||
} finally { submitting.value = false }
|
||||
} else {
|
||||
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
|
||||
@ -1525,7 +1452,7 @@ const handlePrint = async (row: any) => {
|
||||
}
|
||||
const confirmPrint = async () => { printing.value = true; try { await executePrint({ ...currentPrintData.value, copies: printCopies.value }); ElMessage.success(`已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error('打印失败') } finally { printing.value = false } }
|
||||
const resetForm = () => {
|
||||
materialOptions.value = []; bomOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = ''
|
||||
materialNameInput.value = ''; bomOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = ''
|
||||
Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', unit: '', sku: '', barcode: '', serial_number: '', in_date: '', in_quantity: 1, stock_quantity: 1, available_quantity: 1, print_copies: 1, warehouse_location: '', status: '在库', quality_status: '合格', bom_code: '', bom_version: '', work_order_code: '', order_id: '', production_manager: '', production_time_range: [], raw_material_cost: undefined, unit_total_cost: undefined, total_price: undefined, sale_price: undefined, quality_report_link: [], inspection_report_link: [], product_photo: [], detail_link: '' })
|
||||
}
|
||||
const getStatusType = (s:string) => ({'在库':'success','出库':'info','借库':'warning','损耗':'danger'}[s]||'warning')
|
||||
|
||||
@ -318,29 +318,19 @@
|
||||
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
|
||||
<el-select
|
||||
v-model="form.base_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword="true"
|
||||
clearable
|
||||
<el-autocomplete
|
||||
v-model="materialNameInput"
|
||||
:fetch-suggestions="fetchMaterialSuggestions"
|
||||
:value-key="'name'"
|
||||
placeholder="请输入名称或规格进行检索..."
|
||||
:remote-method="handleSearchMaterial"
|
||||
@visible-change="handleMaterialDropdownVisible"
|
||||
:loading="searchLoading"
|
||||
:trigger-on-focus="true"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="onMaterialSelected"
|
||||
default-first-option="true"
|
||||
v-loadmore="loadMoreMaterials"
|
||||
popper-class="long-dropdown"
|
||||
@select="onMaterialSelected"
|
||||
@clear="onMaterialClear"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
<el-option
|
||||
v-for="item in materialOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="option-item">
|
||||
<div class="opt-main">
|
||||
<span class="opt-name" :title="item.name">{{ item.name }}</span>
|
||||
@ -354,11 +344,8 @@
|
||||
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-option>
|
||||
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
|
||||
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
|
||||
</div>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" style="display: flex; align-items: center;">
|
||||
@ -791,11 +778,8 @@ const operatorOptions = ref([
|
||||
{ label: '大于等于', value: '>=' },
|
||||
{ label: '小于等于', value: '<=' },
|
||||
])
|
||||
const materialNameInput = ref('')
|
||||
const materialOptions = ref<any[]>([])
|
||||
const searchPage = ref(1)
|
||||
const searchKeyword = ref('')
|
||||
const hasNextPage = ref(true)
|
||||
let searchTimer: any = null
|
||||
|
||||
// BOM 搜索相关
|
||||
const bomSearchLoading = ref(false)
|
||||
@ -1059,90 +1043,58 @@ const handleManagerSelect = (item: any) => {
|
||||
// ------------------------------------
|
||||
// Material Search (Matches Buy.vue)
|
||||
// ------------------------------------
|
||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||
if (!visible) return
|
||||
// 防御性拦截:竞态条件守卫
|
||||
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
||||
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
||||
// 只有当没有搜索关键字、且下拉列表为空时,才加载默认数据。
|
||||
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
||||
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
|
||||
const handleSearchMaterialDebounced = (query: string) => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
handleSearchMaterial(query)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleSearchMaterial = async (query: string) => {
|
||||
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||
// 1) 强制转字符串,防 ClipboardEvent 对象
|
||||
// 2) 深度净化:剔除所有控制字符、零宽字符、BOM
|
||||
// 3) 常规 trim
|
||||
const rawQuery = String(query || '')
|
||||
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||
const fetchMaterialSuggestions = async (query: string, cb: (results: any[]) => void) => {
|
||||
const safeQuery = String(query || '').replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||
searchLoading.value = true
|
||||
searchKeyword.value = safeQuery
|
||||
searchPage.value = 1
|
||||
materialOptions.value = []
|
||||
|
||||
try {
|
||||
const res: any = await searchMaterialBase(safeQuery, 1)
|
||||
const apiResults = (res.data?.items || []).map((i: any) => ({...i, isHistory: false}))
|
||||
materialOptions.value = apiResults
|
||||
hasNextPage.value = res.data?.has_next ?? false
|
||||
} finally { searchLoading.value = false }
|
||||
}
|
||||
|
||||
const loadMoreMaterials = async () => {
|
||||
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
|
||||
loadingMore.value = true
|
||||
searchPage.value += 1
|
||||
try {
|
||||
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
|
||||
if (res.data && res.data.items && res.data.items.length > 0) {
|
||||
const newItems = res.data.items.map((i: any) => ({...i, isHistory: false}))
|
||||
materialOptions.value.push(...newItems)
|
||||
hasNextPage.value = res.data.has_next
|
||||
const res: any = await searchMaterialBase(safeQuery)
|
||||
if (res.code === 200 && res.data) {
|
||||
cb((res.data?.items || res.data || []).map((i: any) => ({ ...i, isHistory: false })))
|
||||
} else {
|
||||
hasNextPage.value = false
|
||||
cb([])
|
||||
}
|
||||
} catch (e) {
|
||||
searchPage.value -= 1
|
||||
cb([])
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onMaterialSelected = async (val: number) => {
|
||||
const item = materialOptions.value.find(i => i.id === val)
|
||||
if (item) {
|
||||
form.company_name = item.company_name // [新增]
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
form.material_type = item.type
|
||||
// 切换物料时清空已选 BOM,防止脏数据
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
bomOptions.value = []
|
||||
checkHistoryAndSetMode(item.id)
|
||||
const onMaterialClear = () => {
|
||||
form.base_id = undefined
|
||||
form.company_name = ''
|
||||
form.material_name = ''
|
||||
form.spec_model = ''
|
||||
form.category = ''
|
||||
form.unit = ''
|
||||
form.material_type = ''
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
bomOptions.value = []
|
||||
}
|
||||
|
||||
// 获取该物料历史入库库位(新增独立接口)
|
||||
try {
|
||||
const res = await request.get('/v1/inbound/semi/last-location', { params: { base_id: val } })
|
||||
if (res.code === 200 && res.data.location) {
|
||||
form.warehouse_location = res.data.location
|
||||
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取历史库位失败', e)
|
||||
const onMaterialSelected = async (item: any) => {
|
||||
form.base_id = item.id
|
||||
form.company_name = item.company_name
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
form.material_type = item.type
|
||||
materialNameInput.value = item.name
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
bomOptions.value = []
|
||||
checkHistoryAndSetMode(item.id)
|
||||
|
||||
try {
|
||||
const res = await request.get('/v1/inbound/semi/last-location', { params: { base_id: item.id } })
|
||||
if (res.code === 200 && res.data.location) {
|
||||
form.warehouse_location = res.data.location
|
||||
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取历史库位失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1412,6 +1364,7 @@ const handleUpdate = (row: any) => {
|
||||
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
|
||||
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
|
||||
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }]
|
||||
materialNameInput.value = row.material_name
|
||||
// 回显BOM,如果存在
|
||||
if (form.bom_code) {
|
||||
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
|
||||
@ -1553,8 +1506,10 @@ const submitForm = async () => {
|
||||
}
|
||||
} else { await updateSemiInbound(form.id!, payload); ElMessage.success('更新成功') }
|
||||
await fetchData(); visible.value = false
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.msg || '操作失败')
|
||||
} catch (error: any) {
|
||||
// 后端返回 HTTP 500 时(如物料类别隔离校验),从 axios 错误的 response.data.msg 提取具体报错
|
||||
const errorMsg = error.response?.data?.msg || error.message || '系统内部错误,入库失败'
|
||||
ElMessage.error(errorMsg)
|
||||
} finally { submitting.value = false }
|
||||
} else {
|
||||
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
|
||||
@ -1603,7 +1558,7 @@ const handlePrint = async (row: any) => {
|
||||
}
|
||||
const confirmPrint = async () => { printing.value = true; try { await executePrint({ ...currentPrintData.value, copies: printCopies.value }); ElMessage.success(`指令已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error(e.msg || '打印失败') } finally { printing.value = false } }
|
||||
const resetForm = () => {
|
||||
materialOptions.value = []; bomOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = ''
|
||||
materialOptions.value = []; materialNameInput.value = ''; bomOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = ''
|
||||
Object.assign(form, {
|
||||
id: undefined, base_id: undefined,
|
||||
company_name: '', // [新增]
|
||||
|
||||
@ -333,9 +333,12 @@ const handlePageChange = (val: number) => {
|
||||
|
||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||
if (!visible) return
|
||||
// 防御性拦截:竞态条件守卫
|
||||
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
||||
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
||||
// 防御性拦截 1:用户已选过物料(form.base_id 有值)
|
||||
// 此时下拉打开只是 el-select 切换到"输入模式",绝不能去请求默认列表。
|
||||
// 否则会清空 searchKeyword 和 materialOptions,破坏用户正在编辑的搜索结果。
|
||||
if (form.base_id) return
|
||||
// 防御性拦截 2:已经有搜索关键字或已经有下拉数据
|
||||
// 同样不要重置、不要再请求默认列表
|
||||
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
@ -344,6 +347,12 @@ const handleSearchMaterial = async (query: string) => {
|
||||
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||
const rawQuery = String(query || '')
|
||||
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||
// 防御性拦截:el-select 在 filterable + remote 模式下,用户点击已聚焦的 input 时
|
||||
// 会内部 emit query='' 触发 remote-method。这种"清空式 emit"是 el-select 切换到输入模式
|
||||
// 的固有行为,绝不能破坏已选物料对应的搜索结果(清空 searchKeyword + materialOptions)。
|
||||
// 只有当 form.base_id 已有值、当前查询为空、且下拉列表非空时,才拦截。
|
||||
// 真正"清空"的场景(用户点 X 按钮)会通过 clearable 把 form.base_id 置空,本拦截放行。
|
||||
if (!safeQuery && form.base_id && materialOptions.value.length > 0) return
|
||||
searchKeyword.value = safeQuery
|
||||
searchLoading.value = true
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user