feat: 档口入库码支持软删除与重导恢复 (#238)
This commit is contained in:
+100
-14
@@ -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, ¤tInnerCode, &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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user