feat(t237): import freight orders synchronously
This commit is contained in:
@@ -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