feat: 档口入库码支持软删除与重导恢复 (#238)
This commit is contained in:
@@ -108,11 +108,25 @@ func (h *Handler) InnerCodeImport(c *gin.Context) {
|
||||
fail(c, http.StatusInternalServerError, "档口入库码写库失败,本次导入已整体回滚,请稍后重试。")
|
||||
return
|
||||
}
|
||||
message := fmt.Sprintf("导入完成:业务日期 %s,读取 %d 行,新增 %d 条,更新 %d 条,文件内重复 %d 行。",
|
||||
result.BusinessDate, result.TotalRows, result.CreatedCount, result.UpdatedCount, result.DuplicateRows)
|
||||
message := fmt.Sprintf("导入完成:业务日期 %s,读取 %d 行,新增 %d 条,更新 %d 条,恢复 %d 条,文件内重复 %d 行。",
|
||||
result.BusinessDate, result.TotalRows, result.CreatedCount, result.UpdatedCount, result.RestoredCount, result.DuplicateRows)
|
||||
h.innerCodeRedirect(c, result.BusinessDate, "", "", 1, message)
|
||||
}
|
||||
|
||||
// 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())
|
||||
if err != nil {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "删除失败:"+err.Error()+";没有部分删除,请刷新后重试。")
|
||||
return
|
||||
}
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber,
|
||||
fmt.Sprintf("已删除 %d 条档口入库码记录;顺运宝已写入的快递单号不会撤销。重新导入相同数据可恢复。", count))
|
||||
}
|
||||
|
||||
// InnerCodeMatch 读取远端最新详情并保存只读规划,不执行任何回写。
|
||||
func (h *Handler) InnerCodeMatch(c *gin.Context) {
|
||||
businessDate := strings.TrimSpace(c.PostForm("date"))
|
||||
|
||||
@@ -119,6 +119,7 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration, aiSecret
|
||||
pages.GET("/inner-codes", h.InnerCodeList)
|
||||
innerCodes := pages.Group("/inner-codes")
|
||||
innerCodes.POST("/import", h.InnerCodeImport)
|
||||
innerCodes.POST("/delete", h.InnerCodeDelete)
|
||||
innerCodes.POST("/match", h.InnerCodeMatch)
|
||||
innerCodes.POST("/apply", h.InnerCodeApply)
|
||||
innerCodes.POST("/recheck", h.InnerCodeRecheck)
|
||||
|
||||
@@ -29,16 +29,20 @@ func TestInnerCodeTemplate_独立导航与安全表单(t *testing.T) {
|
||||
page := string(raw)
|
||||
for _, want := range []string{
|
||||
`action="/inner-codes/import"`, `action="/inner-codes/match"`,
|
||||
`action="/inner-codes/apply"`, `action="/inner-codes/recheck"`,
|
||||
`action="/inner-codes/apply"`, `action="/inner-codes/recheck"`, `action="/inner-codes/delete"`,
|
||||
`name="csrf_token"`, `data-check-all`, `data-inner-code-apply-open`,
|
||||
`data-need-checked="inner-code-match"`, `data-inner-code-id="{{.ID}}"`,
|
||||
`inner-code-match{{end}}{{if .CanApply}} inner-code-apply`,
|
||||
`{{if not .CanMatch}}disabled{{end}}`, `匹配已选`, `aria-label="档口入库码记录列表"`,
|
||||
`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="档口入库码记录列表"`,
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("正式页面缺少 %s", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(page, `{{if not .CanMatch}}disabled{{end}}`) {
|
||||
t.Fatal("所有状态都应允许勾选删除,不能再按 CanMatch 禁用复选框")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerCodeTemplate_模块状态白名单已登记(t *testing.T) {
|
||||
@@ -52,6 +56,8 @@ func TestInnerCodeTemplate_模块状态白名单已登记(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
`appendSelectedIDs(matchForm, "inner-code-match")`,
|
||||
`appendSelectedIDs(applyForm, "inner-code-apply")`,
|
||||
`appendSelectedIDs(deleteForm, "inner-code-delete")`,
|
||||
`checkedCount(action)`,
|
||||
`field.name = "ids"`,
|
||||
} {
|
||||
if !strings.Contains(string(raw), want) {
|
||||
|
||||
@@ -43,6 +43,8 @@ type InnerCodeRecord struct {
|
||||
PlannedAt string
|
||||
ApplyStartedAt string
|
||||
AppliedAt string
|
||||
DeletedAt string
|
||||
DeletedByUserID string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ import (
|
||||
// ErrInnerCodeUniqueConflict 表示同一日期的入库码已属于另一个业务键。
|
||||
var ErrInnerCodeUniqueConflict = errors.New("同一业务日期的档口入库码已被其他记录使用")
|
||||
|
||||
// ErrInnerCodeRestoreConflict 表示已完成或结果未知的软删除记录不能换入库码后直接恢复。
|
||||
var ErrInnerCodeRestoreConflict = errors.New("已回写或需核对的删除记录不能用不同入库码恢复")
|
||||
|
||||
// ErrInnerCodeDeleteConflict 表示批量删除时记录已经不可见或不存在,整批不会部分删除。
|
||||
var ErrInnerCodeDeleteConflict = errors.New("部分档口入库码记录已删除或不存在")
|
||||
|
||||
// InnerCodeSybSnapshot 是本地顺运宝明细用于解析货运单 stock id 的最小快照。
|
||||
type InnerCodeSybSnapshot struct {
|
||||
OrderNumber string
|
||||
@@ -35,12 +41,13 @@ type InnerCodeStatusCounts struct {
|
||||
NeedsCheck int
|
||||
}
|
||||
|
||||
// InnerCodeImportOutcome 说明幂等导入是新增还是更新。
|
||||
// InnerCodeImportOutcome 说明幂等导入是新增、更新还是恢复软删除记录。
|
||||
type InnerCodeImportOutcome string
|
||||
|
||||
const (
|
||||
InnerCodeImportCreated InnerCodeImportOutcome = "created"
|
||||
InnerCodeImportUpdated InnerCodeImportOutcome = "updated"
|
||||
InnerCodeImportRestored InnerCodeImportOutcome = "restored"
|
||||
)
|
||||
|
||||
// UpsertInnerCodeImportRow 按已确认业务键写入一行。
|
||||
@@ -58,10 +65,13 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
|
||||
return "", fmt.Errorf("%w(记录 %d)", ErrInnerCodeUniqueConflict, codeRecordID)
|
||||
}
|
||||
var id int64
|
||||
var status model.InnerCodeStatus
|
||||
var currentInnerCode, deletedAt string
|
||||
err := tx.QueryRow(`
|
||||
SELECT id FROM syb_inner_code_records
|
||||
SELECT id,status,inner_code,COALESCE(deleted_at,'') FROM syb_inner_code_records
|
||||
WHERE business_date=? AND order_number=? AND stall=? AND spec_key=?
|
||||
FOR UPDATE`, row.BusinessDate, row.OrderNumber, row.Stall, row.SpecKey).Scan(&id)
|
||||
FOR UPDATE`, row.BusinessDate, row.OrderNumber, row.Stall, row.SpecKey).
|
||||
Scan(&id, &status, ¤tInnerCode, &deletedAt)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("锁定档口入库码业务键失败: %w", err)
|
||||
}
|
||||
@@ -80,6 +90,32 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
|
||||
}
|
||||
return InnerCodeImportCreated, nil
|
||||
}
|
||||
if deletedAt != "" {
|
||||
if innerCodeStatusPreservesImportResult(status) {
|
||||
if currentInnerCode != row.InnerCode {
|
||||
return "", fmt.Errorf("%w(记录 %d,原入库码 %q,新入库码 %q)",
|
||||
ErrInnerCodeRestoreConflict, id, currentInnerCode, row.InnerCode)
|
||||
}
|
||||
_, err = tx.Exec(`UPDATE syb_inner_code_records
|
||||
SET source_row=?,print_sequence=?,shop_name=?,spec_raw=?,source_duplicate_count=?,
|
||||
deleted_at=NULL,deleted_by_user_id=NULL,updated_at=?
|
||||
WHERE id=?`, row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName),
|
||||
row.SpecRaw, row.SourceDuplicateCount, now, id)
|
||||
} else {
|
||||
_, err = tx.Exec(`UPDATE syb_inner_code_records
|
||||
SET source_row=?,print_sequence=?,shop_name=?,spec_raw=?,inner_code=?,source_duplicate_count=?,
|
||||
status='pending',stock_id=NULL,detail_id=NULL,syb_spec=NULL,syb_sku=NULL,
|
||||
syb_variation_sku=NULL,purchase_platform=NULL,purchase_code=NULL,
|
||||
remote_inner_code=NULL,result_message=NULL,planned_at=NULL,
|
||||
deleted_at=NULL,deleted_by_user_id=NULL,updated_at=?
|
||||
WHERE id=?`, row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName),
|
||||
row.SpecRaw, row.InnerCode, row.SourceDuplicateCount, now, id)
|
||||
}
|
||||
if err != nil {
|
||||
return "", innerCodeImportWriteError("恢复档口入库码导入记录失败", err)
|
||||
}
|
||||
return InnerCodeImportRestored, nil
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
UPDATE syb_inner_code_records
|
||||
@@ -107,6 +143,15 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
|
||||
return InnerCodeImportUpdated, nil
|
||||
}
|
||||
|
||||
func innerCodeStatusPreservesImportResult(status model.InnerCodeStatus) bool {
|
||||
switch status {
|
||||
case model.InnerCodeApplying, model.InnerCodeUpdated, model.InnerCodeAlreadyFilled, model.InnerCodeNeedsCheck:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func innerCodeImportWriteError(action string, err error) error {
|
||||
// 并发导入可能都在 SELECT 时看不到对方,最终仍由唯一索引裁决。
|
||||
// 不把 MySQL 索引名或 SQL 原文显示给操作员。
|
||||
@@ -132,6 +177,7 @@ func ListInnerCodeRecordsForPlanning(q Execer, businessDate string, ids []int64)
|
||||
rows, err := q.Query(`SELECT `+innerCodeListColumns+`
|
||||
FROM syb_inner_code_records
|
||||
WHERE business_date=? AND id IN (`+strings.Join(placeholders, ",")+`)
|
||||
AND deleted_at IS NULL
|
||||
AND status IN ('pending','ready','skipped','failed')
|
||||
ORDER BY source_row,id`, args...)
|
||||
if err != nil {
|
||||
@@ -167,6 +213,7 @@ func ListInnerCodePlanningContext(q Execer, businessDate string, orderNumbers []
|
||||
rows, err := q.Query(`SELECT `+innerCodeListColumns+`
|
||||
FROM syb_inner_code_records
|
||||
WHERE business_date=? AND order_number IN (`+strings.Join(placeholders, ",")+`)
|
||||
AND (deleted_at IS NULL OR status IN ('applying','updated','already_filled','needs_check'))
|
||||
ORDER BY source_row,id`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询档口入库码匹配上下文失败: %w", err)
|
||||
@@ -233,7 +280,7 @@ func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt str
|
||||
SET stock_id=?,detail_id=?,syb_spec=?,syb_sku=?,syb_variation_sku=?,
|
||||
purchase_platform=?,purchase_code=?,remote_inner_code=?,status=?,
|
||||
result_message=?,planned_at=?,updated_at=?
|
||||
WHERE id=? AND status IN ('pending','ready','skipped','failed')`,
|
||||
WHERE id=? AND deleted_at IS NULL AND status IN ('pending','ready','skipped','failed')`,
|
||||
nullablePositiveInt64(plan.StockID), nullablePositiveInt64(plan.DetailID),
|
||||
nullableString(plan.SybSpec), nullableString(plan.SybSKU), nullableString(plan.SybVariationSKU),
|
||||
nullableString(plan.PurchasePlatform), nullableString(plan.PurchaseCode),
|
||||
@@ -279,7 +326,9 @@ func lockAndValidateInnerCodePlanClaims(tx *sql.Tx, plans []model.InnerCodeRecor
|
||||
for _, key := range keys {
|
||||
rows, err := tx.Query(`SELECT id,COALESCE(detail_id,0),status
|
||||
FROM syb_inner_code_records
|
||||
WHERE business_date=? AND order_number=? ORDER BY id FOR UPDATE`, key.BusinessDate, key.OrderNumber)
|
||||
WHERE business_date=? AND order_number=?
|
||||
AND (deleted_at IS NULL OR status IN ('applying','updated','already_filled','needs_check'))
|
||||
ORDER BY id FOR UPDATE`, key.BusinessDate, key.OrderNumber)
|
||||
if err != nil {
|
||||
return fmt.Errorf("锁定档口入库码订单匹配上下文失败: %w", err)
|
||||
}
|
||||
@@ -332,10 +381,10 @@ const innerCodeListColumns = `id,business_date,source_row,COALESCE(print_sequenc
|
||||
COALESCE(syb_variation_sku,''),COALESCE(purchase_platform,''),COALESCE(purchase_code,''),
|
||||
COALESCE(remote_inner_code,''),status,COALESCE(result_message,''),created_by_user_id,
|
||||
COALESCE(applied_by_user_id,''),COALESCE(planned_at,''),COALESCE(apply_started_at,''),
|
||||
COALESCE(applied_at,''),created_at,updated_at`
|
||||
COALESCE(applied_at,''),COALESCE(deleted_at,''),COALESCE(deleted_by_user_id,''),created_at,updated_at`
|
||||
|
||||
func innerCodeFilterClause(filter InnerCodeListFilter) (string, []any) {
|
||||
clauses := []string{"business_date=?"}
|
||||
clauses := []string{"business_date=?", "deleted_at IS NULL"}
|
||||
args := []any{filter.BusinessDate}
|
||||
if filter.Status != "" {
|
||||
clauses = append(clauses, "status=?")
|
||||
@@ -390,7 +439,7 @@ func CountInnerCodeStatuses(q Execer, businessDate string) (InnerCodeStatusCount
|
||||
var result InnerCodeStatusCounts
|
||||
err := q.QueryRow(`SELECT COUNT(*),
|
||||
COALESCE(SUM(status='ready'),0),COALESCE(SUM(status='needs_check'),0)
|
||||
FROM syb_inner_code_records WHERE business_date=?`, businessDate).
|
||||
FROM syb_inner_code_records WHERE business_date=? AND deleted_at IS NULL`, businessDate).
|
||||
Scan(&result.Total, &result.Ready, &result.NeedsCheck)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("统计档口入库码状态失败: %w", err)
|
||||
@@ -410,7 +459,7 @@ func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, er
|
||||
&row.SybSpec, &row.SybSKU, &row.SybVariationSKU, &row.PurchasePlatform,
|
||||
&row.PurchaseCode, &row.RemoteInnerCode, &row.Status, &row.ResultMessage,
|
||||
&row.CreatedByUserID, &row.AppliedByUserID, &row.PlannedAt, &row.ApplyStartedAt,
|
||||
&row.AppliedAt, &row.CreatedAt, &row.UpdatedAt)
|
||||
&row.AppliedAt, &row.DeletedAt, &row.DeletedByUserID, &row.CreatedAt, &row.UpdatedAt)
|
||||
if err != nil {
|
||||
return row, fmt.Errorf("读取档口入库码记录失败: %w", err)
|
||||
}
|
||||
@@ -425,7 +474,7 @@ func ClaimInnerCodeForApply(db *sql.DB, id int64, actorUserID, now string) (*mod
|
||||
}
|
||||
defer tx.Rollback()
|
||||
record, err := scanInnerCodeRecord(tx.QueryRow(`SELECT `+innerCodeListColumns+
|
||||
` FROM syb_inner_code_records WHERE id=? FOR UPDATE`, id))
|
||||
` FROM syb_inner_code_records WHERE id=? AND deleted_at IS NULL FOR UPDATE`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
@@ -437,7 +486,7 @@ func ClaimInnerCodeForApply(db *sql.DB, id int64, actorUserID, now string) (*mod
|
||||
}
|
||||
result, err := tx.Exec(`UPDATE syb_inner_code_records
|
||||
SET status='applying',applied_by_user_id=?,apply_started_at=?,updated_at=?
|
||||
WHERE id=? AND status='ready'`, actorUserID, now, now, id)
|
||||
WHERE id=? AND deleted_at IS NULL AND status='ready'`, actorUserID, now, now, id)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("领取档口入库码记录失败: %w", err)
|
||||
}
|
||||
@@ -473,7 +522,7 @@ func FinishInnerCodeApply(q Execer, id int64, status model.InnerCodeStatus, mess
|
||||
// GetInnerCodeForRecheck 读取一条需核对记录。
|
||||
func GetInnerCodeForRecheck(q Execer, id int64) (*model.InnerCodeRecord, error) {
|
||||
record, err := scanInnerCodeRecord(q.QueryRow(`SELECT `+innerCodeListColumns+
|
||||
` FROM syb_inner_code_records WHERE id=?`, id))
|
||||
` FROM syb_inner_code_records WHERE id=? AND deleted_at IS NULL`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -483,6 +532,43 @@ 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) {
|
||||
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)
|
||||
for index, id := range ids {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
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=?
|
||||
WHERE deleted_at IS NULL AND id IN (`+strings.Join(placeholders, ",")+`)`, args...)
|
||||
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(len(ids)) {
|
||||
return 0, ErrInnerCodeDeleteConflict
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("提交档口入库码删除事务失败: %w", err)
|
||||
}
|
||||
return int(affected), nil
|
||||
}
|
||||
|
||||
// SaveInnerCodeRecheck 保存只读重新核对的远端结果,不执行状态领取或写入。
|
||||
func SaveInnerCodeRecheck(q Execer, id int64, status model.InnerCodeStatus, message, remoteCode, checkedAt string) error {
|
||||
result, err := q.Exec(`UPDATE syb_inner_code_records
|
||||
|
||||
@@ -3,10 +3,13 @@ package repository
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
)
|
||||
|
||||
func TestInterruptApplyingInnerCodes_只收敛回写中记录(t *testing.T) {
|
||||
@@ -50,3 +53,116 @@ func TestInnerCodeImportWriteError_唯一冲突不泄漏索引细节(t *testing.
|
||||
t.Fatalf("不应回显索引细节: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftDeleteInnerCodeRecords_所有状态只写删除审计(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", "file: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,deleted_by_user_id TEXT,updated_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statuses := []string{"pending", "ready", "applying", "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)
|
||||
}
|
||||
}
|
||||
ids := make([]int64, len(statuses))
|
||||
for index := range ids {
|
||||
ids[index] = int64(index + 1)
|
||||
}
|
||||
count, err := SoftDeleteInnerCodeRecords(db, ids, "user-1", "2026-08-15T02:00:00Z")
|
||||
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 {
|
||||
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++
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftDeleteInnerCodeRecords_有失效ID时整批回滚(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", "file:inner_code_delete_conflict?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,deleted_by_user_id TEXT,updated_at TEXT NOT NULL
|
||||
); 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) {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
t.Fatalf("整批回滚后不应保留删除标记: %+v", deletedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishInnerCodeApply_软删除后仍保存后台结果(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", "file:inner_code_finish_deleted?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,result_message TEXT,remote_inner_code TEXT,
|
||||
applied_at TEXT,updated_at TEXT NOT NULL,deleted_at TEXT
|
||||
); INSERT INTO syb_inner_code_records(id,status,updated_at,deleted_at)
|
||||
VALUES(1,'applying','old','2026-08-15T02:00:00Z')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := FinishInnerCodeApply(db, 1, model.InnerCodeUpdated, "远端核验成功", "DK-1", "2026-08-15T02:01:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status, message, remoteCode, appliedAt, deletedAt string
|
||||
if err := db.QueryRow(`SELECT status,result_message,remote_inner_code,applied_at,deleted_at
|
||||
FROM syb_inner_code_records WHERE id=1`).Scan(&status, &message, &remoteCode, &appliedAt, &deletedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "updated" || message != "远端核验成功" || remoteCode != "DK-1" ||
|
||||
appliedAt != "2026-08-15T02:01:00Z" || deletedAt != "2026-08-15T02:00:00Z" {
|
||||
t.Fatalf("隐藏记录后台收尾错误: %s %s %s %s %s", status, message, remoteCode, appliedAt, deletedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerCodeStatusPreservesImportResult(t *testing.T) {
|
||||
for _, status := range []string{"pending", "ready", "skipped", "failed"} {
|
||||
if innerCodeStatusPreservesImportResult(model.InnerCodeStatus(status)) {
|
||||
t.Errorf("%s 不应保留旧规划", status)
|
||||
}
|
||||
}
|
||||
for _, status := range []string{"applying", "updated", "already_filled", "needs_check"} {
|
||||
if !innerCodeStatusPreservesImportResult(model.InnerCodeStatus(status)) {
|
||||
t.Errorf("%s 应保留远端结果和审计", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerCodeFilterClause_正常列表固定隐藏软删除记录(t *testing.T) {
|
||||
where, args := innerCodeFilterClause(InnerCodeListFilter{BusinessDate: "2026-08-15", Status: "ready", Keyword: "ORDER"})
|
||||
if !strings.Contains(where, "deleted_at IS NULL") || len(args) != 6 {
|
||||
t.Fatalf("列表软删除过滤不完整: where=%q args=%v", where, args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
const mysqlSchemaVersion = 23
|
||||
const mysqlSchemaVersion = 24
|
||||
|
||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||
@@ -700,10 +700,56 @@ func MigrateMySQL(db *sql.DB) error {
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 23, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("记录 MySQL schema v23 失败: %w", err)
|
||||
}
|
||||
current = 23
|
||||
}
|
||||
if current < 24 {
|
||||
if err := migrateMySQLV24(db); err != nil {
|
||||
return fmt.Errorf("执行 MySQL schema v24 失败: %w", err)
|
||||
}
|
||||
if err := checkMySQLV24Shape(db); err != nil {
|
||||
return fmt.Errorf("MySQL schema v24 自检失败,未记录版本: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 24, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("记录 MySQL schema v24 失败: %w", err)
|
||||
}
|
||||
}
|
||||
return CheckMySQLSchema(db)
|
||||
}
|
||||
|
||||
// migrateMySQLV24 为档口入库码增加可恢复软删除审计。
|
||||
func migrateMySQLV24(db *sql.DB) error {
|
||||
for _, column := range []struct{ name, ddl string }{
|
||||
{"deleted_at", `ALTER TABLE syb_inner_code_records ADD COLUMN deleted_at VARCHAR(35) NULL AFTER applied_at`},
|
||||
{"deleted_by_user_id", `ALTER TABLE syb_inner_code_records ADD COLUMN deleted_by_user_id VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER deleted_at`},
|
||||
} {
|
||||
exists, err := mysqlColumnExists(db, "syb_inner_code_records", column.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
if _, err := db.Exec(column.ddl); err != nil {
|
||||
return fmt.Errorf("增加 syb_inner_code_records.%s 失败: %w", column.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if exists, err := mysqlIndexExists(db, "syb_inner_code_records", "idx_inner_code_deleted"); err != nil {
|
||||
return err
|
||||
} else if !exists {
|
||||
if _, err := db.Exec(`ALTER TABLE syb_inner_code_records ADD INDEX idx_inner_code_deleted (deleted_at,business_date,status,id)`); err != nil {
|
||||
return fmt.Errorf("增加档口入库码软删除索引失败: %w", err)
|
||||
}
|
||||
}
|
||||
if exists, err := mysqlConstraintExists(db, "syb_inner_code_records", "fk_inner_code_deleted_by"); err != nil {
|
||||
return err
|
||||
} else if !exists {
|
||||
if _, err := db.Exec(`ALTER TABLE syb_inner_code_records ADD CONSTRAINT fk_inner_code_deleted_by
|
||||
FOREIGN KEY (deleted_by_user_id) REFERENCES users(user_id) ON DELETE SET NULL`); err != nil {
|
||||
return fmt.Errorf("增加档口入库码删除人外键失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateMySQLV23 建立档口入库码最小闭环使用的唯一业务表。
|
||||
// Excel 原文件、批次和每次尝试不会另建表保存。
|
||||
func migrateMySQLV23(db *sql.DB) error {
|
||||
@@ -2132,7 +2178,35 @@ func CheckMySQLSchema(db *sql.DB) error {
|
||||
if err := checkMySQLV22Shape(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkMySQLV23Shape(db)
|
||||
if err := checkMySQLV23Shape(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkMySQLV24Shape(db)
|
||||
}
|
||||
|
||||
func checkMySQLV24Shape(db *sql.DB) error {
|
||||
for _, name := range []string{"deleted_at", "deleted_by_user_id"} {
|
||||
exists, err := mysqlColumnExists(db, "syb_inner_code_records", name)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("档口入库码软删除字段 %s 缺失: %v", name, err)
|
||||
}
|
||||
}
|
||||
for _, item := range []struct{ kind, name string }{
|
||||
{"index", "idx_inner_code_deleted"},
|
||||
{"constraint", "fk_inner_code_deleted_by"},
|
||||
} {
|
||||
var exists bool
|
||||
var err error
|
||||
if item.kind == "index" {
|
||||
exists, err = mysqlIndexExists(db, "syb_inner_code_records", item.name)
|
||||
} else {
|
||||
exists, err = mysqlConstraintExists(db, "syb_inner_code_records", item.name)
|
||||
}
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("档口入库码软删除%s %s 缺失: %v", item.kind, item.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMySQLV23Shape(db *sql.DB) error {
|
||||
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -1117,6 +1118,117 @@ func TestMySQLMigrate_V22升级V23且唯一约束生效(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLMigrate_V23升级V24且软删除字段有效(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)
|
||||
}
|
||||
mustExec(t, db, `ALTER TABLE syb_inner_code_records DROP FOREIGN KEY fk_inner_code_deleted_by`)
|
||||
mustExec(t, db, `ALTER TABLE syb_inner_code_records DROP INDEX idx_inner_code_deleted`)
|
||||
mustExec(t, db, `ALTER TABLE syb_inner_code_records DROP COLUMN deleted_by_user_id,DROP COLUMN deleted_at`)
|
||||
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=24`)
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatalf("v23 升级 v24 失败: %v", err)
|
||||
}
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatalf("v24 重复迁移失败: %v", err)
|
||||
}
|
||||
if err := checkMySQLV24Shape(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertInnerCodeImportRow_软删除记录按状态安全恢复(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-15T03:00:00Z"
|
||||
mustExec(t, db, `INSERT INTO users(user_id,username,password_hash,role,status,password_changed_at,created_at,updated_at)
|
||||
VALUES('restore-user','restore-user','hash','purchaser','active',?,?,?)`, now, now, now)
|
||||
for _, row := range []struct {
|
||||
orderNumber, stall, specKey, innerCode, status string
|
||||
}{
|
||||
{"ORDER-F", "A#1", "黑色,M", "DK-F", "failed"},
|
||||
{"ORDER-U", "B#2", "白色,L", "DK-U", "updated"},
|
||||
{"ORDER-N", "C#3", "杏色,S", "DK-N", "needs_check"},
|
||||
} {
|
||||
mustExec(t, db, `INSERT INTO syb_inner_code_records
|
||||
(business_date,source_row,order_number,stall,spec_raw,spec_key,inner_code,stock_id,status,
|
||||
result_message,created_by_user_id,deleted_at,deleted_by_user_id,created_at,updated_at)
|
||||
VALUES('2026-08-15',2,?,?,?,?,?,99,?,'旧审计','restore-user',?,'restore-user',?,?)`,
|
||||
row.orderNumber, row.stall, row.specKey, row.specKey, row.innerCode, row.status, now, now, now)
|
||||
}
|
||||
|
||||
failedRow := model.InnerCodeImportRow{BusinessDate: "2026-08-15", SourceRow: 8, OrderNumber: "ORDER-F",
|
||||
Stall: "A#1", SpecRaw: "黑色,M", SpecKey: "黑色,M", InnerCode: "DK-F-NEW",
|
||||
SourceDuplicateCount: 1, CreatedByUserID: "restore-user"}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outcome, err := UpsertInnerCodeImportRow(tx, failedRow, "2026-08-15T04:00:00Z")
|
||||
if err != nil || outcome != InnerCodeImportRestored {
|
||||
tx.Rollback()
|
||||
t.Fatalf("失败记录恢复不正确 outcome=%s err=%v", outcome, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status, innerCode string
|
||||
var stockID sql.NullInt64
|
||||
var deletedAt, message sql.NullString
|
||||
if err := db.QueryRow(`SELECT status,inner_code,stock_id,deleted_at,result_message FROM syb_inner_code_records WHERE order_number='ORDER-F'`).
|
||||
Scan(&status, &innerCode, &stockID, &deletedAt, &message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "pending" || innerCode != "DK-F-NEW" || stockID.Valid || deletedAt.Valid || message.Valid {
|
||||
t.Fatalf("未完成记录恢复后未重置: status=%s code=%s stock=%+v deleted=%+v message=%+v", status, innerCode, stockID, deletedAt, message)
|
||||
}
|
||||
|
||||
updatedRow := model.InnerCodeImportRow{BusinessDate: "2026-08-15", SourceRow: 9, OrderNumber: "ORDER-U",
|
||||
Stall: "B#2", SpecRaw: "白色,L", SpecKey: "白色,L", InnerCode: "DK-U",
|
||||
SourceDuplicateCount: 1, CreatedByUserID: "restore-user"}
|
||||
tx, err = db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outcome, err = UpsertInnerCodeImportRow(tx, updatedRow, "2026-08-15T04:00:00Z")
|
||||
if err != nil || outcome != InnerCodeImportRestored {
|
||||
tx.Rollback()
|
||||
t.Fatalf("已完成记录恢复不正确 outcome=%s err=%v", outcome, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT status,stock_id,deleted_at,result_message FROM syb_inner_code_records WHERE order_number='ORDER-U'`).
|
||||
Scan(&status, &stockID, &deletedAt, &message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "updated" || !stockID.Valid || deletedAt.Valid || !message.Valid || message.String != "旧审计" {
|
||||
t.Fatalf("已完成记录恢复后未保留审计: status=%s stock=%+v deleted=%+v message=%+v", status, stockID, deletedAt, message)
|
||||
}
|
||||
|
||||
conflictRow := model.InnerCodeImportRow{BusinessDate: "2026-08-15", SourceRow: 10, OrderNumber: "ORDER-N",
|
||||
Stall: "C#3", SpecRaw: "杏色,S", SpecKey: "杏色,S", InnerCode: "DK-N-CHANGED",
|
||||
SourceDuplicateCount: 1, CreatedByUserID: "restore-user"}
|
||||
tx, err = db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = UpsertInnerCodeImportRow(tx, conflictRow, "2026-08-15T04:00:00Z")
|
||||
tx.Rollback()
|
||||
if !errors.Is(err, ErrInnerCodeRestoreConflict) {
|
||||
t.Fatalf("需核对记录换入库码应拒绝,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(query, args...); err != nil {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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) {
|
||||
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 {
|
||||
if id <= 0 || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
unique = append(unique, id)
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return 0, fmt.Errorf("没有有效的档口入库码记录")
|
||||
}
|
||||
if len(unique) > PageSize {
|
||||
return 0, fmt.Errorf("一次最多删除当前页 %d 条记录", PageSize)
|
||||
}
|
||||
return repository.SoftDeleteInnerCodeRecords(db, unique, actorUserID, now.UTC().Format(model.TimeLayout))
|
||||
}
|
||||
@@ -45,6 +45,7 @@ type InnerCodeImportResult struct {
|
||||
TotalRows int
|
||||
CreatedCount int
|
||||
UpdatedCount int
|
||||
RestoredCount int
|
||||
DuplicateRows int
|
||||
}
|
||||
|
||||
@@ -130,10 +131,16 @@ func ImportInnerCodeExcel(db *sql.DB, path, originalFilename, actorUserID string
|
||||
return nil, fmt.Errorf("%w:Excel 第 %d 行的入库码 %q 在该业务日期已属于另一条记录",
|
||||
ErrInvalidInnerCodeImport, row.SourceRow, row.InnerCode)
|
||||
}
|
||||
if errors.Is(err, repository.ErrInnerCodeRestoreConflict) {
|
||||
return nil, fmt.Errorf("%w:Excel 第 %d 行对应的已删除记录已有远端结果,且入库码发生变化;请人工核对后处理",
|
||||
ErrInvalidInnerCodeImport, row.SourceRow)
|
||||
}
|
||||
return nil, fmt.Errorf("导入 Excel 第 %d 行失败: %w", row.SourceRow, err)
|
||||
}
|
||||
if outcome == repository.InnerCodeImportCreated {
|
||||
result.CreatedCount++
|
||||
} else if outcome == repository.InnerCodeImportRestored {
|
||||
result.RestoredCount++
|
||||
} else {
|
||||
result.UpdatedCount++
|
||||
}
|
||||
|
||||
+16
-1
@@ -164,7 +164,8 @@
|
||||
document.querySelectorAll("[data-confirm-delete]").forEach(function (el) {
|
||||
var isForm = el.tagName === "FORM";
|
||||
el.addEventListener(isForm ? "submit" : "click", function (e) {
|
||||
var n = checkedCount();
|
||||
var action = el.getAttribute("data-confirm-delete-action") || "";
|
||||
var n = checkedCount(action);
|
||||
if (n === 0) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
@@ -374,6 +375,7 @@
|
||||
function updateSelectionSummary() {
|
||||
var matchSelected = checkedBoxes("inner-code-match");
|
||||
var applySelected = checkedBoxes("inner-code-apply");
|
||||
var deleteSelected = checkedBoxes("inner-code-delete");
|
||||
var replacements = applySelected.filter(function (box) {
|
||||
return box.getAttribute("data-inner-code-old-value") === "1";
|
||||
}).length;
|
||||
@@ -383,6 +385,8 @@
|
||||
.forEach(function (node) { node.textContent = applySelected.length; });
|
||||
document.querySelectorAll("[data-inner-code-replace-count]")
|
||||
.forEach(function (node) { node.textContent = replacements; });
|
||||
document.querySelectorAll("[data-inner-code-delete-count]")
|
||||
.forEach(function (node) { node.textContent = deleteSelected.length; });
|
||||
}
|
||||
|
||||
function appendSelectedIDs(form, action) {
|
||||
@@ -431,6 +435,17 @@
|
||||
button.textContent = "回写中…";
|
||||
}
|
||||
});
|
||||
var deleteForm = document.getElementById("inner-code-delete-form");
|
||||
if (deleteForm) deleteForm.addEventListener("submit", function (event) {
|
||||
if (event.defaultPrevented) return;
|
||||
appendSelectedIDs(deleteForm, "inner-code-delete");
|
||||
var button = document.querySelector('[form="inner-code-delete-form"]');
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.setAttribute("aria-busy", "true");
|
||||
button.textContent = "删除中…";
|
||||
}
|
||||
});
|
||||
syncDate();
|
||||
updateSelectionSummary();
|
||||
}
|
||||
|
||||
@@ -48,6 +48,18 @@
|
||||
</form>
|
||||
<button type="button" data-modal-open="inner-code-apply-modal" data-need-checked="inner-code-apply"
|
||||
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-action="inner-code-delete">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="date" value="{{.BusinessDate}}" data-inner-code-date-field>
|
||||
<input type="hidden" name="status" value="{{.StatusFilter}}">
|
||||
<input type="hidden" name="q" value="{{.Keyword}}">
|
||||
<input type="hidden" name="page" value="{{.CurrentPage}}">
|
||||
</form>
|
||||
<button type="submit" class="danger" form="inner-code-delete-form"
|
||||
data-need-checked="inner-code-delete" disabled>删除已选 <span data-inner-code-delete-count>0</span> 条</button>
|
||||
</div>
|
||||
|
||||
{{if .Message}}<p class="notice inner-code-notice" role="status" aria-live="polite">{{.Message}}</p>{{end}}
|
||||
@@ -56,7 +68,7 @@
|
||||
<table class="inner-code-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" data-check-all aria-label="全选当前页可匹配记录"></th>
|
||||
<th class="col-check"><input type="checkbox" data-check-all aria-label="全选当前页记录"></th>
|
||||
<th>业务日期</th>
|
||||
<th>订单号</th>
|
||||
<th>档口及货号</th>
|
||||
@@ -75,9 +87,9 @@
|
||||
<tr {{if or (eq .Status "failed") (eq .Status "needs_check")}}class="row-warn"{{end}}>
|
||||
<td class="col-check">
|
||||
<input type="checkbox" value="{{.ID}}" data-inner-code-id="{{.ID}}"
|
||||
data-select-action="{{if .CanMatch}}inner-code-match{{end}}{{if .CanApply}} inner-code-apply{{end}}"
|
||||
data-select-action="inner-code-delete {{if .CanMatch}}inner-code-match{{end}}{{if .CanApply}} inner-code-apply{{end}}"
|
||||
data-inner-code-old-value="{{if .HasRemoteOldCode}}1{{else}}0{{end}}"
|
||||
aria-label="选择订单 {{.OrderNumber}}" {{if not .CanMatch}}disabled{{end}}>
|
||||
aria-label="选择订单 {{.OrderNumber}}">
|
||||
</td>
|
||||
<td>{{.BusinessDate}}</td>
|
||||
<td>{{.OrderNumber}}</td>
|
||||
|
||||
@@ -1022,7 +1022,7 @@ CREATE UNIQUE INDEX idx_client_assignment_current
|
||||
`ai_spec_match_decisions`。Admin 重启把未完成明细改为 `interrupted` 并完成批次计数,已经
|
||||
成功写入的 `spec_mappings` 不回滚。
|
||||
|
||||
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23)
|
||||
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23,软删除 v24)
|
||||
|
||||
档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统
|
||||
不保存原文件、文件哈希,不再拆批次表或尝试记录表。
|
||||
@@ -1038,3 +1038,9 @@ CREATE UNIQUE INDEX idx_client_assignment_current
|
||||
或 `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` 并清空旧规划;`applying/updated/already_filled/needs_check` 保留状态和远端
|
||||
审计。后一组状态若导入的 `inner_code` 已变化则拒绝恢复,交由人工核对。
|
||||
|
||||
Reference in New Issue
Block a user