feat(t219): show pending-payment reconciliation
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+48
-1
@@ -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 -> "订单需要人工核对"
|
||||||
|
}
|
||||||
|
|||||||
+49
@@ -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")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,并恢复、确认肉包无障碍服务。
|
||||||
|
|||||||
Reference in New Issue
Block a user