feat(t219): show pending-payment reconciliation

This commit is contained in:
QiuSW
2026-07-28 18:16:09 +08:00
parent c2ec98d5be
commit 78dc595815
20 changed files with 768 additions and 37 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.roubao.autopilot" applicationId = "com.roubao.autopilot"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 20 versionCode = 21
versionName = "1.4.15" versionName = "1.4.16"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.roubao.autopilot.procurement.LoginInput import com.roubao.autopilot.procurement.LoginInput
import com.roubao.autopilot.procurement.ExecutionMode import com.roubao.autopilot.procurement.ExecutionMode
import com.roubao.autopilot.procurement.OrderSubmissionStatus
import com.roubao.autopilot.procurement.ProcurementPhase import com.roubao.autopilot.procurement.ProcurementPhase
import com.roubao.autopilot.procurement.ProcurementUiState import com.roubao.autopilot.procurement.ProcurementUiState
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
@@ -471,6 +472,30 @@ private fun ExecutionDetails(state: ProcurementUiState) {
DetailRow("核验总额", "¥%.2f".format(cents / 100.0)) 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( DetailRow(
"后台连接", "后台连接",
when (state.backendOnline) { when (state.backendOnline) {
@@ -505,7 +530,7 @@ private fun ExecutionDetails(state: ProcurementUiState) {
ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW -> ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW ->
"订单未能唯一对账,保持禁止重复提交" "订单未能唯一对账,保持禁止重复提交"
ProcurementPhase.ORDER_RECONCILED -> ProcurementPhase.ORDER_RECONCILED ->
"待付款订单已回传后台,请人工确认付款" "待付款订单已回传后台,请到拼多多订单列表人工确认付款"
else -> "订单提交保持禁用" else -> "订单提交保持禁用"
}, },
color = if ( color = if (
@@ -577,3 +602,25 @@ private fun phaseLabel(state: ProcurementUiState): String =
ProcurementPhase.ORDER_RECONCILED -> "待付款订单已回传" ProcurementPhase.ORDER_RECONCILED -> "待付款订单已回传"
ProcurementPhase.AUTHORIZATION_EXPIRED -> "授权到期,已停止" 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 -> "订单需要人工核对"
}
@@ -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")
)
}
}
+1
View File
@@ -403,6 +403,7 @@ type TaskDetail struct {
Events []TaskEvent Events []TaskEvent
Report *ExecutionReport Report *ExecutionReport
OrderAuthorizations []OrderAuthorization OrderAuthorizations []OrderAuthorization
OrderSubmissions []OrderSubmission
} }
type TaskValidationError struct { type TaskValidationError struct {
@@ -728,6 +728,35 @@ const orderSubmissionSelect = `SELECT
manual_reason_code, fenced_at, reconciled_at, manual_review_at manual_reason_code, fenced_at, reconciled_at, manual_review_at
FROM order_submissions` 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( func scanOrderSubmission(
scanner rowScanner, scanner rowScanner,
) (domain.OrderSubmission, error) { ) (domain.OrderSubmission, error) {
@@ -186,6 +186,7 @@ func TestStoreAssetAndTaskLifecycleIsTransactionalAndIdempotent(
} }
if detail.Asset.ID != asset.ID || if detail.Asset.ID != asset.ID ||
len(detail.Events) != 1 || len(detail.Events) != 1 ||
len(detail.OrderSubmissions) != 0 ||
detail.Events[0].Type != "TASK_CREATED" { detail.Events[0].Type != "TASK_CREATED" {
t.Fatalf("detail = %+v", detail) t.Fatalf("detail = %+v", detail)
} }
@@ -324,6 +324,10 @@ func (s *Store) GetTaskDetail(
if err != nil { if err != nil {
return domain.TaskDetail{}, err return domain.TaskDetail{}, err
} }
orderSubmissions, err := listOrderSubmissions(ctx, tx, taskID)
if err != nil {
return domain.TaskDetail{}, err
}
detail := domain.TaskDetail{ detail := domain.TaskDetail{
Task: task, Task: task,
Asset: asset, Asset: asset,
@@ -331,6 +335,7 @@ func (s *Store) GetTaskDetail(
Events: events, Events: events,
Report: report, Report: report,
OrderAuthorizations: orderAuthorizations, OrderAuthorizations: orderAuthorizations,
OrderSubmissions: orderSubmissions,
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return domain.TaskDetail{}, repositoryFailure(err) return domain.TaskDetail{}, repositoryFailure(err)
@@ -380,6 +380,9 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
"order_authorizations": orderAuthorizationResponses( "order_authorizations": orderAuthorizationResponses(
detail.OrderAuthorizations, detail.OrderAuthorizations,
), ),
"order_submissions": adminOrderSubmissionResponses(
detail.OrderSubmissions,
),
"events": events, "events": events,
"assets": []gin.H{ "assets": []gin.H{
assetResponse(detail.Asset), 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) { func (h *adminHandlers) createOrderAuthorization(ctx *gin.Context) {
var request struct { var request struct {
ExecutionID string `json:"execution_id"` ExecutionID string `json:"execution_id"`
@@ -197,9 +197,13 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
var detail map[string]any var detail map[string]any
decodeResponse(t, detailResponse, &detail) decodeResponse(t, detailResponse, &detail)
requirement, _ := detail["original_requirement"].(map[string]any) requirement, _ := detail["original_requirement"].(map[string]any)
orderSubmissions, ok := detail["order_submissions"].([]any)
if detailResponse.Code != http.StatusOK || if detailResponse.Code != http.StatusOK ||
requirement["sku"] != "BLACK-20L" || 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( t.Fatalf(
"task detail status/body = %d / %#v", "task detail status/body = %d / %#v",
detailResponse.Code, detailResponse.Code,
@@ -1314,6 +1314,71 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
authorizationStatus, 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, var deliveredEvents, acknowledgedEvents, dryRunStartedEvents,
dryRunReadyEvents, fencedEvents, manualEvents, reconciledEvents int dryRunReadyEvents, fencedEvents, manualEvents, reconciledEvents int
for eventType, target := range map[string]*int{ for eventType, target := range map[string]*int{
@@ -1617,11 +1682,12 @@ func TestDeviceCancelAcknowledgementFollowsAdminStopRequest(
} }
type deviceHTTPFixture struct { type deviceHTTPFixture struct {
db *sql.DB db *sql.DB
store *repository.Store store *repository.Store
assets *usecase.AssetService assets *usecase.AssetService
tasks *usecase.TaskService tasks *usecase.TaskService
router http.Handler router http.Handler
adminRouter http.Handler
taskSequence int taskSequence int
} }
@@ -1678,6 +1744,14 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
if err != nil { if err != nil {
t.Fatalf("usecase.NewExecutionResultService() error = %v", err) 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) commands, err := usecase.NewDeviceOrderCommandService(store, clock, ids)
if err != nil { if err != nil {
t.Fatalf("usecase.NewDeviceOrderCommandService() error = %v", err) t.Fatalf("usecase.NewDeviceOrderCommandService() error = %v", err)
@@ -1716,12 +1790,37 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
if err != nil { if err != nil {
t.Fatalf("NewRouter() error = %v", err) 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{ return &deviceHTTPFixture{
db: db, db: db,
store: store, store: store,
assets: assets, assets: assets,
tasks: tasks, tasks: tasks,
router: router, router: router,
adminRouter: adminRouter,
} }
} }
@@ -834,6 +834,8 @@ type taskDetailView struct {
ExecutionReport *ExecutionReport ExecutionReport *ExecutionReport
Candidates []AuthorizationCandidate Candidates []AuthorizationCandidate
OrderAuthorizations []OrderAuthorization OrderAuthorizations []OrderAuthorization
OrderSubmissions []OrderSubmission
HasReconciledOrder bool
CanAuthorizeOrder bool CanAuthorizeOrder bool
ActiveAuthorizationID string ActiveAuthorizationID string
} }
@@ -865,6 +867,13 @@ func taskDetailViewFrom(task Task) taskDetailView {
canAuthorizeOrder = false canAuthorizeOrder = false
} }
} }
hasReconciledOrder := false
for _, submission := range task.OrderSubmissions {
if submission.Status == "RECONCILED" {
hasReconciledOrder = true
break
}
}
return taskDetailView{ return taskDetailView{
ID: task.ID, ID: task.ID,
Title: task.Title, Title: task.Title,
@@ -885,6 +894,8 @@ func taskDetailViewFrom(task Task) taskDetailView {
ExecutionReport: task.ExecutionReport, ExecutionReport: task.ExecutionReport,
Candidates: task.Candidates, Candidates: task.Candidates,
OrderAuthorizations: task.OrderAuthorizations, OrderAuthorizations: task.OrderAuthorizations,
OrderSubmissions: task.OrderSubmissions,
HasReconciledOrder: hasReconciledOrder,
CanAuthorizeOrder: canAuthorizeOrder, CanAuthorizeOrder: canAuthorizeOrder,
ActiveAuthorizationID: activeAuthorizationID, ActiveAuthorizationID: activeAuthorizationID,
} }
@@ -14,6 +14,7 @@ import (
"testing" "testing"
"time" "time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase" "cmroubao/backend-api/internal/usecase"
"github.com/gin-gonic/gin" "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) { func TestTaskDetailCancelModeFollowsLifecycleStatus(t *testing.T) {
tests := []struct { tests := []struct {
status string status string
@@ -813,6 +813,87 @@ tbody tr:last-child td {
font-weight: 800; 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 { .detail-layout {
display: grid; display: grid;
grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
@@ -1053,6 +1134,7 @@ tbody tr:last-child td {
.upload-layout, .upload-layout,
.detail-layout, .detail-layout,
.requirement-layout, .requirement-layout,
.pending-payment-layout,
.authorization-reasons { .authorization-reasons {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -29,7 +29,54 @@
</div> </div>
</div> </div>
{{if eq .Task.Status "SUCCEEDED"}} {{range .Task.OrderSubmissions}}
{{if eq .Status "RECONCILED"}}
<section class="order-state-banner order-state-payment"
aria-labelledby="pending-payment-heading">
<div class="order-state-heading">
<div>
<p class="order-state-label">{{.StatusLabel}}</p>
<h2 id="pending-payment-heading">待人工确认付款</h2>
</div>
<span class="status status-success">待付款</span>
</div>
<p class="order-state-instruction">
请采购员打开拼多多订单列表,核对商品、规格、数量和金额后人工付款。
</p>
<div class="pending-payment-layout">
<dl class="definition-list pending-payment-details">
<dt>拼多多订单号</dt><dd class="order-number">{{.PlatformOrderNo}}</dd>
<dt>平台下单时间</dt>
<dd><time datetime="{{machineTime .PlatformOrderedAt}}">{{displayTime .PlatformOrderedAt}}</time></dd>
<dt>平台状态</dt><dd>待付款({{.PlatformOrderStatus}})</dd>
<dt>商品标题</dt><dd>{{.ExpectedTitle}}</dd>
<dt>SKU</dt><dd>{{.ExpectedSKU}}</dd>
<dt>数量</dt><dd>{{.ExpectedQuantity}}</dd>
<dt>核验单价</dt><dd>¥{{.ExpectedUnitPrice}}</dd>
<dt>核验总额</dt><dd>¥{{.ExpectedTotalPrice}}</dd>
</dl>
{{if .EvidenceContentURL}}
<figure class="pending-payment-evidence">
<img src="{{.EvidenceContentURL}}" alt="待付款订单列表对账截图">
<figcaption>设备回传的订单列表对账证据</figcaption>
</figure>
{{end}}
</div>
</section>
{{else if eq .Status "MANUAL_REVIEW"}}
<section class="order-state-banner order-state-manual" role="status">
<h2>{{.StatusLabel}}</h2>
<p>{{.ManualReasonLabel}}。提交围栏保持有效,禁止重新提交订单。</p>
</section>
{{else if eq .Status "FENCED"}}
<section class="order-state-banner order-state-reconciling" role="status">
<h2>{{.StatusLabel}}</h2>
<p>订单提交结果正在对账,提交围栏已经消费,禁止重复提交。</p>
</section>
{{end}}
{{end}}
{{if and (eq .Task.Status "SUCCEEDED") (not .Task.HasReconciledOrder)}}
<section class="success-banner" aria-labelledby="success-heading"> <section class="success-banner" aria-labelledby="success-heading">
<div aria-hidden="true" class="success-mark">✓</div> <div aria-hidden="true" class="success-mark">✓</div>
<div> <div>
@@ -65,6 +65,7 @@ type Task struct {
ExecutionReport *ExecutionReport ExecutionReport *ExecutionReport
Candidates []AuthorizationCandidate Candidates []AuthorizationCandidate
OrderAuthorizations []OrderAuthorization OrderAuthorizations []OrderAuthorization
OrderSubmissions []OrderSubmission
} }
type AuthorizationCandidate struct { type AuthorizationCandidate struct {
@@ -88,6 +89,25 @@ type OrderAuthorization struct {
CreatedAt time.Time 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 { type ExecutionReport struct {
Events []ExecutionReportEvent Events []ExecutionReportEvent
Evidence []ExecutionReportEvidence Evidence []ExecutionReportEvidence
@@ -280,9 +280,91 @@ func taskFromDetail(detail domain.TaskDetail) Task {
orderAuthorizationFrom(authorization), orderAuthorizationFrom(authorization),
) )
} }
for _, submission := range detail.OrderSubmissions {
task.OrderSubmissions = append(
task.OrderSubmissions,
orderSubmissionFrom(submission),
)
}
return task 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( func orderAuthorizationFrom(
authorization domain.OrderAuthorization, authorization domain.OrderAuthorization,
) OrderAuthorization { ) OrderAuthorization {
+2 -2
View File
@@ -61,8 +61,8 @@ T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207
规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选 规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选
结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹、T-215 结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹、T-215
Admin 候选确认、不可变待投递授权、T-216 设备命令可靠投递及 T-217 已授权商品 Admin 候选确认、不可变待投递授权、T-216 设备命令可靠投递及 T-217 已授权商品
重新定位与订单 dry-run、T-218 单次订单提交围栏和订单回读均已完成;T-219 Admin 重新定位与订单 dry-run、T-218 单次订单提交围栏和订单回读,以及 T-219 Admin
待付款提醒与端到端验收正在开发。 待付款提醒与端到端验收均已完成。下一任务是 T-301 P0 UI 完整交互验收。
不得直接把候选链接或列表 ordinal 当成授权。 不得直接把候选链接或列表 ordinal 当成授权。
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。 手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。 T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。
+1 -1
View File
@@ -17,7 +17,7 @@
| IX-008 | US-006 | App/管理端错误状态 | 自动失败、取消、重试上传 | 显示结构化原因和恢复动作 | P0 | 已定 | | IX-008 | US-006 | App/管理端错误状态 | 自动失败、取消、重试上传 | 显示结构化原因和恢复动作 | P0 | 已定 |
| IX-009 | US-008 | App 候选理由/管理端决策详情 | 接受、拒绝、改选或修正 | 保存逐候选结构化人工标签 | P1 | T-208 已实现 | | IX-009 | US-008 | App 候选理由/管理端决策详情 | 接受、拒绝、改选或修正 | 保存逐候选结构化人工标签 | P1 | T-208 已实现 |
| IX-010 | US-009 | App 独立执行设置/同步状态 | 配置模式、离线执行或补报 | 授权内独立执行并可审计同步 | P0 | T-206 离线控制已实现,结果补报待 T-207 | | 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 登录 ## IX-001 管理 Web 登录
+16 -11
View File
@@ -5,8 +5,8 @@
## 当前快照 ## 当前快照
- 日期:2026-07-28 - 日期:2026-07-28
- 阶段:T-219 Admin 待付款提醒与端到端验收已领取,合约冻结中 - 阶段:T-219 Admin 待付款提醒与端到端验收已完成
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-218 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
均按文档提交、实现提交的顺序纳入历史 均按文档提交、实现提交的顺序纳入历史
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
@@ -17,10 +17,10 @@
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:T-218 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过; - 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
Debug APK `1.4.15 (20)` 已覆盖安装到 PKG110 Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
- 后端测试:T-218 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`; - 后端测试:T-219 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
v11 migration 降级保护及 fence/replay/manual-review/reconcile HTTP 集成测试通过 唯一 submission 的设备到 Admin HTTP 闭环及三态 SSR 测试通过
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
脚本错误或外部请求,Android 可见交互控件均不小于 44px 脚本错误或外部请求,Android 可见交互控件均不小于 44px
@@ -80,6 +80,10 @@
`CLICK_ASSUMED`,最多点击一次精确“提交订单”;进程/网络不确定和租约到期后只 `CLICK_ASSUMED`,最多点击一次精确“提交订单”;进程/网络不确定和租约到期后只
回读待付款订单或转人工,绝不重复提交或点击支付。v11 保存围栏、幂等操作、订单号/ 回读待付款订单或转人工,绝不重复提交或点击支付。v11 保存围栏、幂等操作、订单号/
时间、证据和审计事件,唯一对账后才写 `order_submitted=true`。 时间、证据和审计事件,唯一对账后才写 `order_submitted=true`。
- T-219 待付款提醒:Admin API/SSR 按任务鉴权读取 submission,区分围栏中、人工
对账和唯一对账成功;成功态展示完整订单号、平台时间、授权 SKU/数量/金额及受控
证据,只提醒采购员打开拼多多人工核对付款。Roubao 显示相同终态;Web/App 均没有
付款、重复提交或自动支付动作。隔离数据库真实 SSR 已通过三个响应式视口验收。
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
@@ -93,7 +97,7 @@
- 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110 - 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110
真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止, 真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止,
RUNNING 不自动重新分配 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 (81700)`
- 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0 - 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0
真机验证;ColorOS 本次覆盖安装后移除了肉包采购无障碍,重新启用后服务已连接, 真机验证;ColorOS 本次覆盖安装后移除了肉包采购无障碍,重新启用后服务已连接,
@@ -108,7 +112,7 @@
已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/` 已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/`
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1` - 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
- 标准验证路径:`.\init.ps1` - 标准验证路径:`.\init.ps1`
- 当前 blocker:T-219 实现无代码阻塞;当前真机确认页缺少已配置收货地址且显示 - 当前 blocker:当前真机确认页缺少已配置收货地址且显示
“立即支付/先用后付”,T-218 已按合约证明不创建围栏、不点击。不能为测试自动添加 “立即支付/先用后付”,T-218 已按合约证明不创建围栏、不点击。不能为测试自动添加
地址或跨过付款边界。真实 VLM 服务地址、模型、 地址或跨过付款边界。真实 VLM 服务地址、模型、
设备级测试凭证、成本上限和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针 设备级测试凭证、成本上限和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针
@@ -146,6 +150,7 @@
| `docs/tasks/T-216.md` | DONE | App 主动拉取并先加密落盘再确认同一条下单命令 | | `docs/tasks/T-216.md` | DONE | App 主动拉取并先加密落盘再确认同一条下单命令 |
| `docs/tasks/T-217.md` | DONE | 重新核对已授权商品并选择 SKU/数量,停在最终提交前 | | `docs/tasks/T-217.md` | DONE | 重新核对已授权商品并选择 SKU/数量,停在最终提交前 |
| `docs/tasks/T-218.md` | DONE | 单次提交围栏、最多一次提交动作和待付款订单唯一对账 | | `docs/tasks/T-218.md` | DONE | 单次提交围栏、最多一次提交动作和待付款订单唯一对账 |
| `docs/tasks/T-219.md` | DONE | Admin 待付款提醒、Roubao 终态和跨端闭环验收 |
| `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 基线 |
@@ -156,9 +161,9 @@
## 任务摘要 ## 任务摘要
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-218。 - 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
- 进行中:T-219 Admin 待付款提醒与端到端验收。 - 进行中:无。
- 下一步:完成 T-219 后进入 T-301 P0 UI 完整交互验收。 - 下一步:进入 T-301 P0 UI 完整交互验收。
## 当前可运行内容 ## 当前可运行内容
+19 -8
View File
@@ -4,7 +4,7 @@ title: Admin 待付款提醒与端到端验收
phase: 2 phase: 2
deps: deps:
- T-218 - T-218
status: DOING status: DONE
created: 2026-07-28 created: 2026-07-28
context_ref: a77d8be context_ref: a77d8be
work_branch: null work_branch: null
@@ -106,13 +106,13 @@ T-218 已把唯一对账成功的待付款订单保存到 `order_submissions`,
## 验收要点 ## 验收要点
- [ ] repository 按任务和 Admin subject 读取稳定排序的 submission,空值不报错。 - [x] repository 按任务和 Admin subject 读取稳定排序的 submission,空值不报错。
- [ ] Admin API 仅鉴权返回对账字段且 `no-store`,订单号不进 URL/日志。 - [x] Admin API 仅鉴权返回对账字段且 `no-store`,订单号不进 URL/日志。
- [ ] SSR 正确区分 FENCED、MANUAL_REVIEW、RECONCILED 和历史未下单 SUCCEEDED。 - [x] SSR 正确区分 FENCED、MANUAL_REVIEW、RECONCILED 和历史未下单 SUCCEEDED。
- [ ] RECONCILED 展示订单号、时间、SKU、数量、金额、截图及人工付款提醒。 - [x] RECONCILED 展示订单号、时间、SKU、数量、金额、截图及人工付款提醒。
- [ ] Web/App 均没有付款按钮、重试提交或自动支付动作。 - [x] Web/App 均没有付款按钮、重试提交或自动支付动作。
- [ ] Go test/race/vet、Android test/Debug/Release、根验证与响应式 Playwright 通过。 - [x] Go test/race/vet、Android test/Debug/Release、根验证与响应式 Playwright 通过。
- [ ] 最小跨端 fixture 证明唯一 submission 和 Admin 提醒闭环。 - [x] 最小跨端 fixture 证明唯一 submission 和 Admin 提醒闭环。
## 边界 ## 边界
@@ -125,3 +125,14 @@ T-218 已把唯一对账成功的待付款订单保存到 `order_submissions`,
- 2026-07-28:T-218 实现提交 `a77d8be` 后领取。冻结 Admin 三态展示、鉴权订单字段、 - 2026-07-28:T-218 实现提交 `a77d8be` 后领取。冻结 Admin 三态展示、鉴权订单字段、
历史成功文案兼容、Roubao 终态提醒和隔离数据库端到端验收。 历史成功文案兼容、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,并恢复、确认肉包无障碍服务。