feat: 档口入库码后台批量回写 (#246)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 把五个模块的页面路由挂上去。
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -137,7 +137,11 @@
|
||||
避免操作员看不到将被处理的记录。
|
||||
- `[必须]` 每页条数只控制读取和展示,不能充当外部写操作的安全上限。档口入库码
|
||||
只读匹配允许处理当前页全部选中记录,内部每次最多读取 100 个顺运宝货运单;
|
||||
回写会修改远端数据,继续使用独立的单次上限 20。
|
||||
回写允许提交当前页全部选中记录,页面立即返回。后台每次最多从数据库读取 20 条,
|
||||
但远端仍逐条执行写前核验、最多一次写入和写后复核。
|
||||
- `[必须]` 档口入库码后台回写进度使用文字和原生 `<progress>` 同时表达,并提供普通
|
||||
GET 刷新链接;不只依赖颜色,也不使用脚本自动轮询。页面至少显示排队中、回写中、
|
||||
成功、失败和需核对数量。
|
||||
- `[建议]` 不做 `1 2 3 … N` 这种页码列表。页数一多列出来没有意义,
|
||||
操作员应该靠筛选定位,不是靠翻页数页码。
|
||||
|
||||
|
||||
@@ -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` 转为需核对。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user