From 8d5b88f4a2a2d244f0d7666140a97eb884b8f655 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 29 Jul 2026 12:11:56 +0800 Subject: [PATCH] feat(t237): import freight orders synchronously --- backend-api/internal/config/config.go | 2 +- backend-api/internal/config/config_test.go | 3 + .../transport/httpapi/admin_handlers.go | 6 + .../transport/httpapi/admin_handlers_test.go | 38 +-- .../transport/httpapi/freight_handlers.go | 10 +- .../transport/httpapi/preflight_error_test.go | 1 + .../internal/transport/webui/handler.go | 27 +- .../internal/transport/webui/handler_test.go | 25 +- .../transport/webui/preflight_error_test.go | 25 ++ .../webui/templates/freight-import.gohtml | 2 +- .../transport/webui/templates/freight.gohtml | 1 + backend-api/internal/transport/webui/types.go | 29 +- .../transport/webui/usecase_adapter.go | 17 + .../internal/usecase/freight_service.go | 204 ++++++++++-- .../internal/usecase/freight_service_test.go | 309 ++++++++++++++++++ docs/api.md | 20 +- docs/current-state.md | 9 +- docs/tasks/T-237.md | 22 +- 18 files changed, 653 insertions(+), 97 deletions(-) diff --git a/backend-api/internal/config/config.go b/backend-api/internal/config/config.go index 32ac8d5..57d9b6e 100644 --- a/backend-api/internal/config/config.go +++ b/backend-api/internal/config/config.go @@ -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, diff --git a/backend-api/internal/config/config_test.go b/backend-api/internal/config/config_test.go index 487187a..ce302d3 100644 --- a/backend-api/internal/config/config_test.go +++ b/backend-api/internal/config/config_test.go @@ -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 { diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go index 69dfee5..09bd838 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers.go +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -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 } diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index 39102b3..60fe2c2 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -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, diff --git a/backend-api/internal/transport/httpapi/freight_handlers.go b/backend-api/internal/transport/httpapi/freight_handlers.go index 584be19..971e528 100644 --- a/backend-api/internal/transport/httpapi/freight_handlers.go +++ b/backend-api/internal/transport/httpapi/freight_handlers.go @@ -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, }) diff --git a/backend-api/internal/transport/httpapi/preflight_error_test.go b/backend-api/internal/transport/httpapi/preflight_error_test.go index 15f411d..a780987 100644 --- a/backend-api/internal/transport/httpapi/preflight_error_test.go +++ b/backend-api/internal/transport/httpapi/preflight_error_test.go @@ -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) { diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go index e522d30..a290783 100644 --- a/backend-api/internal/transport/webui/handler.go +++ b/backend-api/internal/transport/webui/handler.go @@ -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 { diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index 17332e2..0bc22fd 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -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) diff --git a/backend-api/internal/transport/webui/preflight_error_test.go b/backend-api/internal/transport/webui/preflight_error_test.go index ae4a330..e4a5e93 100644 --- a/backend-api/internal/transport/webui/preflight_error_test.go +++ b/backend-api/internal/transport/webui/preflight_error_test.go @@ -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) + } + } +} diff --git a/backend-api/internal/transport/webui/templates/freight-import.gohtml b/backend-api/internal/transport/webui/templates/freight-import.gohtml index b7f117d..d728ad7 100644 --- a/backend-api/internal/transport/webui/templates/freight-import.gohtml +++ b/backend-api/internal/transport/webui/templates/freight-import.gohtml @@ -63,7 +63,7 @@ - +