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
+16 -2
View File
@@ -108,11 +108,25 @@ func (h *Handler) InnerCodeImport(c *gin.Context) {
fail(c, http.StatusInternalServerError, "档口入库码写库失败,本次导入已整体回滚,请稍后重试。")
return
}
message := fmt.Sprintf("导入完成:业务日期 %s,读取 %d 行,新增 %d 条,更新 %d 条,文件内重复 %d 行。",
result.BusinessDate, result.TotalRows, result.CreatedCount, result.UpdatedCount, result.DuplicateRows)
message := fmt.Sprintf("导入完成:业务日期 %s,读取 %d 行,新增 %d 条,更新 %d 条,恢复 %d 条,文件内重复 %d 行。",
result.BusinessDate, result.TotalRows, result.CreatedCount, result.UpdatedCount, result.RestoredCount, result.DuplicateRows)
h.innerCodeRedirect(c, result.BusinessDate, "", "", 1, message)
}
// InnerCodeDelete 软删除当前页已选记录;不撤销任何顺运宝远端操作。
func (h *Handler) InnerCodeDelete(c *gin.Context) {
businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q")
pageNumber := service.ParsePage(c.PostForm("page"))
ids := parseInnerCodeIDs(c.PostFormArray("ids"))
count, err := service.DeleteInnerCodes(h.db, ids, currentUser(c).UserID, time.Now())
if err != nil {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "删除失败:"+err.Error()+";没有部分删除,请刷新后重试。")
return
}
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber,
fmt.Sprintf("已删除 %d 条档口入库码记录;顺运宝已写入的快递单号不会撤销。重新导入相同数据可恢复。", count))
}
// InnerCodeMatch 读取远端最新详情并保存只读规划,不执行任何回写。
func (h *Handler) InnerCodeMatch(c *gin.Context) {
businessDate := strings.TrimSpace(c.PostForm("date"))
+1
View File
@@ -119,6 +119,7 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration, aiSecret
pages.GET("/inner-codes", h.InnerCodeList)
innerCodes := pages.Group("/inner-codes")
innerCodes.POST("/import", h.InnerCodeImport)
innerCodes.POST("/delete", h.InnerCodeDelete)
innerCodes.POST("/match", h.InnerCodeMatch)
innerCodes.POST("/apply", h.InnerCodeApply)
innerCodes.POST("/recheck", h.InnerCodeRecheck)
+9 -3
View File
@@ -29,16 +29,20 @@ func TestInnerCodeTemplate_独立导航与安全表单(t *testing.T) {
page := string(raw)
for _, want := range []string{
`action="/inner-codes/import"`, `action="/inner-codes/match"`,
`action="/inner-codes/apply"`, `action="/inner-codes/recheck"`,
`action="/inner-codes/apply"`, `action="/inner-codes/recheck"`, `action="/inner-codes/delete"`,
`name="csrf_token"`, `data-check-all`, `data-inner-code-apply-open`,
`data-need-checked="inner-code-match"`, `data-inner-code-id="{{.ID}}"`,
`inner-code-match{{end}}{{if .CanApply}} inner-code-apply`,
`{{if not .CanMatch}}disabled{{end}}`, `匹配已选`, `aria-label="档口入库码记录列表"`,
`data-need-checked="inner-code-delete"`, `data-confirm-delete-action="inner-code-delete"`,
`inner-code-delete {{if .CanMatch}}inner-code-match{{end}}{{if .CanApply}} inner-code-apply`,
`删除不会撤销顺运宝已写入的快递单号`, `匹配已选`, `aria-label="档口入库码记录列表"`,
} {
if !strings.Contains(page, want) {
t.Errorf("正式页面缺少 %s", want)
}
}
if strings.Contains(page, `{{if not .CanMatch}}disabled{{end}}`) {
t.Fatal("所有状态都应允许勾选删除,不能再按 CanMatch 禁用复选框")
}
}
func TestInnerCodeTemplate_模块状态白名单已登记(t *testing.T) {
@@ -52,6 +56,8 @@ func TestInnerCodeTemplate_模块状态白名单已登记(t *testing.T) {
for _, want := range []string{
`appendSelectedIDs(matchForm, "inner-code-match")`,
`appendSelectedIDs(applyForm, "inner-code-apply")`,
`appendSelectedIDs(deleteForm, "inner-code-delete")`,
`checkedCount(action)`,
`field.name = "ids"`,
} {
if !strings.Contains(string(raw), want) {
+2
View File
@@ -43,6 +43,8 @@ type InnerCodeRecord struct {
PlannedAt string
ApplyStartedAt string
AppliedAt string
DeletedAt string
DeletedByUserID string
CreatedAt string
UpdatedAt string
}
+98 -12
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"
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 {
+38
View File
@@ -0,0 +1,38 @@
package service
import (
"database/sql"
"fmt"
"strings"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
// DeleteInnerCodes 软删除当前页选中的档口入库码记录。
// 删除只隐藏本地记录,不取消在途请求,也不撤销顺运宝远端值。
func DeleteInnerCodes(db *sql.DB, ids []int64, actorUserID string, now time.Time) (int, error) {
if len(ids) == 0 {
return 0, fmt.Errorf("请先选择要删除的记录")
}
if strings.TrimSpace(actorUserID) == "" {
return 0, fmt.Errorf("删除账号不能为空")
}
seen := make(map[int64]bool, len(ids))
unique := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 || seen[id] {
continue
}
seen[id] = true
unique = append(unique, id)
}
if len(unique) == 0 {
return 0, fmt.Errorf("没有有效的档口入库码记录")
}
if len(unique) > PageSize {
return 0, fmt.Errorf("一次最多删除当前页 %d 条记录", PageSize)
}
return repository.SoftDeleteInnerCodeRecords(db, unique, actorUserID, now.UTC().Format(model.TimeLayout))
}
+7
View File
@@ -45,6 +45,7 @@ type InnerCodeImportResult struct {
TotalRows int
CreatedCount int
UpdatedCount int
RestoredCount int
DuplicateRows int
}
@@ -130,10 +131,16 @@ func ImportInnerCodeExcel(db *sql.DB, path, originalFilename, actorUserID string
return nil, fmt.Errorf("%w:Excel 第 %d 行的入库码 %q 在该业务日期已属于另一条记录",
ErrInvalidInnerCodeImport, row.SourceRow, row.InnerCode)
}
if errors.Is(err, repository.ErrInnerCodeRestoreConflict) {
return nil, fmt.Errorf("%w:Excel 第 %d 行对应的已删除记录已有远端结果,且入库码发生变化;请人工核对后处理",
ErrInvalidInnerCodeImport, row.SourceRow)
}
return nil, fmt.Errorf("导入 Excel 第 %d 行失败: %w", row.SourceRow, err)
}
if outcome == repository.InnerCodeImportCreated {
result.CreatedCount++
} else if outcome == repository.InnerCodeImportRestored {
result.RestoredCount++
} else {
result.UpdatedCount++
}
+16 -1
View File
@@ -164,7 +164,8 @@
document.querySelectorAll("[data-confirm-delete]").forEach(function (el) {
var isForm = el.tagName === "FORM";
el.addEventListener(isForm ? "submit" : "click", function (e) {
var n = checkedCount();
var action = el.getAttribute("data-confirm-delete-action") || "";
var n = checkedCount(action);
if (n === 0) {
e.preventDefault();
return;
@@ -374,6 +375,7 @@
function updateSelectionSummary() {
var matchSelected = checkedBoxes("inner-code-match");
var applySelected = checkedBoxes("inner-code-apply");
var deleteSelected = checkedBoxes("inner-code-delete");
var replacements = applySelected.filter(function (box) {
return box.getAttribute("data-inner-code-old-value") === "1";
}).length;
@@ -383,6 +385,8 @@
.forEach(function (node) { node.textContent = applySelected.length; });
document.querySelectorAll("[data-inner-code-replace-count]")
.forEach(function (node) { node.textContent = replacements; });
document.querySelectorAll("[data-inner-code-delete-count]")
.forEach(function (node) { node.textContent = deleteSelected.length; });
}
function appendSelectedIDs(form, action) {
@@ -431,6 +435,17 @@
button.textContent = "回写中…";
}
});
var deleteForm = document.getElementById("inner-code-delete-form");
if (deleteForm) deleteForm.addEventListener("submit", function (event) {
if (event.defaultPrevented) return;
appendSelectedIDs(deleteForm, "inner-code-delete");
var button = document.querySelector('[form="inner-code-delete-form"]');
if (button) {
button.disabled = true;
button.setAttribute("aria-busy", "true");
button.textContent = "删除中…";
}
});
syncDate();
updateSelectionSummary();
}
+15 -3
View File
@@ -48,6 +48,18 @@
</form>
<button type="button" data-modal-open="inner-code-apply-modal" data-need-checked="inner-code-apply"
data-inner-code-apply-open disabled>回写已选 <span data-inner-code-selected-count>0</span> 条</button>
<form id="inner-code-delete-form" method="post" action="/inner-codes/delete" hidden
data-confirm-delete="将删除 {n} 条档口入库码记录。删除不会撤销顺运宝已写入的快递单号,也不会取消正在回写的请求;重新导入相同数据可以恢复。"
data-confirm-delete-action="inner-code-delete">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="date" value="{{.BusinessDate}}" data-inner-code-date-field>
<input type="hidden" name="status" value="{{.StatusFilter}}">
<input type="hidden" name="q" value="{{.Keyword}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
</form>
<button type="submit" class="danger" form="inner-code-delete-form"
data-need-checked="inner-code-delete" disabled>删除已选 <span data-inner-code-delete-count>0</span> 条</button>
</div>
{{if .Message}}<p class="notice inner-code-notice" role="status" aria-live="polite">{{.Message}}</p>{{end}}
@@ -56,7 +68,7 @@
<table class="inner-code-table">
<thead>
<tr>
<th class="col-check"><input type="checkbox" data-check-all aria-label="全选当前页可匹配记录"></th>
<th class="col-check"><input type="checkbox" data-check-all aria-label="全选当前页记录"></th>
<th>业务日期</th>
<th>订单号</th>
<th>档口及货号</th>
@@ -75,9 +87,9 @@
<tr {{if or (eq .Status "failed") (eq .Status "needs_check")}}class="row-warn"{{end}}>
<td class="col-check">
<input type="checkbox" value="{{.ID}}" data-inner-code-id="{{.ID}}"
data-select-action="{{if .CanMatch}}inner-code-match{{end}}{{if .CanApply}} inner-code-apply{{end}}"
data-select-action="inner-code-delete {{if .CanMatch}}inner-code-match{{end}}{{if .CanApply}} inner-code-apply{{end}}"
data-inner-code-old-value="{{if .HasRemoteOldCode}}1{{else}}0{{end}}"
aria-label="选择订单 {{.OrderNumber}}" {{if not .CanMatch}}disabled{{end}}>
aria-label="选择订单 {{.OrderNumber}}">
</td>
<td>{{.BusinessDate}}</td>
<td>{{.OrderNumber}}</td>
+7 -1
View File
@@ -1022,7 +1022,7 @@ CREATE UNIQUE INDEX idx_client_assignment_current
`ai_spec_match_decisions`。Admin 重启把未完成明细改为 `interrupted` 并完成批次计数,已经
成功写入的 `spec_mappings` 不回滚。
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23)
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23,软删除 v24)
档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统
不保存原文件、文件哈希,不再拆批次表或尝试记录表。
@@ -1038,3 +1038,9 @@ CREATE UNIQUE INDEX idx_client_assignment_current
或 `needs_check` 的远端结果和审计状态。
- `created_by_user_id` 记录首次导入账号,`applied_by_user_id` 记录实际回写账号;时间字段
使用 UTC ISO 8601。用户只停用不物理删除,因此外键不会阻塞账号生命周期。
- v24 增加 `deleted_at`、`deleted_by_user_id`。正常列表、统计、匹配和新的回写入口只读取
未删除记录;删除不改变业务状态,也不撤销顺运宝远端值。在途 `applying` 的最终结果仍
写回同一条隐藏记录,保证远端结果审计不会丢失。
- 重新导入同一业务键会恢复软删除记录:`pending/ready/skipped/failed` 重置为
`pending` 并清空旧规划;`applying/updated/already_filled/needs_check` 保留状态和远端
审计。后一组状态若导入的 `inner_code` 已变化则拒绝恢复,交由人工核对。