diff --git a/server/app/goauto/access/purchaser.go b/server/app/goauto/access/purchaser.go index acdeb8f..a9f8302 100644 --- a/server/app/goauto/access/purchaser.go +++ b/server/app/goauto/access/purchaser.go @@ -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}, diff --git a/server/app/goauto/access/purchaser_test.go b/server/app/goauto/access/purchaser_test.go index 837e9b1..2f8645a 100644 --- a/server/app/goauto/access/purchaser_test.go +++ b/server/app/goauto/access/purchaser_test.go @@ -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 { diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 2b1206f..0fc643e 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -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{}, diff --git a/server/app/goauto/models/purchase_order_writeback.go b/server/app/goauto/models/purchase_order_writeback.go new file mode 100644 index 0000000..b862697 --- /dev/null +++ b/server/app/goauto/models/purchase_order_writeback.go @@ -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" } diff --git a/server/app/goauto/purchase/admin_query.go b/server/app/goauto/purchase/admin_query.go index ccfb0f0..d3ab64a 100644 --- a/server/app/goauto/purchase/admin_query.go +++ b/server/app/goauto/purchase/admin_query.go @@ -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 { diff --git a/server/app/goauto/purchase/admin_query_test.go b/server/app/goauto/purchase/admin_query_test.go index 7e60002..ff8c4dd 100644 --- a/server/app/goauto/purchase/admin_query_test.go +++ b/server/app/goauto/purchase/admin_query_test.go @@ -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(`{}`)) diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index e107652..4d58310 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -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 { diff --git a/server/app/goauto/purchase/order_backfill.go b/server/app/goauto/purchase/order_backfill.go index ad3e14d..8b27fc3 100644 --- a/server/app/goauto/purchase/order_backfill.go +++ b/server/app/goauto/purchase/order_backfill.go @@ -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 diff --git a/server/app/goauto/purchase/order_writeback.go b/server/app/goauto/purchase/order_writeback.go new file mode 100644 index 0000000..b27dd29 --- /dev/null +++ b/server/app/goauto/purchase/order_writeback.go @@ -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 +} diff --git a/server/app/goauto/purchase/order_writeback_handler.go b/server/app/goauto/purchase/order_writeback_handler.go new file mode 100644 index 0000000..0c7198a --- /dev/null +++ b/server/app/goauto/purchase/order_writeback_handler.go @@ -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) +} diff --git a/server/app/goauto/purchase/order_writeback_test.go b/server/app/goauto/purchase/order_writeback_test.go new file mode 100644 index 0000000..fe964b5 --- /dev/null +++ b/server/app/goauto/purchase/order_writeback_test.go @@ -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") + } +} diff --git a/server/app/goauto/purchase/order_writeback_worker.go b/server/app/goauto/purchase/order_writeback_worker.go new file mode 100644 index 0000000..21aaed4 --- /dev/null +++ b/server/app/goauto/purchase/order_writeback_worker.go @@ -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", "写入后尚未回读确认,请人工核对后重试") +} diff --git a/server/app/goauto/purchase/router.go b/server/app/goauto/purchase/router.go index 849dc2e..c99878e 100644 --- a/server/app/goauto/purchase/router.go +++ b/server/app/goauto/purchase/router.go @@ -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) diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index 335e6d0..edcdad2 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -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) diff --git a/server/app/goauto/sybclient/order_number.go b/server/app/goauto/sybclient/order_number.go new file mode 100644 index 0000000..aedc787 --- /dev/null +++ b/server/app/goauto/sybclient/order_number.go @@ -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) +} diff --git a/server/app/goauto/sybclient/order_number_test.go b/server/app/goauto/sybclient/order_number_test.go new file mode 100644 index 0000000..a595da7 --- /dev/null +++ b/server/app/goauto/sybclient/order_number_test.go @@ -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) + } +} diff --git a/server/cmd/api/server.go b/server/cmd/api/server.go index 3266e0c..e7c93b7 100644 --- a/server/cmd/api/server.go +++ b/server/cmd/api/server.go @@ -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()) diff --git a/server/cmd/migrate/migration/version-local/1789800200000_purchase_order_writeback.go b/server/cmd/migrate/migration/version-local/1789800200000_purchase_order_writeback.go new file mode 100644 index 0000000..ecd457e --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789800200000_purchase_order_writeback.go @@ -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 + }) +} diff --git a/server/cmd/migrate/migration/version-local/1789800200000_purchase_order_writeback_test.go b/server/cmd/migrate/migration/version-local/1789800200000_purchase_order_writeback_test.go new file mode 100644 index 0000000..ffe9fe9 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789800200000_purchase_order_writeback_test.go @@ -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) + } +} diff --git a/web/src/api/goauto/purchase-tasks.js b/web/src/api/goauto/purchase-tasks.js index 9298699..dcd91d8 100644 --- a/web/src/api/goauto/purchase-tasks.js +++ b/web/src/api/goauto/purchase-tasks.js @@ -6,6 +6,7 @@ export function previewPurchaseTasks(data, options = {}) { return request({ url: export function matchPurchaseSpecsBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-spec-match', method: 'post', data, timeout: 610000 }) } export function createPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch', method: 'post', data, timeout: 60000 }) } export function retryPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-retry', method: 'post', data, timeout: 60000 }) } +export function writebackPurchaseOrderNumbers(data) { return request({ url: '/api/admin/v1/purchase-tasks/syb-order-writeback', method: 'post', data }) } export function createStockPurchaseTask(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/stock', method: 'post', data, ...options }) } export function createPurchaseTask(data) { return request({ url: '/api/admin/v1/purchase-tasks', method: 'post', data, timeout: 60000 }) } export function getPurchaseMatching(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/matching`, method: 'get' }) } diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue index 5b0dd6c..5aceba2 100644 --- a/web/src/views/goauto/purchase-tasks/index.vue +++ b/web/src/views/goauto/purchase-tasks/index.vue @@ -11,11 +11,11 @@ 查询重置 - 已选择 {{ retrySelection.length }} 条重试({{ retrySelection.length }}) +
已选择 {{ retrySelection.length }} 条重试({{ retryCandidates.length }})批量回填 SYB({{ orderWritebackCandidates.length }})
- + @@ -26,6 +26,7 @@ + @@ -59,9 +60,11 @@
订单{{ detail.task.pddOrderNo || '尚未取得订单号' }}{{ detail.task.orderSubmittedAt ? `下单时间 ${formatTime(detail.task.orderSubmittedAt)}` : '获取订单号失败即采购失败' }}实付价格:{{ detail.task.pddOrderAmountCent == null ? '尚未获取' : priceText(detail.task.pddOrderAmountCent, 'CNY') }}
支付复核{{ paymentLabel(detail.task.paymentReviewStatus) }}{{ detail.task.paymentReviewedAt ? formatTime(detail.task.paymentReviewedAt) : '仅人工记录,系统不支付' }}
物流{{ logisticsLabel(detail.task.logisticsStatus) }}{{ detail.task.trackingNo || '尚未取得快递单号' }}
-
SYB 回填{{ writebackLabel(detail.task.writebackStatus) }}{{ detail.task.writebackAt ? formatTime(detail.task.writebackAt) : '由独立回填流程处理' }}
+
SYB 物流回填{{ writebackLabel(detail.task.writebackStatus) }}{{ detail.task.writebackAt ? formatTime(detail.task.writebackAt) : '由独立回填流程处理' }}
+

