feat(t218): reconcile single order submissions
This commit is contained in:
+41
@@ -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) {
|
||||
|
||||
+143
-1
@@ -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}$")
|
||||
}
|
||||
}
|
||||
|
||||
+70
@@ -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
|
||||
}
|
||||
}
|
||||
+348
@@ -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
|
||||
}
|
||||
+150
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+194
-1
@@ -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? =
|
||||
|
||||
+21
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+125
-2
@@ -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,
|
||||
|
||||
+483
-7
@@ -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",
|
||||
|
||||
+82
@@ -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)
|
||||
|
||||
+18
-2
@@ -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 -> "授权到期,已停止"
|
||||
}
|
||||
|
||||
+186
@@ -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
|
||||
)
|
||||
}
|
||||
+62
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user