加强发货提交可靠性并统一契约口径
- 发货提交记录阶段推进与失败分类,失败不再清空 result_data - 超时巡检区分已提交上游与提交中断两类卡单,避免误判 - 已提交上游的失败订单禁止自动重发,防止重复发货 - ship_attempts 仅在 claim 时计数,失败阶段只记录分类信息 - CanFulfill 对已提交上游的失败单返回不可发货 - 删除预留的 pending 订单状态,统一订单状态模型 - 契约改名:order.fulfillment.updated -> order.shipping.updated,fulfillment:read -> shipping:read - 文档修正 scope 为或关系
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -519,6 +520,113 @@ func TestMarkProcessingTimeoutsSkipsRecentOrders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeResultDataKeepsHistoricalFields(t *testing.T) {
|
||||
merged := mergeResultData(`{"a":1,"stage":"claimed","nested":{"x":true}}`, map[string]interface{}{
|
||||
"stage": "submitted",
|
||||
"b": 2,
|
||||
})
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(merged), &m); err != nil {
|
||||
t.Fatalf("merge result should be valid json: %v", err)
|
||||
}
|
||||
if m["stage"] != "submitted" || m["a"] != float64(1) || m["b"] != float64(2) {
|
||||
t.Fatalf("merge should override patched keys and keep others, got %s", merged)
|
||||
}
|
||||
nested, ok := m["nested"].(map[string]interface{})
|
||||
if !ok || nested["x"] != true {
|
||||
t.Fatalf("merge should keep nested objects, got %s", merged)
|
||||
}
|
||||
if got := mergeResultData("not-json", map[string]interface{}{"k": "v"}); got == "not-json" {
|
||||
t.Fatalf("invalid existing json should be replaced, got %s", got)
|
||||
}
|
||||
if got := mergeResultData(`{"k":"v"}`, nil); got != `{"k":"v"}` {
|
||||
t.Fatalf("empty patch should keep existing, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkProcessingTimeoutDistinguishesUpstreamSubmission(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-timeout-kind", 1000, -1, 100)
|
||||
svc := NewFulfillmentService(db, nil)
|
||||
|
||||
submittedOrder, err := svc.CreateOrder(CreateFulfillmentOrderInput{
|
||||
MerchantID: merchantID,
|
||||
APIClientID: 13,
|
||||
ClientOrderNo: "client-timeout-submitted",
|
||||
SKU: product.SKU,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create submitted order: %v", err)
|
||||
}
|
||||
if _, err := svc.UpdateFulfillment(FulfillmentUpdateInput{
|
||||
MerchantID: merchantID,
|
||||
APIClientID: 13,
|
||||
OrderNo: submittedOrder.Order.OrderNo,
|
||||
Status: model.OrderStatusDelivering,
|
||||
ResultData: map[string]interface{}{
|
||||
"provider_order_stage": "submitted",
|
||||
"provider_order_no": "provider-1",
|
||||
"upstream_order": map[string]interface{}{"order_id": "up-1"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("mark submitted delivering: %v", err)
|
||||
}
|
||||
|
||||
interruptedOrder, err := svc.CreateOrder(CreateFulfillmentOrderInput{
|
||||
MerchantID: merchantID,
|
||||
APIClientID: 13,
|
||||
ClientOrderNo: "client-timeout-interrupted",
|
||||
SKU: product.SKU,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create interrupted order: %v", err)
|
||||
}
|
||||
if _, err := svc.UpdateFulfillment(FulfillmentUpdateInput{
|
||||
MerchantID: merchantID,
|
||||
APIClientID: 13,
|
||||
OrderNo: interruptedOrder.Order.OrderNo,
|
||||
Status: model.OrderStatusDelivering,
|
||||
ResultData: map[string]interface{}{
|
||||
"provider_order_stage": "claimed",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("mark claimed delivering: %v", err)
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-time.Hour)
|
||||
for _, id := range []uint{submittedOrder.Order.ID, interruptedOrder.Order.ID} {
|
||||
if err := db.Model(&model.FulfillmentOrder{}).Where("id = ?", id).Update("updated_at", cutoff).Error; err != nil {
|
||||
t.Fatalf("age order %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
changed, err := svc.MarkProcessingTimeouts(30*time.Minute, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("mark timeouts: %v", err)
|
||||
}
|
||||
if changed != 2 {
|
||||
t.Fatalf("expected both orders timed out, got %d", changed)
|
||||
}
|
||||
|
||||
var submitted model.FulfillmentOrder
|
||||
if err := db.First(&submitted, submittedOrder.Order.ID).Error; err != nil {
|
||||
t.Fatalf("query submitted order: %v", err)
|
||||
}
|
||||
if submitted.OrderStatus != model.OrderStatusShipFailed || !strings.Contains(submitted.FailureReason, "已提交上游") {
|
||||
t.Fatalf("submitted-upstream order should warn about upstream, got %+v", submitted)
|
||||
}
|
||||
if !strings.Contains(submitted.ResultData, `"provider_order_no":"provider-1"`) {
|
||||
t.Fatalf("submitted-upstream order should keep context, got %s", submitted.ResultData)
|
||||
}
|
||||
|
||||
var interrupted model.FulfillmentOrder
|
||||
if err := db.First(&interrupted, interruptedOrder.Order.ID).Error; err != nil {
|
||||
t.Fatalf("query interrupted order: %v", err)
|
||||
}
|
||||
if interrupted.OrderStatus != model.OrderStatusShipFailed || !strings.Contains(interrupted.FailureReason, "提交中断") {
|
||||
t.Fatalf("interrupted order should say retryable, got %+v", interrupted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFulfillmentMerchantIsolation(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
merchantA, productA := seedFulfillmentMerchant(t, db, "merchant-d", 1000, 1, 100)
|
||||
@@ -580,6 +688,13 @@ func TestCanFulfill(t *testing.T) {
|
||||
if !ok || reason != "" {
|
||||
t.Fatalf("failed paid order should be fulfillable")
|
||||
}
|
||||
ok, reason = CanFulfill(&model.FulfillmentOrder{
|
||||
OrderStatus: model.OrderStatusShipFailed,
|
||||
ProviderOrderNo: "provider-1",
|
||||
})
|
||||
if ok || !strings.Contains(reason, "已提交上游") {
|
||||
t.Fatalf("failed order already submitted upstream should not be re-fulfillable, ok=%v reason=%q", ok, reason)
|
||||
}
|
||||
ok, _ = CanFulfill(&model.FulfillmentOrder{
|
||||
OrderStatus: model.OrderStatusCancelled,
|
||||
})
|
||||
@@ -711,7 +826,7 @@ func TestHandleShipNotifyUpdatesOrderAndEnqueuesMerchantCallback(t *testing.T) {
|
||||
if _, err := callbackSvc.CreateSubscription(merchantID, CreateCallbackInput{
|
||||
Name: "发货回调",
|
||||
URL: "https://example.com/callback",
|
||||
Events: "order.fulfillment.updated",
|
||||
Events: "order.shipping.updated",
|
||||
}, 7); err != nil {
|
||||
t.Fatalf("create callback subscription: %v", err)
|
||||
}
|
||||
@@ -737,7 +852,7 @@ func TestHandleShipNotifyUpdatesOrderAndEnqueuesMerchantCallback(t *testing.T) {
|
||||
t.Fatalf("unexpected order after source notify: %+v", order)
|
||||
}
|
||||
var delivery model.CallbackDelivery
|
||||
if err := db.Where("merchant_id = ? AND event = ?", merchantID, "order.fulfillment.updated").First(&delivery).Error; err != nil {
|
||||
if err := db.Where("merchant_id = ? AND event = ?", merchantID, "order.shipping.updated").First(&delivery).Error; err != nil {
|
||||
t.Fatalf("query callback delivery: %v", err)
|
||||
}
|
||||
if !strings.Contains(delivery.Payload, created.Order.OrderNo) || !strings.Contains(delivery.Payload, "SRC-10001") {
|
||||
|
||||
Reference in New Issue
Block a user