From ff5418afa3f502e9f361acab0fb2be06b69a8c78 Mon Sep 17 00:00:00 2001 From: DXC Date: Thu, 4 Jun 2026 17:57:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A5=E5=BA=93=E6=A8=A1=E5=9D=97=EF=BC=9A?= =?UTF-8?q?=E7=89=A9=E6=96=99=E6=90=9C=E7=B4=A2=E7=82=B9=E5=87=BB=E6=97=A0?= =?UTF-8?q?=E6=84=9F=E4=BF=AE=E5=A4=8D=20+=20=E7=B1=BB=E5=88=AB=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E7=99=BD=E5=90=8D=E5=8D=95=E5=87=86=E5=85=A5=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端(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 精准提取),后端新错误 信息可原样弹窗给用户。 --- .../app/services/inbound/buy_service.py | 6 ++--- .../app/services/inbound/product_service.py | 4 ++-- .../app/services/inbound/semi_service.py | 4 ++-- inventory-web/src/views/stock/inbound/buy.vue | 21 +++++++++++++----- .../src/views/stock/inbound/product.vue | 22 ++++++++++++++----- .../src/views/stock/inbound/semi.vue | 22 ++++++++++++++----- .../src/views/stock/inbound/service.vue | 15 ++++++++++--- 7 files changed, 67 insertions(+), 27 deletions(-) diff --git a/inventory-backend/app/services/inbound/buy_service.py b/inventory-backend/app/services/inbound/buy_service.py index 11fbe5e..1b00f37 100644 --- a/inventory-backend/app/services/inbound/buy_service.py +++ b/inventory-backend/app/services/inbound/buy_service.py @@ -101,10 +101,10 @@ class BuyInboundService: 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}】,禁止作为采购件入库!") + if not material.category or "原材料" not in material.category: + raise ValueError(f"物料【{material.name}】属于【{material.category or '未分类'}】,只有【原材料】才允许采购入库!") # ============================================================ # 强制质检校验:如果物料标记为强制质检,则必须提供到检状态和检测报告 diff --git a/inventory-backend/app/services/inbound/product_service.py b/inventory-backend/app/services/inbound/product_service.py index a073448..a1f4a21 100644 --- a/inventory-backend/app/services/inbound/product_service.py +++ b/inventory-backend/app/services/inbound/product_service.py @@ -116,10 +116,10 @@ class ProductInboundService: raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。") # ============================================================ - # 物料类别隔离校验:成品入库必须为"成品"类目 + # 物料类别隔离校验:成品入库必须为"成品"类目(白名单准入制) # ============================================================ 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( serial_number=data.get('serial_number') diff --git a/inventory-backend/app/services/inbound/semi_service.py b/inventory-backend/app/services/inbound/semi_service.py index c4e1b79..290e65b 100644 --- a/inventory-backend/app/services/inbound/semi_service.py +++ b/inventory-backend/app/services/inbound/semi_service.py @@ -123,10 +123,10 @@ class SemiInboundService: raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。") # ============================================================ - # 物料类别隔离校验:半成品入库必须为"半成品"类目 + # 物料类别隔离校验:半成品入库必须为"半成品"类目(白名单准入制) # ============================================================ 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( base_id=base_id, diff --git a/inventory-web/src/views/stock/inbound/buy.vue b/inventory-web/src/views/stock/inbound/buy.vue index 9893ae7..571a743 100644 --- a/inventory-web/src/views/stock/inbound/buy.vue +++ b/inventory-web/src/views/stock/inbound/buy.vue @@ -1138,9 +1138,12 @@ const querySearchCurrency = (queryString: string, cb: any) => { 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 // 打断正在排队的 debounce 定时器,避免与默认请求相互打架 if (searchTimer) { clearTimeout(searchTimer); searchTimer = null } @@ -1158,6 +1161,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 searchLoading.value = true searchKeyword.value = safeQuery searchPage.value = 1 @@ -1563,8 +1572,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('入库校验未通过,请检查必填项(如:库位)是否已填写完整!') diff --git a/inventory-web/src/views/stock/inbound/product.vue b/inventory-web/src/views/stock/inbound/product.vue index 8fe88df..4f60d16 100644 --- a/inventory-web/src/views/stock/inbound/product.vue +++ b/inventory-web/src/views/stock/inbound/product.vue @@ -1067,10 +1067,12 @@ const rules = { // ------------------------------------ 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 // 打断正在排队的 debounce 定时器,避免与默认请求相互打架 if (searchTimer) { clearTimeout(searchTimer); searchTimer = null } @@ -1091,6 +1093,12 @@ const handleSearchMaterial = async (query: string) => { // 3) 常规 trim 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 searchLoading.value = true searchKeyword.value = safeQuery 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('打印失败') } } } 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('入库校验未通过,请检查必填项(如:库位)是否已填写完整!') diff --git a/inventory-web/src/views/stock/inbound/semi.vue b/inventory-web/src/views/stock/inbound/semi.vue index 91754c5..820081a 100644 --- a/inventory-web/src/views/stock/inbound/semi.vue +++ b/inventory-web/src/views/stock/inbound/semi.vue @@ -1061,10 +1061,12 @@ const handleManagerSelect = (item: any) => { // ------------------------------------ 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 // 打断正在排队的 debounce 定时器,避免与默认请求相互打架 if (searchTimer) { clearTimeout(searchTimer); searchTimer = null } @@ -1085,6 +1087,12 @@ const handleSearchMaterial = async (query: string) => { // 3) 常规 trim 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 searchLoading.value = true searchKeyword.value = safeQuery searchPage.value = 1 @@ -1553,8 +1561,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('入库校验未通过,请检查必填项(如:库位)是否已填写完整!') diff --git a/inventory-web/src/views/stock/inbound/service.vue b/inventory-web/src/views/stock/inbound/service.vue index 801a69a..561d04c 100644 --- a/inventory-web/src/views/stock/inbound/service.vue +++ b/inventory-web/src/views/stock/inbound/service.vue @@ -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 {