Compare commits

...

3 Commits

Author SHA1 Message Date
文睿 张
ec7742a39c chore: 编译产物不再入库,启动脚本改用 server.exe
- server.exe 早已写在 .gitignore 第 9 行,却仍被追踪,规则一直没生效(.gitignore
  只管未被追踪的文件)。git rm --cached 移出索引,本地文件保留
- 启动项目.bat 改为启动前先 go build 重建再运行 server.exe,编译失败就停住不启动:
  原来用 go run 正是为了不跑到过期的 exe,换成 exe 后得靠这一步补上
2026-10-10 17:32:30 +08:00
文睿 张
f634e7cf07 fix: 物料导入分类匹配兼容界面上的「编码 名称」写法
界面上的分类一律显示成「编码 名称」(如 A 机械结构类),用户照此填进导入模板,
却报「大类「A 机械结构类」不存在或已停用」。分类其实存在且已启用,是后端只按
分类名称精确匹配,对不上带编码前缀的写法。单位早有名称/编码双匹配,分类漏了。

- 分类索引同时登记名称、编码、「编码 名称」三种写法。名称优先、键已占用时不覆盖,
  结果不随分类遍历顺序变化;停用分类仍在登记前被过滤,放宽的只是写法
- 报错文案补「请核对分类名称或编码」:原文案说「不存在或已停用」,而分类明明在,
  把排查方向带偏了
- 模板「填写说明」与 api.md 同步口径(原文写的是「分类按名称填写」)

验证:2 条新用例先跑成失败再转绿(「A 机械结构类」+「A01 标准件」、纯编码);
另加 1 条停用分类的边界用例,并做变异验证——临时关掉 status 过滤后它如期失败。
go build ./...、go vet ./internal/...、go test ./... 全部通过。
2026-10-10 17:32:15 +08:00
文睿 张
e3ab3a094a docs: 补充文档覆盖范围中的物料批量导入说明
356c8520 提交接口文档时漏掉的一行,随 Task 10 收尾补上。
2026-10-10 17:04:56 +08:00
6 changed files with 149 additions and 29 deletions

View File

@ -127,7 +127,7 @@
### 本文档覆盖范围 ### 本文档覆盖范围
本文档详述**物料、入库、出库、库存**四个资源域。以下分组已实现但未在本文档展开,需要时请直接查阅 `internal/handler/api.go` 的路由注册: 本文档详述**物料、入库、出库、库存**四个资源域,物料批量导入(`/material-import`)已并入 §1。以下分组已实现但未在本文档展开,需要时请直接查阅 `internal/handler/api.go` 的路由注册:
| 分组 | 路径 | 权限 | | 分组 | 路径 | 权限 |
|-------------------------|---------------------------------------------|--------| |-------------------------|---------------------------------------------|--------|
@ -410,7 +410,7 @@
- 必填列 `物料名称`、`大类`、`小类` 缺任何一列,判为模板不合法并返回「模板格式不正确,请重新下载最新模板」; - 必填列 `物料名称`、`大类`、`小类` 缺任何一列,判为模板不合法并返回「模板格式不正确,请重新下载最新模板」;
- 选填列 `规格`、`单位` 可以整列删掉,取值按空处理。 - 选填列 `规格`、`单位` 可以整列删掉,取值按空处理。
分类**按名称**填写,后端按「大类名 + 小类名」在本公司内组合匹配(小类名在一个公司内会重名,例如多个大类下都有「其他」,所以必须两级组合)。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。 分类**按名称**填写;名称之外也接受**编码**和界面上的「编码 名称」写法(界面一律显示成 `A 机械结构类`,用户整段照抄进模板是常事)。后端按「大类 + 小类」在本公司内组合匹配(小类名在一个公司内会重名,例如多个大类下都有「其他」,所以必须两级组合)。名称、编码、「编码 名称」三种写法同名或同码时以**名称**优先。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。
### POST /material-import ### POST /material-import
@ -425,8 +425,8 @@
| 物料名称 | 必填,去首尾空格后非空,≤ 200 字 | | 物料名称 | 必填,去首尾空格后非空,≤ 200 字 |
| 规格 | 选填,≤ 200 字 | | 规格 | 选填,≤ 200 字 |
| 单位 | 选填;留空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 | | 单位 | 选填;留空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 |
| 大类 | 必填,必须是当前公司已启用的顶层分类名称 | | 大类 | 必填,必须是当前公司已启用的顶层分类,按名称、编码或「编码 名称」匹配 |
| 小类 | 必填,必须是该大类下已启用的小类名称 | | 小类 | 必填,必须是该大类下已启用的小类,同样认名称、编码或「编码 名称」 |
| 编码规则 | 该小类必须存在启用的编码规则(`code_rules.status = 'active'`) | | 编码规则 | 该小类必须存在启用的编码规则(`code_rules.status = 'active'`) |
物料编码由系统按小类编码规则生成,**不接受模板里的编码列**。同一行可能产生多条原因,用中文分号连接。 物料编码由系统按小类编码规则生成,**不接受模板里的编码列**。同一行可能产生多条原因,用中文分号连接。

