material_manage_system/docs/superpowers/plans/2026-10-10-material-bulk-import.md
文睿 张 f34803e1a2 docs: 修正物料导入计划中 Task 5/6 的测试片段笔误
- Task 5:expectImportLog 的行数参数显式转 int64。database/sql 会把实参归一成
  driver.Value(int→int64),sqlmock 按类型比对,传 Go int 必然匹配不上
- Task 5:expectImportLog 补 fileName 参数,模板非法那条用例传的文件名与其它用例不同
- Task 5:事务内 SQL 的期望顺序改为「推进 code_rules → 分批 INSERT」,
  与已提交的 ImportTx 实现(material.go:480 在 :485 之前)一致
- Task 6:测试片段补上缺失的 strings import

按修正后的片段替换 internal/service/material_import_test.go 实跑,5 个用例全通过。
2026-10-10 15:42:06 +08:00

2627 lines
98 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 物料批量导入实现计划
> **For agentic workers:** 使用 `superpowers:executing-plans` 按任务执行;步骤使用复选框(`- [ ]`)追踪。
**Goal:** 物料页面提供「下载模板」与「批量导入」两个入口,用户上传填好的 .xlsx 后,后端逐行校验并在同一个请求内事务入库,失败时整批不入库并返回逐行错误明细。
**Architecture:** 新增 `internal/service/material_import*.go` 负责 xlsx 解析、模板生成与逐行校验;`internal/repository/material.go` 新增批量入库方法(单事务内锁编码规则、分配编码、分批多值插入);`internal/handler/material_import.go` 暴露两个 HTTP 接口。每次导入(无论成败)写一条 `material_import_logs` 记录;逐行错误只在响应里返回,不落库。
**Tech Stack:** Go 1.21、Gin、MySQL 8.0、`github.com/xuri/excelize/v2 v2.8.1`、sqlmock、Vue 3、Pinia、Element Plus、Vitest。
**Spec:** 本计划正文「设计约定」一节即本次交付的完整规格(源自需求方给出的《物料批量导入功能设计(最终版)》及后续澄清)。用户故事 `docs/MMS-用户故事-v2.3.md` 的 BAT-001、5.8.1 为背景需求。
## Global Constraints
- Excel 库固定 `github.com/xuri/excelize/v2 v2.8.1`:**不得升级到 v2.9+**,v2.9 起要求 Go ≥1.23,而 `Dockerfile` 使用 `golang:1.21-alpine`(已实测 v2.8.1 可编译、可 `OpenReader`/`WriteToBuffer`)。
- 文件限制:仅 `.xlsx`,≤ 5 MB,数据区 ≤ 20,000 行(不含表头)。
- 首版仅支持**新增**物料,不做更新、不做分类/单位的自动创建。
- 物料编码由系统按小类编码规则生成,**不接受 Excel 中的编码列**;小类未配置启用的编码规则时该行报错。
- 任意一行校验失败 → 整批不入库,返回全部逐行错误。
- 所有接口必须挂 `middleware.AuthMiddleware(), middleware.OrganizationMiddleware()`,公司取服务端上下文,忽略请求体伪造值。
- 注释、提交信息、用户可见文案全部用中文(与仓库既有风格一致)。
-
---
## 设计约定
### 1. 流程
下载模板 → 填写 → 上传 → 后端解析 → 逐行校验 → **全部通过即在同一个请求内事务入库** → 返回结果。
**已确认去掉「预览确认」环节**:不再有暂存任务 ID、不再有确认接口,也不存在"确认时重新校验"。校验失败时整批不入库,错误明细随响应返回。
### 2. 模板
两个工作表:
- `填写说明`:字段名、是否必填、示例、填写规则。
- `物料数据`:第 1 行表头,数据从第 2 行开始。
模板生成的表头是 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时**按列名定位,不按列位置**:
- 必填列 `物料名称`、`大类`、`小类` 缺任何一列 → 报「模板格式不正确,请重新下载最新模板」;
- 选填列 `规格`、`单位` 可以整列删掉,取值按空处理;
- 列顺序可以随意调整;
- 表头允许带首尾空格与末尾的 `*` 必填标记;不认识的列忽略。
「单元格留空」和「整列缺失」是两回事:规格与单位留空属于合法的选填,不会报错;只有必填列缺失才判定模板不合法。
分类**按名称填写**,后端按「大类名 + 小类名」组合在本公司内匹配(小类名在本公司内会重名,如「其他」在 A07/B07/C03/D04/F05 都叫「其他」,因此必须两级组合)。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。
### 3. 接口
| 方法 | 路径 | 说明 |
| ---- | ------------------------------- | -------------------------------------- |
| GET | `/api/material-import/template` | 下载 xlsx 模板,返回文件流 |
| POST | `/api/material-import` | `multipart/form-data`,字段名 `file`,上传即入库 |
**路由必须挂在独立前缀 `/material-import`**:`materials` 组下已有 `GET /:id`,gin 的路由树不允许同一段既有静态前缀又有 `:id` 通配,把 `/materials/import/...` 加进该组会直接 panic。
成功响应 `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 定位。
### 4. 校验规则(逐行)
| 列 | 规则 |
| ---- | ------------------------------------------- |
| 物料名称 | 必填,去首尾空格后非空,≤ 200 字 |
| 规格 | 选填,≤ 200 字 |
| 单位 | 选填;为空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 |
| 大类 | 必填,必须是当前公司已启用的顶层分类名称 |
| 小类 | 必填,必须是该大类下已启用的小类名称 |
| 编码规则 | 该小类必须存在 `status='active'` 的 `code_rules` 记录 |
同一行可产生多条原因,用中文分号连接。
### 5. 编码生成与并发
- 入库事务内按 `category_id` **升序**依次 `SELECT ... FOR UPDATE` 锁定 `code_rules` 行(升序加锁避免多事务互相等待造成死锁)。
- 每个小类一次性取出已占用编码集合(`SELECT material_code FROM materials WHERE organization_id=? AND material_code LIKE ?`),在内存里顺序分配,跳过已占用号(库中可能存在手工录入、落在号段内的编码)。
- 分配完成后 `UPDATE code_rules SET next_sequence = ?`。
- 校验阶段不生成编码,预览/响应里也不承诺最终编码。
### 6. 事务与记录
- 整批入库在**一个事务**内:分配编码 → 分批多值 INSERT 物料(每批 500 行)→ 回填 ID → 批量写 `material_logs`(每条物料一条 `CREATE` 审计记录,与单条新增保持一致)。
- 任一行插入失败则整体回滚。
- 每次导入(成功或失败)都写一条 `material_import_logs`:操作人、公司、时间、文件名、总行数、成功数、失败数、状态、失败原因摘要。**逐行错误明细不落库**,只在响应里返回。
### 7. 权限
沿用物料新增的权限口径:仅要求登录 + 当前公司可访问(与 `POST /api/materials` 相同,不额外要求 `super_admin`)。公司取自 `X-Organization-ID` 经中间件校验后的上下文。
### 8. 防止重复提交
去掉预览与暂存后没有服务端幂等键(设计第 7 点原文的「任务 ID 作为幂等键」随预览一起取消),靠两道前端约束:
- 保存互斥:整个上传流程包在 `runWithSaving` 里,飞行期间禁止切换公司;
- 入口状态:`importing` 标志守住 `triggerImport`,上传期间再点不会重新选文件;`handleImportFile` 在发起请求前就把 input 的 `value` 清空,避免同一个文件连续触发两次 `change`。
后端不做文件级去重:同一份文件重复上传会真的导入两遍(编码不同)。这是首版明确接受的取舍,见文末「已知取舍」。
---
## 文件结构
**新建:**
| 文件 | 职责 |
| ----------------------------------------------------------- | ----------------------------- |
| `internal/model/material_import.go` | 导入相关模型:行、错误、结果、日志 |
| `internal/service/material_import_excel.go` | xlsx 模板生成与解析(纯 IO,不碰数据库) |
| `internal/service/material_import.go` | 导入编排:解析 → 校验 → 入库 → 写日志 |
| `internal/repository/material_import.go` | `material_import_logs` 的写入与查询 |
| `internal/handler/material_import.go` | 两个 HTTP handler |
| `scripts/upgrades/2026-10-10-material-bulk-import.sql` | 已部署库的增量升级脚本 |
| `frontend/src/utils/exportCsv.js` | 前端生成 CSV(UTF-8 BOM) |
| `frontend/src/components/business/MaterialImportResult.vue` | 导入结果弹窗(摘要 + 错误表格 + 分页 + 导出) |
**修改:**
| 文件 | 改动 |
| ------------------------------------- | -------------------------------- |
| `go.mod` / `go.sum` | 新增 `excelize/v2 v2.8.1` |
| `scripts/init.sql` | 新增 `material_import_logs` 表 |
| `internal/repository/material.go` | 新增 `ImportTx`(批量分配编码 + 插入) |
| `internal/repository/material_log.go` | 新增 `CreateBatchTx` |
| `internal/handler/api.go` | 注册 `/material-import` 路由组 |
| `pkg/response/response.go` | 新增 `ErrorWithData`(带 data 的错误响应) |
| `frontend/src/api/materials.js` | 新增导入上传与模板下载 |
| `frontend/src/views/Materials.vue` | 顶部两个按钮 + 上传 + 结果弹窗挂载 |
| `internal/handler/routes_test.go` | 补两条路由断言 |
| `docs/api.md` | 补接口文档 |
**测试:**
| 文件 | 覆盖 |
| --------------------------------------------------------------- | --------------------- |
| `internal/service/material_import_excel_test.go` | 模板生成、解析、表头不符、超行数、空行跳过 |
| `internal/service/material_import_test.go` | 逐行校验各分支、整批不入库、成功入库 |
| `internal/repository/material_import_test.go` | 日志写入 |
| `internal/handler/material_import_test.go` | 路由权限、响应结构与状态码 |
| `frontend/src/utils/exportCsv.test.js` | CSV 转义与 BOM |
| `frontend/src/components/business/MaterialImportResult.test.js` | 摘要渲染、错误表格、导出调用 |
---
## Task 1: 数据库表与模型
**Files:**
- Create: `internal/model/material_import.go`
- Create: `scripts/upgrades/2026-10-10-material-bulk-import.sql`
- Modify: `scripts/init.sql`(在 `code_rules` 建表语句之后追加)
**Interfaces:**
- Produces: `model.MaterialImportRow`、`model.MaterialImportError`、`model.MaterialImportResult`、`model.MaterialImportLog`
- Produces: 表 `material_import_logs`
- [ ] **Step 1: 在 init.sql 增加建表语句**
在 `scripts/init.sql` 的 `code_rules` 那行之后插入:
```sql
CREATE TABLE material_import_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, operator VARCHAR(100) NOT NULL, file_name VARCHAR(255) NOT NULL, total_rows INT NOT NULL DEFAULT 0, success_rows INT NOT NULL DEFAULT 0, failed_rows INT NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL, failure_reason VARCHAR(1000) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material_import_logs_org_created(organization_id,created_at), FOREIGN KEY(organization_id) REFERENCES organizations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
- [ ] **Step 2: 写增量升级脚本**
创建 `scripts/upgrades/2026-10-10-material-bulk-import.sql`,内容与上一步完全相同的 `CREATE TABLE material_import_logs`,并在末尾追加迁移登记:
```sql
INSERT INTO schema_migrations(version,status,completed_at,details) VALUES('2026-10-10-material-bulk-import','completed',NOW(),JSON_OBJECT('version','2026-10-10-material-bulk-import','source','upgrade'));
```
- [ ] **Step 3: 写模型**
创建 `internal/model/material_import.go`:
```go
package model
import "time"
// MaterialImportRow 是解析后的一行原始数据。Row 保留 Excel 真实行号(从 1
// 开始、含表头行),校验失败时用它定位,用户可直接对着 Excel 找。
type MaterialImportRow struct {
Row int
Name string
Spec string
Unit string
MajorName string
MinorName string
}
// Data 返回该行的原始内容,用于错误明细里的"原始数据"。
func (r MaterialImportRow) Data() map[string]string {
return map[string]string{
"name": r.Name,
"spec": r.Spec,
"unit": r.Unit,
"major": r.MajorName,
"minor": r.MinorName,
}
}
// MaterialImportError 是一行的失败原因,Reason 可能包含多条,用中文分号连接。
type MaterialImportError struct {
Row int `json:"row"`
Data map[string]string `json:"data"`
Reason string `json:"reason"`
}
// MaterialImportResult 是一次导入的返回值。校验失败时 SuccessRows 为 0、
// Errors 非空;成功时 Errors 为空数组(不是 null)。
type MaterialImportResult struct {
TotalRows int `json:"total_rows"`
SuccessRows int `json:"success_rows"`
FailedRows int `json:"failed_rows"`
Errors []MaterialImportError `json:"errors"`
}
// MaterialImportLog 是一次导入的持久化记录,成功或失败都会写一条。
type MaterialImportLog struct {
ID int64 `json:"id"`
OrganizationID int64 `json:"organization_id"`
Operator string `json:"operator"`
FileName string `json:"file_name"`
TotalRows int `json:"total_rows"`
SuccessRows int `json:"success_rows"`
FailedRows int `json:"failed_rows"`
Status string `json:"status"`
FailureReason string `json:"failure_reason"`
CreatedAt time.Time `json:"created_at"`
}
const (
MaterialImportStatusSuccess = "success"
MaterialImportStatusFailed = "failed"
)
```
- [ ] **Step 4: 编译验证**
Run: `go build ./...`
Expected: 通过。
- [ ] **Step 5: 重建本地数据库(旧库无增量升级路径)**
Run: `docker compose down -v && docker compose up -d db`
说明:本地库只认 `init.sql`,不执行 `upgrades/` 脚本;这一步之后表才会存在。
- [ ] **Step 6: Commit**
```bash
git add scripts/init.sql scripts/upgrades/2026-10-10-material-bulk-import.sql internal/model/material_import.go
git commit -m "feat: 新增物料导入记录表与模型"
```
---
## Task 2: Excel 模板生成与解析
**Files:**
- Create: `internal/service/material_import_excel.go`
- Test: `internal/service/material_import_excel_test.go`
- Modify: `go.mod`、`go.sum`
**Interfaces:**
- Produces: `BuildMaterialImportTemplate() ([]byte, error)`
- Produces: `ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error)`
- Produces: 错误变量 `ErrImportTemplateInvalid`、`ErrImportTooManyRows`
- [ ] **Step 1: 引入 excelize v2.8.1**
```bash
go get github.com/xuri/excelize/v2@v2.8.1
```
Run: `grep excelize go.mod`
Expected: `github.com/xuri/excelize/v2 v2.8.1`(**不可为 v2.9+**)
- [ ] **Step 2: 写失败的测试**
创建 `internal/service/material_import_excel_test.go`:
```go
package service
import (
"bytes"
"errors"
"testing"
"github.com/xuri/excelize/v2"
)
func TestParseMaterialImportFileReadsRowsWithExcelRowNumbers(t *testing.T) {
data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
{"", "", "", "", ""}, // 整行空,应被跳过
{"急停按钮", "XB2", "个", "电气元器件类", "成品件"},
})
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(rows) != 2 {
t.Fatalf("rows = %d, want 2", len(rows))
}
if rows[0].Row != 2 || rows[0].Name != "内六角螺栓" || rows[0].MinorName != "标准件" {
t.Fatalf("first row = %+v", rows[0])
}
// 第 3 行是空行被跳过,所以第二条数据是 Excel 第 4 行
if rows[1].Row != 4 || rows[1].Name != "急停按钮" {
t.Fatalf("second row = %+v", rows[1])
}
}
func TestParseMaterialImportFileKeepsRowsWithAnyContent(t *testing.T) {
// 名称填了、其余为空:不是空行,必须保留交给校验去报错
data := buildTestWorkbook(t, [][]string{
{"只有名称", "", "", "", ""},
})
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(rows) != 1 || rows[0].Name != "只有名称" {
t.Fatalf("rows = %+v", rows)
}
}
func TestParseMaterialImportFileRejectsMissingRequiredColumn(t *testing.T) {
// 只有「物料名称」和「规格」,缺必填的大类、小类
data := buildTestWorkbookWithHeaders(t, []string{"物料名称", "规格"}, [][]string{
{"内六角螺栓", "M8x30"},
})
_, err := ParseMaterialImportFile(bytes.NewReader(data))
if !errors.Is(err, ErrImportTemplateInvalid) {
t.Fatalf("err = %v, want ErrImportTemplateInvalid", err)
}
}
func TestParseMaterialImportFileAcceptsMissingOptionalColumnsAndReorderedColumns(t *testing.T) {
// 删掉「规格」,并把「单位」挪到「物料名称」前面
headers := []string{"单位", "物料名称", "大类", "小类"}
data := buildTestWorkbookWithHeaders(t, headers, [][]string{
{"个", "内六角螺栓", "机械结构类", "标准件"},
})
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(rows) != 1 {
t.Fatalf("rows = %d, want 1", len(rows))
}
if rows[0].Name != "内六角螺栓" || rows[0].Unit != "个" || rows[0].MinorName != "标准件" {
t.Fatalf("row = %+v", rows[0])
}
if rows[0].Spec != "" {
t.Fatalf("缺失的规格列应解析为空,得到 %q", rows[0].Spec)
}
}
func TestParseMaterialImportFileIgnoresUnknownColumns(t *testing.T) {
headers := []string{"物料名称", "备注", "大类", "小类"}
data := buildTestWorkbookWithHeaders(t, headers, [][]string{
{"内六角螺栓", "与导入无关", "机械结构类", "标准件"},
})
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(rows) != 1 || rows[0].Name != "内六角螺栓" {
t.Fatalf("rows = %+v", rows)
}
}
func TestParseMaterialImportFileRejectsTooManyRows(t *testing.T) {
rows := make([][]string, 0, MaterialImportMaxRows+1)
for i := 0; i < MaterialImportMaxRows+1; i++ {
rows = append(rows, []string{"物料", "", "", "机械结构类", "标准件"})
}
data := buildTestWorkbook(t, rows)
_, err := ParseMaterialImportFile(bytes.NewReader(data))
if !errors.Is(err, ErrImportTooManyRows) {
t.Fatalf("err = %v, want ErrImportTooManyRows", err)
}
}
func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) {
data, err := BuildMaterialImportTemplate()
if err != nil {
t.Fatalf("build template: %v", err)
}
f, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
t.Fatalf("open template: %v", err)
}
defer f.Close()
if idx, _ := f.GetSheetIndex(MaterialImportSheetData); idx < 0 {
t.Fatal("模板缺少「物料数据」工作表")
}
if idx, _ := f.GetSheetIndex(MaterialImportSheetGuide); idx < 0 {
t.Fatal("模板缺少「填写说明」工作表")
}
headers, err := f.GetRows(MaterialImportSheetData)
if err != nil {
t.Fatalf("read rows: %v", err)
}
if len(headers) == 0 || headers[0][0] != "物料名称" || headers[0][4] != "小类" {
t.Fatalf("表头 = %v", headers)
}
// 模板必须能被自己的解析器接受(表头一致)
if _, err := ParseMaterialImportFile(bytes.NewReader(data)); err != nil {
t.Fatalf("模板无法被解析器接受: %v", err)
}
}
// buildTestWorkbook 用标准表头 + 给定数据行构造一个 xlsx 字节流。
func buildTestWorkbook(t *testing.T, dataRows [][]string) []byte {
t.Helper()
return buildTestWorkbookWithHeaders(t, materialImportHeaders, dataRows)
}
// buildTestWorkbookWithHeaders 允许指定表头,用来覆盖缺列、乱序、多余列的解析分支。
func buildTestWorkbookWithHeaders(t *testing.T, headers []string, dataRows [][]string) []byte {
t.Helper()
f := excelize.NewFile()
defer f.Close()
f.SetSheetName("Sheet1", MaterialImportSheetData)
for i, header := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
f.SetCellValue(MaterialImportSheetData, cell, header)
}
for r, row := range dataRows {
for c, value := range row {
cell, _ := excelize.CoordinatesToCellName(c+1, r+2)
f.SetCellValue(MaterialImportSheetData, cell, value)
}
}
buf, err := f.WriteToBuffer()
if err != nil {
t.Fatalf("write workbook: %v", err)
}
return buf.Bytes()
}
```
- [ ] **Step 3: 跑测试确认失败**
Run: `go test ./internal/service/ -run TestParseMaterialImportFile -v`
Expected: FAIL / 编译错误 `undefined: ParseMaterialImportFile`
- [ ] **Step 4: 实现模板生成与解析**
创建 `internal/service/material_import_excel.go`:
```go
package service
import (
"errors"
"fmt"
"io"
"strings"
"github.com/wmms/wmms/internal/model"
"github.com/xuri/excelize/v2"
)
const (
// MaterialImportSheetData 是填写数据的表名,「物料数据」。
MaterialImportSheetData = "物料数据"
// MaterialImportSheetGuide 是填写说明的表名。
MaterialImportSheetGuide = "填写说明"
// MaterialImportMaxRows 是数据区最大行数(不含表头)。
MaterialImportMaxRows = 20000
// MaterialImportMaxBytes 是上传文件大小上限。
MaterialImportMaxBytes = 5 << 20
)
var (
// ErrImportTemplateInvalid 表示上传的文件不是本功能的模板。
ErrImportTemplateInvalid = errors.New("模板格式不正确,请重新下载最新模板")
// ErrImportTooManyRows 表示数据行数超出上限。
ErrImportTooManyRows = fmt.Errorf("导入行数超过 %d 行上限", MaterialImportMaxRows)
)
// materialImportHeaders 是生成模板时的列顺序(含选填列)。
var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类", "小类"}
// materialImportRequiredHeaders 是表头里必须存在的列;缺任何一列都判定为
// 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。
var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"}
// BuildMaterialImportTemplate 生成导入模板:一张填写说明表 + 一张空白数据表。
func BuildMaterialImportTemplate() ([]byte, error) {
f := excelize.NewFile()
defer f.Close()
if err := f.SetSheetName("Sheet1", MaterialImportSheetData); err != nil {
return nil, err
}
if _, err := f.NewSheet(MaterialImportSheetGuide); err != nil {
return nil, err
}
// 数据表放在最前面,用户打开文件即停在填写页。
idx, err := f.GetSheetIndex(MaterialImportSheetData)
if err != nil {
return nil, err
}
f.SetActiveSheet(idx)
guide := [][]string{
{"字段", "是否必填", "示例", "填写规则"},
{"物料名称", "必填", "内六角螺栓", "最长 200 字"},
{"规格", "选填", "M8x30", "最长 200 字"},
{"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"},
{"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称"},
{"小类", "必填", "标准件", "填该大类下已启用的小类名称,且该小类必须已配置编码规则"},
{"", "", "", ""},
{"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"},
}
for r, row := range guide {
for c, value := range row {
cell, err := excelize.CoordinatesToCellName(c+1, r+1)
if err != nil {
return nil, err
}
if err := f.SetCellValue(MaterialImportSheetGuide, cell, value); err != nil {
return nil, err
}
}
}
for i := range guide[0] {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
_ = f.SetCellStyle(MaterialImportSheetGuide, cell, cell, guideHeaderStyle(f))
}
_ = f.SetColWidth(MaterialImportSheetGuide, "A", "A", 14)
_ = f.SetColWidth(MaterialImportSheetGuide, "B", "D", 24)
for i, header := range materialImportHeaders {
cell, err := excelize.CoordinatesToCellName(i+1, 1)
if err != nil {
return nil, err
}
if err := f.SetCellValue(MaterialImportSheetData, cell, header); err != nil {
return nil, err
}
if err := f.SetCellStyle(MaterialImportSheetData, cell, cell, guideHeaderStyle(f)); err != nil {
return nil, err
}
}
_ = f.SetColWidth(MaterialImportSheetData, "A", "E", 18)
buf, err := f.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// guideHeaderStyle 返回表头加粗样式;样式创建失败不影响模板可用,调用方忽略错误。
func guideHeaderStyle(f *excelize.File) int {
style, err := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}})
if err != nil {
return 0
}
return style
}
// ParseMaterialImportFile 解析上传的 xlsx,返回带 Excel 行号的数据行。
// 列按表头名定位:列顺序可调、选填列可整列删掉、多余列忽略;只有缺少必填列
// 或缺少「物料数据」表时才判定模板不合法。
// 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。
func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) {
f, err := excelize.OpenReader(r)
if err != nil {
return nil, ErrImportTemplateInvalid
}
defer f.Close()
idx, err := f.GetSheetIndex(MaterialImportSheetData)
if err != nil || idx < 0 {
return nil, ErrImportTemplateInvalid
}
all, err := f.GetRows(MaterialImportSheetData)
if err != nil {
return nil, err
}
if len(all) == 0 {
return nil, ErrImportTemplateInvalid
}
columns := materialImportColumnIndex(all[0])
for _, required := range materialImportRequiredHeaders {
if _, ok := columns[required]; !ok {
return nil, ErrImportTemplateInvalid
}
}
rows := make([]model.MaterialImportRow, 0, len(all)-1)
for i, raw := range all[1:] {
row := model.MaterialImportRow{
Row: i + 2, // Excel 行号:表头占第 1 行
Name: materialImportColumnValue(raw, columns, "物料名称"),
Spec: materialImportColumnValue(raw, columns, "规格"),
Unit: materialImportColumnValue(raw, columns, "单位"),
MajorName: materialImportColumnValue(raw, columns, "大类"),
MinorName: materialImportColumnValue(raw, columns, "小类"),
}
if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" {
continue
}
rows = append(rows, row)
}
if len(rows) > MaterialImportMaxRows {
return nil, ErrImportTooManyRows
}
return rows, nil
}
// materialImportColumnIndex 把表头行转成「列名 → 列号」。允许用户在表头里加
// 首尾空格、末尾加 * 标记必填;不认识的列忽略,重复列名取第一列。
func materialImportColumnIndex(header []string) map[string]int {
index := make(map[string]int, len(header))
for i, cell := range header {
name := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(cell), "*"))
if name == "" {
continue
}
if _, exists := index[name]; !exists {
index[name] = i
}
}
return index
}
// materialImportColumnValue 按列名取值;选填列被整列删掉时返回空串。
func materialImportColumnValue(row []string, columns map[string]int, name string) string {
index, ok := columns[name]
if !ok {
return ""
}
return materialImportCell(row, index)
}
func materialImportCell(row []string, index int) string {
if index >= len(row) {
return ""
}
return strings.TrimSpace(row[index])
}
```
- [ ] **Step 5: 跑测试确认通过**
Run: `go test ./internal/service/ -run 'MaterialImport(File|Template)' -v`
Expected: PASS(全部 4 个函数)
- [ ] **Step 6: Commit**
```bash
git add go.mod go.sum internal/service/material_import_excel.go internal/service/material_import_excel_test.go
git commit -m "feat: 物料导入模板生成与 xlsx 解析"
```
---
## Task 3: 批量入库(编码分配 + 事务插入)
**Files:**
- Modify: `internal/repository/material.go`(追加方法,不改动已有函数)
- Modify: `internal/repository/material_log.go`(追加 `CreateBatchTx`)
- Test: `internal/repository/material_import_test.go`
**Interfaces:**
- Consumes: `model.Material`、`sqlExecer`(已存在)、`lockCodeRule`(已存在,`material.go:84`)
- Produces: `(*MaterialRepo) ImportTx(tx *sql.Tx, organizationID int64, materials []*model.Material) error`
- Produces: `(*MaterialLogRepo) CreateBatchTx(tx *sql.Tx, logs []*model.MaterialLog) error`
- Produces: `materialImportBatchSize = 500`
- [ ] **Step 1: 写失败的测试**
创建 `internal/repository/material_import_test.go`:
```go
package repository
import (
"fmt"
"regexp"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
const importLockRuleQuery = `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE`
func newImportMock(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sql mock: %v", err)
}
previous := database.DB
database.DB = db
return mock, func() {
database.DB = previous
db.Close()
}
}
func TestMaterialRepoImportTxAllocatesCodesAndInsertsOneByOne(t *testing.T) {
mock, cleanup := newImportMock(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}).
AddRow(5, 1, 12, "A01", 4, 201))
// 已占用编码探测:A010201 已被手工录入占用,应跳到 A010202
mock.ExpectQuery(regexp.QuoteMeta(`SELECT material_code FROM materials WHERE organization_id = ? AND material_code LIKE ?`)).
WithArgs(int64(1), "A01%").
WillReturnRows(sqlmock.NewRows([]string{"material_code"}).AddRow("A010201"))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).
WithArgs(int64(204), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1))
// 两行折叠成一条多值 INSERT
mock.ExpectExec(regexp.QuoteMeta(insertMaterialBatchStmt)).
WithArgs(
int64(1), "A010202", "螺栓", nil, "", nil, int64(12), nil, "个", "active", 1,
int64(1), "A010203", "螺母", nil, "", nil, int64(12), nil, "个", "active", 1,
).
WillReturnResult(sqlmock.NewResult(31, 2))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (?,?)`)).
WithArgs(int64(1), "A010202", "A010203").
WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).
AddRow(31, "A010202").AddRow(32, "A010203"))
tx, err := database.DB.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
categoryID := int64(12)
materials := []*model.Material{
{OrganizationID: 1, Name: "螺栓", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1},
{OrganizationID: 1, Name: "螺母", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1},
}
if err := NewMaterialRepo().ImportTx(tx, 1, materials); err != nil {
t.Fatalf("import: %v", err)
}
if materials[0].MaterialCode != "A010202" || materials[0].ID != 31 {
t.Fatalf("first = %+v", materials[0])
}
if materials[1].MaterialCode != "A010203" || materials[1].ID != 32 {
t.Fatalf("second = %+v", materials[1])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialRepoImportTxReportsMissingCodeRule(t *testing.T) {
mock, cleanup := newImportMock(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}))
tx, err := database.DB.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
materials := []*model.Material{
{OrganizationID: 1, Name: "螺栓", CategoryID: materialCategoryPtr(12), Unit: "个", Status: "active", Version: 1},
}
err = NewMaterialRepo().ImportTx(tx, 1, materials)
if err == nil || !strings.Contains(err.Error(), "编码规则") {
t.Fatalf("err = %v, want 编码规则错误", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialRepoImportTxBatchesInsertsOverFiveHundredRows(t *testing.T) {
// 501 行 → 两批插入(500 + 1)。插入语句与回查参数很长,这里用子串松匹配,
// 只断言"分了两批"以及编码序号按批连续。
mock, cleanup := newImportMock(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}).
AddRow(5, 1, 12, "A01", 4, 1))
mock.ExpectQuery(`SELECT material_code FROM materials`).
WillReturnRows(sqlmock.NewRows([]string{"material_code"}))
mock.ExpectExec(`UPDATE code_rules SET next_sequence`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(1000, 500))
mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(idRowsFor(1, 500))
mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(1500, 1))
mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(idRowsFor(501, 501))
tx, err := database.DB.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
categoryID := int64(12)
materials := make([]*model.Material, 0, 501)
for i := 0; i < 501; i++ {
materials = append(materials, &model.Material{
OrganizationID: 1, Name: "物料", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1,
})
}
if err := NewMaterialRepo().ImportTx(tx, 1, materials); err != nil {
t.Fatalf("import: %v", err)
}
if materials[0].MaterialCode != "A010001" || materials[500].MaterialCode != "A010501" {
t.Fatalf("codes = %s ... %s", materials[0].MaterialCode, materials[500].MaterialCode)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// idRowsFor 造出回查语句的返回行:序号 [from, to] 对应的物料 ID 与编码。
func idRowsFor(from, to int) *sqlmock.Rows {
rows := sqlmock.NewRows([]string{"id", "material_code"})
for i := from; i <= to; i++ {
rows.AddRow(int64(i), fmt.Sprintf("A01%04d", i))
}
return rows
}
func TestMaterialLogRepoCreateBatchTx(t *testing.T) {
mock, cleanup := newImportMock(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 2))
tx, err := database.DB.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
logs := []*model.MaterialLog{
{OperationType: "CREATE", MaterialID: 31, Operator: "tester", Description: "批量导入"},
{OperationType: "CREATE", MaterialID: 32, Operator: "tester", Description: "批量导入"},
}
if err := NewMaterialLogRepo().CreateBatchTx(tx, logs); err != nil {
t.Fatalf("create batch: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
```
**注意:** 仓库里 `insertMaterialStmt` 定义在 `internal/service/material_test.go`(service 包),repository 包内没有同名常量。批量插入是**多值**语句,所以要在 `internal/repository/material_import_test.go` 里按参与行数拼出期望文本。测试里用到的是两行一批:
```go
const insertMaterialBatchStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
```
行数更多的场景(如 501 行那一条)用 `mock.ExpectExec("INSERT INTO materials")` 松匹配即可,不必拼 501 组占位符。
- [ ] **Step 2: 跑测试确认失败**
Run: `go test ./internal/repository/ -run 'ImportTx|CreateBatchTx' -v`
Expected: FAIL / 编译错误 `undefined: ImportTx`
- [ ] **Step 3: 实现批量入库**
在 `internal/repository/material.go` 末尾追加(**不要修改已有函数**):
```go
// materialImportBatchSize 是批量插入物料时的每批行数。编码已在内存里按小类
// 顺序分配好,这里只负责把 INSERT 折叠成分批的多值语句,避免两万行逐条往返。
const materialImportBatchSize = 500
// ImportTx 在调用方事务内为整批物料分配编码并插入,任意一行失败都由调用方回滚。
// 编码按小类分组生成:组内先对 code_rules 行加锁(按 category_id 升序加锁,
// 避免两个导入事务以不同顺序持锁而死锁),再跳过库中已占用的号段。
func (r *MaterialRepo) ImportTx(tx *sql.Tx, organizationID int64, materials []*model.Material) error {
if len(materials) == 0 {
return nil
}
// 1. 按 category_id 分组,并保证加锁顺序升序。
type group struct {
categoryID int64
items []*model.Material
}
index := make(map[int64]int)
groups := make([]*group, 0)
for _, m := range materials {
var categoryID int64
if m.CategoryID != nil {
categoryID = *m.CategoryID
}
position, ok := index[categoryID]
if !ok {
position = len(groups)
index[categoryID] = position
groups = append(groups, &group{categoryID: categoryID})
}
groups[position].items = append(groups[position].items, m)
}
sort.Slice(groups, func(i, j int) bool { return groups[i].categoryID < groups[j].categoryID })
// 2. 逐组锁规则、分配编码。
for _, g := range groups {
rule, err := lockCodeRule(tx, organizationID, g.categoryID)
if err != nil {
return err
}
if rule == nil {
return fmt.Errorf("小类(ID %d)未配置启用的编码规则", g.categoryID)
}
taken, err := loadTakenMaterialCodes(tx, organizationID, rule.Prefix)
if err != nil {
return err
}
sequence := rule.NextSequence
for _, m := range g.items {
code, next, err := nextAvailableMaterialCode(rule, sequence, taken)
if err != nil {
return err
}
m.MaterialCode = code
taken[code] = struct{}{}
sequence = next
}
if _, err := tx.Exec(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`, sequence, rule.ID); err != nil {
return err
}
}
// 3. 分批多值插入,每批插入后按编码回查自增 ID。
// 多值 INSERT 只返回首个自增 ID 且不保证连续,拿不到逐行 ID,
// 而后面写审计日志需要它;按批回查则把 IN 列表控制在 500 项以内。
for start := 0; start < len(materials); start += materialImportBatchSize {
end := start + materialImportBatchSize
if end > len(materials) {
end = len(materials)
}
batch := materials[start:end]
query, args := buildMaterialInsertBatch(batch)
if _, err := tx.Exec(query, args...); err != nil {
if strings.Contains(err.Error(), "Duplicate entry") {
return ErrDuplicateMaterialCode
}
return err
}
if err := fillMaterialIDs(tx, organizationID, batch); err != nil {
return err
}
}
return nil
}
// loadTakenMaterialCodes 取出本公司该前缀下已存在的全部物料编码,供内存判重。
// 前缀来自公司自己的编码规则配置,若它含 % 或 _ 只会让 LIKE 多匹配到无关编码,
// 而那些编码的格式与本规则生成的编码不同、永远不会被 FormatCode 产出,
// 因此多匹配不会漏判,无需额外转义。
func loadTakenMaterialCodes(tx *sql.Tx, organizationID int64, prefix string) (map[string]struct{}, error) {
rows, err := tx.Query(
`SELECT material_code FROM materials WHERE organization_id = ? AND material_code LIKE ?`,
organizationID, prefix+"%")
if err != nil {
return nil, err
}
defer rows.Close()
taken := make(map[string]struct{})
for rows.Next() {
var code string
if err := rows.Scan(&code); err != nil {
return nil, err
}
taken[code] = struct{}{}
}
return taken, rows.Err()
}
// nextAvailableMaterialCode 从 start 起找到第一个未占用的编码,返回编码与下一个
// 待用序号。探测上限沿用单条新增的 probeLimit,防止规则被用尽时无限循环。
func nextAvailableMaterialCode(rule *model.CodeRule, start int64, taken map[string]struct{}) (string, int64, error) {
for sequence := start; sequence < start+probeLimit; sequence++ {
code := rule.FormatCode(sequence)
if _, occupied := taken[code]; !occupied {
return code, sequence + 1, nil
}
}
return "", 0, fmt.Errorf("编码规则 %s 的可用流水号已用尽", rule.Prefix)
}
// buildMaterialInsertBatch 拼一条多值 INSERT,返回 SQL 与按顺序展开的参数。
func buildMaterialInsertBatch(batch []*model.Material) (string, []interface{}) {
var builder strings.Builder
builder.WriteString(`INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES `)
args := make([]interface{}, 0, len(batch)*11)
for i, m := range batch {
if i > 0 {
builder.WriteString(", ")
}
builder.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
args = append(args, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec,
m.ManufacturerModel, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
}
return builder.String(), args
}
// fillMaterialIDs 按物料编码回查自增 ID 并回填。多值插入拿不到逐行 ID,
// 而后续写 material_logs 需要它。
func fillMaterialIDs(tx *sql.Tx, organizationID int64, materials []*model.Material) error {
codes := make([]interface{}, 0, len(materials))
for _, m := range materials {
codes = append(codes, m.MaterialCode)
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(codes)), ",")
query := `SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (` + placeholders + `)`
args := append([]interface{}{organizationID}, codes...)
rows, err := tx.Query(query, args...)
if err != nil {
return err
}
defer rows.Close()
ids := make(map[string]int64, len(materials))
for rows.Next() {
var id int64
var code string
if err := rows.Scan(&id, &code); err != nil {
return err
}
ids[code] = id
}
if err := rows.Err(); err != nil {
return err
}
for _, m := range materials {
id, ok := ids[m.MaterialCode]
if !ok {
return fmt.Errorf("物料 %s 插入后未能回查到 ID", m.MaterialCode)
}
m.ID = id
}
return nil
}
```
需要在 `material.go` 的 import 块补上 `"sort"`(`fmt`、`strings`、`sql`、`model` 已在)。
- [ ] **Step 4: 实现批量写日志**
在 `internal/repository/material_log.go` 末尾追加:
```go
// CreateBatchTx 在同一事务里批量写审计日志。导入场景一条物料一条 CREATE 日志,
// 逐条往返太慢,这里折叠成多值 INSERT。
func (r *MaterialLogRepo) CreateBatchTx(tx *sql.Tx, logs []*model.MaterialLog) error {
if len(logs) == 0 {
return nil
}
var builder strings.Builder
builder.WriteString(`INSERT INTO material_logs (operation_type, material_id, old_value, new_value, operator, description) VALUES `)
args := make([]interface{}, 0, len(logs)*6)
for i, log := range logs {
if i > 0 {
builder.WriteString(", ")
}
builder.WriteString("(?, ?, ?, ?, ?, ?)")
args = append(args, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Operator, log.Description)
}
_, err := tx.Exec(builder.String(), args...)
return err
}
```
- [ ] **Step 5: 跑测试确认通过**
Run: `go test ./internal/repository/ -run 'ImportTx|CreateBatchTx' -v`
Expected: PASS(4 个测试)
- [ ] **Step 6: 跑全仓测试确认没弄坏既有逻辑**
Run: `go test ./... 2>&1 | grep -v "^ok\|no test files"`
Expected: 无 FAIL
- [ ] **Step 7: Commit**
```bash
git add internal/repository/material.go internal/repository/material_log.go internal/repository/material_import_test.go
git commit -m "feat: 物料批量入库的编码分配与事务插入"
```
---
## Task 4: 导入记录 repository
**Files:**
- Create: `internal/repository/material_import.go`
- Test: `internal/repository/material_import_test.go`(追加)
**Interfaces:**
- Consumes: `model.MaterialImportLog`
- Produces: `(*MaterialImportLogRepo) Create(log *model.MaterialImportLog) error`
- [ ] **Step 1: 写失败的测试**
在 `internal/repository/material_import_test.go` 末尾追加:
```go
func TestMaterialImportLogRepoCreate(t *testing.T) {
mock, cleanup := newImportMock(t)
defer cleanup()
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)).
WithArgs(int64(1), "tester", "物料.xlsx", 200, 0, 3, "failed", "共 3 行校验未通过").
WillReturnResult(sqlmock.NewResult(9, 1))
log := &model.MaterialImportLog{
OrganizationID: 1, Operator: "tester", FileName: "物料.xlsx",
TotalRows: 200, SuccessRows: 0, FailedRows: 3,
Status: "failed", FailureReason: "共 3 行校验未通过",
}
if err := NewMaterialImportLogRepo().Create(log); err != nil {
t.Fatalf("create: %v", err)
}
if log.ID != 9 {
t.Fatalf("id = %d, want 9", log.ID)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
```
- [ ] **Step 2: 跑测试确认失败**
Run: `go test ./internal/repository/ -run TestMaterialImportLogRepoCreate -v`
Expected: FAIL / 编译错误 `undefined: NewMaterialImportLogRepo`
- [ ] **Step 3: 实现**
创建 `internal/repository/material_import.go`:
```go
package repository
import (
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
type MaterialImportLogRepo struct{}
func NewMaterialImportLogRepo() *MaterialImportLogRepo { return &MaterialImportLogRepo{} }
// Create 写一条导入记录。成功和失败都会调用;失败原因只存汇总,逐行明细
// 只随接口响应返回,不落库。
func (r *MaterialImportLogRepo) Create(log *model.MaterialImportLog) error {
result, err := database.DB.Exec(
`INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
log.OrganizationID, log.Operator, log.FileName, log.TotalRows, log.SuccessRows, log.FailedRows, log.Status, log.FailureReason)
if err != nil {
return err
}
log.ID, err = result.LastInsertId()
return err
}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `go test ./internal/repository/ -run TestMaterialImportLogRepoCreate -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add internal/repository/material_import.go internal/repository/material_import_test.go
git commit -m "feat: 物料导入记录仓储"
```
---
## Task 5: 导入编排(校验 + 入库 + 记录)
**Files:**
- Create: `internal/service/material_import.go`
- Test: `internal/service/material_import_test.go`
**Interfaces:**
- Consumes: `ParseMaterialImportFile`、`MaterialRepo.ImportTx`、`MaterialLogRepo.CreateBatchTx`、`MaterialImportLogRepo.Create`、`MasterDataRepository.ListCategories/ListUnits`、`MaterialRepo.LocklessFindActiveCodeRule`
- Produces: `(*MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error)`
- Produces: `NewMaterialImportService() *MaterialImportService`
**说明:** 校验阶段需要"某小类是否配置了启用规则",现有 `MasterDataRepository.FindActiveCodeRule`(`master_data.go:142`)即可用,无需新增。
- [ ] **Step 1: 写失败的测试**
创建 `internal/service/material_import_test.go`:
```go
package service
import (
"bytes"
"regexp"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/pkg/database"
)
const importCategoryQuery = `SELECT id, organization_id, code, name, description, parent_id, status FROM material_categories WHERE organization_id = ? ORDER BY code`
const importUnitQuery = `SELECT id, organization_id, code, name, precision_scale, status FROM units WHERE organization_id = ? ORDER BY code`
const importRuleQuery = `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence, status FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active'`
func newImportServiceMock(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sql mock: %v", err)
}
previous := database.DB
database.DB = db
return mock, func() {
database.DB = previous
db.Close()
}
}
func expectImportMasterData(mock sqlmock.Sqlmock, withRule bool) {
mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "description", "parent_id", "status"}).
AddRow(3, 1, "A", "机械结构类", "", nil, "active").
AddRow(12, 1, "A01", "标准件", "", 3, "active"))
mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
AddRow(1, 1, "个", "个", 0, "active"))
if withRule {
mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence", "status"}).
AddRow(5, 1, 12, "A01", 4, 201, "active"))
} else {
mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}))
}
}
// fileName 由调用方指定:多数用例用「物料.xlsx」,模板非法那条用的是另一个名字。
// reason 用 interface{}:成功场景断言具体空串,失败场景传 sqlmock.AnyArg() 表示
// 只校验"写了一条记录",不锁死文案。
// 行数显式转 int64:database/sql 会把实参归一成 driver.Value(int→int64),
// sqlmock 按类型比对,直接传 Go int 匹配不上。
func expectImportLog(mock sqlmock.Sqlmock, fileName, status string, total, success, failed int, reason interface{}) {
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)).
WithArgs(int64(1), "tester", fileName, int64(total), int64(success), int64(failed), status, reason).
WillReturnResult(sqlmock.NewResult(9, 1))
}
func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportMasterData(mock, true)
expectImportLog(mock, "物料.xlsx", "failed", 2, 0, 2, sqlmock.AnyArg())
data := buildTestWorkbook(t, [][]string{
{"", "", "", "机械结构类", "标准件"}, // 名称缺失
{"螺栓", "", "吨", "不存在的类", "标准件"}, // 大类不存在
})
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
if err != nil {
t.Fatalf("import: %v", err)
}
if result.SuccessRows != 0 || result.FailedRows != 2 || len(result.Errors) != 2 {
t.Fatalf("result = %+v", result)
}
if result.Errors[0].Row != 2 || !strings.Contains(result.Errors[0].Reason, "物料名称") {
t.Fatalf("first error = %+v", result.Errors[0])
}
if result.Errors[1].Row != 3 || !strings.Contains(result.Errors[1].Reason, "大类") {
t.Fatalf("second error = %+v", result.Errors[1])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportMasterData(mock, false)
expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg())
data := buildTestWorkbook(t, [][]string{
{"螺栓", "", "个", "机械结构类", "标准件"},
})
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
if err != nil {
t.Fatalf("import: %v", err)
}
if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "编码规则") {
t.Fatalf("result = %+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportLog(mock, "错的.xlsx", "failed", 0, 0, 0, sqlmock.AnyArg())
result, err := NewMaterialImportService().Import(1, "tester", "错的.xlsx", strings.NewReader("not an xlsx"))
if err == nil {
t.Fatal("期望返回模板错误")
}
if result != nil {
t.Fatalf("result = %+v, want nil", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportMasterData(mock, true)
// 事务内:锁规则 → 取已占用编码 → 推进序号 → 插入 → 回查 ID
// ImportTx 先把整批编码分配完并推进 code_rules,再分批插入。
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE`)).
WithArgs(int64(1), int64(12)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}).
AddRow(5, 1, 12, "A01", 4, 201))
mock.ExpectQuery(`SELECT material_code FROM materials WHERE organization_id = \? AND material_code LIKE \?`).
WithArgs(int64(1), "A01%").
WillReturnRows(sqlmock.NewRows([]string{"material_code"}))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).
WithArgs(int64(202), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(31, 1))
mock.ExpectQuery(`SELECT id, material_code FROM materials WHERE organization_id = \? AND material_code IN \(\?\)`).
WithArgs(int64(1), "A010201").
WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).AddRow(31, "A010201"))
mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "")
data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
})
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
if err != nil {
t.Fatalf("import: %v", err)
}
if result.SuccessRows != 1 || result.FailedRows != 0 || len(result.Errors) != 0 {
t.Fatalf("result = %+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportMasterData(mock, true)
mock.ExpectBegin()
mock.ExpectQuery(`FOR UPDATE`).WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}).
AddRow(5, 1, 12, "A01", 4, 201))
mock.ExpectQuery(`SELECT material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"material_code"}))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(0, 0))
// 插入后回查不到 ID(模拟插入未真正落库),persist 据此报错并回滚
mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}))
mock.ExpectRollback()
expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg())
data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
})
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
if err == nil {
t.Fatal("期望入库失败")
}
if result != nil {
t.Fatalf("result = %+v, want nil", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
```
- [ ] **Step 2: 跑测试确认失败**
Run: `go test ./internal/service/ -run TestMaterialImportService -v`
Expected: FAIL / 编译错误 `undefined: NewMaterialImportService`
- [ ] **Step 3: 实现**
创建 `internal/service/material_import.go`:
```go
package service
import (
"fmt"
"io"
"strings"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
)
// MaterialImportService 负责一次物料批量导入:解析、逐行校验、事务入库、写记录。
// 首版只支持新增;校验阶段不生成编码,编码在入库事务里按小类规则分配。
type MaterialImportService struct {
materialRepo *repository.MaterialRepo
masterRepo *repository.MasterDataRepository
logRepo *repository.MaterialLogRepo
importRepo *repository.MaterialImportLogRepo
}
func NewMaterialImportService() *MaterialImportService {
return &MaterialImportService{
materialRepo: repository.NewMaterialRepo(),
masterRepo: repository.NewMasterDataRepository(),
logRepo: repository.NewMaterialLogRepo(),
importRepo: repository.NewMaterialImportLogRepo(),
}
}
// Import 处理一次上传。返回值约定:
// - 校验未通过:返回 result(含逐行错误)与 nil error,前端按明细展示;
// - 模板或文件不合法:返回 nil 与错误,前端提示原因;
// - 入库失败:返回 nil 与错误,事务已回滚,整批未落库。
//
// 三种情况都会写一条导入记录。
func (s *MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) {
if organizationID <= 0 {
return nil, ErrInvalidOrganizationID
}
rows, err := ParseMaterialImportFile(r)
if err != nil {
s.recordImport(organizationID, operator, fileName, 0, 0, 0, model.MaterialImportStatusFailed, err.Error())
return nil, err
}
materials, importErrors, err := s.validateRows(organizationID, rows)
if err != nil {
s.recordImport(organizationID, operator, fileName, len(rows), 0, len(rows), model.MaterialImportStatusFailed, err.Error())
return nil, err
}
if len(importErrors) > 0 {
result := &model.MaterialImportResult{
TotalRows: len(rows),
SuccessRows: 0,
FailedRows: len(importErrors),
Errors: importErrors,
}
s.recordImport(organizationID, operator, fileName, result.TotalRows, 0, result.FailedRows,
model.MaterialImportStatusFailed, summarizeImportErrors(importErrors))
return result, nil
}
if err := s.persist(organizationID, operator, materials); err != nil {
s.recordImport(organizationID, operator, fileName, len(rows), 0, len(rows), model.MaterialImportStatusFailed, err.Error())
return nil, err
}
result := &model.MaterialImportResult{
TotalRows: len(rows),
SuccessRows: len(materials),
FailedRows: 0,
Errors: []model.MaterialImportError{},
}
s.recordImport(organizationID, operator, fileName, result.TotalRows, result.SuccessRows, 0, model.MaterialImportStatusSuccess, "")
return result, nil
}
// validateRows 逐行校验并组装待插入物料。返回的错误是查询主数据失败这类系统级
// 错误;逐行的问题以 importErrors 返回,两者互斥。
func (s *MaterialImportService) validateRows(organizationID int64, rows []model.MaterialImportRow) ([]*model.Material, []model.MaterialImportError, error) {
categories, err := s.masterRepo.ListCategories(organizationID)
if err != nil {
return nil, nil, err
}
units, err := s.masterRepo.ListUnits(organizationID)
if err != nil {
return nil, nil, err
}
majors := make(map[string]*model.MaterialCategory)
minors := make(map[string]*model.MaterialCategory) // key: 大类ID + "\x00" + 小类名
for i := range categories {
category := &categories[i]
if category.Status != "active" {
continue
}
if category.ParentID == nil {
majors[category.Name] = category
continue
}
minors[minorCategoryKey(*category.ParentID, category.Name)] = category
}
unitByName := make(map[string]*model.Unit)
unitByCode := make(map[string]*model.Unit)
for i := range units {
unit := &units[i]
if unit.Status != "active" {
continue
}
// 名称和编码各自只认第一条,避免同名单位覆盖成不确定的结果
if _, exists := unitByName[unit.Name]; !exists {
unitByName[unit.Name] = unit
}
if _, exists := unitByCode[unit.Code]; !exists {
unitByCode[unit.Code] = unit
}
}
materials := make([]*model.Material, 0, len(rows))
importErrors := make([]model.MaterialImportError, 0)
// 同一小类的规则只查一次:两万行通常只落在少数几个小类上,
// 逐行去查会把一次导入放大成两万次往返。
ruleAvailable := make(map[int64]bool)
for _, row := range rows {
reasons := make([]string, 0, 3)
name := row.Name
if name == "" {
reasons = append(reasons, "物料名称不能为空")
} else if len([]rune(name)) > 200 {
reasons = append(reasons, "物料名称超过 200 字")
}
if len([]rune(row.Spec)) > 200 {
reasons = append(reasons, "规格超过 200 字")
}
major := majors[row.MajorName]
if row.MajorName == "" {
reasons = append(reasons, "大类不能为空")
} else if major == nil {
reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用", row.MajorName))
}
var minor *model.MaterialCategory
if row.MinorName == "" {
reasons = append(reasons, "小类不能为空")
} else if major == nil {
// 大类已经报错,小类无法定位,不重复报
} else {
minor = minors[minorCategoryKey(major.ID, row.MinorName)]
if minor == nil {
reasons = append(reasons, fmt.Sprintf("小类「%s」在「%s」下不存在或已停用", row.MinorName, row.MajorName))
}
}
// 单位留空时按单条新增的口径默认「个」;填了则先按名称、再按编码匹配,
// 因为像「kg」和「千克」这种编码与名称不一致的单位两种写法都合理。
unitName := row.Unit
if unitName == "" {
unitName = "个"
}
unit := unitByName[unitName]
if unit == nil {
unit = unitByCode[unitName]
}
if unit == nil {
if row.Unit == "" {
reasons = append(reasons, "本单位缺少默认单位「个」,请在模板中填写单位")
} else {
reasons = append(reasons, fmt.Sprintf("单位「%s」不存在或已停用", row.Unit))
}
}
if minor != nil {
available, checked := ruleAvailable[minor.ID]
if !checked {
rule, err := s.masterRepo.FindActiveCodeRule(organizationID, minor.ID)
if err != nil {
return nil, nil, err
}
available = rule != nil
ruleAvailable[minor.ID] = available
}
if !available {
reasons = append(reasons, fmt.Sprintf("小类「%s」未配置编码规则", row.MinorName))
}
}
if len(reasons) > 0 {
importErrors = append(importErrors, model.MaterialImportError{
Row: row.Row,
Data: row.Data(),
Reason: strings.Join(reasons, ";"),
})
continue
}
var baseUnitID *int64
if unit != nil {
id := unit.ID
baseUnitID = &id
}
categoryID := minor.ID
materials = append(materials, &model.Material{
OrganizationID: organizationID,
Name: name,
Spec: row.Spec,
CategoryID: &categoryID,
BaseUnitID: baseUnitID,
Unit: unitName,
Status: "active",
Version: 1,
})
}
return materials, importErrors, nil
}
// persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。
func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*model.Material) error {
tx, err := repository.BeginTx()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := s.materialRepo.ImportTx(tx, organizationID, materials); err != nil {
return err
}
logs := make([]*model.MaterialLog, 0, len(materials))
for _, material := range materials {
logs = append(logs, &model.MaterialLog{
OperationType: "CREATE",
MaterialID: material.ID,
Operator: operator,
Description: "批量导入创建",
})
}
if err := s.logRepo.CreateBatchTx(tx, logs); err != nil {
return err
}
return tx.Commit()
}
// recordImport 写导入记录。记录失败只影响可追溯性,不应该让已经完成的导入
// 变成失败,所以这里吞掉错误。
func (s *MaterialImportService) recordImport(organizationID int64, operator, fileName string, total, success, failed int, status, reason string) {
if len([]rune(reason)) > 1000 {
reason = string([]rune(reason)[:1000])
}
_ = s.importRepo.Create(&model.MaterialImportLog{
OrganizationID: organizationID,
Operator: operator,
FileName: fileName,
TotalRows: total,
SuccessRows: success,
FailedRows: failed,
Status: status,
FailureReason: reason,
})
}
// summarizeImportErrors 生成给记录表用的失败原因摘要:前 5 行行号 + 总数。
func summarizeImportErrors(importErrors []model.MaterialImportError) string {
rows := make([]string, 0, 5)
for i, item := range importErrors {
if i == 5 {
break
}
rows = append(rows, fmt.Sprintf("%d", item.Row))
}
summary := fmt.Sprintf("共 %d 行校验未通过(行号:%s", len(importErrors), strings.Join(rows, "、"))
if len(importErrors) > 5 {
summary += " 等"
}
return summary + ")"
}
func minorCategoryKey(majorID int64, minorName string) string {
return fmt.Sprintf("%d\x00%s", majorID, minorName)
}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `go test ./internal/service/ -run TestMaterialImportService -v`
Expected: PASS(5 个测试)
- [ ] **Step 5: 跑全仓测试**
Run: `go test ./... 2>&1 | grep -v "^ok\|no test files"`
Expected: 无 FAIL
- [ ] **Step 6: Commit**
```bash
git add internal/service/material_import.go internal/service/material_import_test.go
git commit -m "feat: 物料导入校验与事务入库编排"
```
---
## Task 6: HTTP 接口与路由
**Files:**
- Create: `internal/handler/material_import.go`
- Modify: `pkg/response/response.go`(新增 `ErrorWithData`)
- Modify: `internal/handler/api.go`(Handler 结构体 + 路由组)
- Test: `internal/handler/material_import_test.go`、`internal/handler/routes_test.go`
**Interfaces:**
- Consumes: `MaterialImportService.Import`、`BuildMaterialImportTemplate`
- Produces: `GET /api/material-import/template`、`POST /api/material-import`
- Produces: `response.ErrorWithData(c *gin.Context, httpStatus int, message string, data interface{})`
- [ ] **Step 1: 加通用错误响应**
在 `pkg/response/response.go` 的 `Error` 之后追加:
```go
// ErrorWithData 返回带业务数据的错误响应。批量导入校验失败时需要把逐行错误
// 一起带回去,不能只给一句话。
func ErrorWithData(c *gin.Context, httpStatus int, message string, data interface{}) {
c.JSON(httpStatus, Response{
Code: httpStatus,
Message: message,
Data: data,
})
}
```
- [ ] **Step 2: 写失败的测试**
创建 `internal/handler/material_import_test.go`:
```go
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/wmms/wmms/pkg/response"
)
func TestErrorWithDataKeepsPayload(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
response.ErrorWithData(c, http.StatusBadRequest, "有 1 行校验未通过", map[string]interface{}{
"total_rows": 1, "failed_rows": 1,
})
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", recorder.Code)
}
body := recorder.Body.String()
if !strings.Contains(body, "有 1 行校验未通过") || !strings.Contains(body, `"failed_rows":1`) {
t.Fatalf("body = %s", body)
}
}
```
并在 `internal/handler/routes_test.go` 的 `TestPhaseOneAndLegacyRoutesAreRegistered` 里,往 `want` 切片的 `"POST /api/logs"` 那一行之后追加(该切片每项是 `"方法 路径"` 字符串):
```go
"GET /api/material-import/template", "POST /api/material-import",
```
同文件的 `TestOrganizationBusinessRoutesRequireHeaderBeforeHandler` 会自动遍历到这两条新路由(它们不在豁免前缀里),断言缺少 `X-Organization-ID` 时返回 400,因此只要路由挂上了 `OrganizationMiddleware()` 就会被它覆盖,无需另写权限用例。
- [ ] **Step 3: 跑测试确认失败**
Run: `go test ./internal/handler/ -run 'ErrorWithData|Routes' -v`
Expected: FAIL(`ErrorWithData` 未定义 / 路由缺失)
- [ ] **Step 4: 实现 handler**
创建 `internal/handler/material_import.go`:
```go
package handler
import (
"errors"
"net/http"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
"github.com/wmms/wmms/internal/middleware"
"github.com/wmms/wmms/internal/service"
"github.com/wmms/wmms/pkg/response"
)
// DownloadMaterialImportTemplate 返回物料导入模板文件。模板随代码生成,
// 不落盘,保证下载到的永远和当前校验规则一致。
func (h *Handler) DownloadMaterialImportTemplate(c *gin.Context) {
data, err := service.BuildMaterialImportTemplate()
if err != nil {
response.InternalError(c, "模板生成失败")
return
}
fileName := url.PathEscape("物料导入模板.xlsx")
c.Header("Content-Disposition", `attachment; filename="material-import-template.xlsx"; filename*=UTF-8''`+fileName)
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
}
// ImportMaterials 接收上传的 xlsx,校验通过即整批入库。
// 校验失败返回 400 且 data 里带逐行错误明细。
func (h *Handler) ImportMaterials(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
file, err := c.FormFile("file")
if err != nil {
response.BadRequest(c, "请选择要导入的 xlsx 文件")
return
}
if file.Size > service.MaterialImportMaxBytes {
response.BadRequest(c, "文件超过 5MB 上限")
return
}
source, err := file.Open()
if err != nil {
response.BadRequest(c, "文件读取失败")
return
}
defer source.Close()
operator := middleware.GetCurrentUsername(c)
if operator == "" {
operator = "系统"
}
result, err := h.materialImportSvc.Import(orgID, operator, file.Filename, source)
if err != nil {
if errors.Is(err, service.ErrImportTemplateInvalid) || errors.Is(err, service.ErrImportTooManyRows) {
response.BadRequest(c, err.Error())
return
}
response.InternalError(c, err.Error())
return
}
if result.FailedRows > 0 {
response.ErrorWithData(c, http.StatusBadRequest,
"有 "+strconv.Itoa(result.FailedRows)+" 行校验未通过,本次未导入任何数据", result)
return
}
response.Created(c, "成功导入 "+strconv.Itoa(result.SuccessRows)+" 条物料", result)
}
```
- [ ] **Step 5: 注册路由与服务**
`internal/handler/api.go`:
1. `Handler` 结构体加字段:
```go
materialImportSvc *service.MaterialImportService
```
2. `NewHandler()` 加:
```go
materialImportSvc: service.NewMaterialImportService(),
```
3. 在 `materials` 路由组之后加(**必须是独立前缀**):
```go
// 物料批量导入单独挂前缀:materials 组已有 GET /:id,
// gin 的路由树不允许同段既有静态前缀又有通配参数。
materialImport := api.Group("/material-import")
materialImport.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware())
{
materialImport.GET("/template", h.DownloadMaterialImportTemplate)
materialImport.POST("", h.ImportMaterials)
}
```
- [ ] **Step 6: 跑测试确认通过**
Run: `go test ./internal/handler/ -v -run 'ErrorWithData|Routes'`
Expected: PASS
- [ ] **Step 7: 起服务验证路由不 panic**
Run: `go build -o server.exe ./cmd/server && echo BUILD_OK`
Expected: `BUILD_OK`(gin 路由冲突会在注册时 panic,构建通过后由下一步的路由测试兜住)
- [ ] **Step 8: Commit**
```bash
git add internal/handler/material_import.go internal/handler/material_import_test.go internal/handler/api.go internal/handler/routes_test.go pkg/response/response.go
git commit -m "feat: 物料导入与模板下载接口"
```
---
## Task 7: 前端 API 与工具
**Files:**
- Modify: `frontend/src/api/materials.js`
- Create: `frontend/src/utils/exportCsv.js`
- Test: `frontend/src/utils/exportCsv.test.js`
**Interfaces:**
- Produces: `materialsApi.importFile(formData)`、`materialsApi.downloadImportTemplate()`
- Produces: `buildCsv(rows)`、`downloadCsv(fileName, csv)`
- [ ] **Step 1: 写失败的测试**
创建 `frontend/src/utils/exportCsv.test.js`:
```js
import { describe, expect, test } from 'vitest'
import { buildCsv } from './exportCsv'
describe('buildCsv', () => {
test('用逗号分隔并在行尾换行', () => {
const csv = buildCsv([
['行号', '失败原因'],
[3, '物料名称不能为空']
])
expect(csv).toBe('行号,失败原因\r\n3,物料名称不能为空\r\n')
})
test('包含逗号、引号、换行的字段用双引号包裹并转义', () => {
const csv = buildCsv([['规格', '备注'], ['M8,30', '含"引号"'], ['两\n行', '']])
expect(csv).toBe('规格,备注\r\n"M8,30","含""引号"""\r\n"两\n行",\r\n')
})
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `cd frontend && npx vitest run src/utils/exportCsv.test.js`
Expected: FAIL(模块不存在)
- [ ] **Step 3: 实现**
创建 `frontend/src/utils/exportCsv.js`:
```js
// 生成 CSV 文本。带 UTF-8 BOM,Excel 打开中文才不会乱码;字段里的
// 逗号、引号、换行按 RFC 4180 用双引号包裹并转义。
export function buildCsv(rows) {
const lines = rows.map(row => row.map(escapeCsvCell).join(','))
return '' + lines.map(line => line + '\r\n').join('')
}
function escapeCsvCell(value) {
const text = value === null || value === undefined ? '' : String(value)
if (/[",\r\n]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`
}
return text
}
// 触发浏览器下载。用 Blob + a[download],不依赖任何第三方库。
export function downloadCsv(fileName, csv) {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `cd frontend && npx vitest run src/utils/exportCsv.test.js`
Expected: PASS
- [ ] **Step 5: 加 API**
在 `frontend/src/api/materials.js` 的 `materialsApi` 对象里追加两个方法(放在 `uploadImage` 之后):
```js
importFile(formData) {
// silent:校验失败的明细要自己弹窗展示,不让拦截器先弹一句笼统提示
return request.post('/material-import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
silent: true
})
},
downloadImportTemplate() {
// 下载走原生 fetch:响应是文件流,axios 拦截器只认 JSON 的 code 字段
return fetch('/api/material-import/template', {
headers: downloadHeaders()
})
}
```
并在文件顶部补上取头信息的辅助:
```js
import { getOrganizationId } from '../utils/organizationContext'
// 下载接口绕开 axios,得自己带令牌与当前公司,否则 401 / 400。
// 令牌沿用拦截器同款读法(localStorage),避免在 API 模块里依赖 Pinia 激活时序。
function downloadHeaders() {
const token = localStorage.getItem('wmms_token')
const organizationId = getOrganizationId()
return {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(organizationId ? { 'X-Organization-ID': String(organizationId) } : {})
}
}
```
- [ ] **Step 6: 跑前端测试**
Run: `cd frontend && npx vitest run src/api`
Expected: 无新增失败
- [ ] **Step 7: Commit**
```bash
git add frontend/src/api/materials.js frontend/src/utils/exportCsv.js frontend/src/utils/exportCsv.test.js
git commit -m "feat: 物料导入前端接口与 CSV 导出工具"
```
---
## Task 8: 导入结果弹窗组件
**Files:**
- Create: `frontend/src/components/business/MaterialImportResult.vue`
- Test: `frontend/src/components/business/MaterialImportResult.test.js`
**Interfaces:**
- Consumes: `buildCsv`、`downloadCsv`
- Produces: 组件 props `result`(`{ total_rows, success_rows, failed_rows, errors }`)、emits `close`
- [ ] **Step 1: 写失败的测试**
创建 `frontend/src/components/business/MaterialImportResult.test.js`:
```js
import { mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { describe, expect, test, vi } from 'vitest'
import MaterialImportResult from './MaterialImportResult.vue'
const failedResult = {
total_rows: 3,
success_rows: 0,
failed_rows: 2,
errors: [
{ row: 2, data: { name: '', spec: 'M8x30', unit: '个', major: '机械结构类', minor: '标准件' }, reason: '物料名称不能为空' },
{ row: 4, data: { name: '螺栓', spec: '', unit: '吨', major: '机械结构类', minor: '标准件' }, reason: '单位「吨」不存在或已停用' }
]
}
function mountResult(result) {
return mount(MaterialImportResult, {
props: { result },
global: { plugins: [ElementPlus], stubs: { teleport: true } }
})
}
describe('MaterialImportResult', () => {
test('失败时展示行数摘要与每行错误', () => {
const wrapper = mountResult(failedResult)
expect(wrapper.text()).toContain('共 3 行')
expect(wrapper.text()).toContain('2 行未通过')
const rows = wrapper.findAll('tbody tr')
expect(rows.length).toBe(2)
expect(rows[0].text()).toContain('2')
expect(rows[0].text()).toContain('物料名称不能为空')
wrapper.unmount()
})
test('导出错误明细时把行号与原因写进 CSV', async () => {
const download = vi.fn()
const wrapper = mount(MaterialImportResult, {
props: { result: failedResult },
global: {
plugins: [ElementPlus],
stubs: { teleport: true },
provide: { downloadCsv: download }
}
})
await wrapper.find('[data-test="export-errors"]').trigger('click')
expect(download).toHaveBeenCalledTimes(1)
const [fileName, csv] = download.mock.calls[0]
expect(fileName).toContain('导入错误明细')
expect(csv).toContain('物料名称不能为空')
expect(csv).toContain('行号')
wrapper.unmount()
})
test('全部成功时只展示成功摘要,不显示错误表格', () => {
const wrapper = mountResult({ total_rows: 2, success_rows: 2, failed_rows: 0, errors: [] })
expect(wrapper.text()).toContain('成功导入 2 条')
expect(wrapper.find('[data-test="error-table"]').exists()).toBe(false)
wrapper.unmount()
})
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `cd frontend && npx vitest run src/components/business/MaterialImportResult.test.js`
Expected: FAIL(组件不存在)
- [ ] **Step 3: 实现组件**
创建 `frontend/src/components/business/MaterialImportResult.vue`:
```vue
<template>
<div class="import-result">
<el-alert
v-if="result.failed_rows === 0"
:title="`共 ${result.total_rows} 行,成功导入 ${result.success_rows} 条`"
type="success"
:closable="false"
show-icon
/>
<el-alert
v-else
:title="`共 ${result.total_rows} 行,${result.failed_rows} 行未通过,本次未导入任何数据`"
type="error"
:closable="false"
show-icon
/>
<template v-if="result.failed_rows > 0">
<div class="toolbar">
<el-button type="primary" plain data-test="export-errors" @click="exportErrors">导出错误明细</el-button>
</div>
<el-table
data-test="error-table"
:data="pagedErrors"
size="small"
border
max-height="320"
>
<el-table-column prop="row" label="行号" width="80" />
<el-table-column label="物料名称" width="140">
<template #default="{ row }">{{ row.data.name || '—' }}</template>
</el-table-column>
<el-table-column label="规格" width="120">
<template #default="{ row }">{{ row.data.spec || '—' }}</template>
</el-table-column>
<el-table-column label="单位" width="90">
<template #default="{ row }">{{ row.data.unit || '—' }}</template>
</el-table-column>
<el-table-column label="大类" width="140">
<template #default="{ row }">{{ row.data.major || '—' }}</template>
</el-table-column>
<el-table-column label="小类" width="120">
<template #default="{ row }">{{ row.data.minor || '—' }}</template>
</el-table-column>
<el-table-column prop="reason" label="失败原因" min-width="220" />
</el-table>
<BasePagination
:current-page="currentPage"
:page-size="pageSize"
:total="result.errors.length"
@change="handlePageChange"
/>
</template>
<div class="footer">
<el-button @click="$emit('close')">关闭</el-button>
</div>
</div>
</template>
<script setup>
import { computed, inject, ref } from 'vue'
import BasePagination from './BasePagination.vue'
import { buildCsv, downloadCsv as defaultDownloadCsv } from '../../utils/exportCsv'
const props = defineProps({
result: { type: Object, required: true }
})
defineEmits(['close'])
// 注入只用于测试替换下载动作,生产走默认实现。
const downloadCsv = inject('downloadCsv', defaultDownloadCsv)
const pageSize = 20
const currentPage = ref(1)
const pagedErrors = computed(() => {
const start = (currentPage.value - 1) * pageSize
return props.result.errors.slice(start, start + pageSize)
})
function handlePageChange(page) {
currentPage.value = page
}
function exportErrors() {
const rows = [['行号', '物料名称', '规格', '单位', '大类', '小类', '失败原因']]
for (const item of props.result.errors) {
rows.push([
item.row,
item.data?.name || '',
item.data?.spec || '',
item.data?.unit || '',
item.data?.major || '',
item.data?.minor || '',
item.reason
])
}
downloadCsv(`导入错误明细_${timestampSuffix()}.csv`, buildCsv(rows))
}
// 文件名后缀用本地时间,不依赖 datetime.js 的展示格式
function timestampSuffix() {
const now = new Date()
const pad = value => String(value).padStart(2, '0')
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
}
</script>
<style scoped>
.import-result { display: flex; flex-direction: column; gap: 12px; }
.toolbar { display: flex; justify-content: flex-end; }
.footer { display: flex; justify-content: flex-end; }
</style>
```
**注意:** `BasePagination` 的 props 是 `currentPage` / `pageSize` / `total`,emit 的是 `change`(`frontend/src/components/business/BasePagination.vue:18-24`),上面用法与之一致。
- [ ] **Step 4: 跑测试确认通过**
Run: `cd frontend && npx vitest run src/components/business/MaterialImportResult.test.js`
Expected: PASS(3 个测试)
- [ ] **Step 5: Commit**
```bash
git add frontend/src/components/business/MaterialImportResult.vue frontend/src/components/business/MaterialImportResult.test.js
git commit -m "feat: 物料导入结果弹窗组件"
```
---
## Task 9: 物料页面接入
**Files:**
- Modify: `frontend/src/views/Materials.vue`
- Test: `frontend/src/views/Materials.test.js`(追加)
**Interfaces:**
- Consumes: `materialsApi.importFile`、`materialsApi.downloadImportTemplate`、`MaterialImportResult.vue`、`useSavingGuard`
- [ ] **Step 1: 写失败的测试**
在 `frontend/src/views/Materials.test.js` 末尾追加(沿用文件里已有的 `mountMaterials()`、mock 结构):
```js
test('批量导入成功后提示成功并刷新列表', async () => {
materialsApi.importFile.mockResolvedValue({
data: { total_rows: 2, success_rows: 2, failed_rows: 0, errors: [] }
})
const wrapper = mountMaterials()
await flushPromises()
await setupState(wrapper).handleImportFile({
target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' }
})
await flushPromises()
expect(materialsApi.importFile).toHaveBeenCalled()
expect(materialsStore.fetchMaterials).toHaveBeenCalled()
expect(ElMessage.success).toHaveBeenCalledWith('成功导入 2 条物料')
wrapper.unmount()
})
test('批量导入失败时展示错误明细且不刷新列表', async () => {
const error = new Error('校验未通过')
error.response = {
data: {
code: 400,
message: '有 1 行校验未通过,本次未导入任何数据',
data: {
total_rows: 1, success_rows: 0, failed_rows: 1,
errors: [{ row: 2, data: { name: '', spec: '', unit: '', major: '', minor: '' }, reason: '物料名称不能为空' }]
}
}
}
materialsApi.importFile.mockRejectedValue(error)
const wrapper = mountMaterials()
await flushPromises()
await setupState(wrapper).handleImportFile({
target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' }
})
await flushPromises()
expect(setupState(wrapper).importResult.failed_rows).toBe(1)
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')` 方式补齐。
- [ ] **Step 2: 跑测试确认失败**
Run: `cd frontend && npx vitest run src/views/Materials.test.js`
Expected: 新增的两个用例 FAIL
- [ ] **Step 3: 实现页面接入**
`frontend/src/views/Materials.vue` 改动:
1. 顶部按钮区(第 3-6 行)加两个按钮和一个隐藏的文件输入:
```vue
<div class="page-header">
<h2>物料主数据</h2>
<div class="header-actions">
<el-button @click="handleDownloadTemplate">下载模板</el-button>
<el-button @click="triggerImport">批量导入</el-button>
<el-button type="primary" @click="showAddModal">新增物料</el-button>
<input
ref="importInput"
type="file"
accept=".xlsx"
style="display: none"
@change="handleImportFile"
/>
</div>
</div>
```
2. 在页面底部加导入结果弹窗:
```vue
<el-dialog v-model="importDialogVisible" title="批量导入结果" width="900px" @closed="handleImportDialogClosed">
<MaterialImportResult v-if="importResult" :result="importResult" @close="importDialogVisible = false" />
</el-dialog>
```
3. `script setup` 里补充:
```js
import MaterialImportResult from '../components/business/MaterialImportResult.vue'
import { materialsApi } from '../api/materials'
const importInput = ref(null)
const importDialogVisible = ref(false)
const importResult = ref(null)
const importing = ref(false)
function triggerImport() {
if (importing.value) return
importInput.value?.click()
}
async function handleImportFile(event) {
const file = event.target.files?.[0]
// 清空 input,否则连续选同一个文件不会再触发 change
event.target.value = ''
if (!file) return
if (!file.name.toLowerCase().endsWith('.xlsx')) {
ElMessage.warning('只支持 .xlsx 文件')
return
}
if (file.size > 5 * 1024 * 1024) {
ElMessage.warning('文件不能超过 5MB')
return
}
const formData = new FormData()
formData.append('file', file)
importing.value = true
try {
// 导入是写请求:包进保存互斥,飞行期间禁止切换公司
const res = await runWithSaving(() => materialsApi.importFile(formData))
const result = res.data
await materialsStore.fetchMaterials(1, '', 'all')
searchKeyword.value = ''
ElMessage.success(`成功导入 ${result.success_rows} 条物料`)
} catch (e) {
// 校验失败时响应里带逐行明细,弹窗展示;其余错误交给拦截器之外的兜底提示
const detail = e?.response?.data?.data
if (detail && Array.isArray(detail.errors)) {
importResult.value = detail
importDialogVisible.value = true
} else if (!e?.response) {
console.error('批量导入失败:', e)
ElMessage.error(errorText(e, '批量导入失败'))
}
} finally {
importing.value = false
}
}
function handleImportDialogClosed() {
importResult.value = null
}
async function handleDownloadTemplate() {
try {
const res = await runWithSaving(() => materialsApi.downloadImportTemplate())
if (!res.ok) {
ElMessage.error('模板下载失败')
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '物料导入模板.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (e) {
console.error('下载模板失败:', e)
ElMessage.error('模板下载失败')
}
}
```
4. `<style scoped>` 里补:
```css
.header-actions { display: flex; gap: 8px; }
```
**注意:** `errorText` 已在页面中导入(第 458 行附近在用),无需重复导入。`runWithSaving` 已有。
- [ ] **Step 4: 跑测试确认通过**
Run: `cd frontend && npx vitest run src/views/Materials.test.js`
Expected: PASS
- [ ] **Step 5: 跑全量前端测试并比对错误数**
Run: `cd frontend && npx vitest run 2>&1 | tail -5`
Expected: 错误数不高于改动前基线(干净 master 为 172 errors、退出码 1,**以错误条数为准,不要以退出码判断**)
- [ ] **Step 6: Commit**
```bash
git add frontend/src/views/Materials.vue frontend/src/views/Materials.test.js
git commit -m "feat: 物料页面接入批量导入与模板下载"
```
---
## Task 10: 文档与端到端验证
**Files:**
- Modify: `docs/api.md`
- [ ] **Step 1: 补接口文档**
在 `docs/api.md` 的「图片上传」一行之后新增一行分组说明:
```markdown
| 物料批量导入 | `GET /material-import/template`、`POST /material-import` | 需登录 |
```
并在「## 1. 物料接口」章节末尾(`POST /materials/:id/restore` 之后)补完整接口说明:请求方式、模板列定义、5MB/20000 行限制、成功与失败响应示例、`row` 为 Excel 真实行号、首版仅新增、任意一行失败整批不入库。
- [ ] **Step 2: 端到端验证(真实服务 + 真实库)**
```bash
docker compose down -v && docker compose up -d --build
```
然后在 `frontend/` 下 `npm run build` 并重建镜像(**静态文件打进镜像,跑本地 dev/build 看不到效果;nginx 的 immutable 缓存要求浏览器硬刷新**)。
- [ ] **Step 3: 手工验证清单**
| 场景 | 期望 |
| ------------------------- | -------------------------------------------- |
| 下载模板 | 得到 xlsx,含「填写说明」「物料数据」两张表,表头 5 列 |
| 上传模板原样(无数据) | 提示没有数据行(0 行),不写记录或写 0 行记录 |
| 上传 2 行合法数据 | 201,列表出现 2 条物料,编码为 `小类前缀+序号`,日志页有 2 条 CREATE |
| 上传含空名称、错误大类、错误单位、无规则小类的文件 | 400,逐行错误行号与 Excel 一致,列表**没有**新增任何物料 |
| 上传同名同规格的重复文件两次 | 第二次仍成功(编码不同),确认首版不做重复检测 |
| 上传 .xls / .csv / 6MB 文件 | 400,提示格式或大小 |
| 非本公司分类名 | 400,提示分类不存在 |
| 导入过程中切公司 | 被保存互斥拦住 |
- [ ] **Step 4: 确认导入记录落库**
Run: `docker compose exec db mysql -uroot -pwmms1234 wmms -e "SELECT organization_id,operator,file_name,total_rows,success_rows,failed_rows,status,failure_reason,created_at FROM material_import_logs ORDER BY id DESC LIMIT 5"`
Expected: 每次导入(成功与失败)各一条
- [ ] **Step 5: 确认回滚确实生效**
上传一个"前 199 行合法、第 200 行非法"的文件,然后:
Run: `docker compose exec db mysql -uroot -pwmms1234 wmms -e "SELECT COUNT(*) FROM materials WHERE material_code LIKE 'A01%'"`
Expected: 数量与导入前一致(整批未落库)
- [ ] **Step 6: 提交文档**
```bash
git add docs/api.md
git commit -m "docs: 补充物料批量导入接口说明"
```
---
## 已知取舍
- **不做重复检测**:同一份文件里两行相同物料都会成功入库,同一份文件重复上传也会导入两遍(编码都不同)。用户故事 BAT-001 提到的「疑似重复物料」不在首版范围。
- **没有服务端幂等键**:设计原文的「任务 ID 防重复提交」随预览环节一起取消,改为纯前端约束,见「设计约定 8」。
- **错误明细不落库**:只随响应返回,前端用 CSV 导出。因此历史导入记录里只有汇总原因,翻不回逐行明细。
- **不使用 `material_types`、`material_unit_conversions`**:与现有单条新增保持一致。
- **不引入消息队列**:20000 行同步处理,前端超时沿用 axios 默认 30 秒;若实测慢,后续再谈异步。
- **`material_import_logs` 只写汇总**:第 8 点的「执行状态」只有 `success` / `failed` 两态,没有中间态。