material_manage_system/docs/superpowers/plans/2026-10-10-material-bulk-import.md
文睿 张 616a4a3b1d feat: 物料增加初始单位,补充物料批量上传计划
- 物料默认单位由 pcs 改为「个」,新建/编辑表单与后端 Create 保持一致
- 单位下拉增加未选择时的默认提示
- init.sql 补充 30 个常用计量单位种子数据
- init.sql 新增 material_import_logs 表,为批量导入提供落库
- 新增物料批量导入实施计划文档,相关测试期望值同步更新
2026-10-10 14:15:49 +08:00

88 KiB
Raw Blame History

物料批量导入实现计划

For agentic workers: 使用 superpowers:executing-plans 按任务执行;步骤使用复选框(- [ ])追踪。

Goal: 物料页面提供「下载模板」与「批量导入」两个入口,用户上传填好的 .xlsx 后,后端逐行校验并在同一个请求内事务入库,失败时整批不入库并返回逐行错误明细。

Architecture: 新增 internal/service/material_import*.go 负责 xlsx 解析、模板生成与逐行校验;internal/repository/material.go 新增批量入库方法(单事务内锁编码规则、分配编码、分批多值插入);internal/handler/material_import.go 暴露两个 HTTP 接口。每次导入(无论成败)写一条 material_import_logs 记录;逐行错误只在响应里返回,不落库。

Tech Stack: Go 1.21、Gin、MySQL 8.0、github.com/xuri/excelize/v2 v2.8.1、sqlmock、Vue 3、Pinia、Element Plus、Vitest。

Spec: 本计划正文「设计约定」一节即本次交付的完整规格(源自需求方给出的《物料批量导入功能设计(最终版)》及后续澄清)。用户故事 docs/MMS-用户故事-v2.3.md 的 BAT-001、5.8.1 为背景需求。

Global Constraints

  • Excel 库固定 github.com/xuri/excelize/v2 v2.8.1:不得升级到 v2.9+,v2.9 起要求 Go ≥1.23,而 Dockerfile 使用 golang:1.21-alpine(已实测 v2.8.1 可编译、可 OpenReader/WriteToBuffer)。
  • 文件限制:仅 .xlsx,≤ 5 MB,数据区 ≤ 20,000 行(不含表头)。
  • 首版仅支持新增物料,不做更新、不做分类/单位的自动创建。
  • 物料编码由系统按小类编码规则生成,不接受 Excel 中的编码列;小类未配置启用的编码规则时该行报错。
  • 任意一行校验失败 → 整批不入库,返回全部逐行错误。
  • 所有接口必须挂 middleware.AuthMiddleware(), middleware.OrganizationMiddleware(),公司取服务端上下文,忽略请求体伪造值。
  • 注释、提交信息、用户可见文案全部用中文(与仓库既有风格一致)。
  • 提交只包含本功能涉及的文件;工作区已有的「默认单位 pcs→个」在途改动(frontend/src/views/materialForm.js、internal/service/material.go 等 7 个文件)不要一起提交。

设计约定

1. 流程

下载模板 → 填写 → 上传 → 后端解析 → 逐行校验 → 全部通过即在同一个请求内事务入库 → 返回结果。

已确认去掉「预览确认」环节:不再有暂存任务 ID、不再有确认接口,也不存在"确认时重新校验"。校验失败时整批不入库,错误明细随响应返回。

2. 模板

两个工作表:

  • 填写说明:字段名、是否必填、示例、填写规则。
  • 物料数据:第 1 行表头,数据从第 2 行开始。

表头固定 5 列:物料名称、规格、单位、大类、小类。解析时按「去空格 + 去尾部 *」比对表头,不匹配即报「模板格式不正确,请重新下载最新模板」。

分类按名称填写,后端按「大类名 + 小类名」组合在本公司内匹配(小类名在本公司内会重名,如「其他」在 A07/B07/C03/D04/F05 都叫「其他」,因此必须两级组合)。单位按名称匹配,同时兼容按编码匹配(如 kg 与 千克)。

3. 接口

方法 路径 说明
GET /api/material-import/template 下载 xlsx 模板,返回文件流
POST /api/material-import multipart/form-data,字段名 file,上传即入库

路由必须挂在独立前缀 /material-import:materials 组下已有 GET /:id,gin 的路由树不允许同一段既有静态前缀又有 :id 通配,把 /materials/import/... 加进该组会直接 panic。

成功响应 201 Created:

{
  "code": 0,
  "message": "成功导入 200 条物料",
  "data": { "total_rows": 200, "success_rows": 200, "failed_rows": 0, "errors": [] }
}

校验失败 400 Bad Request(HTTP 状态 400,但 data 带完整明细):

{
  "code": 400,
  "message": "有 3 行校验未通过,本次未导入任何数据",
  "data": {
    "total_rows": 200, "success_rows": 0, "failed_rows": 3,
    "errors": [
      { "row": 3, "data": { "name": "", "spec": "M8x30", "unit": "个", "major": "机械结构类", "minor": "标准件" }, "reason": "物料名称不能为空" }
    ]
  }
}

row 是 Excel 中的真实行号(从 1 开始,含表头行),用户可直接对着 Excel 定位。

4. 校验规则(逐行)

列 规则
物料名称 必填,去首尾空格后非空,≤ 200 字
规格 选填,≤ 200 字
单位 选填;为空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到
大类 必填,必须是当前公司已启用的顶层分类名称
小类 必填,必须是该大类下已启用的小类名称
编码规则 该小类必须存在 status='active' 的 code_rules 记录

同一行可产生多条原因,用中文分号连接。

5. 编码生成与并发

  • 入库事务内按 category_id 升序依次 SELECT ... FOR UPDATE 锁定 code_rules 行(升序加锁避免多事务互相等待造成死锁)。
  • 每个小类一次性取出已占用编码集合(SELECT material_code FROM materials WHERE organization_id=? AND material_code LIKE ?),在内存里顺序分配,跳过已占用号(库中可能存在手工录入、落在号段内的编码)。
  • 分配完成后 UPDATE code_rules SET next_sequence = ?。
  • 校验阶段不生成编码,预览/响应里也不承诺最终编码。

6. 事务与记录

  • 整批入库在一个事务内:分配编码 → 分批多值 INSERT 物料(每批 500 行)→ 回填 ID → 批量写 material_logs(每条物料一条 CREATE 审计记录,与单条新增保持一致)。
  • 任一行插入失败则整体回滚。
  • 每次导入(成功或失败)都写一条 material_import_logs:操作人、公司、时间、文件名、总行数、成功数、失败数、状态、失败原因摘要。逐行错误明细不落库,只在响应里返回。

7. 权限

沿用物料新增的权限口径:仅要求登录 + 当前公司可访问(与 POST /api/materials 相同,不额外要求 super_admin)。公司取自 X-Organization-ID 经中间件校验后的上下文。

8. 防止重复提交

