完善库存流水

This commit is contained in:
文睿 张 2026-09-20 14:29:41 +08:00
parent 332ac1ff7a
commit 936a8d3b61
24 changed files with 426 additions and 41 deletions

View File

@ -88,7 +88,7 @@
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|----------|---------|------|----------------------------| |----------|---------|------|----------------------------|
| search | string | 否 | 按物料编码或名称模糊搜索 | | search | string | 否 | 按物料编码或名称模糊搜索 |
| category | string | 否 | **当前未生效**,服务端接收但未用于过滤 | | status | string | 否 | `active`(默认)、`obsolete` 或 `all`;业务下拉应使用默认值,物料管理页使用 `all` |
| limit | integer | 否 | 每页条数,默认 20,上限 100 | | limit | integer | 否 | 每页条数,默认 20,上限 100 |
| offset | integer | 否 | 偏移量,默认 0 | | offset | integer | 否 | 偏移量,默认 0 |
@ -254,7 +254,7 @@
作废物料。这是**软删除**:物料从库存视图(`GET /stock`、`GET /stock/warning`)中消失,但记录保留,可以恢复。 作废物料。这是**软删除**:物料从库存视图(`GET /stock`、`GET /stock/warning`)中消失,但记录保留,可以恢复。
> 注意作废**不会**把物料从 `GET /materials` 的列表和物料下拉中移除——那个接口不过滤状态。因此作废物料依然出现在入库、出库、物料申请的物料选择器里,服务端也不会阻止给它入库。物料的可用性判断需要调用方自己看 `status` 字段。 > `GET /materials` 默认仅返回有效物料;物料管理页应传 `status=all` 才会同时看到作废项并执行撤销。入库、出库和申领的服务端也会拒绝使用已作废物料。历史单据仍可读取作废物料的名称与编码。
**路径参数:** **路径参数:**
@ -1030,9 +1030,7 @@
**查询参数:** `limit`(默认 20,上限 100)、`offset`(默认 0)。 **查询参数:** `limit`(默认 20,上限 100)、`offset`(默认 0)。
**响应**:结构与 `GET /stock/logs` 相同。 **响应**:结构与 `GET /stock/logs` 相同。`change_quantity` 采用带符号约定:入库为正数、出库为负数;`change_type` 仍用于展示入库/出库文字、样式和筛选。
> **注意**:本接口的 `change_quantity` 是**不带符号的正数**(方向由 `change_type` 区分),与 `GET /stock/logs` 的带符号约定不一致。这是当前实现的实际行为,使用前请留意。
**状态码:** **状态码:**

View File

@ -0,0 +1,81 @@
# Obsolete Material Policy Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Exclude obsolete materials from operational selection and reject them in new or edited warehouse operations while preserving administrative management, historical records, and readable disabled selections when editing old documents.
**Architecture:** `GET /materials` gains a validated status scope that defaults to active records. The material-management UI requests all records, while shared operational options keep the active default. A distinct active-status check is applied at server-side operation boundaries; historical queries remain status-agnostic.
**Tech Stack:** Go 1.21, Gin, MySQL, Vue 3, Pinia, Vitest, sqlmock.
**Spec:** User-approved table in this conversation: operational selectors return active materials; material management returns all; historical documents remain readable; new and edited operations require active materials.
## Global Constraints
- Do not delete or mutate historical document records.
- Keep `Exists` as an existence check; use a new active-status check for operational validation.
- Default list scope is `active`; only material management requests `all`.
- Use test-first development for each behavior change.
---
### Task 1: Status-scoped material lists
**Files:**
- Modify: `internal/handler/api.go`, `internal/service/material.go`, `internal/repository/material.go`
- Modify: `internal/repository/material_test.go`
- Modify: `frontend/src/stores/materials.js`, `frontend/src/views/Materials.vue`, `docs/api.md`
**Interfaces:**
- Produces: `MaterialService.List(search, status string, limit, offset int)` and repository equivalent.
- Produces: `GET /materials` defaults to `status=active`; `status=all` returns active and obsolete records.
- [ ] Write sqlmock tests for active and all list SQL predicates.
- [ ] Run the focused repository tests and observe missing status-aware query behavior.
- [ ] Add a `status` filter to the count and result queries; accept `active`, `obsolete`, and `all` in the handler.
- [ ] Make `fetchMaterials` accept a status argument and have `Materials.vue` request `all`; leave `fetchMaterialOptions` on the active default.
- [ ] Update API documentation and run focused backend/frontend tests.
### Task 2: Server-side active material validation
**Files:**
- Modify: `internal/service/material.go`, `internal/service/errors.go`
- Modify: `internal/service/inbound.go`, `internal/service/outbound.go`, `internal/service/application.go`
- Modify: `internal/handler/api.go`, `internal/service/material_test.go`
**Interfaces:**
- Produces: `MaterialService.IsActive(id int64) (bool, error)` and `ErrMaterialObsolete`.
- Consumes: active check in inbound, outbound, and application create/edit validation.
- [ ] Write a failing service test for a material whose status is `obsolete`.
- [ ] Run it and observe that existence currently passes without evaluating status.
- [ ] Implement `IsActive` and use it before transaction work in inbound/outbound/application workflows.
- [ ] Map `ErrMaterialObsolete` to a clear 400 API response.
- [ ] Run related service/handler tests.
### Task 3: Cache and regression verification
**Files:**
- Modify: `frontend/src/stores/materials.js`, `frontend/src/stores/materials.test.js`
- Modify: `docs/api.md`
- [ ] Write a failing frontend store test proving obsolete/restore refreshes operational options.
- [ ] Refresh `materialOptions` after obsolete and restore calls.
- [ ] Run the full frontend test suite and `go test ./...`.
- [ ] Confirm historical document detail queries remain unfiltered by material status.
### Task 4: Read-only representation of obsolete historical selections
**Files:**
- Modify: `internal/model/model.go`, `internal/repository/inbound.go`, `internal/repository/outbound.go`
- Modify: `frontend/src/views/Inbound.vue`, `frontend/src/views/Outbound.vue`
- Modify: `frontend/src/views/Inbound.test.js`
**Interfaces:**
- Produces: inbound/outbound item responses that include the material status used by editing forms.
- Produces: selected obsolete items are kept in their edit-form options with `disabled: true`.
- [ ] Write a failing view test that opens a historical document containing an obsolete material and expects its code/name to remain visible in a disabled select option.
- [ ] Include `m.status` in document item detail queries and responses without filtering the historical join.
- [ ] Merge an obsolete item from the document detail into that edit row's options only; mark it disabled and do not add it to shared active options.
- [ ] Verify save remains blocked server-side by Task 2 when the old obsolete material is retained.