View File

@ -89,17 +89,28 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
} }
majors := make(map[string]*model.MaterialCategory) majors := make(map[string]*model.MaterialCategory)
minors := make(map[string]*model.MaterialCategory) // key: 大类ID + "\x00" + 小类名 minors := make(map[string]*model.MaterialCategory) // key: 大类ID + "\x00" + 小类写法
for i := range categories { for i := range categories {
category := &categories[i] category := &categories[i]
if category.Status != "active" { if category.Status != "active" {
continue continue
} }
// 界面上分类一律显示成「编码 名称」(前端 categoryLabel 的拼法),用户整段
// 照抄进模板是常事,所以名称之外再登记编码与「编码 名称」两种写法。名称排
// 在最前、已有键不覆盖,与单位一样保证同名或同码时结果确定。
aliases := []string{category.Name, category.Code}
if category.Code != "" {
aliases = append(aliases, category.Code+" "+category.Name)
}
if category.ParentID == nil { if category.ParentID == nil {
majors[category.Name] = category for _, alias := range aliases {
registerCategoryAlias(majors, alias, category)
}
continue continue
} }
minors[minorCategoryKey(*category.ParentID, category.Name)] = category for _, alias := range aliases {
registerCategoryAlias(minors, minorCategoryKey(*category.ParentID, alias), category)
}
} }
unitByName := make(map[string]*model.Unit) unitByName := make(map[string]*model.Unit)
unitByCode := make(map[string]*model.Unit) unitByCode := make(map[string]*model.Unit)
@ -140,7 +151,7 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
if row.MajorName == "" { if row.MajorName == "" {
reasons = append(reasons, "大类不能为空") reasons = append(reasons, "大类不能为空")
} else if major == nil { } else if major == nil {
reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用", row.MajorName)) reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用,请核对分类名称或编码", row.MajorName))
} }
var minor *model.MaterialCategory var minor *model.MaterialCategory
@ -151,7 +162,7 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
} else { } else {
minor = minors[minorCategoryKey(major.ID, row.MinorName)] minor = minors[minorCategoryKey(major.ID, row.MinorName)]
if minor == nil { if minor == nil {
reasons = append(reasons, fmt.Sprintf("小类「%s」在「%s」下不存在或已停用", row.MinorName, row.MajorName)) reasons = append(reasons, fmt.Sprintf("小类「%s」在「%s」下不存在或已停用,请核对分类名称或编码", row.MinorName, row.MajorName))
} }
} }
@ -282,3 +293,14 @@ func summarizeImportErrors(importErrors []model.MaterialImportError) string {
func minorCategoryKey(majorID int64, minorName string) string { func minorCategoryKey(majorID int64, minorName string) string {
return fmt.Sprintf("%d\x00%s", majorID, minorName) return fmt.Sprintf("%d\x00%s", majorID, minorName)
} }
// registerCategoryAlias 把一个写法登记进分类索引。空写法忽略;键已被别的分类占用
// 时不覆盖,保证结果不随分类的遍历顺序变化。
func registerCategoryAlias(index map[string]*model.MaterialCategory, key string, category *model.MaterialCategory) {
if key == "" {
return
}
if _, exists := index[key]; !exists {
index[key] = category
}
}

View File

@ -58,8 +58,8 @@ func BuildMaterialImportTemplate() ([]byte, error) {
{"物料名称", "必填", "内六角螺栓", "最长 200 字"}, {"物料名称", "必填", "内六角螺栓", "最长 200 字"},
{"规格", "选填", "M8x30", "最长 200 字"}, {"规格", "选填", "M8x30", "最长 200 字"},
{"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"}, {"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"},
{"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称"}, {"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称或编码"},
{"小类", "必填", "标准件", "填该大类下已启用的小类名称,且该小类必须已配置编码规则"}, {"小类", "必填", "标准件", "填该大类下已启用的小类名称或编码,且该小类必须已配置编码规则"},
{"", "", "", ""}, {"", "", "", ""},
{"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"}, {"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"},
} }

View File