去掉预览与暂存后没有服务端幂等键(设计第 7 点原文的「任务 ID 作为幂等键」随预览一起取消),靠两道前端约束:

  • 保存互斥:整个上传流程包在 runWithSaving 里,飞行期间禁止切换公司;
  • 入口状态:importing 标志守住 triggerImport,上传期间再点不会重新选文件;handleImportFile 在发起请求前就把 input 的 value 清空,避免同一个文件连续触发两次 change。

后端不做文件级去重:同一份文件重复上传会真的导入两遍(编码不同)。这是首版明确接受的取舍,见文末「已知取舍」。


文件结构

新建:

文件 职责
internal/model/material_import.go 导入相关模型:行、错误、结果、日志
internal/service/material_import_excel.go xlsx 模板生成与解析(纯 IO,不碰数据库)
internal/service/material_import.go 导入编排:解析 → 校验 → 入库 → 写日志
internal/repository/material_import.go material_import_logs 的写入与查询
internal/handler/material_import.go 两个 HTTP handler
scripts/upgrades/2026-10-10-material-bulk-import.sql 已部署库的增量升级脚本
frontend/src/utils/exportCsv.js 前端生成 CSV(UTF-8 BOM)
frontend/src/components/business/MaterialImportResult.vue 导入结果弹窗(摘要 + 错误表格 + 分页 + 导出)

修改:

文件 改动
go.mod / go.sum 新增 excelize/v2 v2.8.1
scripts/init.sql 新增 material_import_logs 表
internal/repository/material.go 新增 ImportTx(批量分配编码 + 插入)
internal/repository/material_log.go 新增 CreateBatchTx
internal/handler/api.go 注册 /material-import 路由组
pkg/response/response.go 新增 ErrorWithData(带 data 的错误响应)
frontend/src/api/materials.js 新增导入上传与模板下载
frontend/src/views/Materials.vue 顶部两个按钮 + 上传 + 结果弹窗挂载
internal/handler/routes_test.go 补两条路由断言
docs/api.md 补接口文档

测试:

文件 覆盖
internal/service/material_import_excel_test.go 模板生成、解析、表头不符、超行数、空行跳过
internal/service/material_import_test.go 逐行校验各分支、整批不入库、成功入库
internal/repository/material_import_test.go 日志写入
internal/handler/material_import_test.go 路由权限、响应结构与状态码
frontend/src/utils/exportCsv.test.js CSV 转义与 BOM
frontend/src/components/business/MaterialImportResult.test.js 摘要渲染、错误表格、导出调用

Task 1: 数据库表与模型

Files:

  • Create: internal/model/material_import.go
  • Create: scripts/upgrades/2026-10-10-material-bulk-import.sql
  • Modify: scripts/init.sql(在 code_rules 建表语句之后追加)

Interfaces:

  • Produces: model.MaterialImportRow、model.MaterialImportError、model.MaterialImportResult、model.MaterialImportLog

  • Produces: 表 material_import_logs

  • Step 1: 在 init.sql 增加建表语句

在 scripts/init.sql 的 code_rules 那行之后插入:

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,并在末尾追加迁移登记:

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:

package model

import "time"

// MaterialImportRow 是解析后的一行原始数据。Row 保留 Excel 真实行号(从 1
// 开始、含表头行),校验失败时用它定位,用户可直接对着 Excel 找。
type MaterialImportRow struct {
	Row       int
	Name      string
	Spec      string
	Unit      string
	MajorName string
	MinorName string
}

// Data 返回该行的原始内容,用于错误明细里的"原始数据"。
func (r MaterialImportRow) Data() map[string]string {
	return map[string]string{
		"name":  r.Name,
		"spec":  r.Spec,
		"unit":  r.Unit,
		"major": r.MajorName,
		"minor": r.MinorName,
	}
}

// MaterialImportError 是一行的失败原因,Reason 可能包含多条,用中文分号连接。
type MaterialImportError struct {
	Row    int               `json:"row"`
	Data   map[string]string `json:"data"`
	Reason string            `json:"reason"`
}

// MaterialImportResult 是一次导入的返回值。校验失败时 SuccessRows 为 0、
// Errors 非空;成功时 Errors 为空数组(不是 null)。
type MaterialImportResult struct {
	TotalRows   int                   `json:"total_rows"`
	SuccessRows int                   `json:"success_rows"`
	FailedRows  int                   `json:"failed_rows"`
	Errors      []MaterialImportError `json:"errors"`
}

// MaterialImportLog 是一次导入的持久化记录,成功或失败都会写一条。
type MaterialImportLog struct {
	ID             int64     `json:"id"`
	OrganizationID int64     `json:"organization_id"`
	Operator       string    `json:"operator"`
	FileName       string    `json:"file_name"`
	TotalRows      int       `json:"total_rows"`
	SuccessRows    int       `json:"success_rows"`
	FailedRows     int       `json:"failed_rows"`
	Status         string    `json:"status"`
	FailureReason  string    `json:"failure_reason"`
	CreatedAt      time.Time `json:"created_at"`
}

const (
	MaterialImportStatusSuccess = "success"
	MaterialImportStatusFailed  = "failed"
)
  • Step 4: 编译验证

Run: go build ./... Expected: 通过。

  • Step 5: 重建本地数据库(旧库无增量升级路径)

Run: docker compose down -v && docker compose up -d db 说明:本地库只认 init.sql,不执行 upgrades/ 脚本;这一步之后表才会存在。

  • Step 6: Commit
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

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:

package service

import (
	"bytes"
	"errors"
	"testing"

	"github.com/xuri/excelize/v2"
)

