入库模块:物料搜索点击无感修复 + 类别校验白名单准入制
前端(buy/semi/product/service.vue,4 文件): 修复物料搜索"点击已聚焦 input 时内容被清空"交互 bug。 el-select 在 filterable+remote 模式下点击已聚焦的 input 时,el-select 内部 会 emit query='' 触发 remote-method,绕过 handleMaterialDropdownVisible 入口保护,直接清空 searchKeyword 和 materialOptions,导致用户被迫重写。 新增两层防御实现"编辑无感": 1) handleMaterialDropdownVisible 入口拦截:已选过物料(form.base_id 有值) 时下拉打开直接 return,不请求默认列表 2) handleSearchMaterial 内部拦截:拦截 el-select 内部 emit 的空 query, 仅在 form.base_id 有值 + safeQuery 为空 + 列表非空时 return 后端(buy/semi/product_service.py,3 文件): 入库类别校验从黑名单改为白名单准入制,彻底杜绝"成品进半成品库" 等非法组合(d94b52b 黑名单方案"成品不能进采购库"已挡不住这种组合)。 - buy_service.py: 黑名单(禁半成品/成品进采购)→ 白名单(必须含"原材料") - semi_service.py: 统一错误信息格式为"只有【半成品】才允许半成品入库!" - product_service.py: 统一错误信息格式为"只有【成品】才允许成品入库!" - 三处空 category 统一显示为"未分类" 配合前端已修复的 catch 块(e.response.data.msg 精准提取),后端新错误 信息可原样弹窗给用户。
This commit is contained in:
@ -101,10 +101,10 @@ class BuyInboundService:
|
|||||||
if not material.is_enabled: raise ValueError(f"物料【{material.name}】已停用")
|
if not material.is_enabled: raise ValueError(f"物料【{material.name}】已停用")
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 物料类别隔离校验:采购入库禁止"半成品"/"成品"
|
# 物料类别隔离校验:采购入库必须为"原材料"类目(白名单准入制)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
if material.category and ("半成品" in material.category or "成品" in material.category):
|
if not material.category or "原材料" not in material.category:
|
||||||
raise ValueError(f"物料【{material.name}】类别为【{material.category}】,禁止作为采购件入库!")
|
raise ValueError(f"物料【{material.name}】属于【{material.category or '未分类'}】,只有【原材料】才允许采购入库!")
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 强制质检校验:如果物料标记为强制质检,则必须提供到检状态和检测报告
|
# 强制质检校验:如果物料标记为强制质检,则必须提供到检状态和检测报告
|
||||||
|
|||||||
@ -116,10 +116,10 @@ class ProductInboundService:
|
|||||||
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
|
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 物料类别隔离校验:成品入库必须为"成品"类目
|
# 物料类别隔离校验:成品入库必须为"成品"类目(白名单准入制)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
if not material.category or "成品" not in material.category:
|
if not material.category or "成品" not in material.category:
|
||||||
raise ValueError(f"物料【{material.name}】类别为【{material.category or '空'}】,非成品,无法办理成品入库!")
|
raise ValueError(f"物料【{material.name}】属于【{material.category or '未分类'}】,只有【成品】才允许成品入库!")
|
||||||
|
|
||||||
ProductInboundService._check_unique(
|
ProductInboundService._check_unique(
|
||||||
serial_number=data.get('serial_number')
|
serial_number=data.get('serial_number')
|
||||||
|
|||||||
@ -123,10 +123,10 @@ class SemiInboundService:
|
|||||||
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
|
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 物料类别隔离校验:半成品入库必须为"半成品"类目
|
# 物料类别隔离校验:半成品入库必须为"半成品"类目(白名单准入制)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
if not material.category or "半成品" not in material.category:
|
if not material.category or "半成品" not in material.category:
|
||||||
raise ValueError(f"物料【{material.name}】类别为【{material.category or '空'}】,非半成品,无法办理半成品入库!")
|
raise ValueError(f"物料【{material.name}】属于【{material.category or '未分类'}】,只有【半成品】才允许半成品入库!")
|
||||||
|
|
||||||
SemiInboundService._check_unique(
|
SemiInboundService._check_unique(
|
||||||
base_id=base_id,
|
base_id=base_id,
|
||||||
|
|||||||
@ -1138,9 +1138,12 @@ const querySearchCurrency = (queryString: string, cb: any) => {
|
|||||||
|
|
||||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
// 防御性拦截:竞态条件守卫
|
// 防御性拦截 1:用户已选过物料(form.base_id 有值)
|
||||||
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
// 此时下拉打开只是 el-select 切换到"输入模式",绝不能去请求默认列表。
|
||||||
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
// 否则会清空 searchKeyword 和 materialOptions,破坏用户正在编辑的搜索结果。
|
||||||
|
if (form.base_id) return
|
||||||
|
// 防御性拦截 2:已经有搜索关键字或已经有下拉数据
|
||||||
|
// 同样不要重置、不要再请求默认列表
|
||||||
if (searchKeyword.value || materialOptions.value.length > 0) return
|
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||||
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
||||||
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
||||||
@ -1158,6 +1161,12 @@ const handleSearchMaterial = async (query: string) => {
|
|||||||
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||||
const rawQuery = String(query || '')
|
const rawQuery = String(query || '')
|
||||||
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
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
|
||||||
searchLoading.value = true
|
searchLoading.value = true
|
||||||
searchKeyword.value = safeQuery
|
searchKeyword.value = safeQuery
|
||||||
searchPage.value = 1
|
searchPage.value = 1
|
||||||
@ -1563,8 +1572,10 @@ const submitForm = async () => {
|
|||||||
|
|
||||||
await fetchData()
|
await fetchData()
|
||||||
visible.value = false
|
visible.value = false
|
||||||
} catch (e: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(e.msg || '操作失败')
|
// 后端返回 HTTP 500 时(如物料类别隔离校验),从 axios 错误的 response.data.msg 提取具体报错
|
||||||
|
const errorMsg = error.response?.data?.msg || error.message || '系统内部错误,入库失败'
|
||||||
|
ElMessage.error(errorMsg)
|
||||||
} finally { submitting.value = false }
|
} finally { submitting.value = false }
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
|
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
|
||||||
|
|||||||
@ -1067,10 +1067,12 @@ const rules = {
|
|||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
// 防御性拦截:竞态条件守卫
|
// 防御性拦截 1:用户已选过物料(form.base_id 有值)
|
||||||
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
// 此时下拉打开只是 el-select 切换到"输入模式",绝不能去请求默认列表。
|
||||||
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
// 否则会清空 searchKeyword 和 materialOptions,破坏用户正在编辑的搜索结果。
|
||||||
// 只有当没有搜索关键字、且下拉列表为空时,才加载默认数据。
|
if (form.base_id) return
|
||||||
|
// 防御性拦截 2:已经有搜索关键字或已经有下拉数据
|
||||||
|
// 同样不要重置、不要再请求默认列表
|
||||||
if (searchKeyword.value || materialOptions.value.length > 0) return
|
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||||
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
||||||
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
||||||
@ -1091,6 +1093,12 @@ const handleSearchMaterial = async (query: string) => {
|
|||||||
// 3) 常规 trim
|
// 3) 常规 trim
|
||||||
const rawQuery = String(query || '')
|
const rawQuery = String(query || '')
|
||||||
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
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
|
||||||
searchLoading.value = true
|
searchLoading.value = true
|
||||||
searchKeyword.value = safeQuery
|
searchKeyword.value = safeQuery
|
||||||
searchPage.value = 1
|
searchPage.value = 1
|
||||||
@ -1475,8 +1483,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('打印失败') } }
|
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('更新成功') }
|
} else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') }
|
||||||
visible.value = false; fetchData()
|
visible.value = false; fetchData()
|
||||||
} catch(e:any) {
|
} catch(error:any) {
|
||||||
ElMessage.error(e.msg || '操作失败')
|
// 后端返回 HTTP 500 时(如物料类别隔离校验),从 axios 错误的 response.data.msg 提取具体报错
|
||||||
|
const errorMsg = error.response?.data?.msg || error.message || '系统内部错误,入库失败'
|
||||||
|
ElMessage.error(errorMsg)
|
||||||
} finally { submitting.value = false }
|
} finally { submitting.value = false }
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
|
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
|
||||||
|
|||||||
@ -1061,10 +1061,12 @@ const handleManagerSelect = (item: any) => {
|
|||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
// 防御性拦截:竞态条件守卫
|
// 防御性拦截 1:用户已选过物料(form.base_id 有值)
|
||||||
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
// 此时下拉打开只是 el-select 切换到"输入模式",绝不能去请求默认列表。
|
||||||
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
// 否则会清空 searchKeyword 和 materialOptions,破坏用户正在编辑的搜索结果。
|
||||||
// 只有当没有搜索关键字、且下拉列表为空时,才加载默认数据。
|
if (form.base_id) return
|
||||||
|
// 防御性拦截 2:已经有搜索关键字或已经有下拉数据
|
||||||
|
// 同样不要重置、不要再请求默认列表
|
||||||
if (searchKeyword.value || materialOptions.value.length > 0) return
|
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||||
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
||||||
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
||||||
@ -1085,6 +1087,12 @@ const handleSearchMaterial = async (query: string) => {
|
|||||||
// 3) 常规 trim
|
// 3) 常规 trim
|
||||||
const rawQuery = String(query || '')
|
const rawQuery = String(query || '')
|
||||||
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
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
|
||||||
searchLoading.value = true
|
searchLoading.value = true
|
||||||
searchKeyword.value = safeQuery
|
searchKeyword.value = safeQuery
|
||||||
searchPage.value = 1
|
searchPage.value = 1
|
||||||
@ -1553,8 +1561,10 @@ const submitForm = async () => {
|
|||||||
}
|
}
|
||||||
} else { await updateSemiInbound(form.id!, payload); ElMessage.success('更新成功') }
|
} else { await updateSemiInbound(form.id!, payload); ElMessage.success('更新成功') }
|
||||||
await fetchData(); visible.value = false
|
await fetchData(); visible.value = false
|
||||||
} catch (e: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(e.msg || '操作失败')
|
// 后端返回 HTTP 500 时(如物料类别隔离校验),从 axios 错误的 response.data.msg 提取具体报错
|
||||||
|
const errorMsg = error.response?.data?.msg || error.message || '系统内部错误,入库失败'
|
||||||
|
ElMessage.error(errorMsg)
|
||||||
} finally { submitting.value = false }
|
} finally { submitting.value = false }
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
|
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
|
||||||
|
|||||||
@ -333,9 +333,12 @@ const handlePageChange = (val: number) => {
|
|||||||
|
|
||||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
// 防御性拦截:竞态条件守卫
|
// 防御性拦截 1:用户已选过物料(form.base_id 有值)
|
||||||
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
// 此时下拉打开只是 el-select 切换到"输入模式",绝不能去请求默认列表。
|
||||||
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
// 否则会清空 searchKeyword 和 materialOptions,破坏用户正在编辑的搜索结果。
|
||||||
|
if (form.base_id) return
|
||||||
|
// 防御性拦截 2:已经有搜索关键字或已经有下拉数据
|
||||||
|
// 同样不要重置、不要再请求默认列表
|
||||||
if (searchKeyword.value || materialOptions.value.length > 0) return
|
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||||
handleSearchMaterial('')
|
handleSearchMaterial('')
|
||||||
}
|
}
|
||||||
@ -344,6 +347,12 @@ const handleSearchMaterial = async (query: string) => {
|
|||||||
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||||
const rawQuery = String(query || '')
|
const rawQuery = String(query || '')
|
||||||
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
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
|
searchKeyword.value = safeQuery
|
||||||
searchLoading.value = true
|
searchLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user