feat(t219): show pending-payment reconciliation
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,54 @@
|
||||
</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">
|
||||
<div aria-hidden="true" class="success-mark">✓</div>
|
||||
<div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user