diff --git a/admin/handler/web/inner_code.go b/admin/handler/web/inner_code.go index bf1fe87..effa961 100644 --- a/admin/handler/web/inner_code.go +++ b/admin/handler/web/inner_code.go @@ -154,19 +154,19 @@ func (h *Handler) InnerCodeImport(c *gin.Context) { h.innerCodeRedirect(c, result.BusinessDate, "", "", 1, innerCodeFeedbackSuccess, message) } -// InnerCodeDelete 软删除当前页已选记录;不撤销任何顺运宝远端操作。 +// 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()) + count, err := service.DeleteInnerCodes(h.db, ids) if err != nil { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, "删除失败:"+err.Error()+";没有部分删除,请刷新后重试。") return } h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackSuccess, - fmt.Sprintf("已删除 %d 条档口入库码记录;顺运宝已写入的快递单号不会撤销。重新导入相同数据可恢复。", count)) + fmt.Sprintf("已永久删除 %d 条档口入库码记录;顺运宝已写入的快递单号不会撤销。", count)) } // InnerCodeMatch 读取远端最新详情并保存只读规划,不执行任何回写。 diff --git a/admin/inner_code_template_test.go b/admin/inner_code_template_test.go index 59a89c1..afc581d 100644 --- a/admin/inner_code_template_test.go +++ b/admin/inner_code_template_test.go @@ -35,7 +35,8 @@ func TestInnerCodeTemplate_独立导航与安全表单(t *testing.T) { `data-need-checked="inner-code-match"`, `data-inner-code-id="{{.ID}}"`, `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="档口入库码记录列表"`, + `将永久删除 {n} 条档口入库码记录,不可恢复`, `删除不会撤销顺运宝已写入的快递单号`, + `正在回写的记录会拒绝整批删除`, `匹配已选`, `aria-label="档口入库码记录列表"`, `data-auto-dismiss-toast data-transient-feedback`, `role="alertdialog"`, `id="inner-code-feedback-error-modal" data-auto-open-modal`, `data-upload-form`, `data-upload-submit`, diff --git a/admin/repository/inner_code.go b/admin/repository/inner_code.go index 1e6d000..68bf0c4 100644 --- a/admin/repository/inner_code.go +++ b/admin/repository/inner_code.go @@ -21,8 +21,11 @@ var ErrInnerCodeRestoreConflict = errors.New("已回写或需核对的删除记 // ErrInnerCodeSnapshotChanged 表示安全替换校验后,同日旧快照又发生了并发变化。 var ErrInnerCodeSnapshotChanged = errors.New("档口入库码旧快照已发生变化") -// ErrInnerCodeDeleteConflict 表示批量删除时记录已经不可见或不存在,整批不会部分删除。 -var ErrInnerCodeDeleteConflict = errors.New("部分档口入库码记录已删除或不存在") +// ErrInnerCodeDeleteConflict 表示批量删除时记录不存在或状态已经变化,整批不会部分删除。 +var ErrInnerCodeDeleteConflict = errors.New("部分档口入库码记录不存在或状态已变化") + +// ErrInnerCodeDeleteApplying 表示至少一条记录正在执行顺运宝回写,不能丢失本地收尾依据。 +var ErrInnerCodeDeleteApplying = errors.New("所选档口入库码记录中有记录正在回写") // ErrInnerCodeApplyConflict 表示所选记录有一条已不再可回写,整批不会部分入队。 var ErrInnerCodeApplyConflict = errors.New("部分档口入库码记录已不再可回写") @@ -734,15 +737,14 @@ 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) { +// HardDeleteInnerCodeRecords 物理删除整批记录。 +// 正在回写的 applying 记录固定禁止删除;任一记录不存在或并发变化时整批回滚。 +func HardDeleteInnerCodeRecords(db *sql.DB, ids []int64) (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) + args := make([]any, 0, len(ids)) for index, id := range ids { placeholders[index] = "?" args = append(args, id) @@ -752,11 +754,42 @@ func SoftDeleteInnerCodeRecords(db *sql.DB, ids []int64, actorUserID, deletedAt 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=? + rows, err := tx.Query(`SELECT id,status FROM syb_inner_code_records WHERE deleted_at IS NULL AND id IN (`+strings.Join(placeholders, ",")+`)`, args...) if err != nil { - return 0, fmt.Errorf("软删除档口入库码记录失败: %w", err) + return 0, fmt.Errorf("核对待删除档口入库码记录失败: %w", err) + } + found := 0 + applying := false + for rows.Next() { + var id int64 + var status model.InnerCodeStatus + if err := rows.Scan(&id, &status); err != nil { + rows.Close() + return 0, fmt.Errorf("读取待删除档口入库码状态失败: %w", err) + } + found++ + if status == model.InnerCodeApplying { + applying = true + } + } + if err := rows.Err(); err != nil { + rows.Close() + return 0, fmt.Errorf("遍历待删除档口入库码状态失败: %w", err) + } + if err := rows.Close(); err != nil { + return 0, fmt.Errorf("关闭待删除档口入库码查询失败: %w", err) + } + if found != len(ids) { + return 0, ErrInnerCodeDeleteConflict + } + if applying { + return 0, ErrInnerCodeDeleteApplying + } + result, err := tx.Exec(`DELETE FROM syb_inner_code_records + WHERE deleted_at IS NULL AND status<>'applying' AND id IN (`+strings.Join(placeholders, ",")+`)`, args...) + if err != nil { + return 0, fmt.Errorf("物理删除档口入库码记录失败: %w", err) } affected, err := result.RowsAffected() if err != nil { diff --git a/admin/repository/inner_code_test.go b/admin/repository/inner_code_test.go index ba4c045..b6eb4fe 100644 --- a/admin/repository/inner_code_test.go +++ b/admin/repository/inner_code_test.go @@ -120,7 +120,7 @@ func TestDeleteInnerCodeSnapshotByDate_数量变化时可整体回滚(t *testing } } -func TestSoftDeleteInnerCodeRecords_所有状态只写删除审计(t *testing.T) { +func TestHardDeleteInnerCodeRecords_除回写中外所有状态物理删除(t *testing.T) { db, err := sql.Open("sqlite", "file:inner_code_delete?mode=memory&cache=shared") if err != nil { t.Fatal(err) @@ -131,7 +131,7 @@ func TestSoftDeleteInnerCodeRecords_所有状态只写删除审计(t *testing.T) )`); err != nil { t.Fatal(err) } - statuses := []string{"pending", "ready", "queued", "applying", "updated", "already_filled", "skipped", "failed", "needs_check"} + statuses := []string{"pending", "ready", "queued", "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) @@ -141,29 +141,43 @@ func TestSoftDeleteInnerCodeRecords_所有状态只写删除审计(t *testing.T) for index := range ids { ids[index] = int64(index + 1) } - count, err := SoftDeleteInnerCodeRecords(db, ids, "user-1", "2026-08-15T02:00:00Z") + count, err := HardDeleteInnerCodeRecords(db, ids) 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 { + var remaining int + if err := db.QueryRow(`SELECT COUNT(*) FROM syb_inner_code_records`).Scan(&remaining); 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++ + if remaining != 0 { + t.Fatalf("物理删除后仍剩余 %d 条记录", remaining) } } -func TestSoftDeleteInnerCodeRecords_有失效ID时整批回滚(t *testing.T) { +func TestHardDeleteInnerCodeRecords_回写中记录让整批回滚(t *testing.T) { + db, err := sql.Open("sqlite", "file:inner_code_delete_applying?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 + ); INSERT INTO syb_inner_code_records(id,status) VALUES(1,'pending'),(2,'applying')`); err != nil { + t.Fatal(err) + } + if _, err := HardDeleteInnerCodeRecords(db, []int64{1, 2}); !errors.Is(err, ErrInnerCodeDeleteApplying) { + t.Fatalf("期望正在回写错误,实际 %v", err) + } + var remaining int + if err := db.QueryRow(`SELECT COUNT(*) FROM syb_inner_code_records`).Scan(&remaining); err != nil { + t.Fatal(err) + } + if remaining != 2 { + t.Fatalf("整批回滚后应保留 2 条记录,实际 %d", remaining) + } +} + +func TestHardDeleteInnerCodeRecords_有失效ID时整批回滚(t *testing.T) { db, err := sql.Open("sqlite", "file:inner_code_delete_conflict?mode=memory&cache=shared") if err != nil { t.Fatal(err) @@ -174,15 +188,15 @@ func TestSoftDeleteInnerCodeRecords_有失效ID时整批回滚(t *testing.T) { ); 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) { + if _, err := HardDeleteInnerCodeRecords(db, []int64{1, 99}); !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 { + var remaining int + if err := db.QueryRow(`SELECT COUNT(*) FROM syb_inner_code_records`).Scan(&remaining); err != nil { t.Fatal(err) } - if deletedAt.Valid { - t.Fatalf("整批回滚后不应保留删除标记: %+v", deletedAt) + if remaining != 1 { + t.Fatalf("整批回滚后应保留原记录,实际 %d 条", remaining) } } diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go index b257981..1d1b342 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -1361,6 +1361,69 @@ func TestMySQLMigrate_V30升级V31放宽AI匹配批次到200条(t *testing.T) { } } +func TestHardDeleteInnerCodeRecords_MySQL物理删除后同业务键按新增导入(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-28T03:00:00Z" + mustExec(t, db, `INSERT INTO users(user_id,username,password_hash,role,status,password_changed_at,created_at,updated_at) + VALUES('delete-user','delete-user','hash','purchaser','active',?,?,?)`, now, now, now) + for _, row := range []struct { + id int + orderNumber, innerCode, status string + }{ + {1, "ORDER-P", "DK-P", "pending"}, + {2, "ORDER-Q", "DK-Q", "queued"}, + {3, "ORDER-A", "DK-A", "applying"}, + } { + mustExec(t, db, `INSERT INTO syb_inner_code_records + (id,business_date,source_row,order_number,stall,spec_raw,spec_key,inner_code,status, + created_by_user_id,created_at,updated_at) + VALUES(?,'2026-08-28',2,?,'A#1','黑色,M','黑色,M',?,?, 'delete-user',?,?)`, + row.id, row.orderNumber, row.innerCode, row.status, now, now) + } + count, err := HardDeleteInnerCodeRecords(db, []int64{1, 2}) + if err != nil || count != 2 { + t.Fatalf("物理删除失败 count=%d err=%v", count, err) + } + row := model.InnerCodeImportRow{BusinessDate: "2026-08-28", SourceRow: 8, OrderNumber: "ORDER-P", + Stall: "A#1", SpecRaw: "黑色,M", SpecKey: "黑色,M", InnerCode: "DK-P", + SourceDuplicateCount: 1, CreatedByUserID: "delete-user"} + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + outcome, err := UpsertInnerCodeImportRow(tx, row, "2026-08-28T04:00:00Z") + if err != nil || outcome != InnerCodeImportCreated { + tx.Rollback() + t.Fatalf("物理删除后应按新增导入 outcome=%s err=%v", outcome, err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + var recreatedID int64 + if err := db.QueryRow(`SELECT id FROM syb_inner_code_records WHERE order_number='ORDER-P'`).Scan(&recreatedID); err != nil { + t.Fatal(err) + } + if recreatedID == 1 { + t.Fatalf("重新导入不应恢复已物理删除的旧主键,实际 id=%d", recreatedID) + } + if _, err := HardDeleteInnerCodeRecords(db, []int64{recreatedID, 3}); !errors.Is(err, ErrInnerCodeDeleteApplying) { + t.Fatalf("包含 applying 时应整批拒绝,实际 %v", err) + } + var remaining int + if err := db.QueryRow(`SELECT COUNT(*) FROM syb_inner_code_records WHERE id IN (?,3)`, recreatedID).Scan(&remaining); err != nil { + t.Fatal(err) + } + if remaining != 2 { + t.Fatalf("拒绝后应完整保留两条记录,实际 %d", remaining) + } +} + func TestUpsertInnerCodeImportRow_软删除记录按状态安全恢复(t *testing.T) { db := openMySQLMigrationTestDB(t) defer db.Close() diff --git a/admin/service/inner_code_delete.go b/admin/service/inner_code_delete.go index 67c56b7..acf2778 100644 --- a/admin/service/inner_code_delete.go +++ b/admin/service/inner_code_delete.go @@ -3,22 +3,16 @@ 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) { +// DeleteInnerCodes 物理删除当前页选中的档口入库码记录。 +// applying 记录禁止删除;删除不会撤销顺运宝已经写入的远端值。 +func DeleteInnerCodes(db *sql.DB, ids []int64) (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 { @@ -34,5 +28,5 @@ func DeleteInnerCodes(db *sql.DB, ids []int64, actorUserID string, now time.Time if len(unique) > InnerCodeDeleteBatchLimit { return 0, fmt.Errorf("一次最多删除当前页 %d 条记录", InnerCodeDeleteBatchLimit) } - return repository.SoftDeleteInnerCodeRecords(db, unique, actorUserID, now.UTC().Format(model.TimeLayout)) + return repository.HardDeleteInnerCodeRecords(db, unique) } diff --git a/admin/service/inner_code_delete_test.go b/admin/service/inner_code_delete_test.go new file mode 100644 index 0000000..188f26d --- /dev/null +++ b/admin/service/inner_code_delete_test.go @@ -0,0 +1,50 @@ +package service + +import ( + "database/sql" + "errors" + "testing" + + "cmautobuy/admin/repository" + _ "modernc.org/sqlite" +) + +func TestDeleteInnerCodes_去重后物理删除(t *testing.T) { + db, err := sql.Open("sqlite", "file:service_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 + ); INSERT INTO syb_inner_code_records(id,status) VALUES(1,'pending'),(2,'queued')`); err != nil { + t.Fatal(err) + } + count, err := DeleteInnerCodes(db, []int64{1, 1, 2, 0, -1}) + if err != nil || count != 2 { + t.Fatalf("count=%d err=%v", count, err) + } + var remaining int + if err := db.QueryRow(`SELECT COUNT(*) FROM syb_inner_code_records`).Scan(&remaining); err != nil { + t.Fatal(err) + } + if remaining != 0 { + t.Fatalf("物理删除后仍剩余 %d 条记录", remaining) + } +} + +func TestDeleteInnerCodes_回写中错误原样返回(t *testing.T) { + db, err := sql.Open("sqlite", "file:service_inner_code_delete_applying?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 + ); INSERT INTO syb_inner_code_records(id,status) VALUES(1,'applying')`); err != nil { + t.Fatal(err) + } + if _, err := DeleteInnerCodes(db, []int64{1}); !errors.Is(err, repository.ErrInnerCodeDeleteApplying) { + t.Fatalf("期望正在回写错误,实际 %v", err) + } +} diff --git a/admin/templates/inner_code/list.html b/admin/templates/inner_code/list.html index ae1d705..b26d65f 100644 --- a/admin/templates/inner_code/list.html +++ b/admin/templates/inner_code/list.html @@ -50,7 +50,7 @@ data-inner-code-apply-open disabled>回写已选 0 条