feat: 档口入库码支持软删除与重导恢复 (#238)
This commit is contained in:
@@ -108,11 +108,25 @@ func (h *Handler) InnerCodeImport(c *gin.Context) {
|
|||||||
fail(c, http.StatusInternalServerError, "档口入库码写库失败,本次导入已整体回滚,请稍后重试。")
|
fail(c, http.StatusInternalServerError, "档口入库码写库失败,本次导入已整体回滚,请稍后重试。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
message := fmt.Sprintf("导入完成:业务日期 %s,读取 %d 行,新增 %d 条,更新 %d 条,文件内重复 %d 行。",
|
message := fmt.Sprintf("导入完成:业务日期 %s,读取 %d 行,新增 %d 条,更新 %d 条,恢复 %d 条,文件内重复 %d 行。",
|
||||||
result.BusinessDate, result.TotalRows, result.CreatedCount, result.UpdatedCount, result.DuplicateRows)
|
result.BusinessDate, result.TotalRows, result.CreatedCount, result.UpdatedCount, result.RestoredCount, result.DuplicateRows)
|
||||||
h.innerCodeRedirect(c, result.BusinessDate, "", "", 1, message)
|
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 读取远端最新详情并保存只读规划,不执行任何回写。
|
// InnerCodeMatch 读取远端最新详情并保存只读规划,不执行任何回写。
|
||||||
func (h *Handler) InnerCodeMatch(c *gin.Context) {
|
func (h *Handler) InnerCodeMatch(c *gin.Context) {
|
||||||
businessDate := strings.TrimSpace(c.PostForm("date"))
|
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)
|
pages.GET("/inner-codes", h.InnerCodeList)
|
||||||
innerCodes := pages.Group("/inner-codes")
|
innerCodes := pages.Group("/inner-codes")
|
||||||
innerCodes.POST("/import", h.InnerCodeImport)
|
innerCodes.POST("/import", h.InnerCodeImport)
|
||||||
|
innerCodes.POST("/delete", h.InnerCodeDelete)
|
||||||
innerCodes.POST("/match", h.InnerCodeMatch)
|
innerCodes.POST("/match", h.InnerCodeMatch)
|
||||||
innerCodes.POST("/apply", h.InnerCodeApply)
|
innerCodes.POST("/apply", h.InnerCodeApply)
|
||||||
innerCodes.POST("/recheck", h.InnerCodeRecheck)
|
innerCodes.POST("/recheck", h.InnerCodeRecheck)
|
||||||
|
|||||||
@@ -29,16 +29,20 @@ func TestInnerCodeTemplate_独立导航与安全表单(t *testing.T) {
|
|||||||
page := string(raw)
|
page := string(raw)
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`action="/inner-codes/import"`, `action="/inner-codes/match"`,
|
`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`,
|
`name="csrf_token"`, `data-check-all`, `data-inner-code-apply-open`,
|
||||||
`data-need-checked="inner-code-match"`, `data-inner-code-id="{{.ID}}"`,
|
`data-need-checked="inner-code-match"`, `data-inner-code-id="{{.ID}}"`,
|
||||||
`inner-code-match{{end}}{{if .CanApply}} inner-code-apply`,
|
`data-need-checked="inner-code-delete"`, `data-confirm-delete-action="inner-code-delete"`,
|
||||||
`{{if not .CanMatch}}disabled{{end}}`, `匹配已选`, `aria-label="档口入库码记录列表"`,
|
`inner-code-delete {{if .CanMatch}}inner-code-match{{end}}{{if .CanApply}} inner-code-apply`,
|
||||||
|
`删除不会撤销顺运宝已写入的快递单号`, `匹配已选`, `aria-label="档口入库码记录列表"`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(page, want) {
|
if !strings.Contains(page, want) {
|
||||||
t.Errorf("正式页面缺少 %s", want)
|
t.Errorf("正式页面缺少 %s", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.Contains(page, `{{if not .CanMatch}}disabled{{end}}`) {
|
||||||
|
t.Fatal("所有状态都应允许勾选删除,不能再按 CanMatch 禁用复选框")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInnerCodeTemplate_模块状态白名单已登记(t *testing.T) {
|
func TestInnerCodeTemplate_模块状态白名单已登记(t *testing.T) {
|
||||||
@@ -52,6 +56,8 @@ func TestInnerCodeTemplate_模块状态白名单已登记(t *testing.T) {
|
|||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`appendSelectedIDs(matchForm, "inner-code-match")`,
|
`appendSelectedIDs(matchForm, "inner-code-match")`,
|
||||||
`appendSelectedIDs(applyForm, "inner-code-apply")`,
|
`appendSelectedIDs(applyForm, "inner-code-apply")`,
|
||||||
|
`appendSelectedIDs(deleteForm, "inner-code-delete")`,
|
||||||
|
`checkedCount(action)`,
|
||||||
`field.name = "ids"`,
|
`field.name = "ids"`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(string(raw), want) {
|
if !strings.Contains(string(raw), want) {
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ type InnerCodeRecord struct {
|
|||||||
PlannedAt string
|
PlannedAt string
|
||||||
ApplyStartedAt string
|
ApplyStartedAt string
|
||||||
AppliedAt string
|
AppliedAt string
|
||||||
|
DeletedAt string
|
||||||
|
DeletedByUserID string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import (
|
|||||||
// ErrInnerCodeUniqueConflict 表示同一日期的入库码已属于另一个业务键。
|
// ErrInnerCodeUniqueConflict 表示同一日期的入库码已属于另一个业务键。
|
||||||
var ErrInnerCodeUniqueConflict = errors.New("同一业务日期的档口入库码已被其他记录使用")
|
var ErrInnerCodeUniqueConflict = errors.New("同一业务日期的档口入库码已被其他记录使用")
|
||||||
|
|
||||||
|
// ErrInnerCodeRestoreConflict 表示已完成或结果未知的软删除记录不能换入库码后直接恢复。
|
||||||
|
var ErrInnerCodeRestoreConflict = errors.New("已回写或需核对的删除记录不能用不同入库码恢复")
|
||||||
|
|
||||||
|
// ErrInnerCodeDeleteConflict 表示批量删除时记录已经不可见或不存在,整批不会部分删除。
|
||||||
|
var ErrInnerCodeDeleteConflict = errors.New("部分档口入库码记录已删除或不存在")
|
||||||
|
|
||||||
// InnerCodeSybSnapshot 是本地顺运宝明细用于解析货运单 stock id 的最小快照。
|
// InnerCodeSybSnapshot 是本地顺运宝明细用于解析货运单 stock id 的最小快照。
|
||||||
type InnerCodeSybSnapshot struct {
|
type InnerCodeSybSnapshot struct {
|
||||||
OrderNumber string
|
OrderNumber string
|
||||||
@@ -35,12 +41,13 @@ type InnerCodeStatusCounts struct {
|
|||||||
NeedsCheck int
|
NeedsCheck int
|
||||||
}
|
}
|
||||||
|
|
||||||
// InnerCodeImportOutcome 说明幂等导入是新增还是更新。
|
// InnerCodeImportOutcome 说明幂等导入是新增、更新还是恢复软删除记录。
|
||||||
type InnerCodeImportOutcome string
|
type InnerCodeImportOutcome string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
InnerCodeImportCreated InnerCodeImportOutcome = "created"
|
InnerCodeImportCreated InnerCodeImportOutcome = "created"
|
||||||
InnerCodeImportUpdated InnerCodeImportOutcome = "updated"
|
InnerCodeImportUpdated InnerCodeImportOutcome = "updated"
|
||||||
|
InnerCodeImportRestored InnerCodeImportOutcome = "restored"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpsertInnerCodeImportRow 按已确认业务键写入一行。
|
// UpsertInnerCodeImportRow 按已确认业务键写入一行。
|
||||||
@@ -58,10 +65,13 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
|
|||||||
return "", fmt.Errorf("%w(记录 %d)", ErrInnerCodeUniqueConflict, codeRecordID)
|
return "", fmt.Errorf("%w(记录 %d)", ErrInnerCodeUniqueConflict, codeRecordID)
|
||||||
}
|
}
|
||||||
var id int64
|
var id int64
|
||||||
|
var status model.InnerCodeStatus
|
||||||
|
var currentInnerCode, deletedAt string
|
||||||
err := tx.QueryRow(`
|
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=?
|
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) {
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
return "", fmt.Errorf("锁定档口入库码业务键失败: %w", err)
|
return "", fmt.Errorf("锁定档口入库码业务键失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -80,6 +90,32 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
|
|||||||
}
|
}
|
||||||
return InnerCodeImportCreated, nil
|
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(`
|
_, err = tx.Exec(`
|
||||||
UPDATE syb_inner_code_records
|
UPDATE syb_inner_code_records
|
||||||
@@ -107,6 +143,15 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
|
|||||||
return InnerCodeImportUpdated, nil
|
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 {
|
func innerCodeImportWriteError(action string, err error) error {
|
||||||
// 并发导入可能都在 SELECT 时看不到对方,最终仍由唯一索引裁决。
|
// 并发导入可能都在 SELECT 时看不到对方,最终仍由唯一索引裁决。
|
||||||
// 不把 MySQL 索引名或 SQL 原文显示给操作员。
|
// 不把 MySQL 索引名或 SQL 原文显示给操作员。
|
||||||
@@ -132,6 +177,7 @@ func ListInnerCodeRecordsForPlanning(q Execer, businessDate string, ids []int64)
|
|||||||
rows, err := q.Query(`SELECT `+innerCodeListColumns+`
|
rows, err := q.Query(`SELECT `+innerCodeListColumns+`
|
||||||
FROM syb_inner_code_records
|
FROM syb_inner_code_records
|
||||||
WHERE business_date=? AND id IN (`+strings.Join(placeholders, ",")+`)
|
WHERE business_date=? AND id IN (`+strings.Join(placeholders, ",")+`)
|
||||||
|
AND deleted_at IS NULL
|
||||||
AND status IN ('pending','ready','skipped','failed')
|
AND status IN ('pending','ready','skipped','failed')
|
||||||
ORDER BY source_row,id`, args...)
|
ORDER BY source_row,id`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -167,6 +213,7 @@ func ListInnerCodePlanningContext(q Execer, businessDate string, orderNumbers []
|
|||||||
rows, err := q.Query(`SELECT `+innerCodeListColumns+`
|
rows, err := q.Query(`SELECT `+innerCodeListColumns+`
|
||||||
FROM syb_inner_code_records
|
FROM syb_inner_code_records
|
||||||
WHERE business_date=? AND order_number IN (`+strings.Join(placeholders, ",")+`)
|
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...)
|
ORDER BY source_row,id`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("查询档口入库码匹配上下文失败: %w", err)
|
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=?,
|
SET stock_id=?,detail_id=?,syb_spec=?,syb_sku=?,syb_variation_sku=?,
|
||||||
purchase_platform=?,purchase_code=?,remote_inner_code=?,status=?,
|
purchase_platform=?,purchase_code=?,remote_inner_code=?,status=?,
|
||||||
result_message=?,planned_at=?,updated_at=?
|
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),
|
nullablePositiveInt64(plan.StockID), nullablePositiveInt64(plan.DetailID),
|
||||||
nullableString(plan.SybSpec), nullableString(plan.SybSKU), nullableString(plan.SybVariationSKU),
|
nullableString(plan.SybSpec), nullableString(plan.SybSKU), nullableString(plan.SybVariationSKU),
|
||||||
nullableString(plan.PurchasePlatform), nullableString(plan.PurchaseCode),
|
nullableString(plan.PurchasePlatform), nullableString(plan.PurchaseCode),
|
||||||
@@ -279,7 +326,9 @@ func lockAndValidateInnerCodePlanClaims(tx *sql.Tx, plans []model.InnerCodeRecor
|
|||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
rows, err := tx.Query(`SELECT id,COALESCE(detail_id,0),status
|
rows, err := tx.Query(`SELECT id,COALESCE(detail_id,0),status
|
||||||
FROM syb_inner_code_records
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("锁定档口入库码订单匹配上下文失败: %w", err)
|
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(syb_variation_sku,''),COALESCE(purchase_platform,''),COALESCE(purchase_code,''),
|
||||||
COALESCE(remote_inner_code,''),status,COALESCE(result_message,''),created_by_user_id,
|
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_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) {
|
func innerCodeFilterClause(filter InnerCodeListFilter) (string, []any) {
|
||||||
clauses := []string{"business_date=?"}
|
clauses := []string{"business_date=?", "deleted_at IS NULL"}
|
||||||
args := []any{filter.BusinessDate}
|
args := []any{filter.BusinessDate}
|
||||||
if filter.Status != "" {
|
if filter.Status != "" {
|
||||||
clauses = append(clauses, "status=?")
|
clauses = append(clauses, "status=?")
|
||||||
@@ -390,7 +439,7 @@ func CountInnerCodeStatuses(q Execer, businessDate string) (InnerCodeStatusCount
|
|||||||
var result InnerCodeStatusCounts
|
var result InnerCodeStatusCounts
|
||||||
err := q.QueryRow(`SELECT COUNT(*),
|
err := q.QueryRow(`SELECT COUNT(*),
|
||||||
COALESCE(SUM(status='ready'),0),COALESCE(SUM(status='needs_check'),0)
|
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)
|
Scan(&result.Total, &result.Ready, &result.NeedsCheck)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("统计档口入库码状态失败: %w", err)
|
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.SybSpec, &row.SybSKU, &row.SybVariationSKU, &row.PurchasePlatform,
|
||||||
&row.PurchaseCode, &row.RemoteInnerCode, &row.Status, &row.ResultMessage,
|
&row.PurchaseCode, &row.RemoteInnerCode, &row.Status, &row.ResultMessage,
|
||||||
&row.CreatedByUserID, &row.AppliedByUserID, &row.PlannedAt, &row.ApplyStartedAt,
|
&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 {
|
if err != nil {
|
||||||
return row, fmt.Errorf("读取档口入库码记录失败: %w", err)
|
return row, fmt.Errorf("读取档口入库码记录失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -425,7 +474,7 @@ func ClaimInnerCodeForApply(db *sql.DB, id int64, actorUserID, now string) (*mod
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
record, err := scanInnerCodeRecord(tx.QueryRow(`SELECT `+innerCodeListColumns+
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, false, nil
|
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
|
result, err := tx.Exec(`UPDATE syb_inner_code_records
|
||||||
SET status='applying',applied_by_user_id=?,apply_started_at=?,updated_at=?
|
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 {
|
if err != nil {
|
||||||
return nil, false, fmt.Errorf("领取档口入库码记录失败: %w", err)
|
return nil, false, fmt.Errorf("领取档口入库码记录失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -473,7 +522,7 @@ func FinishInnerCodeApply(q Execer, id int64, status model.InnerCodeStatus, mess
|
|||||||
// GetInnerCodeForRecheck 读取一条需核对记录。
|
// GetInnerCodeForRecheck 读取一条需核对记录。
|
||||||
func GetInnerCodeForRecheck(q Execer, id int64) (*model.InnerCodeRecord, error) {
|
func GetInnerCodeForRecheck(q Execer, id int64) (*model.InnerCodeRecord, error) {
|
||||||
record, err := scanInnerCodeRecord(q.QueryRow(`SELECT `+innerCodeListColumns+
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -483,6 +532,43 @@ func GetInnerCodeForRecheck(q Execer, id int64) (*model.InnerCodeRecord, error)
|
|||||||
return &record, nil
|
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 保存只读重新核对的远端结果,不执行状态领取或写入。
|
// SaveInnerCodeRecheck 保存只读重新核对的远端结果,不执行状态领取或写入。
|
||||||
func SaveInnerCodeRecheck(q Execer, id int64, status model.InnerCodeStatus, message, remoteCode, checkedAt string) error {
|
func SaveInnerCodeRecheck(q Execer, id int64, status model.InnerCodeStatus, message, remoteCode, checkedAt string) error {
|
||||||
result, err := q.Exec(`UPDATE syb_inner_code_records
|
result, err := q.Exec(`UPDATE syb_inner_code_records
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/go-sql-driver/mysql"
|
"github.com/go-sql-driver/mysql"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"cmautobuy/admin/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestInterruptApplyingInnerCodes_只收敛回写中记录(t *testing.T) {
|
func TestInterruptApplyingInnerCodes_只收敛回写中记录(t *testing.T) {
|
||||||
@@ -50,3 +53,116 @@ func TestInnerCodeImportWriteError_唯一冲突不泄漏索引细节(t *testing.
|
|||||||
t.Fatalf("不应回显索引细节: %v", err)
|
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"
|
"cmautobuy/admin/spec"
|
||||||
)
|
)
|
||||||
|
|
||||||
const mysqlSchemaVersion = 23
|
const mysqlSchemaVersion = 24
|
||||||
|
|
||||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
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 {
|
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)
|
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)
|
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 建立档口入库码最小闭环使用的唯一业务表。
|
// migrateMySQLV23 建立档口入库码最小闭环使用的唯一业务表。
|
||||||
// Excel 原文件、批次和每次尝试不会另建表保存。
|
// Excel 原文件、批次和每次尝试不会另建表保存。
|
||||||
func migrateMySQLV23(db *sql.DB) error {
|
func migrateMySQLV23(db *sql.DB) error {
|
||||||
@@ -2132,7 +2178,35 @@ func CheckMySQLSchema(db *sql.DB) error {
|
|||||||
if err := checkMySQLV22Shape(db); err != nil {
|
if err := checkMySQLV22Shape(db); err != nil {
|
||||||
return err
|
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 {
|
func checkMySQLV23Shape(db *sql.DB) error {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"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) {
|
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, err := db.Exec(query, args...); err != nil {
|
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
|
TotalRows int
|
||||||
CreatedCount int
|
CreatedCount int
|
||||||
UpdatedCount int
|
UpdatedCount int
|
||||||
|
RestoredCount int
|
||||||
DuplicateRows int
|
DuplicateRows int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,10 +131,16 @@ func ImportInnerCodeExcel(db *sql.DB, path, originalFilename, actorUserID string
|
|||||||
return nil, fmt.Errorf("%w:Excel 第 %d 行的入库码 %q 在该业务日期已属于另一条记录",
|
return nil, fmt.Errorf("%w:Excel 第 %d 行的入库码 %q 在该业务日期已属于另一条记录",
|
||||||
ErrInvalidInnerCodeImport, row.SourceRow, row.InnerCode)
|
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)
|
return nil, fmt.Errorf("导入 Excel 第 %d 行失败: %w", row.SourceRow, err)
|
||||||
}
|
}
|
||||||
if outcome == repository.InnerCodeImportCreated {
|
if outcome == repository.InnerCodeImportCreated {
|
||||||
result.CreatedCount++
|
result.CreatedCount++
|
||||||
|
} else if outcome == repository.InnerCodeImportRestored {
|
||||||
|
result.RestoredCount++
|
||||||
} else {
|
} else {
|
||||||
result.UpdatedCount++
|
result.UpdatedCount++
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -164,7 +164,8 @@
|
|||||||
document.querySelectorAll("[data-confirm-delete]").forEach(function (el) {
|
document.querySelectorAll("[data-confirm-delete]").forEach(function (el) {
|
||||||
var isForm = el.tagName === "FORM";
|
var isForm = el.tagName === "FORM";
|
||||||
el.addEventListener(isForm ? "submit" : "click", function (e) {
|
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) {
|
if (n === 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -374,6 +375,7 @@
|
|||||||
function updateSelectionSummary() {
|
function updateSelectionSummary() {
|
||||||
var matchSelected = checkedBoxes("inner-code-match");
|
var matchSelected = checkedBoxes("inner-code-match");
|
||||||
var applySelected = checkedBoxes("inner-code-apply");
|
var applySelected = checkedBoxes("inner-code-apply");
|
||||||
|
var deleteSelected = checkedBoxes("inner-code-delete");
|
||||||
var replacements = applySelected.filter(function (box) {
|
var replacements = applySelected.filter(function (box) {
|
||||||
return box.getAttribute("data-inner-code-old-value") === "1";
|
return box.getAttribute("data-inner-code-old-value") === "1";
|
||||||
}).length;
|
}).length;
|
||||||
@@ -383,6 +385,8 @@
|
|||||||
.forEach(function (node) { node.textContent = applySelected.length; });
|
.forEach(function (node) { node.textContent = applySelected.length; });
|
||||||
document.querySelectorAll("[data-inner-code-replace-count]")
|
document.querySelectorAll("[data-inner-code-replace-count]")
|
||||||
.forEach(function (node) { node.textContent = replacements; });
|
.forEach(function (node) { node.textContent = replacements; });
|
||||||
|
document.querySelectorAll("[data-inner-code-delete-count]")
|
||||||
|
.forEach(function (node) { node.textContent = deleteSelected.length; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendSelectedIDs(form, action) {
|
function appendSelectedIDs(form, action) {
|
||||||
@@ -431,6 +435,17 @@
|
|||||||
button.textContent = "回写中…";
|
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();
|
syncDate();
|
||||||
updateSelectionSummary();
|
updateSelectionSummary();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,18 @@
|
|||||||
</form>
|
</form>
|
||||||
<button type="button" data-modal-open="inner-code-apply-modal" data-need-checked="inner-code-apply"
|
<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>
|
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>
|
</div>
|
||||||
|
|
||||||
{{if .Message}}<p class="notice inner-code-notice" role="status" aria-live="polite">{{.Message}}</p>{{end}}
|
{{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">
|
<table class="inner-code-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>
|
<th>订单号</th>
|
||||||
<th>档口及货号</th>
|
<th>档口及货号</th>
|
||||||
@@ -75,9 +87,9 @@
|
|||||||
<tr {{if or (eq .Status "failed") (eq .Status "needs_check")}}class="row-warn"{{end}}>
|
<tr {{if or (eq .Status "failed") (eq .Status "needs_check")}}class="row-warn"{{end}}>
|
||||||
<td class="col-check">
|
<td class="col-check">
|
||||||
<input type="checkbox" value="{{.ID}}" data-inner-code-id="{{.ID}}"
|
<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}}"
|
data-inner-code-old-value="{{if .HasRemoteOldCode}}1{{else}}0{{end}}"
|
||||||
aria-label="选择订单 {{.OrderNumber}}" {{if not .CanMatch}}disabled{{end}}>
|
aria-label="选择订单 {{.OrderNumber}}">
|
||||||
</td>
|
</td>
|
||||||
<td>{{.BusinessDate}}</td>
|
<td>{{.BusinessDate}}</td>
|
||||||
<td>{{.OrderNumber}}</td>
|
<td>{{.OrderNumber}}</td>
|
||||||
|
|||||||
@@ -1022,7 +1022,7 @@ CREATE UNIQUE INDEX idx_client_assignment_current
|
|||||||
`ai_spec_match_decisions`。Admin 重启把未完成明细改为 `interrupted` 并完成批次计数,已经
|
`ai_spec_match_decisions`。Admin 重启把未完成明细改为 `interrupted` 并完成批次计数,已经
|
||||||
成功写入的 `spec_mappings` 不回滚。
|
成功写入的 `spec_mappings` 不回滚。
|
||||||
|
|
||||||
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23)
|
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23,软删除 v24)
|
||||||
|
|
||||||
档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统
|
档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统
|
||||||
不保存原文件、文件哈希,不再拆批次表或尝试记录表。
|
不保存原文件、文件哈希,不再拆批次表或尝试记录表。
|
||||||
@@ -1038,3 +1038,9 @@ CREATE UNIQUE INDEX idx_client_assignment_current
|
|||||||
或 `needs_check` 的远端结果和审计状态。
|
或 `needs_check` 的远端结果和审计状态。
|
||||||
- `created_by_user_id` 记录首次导入账号,`applied_by_user_id` 记录实际回写账号;时间字段
|
- `created_by_user_id` 记录首次导入账号,`applied_by_user_id` 记录实际回写账号;时间字段
|
||||||
使用 UTC ISO 8601。用户只停用不物理删除,因此外键不会阻塞账号生命周期。
|
使用 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