物料数据迁移,并让每个公司自带默认单位与仓库
数据迁移(北京 / 武汉两个 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:
parent
85525c97a0
commit
3cc783b5ff
|
|
@ -34,14 +34,29 @@ 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()
|
||||||
|
if err != nil {
|
||||||
return err
|
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 {
|
||||||
return execExpectingRow(`UPDATE organizations SET name = ?, status = ?, default_timezone = ? WHERE id = ?`, item.Name, item.Status, item.DefaultTimezone, item.ID)
|
return execExpectingRow(`UPDATE organizations SET name = ?, status = ?, default_timezone = ? WHERE id = ?`, item.Name, item.Status, item.DefaultTimezone, item.ID)
|
||||||
|
|
|
||||||
82
internal/repository/organization_defaults.go
Normal file
82
internal/repository/organization_defaults.go
Normal 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
|
||||||
|
}
|
||||||
137
internal/repository/organization_defaults_test.go
Normal file
137
internal/repository/organization_defaults_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
1953
scripts/init.sql
1953
scripts/init.sql
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user