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" .}}