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
@@ -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)
}