fix(t231): expose freight preflight errors
This commit is contained in:
@@ -11,4 +11,5 @@ var (
|
|||||||
ErrFreightSourceUnavailable = errors.New("freight source is unavailable")
|
ErrFreightSourceUnavailable = errors.New("freight source is unavailable")
|
||||||
ErrFreightSourceProtocol = errors.New("freight source protocol is invalid")
|
ErrFreightSourceProtocol = errors.New("freight source protocol is invalid")
|
||||||
ErrFreightSourceOCRInvalid = errors.New("freight source OCR service is invalid")
|
ErrFreightSourceOCRInvalid = errors.New("freight source OCR service is invalid")
|
||||||
|
ErrFreightSourceLoginRejected = errors.New("freight source login was rejected")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ func (manager *SessionManager) EnsureAuthenticated(ctx context.Context) error {
|
|||||||
return domain.ErrFreightSourceOCRInvalid
|
return domain.ErrFreightSourceOCRInvalid
|
||||||
}
|
}
|
||||||
_, err = manager.Login(ctx, status.CaptchaTicket, code)
|
_, err = manager.Login(ctx, status.CaptchaTicket, code)
|
||||||
|
if errors.Is(err, ErrLoginRejected) {
|
||||||
|
return domain.ErrFreightSourceLoginRejected
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -845,6 +845,18 @@ func writeUsecaseError(ctx *gin.Context, err error) {
|
|||||||
case usecase.ErrorKindUnavailable:
|
case usecase.ErrorKindUnavailable:
|
||||||
status = http.StatusServiceUnavailable
|
status = http.StatusServiceUnavailable
|
||||||
}
|
}
|
||||||
|
switch typed.Code {
|
||||||
|
case "ERP_NOT_CONFIGURED", "ERP_LOGIN_REJECTED":
|
||||||
|
status = http.StatusUnprocessableEntity
|
||||||
|
case "ERP_RESPONSE_INVALID":
|
||||||
|
status = http.StatusBadGateway
|
||||||
|
case "OCR_SERVICE_INVALID", "ERP_UNAVAILABLE":
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
message := typed.Message
|
||||||
|
if preflightMessage, ok := freightPreflightPublicMessage(typed.Code); ok {
|
||||||
|
message = preflightMessage
|
||||||
|
}
|
||||||
details := gin.H{}
|
details := gin.H{}
|
||||||
if len(typed.Fields) > 0 {
|
if len(typed.Fields) > 0 {
|
||||||
details["fields"] = typed.Fields
|
details["fields"] = typed.Fields
|
||||||
@@ -853,12 +865,29 @@ func writeUsecaseError(ctx *gin.Context, err error) {
|
|||||||
ctx,
|
ctx,
|
||||||
status,
|
status,
|
||||||
typed.Code,
|
typed.Code,
|
||||||
typed.Message,
|
message,
|
||||||
typed.Retryable,
|
typed.Retryable,
|
||||||
details,
|
details,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func freightPreflightPublicMessage(code string) (string, bool) {
|
||||||
|
switch code {
|
||||||
|
case "ERP_NOT_CONFIGURED":
|
||||||
|
return "ERP credentials are not configured", true
|
||||||
|
case "ERP_LOGIN_REJECTED":
|
||||||
|
return "ERP login was rejected", true
|
||||||
|
case "ERP_RESPONSE_INVALID":
|
||||||
|
return "ERP response is invalid", true
|
||||||
|
case "OCR_SERVICE_INVALID":
|
||||||
|
return "OCR service is invalid", true
|
||||||
|
case "ERP_UNAVAILABLE":
|
||||||
|
return "ERP is temporarily unavailable", true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writePublicError(
|
func writePublicError(
|
||||||
ctx *gin.Context,
|
ctx *gin.Context,
|
||||||
status int,
|
status int,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/usecase"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteUsecaseErrorUsesPreflightStatusAndCode(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
code string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"ERP_NOT_CONFIGURED", http.StatusUnprocessableEntity},
|
||||||
|
{"ERP_LOGIN_REJECTED", http.StatusUnprocessableEntity},
|
||||||
|
{"ERP_RESPONSE_INVALID", http.StatusBadGateway},
|
||||||
|
{"OCR_SERVICE_INVALID", http.StatusServiceUnavailable},
|
||||||
|
{"ERP_UNAVAILABLE", http.StatusServiceUnavailable},
|
||||||
|
}
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.code, func(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/test", func(ctx *gin.Context) {
|
||||||
|
writeUsecaseError(ctx, &usecase.Error{
|
||||||
|
Kind: usecase.ErrorKindUnavailable,
|
||||||
|
Code: testCase.code,
|
||||||
|
Message: "private upstream response is hidden",
|
||||||
|
Fields: map[string]string{},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/test", nil))
|
||||||
|
if response.Code != testCase.status ||
|
||||||
|
!strings.Contains(response.Body.String(), testCase.code) ||
|
||||||
|
strings.Contains(response.Body.String(), "private upstream") {
|
||||||
|
t.Fatalf("response = %d / %s", response.Code, response.Body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -411,12 +411,7 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
token, _ := csrfToken(ctx)
|
token, _ := csrfToken(ctx)
|
||||||
message := "同步任务创建失败,请稍后使用相同提交标识重试。"
|
code, title, message := freightImportError(err)
|
||||||
code := ""
|
|
||||||
if errors.Is(err, ErrOCRServiceInvalid) {
|
|
||||||
message = "OCR 服务无效,请检查本机 OCR 服务和 CMROUBAO_OCR_API_URL 后重试。"
|
|
||||||
code = "OCR_SERVICE_INVALID"
|
|
||||||
}
|
|
||||||
h.render(ctx, serviceErrorStatus(err), "freight-import", freightImportPage{
|
h.render(ctx, serviceErrorStatus(err), "freight-import", freightImportPage{
|
||||||
Page: pageView{
|
Page: pageView{
|
||||||
Title: "导入 ERP 货运",
|
Title: "导入 ERP 货运",
|
||||||
@@ -430,12 +425,30 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
|
|||||||
IdempotencyKey: key,
|
IdempotencyKey: key,
|
||||||
Error: message,
|
Error: message,
|
||||||
ErrorCode: code,
|
ErrorCode: code,
|
||||||
|
ErrorTitle: title,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.Redirect(http.StatusSeeOther, "/freight/import?sync="+pathEscape(run.ID))
|
ctx.Redirect(http.StatusSeeOther, "/freight/import?sync="+pathEscape(run.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func freightImportError(err error) (string, string, string) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrOCRServiceInvalid):
|
||||||
|
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, 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 服务暂时不可用,请稍后使用相同提交标识重试。"
|
||||||
|
default:
|
||||||
|
return "", "", "同步任务创建失败,请稍后使用相同提交标识重试。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) FreightDetail(ctx *gin.Context) {
|
func (h *Handler) FreightDetail(ctx *gin.Context) {
|
||||||
service := h.service.(FreightService)
|
service := h.service.(FreightService)
|
||||||
detail, err := service.GetFreightOrder(
|
detail, err := service.GetFreightOrder(
|
||||||
@@ -1230,6 +1243,12 @@ func serviceErrorStatus(err error) int {
|
|||||||
return http.StatusUnprocessableEntity
|
return http.StatusUnprocessableEntity
|
||||||
case errors.Is(err, ErrOCRServiceInvalid):
|
case errors.Is(err, ErrOCRServiceInvalid):
|
||||||
return http.StatusServiceUnavailable
|
return http.StatusServiceUnavailable
|
||||||
|
case errors.Is(err, ErrERPNotConfigured), errors.Is(err, ErrERPLoginRejected):
|
||||||
|
return http.StatusUnprocessableEntity
|
||||||
|
case errors.Is(err, ErrERPProtocol):
|
||||||
|
return http.StatusBadGateway
|
||||||
|
case errors.Is(err, ErrERPUnavailable):
|
||||||
|
return http.StatusServiceUnavailable
|
||||||
case errors.Is(err, ErrConflict):
|
case errors.Is(err, ErrConflict):
|
||||||
return http.StatusConflict
|
return http.StatusConflict
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
@@ -1303,6 +1322,7 @@ type freightImportPage struct {
|
|||||||
IdempotencyKey string
|
IdempotencyKey string
|
||||||
Error string
|
Error string
|
||||||
ErrorCode string
|
ErrorCode string
|
||||||
|
ErrorTitle string
|
||||||
Sync *FreightSync
|
Sync *FreightSync
|
||||||
Watermark *FreightWatermark
|
Watermark *FreightWatermark
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1105,11 +1105,23 @@ func TestERPConnectionRoutesAreNotRegistered(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFreightImportShowsOCRServiceDialogBeforeCreatingSync(t *testing.T) {
|
func TestFreightImportShowsStablePreflightErrorDialog(t *testing.T) {
|
||||||
service := &fakeFreightService{
|
testCases := []struct {
|
||||||
fakeService: &fakeService{},
|
name string
|
||||||
err: ErrOCRServiceInvalid,
|
err error
|
||||||
|
status int
|
||||||
|
code string
|
||||||
|
title string
|
||||||
|
}{
|
||||||
|
{"ocr", ErrOCRServiceInvalid, http.StatusServiceUnavailable, "OCR_SERVICE_INVALID", "OCR 服务无效"},
|
||||||
|
{"not configured", ErrERPNotConfigured, http.StatusUnprocessableEntity, "ERP_NOT_CONFIGURED", "ERP 凭证未配置"},
|
||||||
|
{"login rejected", ErrERPLoginRejected, http.StatusUnprocessableEntity, "ERP_LOGIN_REJECTED", "ERP 登录被拒绝"},
|
||||||
|
{"protocol", ErrERPProtocol, http.StatusBadGateway, "ERP_RESPONSE_INVALID", "ERP 响应无效"},
|
||||||
|
{"unavailable", ErrERPUnavailable, http.StatusServiceUnavailable, "ERP_UNAVAILABLE", "ERP 暂时不可用"},
|
||||||
}
|
}
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
service := &fakeFreightService{fakeService: &fakeService{}, err: testCase.err}
|
||||||
router := newTestRouter(t, service)
|
router := newTestRouter(t, service)
|
||||||
page := performRequest(t, router, http.MethodGet, "/freight/import", nil, "")
|
page := performRequest(t, router, http.MethodGet, "/freight/import", nil, "")
|
||||||
if page.Code != http.StatusOK {
|
if page.Code != http.StatusOK {
|
||||||
@@ -1122,20 +1134,20 @@ func TestFreightImportShowsOCRServiceDialogBeforeCreatingSync(t *testing.T) {
|
|||||||
"mode": {"ORDER_NUMBER"},
|
"mode": {"ORDER_NUMBER"},
|
||||||
"order_number": {"ORDER-123"},
|
"order_number": {"ORDER-123"},
|
||||||
}
|
}
|
||||||
request := httptest.NewRequest(
|
request := httptest.NewRequest(http.MethodPost, "/freight/import", strings.NewReader(values.Encode()))
|
||||||
http.MethodPost,
|
|
||||||
"/freight/import",
|
|
||||||
strings.NewReader(values.Encode()),
|
|
||||||
)
|
|
||||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
request.AddCookie(cookie)
|
request.AddCookie(cookie)
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
router.ServeHTTP(response, request)
|
router.ServeHTTP(response, request)
|
||||||
if response.Code != http.StatusServiceUnavailable ||
|
if response.Code != testCase.status ||
|
||||||
!strings.Contains(response.Body.String(), "OCR 服务无效") ||
|
!strings.Contains(response.Body.String(), testCase.code) ||
|
||||||
|
!strings.Contains(response.Body.String(), testCase.title) ||
|
||||||
!strings.Contains(response.Body.String(), "ORDER-123") ||
|
!strings.Contains(response.Body.String(), "ORDER-123") ||
|
||||||
|
strings.Contains(response.Body.String(), "private") ||
|
||||||
service.createInput.OrderNumber != "ORDER-123" {
|
service.createInput.OrderNumber != "ORDER-123" {
|
||||||
t.Fatalf("OCR dialog response/input = %d / %s / %+v", response.Code, response.Body, service.createInput)
|
t.Fatalf("dialog response/input = %d / %s / %+v", response.Code, response.Body, service.createInput)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMapFreightPreflightErrorUsesStablePublicErrors(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
err error
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{domain.ErrFreightSourceOCRInvalid, ErrOCRServiceInvalid},
|
||||||
|
{domain.ErrFreightSourceNotConfigured, ErrERPNotConfigured},
|
||||||
|
{domain.ErrFreightSourceLoginRejected, ErrERPLoginRejected},
|
||||||
|
{domain.ErrFreightSourceProtocol, ErrERPProtocol},
|
||||||
|
{domain.ErrFreightSourceUnavailable, ErrERPUnavailable},
|
||||||
|
}
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
actual := mapFreightPreflightError(testCase.err)
|
||||||
|
if !errors.Is(actual, testCase.want) || !errors.Is(actual, testCase.err) {
|
||||||
|
t.Fatalf("mapFreightPreflightError(%v) = %v", testCase.err, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,10 +16,11 @@
|
|||||||
<a class="button" href="/freight">返回列表</a>
|
<a class="button" href="/freight">返回列表</a>
|
||||||
</div>
|
</div>
|
||||||
{{if .Error}}<div class="notice danger" role="alert">{{.Error}}</div>{{end}}
|
{{if .Error}}<div class="notice danger" role="alert">{{.Error}}</div>{{end}}
|
||||||
{{if eq .ErrorCode "OCR_SERVICE_INVALID"}}
|
{{if .ErrorCode}}
|
||||||
<dialog open aria-labelledby="ocr-service-error-title">
|
<dialog open aria-labelledby="freight-import-error-title">
|
||||||
<h2 id="ocr-service-error-title">OCR 服务无效</h2>
|
<h2 id="freight-import-error-title">{{.ErrorTitle}}</h2>
|
||||||
<p>{{.Error}}</p>
|
<p>{{.Error}}</p>
|
||||||
|
<p class="secondary">{{.ErrorCode}}</p>
|
||||||
<form method="dialog"><button class="button primary" type="submit" autofocus>关闭</button></form>
|
<form method="dialog"><button class="button primary" type="submit" autofocus>关闭</button></form>
|
||||||
</dialog>
|
</dialog>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ var (
|
|||||||
ErrERPCaptchaInvalid = errors.New("ERP captcha is invalid")
|
ErrERPCaptchaInvalid = errors.New("ERP captcha is invalid")
|
||||||
ErrERPLoginRejected = errors.New("ERP login was rejected")
|
ErrERPLoginRejected = errors.New("ERP login was rejected")
|
||||||
ErrERPProtocol = errors.New("ERP protocol is invalid")
|
ErrERPProtocol = errors.New("ERP protocol is invalid")
|
||||||
|
ErrERPUnavailable = errors.New("ERP is unavailable")
|
||||||
ErrOCRServiceInvalid = errors.New("OCR service is invalid")
|
ErrOCRServiceInvalid = errors.New("OCR service is invalid")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -319,14 +319,33 @@ func (adapter *UsecaseAdapter) GetFreightSync(
|
|||||||
}
|
}
|
||||||
run, err := adapter.freight.GetSync(ctx, localAdminSubject, syncID)
|
run, err := adapter.freight.GetSync(ctx, localAdminSubject, syncID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, domain.ErrFreightSourceOCRInvalid) {
|
if mapped := mapFreightPreflightError(err); mapped != nil {
|
||||||
return FreightSync{}, &adapterError{public: ErrOCRServiceInvalid, cause: err}
|
return FreightSync{}, mapped
|
||||||
}
|
}
|
||||||
return FreightSync{}, mapUsecaseError(err)
|
return FreightSync{}, mapUsecaseError(err)
|
||||||
}
|
}
|
||||||
return freightSyncFrom(run), nil
|
return freightSyncFrom(run), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapFreightPreflightError(err error) error {
|
||||||
|
var public error
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrFreightSourceOCRInvalid):
|
||||||
|
public = ErrOCRServiceInvalid
|
||||||
|
case errors.Is(err, domain.ErrFreightSourceNotConfigured):
|
||||||
|
public = ErrERPNotConfigured
|
||||||
|
case errors.Is(err, domain.ErrFreightSourceLoginRejected):
|
||||||
|
public = ErrERPLoginRejected
|
||||||
|
case errors.Is(err, domain.ErrFreightSourceProtocol):
|
||||||
|
public = ErrERPProtocol
|
||||||
|
case errors.Is(err, domain.ErrFreightSourceUnavailable):
|
||||||
|
public = ErrERPUnavailable
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &adapterError{public: public, cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
func (adapter *UsecaseAdapter) CreateFreightSync(
|
func (adapter *UsecaseAdapter) CreateFreightSync(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
input CreateFreightSyncInput,
|
input CreateFreightSyncInput,
|
||||||
|
|||||||
@@ -294,13 +294,14 @@ func (service *FreightService) ensureSource(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := preflight.EnsureAuthenticated(ctx); err != nil {
|
if err := preflight.EnsureAuthenticated(ctx); err != nil {
|
||||||
|
code := freightSourceErrorCode(err)
|
||||||
result := newError(
|
result := newError(
|
||||||
ErrorKindUnavailable,
|
ErrorKindUnavailable,
|
||||||
freightSourceErrorCode(err),
|
code,
|
||||||
"freight source session is unavailable",
|
freightPreflightMessage(code),
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
result.Retryable = true
|
result.Retryable = code == "OCR_SERVICE_INVALID" || code == "ERP_UNAVAILABLE"
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -709,11 +710,28 @@ func freightSourceErrorCode(err error) string {
|
|||||||
return "ERP_RESPONSE_INVALID"
|
return "ERP_RESPONSE_INVALID"
|
||||||
case errors.Is(err, domain.ErrFreightSourceOCRInvalid):
|
case errors.Is(err, domain.ErrFreightSourceOCRInvalid):
|
||||||
return "OCR_SERVICE_INVALID"
|
return "OCR_SERVICE_INVALID"
|
||||||
|
case errors.Is(err, domain.ErrFreightSourceLoginRejected):
|
||||||
|
return "ERP_LOGIN_REJECTED"
|
||||||
default:
|
default:
|
||||||
return "ERP_UNAVAILABLE"
|
return "ERP_UNAVAILABLE"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func freightPreflightMessage(code string) string {
|
||||||
|
switch code {
|
||||||
|
case "ERP_NOT_CONFIGURED":
|
||||||
|
return "ERP credentials are not configured"
|
||||||
|
case "ERP_LOGIN_REJECTED":
|
||||||
|
return "ERP login was rejected"
|
||||||
|
case "ERP_RESPONSE_INVALID":
|
||||||
|
return "ERP response is invalid"
|
||||||
|
case "OCR_SERVICE_INVALID":
|
||||||
|
return "OCR service is invalid"
|
||||||
|
default:
|
||||||
|
return "ERP is temporarily unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validExternalID(value string) (string, bool) {
|
func validExternalID(value string) (string, bool) {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
number, err := strconv.ParseUint(value, 10, 64)
|
number, err := strconv.ParseUint(value, 10, 64)
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ func TestFreightSourceErrorCodesAreSourceNeutral(t *testing.T) {
|
|||||||
{domain.ErrFreightSourceNotFound, "ERP_FREIGHT_NOT_FOUND"},
|
{domain.ErrFreightSourceNotFound, "ERP_FREIGHT_NOT_FOUND"},
|
||||||
{domain.ErrFreightSourceProtocol, "ERP_RESPONSE_INVALID"},
|
{domain.ErrFreightSourceProtocol, "ERP_RESPONSE_INVALID"},
|
||||||
{domain.ErrFreightSourceOCRInvalid, "OCR_SERVICE_INVALID"},
|
{domain.ErrFreightSourceOCRInvalid, "OCR_SERVICE_INVALID"},
|
||||||
|
{domain.ErrFreightSourceLoginRejected, "ERP_LOGIN_REJECTED"},
|
||||||
{errors.New("temporary source failure"), "ERP_UNAVAILABLE"},
|
{errors.New("temporary source failure"), "ERP_UNAVAILABLE"},
|
||||||
}
|
}
|
||||||
for _, testCase := range cases {
|
for _, testCase := range cases {
|
||||||
|
|||||||
+4
-1
@@ -216,7 +216,10 @@ ERP 配置来源:
|
|||||||
- `CMROUBAO_OCR_API_URL` 为可选 OCR `POST` endpoint;只允许 HTTPS,或本机 loopback HTTP,
|
- `CMROUBAO_OCR_API_URL` 为可选 OCR `POST` endpoint;只允许 HTTPS,或本机 loopback HTTP,
|
||||||
不允许 userinfo、query、fragment 或重定向。请求使用 `multipart/form-data` 的 `file` 字段;
|
不允许 userinfo、query、fragment 或重定向。请求使用 `multipart/form-data` 的 `file` 字段;
|
||||||
OCR 不可达、超时、非成功、过大或响应无有效文本时,货运创建返回 `503 OCR_SERVICE_INVALID`
|
OCR 不可达、超时、非成功、过大或响应无有效文本时,货运创建返回 `503 OCR_SERVICE_INVALID`
|
||||||
且不会创建同步记录。
|
且不会创建同步记录。预检的稳定错误还包括 `422 ERP_NOT_CONFIGURED`、
|
||||||
|
`422 ERP_LOGIN_REJECTED`、`502 ERP_RESPONSE_INVALID` 和 `503 ERP_UNAVAILABLE`;SSR 导入页
|
||||||
|
用相同 code 显示可关闭弹窗并保留表单。所有 message 都是固定匿名文本,不含 ERP/OCR 原始
|
||||||
|
响应、验证码、Cookie、账号或密码。
|
||||||
|
|
||||||
会话不写 SQLite 或 Redis;服务重启后在下一次货运导入前重新经 OCR 建立会话。
|
会话不写 SQLite 或 Redis;服务重启后在下一次货运导入前重新经 OCR 建立会话。
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-29
|
- 日期:2026-07-29
|
||||||
- 阶段:T-231 待显示 ERP/OCR 导入预检的稳定错误码与修复提示
|
- 阶段:T-231 已显示 ERP/OCR 导入预检的稳定错误码与修复提示
|
||||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
||||||
均按文档提交、实现提交的顺序纳入历史
|
均按文档提交、实现提交的顺序纳入历史
|
||||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||||
@@ -189,6 +189,7 @@
|
|||||||
| `docs/tasks/T-228.md` | DONE | 移除 Python Connector 并完成 Go 切换 |
|
| `docs/tasks/T-228.md` | DONE | 移除 Python Connector 并完成 Go 切换 |
|
||||||
| `docs/tasks/T-229.md` | DONE | 从受控 `.env` 加载 ERP 凭证 |
|
| `docs/tasks/T-229.md` | DONE | 从受控 `.env` 加载 ERP 凭证 |
|
||||||
| `docs/tasks/T-230.md` | DONE | OCR 自动登录并移除人工 ERP 连接页 |
|
| `docs/tasks/T-230.md` | DONE | OCR 自动登录并移除人工 ERP 连接页 |
|
||||||
|
| `docs/tasks/T-231.md` | DONE | 显示 ERP/OCR 导入预检稳定错误 |
|
||||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||||
@@ -200,11 +201,11 @@
|
|||||||
## 任务摘要
|
## 任务摘要
|
||||||
|
|
||||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
|
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
|
||||||
- 已完成:另含 T-220 至 T-230 ERP 契约、货运存储、采购需求生成、日期增量同步、Go
|
- 已完成:另含 T-220 至 T-231 ERP 契约、货运存储、采购需求生成、日期增量同步、Go
|
||||||
直连协议、OCR 会话预检、直连 `FreightSource`、旧 Connector 清理和受控本地凭证加载。
|
直连协议、OCR 会话预检、稳定预检错误、直连 `FreightSource`、旧 Connector 清理和受控本地
|
||||||
|
凭证加载。
|
||||||
- 进行中:无。
|
- 进行中:无。
|
||||||
- 下一步:T-231 先显示 ERP/OCR 导入预检稳定错误;随后确认开放 API、OCR/ERP 数据使用权限,
|
- 下一步:确认开放 API、OCR/ERP 数据使用权限,并以受控单号执行一次不记录订单内容的 smoke。
|
||||||
并以受控单号执行一次不记录订单内容的 smoke。
|
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -4,7 +4,7 @@ title: 显示 ERP 货运导入预检的稳定错误
|
|||||||
phase: 2
|
phase: 2
|
||||||
deps:
|
deps:
|
||||||
- T-230
|
- T-230
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-29
|
created: 2026-07-29
|
||||||
context_ref: f887bc4
|
context_ref: f887bc4
|
||||||
work_branch: null
|
work_branch: null
|
||||||
@@ -37,11 +37,11 @@ T-230 已让货运导入在创建同步前建立 OCR/ERP 会话,但除 `OCR_SE
|
|||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- [ ] OCR 无效、ERP 未配置、登录拒绝、协议异常和暂时不可用均有稳定 code;登录拒绝不再显示
|
- [x] OCR 无效、ERP 未配置、登录拒绝、协议异常和暂时不可用均有稳定 code;登录拒绝不再显示
|
||||||
泛化“同步任务创建失败”。
|
泛化“同步任务创建失败”。
|
||||||
- [ ] 导入页弹窗保留表单,并在错误发生前不创建同步记录;JSON 返回相同 code。
|
- [x] 导入页弹窗保留表单,并在错误发生前不创建同步记录;JSON 返回相同 code。
|
||||||
- [ ] 错误、HTML 和 JSON 均不含凭证、验证码、Cookie 或上游响应片段。
|
- [x] 错误、HTML 和 JSON 均不含凭证、验证码、Cookie 或上游响应片段。
|
||||||
- [ ] `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go 入口构建通过。
|
- [x] `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go 入口构建通过。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
@@ -52,3 +52,5 @@ T-230 已让货运导入在创建同步前建立 OCR/ERP 会话,但除 `OCR_SE
|
|||||||
|
|
||||||
- 2026-07-29:创建任务。现场检查确认 `.env` 四项均已设置且后端/OCR 均监听;原 SSR 错误
|
- 2026-07-29:创建任务。现场检查确认 `.env` 四项均已设置且后端/OCR 均监听;原 SSR 错误
|
||||||
分支丢弃了 ERP 预检稳定码,尚不能从页面确认上游失败类别。
|
分支丢弃了 ERP 预检稳定码,尚不能从页面确认上游失败类别。
|
||||||
|
- 2026-07-29:将 ERP 登录拒绝归一化为来源错误,并向 SSR/JSON 映射五类匿名稳定预检错误;
|
||||||
|
完整 Go 验证命令和结果见实现提交。
|
||||||
|
|||||||
Reference in New Issue
Block a user