func TestParseMaterialImportFileReadsRowsWithExcelRowNumbers(t *testing.T) {
	data := buildTestWorkbook(t, [][]string{
		{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
		{"", "", "", "", ""}, // 整行空,应被跳过
		{"急停按钮", "XB2", "个", "电气元器件类", "成品件"},
	})

	rows, err := ParseMaterialImportFile(bytes.NewReader(data))
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	if len(rows) != 2 {
		t.Fatalf("rows = %d, want 2", len(rows))
	}
	if rows[0].Row != 2 || rows[0].Name != "内六角螺栓" || rows[0].MinorName != "标准件" {
		t.Fatalf("first row = %+v", rows[0])
	}
	// 第 3 行是空行被跳过,所以第二条数据是 Excel 第 4 行
	if rows[1].Row != 4 || rows[1].Name != "急停按钮" {
		t.Fatalf("second row = %+v", rows[1])
	}
}

func TestParseMaterialImportFileKeepsRowsWithAnyContent(t *testing.T) {
	// 名称填了、其余为空:不是空行,必须保留交给校验去报错
	data := buildTestWorkbook(t, [][]string{
		{"只有名称", "", "", "", ""},
	})
	rows, err := ParseMaterialImportFile(bytes.NewReader(data))
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	if len(rows) != 1 || rows[0].Name != "只有名称" {
		t.Fatalf("rows = %+v", rows)
	}
}

func TestParseMaterialImportFileRejectsWrongHeader(t *testing.T) {
	f := excelize.NewFile()
	defer f.Close()
	f.SetSheetName("Sheet1", MaterialImportSheetData)
	f.SetCellValue(MaterialImportSheetData, "A1", "物料名称")
	f.SetCellValue(MaterialImportSheetData, "B1", "规格")
	buf, err := f.WriteToBuffer()
	if err != nil {
		t.Fatalf("build workbook: %v", err)
	}

	_, err = ParseMaterialImportFile(bytes.NewReader(buf.Bytes()))
	if !errors.Is(err, ErrImportTemplateInvalid) {
		t.Fatalf("err = %v, want ErrImportTemplateInvalid", err)
	}
}

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()
	f := excelize.NewFile()
	defer f.Close()
	f.SetSheetName("Sheet1", MaterialImportSheetData)
	for i, header := range materialImportHeaders {
		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:

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{"物料名称", "规格", "单位", "大类", "小类"}

// BuildMaterialImportTemplate 生成导入模板:一张填写说明表 + 一张空白数据表。
func BuildMaterialImportTemplate() ([]byte, error) {
	f := excelize.NewFile()
	defer f.Close()

	if err := f.SetSheetName("Sheet1", MaterialImportSheetData); err != nil {
		return nil, err
	}
	if _, err := f.NewSheet(MaterialImportSheetGuide); err != nil {
		return nil, err
	}
	// 数据表放在最前面,用户打开文件即停在填写页。
	idx, err := f.GetSheetIndex(MaterialImportSheetData)
	if err != nil {
		return nil, err
	}
	f.SetActiveSheet(idx)

	guide := [][]string{
		{"字段", "是否必填", "示例", "填写规则"},
		{"物料名称", "必填", "内六角螺栓", "最长 200 字"},
		{"规格", "选填", "M8x30", "最长 200 字"},
		{"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"},
		{"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称"},
		{"小类", "必填", "标准件", "填该大类下已启用的小类名称,且该小类必须已配置编码规则"},
		{"", "", "", ""},
		{"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"},
	}
	for r, row := range guide {
		for c, value := range row {
			cell, err := excelize.CoordinatesToCellName(c+1, r+1)
			if err != nil {
				return nil, err
			}
			if err := f.SetCellValue(MaterialImportSheetGuide, cell, value); err != nil {
				return nil, err
			}
		}
	}
	for i := range guide[0] {
		cell, _ := excelize.CoordinatesToCellName(i+1, 1)
		_ = f.SetCellStyle(MaterialImportSheetGuide, cell, cell, guideHeaderStyle(f))
	}
	_ = f.SetColWidth(MaterialImportSheetGuide, "A", "A", 14)
	_ = f.SetColWidth(MaterialImportSheetGuide, "B", "D", 24)

	for i, header := range materialImportHeaders {
		cell, err := excelize.CoordinatesToCellName(i+1, 1)
		if err != nil {
			return nil, err
		}
		if err := f.SetCellValue(MaterialImportSheetData, cell, header); err != nil {
			return nil, err
		}
		if err := f.SetCellStyle(MaterialImportSheetData, cell, cell, guideHeaderStyle(f)); err != nil {
			return nil, err
		}
	}
	_ = f.SetColWidth(MaterialImportSheetData, "A", "E", 18)

	buf, err := f.WriteToBuffer()
	if err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}

// guideHeaderStyle 返回表头加粗样式;样式创建失败不影响模板可用,调用方忽略错误。
func guideHeaderStyle(f *excelize.File) int {
	style, err := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}})
	if err != nil {
		return 0
	}
	return style
}

// ParseMaterialImportFile 解析上传的 xlsx,返回带 Excel 行号的数据行。
// 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。
func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) {
	f, err := excelize.OpenReader(r)
	if err != nil {
		return nil, ErrImportTemplateInvalid
	}
	defer f.Close()

	idx, err := f.GetSheetIndex(MaterialImportSheetData)
	if err != nil || idx < 0 {
		return nil, ErrImportTemplateInvalid
	}

	all, err := f.GetRows(MaterialImportSheetData)
	if err != nil {
		return nil, err
	}
	if len(all) == 0 || !materialImportHeaderMatches(all[0]) {
		return nil, ErrImportTemplateInvalid
	}

	rows := make([]model.MaterialImportRow, 0, len(all)-1)
	for i, raw := range all[1:] {
		row := model.MaterialImportRow{
			Row:       i + 2, // Excel 行号:表头占第 1 行
			Name:      materialImportCell(raw, 0),
			Spec:      materialImportCell(raw, 1),
			Unit:      materialImportCell(raw, 2),
			MajorName: materialImportCell(raw, 3),
			MinorName: materialImportCell(raw, 4),
		}
		if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" {
			continue
		}
		rows = append(rows, row)
	}

	if len(rows) > MaterialImportMaxRows {
		return nil, ErrImportTooManyRows
	}
	return rows, nil
}

// materialImportHeaderMatches 比对表头:允许用户加空格,也允许末尾带 * 标记必填。
func materialImportHeaderMatches(header []string) bool {
	for i, want := range materialImportHeaders {
		got := strings.TrimSuffix(strings.TrimSpace(materialImportCell(header, i)), "*")
		if strings.TrimSpace(got) != want {
			return false
		}
	}
	return true
}

func materialImportCell(row []string, index int) string {
	if index >= len(row) {
		return ""
	}
	return strings.TrimSpace(row[index])
}
  • Step 5: 跑测试确认通过

Run: go test ./internal/service/ -run 'MaterialImport(File|Template)' -v Expected: PASS(全部 4 个函数)

  • Step 6: Commit
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:

package repository

import (
	"fmt"
	"regexp"
	"strings"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/wmms/wmms/internal/model"
	"github.com/wmms/wmms/pkg/database"
)

const importLockRuleQuery = `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE`

func newImportMock(t *testing.T) (sqlmock.Sqlmock, func()) {
	t.Helper()
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("open sql mock: %v", err)
	}
	previous := database.DB
	database.DB = db
	return mock, func() {
		database.DB = previous
		db.Close()
	}
}

