Compare commits
2 Commits
332ac1ff7a
...
84d775bc90
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84d775bc90 | ||
|
|
936a8d3b61 |
63
docs/api.md
63
docs/api.md
|
|
@ -88,11 +88,11 @@
|
|||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|----------|---------|------|----------------------------|
|
||||
| search | string | 否 | 按物料编码或名称模糊搜索 |
|
||||
| category | string | 否 | **当前未生效**,服务端接收但未用于过滤 |
|
||||
| status | string | 否 | `active`(默认)、`obsolete` 或 `all`;业务下拉应使用默认值,物料管理页使用 `all` |
|
||||
| limit | integer | 否 | 每页条数,默认 20,上限 100 |
|
||||
| offset | integer | 否 | 偏移量,默认 0 |
|
||||
|
||||
> 该接口**不按物料状态过滤**,已作废的物料同样会出现在结果里,调用方需要自己判断 `status` 字段。
|
||||
> `status` 默认 `active`,所以该接口默认只返回有效物料,已作废的物料需要显式传 `status=all` 或 `status=obsolete` 才能查到。业务单据的物料下拉正是靠这个默认值自然排除了作废物料;库存流水的检索框则要显式传 `status=all`,才能查到已作废物料的历史。
|
||||
|
||||
**响应**(`data` 为分页对象,此处只展示 `items` 内元素):
|
||||
|
||||
|
|
@ -254,7 +254,7 @@
|
|||
|
||||
作废物料。这是**软删除**:物料从库存视图(`GET /stock`、`GET /stock/warning`)中消失,但记录保留,可以恢复。
|
||||
|
||||
> 注意作废**不会**把物料从 `GET /materials` 的列表和物料下拉中移除——那个接口不过滤状态。因此作废物料依然出现在入库、出库、物料申请的物料选择器里,服务端也不会阻止给它入库。物料的可用性判断需要调用方自己看 `status` 字段。
|
||||
> `GET /materials` 默认仅返回有效物料;物料管理页应传 `status=all` 才会同时看到作废项并执行撤销。入库、出库和申领的服务端也会拒绝使用已作废物料。历史单据仍可读取作废物料的名称与编码。
|
||||
|
||||
**路径参数:**
|
||||
|
||||
|
|
@ -889,6 +889,59 @@
|
|||
|
||||
---
|
||||
|
||||
### GET /stock/:materialId/details
|
||||
|
||||
查询某个物料在各库存维度(组织 / 仓库 / 库位)上的明细。**该接口不分页**,`data` 直接是数组;物料存在但从没有过库存记录时返回空数组。
|
||||
|
||||
**路径参数:**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------------|---------|------|----------|
|
||||
| materialId | integer | 是 | 物料 ID |
|
||||
|
||||
**响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"organization_id": 1,
|
||||
"organization": "默认组织",
|
||||
"warehouse_id": 1,
|
||||
"warehouse": "默认仓库",
|
||||
"location_id": 1,
|
||||
"location": "主库位",
|
||||
"material_id": 1,
|
||||
"on_hand": "500",
|
||||
"reserved": "0",
|
||||
"locked": "0",
|
||||
"in_transit": "0",
|
||||
"updated_at": "2026-01-01T08:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------------|---------|------------------------|
|
||||
| on_hand | string | 现有库存 |
|
||||
| reserved | string | 预留数量 |
|
||||
| locked | string | 锁定数量 |
|
||||
| in_transit | string | 在途数量 |
|
||||
|
||||
> `reserved`、`locked`、`in_transit` 三个维度当前没有任何业务写入,恒为 `0`;只有 `on_hand` 会随出入库变动。作废校验会把这四项一起纳入判断。
|
||||
|
||||
**状态码:**
|
||||
|
||||
- `200 OK` — 成功
|
||||
- `400 Bad Request` — 物料 ID 非法
|
||||
- `404 Not Found` — 物料不存在
|
||||
|
||||
---
|
||||
|
||||
### GET /stock/warning
|
||||
|
||||
分页查询需要关注的物料:预警处于启用状态,并且现有库存已触达阈值。阈值为 0 的物料在库存降到 0 时也会出现在这里。
|
||||
|
|
@ -1030,9 +1083,7 @@
|
|||
|
||||
**查询参数:** `limit`(默认 20,上限 100)、`offset`(默认 0)。
|
||||
|
||||
**响应**:结构与 `GET /stock/logs` 相同。
|
||||
|
||||
> **注意**:本接口的 `change_quantity` 是**不带符号的正数**(方向由 `change_type` 区分),与 `GET /stock/logs` 的带符号约定不一致。这是当前实现的实际行为,使用前请留意。
|
||||
**响应**:结构与 `GET /stock/logs` 相同。`change_quantity` 采用带符号约定:入库为正数、出库为负数;`change_type` 仍用于展示入库/出库文字、样式和筛选。
|
||||
|
||||
**状态码:**
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 钢材' }]
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@
|
|||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" rows="3" />
|
||||
</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 class="item-row__main">
|
||||
<el-select
|
||||
|
|
@ -70,6 +70,7 @@
|
|||
:key="m.id"
|
||||
:label="`${m.material_code} - ${m.name}`"
|
||||
:value="m.id"
|
||||
:disabled="m.status === 'obsolete'"
|
||||
/>
|
||||
</el-select>
|
||||
<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)
|
||||
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]
|
||||
}
|
||||
|
|
|
|||
50
frontend/src/views/Outbound.test.js
Normal file
50
frontend/src/views/Outbound.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -39,13 +39,13 @@
|
|||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑出库单' : '新增出库单'" width="900px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="接收方">
|
||||
<el-form-item label="接收方" required>
|
||||
<el-input v-model="form.receiver" placeholder="请输入接收方" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="出库物料">
|
||||
<el-form-item label="出库物料" required>
|
||||
<div class="material-items">
|
||||
<div v-for="(item, index) in form.items" :key="index" class="item-row">
|
||||
<el-select
|
||||
|
|
@ -61,6 +61,7 @@
|
|||
:key="m.id"
|
||||
:label="`${m.material_code} - ${m.name}`"
|
||||
:value="m.id"
|
||||
:disabled="m.status === 'obsolete'"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input-number v-model="item.quantity" :min="1" />
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@
|
|||
<el-option
|
||||
v-for="m in getFilteredMaterialsForLog()"
|
||||
:key="m.id"
|
||||
:label="`${m.material_code} - ${m.name}`"
|
||||
:label="logMaterialLabel(m)"
|
||||
:value="m.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
13
internal/handler/error_message_test.go
Normal file
13
internal/handler/error_message_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?)`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user