material_manage_system/docs/superpowers/plans/2026-10-10-material-bulk-import.md
2026-10-10 14:56:42 +08:00

98 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(),公司取服务端上下文,忽略请求体伪造值。
  • 注释、提交信息、用户可见文案全部用中文(与仓库既有风格一致)。

设计约定

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

package service

import (
    "errors"
    "fmt"
    "io"
    "strings"

    "github.com/wmms/wmms/internal/model"
    "github.com/xuri/excelize/v2"
)

const (
    // MaterialImportSheetData 是填写数据的表名,「物料数据」。
    MaterialImportSheetData = "物料数据"
    // MaterialImportSheetGuide 是填写说明的表名。
    MaterialImportSheetGuide = "填写说明"
    // MaterialImportMaxRows 是数据区最大行数(不含表头)。
    MaterialImportMaxRows = 20000
    // MaterialImportMaxBytes 是上传文件大小上限。
    MaterialImportMaxBytes = 5 << 20
)

var (
    // ErrImportTemplateInvalid 表示上传的文件不是本功能的模板。
    ErrImportTemplateInvalid = errors.New("模板格式不正确,请重新下载最新模板")
    // ErrImportTooManyRows 表示数据行数超出上限。
    ErrImportTooManyRows = fmt.Errorf("导入行数超过 %d 行上限", MaterialImportMaxRows)
)

// materialImportHeaders 是生成模板时的列顺序(含选填列)。
var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类", "小类"}

// materialImportRequiredHeaders 是表头里必须存在的列;缺任何一列都判定为
// 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。
var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"}

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

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

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

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

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

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

// ParseMaterialImportFile 解析上传的 xlsx,返回带 Excel 行号的数据行。
// 列按表头名定位:列顺序可调、选填列可整列删掉、多余列忽略;只有缺少必填列
// 或缺少「物料数据」表时才判定模板不合法。
// 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。
func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) {
    f, err := excelize.OpenReader(r)
    if err != nil {
        return nil, ErrImportTemplateInvalid
    }
    defer f.Close()

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

    all, err := f.GetRows(MaterialImportSheetData)
    if err != nil {
        return nil, err
    }
    if len(all) == 0 {
        return nil, ErrImportTemplateInvalid
    }
    columns := materialImportColumnIndex(all[0])
    for _, required := range materialImportRequiredHeaders {
        if _, ok := columns[required]; !ok {
            return nil, ErrImportTemplateInvalid
        }
    }

    rows := make([]model.MaterialImportRow, 0, len(all)-1)
    for i, raw := range all[1:] {
        row := model.MaterialImportRow{
            Row:       i + 2, // Excel 行号:表头占第 1 行
            Name:      materialImportColumnValue(raw, columns, "物料名称"),
            Spec:      materialImportColumnValue(raw, columns, "规格"),
            Unit:      materialImportColumnValue(raw, columns, "单位"),
            MajorName: materialImportColumnValue(raw, columns, "大类"),
            MinorName: materialImportColumnValue(raw, columns, "小类"),
        }
        if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" {
            continue
        }
        rows = append(rows, row)
    }

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

// materialImportColumnIndex 把表头行转成「列名 → 列号」。允许用户在表头里加
// 首尾空格、末尾加 * 标记必填;不认识的列忽略,重复列名取第一列。
func materialImportColumnIndex(header []string) map[string]int {
    index := make(map[string]int, len(header))
    for i, cell := range header {
        name := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(cell), "*"))
        if name == "" {
            continue
        }
        if _, exists := index[name]; !exists {
            index[name] = i
        }
    }
    return index
}

// materialImportColumnValue 按列名取值;选填列被整列删掉时返回空串。
func materialImportColumnValue(row []string, columns map[string]int, name string) string {
    index, ok := columns[name]
    if !ok {
        return ""
    }
    return materialImportCell(row, index)
}

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

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

  • Step 6: Commit
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 两态,没有中间态。