diff --git a/docs/api.md b/docs/api.md index d5c213a4..cb051182 100644 --- a/docs/api.md +++ b/docs/api.md @@ -281,7 +281,9 @@ - `404 Not Found` — 物料不存在 - `409 Conflict` — **仍有库存(在库、预留、锁定或在途数量不为 0)**,需要先出库清零再作废 -> 不再补货的物料走作废退役,而不是把预警阈值设为 0。作废要求库存为零,所以典型流程是:出库清零 → 作废。 +> 不再补货的物料走作废退役。作废要求库存为零,所以典型流程是:出库清零 → 作废。 +> +> 注意区分「作废」和「关掉预警」:作废是物料退出库存视野,不再出现在任何库存接口里;关掉预警(`warning_enabled: false`)只是不再提醒,物料和阈值都还在。另外,把阈值设成 0 不等于停用预警——0 是"库存降到 0 时预警",仍然会报警。 --- @@ -420,8 +422,9 @@ "handler": "张三", "supplier": "ABC 钢材", "items": [ - { "material_id": 1, "quantity": 500, "warning_stock": 100 }, - { "material_id": 2, "quantity": 200 } + { "material_id": 1, "quantity": 500, "warning_stock": 100, "warning_enabled": true }, + { "material_id": 2, "quantity": 200, "warning_stock": 0, "warning_enabled": false }, + { "material_id": 3, "quantity": 80 } ] } ``` @@ -434,13 +437,16 @@ **明细字段:** -| 字段 | 类型 | 必填 | 说明 | -|---------------|---------|------|--------------------------------------------------------| -| material_id | integer | 是 | 物料 ID | -| quantity | integer | 是 | 入库数量,必须大于 0 | -| warning_stock | number | 否 | 为该物料设置的预警阈值;**不传表示不改动已存的阈值** | +| 字段 | 类型 | 必填 | 说明 | +|-----------------|---------|------|------------------------------------------------------------| +| material_id | integer | 是 | 物料 ID | +| quantity | integer | 是 | 入库数量,必须大于 0 | +| warning_stock | number | 否 | 为该物料设置的预警阈值,`0` 表示"库存降到 0 时预警";**不传表示不改动已存的阈值** | +| warning_enabled | boolean | 否 | 预警是否启用;**不传默认启用**,传 `false` 关闭预警。仅在同时传了 `warning_stock` 时生效 | -> `warning_stock` 与库存更新在同一个事务里落库,保证物料的第一个阈值和第一行库存同时生效。编辑旧入库单时**不传**这个字段(而不是传 `0`),才不会把别处刚调整好的阈值覆盖回去。 +> `warning_stock` 与库存更新在同一个事务里落库,保证物料的第一个阈值和第一行库存同时生效。编辑旧入库单时**不传**这两个字段(而不是传 `0` 或 `false`),才不会把别处刚调整好的配置覆盖回去。 +> +> 关闭预警而想保留原阈值时,把该物料当前的阈值连同 `warning_enabled: false` 一起传回即可(前端表单正是这么做的)。 > > 本接口**不支持备注字段**。前端即便提交了 `remark`,服务端也会忽略。 @@ -463,7 +469,7 @@ |------|---------|------|----------| | id | integer | 是 | 入库单 ID | -**请求体:** 字段同 `POST /inbound`。`warning_stock` 同样遵循「不传即不改动」的语义。 +**请求体:** 字段同 `POST /inbound`。`warning_stock` 和 `warning_enabled` 同样遵循「不传即不改动」的语义。 **响应:** `200 OK`,`data` 为更新后的入库单。 @@ -757,25 +763,27 @@ 整套预警逻辑只有一句话: ``` -参与预警 = 启用 AND 阈值 > 0 +参与预警 = 启用 报警 = 参与预警 AND 现有库存 <= 阈值 ``` 展开到接口行为上: - **库存查询(`GET /stock`)只列「入过库」的物料**。判断依据是存在库存记录行,而不是库存数量大于 0——所以**已被领用到 0 的物料仍然会列出**,而只存在于主数据、从未入库的物料不会出现。已作废的物料也不列出。 -- **阈值没设过(为 0)的物料不会报警**,即使库存已经是 0。没有设定期望值,就不替使用者判断"该补货了"。 -- **阈值被停用的物料不会报警**,阈值本身保留,随时可以重新启用。 +- **阈值为 0 是合法配置**,含义是"库存降到 0 时预警"。 +- **「不设置预警」等同于「不启用预警」**,两者是同一个状态。从没配过规则和手动关闭预警的物料,接口统一返回 `warning_enabled: false`,都不报警。需要区分这两者做表单预填时看 `warning_configured`(它只表示"配没配过规则",不参与判定)。 +- **关闭预警时阈值本身保留**,随时可以重新启用。 -| 是否入过库 | 现有库存 | 阈值 | 启用 | 状态 | -|------------|----------|------|------|----------| -| 否 | 0 | 0 | — | 不在列表中 | -| 是 | 0 | 0 | 默认 | 正常 | -| 是 | 5 | 0 | 默认 | 正常 | -| 是 | 0 | 10 | 是 | 库存不足 | -| 是 | 10 | 10 | 是 | 库存不足 | -| 是 | 15 | 10 | 是 | 正常 | -| 是 | 0 | 10 | 否 | 正常 | +| 是否入过库 | 是否配过规则 | 现有库存 | 阈值 | 启用 | 状态 | +|------------|--------------|----------|------|------|----------| +| 否 | — | 0 | — | — | 不在列表中 | +| 是 | 否 | 0 | — | false | 正常 | +| 是 | 是 | 5 | 0 | 是 | 正常 | +| 是 | 是 | 0 | 0 | 是 | 库存不足 | +| 是 | 是 | 0 | 10 | 是 | 库存不足 | +| 是 | 是 | 10 | 10 | 是 | 库存不足 | +| 是 | 是 | 15 | 10 | 是 | 正常 | +| 是 | 是 | 0 | 10 | 否 | 正常 | ### GET /stock @@ -808,6 +816,7 @@ "total_on_hand": "500", "warning_stock": "100", "warning_enabled": true, + "warning_configured": true, "stock_status": "normal" } ], @@ -819,14 +828,15 @@ } ``` -| 字段 | 类型 | 说明 | -|-----------------|---------|---------------------------------------------------| -| major_category | string | 大类分类编码 | -| minor_category | string | 小类分类编码 | -| total_on_hand | string | 现有库存合计,以字符串返回以免精度丢失 | -| warning_stock | string | 预警阈值,未设置时为 `"0"` | -| warning_enabled | boolean | 阈值是否启用 | -| stock_status | string | `low`(库存不足)或 `normal`(正常) | +| 字段 | 类型 | 说明 | +|--------------------|---------|------------------------------------------------------------| +| major_category | string | 大类分类编码 | +| minor_category | string | 小类分类编码 | +| total_on_hand | string | 现有库存合计,以字符串返回以免精度丢失 | +| warning_stock | string | 预警阈值,`"0"` 表示"库存降到 0 时预警" | +| warning_enabled | boolean | 预警是否启用。没配过规则和手动关闭都是 `false` | +| warning_configured | boolean | 是否配过预警规则。仅供表单预填区分"从没配过"和"已关闭",不参与判定 | +| stock_status | string | `low`(库存不足)或 `normal`(正常) | **状态码:** `200 OK`、`401 Unauthorized` @@ -859,11 +869,14 @@ "total_on_hand": "500", "warning_stock": "100", "warning_enabled": true, + "warning_configured": true, "stock_status": "normal" } } ``` +> `PUT /stock/:materialId/warning` 的入库表单预填依赖 `warning_configured`:没配过规则的物料要按"默认启用 + 阈值 0"预填,配过但被关闭的物料必须保持关闭,否则入库会把别人刚关掉的预警悄悄重新打开。 + **状态码:** - `200 OK` — 成功 @@ -874,7 +887,7 @@ ### GET /stock/warning -分页查询需要关注的物料:配置了启用状态且大于 0 的阈值,并且现有库存已触达该阈值。 +分页查询需要关注的物料:预警处于启用状态,并且现有库存已触达阈值。阈值为 0 的物料在库存降到 0 时也会出现在这里。 **查询参数:** `limit`(默认 20,上限 100)、`offset`(默认 0)。 @@ -897,6 +910,7 @@ "total_on_hand": "30", "warning_stock": "100", "warning_enabled": true, + "warning_configured": true, "stock_status": "low" } ], @@ -933,7 +947,7 @@ | 字段 | 类型 | 必填 | 说明 | |---------------|---------|------|------------------------------------------------------------| -| warning_stock | number | 是 | 阈值,必须大于等于 0。只有大于 0 的阈值才能触发预警 | +| warning_stock | number | 是 | 阈值,必须大于等于 0。`0` 是合法值,表示"库存降到 0 时预警" | | enabled | boolean | 否 | 默认 `true`;传 `false` 停用该物料的预警,阈值本身保留 | **响应:** diff --git a/frontend/src/stores/inbound.js b/frontend/src/stores/inbound.js index 075e6170..0e88f438 100644 --- a/frontend/src/stores/inbound.js +++ b/frontend/src/stores/inbound.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { inboundApi } from '../api/inbound' +import { useStockStore } from './stock' export const useInboundStore = defineStore('inbound', () => { const inboundList = ref([]) @@ -22,20 +23,29 @@ export const useInboundStore = defineStore('inbound', () => { } } + // 入库单据的任何增删改都会改变库存,必须让单物料库存缓存作废, + // 否则出库表单会继续显示这次入库之前的数量。 + function invalidateStock() { + useStockStore().clearStockCache() + } + async function createInbound(data) { const res = await inboundApi.create(data) + invalidateStock() await fetchInboundList(1) return res } async function updateInbound(id, data) { const res = await inboundApi.update(id, data) + invalidateStock() await fetchInboundList(page.value.currentPage) return res } async function deleteInbound(id) { const res = await inboundApi.delete(id) + invalidateStock() await fetchInboundList(page.value.currentPage) return res } diff --git a/frontend/src/stores/outbound.js b/frontend/src/stores/outbound.js index 8a59c3bc..66ea64e2 100644 --- a/frontend/src/stores/outbound.js +++ b/frontend/src/stores/outbound.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { outboundApi } from '../api/outbound' +import { useStockStore } from './stock' export const useOutboundStore = defineStore('outbound', () => { const outboundList = ref([]) @@ -22,20 +23,29 @@ export const useOutboundStore = defineStore('outbound', () => { } } + // 出库单据的任何增删改都会改变库存,必须让单物料库存缓存作废, + // 否则出库表单会继续显示这次出库之前的数量。 + function invalidateStock() { + useStockStore().clearStockCache() + } + async function createOutbound(data) { const res = await outboundApi.create(data) + invalidateStock() await fetchOutboundList(1) return res } async function updateOutbound(id, data) { const res = await outboundApi.update(id, data) + invalidateStock() await fetchOutboundList(page.value.currentPage) return res } async function deleteOutbound(id) { const res = await outboundApi.delete(id) + invalidateStock() await fetchOutboundList(page.value.currentPage) return res } diff --git a/frontend/src/stores/stock.js b/frontend/src/stores/stock.js index 64063dd1..8e8ad94d 100644 --- a/frontend/src/stores/stock.js +++ b/frontend/src/stores/stock.js @@ -51,7 +51,8 @@ export const useStockStore = defineStore('stock', () => { stockCache.value[materialId] = detail return detail } catch { - stockCache.value[materialId] = null + // 失败不写缓存:否则一次网络抖动就把 null 缓存住,该物料之后一直显示 0, + // 只能靠刷新页面恢复。留空让下次调用重新请求。 return null } } @@ -62,6 +63,13 @@ export const useStockStore = defineStore('stock', () => { return detail?.total_on_hand ?? 0 } + // 清空单物料库存缓存。入库、出库等任何改变库存的写操作之后都必须调用它: + // 入库表单选物料时会为了预填预警值而查询库存,把当时(变更前)的数量缓存下来, + // 之后不清掉的话,出库表单会一直显示变更前的旧数字,用户得刷新页面才能看到正确的。 + function clearStockCache() { + stockCache.value = {} + } + async function fetchStockWarning(page = 1) { loading.value = true try { @@ -106,7 +114,7 @@ export const useStockStore = defineStore('stock', () => { stockList, warningList, logs, loading, stockPage, warningPage, logsPage, fetchStock, fetchAllStock, fetchStockByMaterialId, fetchStockDetailByMaterialId, - fetchStockWarning, fetchStockLogs, + fetchStockWarning, fetchStockLogs, clearStockCache, updateWarningStock } }) diff --git a/frontend/src/stores/stock.test.js b/frontend/src/stores/stock.test.js index 715c260a..757265a7 100644 --- a/frontend/src/stores/stock.test.js +++ b/frontend/src/stores/stock.test.js @@ -1,7 +1,9 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { stockApi } from '../api/stock' +import { inboundApi } from '../api/inbound' import { useStockStore } from './stock' +import { useInboundStore } from './inbound' vi.mock('../api/stock', () => ({ stockApi: { @@ -14,6 +16,16 @@ vi.mock('../api/stock', () => ({ } })) +vi.mock('../api/inbound', () => ({ + inboundApi: { + getAll: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } +})) + describe('stock store warning settings', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -23,6 +35,8 @@ describe('stock store warning settings', () => { data: { items: [{ material_id: 1, warning_stock: 8 }], total: 1 } }) stockApi.getWarning.mockResolvedValue({ data: { items: [], total: 0 } }) + inboundApi.create.mockResolvedValue({ data: { id: 1 } }) + inboundApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } }) }) it('refreshes inventory state after changing a warning threshold', async () => { @@ -44,4 +58,40 @@ describe('stock store warning settings', () => { expect(quantity).toBe('12.500000') }) + + // 入库表单选物料时会为了预填预警值而查询库存,顺手把当时(入库前)的数量缓存下来。 + // 入库保存后库存已经变了,缓存若不失效,出库表单会一直显示入库前的旧数字, + // 用户得刷新页面才能看到正确值。 + it('入库之后重新查询要拿到最新库存,而不是入库前的旧值', async () => { + const stockStore = useStockStore() + stockApi.getById.mockReset() + stockApi.getById.mockResolvedValueOnce({ + data: { material_id: 7, total_on_hand: '100' } + }) + expect(await stockStore.fetchStockByMaterialId(7)).toBe('100') + + await useInboundStore().createInbound({ + handler: '张三', + supplier: 'ABC 钢材', + items: [{ material_id: 7, quantity: 400 }] + }) + + stockApi.getById.mockResolvedValueOnce({ + data: { material_id: 7, total_on_hand: '500' } + }) + expect(await stockStore.fetchStockByMaterialId(7)).toBe('500') + }) + + // 失败若被写进缓存,一次网络抖动就会让该物料一直显示 0,只能靠刷新页面恢复。 + it('单次请求失败不会被缓存,恢复后能拿到正确库存', async () => { + const stockStore = useStockStore() + stockApi.getById.mockReset() + stockApi.getById.mockRejectedValueOnce(new Error('network down')) + expect(await stockStore.fetchStockByMaterialId(7)).toBe(0) + + stockApi.getById.mockResolvedValueOnce({ + data: { material_id: 7, total_on_hand: '500' } + }) + expect(await stockStore.fetchStockByMaterialId(7)).toBe('500') + }) }) diff --git a/frontend/src/utils/quantity.js b/frontend/src/utils/quantity.js index 168e684e..795e39fc 100644 --- a/frontend/src/utils/quantity.js +++ b/frontend/src/utils/quantity.js @@ -30,7 +30,3 @@ export function compareQuantities(leftValue, rightValue) { const magnitude = compareMagnitude(left, right) return left.sign > 0 ? magnitude : -magnitude } - -export function isLowStock(stock) { - return compareQuantities(stock?.total_on_hand, stock?.warning_stock) <= 0 -} diff --git a/frontend/src/utils/quantity.test.js b/frontend/src/utils/quantity.test.js index dfdecbeb..e2f5723a 100644 --- a/frontend/src/utils/quantity.test.js +++ b/frontend/src/utils/quantity.test.js @@ -1,14 +1,13 @@ import { describe, expect, it } from 'vitest' -import { compareQuantities, isLowStock } from './quantity' +import { compareQuantities } from './quantity' describe('decimal quantity comparison', () => { it('compares numeric strings by value instead of lexicographically', () => { expect(compareQuantities('370.000000', '50.000000')).toBeGreaterThan(0) - expect(isLowStock({ total_on_hand: '370.000000', warning_stock: '50.000000' })).toBe(false) }) it('supports equal values and decimal fractions', () => { - expect(isLowStock({ total_on_hand: '50', warning_stock: '50.000000' })).toBe(true) + expect(compareQuantities('50', '50.000000')).toBe(0) expect(compareQuantities('0.25', '0.3')).toBeLessThan(0) }) }) diff --git a/frontend/src/views/Inbound.test.js b/frontend/src/views/Inbound.test.js new file mode 100644 index 00000000..f16da713 --- /dev/null +++ b/frontend/src/views/Inbound.test.js @@ -0,0 +1,243 @@ +import { flushPromises, mount } from '@vue/test-utils' +import ElementPlus, { ElMessage } from 'element-plus' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import Inbound from './Inbound.vue' + +const inboundStore = vi.hoisted(() => ({ + inboundList: [], + loading: false, + page: { currentPage: 1, pageSize: 20, total: 0, totalPages: 1 }, + fetchInboundList: vi.fn(), + fetchInboundDetail: vi.fn(), + createInbound: vi.fn(), + updateInbound: vi.fn(), + deleteInbound: vi.fn() +})) + +const materialsStore = vi.hoisted(() => ({ + materials: [], + materialOptions: [], + fetchMaterials: vi.fn(), + fetchMaterialOptions: vi.fn() +})) + +const stockStore = vi.hoisted(() => ({ + fetchStockDetailByMaterialId: vi.fn() +})) + +vi.mock('../stores/inbound', () => ({ useInboundStore: () => inboundStore })) +vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore })) +vi.mock('../stores/stock', () => ({ useStockStore: () => stockStore })) +vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) })) + +// 真实的 ElSelect 在 jsdom 下会因 filter-method 反复触发自身更新而报 +// "Maximum recursive updates exceeded",这里只关心表单校验,用轻量替身绕开。 +const SelectStub = { + props: ['modelValue', 'placeholder', 'filterable', 'filterMethod', 'noDataText'], + emits: ['update:modelValue', 'change'], + methods: { + pick(value) { + this.$emit('update:modelValue', value) + this.$emit('change', value) + } + }, + template: '
' +} + +const OptionStub = { + props: ['label', 'value'], + template: '{{ label }}' +} + +function mountInbound() { + return mount(Inbound, { + global: { + plugins: [ElementPlus], + stubs: { teleport: true, ElSelect: SelectStub, ElOption: OptionStub } + } + }) +} + +function findButton(wrapper, text) { + return wrapper.findAll('button').find(button => button.text().includes(text)) +} + +async function openAddDialog(wrapper) { + await findButton(wrapper, '新增入库单').trigger('click') + await flushPromises() +} + +async function fillRequiredInputs(wrapper, { handler = '', supplier = '' } = {}) { + await wrapper.find('input[placeholder="请输入经手人"]').setValue(handler) + await wrapper.find('input[placeholder="请输入供应商"]').setValue(supplier) +} + +describe('入库单表单必填校验', () => { + let warnSpy + + beforeEach(() => { + inboundStore.createInbound.mockReset() + inboundStore.updateInbound.mockReset() + inboundStore.fetchInboundDetail.mockReset() + inboundStore.inboundList = [] + materialsStore.fetchMaterials.mockReset() + materialsStore.fetchMaterialOptions.mockReset() + materialsStore.materials = [] + materialsStore.materialOptions = [] + warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + // 列表接口不返回物料明细(InboundOrder.Items 带 omitempty),只拿列表行来填表单的话, + // 编辑时永远看不到这张单当初入了什么。必须单独取一次详情。 + test('编辑入库单时会显示这张单当初入库的物料', async () => { + materialsStore.materialOptions = [{ id: 7, material_code: 'MAT-007', name: '轴承' }] + inboundStore.inboundList = [{ id: 5, handler: '张三', supplier: 'ABC 钢材' }] + inboundStore.fetchInboundDetail.mockResolvedValue({ + id: 5, + handler: '张三', + supplier: 'ABC 钢材', + items: [ + { material_id: 7, quantity: 10 }, + { material_id: 8, quantity: 20 } + ] + }) + + const wrapper = mountInbound() + await flushPromises() + await findButton(wrapper, '编辑').trigger('click') + await flushPromises() + + expect(inboundStore.fetchInboundDetail).toHaveBeenCalledWith(5) + expect(wrapper.findAll('.item-row')).toHaveLength(2) + expect(wrapper.text()).toContain('MAT-007 - 轴承') + wrapper.unmount() + }) + + // 复现线上现象:物料分页只有前 20 条,编辑一张用了第 25 号物料的旧单据时, + // 下拉框的选项里找不到它,el-select 就只能把物料 ID 当文字显示。 + test('物料不在第一页时,编辑仍能显示它的名称', async () => { + const firstPage = Array.from({ length: 20 }, (_, i) => ({ + id: 100 + i, + material_code: `X-${i}`, + name: `其他物料${i}` + })) + materialsStore.materials = firstPage + materialsStore.materialOptions = [...firstPage, { id: 7, material_code: 'MAT-007', name: '轴承' }] + inboundStore.inboundList = [{ id: 5, handler: '张三', supplier: 'ABC 钢材' }] + inboundStore.fetchInboundDetail.mockResolvedValue({ + id: 5, + handler: '张三', + supplier: 'ABC 钢材', + items: [{ material_id: 7, quantity: 10 }] + }) + + const wrapper = mountInbound() + await flushPromises() + await findButton(wrapper, '编辑').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('MAT-007 - 轴承') + wrapper.unmount() + }) + + // 反过来验证:没有这层详情请求时,表单里只剩一个空行。 + test('详情里没有物料明细时回退到一行空明细', async () => { + inboundStore.inboundList = [{ id: 5, handler: '张三', supplier: 'ABC 钢材' }] + inboundStore.fetchInboundDetail.mockResolvedValue({ + id: 5, + handler: '张三', + supplier: 'ABC 钢材', + items: [] + }) + + const wrapper = mountInbound() + await flushPromises() + await findButton(wrapper, '编辑').trigger('click') + await flushPromises() + + expect(wrapper.findAll('.item-row')).toHaveLength(1) + wrapper.unmount() + }) + + // 经手人和供应商在后端标了 binding:"required"。前端若不拦,空值会被发出去, + // 用户看到的是 gin 抛出的原始英文校验错误。 + test('经手人和供应商都为空时不发请求,并提示补齐', async () => { + const wrapper = mountInbound() + await flushPromises() + await openAddDialog(wrapper) + + await findButton(wrapper, '保存').trigger('click') + await flushPromises() + + expect(inboundStore.createInbound).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith('请输入经手人') + wrapper.unmount() + }) + + test('只填了经手人时提示供应商', async () => { + const wrapper = mountInbound() + await flushPromises() + await openAddDialog(wrapper) + await fillRequiredInputs(wrapper, { handler: '张三' }) + + await findButton(wrapper, '保存').trigger('click') + await flushPromises() + + expect(inboundStore.createInbound).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith('请输入供应商') + wrapper.unmount() + }) + + test('只填了空格不算填过', async () => { + const wrapper = mountInbound() + await flushPromises() + await openAddDialog(wrapper) + await fillRequiredInputs(wrapper, { handler: ' ', supplier: 'ABC 钢材' }) + + await findButton(wrapper, '保存').trigger('click') + await flushPromises() + + expect(inboundStore.createInbound).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith('请输入经手人') + wrapper.unmount() + }) + + // 保存过程中抛出的运行时异常(不是接口错误)不会被 axios 拦截器提示, + // 若不在这里兜住,用户看到的就只是"点了保存没反应"。 + test('保存过程抛出运行时异常时给出提示,而不是静默卡住', async () => { + const wrapper = mountInbound() + await flushPromises() + await openAddDialog(wrapper) + await fillRequiredInputs(wrapper, { handler: '张三', supplier: 'ABC 钢材' }) + await wrapper.find('button.pick').trigger('click') + await flushPromises() + + inboundStore.createInbound.mockRejectedValue(new TypeError('boom')) + const errorSpy = vi.spyOn(ElMessage, 'error').mockImplementation(() => {}) + + await findButton(wrapper, '保存').trigger('click') + await flushPromises() + + expect(errorSpy).toHaveBeenCalledWith('boom') + wrapper.unmount() + }) + + test('填好必填项后不再被必填校验拦住', async () => { + const wrapper = mountInbound() + await flushPromises() + await openAddDialog(wrapper) + await fillRequiredInputs(wrapper, { handler: '张三', supplier: 'ABC 钢材' }) + + await findButton(wrapper, '保存').trigger('click') + await flushPromises() + + // 这一单还没选物料,应该被"至少添加一个物料"拦住,而不是被必填校验拦住 + expect(warnSpy).toHaveBeenCalledWith('请至少添加一个物料') + expect(inboundStore.createInbound).not.toHaveBeenCalled() + wrapper.unmount() + }) +}) diff --git a/frontend/src/views/Inbound.vue b/frontend/src/views/Inbound.vue index 04dddb4f..5b59fd3d 100644 --- a/frontend/src/views/Inbound.vue +++ b/frontend/src/views/Inbound.vue @@ -45,10 +45,10 @@ - + - + @@ -76,13 +76,18 @@
+ 启用预警 + 预警值 @@ -93,7 +98,9 @@ >删除
- 添加物料 +
+ 添加物料 +