feat(t237): import freight orders synchronously

This commit is contained in:
QiuSW
2026-07-29 12:11:56 +08:00
parent 4bce1e162c
commit 8d5b88f4a2
18 changed files with 653 additions and 97 deletions
+1 -1
View File
@@ -201,7 +201,7 @@ func Load(lookup LookupEnvironment) (Config, error) {
TLSPrivateKey: cleanOptionalPath(tlsPrivateKey),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
WriteTimeout: 70 * time.Second,
IdleTimeout: 60 * time.Second,
ShutdownTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
@@ -29,6 +29,9 @@ func TestLoadUsesSafeDefaults(t *testing.T) {
cfg.MaxHeaderBytes <= 0 {
t.Fatal("server safety limits must all be positive")
}
if cfg.WriteTimeout != 70*time.Second {
t.Fatalf("WriteTimeout = %s", cfg.WriteTimeout)
}
if cfg.ClaimLease != 10*time.Minute ||
cfg.RunningLease != 30*time.Minute ||
cfg.ReadinessTTL != 2*time.Minute {
@@ -852,6 +852,8 @@ func writeUsecaseError(ctx *gin.Context, err error) {
status = http.StatusBadGateway
case "OCR_SERVICE_INVALID", "ERP_UNAVAILABLE":
status = http.StatusServiceUnavailable
case "FREIGHT_SYNC_TIMEOUT":
status = http.StatusGatewayTimeout
}
message := typed.Message
if preflightMessage, ok := freightPreflightPublicMessage(typed.Code); ok {
@@ -883,6 +885,10 @@ func freightPreflightPublicMessage(code string) (string, bool) {
return "OCR service is invalid", true
case "ERP_UNAVAILABLE":
return "ERP is temporarily unavailable", true
case "FREIGHT_SYNC_TIMEOUT":
return "freight order sync exceeded 55 seconds", true
case "FREIGHT_SYNC_BUSY":
return "another freight order sync is already running", true
default:
return "", false
}
@@ -405,7 +405,9 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
),
"freight-sync-1",
)
if create.Code != http.StatusAccepted {
if create.Code != http.StatusCreated ||
create.Header().Get("Location") != "/api/v1/freight-orders" ||
!strings.Contains(create.Body.String(), `"status":"SUCCEEDED"`) {
t.Fatalf("create status/body = %d / %s", create.Code, create.Body)
}
var createBody struct {
@@ -486,8 +488,9 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
),
"freight-sync-1",
)
if replay.Code != http.StatusAccepted ||
if replay.Code != http.StatusOK ||
!strings.Contains(replay.Body.String(), `"replayed":true`) ||
!strings.Contains(replay.Body.String(), `"status":"SUCCEEDED"`) ||
!strings.Contains(replay.Body.String(), createBody.Sync.ID) {
t.Fatalf("replay status/body = %d / %s", replay.Code, replay.Body)
}
@@ -515,6 +518,9 @@ func TestAdminFreightDateSyncAdvancesInspectableWatermark(t *testing.T) {
"freight-date-sync-1",
)
requireAdminStatus(t, create, http.StatusAccepted)
if !strings.Contains(create.Body.String(), `"status":"PENDING"`) {
t.Fatalf("date sync create response = %s", create.Body)
}
var created struct {
Sync struct {
ID string `json:"id"`
@@ -589,31 +595,9 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
),
"procurement-freight-sync",
)
var syncBody struct {
Sync struct {
ID string `json:"id"`
} `json:"sync"`
}
decodeResponse(t, createSync, &syncBody)
succeeded := false
for attempt := 0; attempt < 50; attempt++ {
status := performAdminRequest(
t,
fixture.router,
http.MethodGet,
"/api/v1/freight-syncs/"+syncBody.Sync.ID,
"",
nil,
"",
)
if strings.Contains(status.Body.String(), `"status":"SUCCEEDED"`) {
succeeded = true
break
}
time.Sleep(10 * time.Millisecond)
}
if !succeeded {
t.Fatal("freight sync did not succeed")
requireAdminStatus(t, createSync, http.StatusCreated)
if !strings.Contains(createSync.Body.String(), `"status":"SUCCEEDED"`) {
t.Fatalf("freight sync response = %s", createSync.Body)
}
orders := performAdminRequest(
t,
@@ -106,7 +106,15 @@ func (h *adminHandlers) createFreightSync(ctx *gin.Context) {
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusAccepted, gin.H{
status := http.StatusAccepted
if request.Mode == domain.FreightSyncOrderNumber {
status = http.StatusCreated
if result.Replayed {
status = http.StatusOK
}
ctx.Header("Location", "/api/v1/freight-orders")
}
ctx.JSON(status, gin.H{
"sync": freightSyncResponse(result.Run),
"replayed": result.Replayed,
})
@@ -21,6 +21,7 @@ func TestWriteUsecaseErrorUsesPreflightStatusAndCode(t *testing.T) {
{"ERP_RESPONSE_INVALID", http.StatusBadGateway},
{"OCR_SERVICE_INVALID", http.StatusServiceUnavailable},
{"ERP_UNAVAILABLE", http.StatusServiceUnavailable},
{"FREIGHT_SYNC_TIMEOUT", http.StatusGatewayTimeout},
}
for _, testCase := range testCases {
t.Run(testCase.code, func(t *testing.T) {
@@ -13,6 +13,7 @@ import (
"time"
"unicode/utf8"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/transport/authcommon"
"github.com/gin-gonic/gin"
@@ -319,6 +320,7 @@ func (h *Handler) ListFreight(ctx *gin.Context) {
CSRFToken: token,
},
Orders: orders,
Notice: freightNotice(ctx.Query("notice")),
})
}
@@ -443,6 +445,10 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
})
return
}
if mode == domain.FreightSyncOrderNumber && run.Status == "SUCCEEDED" {
ctx.Redirect(http.StatusSeeOther, "/freight?notice=import-succeeded")
return
}
ctx.Redirect(http.StatusSeeOther, "/freight/import?sync="+pathEscape(run.ID))
}
@@ -452,7 +458,7 @@ func (h *Handler) logFreightImportFailure(code string, status int) {
return
}
h.logEvent(
"freight_import_preflight_failed code=" + code +
"freight_import_failed code=" + code +
" status=" + strconv.Itoa(status),
)
}
@@ -463,12 +469,20 @@ func freightImportError(err error) (string, string, string) {
return "OCR_SERVICE_INVALID", "OCR 服务无效", "OCR 服务无效,请检查本机 OCR 服务和 CMROUBAO_OCR_API_URL 后重试。"
case errors.Is(err, ErrERPNotConfigured):
return "ERP_NOT_CONFIGURED", "ERP 凭证未配置", "ERP 账号或密码未配置,请检查 backend-api/.env 后重试。"
case errors.Is(err, ErrERPSessionNeeded):
return "ERP_SESSION_REQUIRED", "ERP 会话已失效", "ERP 会话已失效,请使用相同提交标识重试。"
case errors.Is(err, ErrERPFreightNotFound):
return "ERP_FREIGHT_NOT_FOUND", "未找到货运单", "ERP 中没有找到该完整单号,请检查后重试。"
case errors.Is(err, ErrERPLoginRejected):
return "ERP_LOGIN_REJECTED", "ERP 登录被拒绝", "请检查 ERP 账号密码及 OCR 识别结果后重试。"
case errors.Is(err, ErrERPProtocol):
return "ERP_RESPONSE_INVALID", "ERP 响应无效", "ERP 返回格式无法确认,请稍后重试。"
case errors.Is(err, ErrERPUnavailable):
return "ERP_UNAVAILABLE", "ERP 暂时不可用", "ERP 服务暂时不可用,请稍后使用相同提交标识重试。"
case errors.Is(err, ErrFreightSyncBusy):
return "FREIGHT_SYNC_BUSY", "ERP 正在同步", "已有完整单号正在同步,请等待完成后重试。"
case errors.Is(err, ErrFreightSyncTimeout):
return "FREIGHT_SYNC_TIMEOUT", "ERP 同步超时", "完整单号同步超过 55 秒,请使用相同提交标识重试。"
default:
return "", "", "同步任务创建失败,请稍后使用相同提交标识重试。"
}
@@ -646,6 +660,8 @@ func freightNotice(value string) string {
return "参考图已绑定,可以生成采购任务。"
case "task-conflict":
return "需求状态或来源已变化,当前不能生成任务。"
case "import-succeeded":
return "ERP 货运单已同步,货运信息和商品明细已更新。"
default:
return ""
}
@@ -1270,10 +1286,16 @@ func serviceErrorStatus(err error) int {
return http.StatusServiceUnavailable
case errors.Is(err, ErrERPNotConfigured), errors.Is(err, ErrERPLoginRejected):
return http.StatusUnprocessableEntity
case errors.Is(err, ErrERPFreightNotFound):
return http.StatusNotFound
case errors.Is(err, ErrERPProtocol):
return http.StatusBadGateway
case errors.Is(err, ErrERPUnavailable):
case errors.Is(err, ErrERPSessionNeeded), errors.Is(err, ErrERPUnavailable):
return http.StatusServiceUnavailable
case errors.Is(err, ErrFreightSyncBusy):
return http.StatusConflict
case errors.Is(err, ErrFreightSyncTimeout):
return http.StatusGatewayTimeout
case errors.Is(err, ErrConflict):
return http.StatusConflict
case errors.Is(err, context.DeadlineExceeded):
@@ -1336,6 +1358,7 @@ type pageView struct {
type freightPage struct {
Page pageView
Orders []FreightOrder
Notice string
}
type freightImportPage struct {
@@ -923,7 +923,7 @@ func TestRendererUsesMissingKeyErrors(t *testing.T) {
}
}
func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
func TestFreightPagesEscapeSourceDataAndCreateSynchronousOrderSync(t *testing.T) {
now := time.Date(2026, 7, 28, 3, 4, 5, 0, time.UTC)
service := &fakeFreightService{
fakeService: &fakeService{},
@@ -938,7 +938,7 @@ func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
}},
createResult: FreightSync{
ID: testTaskID,
Status: "PENDING",
Status: "SUCCEEDED",
CreatedAt: now,
},
}
@@ -987,7 +987,7 @@ func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") !=
"/freight/import?sync="+testTaskID {
"/freight?notice=import-succeeded" {
t.Fatalf(
"create sync status/location = %d / %q",
response.Code,
@@ -998,6 +998,17 @@ func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
service.createInput.IdempotencyKey != idempotencyKey {
t.Fatalf("create input = %+v", service.createInput)
}
list = performRequest(
t,
router,
http.MethodGet,
"/freight?notice=import-succeeded",
nil,
"",
)
if !strings.Contains(list.Body.String(), "ERP 货运单已同步") {
t.Fatalf("freight success notice = %s", list.Body)
}
}
func TestFreightDateFormSubmitsManualRange(t *testing.T) {
@@ -1042,6 +1053,8 @@ func TestFreightDateFormSubmitsManualRange(t *testing.T) {
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") !=
"/freight/import?sync="+testTaskID ||
service.createInput.Mode != "CREATED_RANGE" ||
service.createInput.CreatedFrom != "2026-07-22" ||
service.createInput.CreatedTo != "2026-07-28" {
@@ -1116,9 +1129,13 @@ func TestFreightImportShowsStablePreflightErrorDialog(t *testing.T) {
}{
{"ocr", ErrOCRServiceInvalid, http.StatusServiceUnavailable, "OCR_SERVICE_INVALID", "OCR 服务无效"},
{"not configured", ErrERPNotConfigured, http.StatusUnprocessableEntity, "ERP_NOT_CONFIGURED", "ERP 凭证未配置"},
{"session", ErrERPSessionNeeded, http.StatusServiceUnavailable, "ERP_SESSION_REQUIRED", "ERP 会话已失效"},
{"not found", ErrERPFreightNotFound, http.StatusNotFound, "ERP_FREIGHT_NOT_FOUND", "未找到货运单"},
{"login rejected", ErrERPLoginRejected, http.StatusUnprocessableEntity, "ERP_LOGIN_REJECTED", "ERP 登录被拒绝"},
{"protocol", ErrERPProtocol, http.StatusBadGateway, "ERP_RESPONSE_INVALID", "ERP 响应无效"},
{"unavailable", ErrERPUnavailable, http.StatusServiceUnavailable, "ERP_UNAVAILABLE", "ERP 暂时不可用"},
{"busy", ErrFreightSyncBusy, http.StatusConflict, "FREIGHT_SYNC_BUSY", "ERP 正在同步"},
{"timeout", ErrFreightSyncTimeout, http.StatusGatewayTimeout, "FREIGHT_SYNC_TIMEOUT", "ERP 同步超时"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
@@ -1151,7 +1168,7 @@ func TestFreightImportShowsStablePreflightErrorDialog(t *testing.T) {
service.createInput.OrderNumber != "ORDER-123" {
t.Fatalf("dialog response/input = %d / %s / %+v", response.Code, response.Body, service.createInput)
}
wantEvent := "freight_import_preflight_failed code=" +
wantEvent := "freight_import_failed code=" +
testCase.code + " status=" + strconv.Itoa(testCase.status)
if event != wantEvent || strings.Contains(event, "private") {
t.Fatalf("event = %q, want %q", event, wantEvent)
@@ -5,6 +5,7 @@ import (
"testing"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
)
func TestMapFreightPreflightErrorUsesStablePublicErrors(t *testing.T) {
@@ -14,6 +15,8 @@ func TestMapFreightPreflightErrorUsesStablePublicErrors(t *testing.T) {
}{
{domain.ErrFreightSourceOCRInvalid, ErrOCRServiceInvalid},
{domain.ErrFreightSourceNotConfigured, ErrERPNotConfigured},
{domain.ErrFreightSourceSessionNeeded, ErrERPSessionNeeded},
{domain.ErrFreightSourceNotFound, ErrERPFreightNotFound},
{domain.ErrFreightSourceLoginRejected, ErrERPLoginRejected},
{domain.ErrFreightSourceProtocol, ErrERPProtocol},
{domain.ErrFreightSourceUnavailable, ErrERPUnavailable},
@@ -33,6 +36,8 @@ func TestMapFreightCreateErrorPreservesPreflightErrors(t *testing.T) {
}{
{domain.ErrFreightSourceOCRInvalid, ErrOCRServiceInvalid},
{domain.ErrFreightSourceNotConfigured, ErrERPNotConfigured},
{domain.ErrFreightSourceSessionNeeded, ErrERPSessionNeeded},
{domain.ErrFreightSourceNotFound, ErrERPFreightNotFound},
{domain.ErrFreightSourceLoginRejected, ErrERPLoginRejected},
{domain.ErrFreightSourceProtocol, ErrERPProtocol},
{domain.ErrFreightSourceUnavailable, ErrERPUnavailable},
@@ -43,3 +48,23 @@ func TestMapFreightCreateErrorPreservesPreflightErrors(t *testing.T) {
}
}
}
func TestMapFreightCreateErrorUsesStableSyncErrors(t *testing.T) {
for _, testCase := range []struct {
code string
want error
}{
{"FREIGHT_SYNC_BUSY", ErrFreightSyncBusy},
{"FREIGHT_SYNC_TIMEOUT", ErrFreightSyncTimeout},
} {
source := &usecase.Error{
Kind: usecase.ErrorKindUnavailable,
Code: testCase.code,
Fields: map[string]string{},
}
actual := mapFreightCreateError(source)
if !errors.Is(actual, testCase.want) || !errors.Is(actual, source) {
t.Fatalf("mapFreightCreateError(%s) = %v", testCase.code, actual)
}
}
}
@@ -63,7 +63,7 @@
<input id="order-number" name="order_number" value="{{.OrderNumber}}"
maxlength="128" autocomplete="off" required>
</div>
<button class="button primary" type="submit" data-loading-label="正在创建…">同步此单号</button>
<button class="button primary" type="submit" data-loading-label="正在同步…">同步此单号</button>
</form>
<form class="form-panel" method="post" action="/freight/import" data-loading-form>
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
@@ -15,6 +15,7 @@
</div>
<a class="button primary" href="/freight/import">导入货运单</a>
</div>
{{if .Notice}}<div class="notice" role="status">{{.Notice}}</div>{{end}}
<section class="table-region" aria-labelledby="freight-table-title">
<h2 id="freight-table-title" class="visually-hidden">货运单列表</h2>
{{if .Orders}}
+16 -13
View File
@@ -8,19 +8,22 @@ import (
)
var (
ErrNotFound = errors.New("resource not found")
ErrForbidden = errors.New("resource forbidden")
ErrConflict = errors.New("resource conflict")
ErrValidation = errors.New("validation failed")
ErrInvalidFile = errors.New("invalid file")
ErrUnavailable = errors.New("service unavailable")
ErrERPNotConfigured = errors.New("ERP is not configured")
ErrERPSessionNeeded = errors.New("ERP session is required")
ErrERPCaptchaInvalid = errors.New("ERP captcha is invalid")
ErrERPLoginRejected = errors.New("ERP login was rejected")
ErrERPProtocol = errors.New("ERP protocol is invalid")
ErrERPUnavailable = errors.New("ERP is unavailable")
ErrOCRServiceInvalid = errors.New("OCR service is invalid")
ErrNotFound = errors.New("resource not found")
ErrForbidden = errors.New("resource forbidden")
ErrConflict = errors.New("resource conflict")
ErrValidation = errors.New("validation failed")
ErrInvalidFile = errors.New("invalid file")
ErrUnavailable = errors.New("service unavailable")
ErrERPNotConfigured = errors.New("ERP is not configured")
ErrERPSessionNeeded = errors.New("ERP session is required")
ErrERPCaptchaInvalid = errors.New("ERP captcha is invalid")
ErrERPLoginRejected = errors.New("ERP login was rejected")
ErrERPProtocol = errors.New("ERP protocol is invalid")
ErrERPUnavailable = errors.New("ERP is unavailable")
ErrERPFreightNotFound = errors.New("ERP freight order was not found")
ErrOCRServiceInvalid = errors.New("OCR service is invalid")
ErrFreightSyncBusy = errors.New("freight sync is busy")
ErrFreightSyncTimeout = errors.New("freight sync timed out")
)
// Service is the application boundary required by the server-rendered admin UI.
@@ -334,6 +334,10 @@ func mapFreightPreflightError(err error) error {
public = ErrOCRServiceInvalid
case errors.Is(err, domain.ErrFreightSourceNotConfigured):
public = ErrERPNotConfigured
case errors.Is(err, domain.ErrFreightSourceSessionNeeded):
public = ErrERPSessionNeeded
case errors.Is(err, domain.ErrFreightSourceNotFound):
public = ErrERPFreightNotFound
case errors.Is(err, domain.ErrFreightSourceLoginRejected):
public = ErrERPLoginRejected
case errors.Is(err, domain.ErrFreightSourceProtocol):
@@ -388,6 +392,19 @@ func mapFreightCreateError(err error) error {
if mapped := mapFreightPreflightError(err); mapped != nil {
return mapped
}
var typed *usecase.Error
if errors.As(err, &typed) {
var public error
switch typed.Code {
case "FREIGHT_SYNC_BUSY":
public = ErrFreightSyncBusy
case "FREIGHT_SYNC_TIMEOUT":
public = ErrFreightSyncTimeout
}
if public != nil {
return &adapterError{public: public, cause: err}
}
}
return mapUsecaseError(err)
}
+174 -30
View File
@@ -20,14 +20,19 @@ const (
maxFreightItemsPerOrder = 1000
maxFreightWindowDays = 7
freightWatermarkOverlap = 10 * time.Minute
orderFreightSyncTimeout = 55 * time.Second
freightCleanupTimeout = 5 * time.Second
)
type FreightService struct {
repository FreightRepository
source FreightSource
clock Clock
ids IDGenerator
timeout time.Duration
repository FreightRepository
source FreightSource
clock Clock
ids IDGenerator
timeout time.Duration
orderTimeout time.Duration
cleanupTimeout time.Duration
orderSyncGate chan struct{}
}
type FreightSourcePreflight interface {
@@ -67,11 +72,14 @@ func NewFreightService(
return nil, errors.New("freight service dependencies are required")
}
return &FreightService{
repository: repository,
source: source,
clock: clock,
ids: ids,
timeout: timeout,
repository: repository,
source: source,
clock: clock,
ids: ids,
timeout: timeout,
orderTimeout: orderFreightSyncTimeout,
cleanupTimeout: freightCleanupTimeout,
orderSyncGate: make(chan struct{}, 1),
}, nil
}
@@ -105,7 +113,16 @@ func (service *FreightService) CreateOrderSync(
fields,
)
}
if err := service.ensureSource(ctx); err != nil {
if !service.acquireOrderSync() {
return CreateFreightSyncResult{}, freightSyncBusyError()
}
defer service.releaseOrderSync()
syncCtx, cancel := context.WithTimeout(ctx, service.orderTimeout)
defer cancel()
if err := service.ensureSource(syncCtx); err != nil {
if syncErr := freightContextError(syncCtx); syncErr != nil {
return CreateFreightSyncResult{}, syncErr
}
return CreateFreightSyncResult{}, err
}
runID, err := service.ids.NewID()
@@ -121,7 +138,7 @@ func (service *FreightService) CreateOrderSync(
OrderNumber string `json:"order_number"`
}{command.OrderNumber})
run, created, err := service.repository.CreateFreightSync(
ctx,
syncCtx,
domain.FreightSyncRun{
ID: runID,
CreatorSubject: command.CreatorSubject,
@@ -138,10 +155,31 @@ func (service *FreightService) CreateOrderSync(
if err != nil {
return CreateFreightSyncResult{}, wrapRepositoryError(err)
}
if created {
go service.execute(run)
if !created {
if run.Status != domain.FreightSyncSucceeded &&
run.Status != domain.FreightSyncFailed {
return CreateFreightSyncResult{}, freightSyncBusyError()
}
return CreateFreightSyncResult{Run: run, Replayed: true}, nil
}
return CreateFreightSyncResult{Run: run, Replayed: !created}, nil
completed, err := service.executeRun(syncCtx, run)
if err != nil {
return CreateFreightSyncResult{}, err
}
return CreateFreightSyncResult{Run: completed}, nil
}
func (service *FreightService) acquireOrderSync() bool {
select {
case service.orderSyncGate <- struct{}{}:
return true
default:
return false
}
}
func (service *FreightService) releaseOrderSync() {
<-service.orderSyncGate
}
func (service *FreightService) CreateDateSync(
@@ -310,10 +348,24 @@ func (service *FreightService) ensureSource(ctx context.Context) error {
func (service *FreightService) execute(run domain.FreightSyncRun) {
ctx, cancel := context.WithTimeout(context.Background(), service.timeout)
defer cancel()
_, _ = service.executeRun(ctx, run)
}
func (service *FreightService) executeRun(
ctx context.Context,
run domain.FreightSyncRun,
) (domain.FreightSyncRun, error) {
now := service.clock.Now().UTC()
if err := service.repository.StartFreightSync(ctx, run.ID, now); err != nil {
return
code := freightFailureCode(ctx, "STORAGE_UNAVAILABLE")
return domain.FreightSyncRun{}, service.finishFreightFailure(
run.ID,
code,
wrapRepositoryError(err),
)
}
run.Status = domain.FreightSyncRunning
run.StartedAt = &now
var source domain.FreightSourceBatch
var err error
if run.Mode == domain.FreightSyncCreatedRange {
@@ -322,23 +374,21 @@ func (service *FreightService) execute(run domain.FreightSyncRun) {
source, err = service.source.QueryOrder(ctx, run.OrderNumber)
}
if err != nil {
_ = service.repository.FailFreightSync(
ctx,
code := freightFailureCode(ctx, freightSourceErrorCode(err))
return domain.FreightSyncRun{}, service.finishFreightFailure(
run.ID,
freightSourceErrorCode(err),
service.clock.Now().UTC(),
code,
freightExecutionError(code, err),
)
return
}
batch, err := service.normalize(source)
if err != nil {
_ = service.repository.FailFreightSync(
ctx,
code := freightFailureCode(ctx, "ERP_RESPONSE_INVALID")
return domain.FreightSyncRun{}, service.finishFreightFailure(
run.ID,
"ERP_RESPONSE_INVALID",
service.clock.Now().UTC(),
code,
freightExecutionError(code, domain.ErrFreightSourceProtocol),
)
return
}
finishedAt := service.clock.Now().UTC()
if run.Mode == domain.FreightSyncCreatedRange &&
@@ -359,13 +409,107 @@ func (service *FreightService) execute(run domain.FreightSyncRun) {
)
}
if err != nil {
_ = service.repository.FailFreightSync(
ctx,
code := freightFailureCode(ctx, "STORAGE_UNAVAILABLE")
return domain.FreightSyncRun{}, service.finishFreightFailure(
run.ID,
"STORAGE_UNAVAILABLE",
service.clock.Now().UTC(),
code,
wrapRepositoryError(err),
)
}
itemCount := 0
for _, order := range batch.Orders {
itemCount += len(order.Items)
}
run.Status = domain.FreightSyncSucceeded
run.OrderCount = len(batch.Orders)
run.ItemCount = itemCount
run.FinishedAt = &finishedAt
return run, nil
}
func (service *FreightService) finishFreightFailure(
runID, code string,
cause error,
) error {
timeout := service.cleanupTimeout
if timeout <= 0 {
timeout = freightCleanupTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if err := service.repository.FailFreightSync(
ctx,
runID,
code,
service.clock.Now().UTC(),
); err != nil {
return wrapRepositoryError(err)
}
return cause
}
func freightFailureCode(ctx context.Context, fallback string) string {
switch ctx.Err() {
case context.DeadlineExceeded:
return "FREIGHT_SYNC_TIMEOUT"
case context.Canceled:
return "FREIGHT_SYNC_CANCELED"
default:
return fallback
}
}
func freightContextError(ctx context.Context) error {
switch ctx.Err() {
case context.DeadlineExceeded:
return freightExecutionError(
"FREIGHT_SYNC_TIMEOUT",
context.DeadlineExceeded,
)
case context.Canceled:
return freightExecutionError(
"FREIGHT_SYNC_CANCELED",
context.Canceled,
)
default:
return nil
}
}
func freightSyncBusyError() error {
result := newError(
ErrorKindConflict,
"FREIGHT_SYNC_BUSY",
"another freight order sync is already running",
nil,
)
result.Retryable = true
return result
}
func freightExecutionError(code string, cause error) error {
var kind ErrorKind
var message string
switch code {
case "FREIGHT_SYNC_TIMEOUT":
kind = ErrorKindUnavailable
message = "freight order sync exceeded its time limit"
case "FREIGHT_SYNC_CANCELED":
kind = ErrorKindUnavailable
message = "freight order sync was canceled"
case "ERP_FREIGHT_NOT_FOUND":
kind = ErrorKindNotFound
message = "ERP freight order was not found"
default:
kind = ErrorKindUnavailable
message = freightPreflightMessage(code)
}
result := newError(kind, code, message, cause)
result.Retryable = code == "FREIGHT_SYNC_TIMEOUT" ||
code == "FREIGHT_SYNC_CANCELED" ||
code == "ERP_UNAVAILABLE" ||
code == "OCR_SERVICE_INVALID"
return result
}
func (service *FreightService) queryCreatedRange(
@@ -3,6 +3,7 @@ package usecase
import (
"context"
"errors"
"sync"
"testing"
"time"
@@ -98,6 +99,176 @@ func TestCreateFreightOrderSyncStopsBeforePersistingWhenOCRIsInvalid(t *testing.
}
}
func TestCreateFreightOrderSyncReturnsCommittedResult(t *testing.T) {
repository := &syncTrackingRepository{}
service, err := NewFreightService(
repository,
&recordingDateSource{},
fakeClock{},
&sequenceIDs{},
time.Minute,
)
if err != nil {
t.Fatalf("NewFreightService() error = %v", err)
}
if service.orderTimeout != 55*time.Second {
t.Fatalf("order timeout = %s", service.orderTimeout)
}
result, err := service.CreateOrderSync(
context.Background(),
validFreightOrderSyncCommand("sync-success"),
)
if err != nil {
t.Fatalf("CreateOrderSync() error = %v", err)
}
if result.Replayed || result.Run.Status != domain.FreightSyncSucceeded ||
result.Run.OrderCount != 1 || result.Run.ItemCount != 1 ||
result.Run.StartedAt == nil || result.Run.FinishedAt == nil {
t.Fatalf("CreateOrderSync() result = %+v", result)
}
repository.mu.Lock()
defer repository.mu.Unlock()
if repository.startCalls != 1 || repository.completeCalls != 1 ||
repository.failCalls != 0 {
t.Fatalf(
"start/complete/fail calls = %d/%d/%d",
repository.startCalls,
repository.completeCalls,
repository.failCalls,
)
}
}
func TestCreateFreightOrderSyncTimeoutUsesLiveCleanupContext(t *testing.T) {
repository := &syncTrackingRepository{}
source := &blockingOrderSource{}
service, err := NewFreightService(
repository,
source,
fakeClock{},
&sequenceIDs{},
time.Minute,
)
if err != nil {
t.Fatalf("NewFreightService() error = %v", err)
}
service.orderTimeout = 10 * time.Millisecond
_, err = service.CreateOrderSync(
context.Background(),
validFreightOrderSyncCommand("sync-timeout"),
)
assertUsecaseError(t, err, ErrorKindUnavailable, "FREIGHT_SYNC_TIMEOUT")
repository.mu.Lock()
defer repository.mu.Unlock()
if repository.failCalls != 1 ||
repository.failCode != "FREIGHT_SYNC_TIMEOUT" ||
repository.failContextErr != nil {
t.Fatalf(
"failure calls/code/context = %d/%q/%v",
repository.failCalls,
repository.failCode,
repository.failContextErr,
)
}
}
func TestCreateFreightOrderSyncCancellationUsesLiveCleanupContext(t *testing.T) {
repository := &syncTrackingRepository{}
source := &blockingOrderSource{started: make(chan struct{})}
service, err := NewFreightService(
repository,
source,
fakeClock{},
&sequenceIDs{},
time.Minute,
)
if err != nil {
t.Fatalf("NewFreightService() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() {
_, runErr := service.CreateOrderSync(
ctx,
validFreightOrderSyncCommand("sync-canceled"),
)
result <- runErr
}()
select {
case <-source.started:
case <-time.After(time.Second):
t.Fatal("order sync did not reach source")
}
cancel()
err = <-result
assertUsecaseError(t, err, ErrorKindUnavailable, "FREIGHT_SYNC_CANCELED")
repository.mu.Lock()
defer repository.mu.Unlock()
if repository.failCalls != 1 ||
repository.failCode != "FREIGHT_SYNC_CANCELED" ||
repository.failContextErr != nil {
t.Fatalf(
"failure calls/code/context = %d/%q/%v",
repository.failCalls,
repository.failCode,
repository.failContextErr,
)
}
}
func TestCreateFreightOrderSyncRejectsConcurrentRequest(t *testing.T) {
repository := &syncTrackingRepository{}
source := &blockingOrderSource{
started: make(chan struct{}),
release: make(chan struct{}),
}
service, err := NewFreightService(
repository,
source,
fakeClock{},
&sequenceIDs{},
time.Minute,
)
if err != nil {
t.Fatalf("NewFreightService() error = %v", err)
}
firstResult := make(chan error, 1)
go func() {
_, runErr := service.CreateOrderSync(
context.Background(),
validFreightOrderSyncCommand("sync-first"),
)
firstResult <- runErr
}()
select {
case <-source.started:
case <-time.After(time.Second):
t.Fatal("first order sync did not reach source")
}
startedAt := time.Now()
_, err = service.CreateOrderSync(
context.Background(),
validFreightOrderSyncCommand("sync-second"),
)
assertUsecaseError(t, err, ErrorKindConflict, "FREIGHT_SYNC_BUSY")
if time.Since(startedAt) > 100*time.Millisecond {
t.Fatalf("busy response took %s", time.Since(startedAt))
}
close(source.release)
if err := <-firstResult; err != nil {
t.Fatalf("first CreateOrderSync() error = %v", err)
}
}
func validFreightOrderSyncCommand(idempotencyKey string) CreateFreightSyncCommand {
return CreateFreightSyncCommand{
CreatorSubject: "local-admin",
ActorUserID: "00000000-0000-4000-8000-000000000099",
IdempotencyKey: idempotencyKey,
OrderNumber: "ORDER-123",
}
}
func TestFreightDateQuerySplitsIntoSevenDayWindows(t *testing.T) {
source := &recordingDateSource{}
service := &FreightService{source: source}
@@ -261,6 +432,144 @@ type recordingDateSource struct {
ensureErr error
}
type blockingOrderSource struct {
started chan struct{}
release chan struct{}
once sync.Once
}
func (source *blockingOrderSource) QueryOrder(
ctx context.Context,
_ string,
) (domain.FreightSourceBatch, error) {
if source.started != nil {
source.once.Do(func() {
close(source.started)
})
}
if source.release != nil {
select {
case <-source.release:
case <-ctx.Done():
return domain.FreightSourceBatch{}, ctx.Err()
}
} else {
<-ctx.Done()
return domain.FreightSourceBatch{}, ctx.Err()
}
return validFreightSource(), nil
}
func (*blockingOrderSource) QueryCreatedRange(
context.Context,
string,
string,
) (domain.FreightSourceBatch, error) {
return domain.FreightSourceBatch{}, errors.New("unexpected date query")
}
type syncTrackingRepository struct {
mu sync.Mutex
startCalls int
completeCalls int
failCalls int
failCode string
failContextErr error
}
func (*syncTrackingRepository) CreateFreightSync(
_ context.Context,
run domain.FreightSyncRun,
_, _ string,
) (domain.FreightSyncRun, bool, error) {
return run, true, nil
}
func (repository *syncTrackingRepository) StartFreightSync(
context.Context,
string,
time.Time,
) error {
repository.mu.Lock()
defer repository.mu.Unlock()
repository.startCalls++
return nil
}
func (repository *syncTrackingRepository) CompleteFreightSync(
context.Context,
domain.FreightSyncRun,
domain.FreightImportBatch,
time.Time,
) error {
repository.mu.Lock()
defer repository.mu.Unlock()
repository.completeCalls++
return nil
}
func (*syncTrackingRepository) CompleteFreightDateSync(
context.Context,
domain.FreightSyncRun,
domain.FreightImportBatch,
time.Time,
time.Time,
) error {
return errors.New("unexpected date completion")
}
func (repository *syncTrackingRepository) FailFreightSync(
ctx context.Context,
_ string,
code string,
_ time.Time,
) error {
repository.mu.Lock()
defer repository.mu.Unlock()
repository.failCalls++
repository.failCode = code
repository.failContextErr = ctx.Err()
return nil
}
func (*syncTrackingRepository) RecoverFreightSyncs(
context.Context,
time.Time,
) (int64, error) {
return 0, nil
}
func (*syncTrackingRepository) GetFreightSync(
context.Context,
string,
string,
) (domain.FreightSyncRun, error) {
return domain.FreightSyncRun{}, nil
}
func (*syncTrackingRepository) GetFreightSyncWatermark(
context.Context,
string,
) (*domain.FreightSyncWatermark, error) {
return nil, nil
}
func (*syncTrackingRepository) ListFreightOrders(
context.Context,
string,
int,
) ([]domain.FreightOrder, error) {
return nil, nil
}
func (*syncTrackingRepository) GetFreightOrder(
context.Context,
string,
string,
) (domain.FreightOrderDetail, error) {
return domain.FreightOrderDetail{}, nil
}
func (source *recordingDateSource) EnsureAuthenticated(context.Context) error {
source.ensureCalls++
return source.ensureErr