SYB 单号回填

{{ orderWritebackLabel(detail.task.orderWriteback?.status) }}{{ detail.task.orderWriteback?.reason || '仅回填 PDD 订单号,不回填实付价格,不要求已支付。' }}完成时间:{{ formatTime(detail.task.orderWriteback?.completedAt) }}
{{ detail.task.orderWriteback.status === 'not_started' ? '回填 SYB 单号' : '重试回填 SYB 单号' }}刷新状态
+

执行记录

@@ -112,6 +115,12 @@ + + + + + + @@ -133,7 +142,7 @@ diff --git a/web/tests/e2e/purchase-order-writeback.spec.ts b/web/tests/e2e/purchase-order-writeback.spec.ts new file mode 100644 index 0000000..f1c4502 --- /dev/null +++ b/web/tests/e2e/purchase-order-writeback.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test' + +async function setup(page: any, state = 'not_started') { + await page.setViewportSize({ width: 1920, height: 1080 }) + await page.context().addCookies([{ name: 'Admin-Token', value: 'test-only', domain: 'localhost', path: '/' }]) + const requests: { path: string, body: any }[] = [] + const items = [ + { id: 11, taskType: 'syb_order', executionMode: 'live', status: 'order_created', pddOrderNo: 'EXAMPLE-11', paymentReviewStatus: 'unpaid', orderWriteback: { status: state, canSubmit: ['not_started', 'failed', 'unknown'].includes(state), reason: state === 'conflict' ? 'SYB 已有不同单号,未覆盖' : '' } }, + { id: 12, taskType: 'syb_order', executionMode: 'live', status: 'failed', retryable: true, orderWriteback: { status: 'not_applicable', canSubmit: false, reason: '' } }, + { id: 13, taskType: 'stock', executionMode: 'live', status: 'order_created', orderWriteback: { status: 'not_applicable', canSubmit: false, reason: '' } } + ] + await page.route('**/api/**', async route => { + const path = new URL(route.request().url()).pathname + if (path.endsWith('/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '测试采购员', avatar: '', introduction: '', permissions: [] } } }) + if (path.endsWith('/menurole')) return route.fulfill({ json: { code: 200, data: [{ path: '/collection-purchase', component: 'Layout', menuName: 'GoAutoCollectionPurchase', title: '采集采购', visible: '0', children: [{ path: '/purchase-tasks/index', component: '/goauto/purchase-tasks/index', menuName: 'GoAutoPurchaseTasks', title: '采购管理', visible: '0' }] }] } }) + if (route.request().method() === 'POST') { + const body = route.request().postDataJSON() + requests.push({ path, body }) + if (path.endsWith('/syb-order-writeback')) { + items[0].orderWriteback = { status: 'pending', canSubmit: false, reason: '' } + return route.fulfill({ json: { code: 200, data: { items: body.purchaseTaskIds.map((taskId: number) => ({ taskId, result: 'pending', reason: '已受理' })) } } }) + } + if (path.endsWith('/batch-retry')) return route.fulfill({ json: { code: 200, data: { items: [], createdCount: 1, failedCount: 0 } } }) + throw new Error('unexpected write: ' + path) + } + if (path.endsWith('/purchase-tasks/11')) return route.fulfill({ json: { code: 200, data: { task: items[0], attempts: [] } } }) + if (path.endsWith('/purchase-tasks')) return route.fulfill({ json: { code: 200, data: { items, total: items.length } } }) + return route.fulfill({ json: { code: 200, data: [] } }) + }) + await page.goto('/#/purchase-tasks/index') + await expect(page.getByText('CG-11', { exact: true })).toBeVisible() + return requests +} + +test('混选任务分别回填与重试,不把已下单任务交给采购重试', async ({ page }) => { + const requests = await setup(page) + await page.locator('.el-table__header-wrapper .el-checkbox').click() + await expect(page.getByRole('button', { name: '重试(1)', exact: true })).toBeEnabled() + await expect(page.getByRole('button', { name: '批量回填 SYB(1)', exact: true })).toBeEnabled() + await page.getByRole('button', { name: '重试(1)', exact: true }).click() + const retry = page.getByRole('dialog', { name: '确认重试失败任务' }) + await expect(retry.getByText('CG-12', { exact: true })).toBeVisible() + await expect(retry.getByText('CG-11', { exact: true })).toHaveCount(0) + await retry.getByRole('button', { name: '确认重试', exact: true }).click() + await expect.poll(() => requests.length).toBe(1) + expect(requests[0].body.taskIds).toEqual([12]) + await page.getByRole('dialog', { name: '批量重试结果' }).getByRole('button', { name: '留在当前页' }).click() + await page.locator('.el-table__header-wrapper .el-checkbox').first().click() + await page.getByRole('button', { name: '批量回填 SYB(1)', exact: true }).click() + const result = page.getByRole('dialog', { name: 'SYB 单号回填提交结果' }) + await expect(result.getByText('待回填', { exact: true })).toBeVisible() + expect(requests[1].body.purchaseTaskIds).toEqual([11]) + expect(requests[1].body.requestId).toMatch(/^[0-9a-f-]{36}$/) + await result.getByRole('button', { name: '关闭', exact: true }).click() + await expect(result).toBeHidden() + await expect(page.getByRole('button', { name: '批量回填 SYB(0)', exact: true })).toBeDisabled() +}) + +test('未支付订单可从详情补偿回填,旧物流流程保持独立', async ({ page }) => { + const requests = await setup(page, 'failed') + await page.getByRole('button', { name: '详情', exact: true }).first().click() + const drawer = page.getByRole('dialog', { name: '采购任务详情' }) + await expect(drawer.getByText('SYB 物流回填', { exact: true })).toBeVisible() + await drawer.getByRole('button', { name: '重试回填 SYB 单号', exact: true }).click() + await expect(page.getByRole('dialog', { name: 'SYB 单号回填提交结果' })).toBeVisible() + expect(requests).toHaveLength(1) + expect(requests[0].path).toContain('/syb-order-writeback') + expect(requests[0].body.purchaseTaskIds).toEqual([11]) +}) + +test('冲突显示原因且没有覆盖按钮', async ({ page }) => { + await setup(page, 'conflict') + await page.getByRole('button', { name: '详情', exact: true }).first().click() + const drawer = page.getByRole('dialog', { name: '采购任务详情' }) + await expect(drawer.getByText('单号冲突', { exact: true })).toBeVisible() + await expect(drawer.getByText('SYB 已有不同单号,未覆盖', { exact: true })).toBeVisible() + await expect(drawer.getByRole('button', { name: /回填 SYB 单号/ })).toHaveCount(0) +})