fix: 档口入库码改为物理删除 (#318)
This commit is contained in:
@@ -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 读取远端最新详情并保存只读规划,不执行任何回写。
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@
|
||||
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="将永久删除 {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>
|
||||
|
||||
+16
-14
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Admin-Data-Model
|
||||
wiki_url: https://git.ilapage.cn/OPC/cmautobuy/wiki/Admin-Data-Model.-
|
||||
wiki_revision: 827b83621ec7ef3753aa4d9c23962af97485a3e5
|
||||
synchronized_at: 2026-08-26T08:51:52Z
|
||||
wiki_revision: 4b1088936cfa06915528a6bcfa09d1ef88e8dbbe
|
||||
synchronized_at: 2026-08-28T03:01:40Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 03 Admin 数据模型
|
||||
@@ -1043,7 +1043,7 @@ MySQL v31 只把 `chk_ai_match_batch_total` 的范围从 1~100 调整为 1~2
|
||||
`ai_spec_match_decisions`。Admin 重启把未完成明细改为 `interrupted` 并完成批次计数,已经
|
||||
成功写入的 `spec_mappings` 不回滚。
|
||||
|
||||
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23,软删除 v24,后台回写 v25,逐件检查点 v27,原始 SKU v28/v29)
|
||||
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23,v24 软删除兼容,后台回写 v25,逐件检查点 v27,原始 SKU v28/v29)
|
||||
|
||||
档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统
|
||||
不保存原文件、文件哈希,不再拆批次表或尝试记录表。
|
||||
@@ -1063,17 +1063,19 @@ MySQL v31 只把 `chk_ai_match_batch_total` 的范围从 1~100 调整为 1~2
|
||||
`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` 并清空旧规划;`queued/applying/updated/already_filled/needs_check` 保留状态和远端
|
||||
审计。后一组状态若导入的 `inner_code` 已变化则拒绝恢复,交由人工核对。
|
||||
- 同日新版 Excel 可能因商品增删或排序变化重新分配入库码。每次导入都会在事务内锁定同日
|
||||
完整旧快照;只有旧快照非空,并且每条旧记录都已软删除、状态严格为 `pending`,且不存在
|
||||
匹配、规划、排队、回写或远端结果字段时,才自动物理删除整日旧快照后导入新版。任一旧
|
||||
记录不满足条件时不物理删除,继续原有唯一归属检查和幂等 upsert;锁定期间数量变化则整批
|
||||
回滚。该流程不新增表,不改变存在未删除或已处理记录时的普通重复导入语义。
|
||||
- v24 增加 `deleted_at`、`deleted_by_user_id`。它们继续保留给 2026-08-28 以前已经软删除的
|
||||
历史记录;正常列表、统计、匹配和新的回写入口仍只读取未删除记录,不迁移或清理存量。
|
||||
- #318 起,页面删除改为事务化物理删除。除 `applying` 外的可见状态均可删除;事务先核对
|
||||
全部稳定 ID,再执行带 `status<>'applying'` 条件的参数化 `DELETE` 并核对影响行数。任一
|
||||
记录不存在、状态并发变化或已经进入 `applying` 时整批回滚。`queued` 尚未被领取时可删除,
|
||||
已被领取时会因变为 `applying` 而拒绝。
|
||||
- 物理删除不会撤销顺运宝已经写入的快递单号,也不保留本地匹配、回写或删除审计;重新导入
|
||||
相同业务键时按新增记录处理。历史软删除记录仍沿用兼容恢复:未完成状态恢复为 `pending`
|
||||
并清空旧规划,已完成或结果不确定状态保留远端信息,入库码冲突时拒绝恢复。
|
||||
- 同日新版 Excel 可能因商品增删或排序变化重新分配入库码。每次导入仍会核对同日完整旧快照;
|
||||
只有历史旧快照非空,并且每条记录都已软删除、状态严格为 `pending`,且不存在匹配、规划、
|
||||
排队、回写或远端结果字段时,才自动物理删除整日旧快照后导入新版。任一旧记录不满足条件时
|
||||
不物理删除,继续原有唯一归属检查和幂等 upsert;锁定期间数量变化则整批回滚。
|
||||
- v25 增加 `apply_batch_id`、`apply_queued_at` 和批次状态索引。批次仍记录在同一业务表,
|
||||
不新增批次表:提交时所选 `ready` 记录必须在同一事务内全部转为 `queued`;后台每次最多
|
||||
读取 20 条并逐条领取为 `applying`。页面按 `apply_batch_id` 聚合当前批次进度。Admin
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Admin-UI-Specification
|
||||
wiki_url: https://git.ilapage.cn/OPC/cmautobuy/wiki/Admin-UI-Specification.-
|
||||
wiki_revision: 30e1998b9775da432e6ecf4461421754a6fed734
|
||||
synchronized_at: 2026-08-26T08:52:03Z
|
||||
wiki_revision: 9643e885d9ae16a82080552fdf3751e4328afb19
|
||||
synchronized_at: 2026-08-28T03:06:06Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 05 Admin 界面规范
|
||||
@@ -999,14 +999,26 @@ AI 建议成功后显示填入、未决和保留人工选择的数量,继续
|
||||
### 8.7 档口入库码同日新版导入
|
||||
|
||||
导入区只保留选择文件、文件名和“导入”按钮,不显示替换模式或专门确认框。上游在同一业务
|
||||
日期重新生成完整 Excel 时,后端自动判断整日旧快照能否安全替换:只有全部旧记录均已软
|
||||
删除、仍为待匹配且没有匹配、排队或回写痕迹时,才在同一事务内删除旧快照并导入新文件。
|
||||
日期重新生成完整 Excel 时,后端自动判断历史软删除的整日旧快照能否安全替换:只有全部旧
|
||||
记录均已软删除、仍为待匹配且没有匹配、排队或回写痕迹时,才在同一事务内删除旧快照并导入
|
||||
新文件。
|
||||
|
||||
没有旧记录,或存在未删除、已处理记录时,继续原有幂等更新和唯一归属检查,不物理删除。
|
||||
没有旧记录、记录已由当前物理删除入口删净,或存在未删除、已处理记录时,继续原有幂等更新
|
||||
和唯一归属检查,不执行旧快照替换。
|
||||
成功自动替换时,反馈必须单独显示实际替换旧记录数量;冲突或并发变化时说明本次没有删除、
|
||||
没有导入以及刷新重试或人工核对的恢复路径。提交期间沿用“导入中…”和禁用按钮,防止重复
|
||||
提交。
|
||||
|
||||
### 8.8 档口入库码物理删除
|
||||
|
||||
列表继续允许所有可见状态参与勾选,但服务端固定禁止删除正在回写的 `applying` 记录。选中
|
||||
集合只要包含正在回写、已经不存在或状态并发变化的记录,就拒绝整批删除,不允许部分成功。
|
||||
`queued` 尚未被后台领取时可以删除;如果已被领取并变成 `applying`,同样整批拒绝。
|
||||
|
||||
其他状态执行物理删除,不进入回收站且不可恢复;重新导入相同业务键会创建新记录。确认框
|
||||
必须显示“将永久删除 N 条、不可恢复”,并说明删除不会撤销顺运宝已经写入的快递单号。成功
|
||||
反馈不得再使用“可恢复”措辞;失败反馈说明没有部分删除并提示刷新后重新选择。
|
||||
|
||||
## 9. 反馈方式
|
||||
|
||||
| 场景 | 怎么反馈 |
|
||||
|
||||
Reference in New Issue
Block a user