From 5c6655f70699cd917a4bc106b863e0788f8613bd Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 28 Aug 2026 10:24:15 +0800 Subject: [PATCH] feat(#119 #120 #121): implement SYB inner-code workflow --- server/app/admin/router/init_router.go | 2 + server/app/goauto/access/purchaser.go | 11 + server/app/goauto/migrations/migrate.go | 9 + server/app/goauto/models/syb_inner_code.go | 162 +++++++ server/app/goauto/sybinnercode/apply.go | 414 ++++++++++++++++++ server/app/goauto/sybinnercode/apply_test.go | 194 ++++++++ server/app/goauto/sybinnercode/handler.go | 256 +++++++++++ server/app/goauto/sybinnercode/match.go | 344 +++++++++++++++ server/app/goauto/sybinnercode/match_test.go | 179 ++++++++ server/app/goauto/sybinnercode/parser.go | 289 ++++++++++++ server/app/goauto/sybinnercode/router.go | 23 + server/app/goauto/sybinnercode/runtime.go | 32 ++ server/app/goauto/sybinnercode/service.go | 349 +++++++++++++++ .../app/goauto/sybinnercode/service_test.go | 211 +++++++++ server/app/goauto/sybinnercode/types.go | 113 +++++ server/cmd/api/server.go | 4 + .../1786701500000_syb_inner_code.go | 25 ++ server/go.mod | 6 + server/go.sum | 13 + 19 files changed, 2636 insertions(+) create mode 100644 server/app/goauto/models/syb_inner_code.go create mode 100644 server/app/goauto/sybinnercode/apply.go create mode 100644 server/app/goauto/sybinnercode/apply_test.go create mode 100644 server/app/goauto/sybinnercode/handler.go create mode 100644 server/app/goauto/sybinnercode/match.go create mode 100644 server/app/goauto/sybinnercode/match_test.go create mode 100644 server/app/goauto/sybinnercode/parser.go create mode 100644 server/app/goauto/sybinnercode/router.go create mode 100644 server/app/goauto/sybinnercode/runtime.go create mode 100644 server/app/goauto/sybinnercode/service.go create mode 100644 server/app/goauto/sybinnercode/service_test.go create mode 100644 server/app/goauto/sybinnercode/types.go create mode 100644 server/cmd/migrate/migration/version-local/1786701500000_syb_inner_code.go diff --git a/server/app/admin/router/init_router.go b/server/app/admin/router/init_router.go index 7ef9ada..45a5695 100644 --- a/server/app/admin/router/init_router.go +++ b/server/app/admin/router/init_router.go @@ -13,6 +13,7 @@ import ( goautorule "go-admin/app/goauto/rule" goautoshopeeproduct "go-admin/app/goauto/shopeeproduct" goautosybimport "go-admin/app/goauto/sybimport" + goautosybinnercode "go-admin/app/goauto/sybinnercode" goautosybshop "go-admin/app/goauto/sybshop" goautotask "go-admin/app/goauto/task" common "go-admin/common/middleware" @@ -56,5 +57,6 @@ func InitRouter() { goautorule.InitRouter(r, authMiddleware) goautoshopeeproduct.InitRouter(r, authMiddleware) goautosybimport.InitRouter(r, authMiddleware) + goautosybinnercode.InitRouter(r, authMiddleware) goautosybshop.InitRouter(r, authMiddleware) } diff --git a/server/app/goauto/access/purchaser.go b/server/app/goauto/access/purchaser.go index 551f89c..fa87b2a 100644 --- a/server/app/goauto/access/purchaser.go +++ b/server/app/goauto/access/purchaser.go @@ -48,6 +48,17 @@ var AdminAPIs = []APIPermission{ {"查看 SYB 同步记录", "/api/admin/v1/syb-products/sync-runs", "GET", true}, {"查看 SYB 同步详情", "/api/admin/v1/syb-products/sync-runs/:runId", "GET", true}, + {"查看档口入库码", "/api/admin/v1/syb-inner-codes", "GET", true}, + {"查看档口入库码详情", "/api/admin/v1/syb-inner-codes/:recordId", "GET", true}, + {"导入档口入库码", "/api/admin/v1/syb-inner-codes/import", "POST", true}, + {"批量删除档口入库码", "/api/admin/v1/syb-inner-codes/batch-delete", "POST", true}, + {"查看档口入库码匹配进度", "/api/admin/v1/syb-inner-codes/match-jobs/:jobId", "GET", true}, + {"预检档口入库码回写", "/api/admin/v1/syb-inner-codes/apply-preview", "POST", true}, + {"提交档口入库码回写", "/api/admin/v1/syb-inner-codes/apply", "POST", true}, + {"查看档口入库码回写批次", "/api/admin/v1/syb-inner-codes/apply-batches/:batchId", "GET", true}, + {"复核档口入库码", "/api/admin/v1/syb-inner-codes/:recordId/recheck", "POST", true}, + {"重新匹配档口入库码", "/api/admin/v1/syb-inner-codes/rematch", "POST", true}, + {"查看 SYB 店铺", "/api/admin/v1/syb-shops", "GET", true}, {"新增 SYB 店铺", "/api/admin/v1/syb-shops", "POST", false}, {"修改 SYB 店铺名称", "/api/admin/v1/syb-shops/:shopId/name", "PATCH", false}, diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 61c56d3..0191460 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -21,6 +21,15 @@ func MigratedModels() []any { &models.SYBSession{}, &models.SYBShop{}, &models.SYBSyncRun{}, + &models.SYBInnerCodeRecord{}, + &models.SYBInnerCodeItem{}, + &models.SYBInnerCodeApplyBatch{}, + &models.SYBInnerCodeApplyItem{}, + &models.SYBInnerCodeCheckpoint{}, + &models.SYBInnerCodeWorkerLease{}, + &models.SYBInnerCodeMutation{}, + &models.SYBInnerCodeMatchJob{}, + &models.SYBInnerCodePlan{}, &models.PDDAccount{}, &models.PurchaseTask{}, &models.PurchaseTaskAttempt{}, diff --git a/server/app/goauto/models/syb_inner_code.go b/server/app/goauto/models/syb_inner_code.go new file mode 100644 index 0000000..7d14d90 --- /dev/null +++ b/server/app/goauto/models/syb_inner_code.go @@ -0,0 +1,162 @@ +package models + +import "time" + +const ( + SYBInnerCodePending = "pending" + SYBInnerCodeMatching = "matching" + SYBInnerCodeReady = "ready" + SYBInnerCodeAlreadyFilled = "already_filled" + SYBInnerCodeSkipped = "skipped" + SYBInnerCodeFailed = "failed" + SYBInnerCodeQueued = "queued" + SYBInnerCodeApplying = "applying" + SYBInnerCodeUpdated = "updated" + SYBInnerCodeNeedsCheck = "needs_check" +) + +// SYBInnerCodeRecord is one Excel business identity. Individual inbound codes +// live in SYBInnerCodeItem so order and uniqueness do not depend on CSV text. +type SYBInnerCodeRecord struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + BusinessDate string `json:"businessDate" gorm:"size:10;not null;index;uniqueIndex:ux_syb_inner_code_business,priority:1"` + OrderNumber string `json:"orderNumber" gorm:"size:64;not null;index;uniqueIndex:ux_syb_inner_code_business,priority:2"` + Stall string `json:"stall" gorm:"size:191;not null;uniqueIndex:ux_syb_inner_code_business,priority:3"` + SpecKey string `json:"specKey" gorm:"size:500;not null;uniqueIndex:ux_syb_inner_code_business,priority:4"` + SpecRaw string `json:"specRaw" gorm:"size:500;not null"` + SourceSKURaw string `json:"sourceSkuRaw" gorm:"size:500;not null"` + ShopName string `json:"shopName" gorm:"size:191;not null"` + SourceRow int `json:"sourceRow" gorm:"not null"` + PrintSequence int `json:"printSequence" gorm:"not null;default:0"` + Status string `json:"status" gorm:"size:24;not null;index"` + ResultMessage string `json:"resultMessage" gorm:"size:1000;not null;default:''"` + CreatedBy uint64 `json:"createdBy" gorm:"not null"` + ImportRequestID string `json:"-" gorm:"size:36;not null;index"` + LastDeleteRequestID *string `json:"-" gorm:"size:36;index"` + ApplyBatchID *string `json:"applyBatchId" gorm:"size:36;index"` + ApplyStartedAt *time.Time `json:"applyStartedAt"` + AppliedAt *time.Time `json:"appliedAt"` + Items []SYBInnerCodeItem `json:"items" gorm:"foreignKey:RecordID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + Plan *SYBInnerCodePlan `json:"plan,omitempty" gorm:"foreignKey:RecordID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (SYBInnerCodeRecord) TableName() string { return "syb_inner_code_record" } + +type SYBInnerCodeItem struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + RecordID uint64 `json:"recordId" gorm:"not null;index;uniqueIndex:ux_syb_inner_code_record_ordinal,priority:1"` + BusinessDate string `json:"-" gorm:"size:10;not null;uniqueIndex:ux_syb_inner_code_date_code,priority:1"` + Code string `json:"code" gorm:"size:128;not null;uniqueIndex:ux_syb_inner_code_date_code,priority:2"` + Ordinal int `json:"ordinal" gorm:"not null;uniqueIndex:ux_syb_inner_code_record_ordinal,priority:2"` + SourceRow int `json:"sourceRow" gorm:"not null"` + CreatedAt time.Time `json:"createdAt"` +} + +func (SYBInnerCodeItem) TableName() string { return "syb_inner_code_item" } + +// SYBInnerCodeApplyBatch is introduced with the import contract so later +// writeback migrations never need to reshape imported records. +type SYBInnerCodeApplyBatch struct { + ID string `json:"id" gorm:"size:36;primaryKey"` + RequestID string `json:"-" gorm:"size:36;not null;uniqueIndex:ux_syb_inner_code_apply_request"` + Status string `json:"status" gorm:"size:24;not null;index"` + Requested int `json:"requested" gorm:"not null"` + Processed int `json:"processed" gorm:"not null;default:0"` + CreatedBy uint64 `json:"createdBy" gorm:"not null"` + StartedAt *time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (SYBInnerCodeApplyBatch) TableName() string { return "syb_inner_code_apply_batch" } + +type SYBInnerCodeApplyItem struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + BatchID string `json:"batchId" gorm:"size:36;not null;index;uniqueIndex:ux_syb_inner_code_apply_item,priority:1"` + Batch SYBInnerCodeApplyBatch `json:"-" gorm:"foreignKey:BatchID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + RecordID uint64 `json:"recordId" gorm:"not null;index;uniqueIndex:ux_syb_inner_code_apply_item,priority:2"` + Record SYBInnerCodeRecord `json:"-" gorm:"foreignKey:RecordID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + Status string `json:"status" gorm:"size:24;not null;index"` + Message string `json:"message" gorm:"size:1000;not null;default:''"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (SYBInnerCodeApplyItem) TableName() string { return "syb_inner_code_apply_item" } + +type SYBInnerCodeCheckpoint struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + RecordID uint64 `json:"recordId" gorm:"not null;index;uniqueIndex:ux_syb_inner_code_checkpoint_seq,priority:1"` + Record SYBInnerCodeRecord `json:"-" gorm:"foreignKey:RecordID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + Sequence int `json:"sequence" gorm:"not null;uniqueIndex:ux_syb_inner_code_checkpoint_seq,priority:2"` + Phase string `json:"phase" gorm:"size:32;not null"` + Action string `json:"action" gorm:"size:32;not null"` + StateJSON string `json:"state" gorm:"type:text;not null"` + CreatedAt time.Time `json:"createdAt"` +} + +func (SYBInnerCodeCheckpoint) TableName() string { return "syb_inner_code_checkpoint" } + +type SYBInnerCodeWorkerLease struct { + ID uint8 `json:"-" gorm:"primaryKey;autoIncrement:false"` + OwnerID string `json:"-" gorm:"size:36;not null"` + ExpiresAt time.Time `json:"-" gorm:"not null;index"` + Version uint64 `json:"-" gorm:"not null;default:0"` + UpdatedAt time.Time `json:"-"` +} + +func (SYBInnerCodeWorkerLease) TableName() string { return "syb_inner_code_worker_lease" } + +// SYBInnerCodeMutation records idempotent import/delete request results. +type SYBInnerCodeMutation struct { + RequestID string `json:"-" gorm:"size:36;primaryKey"` + Action string `json:"-" gorm:"size:16;not null"` + ResultJSON string `json:"-" gorm:"type:text;not null"` + CreatedAt time.Time `json:"-"` +} + +func (SYBInnerCodeMutation) TableName() string { return "syb_inner_code_mutation" } + +type SYBInnerCodeMatchJob struct { + ID string `json:"id" gorm:"size:36;primaryKey"` + WorkKey string `json:"-" gorm:"size:128;not null;uniqueIndex:ux_syb_inner_code_match_work"` + BusinessDate string `json:"businessDate" gorm:"size:10;not null;index"` + RecordIDsJSON string `json:"-" gorm:"type:text;not null"` + Status string `json:"status" gorm:"size:24;not null;index"` + Total int `json:"total" gorm:"not null;default:0"` + Processed int `json:"processed" gorm:"not null;default:0"` + Ready int `json:"ready" gorm:"not null;default:0"` + Failed int `json:"failed" gorm:"not null;default:0"` + ErrorMessage string `json:"errorMessage" gorm:"size:1000;not null;default:''"` + StartedAt *time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (SYBInnerCodeMatchJob) TableName() string { return "syb_inner_code_match_job" } + +type SYBInnerCodePlan struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + RecordID uint64 `json:"recordId" gorm:"not null;uniqueIndex:ux_syb_inner_code_plan_record"` + Record SYBInnerCodeRecord `json:"-" gorm:"foreignKey:RecordID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + MatchJobID string `json:"matchJobId" gorm:"size:36;not null;index"` + StockID int64 `json:"stockId" gorm:"not null"` + DetailID int64 `json:"detailId" gorm:"not null"` + SYBSpec string `json:"sybSpec" gorm:"size:500;not null"` + SYBSKU string `json:"sybSku" gorm:"size:500;not null"` + SYBVariationSKU string `json:"sybVariationSku" gorm:"size:500;not null"` + PurchasePlatform string `json:"purchasePlatform" gorm:"size:255;not null"` + PurchaseCode string `json:"purchaseCode" gorm:"size:255;not null"` + RemoteInnerCode string `json:"remoteInnerCode" gorm:"size:128;not null"` + RemoteItemsJSON string `json:"remoteItems" gorm:"type:text;not null"` + PlaceholderCount int `json:"placeholderCount" gorm:"not null;default:0"` + ReplaceOldCodeCount int `json:"replaceOldCodeCount" gorm:"not null;default:0"` + EvidenceHash string `json:"evidenceHash" gorm:"size:64;not null"` + PlannedAt time.Time `json:"plannedAt" gorm:"not null"` +} + +func (SYBInnerCodePlan) TableName() string { return "syb_inner_code_plan" } diff --git a/server/app/goauto/sybinnercode/apply.go b/server/app/goauto/sybinnercode/apply.go new file mode 100644 index 0000000..27a2d19 --- /dev/null +++ b/server/app/goauto/sybinnercode/apply.go @@ -0,0 +1,414 @@ +package sybinnercode + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybclient" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type InnerCodeWriter interface { + MatchReader + DeleteInnerCode(context.Context, int64) error + CreateInnerCodeDetail(context.Context, int64, string) (int64, error) + UpdateDetailCode(context.Context, int64, int64, string) error +} + +type ApplyRunner struct { + Factory func(context.Context, *gorm.DB) (InnerCodeWriter, error) +} + +func (r ApplyRunner) Start(db *gorm.DB, batchID string) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + writer, err := r.Factory(ctx, db) + if err == nil { + err = RunApplyBatch(ctx, db, writer, batchID) + } + if err != nil { + _ = interruptBatch(db, batchID, err.Error()) + } + }() +} + +func (s *Service) PreviewApply(ctx context.Context, ids []uint64) (ApplyPreview, error) { + ids = uniqueIDs(ids) + if len(ids) == 0 { + return ApplyPreview{}, invalid("没有选择记录") + } + var records []models.SYBInnerCodeRecord + if err := s.db.WithContext(ctx).Preload("Items").Where("id IN ?", ids).Find(&records).Error; err != nil { + return ApplyPreview{}, internal(err) + } + if len(records) != len(ids) { + return ApplyPreview{}, conflict("部分记录不存在") + } + preview := ApplyPreview{Records: len(records)} + for _, record := range records { + if record.Status != models.SYBInnerCodeReady { + preview.Blocked = append(preview.Blocked, BlockedRecord{ID: record.ID, Status: record.Status}) + continue + } + var plan models.SYBInnerCodePlan + if err := s.db.WithContext(ctx).First(&plan, "record_id = ?", record.ID).Error; err != nil { + return ApplyPreview{}, conflict("部分记录缺少有效匹配计划") + } + preview.InboundCodes += len(record.Items) + preview.PlaceholderDetails += plan.PlaceholderCount + preview.ReplaceOldCodes += plan.ReplaceOldCodeCount + } + sort.Slice(preview.Blocked, func(i, j int) bool { return preview.Blocked[i].ID < preview.Blocked[j].ID }) + return preview, nil +} + +func (s *Service) QueueApply(ctx context.Context, actor uint64, request ApplyRequest) (ApplyResult, error) { + request.RequestID = strings.TrimSpace(request.RequestID) + ids := uniqueIDs(request.IDs) + if actor == 0 || uuid.Validate(request.RequestID) != nil || len(ids) == 0 { + return ApplyResult{}, invalid("requestId、操作人或记录无效") + } + if replay, ok, err := loadMutation[ApplyResult](s.db.WithContext(ctx), request.RequestID, "apply"); err != nil { + return ApplyResult{}, internal(err) + } else if ok { + return replay, nil + } + result := ApplyResult{BatchID: uuid.NewString(), Queued: len(ids)} + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if replay, ok, replayErr := loadMutation[ApplyResult](tx, request.RequestID, "apply"); replayErr != nil { + return replayErr + } else if ok { + result = replay + return nil + } + var records []models.SYBInnerCodeRecord + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", ids).Find(&records).Error; err != nil { + return err + } + if len(records) != len(ids) { + return conflict("部分记录不存在,未提交回写") + } + for _, record := range records { + if record.Status != models.SYBInnerCodeReady { + return conflict(fmt.Sprintf("记录 %d 状态为 %s,不能回写", record.ID, record.Status)) + } + var count int64 + if err := tx.Model(&models.SYBInnerCodePlan{}).Where("record_id = ?", record.ID).Count(&count).Error; err != nil || count != 1 { + return conflict(fmt.Sprintf("记录 %d 缺少唯一有效计划", record.ID)) + } + } + batch := models.SYBInnerCodeApplyBatch{ID: result.BatchID, RequestID: request.RequestID, Status: "queued", Requested: len(ids), CreatedBy: actor} + if err := tx.Create(&batch).Error; err != nil { + return err + } + for _, id := range ids { + item := models.SYBInnerCodeApplyItem{BatchID: batch.ID, RecordID: id, Status: models.SYBInnerCodeQueued} + if err := tx.Create(&item).Error; err != nil { + return err + } + } + changed := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ? AND status = ?", ids, models.SYBInnerCodeReady).Updates(map[string]any{"status": models.SYBInnerCodeQueued, "apply_batch_id": batch.ID}) + if changed.Error != nil { + return changed.Error + } + if changed.RowsAffected != int64(len(ids)) { + return conflict("记录状态并发变化,未提交回写") + } + return saveMutation(tx, request.RequestID, "apply", result) + }) + if err != nil { + var target *ServiceError + if errors.As(err, &target) { + return ApplyResult{}, err + } + return ApplyResult{}, internal(err) + } + return result, nil +} + +func acquireLease(ctx context.Context, db *gorm.DB, owner string) (bool, error) { + now := time.Now().UTC() + expires := now.Add(45 * time.Second) + seed := models.SYBInnerCodeWorkerLease{ID: 1, OwnerID: "", ExpiresAt: time.Unix(0, 0).UTC()} + if err := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&seed).Error; err != nil { + return false, err + } + result := db.WithContext(ctx).Model(&models.SYBInnerCodeWorkerLease{}).Where("id = 1 AND (expires_at < ? OR owner_id = ?)", now, owner).Updates(map[string]any{"owner_id": owner, "expires_at": expires, "version": gorm.Expr("version + 1")}) + return result.RowsAffected == 1, result.Error +} +func releaseLease(db *gorm.DB, owner string) { + db.Model(&models.SYBInnerCodeWorkerLease{}).Where("id = 1 AND owner_id = ?", owner).Updates(map[string]any{"owner_id": "", "expires_at": time.Unix(0, 0).UTC()}) +} + +func RunApplyBatch(ctx context.Context, db *gorm.DB, writer InnerCodeWriter, batchID string) error { + owner := uuid.NewString() + ok, err := acquireLease(ctx, db, owner) + if err != nil { + return err + } + if !ok { + return conflict("已有其他服务实例正在执行 SYB 回写") + } + defer releaseLease(db, owner) + startedBatch := db.Model(&models.SYBInnerCodeApplyBatch{}).Where("id = ? AND status = ?", batchID, "queued").Updates(map[string]any{"status": "running", "started_at": time.Now().UTC()}) + if startedBatch.Error != nil { + return startedBatch.Error + } + if startedBatch.RowsAffected != 1 { + return conflict("回写批次不存在或状态已变化") + } + for { + var item models.SYBInnerCodeApplyItem + err := db.WithContext(ctx).Where("batch_id = ? AND status = ?", batchID, models.SYBInnerCodeQueued).Order("id").First(&item).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + break + } + if err != nil { + return err + } + started := time.Now().UTC() + claim := db.Model(&models.SYBInnerCodeRecord{}).Where("id = ? AND status = ? AND apply_batch_id = ?", item.RecordID, models.SYBInnerCodeQueued, batchID).Updates(map[string]any{"status": models.SYBInnerCodeApplying, "apply_started_at": started}) + if claim.Error != nil { + return claim.Error + } + if claim.RowsAffected == 0 { + if err := db.Model(&item).Updates(map[string]any{"status": models.SYBInnerCodeSkipped, "message": "记录状态已变化"}).Error; err != nil { + return err + } + continue + } + if err := db.Model(&item).Update("status", models.SYBInnerCodeApplying).Error; err != nil { + return err + } + status, message := applyOne(ctx, db, writer, item.RecordID) + finished := time.Now().UTC() + if err := db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id = ? AND status = ?", item.RecordID, models.SYBInnerCodeApplying).Updates(map[string]any{"status": status, "result_message": message, "applied_at": finished}).Error; err != nil { + return err + } + return tx.Model(&models.SYBInnerCodeApplyItem{}).Where("id = ?", item.ID).Updates(map[string]any{"status": status, "message": message}).Error + }); err != nil { + return err + } + if err := db.Model(&models.SYBInnerCodeApplyBatch{}).Where("id = ?", batchID).Updates(map[string]any{"processed": gorm.Expr("processed + 1")}).Error; err != nil { + return err + } + } + finished := time.Now().UTC() + return db.Model(&models.SYBInnerCodeApplyBatch{}).Where("id = ?", batchID).Updates(map[string]any{"status": "finished", "finished_at": finished}).Error +} + +func applyOne(ctx context.Context, db *gorm.DB, writer InnerCodeWriter, recordID uint64) (string, string) { + var record models.SYBInnerCodeRecord + if err := db.Preload("Items", func(q *gorm.DB) *gorm.DB { return q.Order("ordinal") }).First(&record, recordID).Error; err != nil { + return models.SYBInnerCodeNeedsCheck, "读取本地记录失败" + } + var plan models.SYBInnerCodePlan + if err := db.First(&plan, "record_id = ?", recordID).Error; err != nil { + return models.SYBInnerCodeNeedsCheck, "读取匹配计划失败" + } + stock, primary, err := readStock(ctx, writer, plan.StockID, plan.DetailID) + if err != nil { + return models.SYBInnerCodeNeedsCheck, "执行前重读 SYB 失败:" + compact(err.Error(), 800) + } + if primary.ProductSpec != plan.SYBSpec || rawText(primary.Raw["sku"]) != plan.SYBSKU || rawText(primary.Raw["variationSku"]) != plan.SYBVariationSKU { + return models.SYBInnerCodeFailed, "SYB 商品身份已变化,停止回写,请重新匹配" + } + var items []plannedRemoteItem + if err := json.Unmarshal([]byte(plan.RemoteItemsJSON), &items); err != nil || len(items) != len(record.Items) { + return models.SYBInnerCodeNeedsCheck, "匹配计划不完整,禁止回写" + } + current := rawText(primary.Raw["innerExpCode"]) + if current != plan.RemoteInnerCode { + return models.SYBInnerCodeNeedsCheck, "原商品入库码在规划后变化,停止回写" + } + for _, target := range items { + if target.DetailID == 0 { + continue + } + remote, found := stockDetail(stock, target.DetailID) + if !found || NormalizeSpecKey(remote.ProductSpec) != NormalizeSpecKey(plan.SYBSpec) || rawText(remote.Raw["purchasePlatform"]) != plan.PurchasePlatform || rawText(remote.Raw["purchaseCode"]) != plan.PurchaseCode || rawText(remote.Raw["innerExpCode"]) != target.RemoteCode { + return models.SYBInnerCodeNeedsCheck, "SYB 商品明细或既有入库码在规划后变化,停止回写" + } + } + if plan.ReplaceOldCodeCount > 0 && current != "" { + if err := checkpoint(db, recordID, "before", "delete_old", items); err != nil { + return models.SYBInnerCodeNeedsCheck, "删除旧码前检查点失败" + } + if err := writer.DeleteInnerCode(ctx, plan.DetailID); err != nil { + return writeFailure(err, "删除旧入库码") + } + if err := checkpoint(db, recordID, "after", "delete_old", items); err != nil { + return models.SYBInnerCodeNeedsCheck, "删除旧码后检查点失败" + } + } + for index := range items { + target := &items[index] + if target.DetailID > 0 && countCode(stock, target.Code) == 1 && detailCode(stock, target.DetailID) == target.Code { + continue + } + if target.DetailID == 0 { + target.Source = "created" + if err := checkpoint(db, recordID, "before", "create_detail", items); err != nil { + return models.SYBInnerCodeNeedsCheck, "创建明细前检查点失败" + } + id, err := writer.CreateInnerCodeDetail(ctx, plan.StockID, fmt.Sprintf("档口入库码-%d-%d", recordID, index+1)) + if err != nil { + return writeFailure(err, "创建零价明细") + } + target.DetailID = id + if err := checkpoint(db, recordID, "after", "create_detail", items); err != nil { + return models.SYBInnerCodeNeedsCheck, "创建明细后检查点失败" + } + } + if err := checkpoint(db, recordID, "before", "write_code", items); err != nil { + return models.SYBInnerCodeNeedsCheck, "写码前检查点失败" + } + if err := writer.UpdateDetailCode(ctx, plan.StockID, target.DetailID, target.Code); err != nil { + return writeFailure(err, "写入入库码") + } + verified, _, err := readStock(ctx, writer, plan.StockID, plan.DetailID) + if err != nil { + return models.SYBInnerCodeNeedsCheck, "写入响应后重读失败,禁止自动重试" + } + if countCode(verified, target.Code) != 1 || detailCode(verified, target.DetailID) != target.Code { + return models.SYBInnerCodeNeedsCheck, "写后入库码未唯一出现在预期明细,禁止自动重试" + } + stock = verified + if err := checkpoint(db, recordID, "after", "write_code", items); err != nil { + return models.SYBInnerCodeNeedsCheck, "写入确认后检查点失败" + } + } + return models.SYBInnerCodeUpdated, fmt.Sprintf("回写完成:%d 个入库码均已逐件回读确认", len(items)) +} + +func writeFailure(err error, action string) (string, string) { + if errors.Is(err, sybclient.ErrWriteResultUnknown) { + return models.SYBInnerCodeNeedsCheck, action + "结果不明确,禁止自动重试" + } + return models.SYBInnerCodeFailed, action + "失败:" + compact(err.Error(), 800) +} +func checkpoint(db *gorm.DB, recordID uint64, phase, action string, state any) error { + raw, err := json.Marshal(state) + if err != nil { + return err + } + return db.Transaction(func(tx *gorm.DB) error { + var max int + tx.Model(&models.SYBInnerCodeCheckpoint{}).Where("record_id = ?", recordID).Select("COALESCE(MAX(sequence),0)").Scan(&max) + return tx.Create(&models.SYBInnerCodeCheckpoint{RecordID: recordID, Sequence: max + 1, Phase: phase, Action: action, StateJSON: string(raw)}).Error + }) +} +func readStock(ctx context.Context, reader MatchReader, stockID, detailID int64) (sybclient.StockDetail, sybclient.DetailItem, error) { + stocks, err := reader.DetailListByStock(ctx, []int64{stockID}) + if err != nil { + return sybclient.StockDetail{}, sybclient.DetailItem{}, err + } + if len(stocks) != 1 || stocks[0].ID != stockID { + return sybclient.StockDetail{}, sybclient.DetailItem{}, fmt.Errorf("货运单不唯一") + } + var found []sybclient.DetailItem + for _, item := range stocks[0].Details { + if item.ID == detailID { + found = append(found, item) + } + } + if len(found) != 1 { + return sybclient.StockDetail{}, sybclient.DetailItem{}, fmt.Errorf("商品明细不唯一") + } + return stocks[0], found[0], nil +} +func countCode(stock sybclient.StockDetail, code string) int { + count := 0 + for _, item := range stock.Details { + if rawText(item.Raw["innerExpCode"]) == code { + count++ + } + } + return count +} +func detailCode(stock sybclient.StockDetail, id int64) string { + if item, ok := stockDetail(stock, id); ok { + return rawText(item.Raw["innerExpCode"]) + } + return "" +} +func stockDetail(stock sybclient.StockDetail, id int64) (sybclient.DetailItem, bool) { + for _, item := range stock.Details { + if item.ID == id { + return item, true + } + } + return sybclient.DetailItem{}, false +} + +func Recheck(ctx context.Context, db *gorm.DB, reader MatchReader, recordID uint64) (string, string, error) { + var record models.SYBInnerCodeRecord + if err := db.Preload("Items").First(&record, recordID).Error; err != nil { + return "", "", err + } + if record.Status != models.SYBInnerCodeNeedsCheck { + return "", "", conflict("只有需复核记录可以执行只读复核") + } + var plan models.SYBInnerCodePlan + if err := db.First(&plan, "record_id = ?", recordID).Error; err != nil { + return "", "", err + } + stock, _, err := readStock(ctx, reader, plan.StockID, plan.DetailID) + if err != nil { + return "", "", err + } + all, none := true, true + for _, item := range record.Items { + count := countCode(stock, item.Code) + if count != 1 { + all = false + } + if count > 0 { + none = false + } + } + status, message := models.SYBInnerCodeNeedsCheck, "远端状态仍不明确,保持需复核" + if all { + status = models.SYBInnerCodeUpdated + message = "只读复核确认全部入库码已正确写入" + } else if none { + status = models.SYBInnerCodeReady + message = "只读复核确认未写入;如需回写必须重新人工确认" + } + err = db.Model(&models.SYBInnerCodeRecord{}).Where("id = ? AND status = ?", recordID, models.SYBInnerCodeNeedsCheck).Updates(map[string]any{"status": status, "result_message": message}).Error + return status, message, err +} + +func RecoverInterrupted(db *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("status = ?", models.SYBInnerCodeQueued).Updates(map[string]any{"status": models.SYBInnerCodeReady, "apply_batch_id": nil, "result_message": "服务重启,未开始的队列已释放,请重新确认"}).Error; err != nil { + return err + } + if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("status = ?", models.SYBInnerCodeApplying).Updates(map[string]any{"status": models.SYBInnerCodeNeedsCheck, "result_message": "服务在远端动作期间重启,只允许只读复核"}).Error; err != nil { + return err + } + return tx.Model(&models.SYBInnerCodeApplyBatch{}).Where("status IN ?", []string{"queued", "running"}).Update("status", "interrupted").Error + }) +} +func interruptBatch(db *gorm.DB, batchID, message string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("apply_batch_id = ? AND status = ?", batchID, models.SYBInnerCodeApplying).Updates(map[string]any{"status": models.SYBInnerCodeNeedsCheck, "result_message": "后台异常中断,只允许只读复核"}).Error; err != nil { + return err + } + if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("apply_batch_id = ? AND status = ?", batchID, models.SYBInnerCodeQueued).Updates(map[string]any{"status": models.SYBInnerCodeReady, "apply_batch_id": nil, "result_message": "后台执行未开始,请重新确认"}).Error; err != nil { + return err + } + return tx.Model(&models.SYBInnerCodeApplyBatch{}).Where("id = ?", batchID).Updates(map[string]any{"status": "interrupted", "finished_at": time.Now().UTC()}).Error + }) +} diff --git a/server/app/goauto/sybinnercode/apply_test.go b/server/app/goauto/sybinnercode/apply_test.go new file mode 100644 index 0000000..3c2cb30 --- /dev/null +++ b/server/app/goauto/sybinnercode/apply_test.go @@ -0,0 +1,194 @@ +package sybinnercode + +import ( + "context" + "errors" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybclient" + + "github.com/google/uuid" +) + +type fakeWriter struct { + fakeMatchReader + deleteCalls, createCalls, updateCalls int + unknownAfterUpdate bool + nextID int64 +} + +func (f *fakeWriter) DeleteInnerCode(_ context.Context, detailID int64) error { + f.deleteCalls++ + for stockID, stock := range f.stocks { + for i := range stock.Details { + if stock.Details[i].ID == detailID { + stock.Details[i].Raw["innerExpCode"] = "" + f.stocks[stockID] = stock + return nil + } + } + } + return errors.New("detail missing") +} +func (f *fakeWriter) CreateInnerCodeDetail(_ context.Context, stockID int64, _ string) (int64, error) { + f.createCalls++ + f.nextID++ + stock := f.stocks[stockID] + stock.Details = append(stock.Details, detail(f.nextID, "", 1, "", "", "")) + f.stocks[stockID] = stock + return f.nextID, nil +} +func (f *fakeWriter) UpdateDetailCode(_ context.Context, stockID, detailID int64, code string) error { + f.updateCalls++ + stock := f.stocks[stockID] + for i := range stock.Details { + if stock.Details[i].ID == detailID { + stock.Details[i].Raw["innerExpCode"] = code + f.stocks[stockID] = stock + if f.unknownAfterUpdate { + return sybclient.ErrWriteResultUnknown + } + return nil + } + } + return errors.New("detail missing") +} + +func readyRecordAndWriter(t *testing.T, code2 bool) (*Service, *fakeWriter, uint64) { + t.Helper() + db := testDB(t) + items := []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: "IC-1", Ordinal: 1, SourceRow: 2}} + qty := 1 + if code2 { + items = append(items, models.SYBInnerCodeItem{BusinessDate: "2026-08-28", Code: "IC-2", Ordinal: 2, SourceRow: 3}) + qty = 2 + } + record := models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: "ORDER-1", Stall: "A#1", SpecKey: "黑色,L", SpecRaw: "黑色,L", SourceSKURaw: "SKU-1", Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: items} + jobID := createMatchJob(t, db, []models.SYBInnerCodeRecord{record}) + writer := &fakeWriter{fakeMatchReader: fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10, Code: "ORDER-1"}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Code: "ORDER-1", Details: []sybclient.DetailItem{detail(20, "黑色,L", qty, "SKU-1", "A#1", "")}}}}, nextID: 20} + if err := RunMatchJob(context.Background(), db, writer, jobID); err != nil { + t.Fatal(err) + } + var saved models.SYBInnerCodeRecord + if err := db.First(&saved).Error; err != nil { + t.Fatal(err) + } + return NewService(db), writer, saved.ID +} + +func TestQueueAndRunApplyWritesEachCodeWithCheckpoints(t *testing.T) { + service, writer, id := readyRecordAndWriter(t, true) + preview, err := service.PreviewApply(context.Background(), []uint64{id}) + if err != nil { + t.Fatal(err) + } + if preview.Records != 1 || preview.InboundCodes != 2 || preview.PlaceholderDetails != 1 { + t.Fatalf("preview=%+v", preview) + } + result, err := service.QueueApply(context.Background(), 1, ApplyRequest{RequestID: uuid.NewString(), IDs: []uint64{id}}) + if err != nil { + t.Fatal(err) + } + if err := RunApplyBatch(context.Background(), service.db, writer, result.BatchID); err != nil { + t.Fatal(err) + } + var record models.SYBInnerCodeRecord + service.db.First(&record, id) + if record.Status != models.SYBInnerCodeUpdated { + t.Fatalf("record=%+v", record) + } + if writer.createCalls != 1 || writer.updateCalls != 2 { + t.Fatalf("create=%d update=%d", writer.createCalls, writer.updateCalls) + } + if countCode(writer.stocks[10], "IC-1") != 1 || countCode(writer.stocks[10], "IC-2") != 1 { + t.Fatalf("stock=%+v", writer.stocks[10]) + } + var checkpoints int64 + service.db.Model(&models.SYBInnerCodeCheckpoint{}).Where("record_id = ?", id).Count(&checkpoints) + if checkpoints < 6 { + t.Fatalf("checkpoints=%d", checkpoints) + } +} + +func TestUnknownWriteResultNeedsCheckAndReadOnlyRecheckConfirms(t *testing.T) { + service, writer, id := readyRecordAndWriter(t, false) + writer.unknownAfterUpdate = true + result, err := service.QueueApply(context.Background(), 1, ApplyRequest{RequestID: uuid.NewString(), IDs: []uint64{id}}) + if err != nil { + t.Fatal(err) + } + if err := RunApplyBatch(context.Background(), service.db, writer, result.BatchID); err != nil { + t.Fatal(err) + } + var record models.SYBInnerCodeRecord + service.db.First(&record, id) + if record.Status != models.SYBInnerCodeNeedsCheck || writer.updateCalls != 1 { + t.Fatalf("record=%+v calls=%d", record, writer.updateCalls) + } + status, _, err := Recheck(context.Background(), service.db, writer, id) + if err != nil { + t.Fatal(err) + } + if status != models.SYBInnerCodeUpdated || writer.updateCalls != 1 { + t.Fatalf("status=%s calls=%d", status, writer.updateCalls) + } +} + +func TestApplyRequestIsIdempotent(t *testing.T) { + service, _, id := readyRecordAndWriter(t, false) + request := ApplyRequest{RequestID: uuid.NewString(), IDs: []uint64{id}} + first, err := service.QueueApply(context.Background(), 1, request) + if err != nil { + t.Fatal(err) + } + second, err := service.QueueApply(context.Background(), 1, request) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatalf("first=%+v second=%+v", first, second) + } + var count int64 + service.db.Model(&models.SYBInnerCodeApplyBatch{}).Count(&count) + if count != 1 { + t.Fatalf("batches=%d", count) + } +} + +func TestDatabaseLeaseAllowsOnlyOneOwner(t *testing.T) { + db := testDB(t) + first, err := acquireLease(context.Background(), db, "owner-a") + if err != nil || !first { + t.Fatalf("first=%v err=%v", first, err) + } + second, err := acquireLease(context.Background(), db, "owner-b") + if err != nil { + t.Fatal(err) + } + if second { + t.Fatal("second owner acquired active lease") + } + releaseLease(db, "owner-a") + second, err = acquireLease(context.Background(), db, "owner-b") + if err != nil || !second { + t.Fatalf("after release=%v err=%v", second, err) + } +} + +func TestRecoverInterruptedSeparatesQueuedAndApplying(t *testing.T) { + db := testDB(t) + records := []models.SYBInnerCodeRecord{{BusinessDate: "2026-08-28", OrderNumber: "Q", Stall: "", SpecKey: "Q", SpecRaw: "Q", Status: models.SYBInnerCodeQueued, CreatedBy: 1, ImportRequestID: uuid.NewString()}, {BusinessDate: "2026-08-28", OrderNumber: "A", Stall: "", SpecKey: "A", SpecRaw: "A", Status: models.SYBInnerCodeApplying, CreatedBy: 1, ImportRequestID: uuid.NewString()}} + for i := range records { + if err := db.Create(&records[i]).Error; err != nil { + t.Fatal(err) + } + } + if err := RecoverInterrupted(db); err != nil { + t.Fatal(err) + } + db.Order("id").Find(&records) + if records[0].Status != models.SYBInnerCodeReady || records[1].Status != models.SYBInnerCodeNeedsCheck { + t.Fatalf("records=%+v", records) + } +} diff --git a/server/app/goauto/sybinnercode/handler.go b/server/app/goauto/sybinnercode/handler.go new file mode 100644 index 0000000..925d5e6 --- /dev/null +++ b/server/app/goauto/sybinnercode/handler.go @@ -0,0 +1,256 @@ +package sybinnercode + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + + "go-admin/app/goauto/models" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/sdk/pkg" + "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user" + "gorm.io/gorm" +) + +type Handler struct{ DB *gorm.DB } + +func (h Handler) List(c *gin.Context) { + service, ok := h.service(c) + if !ok { + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "100")) + result, err := service.List(c.Request.Context(), ListRequest{DateFrom: strings.TrimSpace(c.Query("dateFrom")), DateTo: strings.TrimSpace(c.Query("dateTo")), Keyword: strings.TrimSpace(c.Query("keyword")), Status: strings.TrimSpace(c.Query("status")), Page: page, PageSize: pageSize}) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": result}) +} +func (h Handler) Detail(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("recordId"), 10, 64) + if err != nil || id == 0 { + writeError(c, invalid("recordId 无效")) + return + } + service, ok := h.service(c) + if !ok { + return + } + result, err := service.Detail(c.Request.Context(), id) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": result}}) +} +func (h Handler) Import(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, MaxUploadBytes+(1<<20)) + file, err := c.FormFile("file") + if err != nil { + writeError(c, invalid("没有收到 10MB 以内的 Excel")) + return + } + src, err := file.Open() + if err != nil { + writeError(c, invalid("无法打开 Excel")) + return + } + defer src.Close() + head := make([]byte, 8) + n, _ := io.ReadFull(src, head) + if err := ValidateUpload(file.Filename, file.Size, head[:n]); err != nil { + writeError(c, err) + return + } + if _, err := src.Seek(0, io.SeekStart); err != nil { + writeError(c, internal(err)) + return + } + service, ok := h.service(c) + if !ok { + return + } + result, err := service.Import(c.Request.Context(), src, file.Filename, uint64(user.GetUserId(c)), c.PostForm("requestId")) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": result}) +} +func (h Handler) Delete(c *gin.Context) { + var request DeleteRequest + if err := decode(c, &request); err != nil { + writeError(c, invalid("请求 JSON 无效")) + return + } + service, ok := h.service(c) + if !ok { + return + } + result, err := service.Delete(c.Request.Context(), uint64(user.GetUserId(c)), request) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": result}) +} + +func (h Handler) MatchJob(c *gin.Context) { + service, ok := h.service(c) + if !ok { + return + } + var job models.SYBInnerCodeMatchJob + if err := service.db.WithContext(c.Request.Context()).First(&job, "id = ?", c.Param("jobId")).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + writeError(c, &ServiceError{Code: CodeNotFound, Message: "匹配任务不存在"}) + } else { + writeError(c, internal(err)) + } + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": job}}) +} + +func (h Handler) ApplyPreview(c *gin.Context) { + var request ApplyPreviewRequest + if err := decode(c, &request); err != nil { + writeError(c, invalid("请求 JSON 无效")) + return + } + service, ok := h.service(c) + if !ok { + return + } + result, err := service.PreviewApply(c.Request.Context(), request.IDs) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": result}) +} +func (h Handler) Apply(c *gin.Context) { + var request ApplyRequest + if err := decode(c, &request); err != nil { + writeError(c, invalid("请求 JSON 无效")) + return + } + service, ok := h.service(c) + if !ok { + return + } + result, err := service.QueueApply(c.Request.Context(), uint64(user.GetUserId(c)), request) + if err != nil { + writeError(c, err) + return + } + DefaultApplyRunner.Start(service.db, result.BatchID) + c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": result}) +} +func (h Handler) ApplyBatch(c *gin.Context) { + service, ok := h.service(c) + if !ok { + return + } + var batch models.SYBInnerCodeApplyBatch + if err := service.db.WithContext(c.Request.Context()).First(&batch, "id = ?", c.Param("batchId")).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + writeError(c, &ServiceError{Code: CodeNotFound, Message: "回写批次不存在"}) + } else { + writeError(c, internal(err)) + } + return + } + var items []models.SYBInnerCodeApplyItem + if err := service.db.Where("batch_id = ?", batch.ID).Order("id").Find(&items).Error; err != nil { + writeError(c, internal(err)) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"batch": batch, "items": items}}) +} +func (h Handler) Recheck(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("recordId"), 10, 64) + if err != nil || id == 0 { + writeError(c, invalid("recordId 无效")) + return + } + service, ok := h.service(c) + if !ok { + return + } + reader, err := DefaultMatcher.Factory(c.Request.Context(), service.db) + if err != nil { + writeError(c, internal(err)) + return + } + status, message, err := Recheck(c.Request.Context(), service.db, reader, id) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"status": status, "message": message}}) +} +func (h Handler) Rematch(c *gin.Context) { + var request RematchRequest + if err := decode(c, &request); err != nil { + writeError(c, invalid("请求 JSON 无效")) + return + } + service, ok := h.service(c) + if !ok { + return + } + result, err := service.QueueRematch(c.Request.Context(), request) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": result}) +} +func (h Handler) service(c *gin.Context) (*Service, bool) { + db := h.DB + var err error + if db == nil { + db, err = pkg.GetOrm(c) + } + if err != nil { + writeError(c, internal(err)) + return nil, false + } + return NewService(db).WithMatchEnqueuer(DefaultMatcher), true +} +func decode(c *gin.Context, target any) error { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("one object required") + } + return nil +} +func writeError(c *gin.Context, err error) { + target := AsServiceError(err) + status := http.StatusInternalServerError + switch target.Code { + case CodeInvalidRequest: + status = http.StatusUnprocessableEntity + case CodeConflict: + status = http.StatusConflict + case CodeNotFound: + status = http.StatusNotFound + } + payload := gin.H{"code": target.Code, "message": target.Message} + if target.Details != nil { + payload["data"] = target.Details + } + c.JSON(status, payload) +} diff --git a/server/app/goauto/sybinnercode/match.go b/server/app/goauto/sybinnercode/match.go new file mode 100644 index 0000000..31c63f5 --- /dev/null +++ b/server/app/goauto/sybinnercode/match.go @@ -0,0 +1,344 @@ +package sybinnercode + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybclient" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type MatchReader interface { + ListByOrderNumber(context.Context, string) ([]sybclient.StockRow, error) + DetailListByStock(context.Context, []int64) ([]sybclient.StockDetail, error) +} +type MatchReaderFactory func(context.Context, *gorm.DB) (MatchReader, error) + +type Matcher struct{ Factory MatchReaderFactory } + +func (m Matcher) Start(_ context.Context, db *gorm.DB, jobID string) error { + if uuid.Validate(jobID) != nil { + return invalid("matchJobId 无效") + } + if m.Factory == nil { + return internal(errors.New("match reader factory not configured")) + } + go m.runBackground(db, jobID) + return nil +} +func (m Matcher) runBackground(db *gorm.DB, jobID string) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + reader, err := m.Factory(ctx, db) + if err == nil { + 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 + } +} + +func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID string) error { + 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 { + return claimed.Error + } + if claimed.RowsAffected == 0 { + var existing models.SYBInnerCodeMatchJob + if err := db.WithContext(ctx).First(&existing, "id = ?", jobID).Error; err != nil { + return err + } + if existing.Status == "succeeded" || existing.Status == "running" { + return nil + } + return conflict("匹配任务状态不允许执行") + } + var job models.SYBInnerCodeMatchJob + if err := db.WithContext(ctx).First(&job, "id = ?", jobID).Error; err != nil { + return err + } + var recordIDs []uint64 + if err := json.Unmarshal([]byte(job.RecordIDsJSON), &recordIDs); err != nil || len(recordIDs) == 0 { + 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 { + return err + } + used, err := loadReservedDetails(ctx, db, records) + if err != nil { + return err + } + ready, failed := 0, 0 + for _, record := range records { + plan, status, message, planErr := planRecord(ctx, reader, record, used) + if planErr != nil { + status = models.SYBInnerCodeFailed + message = "读取 SYB 失败:" + compact(planErr.Error(), 900) + } + saveErr := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("record_id = ?", record.ID).Delete(&models.SYBInnerCodePlan{}).Error; err != nil { + return err + } + if plan != nil { + plan.RecordID = record.ID + plan.MatchJobID = jobID + plan.PlannedAt = time.Now().UTC() + if err := tx.Create(plan).Error; err != nil { + return err + } + } + return tx.Model(&models.SYBInnerCodeRecord{}).Where("id = ? AND status IN ?", record.ID, []string{models.SYBInnerCodePending, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped, models.SYBInnerCodeMatching}).Updates(map[string]any{"status": status, "result_message": message}).Error + }) + if saveErr != nil { + return saveErr + } + if status == models.SYBInnerCodeReady || status == models.SYBInnerCodeAlreadyFilled { + ready++ + } else { + failed++ + } + db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", jobID).Updates(map[string]any{"processed": gorm.Expr("processed + 1"), "ready": ready, "failed": failed}) + } + 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 +} + +func loadReservedDetails(ctx context.Context, db *gorm.DB, selected []models.SYBInnerCodeRecord) (map[int64]bool, error) { + selectedIDs := map[uint64]bool{} + for _, r := range selected { + selectedIDs[r.ID] = true + } + var plans []models.SYBInnerCodePlan + if err := db.WithContext(ctx).Joins("JOIN syb_inner_code_record r ON r.id = syb_inner_code_plan.record_id").Where("r.status IN ?", []string{models.SYBInnerCodeReady, models.SYBInnerCodeQueued, models.SYBInnerCodeApplying, models.SYBInnerCodeUpdated, models.SYBInnerCodeAlreadyFilled, models.SYBInnerCodeNeedsCheck}).Find(&plans).Error; err != nil { + return nil, err + } + used := map[int64]bool{} + for _, p := range plans { + if !selectedIDs[p.RecordID] { + used[p.DetailID] = true + var items []plannedRemoteItem + _ = json.Unmarshal([]byte(p.RemoteItemsJSON), &items) + for _, item := range items { + if item.DetailID > 0 { + used[item.DetailID] = true + } + } + } + } + return used, nil +} + +type plannedRemoteItem struct { + Code string `json:"code"` + DetailID int64 `json:"detailId"` + Source string `json:"source"` + RemoteCode string `json:"remoteCode"` +} + +func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerCodeRecord, used map[int64]bool) (*models.SYBInnerCodePlan, string, string, error) { + rows, err := reader.ListByOrderNumber(ctx, record.OrderNumber) + if err != nil { + return nil, "", "", err + } + if len(rows) == 0 { + return nil, models.SYBInnerCodeFailed, "SYB 未找到货运单", nil + } + if len(rows) != 1 { + return nil, models.SYBInnerCodeSkipped, "同一订单号命中多张 SYB 货运单,不能自动选择", nil + } + stocks, err := reader.DetailListByStock(ctx, []int64{rows[0].ID}) + if err != nil { + return nil, "", "", err + } + if len(stocks) != 1 || stocks[0].ID != rows[0].ID { + return nil, models.SYBInnerCodeFailed, "SYB 没有唯一返回货运单详情", nil + } + stock := stocks[0] + eligible := make([]sybclient.DetailItem, 0) + for _, item := range stock.Details { + if used[item.ID] || rawText(item.Raw["purchasePlatform"]) != "" || rawText(item.Raw["purchaseCode"]) != "" { + continue + } + eligible = append(eligible, item) + } + if len(eligible) == 0 { + return nil, models.SYBInnerCodeSkipped, "没有可用的 SYB 商品明细", nil + } + specMatches := matchSpec(record.SpecRaw, eligible) + if len(specMatches) == 0 { + return nil, models.SYBInnerCodeSkipped, "候选商品中没有相同规格", nil + } + matches, reason := matchEvidence(record.Stall, record.SourceSKURaw, specMatches) + if reason != "" { + return nil, models.SYBInnerCodeSkipped, reason, nil + } + count := len(record.Items) + if count == 0 { + return nil, models.SYBInnerCodeSkipped, "记录没有入库码", nil + } + var chosen []sybclient.DetailItem + if len(matches) == 1 && matches[0].ProductQty == count { + chosen = []sybclient.DetailItem{matches[0]} + } else if len(matches) == count && count > 1 { + sort.Slice(matches, func(i, j int) bool { return matches[i].ID < matches[j].ID }) + for _, item := range matches { + if item.ProductQty != 1 { + return nil, models.SYBInnerCodeSkipped, "相同规格候选数量不明确,不能自动分配", nil + } + } + chosen = matches + } else if len(matches) > 1 { + return nil, models.SYBInnerCodeSkipped, "同一订单存在多条相同规格候选商品,不能自动选择", nil + } else { + return nil, models.SYBInnerCodeSkipped, fmt.Sprintf("SYB 商品数量与入库码数量不一致(%d/%d)", matches[0].ProductQty, count), nil + } + primary := chosen[0] + items := make([]plannedRemoteItem, 0, count) + placeholder := 0 + allFilled := true + for i, code := range record.Items { + detailID := int64(0) + source := "placeholder" + remote := "" + if i < len(chosen) { + detailID = chosen[i].ID + source = "existing" + remote = rawText(chosen[i].Raw["innerExpCode"]) + } else if i == 0 { + detailID = primary.ID + source = "original" + remote = rawText(primary.Raw["innerExpCode"]) + } else { + placeholder++ + } + if remote != code.Code { + allFilled = false + } + items = append(items, plannedRemoteItem{Code: code.Code, DetailID: detailID, Source: source, RemoteCode: remote}) + } + for _, item := range chosen { + used[item.ID] = true + } + rawItems, _ := json.Marshal(items) + evidence, _ := json.Marshal(map[string]any{"stockId": stock.ID, "detailId": primary.ID, "spec": primary.ProductSpec, "sku": rawText(primary.Raw["sku"]), "variationSku": rawText(primary.Raw["variationSku"]), "items": items}) + sum := sha256.Sum256(evidence) + replace := 0 + if old := rawText(primary.Raw["innerExpCode"]); old != "" && !containsCode(record.Items, old) { + replace = 1 + } + plan := &models.SYBInnerCodePlan{StockID: stock.ID, DetailID: primary.ID, SYBSpec: primary.ProductSpec, SYBSKU: rawText(primary.Raw["sku"]), SYBVariationSKU: rawText(primary.Raw["variationSku"]), PurchasePlatform: rawText(primary.Raw["purchasePlatform"]), PurchaseCode: rawText(primary.Raw["purchaseCode"]), RemoteInnerCode: rawText(primary.Raw["innerExpCode"]), RemoteItemsJSON: string(rawItems), PlaceholderCount: placeholder, ReplaceOldCodeCount: replace, EvidenceHash: hex.EncodeToString(sum[:])} + if allFilled { + return plan, models.SYBInnerCodeAlreadyFilled, "远端已存在全部入库码,无需重复写入", nil + } + return plan, models.SYBInnerCodeReady, "唯一匹配,等待确认回写", nil +} + +func matchSpec(spec string, items []sybclient.DetailItem) []sybclient.DetailItem { + result := []sybclient.DetailItem{} + for _, item := range items { + if item.ProductSpec == spec { + result = append(result, item) + } + } + if len(result) > 0 { + return result + } + key := NormalizeSpecKey(spec) + for _, item := range items { + if NormalizeSpecKey(item.ProductSpec) == key { + result = append(result, item) + } + } + return result +} +func matchEvidence(stall, sourceSKU string, items []sybclient.DetailItem) ([]sybclient.DetailItem, string) { + sourceSKU = strings.TrimSpace(sourceSKU) + if sourceSKU != "" { + skuMatches := []sybclient.DetailItem{} + for _, item := range items { + if strings.TrimSpace(rawText(item.Raw["sku"])) == sourceSKU || strings.TrimSpace(rawText(item.Raw["variationSku"])) == sourceSKU { + skuMatches = append(skuMatches, item) + } + } + if len(skuMatches) > 1 { + return nil, "原始 SKU 候选重复,不能自动选择" + } + if len(skuMatches) == 1 { + stallMatches := strictStall(stall, items) + if len(stallMatches) > 1 { + return nil, "档口货号候选重复,不能自动选择" + } + if len(stallMatches) == 1 && stallMatches[0].ID != skuMatches[0].ID { + return nil, "原始 SKU 与档口货号冲突,不能自动选择" + } + return skuMatches, "" + } + } + stallMatches := strictStall(stall, items) + if len(stallMatches) > 0 { + return stallMatches, "" + } + fallback := []sybclient.DetailItem{} + for _, item := range items { + if rawText(item.Raw["sku"]) == "" && rawText(item.Raw["variationSku"]) == "" { + fallback = append(fallback, item) + } + } + return fallback, "" +} +func strictStall(stall string, items []sybclient.DetailItem) []sybclient.DetailItem { + stall = strings.TrimSpace(stall) + if stall == "" { + return nil + } + result := []sybclient.DetailItem{} + name, article, has := strings.Cut(stall, "#") + for _, item := range items { + blob := rawText(item.Raw["sku"]) + " " + rawText(item.Raw["variationSku"]) + " " + item.ProductSpec + if strings.Contains(blob, stall) || (has && strings.Contains(blob, strings.TrimSpace(name)) && strings.Contains(blob, strings.TrimSpace(article))) { + result = append(result, item) + } + } + return result +} +func rawText(value any) string { + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + case json.Number: + return v.String() + case float64: + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.6f", v), "0"), ".") + default: + return "" + } +} +func containsCode(items []models.SYBInnerCodeItem, value string) bool { + for _, item := range items { + if item.Code == value { + return true + } + } + return false +} +func compact(value string, max int) string { + value = strings.TrimSpace(value) + runes := []rune(value) + if len(runes) > max { + return string(runes[:max]) + } + return value +} diff --git a/server/app/goauto/sybinnercode/match_test.go b/server/app/goauto/sybinnercode/match_test.go new file mode 100644 index 0000000..3ef7209 --- /dev/null +++ b/server/app/goauto/sybinnercode/match_test.go @@ -0,0 +1,179 @@ +package sybinnercode + +import ( + "context" + "encoding/json" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybclient" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type fakeMatchReader struct { + rows map[string][]sybclient.StockRow + stocks map[int64]sybclient.StockDetail + listCalls, detailCalls int +} + +func (f *fakeMatchReader) ListByOrderNumber(_ context.Context, order string) ([]sybclient.StockRow, error) { + f.listCalls++ + return f.rows[order], nil +} +func (f *fakeMatchReader) DetailListByStock(_ context.Context, ids []int64) ([]sybclient.StockDetail, error) { + f.detailCalls++ + result := make([]sybclient.StockDetail, 0, len(ids)) + for _, id := range ids { + if stock, ok := f.stocks[id]; ok { + result = append(result, stock) + } + } + return result, nil +} + +func createMatchJob(t *testing.T, db *gorm.DB, records []models.SYBInnerCodeRecord) string { + t.Helper() + for i := range records { + if err := db.Create(&records[i]).Error; err != nil { + t.Fatal(err) + } + } + id := uuid.NewString() + ids := make([]uint64, 0, len(records)) + for _, record := range records { + ids = append(ids, record.ID) + } + raw, _ := json.Marshal(ids) + job := models.SYBInnerCodeMatchJob{ID: id, WorkKey: "test:" + id, BusinessDate: "2026-08-28", RecordIDsJSON: string(raw), Status: "pending", Total: len(records)} + if err := db.Create(&job).Error; err != nil { + t.Fatal(err) + } + return id +} + +func TestRunMatchJobOnlyProcessesScopedRecords(t *testing.T) { + db := testDB(t) + selected := matchRecord("ORDER-1", "SKU-1", "A#1", "IC-1") + jobID := createMatchJob(t, db, []models.SYBInnerCodeRecord{selected}) + outside := matchRecord("ORDER-2", "SKU-2", "B#2", "IC-2") + if err := db.Create(&outside).Error; err != nil { + t.Fatal(err) + } + reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Details: []sybclient.DetailItem{detail(20, "黑色,L", 1, "SKU-1", "A#1", "")}}}} + if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil { + t.Fatal(err) + } + var untouched models.SYBInnerCodeRecord + if err := db.First(&untouched, outside.ID).Error; err != nil { + t.Fatal(err) + } + if untouched.Status != models.SYBInnerCodePending { + t.Fatalf("out-of-scope record changed to %s", untouched.Status) + } +} +func detail(id int64, spec string, qty int, sku, variation, code string) sybclient.DetailItem { + return sybclient.DetailItem{ID: id, ProductSpec: spec, ProductQty: qty, Raw: map[string]any{"sku": sku, "variationSku": variation, "innerExpCode": code, "purchasePlatform": "", "purchaseCode": ""}} +} + +func matchRecord(order, sku, stall, code string) models.SYBInnerCodeRecord { + return models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: order, Stall: stall, SpecKey: "黑色,L", SpecRaw: "黑色,L", SourceSKURaw: sku, Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: code, Ordinal: 1, SourceRow: 2}}} +} + +func TestRunMatchJobPlansMultiItemWithoutAnyWriteSurface(t *testing.T) { + db := testDB(t) + record := models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: "ORDER-1", Stall: "A#1", SpecKey: "黑色,L", SpecRaw: "黑色,L", SourceSKURaw: "SKU-1", Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: "IC-1", Ordinal: 1, SourceRow: 2}, {BusinessDate: "2026-08-28", Code: "IC-2", Ordinal: 2, SourceRow: 3}}} + jobID := createMatchJob(t, db, []models.SYBInnerCodeRecord{record}) + reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10, Code: "ORDER-1"}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Code: "ORDER-1", Details: []sybclient.DetailItem{detail(20, "黑色,L", 2, "SKU-1", "A#1", "")}}}} + if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil { + t.Fatal(err) + } + var saved models.SYBInnerCodeRecord + if err := db.Preload("Items").First(&saved).Error; err != nil { + t.Fatal(err) + } + if saved.Status != models.SYBInnerCodeReady { + t.Fatalf("status=%s message=%s", saved.Status, saved.ResultMessage) + } + var plan models.SYBInnerCodePlan + if err := db.First(&plan, "record_id = ?", saved.ID).Error; err != nil { + t.Fatal(err) + } + if plan.DetailID != 20 || plan.PlaceholderCount != 1 || plan.EvidenceHash == "" { + t.Fatalf("plan=%+v", plan) + } + if reader.listCalls != 1 || reader.detailCalls != 1 { + t.Fatalf("calls list=%d detail=%d", reader.listCalls, reader.detailCalls) + } + if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil { + t.Fatal(err) + } + if reader.listCalls != 1 { + t.Fatalf("completed job reran") + } +} + +func TestRunMatchJobRejectsSKUAndStallConflict(t *testing.T) { + db := testDB(t) + record := models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: "ORDER-1", Stall: "B#2", SpecKey: "黑色,L", SpecRaw: "黑色,L", SourceSKURaw: "SKU-A", Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: "IC-1", Ordinal: 1, SourceRow: 2}}} + jobID := createMatchJob(t, db, []models.SYBInnerCodeRecord{record}) + reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10, Code: "ORDER-1"}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Details: []sybclient.DetailItem{detail(20, "黑色,L", 1, "SKU-A", "A#1", ""), detail(21, "黑色,L", 1, "SKU-B", "B#2", "")}}}} + if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil { + t.Fatal(err) + } + var saved models.SYBInnerCodeRecord + db.First(&saved) + if saved.Status != models.SYBInnerCodeSkipped || saved.ResultMessage != "原始 SKU 与档口货号冲突,不能自动选择" { + t.Fatalf("record=%+v", saved) + } +} + +func TestRunMatchJobReservesDifferentDetailsForRecordsInOneBatch(t *testing.T) { + db := testDB(t) + base := func(order, sku, stall, code string) models.SYBInnerCodeRecord { + return models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: order, Stall: stall, SpecKey: "黑色,L", SpecRaw: "黑色,L", SourceSKURaw: sku, Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: code, Ordinal: 1, SourceRow: 2}}} + } + jobID := createMatchJob(t, db, []models.SYBInnerCodeRecord{base("ORDER-1", "SKU-A", "A#1", "IC-A"), base("ORDER-1", "SKU-B", "B#2", "IC-B")}) + stock := sybclient.StockDetail{ID: 10, Details: []sybclient.DetailItem{detail(20, "黑色,L", 1, "SKU-A", "A#1", ""), detail(21, "黑色,L", 1, "SKU-B", "B#2", "")}} + reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10, Code: "ORDER-1"}}}, stocks: map[int64]sybclient.StockDetail{10: stock}} + if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil { + t.Fatal(err) + } + var plans []models.SYBInnerCodePlan + db.Order("record_id").Find(&plans) + if len(plans) != 2 || plans[0].DetailID == plans[1].DetailID { + t.Fatalf("plans=%+v", plans) + } +} + +type captureStarter struct { + jobID string + committed bool +} + +func (c *captureStarter) Start(_ context.Context, db *gorm.DB, jobID string) error { + c.jobID = jobID + var count int64 + db.Model(&models.SYBInnerCodeRecord{}).Count(&count) + c.committed = count > 0 + return nil +} +func TestImportCreatesOneMatchJobAndStartsAfterCommit(t *testing.T) { + db := testDB(t) + starter := &captureStarter{} + service := NewService(db).WithMatchEnqueuer(starter) + rows := [][]any{{"2026-08-28", "ORDER-1", "店铺", "A#1", "SKU-1", "黑色,L", "IC-01", 1}} + result, err := importRows(t, service, uuid.NewString(), rows) + if err != nil { + t.Fatal(err) + } + if result.MatchJobID == "" || starter.jobID != result.MatchJobID || !starter.committed { + t.Fatalf("result=%+v starter=%+v", result, starter) + } + var count int64 + db.Model(&models.SYBInnerCodeMatchJob{}).Count(&count) + if count != 1 { + t.Fatalf("jobs=%d", count) + } +} diff --git a/server/app/goauto/sybinnercode/parser.go b/server/app/goauto/sybinnercode/parser.go new file mode 100644 index 0000000..99771c2 --- /dev/null +++ b/server/app/goauto/sybinnercode/parser.go @@ -0,0 +1,289 @@ +package sybinnercode + +import ( + "bytes" + "fmt" + "io" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/xuri/excelize/v2" +) + +const sheetName = "标签入库码映射" + +var ( + xlsxMagic = []byte{0x50, 0x4b, 0x03, 0x04} + spaces = regexp.MustCompile(`\s+`) + bracketCN = regexp.MustCompile(`【[^】]*】`) + parentheses = regexp.MustCompile(`[((][^))]*[))]`) + suggestionTail = regexp.MustCompile(`建議.*$`) + stallToken = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]*$`) + weightTail = regexp.MustCompile(`(?i)[\d.]+[-~~到至][\d.]+(?:公斤|kg).*$`) + sizeTail = regexp.MustCompile(`(?i)((?:[1-9]\d*)?XL|XXL|XS|S|M|L)$`) + filenameDate = regexp.MustCompile(`(?:^|_)(\d{8})(?:_|\.|$)`) + shortDate = regexp.MustCompile(`^(\d{1,2})[-/](\d{1,2})$`) +) + +func ValidateUpload(filename string, size int64, head []byte) error { + if size <= 0 { + return invalid("Excel 文件为空") + } + if size > MaxUploadBytes { + return invalid("Excel 文件超过 10MB 上限") + } + if !strings.EqualFold(filepath.Ext(filename), ".xlsx") { + return invalid("只允许上传 .xlsx 文件") + } + if !bytes.HasPrefix(head, xlsxMagic) { + return invalid("文件内容不是有效的 xlsx") + } + return nil +} + +func NormalizeSpecKey(value string) string { + text := strings.TrimSpace(value) + text = bracketCN.ReplaceAllString(text, "") + text = parentheses.ReplaceAllString(text, "") + text = suggestionTail.ReplaceAllString(text, "") + text = strings.TrimSpace(spaces.ReplaceAllString(text, " ")) + color, size, found := strings.Cut(text, ",") + if !found { + return spaces.ReplaceAllString(text, "") + } + tokens := strings.Fields(color) + if len(tokens) >= 2 && stallToken.MatchString(tokens[0]) { + color = strings.Join(tokens[1:], "") + } else { + color = spaces.ReplaceAllString(color, "") + } + size = strings.ReplaceAll(spaces.ReplaceAllString(size, ""), "碼", "") + size = weightTail.ReplaceAllString(size, "") + if match := sizeTail.FindStringSubmatch(size); len(match) == 2 { + size = strings.ToUpper(match[1]) + } + return color + "," + size +} + +func parseWorkbook(reader io.Reader, filename string) ([]parsedRecord, int, error) { + book, err := excelize.OpenReader(reader, excelize.Options{RawCellValue: true}) + if err != nil { + return nil, 0, invalid("无法读取 Excel:" + err.Error()) + } + defer book.Close() + if index, _ := book.GetSheetIndex(sheetName); index < 0 { + return nil, 0, invalid(fmt.Sprintf("Excel 中没有工作表 %q", sheetName)) + } + rows, err := book.Rows(sheetName) + if err != nil { + return nil, 0, internal(err) + } + defer rows.Close() + var headers map[string]int + specColumn := -1 + rowNumber := 0 + businessDate := "" + parsed := make([]struct { + record parsedRecord + code string + row int + key string + }, 0, 128) + for rows.Next() { + rowNumber++ + columns, rowErr := rows.Columns() + if rowErr != nil { + return nil, 0, invalid(fmt.Sprintf("读取第 %d 行失败", rowNumber)) + } + if headers == nil { + if rowNumber > 10 { + break + } + candidate, spec := headerMap(columns) + if _, a := candidate["生成日期"]; a { + if _, b := candidate["内部档口入库码"]; b { + if _, c := candidate["Shopee订单编号"]; c && spec >= 0 { + headers, specColumn = candidate, spec + } + } + } + continue + } + order, code := cell(columns, headers["Shopee订单编号"]), cell(columns, headers["内部档口入库码"]) + if order == "" && code == "" { + continue + } + if len(parsed) >= MaxImportRows { + return nil, 0, invalid("Excel 非空数据超过 5000 行") + } + if order == "" || code == "" { + return nil, 0, invalid(fmt.Sprintf("第 %d 行缺少 Shopee订单编号或内部档口入库码", rowNumber)) + } + date, dateErr := parseBusinessDate(named(columns, headers, "生成日期"), filename) + if dateErr != nil { + return nil, 0, invalid(fmt.Sprintf("第 %d 行生成日期无效:%v", rowNumber, dateErr)) + } + if businessDate == "" { + businessDate = date + } else if businessDate != date { + return nil, 0, invalid("同一工作簿包含多个生成日期") + } + specRaw := strings.TrimSpace(strings.NewReplacer("\r", " ", "\n", " ").Replace(cell(columns, specColumn))) + stall := stallValue(columns, headers) + shop := named(columns, headers, "店铺名称") + sku := rawNamed(columns, headers, "原始SKU") + if err := validateFields(rowNumber, order, shop, stall, sku, specRaw, code); err != nil { + return nil, 0, err + } + sequence, _ := strconv.Atoi(named(columns, headers, "标签打印序号")) + if sequence < 0 { + sequence = 0 + } + record := parsedRecord{BusinessDate: date, OrderNumber: order, ShopName: shop, Stall: stall, SourceSKURaw: sku, SpecRaw: specRaw, SpecKey: NormalizeSpecKey(specRaw), SourceRow: rowNumber, PrintSequence: sequence} + key := strings.Join([]string{date, order, stall, record.SpecKey}, "\x00") + parsed = append(parsed, struct { + record parsedRecord + code string + row int + key string + }{record, code, rowNumber, key}) + } + if headers == nil { + return nil, 0, invalid("前 10 行未找到必需表头") + } + if len(parsed) == 0 { + return nil, 0, invalid("工作表没有可导入数据") + } + groups := map[string]*parsedRecord{} + orderKeys := make([]string, 0) + codeOwners := map[string]string{} + for _, row := range parsed { + if owner, ok := codeOwners[row.code]; ok { + if owner != row.key { + return nil, 0, invalid(fmt.Sprintf("同日入库码 %q 对应多组订单/档口/规格", row.code)) + } + return nil, 0, invalid(fmt.Sprintf("同日入库码 %q 在工作簿中重复", row.code)) + } + codeOwners[row.code] = row.key + group := groups[row.key] + if group == nil { + copy := row.record + groups[row.key] = © + group = © + orderKeys = append(orderKeys, row.key) + } + if strings.TrimSpace(group.SourceSKURaw) != "" && strings.TrimSpace(row.record.SourceSKURaw) != "" && strings.TrimSpace(group.SourceSKURaw) != strings.TrimSpace(row.record.SourceSKURaw) { + return nil, 0, invalid(fmt.Sprintf("第 %d 行起的同业务键包含不同原始 SKU", group.SourceRow)) + } + group.Items = append(group.Items, parsedItem{Code: row.code, SourceRow: row.row, Ordinal: len(group.Items) + 1}) + } + result := make([]parsedRecord, 0, len(orderKeys)) + for _, key := range orderKeys { + result = append(result, *groups[key]) + } + return result, len(parsed), nil +} + +func headerMap(columns []string) (map[string]int, int) { + result := map[string]int{} + spec := -1 + for i, raw := range columns { + name := strings.TrimSpace(raw) + result[name] = i + compact := strings.ToLower(spaces.ReplaceAllString(name, "")) + if strings.HasPrefix(compact, "原始sku") { + result["原始SKU"] = i + } + if name == "清洗后规格" || (spec < 0 && strings.HasPrefix(name, "原始产品规格")) { + spec = i + } + } + return result, spec +} +func cell(columns []string, index int) string { + if index < 0 || index >= len(columns) { + return "" + } + return strings.TrimSpace(columns[index]) +} +func named(columns []string, headers map[string]int, name string) string { + index, ok := headers[name] + if !ok { + return "" + } + return cell(columns, index) +} +func rawNamed(columns []string, headers map[string]int, name string) string { + index, ok := headers[name] + if !ok || index >= len(columns) { + return "" + } + return columns[index] +} +func stallValue(columns []string, headers map[string]int) string { + if combined := named(columns, headers, "档口及货号"); combined != "" { + return combined + } + name, article := named(columns, headers, "档口名称"), named(columns, headers, "档口货号") + if name != "" && article != "" { + return name + "#" + article + } + return name + article +} + +func parseBusinessDate(raw, filename string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", fmt.Errorf("不能为空") + } + for _, layout := range []string{"2006-01-02", "2006/01/02", "2006.01.02"} { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed.Format("2006-01-02"), nil + } + } + if serial, err := strconv.ParseFloat(value, 64); err == nil && serial >= 1 { + if parsed, dateErr := excelize.ExcelDateToTime(serial, false); dateErr == nil { + return parsed.Format("2006-01-02"), nil + } + } + match := shortDate.FindStringSubmatch(value) + fileMatch := filenameDate.FindStringSubmatch(filepath.Base(filename)) + if len(match) != 3 || len(fileMatch) != 2 { + return "", fmt.Errorf("短日期缺少年份或文件名日期") + } + base, err := time.Parse("20060102", fileMatch[1]) + if err != nil { + return "", err + } + month, _ := strconv.Atoi(match[1]) + day, _ := strconv.Atoi(match[2]) + parsed := time.Date(base.Year(), time.Month(month), day, 0, 0, 0, 0, time.Local) + if parsed.Month() != time.Month(month) || parsed.Day() != day || parsed.Month() != base.Month() || parsed.Day() != base.Day() { + return "", fmt.Errorf("短日期与文件名日期不一致") + } + return parsed.Format("2006-01-02"), nil +} + +func validateFields(row int, order, shop, stall, sku, spec, code string) error { + if strings.Contains(code, ",") { + return invalid(fmt.Sprintf("第 %d 行入库码不能包含英文逗号", row)) + } + for _, r := range code { + if r < 32 || r == 127 { + return invalid(fmt.Sprintf("第 %d 行入库码不能包含控制字符", row)) + } + } + for _, field := range []struct { + name, value string + max int + }{{"订单号", order, 64}, {"店铺", shop, 191}, {"档口", stall, 191}, {"原始SKU", sku, 500}, {"规格", spec, 500}, {"入库码", code, 128}} { + if utf8.RuneCountInString(field.value) > field.max { + return invalid(fmt.Sprintf("第 %d 行%s超过 %d 个字符", row, field.name, field.max)) + } + } + return nil +} diff --git a/server/app/goauto/sybinnercode/router.go b/server/app/goauto/sybinnercode/router.go new file mode 100644 index 0000000..25fdde9 --- /dev/null +++ b/server/app/goauto/sybinnercode/router.go @@ -0,0 +1,23 @@ +package sybinnercode + +import ( + "go-admin/common/middleware" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" +) + +func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { + handler := Handler{} + group := engine.Group("/api/admin/v1/syb-inner-codes").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) + group.GET("", handler.List) + group.GET("/:recordId", handler.Detail) + group.POST("/import", handler.Import) + group.POST("/batch-delete", handler.Delete) + group.GET("/match-jobs/:jobId", handler.MatchJob) + group.POST("/apply-preview", handler.ApplyPreview) + group.POST("/apply", handler.Apply) + group.GET("/apply-batches/:batchId", handler.ApplyBatch) + group.POST("/:recordId/recheck", handler.Recheck) + group.POST("/rematch", handler.Rematch) +} diff --git a/server/app/goauto/sybinnercode/runtime.go b/server/app/goauto/sybinnercode/runtime.go new file mode 100644 index 0000000..5da2cbe --- /dev/null +++ b/server/app/goauto/sybinnercode/runtime.go @@ -0,0 +1,32 @@ +package sybinnercode + +import ( + "context" + "fmt" + + "go-admin/app/goauto/sybclient" + "go-admin/app/goauto/sybimport" + "go-admin/config" + + "gorm.io/gorm" +) + +var DefaultMatcher = Matcher{Factory: func(ctx context.Context, db *gorm.DB) (MatchReader, error) { + settings := config.ExtConfig.SYB.Resolved() + return sybimport.Connect(ctx, sybclient.NewSessionStore(db), sybimport.ConnectConfig{ + BaseURL: settings.BaseURL, Username: settings.Username, Password: settings.Password, + OcrURL: settings.OcrURL, OcrMaxAttempts: settings.OcrMaxAttempts, + }) +}} + +var DefaultApplyRunner = ApplyRunner{Factory: func(ctx context.Context, db *gorm.DB) (InnerCodeWriter, error) { + reader, err := DefaultMatcher.Factory(ctx, db) + if err != nil { + return nil, err + } + writer, ok := reader.(InnerCodeWriter) + if !ok { + return nil, fmt.Errorf("SYB client does not implement write contract") + } + return writer, nil +}} diff --git a/server/app/goauto/sybinnercode/service.go b/server/app/goauto/sybinnercode/service.go new file mode 100644 index 0000000..dc7d78d --- /dev/null +++ b/server/app/goauto/sybinnercode/service.go @@ -0,0 +1,349 @@ +package sybinnercode + +import ( + "context" + "encoding/json" + "errors" + "io" + "sort" + "strings" + "time" + + "go-admin/app/goauto/models" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type MatchEnqueuer interface { + Start(context.Context, *gorm.DB, string) error +} + +type Service struct { + db *gorm.DB + clock Clock + matcher MatchEnqueuer +} + +func NewService(db *gorm.DB) *Service { return &Service{db: db, clock: time.Now} } +func (s *Service) WithMatchEnqueuer(matcher MatchEnqueuer) *Service { s.matcher = matcher; return s } + +func (s *Service) Import(ctx context.Context, reader io.Reader, filename string, actor uint64, requestID string) (ImportResult, error) { + requestID = strings.TrimSpace(requestID) + if uuid.Validate(requestID) != nil || actor == 0 { + return ImportResult{}, invalid("requestId 或操作人无效") + } + if replay, ok, err := loadMutation[ImportResult](s.db.WithContext(ctx), requestID, "import"); err != nil { + return ImportResult{}, internal(err) + } else if ok { + return replay, nil + } + records, totalRows, err := parseWorkbook(reader, filename) + if err != nil { + return ImportResult{}, err + } + result := ImportResult{BusinessDate: records[0].BusinessDate, TotalRows: totalRows, RecordCount: len(records)} + for _, record := range records { + result.ItemCount += len(record.Items) + } + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if replay, ok, replayErr := loadMutation[ImportResult](tx, requestID, "import"); replayErr != nil { + return replayErr + } else if ok { + result = replay + return nil + } + var existing []models.SYBInnerCodeRecord + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("business_date = ?", result.BusinessDate).Find(&existing).Error; err != nil { + return err + } + if len(existing) > 0 { + for _, row := range existing { + if row.Status != models.SYBInnerCodePending { + return conflict("该营业日期已有匹配或执行证据,不能整批替换;请先核对并删除旧数据") + } + } + ids := make([]uint64, 0, len(existing)) + for _, row := range existing { + ids = append(ids, row.ID) + } + if err := tx.Where("record_id IN ?", ids).Delete(&models.SYBInnerCodeItem{}).Error; err != nil { + return err + } + if err := tx.Where("id IN ?", ids).Delete(&models.SYBInnerCodeRecord{}).Error; err != nil { + return err + } + result.ReplacedCount = len(existing) + } + recordIDs := make([]uint64, 0, len(records)) + for _, parsed := range records { + record := models.SYBInnerCodeRecord{BusinessDate: parsed.BusinessDate, OrderNumber: parsed.OrderNumber, Stall: parsed.Stall, SpecKey: parsed.SpecKey, SpecRaw: parsed.SpecRaw, SourceSKURaw: parsed.SourceSKURaw, ShopName: parsed.ShopName, SourceRow: parsed.SourceRow, PrintSequence: parsed.PrintSequence, Status: models.SYBInnerCodePending, CreatedBy: actor, ImportRequestID: requestID} + if err := tx.Create(&record).Error; err != nil { + return err + } + recordIDs = append(recordIDs, record.ID) + items := make([]models.SYBInnerCodeItem, 0, len(parsed.Items)) + for _, item := range parsed.Items { + items = append(items, models.SYBInnerCodeItem{RecordID: record.ID, BusinessDate: parsed.BusinessDate, Code: item.Code, Ordinal: item.Ordinal, SourceRow: item.SourceRow}) + } + if err := tx.Create(&items).Error; err != nil { + return err + } + } + recordIDsJSON, _ := json.Marshal(recordIDs) + job := models.SYBInnerCodeMatchJob{ID: uuid.NewString(), WorkKey: "import:" + requestID, BusinessDate: result.BusinessDate, RecordIDsJSON: string(recordIDsJSON), Status: "pending", Total: result.RecordCount} + if err := tx.Create(&job).Error; err != nil { + return err + } + result.MatchJobID = job.ID + return saveMutation(tx, requestID, "import", result) + }) + if err != nil { + var serviceErr *ServiceError + if errors.As(err, &serviceErr) { + return ImportResult{}, err + } + return ImportResult{}, internal(err) + } + if s.matcher != nil { + if enqueueErr := s.matcher.Start(ctx, s.db, result.MatchJobID); enqueueErr != nil { + return ImportResult{}, internal(enqueueErr) + } + } + return result, nil +} + +func (s *Service) List(ctx context.Context, request ListRequest) (ListResult, error) { + if request.Page < 1 { + request.Page = 1 + } + if request.PageSize == 0 { + request.PageSize = 100 + } + if request.PageSize != 20 && request.PageSize != 50 && request.PageSize != 100 && request.PageSize != 200 { + return ListResult{}, invalid("pageSize 只允许 20、50、100、200") + } + query := s.db.WithContext(ctx).Model(&models.SYBInnerCodeRecord{}) + if request.DateFrom != "" { + query = query.Where("business_date >= ?", request.DateFrom) + } + if request.DateTo != "" { + query = query.Where("business_date <= ?", request.DateTo) + } + if request.Status != "" { + query = query.Where("status = ?", request.Status) + } + if keyword := strings.TrimSpace(request.Keyword); keyword != "" { + like := "%" + keyword + "%" + query = query.Where("order_number LIKE ? OR EXISTS (SELECT 1 FROM syb_inner_code_item i WHERE i.record_id = syb_inner_code_record.id AND i.code LIKE ?)", like, like) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return ListResult{}, internal(err) + } + var items []models.SYBInnerCodeRecord + if err := query.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("ordinal ASC") }).Order("business_date DESC, source_row ASC, id ASC").Limit(request.PageSize).Offset((request.Page - 1) * request.PageSize).Find(&items).Error; err != nil { + return ListResult{}, internal(err) + } + if len(items) > 0 { + ids := make([]uint64, 0, len(items)) + byID := map[uint64]*models.SYBInnerCodeRecord{} + for i := range items { + ids = append(ids, items[i].ID) + byID[items[i].ID] = &items[i] + } + var plans []models.SYBInnerCodePlan + if err := s.db.WithContext(ctx).Where("record_id IN ?", ids).Find(&plans).Error; err != nil { + return ListResult{}, internal(err) + } + for i := range plans { + byID[plans[i].RecordID].Plan = &plans[i] + } + } + return ListResult{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil +} + +func (s *Service) Detail(ctx context.Context, id uint64) (models.SYBInnerCodeRecord, error) { + var record models.SYBInnerCodeRecord + if id == 0 { + return record, invalid("记录 ID 无效") + } + if err := s.db.WithContext(ctx).Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("ordinal ASC") }).Preload("Plan").First(&record, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return record, &ServiceError{Code: CodeNotFound, Message: "档口入库码记录不存在"} + } + return record, internal(err) + } + return record, nil +} + +func (s *Service) QueueRematch(ctx context.Context, request RematchRequest) (RematchResult, error) { + request.RequestID = strings.TrimSpace(request.RequestID) + ids := uniqueIDs(request.IDs) + if uuid.Validate(request.RequestID) != nil || len(ids) == 0 { + return RematchResult{}, invalid("requestId 或记录无效") + } + if replay, ok, err := loadMutation[RematchResult](s.db.WithContext(ctx), request.RequestID, "rematch"); err != nil { + return RematchResult{}, internal(err) + } else if ok { + return replay, nil + } + result := RematchResult{MatchJobID: uuid.NewString(), Queued: len(ids)} + var date string + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var records []models.SYBInnerCodeRecord + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", ids).Find(&records).Error; err != nil { + return err + } + if len(records) != len(ids) { + return conflict("部分记录不存在") + } + for _, record := range records { + if record.Status != models.SYBInnerCodeFailed && record.Status != models.SYBInnerCodeSkipped { + return conflict("只有读取失败或匹配受限记录可以重新匹配") + } + if date == "" { + date = record.BusinessDate + } else if date != record.BusinessDate { + 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 { + return err + } + recordIDsJSON, _ := json.Marshal(ids) + job := models.SYBInnerCodeMatchJob{ID: result.MatchJobID, WorkKey: "rematch:" + request.RequestID, BusinessDate: date, RecordIDsJSON: string(recordIDsJSON), Status: "pending", Total: len(ids)} + if err := tx.Create(&job).Error; err != nil { + return err + } + return saveMutation(tx, request.RequestID, "rematch", result) + }) + if err != nil { + var target *ServiceError + if errors.As(err, &target) { + return RematchResult{}, err + } + return RematchResult{}, internal(err) + } + if s.matcher != nil { + if err := s.matcher.Start(ctx, s.db, result.MatchJobID); err != nil { + return RematchResult{}, internal(err) + } + } + return result, nil +} + +func (s *Service) Delete(ctx context.Context, actor uint64, request DeleteRequest) (DeleteResult, error) { + request.RequestID = strings.TrimSpace(request.RequestID) + if actor == 0 || uuid.Validate(request.RequestID) != nil { + return DeleteResult{}, invalid("requestId 或操作人无效") + } + ids := uniqueIDs(request.IDs) + if len(ids) == 0 || len(ids) > 200 { + return DeleteResult{}, invalid("一次必须选择 1 至 200 条记录") + } + if replay, ok, err := loadMutation[DeleteResult](s.db.WithContext(ctx), request.RequestID, "delete"); err != nil { + return DeleteResult{}, internal(err) + } else if ok { + return replay, nil + } + result := DeleteResult{} + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if replay, ok, replayErr := loadMutation[DeleteResult](tx, request.RequestID, "delete"); replayErr != nil { + return replayErr + } else if ok { + result = replay + return nil + } + var records []models.SYBInnerCodeRecord + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", ids).Find(&records).Error; err != nil { + return err + } + if len(records) != len(ids) { + return conflict("部分记录不存在,未删除任何数据") + } + for _, record := range records { + if 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}} + } + // The state gate above is the dynamic restriction for active writeback + // evidence. Terminal evidence belongs to imported data and is physically + // removed in the same transaction as the selected records. + if err := tx.Where("record_id IN ?", ids).Delete(&models.SYBInnerCodeCheckpoint{}).Error; err != nil { + return err + } + if err := tx.Where("record_id IN ?", ids).Delete(&models.SYBInnerCodeApplyItem{}).Error; err != nil { + return err + } + if err := tx.Where("record_id IN ?", ids).Delete(&models.SYBInnerCodePlan{}).Error; err != nil { + return err + } + if err := tx.Where("record_id IN ?", ids).Delete(&models.SYBInnerCodeItem{}).Error; err != nil { + return err + } + deleted := tx.Where("id IN ?", ids).Delete(&models.SYBInnerCodeRecord{}) + if deleted.Error != nil { + return deleted.Error + } + if deleted.RowsAffected != int64(len(ids)) { + return conflict("记录状态发生变化,未完成删除") + } + result.Deleted = int(deleted.RowsAffected) + return saveMutation(tx, request.RequestID, "delete", result) + }) + if err != nil { + var serviceErr *ServiceError + if errors.As(err, &serviceErr) { + return result, err + } + return result, internal(err) + } + return result, nil +} + +func uniqueIDs(ids []uint64) []uint64 { + seen := map[uint64]bool{} + result := make([]uint64, 0, len(ids)) + for _, id := range ids { + if id > 0 && !seen[id] { + seen[id] = true + result = append(result, id) + } + } + sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + return result +} +func saveMutation(db *gorm.DB, requestID, action string, result any) error { + payload, err := json.Marshal(result) + if err != nil { + return err + } + return db.Create(&models.SYBInnerCodeMutation{RequestID: requestID, Action: action, ResultJSON: string(payload)}).Error +} +func loadMutation[T any](db *gorm.DB, requestID, action string) (T, bool, error) { + var zero T + var row models.SYBInnerCodeMutation + err := db.Where("request_id = ?", requestID).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return zero, false, nil + } + if err != nil { + return zero, false, err + } + if row.Action != action { + return zero, false, conflict("requestId 已用于其他操作") + } + if err := json.Unmarshal([]byte(row.ResultJSON), &zero); err != nil { + return zero, false, err + } + return zero, true, nil +} diff --git a/server/app/goauto/sybinnercode/service_test.go b/server/app/goauto/sybinnercode/service_test.go new file mode 100644 index 0000000..6f82fdc --- /dev/null +++ b/server/app/goauto/sybinnercode/service_test.go @@ -0,0 +1,211 @@ +package sybinnercode + +import ( + "bytes" + "context" + "errors" + "fmt" + "testing" + + "go-admin/app/goauto/migrations" + "go-admin/app/goauto/models" + + "github.com/google/uuid" + "github.com/xuri/excelize/v2" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func testDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", uuid.NewString())), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = migrations.Migrate(db); err != nil { + t.Fatal(err) + } + return db +} +func workbook(t *testing.T, rows [][]any) []byte { + t.Helper() + book := excelize.NewFile() + book.SetSheetName("Sheet1", sheetName) + headers := []any{"生成日期", "Shopee订单编号", "店铺名称", "档口及货号", "原始SKU", "清洗后规格", "内部档口入库码", "标签打印序号"} + if err := book.SetSheetRow(sheetName, "A1", &headers); err != nil { + t.Fatal(err) + } + for index, row := range rows { + cell := fmt.Sprintf("A%d", index+2) + if err := book.SetSheetRow(sheetName, cell, &row); err != nil { + t.Fatal(err) + } + } + buffer, err := book.WriteToBuffer() + if err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} +func importRows(t *testing.T, service *Service, requestID string, rows [][]any) (ImportResult, error) { + t.Helper() + data := workbook(t, rows) + return service.Import(context.Background(), bytes.NewReader(data), "mapping_20260828.xlsx", 1, requestID) +} + +func TestImportGroupsItemsAndPreservesExcelOrder(t *testing.T) { + db := testDB(t) + service := NewService(db) + rows := [][]any{{"2026-08-28", "ORDER-1", "店铺", "A#1", "SKU-1", "黑色,L", "IC-02", 2}, {"2026-08-28", "ORDER-1", "店铺", "A#1", "SKU-1", "黑色,L", "IC-01", 1}, {"2026-08-28", "ORDER-2", "店铺", "B#2", "SKU-2", "白色,M", "IC-03", 3}} + result, err := importRows(t, service, uuid.NewString(), rows) + if err != nil { + t.Fatal(err) + } + if result.RecordCount != 2 || result.ItemCount != 3 || result.TotalRows != 3 { + t.Fatalf("unexpected result: %+v", result) + } + list, err := service.List(context.Background(), ListRequest{Page: 1, PageSize: 100}) + if err != nil { + t.Fatal(err) + } + if list.Total != 2 || len(list.Items[0].Items) != 2 { + t.Fatalf("unexpected list: %+v", list) + } + if list.Items[0].Items[0].Code != "IC-02" || list.Items[0].Items[1].Code != "IC-01" { + t.Fatalf("item order changed: %+v", list.Items[0].Items) + } +} + +func TestImportRequestIsIdempotentAndPendingSnapshotCanBeReplaced(t *testing.T) { + db := testDB(t) + service := NewService(db) + requestID := uuid.NewString() + rows := [][]any{{"2026-08-28", "ORDER-1", "店铺", "A#1", "SKU-1", "黑色,L", "IC-01", 1}} + first, err := importRows(t, service, requestID, rows) + if err != nil { + t.Fatal(err) + } + replay, err := importRows(t, service, requestID, rows) + if err != nil { + t.Fatal(err) + } + if first != replay { + t.Fatalf("replay changed result: %#v %#v", first, replay) + } + replacement := [][]any{{"2026-08-28", "ORDER-2", "店铺", "B#2", "SKU-2", "白色,M", "IC-02", 1}} + result, err := importRows(t, service, uuid.NewString(), replacement) + if err != nil { + t.Fatal(err) + } + if result.ReplacedCount != 1 { + t.Fatalf("replaced=%d", result.ReplacedCount) + } + var count int64 + db.Model(&models.SYBInnerCodeRecord{}).Where("order_number = ?", "ORDER-1").Count(&count) + if count != 0 { + t.Fatalf("old snapshot remained") + } +} + +func TestImportRejectsReplacementAfterPlanningEvidence(t *testing.T) { + db := testDB(t) + service := NewService(db) + rows := [][]any{{"2026-08-28", "ORDER-1", "店铺", "A#1", "SKU-1", "黑色,L", "IC-01", 1}} + if _, err := importRows(t, service, uuid.NewString(), rows); err != nil { + t.Fatal(err) + } + if err := db.Model(&models.SYBInnerCodeRecord{}).Where("order_number = ?", "ORDER-1").Update("status", models.SYBInnerCodeReady).Error; err != nil { + t.Fatal(err) + } + _, err := importRows(t, service, uuid.NewString(), rows) + var target *ServiceError + if !errors.As(err, &target) || target.Code != CodeConflict { + t.Fatalf("err=%v", err) + } +} + +func TestImportRejectsCodeOwnedByDifferentBusinessKeys(t *testing.T) { + service := NewService(testDB(t)) + rows := [][]any{{"2026-08-28", "ORDER-1", "店铺", "A#1", "SKU-1", "黑色,L", "SAME", 1}, {"2026-08-28", "ORDER-2", "店铺", "B#2", "SKU-2", "白色,M", "SAME", 2}} + _, err := importRows(t, service, uuid.NewString(), rows) + var target *ServiceError + if !errors.As(err, &target) || target.Code != CodeInvalidRequest { + t.Fatalf("err=%v", err) + } +} + +func TestImportRejectsDuplicateCodeWithinSameBusinessKey(t *testing.T) { + service := NewService(testDB(t)) + rows := [][]any{{"2026-08-28", "ORDER-1", "店铺", "A#1", "SKU-1", "黑色,L", "SAME", 1}, {"2026-08-28", "ORDER-1", "店铺", "A#1", "SKU-1", "黑色,L", "SAME", 2}} + _, err := importRows(t, service, uuid.NewString(), rows) + var target *ServiceError + if !errors.As(err, &target) || target.Code != CodeInvalidRequest { + t.Fatalf("err=%v", err) + } +} + +func TestDeleteIsPhysicalAtomicAndBlocksActiveStates(t *testing.T) { + db := testDB(t) + service := NewService(db) + rows := [][]any{{"2026-08-28", "ORDER-1", "店铺", "A#1", "SKU-1", "黑色,L", "IC-01", 1}, {"2026-08-28", "ORDER-2", "店铺", "B#2", "SKU-2", "白色,M", "IC-02", 2}} + if _, err := importRows(t, service, uuid.NewString(), rows); err != nil { + t.Fatal(err) + } + var records []models.SYBInnerCodeRecord + if err := db.Order("id").Find(&records).Error; err != nil { + t.Fatal(err) + } + db.Model(&records[1]).Update("status", models.SYBInnerCodeQueued) + result, err := service.Delete(context.Background(), 1, DeleteRequest{RequestID: uuid.NewString(), IDs: []uint64{records[0].ID, records[1].ID}}) + if err == nil || len(result.Blocked) != 1 { + t.Fatalf("result=%+v err=%v", result, err) + } + var count int64 + db.Model(&models.SYBInnerCodeRecord{}).Count(&count) + if count != 2 { + t.Fatalf("partial delete occurred") + } + db.Model(&records[1]).Update("status", models.SYBInnerCodeFailed) + batch := models.SYBInnerCodeApplyBatch{ID: uuid.NewString(), RequestID: uuid.NewString(), Status: "finished", Requested: 1, Processed: 1, CreatedBy: 1} + if err := db.Create(&batch).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&models.SYBInnerCodeApplyItem{BatchID: batch.ID, RecordID: records[0].ID, Status: models.SYBInnerCodeFailed}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&models.SYBInnerCodeCheckpoint{RecordID: records[0].ID, Sequence: 1, Phase: "after", Action: "test", StateJSON: "{}"}).Error; err != nil { + t.Fatal(err) + } + result, err = service.Delete(context.Background(), 1, DeleteRequest{RequestID: uuid.NewString(), IDs: []uint64{records[0].ID, records[1].ID}}) + if err != nil || result.Deleted != 2 { + t.Fatalf("result=%+v err=%v", result, err) + } + db.Model(&models.SYBInnerCodeItem{}).Count(&count) + if count != 0 { + t.Fatalf("child items remained: %d", count) + } + db.Model(&models.SYBInnerCodeApplyItem{}).Count(&count) + if count != 0 { + t.Fatalf("terminal apply evidence remained: %d", count) + } + db.Model(&models.SYBInnerCodeCheckpoint{}).Count(&count) + if count != 0 { + t.Fatalf("terminal checkpoints remained: %d", count) + } +} + +func TestKeywordSearchUsesChildCodeWithoutDuplicatingParent(t *testing.T) { + db := testDB(t) + service := NewService(db) + rows := [][]any{{"2026-08-28", "ORDER-1", "店铺", "A#1", "SKU-1", "黑色,L", "LOOK-1", 1}, {"2026-08-28", "ORDER-1", "店铺", "A#1", "SKU-1", "黑色,L", "LOOK-2", 2}} + if _, err := importRows(t, service, uuid.NewString(), rows); err != nil { + t.Fatal(err) + } + result, err := service.List(context.Background(), ListRequest{Keyword: "LOOK", Page: 1, PageSize: 100}) + if err != nil { + t.Fatal(err) + } + if result.Total != 1 || len(result.Items) != 1 { + t.Fatalf("duplicated parent: %+v", result) + } +} diff --git a/server/app/goauto/sybinnercode/types.go b/server/app/goauto/sybinnercode/types.go new file mode 100644 index 0000000..67232b9 --- /dev/null +++ b/server/app/goauto/sybinnercode/types.go @@ -0,0 +1,113 @@ +package sybinnercode + +import ( + "errors" + "time" + + "go-admin/app/goauto/models" +) + +const ( + MaxUploadBytes = 10 * 1024 * 1024 + MaxImportRows = 5000 +) + +const ( + CodeInvalidRequest = "SYB_INNER_CODE_INVALID_REQUEST" + CodeConflict = "SYB_INNER_CODE_CONFLICT" + CodeNotFound = "SYB_INNER_CODE_NOT_FOUND" + CodeInternal = "SYB_INNER_CODE_INTERNAL" +) + +type ServiceError struct { + Code, Message string + Cause error + Details any +} + +func (e *ServiceError) Error() string { return e.Message } +func (e *ServiceError) Unwrap() error { return e.Cause } +func invalid(message string) error { return &ServiceError{Code: CodeInvalidRequest, Message: message} } +func conflict(message string) error { return &ServiceError{Code: CodeConflict, Message: message} } +func internal(err error) error { + return &ServiceError{Code: CodeInternal, Message: "档口入库码服务暂时不可用", Cause: err} +} +func AsServiceError(err error) *ServiceError { + var target *ServiceError + if errors.As(err, &target) { + return target + } + return &ServiceError{Code: CodeInternal, Message: "档口入库码服务暂时不可用", Cause: err} +} + +type ImportResult struct { + BusinessDate string `json:"businessDate"` + TotalRows int `json:"totalRows"` + RecordCount int `json:"recordCount"` + ItemCount int `json:"itemCount"` + ReplacedCount int `json:"replacedCount"` + MatchJobID string `json:"matchJobId,omitempty"` +} + +type DeleteRequest struct { + RequestID string `json:"requestId"` + IDs []uint64 `json:"ids"` +} +type DeleteResult struct { + Deleted int `json:"deleted"` + Blocked []BlockedRecord `json:"blocked,omitempty"` +} +type BlockedRecord struct { + ID uint64 `json:"id"` + Status string `json:"status"` +} + +type ListRequest struct { + DateFrom, DateTo, Keyword, Status string + Page, PageSize int +} +type ListResult struct { + Items []models.SYBInnerCodeRecord `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +type ApplyPreviewRequest struct { + IDs []uint64 `json:"ids"` +} +type ApplyPreview struct { + Records int `json:"records"` + InboundCodes int `json:"inboundCodes"` + PlaceholderDetails int `json:"placeholderDetails"` + ReplaceOldCodes int `json:"replaceOldCodes"` + Blocked []BlockedRecord `json:"blocked,omitempty"` +} +type ApplyRequest struct { + RequestID string `json:"requestId"` + IDs []uint64 `json:"ids"` +} +type ApplyResult struct { + BatchID string `json:"batchId"` + Queued int `json:"queued"` +} +type RematchRequest struct { + RequestID string `json:"requestId"` + IDs []uint64 `json:"ids"` +} +type RematchResult struct { + MatchJobID string `json:"matchJobId"` + Queued int `json:"queued"` +} + +type parsedRecord struct { + BusinessDate, OrderNumber, ShopName, Stall, SourceSKURaw, SpecRaw, SpecKey string + SourceRow, PrintSequence int + Items []parsedItem +} +type parsedItem struct { + Code string + SourceRow, Ordinal int +} + +type Clock func() time.Time diff --git a/server/cmd/api/server.go b/server/cmd/api/server.go index cc422ad..bd54c34 100644 --- a/server/cmd/api/server.go +++ b/server/cmd/api/server.go @@ -22,6 +22,7 @@ import ( "go-admin/app/admin/router" goautodevice "go-admin/app/goauto/device" goautosybimport "go-admin/app/goauto/sybimport" + goautosybinnercode "go-admin/app/goauto/sybinnercode" "go-admin/app/jobs" "go-admin/common/database" "go-admin/common/global" @@ -94,6 +95,9 @@ func run() error { if err := goautosybimport.RecoverInterruptedRuns(context.Background(), db); err != nil { return fmt.Errorf("recover interrupted SYB imports: %w", err) } + if err := goautosybinnercode.RecoverInterrupted(db); err != nil { + return fmt.Errorf("recover interrupted SYB inner-code writes: %w", err) + } } offlineMonitorContext, stopOfflineMonitors := context.WithCancel(context.Background()) defer stopOfflineMonitors() diff --git a/server/cmd/migrate/migration/version-local/1786701500000_syb_inner_code.go b/server/cmd/migrate/migration/version-local/1786701500000_syb_inner_code.go new file mode 100644 index 0000000..d0eaa2f --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1786701500000_syb_inner_code.go @@ -0,0 +1,25 @@ +package version_local + +import ( + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBInnerCode) +} + +func migrateSYBInnerCode(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/server/go.mod b/server/go.mod index 87ce119..7173449 100644 --- a/server/go.mod +++ b/server/go.mod @@ -117,15 +117,21 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.61.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/richardlehane/mscfb v1.0.4 // indirect + github.com/richardlehane/msoleps v1.0.4 // indirect github.com/shamsher31/goimgext v1.0.0 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/tiendc/go-deepcopy v1.7.1 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.2 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/excelize/v2 v2.10.0 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/server/go.sum b/server/go.sum index cfb7ee3..a87544d 100644 --- a/server/go.sum +++ b/server/go.sum @@ -486,6 +486,11 @@ github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDP github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= +github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= @@ -560,6 +565,8 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4= +github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/tklauser/go-sysconf v0.3.6/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= @@ -578,6 +585,12 @@ github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4= +github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=