fix(android): recognize sold-out purchases (#158)
This commit is contained in:
+17
@@ -91,6 +91,13 @@ data class VisibleSpecValue(
|
||||
data class VisibleDimension(val key: String, val name: String, val values: List<VisibleSpecValue>)
|
||||
enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION, ORDER_CONFIRMATION }
|
||||
|
||||
object PddSoldOutRecoveryDefaults {
|
||||
const val EXACT_TEXT = "商品已售罄"
|
||||
const val PULL_DOWN_COUNT = 2
|
||||
const val INTERVAL_MILLIS = 1_000L
|
||||
const val SETTLE_MILLIS = 2_000L
|
||||
}
|
||||
|
||||
data class ParsedPddScreen(
|
||||
val summary: ProductSummary,
|
||||
val dimensions: List<VisibleDimension>,
|
||||
@@ -137,6 +144,16 @@ data class ParsedPddScreen(
|
||||
}
|
||||
return pageEvidenceMatched && (hasExactText || hasFallbackAtTop) && !hasPrimaryProductEvidence
|
||||
}
|
||||
|
||||
fun isSoldOutForPurchase(): Boolean {
|
||||
val values = dimensions.flatMap(VisibleDimension::values)
|
||||
return isTransientSoldOut(exactText = PddSoldOutRecoveryDefaults.EXACT_TEXT) ||
|
||||
(specPanelOpen && values.isNotEmpty() && values.none(VisibleSpecValue::available))
|
||||
}
|
||||
|
||||
fun hasPurchaseProductEvidence(): Boolean =
|
||||
packageMatched && rootAvailable &&
|
||||
(specPanelOpen || specEntry != null || quickConfirmationEntry != null || summary.title != null)
|
||||
}
|
||||
|
||||
object PddScreenParser {
|
||||
|
||||
+42
-4
@@ -14,6 +14,7 @@ interface PurchaseUiDriver {
|
||||
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
|
||||
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550)
|
||||
fun backPurchase(): Boolean
|
||||
fun bringPddToForeground(): Boolean = false
|
||||
}
|
||||
@@ -60,8 +61,10 @@ class PurchaseRehearsalExecutor(
|
||||
stepChanged(action.type.wireName)
|
||||
val failure = when (action.type) {
|
||||
PurchaseActionType.OPEN_PRODUCT -> openProduct(input, action)
|
||||
PurchaseActionType.VERIFY_PRODUCT -> verifyProduct()
|
||||
PurchaseActionType.OPEN_SPEC_PANEL -> openSpecPanel(input, action)
|
||||
PurchaseActionType.VERIFY_PRODUCT -> verifyProduct(input)
|
||||
PurchaseActionType.OPEN_SPEC_PANEL -> {
|
||||
openSpecPanel(input, action) ?: recoverSoldOut(input, closeSpecPanel = true) ?: openSpecPanel(input, action)
|
||||
}
|
||||
PurchaseActionType.SELECT_SPEC -> if (input.phase == "spec_probe") null else selectSpecs(input, rule)
|
||||
PurchaseActionType.SET_QUANTITY -> if (input.phase == "spec_probe") null else setQuantity(input.quantity)
|
||||
PurchaseActionType.VERIFY_UNIT_PRICE -> if (input.phase == "spec_probe") null else verifyPrice(input).also {
|
||||
@@ -197,16 +200,50 @@ class PurchaseRehearsalExecutor(
|
||||
return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面")
|
||||
}
|
||||
|
||||
private fun verifyProduct(): PurchaseExecutionOutcome? {
|
||||
private fun verifyProduct(input: PurchaseExecutionInput): PurchaseExecutionOutcome? {
|
||||
repeat(50) {
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)?.let { return it }
|
||||
if (snapshot.packageName == PDD_PACKAGE && snapshot.nodes.any { it.visible }) return null
|
||||
if (snapshot.packageName == PDD_PACKAGE && snapshot.nodes.any { it.visible }) {
|
||||
return recoverSoldOut(input, PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null))
|
||||
}
|
||||
pause(100)
|
||||
}
|
||||
return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面")
|
||||
}
|
||||
|
||||
private fun recoverSoldOut(
|
||||
input: PurchaseExecutionInput,
|
||||
initial: ParsedPddScreen = currentScreen(input),
|
||||
closeSpecPanel: Boolean = false,
|
||||
): PurchaseExecutionOutcome? {
|
||||
if (!initial.isSoldOutForPurchase()) return null
|
||||
if (closeSpecPanel && initial.specPanelOpen) {
|
||||
if (!driver.backPurchase()) {
|
||||
return failure("PDD_GOODS_SOLD_OUT", "商品规格均已售罄,规格面板关闭失败")
|
||||
}
|
||||
pause(SOLD_OUT_PANEL_CLOSE_MILLIS)
|
||||
}
|
||||
repeat(PddSoldOutRecoveryDefaults.PULL_DOWN_COUNT) { index ->
|
||||
if (!driver.pullDownGoodsPage()) {
|
||||
return failure("PDD_GOODS_SOLD_OUT", "商品页显示已售罄,页面下拉恢复失败")
|
||||
}
|
||||
if (index < PddSoldOutRecoveryDefaults.PULL_DOWN_COUNT - 1) {
|
||||
pause(PddSoldOutRecoveryDefaults.INTERVAL_MILLIS)
|
||||
}
|
||||
}
|
||||
pause(PddSoldOutRecoveryDefaults.SETTLE_MILLIS)
|
||||
val recovered = currentScreen(input)
|
||||
recovered.problem?.let { return failure(it.code, it.message) }
|
||||
if (recovered.isSoldOutForPurchase()) {
|
||||
return failure("PDD_GOODS_SOLD_OUT", "商品页下拉恢复后仍显示已售罄")
|
||||
}
|
||||
if (!recovered.hasPurchaseProductEvidence()) {
|
||||
return failure("RULE_NOT_MATCHED", "商品页下拉恢复后页面证据失效")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun openSpecPanel(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
var screen = currentScreen(input)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
@@ -443,6 +480,7 @@ class PurchaseRehearsalExecutor(
|
||||
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
|
||||
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
|
||||
private const val SPEC_SELECTION_POLL_MILLIS = 100L
|
||||
private const val SOLD_OUT_PANEL_CLOSE_MILLIS = 300L
|
||||
val DEFAULT_COLLECTOR = PddCollectorConfig(
|
||||
collectorId = "pddProductDetailV1",
|
||||
specEntryStrategy = "safeBottomSpecEntryV1",
|
||||
|
||||
@@ -177,6 +177,60 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals(50, pauses.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transient sold out page recovers before opening specs`() {
|
||||
val driver = FakePurchaseDriver(soldOut = true, recoverSoldOutAfterPull = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(2, driver.pullDownCount)
|
||||
assertTrue(driver.clicked.contains("选择规格"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persistent sold out page returns replacement eligible error`() {
|
||||
val driver = FakePurchaseDriver(soldOut = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PDD_GOODS_SOLD_OUT", outcome.errorCode)
|
||||
assertEquals(2, driver.pullDownCount)
|
||||
assertFalse(driver.clicked.contains("选择规格"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sold out recovery gesture failure stops immediately`() {
|
||||
val driver = FakePurchaseDriver(soldOut = true, pullDownSucceeds = false)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PDD_GOODS_SOLD_OUT", outcome.errorCode)
|
||||
assertEquals(1, driver.pullDownCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sold out recovery rejects a page that lost product evidence`() {
|
||||
val driver = FakePurchaseDriver(soldOut = true, loseEvidenceAfterPull = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(2, driver.pullDownCount)
|
||||
assertFalse(driver.clicked.contains("选择规格"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all unavailable specs recover on goods page and reopen panel`() {
|
||||
val driver = FakePurchaseDriver(allSpecsUnavailable = true, recoverSoldOutAfterPull = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(2, driver.pullDownCount)
|
||||
assertEquals(2, driver.clicked.count { it == "选择规格" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `goods id mismatch fails before opening pdd`() {
|
||||
var opened = false
|
||||
@@ -308,6 +362,11 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val openPddOnFailedClick: Boolean = false,
|
||||
private val sizeClickResults: MutableList<FreshActionResult> = mutableListOf(),
|
||||
private val sizeSelectsOnFailedClick: Boolean = false,
|
||||
soldOut: Boolean = false,
|
||||
private val recoverSoldOutAfterPull: Boolean = false,
|
||||
private val pullDownSucceeds: Boolean = true,
|
||||
private val loseEvidenceAfterPull: Boolean = false,
|
||||
allSpecsUnavailable: Boolean = false,
|
||||
) : PurchaseUiDriver {
|
||||
var browser = false
|
||||
var panel = false
|
||||
@@ -318,6 +377,10 @@ class PurchaseRehearsalExecutorTest {
|
||||
var upSwipeCount = 0
|
||||
var openClickCount = 0
|
||||
var sizeClickCount = 0
|
||||
var pullDownCount = 0
|
||||
private var soldOut = soldOut
|
||||
private var allSpecsUnavailable = allSpecsUnavailable
|
||||
private var productEvidenceLost = false
|
||||
val clicked = mutableListOf<String>()
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
@@ -328,6 +391,16 @@ class PurchaseRehearsalExecutorTest {
|
||||
return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes)
|
||||
}
|
||||
if (!panel) {
|
||||
if (productEvidenceLost) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200)))
|
||||
}
|
||||
if (soldOut) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
node("sold-out", "商品已售罄", 100, 300, 900, 380),
|
||||
node("similar", "相似商品", 100, 500, 900, 580),
|
||||
))
|
||||
}
|
||||
val nodes = mutableListOf(
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
)
|
||||
@@ -350,11 +423,11 @@ class PurchaseRehearsalExecutorTest {
|
||||
node("scroll/color-heading", "颜色分类", 20, 410, 300, 450, parentPath = "scroll"),
|
||||
)
|
||||
colors.forEachIndexed { index, value ->
|
||||
nodes += node("scroll/color-$index", value, 20 + index * 220, 470, 200 + index * 220, 540, clickable = true, selected = color == value, parentPath = "scroll")
|
||||
nodes += node("scroll/color-$index", value, 20 + index * 220, 470, 200 + index * 220, 540, clickable = true, selected = color == value, enabled = !allSpecsUnavailable, parentPath = "scroll")
|
||||
}
|
||||
nodes += node("scroll/size-heading", "尺码", 20, 650, 300, 690, parentPath = "scroll")
|
||||
val visibleSize = if (upSwipeCount >= hiddenSizeUntilUpSwipes) "XL" else "S"
|
||||
nodes += node("scroll/size", visibleSize, 20, 710, 200, 780, clickable = true, selected = size == visibleSize, parentPath = "scroll")
|
||||
nodes += node("scroll/size", visibleSize, 20, 710, 200, 780, clickable = true, selected = size == visibleSize, enabled = !allSpecsUnavailable, parentPath = "scroll")
|
||||
nodes += node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText")
|
||||
nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true)
|
||||
nodes += node("order", "提交订单", 20, 1100, 500, 1180, clickable = true)
|
||||
@@ -401,7 +474,24 @@ class PurchaseRehearsalExecutorTest {
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean =
|
||||
swipePurchase(direction, durationMs)
|
||||
|
||||
override fun backPurchase(): Boolean = true
|
||||
override fun pullDownGoodsPage(): Boolean {
|
||||
pullDownCount++
|
||||
if (!pullDownSucceeds) return false
|
||||
if (recoverSoldOutAfterPull && pullDownCount >= 2) {
|
||||
soldOut = false
|
||||
allSpecsUnavailable = false
|
||||
}
|
||||
if (loseEvidenceAfterPull && pullDownCount >= 2) {
|
||||
soldOut = false
|
||||
productEvidenceLost = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun backPurchase(): Boolean {
|
||||
panel = false
|
||||
return true
|
||||
}
|
||||
|
||||
private fun node(
|
||||
path: String,
|
||||
@@ -413,9 +503,10 @@ class PurchaseRehearsalExecutorTest {
|
||||
clickable: Boolean = false,
|
||||
scrollable: Boolean = false,
|
||||
selected: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
className: String = "android.widget.TextView",
|
||||
parentPath: String? = null,
|
||||
) = SnapshotNode(path, parentPath, text, null, null, className, NodeBounds(left, top, right, bottom), clickable, scrollable, selected, false, true, true)
|
||||
) = SnapshotNode(path, parentPath, text, null, null, className, NodeBounds(left, top, right, bottom), clickable, scrollable, selected, false, enabled, true)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 1e9bf2da1957b31236921cbf572fa8a61d78e275
|
||||
synchronized_at: 2026-08-29T08:47:19Z
|
||||
wiki_revision: 2e474dd5f804c3e754ecc8e8722c9469e424acf0
|
||||
synchronized_at: 2026-08-29T09:02:23Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -156,6 +156,7 @@ synchronized_at: 2026-08-29T01:39:44Z
|
||||
- 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。
|
||||
- 采购规格由服务端按顺序决策:先使用已确认的人工映射;否则仅在同一规格角色的可选 PDD 原始标签中做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算);仍无唯一结果才调用已启用的服务端 AI。AI 必须返回候选集中的原始标签,候选不完整、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。
|
||||
- Agent 选择服务端下发的精确颜色或尺码时,只能在已确认打开的规格面板内有限纵向滑动、每次重新读取可选节点并按完整原始文字点击;连续没有新证据或达到上限即停止,不得点击相近规格。第一趟探测并固化规格后,第二趟仍无法精确选择而再次提交探测时,服务端必须明确失败并释放活动槽,保留第一次决策证据,禁止清空决策、循环派发或进入地址与创建订单动作。
|
||||
- 采购 Agent 在确认进入 PDD 商品页后、打开规格前,复用采集侧的假售罄识别;规格面板已打开且当前解析到的规格值全部不可选时也按售罄处理。两种情况都只允许关闭规格面板后对商品页执行一次有界恢复(默认下拉 2 次、间隔 1000 毫秒、等待 2000 毫秒),恢复动作失败或恢复后仍售罄时返回 `PDD_GOODS_SOLD_OUT`,恢复后商品页证据丢失时按页面规则不匹配失败;不得继续选择规格、修改地址或创建订单。该失败码继续进入既有替代商品资格判定。
|
||||
- 规格映射不完整、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。
|
||||
- Agent 提交的相同 attempt 最终结果只能写入一次;相同请求重放返回原事实,不同内容拒绝覆盖。`order_result_unknown` 不参与自动派发,只能人工解除。
|
||||
- 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。
|
||||
|
||||
Reference in New Issue
Block a user