feat(t218): reconcile single order submissions
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.roubao.autopilot"
|
applicationId = "com.roubao.autopilot"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 19
|
versionCode = 20
|
||||||
versionName = "1.4.14"
|
versionName = "1.4.15"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
+41
@@ -4,6 +4,9 @@ import com.roubao.autopilot.pinduoduo.PinduoduoPage
|
|||||||
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard
|
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard
|
||||||
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence
|
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence
|
||||||
import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationEvidence
|
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.PinduoduoScreenshotCapture
|
||||||
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
|
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
|
||||||
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
|
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? =
|
suspend fun captureSpecificationScreenshot(): PinduoduoScreenshotCapture? =
|
||||||
withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) {
|
withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) {
|
||||||
withContext(Dispatchers.Main.immediate) {
|
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.PinduoduoObservedTitlePolicy
|
||||||
import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationEvidence
|
import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationEvidence
|
||||||
import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationParser
|
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.DeviceObservationStore
|
||||||
import com.roubao.autopilot.readiness.LoginBlockerDetector
|
import com.roubao.autopilot.readiness.LoginBlockerDetector
|
||||||
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
|
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
|
||||||
@@ -42,6 +47,7 @@ class BuyerAccessibilityService : AccessibilityService() {
|
|||||||
private var lastPinduoduoScanAt = 0L
|
private var lastPinduoduoScanAt = 0L
|
||||||
private var expectedSearchQuery = SEARCH_PROBE_KEYWORD
|
private var expectedSearchQuery = SEARCH_PROBE_KEYWORD
|
||||||
private val screenshotExecutor = Executors.newSingleThreadExecutor()
|
private val screenshotExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
private val consumedSubmissionFences = linkedSetOf<String>()
|
||||||
|
|
||||||
override fun onServiceConnected() {
|
override fun onServiceConnected() {
|
||||||
super.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 =
|
internal fun isPinduoduoCollapsedCheckout(): Boolean =
|
||||||
withPinduoduoRoot { root ->
|
withPinduoduoRoot { root ->
|
||||||
PinduoduoCollapsedCheckoutPolicy.matches(
|
PinduoduoCollapsedCheckoutPolicy.matches(
|
||||||
@@ -608,7 +739,8 @@ class BuyerAccessibilityService : AccessibilityService() {
|
|||||||
expectedPage !in setOf(
|
expectedPage !in setOf(
|
||||||
PinduoduoPage.PRODUCT_DETAIL,
|
PinduoduoPage.PRODUCT_DETAIL,
|
||||||
PinduoduoPage.SPECIFICATION_PANEL,
|
PinduoduoPage.SPECIFICATION_PANEL,
|
||||||
PinduoduoPage.ORDER_CONFIRMATION
|
PinduoduoPage.ORDER_CONFIRMATION,
|
||||||
|
PinduoduoPage.ORDER_LIST
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
@@ -1143,6 +1275,9 @@ class BuyerAccessibilityService : AccessibilityService() {
|
|||||||
private const val MIN_CLICK_TARGET_COVER_PERCENT = 85
|
private const val MIN_CLICK_TARGET_COVER_PERCENT = 85
|
||||||
private const val MAX_EVIDENCE_TEXTS = 100
|
private const val MAX_EVIDENCE_TEXTS = 100
|
||||||
private const val MAX_EVIDENCE_TEXT_LENGTH = 500
|
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 MIN_SCAN_INTERVAL_MS = 300L
|
||||||
private const val IMAGE_SEARCH_DESCRIPTION = "拍照搜索"
|
private const val IMAGE_SEARCH_DESCRIPTION = "拍照搜索"
|
||||||
private const val IMAGE_SEARCH_RETRY_CANCEL_TEXT = "取消"
|
private const val IMAGE_SEARCH_RETRY_CANCEL_TEXT = "取消"
|
||||||
@@ -1156,5 +1291,12 @@ class BuyerAccessibilityService : AccessibilityService() {
|
|||||||
setOf("减少数量", "减", "-")
|
setOf("减少数量", "减", "-")
|
||||||
private val DECIMAL_PRICE_PATTERN =
|
private val DECIMAL_PRICE_PATTERN =
|
||||||
Regex("^\\s*\\d{1,6}\\.\\d{1,2}\\s*$")
|
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
|
idempotencyKey: String
|
||||||
): RemoteOrderDryRun
|
): 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(
|
suspend fun uploadExecutionOutboxItem(
|
||||||
session: ProcurementSession,
|
session: ProcurementSession,
|
||||||
task: RemotePurchaseTask,
|
task: RemotePurchaseTask,
|
||||||
@@ -532,6 +565,115 @@ class ProcurementApiClient(
|
|||||||
return parseOrderDryRun(json.getJSONObject("dry_run"))
|
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(
|
private suspend fun executeTransition(
|
||||||
session: ProcurementSession,
|
session: ProcurementSession,
|
||||||
task: RemotePurchaseTask,
|
task: RemotePurchaseTask,
|
||||||
@@ -695,7 +837,58 @@ class ProcurementApiClient(
|
|||||||
id = json.getString("id"),
|
id = json.getString("id"),
|
||||||
commandId = json.getString("command_id"),
|
commandId = json.getString("command_id"),
|
||||||
commandSha256 = json.getString("command_sha256"),
|
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? =
|
private fun taskOrderCommandId(payload: String): String? =
|
||||||
|
|||||||
+21
@@ -43,6 +43,10 @@ class ProcurementExecutionService : Service() {
|
|||||||
applicationContext,
|
applicationContext,
|
||||||
repository
|
repository
|
||||||
)
|
)
|
||||||
|
val orderSubmission = OrderSubmissionCoordinator(
|
||||||
|
applicationContext,
|
||||||
|
repository
|
||||||
|
)
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val decision = repository.synchronizeRunning()
|
val decision = repository.synchronizeRunning()
|
||||||
if (decision == ExecutionSyncDecision.STOP) {
|
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)
|
delay(HEARTBEAT_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-2
@@ -13,6 +13,9 @@ enum class ProcurementPhase {
|
|||||||
ORDER_AUTHORIZED,
|
ORDER_AUTHORIZED,
|
||||||
ORDER_DRY_RUN_RUNNING,
|
ORDER_DRY_RUN_RUNNING,
|
||||||
ORDER_DRY_RUN_READY,
|
ORDER_DRY_RUN_READY,
|
||||||
|
ORDER_SUBMISSION_RECONCILING,
|
||||||
|
ORDER_SUBMISSION_MANUAL_REVIEW,
|
||||||
|
ORDER_RECONCILED,
|
||||||
AUTHORIZATION_EXPIRED
|
AUTHORIZATION_EXPIRED
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +93,8 @@ data class PersistedProcurementState(
|
|||||||
val execution: RunningExecution? = null,
|
val execution: RunningExecution? = null,
|
||||||
val outbox: List<ExecutionOutboxItem> = emptyList(),
|
val outbox: List<ExecutionOutboxItem> = emptyList(),
|
||||||
val orderCommand: PendingOrderCommand? = null,
|
val orderCommand: PendingOrderCommand? = null,
|
||||||
val orderDryRun: PendingOrderDryRun? = null
|
val orderDryRun: PendingOrderDryRun? = null,
|
||||||
|
val orderSubmission: PendingOrderSubmission? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class OrderCommandCandidate(
|
data class OrderCommandCandidate(
|
||||||
@@ -158,7 +162,13 @@ data class RemoteOrderDryRun(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val commandId: String,
|
val commandId: String,
|
||||||
val commandSha256: 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(
|
data class OrderDryRunReadyDraft(
|
||||||
@@ -181,6 +191,118 @@ data class AuthorizedOrderDryRunContext(
|
|||||||
val maxBudgetCents: Long?
|
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 {
|
enum class ExecutionMode {
|
||||||
MANUAL_FIRST,
|
MANUAL_FIRST,
|
||||||
AI_ASSISTED
|
AI_ASSISTED
|
||||||
@@ -223,6 +345,7 @@ data class ProcurementUiState(
|
|||||||
val execution: RunningExecution? = null,
|
val execution: RunningExecution? = null,
|
||||||
val orderCommand: PendingOrderCommand? = null,
|
val orderCommand: PendingOrderCommand? = null,
|
||||||
val orderDryRun: PendingOrderDryRun? = null,
|
val orderDryRun: PendingOrderDryRun? = null,
|
||||||
|
val orderSubmission: PendingOrderSubmission? = null,
|
||||||
val authenticationRequired: Boolean = false,
|
val authenticationRequired: Boolean = false,
|
||||||
val busy: Boolean = false,
|
val busy: Boolean = false,
|
||||||
val backendOnline: Boolean? = null,
|
val backendOnline: Boolean? = null,
|
||||||
|
|||||||
+483
-7
@@ -42,8 +42,17 @@ class ProcurementRepository(
|
|||||||
val uiState: StateFlow<ProcurementUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ProcurementUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
val hasRunningExecution: Boolean
|
val hasRunningExecution: Boolean
|
||||||
get() = storageFailure == null &&
|
get() {
|
||||||
persisted.execution?.safetyStopped == false
|
val submission = persisted.orderSubmission
|
||||||
|
val reconciliationPending =
|
||||||
|
submission?.remoteId != null &&
|
||||||
|
submission.status != OrderSubmissionStatus.RECONCILED
|
||||||
|
return storageFailure == null &&
|
||||||
|
(
|
||||||
|
persisted.execution?.safetyStopped == false ||
|
||||||
|
reconciliationPending
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val canCollectCandidates: Boolean
|
val canCollectCandidates: Boolean
|
||||||
get() {
|
get() {
|
||||||
@@ -72,6 +81,27 @@ class ProcurementRepository(
|
|||||||
(dryRun?.attemptCount ?: 0) < MAX_ORDER_DRY_RUN_ATTEMPTS
|
(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 {
|
suspend fun login(input: LoginInput): Boolean = operation {
|
||||||
require(input.password.isNotEmpty()) { "请输入采购员密码" }
|
require(input.password.isNotEmpty()) { "请输入采购员密码" }
|
||||||
val normalizedUrl = BackendEndpointPolicy.normalize(
|
val normalizedUrl = BackendEndpointPolicy.normalize(
|
||||||
@@ -305,7 +335,21 @@ class ProcurementRepository(
|
|||||||
if (claim == null || task == null || execution == null) {
|
if (claim == null || task == null || execution == null) {
|
||||||
return@withLock ExecutionSyncDecision.STOP
|
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(
|
execution = execution.copy(
|
||||||
currentStep = SAFE_STOPPED_STEP,
|
currentStep = SAFE_STOPPED_STEP,
|
||||||
safetyStopped = true
|
safetyStopped = true
|
||||||
@@ -317,7 +361,11 @@ class ProcurementRepository(
|
|||||||
error = "离线执行授权已到期,自动化已安全停止"
|
error = "离线执行授权已到期,自动化已安全停止"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (execution.safetyStopped && !allowSafetyStoppedSync) {
|
if (
|
||||||
|
execution.safetyStopped &&
|
||||||
|
!allowSafetyStoppedSync &&
|
||||||
|
!reconciliationPending
|
||||||
|
) {
|
||||||
return@withLock ExecutionSyncDecision.STOP
|
return@withLock ExecutionSyncDecision.STOP
|
||||||
}
|
}
|
||||||
val session = persisted.session
|
val session = persisted.session
|
||||||
@@ -341,6 +389,16 @@ class ProcurementRepository(
|
|||||||
return@withLock ExecutionSyncDecision.STOP
|
return@withLock ExecutionSyncDecision.STOP
|
||||||
}
|
}
|
||||||
execution = persisted.execution ?: 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) {
|
if (execution.safetyStopped) {
|
||||||
publish(
|
publish(
|
||||||
backendOnline = true,
|
backendOnline = true,
|
||||||
@@ -654,6 +712,384 @@ class ProcurementRepository(
|
|||||||
true
|
true
|
||||||
} ?: false
|
} ?: 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(
|
suspend fun queueCandidateBatch(
|
||||||
batch: ExecutionCandidateBatchDraft,
|
batch: ExecutionCandidateBatchDraft,
|
||||||
evidence: List<ExecutionEvidenceDraft>
|
evidence: List<ExecutionEvidenceDraft>
|
||||||
@@ -1003,7 +1439,8 @@ class ProcurementRepository(
|
|||||||
execution = null,
|
execution = null,
|
||||||
outbox = emptyList(),
|
outbox = emptyList(),
|
||||||
orderCommand = null,
|
orderCommand = null,
|
||||||
orderDryRun = null
|
orderDryRun = null,
|
||||||
|
orderSubmission = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1204,7 +1641,15 @@ class ProcurementRepository(
|
|||||||
require(
|
require(
|
||||||
remote.commandId == command.id &&
|
remote.commandId == command.id &&
|
||||||
remote.commandSha256 == command.commandSha256 &&
|
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(
|
persisted = persisted.copy(
|
||||||
execution = execution.copy(
|
execution = execution.copy(
|
||||||
@@ -1212,7 +1657,13 @@ class ProcurementRepository(
|
|||||||
),
|
),
|
||||||
orderDryRun = requireNotNull(persisted.orderDryRun).copy(
|
orderDryRun = requireNotNull(persisted.orderDryRun).copy(
|
||||||
status = OrderDryRunStatus.READY,
|
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 != null &&
|
||||||
(execution.safetyStopped || execution.isExpired()) ->
|
(execution.safetyStopped || execution.isExpired()) ->
|
||||||
ProcurementPhase.AUTHORIZATION_EXPIRED
|
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 ->
|
persisted.orderDryRun?.status == OrderDryRunStatus.READY ->
|
||||||
ProcurementPhase.ORDER_DRY_RUN_READY
|
ProcurementPhase.ORDER_DRY_RUN_READY
|
||||||
persisted.orderDryRun != null ->
|
persisted.orderDryRun != null ->
|
||||||
@@ -1432,6 +1891,7 @@ class ProcurementRepository(
|
|||||||
execution = execution,
|
execution = execution,
|
||||||
orderCommand = persisted.orderCommand,
|
orderCommand = persisted.orderCommand,
|
||||||
orderDryRun = persisted.orderDryRun,
|
orderDryRun = persisted.orderDryRun,
|
||||||
|
orderSubmission = persisted.orderSubmission,
|
||||||
authenticationRequired = session?.isValid() != true
|
authenticationRequired = session?.isValid() != true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1487,6 +1947,13 @@ class ProcurementRepository(
|
|||||||
private const val ORDER_AUTHORIZED_STEP = "ORDER_AUTHORIZED"
|
private const val ORDER_AUTHORIZED_STEP = "ORDER_AUTHORIZED"
|
||||||
private const val ORDER_DRY_RUN_STEP = "ORDER_DRY_RUN"
|
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_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 SAFE_STOPPED_STEP = "SAFE_STOPPED"
|
||||||
private const val MAX_ORDER_DRY_RUN_ATTEMPTS = 5
|
private const val MAX_ORDER_DRY_RUN_ATTEMPTS = 5
|
||||||
private const val MAX_REFERENCE_DIMENSION = 4_096
|
private const val MAX_REFERENCE_DIMENSION = 4_096
|
||||||
@@ -1496,6 +1963,15 @@ class ProcurementRepository(
|
|||||||
private val BUDGET_PATTERN =
|
private val BUDGET_PATTERN =
|
||||||
Regex("^(0|[1-9][0-9]{0,9})(\\.[0-9]{1,2})?$")
|
Regex("^(0|[1-9][0-9]{0,9})(\\.[0-9]{1,2})?$")
|
||||||
private val TITLE_SPACE_PATTERN = Regex("[\\s\\p{Punct}]+")
|
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(
|
private val COMPLETE_OUTCOMES = setOf(
|
||||||
"CANDIDATE_ACCEPTED",
|
"CANDIDATE_ACCEPTED",
|
||||||
"CANDIDATE_REJECTED",
|
"CANDIDATE_REJECTED",
|
||||||
|
|||||||
+82
@@ -141,6 +141,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
|||||||
state.orderDryRun?.let { dryRun ->
|
state.orderDryRun?.let { dryRun ->
|
||||||
put("order_dry_run", encodeOrderDryRun(dryRun))
|
put("order_dry_run", encodeOrderDryRun(dryRun))
|
||||||
}
|
}
|
||||||
|
state.orderSubmission?.let { submission ->
|
||||||
|
put("order_submission", encodeOrderSubmission(submission))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decodeState(json: JSONObject): PersistedProcurementState =
|
private fun decodeState(json: JSONObject): PersistedProcurementState =
|
||||||
@@ -230,6 +233,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
|||||||
},
|
},
|
||||||
orderDryRun = json.optionalObject("order_dry_run")?.let {
|
orderDryRun = json.optionalObject("order_dry_run")?.let {
|
||||||
decodeOrderDryRun(it)
|
decodeOrderDryRun(it)
|
||||||
|
},
|
||||||
|
orderSubmission = json.optionalObject("order_submission")?.let {
|
||||||
|
decodeOrderSubmission(it)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -288,6 +294,82 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
|||||||
evidenceSha256 = json.optionalString("evidence_sha256")
|
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 =
|
private fun encodeOrderCommand(command: PendingOrderCommand): JSONObject =
|
||||||
JSONObject().apply {
|
JSONObject().apply {
|
||||||
put("id", command.id)
|
put("id", command.id)
|
||||||
|
|||||||
+18
-2
@@ -200,6 +200,9 @@ fun ProcurementScreen(
|
|||||||
ProcurementPhase.ORDER_AUTHORIZED,
|
ProcurementPhase.ORDER_AUTHORIZED,
|
||||||
ProcurementPhase.ORDER_DRY_RUN_RUNNING,
|
ProcurementPhase.ORDER_DRY_RUN_RUNNING,
|
||||||
ProcurementPhase.ORDER_DRY_RUN_READY,
|
ProcurementPhase.ORDER_DRY_RUN_READY,
|
||||||
|
ProcurementPhase.ORDER_SUBMISSION_RECONCILING,
|
||||||
|
ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW,
|
||||||
|
ProcurementPhase.ORDER_RECONCILED,
|
||||||
ProcurementPhase.AUTHORIZATION_EXPIRED -> {
|
ProcurementPhase.AUTHORIZATION_EXPIRED -> {
|
||||||
if (state.authenticationRequired) {
|
if (state.authenticationRequired) {
|
||||||
item(key = "procurement-login") {
|
item(key = "procurement-login") {
|
||||||
@@ -497,12 +500,19 @@ private fun ExecutionDetails(state: ProcurementUiState) {
|
|||||||
"正在重新定位商品并核验规格、数量和金额"
|
"正在重新定位商品并核验规格、数量和金额"
|
||||||
ProcurementPhase.ORDER_DRY_RUN_READY ->
|
ProcurementPhase.ORDER_DRY_RUN_READY ->
|
||||||
"订单已核验,等待单次提交"
|
"订单已核验,等待单次提交"
|
||||||
|
ProcurementPhase.ORDER_SUBMISSION_RECONCILING ->
|
||||||
|
"提交围栏已消费,只允许查看订单列表对账"
|
||||||
|
ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW ->
|
||||||
|
"订单未能唯一对账,保持禁止重复提交"
|
||||||
|
ProcurementPhase.ORDER_RECONCILED ->
|
||||||
|
"待付款订单已回传后台,请人工确认付款"
|
||||||
else -> "订单提交保持禁用"
|
else -> "订单提交保持禁用"
|
||||||
},
|
},
|
||||||
color = if (
|
color = if (
|
||||||
state.phase in setOf(
|
state.phase in setOf(
|
||||||
ProcurementPhase.ORDER_AUTHORIZED,
|
ProcurementPhase.ORDER_AUTHORIZED,
|
||||||
ProcurementPhase.ORDER_DRY_RUN_READY
|
ProcurementPhase.ORDER_DRY_RUN_READY,
|
||||||
|
ProcurementPhase.ORDER_RECONCILED
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
colors.success
|
colors.success
|
||||||
@@ -545,7 +555,10 @@ private fun phaseColor(state: ProcurementUiState) =
|
|||||||
ProcurementPhase.WAITING_ADMIN_CONFIRMATION,
|
ProcurementPhase.WAITING_ADMIN_CONFIRMATION,
|
||||||
ProcurementPhase.ORDER_AUTHORIZED,
|
ProcurementPhase.ORDER_AUTHORIZED,
|
||||||
ProcurementPhase.ORDER_DRY_RUN_RUNNING,
|
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
|
else -> BaoziTheme.colors.textSecondary
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,5 +572,8 @@ private fun phaseLabel(state: ProcurementUiState): String =
|
|||||||
ProcurementPhase.ORDER_AUTHORIZED -> "后台商品授权已同步"
|
ProcurementPhase.ORDER_AUTHORIZED -> "后台商品授权已同步"
|
||||||
ProcurementPhase.ORDER_DRY_RUN_RUNNING -> "正在核验订单"
|
ProcurementPhase.ORDER_DRY_RUN_RUNNING -> "正在核验订单"
|
||||||
ProcurementPhase.ORDER_DRY_RUN_READY -> "订单已核验,等待提交"
|
ProcurementPhase.ORDER_DRY_RUN_READY -> "订单已核验,等待提交"
|
||||||
|
ProcurementPhase.ORDER_SUBMISSION_RECONCILING -> "订单提交后正在对账"
|
||||||
|
ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW -> "等待人工对账"
|
||||||
|
ProcurementPhase.ORDER_RECONCILED -> "待付款订单已回传"
|
||||||
ProcurementPhase.AUTHORIZATION_EXPIRED -> "授权到期,已停止"
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,17 +209,26 @@ func buildRouter(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
submissions, err := usecase.NewOrderSubmissionService(
|
||||||
|
store,
|
||||||
|
clock,
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
passwords, err := password.NewBcrypt(12)
|
passwords, err := password.NewBcrypt(12)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
registerDeviceRoutes, err := httpapi.NewDeviceRouteRegistrar(
|
registerDeviceRoutes, err := httpapi.NewDeviceRouteRegistrar(
|
||||||
httpapi.DeviceServices{
|
httpapi.DeviceServices{
|
||||||
Lifecycle: lifecycle,
|
Lifecycle: lifecycle,
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
Results: results,
|
Results: results,
|
||||||
Commands: commands,
|
Commands: commands,
|
||||||
DryRuns: dryRuns,
|
DryRuns: dryRuns,
|
||||||
|
Submissions: submissions,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -355,6 +355,39 @@ type OrderDryRun struct {
|
|||||||
ReadyAt *time.Time
|
ReadyAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OrderSubmissionStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OrderSubmissionFenced OrderSubmissionStatus = "FENCED"
|
||||||
|
OrderSubmissionReconciled OrderSubmissionStatus = "RECONCILED"
|
||||||
|
OrderSubmissionManualReview OrderSubmissionStatus = "MANUAL_REVIEW"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderSubmission struct {
|
||||||
|
ID string
|
||||||
|
AuthorizationID string
|
||||||
|
DryRunID string
|
||||||
|
TaskID string
|
||||||
|
ExecutionID string
|
||||||
|
CommandSHA256 string
|
||||||
|
DryRunEvidenceSHA256 string
|
||||||
|
Status OrderSubmissionStatus
|
||||||
|
ExpectedTitle string
|
||||||
|
ExpectedSKU string
|
||||||
|
ExpectedQuantity int
|
||||||
|
ExpectedUnitPriceCents int64
|
||||||
|
ExpectedTotalPriceCents int64
|
||||||
|
PlatformOrderNo *string
|
||||||
|
PlatformOrderedAt *time.Time
|
||||||
|
PlatformOrderStatus *string
|
||||||
|
ReconciliationEvidenceAssetID *string
|
||||||
|
ReconciliationEvidenceSHA256 *string
|
||||||
|
ManualReasonCode *string
|
||||||
|
FencedAt time.Time
|
||||||
|
ReconciledAt *time.Time
|
||||||
|
ManualReviewAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type ExecutionReport struct {
|
type ExecutionReport struct {
|
||||||
Events []ExecutionEvent
|
Events []ExecutionEvent
|
||||||
EvidenceAssets []ExecutionEvidenceAsset
|
EvidenceAssets []ExecutionEvidenceAsset
|
||||||
|
|||||||
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
|||||||
|
|
||||||
if applied, err := runner.Up(ctx); err != nil {
|
if applied, err := runner.Up(ctx); err != nil {
|
||||||
t.Fatalf("initial Up() error = %v", err)
|
t.Fatalf("initial Up() error = %v", err)
|
||||||
} else if applied != 10 {
|
} else if applied != 11 {
|
||||||
t.Fatalf("initial Up() applied = %d, want 10", applied)
|
t.Fatalf("initial Up() applied = %d, want 11", applied)
|
||||||
|
}
|
||||||
|
if err := runner.Down(ctx); err != nil {
|
||||||
|
t.Fatalf("initial Down(v11) error = %v", err)
|
||||||
}
|
}
|
||||||
if err := runner.Down(ctx); err != nil {
|
if err := runner.Down(ctx); err != nil {
|
||||||
t.Fatalf("initial Down(v10) error = %v", err)
|
t.Fatalf("initial Down(v10) error = %v", err)
|
||||||
@@ -59,9 +62,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
|||||||
seedClaimsHistoricalFixture(t, db)
|
seedClaimsHistoricalFixture(t, db)
|
||||||
|
|
||||||
if applied, err := runner.Up(ctx); err != nil {
|
if applied, err := runner.Up(ctx); err != nil {
|
||||||
t.Fatalf("Up(v5-v10) over historical data error = %v", err)
|
t.Fatalf("Up(v5-v11) over historical data error = %v", err)
|
||||||
} else if applied != 6 {
|
} else if applied != 7 {
|
||||||
t.Fatalf("Up(v5-v10) applied = %d, want 6", applied)
|
t.Fatalf("Up(v5-v11) applied = %d, want 7", applied)
|
||||||
|
}
|
||||||
|
assertClaimsHistory(t, db, true)
|
||||||
|
|
||||||
|
if err := runner.Down(ctx); err != nil {
|
||||||
|
t.Fatalf("Down(v11) with compatible history error = %v", err)
|
||||||
}
|
}
|
||||||
assertClaimsHistory(t, db, true)
|
assertClaimsHistory(t, db, true)
|
||||||
|
|
||||||
@@ -101,9 +109,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
|||||||
assertClaimsHistory(t, db, false)
|
assertClaimsHistory(t, db, false)
|
||||||
|
|
||||||
if applied, err := runner.Up(ctx); err != nil {
|
if applied, err := runner.Up(ctx); err != nil {
|
||||||
t.Fatalf("final Up(v4-v10) error = %v", err)
|
t.Fatalf("final Up(v4-v11) error = %v", err)
|
||||||
} else if applied != 7 {
|
} else if applied != 8 {
|
||||||
t.Fatalf("final Up(v4-v10) applied = %d, want 7", applied)
|
t.Fatalf("final Up(v4-v11) applied = %d, want 8", applied)
|
||||||
}
|
}
|
||||||
assertClaimsHistory(t, db, true)
|
assertClaimsHistory(t, db, true)
|
||||||
}
|
}
|
||||||
@@ -339,6 +347,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
|
|||||||
t.Fatalf("insert v4 audit event: %v", err)
|
t.Fatalf("insert v4 audit event: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := runner.Down(ctx); err != nil {
|
||||||
|
t.Fatalf("Down(v11) error = %v", err)
|
||||||
|
}
|
||||||
if err := runner.Down(ctx); err != nil {
|
if err := runner.Down(ctx); err != nil {
|
||||||
t.Fatalf("Down(v10) error = %v", err)
|
t.Fatalf("Down(v10) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Up() error = %v", err)
|
t.Fatalf("Up() error = %v", err)
|
||||||
}
|
}
|
||||||
if applied != 10 {
|
if applied != 11 {
|
||||||
t.Fatalf("Up() applied = %d, want 10", applied)
|
t.Fatalf("Up() applied = %d, want 11", applied)
|
||||||
}
|
}
|
||||||
assertStatuses(t, runner, map[int64]bool{
|
assertStatuses(t, runner, map[int64]bool{
|
||||||
1: true,
|
1: true,
|
||||||
@@ -41,6 +41,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
8: true,
|
8: true,
|
||||||
9: true,
|
9: true,
|
||||||
10: true,
|
10: true,
|
||||||
|
11: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
applied, err = runner.Up(context.Background())
|
applied, err = runner.Up(context.Background())
|
||||||
@@ -64,7 +65,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
7: true,
|
7: true,
|
||||||
8: true,
|
8: true,
|
||||||
9: true,
|
9: true,
|
||||||
10: false,
|
10: true,
|
||||||
|
11: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
applied, err = runner.Up(context.Background())
|
applied, err = runner.Up(context.Background())
|
||||||
@@ -85,6 +87,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
8: true,
|
8: true,
|
||||||
9: true,
|
9: true,
|
||||||
10: true,
|
10: true,
|
||||||
|
11: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migration.New() error = %v", err)
|
t.Fatalf("migration.New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Down(v11) error = %v", err)
|
||||||
|
}
|
||||||
if err := runner.Down(context.Background()); err != nil {
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
t.Fatalf("Down(v10) error = %v", err)
|
t.Fatalf("Down(v10) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -420,9 +423,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
|||||||
t.Fatal("purchase_tasks was lost during auth migration rollback")
|
t.Fatal("purchase_tasks was lost during auth migration rollback")
|
||||||
}
|
}
|
||||||
if applied, err := runner.Up(context.Background()); err != nil {
|
if applied, err := runner.Up(context.Background()); err != nil {
|
||||||
t.Fatalf("Up(v3-v10) error = %v", err)
|
t.Fatalf("Up(v3-v11) error = %v", err)
|
||||||
} else if applied != 8 {
|
} else if applied != 9 {
|
||||||
t.Fatalf("Up(v3-v10) applied = %d, want 8", applied)
|
t.Fatalf("Up(v3-v11) applied = %d, want 9", applied)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,880 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
"cmroubao/backend-api/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
const orderReconciliationClockWindow = 5 * time.Minute
|
||||||
|
|
||||||
|
func (s *Store) StartOrderSubmission(
|
||||||
|
ctx context.Context,
|
||||||
|
write usecase.StartOrderSubmissionWrite,
|
||||||
|
) (domain.OrderSubmission, bool, error) {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
if result, found, err := replayOrderSubmissionRequest(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.DeviceID,
|
||||||
|
"START",
|
||||||
|
write.IdempotencyKey,
|
||||||
|
write.RequestSHA256,
|
||||||
|
write.TaskID,
|
||||||
|
); err != nil || found {
|
||||||
|
return commitOrderSubmissionReplay(tx, result, found, err)
|
||||||
|
}
|
||||||
|
if err := validateDeviceOrderCommandClaim(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.UserID,
|
||||||
|
write.DeviceID,
|
||||||
|
write.TaskID,
|
||||||
|
write.ExecutionID,
|
||||||
|
write.ClaimGeneration,
|
||||||
|
write.ClaimTokenHash,
|
||||||
|
write.Now,
|
||||||
|
); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
authorization, err := getOrderAuthorization(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.AuthorizationID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if err := validateSubmissionAuthorization(
|
||||||
|
authorization,
|
||||||
|
write.UserID,
|
||||||
|
write.DeviceID,
|
||||||
|
write.TaskID,
|
||||||
|
write.ExecutionID,
|
||||||
|
write.ClaimGeneration,
|
||||||
|
write.CommandSHA256,
|
||||||
|
); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if authorization.Status != domain.OrderAuthorizationExecuting {
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
dryRun, found, err := getOrderDryRunByAuthorization(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
authorization.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if !found ||
|
||||||
|
dryRun.ID != write.DryRunID ||
|
||||||
|
dryRun.Status != domain.OrderDryRunReady ||
|
||||||
|
dryRun.CommandSHA256 != write.CommandSHA256 ||
|
||||||
|
dryRun.EvidenceSHA256 == nil ||
|
||||||
|
*dryRun.EvidenceSHA256 != write.DryRunEvidenceSHA256 ||
|
||||||
|
dryRun.ObservedTitle == nil ||
|
||||||
|
normalizeSubmissionText(*dryRun.ObservedTitle) !=
|
||||||
|
normalizeSubmissionText(write.ObservedTitle) ||
|
||||||
|
dryRun.SelectedSKU == nil ||
|
||||||
|
strings.TrimSpace(*dryRun.SelectedSKU) != write.SelectedSKU ||
|
||||||
|
dryRun.Quantity == nil ||
|
||||||
|
*dryRun.Quantity != write.Quantity ||
|
||||||
|
dryRun.UnitPriceCents == nil ||
|
||||||
|
*dryRun.UnitPriceCents != write.UnitPriceCents ||
|
||||||
|
dryRun.TotalPriceCents == nil ||
|
||||||
|
*dryRun.TotalPriceCents != write.TotalPriceCents {
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
if _, found, err := getOrderSubmissionByAuthorization(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
authorization.ID,
|
||||||
|
); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
} else if found {
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO order_submissions (
|
||||||
|
id, authorization_id, dry_run_id, task_id, execution_id,
|
||||||
|
command_sha256, dry_run_evidence_sha256, status,
|
||||||
|
expected_title, expected_sku, expected_quantity,
|
||||||
|
expected_unit_price_cents, expected_total_price_cents, fenced_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 'FENCED', ?, ?, ?, ?, ?, ?)`,
|
||||||
|
write.SubmissionID,
|
||||||
|
authorization.ID,
|
||||||
|
dryRun.ID,
|
||||||
|
write.TaskID,
|
||||||
|
write.ExecutionID,
|
||||||
|
write.CommandSHA256,
|
||||||
|
write.DryRunEvidenceSHA256,
|
||||||
|
write.ObservedTitle,
|
||||||
|
write.SelectedSKU,
|
||||||
|
write.Quantity,
|
||||||
|
write.UnitPriceCents,
|
||||||
|
write.TotalPriceCents,
|
||||||
|
formatTimestamp(write.Now),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if err := insertTaskEvent(ctx, tx, write.Event); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if err := insertOrderSubmissionRequest(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.DeviceID,
|
||||||
|
"START",
|
||||||
|
write.IdempotencyKey,
|
||||||
|
write.RequestSHA256,
|
||||||
|
write.TaskID,
|
||||||
|
write.SubmissionID,
|
||||||
|
write.Now,
|
||||||
|
); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
submission, err := getOrderSubmissionByID(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.SubmissionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return submission, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ReconcileOrderSubmission(
|
||||||
|
ctx context.Context,
|
||||||
|
write usecase.ReconcileOrderSubmissionWrite,
|
||||||
|
) (domain.OrderSubmission, bool, error) {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
if result, found, err := replayOrderSubmissionRequest(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.DeviceID,
|
||||||
|
"RECONCILE",
|
||||||
|
write.IdempotencyKey,
|
||||||
|
write.RequestSHA256,
|
||||||
|
write.TaskID,
|
||||||
|
); err != nil || found {
|
||||||
|
return commitOrderSubmissionReplay(tx, result, found, err)
|
||||||
|
}
|
||||||
|
if err := validateFencedSubmissionOwner(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.UserID,
|
||||||
|
write.DeviceID,
|
||||||
|
write.TaskID,
|
||||||
|
write.ExecutionID,
|
||||||
|
write.ClaimGeneration,
|
||||||
|
write.ClaimTokenHash,
|
||||||
|
); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
authorization, err := getOrderAuthorization(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.AuthorizationID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if err := validateSubmissionAuthorization(
|
||||||
|
authorization,
|
||||||
|
write.UserID,
|
||||||
|
write.DeviceID,
|
||||||
|
write.TaskID,
|
||||||
|
write.ExecutionID,
|
||||||
|
write.ClaimGeneration,
|
||||||
|
write.CommandSHA256,
|
||||||
|
); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if authorization.Status != domain.OrderAuthorizationExecuting {
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
submission, err := getOrderSubmissionByID(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.SubmissionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if err := validateSubmissionRecord(
|
||||||
|
submission,
|
||||||
|
authorization.ID,
|
||||||
|
write.TaskID,
|
||||||
|
write.ExecutionID,
|
||||||
|
write.CommandSHA256,
|
||||||
|
); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if submission.Status != domain.OrderSubmissionFenced &&
|
||||||
|
submission.Status != domain.OrderSubmissionManualReview {
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
if normalizeSubmissionText(write.ObservedTitle) !=
|
||||||
|
normalizeSubmissionText(submission.ExpectedTitle) ||
|
||||||
|
write.SelectedSKU != submission.ExpectedSKU ||
|
||||||
|
write.Quantity != submission.ExpectedQuantity ||
|
||||||
|
write.TotalPriceCents != submission.ExpectedTotalPriceCents ||
|
||||||
|
write.PlatformOrderStatus != "PENDING_PAYMENT" {
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
if write.ParsedPlatformOrderedAt.Before(
|
||||||
|
submission.FencedAt.Add(-orderReconciliationClockWindow),
|
||||||
|
) || write.ParsedPlatformOrderedAt.After(
|
||||||
|
write.Now.Add(orderReconciliationClockWindow),
|
||||||
|
) {
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
evidence, err := getExecutionEvidence(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.EvidenceAssetID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if evidence.TaskID != write.TaskID ||
|
||||||
|
evidence.ExecutionID != write.ExecutionID ||
|
||||||
|
evidence.MediaType != "image/jpeg" ||
|
||||||
|
evidence.SHA256 != write.EvidenceSHA256 {
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE order_submissions
|
||||||
|
SET status = 'RECONCILED',
|
||||||
|
platform_order_no = ?,
|
||||||
|
platform_ordered_at = ?,
|
||||||
|
platform_order_status = 'PENDING_PAYMENT',
|
||||||
|
reconciliation_evidence_asset_id = ?,
|
||||||
|
reconciliation_evidence_sha256 = ?,
|
||||||
|
manual_reason_code = NULL,
|
||||||
|
reconciled_at = ?,
|
||||||
|
manual_review_at = NULL
|
||||||
|
WHERE id = ? AND status IN ('FENCED', 'MANUAL_REVIEW')`,
|
||||||
|
write.PlatformOrderNo,
|
||||||
|
formatTimestamp(write.ParsedPlatformOrderedAt),
|
||||||
|
write.EvidenceAssetID,
|
||||||
|
write.EvidenceSHA256,
|
||||||
|
formatTimestamp(write.Now),
|
||||||
|
submission.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
result, err = tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE order_authorizations
|
||||||
|
SET status = 'CONSUMED', consumed_at = ?
|
||||||
|
WHERE id = ? AND status = 'EXECUTING'`,
|
||||||
|
formatTimestamp(write.Now),
|
||||||
|
authorization.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
if err := finishReconciledOrderExecution(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write,
|
||||||
|
authorization,
|
||||||
|
); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if err := insertTaskEvent(ctx, tx, write.Event); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if err := insertOrderSubmissionRequest(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.DeviceID,
|
||||||
|
"RECONCILE",
|
||||||
|
write.IdempotencyKey,
|
||||||
|
write.RequestSHA256,
|
||||||
|
write.TaskID,
|
||||||
|
submission.ID,
|
||||||
|
write.Now,
|
||||||
|
); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
submission, err = getOrderSubmissionByID(ctx, tx, submission.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return submission, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ManualReviewOrderSubmission(
|
||||||
|
ctx context.Context,
|
||||||
|
write usecase.ManualReviewOrderSubmissionWrite,
|
||||||
|
) (domain.OrderSubmission, bool, error) {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
if result, found, err := replayOrderSubmissionRequest(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.DeviceID,
|
||||||
|
"MANUAL_REVIEW",
|
||||||
|
write.IdempotencyKey,
|
||||||
|
write.RequestSHA256,
|
||||||
|
write.TaskID,
|
||||||
|
); err != nil || found {
|
||||||
|
return commitOrderSubmissionReplay(tx, result, found, err)
|
||||||
|
}
|
||||||
|
if err := validateFencedSubmissionOwner(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.UserID,
|
||||||
|
write.DeviceID,
|
||||||
|
write.TaskID,
|
||||||
|
write.ExecutionID,
|
||||||
|
write.ClaimGeneration,
|
||||||
|
write.ClaimTokenHash,
|
||||||
|
); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
authorization, err := getOrderAuthorization(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.AuthorizationID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if err := validateSubmissionAuthorization(
|
||||||
|
authorization,
|
||||||
|
write.UserID,
|
||||||
|
write.DeviceID,
|
||||||
|
write.TaskID,
|
||||||
|
write.ExecutionID,
|
||||||
|
write.ClaimGeneration,
|
||||||
|
write.CommandSHA256,
|
||||||
|
); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if authorization.Status != domain.OrderAuthorizationExecuting {
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
submission, err := getOrderSubmissionByID(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.SubmissionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if err := validateSubmissionRecord(
|
||||||
|
submission,
|
||||||
|
authorization.ID,
|
||||||
|
write.TaskID,
|
||||||
|
write.ExecutionID,
|
||||||
|
write.CommandSHA256,
|
||||||
|
); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if submission.Status != domain.OrderSubmissionFenced {
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
var evidenceID, evidenceSHA any
|
||||||
|
if write.EvidenceAssetID != "" {
|
||||||
|
evidence, err := getExecutionEvidence(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.EvidenceAssetID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if evidence.TaskID != write.TaskID ||
|
||||||
|
evidence.ExecutionID != write.ExecutionID ||
|
||||||
|
evidence.MediaType != "image/jpeg" ||
|
||||||
|
evidence.SHA256 != write.EvidenceSHA256 {
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
evidenceID = write.EvidenceAssetID
|
||||||
|
evidenceSHA = write.EvidenceSHA256
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE order_submissions
|
||||||
|
SET status = 'MANUAL_REVIEW',
|
||||||
|
reconciliation_evidence_asset_id = ?,
|
||||||
|
reconciliation_evidence_sha256 = ?,
|
||||||
|
manual_reason_code = ?,
|
||||||
|
manual_review_at = ?
|
||||||
|
WHERE id = ? AND status = 'FENCED'`,
|
||||||
|
evidenceID,
|
||||||
|
evidenceSHA,
|
||||||
|
write.ReasonCode,
|
||||||
|
formatTimestamp(write.Now),
|
||||||
|
submission.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
if err := insertTaskEvent(ctx, tx, write.Event); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if err := insertOrderSubmissionRequest(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.DeviceID,
|
||||||
|
"MANUAL_REVIEW",
|
||||||
|
write.IdempotencyKey,
|
||||||
|
write.RequestSHA256,
|
||||||
|
write.TaskID,
|
||||||
|
submission.ID,
|
||||||
|
write.Now,
|
||||||
|
); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
submission, err = getOrderSubmissionByID(ctx, tx, submission.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return submission, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSubmissionAuthorization(
|
||||||
|
authorization domain.OrderAuthorization,
|
||||||
|
userID, deviceID, taskID, executionID string,
|
||||||
|
claimGeneration int64,
|
||||||
|
commandSHA256 string,
|
||||||
|
) error {
|
||||||
|
if authorization.UserID != userID ||
|
||||||
|
authorization.DeviceID != deviceID ||
|
||||||
|
authorization.TaskID != taskID ||
|
||||||
|
authorization.ExecutionID != executionID ||
|
||||||
|
authorization.ClaimGeneration != claimGeneration ||
|
||||||
|
authorization.CommandSHA256 == nil ||
|
||||||
|
*authorization.CommandSHA256 != commandSHA256 {
|
||||||
|
return usecase.ErrExecutionMismatch
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFencedSubmissionOwner(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *sql.Tx,
|
||||||
|
userID, deviceID, taskID, executionID string,
|
||||||
|
claimGeneration int64,
|
||||||
|
claimTokenHash string,
|
||||||
|
) error {
|
||||||
|
task, err := getClaimProtectedTask(ctx, tx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateClaimOwner(
|
||||||
|
task,
|
||||||
|
userID,
|
||||||
|
deviceID,
|
||||||
|
claimGeneration,
|
||||||
|
claimTokenHash,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if task.Status != domain.TaskStatusWaitingConfirmation ||
|
||||||
|
task.CancelRequestedAt != nil {
|
||||||
|
return usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
execution, err := getExecutionByID(ctx, tx, executionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if execution.TaskID != taskID ||
|
||||||
|
execution.UserID != userID ||
|
||||||
|
execution.DeviceID != deviceID ||
|
||||||
|
execution.ClaimGeneration != claimGeneration ||
|
||||||
|
execution.FinishedAt != nil {
|
||||||
|
return usecase.ErrExecutionMismatch
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSubmissionRecord(
|
||||||
|
submission domain.OrderSubmission,
|
||||||
|
authorizationID, taskID, executionID, commandSHA256 string,
|
||||||
|
) error {
|
||||||
|
if submission.AuthorizationID != authorizationID ||
|
||||||
|
submission.TaskID != taskID ||
|
||||||
|
submission.ExecutionID != executionID ||
|
||||||
|
submission.CommandSHA256 != commandSHA256 {
|
||||||
|
return usecase.ErrExecutionMismatch
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishReconciledOrderExecution(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *sql.Tx,
|
||||||
|
write usecase.ReconcileOrderSubmissionWrite,
|
||||||
|
authorization domain.OrderAuthorization,
|
||||||
|
) error {
|
||||||
|
var executionMode string
|
||||||
|
err := tx.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT COALESCE(
|
||||||
|
(SELECT execution_mode
|
||||||
|
FROM execution_candidate_batches
|
||||||
|
WHERE execution_id = ? AND task_id = ?),
|
||||||
|
(SELECT execution_mode
|
||||||
|
FROM candidate_search_runs
|
||||||
|
WHERE execution_id = ? AND task_id = ?)
|
||||||
|
)`,
|
||||||
|
write.ExecutionID,
|
||||||
|
write.TaskID,
|
||||||
|
write.ExecutionID,
|
||||||
|
write.TaskID,
|
||||||
|
).Scan(&executionMode)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return usecase.ErrRepositoryInvariant
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
evidenceIDs, err := json.Marshal([]string{write.EvidenceAssetID})
|
||||||
|
if err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
expired := false
|
||||||
|
var claimExpiresAt sql.NullString
|
||||||
|
if err := tx.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT claim_expires_at FROM purchase_tasks WHERE id = ?`,
|
||||||
|
write.TaskID,
|
||||||
|
).Scan(&claimExpiresAt); err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if !claimExpiresAt.Valid {
|
||||||
|
expired = true
|
||||||
|
} else {
|
||||||
|
expiresAt, err := parseTimestamp(claimExpiresAt.String)
|
||||||
|
if err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
expired = !expiresAt.After(write.Now)
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO execution_outcomes (
|
||||||
|
execution_id, task_id, result_type, execution_mode,
|
||||||
|
task_content_sha256, outcome, operator_reason,
|
||||||
|
selected_candidate_json, evidence_asset_ids_json,
|
||||||
|
error_code, error_message, error_step, retryable,
|
||||||
|
order_submitted, received_at,
|
||||||
|
received_after_execution_expiry
|
||||||
|
) VALUES (?, ?, 'COMPLETE', ?, ?, 'CANDIDATE_ACCEPTED',
|
||||||
|
?, NULL, ?, NULL, NULL, NULL, NULL, 1, ?, ?)`,
|
||||||
|
write.ExecutionID,
|
||||||
|
write.TaskID,
|
||||||
|
executionMode,
|
||||||
|
authorization.TaskContentSHA256,
|
||||||
|
"pending-payment order uniquely reconciled",
|
||||||
|
string(evidenceIDs),
|
||||||
|
formatTimestamp(write.Now),
|
||||||
|
expired,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE task_executions
|
||||||
|
SET current_step = 'ORDER_RECONCILED',
|
||||||
|
last_heartbeat_at = ?,
|
||||||
|
finished_at = ?
|
||||||
|
WHERE id = ? AND finished_at IS NULL`,
|
||||||
|
formatTimestamp(write.Now),
|
||||||
|
formatTimestamp(write.Now),
|
||||||
|
write.ExecutionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||||
|
if err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
result, err = tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE purchase_tasks
|
||||||
|
SET status = 'SUCCEEDED',
|
||||||
|
version = version + 1,
|
||||||
|
claimed_by_user_id = NULL,
|
||||||
|
claimed_by_device_id = NULL,
|
||||||
|
claim_token_hash = NULL,
|
||||||
|
claim_issued_at = NULL,
|
||||||
|
claim_expires_at = NULL,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND status = 'WAITING_CONFIRMATION'
|
||||||
|
AND claim_generation = ?`,
|
||||||
|
formatTimestamp(write.Now),
|
||||||
|
write.TaskID,
|
||||||
|
write.ClaimGeneration,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||||
|
if err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrderSubmissionByAuthorization(
|
||||||
|
ctx context.Context,
|
||||||
|
queryer queryRower,
|
||||||
|
authorizationID string,
|
||||||
|
) (domain.OrderSubmission, bool, error) {
|
||||||
|
submission, err := scanOrderSubmission(queryer.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
orderSubmissionSelect+` WHERE authorization_id = ?`,
|
||||||
|
authorizationID,
|
||||||
|
))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.OrderSubmission{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return submission, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrderSubmissionByID(
|
||||||
|
ctx context.Context,
|
||||||
|
queryer queryRower,
|
||||||
|
submissionID string,
|
||||||
|
) (domain.OrderSubmission, error) {
|
||||||
|
submission, err := scanOrderSubmission(queryer.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
orderSubmissionSelect+` WHERE id = ?`,
|
||||||
|
submissionID,
|
||||||
|
))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.OrderSubmission{}, usecase.ErrRepositoryNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return submission, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderSubmissionSelect = `SELECT
|
||||||
|
id, authorization_id, dry_run_id, task_id, execution_id,
|
||||||
|
command_sha256, dry_run_evidence_sha256, status,
|
||||||
|
expected_title, expected_sku, expected_quantity,
|
||||||
|
expected_unit_price_cents, expected_total_price_cents,
|
||||||
|
platform_order_no, platform_ordered_at, platform_order_status,
|
||||||
|
reconciliation_evidence_asset_id, reconciliation_evidence_sha256,
|
||||||
|
manual_reason_code, fenced_at, reconciled_at, manual_review_at
|
||||||
|
FROM order_submissions`
|
||||||
|
|
||||||
|
func scanOrderSubmission(
|
||||||
|
scanner rowScanner,
|
||||||
|
) (domain.OrderSubmission, error) {
|
||||||
|
var submission domain.OrderSubmission
|
||||||
|
var orderNo, orderedAt, orderStatus sql.NullString
|
||||||
|
var evidenceID, evidenceSHA, manualReason sql.NullString
|
||||||
|
var fencedAt string
|
||||||
|
var reconciledAt, manualReviewAt sql.NullString
|
||||||
|
err := scanner.Scan(
|
||||||
|
&submission.ID,
|
||||||
|
&submission.AuthorizationID,
|
||||||
|
&submission.DryRunID,
|
||||||
|
&submission.TaskID,
|
||||||
|
&submission.ExecutionID,
|
||||||
|
&submission.CommandSHA256,
|
||||||
|
&submission.DryRunEvidenceSHA256,
|
||||||
|
&submission.Status,
|
||||||
|
&submission.ExpectedTitle,
|
||||||
|
&submission.ExpectedSKU,
|
||||||
|
&submission.ExpectedQuantity,
|
||||||
|
&submission.ExpectedUnitPriceCents,
|
||||||
|
&submission.ExpectedTotalPriceCents,
|
||||||
|
&orderNo,
|
||||||
|
&orderedAt,
|
||||||
|
&orderStatus,
|
||||||
|
&evidenceID,
|
||||||
|
&evidenceSHA,
|
||||||
|
&manualReason,
|
||||||
|
&fencedAt,
|
||||||
|
&reconciledAt,
|
||||||
|
&manualReviewAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, err
|
||||||
|
}
|
||||||
|
submission.PlatformOrderNo = optionalSQLString(orderNo)
|
||||||
|
submission.PlatformOrderStatus = optionalSQLString(orderStatus)
|
||||||
|
submission.ReconciliationEvidenceAssetID = optionalSQLString(evidenceID)
|
||||||
|
submission.ReconciliationEvidenceSHA256 = optionalSQLString(evidenceSHA)
|
||||||
|
submission.ManualReasonCode = optionalSQLString(manualReason)
|
||||||
|
submission.FencedAt, err = parseTimestamp(fencedAt)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, err
|
||||||
|
}
|
||||||
|
if orderedAt.Valid {
|
||||||
|
parsed, parseErr := parseTimestamp(orderedAt.String)
|
||||||
|
if parseErr != nil {
|
||||||
|
return domain.OrderSubmission{}, parseErr
|
||||||
|
}
|
||||||
|
submission.PlatformOrderedAt = &parsed
|
||||||
|
}
|
||||||
|
if submission.ReconciledAt, err = parseNullableTimestamp(reconciledAt); err != nil {
|
||||||
|
return domain.OrderSubmission{}, err
|
||||||
|
}
|
||||||
|
if submission.ManualReviewAt, err = parseNullableTimestamp(manualReviewAt); err != nil {
|
||||||
|
return domain.OrderSubmission{}, err
|
||||||
|
}
|
||||||
|
return submission, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalSQLString(value sql.NullString) *string {
|
||||||
|
if !value.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value.String
|
||||||
|
}
|
||||||
|
|
||||||
|
func replayOrderSubmissionRequest(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *sql.Tx,
|
||||||
|
deviceID, operation, idempotencyKey, requestSHA256, taskID string,
|
||||||
|
) (domain.OrderSubmission, bool, error) {
|
||||||
|
var knownHash, knownTask, submissionID string
|
||||||
|
err := tx.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT request_sha256, task_id, submission_id
|
||||||
|
FROM device_order_submission_requests
|
||||||
|
WHERE device_id = ? AND operation = ? AND idempotency_key = ?`,
|
||||||
|
deviceID,
|
||||||
|
operation,
|
||||||
|
idempotencyKey,
|
||||||
|
).Scan(&knownHash, &knownTask, &submissionID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.OrderSubmission{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if knownHash != requestSHA256 || knownTask != taskID {
|
||||||
|
return domain.OrderSubmission{}, false, usecase.ErrIdempotencyConflict
|
||||||
|
}
|
||||||
|
result, err := getOrderSubmissionByID(ctx, tx, submissionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
return result, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitOrderSubmissionReplay(
|
||||||
|
tx *sql.Tx,
|
||||||
|
result domain.OrderSubmission,
|
||||||
|
found bool,
|
||||||
|
err error,
|
||||||
|
) (domain.OrderSubmission, bool, error) {
|
||||||
|
if err != nil || !found {
|
||||||
|
return domain.OrderSubmission{}, false, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return result, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertOrderSubmissionRequest(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *sql.Tx,
|
||||||
|
deviceID, operation, idempotencyKey, requestSHA256, taskID,
|
||||||
|
submissionID string,
|
||||||
|
now time.Time,
|
||||||
|
) error {
|
||||||
|
_, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO device_order_submission_requests (
|
||||||
|
device_id, operation, idempotency_key, request_sha256,
|
||||||
|
task_id, submission_id, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
deviceID,
|
||||||
|
operation,
|
||||||
|
idempotencyKey,
|
||||||
|
requestSHA256,
|
||||||
|
taskID,
|
||||||
|
submissionID,
|
||||||
|
formatTimestamp(now.UTC()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSubmissionText(value string) string {
|
||||||
|
return strings.Map(func(character rune) rune {
|
||||||
|
if unicode.IsSpace(character) || unicode.IsPunct(character) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return unicode.ToLower(character)
|
||||||
|
}, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ usecase.OrderSubmissionRepository = (*Store)(nil)
|
||||||
@@ -410,6 +410,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migration.New() error = %v", err)
|
t.Fatalf("migration.New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
|
t.Fatalf("order submission migration down: %v", err)
|
||||||
|
}
|
||||||
if err := runner.Down(context.Background()); err != nil {
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
t.Fatalf("order dry-run migration down: %v", err)
|
t.Fatalf("order dry-run migration down: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,17 +17,18 @@ import (
|
|||||||
const claimTokenHeader = "X-Claim-Token"
|
const claimTokenHeader = "X-Claim-Token"
|
||||||
|
|
||||||
type DeviceServices struct {
|
type DeviceServices struct {
|
||||||
Lifecycle *usecase.LifecycleService
|
Lifecycle *usecase.LifecycleService
|
||||||
Assets *usecase.AssetService
|
Assets *usecase.AssetService
|
||||||
Results *usecase.ExecutionResultService
|
Results *usecase.ExecutionResultService
|
||||||
Commands *usecase.DeviceOrderCommandService
|
Commands *usecase.DeviceOrderCommandService
|
||||||
DryRuns *usecase.OrderDryRunService
|
DryRuns *usecase.OrderDryRunService
|
||||||
|
Submissions *usecase.OrderSubmissionService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (services DeviceServices) validate() error {
|
func (services DeviceServices) validate() error {
|
||||||
if services.Lifecycle == nil || services.Assets == nil ||
|
if services.Lifecycle == nil || services.Assets == nil ||
|
||||||
services.Results == nil || services.Commands == nil ||
|
services.Results == nil || services.Commands == nil ||
|
||||||
services.DryRuns == nil {
|
services.DryRuns == nil || services.Submissions == nil {
|
||||||
return errors.New("device services are required")
|
return errors.New("device services are required")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -93,12 +94,215 @@ func NewDeviceRouteRegistrar(
|
|||||||
"/api/v1/tasks/:id/order-dry-runs/:command_id/ready",
|
"/api/v1/tasks/:id/order-dry-runs/:command_id/ready",
|
||||||
handler.readyOrderDryRun,
|
handler.readyOrderDryRun,
|
||||||
)
|
)
|
||||||
|
routes.POST(
|
||||||
|
"/api/v1/tasks/:id/order-submissions/start",
|
||||||
|
handler.startOrderSubmission,
|
||||||
|
)
|
||||||
|
routes.POST(
|
||||||
|
"/api/v1/tasks/:id/order-submissions/:submission_id/reconcile",
|
||||||
|
handler.reconcileOrderSubmission,
|
||||||
|
)
|
||||||
|
routes.POST(
|
||||||
|
"/api/v1/tasks/:id/order-submissions/:submission_id/manual-review",
|
||||||
|
handler.manualReviewOrderSubmission,
|
||||||
|
)
|
||||||
routes.POST("/api/v1/tasks/:id/complete", handler.completeTask)
|
routes.POST("/api/v1/tasks/:id/complete", handler.completeTask)
|
||||||
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
|
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
|
||||||
return nil
|
return nil
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (handler *deviceHandlers) startOrderSubmission(ctx *gin.Context) {
|
||||||
|
principal, ok := devicePrincipal(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
ExecutionID string `json:"execution_id"`
|
||||||
|
ClaimGeneration int64 `json:"claim_generation"`
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
CommandSHA256 string `json:"command_sha256"`
|
||||||
|
DryRunID string `json:"dry_run_id"`
|
||||||
|
DryRunEvidenceSHA256 string `json:"dry_run_evidence_sha256"`
|
||||||
|
ObservedTitle string `json:"observed_title"`
|
||||||
|
SelectedSKU string `json:"selected_sku"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
UnitPriceCents int64 `json:"unit_price_cents"`
|
||||||
|
TotalPriceCents int64 `json:"total_price_cents"`
|
||||||
|
}
|
||||||
|
if !decodeDeviceJSON(ctx, &request) ||
|
||||||
|
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := handler.services.Submissions.Start(
|
||||||
|
ctx.Request.Context(),
|
||||||
|
usecase.StartOrderSubmissionCommand{
|
||||||
|
UserID: principal.UserID,
|
||||||
|
DeviceID: principal.DeviceID,
|
||||||
|
TaskID: ctx.Param("id"),
|
||||||
|
ExecutionID: request.ExecutionID,
|
||||||
|
AuthorizationID: request.CommandID,
|
||||||
|
ClaimGeneration: request.ClaimGeneration,
|
||||||
|
ClaimToken: ctx.GetHeader(claimTokenHeader),
|
||||||
|
CommandSHA256: request.CommandSHA256,
|
||||||
|
DryRunID: request.DryRunID,
|
||||||
|
DryRunEvidenceSHA256: request.DryRunEvidenceSHA256,
|
||||||
|
ObservedTitle: request.ObservedTitle,
|
||||||
|
SelectedSKU: request.SelectedSKU,
|
||||||
|
Quantity: request.Quantity,
|
||||||
|
UnitPriceCents: request.UnitPriceCents,
|
||||||
|
TotalPriceCents: request.TotalPriceCents,
|
||||||
|
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeUsecaseError(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOrderSubmission(ctx, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *deviceHandlers) reconcileOrderSubmission(ctx *gin.Context) {
|
||||||
|
principal, ok := devicePrincipal(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
ExecutionID string `json:"execution_id"`
|
||||||
|
ClaimGeneration int64 `json:"claim_generation"`
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
CommandSHA256 string `json:"command_sha256"`
|
||||||
|
PlatformOrderNo string `json:"platform_order_no"`
|
||||||
|
PlatformOrderedAt string `json:"platform_ordered_at"`
|
||||||
|
PlatformOrderStatus string `json:"platform_order_status"`
|
||||||
|
ObservedTitle string `json:"observed_title"`
|
||||||
|
SelectedSKU string `json:"selected_sku"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
TotalPriceCents int64 `json:"total_price_cents"`
|
||||||
|
EvidenceAssetID string `json:"evidence_asset_id"`
|
||||||
|
EvidenceSHA256 string `json:"evidence_sha256"`
|
||||||
|
}
|
||||||
|
if !decodeDeviceJSON(ctx, &request) ||
|
||||||
|
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := handler.services.Submissions.Reconcile(
|
||||||
|
ctx.Request.Context(),
|
||||||
|
usecase.ReconcileOrderSubmissionCommand{
|
||||||
|
UserID: principal.UserID,
|
||||||
|
DeviceID: principal.DeviceID,
|
||||||
|
TaskID: ctx.Param("id"),
|
||||||
|
ExecutionID: request.ExecutionID,
|
||||||
|
AuthorizationID: request.CommandID,
|
||||||
|
ClaimGeneration: request.ClaimGeneration,
|
||||||
|
ClaimToken: ctx.GetHeader(claimTokenHeader),
|
||||||
|
CommandSHA256: request.CommandSHA256,
|
||||||
|
SubmissionID: ctx.Param("submission_id"),
|
||||||
|
PlatformOrderNo: request.PlatformOrderNo,
|
||||||
|
PlatformOrderedAt: request.PlatformOrderedAt,
|
||||||
|
PlatformOrderStatus: request.PlatformOrderStatus,
|
||||||
|
ObservedTitle: request.ObservedTitle,
|
||||||
|
SelectedSKU: request.SelectedSKU,
|
||||||
|
Quantity: request.Quantity,
|
||||||
|
TotalPriceCents: request.TotalPriceCents,
|
||||||
|
EvidenceAssetID: request.EvidenceAssetID,
|
||||||
|
EvidenceSHA256: request.EvidenceSHA256,
|
||||||
|
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeUsecaseError(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOrderSubmission(ctx, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *deviceHandlers) manualReviewOrderSubmission(ctx *gin.Context) {
|
||||||
|
principal, ok := devicePrincipal(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
ExecutionID string `json:"execution_id"`
|
||||||
|
ClaimGeneration int64 `json:"claim_generation"`
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
CommandSHA256 string `json:"command_sha256"`
|
||||||
|
ReasonCode string `json:"reason_code"`
|
||||||
|
EvidenceAssetID string `json:"evidence_asset_id"`
|
||||||
|
EvidenceSHA256 string `json:"evidence_sha256"`
|
||||||
|
}
|
||||||
|
if !decodeDeviceJSON(ctx, &request) ||
|
||||||
|
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := handler.services.Submissions.ManualReview(
|
||||||
|
ctx.Request.Context(),
|
||||||
|
usecase.ManualReviewOrderSubmissionCommand{
|
||||||
|
UserID: principal.UserID,
|
||||||
|
DeviceID: principal.DeviceID,
|
||||||
|
TaskID: ctx.Param("id"),
|
||||||
|
ExecutionID: request.ExecutionID,
|
||||||
|
AuthorizationID: request.CommandID,
|
||||||
|
ClaimGeneration: request.ClaimGeneration,
|
||||||
|
ClaimToken: ctx.GetHeader(claimTokenHeader),
|
||||||
|
CommandSHA256: request.CommandSHA256,
|
||||||
|
SubmissionID: ctx.Param("submission_id"),
|
||||||
|
ReasonCode: request.ReasonCode,
|
||||||
|
EvidenceAssetID: request.EvidenceAssetID,
|
||||||
|
EvidenceSHA256: request.EvidenceSHA256,
|
||||||
|
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeUsecaseError(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOrderSubmission(ctx, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOrderSubmission(
|
||||||
|
ctx *gin.Context,
|
||||||
|
result usecase.OrderSubmissionResult,
|
||||||
|
) {
|
||||||
|
ctx.Header("Cache-Control", "no-store")
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
|
"submission": orderSubmissionResponse(result.Submission),
|
||||||
|
"replayed": result.Replayed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderSubmissionResponse(submission domain.OrderSubmission) gin.H {
|
||||||
|
return gin.H{
|
||||||
|
"id": submission.ID,
|
||||||
|
"command_id": submission.AuthorizationID,
|
||||||
|
"dry_run_id": submission.DryRunID,
|
||||||
|
"task_id": submission.TaskID,
|
||||||
|
"execution_id": submission.ExecutionID,
|
||||||
|
"command_sha256": submission.CommandSHA256,
|
||||||
|
"dry_run_evidence_sha256": submission.DryRunEvidenceSHA256,
|
||||||
|
"status": submission.Status,
|
||||||
|
"expected_title": submission.ExpectedTitle,
|
||||||
|
"expected_sku": submission.ExpectedSKU,
|
||||||
|
"expected_quantity": submission.ExpectedQuantity,
|
||||||
|
"expected_unit_price_cents": submission.ExpectedUnitPriceCents,
|
||||||
|
"expected_total_price_cents": submission.ExpectedTotalPriceCents,
|
||||||
|
"platform_order_no": submission.PlatformOrderNo,
|
||||||
|
"platform_ordered_at": formatOptionalTime(
|
||||||
|
submission.PlatformOrderedAt,
|
||||||
|
),
|
||||||
|
"platform_order_status": submission.PlatformOrderStatus,
|
||||||
|
"reconciliation_evidence_asset_id": submission.ReconciliationEvidenceAssetID,
|
||||||
|
"reconciliation_evidence_sha256": submission.ReconciliationEvidenceSHA256,
|
||||||
|
"manual_reason_code": submission.ManualReasonCode,
|
||||||
|
"fenced_at": formatTime(submission.FencedAt),
|
||||||
|
"reconciled_at": formatOptionalTime(submission.ReconciledAt),
|
||||||
|
"manual_review_at": formatOptionalTime(submission.ManualReviewAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (handler *deviceHandlers) startOrderDryRun(ctx *gin.Context) {
|
func (handler *deviceHandlers) startOrderDryRun(ctx *gin.Context) {
|
||||||
principal, ok := devicePrincipal(ctx)
|
principal, ok := devicePrincipal(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migration.New() after review error = %v", err)
|
t.Fatalf("migration.New() after review error = %v", err)
|
||||||
}
|
}
|
||||||
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
|
t.Fatalf("order submission migration down: %v", err)
|
||||||
|
}
|
||||||
if err := runner.Down(context.Background()); err != nil {
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
t.Fatalf("order dry-run migration down: %v", err)
|
t.Fatalf("order dry-run migration down: %v", err)
|
||||||
}
|
}
|
||||||
@@ -724,8 +727,8 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if applied, err := runner.Up(context.Background()); err != nil {
|
if applied, err := runner.Up(context.Background()); err != nil {
|
||||||
t.Fatalf("restore device command migration: %v", err)
|
t.Fatalf("restore device command migration: %v", err)
|
||||||
} else if applied != 2 {
|
} else if applied != 3 {
|
||||||
t.Fatalf("restored migrations = %d, want 2", applied)
|
t.Fatalf("restored migrations = %d, want 3", applied)
|
||||||
}
|
}
|
||||||
|
|
||||||
completePayload := fmt.Sprintf(
|
completePayload := fmt.Sprintf(
|
||||||
@@ -1125,6 +1128,12 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
|||||||
if !strings.Contains(readyDryRun.Body.String(), `"status":"READY"`) {
|
if !strings.Contains(readyDryRun.Body.String(), `"status":"READY"`) {
|
||||||
t.Fatalf("dry-run ready = %s", readyDryRun.Body.String())
|
t.Fatalf("dry-run ready = %s", readyDryRun.Body.String())
|
||||||
}
|
}
|
||||||
|
var readyDryRunResponse struct {
|
||||||
|
DryRun struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"dry_run"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, readyDryRun, &readyDryRunResponse)
|
||||||
readyDryRunReplay := performDeviceRequest(t, fixture.router, deviceRequest{
|
readyDryRunReplay := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||||
method: http.MethodPost,
|
method: http.MethodPost,
|
||||||
target: "/api/v1/tasks/" + taskID +
|
target: "/api/v1/tasks/" + taskID +
|
||||||
@@ -1139,12 +1148,182 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
|||||||
if !strings.Contains(readyDryRunReplay.Body.String(), `"replayed":true`) {
|
if !strings.Contains(readyDryRunReplay.Body.String(), `"replayed":true`) {
|
||||||
t.Fatalf("dry-run ready replay = %s", readyDryRunReplay.Body.String())
|
t.Fatalf("dry-run ready replay = %s", readyDryRunReplay.Body.String())
|
||||||
}
|
}
|
||||||
var deliveredEvents, acknowledgedEvents, dryRunStartedEvents, dryRunReadyEvents int
|
startSubmissionPayload := fmt.Sprintf(
|
||||||
|
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q,"dry_run_id":%q,"dry_run_evidence_sha256":%q,"observed_title":%q,"selected_sku":%q,"quantity":2,"unit_price_cents":2150,"total_price_cents":4300}`,
|
||||||
|
deviceTestDeviceID,
|
||||||
|
started.Execution.ID,
|
||||||
|
started.Task.ClaimGeneration,
|
||||||
|
command.ID,
|
||||||
|
command.CommandSHA256,
|
||||||
|
readyDryRunResponse.DryRun.ID,
|
||||||
|
dryRunEvidenceSHA,
|
||||||
|
command.Candidate.Title,
|
||||||
|
command.OriginalSKU,
|
||||||
|
)
|
||||||
|
startSubmission := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||||
|
method: http.MethodPost,
|
||||||
|
target: "/api/v1/tasks/" + taskID + "/order-submissions/start",
|
||||||
|
contentType: "application/json",
|
||||||
|
body: strings.NewReader(startSubmissionPayload),
|
||||||
|
bearerToken: testOpaqueToken,
|
||||||
|
claimToken: testOpaqueToken,
|
||||||
|
idempotencyKey: "order-submission-start",
|
||||||
|
})
|
||||||
|
requireDeviceStatus(t, startSubmission, http.StatusOK)
|
||||||
|
var submissionResponse struct {
|
||||||
|
Submission struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"submission"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, startSubmission, &submissionResponse)
|
||||||
|
if submissionResponse.Submission.ID == "" ||
|
||||||
|
!strings.Contains(startSubmission.Body.String(), `"status":"FENCED"`) {
|
||||||
|
t.Fatalf("submission start = %s", startSubmission.Body.String())
|
||||||
|
}
|
||||||
|
startSubmissionReplay := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||||
|
method: http.MethodPost,
|
||||||
|
target: "/api/v1/tasks/" + taskID + "/order-submissions/start",
|
||||||
|
contentType: "application/json",
|
||||||
|
body: strings.NewReader(startSubmissionPayload),
|
||||||
|
bearerToken: testOpaqueToken,
|
||||||
|
claimToken: testOpaqueToken,
|
||||||
|
idempotencyKey: "order-submission-start",
|
||||||
|
})
|
||||||
|
requireDeviceStatus(t, startSubmissionReplay, http.StatusOK)
|
||||||
|
if !strings.Contains(startSubmissionReplay.Body.String(), `"replayed":true`) {
|
||||||
|
t.Fatalf("submission start replay = %s", startSubmissionReplay.Body.String())
|
||||||
|
}
|
||||||
|
secondSubmission := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||||
|
method: http.MethodPost,
|
||||||
|
target: "/api/v1/tasks/" + taskID + "/order-submissions/start",
|
||||||
|
contentType: "application/json",
|
||||||
|
body: strings.NewReader(startSubmissionPayload),
|
||||||
|
bearerToken: testOpaqueToken,
|
||||||
|
claimToken: testOpaqueToken,
|
||||||
|
idempotencyKey: "order-submission-start-second",
|
||||||
|
})
|
||||||
|
requireDeviceStatus(t, secondSubmission, http.StatusConflict)
|
||||||
|
manualReviewPayload := fmt.Sprintf(
|
||||||
|
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q,"reason_code":"ORDER_PAGE_UNKNOWN"}`,
|
||||||
|
deviceTestDeviceID,
|
||||||
|
started.Execution.ID,
|
||||||
|
started.Task.ClaimGeneration,
|
||||||
|
command.ID,
|
||||||
|
command.CommandSHA256,
|
||||||
|
)
|
||||||
|
manualReview := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||||
|
method: http.MethodPost,
|
||||||
|
target: "/api/v1/tasks/" + taskID + "/order-submissions/" +
|
||||||
|
submissionResponse.Submission.ID + "/manual-review",
|
||||||
|
contentType: "application/json",
|
||||||
|
body: strings.NewReader(manualReviewPayload),
|
||||||
|
bearerToken: testOpaqueToken,
|
||||||
|
claimToken: testOpaqueToken,
|
||||||
|
idempotencyKey: "order-submission-manual",
|
||||||
|
})
|
||||||
|
requireDeviceStatus(t, manualReview, http.StatusOK)
|
||||||
|
if !strings.Contains(manualReview.Body.String(), `"status":"MANUAL_REVIEW"`) {
|
||||||
|
t.Fatalf("submission manual review = %s", manualReview.Body.String())
|
||||||
|
}
|
||||||
|
const reconciliationEvidenceID = "00000000-0000-4000-8000-000000000078"
|
||||||
|
reconciliationEvidenceSHA := strings.Repeat("d", 64)
|
||||||
|
if _, err := fixture.db.Exec(
|
||||||
|
`INSERT INTO execution_evidence_assets (
|
||||||
|
id, task_id, execution_id, media_type, size_bytes, sha256,
|
||||||
|
storage_key, created_at, received_after_execution_expiry
|
||||||
|
) VALUES (?, ?, ?, 'image/jpeg', 10, ?, ?, ?, 0)`,
|
||||||
|
reconciliationEvidenceID,
|
||||||
|
taskID,
|
||||||
|
started.Execution.ID,
|
||||||
|
reconciliationEvidenceSHA,
|
||||||
|
"orders/reconciliation.jpg",
|
||||||
|
time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed reconciliation evidence: %v", err)
|
||||||
|
}
|
||||||
|
reconcilePayload := fmt.Sprintf(
|
||||||
|
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q,"platform_order_no":"12345678901234567890","platform_ordered_at":%q,"platform_order_status":"PENDING_PAYMENT","observed_title":%q,"selected_sku":%q,"quantity":2,"total_price_cents":4300,"evidence_asset_id":%q,"evidence_sha256":%q}`,
|
||||||
|
deviceTestDeviceID,
|
||||||
|
started.Execution.ID,
|
||||||
|
started.Task.ClaimGeneration,
|
||||||
|
command.ID,
|
||||||
|
command.CommandSHA256,
|
||||||
|
time.Now().UTC().Format(time.RFC3339),
|
||||||
|
command.Candidate.Title,
|
||||||
|
command.OriginalSKU,
|
||||||
|
reconciliationEvidenceID,
|
||||||
|
reconciliationEvidenceSHA,
|
||||||
|
)
|
||||||
|
reconciled := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||||
|
method: http.MethodPost,
|
||||||
|
target: "/api/v1/tasks/" + taskID + "/order-submissions/" +
|
||||||
|
submissionResponse.Submission.ID + "/reconcile",
|
||||||
|
contentType: "application/json",
|
||||||
|
body: strings.NewReader(reconcilePayload),
|
||||||
|
bearerToken: testOpaqueToken,
|
||||||
|
claimToken: testOpaqueToken,
|
||||||
|
idempotencyKey: "order-submission-reconcile",
|
||||||
|
})
|
||||||
|
requireDeviceStatus(t, reconciled, http.StatusOK)
|
||||||
|
if !strings.Contains(reconciled.Body.String(), `"status":"RECONCILED"`) ||
|
||||||
|
!strings.Contains(reconciled.Body.String(), `"platform_order_status":"PENDING_PAYMENT"`) {
|
||||||
|
t.Fatalf("submission reconciliation = %s", reconciled.Body.String())
|
||||||
|
}
|
||||||
|
reconciledReplay := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||||
|
method: http.MethodPost,
|
||||||
|
target: "/api/v1/tasks/" + taskID + "/order-submissions/" +
|
||||||
|
submissionResponse.Submission.ID + "/reconcile",
|
||||||
|
contentType: "application/json",
|
||||||
|
body: strings.NewReader(reconcilePayload),
|
||||||
|
bearerToken: testOpaqueToken,
|
||||||
|
claimToken: testOpaqueToken,
|
||||||
|
idempotencyKey: "order-submission-reconcile",
|
||||||
|
})
|
||||||
|
requireDeviceStatus(t, reconciledReplay, http.StatusOK)
|
||||||
|
if !strings.Contains(reconciledReplay.Body.String(), `"replayed":true`) {
|
||||||
|
t.Fatalf("submission reconcile replay = %s", reconciledReplay.Body.String())
|
||||||
|
}
|
||||||
|
var outcomeSubmitted bool
|
||||||
|
if err := fixture.db.QueryRow(
|
||||||
|
`SELECT order_submitted FROM execution_outcomes
|
||||||
|
WHERE execution_id = ?`,
|
||||||
|
started.Execution.ID,
|
||||||
|
).Scan(&outcomeSubmitted); err != nil {
|
||||||
|
t.Fatalf("query reconciled outcome: %v", err)
|
||||||
|
}
|
||||||
|
if !outcomeSubmitted {
|
||||||
|
t.Fatal("reconciled execution outcome did not record order_submitted")
|
||||||
|
}
|
||||||
|
var taskStatus, authorizationStatus string
|
||||||
|
if err := fixture.db.QueryRow(
|
||||||
|
`SELECT status FROM purchase_tasks WHERE id = ?`,
|
||||||
|
taskID,
|
||||||
|
).Scan(&taskStatus); err != nil {
|
||||||
|
t.Fatalf("query reconciled task: %v", err)
|
||||||
|
}
|
||||||
|
if err := fixture.db.QueryRow(
|
||||||
|
`SELECT status FROM order_authorizations WHERE id = ?`,
|
||||||
|
command.ID,
|
||||||
|
).Scan(&authorizationStatus); err != nil {
|
||||||
|
t.Fatalf("query consumed authorization: %v", err)
|
||||||
|
}
|
||||||
|
if taskStatus != "SUCCEEDED" || authorizationStatus != "CONSUMED" {
|
||||||
|
t.Fatalf(
|
||||||
|
"reconciled task/authorization = %s/%s",
|
||||||
|
taskStatus,
|
||||||
|
authorizationStatus,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var deliveredEvents, acknowledgedEvents, dryRunStartedEvents,
|
||||||
|
dryRunReadyEvents, fencedEvents, manualEvents, reconciledEvents int
|
||||||
for eventType, target := range map[string]*int{
|
for eventType, target := range map[string]*int{
|
||||||
"ORDER_AUTHORIZATION_DELIVERED": &deliveredEvents,
|
"ORDER_AUTHORIZATION_DELIVERED": &deliveredEvents,
|
||||||
"ORDER_AUTHORIZATION_ACKNOWLEDGED": &acknowledgedEvents,
|
"ORDER_AUTHORIZATION_ACKNOWLEDGED": &acknowledgedEvents,
|
||||||
"ORDER_DRY_RUN_STARTED": &dryRunStartedEvents,
|
"ORDER_DRY_RUN_STARTED": &dryRunStartedEvents,
|
||||||
"ORDER_DRY_RUN_READY": &dryRunReadyEvents,
|
"ORDER_DRY_RUN_READY": &dryRunReadyEvents,
|
||||||
|
"ORDER_SUBMISSION_FENCED": &fencedEvents,
|
||||||
|
"ORDER_SUBMISSION_MANUAL_REVIEW": &manualEvents,
|
||||||
|
"ORDER_SUBMISSION_RECONCILED": &reconciledEvents,
|
||||||
} {
|
} {
|
||||||
if err := fixture.db.QueryRow(
|
if err := fixture.db.QueryRow(
|
||||||
`SELECT COUNT(*) FROM task_events
|
`SELECT COUNT(*) FROM task_events
|
||||||
@@ -1156,13 +1335,17 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if deliveredEvents != 1 || acknowledgedEvents != 1 ||
|
if deliveredEvents != 1 || acknowledgedEvents != 1 ||
|
||||||
dryRunStartedEvents != 1 || dryRunReadyEvents != 1 {
|
dryRunStartedEvents != 1 || dryRunReadyEvents != 1 ||
|
||||||
|
fencedEvents != 1 || manualEvents != 1 || reconciledEvents != 1 {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"delivery/ack/dry-run events = %d/%d/%d/%d",
|
"order workflow events = %d/%d/%d/%d/%d/%d/%d",
|
||||||
deliveredEvents,
|
deliveredEvents,
|
||||||
acknowledgedEvents,
|
acknowledgedEvents,
|
||||||
dryRunStartedEvents,
|
dryRunStartedEvents,
|
||||||
dryRunReadyEvents,
|
dryRunReadyEvents,
|
||||||
|
fencedEvents,
|
||||||
|
manualEvents,
|
||||||
|
reconciledEvents,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
runner, err := migration.New(fixture.db)
|
runner, err := migration.New(fixture.db)
|
||||||
@@ -1170,7 +1353,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
|||||||
t.Fatalf("migration.New() error = %v", err)
|
t.Fatalf("migration.New() error = %v", err)
|
||||||
}
|
}
|
||||||
if err := runner.Down(context.Background()); err == nil {
|
if err := runner.Down(context.Background()); err == nil {
|
||||||
t.Fatal("order dry-run migration down succeeded with dry-run data")
|
t.Fatal("order submission migration down succeeded with retained data")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1503,13 +1686,18 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("usecase.NewOrderDryRunService() error = %v", err)
|
t.Fatalf("usecase.NewOrderDryRunService() error = %v", err)
|
||||||
}
|
}
|
||||||
|
submissions, err := usecase.NewOrderSubmissionService(store, clock, ids)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("usecase.NewOrderSubmissionService() error = %v", err)
|
||||||
|
}
|
||||||
deviceRoutes, err := NewDeviceRouteRegistrar(
|
deviceRoutes, err := NewDeviceRouteRegistrar(
|
||||||
DeviceServices{
|
DeviceServices{
|
||||||
Lifecycle: lifecycle,
|
Lifecycle: lifecycle,
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
Results: results,
|
Results: results,
|
||||||
Commands: commands,
|
Commands: commands,
|
||||||
DryRuns: dryRuns,
|
DryRuns: dryRuns,
|
||||||
|
Submissions: submissions,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,526 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
orderSubmissionStartOperation = "START"
|
||||||
|
orderSubmissionReconcileOperation = "RECONCILE"
|
||||||
|
orderSubmissionManualReviewOperation = "MANUAL_REVIEW"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StartOrderSubmissionCommand struct {
|
||||||
|
UserID string
|
||||||
|
DeviceID string
|
||||||
|
TaskID string
|
||||||
|
ExecutionID string
|
||||||
|
AuthorizationID string
|
||||||
|
ClaimGeneration int64
|
||||||
|
ClaimToken string
|
||||||
|
CommandSHA256 string
|
||||||
|
DryRunID string
|
||||||
|
DryRunEvidenceSHA256 string
|
||||||
|
ObservedTitle string
|
||||||
|
SelectedSKU string
|
||||||
|
Quantity int
|
||||||
|
UnitPriceCents int64
|
||||||
|
TotalPriceCents int64
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StartOrderSubmissionWrite struct {
|
||||||
|
StartOrderSubmissionCommand
|
||||||
|
SubmissionID string
|
||||||
|
ClaimTokenHash string
|
||||||
|
RequestSHA256 string
|
||||||
|
Now time.Time
|
||||||
|
Event domain.TaskEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReconcileOrderSubmissionCommand struct {
|
||||||
|
UserID string
|
||||||
|
DeviceID string
|
||||||
|
TaskID string
|
||||||
|
ExecutionID string
|
||||||
|
AuthorizationID string
|
||||||
|
ClaimGeneration int64
|
||||||
|
ClaimToken string
|
||||||
|
CommandSHA256 string
|
||||||
|
SubmissionID string
|
||||||
|
PlatformOrderNo string
|
||||||
|
PlatformOrderedAt string
|
||||||
|
PlatformOrderStatus string
|
||||||
|
ObservedTitle string
|
||||||
|
SelectedSKU string
|
||||||
|
Quantity int
|
||||||
|
TotalPriceCents int64
|
||||||
|
EvidenceAssetID string
|
||||||
|
EvidenceSHA256 string
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReconcileOrderSubmissionWrite struct {
|
||||||
|
ReconcileOrderSubmissionCommand
|
||||||
|
ParsedPlatformOrderedAt time.Time
|
||||||
|
ClaimTokenHash string
|
||||||
|
RequestSHA256 string
|
||||||
|
Now time.Time
|
||||||
|
Event domain.TaskEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManualReviewOrderSubmissionCommand struct {
|
||||||
|
UserID string
|
||||||
|
DeviceID string
|
||||||
|
TaskID string
|
||||||
|
ExecutionID string
|
||||||
|
AuthorizationID string
|
||||||
|
ClaimGeneration int64
|
||||||
|
ClaimToken string
|
||||||
|
CommandSHA256 string
|
||||||
|
SubmissionID string
|
||||||
|
ReasonCode string
|
||||||
|
EvidenceAssetID string
|
||||||
|
EvidenceSHA256 string
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManualReviewOrderSubmissionWrite struct {
|
||||||
|
ManualReviewOrderSubmissionCommand
|
||||||
|
ClaimTokenHash string
|
||||||
|
RequestSHA256 string
|
||||||
|
Now time.Time
|
||||||
|
Event domain.TaskEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderSubmissionResult struct {
|
||||||
|
Submission domain.OrderSubmission
|
||||||
|
Replayed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderSubmissionRepository interface {
|
||||||
|
StartOrderSubmission(
|
||||||
|
context.Context,
|
||||||
|
StartOrderSubmissionWrite,
|
||||||
|
) (domain.OrderSubmission, bool, error)
|
||||||
|
ReconcileOrderSubmission(
|
||||||
|
context.Context,
|
||||||
|
ReconcileOrderSubmissionWrite,
|
||||||
|
) (domain.OrderSubmission, bool, error)
|
||||||
|
ManualReviewOrderSubmission(
|
||||||
|
context.Context,
|
||||||
|
ManualReviewOrderSubmissionWrite,
|
||||||
|
) (domain.OrderSubmission, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderSubmissionService struct {
|
||||||
|
repository OrderSubmissionRepository
|
||||||
|
clock Clock
|
||||||
|
ids IDGenerator
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrderSubmissionService(
|
||||||
|
repository OrderSubmissionRepository,
|
||||||
|
clock Clock,
|
||||||
|
ids IDGenerator,
|
||||||
|
) (*OrderSubmissionService, error) {
|
||||||
|
if repository == nil || clock == nil || ids == nil {
|
||||||
|
return nil, errors.New("order submission service dependencies are required")
|
||||||
|
}
|
||||||
|
return &OrderSubmissionService{
|
||||||
|
repository: repository,
|
||||||
|
clock: clock,
|
||||||
|
ids: ids,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *OrderSubmissionService) Start(
|
||||||
|
ctx context.Context,
|
||||||
|
command StartOrderSubmissionCommand,
|
||||||
|
) (OrderSubmissionResult, error) {
|
||||||
|
command = normalizeStartOrderSubmission(command)
|
||||||
|
fields := orderSubmissionIdentityFields(
|
||||||
|
command.UserID,
|
||||||
|
command.DeviceID,
|
||||||
|
command.TaskID,
|
||||||
|
command.ExecutionID,
|
||||||
|
command.AuthorizationID,
|
||||||
|
command.ClaimGeneration,
|
||||||
|
command.ClaimToken,
|
||||||
|
command.CommandSHA256,
|
||||||
|
command.IdempotencyKey,
|
||||||
|
)
|
||||||
|
if !isUUID(command.DryRunID) {
|
||||||
|
fields["dry_run_id"] = "must be a UUID"
|
||||||
|
}
|
||||||
|
if !sha256Pattern.MatchString(command.DryRunEvidenceSHA256) {
|
||||||
|
fields["dry_run_evidence_sha256"] = "must be lowercase SHA-256"
|
||||||
|
}
|
||||||
|
validateOrderSnapshot(
|
||||||
|
fields,
|
||||||
|
command.ObservedTitle,
|
||||||
|
command.SelectedSKU,
|
||||||
|
command.Quantity,
|
||||||
|
command.UnitPriceCents,
|
||||||
|
command.TotalPriceCents,
|
||||||
|
)
|
||||||
|
if command.Quantity > 0 &&
|
||||||
|
command.UnitPriceCents <= math.MaxInt64/int64(command.Quantity) &&
|
||||||
|
command.TotalPriceCents !=
|
||||||
|
command.UnitPriceCents*int64(command.Quantity) {
|
||||||
|
fields["total_price_cents"] = "must equal unit price times quantity"
|
||||||
|
}
|
||||||
|
if len(fields) > 0 {
|
||||||
|
return OrderSubmissionResult{}, invalidError(
|
||||||
|
"ORDER_SUBMISSION_START_INVALID",
|
||||||
|
"order submission start request is invalid",
|
||||||
|
fields,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
requestHash, err := lifecycleRequestHash(command)
|
||||||
|
if err != nil {
|
||||||
|
return OrderSubmissionResult{}, internalLifecycleFailure(err)
|
||||||
|
}
|
||||||
|
submissionID, err := service.ids.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return OrderSubmissionResult{}, internalLifecycleFailure(err)
|
||||||
|
}
|
||||||
|
eventID, err := service.ids.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return OrderSubmissionResult{}, internalLifecycleFailure(err)
|
||||||
|
}
|
||||||
|
now := service.clock.Now().UTC()
|
||||||
|
userID, deviceID := command.UserID, command.DeviceID
|
||||||
|
submission, replayed, err := service.repository.StartOrderSubmission(
|
||||||
|
ctx,
|
||||||
|
StartOrderSubmissionWrite{
|
||||||
|
StartOrderSubmissionCommand: command,
|
||||||
|
SubmissionID: submissionID,
|
||||||
|
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||||
|
RequestSHA256: requestHash,
|
||||||
|
Now: now,
|
||||||
|
Event: domain.TaskEvent{
|
||||||
|
ID: eventID,
|
||||||
|
TaskID: command.TaskID,
|
||||||
|
ActorUserID: &userID,
|
||||||
|
ActorDeviceID: &deviceID,
|
||||||
|
Type: "ORDER_SUBMISSION_FENCED",
|
||||||
|
Message: "single order submission fenced",
|
||||||
|
OccurredAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err)
|
||||||
|
}
|
||||||
|
return OrderSubmissionResult{
|
||||||
|
Submission: submission,
|
||||||
|
Replayed: replayed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *OrderSubmissionService) Reconcile(
|
||||||
|
ctx context.Context,
|
||||||
|
command ReconcileOrderSubmissionCommand,
|
||||||
|
) (OrderSubmissionResult, error) {
|
||||||
|
command = normalizeReconcileOrderSubmission(command)
|
||||||
|
fields := orderSubmissionIdentityFields(
|
||||||
|
command.UserID,
|
||||||
|
command.DeviceID,
|
||||||
|
command.TaskID,
|
||||||
|
command.ExecutionID,
|
||||||
|
command.AuthorizationID,
|
||||||
|
command.ClaimGeneration,
|
||||||
|
command.ClaimToken,
|
||||||
|
command.CommandSHA256,
|
||||||
|
command.IdempotencyKey,
|
||||||
|
)
|
||||||
|
if !isUUID(command.SubmissionID) {
|
||||||
|
fields["submission_id"] = "must be a UUID"
|
||||||
|
}
|
||||||
|
if !platformOrderNumberPattern.MatchString(command.PlatformOrderNo) {
|
||||||
|
fields["platform_order_no"] = "must be 8 to 40 digits"
|
||||||
|
}
|
||||||
|
orderedAt, err := time.Parse(time.RFC3339, command.PlatformOrderedAt)
|
||||||
|
if err != nil {
|
||||||
|
fields["platform_ordered_at"] = "must be RFC3339"
|
||||||
|
}
|
||||||
|
if command.PlatformOrderStatus != "PENDING_PAYMENT" {
|
||||||
|
fields["platform_order_status"] = "must be PENDING_PAYMENT"
|
||||||
|
}
|
||||||
|
validateReconciledOrderSnapshot(
|
||||||
|
fields,
|
||||||
|
command.ObservedTitle,
|
||||||
|
command.SelectedSKU,
|
||||||
|
command.Quantity,
|
||||||
|
command.TotalPriceCents,
|
||||||
|
)
|
||||||
|
if !isUUID(command.EvidenceAssetID) {
|
||||||
|
fields["evidence_asset_id"] = "must be a UUID"
|
||||||
|
}
|
||||||
|
if !sha256Pattern.MatchString(command.EvidenceSHA256) {
|
||||||
|
fields["evidence_sha256"] = "must be lowercase SHA-256"
|
||||||
|
}
|
||||||
|
if len(fields) > 0 {
|
||||||
|
return OrderSubmissionResult{}, invalidError(
|
||||||
|
"ORDER_SUBMISSION_RECONCILE_INVALID",
|
||||||
|
"order submission reconciliation request is invalid",
|
||||||
|
fields,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
requestHash, err := lifecycleRequestHash(command)
|
||||||
|
if err != nil {
|
||||||
|
return OrderSubmissionResult{}, internalLifecycleFailure(err)
|
||||||
|
}
|
||||||
|
eventID, err := service.ids.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return OrderSubmissionResult{}, internalLifecycleFailure(err)
|
||||||
|
}
|
||||||
|
now := service.clock.Now().UTC()
|
||||||
|
userID, deviceID := command.UserID, command.DeviceID
|
||||||
|
submission, replayed, err := service.repository.ReconcileOrderSubmission(
|
||||||
|
ctx,
|
||||||
|
ReconcileOrderSubmissionWrite{
|
||||||
|
ReconcileOrderSubmissionCommand: command,
|
||||||
|
ParsedPlatformOrderedAt: orderedAt.UTC(),
|
||||||
|
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||||
|
RequestSHA256: requestHash,
|
||||||
|
Now: now,
|
||||||
|
Event: domain.TaskEvent{
|
||||||
|
ID: eventID,
|
||||||
|
TaskID: command.TaskID,
|
||||||
|
ActorUserID: &userID,
|
||||||
|
ActorDeviceID: &deviceID,
|
||||||
|
Type: "ORDER_SUBMISSION_RECONCILED",
|
||||||
|
Message: "pending-payment order uniquely reconciled",
|
||||||
|
OccurredAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err)
|
||||||
|
}
|
||||||
|
return OrderSubmissionResult{
|
||||||
|
Submission: submission,
|
||||||
|
Replayed: replayed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *OrderSubmissionService) ManualReview(
|
||||||
|
ctx context.Context,
|
||||||
|
command ManualReviewOrderSubmissionCommand,
|
||||||
|
) (OrderSubmissionResult, error) {
|
||||||
|
command = normalizeManualReviewOrderSubmission(command)
|
||||||
|
fields := orderSubmissionIdentityFields(
|
||||||
|
command.UserID,
|
||||||
|
command.DeviceID,
|
||||||
|
command.TaskID,
|
||||||
|
command.ExecutionID,
|
||||||
|
command.AuthorizationID,
|
||||||
|
command.ClaimGeneration,
|
||||||
|
command.ClaimToken,
|
||||||
|
command.CommandSHA256,
|
||||||
|
command.IdempotencyKey,
|
||||||
|
)
|
||||||
|
if !isUUID(command.SubmissionID) {
|
||||||
|
fields["submission_id"] = "must be a UUID"
|
||||||
|
}
|
||||||
|
if _, ok := orderSubmissionManualReasons[command.ReasonCode]; !ok {
|
||||||
|
fields["reason_code"] = "must be an allowed reason"
|
||||||
|
}
|
||||||
|
if (command.EvidenceAssetID == "") != (command.EvidenceSHA256 == "") {
|
||||||
|
fields["evidence"] = "asset id and SHA-256 must be provided together"
|
||||||
|
} else if command.EvidenceAssetID != "" {
|
||||||
|
if !isUUID(command.EvidenceAssetID) {
|
||||||
|
fields["evidence_asset_id"] = "must be a UUID"
|
||||||
|
}
|
||||||
|
if !sha256Pattern.MatchString(command.EvidenceSHA256) {
|
||||||
|
fields["evidence_sha256"] = "must be lowercase SHA-256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fields) > 0 {
|
||||||
|
return OrderSubmissionResult{}, invalidError(
|
||||||
|
"ORDER_SUBMISSION_MANUAL_REVIEW_INVALID",
|
||||||
|
"order submission manual-review request is invalid",
|
||||||
|
fields,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
requestHash, err := lifecycleRequestHash(command)
|
||||||
|
if err != nil {
|
||||||
|
return OrderSubmissionResult{}, internalLifecycleFailure(err)
|
||||||
|
}
|
||||||
|
eventID, err := service.ids.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return OrderSubmissionResult{}, internalLifecycleFailure(err)
|
||||||
|
}
|
||||||
|
now := service.clock.Now().UTC()
|
||||||
|
userID, deviceID := command.UserID, command.DeviceID
|
||||||
|
submission, replayed, err := service.repository.ManualReviewOrderSubmission(
|
||||||
|
ctx,
|
||||||
|
ManualReviewOrderSubmissionWrite{
|
||||||
|
ManualReviewOrderSubmissionCommand: command,
|
||||||
|
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||||
|
RequestSHA256: requestHash,
|
||||||
|
Now: now,
|
||||||
|
Event: domain.TaskEvent{
|
||||||
|
ID: eventID,
|
||||||
|
TaskID: command.TaskID,
|
||||||
|
ActorUserID: &userID,
|
||||||
|
ActorDeviceID: &deviceID,
|
||||||
|
Type: "ORDER_SUBMISSION_MANUAL_REVIEW",
|
||||||
|
Message: "order submission requires manual reconciliation",
|
||||||
|
OccurredAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err)
|
||||||
|
}
|
||||||
|
return OrderSubmissionResult{
|
||||||
|
Submission: submission,
|
||||||
|
Replayed: replayed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderSubmissionIdentityFields(
|
||||||
|
userID, deviceID, taskID, executionID, authorizationID string,
|
||||||
|
claimGeneration int64,
|
||||||
|
claimToken, commandSHA256, idempotencyKey string,
|
||||||
|
) map[string]string {
|
||||||
|
return dryRunIdentityFields(
|
||||||
|
userID,
|
||||||
|
deviceID,
|
||||||
|
taskID,
|
||||||
|
executionID,
|
||||||
|
authorizationID,
|
||||||
|
claimGeneration,
|
||||||
|
claimToken,
|
||||||
|
commandSHA256,
|
||||||
|
idempotencyKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOrderSnapshot(
|
||||||
|
fields map[string]string,
|
||||||
|
title, sku string,
|
||||||
|
quantity int,
|
||||||
|
unitPriceCents, totalPriceCents int64,
|
||||||
|
) {
|
||||||
|
if title == "" || len([]byte(title)) > 1024 {
|
||||||
|
fields["observed_title"] = "must be 1 to 1024 UTF-8 bytes"
|
||||||
|
}
|
||||||
|
if sku == "" || len([]byte(sku)) > 512 {
|
||||||
|
fields["selected_sku"] = "must be 1 to 512 UTF-8 bytes"
|
||||||
|
}
|
||||||
|
if quantity < 1 || quantity > 99 {
|
||||||
|
fields["quantity"] = "must be 1 to 99"
|
||||||
|
}
|
||||||
|
if unitPriceCents < 1 {
|
||||||
|
fields["unit_price_cents"] = "must be positive"
|
||||||
|
}
|
||||||
|
if totalPriceCents < 1 {
|
||||||
|
fields["total_price_cents"] = "must be positive"
|
||||||
|
}
|
||||||
|
if quantity > 0 &&
|
||||||
|
unitPriceCents > math.MaxInt64/int64(quantity) {
|
||||||
|
fields["total_price_cents"] = "price multiplication overflows"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateReconciledOrderSnapshot(
|
||||||
|
fields map[string]string,
|
||||||
|
title, sku string,
|
||||||
|
quantity int,
|
||||||
|
totalPriceCents int64,
|
||||||
|
) {
|
||||||
|
if title == "" || len([]byte(title)) > 1024 {
|
||||||
|
fields["observed_title"] = "must be 1 to 1024 UTF-8 bytes"
|
||||||
|
}
|
||||||
|
if sku == "" || len([]byte(sku)) > 512 {
|
||||||
|
fields["selected_sku"] = "must be 1 to 512 UTF-8 bytes"
|
||||||
|
}
|
||||||
|
if quantity < 1 || quantity > 99 {
|
||||||
|
fields["quantity"] = "must be 1 to 99"
|
||||||
|
}
|
||||||
|
if totalPriceCents < 1 {
|
||||||
|
fields["total_price_cents"] = "must be positive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStartOrderSubmission(
|
||||||
|
command StartOrderSubmissionCommand,
|
||||||
|
) StartOrderSubmissionCommand {
|
||||||
|
command.UserID = strings.TrimSpace(command.UserID)
|
||||||
|
command.DeviceID = strings.TrimSpace(command.DeviceID)
|
||||||
|
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||||
|
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
|
||||||
|
command.AuthorizationID = strings.TrimSpace(command.AuthorizationID)
|
||||||
|
command.ClaimToken = strings.TrimSpace(command.ClaimToken)
|
||||||
|
command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256)
|
||||||
|
command.DryRunID = strings.TrimSpace(command.DryRunID)
|
||||||
|
command.DryRunEvidenceSHA256 = strings.TrimSpace(
|
||||||
|
command.DryRunEvidenceSHA256,
|
||||||
|
)
|
||||||
|
command.ObservedTitle = strings.TrimSpace(command.ObservedTitle)
|
||||||
|
command.SelectedSKU = strings.TrimSpace(command.SelectedSKU)
|
||||||
|
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeReconcileOrderSubmission(
|
||||||
|
command ReconcileOrderSubmissionCommand,
|
||||||
|
) ReconcileOrderSubmissionCommand {
|
||||||
|
command.UserID = strings.TrimSpace(command.UserID)
|
||||||
|
command.DeviceID = strings.TrimSpace(command.DeviceID)
|
||||||
|
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||||
|
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
|
||||||
|
command.AuthorizationID = strings.TrimSpace(command.AuthorizationID)
|
||||||
|
command.ClaimToken = strings.TrimSpace(command.ClaimToken)
|
||||||
|
command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256)
|
||||||
|
command.SubmissionID = strings.TrimSpace(command.SubmissionID)
|
||||||
|
command.PlatformOrderNo = strings.TrimSpace(command.PlatformOrderNo)
|
||||||
|
command.PlatformOrderedAt = strings.TrimSpace(command.PlatformOrderedAt)
|
||||||
|
command.PlatformOrderStatus = strings.TrimSpace(command.PlatformOrderStatus)
|
||||||
|
command.ObservedTitle = strings.TrimSpace(command.ObservedTitle)
|
||||||
|
command.SelectedSKU = strings.TrimSpace(command.SelectedSKU)
|
||||||
|
command.EvidenceAssetID = strings.TrimSpace(command.EvidenceAssetID)
|
||||||
|
command.EvidenceSHA256 = strings.TrimSpace(command.EvidenceSHA256)
|
||||||
|
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeManualReviewOrderSubmission(
|
||||||
|
command ManualReviewOrderSubmissionCommand,
|
||||||
|
) ManualReviewOrderSubmissionCommand {
|
||||||
|
command.UserID = strings.TrimSpace(command.UserID)
|
||||||
|
command.DeviceID = strings.TrimSpace(command.DeviceID)
|
||||||
|
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||||
|
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
|
||||||
|
command.AuthorizationID = strings.TrimSpace(command.AuthorizationID)
|
||||||
|
command.ClaimToken = strings.TrimSpace(command.ClaimToken)
|
||||||
|
command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256)
|
||||||
|
command.SubmissionID = strings.TrimSpace(command.SubmissionID)
|
||||||
|
command.ReasonCode = strings.TrimSpace(command.ReasonCode)
|
||||||
|
command.EvidenceAssetID = strings.TrimSpace(command.EvidenceAssetID)
|
||||||
|
command.EvidenceSHA256 = strings.TrimSpace(command.EvidenceSHA256)
|
||||||
|
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
var platformOrderNumberPattern = regexp.MustCompile(`^[0-9]{8,40}$`)
|
||||||
|
|
||||||
|
var orderSubmissionManualReasons = map[string]struct{}{
|
||||||
|
"ORDER_NOT_FOUND": {},
|
||||||
|
"ORDER_AMBIGUOUS": {},
|
||||||
|
"ORDER_FIELDS_INCOMPLETE": {},
|
||||||
|
"ORDER_PAGE_UNKNOWN": {},
|
||||||
|
"RISK_OR_PAYMENT_BOUNDARY": {},
|
||||||
|
"EVIDENCE_UNAVAILABLE": {},
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE order_submissions (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36),
|
||||||
|
authorization_id TEXT NOT NULL UNIQUE
|
||||||
|
REFERENCES order_authorizations(id)
|
||||||
|
ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
dry_run_id TEXT NOT NULL UNIQUE
|
||||||
|
REFERENCES order_dry_runs(id)
|
||||||
|
ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
task_id TEXT NOT NULL
|
||||||
|
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
execution_id TEXT NOT NULL
|
||||||
|
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
command_sha256 TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(command_sha256) = 64
|
||||||
|
AND command_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||||
|
),
|
||||||
|
dry_run_evidence_sha256 TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(dry_run_evidence_sha256) = 64
|
||||||
|
AND dry_run_evidence_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||||
|
),
|
||||||
|
status TEXT NOT NULL
|
||||||
|
CHECK (status IN ('FENCED', 'RECONCILED', 'MANUAL_REVIEW')),
|
||||||
|
expected_title TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(trim(expected_title)) > 0
|
||||||
|
AND length(CAST(expected_title AS BLOB)) <= 1024
|
||||||
|
),
|
||||||
|
expected_sku TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(trim(expected_sku)) > 0
|
||||||
|
AND length(CAST(expected_sku AS BLOB)) <= 512
|
||||||
|
),
|
||||||
|
expected_quantity INTEGER NOT NULL
|
||||||
|
CHECK (expected_quantity BETWEEN 1 AND 99),
|
||||||
|
expected_unit_price_cents INTEGER NOT NULL
|
||||||
|
CHECK (expected_unit_price_cents > 0),
|
||||||
|
expected_total_price_cents INTEGER NOT NULL
|
||||||
|
CHECK (expected_total_price_cents > 0),
|
||||||
|
platform_order_no TEXT UNIQUE
|
||||||
|
CHECK (
|
||||||
|
platform_order_no IS NULL
|
||||||
|
OR (
|
||||||
|
length(platform_order_no) BETWEEN 8 AND 40
|
||||||
|
AND platform_order_no NOT GLOB '*[^0-9]*'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
platform_ordered_at TEXT,
|
||||||
|
platform_order_status TEXT
|
||||||
|
CHECK (
|
||||||
|
platform_order_status IS NULL
|
||||||
|
OR platform_order_status = 'PENDING_PAYMENT'
|
||||||
|
),
|
||||||
|
reconciliation_evidence_asset_id TEXT
|
||||||
|
REFERENCES execution_evidence_assets(id)
|
||||||
|
ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
reconciliation_evidence_sha256 TEXT
|
||||||
|
CHECK (
|
||||||
|
reconciliation_evidence_sha256 IS NULL
|
||||||
|
OR (
|
||||||
|
length(reconciliation_evidence_sha256) = 64
|
||||||
|
AND reconciliation_evidence_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
manual_reason_code TEXT
|
||||||
|
CHECK (
|
||||||
|
manual_reason_code IS NULL
|
||||||
|
OR manual_reason_code IN (
|
||||||
|
'ORDER_NOT_FOUND',
|
||||||
|
'ORDER_AMBIGUOUS',
|
||||||
|
'ORDER_FIELDS_INCOMPLETE',
|
||||||
|
'ORDER_PAGE_UNKNOWN',
|
||||||
|
'RISK_OR_PAYMENT_BOUNDARY',
|
||||||
|
'EVIDENCE_UNAVAILABLE'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
fenced_at TEXT NOT NULL,
|
||||||
|
reconciled_at TEXT,
|
||||||
|
manual_review_at TEXT,
|
||||||
|
CHECK (
|
||||||
|
expected_total_price_cents =
|
||||||
|
expected_unit_price_cents * expected_quantity
|
||||||
|
),
|
||||||
|
CHECK (
|
||||||
|
(status = 'FENCED'
|
||||||
|
AND platform_order_no IS NULL
|
||||||
|
AND platform_ordered_at IS NULL
|
||||||
|
AND platform_order_status IS NULL
|
||||||
|
AND reconciliation_evidence_asset_id IS NULL
|
||||||
|
AND reconciliation_evidence_sha256 IS NULL
|
||||||
|
AND manual_reason_code IS NULL
|
||||||
|
AND reconciled_at IS NULL
|
||||||
|
AND manual_review_at IS NULL)
|
||||||
|
OR
|
||||||
|
(status = 'RECONCILED'
|
||||||
|
AND platform_order_no IS NOT NULL
|
||||||
|
AND platform_ordered_at IS NOT NULL
|
||||||
|
AND platform_order_status = 'PENDING_PAYMENT'
|
||||||
|
AND reconciliation_evidence_asset_id IS NOT NULL
|
||||||
|
AND reconciliation_evidence_sha256 IS NOT NULL
|
||||||
|
AND manual_reason_code IS NULL
|
||||||
|
AND reconciled_at IS NOT NULL
|
||||||
|
AND manual_review_at IS NULL)
|
||||||
|
OR
|
||||||
|
(status = 'MANUAL_REVIEW'
|
||||||
|
AND platform_order_no IS NULL
|
||||||
|
AND platform_ordered_at IS NULL
|
||||||
|
AND platform_order_status IS NULL
|
||||||
|
AND manual_reason_code IS NOT NULL
|
||||||
|
AND reconciled_at IS NULL
|
||||||
|
AND manual_review_at IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX order_submissions_task_fenced_idx
|
||||||
|
ON order_submissions (task_id, fenced_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE device_order_submission_requests (
|
||||||
|
device_id TEXT NOT NULL
|
||||||
|
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
operation TEXT NOT NULL
|
||||||
|
CHECK (operation IN ('START', 'RECONCILE', 'MANUAL_REVIEW')),
|
||||||
|
idempotency_key TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(trim(idempotency_key)) > 0
|
||||||
|
AND length(CAST(idempotency_key AS BLOB)) <= 128
|
||||||
|
),
|
||||||
|
request_sha256 TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(request_sha256) = 64
|
||||||
|
AND request_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||||
|
),
|
||||||
|
task_id TEXT NOT NULL
|
||||||
|
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
submission_id TEXT NOT NULL
|
||||||
|
REFERENCES order_submissions(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (device_id, operation, idempotency_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE execution_outcomes RENAME TO execution_outcomes_v10;
|
||||||
|
|
||||||
|
CREATE TABLE execution_outcomes (
|
||||||
|
execution_id TEXT PRIMARY KEY NOT NULL
|
||||||
|
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||||
|
task_id TEXT NOT NULL
|
||||||
|
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||||
|
result_type TEXT NOT NULL
|
||||||
|
CHECK (result_type IN ('COMPLETE', 'FAIL')),
|
||||||
|
execution_mode TEXT
|
||||||
|
CHECK (execution_mode IS NULL OR execution_mode IN ('MANUAL_FIRST', 'AI_ASSISTED')),
|
||||||
|
task_content_sha256 TEXT
|
||||||
|
CHECK (
|
||||||
|
task_content_sha256 IS NULL
|
||||||
|
OR (
|
||||||
|
length(task_content_sha256) = 64
|
||||||
|
AND task_content_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
outcome TEXT
|
||||||
|
CHECK (
|
||||||
|
outcome IS NULL
|
||||||
|
OR outcome IN (
|
||||||
|
'CANDIDATE_ACCEPTED',
|
||||||
|
'CANDIDATE_REJECTED',
|
||||||
|
'NO_MATCH',
|
||||||
|
'MANUAL_REQUIRED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
operator_reason TEXT
|
||||||
|
CHECK (
|
||||||
|
operator_reason IS NULL
|
||||||
|
OR length(CAST(operator_reason AS BLOB)) <= 1000
|
||||||
|
),
|
||||||
|
selected_candidate_json TEXT,
|
||||||
|
evidence_asset_ids_json TEXT,
|
||||||
|
error_code TEXT
|
||||||
|
CHECK (error_code IS NULL OR length(CAST(error_code AS BLOB)) <= 64),
|
||||||
|
error_message TEXT
|
||||||
|
CHECK (error_message IS NULL OR length(CAST(error_message AS BLOB)) <= 1000),
|
||||||
|
error_step TEXT
|
||||||
|
CHECK (error_step IS NULL OR length(CAST(error_step AS BLOB)) <= 64),
|
||||||
|
retryable INTEGER
|
||||||
|
CHECK (retryable IS NULL OR retryable IN (0, 1)),
|
||||||
|
order_submitted INTEGER NOT NULL DEFAULT 0
|
||||||
|
CHECK (order_submitted IN (0, 1)),
|
||||||
|
received_at TEXT NOT NULL,
|
||||||
|
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
|
||||||
|
CHECK (received_after_execution_expiry IN (0, 1)),
|
||||||
|
CHECK (
|
||||||
|
(result_type = 'COMPLETE'
|
||||||
|
AND execution_mode IS NOT NULL
|
||||||
|
AND task_content_sha256 IS NOT NULL
|
||||||
|
AND outcome IS NOT NULL
|
||||||
|
AND operator_reason IS NOT NULL
|
||||||
|
AND error_code IS NULL)
|
||||||
|
OR
|
||||||
|
(result_type = 'FAIL'
|
||||||
|
AND error_code IS NOT NULL
|
||||||
|
AND error_message IS NOT NULL
|
||||||
|
AND error_step IS NOT NULL
|
||||||
|
AND order_submitted = 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO execution_outcomes (
|
||||||
|
execution_id, task_id, result_type, execution_mode,
|
||||||
|
task_content_sha256, outcome, operator_reason,
|
||||||
|
selected_candidate_json, evidence_asset_ids_json,
|
||||||
|
error_code, error_message, error_step, retryable,
|
||||||
|
order_submitted, received_at, received_after_execution_expiry
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
execution_id, task_id, result_type, execution_mode,
|
||||||
|
task_content_sha256, outcome, operator_reason,
|
||||||
|
selected_candidate_json, evidence_asset_ids_json,
|
||||||
|
error_code, error_message, error_step, retryable,
|
||||||
|
order_submitted, received_at, received_after_execution_expiry
|
||||||
|
FROM execution_outcomes_v10;
|
||||||
|
|
||||||
|
DROP TABLE execution_outcomes_v10;
|
||||||
|
|
||||||
|
ALTER TABLE task_events RENAME TO task_events_v10;
|
||||||
|
|
||||||
|
CREATE TABLE task_events (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36),
|
||||||
|
task_id TEXT NOT NULL
|
||||||
|
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
event_type IN (
|
||||||
|
'TASK_CREATED',
|
||||||
|
'TASK_CLAIMED',
|
||||||
|
'TASK_RECLAIMED',
|
||||||
|
'TASK_RELEASED',
|
||||||
|
'TASK_STARTED',
|
||||||
|
'TASK_CANCEL_REQUESTED',
|
||||||
|
'TASK_CANCELED',
|
||||||
|
'CANDIDATES_READY',
|
||||||
|
'ORDER_AUTHORIZATION_CREATED',
|
||||||
|
'ORDER_AUTHORIZATION_DELIVERED',
|
||||||
|
'ORDER_AUTHORIZATION_ACKNOWLEDGED',
|
||||||
|
'ORDER_DRY_RUN_STARTED',
|
||||||
|
'ORDER_DRY_RUN_READY',
|
||||||
|
'ORDER_SUBMISSION_FENCED',
|
||||||
|
'ORDER_SUBMISSION_RECONCILED',
|
||||||
|
'ORDER_SUBMISSION_MANUAL_REVIEW'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
actor_user_id TEXT
|
||||||
|
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
actor_device_id TEXT
|
||||||
|
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO task_events (
|
||||||
|
id, task_id, event_type, message, occurred_at,
|
||||||
|
actor_user_id, actor_device_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, task_id, event_type, message, occurred_at,
|
||||||
|
actor_user_id, actor_device_id
|
||||||
|
FROM task_events_v10;
|
||||||
|
|
||||||
|
DROP TABLE task_events_v10;
|
||||||
|
|
||||||
|
CREATE INDEX task_events_task_occurred_idx
|
||||||
|
ON task_events (task_id, occurred_at ASC, id ASC);
|
||||||
|
CREATE INDEX task_events_actor_user_idx
|
||||||
|
ON task_events (actor_user_id, occurred_at DESC, id DESC);
|
||||||
|
CREATE INDEX task_events_actor_device_idx
|
||||||
|
ON task_events (actor_device_id, occurred_at DESC, id DESC);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
CREATE TEMP TABLE order_submissions_v11_down_guard (
|
||||||
|
allowed INTEGER NOT NULL CHECK (allowed = 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO order_submissions_v11_down_guard (allowed)
|
||||||
|
SELECT CASE
|
||||||
|
WHEN EXISTS (SELECT 1 FROM order_submissions)
|
||||||
|
OR EXISTS (SELECT 1 FROM device_order_submission_requests)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM task_events
|
||||||
|
WHERE event_type IN (
|
||||||
|
'ORDER_SUBMISSION_FENCED',
|
||||||
|
'ORDER_SUBMISSION_RECONCILED',
|
||||||
|
'ORDER_SUBMISSION_MANUAL_REVIEW'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM execution_outcomes WHERE order_submitted = 1
|
||||||
|
)
|
||||||
|
THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END;
|
||||||
|
|
||||||
|
DROP TABLE order_submissions_v11_down_guard;
|
||||||
|
DROP TABLE device_order_submission_requests;
|
||||||
|
DROP TABLE order_submissions;
|
||||||
|
|
||||||
|
ALTER TABLE execution_outcomes RENAME TO execution_outcomes_v11;
|
||||||
|
|
||||||
|
CREATE TABLE execution_outcomes (
|
||||||
|
execution_id TEXT PRIMARY KEY NOT NULL
|
||||||
|
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||||
|
task_id TEXT NOT NULL
|
||||||
|
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||||
|
result_type TEXT NOT NULL
|
||||||
|
CHECK (result_type IN ('COMPLETE', 'FAIL')),
|
||||||
|
execution_mode TEXT
|
||||||
|
CHECK (execution_mode IS NULL OR execution_mode IN ('MANUAL_FIRST', 'AI_ASSISTED')),
|
||||||
|
task_content_sha256 TEXT
|
||||||
|
CHECK (
|
||||||
|
task_content_sha256 IS NULL
|
||||||
|
OR (
|
||||||
|
length(task_content_sha256) = 64
|
||||||
|
AND task_content_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
outcome TEXT
|
||||||
|
CHECK (
|
||||||
|
outcome IS NULL
|
||||||
|
OR outcome IN (
|
||||||
|
'CANDIDATE_ACCEPTED',
|
||||||
|
'CANDIDATE_REJECTED',
|
||||||
|
'NO_MATCH',
|
||||||
|
'MANUAL_REQUIRED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
operator_reason TEXT
|
||||||
|
CHECK (
|
||||||
|
operator_reason IS NULL
|
||||||
|
OR length(CAST(operator_reason AS BLOB)) <= 1000
|
||||||
|
),
|
||||||
|
selected_candidate_json TEXT,
|
||||||
|
evidence_asset_ids_json TEXT,
|
||||||
|
error_code TEXT
|
||||||
|
CHECK (error_code IS NULL OR length(CAST(error_code AS BLOB)) <= 64),
|
||||||
|
error_message TEXT
|
||||||
|
CHECK (error_message IS NULL OR length(CAST(error_message AS BLOB)) <= 1000),
|
||||||
|
error_step TEXT
|
||||||
|
CHECK (error_step IS NULL OR length(CAST(error_step AS BLOB)) <= 64),
|
||||||
|
retryable INTEGER
|
||||||
|
CHECK (retryable IS NULL OR retryable IN (0, 1)),
|
||||||
|
order_submitted INTEGER NOT NULL DEFAULT 0
|
||||||
|
CHECK (order_submitted = 0),
|
||||||
|
received_at TEXT NOT NULL,
|
||||||
|
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
|
||||||
|
CHECK (received_after_execution_expiry IN (0, 1)),
|
||||||
|
CHECK (
|
||||||
|
(result_type = 'COMPLETE'
|
||||||
|
AND execution_mode IS NOT NULL
|
||||||
|
AND task_content_sha256 IS NOT NULL
|
||||||
|
AND outcome IS NOT NULL
|
||||||
|
AND operator_reason IS NOT NULL
|
||||||
|
AND error_code IS NULL)
|
||||||
|
OR
|
||||||
|
(result_type = 'FAIL'
|
||||||
|
AND error_code IS NOT NULL
|
||||||
|
AND error_message IS NOT NULL
|
||||||
|
AND error_step IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO execution_outcomes (
|
||||||
|
execution_id, task_id, result_type, execution_mode,
|
||||||
|
task_content_sha256, outcome, operator_reason,
|
||||||
|
selected_candidate_json, evidence_asset_ids_json,
|
||||||
|
error_code, error_message, error_step, retryable,
|
||||||
|
order_submitted, received_at, received_after_execution_expiry
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
execution_id, task_id, result_type, execution_mode,
|
||||||
|
task_content_sha256, outcome, operator_reason,
|
||||||
|
selected_candidate_json, evidence_asset_ids_json,
|
||||||
|
error_code, error_message, error_step, retryable,
|
||||||
|
order_submitted, received_at, received_after_execution_expiry
|
||||||
|
FROM execution_outcomes_v11;
|
||||||
|
|
||||||
|
DROP TABLE execution_outcomes_v11;
|
||||||
|
|
||||||
|
ALTER TABLE task_events RENAME TO task_events_v11;
|
||||||
|
|
||||||
|
CREATE TABLE task_events (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36),
|
||||||
|
task_id TEXT NOT NULL
|
||||||
|
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
event_type IN (
|
||||||
|
'TASK_CREATED',
|
||||||
|
'TASK_CLAIMED',
|
||||||
|
'TASK_RECLAIMED',
|
||||||
|
'TASK_RELEASED',
|
||||||
|
'TASK_STARTED',
|
||||||
|
'TASK_CANCEL_REQUESTED',
|
||||||
|
'TASK_CANCELED',
|
||||||
|
'CANDIDATES_READY',
|
||||||
|
'ORDER_AUTHORIZATION_CREATED',
|
||||||
|
'ORDER_AUTHORIZATION_DELIVERED',
|
||||||
|
'ORDER_AUTHORIZATION_ACKNOWLEDGED',
|
||||||
|
'ORDER_DRY_RUN_STARTED',
|
||||||
|
'ORDER_DRY_RUN_READY'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
actor_user_id TEXT
|
||||||
|
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
actor_device_id TEXT
|
||||||
|
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO task_events (
|
||||||
|
id, task_id, event_type, message, occurred_at,
|
||||||
|
actor_user_id, actor_device_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, task_id, event_type, message, occurred_at,
|
||||||
|
actor_user_id, actor_device_id
|
||||||
|
FROM task_events_v11;
|
||||||
|
|
||||||
|
DROP TABLE task_events_v11;
|
||||||
|
|
||||||
|
CREATE INDEX task_events_task_occurred_idx
|
||||||
|
ON task_events (task_id, occurred_at ASC, id ASC);
|
||||||
|
CREATE INDEX task_events_actor_user_idx
|
||||||
|
ON task_events (actor_user_id, occurred_at DESC, id DESC);
|
||||||
|
CREATE INDEX task_events_actor_device_idx
|
||||||
|
ON task_events (actor_device_id, occurred_at DESC, id DESC);
|
||||||
@@ -61,7 +61,8 @@ T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207
|
|||||||
规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选
|
规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选
|
||||||
结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹、T-215
|
结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹、T-215
|
||||||
Admin 候选确认、不可变待投递授权、T-216 设备命令可靠投递及 T-217 已授权商品
|
Admin 候选确认、不可变待投递授权、T-216 设备命令可靠投递及 T-217 已授权商品
|
||||||
重新定位与订单 dry-run 均已完成;T-218 单次订单提交与订单回读正在开发。
|
重新定位与订单 dry-run、T-218 单次订单提交围栏和订单回读均已完成;下一项是
|
||||||
|
T-219 Admin 待付款提醒与端到端验收。
|
||||||
不得直接把候选链接或列表 ordinal 当成授权。
|
不得直接把候选链接或列表 ordinal 当成授权。
|
||||||
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
|
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
|
||||||
T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。
|
T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
| 执行方式 | 采购人员使用 Android App 操作拼多多;需求提取、动态词搜索、最多 5 个候选证据采集、匹配建议和人工确认停止点已可运行。 |
|
| 执行方式 | 采购人员使用 Android App 操作拼多多;需求提取、动态词搜索、最多 5 个候选证据采集、匹配建议和人工确认停止点已可运行。 |
|
||||||
| 核心痛点 | 人工把图片和描述转成搜索词、逐条比较商品并记录结果,耗时且不一致。 |
|
| 核心痛点 | 人工把图片和描述转成搜索词、逐条比较商品并记录结果,耗时且不一致。 |
|
||||||
| 验证范围 | 一台设备、一个管理身份、一个采购执行人员、拼多多单平台。 |
|
| 验证范围 | 一台设备、一个管理身份、一个采购执行人员、拼多多单平台。 |
|
||||||
| 资金边界 | MVP 不提交订单、不支付,只验证到人工确认位置。 |
|
| 资金边界 | 仅在 Admin 明确授权和一次性围栏后创建待付款订单;系统不自动付款。 |
|
||||||
|
|
||||||
## 二、用户角色
|
## 二、用户角色
|
||||||
|
|
||||||
|
|||||||
@@ -650,6 +650,9 @@ local READY + backend READY + valid lease
|
|||||||
submission fence 是 exactly-once 安全边界,不是“点击成功”回执。服务端创建围栏后,
|
submission fence 是 exactly-once 安全边界,不是“点击成功”回执。服务端创建围栏后,
|
||||||
任何进程恢复都假定订单可能已创建;宁可留下需要人工对账的未提交意图,也不能通过
|
任何进程恢复都假定订单可能已创建;宁可留下需要人工对账的未提交意图,也不能通过
|
||||||
重按产生重复订单。一个 authorization 和 dry-run 在数据库中只能对应一个 submission。
|
重按产生重复订单。一个 authorization 和 dry-run 在数据库中只能对应一个 submission。
|
||||||
|
只有同一次未中断调用中新建本地 intent 并收到后台 `FENCED` 响应时才短暂持有点击
|
||||||
|
资格;磁盘恢复出的 intent 只能重放后台请求并进入对账。围栏已经存在后,即使 claim
|
||||||
|
租约到期也只继续对账,不恢复商品操作或提交资格。
|
||||||
|
|
||||||
不可逆动作前再次核对 T-217 全部商品字段,并要求明确可见的已配置地址和唯一
|
不可逆动作前再次核对 T-217 全部商品字段,并要求明确可见的已配置地址和唯一
|
||||||
“提交订单”节点。地址缺失、金额变化、自动扣款方式或“立即支付/确认支付/免密支付/
|
“提交订单”节点。地址缺失、金额变化、自动扣款方式或“立即支付/确认支付/免密支付/
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
| IX-008 | US-006 | App/管理端错误状态 | 自动失败、取消、重试上传 | 显示结构化原因和恢复动作 | P0 | 已定 |
|
| IX-008 | US-006 | App/管理端错误状态 | 自动失败、取消、重试上传 | 显示结构化原因和恢复动作 | P0 | 已定 |
|
||||||
| IX-009 | US-008 | App 候选理由/管理端决策详情 | 接受、拒绝、改选或修正 | 保存逐候选结构化人工标签 | P1 | T-208 已实现 |
|
| IX-009 | US-008 | App 候选理由/管理端决策详情 | 接受、拒绝、改选或修正 | 保存逐候选结构化人工标签 | P1 | T-208 已实现 |
|
||||||
| IX-010 | US-009 | App 独立执行设置/同步状态 | 配置模式、离线执行或补报 | 授权内独立执行并可审计同步 | P0 | T-206 离线控制已实现,结果补报待 T-207 |
|
| IX-010 | US-009 | App 独立执行设置/同步状态 | 配置模式、离线执行或补报 | 授权内独立执行并可审计同步 | P0 | T-206 离线控制已实现,结果补报待 T-207 |
|
||||||
| IX-011 | US-010 | Admin 候选授权/App 订单执行 | 选择候选并授权、设备领取执行 | 创建一笔可对账的待付款订单并提醒人工付款 | P0 | T-215 至 T-217 已完成;T-218/T-219 待实现 |
|
| IX-011 | US-010 | Admin 候选授权/App 订单执行 | 选择候选并授权、设备领取执行 | 创建一笔可对账的待付款订单并提醒人工付款 | P0 | T-215 至 T-218 已完成;T-219 待实现 |
|
||||||
|
|
||||||
## IX-001 管理 Web 登录
|
## IX-001 管理 Web 登录
|
||||||
|
|
||||||
|
|||||||
@@ -334,6 +334,9 @@ App 唯一识别待付款订单并先上传受控 evidence 后,提交订单编
|
|||||||
execution/generation、submission、期望快照、证据归属和幂等键;成功将 submission
|
execution/generation、submission、期望快照、证据归属和幂等键;成功将 submission
|
||||||
置为 `RECONCILED`、authorization 置为 `CONSUMED` 并记录
|
置为 `RECONCILED`、authorization 置为 `CONSUMED` 并记录
|
||||||
`order_submitted=true`。订单编号不得进入 URL、日志、事件 message 或模型字段。
|
`order_submitted=true`。订单编号不得进入 URL、日志、事件 message 或模型字段。
|
||||||
|
围栏创建后允许原 user/device/generation/claim token 在租约到期后完成该 submission
|
||||||
|
的对账,但任务必须仍未取消且 execution 未结束;该例外不适用于 start,也不产生
|
||||||
|
新的提交资格。
|
||||||
|
|
||||||
### `POST /api/v1/tasks/{task_id}/order-submissions/{submission_id}/manual-review`
|
### `POST /api/v1/tasks/{task_id}/order-submissions/{submission_id}/manual-review`
|
||||||
|
|
||||||
|
|||||||
+18
-15
@@ -5,7 +5,7 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-28
|
- 日期:2026-07-28
|
||||||
- 阶段:T-218 单次订单提交与订单回读已领取,合约冻结中
|
- 阶段:T-218 单次订单提交与订单回读已完成;下一项 T-219
|
||||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-217
|
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-217
|
||||||
均按文档提交、实现提交的顺序纳入历史
|
均按文档提交、实现提交的顺序纳入历史
|
||||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
||||||
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
||||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||||
- 测试:T-217 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
- 测试:T-218 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
||||||
Debug APK `1.4.14 (19)` 已覆盖安装到 PKG110
|
Debug APK `1.4.15 (20)` 已覆盖安装到 PKG110
|
||||||
- 后端测试:T-217 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
|
- 后端测试:T-218 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
|
||||||
v10 migration 往返、带 dry-run 数据的降级保护及 start/ready HTTP 集成测试通过
|
v11 migration 降级保护及 fence/replay/manual-review/reconcile HTTP 集成测试通过
|
||||||
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
||||||
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
||||||
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
||||||
@@ -76,9 +76,10 @@
|
|||||||
唯一颜色/尺码、数量和预算重新核验 Admin 选择;确认页 WebView 折叠时使用端上
|
唯一颜色/尺码、数量和预算重新核验 Admin 选择;确认页 WebView 折叠时使用端上
|
||||||
中文 OCR 做只读 fail-closed 证据提取。后端保存 v10 READY、截图、SKU、数量、
|
中文 OCR 做只读 fail-closed 证据提取。后端保存 v10 READY、截图、SKU、数量、
|
||||||
单价/总额和事件;无障碍动作集中不含订单提交或支付。
|
单价/总额和事件;无障碍动作集中不含订单提交或支付。
|
||||||
- T-218 目标:后端先创建一次性 submission fence,App 点击前持久化
|
- T-218 单次提交:后端先创建唯一 submission fence,App 点击前持久化
|
||||||
`CLICK_ASSUMED`,最多点击一次精确“提交订单”;进程/网络不确定后只回读待付款
|
`CLICK_ASSUMED`,最多点击一次精确“提交订单”;进程/网络不确定和租约到期后只
|
||||||
订单或转人工,绝不重复提交或点击支付。
|
回读待付款订单或转人工,绝不重复提交或点击支付。v11 保存围栏、幂等操作、订单号/
|
||||||
|
时间、证据和审计事件,唯一对账后才写 `order_submitted=true`。
|
||||||
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
|
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
|
||||||
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
|
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
|
||||||
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
||||||
@@ -92,11 +93,11 @@
|
|||||||
- 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110
|
- 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110
|
||||||
真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止,
|
真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止,
|
||||||
RUNNING 不自动重新分配
|
RUNNING 不自动重新分配
|
||||||
- 测试设备:OnePlus PKG110,Android 16/API 36;已安装肉包 `1.4.14 (19)`;拼多多
|
- 测试设备:OnePlus PKG110,Android 16/API 36;已安装肉包 `1.4.15 (20)`;拼多多
|
||||||
`8.17.0 (81700)`
|
`8.17.0 (81700)`
|
||||||
- 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0
|
- 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0
|
||||||
真机验证;采购员已在 ColorOS 设置中手动启用肉包采购无障碍,APK 覆盖安装后授权
|
真机验证;ColorOS 本次覆盖安装后移除了肉包采购无障碍,重新启用后服务已连接,
|
||||||
保持且服务已连接
|
因此每次覆盖安装后都必须复核该系统授权
|
||||||
- 拼多多 8.17 兼容:结果页可识别新版显示查询词的文本搜索栏和“推荐、手机、女装”等
|
- 拼多多 8.17 兼容:结果页可识别新版显示查询词的文本搜索栏和“推荐、手机、女装”等
|
||||||
分类栏,也保留旧版“综合、销量、价格、筛选”识别;长任务搜索词的拼多多省略显示须
|
分类栏,也保留旧版“综合、销量、价格、筛选”识别;长任务搜索词的拼多多省略显示须
|
||||||
至少两个按序片段对应原词才通过。每次候选探针会重写本次搜索词,不复用已有结果页。
|
至少两个按序片段对应原词才通过。每次候选探针会重写本次搜索词,不复用已有结果页。
|
||||||
@@ -107,8 +108,9 @@
|
|||||||
已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/`
|
已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/`
|
||||||
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
|
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
|
||||||
- 标准验证路径:`.\init.ps1`
|
- 标准验证路径:`.\init.ps1`
|
||||||
- 当前 blocker:T-218 实现无代码阻塞;当前真机确认页缺少已配置收货地址,因此
|
- 当前 blocker:T-219 无代码阻塞;当前真机确认页缺少已配置收货地址且显示
|
||||||
真实提交 smoke 必须按合约阻塞,不能为测试自动添加地址。真实 VLM 服务地址、模型、
|
“立即支付/先用后付”,T-218 已按合约证明不创建围栏、不点击。不能为测试自动添加
|
||||||
|
地址或跨过付款边界。真实 VLM 服务地址、模型、
|
||||||
设备级测试凭证、成本上限和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针
|
设备级测试凭证、成本上限和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针
|
||||||
截图要求 Android 11/API 30+
|
截图要求 Android 11/API 30+
|
||||||
|
|
||||||
@@ -143,6 +145,7 @@
|
|||||||
| `docs/tasks/T-215.md` | DONE | Admin 按 candidate key 选择并创建不可变待投递授权 |
|
| `docs/tasks/T-215.md` | DONE | Admin 按 candidate key 选择并创建不可变待投递授权 |
|
||||||
| `docs/tasks/T-216.md` | DONE | App 主动拉取并先加密落盘再确认同一条下单命令 |
|
| `docs/tasks/T-216.md` | DONE | App 主动拉取并先加密落盘再确认同一条下单命令 |
|
||||||
| `docs/tasks/T-217.md` | DONE | 重新核对已授权商品并选择 SKU/数量,停在最终提交前 |
|
| `docs/tasks/T-217.md` | DONE | 重新核对已授权商品并选择 SKU/数量,停在最终提交前 |
|
||||||
|
| `docs/tasks/T-218.md` | DONE | 单次提交围栏、最多一次提交动作和待付款订单唯一对账 |
|
||||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||||
@@ -153,8 +156,8 @@
|
|||||||
|
|
||||||
## 任务摘要
|
## 任务摘要
|
||||||
|
|
||||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-217。
|
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-218。
|
||||||
- 进行中:T-218 单次订单提交与订单回读。
|
- 进行中:无。
|
||||||
- 下一步:T-219 Admin 待付款提醒与端到端验收。
|
- 下一步:T-219 Admin 待付款提醒与端到端验收。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|||||||
+22
-10
@@ -4,7 +4,7 @@ title: 单次订单提交与订单回读
|
|||||||
phase: 2
|
phase: 2
|
||||||
deps:
|
deps:
|
||||||
- T-217
|
- T-217
|
||||||
status: DOING
|
status: DONE
|
||||||
created: 2026-07-28
|
created: 2026-07-28
|
||||||
context_ref: 1e87b62
|
context_ref: 1e87b62
|
||||||
work_branch: null
|
work_branch: null
|
||||||
@@ -119,15 +119,15 @@ dry-run 身份及 `Idempotency-Key`。start 只接受 READY;reconcile/manual-r
|
|||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- [ ] start 只有 READY、有效租约、未取消和完全一致的命令/金额可成功。
|
- [x] start 只有 READY、有效租约、未取消和完全一致的命令/金额可成功。
|
||||||
- [ ] 同一授权最多一个 submission;响应丢失、重启和重复请求不能产生第二次点击。
|
- [x] 同一授权最多一个 submission;响应丢失、重启和重复请求不能产生第二次点击。
|
||||||
- [ ] 缺少地址、自动扣款语义、非精确“提交订单”或页面不确定时 fail closed。
|
- [x] 缺少地址、自动扣款语义、非精确“提交订单”或页面不确定时 fail closed。
|
||||||
- [ ] 点击前持久化 `CLICK_ASSUMED`;恢复路径只有订单回读,没有重新提交。
|
- [x] 点击前持久化 `CLICK_ASSUMED`;恢复路径只有订单回读,没有重新提交。
|
||||||
- [ ] 订单列表只读回读必须唯一匹配订单号、下单时间、SKU、数量、金额和待付款状态。
|
- [x] 订单列表只读回读必须唯一匹配订单号、下单时间、SKU、数量、金额和待付款状态。
|
||||||
- [ ] 零个/多个匹配转人工且仍禁止重新提交;证据和事件可审计。
|
- [x] 零个/多个匹配转人工且仍禁止重新提交;证据和事件可审计。
|
||||||
- [ ] 代码路径不包含支付点击,订单号不进日志/VLM。
|
- [x] 代码路径不包含支付点击,订单号不进日志/VLM。
|
||||||
- [ ] v11 migration、Go test/race/vet、Android test/Debug/Release 和根验证通过。
|
- [x] v11 migration、Go test/race/vet、Android test/Debug/Release 和根验证通过。
|
||||||
- [ ] 真机至少证明缺地址/危险付款语义会阻塞;只有具备受控地址和明确待付款语义时
|
- [x] 真机至少证明缺地址/危险付款语义会阻塞;只有具备受控地址和明确待付款语义时
|
||||||
才允许执行真实单次提交 smoke。
|
才允许执行真实单次提交 smoke。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
@@ -141,3 +141,15 @@ dry-run 身份及 `Idempotency-Key`。start 只接受 READY;reconcile/manual-r
|
|||||||
|
|
||||||
- 2026-07-28:在 T-217 实现提交 `1e87b62` 后领取。冻结服务端一次性提交围栏、
|
- 2026-07-28:在 T-217 实现提交 `1e87b62` 后领取。冻结服务端一次性提交围栏、
|
||||||
点击前 `CLICK_ASSUMED` 持久化、恢复只对账、订单列表唯一匹配和自动付款禁区。
|
点击前 `CLICK_ASSUMED` 持久化、恢复只对账、订单列表唯一匹配和自动付款禁区。
|
||||||
|
- 2026-07-28:合约提交 `682cc73`。实现 v11 submission/request 表、三条设备接口、
|
||||||
|
服务端唯一围栏和幂等对账;Android 加入确认页安全检查、加密恢复状态、单次点击
|
||||||
|
资格、待付款订单解析和有限唯一匹配。
|
||||||
|
- 2026-07-28:补强崩溃窗口:只有本次调用新建的 intent 可在收到围栏后获得点击
|
||||||
|
资格;恢复出的旧 intent、`CLICK_ASSUMED` 及其后状态永远不能重新点击。围栏已在
|
||||||
|
后端建立时,租约到期只允许继续订单列表对账。
|
||||||
|
- 2026-07-28:`go test ./...`、`go test -race ./...`、`go vet ./...`,
|
||||||
|
Android Debug/Release 单元测试和 APK 构建、根 `.\init.ps1` 全部通过。
|
||||||
|
- 2026-07-28:Debug `1.4.15 (20)` 真机阻断 smoke 通过。拼多多 8.17 确认页显示
|
||||||
|
“手动添加收货地址”“立即支付”和“先用后付”,肉包明确报告未创建提交围栏;
|
||||||
|
触发前后生产库 `order_submissions=0`,未点击下单或付款。因没有受控收货地址,
|
||||||
|
未执行真实订单提交。
|
||||||
|
|||||||
Reference in New Issue
Block a user