func TestMaterialRepoImportTxAllocatesCodesAndInsertsOneByOne(t *testing.T) {
	mock, cleanup := newImportMock(t)
	defer cleanup()

	mock.ExpectBegin()
	mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)).
		WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}).
			AddRow(5, 1, 12, "A01", 4, 201))
	// 已占用编码探测:A010201 已被手工录入占用,应跳到 A010202
	mock.ExpectQuery(regexp.QuoteMeta(`SELECT material_code FROM materials WHERE organization_id = ? AND material_code LIKE ?`)).
		WithArgs(int64(1), "A01%").
		WillReturnRows(sqlmock.NewRows([]string{"material_code"}).AddRow("A010201"))
	mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).
		WithArgs(int64(204), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1))
	// 两行折叠成一条多值 INSERT
	mock.ExpectExec(regexp.QuoteMeta(insertMaterialBatchStmt)).
		WithArgs(
			int64(1), "A010202", "螺栓", nil, "", nil, int64(12), nil, "个", "active", 1,
			int64(1), "A010203", "螺母", nil, "", nil, int64(12), nil, "个", "active", 1,
		).
		WillReturnResult(sqlmock.NewResult(31, 2))
	mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (?,?)`)).
		WithArgs(int64(1), "A010202", "A010203").
		WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).
			AddRow(31, "A010202").AddRow(32, "A010203"))

	tx, err := database.DB.Begin()
	if err != nil {
		t.Fatalf("begin: %v", err)
	}
	categoryID := int64(12)
	materials := []*model.Material{
		{OrganizationID: 1, Name: "螺栓", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1},
		{OrganizationID: 1, Name: "螺母", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1},
	}
	if err := NewMaterialRepo().ImportTx(tx, 1, materials); err != nil {
		t.Fatalf("import: %v", err)
	}
	if materials[0].MaterialCode != "A010202" || materials[0].ID != 31 {
		t.Fatalf("first = %+v", materials[0])
	}
	if materials[1].MaterialCode != "A010203" || materials[1].ID != 32 {
		t.Fatalf("second = %+v", materials[1])
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatal(err)
	}
}

func TestMaterialRepoImportTxReportsMissingCodeRule(t *testing.T) {
	mock, cleanup := newImportMock(t)
	defer cleanup()

	mock.ExpectBegin()
	mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)).
		WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}))

	tx, err := database.DB.Begin()
	if err != nil {
		t.Fatalf("begin: %v", err)
	}
	materials := []*model.Material{
		{OrganizationID: 1, Name: "螺栓", CategoryID: materialCategoryPtr(12), Unit: "个", Status: "active", Version: 1},
	}
	err = NewMaterialRepo().ImportTx(tx, 1, materials)
	if err == nil || !strings.Contains(err.Error(), "编码规则") {
		t.Fatalf("err = %v, want 编码规则错误", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatal(err)
	}
}

func TestMaterialRepoImportTxBatchesInsertsOverFiveHundredRows(t *testing.T) {
	// 501 行 → 两批插入(500 + 1)。插入语句与回查参数很长,这里用子串松匹配,
	// 只断言"分了两批"以及编码序号按批连续。
	mock, cleanup := newImportMock(t)
	defer cleanup()

	mock.ExpectBegin()
	mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)).
		WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}).
			AddRow(5, 1, 12, "A01", 4, 1))
	mock.ExpectQuery(`SELECT material_code FROM materials`).
		WillReturnRows(sqlmock.NewRows([]string{"material_code"}))
	mock.ExpectExec(`UPDATE code_rules SET next_sequence`).WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(1000, 500))
	mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(idRowsFor(1, 500))
	mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(1500, 1))
	mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(idRowsFor(501, 501))

	tx, err := database.DB.Begin()
	if err != nil {
		t.Fatalf("begin: %v", err)
	}
	categoryID := int64(12)
	materials := make([]*model.Material, 0, 501)
	for i := 0; i < 501; i++ {
		materials = append(materials, &model.Material{
			OrganizationID: 1, Name: "物料", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1,
		})
	}
	if err := NewMaterialRepo().ImportTx(tx, 1, materials); err != nil {
		t.Fatalf("import: %v", err)
	}
	if materials[0].MaterialCode != "A010001" || materials[500].MaterialCode != "A010501" {
		t.Fatalf("codes = %s ... %s", materials[0].MaterialCode, materials[500].MaterialCode)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatal(err)
	}
}

// idRowsFor 造出回查语句的返回行:序号 [from, to] 对应的物料 ID 与编码。
func idRowsFor(from, to int) *sqlmock.Rows {
	rows := sqlmock.NewRows([]string{"id", "material_code"})
	for i := from; i <= to; i++ {
		rows.AddRow(int64(i), fmt.Sprintf("A01%04d", i))
	}
	return rows
}

func TestMaterialLogRepoCreateBatchTx(t *testing.T) {
	mock, cleanup := newImportMock(t)
	defer cleanup()

	mock.ExpectBegin()
	mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 2))

	tx, err := database.DB.Begin()
	if err != nil {
		t.Fatalf("begin: %v", err)
	}
	logs := []*model.MaterialLog{
		{OperationType: "CREATE", MaterialID: 31, Operator: "tester", Description: "批量导入"},
		{OperationType: "CREATE", MaterialID: 32, Operator: "tester", Description: "批量导入"},
	}
	if err := NewMaterialLogRepo().CreateBatchTx(tx, logs); err != nil {
		t.Fatalf("create batch: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatal(err)
	}
}

注意: 仓库里 insertMaterialStmt 定义在 internal/service/material_test.go(service 包),repository 包内没有同名常量。批量插入是多值语句,所以要在 internal/repository/material_import_test.go 里按参与行数拼出期望文本。测试里用到的是两行一批:

const insertMaterialBatchStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`

行数更多的场景(如 501 行那一条)用 mock.ExpectExec("INSERT INTO materials") 松匹配即可,不必拼 501 组占位符。

  • Step 2: 跑测试确认失败

Run: go test ./internal/repository/ -run 'ImportTx|CreateBatchTx' -v Expected: FAIL / 编译错误 undefined: ImportTx

  • Step 3: 实现批量入库

在 internal/repository/material.go 末尾追加(不要修改已有函数):

// 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 末尾追加:

// 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
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 末尾追加:

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:

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
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:

package service

import (
	"bytes"
	"regexp"
	"strings"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/wmms/wmms/pkg/database"
)

const importCategoryQuery = `SELECT id, organization_id, code, name, description, parent_id, status FROM material_categories WHERE organization_id = ? ORDER BY code`
const importUnitQuery = `SELECT id, organization_id, code, name, precision_scale, status FROM units WHERE organization_id = ? ORDER BY code`
const importRuleQuery = `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence, status FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active'`

func newImportServiceMock(t *testing.T) (sqlmock.Sqlmock, func()) {
	t.Helper()
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("open sql mock: %v", err)
	}
	previous := database.DB
	database.DB = db
	return mock, func() {
		database.DB = previous
		db.Close()
	}
}

func expectImportMasterData(mock sqlmock.Sqlmock, withRule bool) {
	mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)).
		WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "description", "parent_id", "status"}).
			AddRow(3, 1, "A", "机械结构类", "", nil, "active").
			AddRow(12, 1, "A01", "标准件", "", 3, "active"))
	mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
		WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
			AddRow(1, 1, "个", "个", 0, "active"))
	if withRule {
		mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)).
			WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence", "status"}).
				AddRow(5, 1, 12, "A01", 4, 201, "active"))
	} else {
		mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)).
			WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}))
	}
}

func expectImportLog(mock sqlmock.Sqlmock, status string, total, success, failed int, reason string) {
	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", total, success, failed, status, reason).
		WillReturnResult(sqlmock.NewResult(9, 1))
}

