feat(t218): reconcile single order submissions

This commit is contained in:
QiuSW
2026-07-28 17:48:54 +08:00
parent 682cc7327c
commit a77d8be06d
32 changed files with 4306 additions and 79 deletions
@@ -4,6 +4,9 @@ import com.roubao.autopilot.pinduoduo.PinduoduoPage
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoOrderSubmissionExpectation
import com.roubao.autopilot.pinduoduo.PinduoduoOrderSubmissionReadiness
import com.roubao.autopilot.pinduoduo.PinduoduoPendingOrderObservation
import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
@@ -149,6 +152,44 @@ object BuyerAccessibilityBridge {
}
}
suspend fun orderSubmissionReadiness(
expectation: PinduoduoOrderSubmissionExpectation
): PinduoduoOrderSubmissionReadiness? =
withContext(Dispatchers.Main.immediate) {
service?.readPinduoduoOrderSubmissionReadiness(expectation)
}
suspend fun submitOrderOnce(
submissionFenceId: String,
expectation: PinduoduoOrderSubmissionExpectation
): Boolean = withContext(Dispatchers.Main.immediate) {
service?.submitPinduoduoOrderOnce(
submissionFenceId,
expectation
) == true
}
suspend fun navigateToOrderListAfterSubmit(): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.navigateToPinduoduoOrderListAfterSubmit() == true
}
suspend fun pendingOrders(
limit: Int
): List<PinduoduoPendingOrderObservation> =
withContext(Dispatchers.Main.immediate) {
service?.readPinduoduoPendingOrders(limit).orEmpty()
}
suspend fun captureOrderListScreenshot(): PinduoduoScreenshotCapture? =
withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) {
withContext(Dispatchers.Main.immediate) {
service?.capturePinduoduoScreenshot(
PinduoduoPage.ORDER_LIST
)
}
}
suspend fun captureSpecificationScreenshot(): PinduoduoScreenshotCapture? =
withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) {
withContext(Dispatchers.Main.immediate) {
@@ -17,6 +17,11 @@ import com.roubao.autopilot.pinduoduo.PinduoduoCollapsedCheckoutPolicy
import com.roubao.autopilot.pinduoduo.PinduoduoObservedTitlePolicy
import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationParser
import com.roubao.autopilot.pinduoduo.PinduoduoOrderSubmissionExpectation
import com.roubao.autopilot.pinduoduo.PinduoduoOrderSubmissionReadiness
import com.roubao.autopilot.pinduoduo.PinduoduoOrderSubmissionSafetyPolicy
import com.roubao.autopilot.pinduoduo.PinduoduoPendingOrderObservation
import com.roubao.autopilot.pinduoduo.PinduoduoPendingOrderParser
import com.roubao.autopilot.readiness.DeviceObservationStore
import com.roubao.autopilot.readiness.LoginBlockerDetector
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
@@ -42,6 +47,7 @@ class BuyerAccessibilityService : AccessibilityService() {
private var lastPinduoduoScanAt = 0L
private var expectedSearchQuery = SEARCH_PROBE_KEYWORD
private val screenshotExecutor = Executors.newSingleThreadExecutor()
private val consumedSubmissionFences = linkedSetOf<String>()
override fun onServiceConnected() {
super.onServiceConnected()
@@ -520,6 +526,131 @@ class BuyerAccessibilityService : AccessibilityService() {
)
}
internal fun readPinduoduoOrderSubmissionReadiness(
expectation: PinduoduoOrderSubmissionExpectation
): PinduoduoOrderSubmissionReadiness? =
withPinduoduoRoot { root ->
val snapshot = classifyPinduoduoRoot(root)
if (
snapshot.page != PinduoduoPage.ORDER_CONFIRMATION ||
snapshot.safetyStopReason !in setOf(
null,
SafetyStopReason.PAYMENT_BOUNDARY
)
) {
return@withPinduoduoRoot null
}
PinduoduoOrderSubmissionSafetyPolicy.inspect(
collectNodes(root).map(::uiElement),
expectation
)
}
internal fun submitPinduoduoOrderOnce(
submissionFenceId: String,
expectation: PinduoduoOrderSubmissionExpectation
): Boolean = withPinduoduoRoot { root ->
if (!SUBMISSION_FENCE_PATTERN.matches(submissionFenceId)) {
return@withPinduoduoRoot false
}
val snapshot = classifyPinduoduoRoot(root)
if (
snapshot.page != PinduoduoPage.ORDER_CONFIRMATION ||
snapshot.safetyStopReason !in setOf(
null,
SafetyStopReason.PAYMENT_BOUNDARY
) ||
PinduoduoOrderSubmissionSafetyPolicy.inspect(
collectNodes(root).map(::uiElement),
expectation
) == null
) {
return@withPinduoduoRoot false
}
val targets = uniqueClickableTargets(
collectNodes(root).filter { node ->
node.isVisibleToUser &&
node.isEnabled &&
PinduoduoOrderSubmissionSafetyPolicy.isExactSubmitText(
semanticText(node)
)
}
)
val target = targets.singleOrNull()
?: return@withPinduoduoRoot false
synchronized(consumedSubmissionFences) {
if (
consumedSubmissionFences.size >=
MAX_CONSUMED_SUBMISSION_FENCES
) {
return@withPinduoduoRoot false
}
if (!consumedSubmissionFences.add(submissionFenceId)) {
return@withPinduoduoRoot false
}
}
target.performAction(AccessibilityNodeInfo.ACTION_CLICK)
} ?: false
internal fun navigateToPinduoduoOrderListAfterSubmit(): Boolean =
withPinduoduoRoot { root ->
val snapshot = classifyPinduoduoRoot(root)
when {
snapshot.page == PinduoduoPage.ORDER_LIST -> true
snapshot.safetyStopReason in setOf(
SafetyStopReason.LOGIN_REQUIRED,
SafetyStopReason.VERIFICATION_REQUIRED,
SafetyStopReason.RISK_CONTROL
) -> false
snapshot.page in setOf(
PinduoduoPage.ORDER_CONFIRMATION,
PinduoduoPage.UNKNOWN
) &&
snapshot.safetyStopReason in setOf(
null,
SafetyStopReason.PAYMENT_BOUNDARY
) ->
performGlobalAction(GLOBAL_ACTION_BACK)
else -> false
}
} ?: false
internal fun readPinduoduoPendingOrders(
limit: Int
): List<PinduoduoPendingOrderObservation> =
withPinduoduoRoot { root ->
if (
limit !in 1..MAX_PENDING_ORDERS ||
classifyPinduoduoRoot(root).page !=
PinduoduoPage.ORDER_LIST
) {
return@withPinduoduoRoot emptyList()
}
val orderNumberNodes = collectNodes(root).filter { node ->
node.isVisibleToUser &&
ORDER_NUMBER_LABEL_PATTERN.matches(
normalizeActionText(semanticText(node))
)
}
orderNumberNodes.mapNotNull { orderNumberNode ->
var container: AccessibilityNodeInfo? =
orderNumberNode.parent
repeat(MAX_ORDER_CARD_ANCESTORS) {
val current = container ?: return@repeat
val observation = PinduoduoPendingOrderParser.parse(
collectNodes(current).map(::uiElement)
)
if (observation != null) {
return@mapNotNull observation
}
container = current.parent
}
null
}.distinctBy {
it.platformOrderNo
}.take(limit)
} ?: emptyList()
internal fun isPinduoduoCollapsedCheckout(): Boolean =
withPinduoduoRoot { root ->
PinduoduoCollapsedCheckoutPolicy.matches(
@@ -608,7 +739,8 @@ class BuyerAccessibilityService : AccessibilityService() {
expectedPage !in setOf(
PinduoduoPage.PRODUCT_DETAIL,
PinduoduoPage.SPECIFICATION_PANEL,
PinduoduoPage.ORDER_CONFIRMATION
PinduoduoPage.ORDER_CONFIRMATION,
PinduoduoPage.ORDER_LIST
)
) {
return null
@@ -1143,6 +1275,9 @@ class BuyerAccessibilityService : AccessibilityService() {
private const val MIN_CLICK_TARGET_COVER_PERCENT = 85
private const val MAX_EVIDENCE_TEXTS = 100
private const val MAX_EVIDENCE_TEXT_LENGTH = 500
private const val MAX_PENDING_ORDERS = 10
private const val MAX_ORDER_CARD_ANCESTORS = 6
private const val MAX_CONSUMED_SUBMISSION_FENCES = 1_024
private const val MIN_SCAN_INTERVAL_MS = 300L
private const val IMAGE_SEARCH_DESCRIPTION = "拍照搜索"
private const val IMAGE_SEARCH_RETRY_CANCEL_TEXT = "取消"
@@ -1156,5 +1291,12 @@ class BuyerAccessibilityService : AccessibilityService() {
setOf("减少数量", "减", "-")
private val DECIMAL_PRICE_PATTERN =
Regex("^\\s*\\d{1,6}\\.\\d{1,2}\\s*$")
private val SUBMISSION_FENCE_PATTERN = Regex(
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-" +
"[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-" +
"[0-9a-fA-F]{12}$"
)
private val ORDER_NUMBER_LABEL_PATTERN =
Regex("^订单(?:编号|号)[::]?[0-9]{8,40}$")
}
}
@@ -0,0 +1,70 @@
package com.roubao.autopilot.pinduoduo
import com.roubao.autopilot.accessibility.BuyerAccessibilityBridge
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import kotlinx.coroutines.delay
class AndroidPinduoduoOrderSubmissionDriver(
private val expectation: PinduoduoOrderSubmissionExpectation,
private val pagePollLimit: Int = 10,
private val pollIntervalMillis: Long = 800L
) {
private var navigationActionConsumed = false
suspend fun revalidate():
PinduoduoOrderSubmissionReadiness? =
BuyerAccessibilityBridge.orderSubmissionReadiness(expectation)
suspend fun submitOnce(submissionFenceId: String): Boolean =
BuyerAccessibilityBridge.submitOrderOnce(
submissionFenceId,
expectation
)
suspend fun awaitAndNavigateToOrderList(
allowBackNavigation: Boolean
): Boolean {
repeat(pagePollLimit) {
val snapshot = BuyerAccessibilityBridge.snapshot()
if (
snapshot.foregroundPackage == PINDUODUO_PACKAGE &&
snapshot.page == PinduoduoPage.ORDER_LIST &&
snapshot.safetyStopReason == null
) {
return true
}
if (
snapshot.foregroundPackage != PINDUODUO_PACKAGE ||
snapshot.safetyStopReason in setOf(
com.roubao.autopilot.workflow.SafetyStopReason.LOGIN_REQUIRED,
com.roubao.autopilot.workflow.SafetyStopReason.VERIFICATION_REQUIRED,
com.roubao.autopilot.workflow.SafetyStopReason.RISK_CONTROL
)
) {
return false
}
if (!navigationActionConsumed && allowBackNavigation) {
navigationActionConsumed = true
if (!BuyerAccessibilityBridge.navigateToOrderListAfterSubmit()) {
return false
}
} else if (!allowBackNavigation) {
return false
}
delay(pollIntervalMillis)
}
return false
}
suspend fun pendingOrders(
limit: Int = MAX_PENDING_ORDERS
): List<PinduoduoPendingOrderObservation> =
BuyerAccessibilityBridge.pendingOrders(limit)
suspend fun captureOrderList(): PinduoduoScreenshotCapture? =
BuyerAccessibilityBridge.captureOrderListScreenshot()
companion object {
const val MAX_PENDING_ORDERS = 10
}
}
@@ -0,0 +1,348 @@
package com.roubao.autopilot.pinduoduo
import java.text.Normalizer
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
data class PinduoduoOrderSubmissionExpectation(
val title: String,
val color: String,
val size: String,
val quantity: Int,
val totalPriceCents: Long
)
data class PinduoduoOrderSubmissionReadiness(
val confirmation: PinduoduoOrderConfirmationEvidence,
val addressConfigured: Boolean,
val exactSubmitActionCount: Int
)
data class PinduoduoPendingOrderObservation(
val platformOrderNo: String,
val platformOrderedAt: String,
val status: String,
val observedTitle: String,
val selectedSummary: String,
val quantity: Int,
val totalPriceCents: Long
)
object PinduoduoOrderSubmissionSafetyPolicy {
fun inspect(
elements: Collection<PinduoduoUiElement>,
expectation: PinduoduoOrderSubmissionExpectation
): PinduoduoOrderSubmissionReadiness? {
if (expectation.quantity !in 1..99 ||
expectation.totalPriceCents <= 0
) {
return null
}
val visible = elements.filter {
it.visibleToUser && it.enabled
}.take(MAX_ELEMENTS + 1)
if (visible.isEmpty() || visible.size > MAX_ELEMENTS) {
return null
}
val texts = visible.mapNotNull(::semanticText).distinct()
val normalized = texts.map(::normalize)
if (normalized.any { text ->
ADD_ADDRESS_MARKERS.any(text::contains) ||
FORBIDDEN_PAYMENT_MARKERS.any(text::contains)
}
) {
return null
}
val hasAddressLabel = normalized.any { text ->
ADDRESS_LABELS.any(text::contains)
}
val hasRecipientPhone = texts.any { text ->
PHONE_PATTERN.containsMatchIn(
Normalizer.normalize(text, Normalizer.Form.NFKC)
.replace(" ", "")
)
}
if (!hasAddressLabel || !hasRecipientPhone) {
return null
}
val confirmation = PinduoduoOrderConfirmationParser.parse(visible)
?: return null
if (
PinduoduoOrderConfirmationParser.normalize(
confirmation.observedTitle
) != PinduoduoOrderConfirmationParser.normalize(expectation.title) ||
!PinduoduoSpecificationTargetMatcher.matches(
PinduoduoSpecificationGroupKind.COLOR,
expectation.color,
confirmation.selectedSummary
) ||
!PinduoduoSpecificationTargetMatcher.matches(
PinduoduoSpecificationGroupKind.SIZE,
expectation.size,
confirmation.selectedSummary
) ||
confirmation.quantity != expectation.quantity ||
confirmation.totalPriceCents != expectation.totalPriceCents
) {
return null
}
val exactSubmitActions = visible.count { element ->
element.clickable &&
normalize(semanticText(element).orEmpty()) ==
EXACT_SUBMIT_TEXT
}
if (exactSubmitActions != 1) {
return null
}
return PinduoduoOrderSubmissionReadiness(
confirmation = confirmation,
addressConfigured = true,
exactSubmitActionCount = exactSubmitActions
)
}
internal fun isExactSubmitText(value: String): Boolean =
normalize(value) == EXACT_SUBMIT_TEXT
private fun semanticText(element: PinduoduoUiElement): String? =
sequenceOf(element.text, element.contentDescription)
.filterNotNull()
.map(String::trim)
.firstOrNull(String::isNotEmpty)
private fun normalize(value: String): String =
Normalizer.normalize(value.trim(), Normalizer.Form.NFKC)
.lowercase()
.replace(Regex("\\s+"), "")
private const val MAX_ELEMENTS = 220
private const val EXACT_SUBMIT_TEXT = "提交订单"
private val ADDRESS_LABELS = setOf("收货地址", "配送地址")
private val ADD_ADDRESS_MARKERS = setOf(
"添加收货地址",
"手动添加收货地址",
"请选择收货地址",
"请选择地址",
"新增地址"
)
private val FORBIDDEN_PAYMENT_MARKERS = setOf(
"立即支付",
"确认支付",
"免密支付",
"先用后付",
"微信免密",
"支付宝免密",
"开通免密"
)
private val PHONE_PATTERN =
Regex("""1[3-9]\d(?:\*{4}|\d{4})\d{4}""")
}
object PinduoduoPendingOrderParser {
fun parse(elements: Collection<PinduoduoUiElement>):
PinduoduoPendingOrderObservation? {
val texts = elements.asSequence()
.filter { it.visibleToUser && it.enabled }
.sortedWith(
compareBy<PinduoduoUiElement> { it.boundsTop }
.thenBy { it.boundsLeft }
)
.mapNotNull(::semanticText)
.distinct()
.take(MAX_ELEMENTS + 1)
.toList()
if (texts.isEmpty() || texts.size > MAX_ELEMENTS) {
return null
}
val orderNumbers = texts.mapNotNull(::parseOrderNumber).distinct()
val orderedTimes = texts.mapNotNull(::parseOrderedAt).distinct()
val quantities = texts.mapNotNull(::parseQuantity).distinct()
val totals = texts.mapNotNull(::parseTotal).distinct()
val title = PinduoduoObservedTitlePolicy.select(
texts.filterNot { value ->
val normalized = normalize(value)
parseOrderNumber(value) != null ||
parseOrderedAt(value) != null ||
parseQuantity(value) != null ||
parseTotal(value) != null ||
normalized == "待付款" ||
normalized.startsWith("规格") ||
normalized.startsWith("已选") ||
normalized.startsWith("颜色") ||
normalized.startsWith("尺码")
}
)
val summary = texts.singleOrNull { value ->
val normalized = normalize(value)
normalized.startsWith("已选") ||
normalized.startsWith("规格")
} ?: parseSplitSpecificationSummary(texts)
val pending = texts.any { normalize(it) == "待付款" }
return PinduoduoPendingOrderObservation(
platformOrderNo = orderNumbers.singleOrNull() ?: return null,
platformOrderedAt = orderedTimes.singleOrNull() ?: return null,
status = if (pending) "PENDING_PAYMENT" else return null,
observedTitle = title ?: return null,
selectedSummary = summary ?: return null,
quantity = quantities.singleOrNull() ?: return null,
totalPriceCents = totals.singleOrNull() ?: return null
)
}
private fun parseOrderNumber(value: String): String? =
ORDER_NUMBER_PATTERN.matchEntire(
Normalizer.normalize(value.trim(), Normalizer.Form.NFKC)
.replace(Regex("\\s+"), "")
)?.groupValues?.get(1)
private fun parseOrderedAt(value: String): String? {
val match = ORDERED_AT_PATTERN.matchEntire(
Normalizer.normalize(value.trim(), Normalizer.Form.NFKC)
.replace(':', ':')
) ?: return null
val raw = match.groupValues[1]
val formatter = if (raw.length == 16) {
ORDERED_MINUTE_FORMAT
} else {
ORDERED_SECOND_FORMAT
}
return runCatching {
LocalDateTime.parse(raw, formatter)
.atZone(PDD_TIME_ZONE)
.toInstant()
.toString()
}.getOrNull()
}
private fun parseQuantity(value: String): Int? {
val normalized = normalize(value)
return QUANTITY_PATTERNS.firstNotNullOfOrNull { pattern ->
pattern.matchEntire(normalized)
?.groupValues
?.get(1)
?.toIntOrNull()
?.takeIf { it in 1..99 }
}
}
private fun parseTotal(value: String): Long? {
val normalized = Normalizer.normalize(
value.trim(),
Normalizer.Form.NFKC
).lowercase().replace(Regex("\\s+"), "")
val match = TOTAL_PATTERN.matchEntire(normalized) ?: return null
val whole = match.groupValues[1].toLongOrNull() ?: return null
val fraction = match.groupValues[2].padEnd(2, '0')
.ifEmpty { "00" }
.toLongOrNull() ?: return null
return (whole * 100L + fraction).takeIf {
it in 1..MAX_TOTAL_CENTS
}
}
private fun parseSplitSpecificationSummary(texts: List<String>): String? {
val colors = texts.mapNotNull {
COLOR_PATTERN.matchEntire(it.trim())?.groupValues?.get(1)
}.distinct()
val sizes = texts.mapNotNull {
SIZE_PATTERN.matchEntire(it.trim())?.groupValues?.get(1)
}.distinct()
val color = colors.singleOrNull() ?: return null
val size = sizes.singleOrNull() ?: return null
return "$color $size"
}
private fun semanticText(element: PinduoduoUiElement): String? =
sequenceOf(element.text, element.contentDescription)
.filterNotNull()
.map(String::trim)
.firstOrNull(String::isNotEmpty)
private fun normalize(value: String): String =
Normalizer.normalize(value.trim(), Normalizer.Form.NFKC)
.lowercase()
.replace(Regex("\\s+"), "")
private const val MAX_ELEMENTS = 180
private const val MAX_TOTAL_CENTS = 100_000_000L
private val ORDER_NUMBER_PATTERN =
Regex("""^订单(?:编号|号)[::]?(\d{8,40})$""")
private val ORDERED_AT_PATTERN =
Regex("""^下单时间[::]\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?)$""")
private val ORDERED_MINUTE_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
private val ORDERED_SECOND_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
private val PDD_TIME_ZONE = ZoneId.of("Asia/Shanghai")
private val QUANTITY_PATTERNS = listOf(
Regex("""^(?:x|×)(\d{1,2})$"""),
Regex("""^共(\d{1,2})件$""")
)
private val TOTAL_PATTERN = Regex(
"""^(?:应付|实付款|合计|订单金额)[::]?[¥¥](\d{1,7})(?:\.(\d{1,2}))?$"""
)
private val COLOR_PATTERN =
Regex("""^(?:颜色分类|颜色|颜色款式)[::]\s*(.+)$""")
private val SIZE_PATTERN =
Regex("""^(?:尺码|尺寸)[::]\s*(.+)$""")
}
sealed interface PinduoduoOrderReconciliation {
data class Matched(
val order: PinduoduoPendingOrderObservation
) : PinduoduoOrderReconciliation
data class ManualReview(
val reasonCode: String
) : PinduoduoOrderReconciliation
}
object PinduoduoOrderReconciliationPolicy {
fun reconcile(
observations: Collection<PinduoduoPendingOrderObservation>,
expectation: PinduoduoOrderSubmissionExpectation,
fencedAt: String,
now: Instant
): PinduoduoOrderReconciliation {
val fence = runCatching { Instant.parse(fencedAt) }.getOrNull()
?: return PinduoduoOrderReconciliation.ManualReview(
"ORDER_FIELDS_INCOMPLETE"
)
val matches = observations.filter { observation ->
val orderedAt = runCatching {
Instant.parse(observation.platformOrderedAt)
}.getOrNull() ?: return@filter false
observation.status == "PENDING_PAYMENT" &&
PinduoduoOrderConfirmationParser.normalize(
observation.observedTitle
) == PinduoduoOrderConfirmationParser.normalize(
expectation.title
) &&
PinduoduoSpecificationTargetMatcher.matches(
PinduoduoSpecificationGroupKind.COLOR,
expectation.color,
observation.selectedSummary
) &&
PinduoduoSpecificationTargetMatcher.matches(
PinduoduoSpecificationGroupKind.SIZE,
expectation.size,
observation.selectedSummary
) &&
observation.quantity == expectation.quantity &&
observation.totalPriceCents == expectation.totalPriceCents &&
!orderedAt.isBefore(fence.minusSeconds(CLOCK_WINDOW_SECONDS)) &&
!orderedAt.isAfter(now.plusSeconds(CLOCK_WINDOW_SECONDS))
}
return when (matches.size) {
1 -> PinduoduoOrderReconciliation.Matched(matches.single())
0 -> PinduoduoOrderReconciliation.ManualReview("ORDER_NOT_FOUND")
else -> PinduoduoOrderReconciliation.ManualReview(
"ORDER_AMBIGUOUS"
)
}
}
private const val CLOCK_WINDOW_SECONDS = 5L * 60L
}
@@ -0,0 +1,150 @@
package com.roubao.autopilot.procurement
import android.content.Context
import com.roubao.autopilot.pinduoduo.AndroidPinduoduoOrderSubmissionDriver
import com.roubao.autopilot.pinduoduo.PinduoduoEvidenceHash
import com.roubao.autopilot.pinduoduo.PinduoduoOrderReconciliation
import com.roubao.autopilot.pinduoduo.PinduoduoOrderReconciliationPolicy
import com.roubao.autopilot.pinduoduo.PinduoduoOrderSubmissionExpectation
import com.roubao.autopilot.vlm.SkuConstraintKind
import com.roubao.autopilot.vlm.SkuHardConstraintExtractor
import java.time.Instant
class OrderSubmissionCoordinator(
context: Context,
private val repository: ProcurementRepository
) {
@Suppress("UNUSED_PARAMETER")
private val appContext = context.applicationContext
suspend fun runOnce(): Boolean {
var context =
repository.currentAuthorizedOrderSubmissionContext()
?: return false
val constraints = SkuHardConstraintExtractor.extract(
context.command.originalSku
)
if (!constraints.readyForAutomaticMatching) {
repository.reportOrderSubmissionBlocked(
"SKU 的颜色或尺码无法唯一解析,未创建提交围栏"
)
return false
}
val values = constraints.constraints.associate {
it.kind to it.expected
}
val expectation = PinduoduoOrderSubmissionExpectation(
title = requireNotNull(context.dryRun.observedTitle),
color = requireNotNull(values[SkuConstraintKind.COLOR]),
size = requireNotNull(values[SkuConstraintKind.SIZE]),
quantity = requireNotNull(context.dryRun.quantity),
totalPriceCents = requireNotNull(
context.dryRun.totalPriceCents
)
)
val driver = AndroidPinduoduoOrderSubmissionDriver(expectation)
val beforeFence = context.submission
if (
beforeFence == null ||
beforeFence.status == OrderSubmissionStatus.FENCE_INTENT_SAVED
) {
if (driver.revalidate() == null) {
repository.reportOrderSubmissionBlocked(
"确认订单页缺少已配置地址、唯一提交按钮," +
"或存在免密/自动扣款语义;未创建提交围栏"
)
return false
}
val armed = repository.armOrderSubmission() ?: return false
context = armed.context
if (armed.clickNow) {
val fenceID = requireNotNull(context.submission?.remoteId)
val clicked = driver.submitOnce(fenceID)
repository.markOrderSubmissionReconciling()
if (!clicked) {
repository.markOrderSubmissionManualReview(
"ORDER_PAGE_UNKNOWN"
)
return false
}
}
}
context = repository.currentAuthorizedOrderSubmissionContext()
?: return false
when (context.submission?.status) {
OrderSubmissionStatus.RECONCILED -> return true
OrderSubmissionStatus.CLICK_ASSUMED -> {
repository.markOrderSubmissionReconciling()
}
OrderSubmissionStatus.FENCE_INTENT_SAVED,
null -> return false
else -> Unit
}
val manualReview =
context.submission?.status == OrderSubmissionStatus.MANUAL_REVIEW
if (!driver.awaitAndNavigateToOrderList(
allowBackNavigation = !manualReview
)
) {
if (
context.submission?.status !=
OrderSubmissionStatus.MANUAL_REVIEW
) {
repository.markOrderSubmissionManualReview(
"ORDER_PAGE_UNKNOWN"
)
}
return false
}
val reconciliation = PinduoduoOrderReconciliationPolicy.reconcile(
observations = driver.pendingOrders(),
expectation = expectation,
fencedAt = requireNotNull(
repository.currentAuthorizedOrderSubmissionContext()
?.submission
?.fencedAt
),
now = Instant.now()
)
return when (reconciliation) {
is PinduoduoOrderReconciliation.Matched -> {
val screenshot = driver.captureOrderList()
if (screenshot == null) {
repository.markOrderSubmissionManualReview(
"EVIDENCE_UNAVAILABLE"
)
false
} else {
val bytes = screenshot.pngBytes
val order = reconciliation.order
repository.reconcileOrderSubmission(
OrderReconciliationDraft(
platformOrderNo = order.platformOrderNo,
platformOrderedAt =
order.platformOrderedAt,
platformOrderStatus = order.status,
observedTitle = order.observedTitle,
selectedSku = context.command.originalSku,
quantity = order.quantity,
totalPriceCents = order.totalPriceCents,
orderListPng = bytes,
evidenceSha256 =
PinduoduoEvidenceHash.sha256(bytes)
)
)
}
}
is PinduoduoOrderReconciliation.ManualReview -> {
if (
context.submission?.status !=
OrderSubmissionStatus.MANUAL_REVIEW
) {
repository.markOrderSubmissionManualReview(
reconciliation.reasonCode
)
}
false
}
}
}
}
@@ -111,6 +111,39 @@ interface ProcurementRemoteApi {
idempotencyKey: String
): RemoteOrderDryRun
suspend fun startOrderSubmission(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String,
command: PendingOrderCommand,
dryRun: PendingOrderDryRun,
idempotencyKey: String
): RemoteOrderSubmission
suspend fun reconcileOrderSubmission(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String,
command: PendingOrderCommand,
submission: PendingOrderSubmission,
draft: OrderReconciliationDraft,
evidenceAssetId: String,
idempotencyKey: String
): RemoteOrderSubmission
suspend fun markOrderSubmissionManualReview(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String,
command: PendingOrderCommand,
submission: PendingOrderSubmission,
reasonCode: String,
idempotencyKey: String
): RemoteOrderSubmission
suspend fun uploadExecutionOutboxItem(
session: ProcurementSession,
task: RemotePurchaseTask,
@@ -532,6 +565,115 @@ class ProcurementApiClient(
return parseOrderDryRun(json.getJSONObject("dry_run"))
}
override suspend fun startOrderSubmission(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String,
command: PendingOrderCommand,
dryRun: PendingOrderDryRun,
idempotencyKey: String
): RemoteOrderSubmission {
val payload = JSONObject()
.put("device_id", session.deviceId)
.put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration)
.put("command_id", command.id)
.put("command_sha256", command.commandSha256)
.put("dry_run_id", requireNotNull(dryRun.remoteId))
.put(
"dry_run_evidence_sha256",
requireNotNull(dryRun.evidenceSha256)
)
.put("observed_title", requireNotNull(dryRun.observedTitle))
.put("selected_sku", requireNotNull(dryRun.selectedSku))
.put("quantity", requireNotNull(dryRun.quantity))
.put("unit_price_cents", requireNotNull(dryRun.unitPriceCents))
.put("total_price_cents", requireNotNull(dryRun.totalPriceCents))
val json = executeJson(
authorizedRequest(
session,
"/api/v1/tasks/${task.id}/order-submissions/start"
)
.header(CLAIM_TOKEN_HEADER, claimToken)
.header(IDEMPOTENCY_HEADER, idempotencyKey)
.post(payload.jsonBody())
.build()
)
return parseOrderSubmission(json.getJSONObject("submission"))
}
override suspend fun reconcileOrderSubmission(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String,
command: PendingOrderCommand,
submission: PendingOrderSubmission,
draft: OrderReconciliationDraft,
evidenceAssetId: String,
idempotencyKey: String
): RemoteOrderSubmission {
val payload = JSONObject()
.put("device_id", session.deviceId)
.put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration)
.put("command_id", command.id)
.put("command_sha256", command.commandSha256)
.put("platform_order_no", draft.platformOrderNo)
.put("platform_ordered_at", draft.platformOrderedAt)
.put("platform_order_status", draft.platformOrderStatus)
.put("observed_title", draft.observedTitle)
.put("selected_sku", draft.selectedSku)
.put("quantity", draft.quantity)
.put("total_price_cents", draft.totalPriceCents)
.put("evidence_asset_id", evidenceAssetId)
.put("evidence_sha256", draft.evidenceSha256)
val json = executeJson(
authorizedRequest(
session,
"/api/v1/tasks/${task.id}/order-submissions/" +
"${requireNotNull(submission.remoteId)}/reconcile"
)
.header(CLAIM_TOKEN_HEADER, claimToken)
.header(IDEMPOTENCY_HEADER, idempotencyKey)
.post(payload.jsonBody())
.build()
)
return parseOrderSubmission(json.getJSONObject("submission"))
}
override suspend fun markOrderSubmissionManualReview(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String,
command: PendingOrderCommand,
submission: PendingOrderSubmission,
reasonCode: String,
idempotencyKey: String
): RemoteOrderSubmission {
val payload = JSONObject()
.put("device_id", session.deviceId)
.put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration)
.put("command_id", command.id)
.put("command_sha256", command.commandSha256)
.put("reason_code", reasonCode)
val json = executeJson(
authorizedRequest(
session,
"/api/v1/tasks/${task.id}/order-submissions/" +
"${requireNotNull(submission.remoteId)}/manual-review"
)
.header(CLAIM_TOKEN_HEADER, claimToken)
.header(IDEMPOTENCY_HEADER, idempotencyKey)
.post(payload.jsonBody())
.build()
)
return parseOrderSubmission(json.getJSONObject("submission"))
}
private suspend fun executeTransition(
session: ProcurementSession,
task: RemotePurchaseTask,
@@ -695,7 +837,58 @@ class ProcurementApiClient(
id = json.getString("id"),
commandId = json.getString("command_id"),
commandSha256 = json.getString("command_sha256"),
status = json.getString("status")
status = json.getString("status"),
observedTitle = json.optionalString("observed_title"),
selectedSku = json.optionalString("selected_sku"),
quantity = if (json.has("quantity") && !json.isNull("quantity")) {
json.getInt("quantity")
} else {
null
},
unitPriceCents =
if (
json.has("unit_price_cents") &&
!json.isNull("unit_price_cents")
) {
json.getLong("unit_price_cents")
} else {
null
},
totalPriceCents =
if (
json.has("total_price_cents") &&
!json.isNull("total_price_cents")
) {
json.getLong("total_price_cents")
} else {
null
},
evidenceSha256 = json.optionalString("evidence_sha256")
)
private fun parseOrderSubmission(json: JSONObject): RemoteOrderSubmission =
RemoteOrderSubmission(
id = json.getString("id"),
commandId = json.getString("command_id"),
dryRunId = json.getString("dry_run_id"),
commandSha256 = json.getString("command_sha256"),
dryRunEvidenceSha256 =
json.getString("dry_run_evidence_sha256"),
status = json.getString("status"),
expectedTitle = json.getString("expected_title"),
expectedSku = json.getString("expected_sku"),
expectedQuantity = json.getInt("expected_quantity"),
expectedUnitPriceCents =
json.getLong("expected_unit_price_cents"),
expectedTotalPriceCents =
json.getLong("expected_total_price_cents"),
platformOrderNo = json.optionalString("platform_order_no"),
platformOrderedAt =
json.optionalString("platform_ordered_at"),
platformOrderStatus =
json.optionalString("platform_order_status"),
manualReasonCode = json.optionalString("manual_reason_code"),
fencedAt = json.getString("fenced_at")
)
private fun taskOrderCommandId(payload: String): String? =
@@ -43,6 +43,10 @@ class ProcurementExecutionService : Service() {
applicationContext,
repository
)
val orderSubmission = OrderSubmissionCoordinator(
applicationContext,
repository
)
while (isActive) {
val decision = repository.synchronizeRunning()
if (decision == ExecutionSyncDecision.STOP) {
@@ -62,6 +66,23 @@ class ProcurementExecutionService : Service() {
)
}
}
if (
repository.shouldRunOrderSubmission &&
readiness.snapshot().canStartProbe
) {
runCatching { orderSubmission.runOnce() }
.onFailure { error ->
Log.w(
TAG,
"Single order submission stopped safely",
error
)
}
}
if (repository.hasReconciledOrder) {
stopSelf()
break
}
delay(HEARTBEAT_INTERVAL_MS)
}
}
@@ -13,6 +13,9 @@ enum class ProcurementPhase {
ORDER_AUTHORIZED,
ORDER_DRY_RUN_RUNNING,
ORDER_DRY_RUN_READY,
ORDER_SUBMISSION_RECONCILING,
ORDER_SUBMISSION_MANUAL_REVIEW,
ORDER_RECONCILED,
AUTHORIZATION_EXPIRED
}
@@ -90,7 +93,8 @@ data class PersistedProcurementState(
val execution: RunningExecution? = null,
val outbox: List<ExecutionOutboxItem> = emptyList(),
val orderCommand: PendingOrderCommand? = null,
val orderDryRun: PendingOrderDryRun? = null
val orderDryRun: PendingOrderDryRun? = null,
val orderSubmission: PendingOrderSubmission? = null
)
data class OrderCommandCandidate(
@@ -158,7 +162,13 @@ data class RemoteOrderDryRun(
val id: String,
val commandId: String,
val commandSha256: String,
val status: String
val status: String,
val observedTitle: String? = null,
val selectedSku: String? = null,
val quantity: Int? = null,
val unitPriceCents: Long? = null,
val totalPriceCents: Long? = null,
val evidenceSha256: String? = null
)
data class OrderDryRunReadyDraft(
@@ -181,6 +191,118 @@ data class AuthorizedOrderDryRunContext(
val maxBudgetCents: Long?
)
enum class OrderSubmissionStatus {
FENCE_INTENT_SAVED,
CLICK_ASSUMED,
RECONCILING,
MANUAL_REVIEW,
RECONCILED
}
data class OrderSubmissionFenceTransition(
val status: OrderSubmissionStatus,
val mayClickInCurrentCall: Boolean
)
object OrderSubmissionRecoveryPolicy {
fun afterFenceResponse(
persistedBeforeRequest: OrderSubmissionStatus,
remoteStatus: String,
intentCreatedInCurrentCall: Boolean
): OrderSubmissionFenceTransition =
when (remoteStatus) {
"RECONCILED" -> OrderSubmissionFenceTransition(
OrderSubmissionStatus.RECONCILED,
false
)
"MANUAL_REVIEW" -> OrderSubmissionFenceTransition(
OrderSubmissionStatus.MANUAL_REVIEW,
false
)
"FENCED" -> OrderSubmissionFenceTransition(
OrderSubmissionStatus.CLICK_ASSUMED,
intentCreatedInCurrentCall &&
persistedBeforeRequest ==
OrderSubmissionStatus.FENCE_INTENT_SAVED
)
else -> throw IllegalArgumentException(
"unsupported remote submission status"
)
}
fun mayResumeClick(status: OrderSubmissionStatus): Boolean =
when (status) {
OrderSubmissionStatus.FENCE_INTENT_SAVED,
OrderSubmissionStatus.CLICK_ASSUMED,
OrderSubmissionStatus.RECONCILING,
OrderSubmissionStatus.MANUAL_REVIEW,
OrderSubmissionStatus.RECONCILED -> false
}
}
data class PendingOrderSubmission(
val commandId: String,
val commandSha256: String,
val dryRunId: String,
val dryRunEvidenceSha256: String,
val status: OrderSubmissionStatus,
val startIdempotencyKey: String,
val evidenceIdempotencyKey: String,
val reconcileIdempotencyKey: String,
val manualReviewIdempotencyKey: String,
val remoteId: String? = null,
val fencedAt: String? = null,
val platformOrderNo: String? = null,
val platformOrderedAt: String? = null,
val platformOrderStatus: String? = null,
val reconciliationEvidenceRelativePath: String? = null,
val reconciliationEvidenceSha256: String? = null,
val manualReasonCode: String? = null
)
data class RemoteOrderSubmission(
val id: String,
val commandId: String,
val dryRunId: String,
val commandSha256: String,
val dryRunEvidenceSha256: String,
val status: String,
val expectedTitle: String,
val expectedSku: String,
val expectedQuantity: Int,
val expectedUnitPriceCents: Long,
val expectedTotalPriceCents: Long,
val platformOrderNo: String?,
val platformOrderedAt: String?,
val platformOrderStatus: String?,
val manualReasonCode: String?,
val fencedAt: String
)
data class AuthorizedOrderSubmissionContext(
val task: RemotePurchaseTask,
val command: PendingOrderCommand,
val dryRun: PendingOrderDryRun,
val submission: PendingOrderSubmission?
)
data class ArmedOrderSubmission(
val context: AuthorizedOrderSubmissionContext,
val clickNow: Boolean
)
data class OrderReconciliationDraft(
val platformOrderNo: String,
val platformOrderedAt: String,
val platformOrderStatus: String,
val observedTitle: String,
val selectedSku: String,
val quantity: Int,
val totalPriceCents: Long,
val orderListPng: ByteArray,
val evidenceSha256: String
)
enum class ExecutionMode {
MANUAL_FIRST,
AI_ASSISTED
@@ -223,6 +345,7 @@ data class ProcurementUiState(
val execution: RunningExecution? = null,
val orderCommand: PendingOrderCommand? = null,
val orderDryRun: PendingOrderDryRun? = null,
val orderSubmission: PendingOrderSubmission? = null,
val authenticationRequired: Boolean = false,
val busy: Boolean = false,
val backendOnline: Boolean? = null,
@@ -42,8 +42,17 @@ class ProcurementRepository(
val uiState: StateFlow<ProcurementUiState> = _uiState.asStateFlow()
val hasRunningExecution: Boolean
get() = storageFailure == null &&
persisted.execution?.safetyStopped == false
get() {
val submission = persisted.orderSubmission
val reconciliationPending =
submission?.remoteId != null &&
submission.status != OrderSubmissionStatus.RECONCILED
return storageFailure == null &&
(
persisted.execution?.safetyStopped == false ||
reconciliationPending
)
}
val canCollectCandidates: Boolean
get() {
@@ -72,6 +81,27 @@ class ProcurementRepository(
(dryRun?.attemptCount ?: 0) < MAX_ORDER_DRY_RUN_ATTEMPTS
}
val shouldRunOrderSubmission: Boolean
get() {
val execution = persisted.execution ?: return false
val dryRun = persisted.orderDryRun ?: return false
val submission = persisted.orderSubmission
val reconciliationPending =
submission?.remoteId != null &&
submission.status != OrderSubmissionStatus.RECONCILED
return storageFailure == null &&
dryRun.status == OrderDryRunStatus.READY &&
submission?.status != OrderSubmissionStatus.RECONCILED &&
(
reconciliationPending ||
(!execution.safetyStopped && !execution.isExpired())
)
}
val hasReconciledOrder: Boolean
get() = persisted.orderSubmission?.status ==
OrderSubmissionStatus.RECONCILED
suspend fun login(input: LoginInput): Boolean = operation {
require(input.password.isNotEmpty()) { "请输入采购员密码" }
val normalizedUrl = BackendEndpointPolicy.normalize(
@@ -305,7 +335,21 @@ class ProcurementRepository(
if (claim == null || task == null || execution == null) {
return@withLock ExecutionSyncDecision.STOP
}
if (execution.isExpired() && !execution.safetyStopped) {
if (persisted.orderSubmission?.status ==
OrderSubmissionStatus.RECONCILED
) {
return@withLock ExecutionSyncDecision.STOP
}
val reconciliationPending =
persisted.orderSubmission?.let { submission ->
submission.remoteId != null &&
submission.status != OrderSubmissionStatus.RECONCILED
} == true
if (
execution.isExpired() &&
!execution.safetyStopped &&
!reconciliationPending
) {
execution = execution.copy(
currentStep = SAFE_STOPPED_STEP,
safetyStopped = true
@@ -317,7 +361,11 @@ class ProcurementRepository(
error = "离线执行授权已到期,自动化已安全停止"
)
}
if (execution.safetyStopped && !allowSafetyStoppedSync) {
if (
execution.safetyStopped &&
!allowSafetyStoppedSync &&
!reconciliationPending
) {
return@withLock ExecutionSyncDecision.STOP
}
val session = persisted.session
@@ -341,6 +389,16 @@ class ProcurementRepository(
return@withLock ExecutionSyncDecision.STOP
}
execution = persisted.execution ?: return@withLock ExecutionSyncDecision.STOP
if (
reconciliationPending &&
(execution.safetyStopped || execution.isExpired())
) {
publish(
backendOnline = true,
message = "提交围栏已生效;授权到期后仅继续订单对账"
)
return@withLock ExecutionSyncDecision.CONTINUE
}
if (execution.safetyStopped) {
publish(
backendOnline = true,
@@ -654,6 +712,384 @@ class ProcurementRepository(
true
} ?: false
suspend fun currentAuthorizedOrderSubmissionContext():
AuthorizedOrderSubmissionContext? = operation {
val task = requireCurrentTask()
val execution = requireActiveExecution()
val command = requireNotNull(persisted.orderCommand) {
"尚未收到后台下单命令"
}
val dryRun = requireNotNull(persisted.orderDryRun) {
"订单核验尚未完成"
}
require(command.acknowledged) { "下单命令尚未安全确认" }
require(dryRun.status == OrderDryRunStatus.READY) {
"本地和后台订单核验尚未同时就绪"
}
val existingSubmission = persisted.orderSubmission
val reconciliationPending = existingSubmission?.remoteId != null
require(
reconciliationPending ||
(!execution.safetyStopped && !execution.isExpired())
) {
"执行授权已到期,不能提交订单"
}
require(
command.taskId == task.id &&
command.executionId == execution.id &&
dryRun.commandId == command.id &&
dryRun.commandSha256 == command.commandSha256 &&
dryRun.remoteId != null &&
dryRun.evidenceSha256 != null
) { "订单核验与后台授权不一致" }
existingSubmission?.let { submission ->
require(
submission.commandId == command.id &&
submission.commandSha256 == command.commandSha256 &&
submission.dryRunId == dryRun.remoteId &&
submission.dryRunEvidenceSha256 ==
dryRun.evidenceSha256
) { "本地单次提交围栏与订单核验不一致" }
}
AuthorizedOrderSubmissionContext(
task = task,
command = command,
dryRun = dryRun,
submission = existingSubmission
)
}
suspend fun armOrderSubmission(): ArmedOrderSubmission? = operation {
val session = requireValidSession()
val claim = requireNotNull(persisted.claim)
val task = requireCurrentTask()
val execution = requireActiveExecution()
val command = requireNotNull(persisted.orderCommand)
var dryRun = requireNotNull(persisted.orderDryRun)
require(dryRun.status == OrderDryRunStatus.READY) {
"订单核验尚未在后台就绪"
}
require(!execution.safetyStopped && !execution.isExpired()) {
"执行授权已到期,不能创建提交围栏"
}
val refreshedDryRun = api.startOrderDryRun(
session,
task,
execution,
claim.token,
command,
dryRun.startIdempotencyKey
)
require(
refreshedDryRun.id == dryRun.remoteId &&
refreshedDryRun.commandId == command.id &&
refreshedDryRun.commandSha256 == command.commandSha256 &&
refreshedDryRun.status == "READY" &&
refreshedDryRun.observedTitle != null &&
normalizeTitle(refreshedDryRun.observedTitle) ==
normalizeTitle(requireNotNull(dryRun.observedTitle)) &&
refreshedDryRun.selectedSku == dryRun.selectedSku &&
refreshedDryRun.quantity == dryRun.quantity &&
refreshedDryRun.unitPriceCents == dryRun.unitPriceCents &&
refreshedDryRun.totalPriceCents ==
dryRun.totalPriceCents &&
refreshedDryRun.evidenceSha256?.matches(
SHA256_PATTERN
) == true
) { "后台 READY 核验记录与本地状态不一致" }
dryRun = dryRun.copy(
evidenceSha256 = refreshedDryRun.evidenceSha256
)
persisted = persisted.copy(orderDryRun = dryRun)
store.save(persisted)
var submission = persisted.orderSubmission
var intentCreatedInCurrentCall = false
if (submission == null) {
intentCreatedInCurrentCall = true
submission = PendingOrderSubmission(
commandId = command.id,
commandSha256 = command.commandSha256,
dryRunId = requireNotNull(dryRun.remoteId),
dryRunEvidenceSha256 =
requireNotNull(dryRun.evidenceSha256),
status = OrderSubmissionStatus.FENCE_INTENT_SAVED,
startIdempotencyKey = newOpaqueSecret(),
evidenceIdempotencyKey = newOpaqueSecret(),
reconcileIdempotencyKey = newOpaqueSecret(),
manualReviewIdempotencyKey = newOpaqueSecret()
)
persisted = persisted.copy(orderSubmission = submission)
store.save(persisted)
}
require(
submission.commandId == command.id &&
submission.commandSha256 == command.commandSha256 &&
submission.dryRunId == dryRun.remoteId &&
submission.dryRunEvidenceSha256 == dryRun.evidenceSha256
) { "提交围栏与当前核验不一致" }
val clickNow =
submission.status == OrderSubmissionStatus.FENCE_INTENT_SAVED
if (!clickNow) {
return@operation ArmedOrderSubmission(
AuthorizedOrderSubmissionContext(
task,
command,
dryRun,
submission
),
clickNow = false
)
}
val remote = api.startOrderSubmission(
session,
task,
execution,
claim.token,
command,
dryRun,
submission.startIdempotencyKey
)
require(
remote.commandId == command.id &&
remote.dryRunId == dryRun.remoteId &&
remote.commandSha256 == command.commandSha256 &&
remote.dryRunEvidenceSha256 == dryRun.evidenceSha256 &&
remote.status in setOf(
"FENCED",
"MANUAL_REVIEW",
"RECONCILED"
) &&
normalizeTitle(remote.expectedTitle) ==
normalizeTitle(requireNotNull(dryRun.observedTitle)) &&
remote.expectedSku == dryRun.selectedSku &&
remote.expectedQuantity == dryRun.quantity &&
remote.expectedUnitPriceCents == dryRun.unitPriceCents &&
remote.expectedTotalPriceCents == dryRun.totalPriceCents
) { "后台返回的单次提交围栏与本地核验不一致" }
val transition = OrderSubmissionRecoveryPolicy.afterFenceResponse(
submission.status,
remote.status,
intentCreatedInCurrentCall
)
val localStatus = transition.status
submission = submission.copy(
status = localStatus,
remoteId = remote.id,
fencedAt = remote.fencedAt,
platformOrderNo = remote.platformOrderNo,
platformOrderedAt = remote.platformOrderedAt,
platformOrderStatus = remote.platformOrderStatus,
manualReasonCode = remote.manualReasonCode
)
persisted = persisted.copy(
execution = execution.copy(
currentStep = ORDER_SUBMISSION_FENCED_STEP
),
orderSubmission = submission
)
saveAndPublish(
backendOnline = true,
message = "单次提交围栏已保存;此后不会重复点击"
)
ArmedOrderSubmission(
AuthorizedOrderSubmissionContext(
task,
command,
dryRun,
submission
),
clickNow = transition.mayClickInCurrentCall
)
}
suspend fun markOrderSubmissionReconciling(): Boolean = operation {
val execution = requireActiveExecution()
val submission = requireNotNull(persisted.orderSubmission)
require(submission.status == OrderSubmissionStatus.CLICK_ASSUMED) {
"提交点击资格已经消费"
}
persisted = persisted.copy(
execution = execution.copy(
currentStep = ORDER_SUBMISSION_RECONCILING_STEP
),
orderSubmission = submission.copy(
status = OrderSubmissionStatus.RECONCILING
)
)
saveAndPublish(
backendOnline = _uiState.value.backendOnline,
message = "提交结果不作假设,正在订单列表对账"
)
true
} ?: false
suspend fun reconcileOrderSubmission(
draft: OrderReconciliationDraft
): Boolean = operation {
val session = requireValidSession()
val claim = requireNotNull(persisted.claim)
val task = requireCurrentTask()
val execution = requireActiveExecution()
val command = requireNotNull(persisted.orderCommand)
var submission = requireNotNull(persisted.orderSubmission)
require(
submission.status in setOf(
OrderSubmissionStatus.RECONCILING,
OrderSubmissionStatus.MANUAL_REVIEW
)
) { "当前提交状态不能回传订单对账" }
require(
draft.platformOrderStatus == "PENDING_PAYMENT" &&
draft.platformOrderNo.matches(PLATFORM_ORDER_NO_PATTERN) &&
normalizeTitle(draft.observedTitle) ==
normalizeTitle(requireNotNull(persisted.orderDryRun?.observedTitle)) &&
draft.selectedSku == persisted.orderDryRun?.selectedSku &&
draft.quantity == persisted.orderDryRun?.quantity &&
draft.totalPriceCents ==
persisted.orderDryRun?.totalPriceCents &&
draft.orderListPng.isNotEmpty() &&
sha256(draft.orderListPng) == draft.evidenceSha256
) { "订单列表对账证据与提交围栏不一致" }
val evidenceID = UUID.randomUUID().toString()
val relativePath =
submission.reconciliationEvidenceRelativePath
?: "$OUTBOX_DIRECTORY/order-$evidenceID.png"
if (submission.reconciliationEvidenceRelativePath == null) {
persistEvidenceLocked(
File(relativePath).nameWithoutExtension,
draft.orderListPng
)
submission = submission.copy(
status = OrderSubmissionStatus.RECONCILING,
reconciliationEvidenceRelativePath = relativePath,
reconciliationEvidenceSha256 = draft.evidenceSha256,
platformOrderNo = draft.platformOrderNo,
platformOrderedAt = draft.platformOrderedAt,
platformOrderStatus = draft.platformOrderStatus
)
persisted = persisted.copy(orderSubmission = submission)
store.save(persisted)
}
val evidenceFile = File(
appContext.filesDir,
requireNotNull(submission.reconciliationEvidenceRelativePath)
)
require(evidenceFile.isFile) { "订单对账截图文件缺失" }
val uploaded = api.uploadExecutionOutboxItem(
session,
task,
execution,
claim.token,
ExecutionOutboxItem(
id = File(relativePath).nameWithoutExtension,
type = ExecutionOutboxType.EVIDENCE,
idempotencyKey = submission.evidenceIdempotencyKey,
payload = "{}",
evidenceRelativePath = relativePath
),
evidenceFile.readBytes()
)
val remoteEvidenceID = requireNotNull(uploaded.evidenceAssetId)
val remoteEvidenceSHA = requireNotNull(uploaded.evidenceSha256)
val remote = api.reconcileOrderSubmission(
session,
task,
execution,
claim.token,
command,
submission,
draft.copy(evidenceSha256 = remoteEvidenceSHA),
remoteEvidenceID,
submission.reconcileIdempotencyKey
)
require(
remote.id == submission.remoteId &&
remote.status == "RECONCILED" &&
remote.platformOrderNo == draft.platformOrderNo &&
remote.platformOrderStatus == "PENDING_PAYMENT"
) { "后台订单对账结果不一致" }
evidenceFile.delete()
submission = submission.copy(
status = OrderSubmissionStatus.RECONCILED,
platformOrderNo = remote.platformOrderNo,
platformOrderedAt = remote.platformOrderedAt,
platformOrderStatus = remote.platformOrderStatus,
reconciliationEvidenceRelativePath = null,
reconciliationEvidenceSha256 = remoteEvidenceSHA,
manualReasonCode = null
)
persisted = persisted.copy(
execution = execution.copy(currentStep = ORDER_RECONCILED_STEP),
orderSubmission = submission
)
saveAndPublish(
backendOnline = true,
message = "待付款订单已唯一对账并回传后台"
)
true
} ?: false
suspend fun markOrderSubmissionManualReview(
reasonCode: String
): Boolean = operation {
require(reasonCode in ORDER_SUBMISSION_MANUAL_REASONS) {
"订单人工对账理由无效"
}
val session = requireValidSession()
val claim = requireNotNull(persisted.claim)
val task = requireCurrentTask()
val execution = requireActiveExecution()
val command = requireNotNull(persisted.orderCommand)
var submission = requireNotNull(persisted.orderSubmission)
if (submission.status == OrderSubmissionStatus.MANUAL_REVIEW) {
return@operation true
}
require(
submission.status in setOf(
OrderSubmissionStatus.CLICK_ASSUMED,
OrderSubmissionStatus.RECONCILING
)
) { "当前状态不能转人工对账" }
val remote = api.markOrderSubmissionManualReview(
session,
task,
execution,
claim.token,
command,
submission,
reasonCode,
submission.manualReviewIdempotencyKey
)
require(
remote.id == submission.remoteId &&
remote.status == "MANUAL_REVIEW"
) { "后台人工对账状态无效" }
submission = submission.copy(
status = OrderSubmissionStatus.MANUAL_REVIEW,
manualReasonCode = remote.manualReasonCode ?: reasonCode
)
persisted = persisted.copy(
execution = execution.copy(
currentStep = ORDER_SUBMISSION_MANUAL_REVIEW_STEP
),
orderSubmission = submission
)
saveAndPublish(
backendOnline = true,
message = "未唯一找到新订单,保持禁止重复提交并转人工对账"
)
true
} ?: false
suspend fun reportOrderSubmissionBlocked(message: String) {
mutex.withLock {
publish(
backendOnline = _uiState.value.backendOnline,
error = message
)
}
}
suspend fun queueCandidateBatch(
batch: ExecutionCandidateBatchDraft,
evidence: List<ExecutionEvidenceDraft>
@@ -1003,7 +1439,8 @@ class ProcurementRepository(
execution = null,
outbox = emptyList(),
orderCommand = null,
orderDryRun = null
orderDryRun = null,
orderSubmission = null
)
}
@@ -1204,7 +1641,15 @@ class ProcurementRepository(
require(
remote.commandId == command.id &&
remote.commandSha256 == command.commandSha256 &&
remote.status == "READY"
remote.status == "READY" &&
!remote.observedTitle.isNullOrBlank() &&
!remote.selectedSku.isNullOrBlank() &&
remote.quantity != null &&
remote.unitPriceCents != null &&
remote.totalPriceCents != null &&
remote.evidenceSha256?.matches(
SHA256_PATTERN
) == true
) { "后台订单核验状态无效" }
persisted = persisted.copy(
execution = execution.copy(
@@ -1212,7 +1657,13 @@ class ProcurementRepository(
),
orderDryRun = requireNotNull(persisted.orderDryRun).copy(
status = OrderDryRunStatus.READY,
remoteId = remote.id
remoteId = remote.id,
observedTitle = remote.observedTitle,
selectedSku = remote.selectedSku,
quantity = remote.quantity,
unitPriceCents = remote.unitPriceCents,
totalPriceCents = remote.totalPriceCents,
evidenceSha256 = remote.evidenceSha256
)
)
}
@@ -1401,6 +1852,14 @@ class ProcurementRepository(
execution != null &&
(execution.safetyStopped || execution.isExpired()) ->
ProcurementPhase.AUTHORIZATION_EXPIRED
persisted.orderSubmission?.status ==
OrderSubmissionStatus.RECONCILED ->
ProcurementPhase.ORDER_RECONCILED
persisted.orderSubmission?.status ==
OrderSubmissionStatus.MANUAL_REVIEW ->
ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW
persisted.orderSubmission != null ->
ProcurementPhase.ORDER_SUBMISSION_RECONCILING
persisted.orderDryRun?.status == OrderDryRunStatus.READY ->
ProcurementPhase.ORDER_DRY_RUN_READY
persisted.orderDryRun != null ->
@@ -1432,6 +1891,7 @@ class ProcurementRepository(
execution = execution,
orderCommand = persisted.orderCommand,
orderDryRun = persisted.orderDryRun,
orderSubmission = persisted.orderSubmission,
authenticationRequired = session?.isValid() != true
)
}
@@ -1487,6 +1947,13 @@ class ProcurementRepository(
private const val ORDER_AUTHORIZED_STEP = "ORDER_AUTHORIZED"
private const val ORDER_DRY_RUN_STEP = "ORDER_DRY_RUN"
private const val ORDER_DRY_RUN_READY_STEP = "ORDER_DRY_RUN_READY"
private const val ORDER_SUBMISSION_FENCED_STEP =
"ORDER_SUBMISSION_FENCED"
private const val ORDER_SUBMISSION_RECONCILING_STEP =
"ORDER_SUBMISSION_RECONCILING"
private const val ORDER_SUBMISSION_MANUAL_REVIEW_STEP =
"ORDER_SUBMISSION_MANUAL_REVIEW"
private const val ORDER_RECONCILED_STEP = "ORDER_RECONCILED"
private const val SAFE_STOPPED_STEP = "SAFE_STOPPED"
private const val MAX_ORDER_DRY_RUN_ATTEMPTS = 5
private const val MAX_REFERENCE_DIMENSION = 4_096
@@ -1496,6 +1963,15 @@ class ProcurementRepository(
private val BUDGET_PATTERN =
Regex("^(0|[1-9][0-9]{0,9})(\\.[0-9]{1,2})?$")
private val TITLE_SPACE_PATTERN = Regex("[\\s\\p{Punct}]+")
private val PLATFORM_ORDER_NO_PATTERN = Regex("^[0-9]{8,40}$")
private val ORDER_SUBMISSION_MANUAL_REASONS = setOf(
"ORDER_NOT_FOUND",
"ORDER_AMBIGUOUS",
"ORDER_FIELDS_INCOMPLETE",
"ORDER_PAGE_UNKNOWN",
"RISK_OR_PAYMENT_BOUNDARY",
"EVIDENCE_UNAVAILABLE"
)
private val COMPLETE_OUTCOMES = setOf(
"CANDIDATE_ACCEPTED",
"CANDIDATE_REJECTED",
@@ -141,6 +141,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
state.orderDryRun?.let { dryRun ->
put("order_dry_run", encodeOrderDryRun(dryRun))
}
state.orderSubmission?.let { submission ->
put("order_submission", encodeOrderSubmission(submission))
}
}
private fun decodeState(json: JSONObject): PersistedProcurementState =
@@ -230,6 +233,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
},
orderDryRun = json.optionalObject("order_dry_run")?.let {
decodeOrderDryRun(it)
},
orderSubmission = json.optionalObject("order_submission")?.let {
decodeOrderSubmission(it)
}
)
@@ -288,6 +294,82 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
evidenceSha256 = json.optionalString("evidence_sha256")
)
private fun encodeOrderSubmission(
submission: PendingOrderSubmission
): JSONObject =
JSONObject().apply {
put("command_id", submission.commandId)
put("command_sha256", submission.commandSha256)
put("dry_run_id", submission.dryRunId)
put(
"dry_run_evidence_sha256",
submission.dryRunEvidenceSha256
)
put("status", submission.status.name)
put("start_idempotency_key", submission.startIdempotencyKey)
put(
"evidence_idempotency_key",
submission.evidenceIdempotencyKey
)
put(
"reconcile_idempotency_key",
submission.reconcileIdempotencyKey
)
put(
"manual_review_idempotency_key",
submission.manualReviewIdempotencyKey
)
putNullable("remote_id", submission.remoteId)
putNullable("fenced_at", submission.fencedAt)
putNullable("platform_order_no", submission.platformOrderNo)
putNullable("platform_ordered_at", submission.platformOrderedAt)
putNullable(
"platform_order_status",
submission.platformOrderStatus
)
putNullable(
"reconciliation_evidence_relative_path",
submission.reconciliationEvidenceRelativePath
)
putNullable(
"reconciliation_evidence_sha256",
submission.reconciliationEvidenceSha256
)
putNullable("manual_reason_code", submission.manualReasonCode)
}
private fun decodeOrderSubmission(
json: JSONObject
): PendingOrderSubmission =
PendingOrderSubmission(
commandId = json.getString("command_id"),
commandSha256 = json.getString("command_sha256"),
dryRunId = json.getString("dry_run_id"),
dryRunEvidenceSha256 =
json.getString("dry_run_evidence_sha256"),
status = OrderSubmissionStatus.valueOf(json.getString("status")),
startIdempotencyKey = json.getString("start_idempotency_key"),
evidenceIdempotencyKey =
json.getString("evidence_idempotency_key"),
reconcileIdempotencyKey =
json.getString("reconcile_idempotency_key"),
manualReviewIdempotencyKey =
json.getString("manual_review_idempotency_key"),
remoteId = json.optionalString("remote_id"),
fencedAt = json.optionalString("fenced_at"),
platformOrderNo = json.optionalString("platform_order_no"),
platformOrderedAt = json.optionalString("platform_ordered_at"),
platformOrderStatus =
json.optionalString("platform_order_status"),
reconciliationEvidenceRelativePath =
json.optionalString(
"reconciliation_evidence_relative_path"
),
reconciliationEvidenceSha256 =
json.optionalString("reconciliation_evidence_sha256"),
manualReasonCode = json.optionalString("manual_reason_code")
)
private fun encodeOrderCommand(command: PendingOrderCommand): JSONObject =
JSONObject().apply {
put("id", command.id)
@@ -200,6 +200,9 @@ fun ProcurementScreen(
ProcurementPhase.ORDER_AUTHORIZED,
ProcurementPhase.ORDER_DRY_RUN_RUNNING,
ProcurementPhase.ORDER_DRY_RUN_READY,
ProcurementPhase.ORDER_SUBMISSION_RECONCILING,
ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW,
ProcurementPhase.ORDER_RECONCILED,
ProcurementPhase.AUTHORIZATION_EXPIRED -> {
if (state.authenticationRequired) {
item(key = "procurement-login") {
@@ -497,12 +500,19 @@ private fun ExecutionDetails(state: ProcurementUiState) {
"正在重新定位商品并核验规格、数量和金额"
ProcurementPhase.ORDER_DRY_RUN_READY ->
"订单已核验,等待单次提交"
ProcurementPhase.ORDER_SUBMISSION_RECONCILING ->
"提交围栏已消费,只允许查看订单列表对账"
ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW ->
"订单未能唯一对账,保持禁止重复提交"
ProcurementPhase.ORDER_RECONCILED ->
"待付款订单已回传后台,请人工确认付款"
else -> "订单提交保持禁用"
},
color = if (
state.phase in setOf(
ProcurementPhase.ORDER_AUTHORIZED,
ProcurementPhase.ORDER_DRY_RUN_READY
ProcurementPhase.ORDER_DRY_RUN_READY,
ProcurementPhase.ORDER_RECONCILED
)
) {
colors.success
@@ -545,7 +555,10 @@ private fun phaseColor(state: ProcurementUiState) =
ProcurementPhase.WAITING_ADMIN_CONFIRMATION,
ProcurementPhase.ORDER_AUTHORIZED,
ProcurementPhase.ORDER_DRY_RUN_RUNNING,
ProcurementPhase.ORDER_DRY_RUN_READY -> BaoziTheme.colors.success
ProcurementPhase.ORDER_DRY_RUN_READY,
ProcurementPhase.ORDER_SUBMISSION_RECONCILING,
ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW,
ProcurementPhase.ORDER_RECONCILED -> BaoziTheme.colors.success
else -> BaoziTheme.colors.textSecondary
}
@@ -559,5 +572,8 @@ private fun phaseLabel(state: ProcurementUiState): String =
ProcurementPhase.ORDER_AUTHORIZED -> "后台商品授权已同步"
ProcurementPhase.ORDER_DRY_RUN_RUNNING -> "正在核验订单"
ProcurementPhase.ORDER_DRY_RUN_READY -> "订单已核验,等待提交"
ProcurementPhase.ORDER_SUBMISSION_RECONCILING -> "订单提交后正在对账"
ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW -> "等待人工对账"
ProcurementPhase.ORDER_RECONCILED -> "待付款订单已回传"
ProcurementPhase.AUTHORIZATION_EXPIRED -> "授权到期,已停止"
}
@@ -0,0 +1,186 @@
package com.roubao.autopilot.pinduoduo
import java.time.Instant
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PinduoduoOrderSubmissionModelsTest {
private val expectation = PinduoduoOrderSubmissionExpectation(
title = "设计拼接T恤短袖2026轻奢小众夏季气质新款百搭漂亮上衣出片",
color = "GRAY",
size = "2XL",
quantity = 1,
totalPriceCents = 1_435L
)
@Test
fun `safe confirmation requires address and one exact submit action`() {
val readiness = PinduoduoOrderSubmissionSafetyPolicy.inspect(
confirmationElements(),
expectation
)
assertNotNull(readiness)
assertTrue(readiness!!.addressConfigured)
assertEquals(1, readiness.exactSubmitActionCount)
}
@Test
fun `missing address and automatic payment semantics fail closed`() {
assertNull(
PinduoduoOrderSubmissionSafetyPolicy.inspect(
confirmationElements().map {
if (it.text == "张三 138****1234") {
it.copy(text = "手动添加收货地址")
} else {
it
}
},
expectation
)
)
assertNull(
PinduoduoOrderSubmissionSafetyPolicy.inspect(
confirmationElements() + element("先用后付"),
expectation
)
)
}
@Test
fun `duplicate or payment submit actions are never accepted`() {
assertNull(
PinduoduoOrderSubmissionSafetyPolicy.inspect(
confirmationElements() + element(
"提交订单",
clickable = true
),
expectation
)
)
assertNull(
PinduoduoOrderSubmissionSafetyPolicy.inspect(
confirmationElements().map {
if (it.text == "提交订单") {
it.copy(text = "立即支付")
} else {
it
}
},
expectation
)
)
}
@Test
fun `pending order parser reads labeled identity and local order time`() {
val parsed = PinduoduoPendingOrderParser.parse(orderElements())
assertNotNull(parsed)
assertEquals("12345678901234567890", parsed!!.platformOrderNo)
assertEquals("PENDING_PAYMENT", parsed.status)
assertEquals("2026-07-28T01:31:00Z", parsed.platformOrderedAt)
assertEquals(1_435L, parsed.totalPriceCents)
}
@Test
fun `reconciliation requires one exact recent pending order`() {
val parsed = requireNotNull(
PinduoduoPendingOrderParser.parse(orderElements())
)
val matched = PinduoduoOrderReconciliationPolicy.reconcile(
listOf(parsed),
expectation,
fencedAt = "2026-07-28T01:30:00Z",
now = Instant.parse("2026-07-28T01:32:00Z")
)
assertTrue(matched is PinduoduoOrderReconciliation.Matched)
val ambiguous = PinduoduoOrderReconciliationPolicy.reconcile(
listOf(
parsed,
parsed.copy(
platformOrderNo = "22345678901234567890"
)
),
expectation,
fencedAt = "2026-07-28T01:30:00Z",
now = Instant.parse("2026-07-28T01:32:00Z")
)
assertEquals(
"ORDER_AMBIGUOUS",
(ambiguous as PinduoduoOrderReconciliation.ManualReview)
.reasonCode
)
}
@Test
fun `unlabeled order numbers and stale times fail closed`() {
assertNull(
PinduoduoPendingOrderParser.parse(
orderElements().map {
if (it.text?.startsWith("订单编号") == true) {
it.copy(text = "12345678901234567890")
} else {
it
}
}
)
)
val parsed = requireNotNull(
PinduoduoPendingOrderParser.parse(orderElements())
)
val result = PinduoduoOrderReconciliationPolicy.reconcile(
listOf(
parsed.copy(
platformOrderedAt = "2026-07-27T01:31:00Z"
)
),
expectation,
fencedAt = "2026-07-28T01:30:00Z",
now = Instant.parse("2026-07-28T01:32:00Z")
)
assertEquals(
"ORDER_NOT_FOUND",
(result as PinduoduoOrderReconciliation.ManualReview)
.reasonCode
)
}
private fun confirmationElements() = listOf(
element("收货地址"),
element("张三 138****1234"),
element(expectation.title),
element("已选:灰色,2XL 建议125-135斤"),
element("×1"),
element("实付款¥14.35"),
element("提交订单", clickable = true)
)
private fun orderElements() = listOf(
element("待付款"),
element(expectation.title),
element("规格:灰色,2XL 建议125-135斤"),
element("×1"),
element("应付¥14.35"),
element("订单编号:12345678901234567890"),
element("下单时间:2026-07-28 09:31")
)
private fun element(
text: String,
clickable: Boolean = false
) = PinduoduoUiElement(
text = text,
contentDescription = null,
className = "android.widget.TextView",
resourceId = null,
clickable = clickable,
editable = false,
enabled = true,
visibleToUser = true
)
}
@@ -0,0 +1,62 @@
package com.roubao.autopilot.procurement
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class OrderSubmissionRecoveryPolicyTest {
@Test
fun `fresh fence response permits only the current call to click`() {
val transition = OrderSubmissionRecoveryPolicy.afterFenceResponse(
OrderSubmissionStatus.FENCE_INTENT_SAVED,
"FENCED",
intentCreatedInCurrentCall = true
)
assertEquals(OrderSubmissionStatus.CLICK_ASSUMED, transition.status)
assertTrue(transition.mayClickInCurrentCall)
assertFalse(
OrderSubmissionRecoveryPolicy.mayResumeClick(transition.status)
)
}
@Test
fun `persisted fence intent can never regain click permission`() {
val transition = OrderSubmissionRecoveryPolicy.afterFenceResponse(
OrderSubmissionStatus.FENCE_INTENT_SAVED,
"FENCED",
intentCreatedInCurrentCall = false
)
assertEquals(OrderSubmissionStatus.CLICK_ASSUMED, transition.status)
assertFalse(transition.mayClickInCurrentCall)
}
@Test
fun `persisted click assumed can never regain click permission`() {
val transition = OrderSubmissionRecoveryPolicy.afterFenceResponse(
OrderSubmissionStatus.CLICK_ASSUMED,
"FENCED",
intentCreatedInCurrentCall = false
)
assertEquals(OrderSubmissionStatus.CLICK_ASSUMED, transition.status)
assertFalse(transition.mayClickInCurrentCall)
OrderSubmissionStatus.entries.forEach { status ->
assertFalse(OrderSubmissionRecoveryPolicy.mayResumeClick(status))
}
}
@Test
fun `terminal backend states never permit a click`() {
listOf("MANUAL_REVIEW", "RECONCILED").forEach { remote ->
val transition = OrderSubmissionRecoveryPolicy.afterFenceResponse(
OrderSubmissionStatus.FENCE_INTENT_SAVED,
remote,
intentCreatedInCurrentCall = true
)
assertFalse(transition.mayClickInCurrentCall)
}
}
}