feat: 新增物料导入记录表与模型

This commit is contained in:
文睿 张 2026-10-10 14:50:43 +08:00
parent 616a4a3b1d
commit 84e7f4d7d0
3 changed files with 1266 additions and 1101 deletions

View File

@ -19,7 +19,7 @@
- 任意一行校验失败 → 整批不入库,返回全部逐行错误。 - 任意一行校验失败 → 整批不入库,返回全部逐行错误。
- 所有接口必须挂 `middleware.AuthMiddleware(), middleware.OrganizationMiddleware()`,公司取服务端上下文,忽略请求体伪造值。 - 所有接口必须挂 `middleware.AuthMiddleware(), middleware.OrganizationMiddleware()`,公司取服务端上下文,忽略请求体伪造值。
- 注释、提交信息、用户可见文案全部用中文(与仓库既有风格一致)。 - 注释、提交信息、用户可见文案全部用中文(与仓库既有风格一致)。
- 提交只包含本功能涉及的文件;工作区已有的「默认单位 pcs→个」在途改动(`frontend/src/views/materialForm.js`、`internal/service/material.go` 等 7 个文件)**不要一起提交**。 -
--- ---
@ -38,14 +38,21 @@
- `填写说明`:字段名、是否必填、示例、填写规则。 - `填写说明`:字段名、是否必填、示例、填写规则。
- `物料数据`:第 1 行表头,数据从第 2 行开始。 - `物料数据`:第 1 行表头,数据从第 2 行开始。
表头固定 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时按「去空格 + 去尾部 `*`」比对表头,不匹配即报「模板格式不正确,请重新下载最新模板」。 模板生成的表头是 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时**按列名定位,不按列位置**:
- 必填列 `物料名称`、`大类`、`小类` 缺任何一列 → 报「模板格式不正确,请重新下载最新模板」;
- 选填列 `规格`、`单位` 可以整列删掉,取值按空处理;
- 列顺序可以随意调整;
- 表头允许带首尾空格与末尾的 `*` 必填标记;不认识的列忽略。
「单元格留空」和「整列缺失」是两回事:规格与单位留空属于合法的选填,不会报错;只有必填列缺失才判定模板不合法。
分类**按名称填写**,后端按「大类名 + 小类名」组合在本公司内匹配(小类名在本公司内会重名,如「其他」在 A07/B07/C03/D04/F05 都叫「其他」,因此必须两级组合)。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。 分类**按名称填写**,后端按「大类名 + 小类名」组合在本公司内匹配(小类名在本公司内会重名,如「其他」在 A07/B07/C03/D04/F05 都叫「其他」,因此必须两级组合)。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。
### 3. 接口 ### 3. 接口
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | ---- | ------------------------------- | -------------------------------------- |
| GET | `/api/material-import/template` | 下载 xlsx 模板,返回文件流 | | GET | `/api/material-import/template` | 下载 xlsx 模板,返回文件流 |
| POST | `/api/material-import` | `multipart/form-data`,字段名 `file`,上传即入库 | | POST | `/api/material-import` | `multipart/form-data`,字段名 `file`,上传即入库 |
@ -81,7 +88,7 @@
### 4. 校验规则(逐行) ### 4. 校验规则(逐行)
| 列 | 规则 | | 列 | 规则 |
| --- | --- | | ---- | ------------------------------------------- |
| 物料名称 | 必填,去首尾空格后非空,≤ 200 字 | | 物料名称 | 必填,去首尾空格后非空,≤ 200 字 |
| 规格 | 选填,≤ 200 字 | | 规格 | 选填,≤ 200 字 |
| 单位 | 选填;为空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 | | 单位 | 选填;为空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 |
@ -124,7 +131,7 @@
**新建:** **新建:**
| 文件 | 职责 | | 文件 | 职责 |
| --- | --- | | ----------------------------------------------------------- | ----------------------------- |
| `internal/model/material_import.go` | 导入相关模型:行、错误、结果、日志 | | `internal/model/material_import.go` | 导入相关模型:行、错误、结果、日志 |
| `internal/service/material_import_excel.go` | xlsx 模板生成与解析(纯 IO,不碰数据库) | | `internal/service/material_import_excel.go` | xlsx 模板生成与解析(纯 IO,不碰数据库) |
| `internal/service/material_import.go` | 导入编排:解析 → 校验 → 入库 → 写日志 | | `internal/service/material_import.go` | 导入编排:解析 → 校验 → 入库 → 写日志 |
@ -137,7 +144,7 @@
**修改:** **修改:**
| 文件 | 改动 | | 文件 | 改动 |
| --- | --- | | ------------------------------------- | -------------------------------- |
| `go.mod` / `go.sum` | 新增 `excelize/v2 v2.8.1` | | `go.mod` / `go.sum` | 新增 `excelize/v2 v2.8.1` |
| `scripts/init.sql` | 新增 `material_import_logs` 表 | | `scripts/init.sql` | 新增 `material_import_logs` 表 |
| `internal/repository/material.go` | 新增 `ImportTx`(批量分配编码 + 插入) | | `internal/repository/material.go` | 新增 `ImportTx`(批量分配编码 + 插入) |
@ -152,7 +159,7 @@
**测试:** **测试:**
| 文件 | 覆盖 | | 文件 | 覆盖 |
| --- | --- | | --------------------------------------------------------------- | --------------------- |
| `internal/service/material_import_excel_test.go` | 模板生成、解析、表头不符、超行数、空行跳过 | | `internal/service/material_import_excel_test.go` | 模板生成、解析、表头不符、超行数、空行跳过 |
| `internal/service/material_import_test.go` | 逐行校验各分支、整批不入库、成功入库 | | `internal/service/material_import_test.go` | 逐行校验各分支、整批不入库、成功入库 |
| `internal/repository/material_import_test.go` | 日志写入 | | `internal/repository/material_import_test.go` | 日志写入 |
@ -165,12 +172,15 @@
## Task 1: 数据库表与模型 ## Task 1: 数据库表与模型
**Files:** **Files:**
- Create: `internal/model/material_import.go` - Create: `internal/model/material_import.go`
- Create: `scripts/upgrades/2026-10-10-material-bulk-import.sql` - Create: `scripts/upgrades/2026-10-10-material-bulk-import.sql`
- Modify: `scripts/init.sql`(在 `code_rules` 建表语句之后追加) - Modify: `scripts/init.sql`(在 `code_rules` 建表语句之后追加)
**Interfaces:** **Interfaces:**
- Produces: `model.MaterialImportRow`、`model.MaterialImportError`、`model.MaterialImportResult`、`model.MaterialImportLog` - Produces: `model.MaterialImportRow`、`model.MaterialImportError`、`model.MaterialImportResult`、`model.MaterialImportLog`
- Produces: 表 `material_import_logs` - Produces: 表 `material_import_logs`
- [ ] **Step 1: 在 init.sql 增加建表语句** - [ ] **Step 1: 在 init.sql 增加建表语句**
@ -278,13 +288,17 @@ git commit -m "feat: 新增物料导入记录表与模型"
## Task 2: Excel 模板生成与解析 ## Task 2: Excel 模板生成与解析
**Files:** **Files:**
- Create: `internal/service/material_import_excel.go` - Create: `internal/service/material_import_excel.go`
- Test: `internal/service/material_import_excel_test.go` - Test: `internal/service/material_import_excel_test.go`
- Modify: `go.mod`、`go.sum` - Modify: `go.mod`、`go.sum`
**Interfaces:** **Interfaces:**
- Produces: `BuildMaterialImportTemplate() ([]byte, error)` - Produces: `BuildMaterialImportTemplate() ([]byte, error)`
- Produces: `ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error)` - Produces: `ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error)`
- Produces: 错误变量 `ErrImportTemplateInvalid`、`ErrImportTooManyRows` - Produces: 错误变量 `ErrImportTemplateInvalid`、`ErrImportTooManyRows`
- [ ] **Step 1: 引入 excelize v2.8.1** - [ ] **Step 1: 引入 excelize v2.8.1**
@ -348,23 +362,55 @@ func TestParseMaterialImportFileKeepsRowsWithAnyContent(t *testing.T) {
} }
} }
func TestParseMaterialImportFileRejectsWrongHeader(t *testing.T) { func TestParseMaterialImportFileRejectsMissingRequiredColumn(t *testing.T) {
f := excelize.NewFile() // 只有「物料名称」和「规格」,缺必填的大类、小类
defer f.Close() data := buildTestWorkbookWithHeaders(t, []string{"物料名称", "规格"}, [][]string{
f.SetSheetName("Sheet1", MaterialImportSheetData) {"内六角螺栓", "M8x30"},
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())) _, err := ParseMaterialImportFile(bytes.NewReader(data))
if !errors.Is(err, ErrImportTemplateInvalid) { if !errors.Is(err, ErrImportTemplateInvalid) {
t.Fatalf("err = %v, want ErrImportTemplateInvalid", err) 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) { func TestParseMaterialImportFileRejectsTooManyRows(t *testing.T) {
rows := make([][]string, 0, MaterialImportMaxRows+1) rows := make([][]string, 0, MaterialImportMaxRows+1)
for i := 0; i < MaterialImportMaxRows+1; i++ { for i := 0; i < MaterialImportMaxRows+1; i++ {
@ -408,13 +454,19 @@ func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) {
} }
} }
// buildTestWorkbook 用「物料数据」表头 + 给定数据行构造一个 xlsx 字节流。 // buildTestWorkbook 用标准表头 + 给定数据行构造一个 xlsx 字节流。
func buildTestWorkbook(t *testing.T, dataRows [][]string) []byte { 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() t.Helper()
f := excelize.NewFile() f := excelize.NewFile()
defer f.Close() defer f.Close()
f.SetSheetName("Sheet1", MaterialImportSheetData) f.SetSheetName("Sheet1", MaterialImportSheetData)
for i, header := range materialImportHeaders { for i, header := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1) cell, _ := excelize.CoordinatesToCellName(i+1, 1)
f.SetCellValue(MaterialImportSheetData, cell, header) f.SetCellValue(MaterialImportSheetData, cell, header)
} }
@ -472,9 +524,13 @@ var (
ErrImportTooManyRows = fmt.Errorf("导入行数超过 %d 行上限", MaterialImportMaxRows) ErrImportTooManyRows = fmt.Errorf("导入行数超过 %d 行上限", MaterialImportMaxRows)
) )
// materialImportHeaders 是「物料数据」表的表头,顺序即列序。 // materialImportHeaders 是生成模板时的列顺序(含选填列)。
var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类", "小类"} var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类", "小类"}
// materialImportRequiredHeaders 是表头里必须存在的列;缺任何一列都判定为
// 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。
var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"}
// BuildMaterialImportTemplate 生成导入模板:一张填写说明表 + 一张空白数据表。 // BuildMaterialImportTemplate 生成导入模板:一张填写说明表 + 一张空白数据表。
func BuildMaterialImportTemplate() ([]byte, error) { func BuildMaterialImportTemplate() ([]byte, error) {
f := excelize.NewFile() f := excelize.NewFile()
@ -552,6 +608,8 @@ func guideHeaderStyle(f *excelize.File) int {
} }
// ParseMaterialImportFile 解析上传的 xlsx,返回带 Excel 行号的数据行。 // ParseMaterialImportFile 解析上传的 xlsx,返回带 Excel 行号的数据行。
// 列按表头名定位:列顺序可调、选填列可整列删掉、多余列忽略;只有缺少必填列
// 或缺少「物料数据」表时才判定模板不合法。
// 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。 // 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。
func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) { func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) {
f, err := excelize.OpenReader(r) f, err := excelize.OpenReader(r)
@ -569,19 +627,25 @@ func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(all) == 0 || !materialImportHeaderMatches(all[0]) { if len(all) == 0 {
return nil, ErrImportTemplateInvalid 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) rows := make([]model.MaterialImportRow, 0, len(all)-1)
for i, raw := range all[1:] { for i, raw := range all[1:] {
row := model.MaterialImportRow{ row := model.MaterialImportRow{
Row: i + 2, // Excel 行号:表头占第 1 行 Row: i + 2, // Excel 行号:表头占第 1 行
Name: materialImportCell(raw, 0), Name: materialImportColumnValue(raw, columns, "物料名称"),
Spec: materialImportCell(raw, 1), Spec: materialImportColumnValue(raw, columns, "规格"),
Unit: materialImportCell(raw, 2), Unit: materialImportColumnValue(raw, columns, "单位"),
MajorName: materialImportCell(raw, 3), MajorName: materialImportColumnValue(raw, columns, "大类"),
MinorName: materialImportCell(raw, 4), MinorName: materialImportColumnValue(raw, columns, "小类"),
} }
if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" { if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" {
continue continue
@ -595,15 +659,29 @@ func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) {
return rows, nil return rows, nil
} }
// materialImportHeaderMatches 比对表头:允许用户加空格,也允许末尾带 * 标记必填。 // materialImportColumnIndex 把表头行转成「列名 → 列号」。允许用户在表头里加
func materialImportHeaderMatches(header []string) bool { // 首尾空格、末尾加 * 标记必填;不认识的列忽略,重复列名取第一列。
for i, want := range materialImportHeaders { func materialImportColumnIndex(header []string) map[string]int {
got := strings.TrimSuffix(strings.TrimSpace(materialImportCell(header, i)), "*") index := make(map[string]int, len(header))
if strings.TrimSpace(got) != want { for i, cell := range header {
return false name := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(cell), "*"))
if name == "" {
continue
}
if _, exists := index[name]; !exists {
index[name] = i
} }
} }
return true 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 { func materialImportCell(row []string, index int) string {
@ -631,14 +709,19 @@ git commit -m "feat: 物料导入模板生成与 xlsx 解析"
## Task 3: 批量入库(编码分配 + 事务插入) ## Task 3: 批量入库(编码分配 + 事务插入)
**Files:** **Files:**
- Modify: `internal/repository/material.go`(追加方法,不改动已有函数) - Modify: `internal/repository/material.go`(追加方法,不改动已有函数)
- Modify: `internal/repository/material_log.go`(追加 `CreateBatchTx`) - Modify: `internal/repository/material_log.go`(追加 `CreateBatchTx`)
- Test: `internal/repository/material_import_test.go` - Test: `internal/repository/material_import_test.go`
**Interfaces:** **Interfaces:**
- Consumes: `model.Material`、`sqlExecer`(已存在)、`lockCodeRule`(已存在,`material.go:84`) - Consumes: `model.Material`、`sqlExecer`(已存在)、`lockCodeRule`(已存在,`material.go:84`)
- Produces: `(*MaterialRepo) ImportTx(tx *sql.Tx, organizationID int64, materials []*model.Material) error` - Produces: `(*MaterialRepo) ImportTx(tx *sql.Tx, organizationID int64, materials []*model.Material) error`
- Produces: `(*MaterialLogRepo) CreateBatchTx(tx *sql.Tx, logs []*model.MaterialLog) error` - Produces: `(*MaterialLogRepo) CreateBatchTx(tx *sql.Tx, logs []*model.MaterialLog) error`
- Produces: `materialImportBatchSize = 500` - Produces: `materialImportBatchSize = 500`
- [ ] **Step 1: 写失败的测试** - [ ] **Step 1: 写失败的测试**
@ -1067,11 +1150,14 @@ git commit -m "feat: 物料批量入库的编码分配与事务插入"
## Task 4: 导入记录 repository ## Task 4: 导入记录 repository
**Files:** **Files:**
- Create: `internal/repository/material_import.go` - Create: `internal/repository/material_import.go`
- Test: `internal/repository/material_import_test.go`(追加) - Test: `internal/repository/material_import_test.go`(追加)
**Interfaces:** **Interfaces:**
- Consumes: `model.MaterialImportLog` - Consumes: `model.MaterialImportLog`
- Produces: `(*MaterialImportLogRepo) Create(log *model.MaterialImportLog) error` - Produces: `(*MaterialImportLogRepo) Create(log *model.MaterialImportLog) error`
- [ ] **Step 1: 写失败的测试** - [ ] **Step 1: 写失败的测试**
@ -1156,10 +1242,12 @@ git commit -m "feat: 物料导入记录仓储"
## Task 5: 导入编排(校验 + 入库 + 记录) ## Task 5: 导入编排(校验 + 入库 + 记录)
**Files:** **Files:**
- Create: `internal/service/material_import.go` - Create: `internal/service/material_import.go`
- Test: `internal/service/material_import_test.go` - Test: `internal/service/material_import_test.go`
**Interfaces:** **Interfaces:**
- Consumes: `ParseMaterialImportFile`、`MaterialRepo.ImportTx`、`MaterialLogRepo.CreateBatchTx`、`MaterialImportLogRepo.Create`、`MasterDataRepository.ListCategories/ListUnits`、`MaterialRepo.LocklessFindActiveCodeRule` - 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: `(*MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error)`
- Produces: `NewMaterialImportService() *MaterialImportService` - Produces: `NewMaterialImportService() *MaterialImportService`
@ -1682,14 +1770,18 @@ git commit -m "feat: 物料导入校验与事务入库编排"
## Task 6: HTTP 接口与路由 ## Task 6: HTTP 接口与路由
**Files:** **Files:**
- Create: `internal/handler/material_import.go` - Create: `internal/handler/material_import.go`
- Modify: `pkg/response/response.go`(新增 `ErrorWithData`) - Modify: `pkg/response/response.go`(新增 `ErrorWithData`)
- Modify: `internal/handler/api.go`(Handler 结构体 + 路由组) - Modify: `internal/handler/api.go`(Handler 结构体 + 路由组)
- Test: `internal/handler/material_import_test.go`、`internal/handler/routes_test.go` - Test: `internal/handler/material_import_test.go`、`internal/handler/routes_test.go`
**Interfaces:** **Interfaces:**
- Consumes: `MaterialImportService.Import`、`BuildMaterialImportTemplate` - Consumes: `MaterialImportService.Import`、`BuildMaterialImportTemplate`
- Produces: `GET /api/material-import/template`、`POST /api/material-import` - Produces: `GET /api/material-import/template`、`POST /api/material-import`
- Produces: `response.ErrorWithData(c *gin.Context, httpStatus int, message string, data interface{})` - Produces: `response.ErrorWithData(c *gin.Context, httpStatus int, message string, data interface{})`
- [ ] **Step 1: 加通用错误响应** - [ ] **Step 1: 加通用错误响应**
@ -1887,12 +1979,15 @@ git commit -m "feat: 物料导入与模板下载接口"
## Task 7: 前端 API 与工具 ## Task 7: 前端 API 与工具
**Files:** **Files:**
- Modify: `frontend/src/api/materials.js` - Modify: `frontend/src/api/materials.js`
- Create: `frontend/src/utils/exportCsv.js` - Create: `frontend/src/utils/exportCsv.js`
- Test: `frontend/src/utils/exportCsv.test.js` - Test: `frontend/src/utils/exportCsv.test.js`
**Interfaces:** **Interfaces:**
- Produces: `materialsApi.importFile(formData)`、`materialsApi.downloadImportTemplate()` - Produces: `materialsApi.importFile(formData)`、`materialsApi.downloadImportTemplate()`
- Produces: `buildCsv(rows)`、`downloadCsv(fileName, csv)` - Produces: `buildCsv(rows)`、`downloadCsv(fileName, csv)`
- [ ] **Step 1: 写失败的测试** - [ ] **Step 1: 写失败的测试**
@ -2017,11 +2112,14 @@ git commit -m "feat: 物料导入前端接口与 CSV 导出工具"
## Task 8: 导入结果弹窗组件 ## Task 8: 导入结果弹窗组件
**Files:** **Files:**
- Create: `frontend/src/components/business/MaterialImportResult.vue` - Create: `frontend/src/components/business/MaterialImportResult.vue`
- Test: `frontend/src/components/business/MaterialImportResult.test.js` - Test: `frontend/src/components/business/MaterialImportResult.test.js`
**Interfaces:** **Interfaces:**
- Consumes: `buildCsv`、`downloadCsv` - Consumes: `buildCsv`、`downloadCsv`
- Produces: 组件 props `result`(`{ total_rows, success_rows, failed_rows, errors }`)、emits `close` - Produces: 组件 props `result`(`{ total_rows, success_rows, failed_rows, errors }`)、emits `close`
- [ ] **Step 1: 写失败的测试** - [ ] **Step 1: 写失败的测试**
@ -2243,10 +2341,12 @@ git commit -m "feat: 物料导入结果弹窗组件"
## Task 9: 物料页面接入 ## Task 9: 物料页面接入
**Files:** **Files:**
- Modify: `frontend/src/views/Materials.vue` - Modify: `frontend/src/views/Materials.vue`
- Test: `frontend/src/views/Materials.test.js`(追加) - Test: `frontend/src/views/Materials.test.js`(追加)
**Interfaces:** **Interfaces:**
- Consumes: `materialsApi.importFile`、`materialsApi.downloadImportTemplate`、`MaterialImportResult.vue`、`useSavingGuard` - Consumes: `materialsApi.importFile`、`materialsApi.downloadImportTemplate`、`MaterialImportResult.vue`、`useSavingGuard`
- [ ] **Step 1: 写失败的测试** - [ ] **Step 1: 写失败的测试**
@ -2453,6 +2553,7 @@ git commit -m "feat: 物料页面接入批量导入与模板下载"
## Task 10: 文档与端到端验证 ## Task 10: 文档与端到端验证
**Files:** **Files:**
- Modify: `docs/api.md` - Modify: `docs/api.md`
- [ ] **Step 1: 补接口文档** - [ ] **Step 1: 补接口文档**
@ -2476,7 +2577,7 @@ docker compose down -v && docker compose up -d --build
- [ ] **Step 3: 手工验证清单** - [ ] **Step 3: 手工验证清单**
| 场景 | 期望 | | 场景 | 期望 |
| --- | --- | | ------------------------- | -------------------------------------------- |
| 下载模板 | 得到 xlsx,含「填写说明」「物料数据」两张表,表头 5 列 | | 下载模板 | 得到 xlsx,含「填写说明」「物料数据」两张表,表头 5 列 |
| 上传模板原样(无数据) | 提示没有数据行(0 行),不写记录或写 0 行记录 | | 上传模板原样(无数据) | 提示没有数据行(0 行),不写记录或写 0 行记录 |
| 上传 2 行合法数据 | 201,列表出现 2 条物料,编码为 `小类前缀+序号`,日志页有 2 条 CREATE | | 上传 2 行合法数据 | 201,列表出现 2 条物料,编码为 `小类前缀+序号`,日志页有 2 条 CREATE |

View File

@ -0,0 +1,60 @@
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"
)

View File

@ -0,0 +1,4 @@
-- 物料批量导入:新增导入记录表。仅在已部署的库上执行一次;本地开发库走 init.sql 重建。
SET NAMES utf8mb4;
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;
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'));