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
|
||||
|
||||
Reference in New Issue
Block a user