Compare commits

...

2 Commits

Author SHA1 Message Date
文睿 张
356c8520ca docs: 补充物料批量导入接口说明
- 覆盖范围表补一行分组入口,并注明已在 §1 展开(该表标题写的是「未展开」)
- 「1. 物料接口」末尾补 GET /material-import/template 与 POST /material-import:
  模板的两张工作表与 5 列定义、按列名解析的规则、逐行校验表、成功与失败的响应
  示例、row 是 Excel 真实行号、5MB 与 20000 行限制、任意一行失败整批不入库、
  导入记录只留汇总不落逐行明细
- 状态码旁列出真实的提示文案(含后端返回的原文),便于与响应逐字对照
2026-10-10 16:41:40 +08:00
文睿 张
2f0d978dbf docs: 修正计划 Task 9 片段的 mock 路径与断言
- 路径笔误:vi.mock('../../api/materials') 改成 '../api/materials'。测试文件在
  src/views/ 下,相对它是一级上跳,原路径会指向仓库根的 api/ 目录
- 补上 materialsApi 替身的具体两行代码,原来只有文字描述、照抄不了
- 两个用例在触发导入前加 fetchMaterials.mockClear():页面 onMounted 就会拉一次
  列表,不清的话失败用例的 not.toHaveBeenCalled() 必然失败,成功用例的断言也没区分度
- 成功用例的断言从 toHaveBeenCalled() 收紧成 toHaveBeenCalledWith(1, '', 'all')
- 两个用例包进 describe('批量导入'),并给出 beforeEach 里重置替身的写法

验证:从 Task 9 之前的测试文件出发,按文档插入替身、追加用例块,实跑 24 个用例通过。
2026-10-10 16:31:39 +08:00
2 changed files with 109 additions and 2 deletions

View File

