From 2aea22b44d744d22924197bf025d91248f548eca Mon Sep 17 00:00:00 2001 From: chengma Date: Sat, 15 Aug 2026 10:49:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A1=A3=E5=8F=A3=E5=85=A5=E5=BA=93?= =?UTF-8?q?=E7=A0=81=E6=94=AF=E6=8C=81=E8=BD=AF=E5=88=A0=E9=99=A4=E4=B8=8E?= =?UTF-8?q?=E9=87=8D=E5=AF=BC=E6=81=A2=E5=A4=8D=20(#238)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/handler/web/inner_code.go | 18 ++- admin/handler/web/web.go | 1 + admin/inner_code_template_test.go | 12 +- admin/model/inner_code.go | 2 + admin/repository/inner_code.go | 114 ++++++++++++++--- admin/repository/inner_code_test.go | 116 ++++++++++++++++++ admin/repository/mysql_db.go | 78 +++++++++++- admin/repository/mysql_db_integration_test.go | 112 +++++++++++++++++ admin/service/inner_code_delete.go | 38 ++++++ admin/service/inner_code_import.go | 7 ++ admin/static/js/app.js | 17 ++- admin/templates/inner_code/list.html | 18 ++- docs/admin/03-data-model.md | 8 +- 13 files changed, 515 insertions(+), 26 deletions(-) create mode 100644 admin/service/inner_code_delete.go diff --git a/admin/handler/web/inner_code.go b/admin/handler/web/inner_code.go index 2b2fe27..8f962a6 100644 --- a/admin/handler/web/inner_code.go +++ b/admin/handler/web/inner_code.go @@ -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")) diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index e2710af..08103e5 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -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) diff --git a/admin/inner_code_template_test.go b/admin/inner_code_template_test.go index e71943c..eaaba3f 100644 --- a/admin/inner_code_template_test.go +++ b/admin/inner_code_template_test.go @@ -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) { diff --git a/admin/model/inner_code.go b/admin/model/inner_code.go index bc7e740..235eda7 100644 --- a/admin/model/inner_code.go +++ b/admin/model/inner_code.go @@ -43,6 +43,8 @@ type InnerCodeRecord struct { PlannedAt string ApplyStartedAt string AppliedAt string + DeletedAt string + DeletedByUserID string CreatedAt string UpdatedAt string } diff --git a/admin/repository/inner_code.go b/admin/repository/inner_code.go index 0865988..62fedd2 100644 --- a/admin/repository/inner_code.go +++ b/admin/repository/inner_code.go @@ -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 diff --git a/admin/repository/inner_code_test.go b/admin/repository/inner_code_test.go index ecbfca0..6700c59 100644 --- a/admin/repository/inner_code_test.go +++ b/admin/repository/inner_code_test.go @@ -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) + } +} diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index 1f1473f..17c6757 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -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 { diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go index 3705302..3ae58ea 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -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 { diff --git a/admin/service/inner_code_delete.go b/admin/service/inner_code_delete.go new file mode 100644 index 0000000..0b0f502 --- /dev/null +++ b/admin/service/inner_code_delete.go @@ -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)) +} diff --git a/admin/service/inner_code_import.go b/admin/service/inner_code_import.go index cc29ca6..d95ad74 100644 --- a/admin/service/inner_code_import.go +++ b/admin/service/inner_code_import.go @@ -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++ } diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 3eff68b..9d93303 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -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(); } diff --git a/admin/templates/inner_code/list.html b/admin/templates/inner_code/list.html index 26077ca..6562bff 100644 --- a/admin/templates/inner_code/list.html +++ b/admin/templates/inner_code/list.html @@ -48,6 +48,18 @@ + + + {{if .Message}}

{{.Message}}

{{end}} @@ -56,7 +68,7 @@ - + @@ -75,9 +87,9 @@ diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 5018262..11e2e9b 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -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` 已变化则拒绝恢复,交由人工核对。
业务日期 订单号 档口及货号
+ aria-label="选择订单 {{.OrderNumber}}"> {{.BusinessDate}} {{.OrderNumber}}