数据迁移(北京 / 武汉两个 SQLite 物料库 → wmms) - 北京进默认组织、武汉单独建组织:两套分类编码完全相同但含义不同(北京 A01=标准件, 武汉 A01=机加工件),共存会撞 material_categories 的组织内编码唯一约束 - 199 条物料、37 个企业、45 条变更日志,以及各分类的 code_rules.next_sequence (对齐到已导入的最大序号 +1,否则用户新增物料会撞上已有编码) - 迁移段并入 scripts/init.sql 末尾,空库初始化即得完整数据 公司默认数据 - 新建公司时种入 32 个物料单位 + 默认仓库 + 主库位,与建公司同事务, 种一半失败会整体回滚(否则留下一个没单位、又占着公司编码的组织) - 单位在后端按公司隔离(MaterialRepo.IsActiveUnit 要求物料引用的单位属于本公司), 此前新建的公司连单位下拉都是空的,物料的 base_unit_id 也无处可指 - init.sql 的单位段补齐「块」「Pcs」,与代码里的清单保持逐条一致 默认组织更名:默认公司组织 → 北京
138 lines
5.1 KiB
Go
138 lines
5.1 KiB
Go
package repository
|
||
|
||
import (
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"testing"
|
||
|
||
"github.com/DATA-DOG/go-sqlmock"
|
||
"github.com/wmms/wmms/internal/model"
|
||
"github.com/wmms/wmms/pkg/database"
|
||
)
|
||
|
||
// 与 organization.go / organization_defaults.go 里的语句逐字一致。
|
||
const (
|
||
createOrganizationSQL = `INSERT INTO organizations (code, name, status, default_timezone) VALUES (?, ?, ?, ?)`
|
||
seedUnitSQL = `INSERT INTO units (organization_id, code, name, precision_scale, status) VALUES (?, ?, ?, ?, 'active')`
|
||
seedWarehouseSQL = `INSERT INTO warehouses (organization_id, code, name, status) VALUES (?, 'DEFAULT', '默认仓库', 'active')`
|
||
seedLocationSQL = `INSERT INTO warehouse_locations (warehouse_id, code, name, status) VALUES (?, 'MAIN', '主库位', 'active')`
|
||
)
|
||
|
||
func newOrganizationSeedMock(t *testing.T) sqlmock.Sqlmock {
|
||
t.Helper()
|
||
db, mock, err := sqlmock.New()
|
||
if err != nil {
|
||
t.Fatalf("open sql mock: %v", err)
|
||
}
|
||
t.Cleanup(func() { database.DB = nil; db.Close() })
|
||
database.DB = db
|
||
return mock
|
||
}
|
||
|
||
// 单位是按公司隔离的,新公司没种单位的话,物料单位下拉是空的,base_unit_id 也无处可指。
|
||
func TestCreateOrganizationSeedsUnitsWarehouseAndLocation(t *testing.T) {
|
||
mock := newOrganizationSeedMock(t)
|
||
mock.ExpectBegin()
|
||
mock.ExpectExec(regexp.QuoteMeta(createOrganizationSQL)).
|
||
WithArgs("WUHAN", "武汉", "active", "Asia/Shanghai").
|
||
WillReturnResult(sqlmock.NewResult(7, 1))
|
||
for _, unit := range defaultMaterialUnits {
|
||
mock.ExpectExec(regexp.QuoteMeta(seedUnitSQL)).
|
||
WithArgs(int64(7), unit.Code, unit.Name, unit.PrecisionScale).
|
||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||
}
|
||
mock.ExpectExec(regexp.QuoteMeta(seedWarehouseSQL)).
|
||
WithArgs(int64(7)).
|
||
WillReturnResult(sqlmock.NewResult(3, 1))
|
||
mock.ExpectExec(regexp.QuoteMeta(seedLocationSQL)).
|
||
WithArgs(int64(3)).
|
||
WillReturnResult(sqlmock.NewResult(5, 1))
|
||
mock.ExpectCommit()
|
||
|
||
item := &model.Organization{Code: "WUHAN", Name: "武汉", Status: "active", DefaultTimezone: "Asia/Shanghai"}
|
||
if err := NewOrganizationRepository().Create(item); err != nil {
|
||
t.Fatalf("Create: %v", err)
|
||
}
|
||
if item.ID != 7 {
|
||
t.Fatalf("回填的组织 ID = %d,期望 7", item.ID)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("有期望未被满足(漏种或多给了语句): %v", err)
|
||
}
|
||
}
|
||
|
||
// 种子失败必须整体回滚:留下一个没有单位的公司,用户重试还会撞公司编码的唯一键。
|
||
func TestCreateOrganizationRollsBackWhenSeedingFails(t *testing.T) {
|
||
mock := newOrganizationSeedMock(t)
|
||
mock.ExpectBegin()
|
||
mock.ExpectExec(regexp.QuoteMeta(createOrganizationSQL)).
|
||
WithArgs("WUHAN", "武汉", "active", "Asia/Shanghai").
|
||
WillReturnResult(sqlmock.NewResult(7, 1))
|
||
// 第一个单位就失败
|
||
mock.ExpectExec(regexp.QuoteMeta(seedUnitSQL)).
|
||
WithArgs(int64(7), defaultMaterialUnits[0].Code, defaultMaterialUnits[0].Name, defaultMaterialUnits[0].PrecisionScale).
|
||
WillReturnError(errors.New("boom"))
|
||
mock.ExpectRollback()
|
||
|
||
err := NewOrganizationRepository().Create(&model.Organization{
|
||
Code: "WUHAN", Name: "武汉", Status: "active", DefaultTimezone: "Asia/Shanghai",
|
||
})
|
||
if err == nil {
|
||
t.Fatal("种子失败时 Create 应当返回错误")
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("有期望未被满足: %v", err)
|
||
}
|
||
}
|
||
|
||
// 单位表有 uk_units_org_code 唯一键,清单里出现重复编码会让建公司直接失败。
|
||
func TestDefaultMaterialUnitsHaveNoDuplicateCodes(t *testing.T) {
|
||
seen := make(map[string]bool, len(defaultMaterialUnits))
|
||
for _, unit := range defaultMaterialUnits {
|
||
if seen[unit.Code] {
|
||
t.Fatalf("默认单位清单里编码重复:%q", unit.Code)
|
||
}
|
||
seen[unit.Code] = true
|
||
}
|
||
}
|
||
|
||
// 新建公司种下的单位,必须和 scripts/init.sql 给默认组织种的那套一致;
|
||
// 两份清单各改各的,空库初始化与界面上新建的公司就会拿到不同的单位表。
|
||
func TestDefaultMaterialUnitsMatchInitSQL(t *testing.T) {
|
||
raw, err := os.ReadFile(filepath.Join("..", "..", "scripts", "init.sql"))
|
||
if err != nil {
|
||
t.Fatalf("读取 init.sql: %v", err)
|
||
}
|
||
// units 段的行形如 (1,'个','个',0,'active');这个五字段模式在 init.sql 里
|
||
// 只匹配单位表(分类是四字段、仓库是五字段但首列是数字)。
|
||
row := regexp.MustCompile(`\(1,'([^']+)','[^']*',(\d+),'active'\)`)
|
||
fromSQL := make(map[string]bool)
|
||
for _, m := range row.FindAllStringSubmatch(string(raw), -1) {
|
||
fromSQL[m[1]] = true
|
||
}
|
||
if len(fromSQL) == 0 {
|
||
t.Fatalf("没从 init.sql 里解析出默认组织的单位,正则可能已失效")
|
||
}
|
||
|
||
fromGo := make(map[string]model.Unit, len(defaultMaterialUnits))
|
||
for _, unit := range defaultMaterialUnits {
|
||
fromGo[unit.Code] = unit
|
||
}
|
||
|
||
for code := range fromGo {
|
||
if !fromSQL[code] {
|
||
t.Errorf("Go 默认单位清单里有 %q,init.sql 里没有", code)
|
||
}
|
||
}
|
||
for code := range fromSQL {
|
||
if _, ok := fromGo[code]; !ok {
|
||
t.Errorf("init.sql 里有单位 %q,Go 默认清单里没有", code)
|
||
}
|
||
}
|
||
if len(fromGo) != len(fromSQL) {
|
||
t.Errorf("单位数量不一致:Go %d 个,init.sql %d 个", len(fromGo), len(fromSQL))
|
||
}
|
||
}
|