func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing.T) {
	mock, cleanup := newImportServiceMock(t)
	defer cleanup()

	expectImportMasterData(mock, true)
	expectImportLog(mock, "failed", 2, 0, 2, sqlmock.AnyArg())

	data := buildTestWorkbook(t, [][]string{
		{"", "", "", "机械结构类", "标准件"},        // 名称缺失
		{"螺栓", "", "吨", "不存在的类", "标准件"}, // 大类不存在
	})
	result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
	if err != nil {
		t.Fatalf("import: %v", err)
	}
	if result.SuccessRows != 0 || result.FailedRows != 2 || len(result.Errors) != 2 {
		t.Fatalf("result = %+v", result)
	}
	if result.Errors[0].Row != 2 || !strings.Contains(result.Errors[0].Reason, "物料名称") {
		t.Fatalf("first error = %+v", result.Errors[0])
	}
	if result.Errors[1].Row != 3 || !strings.Contains(result.Errors[1].Reason, "大类") {
		t.Fatalf("second error = %+v", result.Errors[1])
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatal(err)
	}
}

func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) {
	mock, cleanup := newImportServiceMock(t)
	defer cleanup()

	expectImportMasterData(mock, false)
	expectImportLog(mock, "failed", 1, 0, 1, sqlmock.AnyArg())

	data := buildTestWorkbook(t, [][]string{
		{"螺栓", "", "个", "机械结构类", "标准件"},
	})
	result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
	if err != nil {
		t.Fatalf("import: %v", err)
	}
	if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "编码规则") {
		t.Fatalf("result = %+v", result)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatal(err)
	}
}

func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) {
	mock, cleanup := newImportServiceMock(t)
	defer cleanup()

	expectImportLog(mock, "failed", 0, 0, 0, sqlmock.AnyArg())

	result, err := NewMaterialImportService().Import(1, "tester", "错的.xlsx", strings.NewReader("not an xlsx"))
	if err == nil {
		t.Fatal("期望返回模板错误")
	}
	if result != nil {
		t.Fatalf("result = %+v, want nil", result)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatal(err)
	}
}

func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) {
	mock, cleanup := newImportServiceMock(t)
	defer cleanup()

	expectImportMasterData(mock, true)
	// 事务内:锁规则 → 取已占用编码 → 插入 → 推进序号 → 回查 ID
	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(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(31, 1))
	mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).
		WithArgs(int64(202), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectQuery(`SELECT id, material_code FROM materials WHERE organization_id = \? AND material_code IN \(\?\)`).
		WithArgs(int64(1), "A010201").
		WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).AddRow(31, "A010201"))
	mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectCommit()
	expectImportLog(mock, "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(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}))
	mock.ExpectRollback()
	expectImportLog(mock, "failed", 1, 0, 1, sqlmock.AnyArg())

	data := buildTestWorkbook(t, [][]string{
		{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
	})
	result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
	if err == nil {
		t.Fatal("期望入库失败")
	}
	if result != nil {
		t.Fatalf("result = %+v, want nil", result)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatal(err)
	}
}
  • Step 2: 跑测试确认失败

Run: go test ./internal/service/ -run TestMaterialImportService -v Expected: FAIL / 编译错误 undefined: NewMaterialImportService

  • Step 3: 实现

创建 internal/service/material_import.go:

package service

import (
	"fmt"
	"io"
	"strings"

	"github.com/wmms/wmms/internal/model"
	"github.com/wmms/wmms/internal/repository"
)

// MaterialImportService 负责一次物料批量导入:解析、逐行校验、事务入库、写记录。
// 首版只支持新增;校验阶段不生成编码,编码在入库事务里按小类规则分配。
type MaterialImportService struct {
	materialRepo *repository.MaterialRepo
	masterRepo   *repository.MasterDataRepository
	logRepo      *repository.MaterialLogRepo
	importRepo   *repository.MaterialImportLogRepo
}

func NewMaterialImportService() *MaterialImportService {
	return &MaterialImportService{
		materialRepo: repository.NewMaterialRepo(),
		masterRepo:   repository.NewMasterDataRepository(),
		logRepo:      repository.NewMaterialLogRepo(),
		importRepo:   repository.NewMaterialImportLogRepo(),
	}
}

// Import 处理一次上传。返回值约定:
//   - 校验未通过:返回 result(含逐行错误)与 nil error,前端按明细展示;
//   - 模板或文件不合法:返回 nil 与错误,前端提示原因;
//   - 入库失败:返回 nil 与错误,事务已回滚,整批未落库。
//
// 三种情况都会写一条导入记录。
func (s *MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) {
	if organizationID <= 0 {
		return nil, ErrInvalidOrganizationID
	}

	rows, err := ParseMaterialImportFile(r)
	if err != nil {
		s.recordImport(organizationID, operator, fileName, 0, 0, 0, model.MaterialImportStatusFailed, err.Error())
		return nil, err
	}

	materials, importErrors, err := s.validateRows(organizationID, rows)
	if err != nil {
		s.recordImport(organizationID, operator, fileName, len(rows), 0, len(rows), model.MaterialImportStatusFailed, err.Error())
		return nil, err
	}
	if len(importErrors) > 0 {
		result := &model.MaterialImportResult{
			TotalRows:   len(rows),
			SuccessRows: 0,
			FailedRows:  len(importErrors),
			Errors:      importErrors,
		}
		s.recordImport(organizationID, operator, fileName, result.TotalRows, 0, result.FailedRows,
			model.MaterialImportStatusFailed, summarizeImportErrors(importErrors))
		return result, nil
	}

	if err := s.persist(organizationID, operator, materials); err != nil {
		s.recordImport(organizationID, operator, fileName, len(rows), 0, len(rows), model.MaterialImportStatusFailed, err.Error())
		return nil, err
	}

	result := &model.MaterialImportResult{
		TotalRows:   len(rows),
		SuccessRows: len(materials),
		FailedRows:  0,
		Errors:      []model.MaterialImportError{},
	}
	s.recordImport(organizationID, operator, fileName, result.TotalRows, result.SuccessRows, 0, model.MaterialImportStatusSuccess, "")
	return result, nil
}

// validateRows 逐行校验并组装待插入物料。返回的错误是查询主数据失败这类系统级
// 错误;逐行的问题以 importErrors 返回,两者互斥。
func (s *MaterialImportService) validateRows(organizationID int64, rows []model.MaterialImportRow) ([]*model.Material, []model.MaterialImportError, error) {
	categories, err := s.masterRepo.ListCategories(organizationID)
	if err != nil {
		return nil, nil, err
	}
	units, err := s.masterRepo.ListUnits(organizationID)
	if err != nil {
		return nil, nil, err
	}

	majors := make(map[string]*model.MaterialCategory)
	minors := make(map[string]*model.MaterialCategory) // key: 大类ID + "\x00" + 小类名
	for i := range categories {
		category := &categories[i]
		if category.Status != "active" {
			continue
		}
		if category.ParentID == nil {
			majors[category.Name] = category
			continue
		}
		minors[minorCategoryKey(*category.ParentID, category.Name)] = category
	}
	unitByName := make(map[string]*model.Unit)
	unitByCode := make(map[string]*model.Unit)
	for i := range units {
		unit := &units[i]
		if unit.Status != "active" {
			continue
		}
		// 名称和编码各自只认第一条,避免同名单位覆盖成不确定的结果
		if _, exists := unitByName[unit.Name]; !exists {
			unitByName[unit.Name] = unit
		}
		if _, exists := unitByCode[unit.Code]; !exists {
			unitByCode[unit.Code] = unit
		}
	}

	materials := make([]*model.Material, 0, len(rows))
	importErrors := make([]model.MaterialImportError, 0)
	// 同一小类的规则只查一次:两万行通常只落在少数几个小类上,
	// 逐行去查会把一次导入放大成两万次往返。
	ruleAvailable := make(map[int64]bool)

	for _, row := range rows {
		reasons := make([]string, 0, 3)

		name := row.Name
		if name == "" {
			reasons = append(reasons, "物料名称不能为空")
		} else if len([]rune(name)) > 200 {
			reasons = append(reasons, "物料名称超过 200 字")
		}
		if len([]rune(row.Spec)) > 200 {
			reasons = append(reasons, "规格超过 200 字")
		}

		major := majors[row.MajorName]
		if row.MajorName == "" {
			reasons = append(reasons, "大类不能为空")
		} else if major == nil {
			reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用", row.MajorName))
		}

		var minor *model.MaterialCategory
		if row.MinorName == "" {
			reasons = append(reasons, "小类不能为空")
		} else if major == nil {
			// 大类已经报错,小类无法定位,不重复报
		} else {
			minor = minors[minorCategoryKey(major.ID, row.MinorName)]
			if minor == nil {
				reasons = append(reasons, fmt.Sprintf("小类「%s」在「%s」下不存在或已停用", row.MinorName, row.MajorName))
			}
		}

		// 单位留空时按单条新增的口径默认「个」;填了则先按名称、再按编码匹配,
		// 因为像「kg」和「千克」这种编码与名称不一致的单位两种写法都合理。
		unitName := row.Unit
		if unitName == "" {
			unitName = "个"
		}
		unit := unitByName[unitName]
		if unit == nil {
			unit = unitByCode[unitName]
		}
		if unit == nil {
			if row.Unit == "" {
				reasons = append(reasons, "本单位缺少默认单位「个」,请在模板中填写单位")
			} else {
				reasons = append(reasons, fmt.Sprintf("单位「%s」不存在或已停用", row.Unit))
			}
		}

		if minor != nil {
			available, checked := ruleAvailable[minor.ID]
			if !checked {
				rule, err := s.masterRepo.FindActiveCodeRule(organizationID, minor.ID)
				if err != nil {
					return nil, nil, err
				}
				available = rule != nil
				ruleAvailable[minor.ID] = available
			}
			if !available {
				reasons = append(reasons, fmt.Sprintf("小类「%s」未配置编码规则", row.MinorName))
			}
		}

		if len(reasons) > 0 {
			importErrors = append(importErrors, model.MaterialImportError{
				Row:    row.Row,
				Data:   row.Data(),
				Reason: strings.Join(reasons, ";"),
			})
			continue
		}

		var baseUnitID *int64
		if unit != nil {
			id := unit.ID
			baseUnitID = &id
		}
		categoryID := minor.ID
		materials = append(materials, &model.Material{
			OrganizationID: organizationID,
			Name:           name,
			Spec:           row.Spec,
			CategoryID:     &categoryID,
			BaseUnitID:     baseUnitID,
			Unit:           unitName,
			Status:         "active",
			Version:        1,
		})
	}

	return materials, importErrors, nil
}

// persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。
func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*model.Material) error {
	tx, err := repository.BeginTx()
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()

	if err := s.materialRepo.ImportTx(tx, organizationID, materials); err != nil {
		return err
	}

	logs := make([]*model.MaterialLog, 0, len(materials))
	for _, material := range materials {
		logs = append(logs, &model.MaterialLog{
			OperationType: "CREATE",
			MaterialID:    material.ID,
			Operator:      operator,
			Description:   "批量导入创建",
		})
	}
	if err := s.logRepo.CreateBatchTx(tx, logs); err != nil {
		return err
	}
	return tx.Commit()
}

// recordImport 写导入记录。记录失败只影响可追溯性,不应该让已经完成的导入
// 变成失败,所以这里吞掉错误。
func (s *MaterialImportService) recordImport(organizationID int64, operator, fileName string, total, success, failed int, status, reason string) {
	if len([]rune(reason)) > 1000 {
		reason = string([]rune(reason)[:1000])
	}
	_ = s.importRepo.Create(&model.MaterialImportLog{
		OrganizationID: organizationID,
		Operator:       operator,
		FileName:       fileName,
		TotalRows:      total,
		SuccessRows:    success,
		FailedRows:     failed,
		Status:         status,
		FailureReason:  reason,
	})
}

// summarizeImportErrors 生成给记录表用的失败原因摘要:前 5 行行号 + 总数。
func summarizeImportErrors(importErrors []model.MaterialImportError) string {
	rows := make([]string, 0, 5)
	for i, item := range importErrors {
		if i == 5 {
			break
		}
		rows = append(rows, fmt.Sprintf("%d", item.Row))
	}
	summary := fmt.Sprintf("共 %d 行校验未通过(行号:%s", len(importErrors), strings.Join(rows, "、"))
	if len(importErrors) > 5 {
		summary += " 等"
	}
	return summary + ")"
}

func minorCategoryKey(majorID int64, minorName string) string {
	return fmt.Sprintf("%d\x00%s", majorID, minorName)
}
  • Step 4: 跑测试确认通过

Run: go test ./internal/service/ -run TestMaterialImportService -v Expected: PASS(5 个测试)

  • Step 5: 跑全仓测试

Run: go test ./... 2>&1 | grep -v "^ok\|no test files" Expected: 无 FAIL

  • Step 6: Commit
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 之后追加:

// 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:

package handler

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/wmms/wmms/pkg/response"
)

func TestErrorWithDataKeepsPayload(t *testing.T) {
	gin.SetMode(gin.TestMode)
	recorder := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(recorder)

	response.ErrorWithData(c, http.StatusBadRequest, "有 1 行校验未通过", map[string]interface{}{
		"total_rows": 1, "failed_rows": 1,
	})

	if recorder.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", recorder.Code)
	}
	body := recorder.Body.String()
	if !strings.Contains(body, "有 1 行校验未通过") || !strings.Contains(body, `"failed_rows":1`) {
		t.Fatalf("body = %s", body)
	}
}

并在 internal/handler/routes_test.go 的 TestPhaseOneAndLegacyRoutesAreRegistered 里,往 want 切片的 "POST /api/logs" 那一行之后追加(该切片每项是 "方法 路径" 字符串):

		"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:

package handler

