fix: 档口入库码改为物理删除 (#318)

This commit is contained in:
chengma
2026-08-28 11:06:22 +08:00
parent 9fbed74e86
commit e784806df9
10 changed files with 235 additions and 66 deletions
+3 -3
View File
@@ -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 读取远端最新详情并保存只读规划,不执行任何回写。
+2 -1
View File
@@ -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`,
+43 -10
View File
@@ -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 {
+36 -22
View File
@@ -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()
+4 -10
View File
@@ -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)
}
+50
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
View File
@@ -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
+17 -5
View File
@@ -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. 反馈方式
| 场景 | 怎么反馈 |