物料数据迁移,并让每个公司自带默认单位与仓库

数据迁移(北京 / 武汉两个 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」,与代码里的清单保持逐条一致

默认组织更名:默认公司组织 → 北京
This commit is contained in:
文睿 张 2026-10-14 16:05:36 +08:00
parent 85525c97a0
commit 3cc783b5ff
4 changed files with 2187 additions and 4 deletions

View File

@ -34,13 +34,28 @@ func (r *OrganizationRepository) List() ([]model.Organization, error) {
return organizations, rows.Err() return organizations, rows.Err()
} }
// Create 建公司并同时种上默认主数据(物料单位、默认仓库与主库位)。两者放在一个
// 事务里:只建成公司却没种上单位,新公司连物料单位下拉都是空的,而用户重试还会
// 撞上公司编码的唯一键。
func (r *OrganizationRepository) Create(item *model.Organization) error { func (r *OrganizationRepository) Create(item *model.Organization) error {
result, err := database.DB.Exec(`INSERT INTO organizations (code, name, status, default_timezone) VALUES (?, ?, ?, ?)`, item.Code, item.Name, item.Status, item.DefaultTimezone) tx, err := database.DB.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
result, err := tx.Exec(`INSERT INTO organizations (code, name, status, default_timezone) VALUES (?, ?, ?, ?)`, item.Code, item.Name, item.Status, item.DefaultTimezone)
if err != nil { if err != nil {
return err return err
} }
item.ID, err = result.LastInsertId() item.ID, err = result.LastInsertId()
return err if err != nil {
return err
}
if err := seedOrganizationDefaultsTx(tx, item.ID); err != nil {
return err
}
return tx.Commit()
} }
func (r *OrganizationRepository) Update(item *model.Organization) error { func (r *OrganizationRepository) Update(item *model.Organization) error {

View File

@ -0,0 +1,82 @@
package repository
import (
"database/sql"
"github.com/wmms/wmms/internal/model"
)
// defaultMaterialUnits 是每个公司开箱可用的物料单位,与 scripts/init.sql 里给默认
// 组织种的那套逐条一致(含后补的「块」与「Pcs」)。
//
// 单位在后端是按公司隔离的:MasterDataRepository.ListUnits 按 organization_id
// 过滤,MaterialRepo.IsActiveUnit 还要求物料引用的单位必须属于同一
// 公司。所以新建公司时必须把这份清单一起种下去,否则新公司连单位下拉都是空的,
// 物料的 base_unit_id 也无处可指。
var defaultMaterialUnits = []model.Unit{
{Code: "个", Name: "个"},
{Code: "件", Name: "件"},
{Code: "只", Name: "只"},
{Code: "支", Name: "支"},
{Code: "根", Name: "根"},
{Code: "条", Name: "条"},
{Code: "片", Name: "片"},
{Code: "张", Name: "张"},
{Code: "套", Name: "套"},
{Code: "台", Name: "台"},
{Code: "对", Name: "对"},
{Code: "双", Name: "双"},
{Code: "包", Name: "包"},
{Code: "袋", Name: "袋"},
{Code: "盒", Name: "盒"},
{Code: "箱", Name: "箱"},
{Code: "瓶", Name: "瓶"},
{Code: "罐", Name: "罐"},
{Code: "桶", Name: "桶"},
{Code: "卷", Name: "卷"},
{Code: "块", Name: "块"},
{Code: "Pcs", Name: "件"},
// 重量、容量、长度、面积保留 3 位小数,计数类单位是整数。
{Code: "g", Name: "克", PrecisionScale: 3},
{Code: "kg", Name: "千克", PrecisionScale: 3},
{Code: "t", Name: "吨", PrecisionScale: 3},
{Code: "mL", Name: "毫升", PrecisionScale: 3},
{Code: "L", Name: "升", PrecisionScale: 3},
{Code: "m³", Name: "立方米", PrecisionScale: 3},
{Code: "mm", Name: "毫米", PrecisionScale: 3},
{Code: "cm", Name: "厘米", PrecisionScale: 3},
{Code: "m", Name: "米", PrecisionScale: 3},
{Code: "m²", Name: "平方米", PrecisionScale: 3},
}
// seedOrganizationDefaultsTx 给新公司种上开箱可用的主数据:上面那份物料单位,
// 外加一个默认仓库和一个主库位。仓库与库位各只允许一个(init.sql 里有对应的唯一键
// uk_warehouses_organization / uk_warehouse_locations_warehouse),所以固定给一对。
//
// 走事务是为了和 organizations 的插入同生共死:种一半失败会留下一个既没单位、
// 又已经占用了公司编码的组织,用户重试还会撞 uk_organizations_code。
func seedOrganizationDefaultsTx(tx *sql.Tx, organizationID int64) error {
for _, unit := range defaultMaterialUnits {
if _, err := tx.Exec(
`INSERT INTO units (organization_id, code, name, precision_scale, status) VALUES (?, ?, ?, ?, 'active')`,
organizationID, unit.Code, unit.Name, unit.PrecisionScale); err != nil {
return err
}
}
result, err := tx.Exec(
`INSERT INTO warehouses (organization_id, code, name, status) VALUES (?, 'DEFAULT', '默认仓库', 'active')`,
organizationID)
if err != nil {
return err
}
warehouseID, err := result.LastInsertId()
if err != nil {
return err
}
_, err = tx.Exec(
`INSERT INTO warehouse_locations (warehouse_id, code, name, status) VALUES (?, 'MAIN', '主库位', 'active')`,
warehouseID)
return err
}

View File

@ -0,0 +1,137 @@
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))
}
}

File diff suppressed because it is too large Load Diff