feat: 物料批量入库的编码分配与事务插入

This commit is contained in:
文睿 张 2026-10-10 15:13:18 +08:00
parent 4960a88497
commit f3146af838
3 changed files with 375 additions and 0 deletions

View File

@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"github.com/wmms/wmms/internal/model"
@ -417,3 +418,180 @@ func (r *MaterialRepo) Restore(organizationID, id int64) error {
func (r *MaterialRepo) RestoreTx(tx *sql.Tx, organizationID, id int64) error {
return restoreMaterialTx(tx, organizationID, id)
}
// materialImportBatchSize 是批量插入物料时的每批行数。编码已在内存里按小类
// 顺序分配好,这里只负责把 INSERT 折叠成分批的多值语句,避免两万行逐条往返。
const materialImportBatchSize = 500
// ImportTx 在调用方事务内为整批物料分配编码并插入,任意一行失败都由调用方回滚。
// 编码按小类分组生成:组内先对 code_rules 行加锁(按 category_id 升序加锁,
// 避免两个导入事务以不同顺序持锁而死锁),再跳过库中已占用的号段。
func (r *MaterialRepo) ImportTx(tx *sql.Tx, organizationID int64, materials []*model.Material) error {
if len(materials) == 0 {
return nil
}
// 1. 按 category_id 分组,并保证加锁顺序升序。
type group struct {
categoryID int64
items []*model.Material
}
index := make(map[int64]int)
groups := make([]*group, 0)
for _, m := range materials {
var categoryID int64
if m.CategoryID != nil {
categoryID = *m.CategoryID
}
position, ok := index[categoryID]
if !ok {
position = len(groups)
index[categoryID] = position
groups = append(groups, &group{categoryID: categoryID})
}
groups[position].items = append(groups[position].items, m)
}
sort.Slice(groups, func(i, j int) bool { return groups[i].categoryID < groups[j].categoryID })
// 2. 逐组锁规则、分配编码。
for _, g := range groups {
rule, err := lockCodeRule(tx, organizationID, g.categoryID)
if err != nil {
return err
}
if rule == nil {
return fmt.Errorf("小类(ID %d)未配置启用的编码规则", g.categoryID)
}
taken, err := loadTakenMaterialCodes(tx, organizationID, rule.Prefix)
if err != nil {
return err
}
sequence := rule.NextSequence
for _, m := range g.items {
code, next, err := nextAvailableMaterialCode(rule, sequence, taken)
if err != nil {
return err
}
m.MaterialCode = code
taken[code] = struct{}{}
sequence = next
}
if _, err := tx.Exec(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`, sequence, rule.ID); err != nil {
return err
}
}
// 3. 分批多值插入,每批插入后按编码回查自增 ID。
// 多值 INSERT 只返回首个自增 ID 且不保证连续,拿不到逐行 ID,
// 而后面写审计日志需要它;按批回查则把 IN 列表控制在 500 项以内。
for start := 0; start < len(materials); start += materialImportBatchSize {
end := start + materialImportBatchSize
if end > len(materials) {
end = len(materials)
}
batch := materials[start:end]
query, args := buildMaterialInsertBatch(batch)
if _, err := tx.Exec(query, args...); err != nil {
if strings.Contains(err.Error(), "Duplicate entry") {
return ErrDuplicateMaterialCode
}
return err
}
if err := fillMaterialIDs(tx, organizationID, batch); err != nil {
return err
}
}
return nil
}
// loadTakenMaterialCodes 取出本公司该前缀下已存在的全部物料编码,供内存判重。
// 前缀来自公司自己的编码规则配置,若它含 % 或 _ 只会让 LIKE 多匹配到无关编码,
// 而那些编码的格式与本规则生成的编码不同、永远不会被 FormatCode 产出,
// 因此多匹配不会漏判,无需额外转义。
func loadTakenMaterialCodes(tx *sql.Tx, organizationID int64, prefix string) (map[string]struct{}, error) {
rows, err := tx.Query(
`SELECT material_code FROM materials WHERE organization_id = ? AND material_code LIKE ?`,
organizationID, prefix+"%")
if err != nil {
return nil, err
}
defer rows.Close()
taken := make(map[string]struct{})
for rows.Next() {
var code string
if err := rows.Scan(&code); err != nil {
return nil, err
}
taken[code] = struct{}{}
}
return taken, rows.Err()
}
// nextAvailableMaterialCode 从 start 起找到第一个未占用的编码,返回编码与下一个
// 待用序号。探测上限沿用单条新增的 probeLimit,防止规则被用尽时无限循环。
func nextAvailableMaterialCode(rule *model.CodeRule, start int64, taken map[string]struct{}) (string, int64, error) {
for sequence := start; sequence < start+probeLimit; sequence++ {
code := rule.FormatCode(sequence)
if _, occupied := taken[code]; !occupied {
return code, sequence + 1, nil
}
}
return "", 0, fmt.Errorf("编码规则 %s 的可用流水号已用尽", rule.Prefix)
}
// buildMaterialInsertBatch 拼一条多值 INSERT,返回 SQL 与按顺序展开的参数。
func buildMaterialInsertBatch(batch []*model.Material) (string, []interface{}) {
var builder strings.Builder
builder.WriteString(`INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES `)
args := make([]interface{}, 0, len(batch)*11)
for i, m := range batch {
if i > 0 {
builder.WriteString(", ")
}
builder.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
args = append(args, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec,
m.ManufacturerModel, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
}
return builder.String(), args
}
// fillMaterialIDs 按物料编码回查自增 ID 并回填。多值插入拿不到逐行 ID,
// 而后续写 material_logs 需要它。
func fillMaterialIDs(tx *sql.Tx, organizationID int64, materials []*model.Material) error {
codes := make([]interface{}, 0, len(materials))
for _, m := range materials {
codes = append(codes, m.MaterialCode)
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(codes)), ",")
query := `SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (` + placeholders + `)`
args := append([]interface{}{organizationID}, codes...)
rows, err := tx.Query(query, args...)
if err != nil {
return err
}
defer rows.Close()
ids := make(map[string]int64, len(materials))
for rows.Next() {
var id int64
var code string
if err := rows.Scan(&id, &code); err != nil {
return err
}
ids[code] = id
}
if err := rows.Err(); err != nil {
return err
}
for _, m := range materials {
id, ok := ids[m.MaterialCode]
if !ok {
return fmt.Errorf("物料 %s 插入后未能回查到 ID", m.MaterialCode)
}
m.ID = id
}
return nil
}

View File

@ -0,0 +1,177 @@
package repository
import (
"fmt"
"regexp"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
const importLockRuleQuery = `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`
const insertMaterialBatchStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
func newImportMock(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sql mock: %v", err)
}
previous := database.DB
database.DB = db
return mock, func() {
database.DB = previous
db.Close()
}
}
func materialCategoryPtr(id int64) *int64 { return &id }
func TestMaterialRepoImportTxAllocatesCodesAndInsertsOneByOne(t *testing.T) {
mock, cleanup := newImportMock(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).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))
// 已占用编码探测:A010201 已被手工录入占用,应跳到 A010202
mock.ExpectQuery(regexp.QuoteMeta(`SELECT material_code FROM materials WHERE organization_id = ? AND material_code LIKE ?`)).
WithArgs(int64(1), "A01%").
WillReturnRows(sqlmock.NewRows([]string{"material_code"}).AddRow("A010201"))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).
WithArgs(int64(204), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1))
// 两行折叠成一条多值 INSERT
mock.ExpectExec(regexp.QuoteMeta(insertMaterialBatchStmt)).
WithArgs(
int64(1), "A010202", "螺栓", nil, "", nil, int64(12), nil, "个", "active", 1,
int64(1), "A010203", "螺母", nil, "", nil, int64(12), nil, "个", "active", 1,
).
WillReturnResult(sqlmock.NewResult(31, 2))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (?,?)`)).
WithArgs(int64(1), "A010202", "A010203").
WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).
AddRow(31, "A010202").AddRow(32, "A010203"))
tx, err := database.DB.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
categoryID := int64(12)
materials := []*model.Material{
{OrganizationID: 1, Name: "螺栓", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1},
{OrganizationID: 1, Name: "螺母", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1},
}
if err := NewMaterialRepo().ImportTx(tx, 1, materials); err != nil {
t.Fatalf("import: %v", err)
}
if materials[0].MaterialCode != "A010202" || materials[0].ID != 31 {
t.Fatalf("first = %+v", materials[0])
}
if materials[1].MaterialCode != "A010203" || materials[1].ID != 32 {
t.Fatalf("second = %+v", materials[1])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialRepoImportTxReportsMissingCodeRule(t *testing.T) {
mock, cleanup := newImportMock(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}))
tx, err := database.DB.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
materials := []*model.Material{
{OrganizationID: 1, Name: "螺栓", CategoryID: materialCategoryPtr(12), Unit: "个", Status: "active", Version: 1},
}
err = NewMaterialRepo().ImportTx(tx, 1, materials)
if err == nil || !strings.Contains(err.Error(), "编码规则") {
t.Fatalf("err = %v, want 编码规则错误", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialRepoImportTxBatchesInsertsOverFiveHundredRows(t *testing.T) {
// 501 行 → 两批插入(500 + 1)。插入语句与回查参数很长,这里用子串松匹配,
// 只断言"分了两批"以及编码序号按批连续。
mock, cleanup := newImportMock(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).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, 1))
mock.ExpectQuery(`SELECT material_code FROM materials`).
WillReturnRows(sqlmock.NewRows([]string{"material_code"}))
mock.ExpectExec(`UPDATE code_rules SET next_sequence`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(1000, 500))
mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(idRowsFor(1, 500))
mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(1500, 1))
mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(idRowsFor(501, 501))
tx, err := database.DB.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
categoryID := int64(12)
materials := make([]*model.Material, 0, 501)
for i := 0; i < 501; i++ {
materials = append(materials, &model.Material{
OrganizationID: 1, Name: "物料", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1,
})
}
if err := NewMaterialRepo().ImportTx(tx, 1, materials); err != nil {
t.Fatalf("import: %v", err)
}
if materials[0].MaterialCode != "A010001" || materials[500].MaterialCode != "A010501" {
t.Fatalf("codes = %s ... %s", materials[0].MaterialCode, materials[500].MaterialCode)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// idRowsFor 造出回查语句的返回行:序号 [from, to] 对应的物料 ID 与编码。
func idRowsFor(from, to int) *sqlmock.Rows {
rows := sqlmock.NewRows([]string{"id", "material_code"})
for i := from; i <= to; i++ {
rows.AddRow(int64(i), fmt.Sprintf("A01%04d", i))
}
return rows
}
func TestMaterialLogRepoCreateBatchTx(t *testing.T) {
mock, cleanup := newImportMock(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 2))
tx, err := database.DB.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
logs := []*model.MaterialLog{
{OperationType: "CREATE", MaterialID: 31, Operator: "tester", Description: "批量导入"},
{OperationType: "CREATE", MaterialID: 32, Operator: "tester", Description: "批量导入"},
}
if err := NewMaterialLogRepo().CreateBatchTx(tx, logs); err != nil {
t.Fatalf("create batch: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -127,6 +127,26 @@ func (r *MaterialLogRepo) List(organizationID int64, operationType, materialId s
return logs, total, nil
}
// CreateBatchTx 在同一事务里批量写审计日志。导入场景一条物料一条 CREATE 日志,
// 逐条往返太慢,这里折叠成多值 INSERT。
func (r *MaterialLogRepo) CreateBatchTx(tx *sql.Tx, logs []*model.MaterialLog) error {
if len(logs) == 0 {
return nil
}
var builder strings.Builder
builder.WriteString(`INSERT INTO material_logs (operation_type, material_id, old_value, new_value, operator, description) VALUES `)
args := make([]interface{}, 0, len(logs)*6)
for i, log := range logs {
if i > 0 {
builder.WriteString(", ")
}
builder.WriteString("(?, ?, ?, ?, ?, ?)")
args = append(args, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Operator, log.Description)
}
_, err := tx.Exec(builder.String(), args...)
return err
}
// GetByMaterialID 只返回属于当前公司的该物料日志;物料不属于本公司时结果为空。
func (r *MaterialLogRepo) GetByMaterialID(organizationID, materialID int64) ([]model.MaterialLog, error) {
query := `SELECT ml.id, ml.operation_type, ml.material_id, ml.old_value, ml.new_value, ml.operator, ml.description, ml.created_at