import (
	"errors"
	"net/http"
	"net/url"
	"strconv"

	"github.com/gin-gonic/gin"
	"github.com/wmms/wmms/internal/middleware"
	"github.com/wmms/wmms/internal/service"
	"github.com/wmms/wmms/pkg/response"
)

// DownloadMaterialImportTemplate 返回物料导入模板文件。模板随代码生成,
// 不落盘,保证下载到的永远和当前校验规则一致。
func (h *Handler) DownloadMaterialImportTemplate(c *gin.Context) {
	data, err := service.BuildMaterialImportTemplate()
	if err != nil {
		response.InternalError(c, "模板生成失败")
		return
	}
	fileName := url.PathEscape("物料导入模板.xlsx")
	c.Header("Content-Disposition", `attachment; filename="material-import-template.xlsx"; filename*=UTF-8''`+fileName)
	c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
}

// ImportMaterials 接收上传的 xlsx,校验通过即整批入库。
// 校验失败返回 400 且 data 里带逐行错误明细。
func (h *Handler) ImportMaterials(c *gin.Context) {
	orgID, ok := organizationID(c)
	if !ok {
		return
	}

	file, err := c.FormFile("file")
	if err != nil {
		response.BadRequest(c, "请选择要导入的 xlsx 文件")
		return
	}
	if file.Size > service.MaterialImportMaxBytes {
		response.BadRequest(c, "文件超过 5MB 上限")
		return
	}

	source, err := file.Open()
	if err != nil {
		response.BadRequest(c, "文件读取失败")
		return
	}
	defer source.Close()

	operator := middleware.GetCurrentUsername(c)
	if operator == "" {
		operator = "系统"
	}

	result, err := h.materialImportSvc.Import(orgID, operator, file.Filename, source)
	if err != nil {
		if errors.Is(err, service.ErrImportTemplateInvalid) || errors.Is(err, service.ErrImportTooManyRows) {
			response.BadRequest(c, err.Error())
			return
		}
		response.InternalError(c, err.Error())
		return
	}
	if result.FailedRows > 0 {
		response.ErrorWithData(c, http.StatusBadRequest,
			"有 "+strconv.Itoa(result.FailedRows)+" 行校验未通过,本次未导入任何数据", result)
		return
	}
	response.Created(c, "成功导入 "+strconv.Itoa(result.SuccessRows)+" 条物料", result)
}
  • Step 5: 注册路由与服务

internal/handler/api.go:

  1. Handler 结构体加字段:
	materialImportSvc *service.MaterialImportService
  1. NewHandler() 加:
		materialImportSvc: service.NewMaterialImportService(),
  1. 在 materials 路由组之后加(必须是独立前缀):
		// 物料批量导入单独挂前缀:materials 组已有 GET /:id,
		// gin 的路由树不允许同段既有静态前缀又有通配参数。
		materialImport := api.Group("/material-import")
		materialImport.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware())
		{
			materialImport.GET("/template", h.DownloadMaterialImportTemplate)
			materialImport.POST("", h.ImportMaterials)
		}
  • Step 6: 跑测试确认通过

Run: go test ./internal/handler/ -v -run 'ErrorWithData|Routes' Expected: PASS

  • Step 7: 起服务验证路由不 panic

Run: go build -o server.exe ./cmd/server && echo BUILD_OK Expected: BUILD_OK(gin 路由冲突会在注册时 panic,构建通过后由下一步的路由测试兜住)

  • Step 8: Commit
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:

import { describe, expect, test } from 'vitest'
import { buildCsv } from './exportCsv'

describe('buildCsv', () => {
  test('用逗号分隔并在行尾换行', () => {
    const csv = buildCsv([
      ['行号', '失败原因'],
      [3, '物料名称不能为空']
    ])
    expect(csv).toBe('行号,失败原因\r\n3,物料名称不能为空\r\n')
  })

  test('包含逗号、引号、换行的字段用双引号包裹并转义', () => {
    const csv = buildCsv([['规格', '备注'], ['M8,30', '含"引号"'], ['两\n行', '']])
    expect(csv).toBe('规格,备注\r\n"M8,30","含""引号"""\r\n"两\n行",\r\n')
  })
})
  • Step 2: 跑测试确认失败

Run: cd frontend && npx vitest run src/utils/exportCsv.test.js Expected: FAIL(模块不存在)

  • Step 3: 实现

创建 frontend/src/utils/exportCsv.js:

// 生成 CSV 文本。带 UTF-8 BOM,Excel 打开中文才不会乱码;字段里的
// 逗号、引号、换行按 RFC 4180 用双引号包裹并转义。
export function buildCsv(rows) {
  const lines = rows.map(row => row.map(escapeCsvCell).join(','))
  return '' + lines.map(line => line + '\r\n').join('')
}

function escapeCsvCell(value) {
  const text = value === null || value === undefined ? '' : String(value)
  if (/[",\r\n]/.test(text)) {
    return `"${text.replace(/"/g, '""')}"`
  }
  return text
}

// 触发浏览器下载。用 Blob + a[download],不依赖任何第三方库。
export function downloadCsv(fileName, csv) {
  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
  const url = URL.createObjectURL(blob)
  const link = document.createElement('a')
  link.href = url
  link.download = fileName
  document.body.appendChild(link)
  link.click()
  document.body.removeChild(link)
  URL.revokeObjectURL(url)
}
  • Step 4: 跑测试确认通过

Run: cd frontend && npx vitest run src/utils/exportCsv.test.js Expected: PASS

  • Step 5: 加 API

在 frontend/src/api/materials.js 的 materialsApi 对象里追加两个方法(放在 uploadImage 之后):

  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()
    })
  }

并在文件顶部补上取头信息的辅助:

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
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:

import { mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { describe, expect, test, vi } from 'vitest'
import MaterialImportResult from './MaterialImportResult.vue'

const failedResult = {
  total_rows: 3,
  success_rows: 0,
  failed_rows: 2,
  errors: [
    { row: 2, data: { name: '', spec: 'M8x30', unit: '个', major: '机械结构类', minor: '标准件' }, reason: '物料名称不能为空' },
    { row: 4, data: { name: '螺栓', spec: '', unit: '吨', major: '机械结构类', minor: '标准件' }, reason: '单位「吨」不存在或已停用' }
  ]
}

function mountResult(result) {
  return mount(MaterialImportResult, {
    props: { result },
    global: { plugins: [ElementPlus], stubs: { teleport: true } }
  })
}

describe('MaterialImportResult', () => {
  test('失败时展示行数摘要与每行错误', () => {
    const wrapper = mountResult(failedResult)

    expect(wrapper.text()).toContain('共 3 行')
    expect(wrapper.text()).toContain('2 行未通过')
    const rows = wrapper.findAll('tbody tr')
    expect(rows.length).toBe(2)
    expect(rows[0].text()).toContain('2')
    expect(rows[0].text()).toContain('物料名称不能为空')
    wrapper.unmount()
  })

  test('导出错误明细时把行号与原因写进 CSV', async () => {
    const download = vi.fn()
    const wrapper = mount(MaterialImportResult, {
      props: { result: failedResult },
      global: {
        plugins: [ElementPlus],
        stubs: { teleport: true },
        provide: { downloadCsv: download }
      }
    })

    await wrapper.find('[data-test="export-errors"]').trigger('click')

    expect(download).toHaveBeenCalledTimes(1)
    const [fileName, csv] = download.mock.calls[0]
    expect(fileName).toContain('导入错误明细')
    expect(csv).toContain('物料名称不能为空')
    expect(csv).toContain('行号')
    wrapper.unmount()
  })

  test('全部成功时只展示成功摘要,不显示错误表格', () => {
    const wrapper = mountResult({ total_rows: 2, success_rows: 2, failed_rows: 0, errors: [] })

    expect(wrapper.text()).toContain('成功导入 2 条')
    expect(wrapper.find('[data-test="error-table"]').exists()).toBe(false)
    wrapper.unmount()
  })
})
  • Step 2: 跑测试确认失败

Run: cd frontend && npx vitest run src/components/business/MaterialImportResult.test.js Expected: FAIL(组件不存在)

  • Step 3: 实现组件

创建 frontend/src/components/business/MaterialImportResult.vue:

<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
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 结构):

  test('批量导入成功后提示成功并刷新列表', async () => {
    materialsApi.importFile.mockResolvedValue({
      data: { total_rows: 2, success_rows: 2, failed_rows: 0, errors: [] }
    })

    const wrapper = mountMaterials()
    await flushPromises()

    await setupState(wrapper).handleImportFile({
      target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' }
    })
    await flushPromises()

    expect(materialsApi.importFile).toHaveBeenCalled()
    expect(materialsStore.fetchMaterials).toHaveBeenCalled()
    expect(ElMessage.success).toHaveBeenCalledWith('成功导入 2 条物料')
    wrapper.unmount()
  })

  test('批量导入失败时展示错误明细且不刷新列表', async () => {
    const error = new Error('校验未通过')
    error.response = {
      data: {
        code: 400,
        message: '有 1 行校验未通过,本次未导入任何数据',
        data: {
          total_rows: 1, success_rows: 0, failed_rows: 1,
          errors: [{ row: 2, data: { name: '', spec: '', unit: '', major: '', minor: '' }, reason: '物料名称不能为空' }]
        }
      }
    }
    materialsApi.importFile.mockRejectedValue(error)

    const wrapper = mountMaterials()
    await flushPromises()

    await setupState(wrapper).handleImportFile({
      target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' }
    })
    await flushPromises()

    expect(setupState(wrapper).importResult.failed_rows).toBe(1)
    expect(materialsStore.fetchMaterials).not.toHaveBeenCalled()
    wrapper.unmount()
  })

注意: Materials.test.js 目前 mock 的是 vi.mock('../stores/materials') 与 vi.mock('../../api/masterData'),需要在顶部补 vi.mock('../../api/materials'),导出 materialsApi(含 importFile/downloadImportTemplate 两个 vi.fn());ElMessage 的 spy 若尚未建立,按文件里既有的 vi.mock('element-plus') 方式补齐。

  • Step 2: 跑测试确认失败

Run: cd frontend && npx vitest run src/views/Materials.test.js Expected: 新增的两个用例 FAIL

  • Step 3: 实现页面接入

frontend/src/views/Materials.vue 改动:

  1. 顶部按钮区(第 3-6 行)加两个按钮和一个隐藏的文件输入:
  <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>
  1. 在页面底部加导入结果弹窗:
    <el-dialog v-model="importDialogVisible" title="批量导入结果" width="900px" @closed="handleImportDialogClosed">
      <MaterialImportResult v-if="importResult" :result="importResult" @close="importDialogVisible = false" />
    </el-dialog>
  1. script setup 里补充:
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('模板下载失败')
  }
}
  1. <style scoped> 里补:
.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
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 的「图片上传」一行之后新增一行分组说明:

| 物料批量导入            | `GET /material-import/template`、`POST /material-import` | 需登录 |

并在「## 1. 物料接口」章节末尾(POST /materials/:id/restore 之后)补完整接口说明:请求方式、模板列定义、5MB/20000 行限制、成功与失败响应示例、row 为 Excel 真实行号、首版仅新增、任意一行失败整批不入库。

  • Step 2: 端到端验证(真实服务 + 真实库)
docker compose down -v && docker compose up -d --build

然后在 frontend/ 下 npm run build 并重建镜像(静态文件打进镜像,跑本地 dev/build 看不到效果;nginx 的 immutable 缓存要求浏览器硬刷新)。

  • Step 3: 手工验证清单
场景 期望
下载模板 得到 xlsx,含「填写说明」「物料数据」两张表,表头 5 列
上传模板原样(无数据) 提示没有数据行(0 行),不写记录或写 0 行记录
上传 2 行合法数据 201,列表出现 2 条物料,编码为 小类前缀+序号,日志页有 2 条 CREATE
上传含空名称、错误大类、错误单位、无规则小类的文件 400,逐行错误行号与 Excel 一致,列表没有新增任何物料
上传同名同规格的重复文件两次 第二次仍成功(编码不同),确认首版不做重复检测
上传 .xls / .csv / 6MB 文件 400,提示格式或大小
非本公司分类名 400,提示分类不存在
导入过程中切公司 被保存互斥拦住
  • Step 4: 确认导入记录落库

Run: docker compose exec db mysql -uroot -pwmms1234 wmms -e "SELECT organization_id,operator,file_name,total_rows,success_rows,failed_rows,status,failure_reason,created_at FROM material_import_logs ORDER BY id DESC LIMIT 5" Expected: 每次导入(成功与失败)各一条

  • Step 5: 确认回滚确实生效

上传一个"前 199 行合法、第 200 行非法"的文件,然后:

Run: docker compose exec db mysql -uroot -pwmms1234 wmms -e "SELECT COUNT(*) FROM materials WHERE material_code LIKE 'A01%'" Expected: 数量与导入前一致(整批未落库)

  • Step 6: 提交文档
git add docs/api.md
git commit -m "docs: 补充物料批量导入接口说明"

已知取舍

  • 不做重复检测:同一份文件里两行相同物料都会成功入库,同一份文件重复上传也会导入两遍(编码都不同)。用户故事 BAT-001 提到的「疑似重复物料」不在首版范围。
  • 没有服务端幂等键:设计原文的「任务 ID 防重复提交」随预览环节一起取消,改为纯前端约束,见「设计约定 8」。
  • 错误明细不落库:只随响应返回,前端用 CSV 导出。因此历史导入记录里只有汇总原因,翻不回逐行明细。
  • 不使用 material_types、material_unit_conversions:与现有单条新增保持一致。
  • 不引入消息队列:20000 行同步处理,前端超时沿用 axios 默认 30 秒;若实测慢,后续再谈异步。
  • material_import_logs 只写汇总:第 8 点的「执行状态」只有 success / failed 两态,没有中间态。