feat(t213): complete live SKU price verification
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.roubao.autopilot"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 9
|
||||
versionName = "1.4.4"
|
||||
versionCode = 15
|
||||
versionName = "1.4.10"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -292,6 +292,10 @@ class MainActivity : ComponentActivity() {
|
||||
val evaluationFailure by remember { candidateEvaluationFailureCode }
|
||||
val procurementState by procurementRepository.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(procurementState.task?.id) {
|
||||
resetTaskBoundProbeState()
|
||||
}
|
||||
|
||||
// 监听跳转事件
|
||||
LaunchedEffect(navigateToRecord, recordId) {
|
||||
if (navigateToRecord && recordId != null) {
|
||||
@@ -509,6 +513,11 @@ class MainActivity : ComponentActivity() {
|
||||
if (::readinessChecker.isInitialized) {
|
||||
refreshReadiness()
|
||||
lifecycleScope.launch {
|
||||
if (::procurementRepository.isInitialized) {
|
||||
procurementRepository.reconcileCurrentClaim(
|
||||
readinessChecker.snapshot()
|
||||
)
|
||||
}
|
||||
delay(READINESS_RECHECK_DELAY_MS)
|
||||
refreshReadiness()
|
||||
}
|
||||
@@ -594,6 +603,13 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
val procurementTask = procurementRepository.currentProbeTask()
|
||||
val procurementMode = procurementRepository.activeExecutionMode()
|
||||
if (
|
||||
procurementTask != null &&
|
||||
!procurementRepository.hasRunningExecution
|
||||
) {
|
||||
Toast.makeText(this, "请先开始采购任务", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val boundRequirement = requirementExtraction.value?.takeIf {
|
||||
requirementProbeState.value == RequirementProbeState.READY &&
|
||||
!it.manualReviewRequired
|
||||
@@ -1343,6 +1359,22 @@ class MainActivity : ComponentActivity() {
|
||||
taskCandidateDrafts.value = emptyList()
|
||||
}
|
||||
|
||||
private fun resetTaskBoundProbeState() {
|
||||
searchProbeRunner?.requestStop()
|
||||
searchProbeJob?.cancel()
|
||||
requirementProbeJob?.cancel()
|
||||
searchProbeReport.value = null
|
||||
searchProbeState.value = WorkflowState.IDLE
|
||||
searchProbeStepId.value = null
|
||||
candidateEvidence.value = emptyList()
|
||||
candidateSearchKeyword.value = null
|
||||
candidateRequirementSnapshot.value = null
|
||||
requirementProbeState.value = RequirementProbeState.IDLE
|
||||
requirementExtraction.value = null
|
||||
requirementFailureCode.value = null
|
||||
clearCandidateEvaluation()
|
||||
}
|
||||
|
||||
private fun checkShizukuPermission(): Boolean {
|
||||
return try {
|
||||
val granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ class AndroidPinduoduoImageSearchDriver(
|
||||
) ?: return false
|
||||
intent.addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
)
|
||||
appContext.startActivity(intent)
|
||||
return true
|
||||
|
||||
+4
@@ -11,6 +11,7 @@ import android.provider.MediaStore
|
||||
import com.roubao.task.ProbeReferenceImage
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class PreparedPinduoduoSearchImage(
|
||||
@@ -89,6 +90,8 @@ class PinduoduoImageSearchAssetStore(context: Context) {
|
||||
null,
|
||||
null
|
||||
)
|
||||
resolver.notifyChange(uri, null)
|
||||
delay(MEDIA_INDEX_SETTLE_MILLIS)
|
||||
PreparedPinduoduoSearchImage(uri, reference.sha256)
|
||||
} catch (_: Exception) {
|
||||
resolver.delete(uri, null, null)
|
||||
@@ -152,5 +155,6 @@ class PinduoduoImageSearchAssetStore(context: Context) {
|
||||
|
||||
private companion object {
|
||||
const val MEDIA_DIRECTORY = "Pictures/RoubaoSearch"
|
||||
const val MEDIA_INDEX_SETTLE_MILLIS = 750L
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@ object PinduoduoPageClassifier {
|
||||
element.clickable &&
|
||||
normalize(element.contentDescription.orEmpty()) == "返回"
|
||||
}
|
||||
val detailMarkerCount = setOf("联系客服", "收藏", "店铺")
|
||||
val detailMarkerCount = setOf("客服", "收藏", "店铺")
|
||||
.count { marker -> normalized.any { it.contains(marker) } }
|
||||
val specificationGroupCount = setOf("颜色分类", "颜色", "尺码", "尺寸")
|
||||
.count { marker -> normalized.any { it == marker } }
|
||||
|
||||
+2
-1
@@ -301,7 +301,8 @@ object PinduoduoSpecificationParser {
|
||||
"关闭",
|
||||
"确认款式"
|
||||
)
|
||||
private val PRICE_PATTERN = Regex("""^[¥¥](\d{1,7})(?:\.(\d{1,2}))?$""")
|
||||
private val PRICE_PATTERN =
|
||||
Regex("""^(?:首件)?[¥¥](\d{1,7})(?:\.(\d{1,2}))?$""")
|
||||
private const val MAX_ELEMENTS = 160
|
||||
private const val MAX_OPTIONS_PER_GROUP = 30
|
||||
private const val MAX_OPTION_TEXT_LENGTH = 80
|
||||
|
||||
+83
-12
@@ -82,21 +82,36 @@ class ProcurementRepository(
|
||||
|
||||
suspend fun claimNext(snapshot: DeviceReadinessSnapshot): Boolean = operation {
|
||||
val session = requireValidSession()
|
||||
val existing = persisted.claim
|
||||
if (existing?.task != null) {
|
||||
if (existing.referenceImage == null) {
|
||||
downloadAndPersistReference(session, existing, existing.task)
|
||||
}
|
||||
saveAndPublish(
|
||||
backendOnline = true,
|
||||
message = "已恢复当前领取任务"
|
||||
)
|
||||
return@operation true
|
||||
}
|
||||
var existing = persisted.claim
|
||||
val heartbeat = api.deviceHeartbeat(
|
||||
session,
|
||||
snapshot.toHeartbeatInput(persisted.claim?.task?.id)
|
||||
snapshot.toHeartbeatInput(existing?.task?.id)
|
||||
)
|
||||
when (
|
||||
reconcileClaim(
|
||||
localTaskId = existing?.task?.id,
|
||||
serverActiveTaskId = heartbeat.activeTaskId
|
||||
)
|
||||
) {
|
||||
ClaimReconciliationDecision.RESTORE -> {
|
||||
val current = requireNotNull(existing)
|
||||
val task = requireNotNull(current.task)
|
||||
if (current.referenceImage == null) {
|
||||
downloadAndPersistReference(session, current, task)
|
||||
}
|
||||
saveAndPublish(
|
||||
backendOnline = true,
|
||||
message = "已恢复当前领取任务"
|
||||
)
|
||||
return@operation true
|
||||
}
|
||||
ClaimReconciliationDecision.DISCARD_LOCAL -> {
|
||||
clearCurrentTask()
|
||||
store.save(persisted)
|
||||
existing = null
|
||||
}
|
||||
ClaimReconciliationDecision.CLAIM_OR_REPLAY -> Unit
|
||||
}
|
||||
if (heartbeat.activeTaskId != null &&
|
||||
existing == null
|
||||
) {
|
||||
@@ -209,6 +224,47 @@ class ProcurementRepository(
|
||||
true
|
||||
} ?: false
|
||||
|
||||
suspend fun reconcileCurrentClaim(
|
||||
snapshot: DeviceReadinessSnapshot
|
||||
): Boolean = operation {
|
||||
if (persisted.execution != null) {
|
||||
return@operation true
|
||||
}
|
||||
val current = persisted.claim ?: return@operation false
|
||||
val task = current.task ?: return@operation false
|
||||
val session = requireValidSession()
|
||||
val heartbeat = api.deviceHeartbeat(
|
||||
session,
|
||||
snapshot.toHeartbeatInput(task.id)
|
||||
)
|
||||
when (
|
||||
reconcileClaim(
|
||||
localTaskId = task.id,
|
||||
serverActiveTaskId = heartbeat.activeTaskId
|
||||
)
|
||||
) {
|
||||
ClaimReconciliationDecision.RESTORE -> {
|
||||
if (current.referenceImage == null) {
|
||||
downloadAndPersistReference(session, current, task)
|
||||
}
|
||||
saveAndPublish(
|
||||
backendOnline = true,
|
||||
message = "已恢复当前领取任务"
|
||||
)
|
||||
true
|
||||
}
|
||||
ClaimReconciliationDecision.DISCARD_LOCAL -> {
|
||||
clearCurrentTask()
|
||||
saveAndPublish(
|
||||
backendOnline = true,
|
||||
message = "后台任务已结束,本地状态已清理"
|
||||
)
|
||||
false
|
||||
}
|
||||
ClaimReconciliationDecision.CLAIM_OR_REPLAY -> false
|
||||
}
|
||||
} ?: false
|
||||
|
||||
suspend fun synchronizeRunning(): ExecutionSyncDecision =
|
||||
synchronize(allowSafetyStoppedSync = false)
|
||||
|
||||
@@ -975,6 +1031,21 @@ class ProcurementRepository(
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class ClaimReconciliationDecision {
|
||||
RESTORE,
|
||||
DISCARD_LOCAL,
|
||||
CLAIM_OR_REPLAY
|
||||
}
|
||||
|
||||
internal fun reconcileClaim(
|
||||
localTaskId: String?,
|
||||
serverActiveTaskId: String?
|
||||
): ClaimReconciliationDecision = when {
|
||||
localTaskId == null -> ClaimReconciliationDecision.CLAIM_OR_REPLAY
|
||||
localTaskId == serverActiveTaskId -> ClaimReconciliationDecision.RESTORE
|
||||
else -> ClaimReconciliationDecision.DISCARD_LOCAL
|
||||
}
|
||||
|
||||
private object UnavailableProcurementStateStore : ProcurementStateStore {
|
||||
override fun load() = PersistedProcurementState()
|
||||
|
||||
|
||||
+12
@@ -202,6 +202,18 @@ class PinduoduoPageClassifierTest {
|
||||
assertNull(snapshot.safetyStopReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modern detail footer accepts short customer service label`() {
|
||||
val snapshot = classify(
|
||||
element(contentDescription = "返回", clickable = true),
|
||||
element(contentDescription = "客服", clickable = true),
|
||||
element(contentDescription = "收藏", clickable = true)
|
||||
)
|
||||
|
||||
assertEquals(PinduoduoPage.PRODUCT_DETAIL, snapshot.page)
|
||||
assertNull(snapshot.safetyStopReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `specification panel requires color size and options`() {
|
||||
val snapshot = classify(
|
||||
|
||||
+25
@@ -106,6 +106,31 @@ class PinduoduoSpecificationParserTest {
|
||||
assertFalse(evidence.priceAmbiguous)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reads allowlisted first unit price and ignores comparison price`() {
|
||||
val evidence = PinduoduoSpecificationParser.parse(
|
||||
listOf(
|
||||
element("首件¥16.7", top = 20),
|
||||
element("大促价¥20.7", top = 30),
|
||||
element("已选择:XL码/裙长90胸围89 红色", top = 40),
|
||||
element("颜色分类", top = 100),
|
||||
element("红色", top = 140, clickable = true, selected = true),
|
||||
element("尺码", top = 240),
|
||||
element(
|
||||
"XL码/裙长90胸围89",
|
||||
top = 280,
|
||||
clickable = true,
|
||||
selected = true
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
requireNotNull(evidence)
|
||||
assertEquals(1670L, evidence.price?.cents)
|
||||
assertEquals("首件¥16.7", evidence.price?.rawText)
|
||||
assertFalse(evidence.priceAmbiguous)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple explicit prices are ambiguous`() {
|
||||
val evidence = PinduoduoSpecificationParser.parse(
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ClaimReconciliationPolicyTest {
|
||||
@Test
|
||||
fun `restores only when local and server task identities match`() {
|
||||
assertEquals(
|
||||
ClaimReconciliationDecision.RESTORE,
|
||||
reconcileClaim("task-1", "task-1")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discards local task when backend has ended or replaced it`() {
|
||||
assertEquals(
|
||||
ClaimReconciliationDecision.DISCARD_LOCAL,
|
||||
reconcileClaim("task-1", null)
|
||||
)
|
||||
assertEquals(
|
||||
ClaimReconciliationDecision.DISCARD_LOCAL,
|
||||
reconcileClaim("task-1", "task-2")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continues claim or replay when no local task is materialized`() {
|
||||
assertEquals(
|
||||
ClaimReconciliationDecision.CLAIM_OR_REPLAY,
|
||||
reconcileClaim(null, null)
|
||||
)
|
||||
assertEquals(
|
||||
ClaimReconciliationDecision.CLAIM_OR_REPLAY,
|
||||
reconcileClaim(null, "task-1")
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user