@ -143,6 +143,7 @@
| 物料申请 | `/applications`、`/applications/my` | 需登录 |
| 操作日志 | `/logs`、`/logs/materials/:materialId` | 需登录 |
| 图片上传 | `POST /upload` | 需登录 |
| 物料批量导入 | `GET /material-import/template`、`POST /material-import` | 需登录,见 §1 |
---
@ -388,6 +389,91 @@
---
### GET /material-import/template
下载批量导入用的 xlsx 模板。模板随代码实时生成、不落盘,所以下载到的列定义永远与当前校验规则一致。
**请求:** 无参数。
**响应:** xlsx 文件流,`Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,文件名为 `物料导入模板.xlsx`。
模板含两张工作表:
| 工作表 | 内容 |
|--------|------|
| 填写说明 | 字段、是否必填、示例、填写规则 |
| 物料数据 | 第 1 行是表头,数据从第 2 行开始 |
`物料数据` 的表头是 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时**按列名定位,不按列位置**:
- 列顺序可以随意调整;表头允许带首尾空格和末尾的 `*` 必填标记;不认识的列忽略;
- 必填列 `物料名称`、`大类`、`小类` 缺任何一列,判为模板不合法并返回「模板格式不正确,请重新下载最新模板」;
- 选填列 `规格`、`单位` 可以整列删掉,取值按空处理。
分类**按名称**填写,后端按「大类名 + 小类名」在本公司内组合匹配(小类名在一个公司内会重名,例如多个大类下都有「其他」,所以必须两级组合)。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。
### POST /material-import
上传填好的 xlsx,**校验通过即在同一个请求内入库**——没有「预览确认」环节,也没有暂存任务 ID。
**请求:** `multipart/form-data`,文件字段名 `file`。仅支持 `.xlsx`,单文件不超过 5 MB,数据区不超过 20,000 行(不含表头)。
**逐行校验规则:**
| 列 | 规则 |
|----|------|
| 物料名称 | 必填,去首尾空格后非空,≤ 200 字 |
| 规格 | 选填,≤ 200 字 |
| 单位 | 选填;留空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 |
| 大类 | 必填,必须是当前公司已启用的顶层分类名称 |
| 小类 | 必填,必须是该大类下已启用的小类名称 |
| 编码规则 | 该小类必须存在启用的编码规则(`code_rules.status = 'active'`) |
物料编码由系统按小类编码规则生成,**不接受模板里的编码列**。同一行可能产生多条原因,用中文分号连接。
**成功响应 `201 Created`:**
```json
{
"code": 0,
"message": "成功导入 200 条物料",
"data": { "total_rows": 200, "success_rows": 200, "failed_rows": 0, "errors": [] }
}
```
**校验失败响应 `400 Bad Request`**(HTTP 状态是 400,但 `data` 里带完整明细):
```json
{
"code": 400,
"message": "有 3 行校验未通过,本次未导入任何数据",
"data": {
"total_rows": 200, "success_rows": 0, "failed_rows": 3,
"errors": [
{ "row": 3, "data": { "name": "", "spec": "M8x30", "unit": "个", "major": "机械结构类", "minor": "标准件" }, "reason": "物料名称不能为空" }
]
}
}
```
`row` 是 **Excel 里的真实行号**(从 1 开始、含表头行),用户可以直接对着 Excel 定位;`data` 是该行的原始内容。
**状态码:**
- `201 Created` — 全部行通过并入库
- `400 Bad Request` — 存在校验未通过的行(整批不入库);模板不合法(「模板格式不正确,请重新下载最新模板」)、行数超限(「导入行数超过 20000 行上限」)、未选文件(「请选择要导入的 xlsx 文件」)、文件超过 5 MB(「文件超过 5MB 上限」)
- `500 Internal Server Error` — 入库失败,事务已回滚,整批未落库
**行为说明:**
- **任意一行校验失败,整批不入库**,响应中返回全部逐行错误;重新上传前请先修正后重试。
- 整批在**一个事务**内完成:按小类锁编码规则 → 分配编码 → 分批插入物料(每批 500 行)→ 写审计日志;任一步失败整体回滚。
- 每次导入(无论成功或失败)都会写一条 `material_import_logs` 记录:操作人、公司、时间、文件名、总行数、成功数、失败数、状态、失败原因摘要(前 5 行行号)。**逐行错误明细只随响应返回,不落库。**
- 首版只支持**新增**物料:不做更新,也不自动创建分类或单位。
- **不做重复检测**:同一份文件重复上传会真的导入两遍(生成不同的编码)。
---
## 2. 入库接口
入库是物料**进入库存视野**的唯一入口:物料的第一行库存记录和第一个预警阈值都在这里产生。入库成功后,服务端会通过 WebSocket 广播库存变更。

View File

@ -2367,6 +2367,14 @@ git commit -m "feat: 物料导入结果弹窗组件"
在 `frontend/src/views/Materials.test.js` 末尾追加(沿用文件里已有的 `mountMaterials()`、mock 结构):
```js
describe('批量导入', () => {
beforeEach(() => {
materialsApi.importFile.mockReset()
materialsApi.downloadImportTemplate.mockReset()
materialsStore.fetchMaterials.mockReset()
vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
})
test('批量导入成功后提示成功并刷新列表', async () => {
materialsApi.importFile.mockResolvedValue({
data: { total_rows: 2, success_rows: 2, failed_rows: 0, errors: [] }
@ -2374,6 +2382,8 @@ git commit -m "feat: 物料导入结果弹窗组件"
const wrapper = mountMaterials()
await flushPromises()
// 挂载时 onMounted 已经拉过一次列表,清掉才能断言“导入后又拉了一次”
materialsStore.fetchMaterials.mockClear()
await setupState(wrapper).handleImportFile({
target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' }
@ -2381,7 +2391,7 @@ git commit -m "feat: 物料导入结果弹窗组件"
await flushPromises()
expect(materialsApi.importFile).toHaveBeenCalled()
expect(materialsStore.fetchMaterials).toHaveBeenCalled()
expect(materialsStore.fetchMaterials).toHaveBeenCalledWith(1, '', 'all')
expect(ElMessage.success).toHaveBeenCalledWith('成功导入 2 条物料')
wrapper.unmount()
})
@ -2402,6 +2412,7 @@ git commit -m "feat: 物料导入结果弹窗组件"
const wrapper = mountMaterials()
await flushPromises()
materialsStore.fetchMaterials.mockClear()
await setupState(wrapper).handleImportFile({
target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' }
@ -2412,9 +2423,19 @@ git commit -m "feat: 物料导入结果弹窗组件"
expect(materialsStore.fetchMaterials).not.toHaveBeenCalled()
wrapper.unmount()
})
})
```
**注意:** `Materials.test.js` 目前 mock 的是 `vi.mock('../stores/materials')` 与 `vi.mock('../../api/masterData')`,需要在顶部补 `vi.mock('../../api/materials')`,导出 `materialsApi`(含 `importFile`/`downloadImportTemplate` 两个 `vi.fn()`);`ElMessage` 的 spy 若尚未建立,按文件里既有的 `vi.mock('element-plus')` 方式补齐。
**注意:** `Materials.test.js` 里已 mock 的是 `vi.mock('../stores/materials')` 与 `vi.mock('../api/masterData')`——路径相对 `src/views/`,是一级上跳。顶部要补两行(与文件里其它替身放在一起):
```js
const materialsApi = vi.hoisted(() => ({ importFile: vi.fn(), downloadImportTemplate: vi.fn() }))
vi.mock('../api/materials', () => ({ materialsApi }))
```
`ElMessage` 不需要 mock 整个模块,文件里既有的写法是 `vi.spyOn(ElMessage, 'success').mockImplementation(() => {})`,新 describe 的 `beforeEach` 里照做即可。
**另外:** 页面 `onMounted` 就会调 `fetchMaterials()`,所以两个用例在触发导入前都要 `mockClear()`——不清的话失败用例的 `not.toHaveBeenCalled()` 必然失败,成功用例的 `toHaveBeenCalled()` 也没有区分度。
- [ ] **Step 2: 跑测试确认失败**