View File

@ -4,7 +4,11 @@ import { materialsApi } from '../api/materials'
export const useMaterialsStore = defineStore('materials', () => { export const useMaterialsStore = defineStore('materials', () => {
const materials = ref([]) const materials = ref([])
// 有效物料选项,供入库、出库、物料申请等业务单据的表单使用。
const materialOptions = ref([]) const materialOptions = ref([])
// 含已作废物料的选项,只给库存流水这类历史记录的检索框用:查历史时需要能选中
// 已经作废的物料,但业务单据的表单不该出现它们。
const historicalMaterialOptions = ref([])
const loading = ref(false) const loading = ref(false)
const pagination = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) const pagination = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
@ -25,12 +29,12 @@ export const useMaterialsStore = defineStore('materials', () => {
return name ? `${category} - ${name}` : category return name ? `${category} - ${name}` : category
} }
async function fetchMaterials(page = 1, search = '') { async function fetchMaterials(page = 1, search = '', status = 'all') {
loading.value = true loading.value = true
try { try {
const pageSize = pagination.value.pageSize const pageSize = pagination.value.pageSize
const offset = (page - 1) * pageSize const offset = (page - 1) * pageSize
const res = await materialsApi.getAll({ search, limit: pageSize, offset }) const res = await materialsApi.getAll({ search, status, limit: pageSize, offset })
materials.value = res.data?.items || res.data || [] materials.value = res.data?.items || res.data || []
pagination.value.total = res.data?.total || materials.value.length pagination.value.total = res.data?.total || materials.value.length
pagination.value.currentPage = page pagination.value.currentPage = page
@ -40,21 +44,34 @@ export const useMaterialsStore = defineStore('materials', () => {
} }
} }
async function fetchMaterialOptions() { // 接口单页上限是 100,循环翻页把符合条件的物料全部取回来。
async function fetchOptionsByStatus(status) {
const pageSize = 100 const pageSize = 100
let offset = 0 let offset = 0
let total = 0 let total = 0
const options = [] const options = []
do { do {
const res = await materialsApi.getAll({ limit: pageSize, offset }) const res = await materialsApi.getAll({ status, limit: pageSize, offset })
const items = res.data?.items || res.data || [] const items = res.data?.items || res.data || []
options.push(...items) options.push(...items)
total = res.data?.total ?? options.length total = res.data?.total ?? options.length
offset += items.length offset += items.length
} while (offset < total) } while (offset < total)
materialOptions.value = options return options
}
async function fetchMaterialOptions() {
materialOptions.value = await fetchOptionsByStatus('active')
}
async function fetchHistoricalMaterialOptions() {
historicalMaterialOptions.value = await fetchOptionsByStatus('all')
}
async function refreshMaterialOptions() {
await Promise.all([fetchMaterialOptions(), fetchHistoricalMaterialOptions()])
} }
async function createMaterial(data) { async function createMaterial(data) {
@ -72,18 +89,21 @@ export const useMaterialsStore = defineStore('materials', () => {
async function obsoleteMaterial(id) { async function obsoleteMaterial(id) {
const res = await materialsApi.obsolete(id) const res = await materialsApi.obsolete(id)
await fetchMaterials(pagination.value.currentPage) await fetchMaterials(pagination.value.currentPage)
// 作废会改变物料在「有效」和「全部」两份选项里的归属,两份都要刷新
await refreshMaterialOptions()
return res return res
} }
async function restoreMaterial(id) { async function restoreMaterial(id) {
const res = await materialsApi.restore(id) const res = await materialsApi.restore(id)
await fetchMaterials(pagination.value.currentPage) await fetchMaterials(pagination.value.currentPage)
await refreshMaterialOptions()
return res return res
} }
return { return {
materials, materialOptions, loading, pagination, categoryMap, materials, materialOptions, historicalMaterialOptions, loading, pagination, categoryMap,
getCategoryDisplay, fetchMaterials, fetchMaterialOptions, getCategoryDisplay, fetchMaterials, fetchMaterialOptions, fetchHistoricalMaterialOptions,
createMaterial, updateMaterial, obsoleteMaterial, restoreMaterial createMaterial, updateMaterial, obsoleteMaterial, restoreMaterial
} }
}) })

View File

@ -51,4 +51,26 @@ describe('materials store selection options', () => {
name: '早期物料' name: '早期物料'
}) })
}) })
it('loads obsolete materials for historical record selectors only', async () => {
materialsApi.getAll.mockResolvedValue({
data: {
items: [
{ id: 1, material_code: 'A-001', name: '有效物料', status: 'active' },
{ id: 2, material_code: 'B-002', name: '历史物料', status: 'obsolete' }
],
total: 2
}
})
const store = useMaterialsStore()
await store.fetchHistoricalMaterialOptions()
expect(materialsApi.getAll).toHaveBeenCalledWith({ status: 'all', limit: 100, offset: 0 })
expect(store.historicalMaterialOptions).toEqual([
{ id: 1, material_code: 'A-001', name: '有效物料', status: 'active' },
{ id: 2, material_code: 'B-002', name: '历史物料', status: 'obsolete' }
])
expect(store.materialOptions).toEqual([])
})
}) })

