feat(t237): import freight orders synchronously
This commit is contained in:
@@ -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}}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user