feat: queue and reconcile SYB purchase order numbers (#305)
This commit is contained in:
@@ -110,6 +110,7 @@ var AdminAPIs = []APIPermission{
|
||||
{"批量 AI 匹配采购规格", "/api/admin/v1/purchase-tasks/batch-spec-match", "POST", true},
|
||||
{"批量创建采购任务", "/api/admin/v1/purchase-tasks/batch", "POST", true},
|
||||
{"批量重试采购任务", "/api/admin/v1/purchase-tasks/batch-retry", "POST", true},
|
||||
{"回填SYB采购单号", "/api/admin/v1/purchase-tasks/syb-order-writeback", "POST", true},
|
||||
{"创建备货采购任务", "/api/admin/v1/purchase-tasks/stock", "POST", true},
|
||||
{"查看采购任务详情", "/api/admin/v1/purchase-tasks/:taskId", "GET", true},
|
||||
{"创建采购任务", "/api/admin/v1/purchase-tasks", "POST", true},
|
||||
|
||||
@@ -2,6 +2,15 @@ package access
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPurchaserMayWritebackOrderNumber(t *testing.T) {
|
||||
for _, permission := range PurchaserAPIs() {
|
||||
if permission.Method == "POST" && permission.Path == "/api/admin/v1/purchase-tasks/syb-order-writeback" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("missing purchaser order number writeback permission")
|
||||
}
|
||||
|
||||
func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, permission := range AdminAPIs {
|
||||
|
||||
@@ -56,6 +56,9 @@ func MigratedModels() []any {
|
||||
&models.PDDAccount{},
|
||||
&models.PurchaseTask{},
|
||||
&models.PurchaseTaskAttempt{},
|
||||
&models.PurchaseOrderWriteback{},
|
||||
&models.PurchaseOrderWritebackLease{},
|
||||
&models.PurchaseOrderWritebackCommand{},
|
||||
&models.PurchaseSpecMatchWorkItem{},
|
||||
&models.CollectionRule{},
|
||||
&models.PurchaseRule{},
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// PurchaseOrderWriteback is independent of the legacy logistics writeback fields.
|
||||
type PurchaseOrderWriteback struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement"`
|
||||
PurchaseTaskID uint64 `gorm:"not null;uniqueIndex"`
|
||||
StockID int64 `gorm:"not null;index:idx_order_writeback_target,priority:1"`
|
||||
DetailID int64 `gorm:"not null;index:idx_order_writeback_target,priority:2"`
|
||||
OrderNo string `json:"-" gorm:"size:100;not null"`
|
||||
Status string `gorm:"size:24;not null;index"`
|
||||
AttemptCount int `gorm:"not null;default:0"`
|
||||
WriteStarted bool `gorm:"not null;default:false"`
|
||||
LeaseOwner string `gorm:"size:36"`
|
||||
LeaseExpiresAt *time.Time
|
||||
ErrorCode string `gorm:"size:80"`
|
||||
ErrorMessage string `gorm:"size:300"`
|
||||
CompletedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (PurchaseOrderWriteback) TableName() string { return "purchase_order_writeback" }
|
||||
|
||||
// A singleton lease serializes all SYB order-number writers, including different
|
||||
// tasks that resolve to the same remote stock/detail identity.
|
||||
type PurchaseOrderWritebackLease struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
Owner string `gorm:"size:36"`
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
func (PurchaseOrderWritebackLease) TableName() string { return "purchase_order_writeback_lease" }
|
||||
|
||||
type PurchaseOrderWritebackCommand struct {
|
||||
RequestID string `gorm:"primaryKey;size:36"`
|
||||
InputHash string `gorm:"size:64;not null"`
|
||||
ResultJSON string `json:"-" gorm:"type:text;not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (PurchaseOrderWritebackCommand) TableName() string { return "purchase_order_writeback_command" }
|
||||
@@ -25,59 +25,60 @@ type AdminListRequest struct {
|
||||
}
|
||||
|
||||
type AdminTaskItem struct {
|
||||
ID uint64 `json:"id"`
|
||||
TaskType string `json:"taskType"`
|
||||
ExecutionMode string `json:"executionMode"`
|
||||
Status string `json:"status"`
|
||||
SYBProductID *uint64 `json:"sybProductId,omitempty"`
|
||||
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
DeviceID *uint64 `json:"deviceId,omitempty"`
|
||||
DeviceName string `json:"deviceName,omitempty"`
|
||||
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
|
||||
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
|
||||
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
|
||||
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
|
||||
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
|
||||
PDDTitleSnapshot string `json:"pddTitleSnapshot"`
|
||||
TargetColorSnapshot string `json:"targetColorSnapshot"`
|
||||
TargetSizeSnapshot string `json:"targetSizeSnapshot"`
|
||||
MappedColorSnapshot string `json:"mappedColorSnapshot"`
|
||||
MappedSizeSnapshot string `json:"mappedSizeSnapshot"`
|
||||
SpecSource string `json:"specSource"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"`
|
||||
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
|
||||
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
|
||||
Currency string `json:"currency"`
|
||||
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"`
|
||||
AddressSuffix string `json:"addressSuffix"`
|
||||
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
|
||||
IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"`
|
||||
PaymentReviewStatus string `json:"paymentReviewStatus"`
|
||||
PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"`
|
||||
TrackingNo *string `json:"trackingNo,omitempty"`
|
||||
TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"`
|
||||
LogisticsStatus string `json:"logisticsStatus"`
|
||||
WritebackStatus string `json:"writebackStatus"`
|
||||
WritebackAt *time.Time `json:"writebackAt,omitempty"`
|
||||
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"`
|
||||
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"`
|
||||
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
|
||||
CancelReason *string `json:"cancelReason,omitempty"`
|
||||
ErrorCode *string `json:"errorCode,omitempty"`
|
||||
ErrorMessage *string `json:"errorMessage,omitempty"`
|
||||
Retryable bool `json:"retryable"`
|
||||
RetryDisabledCode string `json:"retryDisabledCode,omitempty"`
|
||||
RetryDisabledReason string `json:"retryDisabledReason,omitempty"`
|
||||
StatusVersion uint64 `json:"statusVersion"`
|
||||
StatusChangedAt time.Time `json:"statusChangedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Matching MatchingView `json:"matching"`
|
||||
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
|
||||
OrderWriteback OrderWritebackView `json:"orderWriteback"`
|
||||
ID uint64 `json:"id"`
|
||||
TaskType string `json:"taskType"`
|
||||
ExecutionMode string `json:"executionMode"`
|
||||
Status string `json:"status"`
|
||||
SYBProductID *uint64 `json:"sybProductId,omitempty"`
|
||||
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
DeviceID *uint64 `json:"deviceId,omitempty"`
|
||||
DeviceName string `json:"deviceName,omitempty"`
|
||||
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
|
||||
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
|
||||
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
|
||||
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
|
||||
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
|
||||
PDDTitleSnapshot string `json:"pddTitleSnapshot"`
|
||||
TargetColorSnapshot string `json:"targetColorSnapshot"`
|
||||
TargetSizeSnapshot string `json:"targetSizeSnapshot"`
|
||||
MappedColorSnapshot string `json:"mappedColorSnapshot"`
|
||||
MappedSizeSnapshot string `json:"mappedSizeSnapshot"`
|
||||
SpecSource string `json:"specSource"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"`
|
||||
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
|
||||
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
|
||||
Currency string `json:"currency"`
|
||||
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"`
|
||||
AddressSuffix string `json:"addressSuffix"`
|
||||
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
|
||||
IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"`
|
||||
PaymentReviewStatus string `json:"paymentReviewStatus"`
|
||||
PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"`
|
||||
TrackingNo *string `json:"trackingNo,omitempty"`
|
||||
TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"`
|
||||
LogisticsStatus string `json:"logisticsStatus"`
|
||||
WritebackStatus string `json:"writebackStatus"`
|
||||
WritebackAt *time.Time `json:"writebackAt,omitempty"`
|
||||
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"`
|
||||
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"`
|
||||
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
|
||||
CancelReason *string `json:"cancelReason,omitempty"`
|
||||
ErrorCode *string `json:"errorCode,omitempty"`
|
||||
ErrorMessage *string `json:"errorMessage,omitempty"`
|
||||
Retryable bool `json:"retryable"`
|
||||
RetryDisabledCode string `json:"retryDisabledCode,omitempty"`
|
||||
RetryDisabledReason string `json:"retryDisabledReason,omitempty"`
|
||||
StatusVersion uint64 `json:"statusVersion"`
|
||||
StatusChangedAt time.Time `json:"statusChangedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Matching MatchingView `json:"matching"`
|
||||
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
|
||||
}
|
||||
|
||||
type AdminAttemptItem struct {
|
||||
@@ -170,8 +171,13 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
items := make([]AdminTaskItem, 0, len(tasks))
|
||||
writebacks, err := s.OrderWritebackViews(ctx, tasks)
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
for _, task := range tasks {
|
||||
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
|
||||
item.OrderWriteback = writebacks[task.ID]
|
||||
item.Matching = matching[task.ID]
|
||||
if task.ShopeeProductID != nil {
|
||||
item.ImageSearchLinked = imageSearchLinked[*task.ShopeeProductID]
|
||||
@@ -237,6 +243,11 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
|
||||
return AdminDetailResponse{}, matchErr
|
||||
}
|
||||
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
|
||||
writebacks, wbErr := s.OrderWritebackViews(ctx, []models.PurchaseTask{task})
|
||||
if wbErr != nil {
|
||||
return AdminDetailResponse{}, wbErr
|
||||
}
|
||||
item.OrderWriteback = writebacks[task.ID]
|
||||
item.Matching = matching
|
||||
imageSearchLinked, err := s.loadImageSearchLinked(ctx, []models.PurchaseTask{task})
|
||||
if err != nil {
|
||||
|
||||
@@ -125,7 +125,7 @@ func TestAdminQueryHandlersRequireOperatorRole(t *testing.T) {
|
||||
|
||||
func TestAdminBatchHandlersRequireOperatorRole(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, handler := range []func(*gin.Context){(Handler{}).AdminBatchPreview, (Handler{}).AdminBatchCreate, (Handler{}).AdminBatchRetry} {
|
||||
for _, handler := range []func(*gin.Context){(Handler{}).AdminBatchPreview, (Handler{}).AdminBatchCreate, (Handler{}).AdminBatchRetry, (Handler{}).AdminOrderWriteback} {
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = httptest.NewRequest(http.MethodPost, "/api/admin/v1/purchase-tasks/batch", strings.NewReader(`{}`))
|
||||
|
||||
@@ -448,6 +448,9 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
|
||||
if e := tx.Save(t).Error; e != nil {
|
||||
return TaskPayload{}, conflictOrInternal(e)
|
||||
}
|
||||
if e := ensureOrderWriteback(tx, *t); e != nil {
|
||||
return TaskPayload{}, internal(e)
|
||||
}
|
||||
return valuePayload(s, t, a, false)
|
||||
})
|
||||
if err != nil || req.ResultType != "spec_probe_completed" || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending {
|
||||
|
||||
@@ -146,7 +146,7 @@ func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, re
|
||||
}
|
||||
}
|
||||
r.Result, r.Code = "already_backfilled", "ALREADY_BACKFILLED"
|
||||
return nil
|
||||
return ensureOrderWriteback(tx, task)
|
||||
}
|
||||
var submitted time.Time
|
||||
source := "page"
|
||||
@@ -186,7 +186,7 @@ func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, re
|
||||
return err
|
||||
}
|
||||
r.Result, r.Code = "backfilled", "BACKFILLED"
|
||||
return nil
|
||||
return ensureOrderWriteback(tx, task)
|
||||
})
|
||||
if err != nil {
|
||||
r.Result, r.Code = "failed", CodeInternal
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type OrderWritebackView struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
CanSubmit bool `json:"canSubmit"`
|
||||
}
|
||||
type OrderWritebackRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
PurchaseTaskIDs []uint64 `json:"purchaseTaskIds"`
|
||||
}
|
||||
type OrderWritebackAcceptance struct {
|
||||
TaskID uint64 `json:"taskId"`
|
||||
Result string `json:"result"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
type OrderWritebackResponse struct {
|
||||
Items []OrderWritebackAcceptance `json:"items"`
|
||||
}
|
||||
|
||||
func orderWritebackEligible(t models.PurchaseTask) bool {
|
||||
return t.ExecutionMode == models.PurchaseExecutionModeLive && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.Status == models.PurchaseTaskStatusOrderCreated && t.SYBProductID != nil && t.PDDOrderNo != nil && strings.TrimSpace(*t.PDDOrderNo) != ""
|
||||
}
|
||||
|
||||
// Called within the same transaction as the order fact. Never contacts SYB.
|
||||
func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
|
||||
if !orderWritebackEligible(t) {
|
||||
return nil
|
||||
}
|
||||
var syb models.SYBProduct
|
||||
if err := tx.First(&syb, *t.SYBProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if syb.StockID <= 0 || syb.DetailID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if syb.StockID > 1<<63-1 || syb.DetailID > 1<<63-1 {
|
||||
return nil
|
||||
}
|
||||
row := models.PurchaseOrderWriteback{PurchaseTaskID: t.ID, StockID: int64(syb.StockID), DetailID: int64(syb.DetailID), OrderNo: *t.PDDOrderNo, Status: "pending"}
|
||||
return tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]OrderWritebackView, error) {
|
||||
out := map[uint64]OrderWritebackView{}
|
||||
ids := make([]uint64, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
ids = append(ids, t.ID)
|
||||
v := OrderWritebackView{Status: "not_applicable", Reason: "不符合正式SYB订单回填条件"}
|
||||
if orderWritebackEligible(t) {
|
||||
v = OrderWritebackView{Status: "not_started", CanSubmit: true}
|
||||
}
|
||||
out[t.ID] = v
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []models.PurchaseOrderWriteback
|
||||
if err := s.DB.WithContext(ctx).Where("purchase_task_id IN ?", ids).Find(&rows).Error; err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
v := out[r.PurchaseTaskID]
|
||||
v.Status = r.Status
|
||||
v.Reason = r.ErrorMessage
|
||||
v.CompletedAt = r.CompletedAt
|
||||
v.CanSubmit = v.CanSubmit && (r.Status == "failed" || r.Status == "unknown") && (r.LeaseExpiresAt == nil || !r.LeaseExpiresAt.After(s.Now()))
|
||||
out[r.PurchaseTaskID] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) RequestOrderWriteback(ctx context.Context, req OrderWritebackRequest) (OrderWritebackResponse, error) {
|
||||
out := OrderWritebackResponse{Items: []OrderWritebackAcceptance{}}
|
||||
if uuid.Validate(req.RequestID) != nil || len(req.PurchaseTaskIDs) == 0 || len(req.PurchaseTaskIDs) > 100 {
|
||||
return out, fail(CodeInvalidRequest, "请选择1~100条采购任务")
|
||||
}
|
||||
ids := append([]uint64(nil), req.PurchaseTaskIDs...)
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
for i, id := range ids {
|
||||
if id == 0 || (i > 0 && id == ids[i-1]) {
|
||||
return out, fail(CodeInvalidRequest, "任务编号无效或重复")
|
||||
}
|
||||
}
|
||||
data, _ := json.Marshal(ids)
|
||||
sum := sha256.Sum256(data)
|
||||
fingerprint := hex.EncodeToString(sum[:])
|
||||
db := s.DB.WithContext(ctx).Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Serializes batch command replay without holding a lock across remote IO.
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.PurchaseOrderWritebackLease{ID: 1}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var guard models.PurchaseOrderWritebackLease
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&guard, 1).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var command models.PurchaseOrderWritebackCommand
|
||||
err := tx.First(&command, "request_id = ?", req.RequestID).Error
|
||||
if err == nil {
|
||||
if command.InputHash != fingerprint {
|
||||
return fail(CodeInvalidRequest, "同一requestId不能改变任务集合")
|
||||
}
|
||||
return json.Unmarshal([]byte(command.ResultJSON), &out)
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
a := OrderWritebackAcceptance{TaskID: id, Result: "skipped", Reason: "任务不存在或不符合回填条件"}
|
||||
var task models.PurchaseTask
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, id).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, a)
|
||||
continue
|
||||
}
|
||||
if !orderWritebackEligible(task) {
|
||||
out.Items = append(out.Items, a)
|
||||
continue
|
||||
}
|
||||
if err := ensureOrderWriteback(tx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
var row models.PurchaseOrderWriteback
|
||||
if err := tx.Where("purchase_task_id = ?", id).First(&row).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
a.Reason = "SYB明细关联已失效"
|
||||
out.Items = append(out.Items, a)
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case row.OrderNo != *task.PDDOrderNo:
|
||||
a.Result, a.Reason = "conflict", "订单号快照不一致,请人工核对"
|
||||
case row.Status == "succeeded":
|
||||
a.Result, a.Reason = "succeeded", "已回填"
|
||||
case row.Status == "conflict":
|
||||
a.Result, a.Reason = "conflict", row.ErrorMessage
|
||||
case row.Status == "running" || (row.LeaseExpiresAt != nil && row.LeaseExpiresAt.After(s.Now())):
|
||||
a.Reason = "正在回填或等待在途请求结束"
|
||||
case row.Status == "pending":
|
||||
a.Result, a.Reason = "pending", "已加入回填"
|
||||
default:
|
||||
if err := tx.Model(&row).Updates(map[string]any{"status": "pending", "write_started": false, "lease_owner": "", "lease_expires_at": nil, "error_code": "", "error_message": ""}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
a.Result, a.Reason = "pending", "已加入回填,将先回读SYB"
|
||||
}
|
||||
out.Items = append(out.Items, a)
|
||||
}
|
||||
result, _ := json.Marshal(out)
|
||||
return tx.Create(&models.PurchaseOrderWritebackCommand{RequestID: req.RequestID, InputHash: fingerprint, ResultJSON: string(result)}).Error
|
||||
})
|
||||
if err != nil {
|
||||
var e *ServiceError
|
||||
if errors.As(err, &e) {
|
||||
return out, err
|
||||
}
|
||||
return out, internal(err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h Handler) AdminOrderWriteback(c *gin.Context) {
|
||||
if !allowedOperator(c) {
|
||||
return
|
||||
}
|
||||
var req OrderWritebackRequest
|
||||
if !decode(c, &req) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := s.RequestOrderWriteback(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
writeAdminData(c, out)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type fakeOrderNumberClient struct {
|
||||
code, platform string
|
||||
reads, writes int
|
||||
writeErr, readErr error
|
||||
apply bool
|
||||
duplicate bool
|
||||
beforeWrite func()
|
||||
}
|
||||
|
||||
func (f *fakeOrderNumberClient) DetailListByStock(_ context.Context, ids []int64) ([]sybclient.StockDetail, error) {
|
||||
f.reads++
|
||||
if f.readErr != nil {
|
||||
return nil, f.readErr
|
||||
}
|
||||
d := sybclient.DetailItem{ID: 1, Raw: map[string]any{"purchaseCode": f.code, "purchasePlatform": f.platform}}
|
||||
details := []sybclient.DetailItem{{ID: 99, Raw: map[string]any{"purchaseCode": "UNRELATED", "purchasePlatform": "pdd"}}, d}
|
||||
if f.duplicate {
|
||||
details = append(details, d)
|
||||
}
|
||||
return []sybclient.StockDetail{{ID: ids[0], Details: details}}, nil
|
||||
}
|
||||
func (f *fakeOrderNumberClient) UpdateDetailPurchaseCode(_ context.Context, stock, detail int64, code string) error {
|
||||
if stock != 2 || detail != 1 {
|
||||
panic("wrong remote identity")
|
||||
}
|
||||
f.writes++
|
||||
if f.beforeWrite != nil {
|
||||
f.beforeWrite()
|
||||
}
|
||||
if f.apply {
|
||||
f.code, f.platform = code, "pdd"
|
||||
}
|
||||
return f.writeErr
|
||||
}
|
||||
func orderWritebackFixture(t *testing.T) (*Service, models.PurchaseTask) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
task.TaskType, task.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
|
||||
if err := db.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "EXAMPLE-ORDER"))[0]
|
||||
if result.Code != "BACKFILLED" {
|
||||
t.Fatalf("backfill=%s", result.Code)
|
||||
}
|
||||
return s, loadBackfillTask(t, db, task.ID)
|
||||
}
|
||||
func loadOrderWriteback(t *testing.T, db *gorm.DB, id uint64) models.PurchaseOrderWriteback {
|
||||
t.Helper()
|
||||
var r models.PurchaseOrderWriteback
|
||||
if err := db.Where("purchase_task_id = ?", id).First(&r).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
func wbWorker(s *Service, f *fakeOrderNumberClient) *OrderWritebackWorker {
|
||||
return &OrderWritebackWorker{DB: s.DB, Now: s.Now, Factory: func(context.Context, *gorm.DB) (OrderNumberClient, error) { return f, nil }}
|
||||
}
|
||||
|
||||
func TestOrderWritebackRemoteOutcomes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fake fakeOrderNumberClient
|
||||
want string
|
||||
writes int
|
||||
}{
|
||||
{"write_and_verify", fakeOrderNumberClient{apply: true}, "succeeded", 1},
|
||||
{"same_value", fakeOrderNumberClient{code: "EXAMPLE-ORDER", platform: "pdd"}, "succeeded", 0},
|
||||
{"different_value", fakeOrderNumberClient{code: "OTHER", platform: "pdd"}, "conflict", 0},
|
||||
{"different_platform", fakeOrderNumberClient{code: "EXAMPLE-ORDER", platform: "other"}, "conflict", 0},
|
||||
{"empty_code_other_platform", fakeOrderNumberClient{platform: "other"}, "conflict", 0},
|
||||
{"unknown_applied", fakeOrderNumberClient{apply: true, writeErr: sybclient.ErrWriteResultUnknown}, "succeeded", 1},
|
||||
{"unknown_unapplied", fakeOrderNumberClient{writeErr: sybclient.ErrWriteResultUnknown}, "unknown", 1},
|
||||
{"success_not_visible", fakeOrderNumberClient{}, "unknown", 1},
|
||||
{"explicit_rejection", fakeOrderNumberClient{writeErr: sybclient.ErrSessionInvalid}, "failed", 1},
|
||||
{"read_failure", fakeOrderNumberClient{readErr: errors.New("offline")}, "failed", 0},
|
||||
{"ambiguous_target", fakeOrderNumberClient{duplicate: true}, "failed", 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
f := tc.fake
|
||||
w := wbWorker(s, &f)
|
||||
if ok, err := w.RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWriteback(t, s.DB, task.ID)
|
||||
if row.Status != tc.want || f.writes != tc.writes {
|
||||
t.Fatalf("status=%s writes=%d", row.Status, f.writes)
|
||||
}
|
||||
if _, err := w.RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.writes != tc.writes {
|
||||
t.Fatal("automatically repeated write")
|
||||
}
|
||||
after := loadBackfillTask(t, s.DB, task.ID)
|
||||
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != task.WritebackStatus || after.StatusVersion != task.StatusVersion {
|
||||
t.Fatal("changed purchase/payment/logistics facts")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackRecoveryNeverBlindWrites(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
row := loadOrderWriteback(t, s.DB, task.ID)
|
||||
expired := s.Now().Add(-time.Minute)
|
||||
if err := s.DB.Model(&row).Updates(map[string]any{"status": "running", "write_started": true, "lease_owner": "crashed", "lease_expires_at": expired}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "unknown" || f.writes != 0 {
|
||||
t.Fatal("crash recovery wrote remotely")
|
||||
}
|
||||
// Manual compensation remains disabled while the previous lease could be alive.
|
||||
r, err := s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
||||
if err != nil || r.Items[0].Result != "skipped" {
|
||||
t.Fatalf("in-flight manual retry: %v %+v", err, r)
|
||||
}
|
||||
s.Now = func() time.Time { return expired.Add(5 * time.Minute) }
|
||||
r, err = s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
||||
if err != nil || r.Items[0].Result != "pending" {
|
||||
t.Fatal("manual compensation rejected")
|
||||
}
|
||||
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "succeeded" || f.writes != 1 {
|
||||
t.Fatal("manual compensation failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackBatchReplayAndPartialAcceptance(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
req := OrderWritebackRequest{uuid.NewString(), []uint64{task.ID, 9999}}
|
||||
first, err := s.RequestOrderWriteback(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Items[0].Result != "pending" || first.Items[1].Result != "skipped" {
|
||||
t.Fatalf("%+v", first)
|
||||
}
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := s.RequestOrderWriteback(context.Background(), req)
|
||||
if err != nil || again.Items[0].Result != "pending" || f.writes != 1 {
|
||||
t.Fatal("replay changed acceptance or wrote")
|
||||
}
|
||||
req.PurchaseTaskIDs = []uint64{task.ID}
|
||||
if _, err = s.RequestOrderWriteback(context.Background(), req); err == nil {
|
||||
t.Fatal("changed requestId content accepted")
|
||||
}
|
||||
views, err := s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
|
||||
if err != nil || views[task.ID].Status != "succeeded" || views[task.ID].CanSubmit {
|
||||
t.Fatal("incorrect admin view")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackGlobalClaimSerializesWriters(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
other := &fakeOrderNumberClient{apply: true}
|
||||
f.beforeWrite = func() {
|
||||
if ok, err := wbWorker(s, other).RunOnce(context.Background()); err != nil || ok {
|
||||
t.Fatalf("parallel claim %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.writes != 1 || other.writes != 0 || loadOrderWriteback(t, s.DB, task.ID).Status != "succeeded" {
|
||||
t.Fatal("concurrent writer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackEnqueueRollbackAndSameOrderReplay(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
if err := s.DB.Where("purchase_task_id = ?", task.ID).Delete(&models.PurchaseOrderWriteback{}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantErr := errors.New("rollback")
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if e := ensureOrderWriteback(tx, task); e != nil {
|
||||
return e
|
||||
}
|
||||
return wantErr
|
||||
})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
s.DB.Model(&models.PurchaseOrderWriteback{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("queue escaped transaction")
|
||||
}
|
||||
var dev models.AgentDevice
|
||||
s.DB.First(&dev, *task.DeviceID)
|
||||
// Directly exercise the already_backfilled branch without needing a raw token.
|
||||
r := s.backfillOrder(context.Background(), dev.ID, task.ID, uuid.NewString(), backfillItem(task.ID, *task.PDDOrderNo), false)
|
||||
if r.Result != "already_backfilled" {
|
||||
t.Fatal(r.Code)
|
||||
}
|
||||
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "pending" {
|
||||
t.Fatal("same-order replay did not ensure queue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackAdminEnvelopeAndOperator(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
s, task := orderWritebackFixture(t)
|
||||
for _, role := range []string{"admin", "purchaser"} {
|
||||
body, _ := json.Marshal(OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
||||
r := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(r)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/admin/v1/purchase-tasks/syb-order-writeback", strings.NewReader(string(body)))
|
||||
(Handler{DB: s.DB}).AdminOrderWriteback(c)
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Data OrderWritebackResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(r.Body.Bytes(), &response); err != nil || r.Code != 200 || response.Code != 200 || len(response.Data.Items) != 1 || response.Data.Items[0].Result != "pending" {
|
||||
t.Fatalf("admin contract rejected role=%s status=%d", role, r.Code)
|
||||
}
|
||||
if r.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("missing cache policy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackOtherUnknownTargetBlocksNewWrite(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
other := models.PurchaseOrderWriteback{PurchaseTaskID: task.ID + 100, StockID: 2, DetailID: 1, OrderNo: "OTHER", Status: "unknown", WriteStarted: true}
|
||||
if err := s.DB.Create(&other).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "unknown" || got.ErrorCode != "SYB_TARGET_IN_FLIGHT" || f.writes != 0 {
|
||||
t.Fatal("another unresolved target was overwritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackSnapshotChangeDoesNotWrite(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
if err := s.DB.Model(&task).Update("pdd_order_no", "CHANGED").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "conflict" || f.writes != 0 {
|
||||
t.Fatal("changed snapshot written")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/config"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type OrderNumberClient interface {
|
||||
DetailListByStock(context.Context, []int64) ([]sybclient.StockDetail, error)
|
||||
UpdateDetailPurchaseCode(context.Context, int64, int64, string) error
|
||||
}
|
||||
type OrderWritebackWorker struct {
|
||||
DB *gorm.DB
|
||||
Now func() time.Time
|
||||
Factory func(context.Context, *gorm.DB) (OrderNumberClient, error)
|
||||
}
|
||||
|
||||
func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberClient, error) {
|
||||
cfg := config.ExtConfig.SYB.Resolved()
|
||||
session, err := sybclient.NewSessionStore(db).Load(ctx, cfg.Username, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := sybclient.New(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = c.ImportCookiesJSON(session.CookiesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// One short-lived claim at a time across processes. No business writes occur
|
||||
// during startup itself; only explicitly persisted pending records are handled.
|
||||
func RecoverOrderWritebacks(db *gorm.DB) {
|
||||
go func() {
|
||||
w := OrderWritebackWorker{DB: db, Now: func() time.Time { return time.Now().UTC() }, Factory: restoreOrderWritebackClient}
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
_, _ = w.RunOnce(context.Background())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
if w.Now == nil {
|
||||
w.Now = func() time.Time { return time.Now().UTC() }
|
||||
}
|
||||
if w.Factory == nil {
|
||||
w.Factory = restoreOrderWritebackClient
|
||||
}
|
||||
db := w.DB.WithContext(ctx).Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.PurchaseOrderWritebackLease{ID: 1}).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
owner := uuid.NewString()
|
||||
now := w.Now()
|
||||
expires := now.Add(2 * time.Minute)
|
||||
claim := db.Model(&models.PurchaseOrderWritebackLease{}).Where("id = 1 AND (expires_at IS NULL OR expires_at <= ?)", now).Updates(map[string]any{"owner": owner, "expires_at": expires})
|
||||
if claim.Error != nil {
|
||||
return false, claim.Error
|
||||
}
|
||||
if claim.RowsAffected != 1 {
|
||||
return false, nil
|
||||
}
|
||||
defer db.Model(&models.PurchaseOrderWritebackLease{}).Where("id = 1 AND owner = ?", owner).Updates(map[string]any{"owner": "", "expires_at": nil})
|
||||
var item models.PurchaseOrderWriteback
|
||||
recovering := false
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("status = ? OR (status = ? AND lease_expires_at <= ?)", "pending", "running", now).Order("id").First(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
recovering = item.Status == "running"
|
||||
return tx.Model(&item).Updates(map[string]any{"status": "running", "attempt_count": gorm.Expr("attempt_count + 1"), "lease_owner": owner, "lease_expires_at": expires}).Error
|
||||
})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
finish := func(status, code, message string) error {
|
||||
updates := map[string]any{"status": status, "error_code": code, "error_message": message, "lease_owner": ""}
|
||||
if status != "unknown" {
|
||||
updates["lease_expires_at"] = nil
|
||||
}
|
||||
if status == "succeeded" {
|
||||
updates["completed_at"] = w.Now()
|
||||
}
|
||||
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
if err = db.First(&task, item.PurchaseTaskID).Error; err != nil {
|
||||
return true, finish("failed", "TASK_UNAVAILABLE", "采购任务不可用,请人工核对")
|
||||
}
|
||||
var syb models.SYBProduct
|
||||
if !orderWritebackEligible(task) || *task.PDDOrderNo != item.OrderNo {
|
||||
return true, finish("conflict", "ORDER_FACT_CHANGED", "采购订单事实已变化,请人工核对")
|
||||
}
|
||||
if err = db.First(&syb, *task.SYBProductID).Error; err != nil || int64(syb.StockID) != item.StockID || int64(syb.DetailID) != item.DetailID {
|
||||
return true, finish("conflict", "SYB_TARGET_CHANGED", "SYB商品明细关联已变化")
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
client, err := w.Factory(callCtx, db)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return true, finish("failed", "SYB_SESSION_UNAVAILABLE", "SYB会话不可用,请恢复登录后重试")
|
||||
}
|
||||
read := func() (string, string, error) {
|
||||
readCtx, stop := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer stop()
|
||||
rows, e := client.DetailListByStock(readCtx, []int64{item.StockID})
|
||||
if e != nil {
|
||||
return "", "", e
|
||||
}
|
||||
found := 0
|
||||
code, platform := "", ""
|
||||
for _, stock := range rows {
|
||||
if stock.ID != item.StockID {
|
||||
continue
|
||||
}
|
||||
for _, detail := range stock.Details {
|
||||
if detail.ID == item.DetailID {
|
||||
found++
|
||||
var ok bool
|
||||
code, ok = detail.Raw["purchaseCode"].(string)
|
||||
if !ok && detail.Raw["purchaseCode"] != nil {
|
||||
return "", "", errors.New("invalid purchase code")
|
||||
}
|
||||
platform, ok = detail.Raw["purchasePlatform"].(string)
|
||||
if !ok && detail.Raw["purchasePlatform"] != nil {
|
||||
return "", "", errors.New("invalid platform")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if found != 1 {
|
||||
return "", "", errors.New("ambiguous remote detail")
|
||||
}
|
||||
return code, platform, nil
|
||||
}
|
||||
code, platform, err := read()
|
||||
if err != nil {
|
||||
status := "failed"
|
||||
if recovering || item.WriteStarted {
|
||||
status = "unknown"
|
||||
}
|
||||
return true, finish(status, "SYB_READ_FAILED", "无法回读SYB目标明细,请恢复连接后重试")
|
||||
}
|
||||
if code == item.OrderNo && platform == "pdd" {
|
||||
return true, finish("succeeded", "", "")
|
||||
}
|
||||
if code != "" || (platform != "" && platform != "pdd") {
|
||||
return true, finish("conflict", "SYB_ORDER_CONFLICT", "SYB已有不同单号或平台,未覆盖,请人工核对")
|
||||
}
|
||||
if recovering || item.WriteStarted {
|
||||
return true, finish("unknown", "SYB_WRITE_UNCONFIRMED", "上次写入结果未确认,未自动重发,请人工核对后重试")
|
||||
}
|
||||
var blocked int64
|
||||
if err = db.Model(&models.PurchaseOrderWriteback{}).Where("id <> ? AND stock_id = ? AND detail_id = ? AND write_started = ? AND status IN ?", item.ID, item.StockID, item.DetailID, true, []string{"running", "unknown"}).Count(&blocked).Error; err != nil {
|
||||
return true, err
|
||||
}
|
||||
if blocked > 0 {
|
||||
return true, finish("unknown", "SYB_TARGET_IN_FLIGHT", "同一SYB明细有其他未确认写入,请先核对该记录")
|
||||
}
|
||||
// Fence immediately before the sole external write. A recovered owner never
|
||||
// writes; a durable marker survives crashes between request and acknowledgment.
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
var lease models.PurchaseOrderWritebackLease
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&lease, 1).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if lease.Owner != owner || lease.ExpiresAt == nil || !lease.ExpiresAt.After(w.Now().Add(25*time.Second)) {
|
||||
return errors.New("write lease lost")
|
||||
}
|
||||
r := tx.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND lease_owner = ? AND status = 'running'", item.ID, owner).Update("write_started", true)
|
||||
if r.Error != nil {
|
||||
return r.Error
|
||||
}
|
||||
if r.RowsAffected != 1 {
|
||||
return errors.New("item lease lost")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
writeCtx, stop := context.WithTimeout(ctx, 20*time.Second)
|
||||
writeErr := client.UpdateDetailPurchaseCode(writeCtx, item.StockID, item.DetailID, item.OrderNo)
|
||||
stop()
|
||||
code, platform, err = read()
|
||||
if err == nil && code == item.OrderNo && platform == "pdd" {
|
||||
return true, finish("succeeded", "", "")
|
||||
}
|
||||
if err == nil && (code != "" || (platform != "" && platform != "pdd")) {
|
||||
return true, finish("conflict", "SYB_ORDER_CONFLICT", "SYB单号或平台与目标不一致,未覆盖")
|
||||
}
|
||||
if writeErr != nil && !errors.Is(writeErr, sybclient.ErrWriteResultUnknown) {
|
||||
return true, finish("failed", "SYB_WRITE_REJECTED", "SYB拒绝回填,请检查会话与明细后重试")
|
||||
}
|
||||
return true, finish("unknown", "SYB_WRITE_UNCONFIRMED", "写入后尚未回读确认,请人工核对后重试")
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.POST("/batch-spec-match", h.AdminBatchSpecMatch)
|
||||
admin.POST("/batch", h.AdminBatchCreate)
|
||||
admin.POST("/batch-retry", h.AdminBatchRetry)
|
||||
admin.POST("/syb-order-writeback", h.AdminOrderWriteback)
|
||||
admin.POST("/stock", h.AdminCreateStock)
|
||||
admin.GET("/:taskId", h.AdminDetail)
|
||||
admin.POST("", h.AdminCreate)
|
||||
|
||||
@@ -289,6 +289,10 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queued := loadOrderWriteback(t, db, task.ID)
|
||||
if queued.Status != "pending" || queued.OrderNo != "PDD-1" {
|
||||
t.Fatalf("order result did not enqueue writeback: status=%s", queued.Status)
|
||||
}
|
||||
replay, err := s.SubmitResult(context.Background(), task.ID, req, f.token)
|
||||
if err != nil || !replay.Replayed {
|
||||
t.Fatalf("result replay failed: %+v %v", replay, err)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package sybclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// UpdateDetailPurchaseCode sends exactly one write. No payment or amount is
|
||||
// inferred: cost=0 and created="" are the confirmed SYB protocol constants.
|
||||
func (c *Client) UpdateDetailPurchaseCode(ctx context.Context, stockID, detailID int64, code string) error {
|
||||
if stockID <= 0 || detailID <= 0 || strings.TrimSpace(code) == "" || code != strings.TrimSpace(code) || utf8.RuneCountInString(code) > 100 || strings.ContainsAny(code, "\r\n\t") {
|
||||
return fmt.Errorf("采购单号回填参数无效")
|
||||
}
|
||||
// A 307/308 redirect must not replay a mutation or forward credentials.
|
||||
client := *c
|
||||
httpClient := *c.http
|
||||
httpClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
client.http = &httpClient
|
||||
_, err := client.do(ctx, http.MethodPost, "/am/stock/detail/updateDetailPurchaseCode", url.Values{
|
||||
"id": {strconv.FormatInt(stockID, 10)}, "detailId": {strconv.FormatInt(detailID, 10)},
|
||||
"code": {code}, "type": {"pdd"}, "created": {""}, "cost": {"0"},
|
||||
}, nil)
|
||||
return classifyInnerCodeWriteError(err)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package sybclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPurchaseCodeSingleWriteContract(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
status int
|
||||
unknown bool
|
||||
}{
|
||||
{"success", `{"status":true,"data":null}`, 200, false},
|
||||
{"business", `{"status":false,"msg":"rejected"}`, 200, false},
|
||||
{"login", `{"status":false,"msg":"未登录"}`, 200, false},
|
||||
{"bad_json", `broken`, 200, true},
|
||||
{"server_error", ``, 502, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
q := r.URL.Query()
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if r.Method != "POST" || r.URL.Path != "/am/stock/detail/updateDetailPurchaseCode" || q.Get("id") != "2" || q.Get("detailId") != "1" || q.Get("code") != "EXAMPLE-ORDER" || q.Get("type") != "pdd" || q.Get("cost") != "0" || !q.Has("created") || q.Get("created") != "" || len(q) != 6 || len(body) != 0 {
|
||||
t.Error("invalid contract")
|
||||
}
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := New(srv.URL)
|
||||
err := c.UpdateDetailPurchaseCode(context.Background(), 2, 1, "EXAMPLE-ORDER")
|
||||
if calls != 1 || errors.Is(err, ErrWriteResultUnknown) != tc.unknown {
|
||||
t.Fatalf("calls=%d unknown=%v", calls, errors.Is(err, ErrWriteResultUnknown))
|
||||
}
|
||||
if (err == nil) != (tc.name == "success") {
|
||||
t.Fatal("unexpected outcome")
|
||||
}
|
||||
if e := c.UpdateDetailPurchaseCode(context.Background(), 0, 1, "EXAMPLE-ORDER"); e == nil || calls != 1 {
|
||||
t.Fatal("invalid request sent")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseCodeDoesNotFollowRedirect(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
http.Redirect(w, r, "/again", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := New(srv.URL)
|
||||
if err := c.UpdateDetailPurchaseCode(context.Background(), 2, 1, "EXAMPLE-ORDER"); err == nil || calls != 1 {
|
||||
t.Fatalf("redirect must not repeat write: calls=%d", calls)
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ func run() error {
|
||||
}
|
||||
goautoreplacement.RecoverMatching(db)
|
||||
goautopurchase.RecoverPurchaseMatching(db)
|
||||
goautopurchase.RecoverOrderWritebacks(db)
|
||||
goautotask.RecoverReplacementActivations(db)
|
||||
}
|
||||
offlineMonitorContext, stopOfflineMonitors := context.WithCancel(context.Background())
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migratePurchaseOrderWriteback)
|
||||
}
|
||||
|
||||
// Append-only schema. Never queues historical purchases or changes job switches.
|
||||
func migratePurchaseOrderWriteback(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&models.PurchaseOrderWriteback{}, &models.PurchaseOrderWritebackCommand{}, &models.PurchaseOrderWritebackLease{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.PurchaseOrderWritebackLease{ID: 1}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/models"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPurchaseOrderWritebackMigrationPreservesFacts(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec("CREATE TABLE purchase_task (id integer primary key, status text)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec("INSERT INTO purchase_task VALUES (1, 'order_created')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = migratePurchaseOrderWriteback(db, "1789800200000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&models.PurchaseOrderWritebackLease{}).Where("id = 1").Update("owner", "active-owner").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Repeating schema work must neither queue history nor reset an active lease.
|
||||
if err = migratePurchaseOrderWriteback(db, "repeat-schema-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var lease models.PurchaseOrderWritebackLease
|
||||
if err = db.First(&lease, 1).Error; err != nil || lease.Owner != "active-owner" {
|
||||
t.Fatal("lease reset", err)
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(&models.PurchaseOrderWriteback{}).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatal("history queued", err)
|
||||
}
|
||||
var status string
|
||||
if err = db.Table("purchase_task").Select("status").Where("id = 1").Scan(&status).Error; err != nil || status != "order_created" {
|
||||
t.Fatal("purchase changed", err)
|
||||
}
|
||||
if err = db.Model(&common.Migration{}).Where("version = ?", "1789800200000").Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatal("version missing", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user