From ee3049beac65c2bb16f0c539c2f0550c5c07e5da Mon Sep 17 00:00:00 2001 From: chengma Date: Wed, 26 Aug 2026 12:10:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=E6=A1=A3=E5=8F=A3?= =?UTF-8?q?=E5=85=A5=E5=BA=93=E7=A0=81=E5=90=8C=E6=97=A5=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=20(#304)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/handler/web/inner_code.go | 24 +++++- admin/handler/web/inner_code_test.go | 21 ++++++ admin/inner_code_template_test.go | 27 +++++++ admin/repository/inner_code.go | 51 +++++++++++++ admin/repository/inner_code_test.go | 63 ++++++++++++++++ admin/service/inner_code_import.go | 98 ++++++++++++++++++++----- admin/service/inner_code_import_test.go | 77 +++++++++++++++++++ admin/static/css/app.css | 16 ++++ admin/static/js/app.js | 11 ++- admin/templates/inner_code/list.html | 11 ++- docs/admin/03-data-model.md | 5 ++ docs/admin/05-ui-specification.md | 12 +++ 12 files changed, 392 insertions(+), 24 deletions(-) diff --git a/admin/handler/web/inner_code.go b/admin/handler/web/inner_code.go index 979d558..233fd69 100644 --- a/admin/handler/web/inner_code.go +++ b/admin/handler/web/inner_code.go @@ -131,8 +131,15 @@ func (h *Handler) InnerCodeImport(c *gin.Context) { fail(c, http.StatusInternalServerError, "保存临时上传文件失败;没有导入任何数据。") return } - result, err := service.ImportInnerCodeExcel(h.db, tmpPath, fileHeader.Filename, currentUser(c).UserID) + options := innerCodeImportOptions(c) + result, err := service.ImportInnerCodeExcelWithOptions( + h.db, tmpPath, fileHeader.Filename, currentUser(c).UserID, options, + ) if err != nil { + if service.IsInnerCodeSnapshotReplaceBlocked(err) { + fail(c, http.StatusConflict, err.Error()+";本次没有删除或导入任何数据。") + return + } if service.IsInvalidInnerCodeImport(err) { fail(c, http.StatusBadRequest, err.Error()+";请修正文件后重新导入。") return @@ -140,11 +147,22 @@ func (h *Handler) InnerCodeImport(c *gin.Context) { fail(c, http.StatusInternalServerError, "档口入库码写库失败,本次导入已整体回滚,请稍后重试。") return } - message := fmt.Sprintf("导入完成:业务日期 %s,读取 %d 行,新增 %d 条,更新 %d 条,恢复 %d 条,同业务键合并 %d 行。", - result.BusinessDate, result.TotalRows, result.CreatedCount, result.UpdatedCount, result.RestoredCount, result.MergedRows) + replaceSummary := "" + if options.ReplaceDeletedUnprocessedSnapshot { + replaceSummary = fmt.Sprintf(",替换旧数据 %d 条", result.ReplacedCount) + } + message := fmt.Sprintf("导入完成:业务日期 %s,读取 %d 行,新增 %d 条,更新 %d 条,恢复 %d 条,同业务键合并 %d 行%s。", + result.BusinessDate, result.TotalRows, result.CreatedCount, result.UpdatedCount, + result.RestoredCount, result.MergedRows, replaceSummary) h.innerCodeRedirect(c, result.BusinessDate, "", "", 1, innerCodeFeedbackSuccess, message) } +func innerCodeImportOptions(c *gin.Context) service.InnerCodeImportOptions { + return service.InnerCodeImportOptions{ + ReplaceDeletedUnprocessedSnapshot: c.PostForm("replace_unprocessed_snapshot") == "1", + } +} + // InnerCodeDelete 软删除当前页已选记录;不撤销任何顺运宝远端操作。 func (h *Handler) InnerCodeDelete(c *gin.Context) { businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q") diff --git a/admin/handler/web/inner_code_test.go b/admin/handler/web/inner_code_test.go index bc06046..8a86286 100644 --- a/admin/handler/web/inner_code_test.go +++ b/admin/handler/web/inner_code_test.go @@ -60,6 +60,27 @@ func TestInnerCodeMatch_不再按20条提前拒绝只读匹配(t *testing.T) { } } +func TestInnerCodeImportOptions_只有明确值才启用安全替换(t *testing.T) { + for _, test := range []struct { + value string + want bool + }{ + {"", false}, + {"0", false}, + {"true", false}, + {"1", true}, + } { + gin.SetMode(gin.TestMode) + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + form := url.Values{"replace_unprocessed_snapshot": {test.value}} + context.Request = httptest.NewRequest(http.MethodPost, "/inner-codes/import", strings.NewReader(form.Encode())) + context.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if got := innerCodeImportOptions(context).ReplaceDeletedUnprocessedSnapshot; got != test.want { + t.Errorf("value=%q got=%v want=%v", test.value, got, test.want) + } + } +} + func TestEnsureInnerCodeMatchSession_有效会话不自动登录(t *testing.T) { loginCalls := 0 autoLoggedIn, message := ensureInnerCodeMatchSession( diff --git a/admin/inner_code_template_test.go b/admin/inner_code_template_test.go index 56869ea..ccef9f3 100644 --- a/admin/inner_code_template_test.go +++ b/admin/inner_code_template_test.go @@ -38,6 +38,8 @@ func TestInnerCodeTemplate_独立导航与安全表单(t *testing.T) { `删除不会撤销顺运宝已写入的快递单号`, `匹配已选`, `aria-label="档口入库码记录列表"`, `data-auto-dismiss-toast data-transient-feedback`, `role="alertdialog"`, `id="inner-code-feedback-error-modal" data-auto-open-modal`, + `name="replace_unprocessed_snapshot" value="1"`, `data-replace-unprocessed-snapshot`, + `替换当日已删除旧数据`, `data-replace-confirm=`, } { if !strings.Contains(page, want) { t.Errorf("正式页面缺少 %s", want) @@ -49,6 +51,31 @@ func TestInnerCodeTemplate_独立导航与安全表单(t *testing.T) { if strings.Contains(page, `inner-code-notice`) { t.Fatal("档口入库码反馈不应继续占用列表上方布局") } + if strings.Contains(page, `name="replace_unprocessed_snapshot" value="1" checked`) { + t.Fatal("危险替换选项必须默认不勾选") + } + + jsRaw, err := os.ReadFile("static/js/app.js") + if err != nil { + t.Fatal(err) + } + js := string(jsRaw) + for _, want := range []string{`replaceSnapshot.checked`, `window.confirm(message)`, `event.preventDefault()`} { + if !strings.Contains(js, want) { + t.Errorf("安全替换确认脚本缺少 %s", want) + } + } + + cssRaw, err := os.ReadFile("static/css/app.css") + if err != nil { + t.Fatal(err) + } + css := string(cssRaw) + for _, want := range []string{`.inner-code-replace-option {`, `.inner-code-replace-option:focus-within`} { + if !strings.Contains(css, want) { + t.Errorf("安全替换选项样式缺少 %s", want) + } + } } func TestInnerCodeTemplate_错误弹窗自动转义且成功提示不占布局(t *testing.T) { diff --git a/admin/repository/inner_code.go b/admin/repository/inner_code.go index 1197663..1e6d000 100644 --- a/admin/repository/inner_code.go +++ b/admin/repository/inner_code.go @@ -18,6 +18,9 @@ var ErrInnerCodeUniqueConflict = errors.New("同一业务日期的档口入库 // ErrInnerCodeRestoreConflict 表示已完成或结果未知的软删除记录不能换入库码后直接恢复。 var ErrInnerCodeRestoreConflict = errors.New("已回写或需核对的删除记录不能用不同入库码恢复") +// ErrInnerCodeSnapshotChanged 表示安全替换校验后,同日旧快照又发生了并发变化。 +var ErrInnerCodeSnapshotChanged = errors.New("档口入库码旧快照已发生变化") + // ErrInnerCodeDeleteConflict 表示批量删除时记录已经不可见或不存在,整批不会部分删除。 var ErrInnerCodeDeleteConflict = errors.New("部分档口入库码记录已删除或不存在") @@ -100,6 +103,54 @@ func LockInnerCodeCodeOwnersByDate(tx *sql.Tx, businessDate string) ([]InnerCode return owners, nil } +// LockInnerCodeSnapshotByDate 锁定同一业务日期的完整旧快照。 +// service 必须逐项确认这些记录已删除且从未参与匹配或回写,才能物理替换。 +func LockInnerCodeSnapshotByDate(tx *sql.Tx, businessDate string) ([]model.InnerCodeRecord, error) { + return listInnerCodeSnapshotByDate(tx, businessDate, true) +} + +func listInnerCodeSnapshotByDate(q Execer, businessDate string, lock bool) ([]model.InnerCodeRecord, error) { + query := `SELECT ` + innerCodeListColumns + ` FROM syb_inner_code_records + WHERE business_date=? ORDER BY id` + if lock { + query += ` FOR UPDATE` + } + rows, err := q.Query(query, businessDate) + if err != nil { + return nil, fmt.Errorf("锁定同日档口入库码旧快照失败: %w", err) + } + defer rows.Close() + result := make([]model.InnerCodeRecord, 0) + for rows.Next() { + row, err := scanInnerCodeRecord(rows) + if err != nil { + return nil, err + } + result = append(result, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("遍历同日档口入库码旧快照失败: %w", err) + } + return result, nil +} + +// DeleteInnerCodeSnapshotByDate 物理删除已经由 service 完整校验并锁定的旧快照。 +// affected 与 expected 不一致时必须让上层回滚,不能在并发变化后继续导入。 +func DeleteInnerCodeSnapshotByDate(tx *sql.Tx, businessDate string, expected int) (int, error) { + result, err := tx.Exec(`DELETE FROM syb_inner_code_records WHERE business_date=?`, businessDate) + 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(expected) { + return 0, fmt.Errorf("%w:预期 %d 条,实际 %d 条", ErrInnerCodeSnapshotChanged, expected, affected) + } + return int(affected), nil +} + // UpsertInnerCodeImportRow 按已确认业务键写入一行。 // 必须在事务中调用;先锁定业务键,避免另一个唯一键冲突时更新错行。 func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now string) (InnerCodeImportOutcome, error) { diff --git a/admin/repository/inner_code_test.go b/admin/repository/inner_code_test.go index 7030b8d..ba4c045 100644 --- a/admin/repository/inner_code_test.go +++ b/admin/repository/inner_code_test.go @@ -57,6 +57,69 @@ func TestInnerCodeImportWriteError_唯一冲突不泄漏索引细节(t *testing. } } +func TestDeleteInnerCodeSnapshotByDate_只删除目标日期且核对数量(t *testing.T) { + db, err := sql.Open("sqlite", "file:inner_code_snapshot_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,business_date TEXT NOT NULL + ); INSERT INTO syb_inner_code_records(id,business_date) VALUES + (1,'2026-08-26'),(2,'2026-08-26'),(3,'2026-08-25')`); err != nil { + t.Fatal(err) + } + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + count, err := DeleteInnerCodeSnapshotByDate(tx, "2026-08-26", 2) + if err != nil || count != 2 { + t.Fatalf("count=%d err=%v", count, err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + var remaining int + if err := db.QueryRow(`SELECT COUNT(*) FROM syb_inner_code_records`).Scan(&remaining); err != nil { + t.Fatal(err) + } + if remaining != 1 { + t.Fatalf("应保留其他业务日期,实际剩余 %d 条", remaining) + } +} + +func TestDeleteInnerCodeSnapshotByDate_数量变化时可整体回滚(t *testing.T) { + db, err := sql.Open("sqlite", "file:inner_code_snapshot_changed?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,business_date TEXT NOT NULL + ); INSERT INTO syb_inner_code_records(id,business_date) VALUES + (1,'2026-08-26'),(2,'2026-08-26')`); err != nil { + t.Fatal(err) + } + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + if _, err := DeleteInnerCodeSnapshotByDate(tx, "2026-08-26", 1); !errors.Is(err, ErrInnerCodeSnapshotChanged) { + t.Fatalf("期望快照变化错误,实际 %v", err) + } + if err := tx.Rollback(); err != nil { + t.Fatal(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("回滚后旧快照应完整保留,实际 %d 条", remaining) + } +} + func TestSoftDeleteInnerCodeRecords_所有状态只写删除审计(t *testing.T) { db, err := sql.Open("sqlite", "file:inner_code_delete?mode=memory&cache=shared") if err != nil { diff --git a/admin/service/inner_code_import.go b/admin/service/inner_code_import.go index 87055c0..0e868cd 100644 --- a/admin/service/inner_code_import.go +++ b/admin/service/inner_code_import.go @@ -27,18 +27,19 @@ const ( ) var ( - ErrInvalidInnerCodeImport = errors.New("档口入库码导入文件无效") - innerCodeXLSXMagic = []byte{0x50, 0x4B, 0x03, 0x04} - innerCodeBracketCN = regexp.MustCompile(`【[^】]*】`) - innerCodeParenthesesCN = regexp.MustCompile(`([^)]*)`) - innerCodeParenthesesASCII = regexp.MustCompile(`\([^)]*\)`) - innerCodeSuggestionTail = regexp.MustCompile(`建議.*$`) - innerCodeSpaces = regexp.MustCompile(`\s+`) - innerCodeStallToken = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]*$`) - innerCodeWeightTail = regexp.MustCompile(`(?i)[\d.]+[-~~到至][\d.]+(?:公斤|kg).*$`) - innerCodeSizeTail = regexp.MustCompile(`(?i)((?:[1-9]\d*)?XL|XXL|XS|S|M|L)$`) - innerCodeFilenameDate = regexp.MustCompile(`(?:^|_)(\d{8})(?:_|\.|$)`) - innerCodeShortDate = regexp.MustCompile(`^(\d{1,2})[-/](\d{1,2})$`) + ErrInvalidInnerCodeImport = errors.New("档口入库码导入文件无效") + ErrInnerCodeSnapshotReplaceBlocked = errors.New("档口入库码旧数据不能安全替换") + innerCodeXLSXMagic = []byte{0x50, 0x4B, 0x03, 0x04} + innerCodeBracketCN = regexp.MustCompile(`【[^】]*】`) + innerCodeParenthesesCN = regexp.MustCompile(`([^)]*)`) + innerCodeParenthesesASCII = regexp.MustCompile(`\([^)]*\)`) + innerCodeSuggestionTail = regexp.MustCompile(`建議.*$`) + innerCodeSpaces = regexp.MustCompile(`\s+`) + innerCodeStallToken = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]*$`) + innerCodeWeightTail = regexp.MustCompile(`(?i)[\d.]+[-~~到至][\d.]+(?:公斤|kg).*$`) + innerCodeSizeTail = regexp.MustCompile(`(?i)((?:[1-9]\d*)?XL|XXL|XS|S|M|L)$`) + innerCodeFilenameDate = regexp.MustCompile(`(?:^|_)(\d{8})(?:_|\.|$)`) + innerCodeShortDate = regexp.MustCompile(`^(\d{1,2})[-/](\d{1,2})$`) ) // InnerCodeImportResult 是一次单表幂等导入的统计。 @@ -49,6 +50,12 @@ type InnerCodeImportResult struct { UpdatedCount int RestoredCount int MergedRows int + ReplacedCount int +} + +// InnerCodeImportOptions 只承载需要操作员明确选择的危险导入模式。 +type InnerCodeImportOptions struct { + ReplaceDeletedUnprocessedSnapshot bool } // IsInvalidInnerCodeImport 判断错误是否需要用户修正上传文件。 @@ -56,6 +63,11 @@ func IsInvalidInnerCodeImport(err error) bool { return errors.Is(err, ErrInvalidInnerCodeImport) } +// IsInnerCodeSnapshotReplaceBlocked 判断失败是否来自旧快照安全门禁。 +func IsInnerCodeSnapshotReplaceBlocked(err error) bool { + return errors.Is(err, ErrInnerCodeSnapshotReplaceBlocked) +} + // ValidateInnerCodeUpload 在落盘前限制 Excel 类型和大小。 func ValidateInnerCodeUpload(filename string, size int64, head []byte) error { if size <= 0 { @@ -107,6 +119,11 @@ func NormalizeInnerCodeSpecKey(value string) string { // ImportInnerCodeExcel 解析工作表并在一个事务中幂等写入业务表。 // 业务日期只取 Excel“生成日期”;originalFilename 仅用于给短日期补充并校验年份。 func ImportInnerCodeExcel(db *sql.DB, path, originalFilename, actorUserID string) (*InnerCodeImportResult, error) { + return ImportInnerCodeExcelWithOptions(db, path, originalFilename, actorUserID, InnerCodeImportOptions{}) +} + +// ImportInnerCodeExcelWithOptions 解析工作表,并按显式选项在同一个事务内幂等导入或安全替换。 +func ImportInnerCodeExcelWithOptions(db *sql.DB, path, originalFilename, actorUserID string, options InnerCodeImportOptions) (*InnerCodeImportResult, error) { if strings.TrimSpace(actorUserID) == "" { return nil, fmt.Errorf("导入账号不能为空") } @@ -125,12 +142,30 @@ func ImportInnerCodeExcel(db *sql.DB, path, originalFilename, actorUserID string return nil, fmt.Errorf("开始档口入库码导入事务失败: %w", err) } defer tx.Rollback() - owners, err := repository.LockInnerCodeCodeOwnersByDate(tx, result.BusinessDate) - if err != nil { - return nil, err - } - if err := validateExistingInnerCodeOwners(result.BusinessDate, owners, rows); err != nil { - return nil, fmt.Errorf("%w:%v", ErrInvalidInnerCodeImport, err) + if options.ReplaceDeletedUnprocessedSnapshot { + existing, err := repository.LockInnerCodeSnapshotByDate(tx, result.BusinessDate) + if err != nil { + return nil, err + } + if err := validateReplaceableInnerCodeSnapshot(result.BusinessDate, existing); err != nil { + return nil, fmt.Errorf("%w:%v", ErrInnerCodeSnapshotReplaceBlocked, err) + } + replaced, err := repository.DeleteInnerCodeSnapshotByDate(tx, result.BusinessDate, len(existing)) + if err != nil { + if errors.Is(err, repository.ErrInnerCodeSnapshotChanged) { + return nil, fmt.Errorf("%w:旧数据在确认期间发生变化,请刷新后重新导入", ErrInnerCodeSnapshotReplaceBlocked) + } + return nil, err + } + result.ReplacedCount = replaced + } else { + owners, err := repository.LockInnerCodeCodeOwnersByDate(tx, result.BusinessDate) + if err != nil { + return nil, err + } + if err := validateExistingInnerCodeOwners(result.BusinessDate, owners, rows); err != nil { + return nil, fmt.Errorf("%w:%v", ErrInvalidInnerCodeImport, err) + } } now := model.NowISO() for _, row := range rows { @@ -160,6 +195,33 @@ func ImportInnerCodeExcel(db *sql.DB, path, originalFilename, actorUserID string return result, nil } +func validateReplaceableInnerCodeSnapshot(businessDate string, records []model.InnerCodeRecord) error { + for _, record := range records { + identity := fmt.Sprintf("业务日期 %s 的订单 %s(记录 %d)", businessDate, record.OrderNumber, record.ID) + if strings.TrimSpace(record.DeletedAt) == "" { + return fmt.Errorf("%s 尚未删除;请先确认并删除该日期全部旧记录", identity) + } + if record.Status != model.InnerCodePending { + return fmt.Errorf("%s 状态为 %s,不是从未处理的待匹配状态", identity, record.Status) + } + if innerCodeRecordHasProcessingEvidence(record) { + return fmt.Errorf("%s 已存在匹配、批次或远端处理信息,必须人工核对", identity) + } + } + return nil +} + +func innerCodeRecordHasProcessingEvidence(record model.InnerCodeRecord) bool { + return record.StockID != 0 || record.DetailID != 0 || + strings.TrimSpace(record.SybSpec) != "" || strings.TrimSpace(record.SybSKU) != "" || + strings.TrimSpace(record.SybVariationSKU) != "" || strings.TrimSpace(record.PurchasePlatform) != "" || + strings.TrimSpace(record.PurchaseCode) != "" || strings.TrimSpace(record.RemoteInnerCode) != "" || + strings.TrimSpace(record.RemoteItemsJSON) != "" || strings.TrimSpace(record.ResultMessage) != "" || + strings.TrimSpace(record.PlannedAt) != "" || strings.TrimSpace(record.ApplyBatchID) != "" || + strings.TrimSpace(record.ApplyQueuedAt) != "" || strings.TrimSpace(record.ApplyStartedAt) != "" || + strings.TrimSpace(record.AppliedByUserID) != "" || strings.TrimSpace(record.AppliedAt) != "" +} + type innerCodeParsedRow struct { model.InnerCodeImportRow businessKey string diff --git a/admin/service/inner_code_import_test.go b/admin/service/inner_code_import_test.go index ca4b36e..9743868 100644 --- a/admin/service/inner_code_import_test.go +++ b/admin/service/inner_code_import_test.go @@ -1,6 +1,7 @@ package service import ( + "fmt" "path/filepath" "strings" "testing" @@ -177,6 +178,82 @@ func TestValidateExistingInnerCodeOwners_聚合中的单件码不能串到其他 } } +func TestValidateReplaceableInnerCodeSnapshot_只允许已删除且从未处理的待匹配记录(t *testing.T) { + safe := model.InnerCodeRecord{ + ID: 1, + OrderNumber: "ORDER-1", + Status: model.InnerCodePending, + DeletedAt: "2026-08-26T01:00:00Z", + } + if err := validateReplaceableInnerCodeSnapshot("2026-08-26", []model.InnerCodeRecord{safe}); err != nil { + t.Fatalf("安全旧快照应允许替换: %v", err) + } + if err := validateReplaceableInnerCodeSnapshot("2026-08-26", nil); err != nil { + t.Fatalf("没有旧快照时也应允许按新文件导入: %v", err) + } + + active := safe + active.DeletedAt = "" + if err := validateReplaceableInnerCodeSnapshot("2026-08-26", []model.InnerCodeRecord{active}); err == nil || + !strings.Contains(err.Error(), "尚未删除") { + t.Fatalf("未删除记录必须阻止替换: %v", err) + } + + processed := safe + processed.Status = model.InnerCodeReady + if err := validateReplaceableInnerCodeSnapshot("2026-08-26", []model.InnerCodeRecord{processed}); err == nil || + !strings.Contains(err.Error(), "不是从未处理") { + t.Fatalf("非 pending 记录必须阻止替换: %v", err) + } +} + +func TestValidateReplaceableInnerCodeSnapshot_任一处理证据都阻止替换(t *testing.T) { + base := model.InnerCodeRecord{ + ID: 1, + OrderNumber: "ORDER-1", + Status: model.InnerCodePending, + DeletedAt: "2026-08-26T01:00:00Z", + } + tests := []struct { + name string + mutate func(*model.InnerCodeRecord) + }{ + {"stock_id", func(row *model.InnerCodeRecord) { row.StockID = 1 }}, + {"detail_id", func(row *model.InnerCodeRecord) { row.DetailID = 1 }}, + {"syb_spec", func(row *model.InnerCodeRecord) { row.SybSpec = "黑色,M" }}, + {"syb_sku", func(row *model.InnerCodeRecord) { row.SybSKU = "SKU-1" }}, + {"syb_variation_sku", func(row *model.InnerCodeRecord) { row.SybVariationSKU = "V-1" }}, + {"purchase_platform", func(row *model.InnerCodeRecord) { row.PurchasePlatform = "pdd" }}, + {"purchase_code", func(row *model.InnerCodeRecord) { row.PurchaseCode = "P-1" }}, + {"remote_inner_code", func(row *model.InnerCodeRecord) { row.RemoteInnerCode = "DK-1" }}, + {"remote_items_json", func(row *model.InnerCodeRecord) { row.RemoteItemsJSON = "[]" }}, + {"result_message", func(row *model.InnerCodeRecord) { row.ResultMessage = "曾处理" }}, + {"planned_at", func(row *model.InnerCodeRecord) { row.PlannedAt = "2026-08-26T01:00:00Z" }}, + {"apply_batch_id", func(row *model.InnerCodeRecord) { row.ApplyBatchID = "B-1" }}, + {"apply_queued_at", func(row *model.InnerCodeRecord) { row.ApplyQueuedAt = "2026-08-26T01:00:00Z" }}, + {"apply_started_at", func(row *model.InnerCodeRecord) { row.ApplyStartedAt = "2026-08-26T01:00:00Z" }}, + {"applied_by_user_id", func(row *model.InnerCodeRecord) { row.AppliedByUserID = "user-1" }}, + {"applied_at", func(row *model.InnerCodeRecord) { row.AppliedAt = "2026-08-26T01:00:00Z" }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + row := base + test.mutate(&row) + err := validateReplaceableInnerCodeSnapshot("2026-08-26", []model.InnerCodeRecord{row}) + if err == nil || !strings.Contains(err.Error(), "必须人工核对") { + t.Fatalf("%s 应阻止替换: %v", test.name, err) + } + }) + } +} + +func TestInnerCodeSnapshotReplaceBlocked_错误分类支持包装(t *testing.T) { + err := fmt.Errorf("导入失败: %w", ErrInnerCodeSnapshotReplaceBlocked) + if !IsInnerCodeSnapshotReplaceBlocked(err) { + t.Fatalf("应识别包装后的安全门禁错误: %v", err) + } +} + func TestValidateInnerCodeUpload(t *testing.T) { if err := ValidateInnerCodeUpload("labels.xlsx", 100, []byte{'P', 'K', 3, 4}); err != nil { t.Fatal(err) diff --git a/admin/static/css/app.css b/admin/static/css/app.css index 40a427f..af7b6d9 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -640,6 +640,22 @@ input.wide { width: 100%; } white-space: nowrap; color: #57606a; } +.inner-code-toolbar .inner-code-replace-option { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 32px; + padding: 3px 7px; + border: 1px solid #d4a72c; + border-radius: 4px; + color: #7a4b00; + background: #fff8c5; + cursor: pointer; +} +.inner-code-toolbar .inner-code-replace-option:focus-within { + outline: 2px solid #0969da; + outline-offset: 2px; +} .inner-code-toast { position: fixed; right: 16px; diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 03194c8..7c5e103 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -402,7 +402,16 @@ document.querySelectorAll("[data-upload-form]").forEach(function (form) { var button = form.querySelector("[data-upload-submit]"); if (!button) return; - form.addEventListener("submit", function () { + var replaceSnapshot = form.querySelector("[data-replace-unprocessed-snapshot]"); + form.addEventListener("submit", function (event) { + if (event.defaultPrevented) return; + if (replaceSnapshot && replaceSnapshot.checked) { + var message = form.getAttribute("data-replace-confirm") || "确定替换旧数据并继续导入?"; + if (!window.confirm(message)) { + event.preventDefault(); + return; + } + } button.disabled = true; button.setAttribute("aria-busy", "true"); button.textContent = "导入中…"; diff --git a/admin/templates/inner_code/list.html b/admin/templates/inner_code/list.html index 007b5cf..216af9c 100644 --- a/admin/templates/inner_code/list.html +++ b/admin/templates/inner_code/list.html @@ -2,13 +2,20 @@ {{template "header" .}}
-
+ 未选择文件 +
diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index eb3de07..a0447c0 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -1057,6 +1057,11 @@ CREATE UNIQUE INDEX idx_client_assignment_current - 重新导入同一业务键会恢复软删除记录:`pending/ready/skipped/failed` 重置为 `pending` 并清空旧规划;`queued/applying/updated/already_filled/needs_check` 保留状态和远端 审计。后一组状态若导入的 `inner_code` 已变化则拒绝恢复,交由人工核对。 +- 同日新版 Excel 可能因商品增删或排序变化重新分配入库码。默认导入仍保留上述唯一归属 + 门禁;只有操作员显式选择“替换当日已删除旧数据”,并且该业务日期的每条旧记录都已 + 软删除、状态严格为 `pending`,且不存在匹配、规划、排队、回写或远端结果字段时,才允许 + 在同一事务内锁定并物理删除整日旧快照后导入新版。任一旧记录不满足条件或锁定期间数量 + 变化,整批回滚,不删除旧数据也不导入新数据。该流程不新增表,不改变普通重复导入语义。 - v25 增加 `apply_batch_id`、`apply_queued_at` 和批次状态索引。批次仍记录在同一业务表, 不新增批次表:提交时所选 `ready` 记录必须在同一事务内全部转为 `queued`;后台每次最多 读取 20 条并逐条领取为 `applying`。页面按 `apply_batch_id` 聚合当前批次进度。Admin diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 7d0ab56..e8efb6f 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -986,6 +986,18 @@ AI 建议成功后显示填入、未决和保留人工选择的数量,继续 状态不能只靠颜色,动态变更计数使用 `role=status`,保存错误使用 `role=alert`;表格容器在 1366×768 内独立滚动,原生控件保持可见焦点和顺序一致的键盘操作。 +### 8.7 档口入库码同日新版导入 + +导入区在文件名与“导入”按钮之间提供可见复选项“替换当日已删除旧数据”,默认不勾选。 +它只用于上游在同一业务日期重新生成完整 Excel、导致入库码重新分配的情况;普通导入继续 +执行原有幂等更新和唯一归属检查。 + +勾选后提交必须再次确认,明确说明旧记录将被永久移除,并说明后端只有在该日期全部旧记录 +均已软删除、仍为待匹配且没有匹配、排队或回写痕迹时才会执行。后端门禁不能依赖浏览器; +任一旧记录不安全时返回冲突错误,弹窗指出整批未删除、未导入以及先人工核对的恢复路径。 +通过门禁后,删除旧快照和导入新文件处于同一事务,成功反馈单独显示替换旧记录数量。提交 +期间沿用“导入中…”和禁用按钮,防止重复提交。 + ## 9. 反馈方式 | 场景 | 怎么反馈 |