From 78dc595815fc0b0d91970e64fe8da95359e4979c Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 28 Jul 2026 18:16:09 +0800 Subject: [PATCH] feat(t219): show pending-payment reconciliation --- android-buyer/app/build.gradle.kts | 4 +- .../autopilot/ui/screens/ProcurementScreen.kt | 49 ++++- .../procurement/OrderSubmissionUiLabelTest.kt | 49 +++++ backend-api/internal/domain/task.go | 1 + .../sqlite/order_submission_repository.go | 29 +++ .../internal/repository/sqlite/store_test.go | 1 + .../repository/sqlite/task_repository.go | 5 + .../transport/httpapi/admin_handlers.go | 47 +++++ .../transport/httpapi/admin_handlers_test.go | 6 +- .../transport/httpapi/device_handlers_test.go | 119 ++++++++++- .../internal/transport/webui/handler.go | 11 + .../internal/transport/webui/handler_test.go | 191 ++++++++++++++++++ .../internal/transport/webui/static/admin.css | 82 ++++++++ .../webui/templates/task-detail.gohtml | 49 ++++- backend-api/internal/transport/webui/types.go | 20 ++ .../transport/webui/usecase_adapter.go | 82 ++++++++ docs/00-ai-start-here.md | 4 +- docs/08-interaction-checklist.md | 2 +- docs/current-state.md | 27 ++- docs/tasks/T-219.md | 27 ++- 20 files changed, 768 insertions(+), 37 deletions(-) create mode 100644 android-buyer/app/src/test/java/com/roubao/autopilot/procurement/OrderSubmissionUiLabelTest.kt diff --git a/android-buyer/app/build.gradle.kts b/android-buyer/app/build.gradle.kts index d4f0f3a..1dd3a77 100644 --- a/android-buyer/app/build.gradle.kts +++ b/android-buyer/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.roubao.autopilot" minSdk = 26 targetSdk = 34 - versionCode = 20 - versionName = "1.4.15" + versionCode = 21 + versionName = "1.4.16" vectorDrawables { useSupportLibrary = true diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/ProcurementScreen.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/ProcurementScreen.kt index a0fedc0..c963c6c 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/ProcurementScreen.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/ProcurementScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.roubao.autopilot.procurement.LoginInput import com.roubao.autopilot.procurement.ExecutionMode +import com.roubao.autopilot.procurement.OrderSubmissionStatus import com.roubao.autopilot.procurement.ProcurementPhase import com.roubao.autopilot.procurement.ProcurementUiState import com.roubao.autopilot.readiness.DeviceReadinessSnapshot @@ -471,6 +472,30 @@ private fun ExecutionDetails(state: ProcurementUiState) { DetailRow("核验总额", "¥%.2f".format(cents / 100.0)) } } + state.orderSubmission?.let { submission -> + DetailRow( + "提交对账", + orderSubmissionStatusLabel(submission.status) + ) + submission.platformOrderNo?.let { orderNo -> + DetailRow("拼多多订单号", orderNo) + } + submission.platformOrderedAt?.let { orderedAt -> + DetailRow("平台下单时间", orderedAt) + } + submission.platformOrderStatus?.let { status -> + DetailRow( + "平台订单状态", + if (status == "PENDING_PAYMENT") "待付款" else status + ) + } + submission.manualReasonCode?.let { reason -> + DetailRow( + "人工对账原因", + orderSubmissionManualReasonLabel(reason) + ) + } + } DetailRow( "后台连接", when (state.backendOnline) { @@ -505,7 +530,7 @@ private fun ExecutionDetails(state: ProcurementUiState) { ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW -> "订单未能唯一对账,保持禁止重复提交" ProcurementPhase.ORDER_RECONCILED -> - "待付款订单已回传后台,请人工确认付款" + "待付款订单已回传后台,请到拼多多订单列表人工确认付款" else -> "订单提交保持禁用" }, color = if ( @@ -577,3 +602,25 @@ private fun phaseLabel(state: ProcurementUiState): String = ProcurementPhase.ORDER_RECONCILED -> "待付款订单已回传" ProcurementPhase.AUTHORIZATION_EXPIRED -> "授权到期,已停止" } + +internal fun orderSubmissionStatusLabel( + status: OrderSubmissionStatus +): String = + when (status) { + OrderSubmissionStatus.FENCE_INTENT_SAVED -> "提交意图已安全保存" + OrderSubmissionStatus.CLICK_ASSUMED -> "提交结果待确认" + OrderSubmissionStatus.RECONCILING -> "正在对账" + OrderSubmissionStatus.MANUAL_REVIEW -> "需要人工对账" + OrderSubmissionStatus.RECONCILED -> "待人工确认付款" + } + +internal fun orderSubmissionManualReasonLabel(reason: String): String = + when (reason) { + "ORDER_NOT_FOUND" -> "未找到符合条件的新订单" + "ORDER_AMBIGUOUS" -> "找到多个可能订单" + "ORDER_FIELDS_INCOMPLETE" -> "订单字段不完整" + "ORDER_PAGE_UNKNOWN" -> "订单页面无法确认" + "RISK_OR_PAYMENT_BOUNDARY" -> "遇到风控或付款边界" + "EVIDENCE_UNAVAILABLE" -> "无法取得对账证据" + else -> "订单需要人工核对" + } diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/OrderSubmissionUiLabelTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/OrderSubmissionUiLabelTest.kt new file mode 100644 index 0000000..50b39fb --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/OrderSubmissionUiLabelTest.kt @@ -0,0 +1,49 @@ +package com.roubao.autopilot.ui.screens + +import com.roubao.autopilot.procurement.OrderSubmissionStatus +import org.junit.Assert.assertEquals +import org.junit.Test + +class OrderSubmissionUiLabelTest { + @Test + fun `submission states use stable buyer-facing labels`() { + assertEquals( + "提交意图已安全保存", + orderSubmissionStatusLabel( + OrderSubmissionStatus.FENCE_INTENT_SAVED + ) + ) + assertEquals( + "提交结果待确认", + orderSubmissionStatusLabel(OrderSubmissionStatus.CLICK_ASSUMED) + ) + assertEquals( + "正在对账", + orderSubmissionStatusLabel(OrderSubmissionStatus.RECONCILING) + ) + assertEquals( + "需要人工对账", + orderSubmissionStatusLabel(OrderSubmissionStatus.MANUAL_REVIEW) + ) + assertEquals( + "待人工确认付款", + orderSubmissionStatusLabel(OrderSubmissionStatus.RECONCILED) + ) + } + + @Test + fun `manual review reasons never expose internal codes`() { + assertEquals( + "找到多个可能订单", + orderSubmissionManualReasonLabel("ORDER_AMBIGUOUS") + ) + assertEquals( + "遇到风控或付款边界", + orderSubmissionManualReasonLabel("RISK_OR_PAYMENT_BOUNDARY") + ) + assertEquals( + "订单需要人工核对", + orderSubmissionManualReasonLabel("UNKNOWN_REASON") + ) + } +} diff --git a/backend-api/internal/domain/task.go b/backend-api/internal/domain/task.go index 32924ea..8b9fcf9 100644 --- a/backend-api/internal/domain/task.go +++ b/backend-api/internal/domain/task.go @@ -403,6 +403,7 @@ type TaskDetail struct { Events []TaskEvent Report *ExecutionReport OrderAuthorizations []OrderAuthorization + OrderSubmissions []OrderSubmission } type TaskValidationError struct { diff --git a/backend-api/internal/repository/sqlite/order_submission_repository.go b/backend-api/internal/repository/sqlite/order_submission_repository.go index 8fd56d5..4ab289f 100644 --- a/backend-api/internal/repository/sqlite/order_submission_repository.go +++ b/backend-api/internal/repository/sqlite/order_submission_repository.go @@ -728,6 +728,35 @@ const orderSubmissionSelect = `SELECT manual_reason_code, fenced_at, reconciled_at, manual_review_at FROM order_submissions` +func listOrderSubmissions( + ctx context.Context, + queryer queryer, + taskID string, +) ([]domain.OrderSubmission, error) { + rows, err := queryer.QueryContext( + ctx, + orderSubmissionSelect+ + ` WHERE task_id = ? ORDER BY fenced_at ASC, id ASC`, + taskID, + ) + if err != nil { + return nil, repositoryFailure(err) + } + defer rows.Close() + submissions := make([]domain.OrderSubmission, 0) + for rows.Next() { + submission, err := scanOrderSubmission(rows) + if err != nil { + return nil, repositoryFailure(err) + } + submissions = append(submissions, submission) + } + if err := rows.Err(); err != nil { + return nil, repositoryFailure(err) + } + return submissions, nil +} + func scanOrderSubmission( scanner rowScanner, ) (domain.OrderSubmission, error) { diff --git a/backend-api/internal/repository/sqlite/store_test.go b/backend-api/internal/repository/sqlite/store_test.go index b9e9155..07bb5ff 100644 --- a/backend-api/internal/repository/sqlite/store_test.go +++ b/backend-api/internal/repository/sqlite/store_test.go @@ -186,6 +186,7 @@ func TestStoreAssetAndTaskLifecycleIsTransactionalAndIdempotent( } if detail.Asset.ID != asset.ID || len(detail.Events) != 1 || + len(detail.OrderSubmissions) != 0 || detail.Events[0].Type != "TASK_CREATED" { t.Fatalf("detail = %+v", detail) } diff --git a/backend-api/internal/repository/sqlite/task_repository.go b/backend-api/internal/repository/sqlite/task_repository.go index 37e6072..8a74a7e 100644 --- a/backend-api/internal/repository/sqlite/task_repository.go +++ b/backend-api/internal/repository/sqlite/task_repository.go @@ -324,6 +324,10 @@ func (s *Store) GetTaskDetail( if err != nil { return domain.TaskDetail{}, err } + orderSubmissions, err := listOrderSubmissions(ctx, tx, taskID) + if err != nil { + return domain.TaskDetail{}, err + } detail := domain.TaskDetail{ Task: task, Asset: asset, @@ -331,6 +335,7 @@ func (s *Store) GetTaskDetail( Events: events, Report: report, OrderAuthorizations: orderAuthorizations, + OrderSubmissions: orderSubmissions, } if err := tx.Commit(); err != nil { return domain.TaskDetail{}, repositoryFailure(err) diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go index 4065ba7..80d3fac 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers.go +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -380,6 +380,9 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) { "order_authorizations": orderAuthorizationResponses( detail.OrderAuthorizations, ), + "order_submissions": adminOrderSubmissionResponses( + detail.OrderSubmissions, + ), "events": events, "assets": []gin.H{ assetResponse(detail.Asset), @@ -387,6 +390,50 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) { }) } +func adminOrderSubmissionResponses( + submissions []domain.OrderSubmission, +) []gin.H { + result := make([]gin.H, 0, len(submissions)) + for _, submission := range submissions { + result = append(result, adminOrderSubmissionResponse(submission)) + } + return result +} + +func adminOrderSubmissionResponse( + submission domain.OrderSubmission, +) gin.H { + return gin.H{ + "id": submission.ID, + "authorization_id": submission.AuthorizationID, + "dry_run_id": submission.DryRunID, + "execution_id": submission.ExecutionID, + "status": submission.Status, + "expected_title": submission.ExpectedTitle, + "expected_sku": submission.ExpectedSKU, + "expected_quantity": submission.ExpectedQuantity, + "expected_unit_price_cents": submission.ExpectedUnitPriceCents, + "expected_total_price_cents": submission.ExpectedTotalPriceCents, + "platform_order_no": submission.PlatformOrderNo, + "platform_ordered_at": formatOptionalTime( + submission.PlatformOrderedAt, + ), + "platform_order_status": submission.PlatformOrderStatus, + "reconciliation_evidence_asset_id": submission. + ReconciliationEvidenceAssetID, + "reconciliation_evidence_sha256": submission. + ReconciliationEvidenceSHA256, + "manual_reason_code": submission.ManualReasonCode, + "fenced_at": formatTime(submission.FencedAt), + "reconciled_at": formatOptionalTime( + submission.ReconciledAt, + ), + "manual_review_at": formatOptionalTime( + submission.ManualReviewAt, + ), + } +} + func (h *adminHandlers) createOrderAuthorization(ctx *gin.Context) { var request struct { ExecutionID string `json:"execution_id"` diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index 01ffa6d..9de21cf 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -197,9 +197,13 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) { var detail map[string]any decodeResponse(t, detailResponse, &detail) requirement, _ := detail["original_requirement"].(map[string]any) + orderSubmissions, ok := detail["order_submissions"].([]any) if detailResponse.Code != http.StatusOK || requirement["sku"] != "BLACK-20L" || - requirement["quantity"] != float64(2) { + requirement["quantity"] != float64(2) || + !ok || + len(orderSubmissions) != 0 || + detailResponse.Header().Get("Cache-Control") != "no-store" { t.Fatalf( "task detail status/body = %d / %#v", detailResponse.Code, diff --git a/backend-api/internal/transport/httpapi/device_handlers_test.go b/backend-api/internal/transport/httpapi/device_handlers_test.go index 4507b45..2d5e631 100644 --- a/backend-api/internal/transport/httpapi/device_handlers_test.go +++ b/backend-api/internal/transport/httpapi/device_handlers_test.go @@ -1314,6 +1314,71 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable( authorizationStatus, ) } + adminDetail := performAdminRequest( + t, + fixture.adminRouter, + http.MethodGet, + "/api/v1/tasks/"+taskID, + "", + nil, + "", + ) + requireAdminStatus(t, adminDetail, http.StatusOK) + if adminDetail.Header().Get("Cache-Control") != "no-store" { + t.Fatalf( + "admin detail cache control = %q", + adminDetail.Header().Get("Cache-Control"), + ) + } + var adminDetailBody struct { + OrderSubmissions []struct { + ID string `json:"id"` + AuthorizationID string `json:"authorization_id"` + Status string `json:"status"` + ExpectedSKU string `json:"expected_sku"` + ExpectedQuantity int `json:"expected_quantity"` + ExpectedTotalCents int64 `json:"expected_total_price_cents"` + PlatformOrderNo string `json:"platform_order_no"` + PlatformOrderedAt string `json:"platform_ordered_at"` + PlatformOrderStatus string `json:"platform_order_status"` + EvidenceAssetID string `json:"reconciliation_evidence_asset_id"` + } `json:"order_submissions"` + } + decodeResponse(t, adminDetail, &adminDetailBody) + if len(adminDetailBody.OrderSubmissions) != 1 { + t.Fatalf( + "admin order submissions = %+v", + adminDetailBody.OrderSubmissions, + ) + } + adminSubmission := adminDetailBody.OrderSubmissions[0] + if adminSubmission.ID != submissionResponse.Submission.ID || + adminSubmission.AuthorizationID != command.ID || + adminSubmission.Status != "RECONCILED" || + adminSubmission.ExpectedSKU != command.OriginalSKU || + adminSubmission.ExpectedQuantity != 2 || + adminSubmission.ExpectedTotalCents != 4300 || + adminSubmission.PlatformOrderNo != "12345678901234567890" || + adminSubmission.PlatformOrderedAt == "" || + adminSubmission.PlatformOrderStatus != "PENDING_PAYMENT" || + adminSubmission.EvidenceAssetID != reconciliationEvidenceID { + t.Fatalf("admin submission = %+v", adminSubmission) + } + detailAfterReconcile, err := fixture.tasks.Get( + context.Background(), + localAdminSubject, + taskID, + ) + if err != nil || + len(detailAfterReconcile.OrderSubmissions) != 1 || + detailAfterReconcile.OrderSubmissions[0].ID != + submissionResponse.Submission.ID { + t.Fatalf( + "task detail submissions = %+v, error = %v", + detailAfterReconcile.OrderSubmissions, + err, + ) + } var deliveredEvents, acknowledgedEvents, dryRunStartedEvents, dryRunReadyEvents, fencedEvents, manualEvents, reconciledEvents int for eventType, target := range map[string]*int{ @@ -1617,11 +1682,12 @@ func TestDeviceCancelAcknowledgementFollowsAdminStopRequest( } type deviceHTTPFixture struct { - db *sql.DB - store *repository.Store - assets *usecase.AssetService - tasks *usecase.TaskService - router http.Handler + db *sql.DB + store *repository.Store + assets *usecase.AssetService + tasks *usecase.TaskService + router http.Handler + adminRouter http.Handler taskSequence int } @@ -1678,6 +1744,14 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture { if err != nil { t.Fatalf("usecase.NewExecutionResultService() error = %v", err) } + authorizations, err := usecase.NewOrderAuthorizationService( + store, + clock, + ids, + ) + if err != nil { + t.Fatalf("usecase.NewOrderAuthorizationService() error = %v", err) + } commands, err := usecase.NewDeviceOrderCommandService(store, clock, ids) if err != nil { t.Fatalf("usecase.NewDeviceOrderCommandService() error = %v", err) @@ -1716,12 +1790,37 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture { if err != nil { t.Fatalf("NewRouter() error = %v", err) } + adminRoutes, err := NewAdminRouteRegistrar( + AdminServices{ + Assets: assets, + Tasks: tasks, + Results: results, + Authorizations: authorizations, + }, + emptyAdminWeb{}, + ) + if err != nil { + t.Fatalf("NewAdminRouteRegistrar() error = %v", err) + } + adminRouter, err := NewRouter(RouterDependencies{ + Database: db, + RegisterPublicRoutes: discardRoutes, + RegisterAdminRoutes: adminRoutes, + RegisterDeviceRoutes: discardRoutes, + AdminSessions: authenticator, + DeviceAccess: authenticator, + LogEvent: discardEvent, + }) + if err != nil { + t.Fatalf("NewRouter(admin) error = %v", err) + } return &deviceHTTPFixture{ - db: db, - store: store, - assets: assets, - tasks: tasks, - router: router, + db: db, + store: store, + assets: assets, + tasks: tasks, + router: router, + adminRouter: adminRouter, } } diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go index b8e313e..059dcc6 100644 --- a/backend-api/internal/transport/webui/handler.go +++ b/backend-api/internal/transport/webui/handler.go @@ -834,6 +834,8 @@ type taskDetailView struct { ExecutionReport *ExecutionReport Candidates []AuthorizationCandidate OrderAuthorizations []OrderAuthorization + OrderSubmissions []OrderSubmission + HasReconciledOrder bool CanAuthorizeOrder bool ActiveAuthorizationID string } @@ -865,6 +867,13 @@ func taskDetailViewFrom(task Task) taskDetailView { canAuthorizeOrder = false } } + hasReconciledOrder := false + for _, submission := range task.OrderSubmissions { + if submission.Status == "RECONCILED" { + hasReconciledOrder = true + break + } + } return taskDetailView{ ID: task.ID, Title: task.Title, @@ -885,6 +894,8 @@ func taskDetailViewFrom(task Task) taskDetailView { ExecutionReport: task.ExecutionReport, Candidates: task.Candidates, OrderAuthorizations: task.OrderAuthorizations, + OrderSubmissions: task.OrderSubmissions, + HasReconciledOrder: hasReconciledOrder, CanAuthorizeOrder: canAuthorizeOrder, ActiveAuthorizationID: activeAuthorizationID, } diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index 296f996..4f96ba5 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/usecase" "github.com/gin-gonic/gin" @@ -627,6 +628,196 @@ func TestTaskDetailDisablesAuthorizationAfterDelivery(t *testing.T) { } } +func TestTaskDetailRendersOrderSubmissionStatesWithoutPaymentActions( + t *testing.T, +) { + now := time.Date(2026, 7, 28, 9, 30, 0, 0, time.UTC) + tests := []struct { + name string + taskStatus string + submission OrderSubmission + expected []string + forbidden []string + }{ + { + name: "reconciling", + taskStatus: "WAITING_CONFIRMATION", + submission: OrderSubmission{ + Status: "FENCED", + StatusLabel: "正在对账", + FencedAt: now, + }, + expected: []string{ + "订单提交结果正在对账", + "禁止重复提交", + }, + forbidden: []string{"待人工确认付款", "验证完成,未提交订单"}, + }, + { + name: "manual review", + taskStatus: "WAITING_CONFIRMATION", + submission: OrderSubmission{ + Status: "MANUAL_REVIEW", + StatusLabel: "需要人工对账", + ManualReasonLabel: "找到多个可能订单", + FencedAt: now, + ManualReviewAt: now.Add(time.Minute), + }, + expected: []string{ + "需要人工对账", + "找到多个可能订单", + "禁止重新提交订单", + }, + forbidden: []string{"待人工确认付款", "验证完成,未提交订单"}, + }, + { + name: "pending payment", + taskStatus: "SUCCEEDED", + submission: OrderSubmission{ + Status: "RECONCILED", + StatusLabel: "待人工确认付款", + ExpectedTitle: "已授权灰色上衣", + ExpectedSKU: "灰色,2XL", + ExpectedQuantity: 2, + ExpectedUnitPrice: "21.50", + ExpectedTotalPrice: "43.00", + PlatformOrderNo: "12345678901234567890", + PlatformOrderedAt: now, + PlatformOrderStatus: "PENDING_PAYMENT", + EvidenceContentURL: "/api/v1/tasks/" + testTaskID + + "/evidence/00000000-0000-4000-8000-000000000078/content", + FencedAt: now.Add(-time.Minute), + ReconciledAt: now.Add(time.Minute), + }, + expected: []string{ + "待人工确认付款", + "请采购员打开拼多多订单列表", + "12345678901234567890", + "灰色,2XL", + "¥43.00", + "待付款订单列表对账截图", + }, + forbidden: []string{ + "验证完成,未提交订单", + "重试提交", + `href="pinduoduo`, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + service := &fakeService{ + getResult: Task{ + ID: testTaskID, + Title: "订单状态任务", + SKU: "灰色,2XL", + Quantity: 2, + Status: test.taskStatus, + ReferenceAssetID: "00000000-0000-4000-8000-000000000009", + CreatedAt: now, + UpdatedAt: now, + OrderSubmissions: []OrderSubmission{test.submission}, + }, + } + response := performRequest( + t, + newTestRouter(t, service), + http.MethodGet, + "/tasks/"+testTaskID, + nil, + "", + ) + if response.Code != http.StatusOK { + t.Fatalf( + "detail status/body = %d/%s", + response.Code, + response.Body, + ) + } + body := response.Body.String() + for _, expected := range test.expected { + if !strings.Contains(body, expected) { + t.Fatalf("detail missing %q: %s", expected, body) + } + } + for _, forbidden := range test.forbidden { + if strings.Contains(body, forbidden) { + t.Fatalf("detail contains forbidden %q", forbidden) + } + } + if regexp.MustCompile( + `(?s)<(?:a|button)[^>]*>[^<]*(?:立即支付|确认支付|自动付款)`, + ).MatchString(body) { + t.Fatal("detail exposes a payment action") + } + }) + } +} + +func TestTaskDetailKeepsHistoricalSucceededCopyWithoutSubmission(t *testing.T) { + now := time.Date(2026, 7, 28, 9, 30, 0, 0, time.UTC) + response := performRequest( + t, + newTestRouter(t, &fakeService{getResult: Task{ + ID: testTaskID, + Title: "历史验证任务", + SKU: "HISTORY-SKU", + Quantity: 1, + Status: "SUCCEEDED", + ReferenceAssetID: "00000000-0000-4000-8000-000000000009", + CreatedAt: now, + UpdatedAt: now, + }}), + http.MethodGet, + "/tasks/"+testTaskID, + nil, + "", + ) + if response.Code != http.StatusOK || + !strings.Contains( + response.Body.String(), + "验证完成,未提交订单", + ) { + t.Fatalf( + "historical succeeded detail = %d/%s", + response.Code, + response.Body, + ) + } +} + +func TestOrderSubmissionAdapterBuildsAuthenticatedEvidenceURL(t *testing.T) { + orderNo := "12345678901234567890" + status := "PENDING_PAYMENT" + evidenceID := "00000000-0000-4000-8000-000000000078" + orderedAt := time.Date(2026, 7, 28, 9, 30, 0, 0, time.UTC) + submission := orderSubmissionFrom(domain.OrderSubmission{ + ID: "00000000-0000-4000-8000-000000000079", + TaskID: testTaskID, + Status: domain.OrderSubmissionReconciled, + ExpectedTitle: "已授权商品", + ExpectedSKU: "灰色,2XL", + ExpectedQuantity: 2, + ExpectedUnitPriceCents: 2150, + ExpectedTotalPriceCents: 4300, + PlatformOrderNo: &orderNo, + PlatformOrderedAt: &orderedAt, + PlatformOrderStatus: &status, + ReconciliationEvidenceAssetID: &evidenceID, + FencedAt: orderedAt.Add(-time.Minute), + ReconciledAt: &orderedAt, + }) + if submission.StatusLabel != "待人工确认付款" || + submission.PlatformOrderNo != orderNo || + submission.ExpectedUnitPrice != "21.50" || + submission.ExpectedTotalPrice != "43.00" || + submission.EvidenceContentURL != + "/api/v1/tasks/"+testTaskID+"/evidence/"+evidenceID+"/content" || + strings.Contains(submission.EvidenceContentURL, orderNo) { + t.Fatalf("submission view = %+v", submission) + } +} + func TestTaskDetailCancelModeFollowsLifecycleStatus(t *testing.T) { tests := []struct { status string diff --git a/backend-api/internal/transport/webui/static/admin.css b/backend-api/internal/transport/webui/static/admin.css index ddd8a62..279dfc2 100644 --- a/backend-api/internal/transport/webui/static/admin.css +++ b/backend-api/internal/transport/webui/static/admin.css @@ -813,6 +813,87 @@ tbody tr:last-child td { font-weight: 800; } +.order-state-banner { + margin-bottom: 18px; + padding: 18px; + border-left: 5px solid var(--info); + background: var(--info-soft); +} + +.order-state-banner h2, +.order-state-banner p { + margin-top: 0; +} + +.order-state-heading { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; +} + +.order-state-label { + margin-bottom: 3px; + color: var(--success); + font-size: 13px; + font-weight: 800; +} + +.order-state-payment { + border-left-color: var(--success); + background: var(--success-soft); +} + +.order-state-manual { + border-left-color: var(--warn); + background: var(--warn-soft); +} + +.order-state-reconciling { + border-left-color: var(--info); + background: var(--info-soft); +} + +.order-state-instruction { + margin-bottom: 16px; + font-weight: 700; +} + +.pending-payment-layout { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(220px, 0.6fr); + gap: 20px; + align-items: start; +} + +.pending-payment-details { + margin: 0; +} + +.order-number { + overflow-wrap: anywhere; + font: 700 15px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.pending-payment-evidence { + margin: 0; +} + +.pending-payment-evidence img { + display: block; + width: 100%; + max-height: 300px; + object-fit: contain; + border: 1px solid var(--line); + background: var(--surface); +} + +.pending-payment-evidence figcaption { + margin-top: 6px; + color: var(--muted); + font-size: 12px; +} + .detail-layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); @@ -1053,6 +1134,7 @@ tbody tr:last-child td { .upload-layout, .detail-layout, .requirement-layout, + .pending-payment-layout, .authorization-reasons { grid-template-columns: 1fr; } diff --git a/backend-api/internal/transport/webui/templates/task-detail.gohtml b/backend-api/internal/transport/webui/templates/task-detail.gohtml index 564a8ea..4424270 100644 --- a/backend-api/internal/transport/webui/templates/task-detail.gohtml +++ b/backend-api/internal/transport/webui/templates/task-detail.gohtml @@ -29,7 +29,54 @@ - {{if eq .Task.Status "SUCCEEDED"}} + {{range .Task.OrderSubmissions}} + {{if eq .Status "RECONCILED"}} +
+
+
+