View File

@ -144,6 +144,24 @@ describe('入库单表单必填校验', () => {
wrapper.unmount() wrapper.unmount()
}) })
test('编辑历史单据时保留已作废物料的名称', async () => {
inboundStore.inboundList = [{ id: 5, handler: '张三', supplier: 'ABC 钢材' }]
inboundStore.fetchInboundDetail.mockResolvedValue({
id: 5,
handler: '张三',
supplier: 'ABC 钢材',
items: [{ material_id: 7, material_code: 'MAT-007', material_name: '已作废轴承', material_status: 'obsolete', quantity: 10 }]
})
const wrapper = mountInbound()
await flushPromises()
await findButton(wrapper, '编辑').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('MAT-007 - 已作废轴承')
wrapper.unmount()
})
// 反过来验证:没有这层详情请求时,表单里只剩一个空行。 // 反过来验证:没有这层详情请求时,表单里只剩一个空行。
test('详情里没有物料明细时回退到一行空明细', async () => { test('详情里没有物料明细时回退到一行空明细', async () => {
inboundStore.inboundList = [{ id: 5, handler: '张三', supplier: 'ABC 钢材' }] inboundStore.inboundList = [{ id: 5, handler: '张三', supplier: 'ABC 钢材' }]

View File

@ -54,7 +54,7 @@
<el-form-item label="备注"> <el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" rows="3" /> <el-input v-model="form.remark" type="textarea" rows="3" />
</el-form-item> </el-form-item>
<el-form-item label="入库物料"> <el-form-item label="入库物料" required>
<div v-for="(item, index) in form.items" :key="index" class="item-row"> <div v-for="(item, index) in form.items" :key="index" class="item-row">
<div class="item-row__main"> <div class="item-row__main">
<el-select <el-select
@ -70,6 +70,7 @@
:key="m.id" :key="m.id"
:label="`${m.material_code} - ${m.name}`" :label="`${m.material_code} - ${m.name}`"
:value="m.id" :value="m.id"
:disabled="m.status === 'obsolete'"
/> />
</el-select> </el-select>
<el-input-number v-model="item.quantity" :min="1" /> <el-input-number v-model="item.quantity" :min="1" />
@ -229,7 +230,10 @@ async function showEditModal(row) {
const material = materialsStore.materialOptions.find(m => m.id === item.material_id) const material = materialsStore.materialOptions.find(m => m.id === item.material_id)
return { return {
material_id: item.material_id, material_id: item.material_id,
materialSearchText: material ? `${material.material_code} - ${material.name}` : '', materialSearchText: material ? `${material.material_code} - ${material.name}` : `${item.material_code || ''} - ${item.material_name || ''}`,
material_code: item.material_code,
material_name: item.material_name,
material_status: item.material_status,
quantity: item.quantity, quantity: item.quantity,
warning_stock: 0, warning_stock: 0,
warning_enabled: true, warning_enabled: true,
@ -298,7 +302,10 @@ function getFilteredMaterials(item) {
: source : source
// 当前选中的物料必须始终留在选项里:el-select 靠选项列表把 id 映射成名称, // 当前选中的物料必须始终留在选项里:el-select 靠选项列表把 id 映射成名称,
// 选项里没有它就只能把物料 ID 当文字显示。 // 选项里没有它就只能把物料 ID 当文字显示。
const selected = source.find(m => m.id === item.material_id) let selected = source.find(m => m.id === item.material_id)
if (!selected && item.material_status === 'obsolete') {
selected = { id: item.material_id, material_code: item.material_code, name: item.material_name, status: 'obsolete' }
}
if (selected && !matched.some(m => m.id === selected.id)) { if (selected && !matched.some(m => m.id === selected.id)) {
return [selected, ...matched] return [selected, ...matched]
} }

View File

@ -0,0 +1,50 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessage } from 'element-plus'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import Outbound from './Outbound.vue'
const outboundStore = vi.hoisted(() => ({
outboundList: [], loading: false, page: { currentPage: 1, pageSize: 20, total: 0 },
fetchOutboundList: vi.fn(), fetchOutboundDetail: vi.fn(), createOutbound: vi.fn(), updateOutbound: vi.fn(), deleteOutbound: vi.fn()
}))
const materialsStore = vi.hoisted(() => ({ materialOptions: [], fetchMaterialOptions: vi.fn() }))
const stockStore = vi.hoisted(() => ({ stockList: [], fetchAllStock: vi.fn(), fetchStockByMaterialId: vi.fn() }))
vi.mock('../stores/outbound', () => ({ useOutboundStore: () => outboundStore }))
vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore }))
vi.mock('../stores/stock', () => ({ useStockStore: () => stockStore }))
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
function mountOutbound() {
return mount(Outbound, {
global: {
plugins: [ElementPlus],
stubs: {
teleport: true,
ElSelect: { props: ['modelValue'], template: '<div><slot /></div>' },
ElOption: { props: ['label', 'value', 'disabled'], template: '<span>{{ label }}</span>' }
}
}
})
}
describe('出库单表单必填校验', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => vi.restoreAllMocks())
test('未填写接收方时提示友好信息且不发送请求', async () => {
const warning = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {})
const wrapper = mountOutbound()
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('新增出库单')).trigger('click')
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('保存')).trigger('click')
await flushPromises()
expect(warning).toHaveBeenCalledWith('请输入接收方')
expect(outboundStore.createOutbound).not.toHaveBeenCalled()
wrapper.unmount()
})
})

