fix: recover inner-code matching and enable batch rematch #350
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type MatchReader interface {
|
||||
@@ -45,12 +46,19 @@ func (m Matcher) runBackground(db *gorm.DB, jobID string) {
|
||||
err = RunMatchJob(ctx, db, reader, jobID)
|
||||
}
|
||||
if err != nil {
|
||||
now := time.Now().UTC()
|
||||
_ = db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status IN ?", jobID, []string{"pending", "running"}).Updates(map[string]any{"status": "failed", "error_message": compact(err.Error(), 1000), "finished_at": now}).Error
|
||||
_ = failMatchJob(db, jobID, "匹配任务中断,请重新匹配")
|
||||
}
|
||||
}
|
||||
|
||||
func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID string) error {
|
||||
func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID string) (runErr error) {
|
||||
claimedJob := false
|
||||
defer func() {
|
||||
if runErr != nil && claimedJob {
|
||||
if err := failMatchJob(db, jobID, "匹配任务中断,请重新匹配"); err != nil {
|
||||
runErr = errors.Join(runErr, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
now := time.Now().UTC()
|
||||
claimed := db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status = ?", jobID, "pending").Updates(map[string]any{"status": "running", "started_at": now})
|
||||
if claimed.Error != nil {
|
||||
@@ -66,6 +74,7 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
|
||||
}
|
||||
return conflict("匹配任务状态不允许执行")
|
||||
}
|
||||
claimedJob = true
|
||||
var job models.SYBInnerCodeMatchJob
|
||||
if err := db.WithContext(ctx).First(&job, "id = ?", jobID).Error; err != nil {
|
||||
return err
|
||||
@@ -75,7 +84,7 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
|
||||
return fmt.Errorf("匹配任务记录范围无效")
|
||||
}
|
||||
var records []models.SYBInnerCodeRecord
|
||||
if err := db.WithContext(ctx).Preload("Items", func(q *gorm.DB) *gorm.DB { return q.Order("ordinal ASC") }).Where("id IN ? AND business_date = ? AND status IN ?", recordIDs, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}).Order("source_row,id").Find(&records).Error; err != nil {
|
||||
if err := db.WithContext(ctx).Preload("Items", func(q *gorm.DB) *gorm.DB { return q.Order("ordinal ASC") }).Where("id IN ? AND business_date = ? AND status IN ?", recordIDs, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeMatching, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}).Order("source_row,id").Find(&records).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
used, err := loadReservedDetails(ctx, db, records)
|
||||
@@ -84,7 +93,10 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
|
||||
}
|
||||
ready, failed := 0, 0
|
||||
for _, record := range records {
|
||||
plan, status, message, planErr := planRecord(ctx, reader, record, used)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
plan, status, message, planErr := safePlanRecord(ctx, reader, record, used)
|
||||
if planErr != nil {
|
||||
status = models.SYBInnerCodeFailed
|
||||
message = "读取 SYB 失败:" + compact(planErr.Error(), 900)
|
||||
@@ -111,12 +123,62 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", jobID).Updates(map[string]any{"processed": gorm.Expr("processed + 1"), "ready": ready, "failed": failed})
|
||||
if err := db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", jobID).Updates(map[string]any{"processed": gorm.Expr("processed + 1"), "ready": ready, "failed": failed}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
finished := time.Now().UTC()
|
||||
return db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status = ?", jobID, "running").Updates(map[string]any{"status": "succeeded", "finished_at": finished, "ready": ready, "failed": failed}).Error
|
||||
}
|
||||
|
||||
// Only the read/plan step is isolated: no remote write is retried here.
|
||||
func safePlanRecord(ctx context.Context, reader MatchReader, record models.SYBInnerCodeRecord, used map[int64]bool) (plan *models.SYBInnerCodePlan, status, message string, err error) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
plan, status, message = nil, models.SYBInnerCodeFailed, "匹配处理异常,请重新匹配"
|
||||
err = nil
|
||||
}
|
||||
}()
|
||||
return planRecord(ctx, reader, record, used)
|
||||
}
|
||||
|
||||
// Cleanup must not inherit an expired job or HTTP request context.
|
||||
func failMatchJob(db *gorm.DB, jobID, message string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var job models.SYBInnerCodeMatchJob
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&job, "id = ?", jobID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if job.Status != "pending" && job.Status != "running" {
|
||||
return nil
|
||||
}
|
||||
var ids []uint64
|
||||
if err := json.Unmarshal([]byte(job.RecordIDsJSON), &ids); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ? AND business_date = ? AND status IN ?", ids, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeMatching}).Updates(map[string]any{"status": models.SYBInnerCodeFailed, "result_message": message}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&job).Updates(map[string]any{"status": "failed", "error_message": message, "finished_at": time.Now().UTC()}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Called at startup before new jobs can be submitted; never resumes remote writes.
|
||||
func RecoverInterruptedMatches(db *gorm.DB) error {
|
||||
var jobs []models.SYBInnerCodeMatchJob
|
||||
if err := db.Where("status IN ?", []string{"pending", "running"}).Find(&jobs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, job := range jobs {
|
||||
if err := failMatchJob(db, job.ID, "服务重启,匹配任务中断,请重新匹配"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadReservedDetails(ctx context.Context, db *gorm.DB, selected []models.SYBInnerCodeRecord) (map[int64]bool, error) {
|
||||
selectedIDs := map[uint64]bool{}
|
||||
for _, r := range selected {
|
||||
@@ -186,6 +248,9 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
|
||||
if reason != "" {
|
||||
return nil, models.SYBInnerCodeSkipped, reason, nil
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return nil, models.SYBInnerCodeSkipped, "相同规格候选的原始 SKU 或档口货号未匹配", nil
|
||||
}
|
||||
count := len(record.Items)
|
||||
if count == 0 {
|
||||
return nil, models.SYBInnerCodeSkipped, "记录没有入库码", nil
|
||||
@@ -213,6 +278,9 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
|
||||
} else {
|
||||
return nil, models.SYBInnerCodeSkipped, fmt.Sprintf("SYB 商品数量与入库码数量不一致(%d/%d)", matches[0].ProductQty, count), nil
|
||||
}
|
||||
if len(chosen) == 0 {
|
||||
return nil, models.SYBInnerCodeSkipped, "未形成唯一的商品分配,不能自动选择", nil
|
||||
}
|
||||
primary := chosen[0]
|
||||
items := make([]plannedRemoteItem, 0, count)
|
||||
placeholder := 0
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package sybinnercode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
)
|
||||
|
||||
type interruptingReader struct {
|
||||
*fakeMatchReader
|
||||
panicOrder string
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (r interruptingReader) ListByOrderNumber(ctx context.Context, order string) ([]sybclient.StockRow, error) {
|
||||
if order == r.panicOrder {
|
||||
panic("must not escape or be persisted")
|
||||
}
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return r.fakeMatchReader.ListByOrderNumber(ctx, order)
|
||||
}
|
||||
|
||||
func TestEmptyEvidenceDoesNotPanicAndNextRecordContinues(t *testing.T) {
|
||||
db := testDB(t)
|
||||
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("BAD", "NO-SKU", "NO#9", "IC-1"), matchRecord("GOOD", "SKU-A", "A#1", "IC-2")})
|
||||
r := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"BAD": {{ID: 10}}, "GOOD": {{ID: 11}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Details: []sybclient.DetailItem{detail(20, "黑色,L", 1, "SKU-A", "A#1", "")}}, 11: {ID: 11, Details: []sybclient.DetailItem{detail(21, "黑色,L", 1, "SKU-A", "A#1", "")}}}}
|
||||
if err := RunMatchJob(context.Background(), db, r, job); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Order("id").Find(&rows)
|
||||
if rows[0].Status != models.SYBInnerCodeSkipped || rows[1].Status != models.SYBInnerCodeReady {
|
||||
t.Fatal("empty evidence must be limited, next row ready")
|
||||
}
|
||||
var plans int64
|
||||
db.Model(&models.SYBInnerCodePlan{}).Count(&plans)
|
||||
if plans != 1 {
|
||||
t.Fatalf("unexpected plans=%d", plans)
|
||||
}
|
||||
var j models.SYBInnerCodeMatchJob
|
||||
db.First(&j, "id = ?", job)
|
||||
if j.Processed != 2 || j.Ready != 1 || j.Failed != 1 || j.Status != "succeeded" {
|
||||
t.Fatalf("counts=%+v", j)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleRecordPanicIsIsolated(t *testing.T) {
|
||||
db := testDB(t)
|
||||
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("PANIC", "S", "A", "IC-1"), matchRecord("NEXT", "S", "A", "IC-2")})
|
||||
r := interruptingReader{fakeMatchReader: &fakeMatchReader{rows: map[string][]sybclient.StockRow{}}, panicOrder: "PANIC"}
|
||||
if err := RunMatchJob(context.Background(), db, r, job); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Order("id").Find(&rows)
|
||||
if rows[0].Status != models.SYBInnerCodeFailed || rows[0].ResultMessage != "匹配处理异常,请重新匹配" || rows[1].Status != models.SYBInnerCodeFailed {
|
||||
t.Fatal("panic was not safely persisted or next row not processed")
|
||||
}
|
||||
var j models.SYBInnerCodeMatchJob
|
||||
db.First(&j, "id = ?", job)
|
||||
if j.Processed != 2 {
|
||||
t.Fatal("remaining row was not processed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelledMatchPersistsFailureWithIndependentContext(t *testing.T) {
|
||||
db := testDB(t)
|
||||
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "IC-1"), matchRecord("B", "S", "B", "IC-2")})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
err := RunMatchJob(ctx, db, interruptingReader{fakeMatchReader: &fakeMatchReader{}, cancel: cancel}, job)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Find(&rows)
|
||||
for _, r := range rows {
|
||||
if r.Status != models.SYBInnerCodeFailed {
|
||||
t.Fatal("cancel left pending row")
|
||||
}
|
||||
}
|
||||
var j models.SYBInnerCodeMatchJob
|
||||
db.First(&j, "id = ?", job)
|
||||
if j.Status != "failed" || j.FinishedAt == nil {
|
||||
t.Fatal("cancel left running job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartRecoveryPreservesSuccessfulRecordsAndScope(t *testing.T) {
|
||||
db := testDB(t)
|
||||
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "IC-1"), matchRecord("B", "S", "B", "IC-2")})
|
||||
db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", job).Update("status", "running")
|
||||
db.Model(&models.SYBInnerCodeRecord{}).Where("order_number = ?", "A").Update("status", models.SYBInnerCodeUpdated)
|
||||
outside := matchRecord("OUTSIDE", "S", "C", "IC-3")
|
||||
db.Create(&outside)
|
||||
if err := RecoverInterruptedMatches(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RecoverInterruptedMatches(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Order("id").Find(&rows)
|
||||
if rows[0].Status != models.SYBInnerCodeUpdated || rows[1].Status != models.SYBInnerCodeFailed || rows[2].Status != models.SYBInnerCodePending {
|
||||
t.Fatal("recovery altered success or outside scope")
|
||||
}
|
||||
var j models.SYBInnerCodeMatchJob
|
||||
db.First(&j, "id = ?", job)
|
||||
if j.Status != "failed" || j.FinishedAt == nil {
|
||||
t.Fatal("recovery left running job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchRematchClaimsPendingFailedSkippedAndIsIdempotent(t *testing.T) {
|
||||
db := testDB(t)
|
||||
statuses := []string{models.SYBInnerCodePending, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}
|
||||
ids := []uint64{}
|
||||
for i, status := range statuses {
|
||||
r := matchRecord(string(rune('A'+i)), "S", "A", string(rune('X'+i)))
|
||||
r.Status = status
|
||||
if err := db.Create(&r).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
s := NewService(db)
|
||||
req := RematchRequest{RequestID: uuid.NewString(), IDs: ids}
|
||||
result, err := s.QueueRematch(context.Background(), req)
|
||||
if err != nil || result.Queued != 3 {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
replay, err := s.QueueRematch(context.Background(), req)
|
||||
if err != nil || replay.MatchJobID != result.MatchJobID {
|
||||
t.Fatal("idempotent replay failed")
|
||||
}
|
||||
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: ids}); err == nil {
|
||||
t.Fatal("overlapping job accepted")
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Find(&rows)
|
||||
for _, r := range rows {
|
||||
if r.Status != models.SYBInnerCodeMatching {
|
||||
t.Fatal("not claimed")
|
||||
}
|
||||
}
|
||||
if _, err := s.Delete(context.Background(), 1, DeleteRequest{RequestID: uuid.NewString(), IDs: ids}); err == nil {
|
||||
t.Fatal("matching records can be deleted during execution")
|
||||
}
|
||||
if err := RunMatchJob(context.Background(), db, &fakeMatchReader{rows: map[string][]sybclient.StockRow{}}, result.MatchJobID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var job models.SYBInnerCodeMatchJob
|
||||
db.First(&job, "id = ?", result.MatchJobID)
|
||||
if job.Processed != 3 || job.Status != "succeeded" {
|
||||
t.Fatal("claimed matching records were not executed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRematchRejectsPendingOwnedByImportAndProtectedStates(t *testing.T) {
|
||||
db := testDB(t)
|
||||
createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "X")})
|
||||
var r models.SYBInnerCodeRecord
|
||||
db.First(&r)
|
||||
s := NewService(db)
|
||||
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: []uint64{r.ID}}); err == nil {
|
||||
t.Fatal("active import overlapped")
|
||||
}
|
||||
for _, status := range []string{models.SYBInnerCodeReady, models.SYBInnerCodeUpdated, models.SYBInnerCodeAlreadyFilled, models.SYBInnerCodeQueued, models.SYBInnerCodeApplying, models.SYBInnerCodeNeedsCheck} {
|
||||
db.Model(&r).Update("status", status)
|
||||
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: []uint64{r.ID}}); err == nil {
|
||||
t.Fatalf("protected status accepted: %s", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,8 +200,8 @@ func (s *Service) QueueRematch(ctx context.Context, request RematchRequest) (Rem
|
||||
return conflict("部分记录不存在")
|
||||
}
|
||||
for _, record := range records {
|
||||
if record.Status != models.SYBInnerCodeFailed && record.Status != models.SYBInnerCodeSkipped {
|
||||
return conflict("只有读取失败或匹配受限记录可以重新匹配")
|
||||
if record.Status != models.SYBInnerCodePending && record.Status != models.SYBInnerCodeFailed && record.Status != models.SYBInnerCodeSkipped {
|
||||
return conflict("只有待匹配、读取失败或匹配受限记录可以重新匹配")
|
||||
}
|
||||
if date == "" {
|
||||
date = record.BusinessDate
|
||||
@@ -209,10 +209,29 @@ func (s *Service) QueueRematch(ctx context.Context, request RematchRequest) (Rem
|
||||
return conflict("重新匹配记录必须属于同一营业日期")
|
||||
}
|
||||
}
|
||||
var activeJobs []models.SYBInnerCodeMatchJob
|
||||
if err := tx.Where("business_date = ? AND status IN ?", date, []string{"pending", "running"}).Find(&activeJobs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
selected := make(map[uint64]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
selected[id] = true
|
||||
}
|
||||
for _, job := range activeJobs {
|
||||
var jobIDs []uint64
|
||||
if err := json.Unmarshal([]byte(job.RecordIDsJSON), &jobIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range jobIDs {
|
||||
if selected[id] {
|
||||
return conflict("选中记录已有匹配任务,请等待任务结束")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Where("record_id IN ?", ids).Delete(&models.SYBInnerCodePlan{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ?", ids).Updates(map[string]any{"status": models.SYBInnerCodePending, "result_message": "等待重新匹配"}).Error; err != nil {
|
||||
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ?", ids).Updates(map[string]any{"status": models.SYBInnerCodeMatching, "result_message": "等待重新匹配"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
recordIDsJSON, _ := json.Marshal(ids)
|
||||
@@ -267,13 +286,13 @@ func (s *Service) Delete(ctx context.Context, actor uint64, request DeleteReques
|
||||
return conflict("部分记录不存在,未删除任何数据")
|
||||
}
|
||||
for _, record := range records {
|
||||
if record.Status == models.SYBInnerCodeQueued || record.Status == models.SYBInnerCodeApplying || record.Status == models.SYBInnerCodeNeedsCheck {
|
||||
if record.Status == models.SYBInnerCodeMatching || record.Status == models.SYBInnerCodeQueued || record.Status == models.SYBInnerCodeApplying || record.Status == models.SYBInnerCodeNeedsCheck {
|
||||
result.Blocked = append(result.Blocked, BlockedRecord{ID: record.ID, Status: record.Status})
|
||||
}
|
||||
}
|
||||
if len(result.Blocked) > 0 {
|
||||
sort.Slice(result.Blocked, func(i, j int) bool { return result.Blocked[i].ID < result.Blocked[j].ID })
|
||||
return &ServiceError{Code: CodeConflict, Message: "选中记录包含排队中、回写中或需复核状态,未删除任何数据", Details: map[string]any{"blocked": result.Blocked}}
|
||||
return &ServiceError{Code: CodeConflict, Message: "选中记录包含匹配中、排队中、回写中或需复核状态,未删除任何数据", Details: map[string]any{"blocked": result.Blocked}}
|
||||
}
|
||||
// The state gate above is the dynamic restriction for active writeback
|
||||
// evidence. Terminal evidence belongs to imported data and is physically
|
||||
|
||||
@@ -117,6 +117,9 @@ func run() error {
|
||||
if err := goautosybinnercode.RecoverInterrupted(db); err != nil {
|
||||
return fmt.Errorf("recover interrupted SYB inner-code writes: %w", err)
|
||||
}
|
||||
if err := goautosybinnercode.RecoverInterruptedMatches(db); err != nil {
|
||||
return fmt.Errorf("recover interrupted SYB inner-code matches: %w", err)
|
||||
}
|
||||
goautoreplacement.RecoverMatching(db)
|
||||
goautopurchase.RecoverPurchaseMatching(db)
|
||||
goautopurchase.RecoverOrderWritebacks(db)
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
<div class="page-heading"><div><h1>档口入库码</h1><p>导入 Excel 后自动匹配 SYB 商品;确认后逐件回写,结果不明确时只读复核。</p></div></div>
|
||||
<div class="toolbar" role="search" aria-label="档口入库码筛选与批量操作">
|
||||
<el-upload ref="upload" :auto-upload="false" :limit="1" accept=".xlsx" :show-file-list="false" :on-change="selectFile" :on-exceed="replaceFile"><el-button :icon="FolderOpened">选择 Excel</el-button></el-upload>
|
||||
<el-button type="primary" :icon="Upload" :loading="importing" :disabled="!selectedFile || importing" @click="submitImport">导入</el-button>
|
||||
<el-button type="primary" :icon="Upload" :loading="importing" :disabled="!selectedFile || importing || matchingBusy" @click="submitImport">导入</el-button>
|
||||
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" class="date-range" />
|
||||
<el-input v-model="query.keyword" clearable placeholder="订单号或入库码" class="keyword" @keyup.enter="search" />
|
||||
<el-button :icon="RefreshLeft" @click="clearFilters">清除</el-button>
|
||||
<el-button type="primary" :icon="Search" @click="search">搜索</el-button>
|
||||
<span class="toolbar-spacer" />
|
||||
<el-button :loading="matchSubmitting" :disabled="!matchEligibleCount || loading || !!loadError || importing || busy || matchingBusy || !!rowActionId" @click="matchSelected">匹配 {{ matchEligibleCount }}</el-button>
|
||||
<el-button type="primary" :disabled="selected.length === 0 || busy" @click="openApply">回写({{ applyEligibleCount }})</el-button>
|
||||
<el-button type="danger" plain :disabled="selected.length === 0 || busy" @click="openDelete">删除({{ deleteEligibleCount }})</el-button>
|
||||
<el-button type="danger" plain :disabled="selected.length === 0 || busy || matchingBusy" @click="openDelete">删除({{ deleteEligibleCount }})</el-button>
|
||||
</div>
|
||||
<div class="file-state" aria-live="polite"><template v-if="selectedFile">已选择:<strong>{{ selectedFile.name }}</strong>({{ fileSize(selectedFile.size) }})</template><template v-else>请选择“标签入库码映射”Excel 文件,最大 10MB。</template></div>
|
||||
|
||||
@@ -29,7 +30,7 @@
|
||||
<el-table-column label="本地规格" prop="specRaw" min-width="170" />
|
||||
<el-table-column label="SYB 规格" min-width="170"><template #default="{ row }"><span v-if="row.plan">{{ row.plan.sybSpec || '已读取,规格为空' }}</span><span v-else class="muted">{{ sybSpecPlaceholder(row.status) }}</span></template></el-table-column>
|
||||
<el-table-column label="状态" min-width="180"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag><div v-if="row.resultMessage" class="status-note">{{ row.resultMessage }}</div></template></el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="['failed', 'skipped'].includes(row.status)" type="primary" link :loading="rowActionId === row.id" @click="rematch(row)">重新匹配</el-button><el-button v-if="row.status === 'needs_check'" type="warning" link :loading="rowActionId === row.id" @click="recheck(row)">只读复核</el-button></template></el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="['pending', 'failed', 'skipped'].includes(row.status)" type="primary" link :loading="rowActionId === row.id" :disabled="matchingBusy || busy || importing || loading || !!rowActionId" @click="rematch(row)">重新匹配</el-button><el-button v-if="row.status === 'needs_check'" type="warning" link :loading="rowActionId === row.id" @click="recheck(row)">只读复核</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :page-sizes="[20, 50, 100, 200]" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
@@ -66,10 +67,13 @@ import { createRequestId } from '@/utils/request-id'
|
||||
export default {
|
||||
name: 'GoAutoSybInnerCodes',
|
||||
setup() { return { FolderOpened, RefreshLeft, Search, Upload } },
|
||||
data() { return { loading: false, importing: false, busy: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: null, rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: '', dateTo: '', keyword: '' }, applyDialog: { open: false, loading: false, saving: false, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
|
||||
data() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return { loading: false, importing: false, busy: false, matchSubmitting: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: [today, today], rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: today, dateTo: today, keyword: '' }, applyDialog: { open: false, loading: false, saving: false, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
|
||||
computed: {
|
||||
matchingBusy() { return this.matchSubmitting || ['pending', 'running'].includes(this.activeJob?.status) },
|
||||
matchEligibleRows() { const ids = new Set(this.selected.map(item => item.id)); return this.items.filter(item => ids.has(item.id) && ['pending', 'failed', 'skipped'].includes(item.status)) },
|
||||
matchEligibleCount() { return this.matchEligibleRows.length },
|
||||
applyEligibleCount() { return this.selected.filter(item => item.status === 'ready').length },
|
||||
deleteBlocked() { return this.selected.filter(item => ['queued', 'applying', 'needs_check'].includes(item.status)) },
|
||||
deleteBlocked() { return this.selected.filter(item => ['matching', 'queued', 'applying', 'needs_check'].includes(item.status)) },
|
||||
deleteEligibleCount() { return this.deleteBlocked.length ? 0 : this.selected.length },
|
||||
jobPercent() { return this.activeJob?.total ? Math.round(this.activeJob.processed * 100 / this.activeJob.total) : 0 },
|
||||
jobTitle() { const job = this.activeJob; return job?.status === 'failed' ? '自动匹配失败' : `自动匹配:${job?.processed || 0}/${job?.total || 0},可回写 ${job?.ready || 0},异常 ${job?.failed || 0}` },
|
||||
@@ -89,7 +93,19 @@ export default {
|
||||
async confirmApply() { this.applyDialog.saving = true; this.busy = true; try { const r = await applySybInnerCodes({ requestId: createRequestId(), ids: this.selected.map(item => item.id) }); this.activeBatch = { id: r.data.batchId, status: 'queued', requested: r.data.queued, processed: 0 }; this.applyDialog.open = false; ElMessage.success('回写已提交,页面可以继续使用'); this.startPolling(); await this.load() } finally { this.applyDialog.saving = false; this.busy = false } },
|
||||
openDelete() { this.deleteDialog.open = true }, async confirmDelete() { this.deleteDialog.saving = true; this.busy = true; try { const r = await deleteSybInnerCodes({ requestId: createRequestId(), ids: this.selected.map(item => item.id) }); ElMessage.success(`已物理删除 ${r.data.deleted} 条数据`); this.deleteDialog.open = false; await this.load() } finally { this.deleteDialog.saving = false; this.busy = false } },
|
||||
async recheck(row) { this.rowActionId = row.id; try { await recheckSybInnerCode(row.id); ElMessage.success('只读复核完成'); await this.load() } finally { this.rowActionId = 0 } },
|
||||
async rematch(row) { this.rowActionId = row.id; try { const r = await rematchSybInnerCodes({ requestId: createRequestId(), ids: [row.id] }); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: 1, processed: 0, ready: 0, failed: 0 }; ElMessage.success('已开始重新匹配'); this.startPolling(); await this.load() } finally { this.rowActionId = 0 } },
|
||||
async matchSelected() { await this.startMatch(this.matchEligibleRows) },
|
||||
async rematch(row) { if (this.matchingBusy || this.rowActionId) return; this.rowActionId = row.id; try { await this.startMatch([row]) } finally { this.rowActionId = 0 } },
|
||||
async startMatch(rows) {
|
||||
if (!rows.length || this.matchingBusy || this.busy || this.importing || this.loading || this.loadError) return
|
||||
if (new Set(rows.map(row => row.businessDate)).size !== 1) { ElMessage.warning('请按同一营业日期分别匹配'); return }
|
||||
this.matchSubmitting = true
|
||||
try {
|
||||
const r = await rematchSybInnerCodes({ requestId: createRequestId(), ids: [...new Set(rows.map(row => row.id))] })
|
||||
this.activeJob = { id: r.data.matchJobId, status: 'pending', total: r.data.queued, processed: 0, ready: 0, failed: 0 }
|
||||
ElMessage.success(`已开始匹配 ${r.data.queued} 条记录;成功后请另行确认回写`)
|
||||
this.startPolling(); await this.load()
|
||||
} finally { this.matchSubmitting = false }
|
||||
},
|
||||
async openDetail(id) { this.detail = { open: true, loading: true, item: null }; try { const r = await getSybInnerCode(id); this.detail.item = r.data.item } finally { this.detail.loading = false } },
|
||||
startPolling() { if (!this.pollTimer) this.pollTimer = window.setInterval(this.poll, 2000); this.poll() }, stopPolling() { if (this.pollTimer) window.clearInterval(this.pollTimer); this.pollTimer = null },
|
||||
async poll() { let active = false; if (this.activeJob && ['pending', 'running'].includes(this.activeJob.status)) { const r = await getSybInnerCodeMatchJob(this.activeJob.id); this.activeJob = r.data.item; active = ['pending', 'running'].includes(this.activeJob.status) || active } if (this.activeBatch && ['queued', 'running'].includes(this.activeBatch.status)) { const r = await getSybInnerCodeApplyBatch(this.activeBatch.id); this.activeBatch = r.data.batch; active = ['queued', 'running'].includes(this.activeBatch.status) || active } await this.load(); if (!active) this.stopPolling() }
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test, type Page, type BrowserContext } from '@playwright/test'
|
||||
|
||||
async function setup(page: Page, context: BrowserContext, mode = 'success') {
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
|
||||
const lists: URL[] = []; const writes: number[][] = []; const otherWrites: string[] = []
|
||||
let submitted = false
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url()); const pathname = url.pathname
|
||||
if (pathname.startsWith('/src/api/')) return route.continue()
|
||||
const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
|
||||
if (pathname.endsWith('/api/v1/menurole')) return reply([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-inner-codes', component: '/goauto/syb-inner-codes/index', menuName: 'GoAutoSybInnerCodes', title: '档口入库码', visible: '0' }] }])
|
||||
if (pathname.endsWith('/api/v1/getinfo')) return reply({ roles: ['purchaser'], name: '模拟采购员', avatar: '', permissions: [] })
|
||||
if (pathname.endsWith('/syb-inner-codes') && route.request().method() === 'GET') {
|
||||
lists.push(url)
|
||||
if (mode === 'list-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟加载失败' } })
|
||||
const statuses = ['pending', 'failed', 'skipped', 'updated']
|
||||
return reply({ items: statuses.map((status, i) => ({ id: i + 1, businessDate: mode === 'mixed-dates' && i === 1 ? '2026-09-28' : '2026-09-29', orderNumber: `MOCK-${i + 1}`, status: submitted && i < 3 ? 'matching' : status, items: [{ code: `MOCK-CODE-${i}`, ordinal: 1 }], specRaw: '模拟规格' })), total: 4 })
|
||||
}
|
||||
if (pathname.endsWith('/rematch')) {
|
||||
writes.push(route.request().postDataJSON().ids)
|
||||
if (mode === 'submit-error') return route.fulfill({ status: 409, json: { code: 'CONFLICT', message: '模拟状态已变化' } })
|
||||
submitted = true
|
||||
return reply({ matchJobId: 'isolated-job', queued: writes.at(-1)!.length })
|
||||
}
|
||||
if (pathname.includes('/match-jobs/')) return reply({ item: { id: 'isolated-job', status: 'running', total: 3, processed: 1, ready: 1, failed: 0 } })
|
||||
if (route.request().method() !== 'GET') otherWrites.push(pathname)
|
||||
return reply({ items: [], total: 0 })
|
||||
})
|
||||
await page.goto('/#/syb-inner-codes')
|
||||
if (mode !== 'list-error') await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible()
|
||||
return { lists, writes, otherWrites }
|
||||
}
|
||||
|
||||
test('today default, mixed selection only matches eligible rows and never writes SYB', async ({ page, context }) => {
|
||||
const { lists, writes, otherWrites } = await setup(page, context)
|
||||
const today = await page.evaluate(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` })
|
||||
expect(lists[0].searchParams.get('dateFrom')).toBe(today); expect(lists[0].searchParams.get('dateTo')).toBe(today)
|
||||
await expect(page.getByRole('button', { name: '匹配 0', exact: true })).toBeDisabled()
|
||||
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: '匹配 3', exact: true }).click()
|
||||
await expect.poll(() => writes.length).toBe(1); expect(writes[0]).toEqual([1, 2, 3])
|
||||
await expect(page.getByText('自动匹配:1/3,可回写 1,异常 0')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /^匹配 \d+$/ })).toBeDisabled()
|
||||
expect(otherWrites).toEqual([])
|
||||
})
|
||||
|
||||
test('explicitly clear filters removes date range', async ({ page, context }) => {
|
||||
const { lists } = await setup(page, context)
|
||||
await page.getByRole('button', { name: '清除', exact: true }).click()
|
||||
await expect.poll(() => lists.length).toBeGreaterThan(1)
|
||||
expect(lists.at(-1)!.searchParams.get('dateFrom')).toBe('')
|
||||
})
|
||||
|
||||
test('mixed dates warn without submitting', async ({ page, context }) => {
|
||||
const { writes } = await setup(page, context, 'mixed-dates')
|
||||
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: '匹配 3', exact: true }).click()
|
||||
await expect(page.getByText('请按同一营业日期分别匹配')).toBeVisible(); expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
test('submission failure keeps retry available without automatic retries', async ({ page, context }) => {
|
||||
const { writes } = await setup(page, context, 'submit-error')
|
||||
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: '匹配 3', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '匹配 3', exact: true })).toBeEnabled()
|
||||
expect(writes.length).toBe(1)
|
||||
})
|
||||
|
||||
test('list failure disables matching', async ({ page, context }) => {
|
||||
await setup(page, context, 'list-error')
|
||||
await expect(page.getByRole('button', { name: '匹配 0', exact: true })).toBeDisabled()
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-inner-codes/index.vue'), 'utf8').split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
|
||||
function instance(write = async () => ({ data: { matchJobId: 'test-job', queued: 2 } })) {
|
||||
const writes = []; const messages = []
|
||||
const options = new Function('rematchSybInnerCodes', 'createRequestId', 'ElMessage', source)(async body => { writes.push(body); return write(body) }, () => 'isolated-id', { success: m => messages.push(m), warning: m => messages.push(m) })
|
||||
const vm = { ...options.data(), ...options.methods, load: async () => {}, startPolling: () => {} }
|
||||
for (const [key, get] of Object.entries(options.computed)) Object.defineProperty(vm, key, { get: () => get.call(vm) })
|
||||
vm.items = ['pending', 'failed', 'skipped', 'updated', 'ready', 'needs_check', 'matching'].map((status, i) => ({ id: i + 1, status, businessDate: '2026-09-29' }))
|
||||
return { vm, writes, messages }
|
||||
}
|
||||
test('initial range and request use local today, clear explicitly removes it', () => {
|
||||
const { vm } = instance(); const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
|
||||
assert.deepEqual(vm.dateRange, [today, today]); assert.equal(vm.query.dateFrom, today); assert.equal(vm.query.dateTo, today)
|
||||
vm.clearFilters(); assert.equal(vm.dateRange, null); assert.equal(vm.query.dateFrom, '')
|
||||
})
|
||||
test('mixed selection submits only current page eligible rows, does not auto apply', async () => {
|
||||
const { vm, writes } = instance(); vm.selected = [...vm.items, { id: 999, status: 'failed' }]
|
||||
assert.equal(vm.matchEligibleCount, 3); await vm.matchSelected(); assert.deepEqual(writes[0].ids, [1, 2, 3]); assert.equal(writes.length, 1); assert.equal(vm.activeJob.id, 'test-job'); assert.equal(vm.activeBatch, null)
|
||||
await vm.matchSelected(); assert.equal(writes.length, 1)
|
||||
})
|
||||
test('busy, loading, error and empty selection do not submit', async () => {
|
||||
for (const field of ['busy', 'importing', 'loading', 'loadError']) {
|
||||
const { vm, writes } = instance(); vm.selected = [vm.items[0]]; vm[field] = field === 'loadError' ? 'error' : true
|
||||
await vm.matchSelected(); assert.equal(writes.length, 0)
|
||||
}
|
||||
const { vm, writes } = instance(); await vm.matchSelected(); assert.equal(writes.length, 0)
|
||||
})
|
||||
test('mixed dates warn without writes', async () => {
|
||||
const { vm, writes, messages } = instance(); vm.items[1].businessDate = '2026-09-28'; vm.selected = vm.items.slice(0, 2)
|
||||
await vm.matchSelected(); assert.equal(writes.length, 0); assert.equal(messages.length, 1)
|
||||
})
|
||||
test('submission error retains selection and releases busy flag', async () => {
|
||||
const { vm } = instance(async () => { throw new Error('test-only error') }); vm.selected = [vm.items[0]]
|
||||
await assert.rejects(vm.matchSelected()); assert.equal(vm.matchSubmitting, false); assert.equal(vm.selected.length, 1); assert.equal(vm.activeJob, null)
|
||||
})
|
||||
Reference in New Issue
Block a user