@@ -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)
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user