diff --git a/admin/handler/web/inner_code.go b/admin/handler/web/inner_code.go index 75b8d42..979d558 100644 --- a/admin/handler/web/inner_code.go +++ b/admin/handler/web/inner_code.go @@ -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 +} diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 08103e5..b1c54c1 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -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 把五个模块的页面路由挂上去。 diff --git a/admin/inner_code_template_test.go b/admin/inner_code_template_test.go index a65a702..56869ea 100644 --- a/admin/inner_code_template_test.go +++ b/admin/inner_code_template_test.go @@ -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"`, + ``, + `href="{{$.ApplyProgressURL}}">刷新进度`, + `开始后台回写`, + } { + 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 { diff --git a/admin/main.go b/admin/main.go index e9d2c0f..04aac4c 100644 --- a/admin/main.go +++ b/admin/main.go @@ -83,7 +83,7 @@ func main() { log.Fatalf("恢复中断的档口入库码回写失败: %v", err) } if innerCodeInterrupted > 0 { - log.Printf("已把 %d 条上次进程遗留的档口入库码回写标记为需核对", innerCodeInterrupted) + log.Printf("已安全收敛 %d 条上次进程遗留的档口入库码后台记录", innerCodeInterrupted) } log.Printf("数据库已就绪") diff --git a/admin/main_test.go b/admin/main_test.go index 1348001..d2a9952 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -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", diff --git a/admin/model/inner_code.go b/admin/model/inner_code.go index 7b3616d..7513139 100644 --- a/admin/model/inner_code.go +++ b/admin/model/inner_code.go @@ -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 diff --git a/admin/repository/inner_code.go b/admin/repository/inner_code.go index 931b5fc..6371cb4 100644 --- a/admin/repository/inner_code.go +++ b/admin/repository/inner_code.go @@ -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 { diff --git a/admin/repository/inner_code_test.go b/admin/repository/inner_code_test.go index 6700c59..7030b8d 100644 --- a/admin/repository/inner_code_test.go +++ b/admin/repository/inner_code_test.go @@ -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) } diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index 17c6757..968b4a3 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -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 { diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go index 3ae58ea..2dbc3f9 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -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() diff --git a/admin/service/inner_code_apply.go b/admin/service/inner_code_apply.go index b34bd81..861f265 100644 --- a/admin/service/inner_code_apply.go +++ b/admin/service/inner_code_apply.go @@ -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 { diff --git a/admin/service/inner_code_apply_test.go b/admin/service/inner_code_apply_test.go index dc22bff..5e0a19e 100644 --- a/admin/service/inner_code_apply_test.go +++ b/admin/service/inner_code_apply_test.go @@ -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) } } diff --git a/admin/service/inner_code_match.go b/admin/service/inner_code_match.go index c82480e..81200da 100644 --- a/admin/service/inner_code_match.go +++ b/admin/service/inner_code_match.go @@ -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: diff --git a/admin/service/inner_code_page.go b/admin/service/inner_code_page.go index 4f201f6..a8041db 100644 --- a/admin/service/inner_code_page.go +++ b/admin/service/inner_code_page.go @@ -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) } diff --git a/admin/service/inner_code_page_test.go b/admin/service/inner_code_page_test.go index 691ab86..54594df 100644 --- a/admin/service/inner_code_page_test.go +++ b/admin/service/inner_code_page_test.go @@ -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) } } diff --git a/admin/static/css/app.css b/admin/static/css/app.css index 36adc6b..8eea464 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -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; } diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 6ac1fc9..a654192 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -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"); diff --git a/admin/templates/inner_code/list.html b/admin/templates/inner_code/list.html index aa89f84..73f08c8 100644 --- a/admin/templates/inner_code/list.html +++ b/admin/templates/inner_code/list.html @@ -91,6 +91,19 @@ {{end}} +{{with .ApplyProgress}} +
+
+ 后台回写进度 + 刷新进度 +
+ {{.Processed}} / {{.Total}} +

共 {{.Total}} 条,已处理 {{.Processed}} 条;排队中 {{.Queued}} 条,回写中 {{.Applying}} 条, + 成功 {{.Updated}} 条,已存在 {{.AlreadyFilled}} 条,跳过 {{.Skipped}} 条,失败 {{.Failed}} 条,需核对 {{.NeedsCheck}} 条。 + {{if gt .Ready 0}}另有 {{.Ready}} 条未发送远端请求,已恢复为可回写。{{end}}

+
+{{end}} +
@@ -170,12 +183,13 @@ diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index d417d8f..94de907 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -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`。 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index f3a6a06..8a58f75 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -137,7 +137,11 @@ 避免操作员看不到将被处理的记录。 - `[必须]` 每页条数只控制读取和展示,不能充当外部写操作的安全上限。档口入库码 只读匹配允许处理当前页全部选中记录,内部每次最多读取 100 个顺运宝货运单; - 回写会修改远端数据,继续使用独立的单次上限 20。 + 回写允许提交当前页全部选中记录,页面立即返回。后台每次最多从数据库读取 20 条, + 但远端仍逐条执行写前核验、最多一次写入和写后复核。 +- `[必须]` 档口入库码后台回写进度使用文字和原生 `` 同时表达,并提供普通 + GET 刷新链接;不只依赖颜色,也不使用脚本自动轮询。页面至少显示排队中、回写中、 + 成功、失败和需核对数量。 - `[建议]` 不做 `1 2 3 … N` 这种页码列表。页数一多列出来没有意义, 操作员应该靠筛选定位,不是靠翻页数页码。 diff --git a/docs/admin/08-顺运宝接口.md b/docs/admin/08-顺运宝接口.md index 03bfd56..4d6eda0 100644 --- a/docs/admin/08-顺运宝接口.md +++ b/docs/admin/08-顺运宝接口.md @@ -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` 转为需核对。 ---