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 {