View File

@ -39,13 +39,13 @@
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑出库单' : '新增出库单'" width="900px"> <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑出库单' : '新增出库单'" width="900px">
<el-form :model="form" label-width="100px"> <el-form :model="form" label-width="100px">
<el-form-item label="接收方"> <el-form-item label="接收方" required>
<el-input v-model="form.receiver" placeholder="请输入接收方" /> <el-input v-model="form.receiver" placeholder="请输入接收方" />
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" rows="3" /> <el-input v-model="form.remark" type="textarea" rows="3" />
</el-form-item> </el-form-item>
<el-form-item label="出库物料"> <el-form-item label="出库物料" required>
<div class="material-items"> <div class="material-items">
<div v-for="(item, index) in form.items" :key="index" class="item-row"> <div v-for="(item, index) in form.items" :key="index" class="item-row">
<el-select <el-select
@ -61,6 +61,7 @@
:key="m.id" :key="m.id"
:label="`${m.material_code} - ${m.name}`" :label="`${m.material_code} - ${m.name}`"
:value="m.id" :value="m.id"
:disabled="m.status === 'obsolete'"
/> />
</el-select> </el-select>
<el-input-number v-model="item.quantity" :min="1" /> <el-input-number v-model="item.quantity" :min="1" />
@ -172,7 +173,7 @@ async function showEditModal(row) {
stockStore.fetchStockByMaterialId(item.material_id).then(stock => { stockStore.fetchStockByMaterialId(item.material_id).then(stock => {
itemStocks.value[item.material_id] = stock itemStocks.value[item.material_id] = stock
}) })
return { material_id: item.material_id, materialSearchText: material ? `${material.material_code} - ${material.name}` : '', quantity: item.quantity } return { material_id: item.material_id, materialSearchText: material ? `${material.material_code} - ${material.name}` : `${item.material_code || ''} - ${item.material_name || ''}`, material_code: item.material_code, material_name: item.material_name, material_status: item.material_status, quantity: item.quantity }
}) })
: [{ material_id: null, materialSearchText: '', quantity: 1 }] : [{ material_id: null, materialSearchText: '', quantity: 1 }]
dialogVisible.value = true dialogVisible.value = true
@ -204,7 +205,10 @@ function getFilteredMaterials(item) {
: source : source
// 当前选中的物料必须始终留在选项里:el-select 靠选项列表把 id 映射成名称, // 当前选中的物料必须始终留在选项里:el-select 靠选项列表把 id 映射成名称,
// 选项里没有它就只能把物料 ID 当文字显示。 // 选项里没有它就只能把物料 ID 当文字显示。
const selected = source.find(m => m.id === item.material_id) let selected = source.find(m => m.id === item.material_id)
if (!selected && item.material_status === 'obsolete') {
selected = { id: item.material_id, material_code: item.material_code, name: item.material_name, status: 'obsolete' }
}
if (selected && !matched.some(m => m.id === selected.id)) { if (selected && !matched.some(m => m.id === selected.id)) {
return [selected, ...matched] return [selected, ...matched]
} }
@ -231,7 +235,17 @@ function isLowStock(materialId) {
return stock?.stock_status === 'low' return stock?.stock_status === 'low'
} }
function firstMissingField() {
if (!form.receiver.trim()) return '请输入接收方'
return ''
}
async function handleSave() { async function handleSave() {
const missing = firstMissingField()
if (missing) {
ElMessage.warning(missing)
return
}
saveLoading.value = true saveLoading.value = true
try { try {
const validItems = form.items.filter(item => item.material_id && item.quantity > 0) const validItems = form.items.filter(item => item.material_id && item.quantity > 0)

View File

@ -112,7 +112,7 @@
<el-option <el-option
v-for="m in getFilteredMaterialsForLog()" v-for="m in getFilteredMaterialsForLog()"
:key="m.id" :key="m.id"
:label="`${m.material_code} - ${m.name}`" :label="logMaterialLabel(m)"
:value="m.id" :value="m.id"
/> />
</el-select> </el-select>
@ -250,7 +250,10 @@ const warningEnabled = ref(true)
onMounted(async () => { onMounted(async () => {
stockStore.fetchStock() stockStore.fetchStock()
stockStore.fetchStockWarning() stockStore.fetchStockWarning()
// 库存查询用有效物料即可(作废物料本来就不在库存列表里),
// 库存流水则要能查已作废物料的历史,所以额外拉一份含作废的选项。
materialsStore.fetchMaterialOptions() materialsStore.fetchMaterialOptions()
materialsStore.fetchHistoricalMaterialOptions()
const catRes = await materialCategoriesApi.list() const catRes = await materialCategoriesApi.list()
categories.value = catRes.data || [] categories.value = catRes.data || []
}) })
@ -322,7 +325,13 @@ function logMaterialFilter(val) {
} }
function getFilteredMaterialsForLog() { function getFilteredMaterialsForLog() {
return searchMaterialOptions(materialsStore.materialOptions, logMaterialSearchText.value) return searchMaterialOptions(materialsStore.historicalMaterialOptions, logMaterialSearchText.value)
}
// 流水检索框里会出现已作废的物料,标注出来免得和有效物料混淆。
function logMaterialLabel(material) {
const label = `${material.material_code} - ${material.name}`
return material.status === 'obsolete' ? `${label}(已作废)` : label
} }
function handleWarningPageChange(page) { function handleWarningPageChange(page) {

View File

@ -29,6 +29,13 @@ type Handler struct {
warehouseSvc *service.WarehouseService warehouseSvc *service.WarehouseService
} }
func userFacingServiceErrorMessage(err error) string {
if errors.Is(err, service.ErrInsufficientStock) {
return "库存不足"
}
return err.Error()
}
func NewHandler() *Handler { func NewHandler() *Handler {
return &Handler{ return &Handler{
materialSvc: service.NewMaterialService(), materialSvc: service.NewMaterialService(),
@ -219,7 +226,11 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
func (h *Handler) ListMaterials(c *gin.Context) { func (h *Handler) ListMaterials(c *gin.Context) {
search := c.Query("search") search := c.Query("search")
category := c.Query("category") status := c.DefaultQuery("status", "active")
if status != "active" && status != "obsolete" && status != "all" {
response.BadRequest(c, "status must be active, obsolete, or all")
return
}
limit := 20 limit := 20
offset := 0 offset := 0
@ -237,7 +248,7 @@ func (h *Handler) ListMaterials(c *gin.Context) {
} }
} }
materials, total, err := h.materialSvc.List(search, category, limit, offset) materials, total, err := h.materialSvc.List(search, status, limit, offset)
if err != nil { if err != nil {
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())
return return
@ -468,6 +479,10 @@ func (h *Handler) CreateInbound(c *gin.Context) {
response.BadRequest(c, "material not found") response.BadRequest(c, "material not found")
return return
} }
if errors.Is(err, service.ErrMaterialObsolete) {
response.BadRequest(c, "material is obsolete")
return
}
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())
return return
} }
@ -510,6 +525,10 @@ func (h *Handler) UpdateInbound(c *gin.Context) {
response.BadRequest(c, "material not found") response.BadRequest(c, "material not found")
return return
} }
if errors.Is(err, service.ErrMaterialObsolete) {
response.BadRequest(c, "material is obsolete")
return
}
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())
return return
} }
@ -603,8 +622,12 @@ func (h *Handler) CreateOutbound(c *gin.Context) {
response.BadRequest(c, "material not found") response.BadRequest(c, "material not found")
return return
} }
if errors.Is(err, service.ErrMaterialObsolete) {
response.BadRequest(c, "material is obsolete")
return
}
if errors.Is(err, service.ErrInsufficientStock) { if errors.Is(err, service.ErrInsufficientStock) {
response.BadRequest(c, "insufficient stock") response.BadRequest(c, userFacingServiceErrorMessage(err))
return return
} }
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())
@ -653,8 +676,12 @@ func (h *Handler) UpdateOutbound(c *gin.Context) {
response.BadRequest(c, "material not found") response.BadRequest(c, "material not found")
return return
} }
if errors.Is(err, service.ErrMaterialObsolete) {
response.BadRequest(c, "material is obsolete")
return
}
if errors.Is(err, service.ErrInsufficientStock) { if errors.Is(err, service.ErrInsufficientStock) {
response.BadRequest(c, "insufficient stock") response.BadRequest(c, userFacingServiceErrorMessage(err))
return return
} }
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())
@ -1120,6 +1147,10 @@ func (h *Handler) CreateApplication(c *gin.Context) {
app, err := h.appSvc.Create(userID, &req) app, err := h.appSvc.Create(userID, &req)
if err != nil { if err != nil {
if errors.Is(err, service.ErrMaterialNotFound) || errors.Is(err, service.ErrMaterialObsolete) {
response.BadRequest(c, err.Error())
return
}
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())
return return
} }
@ -1233,7 +1264,7 @@ func (h *Handler) ApproveApplication(c *gin.Context) {
return return
} }
if err == service.ErrInsufficientStock { if err == service.ErrInsufficientStock {
response.BadRequest(c, "insufficient stock") response.BadRequest(c, userFacingServiceErrorMessage(err))
return return
} }
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())

