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 @@
- 添加物料
+
+ 添加物料
+
@@ -152,16 +159,18 @@ const editingId = ref(null)
const deleteId = ref(null)
const currentRow = ref(null)
-// 明细行的初始结构。预警阈值单独放在 warning_stock 上,并配一个"用户是否改过"的标记:
-// 只有真正被改动过的明细才会把阈值提交给后端,避免编辑旧入库单时把物料当前的阈值
-// 覆盖回表单里的旧数。
+// 明细行的初始结构。预警阈值和启用状态分别放在 warning_stock / warning_enabled 上,
+// 并共用一个"用户是否改过"的标记:只有真正被改动过的明细才会把预警字段提交给后端,
+// 避免编辑旧入库单时把物料当前的阈值和启用状态覆盖回表单里的旧数。
+// 新行默认启用预警、阈值为 0,也就是"库存降到 0 时预警"。
function createItem() {
return {
material_id: null,
materialSearchText: '',
quantity: 1,
- warning_stock: null,
- warning_stock_edited: false
+ warning_stock: 0,
+ warning_enabled: true,
+ warning_edited: false
}
}
@@ -172,9 +181,24 @@ const form = reactive({
items: [createItem()]
})
+// 经手人和供应商在后端标了 binding:"required",前端必须先拦一道:否则空值会被原样
+// 发出去,再换回一串 gin 的英文校验错误弹给用户。
+//
+// 这里刻意用手写校验而不是 el-form 的 rules:当前依赖的 element-plus 里
+// ElForm.validate() 并不会真正执行校验(连 min 违规都返回成功,async-validator 单独
+// 用是正常的),靠 rules 拦不住,只会让人以为已经校验过了。表单上的 required 属性
+// 仅用于显示必填星号。
+function firstMissingField() {
+ if (!form.handler.trim()) return '请输入经手人'
+ if (!form.supplier.trim()) return '请输入供应商'
+ return ''
+}
+
onMounted(() => {
inboundStore.fetchInboundList()
- materialsStore.fetchMaterials()
+ // 用全量物料而不是分页的第一页:编辑旧单据时,它的物料可能不在前 20 条里,
+ // 那样下拉框找不到对应选项,只会把物料 ID 当文字显示出来。
+ materialsStore.fetchMaterialOptions()
})
function handlePageChange(page) {
@@ -188,23 +212,33 @@ function showAddModal() {
dialogVisible.value = true
}
-function showEditModal(row) {
+async function showEditModal(row) {
editingId.value = row.id
dialogTitle.value = '编辑入库单'
- const items = row.items && row.items.length > 0
- ? row.items.map(item => {
- const material = materialsStore.materials.find(m => m.id === item.material_id)
+ // 列表接口不返回物料明细(InboundOrder.Items 带 omitempty),拿 row.items 会永远是空,
+ // 表单里就看不到这张单当初入了什么。必须单独取一次详情。
+ let order = row
+ try {
+ order = await inboundStore.fetchInboundDetail(row.id)
+ } catch (error) {
+ ElMessage.error('加载入库单详情失败')
+ return
+ }
+ const items = order.items && order.items.length > 0
+ ? order.items.map(item => {
+ const material = materialsStore.materialOptions.find(m => m.id === item.material_id)
return {
material_id: item.material_id,
materialSearchText: material ? `${material.material_code} - ${material.name}` : '',
quantity: item.quantity,
- warning_stock: null,
- warning_stock_edited: false
+ warning_stock: 0,
+ warning_enabled: true,
+ warning_edited: false
}
})
: [createItem()]
- Object.assign(form, { handler: row.handler || '', supplier: row.supplier || '', remark: row.remark || '', items })
- // 预填的是物料"当前"的阈值,不是这张单当年的值;用户不动它就不会被提交回去。
+ Object.assign(form, { handler: order.handler || '', supplier: order.supplier || '', remark: order.remark || '', items })
+ // 预填的是物料"当前"的阈值和启用状态,不是这张单当年的值;用户不动它就不会被提交回去。
form.items.forEach(item => prefillWarningStock(item))
dialogVisible.value = true
}
@@ -227,15 +261,21 @@ function addItem() {
}
function markWarningEdited(item) {
- item.warning_stock_edited = true
+ item.warning_edited = true
}
-// 把物料当前的预警阈值预填到明细行方便沿用,用户不动它就不会被提交。
+// 把物料当前的预警配置预填到明细行方便沿用,用户不动它就不会被提交。
+// 没配过规则的物料按新行的默认值处理(启用 + 阈值 0);配过但被手动关闭的物料要保持
+// 不勾选,否则入库会把它悄悄重新启用——所以这里必须靠 warning_configured 区分两者。
async function prefillWarningStock(item) {
if (!item.material_id) return
const detail = await stockStore.fetchStockDetailByMaterialId(item.material_id)
- if (detail?.warning_stock !== undefined) {
+ if (detail?.warning_configured) {
item.warning_stock = Number(detail.warning_stock) || 0
+ item.warning_enabled = !!detail.warning_enabled
+ } else {
+ item.warning_stock = 0
+ item.warning_enabled = true
}
}
@@ -248,33 +288,50 @@ function materialFilter(val, item) {
}
function getFilteredMaterials(item) {
+ const source = materialsStore.materialOptions
const keyword = (item.materialSearchText || '').toLowerCase()
- if (!keyword) return materialsStore.materials
- return materialsStore.materials.filter(m =>
- m.material_code.toLowerCase().includes(keyword) ||
- m.name.toLowerCase().includes(keyword)
- )
+ const matched = keyword
+ ? source.filter(m =>
+ m.material_code.toLowerCase().includes(keyword) ||
+ m.name.toLowerCase().includes(keyword)
+ )
+ : source
+ // 当前选中的物料必须始终留在选项里:el-select 靠选项列表把 id 映射成名称,
+ // 选项里没有它就只能把物料 ID 当文字显示。
+ const selected = source.find(m => m.id === item.material_id)
+ if (selected && !matched.some(m => m.id === selected.id)) {
+ return [selected, ...matched]
+ }
+ return matched
}
function onMaterialChange(item, id) {
const idx = form.items.indexOf(item)
if (idx === -1) return
- const next = { ...item, warning_stock: null, warning_stock_edited: false }
+ const next = { ...item, warning_stock: 0, warning_enabled: true, warning_edited: false }
form.items.splice(idx, 1, next)
// splice 之后要取回数组里的代理对象再改,直接改 next 会绕过响应式
prefillWarningStock(form.items[idx])
}
async function handleSave() {
+ const missing = firstMissingField()
+ if (missing) {
+ ElMessage.warning(missing)
+ return
+ }
+
saveLoading.value = true
try {
const validItems = form.items
.filter(item => item.material_id && item.quantity > 0)
.map(item => {
const payload = { material_id: item.material_id, quantity: item.quantity }
- // 不带 warning_stock 表示不改动服务端已存的阈值,只有用户改过的明细才提交
- if (item.warning_stock_edited && item.warning_stock !== null) {
+ // 不带这两个字段表示不改动服务端已存的配置,只有用户改过的明细才提交。
+ // 取消勾选时会把预填的当前阈值一并传回,所以"关闭预警但保留阈值"是自然成立的。
+ if (item.warning_edited) {
payload.warning_stock = item.warning_stock
+ payload.warning_enabled = item.warning_enabled
}
return payload
})
@@ -290,6 +347,13 @@ async function handleSave() {
}
ElMessage.success('保存成功')
dialogVisible.value = false
+ } catch (error) {
+ // 接口错误由 axios 拦截器统一提示过了,这里只兜住它覆盖不到的异常
+ // (比如代码里的运行时错误)。没有这层捕获的话,异常会让弹窗原地不动、
+ // 也不给任何提示,用户只能看到"点了保存没反应"。
+ if (!error?.response) {
+ ElMessage.error(error?.message || '保存失败')
+ }
} finally {
saveLoading.value = false
}
@@ -321,12 +385,18 @@ function formatDate(dateStr) {
align-items: center;
margin-bottom: 20px;
}
+/* el-form-item__content 自带 flex-wrap,子元素会横排流动;
+ 占满整行才能保证每个物料块和「添加物料」各自独占一行。 */
.item-row {
display: flex;
flex-direction: column;
gap: 8px;
+ width: 100%;
margin-bottom: 16px;
}
+.item-add {
+ width: 100%;
+}
.item-row__main {
display: flex;
gap: 10px;
diff --git a/frontend/src/views/Outbound.vue b/frontend/src/views/Outbound.vue
index 594b3e9e..5cb943a6 100644
--- a/frontend/src/views/Outbound.vue
+++ b/frontend/src/views/Outbound.vue
@@ -115,7 +115,6 @@ import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue'
import DeleteConfirm from '../components/business/DeleteConfirm.vue'
import { ElMessage } from 'element-plus'
-import { isLowStock as isQuantityLowStock } from '../utils/quantity'
const outboundStore = useOutboundStore()
const materialsStore = useMaterialsStore()
@@ -144,7 +143,9 @@ const itemStocks = ref({})
onMounted(async () => {
await Promise.all([
outboundStore.fetchOutboundList(),
- materialsStore.fetchMaterials(),
+ // 用全量物料而不是分页的第一页:编辑旧单据时,它的物料可能不在前 20 条里,
+ // 那样下拉框找不到对应选项,只会把物料 ID 当文字显示出来。
+ materialsStore.fetchMaterialOptions(),
stockStore.fetchAllStock()
])
})
@@ -171,7 +172,7 @@ async function showEditModal(row) {
form.remark = order.remark || ''
form.items = order.items && order.items.length > 0
? order.items.map(item => {
- const material = materialsStore.materials.find(m => m.id === item.material_id)
+ const material = materialsStore.materialOptions.find(m => m.id === item.material_id)
// Fetch stock for existing items
stockStore.fetchStockByMaterialId(item.material_id).then(stock => {
itemStocks.value[item.material_id] = stock
@@ -198,12 +199,21 @@ function materialFilter(val, item) {
}
function getFilteredMaterials(item) {
+ const source = materialsStore.materialOptions
const keyword = (item.materialSearchText || '').toLowerCase()
- if (!keyword) return materialsStore.materials
- return materialsStore.materials.filter(m =>
- m.material_code.toLowerCase().includes(keyword) ||
- m.name.toLowerCase().includes(keyword)
- )
+ const matched = keyword
+ ? source.filter(m =>
+ m.material_code.toLowerCase().includes(keyword) ||
+ m.name.toLowerCase().includes(keyword)
+ )
+ : source
+ // 当前选中的物料必须始终留在选项里:el-select 靠选项列表把 id 映射成名称,
+ // 选项里没有它就只能把物料 ID 当文字显示。
+ const selected = source.find(m => m.id === item.material_id)
+ if (selected && !matched.some(m => m.id === selected.id)) {
+ return [selected, ...matched]
+ }
+ return matched
}
function onMaterialChange(item, id) {
@@ -218,17 +228,12 @@ function onMaterialChange(item, id) {
}
}
-function getStock(materialId) {
- if (!materialId) return 0
- const stock = stockStore.stockList.find(s => s.material_id === materialId)
- return stock ? stock.total_on_hand : '-'
-}
-
+// 直接采信后端的 stock_status,不再自己比一遍阈值——否则"没配过规则"和"关闭了预警"
+// 这两类物料会在这里被误判成低库存,和库存页、预警列表的口径对不上。
function isLowStock(materialId) {
if (!materialId) return false
const stock = stockStore.stockList.find(s => s.material_id === materialId)
- if (!stock) return false
- return isQuantityLowStock(stock)
+ return stock?.stock_status === 'low'
}
async function handleSave() {
diff --git a/frontend/src/views/Stock.vue b/frontend/src/views/Stock.vue
index d400f197..11a65518 100644
--- a/frontend/src/views/Stock.vue
+++ b/frontend/src/views/Stock.vue
@@ -286,8 +286,8 @@ function handlePageChange(page) {
function openWarningDialog(row) {
warningMaterial.value = row
warningValue.value = row.warning_stock || 0
- // 后端对"没配过规则"返回启用,只有显式关闭过的才读回 false
- warningEnabled.value = row.warning_enabled !== false
+ // 没配过规则和手动关闭预警都由后端报 false,两者在库存页是同一个状态:不预警
+ warningEnabled.value = !!row.warning_enabled
warningDialogVisible.value = true
}
diff --git a/internal/model/model.go b/internal/model/model.go
index 536bfaaf..c9317bba 100644
--- a/internal/model/model.go
+++ b/internal/model/model.go
@@ -120,11 +120,15 @@ type Stock struct {
TotalOnHand decimal.Decimal `json:"total_on_hand"`
WarningStock decimal.Decimal `json:"warning_stock"`
WarningEnabled bool `json:"warning_enabled"`
- StockStatus string `json:"stock_status"`
+ // WarningConfigured 表示该物料到底配没配过预警规则。"从没配过"和"配过但被手动
+ // 关闭"都会被 WarningEnabled 报成 false,仅靠它无法区分;前端预填时需要知道是哪
+ // 一种,才能决定默认勾选还是默认不勾选。它只服务于这个用途,不参与任何判定。
+ WarningConfigured bool `json:"warning_configured"`
+ StockStatus string `json:"stock_status"`
}
-// Stock.StockStatus 的取值。只有配置了启用状态且大于 0 的预警阈值、并且现存量已经
-// 触达该阈值时才是 low,其余一律为 normal。
+// Stock.StockStatus 的取值。只要预警处于启用状态、并且现存量已经触达阈值就是 low,
+// 其余一律为 normal。阈值为 0 是合法配置,含义是"库存降到 0 时预警",不会被排除。
const (
StockStatusLow = "low"
StockStatusNormal = "normal"
@@ -217,6 +221,9 @@ type CreateInboundRequest struct {
Quantity int `json:"quantity" binding:"required,gt=0"`
// WarningStock 可选,为 nil 表示本次不改动已存的预警阈值。
WarningStock *decimal.Decimal `json:"warning_stock"`
+ // WarningEnabled 可选,为 nil 表示本次不改动预警的启用状态。仅在 WarningStock
+ // 非 nil 时生效,用来表达"启用还是关闭预警"。
+ WarningEnabled *bool `json:"warning_enabled"`
} `json:"items" binding:"required,min=1"`
}
@@ -239,6 +246,9 @@ type UpdateInboundRequest struct {
Quantity int `json:"quantity" binding:"required,gt=0"`
// WarningStock 可选,为 nil 表示本次不改动已存的预警阈值。
WarningStock *decimal.Decimal `json:"warning_stock"`
+ // WarningEnabled 可选,为 nil 表示本次不改动预警的启用状态。仅在 WarningStock
+ // 非 nil 时生效,用来表达"启用还是关闭预警"。
+ WarningEnabled *bool `json:"warning_enabled"`
} `json:"items" binding:"required,min=1"`
}
diff --git a/internal/repository/stock.go b/internal/repository/stock.go
index 485fa8a5..c5523955 100644
--- a/internal/repository/stock.go
+++ b/internal/repository/stock.go
@@ -19,11 +19,16 @@ const obsoleteMaterialFilterSQL = ` AND m.status <> 'obsolete'`
const stockedMaterialFilterSQL = obsoleteMaterialFilterSQL +
` AND EXISTS (SELECT 1 FROM inventory_balances ib2 WHERE ib2.material_id = m.id)`
-// stockWarningHavingSQL 必须与 service.ClassifyStock 保持一致:只有配置了启用状态且大于 0
-// 的预警阈值、并且现存量已经触达该阈值时才预警。enabled 的判断刻意放在 HAVING 而不是
-// join 条件里,否则被禁用的规则会塌缩成"没配过规则",反过来又被当成启用。
-const stockWarningHavingSQL = `HAVING COALESCE(MAX(swr.enabled), TRUE) = TRUE
- AND COALESCE(MAX(swr.minimum_available), 0) > 0
+// stockWarningHavingSQL 必须与 service.ClassifyStock 保持一致:只要预警处于启用状态,
+// 现存量触达阈值就预警。阈值 0 是合法配置,含义是"库存降到 0 时预警",不再被排除在外。
+//
+// 没有配过规则的物料靠 COALESCE(..., FALSE) 挡掉:无 swr 行时 MAX(swr.enabled) 返回 NULL,
+// 兜底成 FALSE 即"不参与预警"。这样"从没配过"和"被手动关闭"在判定上统一为不预警,
+// 前端则靠 warning_configured 把两者区分开来做预填。
+//
+// enabled 的判断仍放在 HAVING 而不是 join 条件里:被禁用的规则必须参与分组,否则它会
+// 塌缩成"没配过规则",warning_configured 就报错了。
+const stockWarningHavingSQL = `HAVING COALESCE(MAX(swr.enabled), FALSE) = TRUE
AND COALESCE(SUM(ib.on_hand), 0) <= COALESCE(MAX(swr.minimum_available), 0)`
const upsertStockWarningRuleSQL = `INSERT INTO stock_warning_rules (organization_id, warehouse_id, material_id, minimum_available, enabled)
@@ -56,7 +61,8 @@ func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock
}
query := `SELECT m.id, m.material_code, m.name, m.spec, m.unit,
- COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), COALESCE(MAX(swr.enabled), TRUE),
+ COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), COALESCE(MAX(swr.enabled), FALSE),
+ MAX(swr.material_id) IS NOT NULL,
IFNULL(pc.code, ''), IFNULL(c.code, '')
FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id
@@ -85,7 +91,7 @@ func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock
var stocks []model.Stock
for rows.Next() {
var s model.Stock
- err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.TotalOnHand, &s.WarningStock, &s.WarningEnabled, &s.MajorCategory, &s.MinorCategory)
+ err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.TotalOnHand, &s.WarningStock, &s.WarningEnabled, &s.WarningConfigured, &s.MajorCategory, &s.MinorCategory)
if err != nil {
return nil, 0, err
}
@@ -99,7 +105,8 @@ func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock
func (r *StockRepo) GetByMaterialID(materialID int64) (*model.Stock, error) {
s := &model.Stock{}
query := `SELECT m.id, m.material_code, m.name, m.spec, m.unit,
- COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), COALESCE(MAX(swr.enabled), TRUE),
+ COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), COALESCE(MAX(swr.enabled), FALSE),
+ MAX(swr.material_id) IS NOT NULL,
IFNULL(pc.code, ''), IFNULL(c.code, '')
FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id
@@ -109,7 +116,7 @@ func (r *StockRepo) GetByMaterialID(materialID int64) (*model.Stock, error) {
LEFT JOIN material_categories pc ON pc.id = c.parent_id
WHERE m.id = ?
GROUP BY m.id, m.material_code, m.name, m.spec, m.unit, pc.code, c.code`
- err := database.DB.QueryRow(query, materialID).Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.TotalOnHand, &s.WarningStock, &s.WarningEnabled, &s.MajorCategory, &s.MinorCategory)
+ err := database.DB.QueryRow(query, materialID).Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.TotalOnHand, &s.WarningStock, &s.WarningEnabled, &s.WarningConfigured, &s.MajorCategory, &s.MinorCategory)
if err != nil {
return nil, err
}
@@ -134,7 +141,8 @@ func (r *StockRepo) GetWarningStock(limit, offset int) ([]model.Stock, int64, er
}
query := `SELECT m.id, m.material_code, m.name, m.spec, m.unit,
- COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), COALESCE(MAX(swr.enabled), TRUE),
+ COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), COALESCE(MAX(swr.enabled), FALSE),
+ MAX(swr.material_id) IS NOT NULL,
IFNULL(pc.code, ''), IFNULL(c.code, '')
FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id
@@ -155,7 +163,7 @@ func (r *StockRepo) GetWarningStock(limit, offset int) ([]model.Stock, int64, er
var stocks []model.Stock
for rows.Next() {
var s model.Stock
- err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.TotalOnHand, &s.WarningStock, &s.WarningEnabled, &s.MajorCategory, &s.MinorCategory)
+ err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.TotalOnHand, &s.WarningStock, &s.WarningEnabled, &s.WarningConfigured, &s.MajorCategory, &s.MinorCategory)
if err != nil {
return nil, 0, err
}
diff --git a/internal/repository/stock_test.go b/internal/repository/stock_test.go
index c2c47d66..f8ea63cf 100644
--- a/internal/repository/stock_test.go
+++ b/internal/repository/stock_test.go
@@ -10,7 +10,8 @@ import (
)
const stockByMaterialSQL = `SELECT m.id, m.material_code, m.name, m.spec, m.unit,
- COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), COALESCE(MAX(swr.enabled), TRUE),
+ COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), COALESCE(MAX(swr.enabled), FALSE),
+ MAX(swr.material_id) IS NOT NULL,
IFNULL(pc.code, ''), IFNULL(c.code, '')
FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id
@@ -33,8 +34,8 @@ func TestStockRepoGetByMaterialIDReadsInventoryTables(t *testing.T) {
database.DB = db
mock.ExpectQuery(regexp.QuoteMeta(stockByMaterialSQL)).WithArgs(int64(9)).WillReturnRows(
- sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "major_category", "minor_category"}).
- AddRow(9, "MAT-009", "电机", "80W", "台", "12.500000", "3.000000", true, "B01", "CAT-SUB-027"),
+ sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "warning_configured", "major_category", "minor_category"}).
+ AddRow(9, "MAT-009", "电机", "80W", "台", "12.500000", "3.000000", true, true, "B01", "CAT-SUB-027"),
)
stock, err := NewStockRepo().GetByMaterialID(9)
@@ -135,6 +136,52 @@ func TestStockRepoUpdateWarningStockWritesInventoryRule(t *testing.T) {
}
}
+// 阈值为 0 是合法配置,含义是"库存降到 0 时预警",必须原样落库而不是被当成"未设置"。
+func TestStockRepoUpdateWarningStockAcceptsZeroThreshold(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ if err != nil {
+ t.Fatalf("open sql mock: %v", err)
+ }
+ t.Cleanup(func() {
+ database.DB = nil
+ db.Close()
+ })
+ database.DB = db
+
+ mock.ExpectExec(regexp.QuoteMeta(upsertStockWarningRuleSQL)).
+ WithArgs(int64(13), "0", true).WillReturnResult(sqlmock.NewResult(1, 1))
+
+ if err := NewStockRepo().UpdateWarningStock(13, decimal.Zero, true); err != nil {
+ t.Fatalf("update stock warning: %v", err)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// 关闭预警时只翻 enabled,阈值本身要原样保留,方便之后重新启用。
+func TestStockRepoUpdateWarningStockCanDisableKeepingThreshold(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ if err != nil {
+ t.Fatalf("open sql mock: %v", err)
+ }
+ t.Cleanup(func() {
+ database.DB = nil
+ db.Close()
+ })
+ database.DB = db
+
+ mock.ExpectExec(regexp.QuoteMeta(upsertStockWarningRuleSQL)).
+ WithArgs(int64(13), "8.75", false).WillReturnResult(sqlmock.NewResult(1, 1))
+
+ if err := NewStockRepo().UpdateWarningStock(13, decimal.RequireFromString("8.750000"), false); err != nil {
+ t.Fatalf("update stock warning: %v", err)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatal(err)
+ }
+}
+
// 重复保存同一个阈值时 MySQL 返回 0 影响行,这跟"找不到默认组织/仓库"是两种不同情况,
// 不能把它当成错误报出来。
func TestStockRepoUpdateWarningStockKeepsUnchangedThreshold(t *testing.T) {
@@ -204,8 +251,8 @@ func TestStockRepoGetAllStockFiltersBothCountAndList(t *testing.T) {
mock.ExpectQuery(`(?s)SELECT m\.id, m\.material_code.*`+filter+`.*ORDER BY m\.id LIMIT \? OFFSET \?`).
WithArgs(20, 0).
WillReturnRows(sqlmock.NewRows([]string{
- "id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "major_category", "minor_category",
- }).AddRow(7, "MAT-007", "轴承", "6204", "个", "4.000000", "10.000000", true, "A01", "A01-01"))
+ "id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "warning_configured", "major_category", "minor_category",
+ }).AddRow(7, "MAT-007", "轴承", "6204", "个", "4.000000", "10.000000", true, true, "A01", "A01-01"))
stocks, total, err := NewStockRepo().GetAllStock("", 20, 0)
if err != nil {
@@ -241,8 +288,8 @@ func TestStockRepoGetWarningStockAppliesThresholdRuleToBothQueries(t *testing.T)
mock.ExpectQuery(`(?s)SELECT m\.id, m\.material_code.*`+having+`.*ORDER BY m\.id LIMIT \? OFFSET \?`).
WithArgs(20, 0).
WillReturnRows(sqlmock.NewRows([]string{
- "id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "major_category", "minor_category",
- }).AddRow(7, "MAT-007", "轴承", "6204", "个", "0.000000", "10.000000", true, "A01", "A01-01"))
+ "id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "warning_configured", "major_category", "minor_category",
+ }).AddRow(7, "MAT-007", "轴承", "6204", "个", "0.000000", "10.000000", true, true, "A01", "A01-01"))
stocks, total, err := NewStockRepo().GetWarningStock(20, 0)
if err != nil {
diff --git a/internal/service/inbound.go b/internal/service/inbound.go
index 3b8aa78f..2c20d898 100644
--- a/internal/service/inbound.go
+++ b/internal/service/inbound.go
@@ -22,6 +22,15 @@ func NewInboundService() *InboundService {
}
}
+// resolveWarningEnabled 决定本次写入预警规则时是否启用。调用方已经确认阈值非 nil。
+// 未显式传 warning_enabled 时默认启用,保持老客户端"带上阈值即启用"的行为。
+func resolveWarningEnabled(enabled *bool) bool {
+ if enabled == nil {
+ return true
+ }
+ return *enabled
+}
+
func (s *InboundService) Create(req *model.CreateInboundRequest) (*model.InboundOrder, error) {
order := &model.InboundOrder{
InboundDate: time.Now(),
@@ -83,9 +92,11 @@ func (s *InboundService) Create(req *model.CreateInboundRequest) (*model.Inbound
}
// 入库明细上带的预警阈值在同一个事务里写入,保证物料的第一个阈值和第一行库存
- // 同时落库。阈值为 nil 表示本次不改动已存的值。
+ // 同时落库。阈值为 nil 表示本次不改动已存的值;阈值为 0 是合法配置,含义是
+ // "库存降到 0 时预警"。
if warningStock := req.Items[i].WarningStock; warningStock != nil {
- err = s.stockRepo.UpdateWarningStockTx(tx, item.MaterialID, *warningStock, true)
+ err = s.stockRepo.UpdateWarningStockTx(tx, item.MaterialID, *warningStock,
+ resolveWarningEnabled(req.Items[i].WarningEnabled))
if err != nil {
return nil, err
}
@@ -232,9 +243,10 @@ func (s *InboundService) Update(id int64, req *model.UpdateInboundRequest) (*mod
}
// 阈值随明细一起更新,nil 表示不改动已存的值,避免编辑旧入库单时把刚调好的
- // 阈值覆盖回旧数。
+ // 阈值覆盖回旧数;启用状态同理,nil 表示不改动。
if item.WarningStock != nil {
- err = s.stockRepo.UpdateWarningStockTx(tx, item.MaterialID, *item.WarningStock, true)
+ err = s.stockRepo.UpdateWarningStockTx(tx, item.MaterialID, *item.WarningStock,
+ resolveWarningEnabled(item.WarningEnabled))
if err != nil {
return nil, err
}
diff --git a/internal/service/inbound_test.go b/internal/service/inbound_test.go
new file mode 100644
index 00000000..6db68993
--- /dev/null
+++ b/internal/service/inbound_test.go
@@ -0,0 +1,28 @@
+package service
+
+import "testing"
+
+// resolveWarningEnabled 决定入库时写预警规则要不要启用。老客户端只会传 warning_stock、
+// 从不传 warning_enabled,那种情况下必须保持原来的"带上阈值即启用"行为。
+func TestResolveWarningEnabled(t *testing.T) {
+ enabled := true
+ disabled := false
+
+ cases := []struct {
+ name string
+ in *bool
+ want bool
+ }{
+ {"未传则默认启用", nil, true},
+ {"显式启用", &enabled, true},
+ {"显式关闭", &disabled, false},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := resolveWarningEnabled(tc.in); got != tc.want {
+ t.Fatalf("resolveWarningEnabled(%v) = %v, want %v", tc.in, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/service/stock.go b/internal/service/stock.go
index 38c57cfb..828b8c80 100644
--- a/internal/service/stock.go
+++ b/internal/service/stock.go
@@ -19,13 +19,13 @@ func NewStockService() *StockService {
}
// ClassifyStock 与 repository.stockWarningHavingSQL 是同一套规则,后者负责筛出预警列表,
-// 两者必须保持一致:只有配置了启用状态且大于 0 的预警阈值、并且现存量已经触达该阈值时
-// 才是 low。
+// 两者必须保持一致:只要预警处于启用状态、并且现存量已经触达阈值就是 low。阈值为 0 是
+// 合法配置("库存降到 0 时预警"),不再被排除。
//
-// 这里刻意没有独立的"已耗尽"状态:没设过阈值的物料即使库存归零也只是 normal——那是操作
-// 者自己选择不被告知,不是告警。
+// 没配过规则和手动关闭预警的物料都由 warningEnabled=false 表达,因此在这里自然落到
+// normal——那是操作者自己选择不被告知,不是告警。
func ClassifyStock(totalOnHand, warningStock decimal.Decimal, warningEnabled bool) string {
- if !warningEnabled || warningStock.LessThanOrEqual(decimal.Zero) {
+ if !warningEnabled {
return model.StockStatusNormal
}
if totalOnHand.LessThanOrEqual(warningStock) {
diff --git a/internal/service/stock_test.go b/internal/service/stock_test.go
index a99bac34..f556b3af 100644
--- a/internal/service/stock_test.go
+++ b/internal/service/stock_test.go
@@ -17,8 +17,12 @@ func TestClassifyStockBoundaries(t *testing.T) {
warningEnabled bool
want string
}{
- {"用完且没设过预警值", "0", "0", true, model.StockStatusNormal},
- {"有货且没设过预警值", "5", "0", true, model.StockStatusNormal},
+ // 阈值为 0 是合法配置,含义是"库存降到 0 时预警",不再被排除在预警之外。
+ {"阈值0启用且库存归零", "0", "0", true, model.StockStatusLow},
+ {"阈值0启用但还剩一点", "0.000001", "0", true, model.StockStatusNormal},
+ {"有货且阈值为0", "5", "0", true, model.StockStatusNormal},
+ // 没配过规则和手动关闭预警由同一个 warningEnabled=false 表达,一律不报警。
+ {"没配过预警规则", "0", "0", false, model.StockStatusNormal},
{"设了阈值但库存归零", "0", "10", true, model.StockStatusLow},
{"低于阈值", "5", "10", true, model.StockStatusLow},
{"恰好触达阈值即预警", "10", "10", true, model.StockStatusLow},
diff --git a/scripts/update_warning_stock_zero.sql b/scripts/update_warning_stock_zero.sql
new file mode 100644
index 00000000..388324e0
--- /dev/null
+++ b/scripts/update_warning_stock_zero.sql
@@ -0,0 +1,45 @@
+-- ============================================================================
+-- 预警阈值语义调整脚本(stock_warning_rules)
+--
+-- 用途:清理历史遗留的 minimum_available = 0 的预警规则行。
+--
+-- 背景:旧版判定规则是「参与预警 = 启用 AND 阈值 > 0」,阈值为 0 的行被 HAVING 条件
+-- 完全屏蔽,从来没有生效过。正因为它们一直是沉默的,无法从数据上区分"当年故意
+-- 设成 0"和"入库表单预填逻辑误写 0"。
+-- 新版把 0 定为合法的预警值(含义是"库存降到 0 时预警"),这批行会在升级后
+-- 立刻开始报警。因此一并删除,让它们回到"从未配置"(不预警)的状态——这与
+-- 升级前的实际行为一致。
+--
+-- !! 破坏性操作,执行前请务必备份数据库 !!
+--
+-- 字符集:统一 utf8mb4。脚本开头 SET NAMES utf8mb4 确保中文正确落库,
+-- 不依赖客户端默认字符集。
+-- 幂等性:可重复执行(删除条件固定,清空后再执行不会影响任何行)。
+--
+-- 执行方式:
+-- mysql -uroot -p --default-character-set=utf8mb4 wmms < scripts/update_warning_stock_zero.sql
+-- ============================================================================
+
+SET NAMES utf8mb4;
+
+-- ---------------------------------------------------------------------------
+-- 1. 执行前先看看要删掉哪些行
+-- 如果结果里有你确实想保留的物料,请先手工把它的阈值改成大于 0 的值。
+-- ---------------------------------------------------------------------------
+SELECT id, organization_id, warehouse_id, material_id, minimum_available, enabled, updated_at
+FROM stock_warning_rules
+WHERE minimum_available = 0;
+
+-- ---------------------------------------------------------------------------
+-- 2. 删除阈值为 0 的历史规则
+-- enabled 是 TRUE 还是 FALSE 都要删:前者是"沉默的启用"(升级后会突然报警),
+-- 后者本就等同于"从未配置"。
+-- ---------------------------------------------------------------------------
+DELETE FROM stock_warning_rules WHERE minimum_available = 0;
+
+-- ---------------------------------------------------------------------------
+-- 3. 确认清空,结果应为 0
+-- ---------------------------------------------------------------------------
+SELECT COUNT(*) AS remaining_zero_threshold_rules
+FROM stock_warning_rules
+WHERE minimum_available = 0;
diff --git a/server.exe b/server.exe
index da7dcd5f..a9d061e8 100644
Binary files a/server.exe and b/server.exe differ