加强发货提交可靠性并统一契约口径

- 发货提交记录阶段推进与失败分类,失败不再清空 result_data
- 超时巡检区分已提交上游与提交中断两类卡单,避免误判
- 已提交上游的失败订单禁止自动重发,防止重复发货
- ship_attempts 仅在 claim 时计数,失败阶段只记录分类信息
- CanFulfill 对已提交上游的失败单返回不可发货
- 删除预留的 pending 订单状态,统一订单状态模型
- 契约改名:order.fulfillment.updated -> order.shipping.updated,fulfillment:read -> shipping:read
- 文档修正 scope 为或关系
This commit is contained in:
yml2213
2026-07-31 18:42:52 +08:00
parent 4aa5258c2d
commit eac025c92f
15 changed files with 401 additions and 86 deletions
+117 -2
View File
@@ -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") {