# 物料批量导入实现计划
> **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` 成功汇总;失败导入不写历史记录,逐行错误只在响应里返回,不落库。成功物料的 CREATE 日志通过现有日志页「查看详情」展示「批量导入创建」。
**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 MiB;在 multipart 解析前使用 `http.MaxBytesReader` 将请求体限制为 5 MiB + 64 KiB(表单开销),解析后仍独立校验文件大小。使用 `Rows` 迭代器逐行读取,最多保留 20,000 条非空数据,发现第 20,001 条立即停止;表头和空行不计数。大于 1 MiB 的工作表/共享字符串 XML 使用临时文件,退出时关闭迭代器、工作簿并清理临时文件。
- 首版仅支持**新增**物料,不做更新、不做分类/单位的自动创建。
- 物料编码由系统按小类编码规则生成,**不接受 Excel 中的编码列**;小类未配置启用的编码规则时该行报错。
- 任意一行校验失败 → 整批不入库,返回全部逐行错误。
- 所有接口必须挂 `middleware.AuthMiddleware(), middleware.OrganizationMiddleware()`,公司取服务端上下文,忽略请求体伪造值。
- 注释、提交信息、用户可见文案全部用中文(与仓库既有风格一致)。
-
---
## 设计约定
### 1. 流程
下载模板 → 填写 → 上传 → 后端解析 → 逐行校验 → **全部通过即在同一个请求内事务入库** → 返回结果。
**已确认去掉「预览确认」环节**:不再有暂存任务 ID、不再有确认接口,也不存在"确认时重新校验"。校验失败时整批不入库,错误明细随响应返回。
### 2. 模板
两个工作表:
- `填写说明`:字段名、是否必填、示例、填写规则。
- `物料数据`:第 1 行表头,数据从第 2 行开始。
模板生成的表头是 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时**按列名定位,不按列位置**:
- 必填列 `物料名称`、`大类`、`小类` 缺任何一列 → 报「模板格式不正确,请重新下载最新模板」;
- 选填列 `规格`、`单位` 可以整列删掉,取值按空处理;
- 列顺序可以随意调整;
- 表头允许带首尾空格与末尾的 `*` 必填标记;不认识的列忽略。
「单元格留空」和「整列缺失」是两回事:规格与单位留空属于合法的选填,不会报错;只有必填列缺失才判定模板不合法。
分类**按名称填写**,同时兼容界面上的「编码 名称」写法与纯编码(前端分类下拉显示的就是「编码 名称」,用户整段照抄是常事);后端按「大类 ID + 小类写法」组合在本公司内匹配(小类名在本公司内会重名,如「其他」在 A07/B07/C03/D04/F05 都叫「其他」,因此必须两级组合)。匹配前先把各种空白(半角空格、全角空格 U+3000、NBSP U+00A0、制表符)归一化成单个半角空格,否则会出现「从浏览器复制的能导入、自己手写的报不存在」。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。
### 3. 接口
| 方法 | 路径 | 说明 |
| ---- | ------------------------------- | -------------------------------------- |
| GET | `/api/material-import/template` | 下载 xlsx 模板,返回文件流 |
| POST | `/api/material-import` | `multipart/form-data`,字段名 `file`,上传即入库 |
**路由挂在独立前缀 `/material-import`**:这**不是** gin 的限制——实测 gin v1.9.1 允许静态段与 `:id` 在同一层级共存,把 `/materials/import/...` 加进 `materials` 组并不会 panic。独立成前缀是因为「导入」是含模板下载与上传入库的一条完整链路,两个端点归在一个前缀下,也避免 `/materials/import` 被读成某个物料的 id。
成功响应 `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 字 |
| 单位 | 选填;为空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 |
| 大类 | 必填,必须是当前公司已启用的顶层分类:名称、编码或「编码 名称」写法,匹配前归一化空白 |
| 小类 | 必填,必须是该大类下已启用的小类:名称、编码或「编码 名称」写法(按大类 ID + 小类写法组合定位) |
| 编码规则 | 该小类必须存在 `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` 成功汇总,包含操作人、公司、时间、文件名、总行数和成功数;失败数为 0、状态为 `success`、失败原因为空。模板错误、主数据查询失败、行校验失败和事务失败均不写历史记录;**逐行错误明细不落库**,只在响应里返回并可由前端导出 CSV。
- 成功导入的每件物料保存完整 `new_value` JSON 快照,与单条新增保持一致;现有日志页「查看详情」首行显示「批量导入创建:物料名称(物料编码)」,下方按行显示导入时的编码、名称、规格、分类名称、单位名称和状态,并显示操作人及时间;继续使用该入口查看成功操作。该页面展示物料操作日志,不展示 `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 是一次成功导入的持久化汇总;失败不写记录。
// 保留失败字段以兼容已有表结构和历史数据,新记录失败数为 0、失败原因为空。
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 (
"archive/zip"
"bytes"
"errors"
"io"
"testing"
"github.com/xuri/excelize/v2"
)
func TestParseMaterialImportFileStopsBeforeRowsBeyondLimit(t *testing.T) {
dataRows := make([][]string, MaterialImportMaxRows+1)
for i := range dataRows {
dataRows[i] = []string{"物料", "", "", "机械结构类", "标准件"}
}
data := buildTestWorkbook(t, dataRows)
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatal(err)
}
var changed bytes.Buffer
writer := zip.NewWriter(&changed)
for _, entry := range reader.File {
source, err := entry.Open()
if err != nil {
t.Fatal(err)
}
content, err := io.ReadAll(source)
source.Close()
if err != nil {
t.Fatal(err)
}
if entry.Name == "xl/worksheets/sheet1.xml" {
// 超限行之后故意放非法 XML,证明解析器不会再读取后续行。
content = bytes.Replace(content, []byte(""),
[]byte(`
`), 1)
}
target, err := writer.Create(entry.Name)
if err != nil {
t.Fatal(err)
}
if _, err := target.Write(content); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
rows, err := ParseMaterialImportFile(bytes.NewReader(changed.Bytes()))
if !errors.Is(err, ErrImportTooManyRows) || rows != nil {
t.Fatalf("rows=%d, err=%v, want immediate ErrImportTooManyRows", len(rows), err)
}
}
func TestParseMaterialImportFileAcceptsRowLimit(t *testing.T) {
dataRows := make([][]string, MaterialImportMaxRows)
for i := range dataRows {
dataRows[i] = []string{"物料", "", "", "机械结构类", "标准件"}
}
rows, err := ParseMaterialImportFile(bytes.NewReader(buildTestWorkbook(t, dataRows)))
if err != nil || len(rows) != MaterialImportMaxRows {
t.Fatalf("rows=%d, err=%v", len(rows), err)
}
}
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) {
// 较大的工作表 XML 使用临时文件,避免压缩后很小的工作簿整表驻留内存。
f, err := excelize.OpenReader(r, excelize.Options{UnzipXMLSizeLimit: 1 << 20})
if err != nil {
if f != nil {
_ = f.Close()
}
return nil, ErrImportTemplateInvalid
}
defer f.Close()
idx, err := f.GetSheetIndex(MaterialImportSheetData)
if err != nil || idx < 0 {
return nil, ErrImportTemplateInvalid
}
iterator, err := f.Rows(MaterialImportSheetData)
if err != nil {
return nil, err
}
defer iterator.Close()
if !iterator.Next() {
return nil, ErrImportTemplateInvalid
}
header, err := iterator.Columns()
if err != nil {
return nil, err
}
columns := materialImportColumnIndex(header)
for _, required := range materialImportRequiredHeaders {
if _, ok := columns[required]; !ok {
return nil, ErrImportTemplateInvalid
}
}
rows := make([]model.MaterialImportRow, 0, 256)
rowNumber := 1
for iterator.Next() {
rowNumber++
raw, err := iterator.Columns()
if err != nil {
return nil, err
}
row := model.MaterialImportRow{
Row: rowNumber, // 包含被跳过的空行,保留真实 Excel 行号。
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
}
if len(rows) == MaterialImportMaxRows {
return nil, ErrImportTooManyRows
}
rows = append(rows, row)
}
if err := iterator.Error(); err != nil {
return nil, err
}
if err := iterator.Close(); err != nil {
return nil, err
}
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(全部 7 个函数)
- [ ] **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`
const insertMaterialBatchStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
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 materialCategoryPtr(id int64) *int64 { return &id }
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 包内没有同名常量,所以上面 Step 1 的代码块里自带了 `insertMaterialBatchStmt`(两行一批的多值语句)。行数更多的场景(如 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"
"database/sql/driver"
"encoding/json"
"errors"
"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"}))
}
}
// expectImportLog 校验成功导入的汇总记录。
func expectImportLog(mock sqlmock.Sqlmock, fileName, status string, total, success, failed int, reason interface{}) {
// 行数显式转 int64:database/sql 会把实参归一成 driver.Value(int→int64),
// sqlmock 按类型比对,传 Go int 匹配不上。
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))
}
// 记录写入错误会被服务吞掉,单纯不设置 SQL 期望无法证明没有尝试写入。
// 设一个最后的写入期望,并确认只有它未被消费,且前面的业务期望全部完成。
func expectNoImportLog(t *testing.T, mock sqlmock.Sqlmock) func() {
t.Helper()
unexpected := mock.ExpectExec(`INSERT INTO material_import_logs`).WillReturnResult(sqlmock.NewResult(9, 1))
return func() {
t.Helper()
err := mock.ExpectationsWereMet()
if err == nil {
t.Fatal("失败导入不应写入历史记录")
}
want := "there is a remaining expectation which was not matched: " + unexpected.String()
if err.Error() != want {
t.Fatal(err)
}
}
}
func TestMaterialImportServiceDoesNotRecordMasterDataFailure(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
queryErr := errors.New("主数据查询失败")
mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)).WillReturnError(queryErr)
defer expectNoImportLog(t, mock)()
data := buildTestWorkbook(t, [][]string{
{"螺栓", "", "个", "机械结构类", "标准件"},
})
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
if !errors.Is(err, queryErr) || result != nil {
t.Fatalf("result = %+v, err = %v", result, err)
}
}
func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportMasterData(mock, true)
defer expectNoImportLog(t, mock)()
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])
}
}
func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportMasterData(mock, false)
defer expectNoImportLog(t, mock)()
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)
}
}
func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
defer expectNoImportLog(t, mock)()
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)
}
}
func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportMasterData(mock, true)
// 事务内:锁规则 → 取已占用编码 → 推进序号 → 插入 → 回查 ID
// ImportTx 先把整批编码分配完并推进 code_rules,再分批插入。
expectImportPersist(mock, "A010201")
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()
defer expectNoImportLog(t, mock)()
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)
}
}
// expectImportPersist 描述一次成功入库的事务序列:锁规则 → 取已占用编码 → 推进序号
// → 插入 → 回查 ID → 写审计日志 → 提交。code 是本次分配给物料的编码。
type importSnapshot struct {
code string
}
func (expected importSnapshot) Match(value driver.Value) bool {
text, ok := value.(string)
if !ok {
return false
}
var snapshot struct {
ID int64 `json:"id"`
OrganizationID int64 `json:"organization_id"`
MaterialCode string `json:"material_code"`
Name string `json:"name"`
Spec string `json:"spec"`
CategoryID int64 `json:"category_id"`
BaseUnitID int64 `json:"base_unit_id"`
Unit string `json:"unit"`
Status string `json:"status"`
Version int `json:"version"`
}
return json.Unmarshal([]byte(text), &snapshot) == nil &&
snapshot.ID == 31 && snapshot.OrganizationID == 1 &&
snapshot.MaterialCode == expected.code && snapshot.Name == "内六角螺栓" &&
snapshot.Spec == "M8x30" && snapshot.CategoryID == 12 && snapshot.BaseUnitID == 1 &&
snapshot.Unit == "个" && snapshot.Status == "active" && snapshot.Version == 1
}
func expectImportPersist(mock sqlmock.Sqlmock, code string) {
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), code).
WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).AddRow(31, code))
description := "批量导入创建:内六角螺栓(" + code + ")\n物料编码:" + code +
"\n名称:内六角螺栓\n规格:M8x30\n分类:机械结构类 / 标准件\n单位:个\n状态:启用"
mock.ExpectExec(`INSERT INTO material_logs`).
WithArgs("CREATE", int64(31), "", importSnapshot{code: code}, "tester", description).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
}
// 界面上分类一律显示成「编码 名称」(Materials.vue 的 categoryLabel),用户整段照抄
// 进模板是常事;导入校验得认这种写法,否则分类明明存在也会报「不存在或已停用」。
func TestMaterialImportServiceAcceptsCategoryDisplayLabels(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportMasterData(mock, true)
expectImportPersist(mock, "A010201")
expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "")
data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "A 机械结构类", "A01 标准件"},
})
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 TestMaterialImportServiceAcceptsCategoryCodes(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportMasterData(mock, true)
expectImportPersist(mock, "A010201")
expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "")
data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "A", "A01"},
})
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 TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
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").
AddRow(20, 1, "B", "旧大类", "", nil, "inactive"))
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"))
// 大类查不到,小类不再定位,也就不会去查编码规则
defer expectNoImportLog(t, mock)()
data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "B 旧大类", "标准件"},
})
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
if err != nil {
t.Fatalf("import: %v", err)
}
// 断言必须点到「大类本身不存在」:小类的报错里也带着「B 旧大类」这几个字,
// 只判 Contains("大类") 会被这层巧合放过。
if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "大类「B 旧大类」") {
t.Fatalf("result = %+v", result)
}
}
// 界面上的显示名是「编码 + 半角空格 + 名称」,而中文输入法全角模式下敲出来的空格是
// U+3000、从网页复制有时带 U+00A0。两串肉眼完全一样、码点不同,不归一化就会变成
// 「从浏览器复制的能导入、自己手写的报不存在」。
func TestMaterialImportServiceAcceptsWhitespaceVariantsInCategoryNames(t *testing.T) {
cases := []struct {
name string
major string
minor string
}{
{"全角空格 U+3000", "A 机械结构类", "A01 标准件"},
{"不换行空格 U+00A0", "A 机械结构类", "A01 标准件"},
{"连续半角空格", "A 机械结构类", "A01 标准件"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportMasterData(mock, true)
expectImportPersist(mock, "A010201")
expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "")
data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", tc.major, tc.minor},
})
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)
}
})
}
}
```
- [ ] **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 (
"encoding/json"
"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
}
// materialImportItem 保留导入时匹配到的分类、单位名称,供历史日志展示。
type materialImportItem struct {
*model.Material
categoryLabel string
unitLabel string
}
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 {
return nil, err
}
materials, importErrors, err := s.validateRows(organizationID, rows)
if err != nil {
return nil, err
}
if len(importErrors) > 0 {
result := &model.MaterialImportResult{
TotalRows: len(rows),
SuccessRows: 0,
FailedRows: len(importErrors),
Errors: importErrors,
}
return result, nil
}
if err := s.persist(organizationID, operator, materials); err != nil {
return nil, err
}
result := &model.MaterialImportResult{
TotalRows: len(rows),
SuccessRows: len(materials),
FailedRows: 0,
Errors: []model.MaterialImportError{},
}
s.recordSuccessfulImport(organizationID, operator, fileName, result.TotalRows, result.SuccessRows)
return result, nil
}
// validateRows 逐行校验并组装待插入物料。返回的错误是查询主数据失败这类系统级
// 错误;逐行的问题以 importErrors 返回,两者互斥。
func (s *MaterialImportService) validateRows(organizationID int64, rows []model.MaterialImportRow) ([]*materialImportItem, []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
}
// 界面上分类一律显示成「编码 名称」(前端 categoryLabel 的拼法),用户整段
// 照抄进模板是常事,所以名称之外再登记编码与「编码 名称」两种写法。名称排
// 在最前、已有键不覆盖,与单位一样保证同名或同码时结果确定。
aliases := []string{category.Name, category.Code}
if category.Code != "" {
aliases = append(aliases, category.Code+" "+category.Name)
}
if category.ParentID == nil {
for _, alias := range aliases {
registerCategoryAlias(majors, normalizeWhitespace(alias), category)
}
continue
}
for _, alias := range aliases {
registerCategoryAlias(minors, minorCategoryKey(*category.ParentID, normalizeWhitespace(alias)), 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([]*materialImportItem, 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 字")
}
majorName := normalizeWhitespace(row.MajorName)
major := majors[majorName]
if majorName == "" {
reasons = append(reasons, "大类不能为空")
} else if major == nil {
reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用,请核对分类名称或编码", row.MajorName))
}
minorName := normalizeWhitespace(row.MinorName)
var minor *model.MaterialCategory
if minorName == "" {
reasons = append(reasons, "小类不能为空")
} else if major == nil {
// 大类已经报错,小类无法定位,不重复报
} else {
minor = minors[minorCategoryKey(major.ID, 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, &materialImportItem{Material: &model.Material{
OrganizationID: organizationID,
Name: name,
Spec: row.Spec,
CategoryID: &categoryID,
BaseUnitID: baseUnitID,
Unit: unitName,
Status: "active",
Version: 1,
}, categoryLabel: major.Name + " / " + minor.Name, unitLabel: unit.Name})
}
return materials, importErrors, nil
}
// persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。
func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*materialImportItem) error {
tx, err := repository.BeginTx()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
materialRows := make([]*model.Material, 0, len(materials))
for _, item := range materials {
materialRows = append(materialRows, item.Material)
}
if err := s.materialRepo.ImportTx(tx, organizationID, materialRows); err != nil {
return err
}
logs := make([]*model.MaterialLog, 0, len(materials))
for _, material := range materials {
newValue, err := json.Marshal(material.Material)
if err != nil {
return err
}
spec := material.Spec
if spec == "" {
spec = "—"
}
logs = append(logs, &model.MaterialLog{
OperationType: "CREATE",
MaterialID: material.ID,
NewValue: string(newValue),
Operator: operator,
Description: fmt.Sprintf("批量导入创建:%s(%s)\n物料编码:%s\n名称:%s\n规格:%s\n分类:%s\n单位:%s\n状态:%s",
material.Name, material.MaterialCode, material.MaterialCode, material.Name, spec,
material.categoryLabel, material.unitLabel, materialStatusLabel(material.Status)),
})
}
if err := s.logRepo.CreateBatchTx(tx, logs); err != nil {
return err
}
return tx.Commit()
}
// recordSuccessfulImport 仅写成功导入记录。记录失败只影响可追溯性,不应该让已经完成的导入
// 变成失败,所以这里吞掉错误。
func (s *MaterialImportService) recordSuccessfulImport(organizationID int64, operator, fileName string, total, success int) {
_ = s.importRepo.Create(&model.MaterialImportLog{
OrganizationID: organizationID,
Operator: operator,
FileName: fileName,
TotalRows: total,
SuccessRows: success,
FailedRows: 0,
Status: model.MaterialImportStatusSuccess,
FailureReason: "",
})
}
func minorCategoryKey(majorID int64, minorName string) string {
return fmt.Sprintf("%d\x00%s", majorID, minorName)
}
// normalizeWhitespace 把文本里的各种空白(半角空格、全角空格 U+3000、不换行空格
// U+00A0、制表符等)统一压成单个半角空格,并去掉首尾。
//
// 界面上的分类显示名是前端拼出来的「编码 + 半角空格 + 名称」,用户照抄进模板时,
// 那个空格在中文输入法全角模式下会变成 U+3000、从网页复制偶尔带 U+00A0——两串肉眼
// 一模一样、码点不同,不归一化就成了「浏览器复制的能导入、自己手写的报不存在」。
func normalizeWhitespace(text string) string {
return strings.Join(strings.Fields(text), " ")
}
// registerCategoryAlias 把一个写法登记进分类索引。空写法忽略;键已被别的分类占用
// 时不覆盖,保证结果不随分类的遍历顺序变化。
func registerCategoryAlias(index map[string]*model.MaterialCategory, key string, category *model.MaterialCategory) {
if key == "" {
return
}
if _, exists := index[key]; !exists {
index[key] = category
}
}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `go test ./internal/service/ -run TestMaterialImportService -v`
Expected: PASS(9 个用例;其中空白变体那条是表驱动,跑 3 组子用例)
- [ ] **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 (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/wmms/wmms/internal/service"
"github.com/wmms/wmms/pkg/response"
)
type countedImportBody struct {
io.Reader
read int64
}
func (body *countedImportBody) Read(p []byte) (int, error) {
n, err := body.Reader.Read(p)
body.read += int64(n)
return n, err
}
func (*countedImportBody) Close() error { return nil }
func TestMaterialImportRejectsLargeBodyBeforeReadingItAll(t *testing.T) {
gin.SetMode(gin.TestMode)
var payload bytes.Buffer
writer := multipart.NewWriter(&payload)
part, err := writer.CreateFormFile("file", "物料.xlsx")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(bytes.Repeat([]byte("x"), 8<<20)); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
for _, contentLength := range []int64{int64(payload.Len()), -1} {
t.Run(fmt.Sprintf("content-length=%d", contentLength), func(t *testing.T) {
body := &countedImportBody{Reader: bytes.NewReader(payload.Bytes())}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set("organization_id", int64(1))
c.Request = httptest.NewRequest(http.MethodPost, "/material-import", nil)
c.Request.Body = body
c.Request.ContentLength = contentLength
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
(&Handler{}).ImportMaterials(c)
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "5MB") {
t.Fatalf("status=%d, body=%s", recorder.Code, recorder.Body.String())
}
if body.read > service.MaterialImportMaxBytes+(64<<10)+1 {
t.Fatalf("超大请求已读取 %d 字节,应在解析前限制读取", body.read)
}
})
}
}
func TestMaterialImportFileSizeBoundaryAllowsMultipartOverhead(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, size := range []int{service.MaterialImportMaxBytes, service.MaterialImportMaxBytes + 1} {
t.Run(fmt.Sprintf("file-size=%d", size), func(t *testing.T) {
var payload bytes.Buffer
writer := multipart.NewWriter(&payload)
part, err := writer.CreateFormFile("file", "物料.xlsx")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(bytes.Repeat([]byte("x"), size)); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set("organization_id", int64(1))
c.Request = httptest.NewRequest(http.MethodPost, "/material-import", bytes.NewReader(payload.Bytes()))
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
(&Handler{materialImportSvc: service.NewMaterialImportService()}).ImportMaterials(c)
want := "模板格式不正确"
if size > service.MaterialImportMaxBytes {
want = "文件超过 5MB 上限"
}
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), want) {
t.Fatalf("status=%d, body=%s, want %s", recorder.Code, recorder.Body.String(), want)
}
})
}
}
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
}
// 在 multipart 解析前限制读取;文件限额之外仅给表单头部预留少量空间。
const maxRequestBytes = service.MaterialImportMaxBytes + (64 << 10)
if c.Request.ContentLength > maxRequestBytes {
response.BadRequest(c, "文件超过 5MB 上限或表单数据过大")
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxRequestBytes)
defer c.Request.Body.Close()
defer func() {
if c.Request.MultipartForm != nil {
_ = c.Request.MultipartForm.RemoveAll()
}
}()
file, err := c.FormFile("file")
if err != nil {
var sizeErr *http.MaxBytesError
if errors.As(err, &sizeErr) {
response.BadRequest(c, "文件超过 5MB 上限或表单数据过大")
return
}
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` 路由组之后加(独立前缀,但**不是** gin 的路由限制,理由见「设计约定 3」):
```go
// 物料批量导入单独挂前缀。这不是 gin 的限制——实测 v1.9.1 允许静态段与
// :id 同层共存,把它放进上面的 materials 组并不会冲突;独立成前缀是因为
// 「导入」是含模板下载与上传入库的一条完整链路,两个端点归在一处,
// 也避免 /materials/import 被读成某个物料的 id。
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 test ./internal/handler/ -run TestPhaseOneAndLegacyRoutesAreRegistered -v`
Expected: PASS(路由注册发生在 `NewHandler().RegisterRoutes` 内,这条用例会真正走完注册,真有冲突会在注册时 panic。注意 `go build` 只做编译、不注册路由,兜不住这类问题)
- [ ] **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'
// BOM 用转义写,不内联那个零宽字符:内联的话看不见也改不动,
// 一旦被编辑器或编码转换吃掉,测试就会以一个极难看懂的方式失败。
const BOM = '\uFEFF'
describe('buildCsv', () => {
test('用逗号分隔并在行尾换行', () => {
const csv = buildCsv([
['行号', '失败原因'],
[3, '物料名称不能为空']
])
expect(csv).toBe(BOM + '行号,失败原因\r\n3,物料名称不能为空\r\n')
})
test('包含逗号、引号、换行的字段用双引号包裹并转义', () => {
const csv = buildCsv([['规格', '备注'], ['M8,30', '含"引号"'], ['两\n行', '']])
expect(csv).toBe(BOM + '规格,备注\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 '\uFEFF' + 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 { nextTick } from 'vue'
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: '单位「吨」不存在或已停用' }
]
}
// el-table 分两轮渲染:先注册列,下一轮才把单元格填上。只等一个 tick 的话
// 拿到的是空
,断言文本会莫名其妙地失败(实测过一次),所以统一等两轮。
async function mountResult(result, options = {}) {
const wrapper = mount(MaterialImportResult, {
props: { result },
global: { plugins: [ElementPlus], stubs: { teleport: true }, ...options }
})
await nextTick()
await nextTick()
return wrapper
}
describe('MaterialImportResult', () => {
test('失败时展示行数摘要与每行错误', async () => {
const wrapper = await 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 = await mountResult(failedResult, { 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('全部成功时只展示成功摘要,不显示错误表格', async () => {
const wrapper = await 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
导出错误明细
{{ row.data.name || '—' }}
{{ row.data.spec || '—' }}
{{ row.data.unit || '—' }}
{{ row.data.major || '—' }}
{{ row.data.minor || '—' }}
```
**注意:** `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
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: [] }
})
const wrapper = mountMaterials()
await flushPromises()
// 挂载时 onMounted 已经拉过一次列表,清掉才能断言“导入后又拉了一次”
materialsStore.fetchMaterials.mockClear()
await setupState(wrapper).handleImportFile({
target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' }
})
await flushPromises()
expect(materialsApi.importFile).toHaveBeenCalled()
expect(materialsStore.fetchMaterials).toHaveBeenCalledWith(1, '', 'all')
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()
materialsStore.fetchMaterials.mockClear()
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')`——路径相对 `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: 跑测试确认失败**
Run: `cd frontend && npx vitest run src/views/Materials.test.js`
Expected: 新增的两个用例 FAIL
- [ ] **Step 3: 实现页面接入**
`frontend/src/views/Materials.vue` 改动:
1. 顶部按钮区(第 3-6 行)加两个按钮和一个隐藏的文件输入:
```vue
```
2. 在页面底部加导入结果弹窗:
```vue
```
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. `