Compare commits
3 Commits
356c8520ca
...
ec7742a39c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec7742a39c | ||
|
|
f634e7cf07 | ||
|
|
e3ab3a094a |
|
|
@ -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'`) |
|
||||||
|
|
||||||
物料编码由系统按小类编码规则生成,**不接受模板里的编码列**。同一行可能产生多条原因,用中文分号连接。
|
物料编码由系统按小类编码规则生成,**不接受模板里的编码列**。同一行可能产生多条原因,用中文分号连接。
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,8 @@ func BuildMaterialImportTemplate() ([]byte, error) {
|
||||||
{"物料名称", "必填", "内六角螺栓", "最长 200 字"},
|
{"物料名称", "必填", "内六角螺栓", "最长 200 字"},
|
||||||
{"规格", "选填", "M8x30", "最长 200 字"},
|
{"规格", "选填", "M8x30", "最长 200 字"},
|
||||||
{"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"},
|
{"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"},
|
||||||
{"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称"},
|
{"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称或编码"},
|
||||||
{"小类", "必填", "标准件", "填该大类下已启用的小类名称,且该小类必须已配置编码规则"},
|
{"小类", "必填", "标准件", "填该大类下已启用的小类名称或编码,且该小类必须已配置编码规则"},
|
||||||
{"", "", "", ""},
|
{"", "", "", ""},
|
||||||
{"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"},
|
{"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
BIN
server.exe
BIN
server.exe
Binary file not shown.
14
启动项目.bat
14
启动项目.bat
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user