feat: 档口入库码支持软删除与重导恢复 (#238)

This commit is contained in:
chengma
2026-08-15 10:49:28 +08:00
parent 4cbdc81a30
commit 2aea22b44d
13 changed files with 515 additions and 26 deletions
+100 -14
View File
@@ -15,6 +15,12 @@ import (
// ErrInnerCodeUniqueConflict 表示同一日期的入库码已属于另一个业务键。
var ErrInnerCodeUniqueConflict = errors.New("同一业务日期的档口入库码已被其他记录使用")
// ErrInnerCodeRestoreConflict 表示已完成或结果未知的软删除记录不能换入库码后直接恢复。
var ErrInnerCodeRestoreConflict = errors.New("已回写或需核对的删除记录不能用不同入库码恢复")
// ErrInnerCodeDeleteConflict 表示批量删除时记录已经不可见或不存在,整批不会部分删除。
var ErrInnerCodeDeleteConflict = errors.New("部分档口入库码记录已删除或不存在")
// InnerCodeSybSnapshot 是本地顺运宝明细用于解析货运单 stock id 的最小快照。
type InnerCodeSybSnapshot struct {
OrderNumber string
@@ -35,12 +41,13 @@ type InnerCodeStatusCounts struct {
NeedsCheck int
}
// InnerCodeImportOutcome 说明幂等导入是新增还是更新。
// InnerCodeImportOutcome 说明幂等导入是新增、更新还是恢复软删除记录。
type InnerCodeImportOutcome string
const (
InnerCodeImportCreated InnerCodeImportOutcome = "created"
InnerCodeImportUpdated InnerCodeImportOutcome = "updated"
InnerCodeImportCreated InnerCodeImportOutcome = "created"
InnerCodeImportUpdated InnerCodeImportOutcome = "updated"
InnerCodeImportRestored InnerCodeImportOutcome = "restored"
)
// UpsertInnerCodeImportRow 按已确认业务键写入一行。
@@ -58,10 +65,13 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
return "", fmt.Errorf("%w(记录 %d)", ErrInnerCodeUniqueConflict, codeRecordID)
}
var id int64
var status model.InnerCodeStatus
var currentInnerCode, deletedAt string
err := tx.QueryRow(`
SELECT id FROM syb_inner_code_records
SELECT id,status,inner_code,COALESCE(deleted_at,'') FROM syb_inner_code_records
WHERE business_date=? AND order_number=? AND stall=? AND spec_key=?
FOR UPDATE`, row.BusinessDate, row.OrderNumber, row.Stall, row.SpecKey).Scan(&id)
FOR UPDATE`, row.BusinessDate, row.OrderNumber, row.Stall, row.SpecKey).
Scan(&id, &status, &currentInnerCode, &deletedAt)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("锁定档口入库码业务键失败: %w", err)
}
@@ -80,6 +90,32 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
}
return InnerCodeImportCreated, nil
}
if deletedAt != "" {
if innerCodeStatusPreservesImportResult(status) {
if currentInnerCode != row.InnerCode {
return "", fmt.Errorf("%w(记录 %d,原入库码 %q,新入库码 %q)",
ErrInnerCodeRestoreConflict, id, currentInnerCode, row.InnerCode)
}
_, err = tx.Exec(`UPDATE syb_inner_code_records
SET source_row=?,print_sequence=?,shop_name=?,spec_raw=?,source_duplicate_count=?,
deleted_at=NULL,deleted_by_user_id=NULL,updated_at=?
WHERE id=?`, row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName),
row.SpecRaw, row.SourceDuplicateCount, now, id)
} else {
_, err = tx.Exec(`UPDATE syb_inner_code_records
SET source_row=?,print_sequence=?,shop_name=?,spec_raw=?,inner_code=?,source_duplicate_count=?,
status='pending',stock_id=NULL,detail_id=NULL,syb_spec=NULL,syb_sku=NULL,
syb_variation_sku=NULL,purchase_platform=NULL,purchase_code=NULL,
remote_inner_code=NULL,result_message=NULL,planned_at=NULL,
deleted_at=NULL,deleted_by_user_id=NULL,updated_at=?
WHERE id=?`, row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName),
row.SpecRaw, row.InnerCode, row.SourceDuplicateCount, now, id)
}
if err != nil {
return "", innerCodeImportWriteError("恢复档口入库码导入记录失败", err)
}
return InnerCodeImportRestored, nil
}
_, err = tx.Exec(`
UPDATE syb_inner_code_records
@@ -107,6 +143,15 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
return InnerCodeImportUpdated, nil
}
func innerCodeStatusPreservesImportResult(status model.InnerCodeStatus) bool {
switch status {
case model.InnerCodeApplying, model.InnerCodeUpdated, model.InnerCodeAlreadyFilled, model.InnerCodeNeedsCheck:
return true
default:
return false
}
}
func innerCodeImportWriteError(action string, err error) error {
// 并发导入可能都在 SELECT 时看不到对方,最终仍由唯一索引裁决。
// 不把 MySQL 索引名或 SQL 原文显示给操作员。
@@ -132,6 +177,7 @@ func ListInnerCodeRecordsForPlanning(q Execer, businessDate string, ids []int64)
rows, err := q.Query(`SELECT `+innerCodeListColumns+`
FROM syb_inner_code_records
WHERE business_date=? AND id IN (`+strings.Join(placeholders, ",")+`)
AND deleted_at IS NULL
AND status IN ('pending','ready','skipped','failed')
ORDER BY source_row,id`, args...)
if err != nil {
@@ -167,6 +213,7 @@ func ListInnerCodePlanningContext(q Execer, businessDate string, orderNumbers []
rows, err := q.Query(`SELECT `+innerCodeListColumns+`
FROM syb_inner_code_records
WHERE business_date=? AND order_number IN (`+strings.Join(placeholders, ",")+`)
AND (deleted_at IS NULL OR status IN ('applying','updated','already_filled','needs_check'))
ORDER BY source_row,id`, args...)
if err != nil {
return nil, fmt.Errorf("查询档口入库码匹配上下文失败: %w", err)
@@ -233,7 +280,7 @@ func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt str
SET stock_id=?,detail_id=?,syb_spec=?,syb_sku=?,syb_variation_sku=?,
purchase_platform=?,purchase_code=?,remote_inner_code=?,status=?,
result_message=?,planned_at=?,updated_at=?
WHERE id=? AND status IN ('pending','ready','skipped','failed')`,
WHERE id=? AND deleted_at IS NULL AND status IN ('pending','ready','skipped','failed')`,
nullablePositiveInt64(plan.StockID), nullablePositiveInt64(plan.DetailID),
nullableString(plan.SybSpec), nullableString(plan.SybSKU), nullableString(plan.SybVariationSKU),
nullableString(plan.PurchasePlatform), nullableString(plan.PurchaseCode),
@@ -279,7 +326,9 @@ func lockAndValidateInnerCodePlanClaims(tx *sql.Tx, plans []model.InnerCodeRecor
for _, key := range keys {
rows, err := tx.Query(`SELECT id,COALESCE(detail_id,0),status
FROM syb_inner_code_records
WHERE business_date=? AND order_number=? ORDER BY id FOR UPDATE`, key.BusinessDate, key.OrderNumber)
WHERE business_date=? AND order_number=?
AND (deleted_at IS NULL OR status IN ('applying','updated','already_filled','needs_check'))
ORDER BY id FOR UPDATE`, key.BusinessDate, key.OrderNumber)
if err != nil {
return fmt.Errorf("锁定档口入库码订单匹配上下文失败: %w", err)
}
@@ -332,10 +381,10 @@ const innerCodeListColumns = `id,business_date,source_row,COALESCE(print_sequenc
COALESCE(syb_variation_sku,''),COALESCE(purchase_platform,''),COALESCE(purchase_code,''),
COALESCE(remote_inner_code,''),status,COALESCE(result_message,''),created_by_user_id,
COALESCE(applied_by_user_id,''),COALESCE(planned_at,''),COALESCE(apply_started_at,''),
COALESCE(applied_at,''),created_at,updated_at`
COALESCE(applied_at,''),COALESCE(deleted_at,''),COALESCE(deleted_by_user_id,''),created_at,updated_at`
func innerCodeFilterClause(filter InnerCodeListFilter) (string, []any) {
clauses := []string{"business_date=?"}
clauses := []string{"business_date=?", "deleted_at IS NULL"}
args := []any{filter.BusinessDate}
if filter.Status != "" {
clauses = append(clauses, "status=?")
@@ -390,7 +439,7 @@ func CountInnerCodeStatuses(q Execer, businessDate string) (InnerCodeStatusCount
var result InnerCodeStatusCounts
err := q.QueryRow(`SELECT COUNT(*),
COALESCE(SUM(status='ready'),0),COALESCE(SUM(status='needs_check'),0)
FROM syb_inner_code_records WHERE business_date=?`, businessDate).
FROM syb_inner_code_records WHERE business_date=? AND deleted_at IS NULL`, businessDate).
Scan(&result.Total, &result.Ready, &result.NeedsCheck)
if err != nil {
return result, fmt.Errorf("统计档口入库码状态失败: %w", err)
@@ -410,7 +459,7 @@ func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, er
&row.SybSpec, &row.SybSKU, &row.SybVariationSKU, &row.PurchasePlatform,
&row.PurchaseCode, &row.RemoteInnerCode, &row.Status, &row.ResultMessage,
&row.CreatedByUserID, &row.AppliedByUserID, &row.PlannedAt, &row.ApplyStartedAt,
&row.AppliedAt, &row.CreatedAt, &row.UpdatedAt)
&row.AppliedAt, &row.DeletedAt, &row.DeletedByUserID, &row.CreatedAt, &row.UpdatedAt)
if err != nil {
return row, fmt.Errorf("读取档口入库码记录失败: %w", err)
}
@@ -425,7 +474,7 @@ func ClaimInnerCodeForApply(db *sql.DB, id int64, actorUserID, now string) (*mod
}
defer tx.Rollback()
record, err := scanInnerCodeRecord(tx.QueryRow(`SELECT `+innerCodeListColumns+
` FROM syb_inner_code_records WHERE id=? FOR UPDATE`, id))
` FROM syb_inner_code_records WHERE id=? AND deleted_at IS NULL FOR UPDATE`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
@@ -437,7 +486,7 @@ func ClaimInnerCodeForApply(db *sql.DB, id int64, actorUserID, now string) (*mod
}
result, err := tx.Exec(`UPDATE syb_inner_code_records
SET status='applying',applied_by_user_id=?,apply_started_at=?,updated_at=?
WHERE id=? AND status='ready'`, actorUserID, now, now, id)
WHERE id=? AND deleted_at IS NULL AND status='ready'`, actorUserID, now, now, id)
if err != nil {
return nil, false, fmt.Errorf("领取档口入库码记录失败: %w", err)
}
@@ -473,7 +522,7 @@ func FinishInnerCodeApply(q Execer, id int64, status model.InnerCodeStatus, mess
// GetInnerCodeForRecheck 读取一条需核对记录。
func GetInnerCodeForRecheck(q Execer, id int64) (*model.InnerCodeRecord, error) {
record, err := scanInnerCodeRecord(q.QueryRow(`SELECT `+innerCodeListColumns+
` FROM syb_inner_code_records WHERE id=?`, id))
` FROM syb_inner_code_records WHERE id=? AND deleted_at IS NULL`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -483,6 +532,43 @@ func GetInnerCodeForRecheck(q Execer, id int64) (*model.InnerCodeRecord, error)
return &record, nil
}
// SoftDeleteInnerCodeRecords 隐藏整批记录并保留状态、规划和回写审计。
// 任一记录已经删除或不存在时回滚整批,避免页面提示的数量与实际不一致。
func SoftDeleteInnerCodeRecords(db *sql.DB, ids []int64, actorUserID, deletedAt string) (int, error) {
if len(ids) == 0 {
return 0, nil
}
placeholders := make([]string, len(ids))
args := make([]any, 0, len(ids)+3)
args = append(args, deletedAt, actorUserID, deletedAt)
for index, id := range ids {
placeholders[index] = "?"
args = append(args, id)
}
tx, err := db.Begin()
if err != nil {
return 0, fmt.Errorf("开始删除档口入库码事务失败: %w", err)
}
defer tx.Rollback()
result, err := tx.Exec(`UPDATE syb_inner_code_records
SET deleted_at=?,deleted_by_user_id=?,updated_at=?
WHERE deleted_at IS NULL AND id IN (`+strings.Join(placeholders, ",")+`)`, args...)
if err != nil {
return 0, fmt.Errorf("软删除档口入库码记录失败: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("读取档口入库码删除数量失败: %w", err)
}
if affected != int64(len(ids)) {
return 0, ErrInnerCodeDeleteConflict
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("提交档口入库码删除事务失败: %w", err)
}
return int(affected), nil
}
// SaveInnerCodeRecheck 保存只读重新核对的远端结果,不执行状态领取或写入。
func SaveInnerCodeRecheck(q Execer, id int64, status model.InnerCodeStatus, message, remoteCode, checkedAt string) error {
result, err := q.Exec(`UPDATE syb_inner_code_records
+116
View File
@@ -3,10 +3,13 @@ package repository
import (
"database/sql"
"errors"
"strings"
"testing"
"github.com/go-sql-driver/mysql"
_ "modernc.org/sqlite"
"cmautobuy/admin/model"
)
func TestInterruptApplyingInnerCodes_只收敛回写中记录(t *testing.T) {
@@ -50,3 +53,116 @@ func TestInnerCodeImportWriteError_唯一冲突不泄漏索引细节(t *testing.
t.Fatalf("不应回显索引细节: %v", err)
}
}
func TestSoftDeleteInnerCodeRecords_所有状态只写删除审计(t *testing.T) {
db, err := sql.Open("sqlite", "file:inner_code_delete?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE syb_inner_code_records (
id INTEGER PRIMARY KEY,status TEXT NOT NULL,deleted_at TEXT,deleted_by_user_id TEXT,updated_at TEXT NOT NULL
)`); err != nil {
t.Fatal(err)
}
statuses := []string{"pending", "ready", "applying", "updated", "already_filled", "skipped", "failed", "needs_check"}
for index, status := range statuses {
if _, err := db.Exec(`INSERT INTO syb_inner_code_records(id,status,updated_at) VALUES(?,?,?)`, index+1, status, "old"); err != nil {
t.Fatal(err)
}
}
ids := make([]int64, len(statuses))
for index := range ids {
ids[index] = int64(index + 1)
}
count, err := SoftDeleteInnerCodeRecords(db, ids, "user-1", "2026-08-15T02:00:00Z")
if err != nil || count != len(statuses) {
t.Fatalf("count=%d err=%v", count, err)
}
rows, err := db.Query(`SELECT status,deleted_at,deleted_by_user_id,updated_at FROM syb_inner_code_records ORDER BY id`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
index := 0
for rows.Next() {
var status, deletedAt, actor, updatedAt string
if err := rows.Scan(&status, &deletedAt, &actor, &updatedAt); err != nil {
t.Fatal(err)
}
if status != statuses[index] || deletedAt != "2026-08-15T02:00:00Z" || actor != "user-1" || updatedAt != deletedAt {
t.Fatalf("第 %d 行状态或审计变化错误: %s %s %s %s", index+1, status, deletedAt, actor, updatedAt)
}
index++
}
}
func TestSoftDeleteInnerCodeRecords_有失效ID时整批回滚(t *testing.T) {
db, err := sql.Open("sqlite", "file:inner_code_delete_conflict?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE syb_inner_code_records (
id INTEGER PRIMARY KEY,status TEXT NOT NULL,deleted_at TEXT,deleted_by_user_id TEXT,updated_at TEXT NOT NULL
); INSERT INTO syb_inner_code_records(id,status,updated_at) VALUES(1,'pending','old')`); err != nil {
t.Fatal(err)
}
if _, err := SoftDeleteInnerCodeRecords(db, []int64{1, 99}, "user-1", "2026-08-15T02:00:00Z"); !errors.Is(err, ErrInnerCodeDeleteConflict) {
t.Fatalf("期望整批冲突,实际 %v", err)
}
var deletedAt sql.NullString
if err := db.QueryRow(`SELECT deleted_at FROM syb_inner_code_records WHERE id=1`).Scan(&deletedAt); err != nil {
t.Fatal(err)
}
if deletedAt.Valid {
t.Fatalf("整批回滚后不应保留删除标记: %+v", deletedAt)
}
}
func TestFinishInnerCodeApply_软删除后仍保存后台结果(t *testing.T) {
db, err := sql.Open("sqlite", "file:inner_code_finish_deleted?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE syb_inner_code_records (
id INTEGER PRIMARY KEY,status TEXT NOT NULL,result_message TEXT,remote_inner_code TEXT,
applied_at TEXT,updated_at TEXT NOT NULL,deleted_at TEXT
); INSERT INTO syb_inner_code_records(id,status,updated_at,deleted_at)
VALUES(1,'applying','old','2026-08-15T02:00:00Z')`); err != nil {
t.Fatal(err)
}
if err := FinishInnerCodeApply(db, 1, model.InnerCodeUpdated, "远端核验成功", "DK-1", "2026-08-15T02:01:00Z"); err != nil {
t.Fatal(err)
}
var status, message, remoteCode, appliedAt, deletedAt string
if err := db.QueryRow(`SELECT status,result_message,remote_inner_code,applied_at,deleted_at
FROM syb_inner_code_records WHERE id=1`).Scan(&status, &message, &remoteCode, &appliedAt, &deletedAt); err != nil {
t.Fatal(err)
}
if status != "updated" || message != "远端核验成功" || remoteCode != "DK-1" ||
appliedAt != "2026-08-15T02:01:00Z" || deletedAt != "2026-08-15T02:00:00Z" {
t.Fatalf("隐藏记录后台收尾错误: %s %s %s %s %s", status, message, remoteCode, appliedAt, deletedAt)
}
}
func TestInnerCodeStatusPreservesImportResult(t *testing.T) {
for _, status := range []string{"pending", "ready", "skipped", "failed"} {
if innerCodeStatusPreservesImportResult(model.InnerCodeStatus(status)) {
t.Errorf("%s 不应保留旧规划", status)
}
}
for _, status := range []string{"applying", "updated", "already_filled", "needs_check"} {
if !innerCodeStatusPreservesImportResult(model.InnerCodeStatus(status)) {
t.Errorf("%s 应保留远端结果和审计", status)
}
}
}
func TestInnerCodeFilterClause_正常列表固定隐藏软删除记录(t *testing.T) {
where, args := innerCodeFilterClause(InnerCodeListFilter{BusinessDate: "2026-08-15", Status: "ready", Keyword: "ORDER"})
if !strings.Contains(where, "deleted_at IS NULL") || len(args) != 6 {
t.Fatalf("列表软删除过滤不完整: where=%q args=%v", where, args)
}
}
+76 -2
View File
@@ -20,7 +20,7 @@ import (
"cmautobuy/admin/spec"
)
const mysqlSchemaVersion = 23
const mysqlSchemaVersion = 24
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
@@ -700,10 +700,56 @@ func MigrateMySQL(db *sql.DB) error {
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 23, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v23 失败: %w", err)
}
current = 23
}
if current < 24 {
if err := migrateMySQLV24(db); err != nil {
return fmt.Errorf("执行 MySQL schema v24 失败: %w", err)
}
if err := checkMySQLV24Shape(db); err != nil {
return fmt.Errorf("MySQL schema v24 自检失败,未记录版本: %w", err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 24, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v24 失败: %w", err)
}
}
return CheckMySQLSchema(db)
}
// migrateMySQLV24 为档口入库码增加可恢复软删除审计。
func migrateMySQLV24(db *sql.DB) error {
for _, column := range []struct{ name, ddl string }{
{"deleted_at", `ALTER TABLE syb_inner_code_records ADD COLUMN deleted_at VARCHAR(35) NULL AFTER applied_at`},
{"deleted_by_user_id", `ALTER TABLE syb_inner_code_records ADD COLUMN deleted_by_user_id VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER deleted_at`},
} {
exists, err := mysqlColumnExists(db, "syb_inner_code_records", column.name)
if err != nil {
return err
}
if !exists {
if _, err := db.Exec(column.ddl); err != nil {
return fmt.Errorf("增加 syb_inner_code_records.%s 失败: %w", column.name, err)
}
}
}
if exists, err := mysqlIndexExists(db, "syb_inner_code_records", "idx_inner_code_deleted"); err != nil {
return err
} else if !exists {
if _, err := db.Exec(`ALTER TABLE syb_inner_code_records ADD INDEX idx_inner_code_deleted (deleted_at,business_date,status,id)`); err != nil {
return fmt.Errorf("增加档口入库码软删除索引失败: %w", err)
}
}
if exists, err := mysqlConstraintExists(db, "syb_inner_code_records", "fk_inner_code_deleted_by"); err != nil {
return err
} else if !exists {
if _, err := db.Exec(`ALTER TABLE syb_inner_code_records ADD CONSTRAINT fk_inner_code_deleted_by
FOREIGN KEY (deleted_by_user_id) REFERENCES users(user_id) ON DELETE SET NULL`); err != nil {
return fmt.Errorf("增加档口入库码删除人外键失败: %w", err)
}
}
return nil
}
// migrateMySQLV23 建立档口入库码最小闭环使用的唯一业务表。
// Excel 原文件、批次和每次尝试不会另建表保存。
func migrateMySQLV23(db *sql.DB) error {
@@ -2132,7 +2178,35 @@ func CheckMySQLSchema(db *sql.DB) error {
if err := checkMySQLV22Shape(db); err != nil {
return err
}
return checkMySQLV23Shape(db)
if err := checkMySQLV23Shape(db); err != nil {
return err
}
return checkMySQLV24Shape(db)
}
func checkMySQLV24Shape(db *sql.DB) error {
for _, name := range []string{"deleted_at", "deleted_by_user_id"} {
exists, err := mysqlColumnExists(db, "syb_inner_code_records", name)
if err != nil || !exists {
return fmt.Errorf("档口入库码软删除字段 %s 缺失: %v", name, err)
}
}
for _, item := range []struct{ kind, name string }{
{"index", "idx_inner_code_deleted"},
{"constraint", "fk_inner_code_deleted_by"},
} {
var exists bool
var err error
if item.kind == "index" {
exists, err = mysqlIndexExists(db, "syb_inner_code_records", item.name)
} else {
exists, err = mysqlConstraintExists(db, "syb_inner_code_records", item.name)
}
if err != nil || !exists {
return fmt.Errorf("档口入库码软删除%s %s 缺失: %v", item.kind, item.name, err)
}
}
return nil
}
func checkMySQLV23Shape(db *sql.DB) error {
@@ -2,6 +2,7 @@ package repository
import (
"database/sql"
"errors"
"fmt"
"os"
"strings"
@@ -1117,6 +1118,117 @@ func TestMySQLMigrate_V22升级V23且唯一约束生效(t *testing.T) {
}
}
func TestMySQLMigrate_V23升级V24且软删除字段有效(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
if err := MigrateMySQL(db); err != nil {
t.Fatal(err)
}
mustExec(t, db, `ALTER TABLE syb_inner_code_records DROP FOREIGN KEY fk_inner_code_deleted_by`)
mustExec(t, db, `ALTER TABLE syb_inner_code_records DROP INDEX idx_inner_code_deleted`)
mustExec(t, db, `ALTER TABLE syb_inner_code_records DROP COLUMN deleted_by_user_id,DROP COLUMN deleted_at`)
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=24`)
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v23 升级 v24 失败: %v", err)
}
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v24 重复迁移失败: %v", err)
}
if err := checkMySQLV24Shape(db); err != nil {
t.Fatal(err)
}
}
func TestUpsertInnerCodeImportRow_软删除记录按状态安全恢复(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
if err := MigrateMySQL(db); err != nil {
t.Fatal(err)
}
now := "2026-08-15T03:00:00Z"
mustExec(t, db, `INSERT INTO users(user_id,username,password_hash,role,status,password_changed_at,created_at,updated_at)
VALUES('restore-user','restore-user','hash','purchaser','active',?,?,?)`, now, now, now)
for _, row := range []struct {
orderNumber, stall, specKey, innerCode, status string
}{
{"ORDER-F", "A#1", "黑色,M", "DK-F", "failed"},
{"ORDER-U", "B#2", "白色,L", "DK-U", "updated"},
{"ORDER-N", "C#3", "杏色,S", "DK-N", "needs_check"},
} {
mustExec(t, db, `INSERT INTO syb_inner_code_records
(business_date,source_row,order_number,stall,spec_raw,spec_key,inner_code,stock_id,status,
result_message,created_by_user_id,deleted_at,deleted_by_user_id,created_at,updated_at)
VALUES('2026-08-15',2,?,?,?,?,?,99,?,'旧审计','restore-user',?,'restore-user',?,?)`,
row.orderNumber, row.stall, row.specKey, row.specKey, row.innerCode, row.status, now, now, now)
}
failedRow := model.InnerCodeImportRow{BusinessDate: "2026-08-15", SourceRow: 8, OrderNumber: "ORDER-F",
Stall: "A#1", SpecRaw: "黑色,M", SpecKey: "黑色,M", InnerCode: "DK-F-NEW",
SourceDuplicateCount: 1, CreatedByUserID: "restore-user"}
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
outcome, err := UpsertInnerCodeImportRow(tx, failedRow, "2026-08-15T04:00:00Z")
if err != nil || outcome != InnerCodeImportRestored {
tx.Rollback()
t.Fatalf("失败记录恢复不正确 outcome=%s err=%v", outcome, err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
var status, innerCode string
var stockID sql.NullInt64
var deletedAt, message sql.NullString
if err := db.QueryRow(`SELECT status,inner_code,stock_id,deleted_at,result_message FROM syb_inner_code_records WHERE order_number='ORDER-F'`).
Scan(&status, &innerCode, &stockID, &deletedAt, &message); err != nil {
t.Fatal(err)
}
if status != "pending" || innerCode != "DK-F-NEW" || stockID.Valid || deletedAt.Valid || message.Valid {
t.Fatalf("未完成记录恢复后未重置: status=%s code=%s stock=%+v deleted=%+v message=%+v", status, innerCode, stockID, deletedAt, message)
}
updatedRow := model.InnerCodeImportRow{BusinessDate: "2026-08-15", SourceRow: 9, OrderNumber: "ORDER-U",
Stall: "B#2", SpecRaw: "白色,L", SpecKey: "白色,L", InnerCode: "DK-U",
SourceDuplicateCount: 1, CreatedByUserID: "restore-user"}
tx, err = db.Begin()
if err != nil {
t.Fatal(err)
}
outcome, err = UpsertInnerCodeImportRow(tx, updatedRow, "2026-08-15T04:00:00Z")
if err != nil || outcome != InnerCodeImportRestored {
tx.Rollback()
t.Fatalf("已完成记录恢复不正确 outcome=%s err=%v", outcome, err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`SELECT status,stock_id,deleted_at,result_message FROM syb_inner_code_records WHERE order_number='ORDER-U'`).
Scan(&status, &stockID, &deletedAt, &message); err != nil {
t.Fatal(err)
}
if status != "updated" || !stockID.Valid || deletedAt.Valid || !message.Valid || message.String != "旧审计" {
t.Fatalf("已完成记录恢复后未保留审计: status=%s stock=%+v deleted=%+v message=%+v", status, stockID, deletedAt, message)
}
conflictRow := model.InnerCodeImportRow{BusinessDate: "2026-08-15", SourceRow: 10, OrderNumber: "ORDER-N",
Stall: "C#3", SpecRaw: "杏色,S", SpecKey: "杏色,S", InnerCode: "DK-N-CHANGED",
SourceDuplicateCount: 1, CreatedByUserID: "restore-user"}
tx, err = db.Begin()
if err != nil {
t.Fatal(err)
}
_, err = UpsertInnerCodeImportRow(tx, conflictRow, "2026-08-15T04:00:00Z")
tx.Rollback()
if !errors.Is(err, ErrInnerCodeRestoreConflict) {
t.Fatalf("需核对记录换入库码应拒绝,实际 %v", err)
}
}
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {