fix: 支持档口入库码同日安全替换 (#304)
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
+10
-1
@@ -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 = "导入中…";
|
||||
|
||||
@@ -2,13 +2,20 @@
|
||||
{{template "header" .}}
|
||||
|
||||
<div class="toolbar inner-code-toolbar" data-inner-code-page>
|
||||
<form class="inline" method="post" action="/inner-codes/import" enctype="multipart/form-data"
|
||||
data-upload-form>
|
||||
<form class="inline inner-code-import-form" method="post" action="/inner-codes/import" enctype="multipart/form-data"
|
||||
data-upload-form
|
||||
data-replace-confirm="将永久移除 Excel 业务日期下已删除且从未处理的旧数据,再导入当前文件。只有后端确认全部旧记录未匹配、未排队、未回写时才会执行;否则整批拒绝。确定继续?">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
|
||||
<button type="button" data-inner-code-file-open>选择 Excel</button>
|
||||
<input id="inner-code-file" type="file" name="file" accept=".xlsx" required data-file-input hidden>
|
||||
<span class="file-field" data-file-name>未选择文件</span>
|
||||
<label class="inner-code-replace-option" for="inner-code-replace-snapshot"
|
||||
title="只允许替换已软删除、状态为待匹配且没有任何远端处理信息的当日旧数据">
|
||||
<input id="inner-code-replace-snapshot" type="checkbox" name="replace_unprocessed_snapshot" value="1"
|
||||
data-replace-unprocessed-snapshot>
|
||||
替换当日已删除旧数据
|
||||
</label>
|
||||
<button type="submit" data-upload-submit>导入</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -986,6 +986,18 @@ AI 建议成功后显示填入、未决和保留人工选择的数量,继续
|
||||
状态不能只靠颜色,动态变更计数使用 `role=status`,保存错误使用 `role=alert`;表格容器在
|
||||
1366×768 内独立滚动,原生控件保持可见焦点和顺序一致的键盘操作。
|
||||
|
||||
### 8.7 档口入库码同日新版导入
|
||||
|
||||
导入区在文件名与“导入”按钮之间提供可见复选项“替换当日已删除旧数据”,默认不勾选。
|
||||
它只用于上游在同一业务日期重新生成完整 Excel、导致入库码重新分配的情况;普通导入继续
|
||||
执行原有幂等更新和唯一归属检查。
|
||||
|
||||
勾选后提交必须再次确认,明确说明旧记录将被永久移除,并说明后端只有在该日期全部旧记录
|
||||
均已软删除、仍为待匹配且没有匹配、排队或回写痕迹时才会执行。后端门禁不能依赖浏览器;
|
||||
任一旧记录不安全时返回冲突错误,弹窗指出整批未删除、未导入以及先人工核对的恢复路径。
|
||||
通过门禁后,删除旧快照和导入新文件处于同一事务,成功反馈单独显示替换旧记录数量。提交
|
||||
期间沿用“导入中…”和禁用按钮,防止重复提交。
|
||||
|
||||
## 9. 反馈方式
|
||||
|
||||
| 场景 | 怎么反馈 |
|
||||
|
||||
Reference in New Issue
Block a user