feat: 档口入库码后台批量回写 (#246)

This commit is contained in:
chengma
2026-08-15 17:08:17 +08:00
parent 6b30594f4c
commit f8f22a15c7
21 changed files with 708 additions and 134 deletions
+87 -26
View File
@@ -1,9 +1,11 @@
package web
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
@@ -53,21 +55,35 @@ func (h *Handler) InnerCodeList(c *gin.Context) {
if keyword != "" {
query.Set("q", keyword)
}
applyBatchID := strings.TrimSpace(c.Query("apply_batch_id"))
applyProgress, err := service.GetInnerCodeApplyBatchProgress(h.db, applyBatchID)
if err != nil {
fail(c, http.StatusInternalServerError, "读取后台回写进度失败,请稍后刷新重试。")
return
}
if applyProgress != nil {
query.Set("apply_batch_id", applyProgress.BatchID)
}
progressQuery := cloneURLValues(query)
progressQuery.Set("page", strconv.Itoa(result.Page))
progressQuery.Set("page_size", strconv.Itoa(pageSize))
c.HTML(http.StatusOK, "inner_code/list", page(c, "inner-codes", "档口入库码", gin.H{
"BusinessDate": businessDate,
"StatusFilter": result.Status,
"Keyword": keyword,
"StatusOptions": service.InnerCodeStatusOptions(),
"Rows": result.Rows,
"HasAny": result.Counts.Total > 0,
"IsFiltered": result.IsFiltered,
"CurrentPage": result.Page,
"CurrentPageSize": pageSize,
"FeedbackMessage": feedbackMessage,
"FeedbackSuccess": feedbackMessage != "" && feedbackKind == innerCodeFeedbackSuccess,
"FeedbackError": feedbackMessage != "" && feedbackKind == innerCodeFeedbackError,
"Status": service.InnerCodeStatusMessage(result),
"Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, query.Encode()),
"BusinessDate": businessDate,
"StatusFilter": result.Status,
"Keyword": keyword,
"StatusOptions": service.InnerCodeStatusOptions(),
"Rows": result.Rows,
"HasAny": result.Counts.Total > 0,
"IsFiltered": result.IsFiltered,
"CurrentPage": result.Page,
"CurrentPageSize": pageSize,
"FeedbackMessage": feedbackMessage,
"FeedbackSuccess": feedbackMessage != "" && feedbackKind == innerCodeFeedbackSuccess,
"FeedbackError": feedbackMessage != "" && feedbackKind == innerCodeFeedbackError,
"Status": service.InnerCodeStatusMessage(result),
"ApplyProgress": applyProgress,
"ApplyProgressURL": "/inner-codes?" + progressQuery.Encode(),
"Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, query.Encode()),
}))
}
@@ -220,30 +236,60 @@ func ensureInnerCodeMatchSession(checkSession func() error, autoLogin func() (st
return false, "顺运宝自动登录未完成:" + reason + ";没有执行匹配,请到“顺运宝数据”页面完成登录。"
}
// InnerCodeApply 在操作员确认后逐条执行安全回写。
// InnerCodeApply 在操作员确认后落库排队并立即返回,真实回写由单一后台执行器串行处理。
func (h *Handler) InnerCodeApply(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"))
client, message := h.innerCodeSybClient()
_, message := h.innerCodeSybClient()
if message != "" {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, message)
return
}
result, err := service.ApplyInnerCodes(c.Request.Context(), h.db, client, ids, currentUser(c).UserID)
actorUserID := currentUser(c).UserID
batch, err := service.QueueInnerCodeApplyBatch(h.db, ids, actorUserID)
if err != nil {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
"回写未完成:"+err.Error())
"后台回写未提交:"+err.Error())
return
}
message = fmt.Sprintf("回写完成:选择 %d 条,成功 %d 条,已存在 %d 条,跳过 %d 条,失败 %d 条,需核对 %d 条。",
result.Requested, result.Updated, result.AlreadyFilled, result.Skipped, result.Failed, result.NeedsCheck)
feedbackKind := innerCodeFeedbackSuccess
if result.Failed > 0 || result.NeedsCheck > 0 {
feedbackKind = innerCodeFeedbackError
message += " 请查看失败或需核对的记录,确认远端结果后再继续。"
}
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, feedbackKind, message)
h.startInnerCodeApplyBatch(batch.ID, actorUserID)
message = fmt.Sprintf("已提交 %d 条后台回写,页面可以继续使用;请刷新进度查看逐条结果。", batch.Count)
h.innerCodeRedirectWithBatch(c, businessDate, status, keyword, pageNumber,
innerCodeFeedbackSuccess, message, batch.ID)
}
func (h *Handler) startInnerCodeApplyBatch(batchID, actorUserID string) {
go func() {
h.innerCodeApplyMu.Lock()
defer h.innerCodeApplyMu.Unlock()
var runErr error
defer func() {
if recovered := recover(); recovered != nil {
runErr = fmt.Errorf("后台执行器异常:%v", recovered)
}
if runErr == nil {
return
}
needsCheck, released, interruptErr := service.InterruptInnerCodeApplyBatch(h.db, batchID)
if interruptErr != nil {
log.Printf("档口入库码后台批次中断且状态收敛失败 batch_id=%s err=%v interrupt_err=%v", batchID, runErr, interruptErr)
return
}
log.Printf("档口入库码后台批次中断 batch_id=%s needs_check=%d released=%d err=%v", batchID, needsCheck, released, runErr)
}()
client, message := h.innerCodeSybClient()
if message != "" {
runErr = errors.New(message)
return
}
result, err := service.RunInnerCodeApplyBatch(context.Background(), h.db, client, batchID, actorUserID)
runErr = err
if err == nil {
log.Printf("档口入库码后台批次完成 batch_id=%s processed=%d updated=%d already_filled=%d skipped=%d failed=%d needs_check=%d",
batchID, result.Requested, result.Updated, result.AlreadyFilled, result.Skipped, result.Failed, result.NeedsCheck)
}
}()
}
func parseInnerCodeIDs(rawIDs []string) []int64 {
@@ -307,6 +353,10 @@ func (h *Handler) innerCodeSybClient() (*syb.Client, string) {
}
func (h *Handler) innerCodeRedirect(c *gin.Context, businessDate, status, keyword string, pageNumber int, feedbackKind, message string) {
h.innerCodeRedirectWithBatch(c, businessDate, status, keyword, pageNumber, feedbackKind, message, "")
}
func (h *Handler) innerCodeRedirectWithBatch(c *gin.Context, businessDate, status, keyword string, pageNumber int, feedbackKind, message, batchID string) {
values := url.Values{"date": {businessDate}, "page": {strconv.Itoa(pageNumber)}}
if message = strings.TrimSpace(message); message != "" {
values.Set("feedback", feedbackKind)
@@ -319,5 +369,16 @@ func (h *Handler) innerCodeRedirect(c *gin.Context, businessDate, status, keywor
if strings.TrimSpace(keyword) != "" {
values.Set("q", strings.TrimSpace(keyword))
}
if strings.TrimSpace(batchID) != "" {
values.Set("apply_batch_id", strings.TrimSpace(batchID))
}
c.Redirect(http.StatusSeeOther, "/inner-codes?"+values.Encode())
}
func cloneURLValues(source url.Values) url.Values {
result := make(url.Values, len(source))
for key, values := range source {
result[key] = append([]string(nil), values...)
}
return result
}
+3
View File
@@ -13,6 +13,7 @@ import (
"database/sql"
"net/http"
"net/url"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -29,6 +30,8 @@ type Handler struct {
onlineThreshold time.Duration
aiSecrets service.AISecretStore
aiPolicy service.AIEndpointPolicy
// innerCodeApplyMu 保证真实顺运宝回写批次串行执行,避免共享会话和远端写入并发。
innerCodeApplyMu sync.Mutex
}
// Register 把五个模块的页面路由挂上去。
+22 -1
View File
@@ -109,7 +109,7 @@ func TestInnerCodeTemplate_模块状态白名单已登记(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"/inner-codes": ["date", "status", "q", "page", "page_size"]`) {
if !strings.Contains(string(raw), `"/inner-codes": ["date", "status", "q", "page", "page_size", "apply_batch_id"]`) {
t.Fatal("档口入库码页面切换模块后应保持稳定筛选和页码")
}
for _, want := range []string{
@@ -125,6 +125,27 @@ func TestInnerCodeTemplate_模块状态白名单已登记(t *testing.T) {
}
}
func TestInnerCodeTemplate_后台回写进度可访问且手工刷新(t *testing.T) {
raw, err := os.ReadFile("templates/inner_code/list.html")
if err != nil {
t.Fatal(err)
}
page := string(raw)
for _, want := range []string{
`class="inner-code-batch-progress" role="status" aria-live="polite"`,
`<progress max="{{.Total}}" value="{{.Processed}}">`,
`href="{{$.ApplyProgressURL}}">刷新进度</a>`,
`开始后台回写`,
} {
if !strings.Contains(page, want) {
t.Errorf("后台回写页面缺少 %q", want)
}
}
if strings.Contains(page, "setInterval") {
t.Fatal("档口入库码进度不得在模板内增加自动轮询")
}
}
func TestInnerCodeTemplate_筛选顺序与紧凑宽度(t *testing.T) {
raw, err := os.ReadFile("templates/inner_code/list.html")
if err != nil {
+1 -1
View File
@@ -83,7 +83,7 @@ func main() {
log.Fatalf("恢复中断的档口入库码回写失败: %v", err)
}
if innerCodeInterrupted > 0 {
log.Printf("已把 %d 条上次进程遗留的档口入库码回写标记为需核对", innerCodeInterrupted)
log.Printf("已安全收敛 %d 条上次进程遗留的档口入库码后台记录", innerCodeInterrupted)
}
log.Printf("数据库已就绪")
+1 -1
View File
@@ -72,7 +72,7 @@ func TestModuleNavigation_按账号保存稳定列表状态且安全回退(t *te
"\"/syb\": [\"order_no\", \"shop\", \"stage\", \"page\", \"page_size\", \"date_from\", \"date_to\"]",
"\"/tasks\": [\"type\", \"status\", \"creator\", \"q\", \"page\", \"page_size\"]",
"\"/clients\": [\"name\", \"page\", \"page_size\"]",
"\"/inner-codes\": [\"date\", \"status\", \"q\", \"page\", \"page_size\"]",
"\"/inner-codes\": [\"date\", \"status\", \"q\", \"page\", \"page_size\", \"apply_batch_id\"]",
"\"/users\": [\"q\", \"status\", \"page\", \"page_size\"]",
"var MODULE_STATE_PREFIX = \"cmautobuy:module-state:\"",
"parsed.origin !== window.location.origin || parsed.pathname !== moduleRoot",
+3
View File
@@ -6,6 +6,7 @@ type InnerCodeStatus string
const (
InnerCodePending InnerCodeStatus = "pending"
InnerCodeReady InnerCodeStatus = "ready"
InnerCodeQueued InnerCodeStatus = "queued"
InnerCodeApplying InnerCodeStatus = "applying"
InnerCodeUpdated InnerCodeStatus = "updated"
InnerCodeAlreadyFilled InnerCodeStatus = "already_filled"
@@ -29,6 +30,8 @@ type InnerCodeRecord struct {
SpecKey string
InnerCode string
SourceDuplicateCount int
ApplyBatchID string
ApplyQueuedAt string
StockID int64
DetailID int64
SybSpec string
+194 -44
View File
@@ -21,6 +21,9 @@ var ErrInnerCodeRestoreConflict = errors.New("已回写或需核对的删除记
// ErrInnerCodeDeleteConflict 表示批量删除时记录已经不可见或不存在,整批不会部分删除。
var ErrInnerCodeDeleteConflict = errors.New("部分档口入库码记录已删除或不存在")
// ErrInnerCodeApplyConflict 表示所选记录有一条已不再可回写,整批不会部分入队。
var ErrInnerCodeApplyConflict = errors.New("部分档口入库码记录已不再可回写")
// InnerCodeListFilter 是独立页面可组合的查询条件。
type InnerCodeListFilter struct {
BusinessDate string
@@ -32,9 +35,30 @@ type InnerCodeListFilter struct {
type InnerCodeStatusCounts struct {
Total int
Ready int
Queued int
Applying int
NeedsCheck int
}
// InnerCodeApplyBatchProgress 是从单表聚合得到的后台回写进度。
type InnerCodeApplyBatchProgress struct {
BatchID string
Total int
Queued int
Applying int
Updated int
AlreadyFilled int
Skipped int
Failed int
NeedsCheck int
Ready int
}
// Processed 返回已经结束远端处理的数量;恢复为 ready 的未开始记录不算已处理。
func (p InnerCodeApplyBatchProgress) Processed() int {
return p.Updated + p.AlreadyFilled + p.Skipped + p.Failed + p.NeedsCheck
}
// InnerCodeImportOutcome 说明幂等导入是新增、更新还是恢复软删除记录。
type InnerCodeImportOutcome string
@@ -133,6 +157,7 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
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,
apply_batch_id=NULL,apply_queued_at=NULL,apply_started_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)
@@ -147,18 +172,21 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
UPDATE syb_inner_code_records
SET source_row=?,print_sequence=?,shop_name=?,spec_raw=?,inner_code=?,
source_duplicate_count=?,
status=CASE WHEN status IN ('updated','already_filled','applying','needs_check')
status=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check')
THEN status ELSE 'pending' END,
stock_id=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN stock_id ELSE NULL END,
detail_id=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN detail_id ELSE NULL END,
syb_spec=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN syb_spec ELSE NULL END,
syb_sku=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN syb_sku ELSE NULL END,
syb_variation_sku=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN syb_variation_sku ELSE NULL END,
purchase_platform=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN purchase_platform ELSE NULL END,
purchase_code=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN purchase_code ELSE NULL END,
remote_inner_code=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN remote_inner_code ELSE NULL END,
result_message=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN result_message ELSE NULL END,
planned_at=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN planned_at ELSE NULL END,
stock_id=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN stock_id ELSE NULL END,
detail_id=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN detail_id ELSE NULL END,
syb_spec=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN syb_spec ELSE NULL END,
syb_sku=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN syb_sku ELSE NULL END,
syb_variation_sku=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN syb_variation_sku ELSE NULL END,
purchase_platform=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN purchase_platform ELSE NULL END,
purchase_code=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN purchase_code ELSE NULL END,
remote_inner_code=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN remote_inner_code ELSE NULL END,
result_message=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN result_message ELSE NULL END,
planned_at=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN planned_at ELSE NULL END,
apply_batch_id=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN apply_batch_id ELSE NULL END,
apply_queued_at=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN apply_queued_at ELSE NULL END,
apply_started_at=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN apply_started_at ELSE NULL END,
updated_at=?
WHERE id=?`,
row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName), row.SpecRaw,
@@ -171,7 +199,8 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
func innerCodeStatusPreservesImportResult(status model.InnerCodeStatus) bool {
switch status {
case model.InnerCodeApplying, model.InnerCodeUpdated, model.InnerCodeAlreadyFilled, model.InnerCodeNeedsCheck:
case model.InnerCodeQueued, model.InnerCodeApplying, model.InnerCodeUpdated,
model.InnerCodeAlreadyFilled, model.InnerCodeNeedsCheck:
return true
default:
return false
@@ -239,7 +268,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'))
AND (deleted_at IS NULL OR status IN ('queued','applying','updated','already_filled','needs_check'))
ORDER BY source_row,id`, args...)
if err != nil {
return nil, fmt.Errorf("查询档口入库码匹配上下文失败: %w", err)
@@ -274,7 +303,8 @@ func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt str
UPDATE syb_inner_code_records
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=?
result_message=?,planned_at=?,apply_batch_id=NULL,apply_queued_at=NULL,
apply_started_at=NULL,updated_at=?
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),
@@ -322,7 +352,7 @@ func lockAndValidateInnerCodePlanClaims(tx *sql.Tx, plans []model.InnerCodeRecor
rows, err := tx.Query(`SELECT id,COALESCE(detail_id,0),status
FROM syb_inner_code_records
WHERE business_date=? AND order_number=?
AND (deleted_at IS NULL OR status IN ('applying','updated','already_filled','needs_check'))
AND (deleted_at IS NULL OR status IN ('queued','applying','updated','already_filled','needs_check'))
ORDER BY id FOR UPDATE`, key.BusinessDate, key.OrderNumber)
if err != nil {
return fmt.Errorf("锁定档口入库码订单匹配上下文失败: %w", err)
@@ -362,7 +392,7 @@ func lockAndValidateInnerCodePlanClaims(tx *sql.Tx, plans []model.InnerCodeRecor
func innerCodeStatusHoldsDetail(status model.InnerCodeStatus) bool {
switch status {
case model.InnerCodeReady, model.InnerCodeApplying, model.InnerCodeUpdated,
case model.InnerCodeReady, model.InnerCodeQueued, model.InnerCodeApplying, model.InnerCodeUpdated,
model.InnerCodeAlreadyFilled, model.InnerCodeNeedsCheck:
return true
default:
@@ -372,6 +402,7 @@ func innerCodeStatusHoldsDetail(status model.InnerCodeStatus) bool {
const innerCodeListColumns = `id,business_date,source_row,COALESCE(print_sequence,0),order_number,
COALESCE(shop_name,''),stall,spec_raw,spec_key,inner_code,source_duplicate_count,
COALESCE(apply_batch_id,''),COALESCE(apply_queued_at,''),
COALESCE(stock_id,0),COALESCE(detail_id,0),COALESCE(syb_spec,''),COALESCE(syb_sku,''),
COALESCE(syb_variation_sku,''),COALESCE(purchase_platform,''),COALESCE(purchase_code,''),
COALESCE(remote_inner_code,''),status,COALESCE(result_message,''),created_by_user_id,
@@ -429,13 +460,14 @@ func CountInnerCodeRecords(q Execer, filter InnerCodeListFilter) (int, error) {
return count, nil
}
// CountInnerCodeStatuses 统计当前业务日期总量、可回写和需核对数量。
// CountInnerCodeStatuses 统计当前业务日期总量和后台回写关键状态。
func CountInnerCodeStatuses(q Execer, businessDate string) (InnerCodeStatusCounts, error) {
var result InnerCodeStatusCounts
err := q.QueryRow(`SELECT COUNT(*),
COALESCE(SUM(status='ready'),0),COALESCE(SUM(status='needs_check'),0)
COALESCE(SUM(status='ready'),0),COALESCE(SUM(status='queued'),0),
COALESCE(SUM(status='applying'),0),COALESCE(SUM(status='needs_check'),0)
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.Queued, &result.Applying, &result.NeedsCheck)
if err != nil {
return result, fmt.Errorf("统计档口入库码状态失败: %w", err)
}
@@ -450,7 +482,8 @@ func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, er
var row model.InnerCodeRecord
err := scanner.Scan(&row.ID, &row.BusinessDate, &row.SourceRow, &row.PrintSequence,
&row.OrderNumber, &row.ShopName, &row.Stall, &row.SpecRaw, &row.SpecKey,
&row.InnerCode, &row.SourceDuplicateCount, &row.StockID, &row.DetailID,
&row.InnerCode, &row.SourceDuplicateCount, &row.ApplyBatchID, &row.ApplyQueuedAt,
&row.StockID, &row.DetailID,
&row.SybSpec, &row.SybSKU, &row.SybVariationSKU, &row.PurchasePlatform,
&row.PurchaseCode, &row.RemoteInnerCode, &row.Status, &row.ResultMessage,
&row.CreatedByUserID, &row.AppliedByUserID, &row.PlannedAt, &row.ApplyStartedAt,
@@ -461,41 +494,148 @@ func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, er
return row, nil
}
// ClaimInnerCodeForApply 原子领取一条 ready 记录。返回 claimed=false 表示状态已变化。
func ClaimInnerCodeForApply(db *sql.DB, id int64, actorUserID, now string) (*model.InnerCodeRecord, bool, error) {
// QueueInnerCodeApplyBatch 把所选 ready 记录原子加入同一后台批次。
// 任一记录不存在、已删除或状态变化时整批回滚,避免页面选择与实际队列不一致。
func QueueInnerCodeApplyBatch(db *sql.DB, ids []int64, batchID, actorUserID, queuedAt string) (int, error) {
if len(ids) == 0 {
return 0, nil
}
placeholders := make([]string, len(ids))
args := make([]any, 0, len(ids)+4)
args = append(args, batchID, queuedAt, actorUserID, queuedAt)
for index, id := range ids {
placeholders[index] = "?"
args = append(args, id)
}
tx, err := db.Begin()
if err != nil {
return nil, false, fmt.Errorf("开始领取档口入库码事务失败: %w", err)
return 0, fmt.Errorf("开始档口入库码排队事务失败: %w", err)
}
defer tx.Rollback()
record, err := scanInnerCodeRecord(tx.QueryRow(`SELECT `+innerCodeListColumns+
` FROM syb_inner_code_records WHERE id=? AND deleted_at IS NULL FOR UPDATE`, id))
if errors.Is(err, sql.ErrNoRows) {
result, err := tx.Exec(`UPDATE syb_inner_code_records
SET status='queued',apply_batch_id=?,apply_queued_at=?,applied_by_user_id=?,
result_message='已进入后台回写队列',apply_started_at=NULL,updated_at=?
WHERE deleted_at IS NULL AND status='ready' 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, ErrInnerCodeApplyConflict
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("提交档口入库码排队事务失败: %w", err)
}
return int(affected), nil
}
// ListQueuedInnerCodeIDs 返回批次下一组待处理记录。分组只控制数据库读取,不放宽逐条远端门禁。
func ListQueuedInnerCodeIDs(q Execer, batchID string, limit int) ([]int64, error) {
rows, err := q.Query(`SELECT id FROM syb_inner_code_records
WHERE apply_batch_id=? AND status='queued' ORDER BY id LIMIT ?`, batchID, limit)
if err != nil {
return nil, fmt.Errorf("读取档口入库码后台队列失败: %w", err)
}
defer rows.Close()
ids := make([]int64, 0, limit)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("读取档口入库码后台队列编号失败: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历档口入库码后台队列失败: %w", err)
}
return ids, nil
}
// ClaimQueuedInnerCodeForApply 原子领取批次中的一条 queued 记录。
func ClaimQueuedInnerCodeForApply(db *sql.DB, id int64, batchID, actorUserID, now string) (*model.InnerCodeRecord, bool, error) {
tx, err := db.Begin()
if err != nil {
return nil, false, fmt.Errorf("开始领取档口入库码后台事务失败: %w", err)
}
defer tx.Rollback()
result, err := tx.Exec(`UPDATE syb_inner_code_records
SET status='applying',applied_by_user_id=?,apply_started_at=?,updated_at=?
WHERE id=? AND apply_batch_id=? AND status='queued'`, actorUserID, now, now, id, batchID)
if err != nil {
return nil, false, fmt.Errorf("领取档口入库码后台记录失败: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return nil, false, fmt.Errorf("读取档口入库码后台领取结果失败: %w", err)
}
if affected != 1 {
return nil, false, nil
}
record, err := scanInnerCodeRecord(tx.QueryRow(`SELECT `+innerCodeListColumns+
` FROM syb_inner_code_records WHERE id=? AND apply_batch_id=?`, id, batchID))
if err != nil {
return nil, false, err
}
if record.Status != model.InnerCodeReady {
return &record, false, nil
if err := tx.Commit(); err != nil {
return nil, false, fmt.Errorf("提交档口入库码后台领取失败: %w", err)
}
result, err := tx.Exec(`UPDATE syb_inner_code_records
SET status='applying',applied_by_user_id=?,apply_started_at=?,updated_at=?
WHERE id=? AND deleted_at IS NULL AND status='ready'`, actorUserID, now, now, id)
return &record, true, nil
}
// InterruptInnerCodeApplyBatch 收敛异常批次:未知远端结果转需核对,未开始记录恢复可回写。
func InterruptInnerCodeApplyBatch(db *sql.DB, batchID, interruptedAt string) (int, int, error) {
tx, err := db.Begin()
if err != nil {
return nil, false, fmt.Errorf("领取档口入库码记录失败: %w", err)
return 0, 0, fmt.Errorf("开始收敛档口入库码后台批次失败: %w", err)
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
return &record, false, nil
defer tx.Rollback()
applying, err := tx.Exec(`UPDATE syb_inner_code_records
SET status='needs_check',result_message='后台回写异常中断,远端结果不确定;系统不会自动重写',updated_at=?
WHERE apply_batch_id=? AND status='applying'`, interruptedAt, batchID)
if err != nil {
return 0, 0, fmt.Errorf("收敛档口入库码未知回写结果失败: %w", err)
}
queued, err := tx.Exec(`UPDATE syb_inner_code_records
SET status='ready',result_message='后台回写中断,本条尚未发送远端请求,已恢复为可回写',updated_at=?
WHERE apply_batch_id=? AND status='queued'`, interruptedAt, batchID)
if err != nil {
return 0, 0, fmt.Errorf("释放档口入库码后台队列失败: %w", err)
}
applyingCount, err := applying.RowsAffected()
if err != nil {
return 0, 0, fmt.Errorf("读取档口入库码未知结果数量失败: %w", err)
}
queuedCount, err := queued.RowsAffected()
if err != nil {
return 0, 0, fmt.Errorf("读取档口入库码后台释放数量失败: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, false, fmt.Errorf("提交档口入库码领取失败: %w", err)
return 0, 0, fmt.Errorf("提交档口入库码后台收敛失败: %w", err)
}
record.Status = model.InnerCodeApplying
record.AppliedByUserID = actorUserID
record.ApplyStartedAt = now
record.UpdatedAt = now
return &record, true, nil
return int(applyingCount), int(queuedCount), nil
}
// GetInnerCodeApplyBatchProgress 从现有业务表聚合批次进度。
func GetInnerCodeApplyBatchProgress(q Execer, batchID string) (*InnerCodeApplyBatchProgress, error) {
p := &InnerCodeApplyBatchProgress{BatchID: batchID}
err := q.QueryRow(`SELECT COUNT(*),
COALESCE(SUM(status='queued'),0),COALESCE(SUM(status='applying'),0),
COALESCE(SUM(status='updated'),0),COALESCE(SUM(status='already_filled'),0),
COALESCE(SUM(status='skipped'),0),COALESCE(SUM(status='failed'),0),
COALESCE(SUM(status='needs_check'),0),COALESCE(SUM(status='ready'),0)
FROM syb_inner_code_records WHERE apply_batch_id=?`, batchID).
Scan(&p.Total, &p.Queued, &p.Applying, &p.Updated, &p.AlreadyFilled,
&p.Skipped, &p.Failed, &p.NeedsCheck, &p.Ready)
if err != nil {
return nil, fmt.Errorf("读取档口入库码后台进度失败: %w", err)
}
if p.Total == 0 {
return nil, nil
}
return p, nil
}
// FinishInnerCodeApply 保存一条已领取记录的最终结果。
@@ -579,19 +719,29 @@ func SaveInnerCodeRecheck(q Execer, id int64, status model.InnerCodeStatus, mess
return nil
}
// InterruptApplyingInnerCodes 在启动时把未知结果的 applying 收敛为 needs_check。
// InterruptApplyingInnerCodes 在启动时收敛后台状态:未知写入转需核对,未开始队列恢复可回写。
func InterruptApplyingInnerCodes(q Execer, interruptedAt string) (int, error) {
result, err := q.Exec(`UPDATE syb_inner_code_records
applying, err := q.Exec(`UPDATE syb_inner_code_records
SET status='needs_check',result_message='Admin 在回写完成前退出,请重新核对远端结果;系统不会自动重写',
updated_at=? WHERE status='applying'`, interruptedAt)
if err != nil {
return 0, fmt.Errorf("恢复中断的档口入库码回写失败: %w", err)
}
affected, err := result.RowsAffected()
applyingCount, err := applying.RowsAffected()
if err != nil {
return 0, fmt.Errorf("读取中断档口入库码数量失败: %w", err)
}
return int(affected), nil
queued, err := q.Exec(`UPDATE syb_inner_code_records
SET status='ready',result_message='Admin 在后台回写开始前退出,本条未发送远端请求,已恢复为可回写',
updated_at=? WHERE status='queued'`, interruptedAt)
if err != nil {
return 0, fmt.Errorf("恢复未开始的档口入库码队列失败: %w", err)
}
queuedCount, err := queued.RowsAffected()
if err != nil {
return 0, fmt.Errorf("读取恢复档口入库码队列数量失败: %w", err)
}
return int(applyingCount + queuedCount), nil
}
func nullablePositiveInt(value int) any {
+96 -4
View File
@@ -24,11 +24,11 @@ func TestInterruptApplyingInnerCodes_只收敛回写中记录(t *testing.T) {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO syb_inner_code_records(id,status,updated_at) VALUES
(1,'applying','old'),(2,'ready','old')`); err != nil {
(1,'applying','old'),(2,'ready','old'),(3,'queued','old')`); err != nil {
t.Fatal(err)
}
count, err := InterruptApplyingInnerCodes(db, "2026-08-15T01:00:00Z")
if err != nil || count != 1 {
if err != nil || count != 2 {
t.Fatalf("count=%d err=%v", count, err)
}
var status, message, updatedAt string
@@ -42,6 +42,9 @@ func TestInterruptApplyingInnerCodes_只收敛回写中记录(t *testing.T) {
if err := db.QueryRow(`SELECT status FROM syb_inner_code_records WHERE id=2`).Scan(&status); err != nil || status != "ready" {
t.Fatalf("ready 记录不应变化 status=%s err=%v", status, err)
}
if err := db.QueryRow(`SELECT status,result_message FROM syb_inner_code_records WHERE id=3`).Scan(&status, &message); err != nil || status != "ready" || message == "" {
t.Fatalf("queued 记录应恢复可回写 status=%s message=%q err=%v", status, message, err)
}
}
func TestInnerCodeImportWriteError_唯一冲突不泄漏索引细节(t *testing.T) {
@@ -65,7 +68,7 @@ func TestSoftDeleteInnerCodeRecords_所有状态只写删除审计(t *testing.T)
)`); err != nil {
t.Fatal(err)
}
statuses := []string{"pending", "ready", "applying", "updated", "already_filled", "skipped", "failed", "needs_check"}
statuses := []string{"pending", "ready", "queued", "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)
@@ -120,6 +123,95 @@ func TestSoftDeleteInnerCodeRecords_有失效ID时整批回滚(t *testing.T) {
}
}
func TestQueueInnerCodeApplyBatch_状态冲突时整批回滚(t *testing.T) {
db, err := sql.Open("sqlite", "file:inner_code_queue_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,apply_batch_id TEXT,apply_queued_at TEXT,
applied_by_user_id TEXT,result_message TEXT,apply_started_at TEXT,updated_at TEXT,
deleted_at TEXT
); INSERT INTO syb_inner_code_records(id,status,updated_at) VALUES
(1,'ready','old'),(2,'pending','old')`); err != nil {
t.Fatal(err)
}
if _, err := QueueInnerCodeApplyBatch(db, []int64{1, 2}, "ICB-1", "user-1", "2026-08-15T05:00:00Z"); !errors.Is(err, ErrInnerCodeApplyConflict) {
t.Fatalf("期望整批排队冲突,实际 %v", err)
}
var status string
var batchID sql.NullString
if err := db.QueryRow(`SELECT status,apply_batch_id FROM syb_inner_code_records WHERE id=1`).Scan(&status, &batchID); err != nil {
t.Fatal(err)
}
if status != "ready" || batchID.Valid {
t.Fatalf("冲突后不应部分入队 status=%s batch=%+v", status, batchID)
}
}
func TestInnerCodeApplyBatchProgress_聚合终态与恢复记录(t *testing.T) {
db, err := sql.Open("sqlite", "file:inner_code_batch_progress?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,apply_batch_id TEXT
); INSERT INTO syb_inner_code_records(id,status,apply_batch_id) VALUES
(1,'queued','ICB-1'),(2,'applying','ICB-1'),(3,'updated','ICB-1'),
(4,'failed','ICB-1'),(5,'needs_check','ICB-1'),(6,'ready','ICB-1')`); err != nil {
t.Fatal(err)
}
progress, err := GetInnerCodeApplyBatchProgress(db, "ICB-1")
if err != nil {
t.Fatal(err)
}
if progress == nil || progress.Total != 6 || progress.Queued != 1 || progress.Applying != 1 ||
progress.Updated != 1 || progress.Failed != 1 || progress.NeedsCheck != 1 ||
progress.Ready != 1 || progress.Processed() != 3 {
t.Fatalf("progress=%+v", progress)
}
}
func TestInterruptInnerCodeApplyBatch_区分未知结果和未开始记录(t *testing.T) {
db, err := sql.Open("sqlite", "file:inner_code_batch_interrupt?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,apply_batch_id TEXT,result_message TEXT,updated_at TEXT
); INSERT INTO syb_inner_code_records(id,status,apply_batch_id,updated_at) VALUES
(1,'applying','ICB-1','old'),(2,'queued','ICB-1','old'),(3,'queued','ICB-2','old')`); err != nil {
t.Fatal(err)
}
needsCheck, released, err := InterruptInnerCodeApplyBatch(db, "ICB-1", "2026-08-15T06:00:00Z")
if err != nil || needsCheck != 1 || released != 1 {
t.Fatalf("needs_check=%d released=%d err=%v", needsCheck, released, err)
}
rows, err := db.Query(`SELECT id,status,result_message,updated_at FROM syb_inner_code_records ORDER BY id`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
wants := []string{"needs_check", "ready", "queued"}
index := 0
for rows.Next() {
var id int
var status, message, updatedAt string
var nullableMessage sql.NullString
if err := rows.Scan(&id, &status, &nullableMessage, &updatedAt); err != nil {
t.Fatal(err)
}
message = nullableMessage.String
if status != wants[index] || (id < 3 && (message == "" || updatedAt != "2026-08-15T06:00:00Z")) {
t.Fatalf("id=%d status=%s message=%q updated=%s", id, status, message, updatedAt)
}
index++
}
}
func TestFinishInnerCodeApply_软删除后仍保存后台结果(t *testing.T) {
db, err := sql.Open("sqlite", "file:inner_code_finish_deleted?mode=memory&cache=shared")
if err != nil {
@@ -153,7 +245,7 @@ func TestInnerCodeStatusPreservesImportResult(t *testing.T) {
t.Errorf("%s 不应保留旧规划", status)
}
}
for _, status := range []string{"applying", "updated", "already_filled", "needs_check"} {
for _, status := range []string{"queued", "applying", "updated", "already_filled", "needs_check"} {
if !innerCodeStatusPreservesImportResult(model.InnerCodeStatus(status)) {
t.Errorf("%s 应保留远端结果和审计", status)
}
+93 -2
View File
@@ -20,7 +20,7 @@ import (
"cmautobuy/admin/spec"
)
const mysqlSchemaVersion = 24
const mysqlSchemaVersion = 25
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
@@ -712,10 +712,64 @@ func MigrateMySQL(db *sql.DB) error {
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)
}
current = 24
}
if current < 25 {
if err := migrateMySQLV25(db); err != nil {
return fmt.Errorf("执行 MySQL schema v25 失败: %w", err)
}
if err := checkMySQLV25Shape(db); err != nil {
return fmt.Errorf("MySQL schema v25 自检失败,未记录版本: %w", err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 25, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v25 失败: %w", err)
}
}
return CheckMySQLSchema(db)
}
// migrateMySQLV25 在现有档口入库码业务表上增加后台批次状态,不另建批次表。
func migrateMySQLV25(db *sql.DB) error {
for _, column := range []struct{ name, ddl string }{
{"apply_batch_id", `ALTER TABLE syb_inner_code_records ADD COLUMN apply_batch_id VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER source_duplicate_count`},
{"apply_queued_at", `ALTER TABLE syb_inner_code_records ADD COLUMN apply_queued_at VARCHAR(35) NULL AFTER apply_batch_id`},
} {
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)
}
}
}
clause, exists, err := mysqlCheckConstraintClause(db, "syb_inner_code_records", "chk_inner_code_status")
if err != nil {
return err
}
if exists && !strings.Contains(strings.ToLower(clause), "queued") {
if _, err := db.Exec(`ALTER TABLE syb_inner_code_records DROP CHECK chk_inner_code_status`); err != nil {
return fmt.Errorf("更新档口入库码状态约束前删除旧约束失败: %w", err)
}
exists = false
}
if !exists {
if _, err := db.Exec(`ALTER TABLE syb_inner_code_records ADD CONSTRAINT chk_inner_code_status
CHECK (status IN ('pending','ready','queued','applying','updated','already_filled','skipped','failed','needs_check'))`); err != nil {
return fmt.Errorf("增加档口入库码后台状态约束失败: %w", err)
}
}
if exists, err := mysqlIndexExists(db, "syb_inner_code_records", "idx_inner_code_apply_batch"); err != nil {
return err
} else if !exists {
if _, err := db.Exec(`ALTER TABLE syb_inner_code_records ADD INDEX idx_inner_code_apply_batch (apply_batch_id,status,id)`); err != nil {
return fmt.Errorf("增加档口入库码后台批次索引失败: %w", err)
}
}
return nil
}
// migrateMySQLV24 为档口入库码增加可恢复软删除审计。
func migrateMySQLV24(db *sql.DB) error {
for _, column := range []struct{ name, ddl string }{
@@ -1939,6 +1993,23 @@ func mysqlConstraintExists(db *sql.DB, table, constraint string) (bool, error) {
return count == 1, nil
}
func mysqlCheckConstraintClause(db *sql.DB, table, constraint string) (string, bool, error) {
var clause string
err := db.QueryRow(`SELECT cc.check_clause
FROM information_schema.table_constraints tc
JOIN information_schema.check_constraints cc
ON cc.constraint_schema=tc.constraint_schema AND cc.constraint_name=tc.constraint_name
WHERE tc.constraint_schema=DATABASE() AND tc.table_name=? AND tc.constraint_name=?
AND tc.constraint_type='CHECK'`, table, constraint).Scan(&clause)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("读取 MySQL 约束 %s.%s 表达式失败: %w", table, constraint, err)
}
return clause, true, nil
}
func mysqlIndexExists(db *sql.DB, table, index string) (bool, error) {
var count int
if err := db.QueryRow(`SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
@@ -2181,7 +2252,27 @@ func CheckMySQLSchema(db *sql.DB) error {
if err := checkMySQLV23Shape(db); err != nil {
return err
}
return checkMySQLV24Shape(db)
if err := checkMySQLV24Shape(db); err != nil {
return err
}
return checkMySQLV25Shape(db)
}
func checkMySQLV25Shape(db *sql.DB) error {
for _, name := range []string{"apply_batch_id", "apply_queued_at"} {
exists, err := mysqlColumnExists(db, "syb_inner_code_records", name)
if err != nil || !exists {
return fmt.Errorf("档口入库码后台回写字段 %s 缺失: %v", name, err)
}
}
if exists, err := mysqlIndexExists(db, "syb_inner_code_records", "idx_inner_code_apply_batch"); err != nil || !exists {
return fmt.Errorf("档口入库码后台回写索引 idx_inner_code_apply_batch 缺失: %v", err)
}
clause, exists, err := mysqlCheckConstraintClause(db, "syb_inner_code_records", "chk_inner_code_status")
if err != nil || !exists || !strings.Contains(strings.ToLower(clause), "queued") {
return fmt.Errorf("档口入库码状态约束未包含 queued: %v", err)
}
return nil
}
func checkMySQLV24Shape(db *sql.DB) error {
+27 -2
View File
@@ -1094,7 +1094,7 @@ func TestMySQLMigrate_V22升级V23且唯一约束生效(t *testing.T) {
}
mustExec(t, db, `SET FOREIGN_KEY_CHECKS=0`)
mustExec(t, db, `DROP TABLE syb_inner_code_records`)
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=23`)
mustExec(t, db, `DELETE FROM schema_migrations WHERE version>=23`)
mustExec(t, db, `SET FOREIGN_KEY_CHECKS=1`)
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v22 升级 v23 失败: %v", err)
@@ -1129,7 +1129,7 @@ func TestMySQLMigrate_V23升级V24且软删除字段有效(t *testing.T) {
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`)
mustExec(t, db, `DELETE FROM schema_migrations WHERE version>=24`)
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v23 升级 v24 失败: %v", err)
}
@@ -1141,6 +1141,31 @@ func TestMySQLMigrate_V23升级V24且软删除字段有效(t *testing.T) {
}
}
func TestMySQLMigrate_V24升级V25且后台队列状态有效(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 INDEX idx_inner_code_apply_batch`)
mustExec(t, db, `ALTER TABLE syb_inner_code_records DROP COLUMN apply_queued_at,DROP COLUMN apply_batch_id`)
mustExec(t, db, `ALTER TABLE syb_inner_code_records DROP CHECK chk_inner_code_status`)
mustExec(t, db, `ALTER TABLE syb_inner_code_records ADD CONSTRAINT chk_inner_code_status
CHECK (status IN ('pending','ready','applying','updated','already_filled','skipped','failed','needs_check'))`)
mustExec(t, db, `DELETE FROM schema_migrations WHERE version>=25`)
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v24 升级 v25 失败: %v", err)
}
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v25 重复迁移失败: %v", err)
}
if err := checkMySQLV25Shape(db); err != nil {
t.Fatal(err)
}
}
func TestUpsertInnerCodeImportRow_软删除记录按状态安全恢复(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
+90 -29
View File
@@ -2,7 +2,9 @@ package service
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
@@ -30,51 +32,110 @@ type InnerCodeApplyResult struct {
NeedsCheck int
}
// InnerCodeApplyBatch 是一次已经落库的后台回写请求。
type InnerCodeApplyBatch struct {
ID string
Count int
}
const innerCodeApplyChunkSize = 20
type innerCodeApplyOutcome struct {
Status model.InnerCodeStatus
Message string
RemoteCode string
}
// ApplyInnerCodes 逐条原子领取并回写。每条写请求只发送一次,单条失败不阻断后续行。
func ApplyInnerCodes(ctx context.Context, db *sql.DB, writer InnerCodeWriter, ids []int64, actorUserID string) (*InnerCodeApplyResult, error) {
// QueueInnerCodeApplyBatch 把任意数量的已选记录原子排队,远端请求由后台执行器处理。
func QueueInnerCodeApplyBatch(db *sql.DB, ids []int64, actorUserID string) (*InnerCodeApplyBatch, error) {
ids = uniquePositiveInnerCodeIDs(ids)
if len(ids) == 0 {
return nil, fmt.Errorf("没有选择可回写记录")
}
if len(ids) > InnerCodeApplyBatchLimit {
return nil, fmt.Errorf("单次最多回写 %d 条记录", InnerCodeApplyBatchLimit)
batchID, err := newInnerCodeApplyBatchID()
if err != nil {
return nil, err
}
result := &InnerCodeApplyResult{Requested: len(ids)}
for _, id := range ids {
now := model.NowISO()
record, claimed, err := repository.ClaimInnerCodeForApply(db, id, actorUserID, now)
count, err := repository.QueueInnerCodeApplyBatch(db, ids, batchID, actorUserID, model.NowISO())
if err != nil {
if errors.Is(err, repository.ErrInnerCodeApplyConflict) {
return nil, fmt.Errorf("所选记录中有记录已不再可回写;本批没有部分入队,请刷新后重新选择")
}
return nil, err
}
return &InnerCodeApplyBatch{ID: batchID, Count: count}, nil
}
// RunInnerCodeApplyBatch 分组读取后台队列,再逐条执行原有安全回写门禁。
// 分组大小只用于控制一次数据库读取,远端请求始终逐条发送且不会自动重试。
func RunInnerCodeApplyBatch(ctx context.Context, db *sql.DB, writer InnerCodeWriter, batchID, actorUserID string) (*InnerCodeApplyResult, error) {
result := &InnerCodeApplyResult{}
for {
if err := ctx.Err(); err != nil {
return result, err
}
ids, err := repository.ListQueuedInnerCodeIDs(db, batchID, innerCodeApplyChunkSize)
if err != nil {
return nil, err
return result, err
}
if !claimed {
result.Skipped++
continue
if len(ids) == 0 {
return result, nil
}
outcome := applyClaimedInnerCode(ctx, writer, *record)
finishedAt := model.NowISO()
if err := repository.FinishInnerCodeApply(db, id, outcome.Status, compactInnerCodeMessage(outcome.Message), outcome.RemoteCode, finishedAt); err != nil {
return nil, err
}
switch outcome.Status {
case model.InnerCodeUpdated:
result.Updated++
case model.InnerCodeAlreadyFilled:
result.AlreadyFilled++
case model.InnerCodeNeedsCheck:
result.NeedsCheck++
case model.InnerCodeFailed:
result.Failed++
default:
result.Skipped++
result.Requested += len(ids)
for _, id := range ids {
if err := ctx.Err(); err != nil {
return result, err
}
now := model.NowISO()
record, claimed, err := repository.ClaimQueuedInnerCodeForApply(db, id, batchID, actorUserID, now)
if err != nil {
return result, err
}
if !claimed {
result.Skipped++
continue
}
outcome := applyClaimedInnerCode(ctx, writer, *record)
finishedAt := model.NowISO()
if err := repository.FinishInnerCodeApply(db, id, outcome.Status, compactInnerCodeMessage(outcome.Message), outcome.RemoteCode, finishedAt); err != nil {
return result, err
}
switch outcome.Status {
case model.InnerCodeUpdated:
result.Updated++
case model.InnerCodeAlreadyFilled:
result.AlreadyFilled++
case model.InnerCodeNeedsCheck:
result.NeedsCheck++
case model.InnerCodeFailed:
result.Failed++
default:
result.Skipped++
}
}
}
return result, nil
}
// InterruptInnerCodeApplyBatch 在后台异常时收敛当前批次,不自动重试任何远端请求。
func InterruptInnerCodeApplyBatch(db *sql.DB, batchID string) (int, int, error) {
return repository.InterruptInnerCodeApplyBatch(db, strings.TrimSpace(batchID), model.NowISO())
}
// GetInnerCodeApplyBatchProgress 返回页面使用的单表聚合进度。
func GetInnerCodeApplyBatchProgress(db *sql.DB, batchID string) (*repository.InnerCodeApplyBatchProgress, error) {
batchID = strings.TrimSpace(batchID)
if batchID == "" || len(batchID) > 191 {
return nil, nil
}
return repository.GetInnerCodeApplyBatchProgress(db, batchID)
}
func newInnerCodeApplyBatchID() (string, error) {
random := make([]byte, 8)
if _, err := rand.Read(random); err != nil {
return "", fmt.Errorf("生成档口入库码后台批次失败: %w", err)
}
return "ICB-" + time.Now().UTC().Format("20060102T150405") + "-" + hex.EncodeToString(random), nil
}
func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record model.InnerCodeRecord) innerCodeApplyOutcome {
+34 -4
View File
@@ -2,9 +2,12 @@ package service
import (
"context"
"database/sql"
"errors"
"testing"
_ "modernc.org/sqlite"
"cmautobuy/admin/model"
"cmautobuy/admin/syb"
)
@@ -20,13 +23,40 @@ type fakeInnerCodeWriter struct {
updatedCode string
}
func TestApplyInnerCodes_远程批量上限不随页面容量放宽(t *testing.T) {
ids := make([]int64, InnerCodeApplyBatchLimit+1)
func TestQueueInnerCodeApplyBatch_超过20条可一次排队(t *testing.T) {
if innerCodeApplyChunkSize != 20 {
t.Fatalf("后台数据库读取分组应固定为 20,实际 %d", innerCodeApplyChunkSize)
}
db, err := sql.Open("sqlite", "file:inner_code_queue_over_20?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,apply_batch_id TEXT,apply_queued_at TEXT,
applied_by_user_id TEXT,result_message TEXT,apply_started_at TEXT,updated_at TEXT,
deleted_at TEXT
)`); err != nil {
t.Fatal(err)
}
ids := make([]int64, 45)
for i := range ids {
ids[i] = int64(i + 1)
if _, err := db.Exec(`INSERT INTO syb_inner_code_records(id,status,updated_at) VALUES(?,'ready','old')`, ids[i]); err != nil {
t.Fatal(err)
}
}
if _, err := ApplyInnerCodes(context.Background(), nil, nil, ids, "user-1"); err == nil {
t.Fatal("超过 20 条时应该在访问数据库或顺运宝前拒绝")
batch, err := QueueInnerCodeApplyBatch(db, ids, "user-1")
if err != nil || batch.Count != 45 || batch.ID == "" {
t.Fatalf("batch=%+v err=%v", batch, err)
}
var queued, batches int
if err := db.QueryRow(`SELECT COUNT(*),COUNT(DISTINCT apply_batch_id)
FROM syb_inner_code_records WHERE status='queued'`).Scan(&queued, &batches); err != nil {
t.Fatal(err)
}
if queued != 45 || batches != 1 {
t.Fatalf("queued=%d batches=%d", queued, batches)
}
}
+1 -1
View File
@@ -198,7 +198,7 @@ func innerCodeReservedDetails(records []model.InnerCodeRecord, selected map[int6
func innerCodeStatusReservesDetail(status model.InnerCodeStatus) bool {
switch status {
case model.InnerCodeReady, model.InnerCodeApplying, model.InnerCodeUpdated,
case model.InnerCodeReady, model.InnerCodeQueued, model.InnerCodeApplying, model.InnerCodeUpdated,
model.InnerCodeAlreadyFilled, model.InnerCodeNeedsCheck:
return true
default:
+7 -8
View File
@@ -19,6 +19,7 @@ var innerCodeStatusOptions = []InnerCodeStatusOption{
{"", "全部状态"},
{string(model.InnerCodePending), "待匹配"},
{string(model.InnerCodeReady), "可回写"},
{string(model.InnerCodeQueued), "排队中"},
{string(model.InnerCodeApplying), "回写中"},
{string(model.InnerCodeUpdated), "已回写"},
{string(model.InnerCodeAlreadyFilled), "已存在"},
@@ -27,12 +28,8 @@ var innerCodeStatusOptions = []InnerCodeStatusOption{
{string(model.InnerCodeNeedsCheck), "需核对"},
}
// 删除只改本地数据库,可覆盖最大单页;回写会修改顺运宝,继续限制 20 条。
// 只读匹配不使用这个写操作上限,见 inner_code_match.go 的远端读取分批。
const (
InnerCodeDeleteBatchLimit = 100
InnerCodeApplyBatchLimit = 20
)
// 删除只改本地数据库,可覆盖最大单页。回写已改为后台队列,不再限制选择 20 条。
const InnerCodeDeleteBatchLimit = 100
// InnerCodeRowView 是正式页面的一行。
type InnerCodeRowView struct {
@@ -123,6 +120,7 @@ func validInnerCodeStatusFilter(status string) bool {
func innerCodeRowView(record model.InnerCodeRecord) InnerCodeRowView {
statusText := map[model.InnerCodeStatus]string{
model.InnerCodePending: "待匹配", model.InnerCodeReady: "可回写",
model.InnerCodeQueued: "排队中",
model.InnerCodeApplying: "回写中", model.InnerCodeUpdated: "已回写",
model.InnerCodeAlreadyFilled: "已存在", model.InnerCodeSkipped: "已跳过",
model.InnerCodeFailed: "失败", model.InnerCodeNeedsCheck: "需核对",
@@ -158,6 +156,7 @@ func displayInnerCodeValue(value string) string {
// InnerCodeStatusMessage 生成底栏可读统计。
func InnerCodeStatusMessage(page *InnerCodeListPage) string {
return fmt.Sprintf("共 %d 条,可回写 %d 条,需核对 %d 条;当前筛选 %d 条",
page.Counts.Total, page.Counts.Ready, page.Counts.NeedsCheck, page.Total)
return fmt.Sprintf("共 %d 条,可回写 %d 条,排队中 %d 条,回写中 %d 条,需核对 %d 条;当前筛选 %d 条",
page.Counts.Total, page.Counts.Ready, page.Counts.Queued, page.Counts.Applying,
page.Counts.NeedsCheck, page.Total)
}
+2 -2
View File
@@ -8,7 +8,7 @@ import (
func TestInnerCodeRowView_按状态区分匹配与回写能力(t *testing.T) {
for _, status := range []model.InnerCodeStatus{
model.InnerCodePending, model.InnerCodeReady, model.InnerCodeApplying,
model.InnerCodePending, model.InnerCodeReady, model.InnerCodeQueued, model.InnerCodeApplying,
model.InnerCodeUpdated, model.InnerCodeAlreadyFilled, model.InnerCodeSkipped,
model.InnerCodeFailed, model.InnerCodeNeedsCheck,
} {
@@ -29,7 +29,7 @@ func TestInnerCodeRowView_按状态区分匹配与回写能力(t *testing.T) {
func TestInnerCodeStatusOptions_覆盖所有状态(t *testing.T) {
options := InnerCodeStatusOptions()
if len(options) != 9 || options[0].Value != "" {
if len(options) != 10 || options[0].Value != "" {
t.Fatalf("状态筛选项不完整: %+v", options)
}
}
+12
View File
@@ -618,6 +618,7 @@ input.wide { width: 100%; }
.inner-code-status-ready,
.inner-code-status-updated,
.inner-code-status-already_filled { color: #1a7f37; background: #f0fff4; }
.inner-code-status-queued,
.inner-code-status-applying { color: #0969da; background: #eef6ff; }
.inner-code-status-skipped,
.inner-code-status-needs_check { color: #7a4b00; background: #fff8c5; }
@@ -653,6 +654,17 @@ input.wide { width: 100%; }
background: #f0fff4;
box-shadow: 0 4px 16px rgba(0, 0, 0, .16);
}
.inner-code-batch-progress {
flex: 0 0 auto;
margin: 0 0 8px;
padding: 10px 12px;
border: 1px solid #b6d4fe;
border-radius: 4px;
background: #f4f8ff;
}
.inner-code-batch-progress-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.inner-code-batch-progress progress { width: 100%; height: 14px; margin: 8px 0 4px; }
.inner-code-batch-progress p { margin: 0; color: #3d4852; }
.inner-code-table-wrap { flex: 1 1 auto; min-height: 180px; overflow: auto; scrollbar-gutter: stable; }
.inner-code-table { min-width: 1500px; }
.inner-code-table th { position: sticky; top: 0; z-index: 1; }
+2 -2
View File
@@ -23,7 +23,7 @@
"/shops": [],
"/tasks": ["type", "status", "creator", "q", "page", "page_size"],
"/clients": ["name", "page", "page_size"],
"/inner-codes": ["date", "status", "q", "page", "page_size"],
"/inner-codes": ["date", "status", "q", "page", "page_size", "apply_batch_id"],
"/users": ["q", "status", "page", "page_size"]
};
var MODULE_STATE_PREFIX = "cmautobuy:module-state:";
@@ -488,7 +488,7 @@
if (button) {
button.disabled = true;
button.setAttribute("aria-busy", "true");
button.textContent = "回写中…";
button.textContent = "提交中…";
}
});
var deleteForm = document.getElementById("inner-code-delete-form");
+15 -1
View File
@@ -91,6 +91,19 @@
</div>
{{end}}
{{with .ApplyProgress}}
<section class="inner-code-batch-progress" role="status" aria-live="polite" aria-labelledby="inner-code-batch-title">
<div class="inner-code-batch-progress-head">
<strong id="inner-code-batch-title">后台回写进度</strong>
<a class="button-link" href="{{$.ApplyProgressURL}}">刷新进度</a>
</div>
<progress max="{{.Total}}" value="{{.Processed}}">{{.Processed}} / {{.Total}}</progress>
<p>共 {{.Total}} 条,已处理 {{.Processed}} 条;排队中 {{.Queued}} 条,回写中 {{.Applying}} 条,
成功 {{.Updated}} 条,已存在 {{.AlreadyFilled}} 条,跳过 {{.Skipped}} 条,失败 {{.Failed}} 条,需核对 {{.NeedsCheck}} 条。
{{if gt .Ready 0}}另有 {{.Ready}} 条未发送远端请求,已恢复为可回写。{{end}}</p>
</section>
{{end}}
<div class="table-wrap inner-code-table-wrap" role="region" aria-label="档口入库码记录列表" tabindex="0">
<table class="inner-code-table">
<thead>
@@ -170,12 +183,13 @@
<ul>
<li>每条写入前会重新读取顺运宝详情并核验商品。</li>
<li>写入后会再次读取;只有远端值一致才标记“已回写”。</li>
<li>提交后页面立即返回;后台每次读取最多 20 条,但仍会逐条安全回写。</li>
<li class="missing">超时或结果不确定时只标记“需核对”,不会自动重复写入。</li>
</ul>
</div>
<div class="modal-foot">
<button type="button" data-modal-close>取消</button>
<button type="submit" class="primary" form="inner-code-apply-form">确认回写</button>
<button type="submit" class="primary" form="inner-code-apply-form">开始后台回写</button>
</div>
</section>
</div>
+10 -5
View File
@@ -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,软删除 v24)
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23,软删除 v24,后台回写 v25)
档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统
不保存原文件、文件哈希,不再拆批次表或尝试记录表。
@@ -1036,14 +1036,19 @@ CREATE UNIQUE INDEX idx_client_assignment_current
`source_duplicate_count` 保存合并前的 Excel 源行数,只用于展示和审计,不阻止确定性匹配。
- `stock_id/detail_id` 和 `syb_*`、`purchase_*`、`remote_inner_code` 保存最近一次远端
规划快照,不替代 `syb_orders`,也不修改采购数据。
- 状态固定为 `pending/ready/applying/updated/already_filled/skipped/failed/needs_check`。
重复导入可以重置尚未完成的记录,但不得覆盖 `updated`、`already_filled`、`applying`
或 `needs_check` 的远端结果和审计状态。
- 状态固定为 `pending/ready/queued/applying/updated/already_filled/skipped/failed/needs_check`。
`queued` 只表示尚未发送远端请求,`applying` 才表示已经进入逐条远端处理。重复导入可以
重置尚未完成的记录,但不得覆盖 `queued`、`updated`、`already_filled`、`applying` 或
`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` 保留状态和远端
`pending` 并清空旧规划;`queued/applying/updated/already_filled/needs_check` 保留状态和远端
审计。后一组状态若导入的 `inner_code` 已变化则拒绝恢复,交由人工核对。
- v25 增加 `apply_batch_id`、`apply_queued_at` 和批次状态索引。批次仍记录在同一业务表,
不新增批次表:提交时所选 `ready` 记录必须在同一事务内全部转为 `queued`;后台每次最多
读取 20 条并逐条领取为 `applying`。页面按 `apply_batch_id` 聚合当前批次进度。Admin
重启时,`queued` 恢复为 `ready` 且不会自动写远端,`applying` 转为 `needs_check`。
+5 -1
View File
@@ -137,7 +137,11 @@
避免操作员看不到将被处理的记录。
- `[必须]` 每页条数只控制读取和展示,不能充当外部写操作的安全上限。档口入库码
只读匹配允许处理当前页全部选中记录,内部每次最多读取 100 个顺运宝货运单;
回写会修改远端数据,继续使用独立的单次上限 20。
回写允许提交当前页全部选中记录,页面立即返回。后台每次最多从数据库读取 20 条,
但远端仍逐条执行写前核验、最多一次写入和写后复核。
- `[必须]` 档口入库码后台回写进度使用文字和原生 `<progress>` 同时表达,并提供普通
GET 刷新链接;不只依赖颜色,也不使用脚本自动轮询。页面至少显示排队中、回写中、
成功、失败和需核对数量。
- `[建议]` 不做 `1 2 3 … N` 这种页码列表。页数一多列出来没有意义,
操作员应该靠筛选定位,不是靠翻页数页码。
+3
View File
@@ -426,6 +426,9 @@ GET /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code=
- 每条写入前重新调用 `listByStock`,核对 stock/detail、规格、采购平台、采购单号和规划时
的旧值;写入后再次读取,只有 `innerExpCode` 与目标一致才标为已回写。
- “重新核对”只调用 `listByStock`,绝不能再次调用上述两个写接口。
- 页面提交数量不限制为 20 条。所选记录先在 MySQL 同一业务表中进入 `queued`,HTTP 请求
随即返回;后台每次最多读取 20 条,再逐条执行上述远端门禁。Admin 重启不会自动继续
未开始的真实远端写入:`queued` 恢复为可回写,`applying` 转为需核对。
---