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