View File

@ -0,0 +1,13 @@
package handler
import (
"testing"
"github.com/wmms/wmms/internal/service"
)
func TestUserFacingServiceErrorMessage_LocalizesInsufficientStock(t *testing.T) {
if got := userFacingServiceErrorMessage(service.ErrInsufficientStock); got != "库存不足" {
t.Fatalf("库存不足应返回中文提示,实际为 %q", got)
}
}

View File

@ -76,6 +76,7 @@ type InboundItem struct {
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
MaterialCode string `json:"material_code,omitempty"` MaterialCode string `json:"material_code,omitempty"`
MaterialName string `json:"material_name,omitempty"` MaterialName string `json:"material_name,omitempty"`
MaterialStatus string `json:"material_status,omitempty"`
} }
// OutboundOrder represents an outbound order // OutboundOrder represents an outbound order
@ -101,6 +102,7 @@ type OutboundItem struct {
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
MaterialCode string `json:"material_code,omitempty"` MaterialCode string `json:"material_code,omitempty"`
MaterialName string `json:"material_name,omitempty"` MaterialName string `json:"material_name,omitempty"`
MaterialStatus string `json:"material_status,omitempty"`
} }
// OrderNo returns the stable display number used by inbound and outbound // OrderNo returns the stable display number used by inbound and outbound

