feat(#119 #120 #121): implement SYB inner-code workflow

This commit is contained in:
QiuSW
2026-08-28 10:24:15 +08:00
parent fd15ce0109
commit 5c6655f706
19 changed files with 2636 additions and 0 deletions
+2
View File
@@ -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)
}
+11
View File
@@ -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},
+9
View File
@@ -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{},
+162
View File
@@ -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" }
+414
View File
@@ -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)
}
}
+256
View File
@@ -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)
}
+344
View File
@@ -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)
}
}
+289
View File
@@ -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] = &copy
group = &copy
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
}
+23
View File
@@ -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)
}
+32
View File
@@ -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
}}
+349
View File
@@ -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)
}
}
+113
View File
@@ -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
+4
View File
@@ -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
})
}
+6
View File
@@ -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
+13
View File
@@ -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=