material_manage_system/docs/superpowers/plans/2026-10-10-material-bulk-import.md

3048 lines
118 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` 成功汇总;失败导入不写历史记录,逐行错误只在响应里返回,不落库。成功物料的 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("</sheetData>"),
[]byte(`<row r="20003"><c></row></sheetData>`), 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 的话
// 拿到的是空 <tr>,断言文本会莫名其妙地失败(实测过一次),所以统一等两轮。
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
<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
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
<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: 每次成功导入新增一条 `success` 记录,失败导入不新增记录;日志页可查看成功新增物料的「批量导入创建」详情。
- [ ] **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` 只写成功汇总**:新记录状态仅为 `success`,没有中间态;保留已有表结构兼容历史数据。成功物料操作沿用现有日志页查看。