{{.StatusLabel}}

+

待人工确认付款

+
+ 待付款 +
+

+ 请采购员打开拼多多订单列表,核对商品、规格、数量和金额后人工付款。 +

+
+
+
拼多多订单号
{{.PlatformOrderNo}}
+
平台下单时间
+
+
平台状态
待付款({{.PlatformOrderStatus}})
+
商品标题
{{.ExpectedTitle}}
+
SKU
{{.ExpectedSKU}}
+
数量
{{.ExpectedQuantity}}
+
核验单价
¥{{.ExpectedUnitPrice}}
+
核验总额
¥{{.ExpectedTotalPrice}}
+
+ {{if .EvidenceContentURL}} +
+ 待付款订单列表对账截图 +
设备回传的订单列表对账证据
+
+ {{end}} +
+
+ {{else if eq .Status "MANUAL_REVIEW"}} +
+

{{.StatusLabel}}

+

{{.ManualReasonLabel}}。提交围栏保持有效,禁止重新提交订单。

+
+ {{else if eq .Status "FENCED"}} +
+

{{.StatusLabel}}

+

订单提交结果正在对账,提交围栏已经消费,禁止重复提交。

+
+ {{end}} + {{end}} + + {{if and (eq .Task.Status "SUCCEEDED") (not .Task.HasReconciledOrder)}}
diff --git a/backend-api/internal/transport/webui/types.go b/backend-api/internal/transport/webui/types.go index a0cadfe..c57773a 100644 --- a/backend-api/internal/transport/webui/types.go +++ b/backend-api/internal/transport/webui/types.go @@ -65,6 +65,7 @@ type Task struct { ExecutionReport *ExecutionReport Candidates []AuthorizationCandidate OrderAuthorizations []OrderAuthorization + OrderSubmissions []OrderSubmission } type AuthorizationCandidate struct { @@ -88,6 +89,25 @@ type OrderAuthorization struct { CreatedAt time.Time } +type OrderSubmission struct { + ID string + Status string + StatusLabel string + ExpectedTitle string + ExpectedSKU string + ExpectedQuantity int + ExpectedUnitPrice string + ExpectedTotalPrice string + PlatformOrderNo string + PlatformOrderedAt time.Time + PlatformOrderStatus string + EvidenceContentURL string + ManualReasonLabel string + FencedAt time.Time + ReconciledAt time.Time + ManualReviewAt time.Time +} + type ExecutionReport struct { Events []ExecutionReportEvent Evidence []ExecutionReportEvidence diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go index 89dcb32..e723c50 100644 --- a/backend-api/internal/transport/webui/usecase_adapter.go +++ b/backend-api/internal/transport/webui/usecase_adapter.go @@ -280,9 +280,91 @@ func taskFromDetail(detail domain.TaskDetail) Task { orderAuthorizationFrom(authorization), ) } + for _, submission := range detail.OrderSubmissions { + task.OrderSubmissions = append( + task.OrderSubmissions, + orderSubmissionFrom(submission), + ) + } return task } +func orderSubmissionFrom( + submission domain.OrderSubmission, +) OrderSubmission { + unitPrice := submission.ExpectedUnitPriceCents + totalPrice := submission.ExpectedTotalPriceCents + result := OrderSubmission{ + ID: submission.ID, + Status: string(submission.Status), + StatusLabel: orderSubmissionStatusLabel(submission.Status), + ExpectedTitle: submission.ExpectedTitle, + ExpectedSKU: submission.ExpectedSKU, + ExpectedQuantity: submission.ExpectedQuantity, + ExpectedUnitPrice: *domain.FormatOptionalCNY(&unitPrice), + ExpectedTotalPrice: *domain.FormatOptionalCNY(&totalPrice), + FencedAt: submission.FencedAt, + } + if submission.PlatformOrderNo != nil { + result.PlatformOrderNo = *submission.PlatformOrderNo + } + if submission.PlatformOrderedAt != nil { + result.PlatformOrderedAt = *submission.PlatformOrderedAt + } + if submission.PlatformOrderStatus != nil { + result.PlatformOrderStatus = *submission.PlatformOrderStatus + } + if submission.ReconciliationEvidenceAssetID != nil { + result.EvidenceContentURL = "/api/v1/tasks/" + + submission.TaskID + "/evidence/" + + *submission.ReconciliationEvidenceAssetID + "/content" + } + if submission.ManualReasonCode != nil { + result.ManualReasonLabel = orderSubmissionManualReasonLabel( + *submission.ManualReasonCode, + ) + } + if submission.ReconciledAt != nil { + result.ReconciledAt = *submission.ReconciledAt + } + if submission.ManualReviewAt != nil { + result.ManualReviewAt = *submission.ManualReviewAt + } + return result +} + +func orderSubmissionStatusLabel(status domain.OrderSubmissionStatus) string { + switch status { + case domain.OrderSubmissionFenced: + return "正在对账" + case domain.OrderSubmissionManualReview: + return "需要人工对账" + case domain.OrderSubmissionReconciled: + return "待人工确认付款" + default: + return "未知状态" + } +} + +func orderSubmissionManualReasonLabel(code string) string { + switch code { + case "ORDER_NOT_FOUND": + return "未找到符合条件的新订单" + case "ORDER_AMBIGUOUS": + return "找到多个可能订单" + case "ORDER_FIELDS_INCOMPLETE": + return "订单字段不完整" + case "ORDER_PAGE_UNKNOWN": + return "订单页面无法确认" + case "RISK_OR_PAYMENT_BOUNDARY": + return "遇到风控或付款边界" + case "EVIDENCE_UNAVAILABLE": + return "无法取得对账证据" + default: + return "订单需要人工核对" + } +} + func orderAuthorizationFrom( authorization domain.OrderAuthorization, ) OrderAuthorization { diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 7b6daaa..90fdf82 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -61,8 +61,8 @@ T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207 规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选 结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹、T-215 Admin 候选确认、不可变待投递授权、T-216 设备命令可靠投递及 T-217 已授权商品 -重新定位与订单 dry-run、T-218 单次订单提交围栏和订单回读均已完成;T-219 Admin -待付款提醒与端到端验收正在开发。 +重新定位与订单 dry-run、T-218 单次订单提交围栏和订单回读,以及 T-219 Admin +待付款提醒与端到端验收均已完成。下一任务是 T-301 P0 UI 完整交互验收。 不得直接把候选链接或列表 ordinal 当成授权。 手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。 T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。 diff --git a/docs/08-interaction-checklist.md b/docs/08-interaction-checklist.md index c64f61c..1953a7b 100644 --- a/docs/08-interaction-checklist.md +++ b/docs/08-interaction-checklist.md @@ -17,7 +17,7 @@ | IX-008 | US-006 | App/管理端错误状态 | 自动失败、取消、重试上传 | 显示结构化原因和恢复动作 | P0 | 已定 | | IX-009 | US-008 | App 候选理由/管理端决策详情 | 接受、拒绝、改选或修正 | 保存逐候选结构化人工标签 | P1 | T-208 已实现 | | IX-010 | US-009 | App 独立执行设置/同步状态 | 配置模式、离线执行或补报 | 授权内独立执行并可审计同步 | P0 | T-206 离线控制已实现,结果补报待 T-207 | -| IX-011 | US-010 | Admin 候选授权/App 订单执行 | 选择候选并授权、设备领取执行 | 创建一笔可对账的待付款订单并提醒人工付款 | P0 | T-215 至 T-218 已完成;T-219 开发中 | +| IX-011 | US-010 | Admin 候选授权/App 订单执行 | 选择候选并授权、设备领取执行 | 创建一笔可对账的待付款订单并提醒人工付款 | P0 | T-215 至 T-219 已完成 | ## IX-001 管理 Web 登录 diff --git a/docs/current-state.md b/docs/current-state.md index a27a04c..daf0a86 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,8 +5,8 @@ ## 当前快照 - 日期:2026-07-28 -- 阶段:T-219 Admin 待付款提醒与端到端验收已领取,合约冻结中 -- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-218 +- 阶段:T-219 Admin 待付款提醒与端到端验收已完成 +- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219 均按文档提交、实现提交的顺序纳入历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 @@ -17,10 +17,10 @@ - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 -- 测试:T-218 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过; - Debug APK `1.4.15 (20)` 已覆盖安装到 PKG110 -- 后端测试:T-218 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`; - v11 migration 降级保护及 fence/replay/manual-review/reconcile HTTP 集成测试通过 +- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过; + Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110 +- 后端测试:T-219 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`; + 唯一 submission 的设备到 Admin HTTP 闭环及三态 SSR 测试通过 - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 脚本错误或外部请求,Android 可见交互控件均不小于 44px @@ -80,6 +80,10 @@ `CLICK_ASSUMED`,最多点击一次精确“提交订单”;进程/网络不确定和租约到期后只 回读待付款订单或转人工,绝不重复提交或点击支付。v11 保存围栏、幂等操作、订单号/ 时间、证据和审计事件,唯一对账后才写 `order_submitted=true`。 +- T-219 待付款提醒:Admin API/SSR 按任务鉴权读取 submission,区分围栏中、人工 + 对账和唯一对账成功;成功态展示完整订单号、平台时间、授权 SKU/数量/金额及受控 + 证据,只提醒采购员打开拼多多人工核对付款。Roubao 显示相同终态;Web/App 均没有 + 付款、重复提交或自动支付动作。隔离数据库真实 SSR 已通过三个响应式视口验收。 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture @@ -93,7 +97,7 @@ - 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110 真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止, RUNNING 不自动重新分配 -- 测试设备:OnePlus PKG110,Android 16/API 36;已安装肉包 `1.4.15 (20)`;拼多多 +- 测试设备:OnePlus PKG110,Android 16/API 36;已安装肉包 `1.4.16 (21)`;拼多多 `8.17.0 (81700)` - 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0 真机验证;ColorOS 本次覆盖安装后移除了肉包采购无障碍,重新启用后服务已连接, @@ -108,7 +112,7 @@ 已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/` - 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1` - 标准验证路径:`.\init.ps1` -- 当前 blocker:T-219 实现无代码阻塞;当前真机确认页缺少已配置收货地址且显示 +- 当前 blocker:当前真机确认页缺少已配置收货地址且显示 “立即支付/先用后付”,T-218 已按合约证明不创建围栏、不点击。不能为测试自动添加 地址或跨过付款边界。真实 VLM 服务地址、模型、 设备级测试凭证、成本上限和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针 @@ -146,6 +150,7 @@ | `docs/tasks/T-216.md` | DONE | App 主动拉取并先加密落盘再确认同一条下单命令 | | `docs/tasks/T-217.md` | DONE | 重新核对已授权商品并选择 SKU/数量,停在最终提交前 | | `docs/tasks/T-218.md` | DONE | 单次提交围栏、最多一次提交动作和待付款订单唯一对账 | +| `docs/tasks/T-219.md` | DONE | Admin 待付款提醒、Roubao 终态和跨端闭环验收 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -156,9 +161,9 @@ ## 任务摘要 -- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-218。 -- 进行中:T-219 Admin 待付款提醒与端到端验收。 -- 下一步:完成 T-219 后进入 T-301 P0 UI 完整交互验收。 +- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。 +- 进行中:无。 +- 下一步:进入 T-301 P0 UI 完整交互验收。 ## 当前可运行内容 diff --git a/docs/tasks/T-219.md b/docs/tasks/T-219.md index c627c91..161fd5b 100644 --- a/docs/tasks/T-219.md +++ b/docs/tasks/T-219.md @@ -4,7 +4,7 @@ title: Admin 待付款提醒与端到端验收 phase: 2 deps: - T-218 -status: DOING +status: DONE created: 2026-07-28 context_ref: a77d8be work_branch: null @@ -106,13 +106,13 @@ T-218 已把唯一对账成功的待付款订单保存到 `order_submissions`, ## 验收要点 -- [ ] repository 按任务和 Admin subject 读取稳定排序的 submission,空值不报错。 -- [ ] Admin API 仅鉴权返回对账字段且 `no-store`,订单号不进 URL/日志。 -- [ ] SSR 正确区分 FENCED、MANUAL_REVIEW、RECONCILED 和历史未下单 SUCCEEDED。 -- [ ] RECONCILED 展示订单号、时间、SKU、数量、金额、截图及人工付款提醒。 -- [ ] Web/App 均没有付款按钮、重试提交或自动支付动作。 -- [ ] Go test/race/vet、Android test/Debug/Release、根验证与响应式 Playwright 通过。 -- [ ] 最小跨端 fixture 证明唯一 submission 和 Admin 提醒闭环。 +- [x] repository 按任务和 Admin subject 读取稳定排序的 submission,空值不报错。 +- [x] Admin API 仅鉴权返回对账字段且 `no-store`,订单号不进 URL/日志。 +- [x] SSR 正确区分 FENCED、MANUAL_REVIEW、RECONCILED 和历史未下单 SUCCEEDED。 +- [x] RECONCILED 展示订单号、时间、SKU、数量、金额、截图及人工付款提醒。 +- [x] Web/App 均没有付款按钮、重试提交或自动支付动作。 +- [x] Go test/race/vet、Android test/Debug/Release、根验证与响应式 Playwright 通过。 +- [x] 最小跨端 fixture 证明唯一 submission 和 Admin 提醒闭环。 ## 边界 @@ -125,3 +125,14 @@ T-218 已把唯一对账成功的待付款订单保存到 `order_submissions`, - 2026-07-28:T-218 实现提交 `a77d8be` 后领取。冻结 Admin 三态展示、鉴权订单字段、 历史成功文案兼容、Roubao 终态提醒和隔离数据库端到端验收。 +- 2026-07-28:文档合约提交为 `c2ec98d`。`TaskDetail`、Admin API 和 SSR 已读取 + submission;管理详情区分 `FENCED`、`MANUAL_REVIEW`、`RECONCILED`,唯一对账后 + 展示完整订单号、平台时间、授权快照、受控证据和人工付款提醒。Roubao 同步展示 + 对账终态,未增加付款或重复提交动作。 +- 2026-07-28:设备到 Admin 的自动化 fixture 已覆盖创建、领取、候选、授权、命令 + 确认、dry-run、围栏、唯一对账和提醒,断言每个授权只有一个 submission。真实 SSR + 使用隔离数据库在 1440x900、390x844、360x800 验证,无脚本错误、横向溢出或付款 + 操作,证据请求成功;生产数据库未修改,未创建真实拼多多订单。 +- 2026-07-28:`go test ./...`、`go test -race ./...`、`go vet ./...`、Android + Debug/Release 单元测试与构建、根 `init.ps1` 全部通过。Debug APK + `1.4.16 (21)` 已覆盖安装到 PKG110,并恢复、确认肉包无障碍服务。