diff --git a/docs/api.md b/docs/api.md index 6f1b58a7..1f54ab83 100644 --- a/docs/api.md +++ b/docs/api.md @@ -88,7 +88,7 @@ | 参数 | 类型 | 必填 | 说明 | |----------|---------|------|----------------------------| | search | string | 否 | 按物料编码或名称模糊搜索 | -| category | string | 否 | **当前未生效**,服务端接收但未用于过滤 | +| status | string | 否 | `active`(默认)、`obsolete` 或 `all`;业务下拉应使用默认值,物料管理页使用 `all` | | limit | integer | 否 | 每页条数,默认 20,上限 100 | | offset | integer | 否 | 偏移量,默认 0 | @@ -254,7 +254,7 @@ 作废物料。这是**软删除**:物料从库存视图(`GET /stock`、`GET /stock/warning`)中消失,但记录保留,可以恢复。 -> 注意作废**不会**把物料从 `GET /materials` 的列表和物料下拉中移除——那个接口不过滤状态。因此作废物料依然出现在入库、出库、物料申请的物料选择器里,服务端也不会阻止给它入库。物料的可用性判断需要调用方自己看 `status` 字段。 +> `GET /materials` 默认仅返回有效物料;物料管理页应传 `status=all` 才会同时看到作废项并执行撤销。入库、出库和申领的服务端也会拒绝使用已作废物料。历史单据仍可读取作废物料的名称与编码。 **路径参数:** @@ -1030,9 +1030,7 @@ **查询参数:** `limit`(默认 20,上限 100)、`offset`(默认 0)。 -**响应**:结构与 `GET /stock/logs` 相同。 - -> **注意**:本接口的 `change_quantity` 是**不带符号的正数**(方向由 `change_type` 区分),与 `GET /stock/logs` 的带符号约定不一致。这是当前实现的实际行为,使用前请留意。 +**响应**:结构与 `GET /stock/logs` 相同。`change_quantity` 采用带符号约定:入库为正数、出库为负数;`change_type` 仍用于展示入库/出库文字、样式和筛选。 **状态码:** diff --git a/docs/superpowers/plans/2026-09-20-obsolete-material-policy.md b/docs/superpowers/plans/2026-09-20-obsolete-material-policy.md new file mode 100644 index 00000000..e804cc6a --- /dev/null +++ b/docs/superpowers/plans/2026-09-20-obsolete-material-policy.md @@ -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. diff --git a/frontend/src/stores/materials.js b/frontend/src/stores/materials.js index 2dcd2674..21d791db 100644 --- a/frontend/src/stores/materials.js +++ b/frontend/src/stores/materials.js @@ -4,7 +4,11 @@ import { materialsApi } from '../api/materials' export const useMaterialsStore = defineStore('materials', () => { const materials = ref([]) + // 有效物料选项,供入库、出库、物料申请等业务单据的表单使用。 const materialOptions = ref([]) + // 含已作废物料的选项,只给库存流水这类历史记录的检索框用:查历史时需要能选中 + // 已经作废的物料,但业务单据的表单不该出现它们。 + const historicalMaterialOptions = ref([]) const loading = ref(false) 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 } - async function fetchMaterials(page = 1, search = '') { + async function fetchMaterials(page = 1, search = '', status = 'all') { loading.value = true try { const pageSize = pagination.value.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 || [] pagination.value.total = res.data?.total || materials.value.length pagination.value.currentPage = page @@ -40,21 +44,34 @@ export const useMaterialsStore = defineStore('materials', () => { } } - async function fetchMaterialOptions() { + // 接口单页上限是 100,循环翻页把符合条件的物料全部取回来。 + async function fetchOptionsByStatus(status) { const pageSize = 100 let offset = 0 let total = 0 const options = [] 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 || [] options.push(...items) total = res.data?.total ?? options.length offset += items.length } 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) { @@ -72,18 +89,21 @@ export const useMaterialsStore = defineStore('materials', () => { async function obsoleteMaterial(id) { const res = await materialsApi.obsolete(id) await fetchMaterials(pagination.value.currentPage) + // 作废会改变物料在「有效」和「全部」两份选项里的归属,两份都要刷新 + await refreshMaterialOptions() return res } async function restoreMaterial(id) { const res = await materialsApi.restore(id) await fetchMaterials(pagination.value.currentPage) + await refreshMaterialOptions() return res } return { - materials, materialOptions, loading, pagination, categoryMap, - getCategoryDisplay, fetchMaterials, fetchMaterialOptions, + materials, materialOptions, historicalMaterialOptions, loading, pagination, categoryMap, + getCategoryDisplay, fetchMaterials, fetchMaterialOptions, fetchHistoricalMaterialOptions, createMaterial, updateMaterial, obsoleteMaterial, restoreMaterial } }) diff --git a/frontend/src/stores/materials.test.js b/frontend/src/stores/materials.test.js index 86c1ad81..39b45abf 100644 --- a/frontend/src/stores/materials.test.js +++ b/frontend/src/stores/materials.test.js @@ -51,4 +51,26 @@ describe('materials store selection options', () => { 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([]) + }) }) diff --git a/frontend/src/views/Inbound.test.js b/frontend/src/views/Inbound.test.js index f16da713..c6a814ce 100644 --- a/frontend/src/views/Inbound.test.js +++ b/frontend/src/views/Inbound.test.js @@ -144,6 +144,24 @@ describe('入库单表单必填校验', () => { 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 () => { inboundStore.inboundList = [{ id: 5, handler: '张三', supplier: 'ABC 钢材' }] diff --git a/frontend/src/views/Inbound.vue b/frontend/src/views/Inbound.vue index 5b59fd3d..449f97e3 100644 --- a/frontend/src/views/Inbound.vue +++ b/frontend/src/views/Inbound.vue @@ -54,7 +54,7 @@ - +
@@ -229,7 +230,10 @@ async function showEditModal(row) { const material = materialsStore.materialOptions.find(m => m.id === item.material_id) return { 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, warning_stock: 0, warning_enabled: true, @@ -298,7 +302,10 @@ function getFilteredMaterials(item) { : source // 当前选中的物料必须始终留在选项里:el-select 靠选项列表把 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)) { return [selected, ...matched] } diff --git a/frontend/src/views/Outbound.test.js b/frontend/src/views/Outbound.test.js new file mode 100644 index 00000000..a0c609bd --- /dev/null +++ b/frontend/src/views/Outbound.test.js @@ -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: '
' }, + ElOption: { props: ['label', 'value', 'disabled'], template: '{{ label }}' } + } + } + }) +} + +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() + }) +}) diff --git a/frontend/src/views/Outbound.vue b/frontend/src/views/Outbound.vue index 8dfdfdd1..d4248091 100644 --- a/frontend/src/views/Outbound.vue +++ b/frontend/src/views/Outbound.vue @@ -39,13 +39,13 @@ - + - +
@@ -172,7 +173,7 @@ async function showEditModal(row) { stockStore.fetchStockByMaterialId(item.material_id).then(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 }] dialogVisible.value = true @@ -204,7 +205,10 @@ function getFilteredMaterials(item) { : source // 当前选中的物料必须始终留在选项里:el-select 靠选项列表把 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)) { return [selected, ...matched] } @@ -231,7 +235,17 @@ function isLowStock(materialId) { return stock?.stock_status === 'low' } +function firstMissingField() { + if (!form.receiver.trim()) return '请输入接收方' + return '' +} + 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) diff --git a/frontend/src/views/Stock.vue b/frontend/src/views/Stock.vue index 11a65518..3a9efa33 100644 --- a/frontend/src/views/Stock.vue +++ b/frontend/src/views/Stock.vue @@ -112,7 +112,7 @@ @@ -250,7 +250,10 @@ const warningEnabled = ref(true) onMounted(async () => { stockStore.fetchStock() stockStore.fetchStockWarning() + // 库存查询用有效物料即可(作废物料本来就不在库存列表里), + // 库存流水则要能查已作废物料的历史,所以额外拉一份含作废的选项。 materialsStore.fetchMaterialOptions() + materialsStore.fetchHistoricalMaterialOptions() const catRes = await materialCategoriesApi.list() categories.value = catRes.data || [] }) @@ -322,7 +325,13 @@ function logMaterialFilter(val) { } 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) { diff --git a/internal/handler/api.go b/internal/handler/api.go index 1c4e14bb..486e9f95 100644 --- a/internal/handler/api.go +++ b/internal/handler/api.go @@ -29,6 +29,13 @@ type Handler struct { warehouseSvc *service.WarehouseService } +func userFacingServiceErrorMessage(err error) string { + if errors.Is(err, service.ErrInsufficientStock) { + return "库存不足" + } + return err.Error() +} + func NewHandler() *Handler { return &Handler{ materialSvc: service.NewMaterialService(), @@ -219,7 +226,11 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { func (h *Handler) ListMaterials(c *gin.Context) { 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 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 { response.InternalError(c, err.Error()) return @@ -468,6 +479,10 @@ func (h *Handler) CreateInbound(c *gin.Context) { response.BadRequest(c, "material not found") return } + if errors.Is(err, service.ErrMaterialObsolete) { + response.BadRequest(c, "material is obsolete") + return + } response.InternalError(c, err.Error()) return } @@ -510,6 +525,10 @@ func (h *Handler) UpdateInbound(c *gin.Context) { response.BadRequest(c, "material not found") return } + if errors.Is(err, service.ErrMaterialObsolete) { + response.BadRequest(c, "material is obsolete") + return + } response.InternalError(c, err.Error()) return } @@ -603,8 +622,12 @@ func (h *Handler) CreateOutbound(c *gin.Context) { response.BadRequest(c, "material not found") return } + if errors.Is(err, service.ErrMaterialObsolete) { + response.BadRequest(c, "material is obsolete") + return + } if errors.Is(err, service.ErrInsufficientStock) { - response.BadRequest(c, "insufficient stock") + response.BadRequest(c, userFacingServiceErrorMessage(err)) return } response.InternalError(c, err.Error()) @@ -653,8 +676,12 @@ func (h *Handler) UpdateOutbound(c *gin.Context) { response.BadRequest(c, "material not found") return } + if errors.Is(err, service.ErrMaterialObsolete) { + response.BadRequest(c, "material is obsolete") + return + } if errors.Is(err, service.ErrInsufficientStock) { - response.BadRequest(c, "insufficient stock") + response.BadRequest(c, userFacingServiceErrorMessage(err)) return } response.InternalError(c, err.Error()) @@ -1120,6 +1147,10 @@ func (h *Handler) CreateApplication(c *gin.Context) { app, err := h.appSvc.Create(userID, &req) 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()) return } @@ -1233,7 +1264,7 @@ func (h *Handler) ApproveApplication(c *gin.Context) { return } if err == service.ErrInsufficientStock { - response.BadRequest(c, "insufficient stock") + response.BadRequest(c, userFacingServiceErrorMessage(err)) return } response.InternalError(c, err.Error()) diff --git a/internal/handler/error_message_test.go b/internal/handler/error_message_test.go new file mode 100644 index 00000000..85c4d727 --- /dev/null +++ b/internal/handler/error_message_test.go @@ -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) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index b6eac79c..4b527e89 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -76,6 +76,7 @@ type InboundItem struct { Quantity int `json:"quantity"` MaterialCode string `json:"material_code,omitempty"` MaterialName string `json:"material_name,omitempty"` + MaterialStatus string `json:"material_status,omitempty"` } // OutboundOrder represents an outbound order @@ -101,6 +102,7 @@ type OutboundItem struct { Quantity int `json:"quantity"` MaterialCode string `json:"material_code,omitempty"` MaterialName string `json:"material_name,omitempty"` + MaterialStatus string `json:"material_status,omitempty"` } // OrderNo returns the stable display number used by inbound and outbound diff --git a/internal/repository/inbound.go b/internal/repository/inbound.go index e0ebdba4..0b2c3bec 100644 --- a/internal/repository/inbound.go +++ b/internal/repository/inbound.go @@ -76,7 +76,7 @@ func (r *InboundRepo) GetByID(id int64) (*model.InboundOrder, error) { 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 JOIN materials m ON ii.material_id = m.id WHERE ii.inbound_order_id = ?` @@ -88,7 +88,7 @@ func (r *InboundRepo) GetByID(id int64) (*model.InboundOrder, error) { for rows.Next() { 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 { return nil, err } diff --git a/internal/repository/material.go b/internal/repository/material.go index 978943ad..e6b809a2 100644 --- a/internal/repository/material.go +++ b/internal/repository/material.go @@ -93,9 +93,13 @@ func (r *MaterialRepo) GetByMaterialCode(code string) (*model.Material, error) { 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` var countArgs []interface{} + if status != "all" { + countQuery += ` AND status = ?` + countArgs = append(countArgs, status) + } if search != "" { 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 FROM materials WHERE 1=1` var args []interface{} + if status != "all" { + query += ` AND status = ?` + args = append(args, status) + } if search != "" { query += ` AND (material_code LIKE ? OR name LIKE ?)` diff --git a/internal/repository/material_test.go b/internal/repository/material_test.go index a71cbd0e..183e8e7f 100644 --- a/internal/repository/material_test.go +++ b/internal/repository/material_test.go @@ -146,3 +146,8 @@ func TestMaterialRepoObsoleteIsBlockedByStock(t *testing.T) { t.Fatal(err) } } + +func TestMaterialRepoListUsesStatusFilterInsteadOfCategoryArgument(t *testing.T) { + var list func(string, string, int, int) ([]model.Material, int64, error) = NewMaterialRepo().List + _ = list +} diff --git a/internal/repository/outbound.go b/internal/repository/outbound.go index ff5345cd..ade876a4 100644 --- a/internal/repository/outbound.go +++ b/internal/repository/outbound.go @@ -76,7 +76,7 @@ func (r *OutboundRepo) GetByID(id int64) (*model.OutboundOrder, error) { 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 JOIN materials m ON oi.material_id = m.id WHERE oi.outbound_order_id = ?` @@ -88,7 +88,7 @@ func (r *OutboundRepo) GetByID(id int64) (*model.OutboundOrder, error) { for rows.Next() { 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 { return nil, err } diff --git a/internal/service/application.go b/internal/service/application.go index fa838966..31aa10fe 100644 --- a/internal/service/application.go +++ b/internal/service/application.go @@ -36,6 +36,13 @@ func (s *ApplicationService) Create(userID int64, req *model.CreateApplicationRe if err != nil || !exists { 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{ MaterialID: item.MaterialID, Quantity: item.Quantity, diff --git a/internal/service/errors.go b/internal/service/errors.go index b509bd05..06da1b2e 100644 --- a/internal/service/errors.go +++ b/internal/service/errors.go @@ -4,4 +4,6 @@ import "errors" 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") diff --git a/internal/service/inbound.go b/internal/service/inbound.go index 42c6e63d..55b21bdd 100644 --- a/internal/service/inbound.go +++ b/internal/service/inbound.go @@ -50,6 +50,13 @@ func (s *InboundService) Create(req *model.CreateInboundRequest, operator string if !exists { 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() { return nil, ErrInvalidWarningStock } @@ -193,6 +200,13 @@ func (s *InboundService) Update(id int64, req *model.UpdateInboundRequest) (*mod // 阈值校验放在事务之外,避免为非法输入白开一个事务 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() { return nil, ErrInvalidWarningStock } diff --git a/internal/service/material.go b/internal/service/material.go index 2189b67d..69b226ce 100644 --- a/internal/service/material.go +++ b/internal/service/material.go @@ -125,8 +125,8 @@ func (s *MaterialService) GetByID(id int64) (*model.Material, error) { return m, nil } -func (s *MaterialService) List(search, category string, limit, offset int) ([]model.Material, int64, error) { - return s.repo.List(search, category, limit, offset) +func (s *MaterialService) List(search, status string, limit, offset int) ([]model.Material, int64, error) { + return s.repo.List(search, status, limit, offset) } 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 } + +// 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 +} diff --git a/internal/service/material_test.go b/internal/service/material_test.go index bea10eef..da55b61d 100644 --- a/internal/service/material_test.go +++ b/internal/service/material_test.go @@ -4,6 +4,7 @@ import ( "errors" "regexp" "testing" + "time" "github.com/DATA-DOG/go-sqlmock" "github.com/wmms/wmms/internal/model" @@ -42,3 +43,35 @@ func TestMaterialServiceRejectsInvalidCategory(t *testing.T) { 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) + } +} diff --git a/internal/service/outbound.go b/internal/service/outbound.go index 01172578..3576c93a 100644 --- a/internal/service/outbound.go +++ b/internal/service/outbound.go @@ -44,6 +44,13 @@ func (s *OutboundService) Create(req *model.CreateOutboundRequest, operator stri if !exists { 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) if err != nil { @@ -202,6 +209,13 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest, ope if !exists { 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) if err != nil { diff --git a/internal/service/stock.go b/internal/service/stock.go index 828b8c80..3c0ff1c3 100644 --- a/internal/service/stock.go +++ b/internal/service/stock.go @@ -71,6 +71,16 @@ func (s *StockService) UpdateWarningStock(materialID int64, minimumAvailable dec 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) { logs, total, err := s.stockRepo.GetLogsByMaterialID(materialID, limit, offset) if err != nil { @@ -101,7 +111,7 @@ func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int) ID: log.ID, MaterialID: log.MaterialID, ChangeType: changeType, - ChangeQuantity: quantity, + ChangeQuantity: signedChangeQuantity(log.Type, quantity), BeforeQuantity: beforeQuantity, AfterQuantity: balance, ReferenceID: log.RelatedOrderID, @@ -131,17 +141,11 @@ func (s *StockService) GetAllLogs(limit, offset int) ([]model.StockLogResponse, beforeQuantity = log.RunningBalance.Add(quantity) } - // inbound=+quantity, outbound=-quantity - changeQuantity := quantity - if log.Type != "in" { - changeQuantity = quantity.Neg() - } - responses = append(responses, model.StockLogResponse{ ID: log.ID, MaterialID: log.MaterialID, ChangeType: changeType, - ChangeQuantity: changeQuantity, + ChangeQuantity: signedChangeQuantity(log.Type, quantity), BeforeQuantity: beforeQuantity, AfterQuantity: log.RunningBalance, ReferenceID: log.RelatedOrderID, diff --git a/internal/service/stock_test.go b/internal/service/stock_test.go index f556b3af..7c250777 100644 --- a/internal/service/stock_test.go +++ b/internal/service/stock_test.go @@ -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) + } + }) + } +}