@ -132,22 +132,7 @@ func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) {
expectImportMasterData(mock, true) expectImportMasterData(mock, true)
// 事务内:锁规则 → 取已占用编码 → 推进序号 → 插入 → 回查 ID // 事务内:锁规则 → 取已占用编码 → 推进序号 → 插入 → 回查 ID
// ImportTx 先把整批编码分配完并推进 code_rules,再分批插入。 // ImportTx 先把整批编码分配完并推进 code_rules,再分批插入。
mock.ExpectBegin() expectImportPersist(mock, "A010201")
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE`)).
WithArgs(int64(1), int64(12)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}).
AddRow(5, 1, 12, "A01", 4, 201))
mock.ExpectQuery(`SELECT material_code FROM materials WHERE organization_id = \? AND material_code LIKE \?`).
WithArgs(int64(1), "A01%").
WillReturnRows(sqlmock.NewRows([]string{"material_code"}))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).
WithArgs(int64(202), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(31, 1))
mock.ExpectQuery(`SELECT id, material_code FROM materials WHERE organization_id = \? AND material_code IN \(\?\)`).
WithArgs(int64(1), "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, "物料.xlsx", "success", 1, 1, 0, "") expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "")
data := buildTestWorkbook(t, [][]string{ data := buildTestWorkbook(t, [][]string{
@ -195,3 +180,106 @@ func TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
// expectImportPersist 描述一次成功入库的事务序列:锁规则 → 取已占用编码 → 推进序号
// → 插入 → 回查 ID → 写审计日志 → 提交。code 是本次分配给物料的编码。
func expectImportPersist(mock sqlmock.Sqlmock, code string) {
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE`)).
WithArgs(int64(1), int64(12)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}).
AddRow(5, 1, 12, "A01", 4, 201))
mock.ExpectQuery(`SELECT material_code FROM materials WHERE organization_id = \? AND material_code LIKE \?`).
WithArgs(int64(1), "A01%").
WillReturnRows(sqlmock.NewRows([]string{"material_code"}))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).
WithArgs(int64(202), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(31, 1))
mock.ExpectQuery(`SELECT id, material_code FROM materials WHERE organization_id = \? AND material_code IN \(\?\)`).
WithArgs(int64(1), code).
WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).AddRow(31, code))
mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
}
// 界面上分类一律显示成「编码 名称」(Materials.vue 的 categoryLabel),用户整段照抄
// 进模板是常事;导入校验得认这种写法,否则分类明明存在也会报「不存在或已停用」。
func TestMaterialImportServiceAcceptsCategoryDisplayLabels(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportMasterData(mock, true)
expectImportPersist(mock, "A010201")
expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "")
data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "A 机械结构类", "A01 标准件"},
})
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
if err != nil {
t.Fatalf("import: %v", err)
}
if result.SuccessRows != 1 || result.FailedRows != 0 || len(result.Errors) != 0 {
t.Fatalf("result = %+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 只填编码也认:模板说明写的是名称,但用户对着物料分类页抄编码同样应该通过。
func TestMaterialImportServiceAcceptsCategoryCodes(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportMasterData(mock, true)
expectImportPersist(mock, "A010201")
expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "")
data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "A", "A01"},
})
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
if err != nil {
t.Fatalf("import: %v", err)
}
if result.SuccessRows != 1 || result.FailedRows != 0 || len(result.Errors) != 0 {
t.Fatalf("result = %+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 放宽的只是写法,不是启用状态:停用分类用「编码 名称」引用同样要挡下来。
func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "description", "parent_id", "status"}).
AddRow(3, 1, "A", "机械结构类", "", nil, "active").
AddRow(12, 1, "A01", "标准件", "", 3, "active").
AddRow(20, 1, "B", "旧大类", "", nil, "inactive"))
mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
AddRow(1, 1, "个", "个", 0, "active"))
// 大类查不到,小类不再定位,也就不会去查编码规则
expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg())
data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "B 旧大类", "标准件"},
})
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
if err != nil {
t.Fatalf("import: %v", err)
}
// 断言必须点到「大类本身不存在」:小类的报错里也带着「B 旧大类」这几个字,
// 只判 Contains("大类") 会被这层巧合放过。
if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "大类「B 旧大类」") {
t.Fatalf("result = %+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

Binary file not shown.

View File

@ -3,9 +3,19 @@ title Warehouse Management System Launcher
cd /d "%~dp0" cd /d "%~dp0"
echo Building backend...
rem Rebuild on every launch so the executable can never be a stale one.
go build -o server.exe ./cmd/server
if errorlevel 1 (
echo.
echo Backend build failed. Fix the errors above and run this launcher again.
pause
exit /b 1
)
echo Starting backend (8082)... echo Starting backend (8082)...
rem Run the current source so local testing never uses an outdated server.exe. rem Work dir must stay at the project root: frontend/dist and uploads are relative paths.
start "Warehouse MMS - Backend" /D "%~dp0" cmd /k "go run ./cmd/server" start "Warehouse MMS - Backend" /D "%~dp0" cmd /k "server.exe"
echo Starting frontend (8083)... echo Starting frontend (8083)...
start "Warehouse MMS - Frontend" /D "%~dp0frontend" cmd /k "npm run dev" start "Warehouse MMS - Frontend" /D "%~dp0frontend" cmd /k "npm run dev"