View File

@ -76,7 +76,7 @@ func (r *InboundRepo) GetByID(id int64) (*model.InboundOrder, error) {
return nil, err return nil, err
} }
itemsQuery := `SELECT ii.id, ii.inbound_order_id, ii.material_id, ii.quantity, m.material_code, m.name itemsQuery := `SELECT ii.id, ii.inbound_order_id, ii.material_id, ii.quantity, m.material_code, m.name, m.status
FROM inbound_items ii FROM inbound_items ii
JOIN materials m ON ii.material_id = m.id JOIN materials m ON ii.material_id = m.id
WHERE ii.inbound_order_id = ?` WHERE ii.inbound_order_id = ?`
@ -88,7 +88,7 @@ func (r *InboundRepo) GetByID(id int64) (*model.InboundOrder, error) {
for rows.Next() { for rows.Next() {
var item model.InboundItem var item model.InboundItem
err := rows.Scan(&item.ID, &item.InboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName) err := rows.Scan(&item.ID, &item.InboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName, &item.MaterialStatus)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -93,9 +93,13 @@ func (r *MaterialRepo) GetByMaterialCode(code string) (*model.Material, error) {
return m, nil return m, nil
} }
func (r *MaterialRepo) List(search, category string, limit, offset int) ([]model.Material, int64, error) { func (r *MaterialRepo) List(search, status string, limit, offset int) ([]model.Material, int64, error) {
countQuery := `SELECT COUNT(*) FROM materials WHERE 1=1` countQuery := `SELECT COUNT(*) FROM materials WHERE 1=1`
var countArgs []interface{} var countArgs []interface{}
if status != "all" {
countQuery += ` AND status = ?`
countArgs = append(countArgs, status)
}
if search != "" { if search != "" {
countQuery += ` AND (material_code LIKE ? OR name LIKE ?)` countQuery += ` AND (material_code LIKE ? OR name LIKE ?)`
@ -113,6 +117,10 @@ func (r *MaterialRepo) List(search, category string, limit, offset int) ([]model
category_id, base_unit_id, unit, status, version, created_at, updated_at category_id, base_unit_id, unit, status, version, created_at, updated_at
FROM materials WHERE 1=1` FROM materials WHERE 1=1`
var args []interface{} var args []interface{}
if status != "all" {
query += ` AND status = ?`
args = append(args, status)
}
if search != "" { if search != "" {
query += ` AND (material_code LIKE ? OR name LIKE ?)` query += ` AND (material_code LIKE ? OR name LIKE ?)`

View File

@ -146,3 +146,8 @@ func TestMaterialRepoObsoleteIsBlockedByStock(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
func TestMaterialRepoListUsesStatusFilterInsteadOfCategoryArgument(t *testing.T) {
var list func(string, string, int, int) ([]model.Material, int64, error) = NewMaterialRepo().List
_ = list
}

View File

@ -76,7 +76,7 @@ func (r *OutboundRepo) GetByID(id int64) (*model.OutboundOrder, error) {
return nil, err return nil, err
} }
itemsQuery := `SELECT oi.id, oi.outbound_order_id, oi.material_id, oi.quantity, m.material_code, m.name itemsQuery := `SELECT oi.id, oi.outbound_order_id, oi.material_id, oi.quantity, m.material_code, m.name, m.status
FROM outbound_items oi FROM outbound_items oi
JOIN materials m ON oi.material_id = m.id JOIN materials m ON oi.material_id = m.id
WHERE oi.outbound_order_id = ?` WHERE oi.outbound_order_id = ?`
@ -88,7 +88,7 @@ func (r *OutboundRepo) GetByID(id int64) (*model.OutboundOrder, error) {
for rows.Next() { for rows.Next() {
var item model.OutboundItem var item model.OutboundItem
err := rows.Scan(&item.ID, &item.OutboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName) err := rows.Scan(&item.ID, &item.OutboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName, &item.MaterialStatus)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -36,6 +36,13 @@ func (s *ApplicationService) Create(userID int64, req *model.CreateApplicationRe
if err != nil || !exists { if err != nil || !exists {
return nil, ErrMaterialNotFound return nil, ErrMaterialNotFound
} }
active, err := s.materialSvc.IsActive(item.MaterialID)
if err != nil {
return nil, err
}
if !active {
return nil, ErrMaterialObsolete
}
items = append(items, model.ApplicationItem{ items = append(items, model.ApplicationItem{
MaterialID: item.MaterialID, MaterialID: item.MaterialID,
Quantity: item.Quantity, Quantity: item.Quantity,

View File

@ -4,4 +4,6 @@ import "errors"
var ErrMaterialNotFound = errors.New("material not found") var ErrMaterialNotFound = errors.New("material not found")
var ErrMaterialObsolete = errors.New("material is obsolete")
var ErrInvalidWarningStock = errors.New("warning stock must be greater than or equal to 0") var ErrInvalidWarningStock = errors.New("warning stock must be greater than or equal to 0")

View File

@ -50,6 +50,13 @@ func (s *InboundService) Create(req *model.CreateInboundRequest, operator string
if !exists { if !exists {
return nil, ErrMaterialNotFound return nil, ErrMaterialNotFound
} }
active, err := s.materialSvc.IsActive(item.MaterialID)
if err != nil {
return nil, err
}
if !active {
return nil, ErrMaterialObsolete
}
if item.WarningStock != nil && item.WarningStock.IsNegative() { if item.WarningStock != nil && item.WarningStock.IsNegative() {
return nil, ErrInvalidWarningStock return nil, ErrInvalidWarningStock
} }
@ -193,6 +200,13 @@ func (s *InboundService) Update(id int64, req *model.UpdateInboundRequest) (*mod
// 阈值校验放在事务之外,避免为非法输入白开一个事务 // 阈值校验放在事务之外,避免为非法输入白开一个事务
for _, item := range req.Items { for _, item := range req.Items {
active, err := s.materialSvc.IsActive(item.MaterialID)
if err != nil {
return nil, err
}
if !active {
return nil, ErrMaterialObsolete
}
if item.WarningStock != nil && item.WarningStock.IsNegative() { if item.WarningStock != nil && item.WarningStock.IsNegative() {
return nil, ErrInvalidWarningStock return nil, ErrInvalidWarningStock
} }

View File

@ -125,8 +125,8 @@ func (s *MaterialService) GetByID(id int64) (*model.Material, error) {
return m, nil return m, nil
} }
func (s *MaterialService) List(search, category string, limit, offset int) ([]model.Material, int64, error) { func (s *MaterialService) List(search, status string, limit, offset int) ([]model.Material, int64, error) {
return s.repo.List(search, category, limit, offset) return s.repo.List(search, status, limit, offset)
} }
func (s *MaterialService) Update(id int64, req *model.UpdateMaterialRequest, operator string) (*model.Material, error) { func (s *MaterialService) Update(id int64, req *model.UpdateMaterialRequest, operator string) (*model.Material, error) {
@ -188,3 +188,17 @@ func (s *MaterialService) Exists(id int64) (bool, error) {
} }
return true, nil return true, nil
} }
// IsActive is intentionally separate from Exists: historical documents still
// need to resolve retired materials, while new warehouse operations do not.
func (s *MaterialService) IsActive(id int64) (bool, error) {
m := &model.Material{}
err := s.repo.GetByID(id, m)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return m.Status == "active", nil
}

View File

@ -4,6 +4,7 @@ import (
"errors" "errors"
"regexp" "regexp"
"testing" "testing"
"time"
"github.com/DATA-DOG/go-sqlmock" "github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/model"
@ -42,3 +43,35 @@ func TestMaterialServiceRejectsInvalidCategory(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
func TestMaterialServiceTreatsObsoleteMaterialAsInactive(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
query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
category_id, base_unit_id, unit, status, version, created_at, updated_at
FROM materials WHERE id = ?`
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(7)).WillReturnRows(
sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
AddRow(7, 1, "MAT-007", "轴承", nil, "6205", nil, 12, 3, "件", "obsolete", 1, now, now),
)
active, err := NewMaterialService().IsActive(7)
if err != nil {
t.Fatalf("check material activity: %v", err)
}
if active {
t.Fatal("obsolete material must not be active")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -44,6 +44,13 @@ func (s *OutboundService) Create(req *model.CreateOutboundRequest, operator stri
if !exists { if !exists {
return nil, ErrMaterialNotFound return nil, ErrMaterialNotFound
} }
active, err := s.materialSvc.IsActive(item.MaterialID)
if err != nil {
return nil, err
}
if !active {
return nil, ErrMaterialObsolete
}
currentStock, err := s.stockRepo.GetStock(item.MaterialID) currentStock, err := s.stockRepo.GetStock(item.MaterialID)
if err != nil { if err != nil {
@ -202,6 +209,13 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest, ope
if !exists { if !exists {
return nil, ErrMaterialNotFound return nil, ErrMaterialNotFound
} }
active, err := s.materialSvc.IsActive(item.MaterialID)
if err != nil {
return nil, err
}
if !active {
return nil, ErrMaterialObsolete
}
currentStock, err := s.stockRepo.GetStock(item.MaterialID) currentStock, err := s.stockRepo.GetStock(item.MaterialID)
if err != nil { if err != nil {

View File

@ -71,6 +71,16 @@ func (s *StockService) UpdateWarningStock(materialID int64, minimumAvailable dec
return s.stockRepo.UpdateWarningStock(materialID, minimumAvailable, enabled) return s.stockRepo.UpdateWarningStock(materialID, minimumAvailable, enabled)
} }
// signedChangeQuantity keeps the API contract consistent: inbound changes are
// positive and outbound changes are negative. The type still identifies the
// business operation for display and filtering.
func signedChangeQuantity(logType string, quantity decimal.Decimal) decimal.Decimal {
if logType == "in" {
return quantity
}
return quantity.Neg()
}
func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int) ([]model.StockLogResponse, int64, error) { func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int) ([]model.StockLogResponse, int64, error) {
logs, total, err := s.stockRepo.GetLogsByMaterialID(materialID, limit, offset) logs, total, err := s.stockRepo.GetLogsByMaterialID(materialID, limit, offset)
if err != nil { if err != nil {
@ -101,7 +111,7 @@ func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int)
ID: log.ID, ID: log.ID,
MaterialID: log.MaterialID, MaterialID: log.MaterialID,
ChangeType: changeType, ChangeType: changeType,
ChangeQuantity: quantity, ChangeQuantity: signedChangeQuantity(log.Type, quantity),
BeforeQuantity: beforeQuantity, BeforeQuantity: beforeQuantity,
AfterQuantity: balance, AfterQuantity: balance,
ReferenceID: log.RelatedOrderID, ReferenceID: log.RelatedOrderID,
@ -131,17 +141,11 @@ func (s *StockService) GetAllLogs(limit, offset int) ([]model.StockLogResponse,
beforeQuantity = log.RunningBalance.Add(quantity) beforeQuantity = log.RunningBalance.Add(quantity)
} }
// inbound=+quantity, outbound=-quantity
changeQuantity := quantity
if log.Type != "in" {
changeQuantity = quantity.Neg()
}
responses = append(responses, model.StockLogResponse{ responses = append(responses, model.StockLogResponse{
ID: log.ID, ID: log.ID,
MaterialID: log.MaterialID, MaterialID: log.MaterialID,
ChangeType: changeType, ChangeType: changeType,
ChangeQuantity: changeQuantity, ChangeQuantity: signedChangeQuantity(log.Type, quantity),
BeforeQuantity: beforeQuantity, BeforeQuantity: beforeQuantity,
AfterQuantity: log.RunningBalance, AfterQuantity: log.RunningBalance,
ReferenceID: log.RelatedOrderID, ReferenceID: log.RelatedOrderID,

View File

@ -49,3 +49,22 @@ func TestClassifyStockBoundaries(t *testing.T) {
}) })
} }
} }
func TestSignedChangeQuantityUsesTheSameConventionForInboundAndOutbound(t *testing.T) {
quantity := decimal.NewFromInt(100)
cases := []struct {
logType string
want string
}{
{logType: "in", want: "100"},
{logType: "out", want: "-100"},
}
for _, tc := range cases {
t.Run(tc.logType, func(t *testing.T) {
if got := signedChangeQuantity(tc.logType, quantity); !got.Equal(decimal.RequireFromString(tc.want)) {
t.Fatalf("signedChangeQuantity(%q, 100) = %s, want %s", tc.logType, got, tc.want)
}
})
}
}