Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d528c74c57 | ||
|
|
d8d9ab9edd | ||
|
|
919744ec05 | ||
|
|
72a15f8d9f | ||
|
|
b311885ea8 | ||
|
|
a4a793f449 | ||
|
|
4a669bca86 | ||
|
|
309dce3627 | ||
|
|
8b75a95d67 | ||
|
|
3ee8dfc0aa | ||
|
|
555a954605 | ||
|
|
1e924fa411 | ||
|
|
1c67f3fdd0 | ||
|
|
6f1af70d0d |
+17
-4
@@ -167,6 +167,8 @@ data class ParsedPddScreen(
|
||||
val quantityInputCount: Int = 0,
|
||||
/** #331: address entry, payment entry and quantity input share one bounded panel container. */
|
||||
val requiredEvidenceSameContainer: Boolean = false,
|
||||
/** Only populated by the final-confirmation opt-in; existing selectedSummary behavior is unchanged. */
|
||||
val finalSelectionSummaries: List<String> = emptyList(),
|
||||
) {
|
||||
/** The three #331 required spec-panel items are all visible. */
|
||||
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
|
||||
@@ -265,7 +267,7 @@ object PddScreenParser {
|
||||
/** Whole-sheet containers are clickable too; a real entry row stays well below this share of the screen height. */
|
||||
private const val ENTRY_ROW_MAX_HEIGHT_PERCENT = 30
|
||||
|
||||
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen {
|
||||
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null, forFinalConfirmation: Boolean = false): ParsedPddScreen {
|
||||
val visibleNodes = snapshot.nodes.filter { it.visible }
|
||||
val visible = visibleNodes.mapNotNull { node ->
|
||||
val descendants = descendants(node, visibleNodes)
|
||||
@@ -422,9 +424,12 @@ object PddScreenParser {
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
.groupBy(VisibleSpecValue::rawText)
|
||||
.map { (_, sameLabel) ->
|
||||
sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) }
|
||||
.let { physicalOptions ->
|
||||
// Keep optionBlock's physical parent/child aggregation in both modes.
|
||||
// Only final confirmation must detect separate same-label option blocks.
|
||||
if (forFinalConfirmation) physicalOptions else physicalOptions
|
||||
.groupBy(VisibleSpecValue::rawText)
|
||||
.map { (_, sameLabel) -> sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) } }
|
||||
}
|
||||
if (values.isNotEmpty()) add(VisibleDimension(dimensionKey, heading.label, values))
|
||||
}
|
||||
@@ -471,6 +476,7 @@ object PddScreenParser {
|
||||
else -> SpecPanelType.UNKNOWN
|
||||
}
|
||||
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
|
||||
var unprefixedSummaryCandidates = emptyList<String>()
|
||||
val unprefixedSummary = if (structuredSelectionPanel) {
|
||||
val quantity = quantityInputs.single()
|
||||
val knownColors = (dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } +
|
||||
@@ -486,6 +492,7 @@ object PddScreenParser {
|
||||
knownColors.any { SpecValueNormalizer.summaryHasExactToken(node.label, it) }
|
||||
}.map { it.label }.distinct()
|
||||
if (candidates.isNotEmpty()) {
|
||||
unprefixedSummaryCandidates = candidates
|
||||
summary = candidates.singleOrNull()
|
||||
break
|
||||
}
|
||||
@@ -574,6 +581,12 @@ object PddScreenParser {
|
||||
hasQuantityAdjustControls = hasQuantityAdjustControls,
|
||||
quantityInputCount = quantityInputs.size,
|
||||
requiredEvidenceSameContainer = requiredEvidenceSameContainer,
|
||||
finalSelectionSummaries = if (forFinalConfirmation) {
|
||||
// Use own node labels, not labels propagated into clickable ancestors.
|
||||
(visibleNodes.map(SnapshotNode::label).filter { label ->
|
||||
textAliases.selection.selectedPrefixes.any { label.startsWith(it) }
|
||||
} + unprefixedSummaryCandidates).distinct()
|
||||
} else emptyList(),
|
||||
explicitSpecEntryCount = explicitSpecEntries.size,
|
||||
nestedSpecEntryCount = nestedSpecEntries.size,
|
||||
bottomPurchaseEntryCount = bottomSpecEntries.size,
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/** Current-capture evidence only. No clicks, historical selection proof, or whole-screen text fallback. */
|
||||
internal object PurchaseFinalSpecVerifier {
|
||||
private fun normalize(value: String) = SpecValueNormalizer.normalizeFinalConfirmation(value)
|
||||
|
||||
fun verify(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostic: (String) -> Unit) {
|
||||
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
|
||||
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
|
||||
fun within(node: SnapshotNode, container: SnapshotNode): Boolean {
|
||||
val visited = mutableSetOf<String>()
|
||||
var current: SnapshotNode? = node
|
||||
while (current != null && visited.add(current.path)) {
|
||||
if (current.path == container.path) return true
|
||||
current = current.parentPath?.let(byPath::get)
|
||||
}
|
||||
return false
|
||||
}
|
||||
fun contained(node: SnapshotNode, container: SnapshotNode) =
|
||||
node.bounds.width > 0 && node.bounds.height > 0 &&
|
||||
node.bounds.left >= container.bounds.left && node.bounds.right <= container.bounds.right &&
|
||||
node.bounds.top >= container.bounds.top && node.bounds.bottom <= container.bounds.bottom
|
||||
|
||||
val original = PddScreenParser.parse(snapshot, config, input.goodsId, null, forFinalConfirmation = true)
|
||||
val fullPanel = boundedQuantityPanel(snapshot).takeIf { original.specPanelOpen }
|
||||
// #331/#335 sheet ancestry, as represented by SpecPanelFixtures.sheet and taskOptionDedupSheet.
|
||||
// The inner scroll list is not the full sheet: summary and quantity may be its siblings.
|
||||
val scoped = fullPanel?.let { panel ->
|
||||
val nodes = snapshot.nodes.filter { node ->
|
||||
(within(node, panel) && contained(node, panel)) ||
|
||||
(node.parentPath == null && node.label.isBlank() && !node.clickable)
|
||||
}
|
||||
PddScreenParser.parse(snapshot.copy(nodes = nodes), config, input.goodsId, null, forFinalConfirmation = true)
|
||||
.takeIf { it.specPanelOpen }
|
||||
}
|
||||
val optionScope = if (scoped != null) fullPanel else original.specPanelContainer
|
||||
val dimensions = (scoped ?: original).dimensions.groupBy(VisibleDimension::key).mapValues { (_, groups) ->
|
||||
val options = groups.flatMap(VisibleDimension::values).filter { option ->
|
||||
optionScope != null && within(option.node, optionScope) && contained(option.node, optionScope)
|
||||
}
|
||||
// A blank clickable wrapper and its single text child may survive optionBlock separately.
|
||||
// The #331 size fixture exposes exactly the same rectangle for both. Aggregate only a
|
||||
// same-value ancestor chain with that physical rectangle, never same-label siblings.
|
||||
options.groupBy { option ->
|
||||
options.filter { other -> other.node.bounds == option.node.bounds &&
|
||||
normalize(other.text) == normalize(option.text) && within(option.node, other.node)
|
||||
}.minBy { it.node.path.length }.node.path
|
||||
}.values.map { members ->
|
||||
members.first().let { first -> first.copy(
|
||||
available = members.all(VisibleSpecValue::available),
|
||||
node = first.node.copy(
|
||||
selected = members.any { it.node.selected },
|
||||
checked = members.any { it.node.checked },
|
||||
),
|
||||
) }
|
||||
}
|
||||
}
|
||||
val summaries = scoped?.finalSelectionSummaries.orEmpty().map { raw ->
|
||||
// Only the anchored recognized prefix and its optional colon are removed.
|
||||
val trimmed = raw.trim()
|
||||
val prefix = config.textAliases.selection.selectedPrefixes.firstOrNull(trimmed::startsWith)
|
||||
val value = if (prefix == null) trimmed else trimmed.removePrefix(prefix).trimStart().let {
|
||||
if (it.startsWith(':') || it.startsWith(':')) it.drop(1) else it
|
||||
}
|
||||
normalize(value)
|
||||
}.distinct()
|
||||
val targets = linkedMapOf("color" to normalize(input.mappedColor), "size" to normalize(input.mappedSize))
|
||||
.filterValues(String::isNotBlank)
|
||||
fun reject(dimension: String, stage: String, selected: Boolean = false, conflict: Boolean = false): Nothing {
|
||||
val detail = "finalSpec;dimension=$dimension;stage=$stage;summaryPresent=${if (summaries.isNotEmpty()) 1 else 0};" +
|
||||
"selectedFound=${if (selected) 1 else 0};conflict=${if (conflict) 1 else 0}"
|
||||
diagnostic(detail)
|
||||
throw PurchaseLiveException("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败;$detail")
|
||||
}
|
||||
if (targets.isEmpty()) reject("none", "missing_target")
|
||||
val selectedProof = mutableSetOf<String>()
|
||||
targets.forEach { (dimension, target) ->
|
||||
val options = dimensions[dimension].orEmpty()
|
||||
val selected = options.filter { it.node.selected || it.node.checked }
|
||||
// Parsed text already follows the established UI trailing-price separation contract.
|
||||
// The final-only normalizer removes no additional content from values, input or summary.
|
||||
val exact = options.filter { normalize(it.text) == target }
|
||||
if (selected.any { normalize(it.text) != target }) reject(dimension, "selected_conflict", selected = true, conflict = true)
|
||||
if (exact.size > 1) reject(dimension, "candidate_collision", selected.isNotEmpty(), conflict = true)
|
||||
if (exact.any { !it.available }) reject(dimension, "unavailable", selected.isNotEmpty(), conflict = true)
|
||||
if (exact.any { it.node.selected || it.node.checked }) selectedProof += dimension
|
||||
}
|
||||
if (summaries.size > 1) reject(targets.keys.first(), "summary_conflict", selectedProof.isNotEmpty(), conflict = true)
|
||||
val summary = summaries.singleOrNull()
|
||||
// Evidence only supports color then size with a space. Do not guess reverse order or comma separators.
|
||||
val expected = targets.values.joinToString(" ")
|
||||
if (summary != null && targets.size == 2) {
|
||||
val colors = (dimensions["color"].orEmpty().map { normalize(it.text) } + targets.getValue("color")).distinct()
|
||||
val sizes = (dimensions["size"].orEmpty().map { normalize(it.text) } + targets.getValue("size")).distinct()
|
||||
if (colors.any { color -> sizes.any { size ->
|
||||
"$color $size" == summary && (color != targets["color"] || size != targets["size"])
|
||||
} }) reject("color", "composition_ambiguity", selectedProof.isNotEmpty(), conflict = true)
|
||||
}
|
||||
targets.keys.forEach { dimension ->
|
||||
if (dimension !in selectedProof && summary != expected) reject(dimension, "missing_proof")
|
||||
}
|
||||
}
|
||||
|
||||
/** Existing #335 quantity-ancestor form, retaining identity as well as bounds for summary ownership. */
|
||||
private fun boundedQuantityPanel(snapshot: UiSnapshot): SnapshotNode? {
|
||||
val visible = snapshot.nodes.filter(SnapshotNode::visible)
|
||||
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
|
||||
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
|
||||
val screenArea = screenWidth.toLong() * screenHeight
|
||||
val byPath = visible.associateBy(SnapshotNode::path)
|
||||
var current = visible.singleOrNull {
|
||||
it.enabled && it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true
|
||||
} ?: return null
|
||||
var panel: SnapshotNode? = null
|
||||
val visited = mutableSetOf(current.path)
|
||||
while (true) {
|
||||
val parent = current.parentPath?.let(byPath::get) ?: break
|
||||
if (!visited.add(parent.path)) return null
|
||||
val area = parent.bounds.width.toLong() * parent.bounds.height
|
||||
if (area <= 0 || area >= screenArea || parent.parentPath == null) break
|
||||
panel = parent
|
||||
current = parent
|
||||
}
|
||||
return panel
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -321,10 +321,7 @@ class PurchaseLiveAutomation(
|
||||
val submit = finalSubmitTargets(snapshot)
|
||||
if (submit.size != 1) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不是唯一目标,禁止创建订单")
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
val selected = listOf(input.mappedColor, input.mappedSize).filter(String::isNotBlank)
|
||||
if (selected.any { value -> screen.selectedSummary?.contains(value) != true && snapshot.nodes.none { it.visible && it.label.contains(value) } }) {
|
||||
fail("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败")
|
||||
}
|
||||
PurchaseFinalSpecVerifier.verify(snapshot, input, panelDiagnostic)
|
||||
val price = screen.priceCent ?: fail("RULE_NOT_MATCHED", "创建订单前没有读取到商品单价")
|
||||
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
|
||||
val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() }
|
||||
|
||||
@@ -13,6 +13,20 @@ object SpecValueNormalizer {
|
||||
|
||||
fun normalizeSize(value: String): String = normalizeTrailingPrice(value)
|
||||
|
||||
/** Final purchase confirmation only: display equivalents, never semantic/content removal. */
|
||||
fun normalizeFinalConfirmation(value: String): String = buildString {
|
||||
value.forEach { ch ->
|
||||
append(when (ch) {
|
||||
'【', '〔', '[' -> '['
|
||||
'】', '〕', ']' -> ']'
|
||||
'(' -> '('
|
||||
')' -> ')'
|
||||
in 'A'..'Z', in 'a'..'z', in '0'..'9' -> (ch.code - 0xFEE0).toChar()
|
||||
else -> if (ch.isWhitespace()) ' ' else ch
|
||||
})
|
||||
}
|
||||
}.replace(Regex(" +"), " ").trim()
|
||||
|
||||
/** The same terminal-price grammar as normalization; never reads numbers inside a spec. */
|
||||
internal fun trailingPriceAmount(value: String): java.math.BigDecimal? =
|
||||
trailingPrice.find(value)?.value?.replace(currency, "")?.trim()?.toBigDecimalOrNull()
|
||||
|
||||
+16
-1
@@ -1,15 +1,30 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
import cn.ilapage.goauto.agent.network.AgentApiException
|
||||
|
||||
object PurchaseRejectionPolicy {
|
||||
val codes = setOf("PURCHASE_STATE_CONFLICT", "PURCHASE_LEASE_EXPIRED", "PURCHASE_RESULT_CONFLICT")
|
||||
fun terminalCode(error: Exception): String? = (error as? AgentApiException)
|
||||
?.takeIf { it.status == 409 && it.code in codes }?.code
|
||||
}
|
||||
|
||||
/** Uploads already-persisted results only; it never invokes device automation. */
|
||||
class PurchaseOutboxUploader(
|
||||
private val pending: () -> List<PendingPurchaseOutbox>,
|
||||
private val submit: (PendingPurchaseOutbox) -> Unit,
|
||||
private val markUploaded: (PendingPurchaseOutbox) -> Unit,
|
||||
private val afterUploaded: (PendingPurchaseOutbox) -> Unit = {},
|
||||
private val markRejected: (PendingPurchaseOutbox, String) -> Unit = { _, _ -> error("Rejection persistence not configured") },
|
||||
) {
|
||||
fun flush() {
|
||||
pending().forEach { item ->
|
||||
submit(item)
|
||||
try {
|
||||
submit(item)
|
||||
} catch (error: Exception) {
|
||||
val code = PurchaseRejectionPolicy.terminalCode(error) ?: throw error
|
||||
markRejected(item, code)
|
||||
return@forEach
|
||||
}
|
||||
markUploaded(item)
|
||||
afterUploaded(item)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
data class RejectedPurchaseResult(
|
||||
val id: Long, val taskId: Long, val attemptId: String, val payloadJson: String,
|
||||
val rejectedAt: Long, val errorCode: String, val acknowledgedAt: Long?,
|
||||
)
|
||||
|
||||
internal object PurchaseRejectionSql {
|
||||
val migration = listOf(
|
||||
"ALTER TABLE purchase_outbox ADD COLUMN rejected_at INTEGER",
|
||||
"ALTER TABLE purchase_outbox ADD COLUMN rejection_error_code TEXT",
|
||||
"ALTER TABLE purchase_outbox ADD COLUMN acknowledged_at INTEGER",
|
||||
)
|
||||
const val activeTask = "SELECT task_id FROM purchase_task WHERE upload_status != 'rejected' AND (status IN ('running','order_submit_started') OR upload_status='pending') ORDER BY updated_at ASC LIMIT 1"
|
||||
const val acknowledge = "UPDATE purchase_outbox SET acknowledged_at=? WHERE id=? AND upload_status='rejected' AND acknowledged_at IS NULL"
|
||||
|
||||
/** The caller must wrap both writes in one transaction. Zero task rows means a newer attempt replaced it. */
|
||||
fun reject(item: PendingPurchaseOutbox, code: String, now: Long, update: (String, List<Any>) -> Int) {
|
||||
require(code in PurchaseRejectionPolicy.codes)
|
||||
check(update(
|
||||
"UPDATE purchase_outbox SET upload_status='rejected',rejected_at=?,rejection_error_code=?,updated_at=? WHERE id=? AND task_id=? AND attempt_id=? AND upload_status='pending'",
|
||||
listOf(now, code, now, item.id, item.taskId, item.attemptId),
|
||||
) == 1)
|
||||
update("UPDATE purchase_task SET upload_status='rejected',updated_at=? WHERE task_id=? AND attempt_id=?",
|
||||
listOf(now, item.taskId, item.attemptId))
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
)
|
||||
db.execSQL("CREATE INDEX idx_purchase_outbox_pending ON purchase_outbox(upload_status, id)")
|
||||
db.execSQL("CREATE INDEX idx_purchase_attempt_task ON purchase_attempt(task_id, created_at)")
|
||||
PurchaseRejectionSql.migration.forEach(db::execSQL)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
@@ -74,6 +75,7 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
db.execSQL("ALTER TABLE purchase_task ADD COLUMN final_confirmation_json TEXT")
|
||||
db.execSQL("ALTER TABLE purchase_task ADD COLUMN irreversible_at INTEGER")
|
||||
}
|
||||
if (oldVersion < 3 && newVersion >= 3) PurchaseRejectionSql.migration.forEach(db::execSQL)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -225,8 +227,8 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
|
||||
@Synchronized
|
||||
fun activeTaskId(): Long? = readableDatabase.rawQuery(
|
||||
"SELECT task_id FROM purchase_task WHERE status IN (?,?) OR upload_status=? ORDER BY updated_at ASC LIMIT 1",
|
||||
arrayOf(STATUS_RUNNING, STATUS_ORDER_SUBMIT_STARTED, UPLOAD_PENDING),
|
||||
PurchaseRejectionSql.activeTask,
|
||||
null,
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null }
|
||||
|
||||
@Synchronized
|
||||
@@ -243,9 +245,53 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun markRejected(item: PendingPurchaseOutbox, code: String) {
|
||||
val db = writableDatabase
|
||||
db.beginTransaction()
|
||||
try {
|
||||
PurchaseRejectionSql.reject(item, code, System.currentTimeMillis()) { sql, args ->
|
||||
db.compileStatement(sql).use { statement ->
|
||||
args.forEachIndexed { index, value ->
|
||||
if (value is Long) statement.bindLong(index + 1, value)
|
||||
else statement.bindString(index + 1, value.toString())
|
||||
}
|
||||
statement.executeUpdateDelete()
|
||||
}
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
} finally { db.endTransaction() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun rejectedResults(): List<RejectedPurchaseResult> = readableDatabase.rawQuery(
|
||||
"SELECT id,task_id,attempt_id,payload_json,rejected_at,rejection_error_code,acknowledged_at FROM purchase_outbox WHERE upload_status='rejected' ORDER BY id DESC", null,
|
||||
).use { cursor -> buildList {
|
||||
while (cursor.moveToNext()) add(RejectedPurchaseResult(
|
||||
cursor.getLong(0), cursor.getLong(1), cursor.getString(2), cursor.getString(3),
|
||||
cursor.getLong(4), cursor.getString(5), if (cursor.isNull(6)) null else cursor.getLong(6),
|
||||
))
|
||||
} }
|
||||
|
||||
@Synchronized
|
||||
fun rejectedCount(): Int = readableDatabase.rawQuery(
|
||||
"SELECT COUNT(*) FROM purchase_outbox WHERE upload_status='rejected'", null,
|
||||
).use { it.moveToFirst(); it.getInt(0) }
|
||||
|
||||
@Synchronized
|
||||
fun isCurrentAttemptRejected(taskId: Long, attemptId: String): Boolean = readableDatabase.rawQuery(
|
||||
"SELECT 1 FROM purchase_task WHERE task_id=? AND attempt_id=? AND upload_status='rejected'",
|
||||
arrayOf(taskId.toString(), attemptId),
|
||||
).use { it.moveToFirst() }
|
||||
|
||||
@Synchronized
|
||||
fun acknowledgeRejection(outboxId: Long) {
|
||||
writableDatabase.execSQL(PurchaseRejectionSql.acknowledge, arrayOf(System.currentTimeMillis(), outboxId))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DATABASE_NAME = "goauto_purchase.db"
|
||||
private const val DATABASE_VERSION = 2
|
||||
private const val DATABASE_VERSION = 3
|
||||
private const val STATUS_RUNNING = "running"
|
||||
const val STATUS_ORDER_SUBMIT_STARTED = "order_submit_started"
|
||||
private const val STATUS_COMPLETED = "completed"
|
||||
|
||||
+57
-27
@@ -204,6 +204,7 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stateStore.stop()
|
||||
probeHandoff.invalidate()
|
||||
if (diagnosticInstance === this) diagnosticInstance = null
|
||||
repurchaseClosed.set(true)
|
||||
@@ -268,12 +269,13 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun synchronizeAgent(): String {
|
||||
var heartbeatSucceeded = false
|
||||
return try {
|
||||
val serverUrl = ServerUrlPolicy.normalize(settingsStore.serverUrl())
|
||||
val api = AgentApiClient(serverUrl)
|
||||
var credentials = identityStore.credentials()
|
||||
stateStore.update("CONNECTING", "正在连接并校验设备身份", credentials?.deviceId ?: 0L, credentials != null)
|
||||
updateNotification("正在连接")
|
||||
stateStore.update("CONNECTING", "正在同步", credentials?.deviceId ?: 0L, credentials != null, connection = true)
|
||||
updateNotification("正在同步")
|
||||
|
||||
if (!registeredThisProcess.get()) {
|
||||
val registration = api.register(deviceInfo(), credentials?.token)
|
||||
@@ -290,17 +292,30 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
val activeCredentials = credentials ?: error("设备尚未取得认证凭据")
|
||||
if (taskMutex.currentTaskId() == null) {
|
||||
recoverInterruptedPurchases(api, activeCredentials.token)
|
||||
flushPurchaseOutbox(api, activeCredentials.token)
|
||||
runningTaskId.set(purchaseStore.activeTaskId())
|
||||
val sync = AgentSyncCycle(
|
||||
heartbeat = { currentTaskId ->
|
||||
api.heartbeat(activeCredentials.token, currentTaskId, AgentCapabilities.supported).also {
|
||||
check(it.deviceId == activeCredentials.deviceId) { "心跳设备身份不一致" }
|
||||
}
|
||||
},
|
||||
activeTaskId = {
|
||||
if (taskMutex.currentTaskId() != null) runningTaskId.get()
|
||||
else purchaseStore.activeTaskId().also(runningTaskId::set)
|
||||
},
|
||||
executionActive = { taskMutex.currentTaskId() != null },
|
||||
recover = { recoverInterruptedPurchases(api, activeCredentials.token) },
|
||||
flush = { flushPurchaseOutbox(api, activeCredentials.token) },
|
||||
hasPending = { purchaseStore.pendingOutbox().isNotEmpty() },
|
||||
).run()
|
||||
if (sync.failureCode != null) {
|
||||
cancelIdleReturn("同步未完成")
|
||||
val message = cn.ilapage.goauto.agent.ui.PurchaseRejectionPresentation.connection(sync.failureCode, 0)
|
||||
stateStore.update(sync.failureCode, message, activeCredentials.deviceId, true, connection = true)
|
||||
updateNotification(message)
|
||||
return if (sync.failureCode == "AUTH_ERROR") MANUAL_AUTH_ERROR else MANUAL_NETWORK_ERROR
|
||||
}
|
||||
val heartbeat = api.heartbeat(
|
||||
activeCredentials.token,
|
||||
currentTaskId = runningTaskId.get(),
|
||||
capabilities = AgentCapabilities.supported,
|
||||
)
|
||||
check(heartbeat.deviceId == activeCredentials.deviceId) { "心跳返回了不同的设备身份" }
|
||||
val heartbeat = requireNotNull(sync.heartbeat)
|
||||
heartbeatSucceeded = true
|
||||
stateStore.update(
|
||||
code = if (heartbeat.busy) "BUSY" else "ONLINE",
|
||||
message = if (heartbeat.busy) "设备在线,正在执行任务" else "设备在线空闲",
|
||||
@@ -309,30 +324,32 @@ class AgentForegroundService : Service() {
|
||||
heartbeat = true,
|
||||
)
|
||||
updateNotification(if (heartbeat.busy) "在线 · 执行中" else "在线 · 空闲")
|
||||
scheduleTask(api, activeCredentials.token)
|
||||
if (sync.canClaim) scheduleTask(api, activeCredentials.token) else MANUAL_BUSY
|
||||
} catch (error: AgentApiException) {
|
||||
cancelIdleReturn("网络或服务端请求失败")
|
||||
val authenticationError = error.code in setOf(
|
||||
"DEVICE_TOKEN_INVALID", "DEVICE_INSTALL_ID_CONFLICT", "DEVICE_DISABLED",
|
||||
)
|
||||
val authenticationError = isAgentAuthenticationError(error)
|
||||
val connectionFailure = syncConnectionFailureCode(error, heartbeatSucceeded)
|
||||
val code = connectionFailure ?: "TASK_ERROR"
|
||||
val storedCredentials = runCatching { identityStore.credentials() }.getOrNull()
|
||||
stateStore.update(
|
||||
code = if (authenticationError) "AUTH_ERROR" else "NETWORK_ERROR",
|
||||
message = "${error.code}:${error.message}",
|
||||
code = code,
|
||||
message = if (connectionFailure == null) "任务同步暂未完成,请稍后重试" else cn.ilapage.goauto.agent.ui.PurchaseRejectionPresentation.connection(code, 0),
|
||||
deviceId = storedCredentials?.deviceId ?: 0L,
|
||||
tokenStored = storedCredentials != null,
|
||||
connection = connectionFailure != null,
|
||||
)
|
||||
updateNotification(if (authenticationError) "设备认证失败" else "连接失败,等待网络恢复")
|
||||
updateNotification(if (authenticationError) "设备认证失败" else if (heartbeatSucceeded) "任务同步暂未完成" else "连接失败,等待恢复")
|
||||
if (authenticationError) MANUAL_AUTH_ERROR else MANUAL_NETWORK_ERROR
|
||||
} catch (error: Exception) {
|
||||
cancelIdleReturn("Agent 运行异常")
|
||||
val configured = settingsStore.serverUrl().isNotBlank()
|
||||
val storedCredentials = runCatching { identityStore.credentials() }.getOrNull()
|
||||
stateStore.update(
|
||||
code = if (configured) "ERROR" else "CONFIG_REQUIRED",
|
||||
message = error.message ?: "Agent 运行失败",
|
||||
code = if (heartbeatSucceeded) "TASK_ERROR" else if (configured) "ERROR" else "CONFIG_REQUIRED",
|
||||
message = if (configured) "同步暂未完成,请稍后重试" else "请配置服务端",
|
||||
deviceId = storedCredentials?.deviceId ?: 0L,
|
||||
tokenStored = storedCredentials != null,
|
||||
connection = !heartbeatSucceeded,
|
||||
)
|
||||
updateNotification(if (configured) "运行异常" else "等待配置服务端")
|
||||
if (configured) MANUAL_ERROR else MANUAL_CONFIG_REQUIRED
|
||||
@@ -341,8 +358,6 @@ class AgentForegroundService : Service() {
|
||||
|
||||
private fun scheduleTask(api: AgentApiClient, token: String): String {
|
||||
if (taskMutex.currentTaskId() != null) return MANUAL_BUSY
|
||||
recoverInterruptedPurchases(api, token)
|
||||
flushPurchaseOutbox(api, token)
|
||||
val collectionCooldown = activeCollectionCooldown()
|
||||
val purchaseTask = api.nextPurchaseTask(token)
|
||||
when (TaskDispatchPolicy.decide(purchaseTask?.status, collectionCooldown != null)) {
|
||||
@@ -607,7 +622,7 @@ class AgentForegroundService : Service() {
|
||||
publishCurrentPageResult(0L, "采集间隔中,还需 $remaining 秒。", replacementOriginType, replacementOriginTaskId)
|
||||
return
|
||||
}
|
||||
if (!taskMutex.tryAcquire(CURRENT_PAGE_RESERVATION_ID)) {
|
||||
if (!tryAcquireCurrentPage(taskMutex, working, CURRENT_PAGE_RESERVATION_ID)) {
|
||||
publishCurrentPageResult(0L, "设备正在执行任务,请稍后再试。", replacementOriginType, replacementOriginTaskId)
|
||||
return
|
||||
}
|
||||
@@ -937,9 +952,10 @@ class AgentForegroundService : Service() {
|
||||
)
|
||||
}
|
||||
}
|
||||
val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
|
||||
val rejected = purchaseStore.rejectedResults().any { it.taskId == task.taskId && it.attemptId == task.taskAttemptId }
|
||||
val message = if (rejected) "采购结果被服务端拒收,请在采购页或设置中查看" else if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
|
||||
stateStore.update(if (outcome.resultType == "failed") "TASK_ERROR" else "ONLINE", message, tokenStored = true)
|
||||
updateNotification(if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
|
||||
updateNotification(if (rejected) "$taskLabel #${task.taskId} 结果被拒收" else if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
|
||||
} catch (error: AgentApiException) {
|
||||
failureSnapshot("failed", error.code.takeIf { it.matches(Regex("[A-Z][A-Z0-9_]{0,63}")) } ?: "PURCHASE_API_FAILED")
|
||||
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
|
||||
@@ -1029,9 +1045,10 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome))
|
||||
} catch (_: AgentApiException) {
|
||||
} catch (error: AgentApiException) {
|
||||
// Keep the local irreversible marker. The same server request ID
|
||||
// is replayed after connectivity returns; the order click is never repeated.
|
||||
if (isAgentAuthenticationError(error)) throw error
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
@@ -1066,6 +1083,18 @@ class AgentForegroundService : Service() {
|
||||
runCatching { onFirstSubmitted(item, requireNotNull(acknowledgement)) }
|
||||
.onFailure { probeHandoff.invalidate(); logDiagnosticPersistenceFailure(it) }
|
||||
},
|
||||
markRejected = { item, code ->
|
||||
probeHandoff.invalidate()
|
||||
purchaseStore.markRejected(item, code)
|
||||
synchronized(taskMutex) {
|
||||
val runtime = stateStore.read()
|
||||
if (shouldClearRejectedPurchase(item.taskId, runtime.currentTaskId, runtime.currentTaskType,
|
||||
taskMutex.currentTaskId(), purchaseStore.isCurrentAttemptRejected(item.taskId, item.attemptId))) {
|
||||
stateStore.clearActiveTask(item.taskId)
|
||||
}
|
||||
}
|
||||
sendBroadcast(Intent(ACTION_PURCHASE_REJECTIONS_CHANGED).setPackage(packageName))
|
||||
},
|
||||
).flush()
|
||||
runningTaskId.set(purchaseStore.activeTaskId())
|
||||
}
|
||||
@@ -1693,6 +1722,7 @@ class AgentForegroundService : Service() {
|
||||
const val ACTION_REPURCHASE_CONFIRM = "cn.ilapage.goauto.agent.REPURCHASE_CONFIRM"
|
||||
const val ACTION_REPURCHASE_STOP = "cn.ilapage.goauto.agent.REPURCHASE_STOP"
|
||||
const val ACTION_REPURCHASE_STATE = "cn.ilapage.goauto.agent.REPURCHASE_STATE"
|
||||
const val ACTION_PURCHASE_REJECTIONS_CHANGED = "cn.ilapage.goauto.agent.PURCHASE_REJECTIONS_CHANGED"
|
||||
@Volatile var repurchaseState = RepurchaseState()
|
||||
private set
|
||||
const val ACTION_BACKFILL_START = "cn.ilapage.goauto.agent.BACKFILL_START"
|
||||
|
||||
@@ -191,17 +191,27 @@ class AgentSettingsStore(context: Context) {
|
||||
|
||||
class AgentStateStore(context: Context) {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
private val publicationGate = AgentStatePublicationGate()
|
||||
|
||||
fun update(code: String, message: String, deviceId: Long? = null, tokenStored: Boolean? = null, heartbeat: Boolean = false) {
|
||||
fun update(code: String, message: String, deviceId: Long? = null, tokenStored: Boolean? = null, heartbeat: Boolean = false, connection: Boolean = false) = publicationGate.publish {
|
||||
val editor = preferences.edit()
|
||||
.putString(STATE_CODE, code)
|
||||
.putString(STATE_MESSAGE, message)
|
||||
deviceId?.let { editor.putLong(DEVICE_ID, it) }
|
||||
tokenStored?.let { editor.putBoolean(TOKEN_STORED, it) }
|
||||
if (heartbeat) editor.putLong(LAST_HEARTBEAT, System.currentTimeMillis())
|
||||
if (heartbeat || connection) editor.putString(CONNECTION_CODE, code)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
/** This service instance cannot publish a late heartbeat over STOPPED or a restarted service. */
|
||||
fun stop() = publicationGate.stop {
|
||||
preferences.edit().putString(STATE_CODE, "STOPPED").putString(CONNECTION_CODE, "STOPPED")
|
||||
.putString(STATE_MESSAGE, "前台服务已停止").apply()
|
||||
}
|
||||
|
||||
fun connectionCode(): String = preferences.getString(CONNECTION_CODE, "STOPPED") ?: "STOPPED"
|
||||
|
||||
fun read(): AgentState = AgentState(
|
||||
code = preferences.getString(STATE_CODE, "STOPPED") ?: "STOPPED",
|
||||
message = preferences.getString(STATE_MESSAGE, "前台服务尚未启动") ?: "前台服务尚未启动",
|
||||
@@ -279,6 +289,7 @@ class AgentStateStore(context: Context) {
|
||||
private companion object {
|
||||
const val PREFERENCES = "goauto_agent_runtime"
|
||||
const val STATE_CODE = "state_code"
|
||||
const val CONNECTION_CODE = "connection_code"
|
||||
const val STATE_MESSAGE = "state_message"
|
||||
const val DEVICE_ID = "device_id"
|
||||
const val LAST_HEARTBEAT = "last_heartbeat"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.network.AgentApiException
|
||||
import cn.ilapage.goauto.agent.network.HeartbeatResult
|
||||
|
||||
internal fun isAgentAuthenticationError(error: Throwable?): Boolean = error is AgentApiException &&
|
||||
(error.status in setOf(401, 403) || error.code in setOf("DEVICE_TOKEN_INVALID", "DEVICE_INSTALL_ID_CONFLICT", "DEVICE_DISABLED"))
|
||||
|
||||
internal fun syncConnectionFailureCode(error: Throwable, heartbeatSucceeded: Boolean): String? = when {
|
||||
isAgentAuthenticationError(error) -> "AUTH_ERROR"
|
||||
heartbeatSucceeded -> null
|
||||
error is AgentApiException && error.code == "DEVICE_TASK_MISMATCH" -> "TASK_MISMATCH"
|
||||
error is AgentApiException && error.status >= 500 -> "SERVER_ERROR"
|
||||
else -> "NETWORK_ERROR"
|
||||
}
|
||||
|
||||
internal data class AgentSyncResult(
|
||||
val heartbeat: HeartbeatResult?,
|
||||
val failureCode: String?,
|
||||
val canClaim: Boolean,
|
||||
)
|
||||
|
||||
/** Run under the service's existing sync exclusion; device execution keeps its task mutex. */
|
||||
internal class AgentSyncCycle(
|
||||
private val heartbeat: (Long?) -> HeartbeatResult,
|
||||
private val activeTaskId: () -> Long?,
|
||||
private val executionActive: () -> Boolean,
|
||||
private val recover: () -> Unit,
|
||||
private val flush: () -> Unit,
|
||||
private val hasPending: () -> Boolean,
|
||||
) {
|
||||
fun run(): AgentSyncResult {
|
||||
var pulse = runCatching { heartbeat(activeTaskId()) }
|
||||
var authFailure = isAgentAuthenticationError(pulse.exceptionOrNull())
|
||||
var recovered = false
|
||||
var flushed = false
|
||||
if (!authFailure && !executionActive()) {
|
||||
val recovery = runCatching(recover)
|
||||
recovered = recovery.isSuccess
|
||||
authFailure = isAgentAuthenticationError(recovery.exceptionOrNull())
|
||||
if (!authFailure) {
|
||||
val upload = runCatching(flush)
|
||||
flushed = upload.isSuccess
|
||||
authFailure = isAgentAuthenticationError(upload.exceptionOrNull())
|
||||
}
|
||||
if (!authFailure && (pulse.exceptionOrNull() as? AgentApiException)?.code == "DEVICE_TASK_MISMATCH") {
|
||||
pulse = runCatching { heartbeat(activeTaskId()) }
|
||||
}
|
||||
}
|
||||
val failure = pulse.exceptionOrNull()
|
||||
return AgentSyncResult(
|
||||
pulse.getOrNull(),
|
||||
when {
|
||||
authFailure -> "AUTH_ERROR"
|
||||
failure == null -> null
|
||||
isAgentAuthenticationError(failure) -> "AUTH_ERROR"
|
||||
(failure as? AgentApiException)?.code == "DEVICE_TASK_MISMATCH" -> "TASK_MISMATCH"
|
||||
failure is AgentApiException && failure.status >= 500 -> "SERVER_ERROR"
|
||||
else -> "NETWORK_ERROR"
|
||||
},
|
||||
pulse.isSuccess && !authFailure && recovered && flushed && !executionActive() && !hasPending() && activeTaskId() == null,
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
internal fun shouldClearRejectedPurchase(
|
||||
rejectedTaskId: Long, runtimeTaskId: Long?, runtimeTaskType: String?,
|
||||
executingTaskId: Long?, currentAttemptRejected: Boolean,
|
||||
): Boolean = executingTaskId == null && runtimeTaskId == rejectedTaskId &&
|
||||
runtimeTaskType == "purchase" && currentAttemptRejected
|
||||
|
||||
internal class AgentStatePublicationGate {
|
||||
private var stopped = false
|
||||
@Synchronized fun publish(write: () -> Unit) { if (!stopped) write() }
|
||||
@Synchronized fun stop(write: () -> Unit) { stopped = true; write() }
|
||||
}
|
||||
|
||||
internal fun tryAcquireCurrentPage(mutex: TaskExecutionMutex, syncing: java.util.concurrent.atomic.AtomicBoolean, reservation: Long): Boolean =
|
||||
synchronized(mutex) { !syncing.get() && mutex.tryAcquire(reservation) }
|
||||
@@ -24,6 +24,7 @@ import cn.ilapage.goauto.agent.network.CollectionHistoryItem
|
||||
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
|
||||
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
|
||||
import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseTaskStore
|
||||
import cn.ilapage.goauto.agent.service.AgentForegroundService
|
||||
import cn.ilapage.goauto.agent.service.AgentSettingsStore
|
||||
import cn.ilapage.goauto.agent.service.AgentStateStore
|
||||
@@ -58,6 +59,7 @@ class AgentSettingsFragment : Fragment() {
|
||||
private lateinit var connectionFeedback: TextView
|
||||
private lateinit var disabledReason: TextView
|
||||
private lateinit var diagnostics: TextView
|
||||
private lateinit var rejectedResultsButton: MaterialButton
|
||||
private lateinit var accessibilityText: TextView
|
||||
private lateinit var collectionIntervalStartLayout: TextInputLayout
|
||||
private lateinit var collectionIntervalStartInput: TextInputEditText
|
||||
@@ -326,6 +328,20 @@ class AgentSettingsFragment : Fragment() {
|
||||
diagnostics = context.label("—", 14f, context.getColor(R.color.agent_text))
|
||||
diagnostics.setPadding(0, context.dp(10), 0, 0)
|
||||
addView(diagnostics)
|
||||
rejectedResultsButton = MaterialButton(context).apply {
|
||||
text = "查看拒收结果"
|
||||
minHeight = context.dp(48)
|
||||
setOnClickListener {
|
||||
val records = PurchaseTaskStore(context).use { it.rejectedResults() }
|
||||
MaterialAlertDialogBuilder(context).setTitle("采购结果被服务端拒收")
|
||||
.setMessage(records.joinToString("\n\n") {
|
||||
PurchaseRejectionPresentation.notice(it) + "\n${it.errorCode}" +
|
||||
if (it.acknowledgedAt != null) "\n已核对(仅本机记录)" else ""
|
||||
}.ifBlank { "暂无拒收结果" })
|
||||
.setPositiveButton("知道了", null).show()
|
||||
}
|
||||
}
|
||||
addView(rejectedResultsButton, fullWidth(8))
|
||||
}))
|
||||
}
|
||||
return context.page(content)
|
||||
@@ -669,12 +685,14 @@ class AgentSettingsFragment : Fragment() {
|
||||
disabledReason.text = if (busy) "任务执行中,暂时不能修改服务器或设备名称。" else ""
|
||||
|
||||
val installId = runCatching { identityStore.installId() }.getOrElse { "读取失败" }
|
||||
val rejectedCount = PurchaseTaskStore(context).use { it.rejectedCount() }
|
||||
rejectedResultsButton.visibility = if (rejectedCount > 0) View.VISIBLE else View.GONE
|
||||
diagnostics.text = buildString {
|
||||
append("installId:$installId\n")
|
||||
append("Device Token:${if (state.tokenStored) "已配置" else "未配置"}\n")
|
||||
append("注册状态:${if (state.deviceId > 0) "已注册(设备 ${state.deviceId})" else "未注册"}\n")
|
||||
append("Agent 版本:${BuildConfig.VERSION_NAME}\n")
|
||||
append("服务端连接:${if (state.code in setOf("ONLINE", "BUSY", "COLLECTION_COOLDOWN")) "已连接" else "未连接"}\n")
|
||||
append("服务端连接:${PurchaseRejectionPresentation.connection(stateStore.connectionCode(), rejectedCount)}\n")
|
||||
append("保持屏幕常亮:${if (state.keepScreenOn) "已开启" else "仅在任务执行或采集间隔时开启"}")
|
||||
}
|
||||
accessibilityText.text = when (AccessibilityReadinessDetector.current(context)) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.ilapage.goauto.agent.ui
|
||||
|
||||
import cn.ilapage.goauto.agent.persistence.RejectedPurchaseResult
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object PurchaseRejectionPresentation {
|
||||
private fun payload(item: RejectedPurchaseResult) = runCatching { JSONObject(item.payloadJson) }.getOrNull()
|
||||
private fun orderNo(item: RejectedPurchaseResult): String? = payload(item)?.optString("pddOrderNo")
|
||||
?.takeIf { it.matches(Regex("[0-9A-Za-z-]{1,80}")) }
|
||||
fun showBanner(item: RejectedPurchaseResult): Boolean = item.acknowledgedAt == null &&
|
||||
(payload(item)?.optString("resultType") in setOf("order_created", "order_result_unknown") ||
|
||||
!payload(item)?.optString("pddOrderNo").isNullOrBlank())
|
||||
fun notice(item: RejectedPurchaseResult): String = buildString {
|
||||
append("采购任务 #${item.taskId} 的结果被服务端拒收")
|
||||
orderNo(item)?.let { append("\n拼多多订单号:$it") }
|
||||
append("\n请人工核对拼多多订单及后台任务,避免重复采购")
|
||||
}
|
||||
fun connection(code: String, rejected: Int): String = when (code) {
|
||||
"CONNECTING" -> "正在同步"
|
||||
"ONLINE", "BUSY", "COLLECTION_COOLDOWN", "TASK_ERROR" -> if (rejected > 0) "已连接 · 有 $rejected 条采购结果被服务端拒收" else "已连接"
|
||||
"AUTH_ERROR" -> "未连接 · 设备认证失败"
|
||||
"TASK_MISMATCH" -> "未连接 · 设备任务状态不一致"
|
||||
"NETWORK_ERROR" -> "未连接 · 网络暂不可用"
|
||||
"SERVER_ERROR" -> "未连接 · 服务端暂不可用"
|
||||
"CONFIG_REQUIRED" -> "未连接 · 请配置服务端"
|
||||
else -> "未连接 · 同步暂未完成"
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import cn.ilapage.goauto.agent.network.HistoryColorImage
|
||||
import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail
|
||||
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
|
||||
import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseTaskStore
|
||||
import cn.ilapage.goauto.agent.persistence.RejectedPurchaseResult
|
||||
import cn.ilapage.goauto.agent.service.AgentForegroundService
|
||||
import cn.ilapage.goauto.agent.service.AgentSettingsStore
|
||||
import cn.ilapage.goauto.agent.service.AgentStateStore
|
||||
@@ -146,8 +148,14 @@ class TaskHistoryFragment : Fragment() {
|
||||
private var repurchaseButton: MaterialButton? = null
|
||||
private var repurchaseDialog: androidx.appcompat.app.AlertDialog? = null
|
||||
private var repurchaseDialogRound: String? = null
|
||||
private var rejectionPanel: LinearLayout? = null
|
||||
private var displayedRejections: List<RejectedPurchaseResult>? = null
|
||||
private val currentPageReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AgentForegroundService.ACTION_PURCHASE_REJECTIONS_CHANGED) {
|
||||
renderRejections()
|
||||
return
|
||||
}
|
||||
if (intent?.action == AgentForegroundService.ACTION_REPURCHASE_STATE) {
|
||||
renderRepurchase()
|
||||
if (!collection && isResumed && AgentForegroundService.repurchaseState.phase == cn.ilapage.goauto.agent.service.RepurchasePhase.FINISHED) {
|
||||
@@ -200,6 +208,10 @@ class TaskHistoryFragment : Fragment() {
|
||||
val context = requireContext()
|
||||
pageColumn = context.column()
|
||||
pageColumn.addView(context.screenTitle(if (collection) "采集记录" else "采购记录"))
|
||||
if (!collection) {
|
||||
rejectionPanel = context.column(0)
|
||||
pageColumn.addView(rejectionPanel)
|
||||
}
|
||||
pageColumn.addView(buildSearch())
|
||||
if (!collection) {
|
||||
repurchasePanel = context.column(0)
|
||||
@@ -228,6 +240,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
renderRejections()
|
||||
renderRepurchase()
|
||||
detailState.taskId?.let { taskId ->
|
||||
if (collection) loadCollectionDetail(taskId) else loadPurchaseDetail(taskId)
|
||||
@@ -235,6 +248,8 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
rejectionPanel = null
|
||||
displayedRejections = null
|
||||
repurchaseDialog?.setOnDismissListener(null)
|
||||
repurchaseDialog?.dismiss()
|
||||
repurchaseDialog = null
|
||||
@@ -247,6 +262,29 @@ class TaskHistoryFragment : Fragment() {
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun renderRejections() {
|
||||
val panel = rejectionPanel ?: return
|
||||
val context = context ?: return
|
||||
val records = PurchaseTaskStore(context).use { it.rejectedResults() }.filter(PurchaseRejectionPresentation::showBanner)
|
||||
if (records == displayedRejections) return
|
||||
displayedRejections = records
|
||||
panel.removeAllViews()
|
||||
records.forEach { record ->
|
||||
panel.addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label(PurchaseRejectionPresentation.notice(record), 14f, context.getColor(R.color.agent_warning), true))
|
||||
addView(MaterialButton(context).apply {
|
||||
text = "已核对"
|
||||
minHeight = context.dp(48)
|
||||
contentDescription = "采购任务 #${record.taskId} 已核对,仅隐藏本机提醒"
|
||||
setOnClickListener {
|
||||
val saved = runCatching { PurchaseTaskStore(context).use { it.acknowledgeRejection(record.id) } }.isSuccess
|
||||
if (saved) renderRejections() else toast("暂时无法保存核对记录,请稍后重试")
|
||||
}
|
||||
}, fullWidth(8))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unregisterCurrentPageReceiver()
|
||||
imageLoader.close()
|
||||
@@ -811,6 +849,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
val filter = IntentFilter(AgentForegroundService.ACTION_CURRENT_PAGE_RESULT)
|
||||
filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE)
|
||||
filter.addAction(AgentForegroundService.ACTION_REPURCHASE_STATE)
|
||||
filter.addAction(AgentForegroundService.ACTION_PURCHASE_REJECTIONS_CHANGED)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@@ -893,6 +932,11 @@ class TaskHistoryFragment : Fragment() {
|
||||
private fun renderPurchaseDetail(detail: PurchaseHistoryDetail) {
|
||||
val context = requireContext()
|
||||
val task = detail.task
|
||||
PurchaseTaskStore(context).use { it.rejectedResults() }.filter { it.taskId == task.taskId }.forEach { record ->
|
||||
resultColumn.addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label(PurchaseRejectionPresentation.notice(record) + "\n${record.errorCode}", 14f, context.getColor(R.color.agent_warning)))
|
||||
}))
|
||||
}
|
||||
val info = buildString {
|
||||
append("蝦皮订单号:${task.shopeeOrderNo.ifBlank { "—" }}\n")
|
||||
append("PDD 商品:${task.pddGoodsId}\n")
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/** Final capture only; derived from the existing #331/#332 bounded purchase-sheet fixtures. */
|
||||
class PurchaseFinalSpecConfirmationTest {
|
||||
@Test fun `bracket display variants and offscreen color are proven by full panel summary`() {
|
||||
val snapshot = sheet("已选 [香槟]RW圆领 L码[105-115斤]", colors = listOf("其他颜色"), sizes = listOf("L码[105-115斤]"))
|
||||
.selected("size/o0/t")
|
||||
accepts(snapshot, "【香槟】RW圆领", "L码【105-115斤】")
|
||||
}
|
||||
|
||||
@Test fun `summary above scroll area is accepted for scrollable and non scrollable sheets`() {
|
||||
for (scrollable in listOf(true, false)) {
|
||||
accepts(sheet("已选 黑色 XL", scrollable = scrollable))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `checked only exact option and physical parent child duplicates provide selection proof`() {
|
||||
accepts(sheet(null).selected("color/o0/img", checked = true).selected("size/o0/t", checked = true))
|
||||
}
|
||||
|
||||
@Test fun `selected other color or size overrides matching summary and positive target`() {
|
||||
rejects(sheet("已选 黑色 XL", colors = listOf("黑色", "白色")).selected("color/o0").selected("color/o1"))
|
||||
rejects(sheet("已选 黑色 XL", sizes = listOf("XL", "L")).selected("size/o1/t"))
|
||||
}
|
||||
|
||||
@Test fun `selected bracket variants are not a conflict`() {
|
||||
accepts(sheet("已选 [白色] XL", colors = listOf("[白色]")).selected("color/o0").selected("size/o0/t"), "【白色】")
|
||||
}
|
||||
|
||||
@Test fun `distinct physical identical or normalized target options are collisions`() {
|
||||
for (colors in listOf(listOf("黑色", "黑色"), listOf("【白色】", "[白色]"))) {
|
||||
rejects(sheet("已选 ${colors[0]} XL", colors = colors).selected("color/o0"), colors[0])
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `explicit unavailable target cannot be rescued by summary`() {
|
||||
rejects(sheet("已选 黑色 XL").mapNodes { if (it.path.contains("color/o0")) it.copy(enabled = false) else it })
|
||||
}
|
||||
|
||||
@Test fun `all required selected proofs take priority over a single non proving summary`() {
|
||||
accepts(sheet("已选 其他颜色 其他尺码").selected("color/o0/img").selected("size/o0/t"))
|
||||
rejects(sheet("已选 白色 L", colors = listOf("黑色", "白色"), sizes = listOf("XL", "L"))
|
||||
.selected("color/o0/img").selected("size/o0/t")) // Known alternative composition is a conflict.
|
||||
}
|
||||
|
||||
@Test fun `selected options retain established parser separation of trailing display price`() {
|
||||
accepts(sheet("已选 黑色 XL", colors = listOf("黑色 ¥19.60"), sizes = listOf("XL ¥19.60"))
|
||||
.selected("color/o0/img").selected("size/o0/t"))
|
||||
rejects(sheet("已选 黑色 XL", colors = listOf("黑色加绒 ¥19.60")).selected("color/o0/img"))
|
||||
rejects(sheet("已选 黑色 XL", colors = listOf("黑色 ¥19.60", "黑色 ¥20.00")).selected("color/o0/img"))
|
||||
}
|
||||
|
||||
@Test fun `unselected visible options alone and unrelated title cannot prove selection`() {
|
||||
rejects(sheet(null).addText("r/title", "示例黑色 XL 商品"))
|
||||
}
|
||||
|
||||
@Test fun `panel external prefixed summary is ignored even at same coordinates`() {
|
||||
rejects(sheet(null).addText("r/fake", "已选 黑色 XL"))
|
||||
accepts(sheet("已选 黑色 XL").addText("r/fake", "已选 白色 L"))
|
||||
}
|
||||
|
||||
@Test fun `structural ancestry overrides textual path prefix`() {
|
||||
rejects(sheet(null).addText("r/sheet/fake", "已选 黑色 XL", parent = "r"))
|
||||
}
|
||||
|
||||
@Test fun `whole summary rejects substring sizes colors extra dimensions quantity and unsupported separators`() {
|
||||
for (summary in listOf("已选 黑色 XL", "已选 黑色 加绒 L", "已选 黑色 L 2件", "已选 黑色,L", "已选 L 黑色")) {
|
||||
rejects(sheet(summary, sizes = listOf("L")), size = "L")
|
||||
}
|
||||
rejects(sheet("已选 [白色+黑色]超值两件装 XL", colors = emptyList()), "【白色】")
|
||||
}
|
||||
|
||||
@Test fun `known candidates making another full composition reject ambiguous summary`() {
|
||||
rejects(sheet("已选 黑色 加绒 XL", colors = listOf("黑色", "黑色 加绒"), sizes = listOf("加绒 XL", "XL")), size = "加绒 XL")
|
||||
rejects(sheet("已选 黑色 加绒 XL", colors = listOf("黑色", "黑色 加绒"), sizes = listOf("加绒 XL", "XL")), color = "黑色 加绒")
|
||||
}
|
||||
|
||||
@Test fun `summary permits invisible color despite another unselected visible color`() {
|
||||
accepts(sheet("已选 黑色 XL", colors = listOf("白色")))
|
||||
}
|
||||
|
||||
@Test fun `required single dimension must equal whole summary and empty pair is never proof`() {
|
||||
accepts(sheet("已选 黑色"), size = "")
|
||||
accepts(sheet("已选 XL"), color = "")
|
||||
rejects(sheet("已选 黑色 XL"), size = "")
|
||||
rejects(sheet("已选 黑色 XL"), color = "")
|
||||
rejects(sheet("已选 黑色 XL"), color = " ", size = "")
|
||||
}
|
||||
|
||||
@Test fun `anchored selected prefix variants preserve internal colons commas and whitespace`() {
|
||||
for (prefix in listOf("已选", "已选 ", "已选:", "已选: ", " 已选: ")) {
|
||||
accepts(sheet("$prefix 黑色:加绒,款 XL", colors = emptyList()), color = "黑色:加绒,款")
|
||||
}
|
||||
accepts(sheet("已选 黑色 加绒 XL", colors = emptyList()), color = "黑色 加绒")
|
||||
rejects(sheet("已选 黑色 加绒 XL", colors = emptyList()), color = "黑色加绒")
|
||||
rejects(sheet("已选 黑色:加绒 XL", colors = emptyList()), color = "黑色加绒")
|
||||
rejects(sheet("已选::黑色 XL"))
|
||||
accepts(sheet("已选::黑色 XL", colors = emptyList()), color = ":黑色")
|
||||
}
|
||||
|
||||
@Test fun `conflicting scoped summaries reject even both exact options selected`() {
|
||||
rejects(sheet("已选 黑色 XL").selected("color/o0").selected("size/o0/t").addText("r/sheet/second", "已选 白色 L"))
|
||||
accepts(sheet("已选 黑色 XL").addText("r/sheet/second", "已选:黑色 XL"))
|
||||
}
|
||||
|
||||
@Test fun `display normalization is narrow and content preserving`() {
|
||||
accepts(sheet("已选 [白色](加绒) RW12 XL", colors = emptyList()), color = "〔白色〕(加绒) RW12")
|
||||
rejects(sheet("已选 白色 RW12 XL", colors = emptyList()), color = "白色 rw12")
|
||||
rejects(sheet("已选 白色 1 XL", colors = emptyList()), color = "白色 ①")
|
||||
rejects(sheet("已选 白色 XL", colors = emptyList()), color = "白色 ¥19.6")
|
||||
}
|
||||
|
||||
@Test fun `existing structurally recognized unprefixed summary is usable`() {
|
||||
accepts(prefixlessSheet())
|
||||
rejects(sheet("黑色 XL")) // No quantity/header relationship in this fixture.
|
||||
rejects(sheet(null).addText("r/title", "黑色 XL"))
|
||||
rejects(prefixlessSheet().addText("r/sheet/body/info/second", "黑色 L"))
|
||||
}
|
||||
|
||||
@Test fun `same dimension groups retain earlier selected conflicts and physical candidates`() {
|
||||
val base = sheet("已选 黑色 XL").addText("r/sheet/body/list/extra/h", "颜色分类")
|
||||
.addText("r/sheet/body/list/extra/o", "白色")
|
||||
.mapNodes { when (it.path) {
|
||||
"r/sheet/body/list/extra/h" -> it.copy(bounds = NodeBounds(20, 1000, 500, 1040), parentPath = "r/sheet/body/list")
|
||||
"r/sheet/body/list/extra/o" -> it.copy(bounds = NodeBounds(20, 1045, 500, 1100), parentPath = "r/sheet/body/list", clickable = true, selected = true)
|
||||
"r/sheet/body/list" -> it.copy(bounds = NodeBounds(0, 980, 1080, 2036))
|
||||
else -> it
|
||||
} }
|
||||
rejects(base)
|
||||
rejects(base.mapNodes { if (it.path.endsWith("extra/o")) it.copy(text = "黑色") else it })
|
||||
}
|
||||
|
||||
@Test fun `collector default still collapses same raw labels and leaves summary behavior unchanged`() {
|
||||
val snapshot = sheet("已选 黑色 XL", colors = listOf("黑色", "黑色"))
|
||||
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
|
||||
val regular = PddScreenParser.parse(snapshot, config, "374", null)
|
||||
val final = PddScreenParser.parse(snapshot, config, "374", null, forFinalConfirmation = true)
|
||||
assertEquals(1, regular.dimensions.single { it.key == "color" }.values.size)
|
||||
assertEquals(2, final.dimensions.single { it.key == "color" }.values.size)
|
||||
assertEquals(regular.selectedSummary, final.selectedSummary)
|
||||
assertTrue(regular.finalSelectionSummaries.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun `independently bounded selected options survive absent full panel boundary`() {
|
||||
val snapshot = sheet(null).selected("color/o0/img").selected("size/o0/t")
|
||||
.mapNodes { if (it.path == "r/sheet") it.copy(bounds = NodeBounds(0, 0, 1080, 2216)) else it }
|
||||
PurchaseFinalSpecVerifier.verify(snapshot, input()) { fail(it) }
|
||||
val noSelections = snapshot.mapNodes { it.copy(selected = false, checked = false) }
|
||||
.addText("r/sheet/fake", "已选 黑色 XL")
|
||||
try {
|
||||
PurchaseFinalSpecVerifier.verify(noSelections, input()) {}
|
||||
fail("unproven full panel must not supply summary")
|
||||
} catch (failure: PurchaseLiveException) { assertEquals("PURCHASE_SPEC_NOT_MATCHED", failure.code) }
|
||||
}
|
||||
|
||||
@Test fun `price quantity and address checks remain enforced`() {
|
||||
rejects(sheet("已选 黑色 XL"), expectedCode = "PURCHASE_PRICE_OUT_OF_RANGE", input = input().copy(maxUnitPriceCent = 1))
|
||||
rejects(sheet("已选 黑色 XL"), expectedCode = "PURCHASE_QUANTITY_MISMATCH", input = input().copy(quantity = 2))
|
||||
rejects(sheet("已选 黑色 XL").mapNodes { if (it.path == "r/sheet/body/addr/a/detail") it.copy(text = "示例地址") else it }, expectedCode = "PURCHASE_ADDRESS_UPDATE_FAILED")
|
||||
}
|
||||
|
||||
private fun sheet(
|
||||
summary: String?, colors: List<String> = listOf("黑色"), sizes: List<String> = listOf("XL"), scrollable: Boolean = true,
|
||||
): UiSnapshot = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = colors, sizeLabels = sizes, listScrollable = scrollable))
|
||||
.mapNodes { when (it.path) {
|
||||
"r/sheet/body/summary" -> it.copy(text = summary, visible = summary != null)
|
||||
"r/sheet/body/addr/a/detail" -> it.copy(text = ADDRESS)
|
||||
else -> it
|
||||
} }
|
||||
|
||||
/** Reuses the info-above-list shape from PddProductDetailCollectorTest.prefixlessPanel. */
|
||||
private fun prefixlessSheet(): UiSnapshot {
|
||||
val base = sheet("黑色 XL")
|
||||
val info = base.nodes.single { it.path == "r/sheet/body/qty" }.copy(
|
||||
path = "r/sheet/body/info", bounds = NodeBounds(0, 770, 1080, 1000), parentPath = "r/sheet/body",
|
||||
)
|
||||
return base.mapNodes { when {
|
||||
it.path == "r/sheet/body/summary" -> it.copy(path = "r/sheet/body/info/summary", parentPath = info.path)
|
||||
it.path == "r/sheet/body/qty" -> it.copy(path = "r/sheet/body/info/qty", parentPath = info.path)
|
||||
it.path.startsWith("r/sheet/body/qty/") -> it.copy(path = it.path.replace("body/qty/", "body/info/qty/"), parentPath = "r/sheet/body/info/qty")
|
||||
else -> it
|
||||
} }.let { it.copy(nodes = it.nodes + info) }
|
||||
}
|
||||
|
||||
private fun UiSnapshot.mapNodes(transform: (SnapshotNode) -> SnapshotNode) = copy(nodes = nodes.map(transform))
|
||||
private fun UiSnapshot.selected(suffix: String, checked: Boolean = false) = mapNodes {
|
||||
if (it.path.endsWith(suffix)) it.copy(selected = !checked, checked = checked) else it
|
||||
}
|
||||
private fun UiSnapshot.addText(path: String, text: String, parent: String = path.substringBeforeLast('/')) = copy(nodes = nodes + SnapshotNode(
|
||||
path, parent, text, null, null, "android.widget.TextView", NodeBounds(384, 791, 1068, 852),
|
||||
false, false, false, false, true, true,
|
||||
))
|
||||
private fun input(color: String = "黑色", size: String = "XL") = PurchaseExecutionInput(
|
||||
taskId = 374, executionMode = "live", phase = "purchase", url = "https://mobile.yangkeduo.com/goods.html?goods_id=374",
|
||||
goodsId = "374", mappedColor = color, mappedSize = size, quantity = 1,
|
||||
minUnitPriceCent = 1, maxUnitPriceCent = 3000, addressSuffix = "_cg374",
|
||||
)
|
||||
|
||||
private fun check(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostics: MutableList<String> = mutableListOf()): FinalConfirmationEvidence {
|
||||
val driver = object : PurchaseUiDriver {
|
||||
override fun capture() = snapshot
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult = error("final confirmation must be read only")
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult = error("unexpected input")
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
|
||||
override fun backPurchase(): Boolean = error("unexpected back")
|
||||
}
|
||||
return PurchaseLiveAutomation(driver, pause = { error("unexpected pause") }, panelDiagnostic = diagnostics::add)
|
||||
.finalConfirmation(input, ShippingAddressProof(ADDRESS, "_cg374"))
|
||||
}
|
||||
private fun accepts(snapshot: UiSnapshot, color: String = "黑色", size: String = "XL") {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val result = try { check(snapshot, input(color, size), diagnostics) } catch (failure: PurchaseLiveException) {
|
||||
throw AssertionError("${failure.code}: ${diagnostics.lastOrNull()}", failure)
|
||||
}
|
||||
assertEquals(color, result.mappedColor)
|
||||
}
|
||||
private fun rejects(snapshot: UiSnapshot, color: String = "黑色", size: String = "XL", expectedCode: String = "PURCHASE_SPEC_NOT_MATCHED", input: PurchaseExecutionInput = input(color, size)) {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
try {
|
||||
check(snapshot, input, diagnostics)
|
||||
fail("expected $expectedCode")
|
||||
} catch (failure: PurchaseLiveException) {
|
||||
assertEquals(expectedCode, failure.code)
|
||||
if (expectedCode == "PURCHASE_SPEC_NOT_MATCHED") {
|
||||
val detail = diagnostics.last()
|
||||
assertTrue(detail, detail.matches(Regex("finalSpec;dimension=(color|size|none);stage=[a-z_]+;summaryPresent=[01];selectedFound=[01];conflict=[01]")))
|
||||
assertTrue(failure.message.orEmpty(), failure.message.orEmpty().contains(detail))
|
||||
assertFalse(failure.message.orEmpty().contains(ADDRESS))
|
||||
assertFalse(failure.message.orEmpty().contains("黑色"))
|
||||
assertFalse(failure.message.orEmpty().contains("XL"))
|
||||
}
|
||||
}
|
||||
}
|
||||
private companion object { const val ADDRESS = "示例区示例路374号_cg374" }
|
||||
}
|
||||
@@ -8,6 +8,22 @@ import org.junit.Test
|
||||
class PurchaseOutboxHandoffTest {
|
||||
private val item = PendingPurchaseOutbox(1, 42, "probe", "request", "{}")
|
||||
|
||||
@Test fun `terminal conflict does not block later pending result`() {
|
||||
val submitted = mutableListOf<Long>()
|
||||
val uploaded = mutableListOf<Long>()
|
||||
val result = runCatching {
|
||||
PurchaseOutboxUploader(
|
||||
{ listOf(item, item.copy(id = 2, taskId = 43)) },
|
||||
{ submitted += it.id; if (it.id == 1L) throw cn.ilapage.goauto.agent.network.AgentApiException(409, "PURCHASE_STATE_CONFLICT", "untrusted", false) },
|
||||
{ uploaded += it.id },
|
||||
markRejected = { _, _ -> },
|
||||
).flush()
|
||||
}
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(listOf(1L, 2L), submitted)
|
||||
assertEquals(listOf(2L), uploaded)
|
||||
}
|
||||
|
||||
@Test fun `handoff callback runs only after successful submit and local upload mark`() {
|
||||
for (failure in listOf("submit", "mark", "none")) {
|
||||
val events = mutableListOf<String>()
|
||||
@@ -31,4 +47,37 @@ class PurchaseOutboxHandoffTest {
|
||||
PurchaseOutboxUploader({ listOf(item) }, { submitted++ }, {}).flush()
|
||||
assertEquals(1, submitted)
|
||||
}
|
||||
|
||||
@Test fun `only whitelisted 409 is terminal independent of retryable`() {
|
||||
for (code in listOf("PURCHASE_STATE_CONFLICT", "PURCHASE_LEASE_EXPIRED", "PURCHASE_RESULT_CONFLICT", "OTHER")) {
|
||||
for (status in listOf(409, 401, 403, 500)) for (retryable in listOf(false, true)) {
|
||||
val events = mutableListOf<String>()
|
||||
val result = runCatching {
|
||||
PurchaseOutboxUploader({ listOf(item) },
|
||||
{ throw cn.ilapage.goauto.agent.network.AgentApiException(status, code, "secret URL", retryable) },
|
||||
{ events += "uploaded" }, { events += "handoff" },
|
||||
{ _, fixedCode -> events += fixedCode },
|
||||
).flush()
|
||||
}
|
||||
val rejected = status == 409 && code != "OTHER"
|
||||
assertEquals(rejected, result.isSuccess)
|
||||
assertEquals(if (rejected) listOf(code) else emptyList<String>(), events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `network and rejected persistence failures retain pending and stop flush`() {
|
||||
for (diskFailure in listOf(false, true)) {
|
||||
val events = mutableListOf<String>()
|
||||
val result = runCatching {
|
||||
PurchaseOutboxUploader({ listOf(item, item.copy(id = 2)) },
|
||||
{ events += "submit"; if (diskFailure) throw cn.ilapage.goauto.agent.network.AgentApiException(409, "PURCHASE_STATE_CONFLICT", "raw", false) else error("network") },
|
||||
{ events += "uploaded" }, { events += "handoff" },
|
||||
{ _, _ -> events += "reject"; error("disk") },
|
||||
).flush()
|
||||
}
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(if (diskFailure) listOf("submit", "reject") else listOf("submit"), events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.network.AgentApiClient
|
||||
import cn.ilapage.goauto.agent.network.AgentApiException
|
||||
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
|
||||
import java.net.ServerSocket
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseOutboxHttpTest {
|
||||
@Test fun `missing retryable is not evidence that an unknown 409 is terminal`() {
|
||||
for (code in listOf("PURCHASE_STATE_CONFLICT", "PURCHASE_LEASE_EXPIRED", "PURCHASE_RESULT_CONFLICT", "UNKNOWN_CONFLICT")) {
|
||||
ServerSocket(0).use { server ->
|
||||
server.soTimeout = 3000
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
val serving = executor.submit {
|
||||
server.accept().use { socket ->
|
||||
socket.soTimeout = 3000
|
||||
val reader = socket.getInputStream().bufferedReader()
|
||||
var contentLength = 0
|
||||
while (true) {
|
||||
val line = reader.readLine() ?: break
|
||||
if (line.isEmpty()) break
|
||||
if (line.startsWith("Content-Length:", true)) contentLength = line.substringAfter(':').trim().toInt()
|
||||
}
|
||||
repeat(contentLength) { check(reader.read() >= 0) }
|
||||
val response = """{"code":"$code","message":"untrusted diagnostic"}"""
|
||||
socket.getOutputStream().write(("HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nContent-Length: ${response.toByteArray().size}\r\nConnection: close\r\n\r\n$response").toByteArray())
|
||||
}
|
||||
}
|
||||
try {
|
||||
val api = AgentApiClient("http://127.0.0.1:${server.localPort}")
|
||||
var pending = true
|
||||
val result = runCatching {
|
||||
PurchaseOutboxUploader(
|
||||
{ listOf(PendingPurchaseOutbox(1,42,"probe","request","{}")) },
|
||||
{ api.submitPurchaseResult(it.taskId,it.payloadJson,"synthetic") },
|
||||
{ fail("409 is never uploaded") },
|
||||
{ fail("409 is never a handoff") },
|
||||
{ _, _ -> pending = false },
|
||||
).flush()
|
||||
}
|
||||
assertEquals(code == "UNKNOWN_CONFLICT", pending)
|
||||
assertEquals(code != "UNKNOWN_CONFLICT", result.isSuccess)
|
||||
if (pending) assertFalse((result.exceptionOrNull() as AgentApiException).retryable)
|
||||
serving.get(5, TimeUnit.SECONDS)
|
||||
} finally { executor.shutdownNow() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.persistence.RejectedPurchaseResult
|
||||
import cn.ilapage.goauto.agent.ui.PurchaseRejectionPresentation
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseRejectionPresentationTest {
|
||||
private fun item(payload: String, acknowledged: Long? = null) = RejectedPurchaseResult(1,42,"attempt",payload,100,"PURCHASE_STATE_CONFLICT",acknowledged)
|
||||
@Test fun `only unacknowledged order evidence appears on procurement banner`() {
|
||||
for (payload in listOf("""{"resultType":"order_created"}""", """{"resultType":"order_result_unknown"}""", """{"resultType":"failed","pddOrderNo":"123-456"}""")) {
|
||||
assertTrue(PurchaseRejectionPresentation.showBanner(item(payload)))
|
||||
assertFalse(PurchaseRejectionPresentation.showBanner(item(payload, 200)))
|
||||
}
|
||||
assertFalse(PurchaseRejectionPresentation.showBanner(item("""{"resultType":"spec_probe_completed"}""")))
|
||||
assertFalse(PurchaseRejectionPresentation.showBanner(item("""{"resultType":"spec_probe_completed","pddOrderNo":null}""")))
|
||||
}
|
||||
@Test fun `notice includes task optional order and fixed review instruction without raw errors`() {
|
||||
val message = PurchaseRejectionPresentation.notice(item("""{"resultType":"order_created","pddOrderNo":"123-456","message":"https://secret"}"""))
|
||||
assertTrue(message.contains("#42")); assertTrue(message.contains("123-456"))
|
||||
assertTrue(message.contains("请人工核对拼多多订单及后台任务,避免重复采购"))
|
||||
assertFalse(message.contains("secret"))
|
||||
}
|
||||
@Test fun `connection classifies sync heartbeat and rejected separately`() {
|
||||
assertEquals("正在同步", PurchaseRejectionPresentation.connection("CONNECTING", 2))
|
||||
assertEquals("已连接 · 有 2 条采购结果被服务端拒收", PurchaseRejectionPresentation.connection("ONLINE", 2))
|
||||
assertEquals("未连接 · 设备认证失败", PurchaseRejectionPresentation.connection("AUTH_ERROR", 2))
|
||||
assertEquals("未连接 · 设备任务状态不一致", PurchaseRejectionPresentation.connection("TASK_MISMATCH", 2))
|
||||
assertEquals("未连接 · 网络暂不可用", PurchaseRejectionPresentation.connection("NETWORK_ERROR", 2))
|
||||
assertEquals("未连接 · 服务端暂不可用", PurchaseRejectionPresentation.connection("SERVER_ERROR", 2))
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,15 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SpecValueNormalizerTest {
|
||||
@Test
|
||||
fun `final confirmation maps only display brackets alphanumerics and whitespace`() {
|
||||
assertEquals("[白][黑][灰](薄款) RW12 XL", SpecValueNormalizer.normalizeFinalConfirmation(" 【白】〔黑〕[灰](薄款) RW12\t\nXL "))
|
||||
assertEquals("白色,加绒:XL ¥19.60 ① 2件", SpecValueNormalizer.normalizeFinalConfirmation("白色,加绒:XL ¥19.60 ① 2件"))
|
||||
assertEquals("xl", SpecValueNormalizer.normalizeFinalConfirmation("xl"))
|
||||
assertEquals("【白色】 XL", SpecValueNormalizer.normalizeColor("【白色】 XL"))
|
||||
assertEquals("【白色】 XL", SpecValueNormalizer.normalizeSize("【白色】 XL"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only trailing price is removed`() {
|
||||
assertEquals("紫色", SpecValueNormalizer.normalizeColor("紫色 ¥15.78"))
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
import java.sql.Connection
|
||||
import java.sql.DriverManager
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseRejectionStoreTest {
|
||||
@Test fun `v2 upgrade adds exactly three nullable columns and preserves all old data`() = database { db ->
|
||||
val before = rows(db, "SELECT * FROM purchase_outbox")
|
||||
val taskBefore = rows(db, "SELECT * FROM purchase_task")
|
||||
migrate(db)
|
||||
assertEquals(3, PurchaseRejectionSql.migration.size)
|
||||
assertEquals(taskBefore, rows(db, "SELECT * FROM purchase_task"))
|
||||
assertEquals(before, rows(db, "SELECT id, task_id, attempt_id, request_id, payload_json, upload_status, created_at, updated_at FROM purchase_outbox"))
|
||||
assertEquals(listOf(listOf(null, null, null)), rows(db, "SELECT rejected_at,rejection_error_code,acknowledged_at FROM purchase_outbox"))
|
||||
val columns = rows(db, "PRAGMA table_info(purchase_outbox)").takeLast(3)
|
||||
assertTrue(columns.all { it[3] == "0" && it[4] == null })
|
||||
}
|
||||
|
||||
@Test fun `reject is atomic preserves payload excludes pending and targets current attempt only`() = database { db ->
|
||||
migrate(db)
|
||||
reject(db)
|
||||
assertEquals(listOf(listOf("rejected", "payload", "100", "PURCHASE_STATE_CONFLICT")), rows(db, "SELECT upload_status,payload_json,rejected_at,rejection_error_code FROM purchase_outbox"))
|
||||
assertEquals(listOf(listOf("completed", "rejected", "payload")), rows(db, "SELECT status,upload_status,result_json FROM purchase_task"))
|
||||
assertTrue(rows(db, PurchaseRejectionSql.activeTask).isEmpty())
|
||||
assertTrue(rows(db, "SELECT id FROM purchase_outbox WHERE upload_status='pending'").isEmpty())
|
||||
}
|
||||
|
||||
@Test fun `old outbox rejection cannot overwrite a newer task attempt`() = database { db ->
|
||||
migrate(db)
|
||||
db.createStatement().use { it.execute("UPDATE purchase_task SET attempt_id='new',status='running',upload_status='none'") }
|
||||
reject(db)
|
||||
assertEquals(listOf(listOf("new", "running", "none")), rows(db, "SELECT attempt_id,status,upload_status FROM purchase_task"))
|
||||
}
|
||||
|
||||
@Test fun `rejected record never counts active even if legacy local status says running`() = database { db ->
|
||||
migrate(db); reject(db)
|
||||
db.createStatement().use { it.execute("UPDATE purchase_task SET status='running'") }
|
||||
assertTrue(rows(db, PurchaseRejectionSql.activeTask).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun `invalid whitelist and mismatched outbox identity cannot change evidence`() = database { db ->
|
||||
migrate(db)
|
||||
for ((item, code) in listOf(
|
||||
PendingPurchaseOutbox(1,42,"old","request","payload") to "UNKNOWN",
|
||||
PendingPurchaseOutbox(1,42,"new","request","payload") to "PURCHASE_STATE_CONFLICT",
|
||||
)) {
|
||||
assertTrue(runCatching { PurchaseRejectionSql.reject(item, code, 100) { sql, args -> update(db, sql, args) } }.isFailure)
|
||||
}
|
||||
assertEquals(listOf(listOf("pending", null, null)), rows(db, "SELECT upload_status,rejected_at,rejection_error_code FROM purchase_outbox"))
|
||||
}
|
||||
|
||||
@Test fun `task update failure rolls back rejection evidence too`() = database { db ->
|
||||
migrate(db)
|
||||
db.createStatement().use { it.execute("CREATE TRIGGER fail_task BEFORE UPDATE ON purchase_task BEGIN SELECT RAISE(ABORT, 'test'); END") }
|
||||
assertTrue(runCatching { reject(db) }.isFailure)
|
||||
assertEquals(listOf(listOf("pending", null, null)), rows(db, "SELECT upload_status,rejected_at,rejection_error_code FROM purchase_outbox"))
|
||||
assertEquals(listOf(listOf("pending")), rows(db, "SELECT upload_status FROM purchase_task"))
|
||||
}
|
||||
|
||||
@Test fun `ack survives database reopen and changes no payload or task state`() {
|
||||
val file = java.io.File.createTempFile("purchase-rejection-", ".db")
|
||||
try {
|
||||
connect(file.absolutePath).use { db ->
|
||||
seed(db); migrate(db); reject(db)
|
||||
update(db, PurchaseRejectionSql.acknowledge, listOf(200L, 1L))
|
||||
}
|
||||
connect(file.absolutePath).use { db ->
|
||||
assertEquals(listOf(listOf("200", "payload", "rejected")), rows(db, "SELECT acknowledged_at,payload_json,upload_status FROM purchase_outbox"))
|
||||
assertEquals(listOf(listOf("completed", "rejected")), rows(db, "SELECT status,upload_status FROM purchase_task"))
|
||||
}
|
||||
} finally { check(file.delete()) }
|
||||
}
|
||||
|
||||
private fun reject(db: Connection) {
|
||||
db.autoCommit = false
|
||||
try {
|
||||
PurchaseRejectionSql.reject(PendingPurchaseOutbox(1,42,"old","request","payload"), "PURCHASE_STATE_CONFLICT", 100) { sql, args -> update(db, sql, args) }
|
||||
db.commit()
|
||||
} catch (error: Exception) { db.rollback(); throw error }
|
||||
finally { db.autoCommit = true }
|
||||
}
|
||||
private fun update(db: Connection, sql: String, args: List<Any>): Int = db.prepareStatement(sql).use { statement ->
|
||||
args.forEachIndexed { index, arg -> statement.setObject(index + 1, arg) }
|
||||
statement.executeUpdate()
|
||||
}
|
||||
private fun migrate(db: Connection) = PurchaseRejectionSql.migration.forEach { sql -> db.createStatement().use { it.execute(sql) } }
|
||||
private fun connect(path: String): Connection { Class.forName("org.sqlite.JDBC"); return DriverManager.getConnection("jdbc:sqlite:$path") }
|
||||
private fun database(block: (Connection) -> Unit) = connect(":memory:").use { seed(it); block(it) }
|
||||
private fun rows(db: Connection, sql: String): List<List<String?>> = db.createStatement().use { statement ->
|
||||
statement.executeQuery(sql).use { r -> buildList { while(r.next()) add((1..r.metaData.columnCount).map { r.getString(it) }) } }
|
||||
}
|
||||
private fun seed(db: Connection) = db.createStatement().use {
|
||||
it.execute("CREATE TABLE purchase_task (task_id INTEGER PRIMARY KEY,attempt_id TEXT NOT NULL,rule_snapshot_hash TEXT NOT NULL,current_step TEXT NOT NULL,status TEXT NOT NULL,result_json TEXT,upload_status TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL,order_submit_request_id TEXT,final_confirmation_json TEXT,irreversible_at INTEGER)")
|
||||
it.execute("CREATE TABLE purchase_outbox (id INTEGER PRIMARY KEY AUTOINCREMENT,task_id INTEGER NOT NULL,attempt_id TEXT NOT NULL,request_id TEXT NOT NULL UNIQUE,payload_json TEXT NOT NULL,upload_status TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)")
|
||||
it.execute("INSERT INTO purchase_task VALUES (42,'old','hash','submit_result','completed','payload','pending',1,2,NULL,NULL,NULL)")
|
||||
it.execute("INSERT INTO purchase_outbox VALUES (1,42,'old','request','payload','pending',1,2)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.network.AgentApiException
|
||||
import cn.ilapage.goauto.agent.network.HeartbeatResult
|
||||
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class AgentSyncCycleTest {
|
||||
private val online = HeartbeatResult(7, true, false, 15)
|
||||
private fun api(status: Int, code: String) = AgentApiException(status, code, "untrusted URL", false)
|
||||
|
||||
@Test fun `stale completed probe heartbeat mismatch rejected then refreshed heartbeat permits next claim`() {
|
||||
val events = mutableListOf<String>()
|
||||
var active: Long? = 42
|
||||
var pending = true
|
||||
val result = AgentSyncCycle(
|
||||
heartbeat = { id -> events += "heartbeat:$id"; if (id != null) throw api(409, "DEVICE_TASK_MISMATCH") else online },
|
||||
activeTaskId = { active }, executionActive = { false },
|
||||
recover = { events += "recover" },
|
||||
flush = {
|
||||
PurchaseOutboxUploader(
|
||||
{ listOf(PendingPurchaseOutbox(1,42,"probe","request","{}")) },
|
||||
{ throw api(409,"PURCHASE_STATE_CONFLICT") },
|
||||
{ fail("rejected probe must not be marked uploaded") },
|
||||
{ fail("rejected probe must not establish handoff") },
|
||||
{ _, _ -> events += "reject-probe"; pending = false; active = null },
|
||||
).flush()
|
||||
},
|
||||
hasPending = { pending },
|
||||
).run()
|
||||
if (result.canClaim) events += "claim"
|
||||
assertEquals(listOf("heartbeat:42", "recover", "reject-probe", "heartbeat:null", "claim"), events)
|
||||
assertEquals(online, result.heartbeat)
|
||||
}
|
||||
|
||||
@Test fun `authentication errors skip recovery and flush including unrecognized HTTP 401 and 403`() {
|
||||
for ((status, code) in listOf(401 to "OTHER", 403 to "OTHER", 409 to "DEVICE_TOKEN_INVALID", 409 to "DEVICE_INSTALL_ID_CONFLICT", 409 to "DEVICE_DISABLED")) {
|
||||
val result = AgentSyncCycle({ throw api(status, code) }, { 42 }, { false },
|
||||
{ fail("recovery after auth failure") }, { fail("flush after auth failure") }, { true }).run()
|
||||
assertFalse(result.canClaim)
|
||||
assertEquals("AUTH_ERROR", result.failureCode)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `heartbeat survives recovery or flush failure but claiming stops`() {
|
||||
for (recoverFails in listOf(false, true)) {
|
||||
var flushed = false
|
||||
val result = AgentSyncCycle({ online }, { null }, { false },
|
||||
{ if (recoverFails) error("disk") },
|
||||
{ flushed = true; if (!recoverFails) throw api(500, "INTERNAL") }, { false }).run()
|
||||
assertTrue(flushed)
|
||||
assertEquals(online, result.heartbeat)
|
||||
assertFalse(result.canClaim)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `failed heartbeat still flushes but never claims and mismatch retries only once`() {
|
||||
for (code in listOf("DEVICE_TASK_MISMATCH", "INTERNAL")) {
|
||||
var heartbeats = 0
|
||||
var flushed = false
|
||||
val result = AgentSyncCycle({ heartbeats++; throw api(409, code) }, { null }, { false }, {},
|
||||
{ flushed = true }, { false }).run()
|
||||
assertTrue(flushed)
|
||||
assertEquals(if (code == "DEVICE_TASK_MISMATCH") 2 else 1, heartbeats)
|
||||
assertFalse(result.canClaim)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `network failure leaves pending even if heartbeat succeeds`() {
|
||||
var pending = true
|
||||
val result = AgentSyncCycle({ online }, { 42 }, { false }, {}, { throw java.io.IOException("network") }, { pending }).run()
|
||||
assertTrue(pending)
|
||||
assertEquals(online, result.heartbeat)
|
||||
assertFalse(result.canClaim)
|
||||
}
|
||||
|
||||
@Test fun `active execution sends heartbeat without recovery or flush`() {
|
||||
val result = AgentSyncCycle({ online }, { 42 }, { true }, { fail("recover") }, { fail("flush") }, { false }).run()
|
||||
assertEquals(online, result.heartbeat)
|
||||
assertFalse(result.canClaim)
|
||||
}
|
||||
|
||||
@Test fun `retained irreversible local task prevents claim even without outbox`() {
|
||||
val result = AgentSyncCycle({ online }, { 42 }, { false }, {}, {}, { false }).run()
|
||||
assertFalse(result.canClaim)
|
||||
}
|
||||
|
||||
@Test fun `authentication failure during recovery or flush stops round without heartbeat retry`() {
|
||||
for (duringRecovery in listOf(true, false)) {
|
||||
var heartbeats = 0
|
||||
var flushes = 0
|
||||
val result = AgentSyncCycle({ heartbeats++; throw api(409,"DEVICE_TASK_MISMATCH") }, { 42 }, { false },
|
||||
{ if (duringRecovery) throw api(401,"OTHER") },
|
||||
{ flushes++; throw api(403,"OTHER") }, { true }).run()
|
||||
assertEquals(1, heartbeats)
|
||||
assertEquals(if (duringRecovery) 0 else 1, flushes)
|
||||
assertEquals("AUTH_ERROR", result.failureCode)
|
||||
assertFalse(result.canClaim)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `server failure is classified separately from transport failure`() {
|
||||
val result = AgentSyncCycle({ throw api(503,"UNAVAILABLE") }, { null }, { false }, {}, {}, { false }).run()
|
||||
assertEquals("SERVER_ERROR", result.failureCode)
|
||||
}
|
||||
|
||||
@Test fun `claim failure cannot erase successful heartbeat except authentication`() {
|
||||
assertNull(syncConnectionFailureCode(api(409,"PURCHASE_STATE_CONFLICT"), true))
|
||||
assertNull(syncConnectionFailureCode(IllegalStateException("disk"), true))
|
||||
assertNull(syncConnectionFailureCode(api(503,"INTERNAL"), true))
|
||||
assertEquals("AUTH_ERROR", syncConnectionFailureCode(api(401,"OTHER"), true))
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseRuntimeReconciliationTest {
|
||||
@Test fun `manual current page cannot enter while sync owns recovery and flush`() {
|
||||
val mutex = TaskExecutionMutex()
|
||||
val syncing = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
val syncEntered = java.util.concurrent.CountDownLatch(1)
|
||||
val finishSync = java.util.concurrent.CountDownLatch(1)
|
||||
val worker = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
val future = worker.submit {
|
||||
synchronized(mutex) { assertTrue(syncing.compareAndSet(false, true)) }
|
||||
syncEntered.countDown()
|
||||
check(finishSync.await(3, java.util.concurrent.TimeUnit.SECONDS))
|
||||
syncing.set(false)
|
||||
}
|
||||
try {
|
||||
assertTrue(syncEntered.await(3, java.util.concurrent.TimeUnit.SECONDS))
|
||||
assertFalse(tryAcquireCurrentPage(mutex, syncing, Long.MAX_VALUE))
|
||||
assertNull(mutex.currentTaskId())
|
||||
finishSync.countDown()
|
||||
future.get(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
assertTrue(tryAcquireCurrentPage(mutex, syncing, Long.MAX_VALUE))
|
||||
} finally { finishSync.countDown(); worker.shutdownNow() }
|
||||
}
|
||||
|
||||
@Test fun `reboot restored rejected task clears runtime so repurchase becomes idle`() {
|
||||
var runtimeTaskId: Long? = 42
|
||||
val currentDatabaseAttemptRejected = true
|
||||
if (shouldClearRejectedPurchase(42, runtimeTaskId, "purchase", null, currentDatabaseAttemptRejected)) runtimeTaskId = null
|
||||
assertNull(runtimeTaskId)
|
||||
assertTrue(runtimeTaskId == null) // final runtime predicate in repurchaseLocalIdle
|
||||
}
|
||||
|
||||
@Test fun `another task collection active executor or newer attempt cannot be cleared`() {
|
||||
assertFalse(shouldClearRejectedPurchase(42, 43, "purchase", null, true))
|
||||
assertFalse(shouldClearRejectedPurchase(42, 42, "collection", null, true))
|
||||
assertFalse(shouldClearRejectedPurchase(42, 42, "purchase", 42, true))
|
||||
assertFalse(shouldClearRejectedPurchase(42, 42, "purchase", 43, true))
|
||||
assertFalse(shouldClearRejectedPurchase(42, 42, "purchase", null, false))
|
||||
}
|
||||
|
||||
@Test fun `stopped service remains stopped after an in flight heartbeat completes`() {
|
||||
val gate = AgentStatePublicationGate()
|
||||
var connection = "CONNECTING"
|
||||
gate.publish { connection = "ONLINE" }
|
||||
assertEquals("ONLINE", connection)
|
||||
gate.stop { connection = "STOPPED" }
|
||||
gate.publish { connection = "ONLINE" }
|
||||
assertEquals("STOPPED", connection)
|
||||
val restartedService = AgentStatePublicationGate()
|
||||
restartedService.publish { connection = "CONNECTING" }
|
||||
assertEquals("CONNECTING", connection)
|
||||
gate.publish { connection = "ONLINE" }
|
||||
assertEquals("CONNECTING", connection)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 76854d900b2372ddedd46a2b82b5677d8bf5cb31
|
||||
synchronized_at: 2026-10-09T06:46:07Z
|
||||
wiki_revision: 08da88a4f106004cb5f02448260d2fc22a83e9cc
|
||||
synchronized_at: 2026-10-10T00:59:09Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -673,3 +673,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- `PddProductDetailCollector.collectColors` 只基于既有解析帧记录颜色阶段 UP 派发次数、空发现轮次和横向探索三态;不增加抓取、手势或等待。标签及签名只在内存使用,持久化仍为安全聚合。
|
||||
- 不新增 Server/Web API 字段、服务端迁移或权限;普通结果 payload/missing 与采购探测失败策略未改变。完整遍历与不完整候选拦截仍属 #370 后续待证据实施范围,不能把本地诊断值作为已实现的业务门禁。
|
||||
- 读取与三态限制见 Troubleshooting;旧版不保证降级打开 v5。JDBC 测试与实际 Android 安装升级分别验收,测试不代替真实竖向颜色采集验证。
|
||||
|
||||
## Admin 列表按当前筛选导出全部页(#372,待验收)
|
||||
|
||||
实现绑定 `a4a793f`(分支 `feat/372-list-export`,未合并、未发布)。
|
||||
|
||||
- 采购管理与 SYB 商品页各增「导出」按钮:`web/src/utils/list-export.js` 先按当前筛选读第 1 页取总数,超过 5000 条不导出,否则逐页读取(采购每页 100、SYB 每页 500)再用 `web/src/vendor/Export2Excel.js`(经 `utils/save-excel.js` 按需加载)生成 xlsx;任一页或批次失败不生成文件;筛选条件以点击时复制的参数为准。无服务端导出接口、无异步导出任务。
|
||||
- `GET /api/admin/v1/purchase-tasks` 的 `AdminListRequest`(`server/app/goauto/purchase/admin_query.go`)新增可选 `orderSubmittedFrom` / `orderSubmittedTo`(`YYYY-MM-DD`,含首尾两天,按 `order_submitted_at`,UTC+8 与 SYB 创建时间筛选一致);格式错误或起止倒置返回 `INVALID_REQUEST`;无下单时间的任务在设置该筛选时不出现。采购页筛选栏用日期范围选择器。
|
||||
- `GET /api/admin/v1/return-matches` 的 `returnmatch.ListItem` 新增只读 `yeekeQuantity`(`server/app/goauto/returnmatch/service.go`,来自既有 `yeeke_return_item` join,不参与匹配);该接口原本就支持一次传多个 `sybProductId`,SYB 导出按每批 200 个商品 ID 调用,不逐行请求,且只采用 `matched`/`confirmed` 的匹配。
|
||||
- 无数据库迁移、无新接口、无新权限;导出按钮不单独鉴权,能打开列表的用户(含采购员)都可导出。
|
||||
|
||||
@@ -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: 6c1a317ef67a0b015bb16a615b8c490b384188a0
|
||||
synchronized_at: 2026-10-09T07:21:39Z
|
||||
wiki_revision: 35c42f6136426bd3c295fe912d491c3fd980fcb4
|
||||
synchronized_at: 2026-10-10T01:41:05Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -891,7 +891,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
|
||||
## 采购页面一次性交接与导航稳定等待(#373 v4)
|
||||
|
||||
实现绑定分支 `fix/373-navigation-diagnostics` 提交 `dbf9a5f`,调试版本 `0.9.69-373-fix1`;2026-10-09 尚未合并main、发布或安装该修复版。此节是该提交行为,不代表线上旧版本已生效;替代此前仅凭商品结构复用页面的方式。
|
||||
实现绑定分支 `fix/373-navigation-diagnostics` 提交 `dbf9a5f`;2026-10-09 与 #374 依次合并并推送 main,集成提交 `555a954605a83c12457ed1ada86eb732fe3bb023`。集成APK仍标识 `0.9.69-373-fix1 / code82`,须以哈希区分;未安装或发布。此节不代表线上旧版本已生效;替代此前仅凭商品结构复用页面的方式。
|
||||
|
||||
- Agent在每次采购执行开始时先取出清空服务进程内的交接凭证。凭证只能来自同任务spec_probe正常完成、结果安全落盘、首次提交返回pending且非replayed,并且本地markUploaded成功之后;失败/重传不生成或延长。缺省没有凭证,不因当前有商品结构就复用。
|
||||
- 凭证绑定taskId、goodsId、源探测attemptId、本地执行代次、无障碍实例、前台变更序号和探测完成时的单调时钟。仅紧接的同任务同商品purchase可消费一次;源/目标attempt本来不同。有效年龄为0至不足30000ms;慢AI或网络等待超过上限只导致重开链接。30秒是基于15秒心跳的保守复用政策,不是实际AI耗时保证。
|
||||
@@ -902,3 +902,58 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
- 导航总预算为单调时钟30秒、最多300轮、轮间100ms;耗时系统读取可能超过预算,但返回后检查时限,不再继续点击。浏览器入口重试从点击调用返回后至少间隔1秒;原生点击前在新鲜节点重新校验浏览器与剩余预算。超时返回PDD_DETAIL_ENTRY_FAILED,不继续规格、地址或创建订单步骤。
|
||||
- 用户明确拒绝新增分享/复制链接操作,并接受本版仅解决跨任务无条件复用与导航抢跑。**稳定商品页不是goodsId身份核验;PDD忽略深链而稳定停在旧页、或无可观察变化的同包人工换页,仍可能无法识别。** 不得将任务携带goodsId当作页面回读,不能用本版宣称绝对排除错误商品。
|
||||
- 不改服务端/API线协议/数据库、采集器、规格选择、地址与下单逻辑、重购批次发现/停止逻辑;不涉及任何付款动作。仅增加Android对既有结果响应status/replayed的本地解析(replayed=false在服务端可省略)。普通流程不新增原始树、截图、分享URL或页面文本落盘。
|
||||
|
||||
## 下单前最终规格复核(#374 v3)
|
||||
|
||||
> 以下为 #374 版本行为;包含 #376 的分支构建已按本文后续“采购改地址前复核与提交提示检查”章节替代最终复核策略,尚未合并或发布。不把新规则归于未更新的旧 APK。
|
||||
|
||||
实现绑定 `6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e`(原独立工单分支基于 `e26743c`);2026-10-09 已与 #373 集成并推送 main `555a954605a83c12457ed1ada86eb732fe3bb023`。集成版本同时包含导航等待与最终规格复核,未安装或发布,真机采购未验收。
|
||||
|
||||
- 仅替换 PurchaseLiveAutomation.finalConfirmation 的最终规格检查,不修改之前的规格选择/verifySummary、地址、单价、数量、提交目标与不可逆边界。没有 Server/Web、API 或数据库变化。
|
||||
- 最终专用规范化只统一【】〔〕[]、()、全角字母数字与空白;压缩连续空白,不折叠大小写、不改同义词、不用通用 NFKC、不删除括号内容。选项使用解析器既有的规格 text(沿用 UI 尾价分离),目标和摘要不新增尾价或数量删除。
|
||||
- 复用既有数量输入有界祖先的完整面板形态,但保留真实祖先关系与边界双重归属;滚动列表不等于整个弹层,摘要可位于列表上方/兄弟区域。范围内重新确认面板结构,不将全屏/root 当面板,不用仅坐标重叠的面板外文字。完整范围不可靠时禁用摘要,只使用可独立确认的有界规格滚动区域内选项。
|
||||
- 最终确认专用解析参数默认 false,普通采集及既有调用不变。仅该路径保留同名独立物理选项;沿用原 optionBlock selected/checked 聚合,并合并同值、同矩形、真实祖先链的同一选项,不吞并同名兄弟候选。同一维度多组统一检查。
|
||||
- 只检查下发非空维度,先拒绝同维度明确选中非目标、目标不可用、不同实际候选归一到同一目标或已知候选组合歧义;随后当前面板内规范化完全相等且 selected/checked 的目标可证明该维度。没有选中证明但也没有冲突时才用可信摘要,即使其他未选中候选仍可见也允许摘要补证,不使用历史 attempt 证明。
|
||||
- 摘要仅剥离开头已识别的已选前缀和紧随的一个可选冒号,保留正文标点与内容。无前缀摘要仅沿用已有 quantity/header 结构识别且须属于完整面板。当前双维证据只覆盖“颜色 空格 尺码”,逗号分隔、反序、数量后缀或未知格式不猜测支持;仅一个维度时整段须等于该值,两维均空不作为证明。
|
||||
- 多个不一致的可信摘要、已知组合歧义均拒绝。单个不匹配的摘要不能补证缺失维度;若所有必需维度已有当前精确选中证明且无上述冲突,则按选中证明优先通过。不声称候选缺失时能证明全部潜在 SKU 唯一性。
|
||||
- 拒绝仍为 PURCHASE_SPEC_NOT_MATCHED,错误信息与诊断只附固定 dimension/stage/summaryPresent/selectedFound/conflict,不附页面文字、规格原文或地址。原本靠任意可见控件含有规格子串的通过路径已移除。
|
||||
- 自动化证据:8 个相关套件共366项通过,0失败/错误/跳过;debug构建、release单测源码编译通过。完整单测因未修改的图搜返回测试忙循环而中止,不能宣称全量通过。结构夹具依据为 SpecPanelFixtures.sheet/taskOptionDedupSheet 及 PddProductDetailCollectorTest.prefixlessPanel;未执行真实采购,不以合成测试代替真机效果。
|
||||
|
||||
## 采购改地址前复核与提交提示检查(#376 v3)
|
||||
|
||||
实现绑定工单分支 `fix/376-pre-address-spec-verification` 提交 `9c7357609cee7c73b57edd030136ed12ef9485b6`(2026-10-09),尚未合并 main 或发布;此规则仅适用于包含 #376 的 Android 构建,旧版 #374 行为不因此改变。没有 Server/Web、业务库迁移或 API 边界协议变化。
|
||||
|
||||
- 改地址前,verifySummary 在每个目标维度和既有重定位滑动后的当前面板中检查未选提示。面板摘要的“请选择/請選擇”或唯一提交按钮的选择提示导致 PURCHASE_SPEC_SELECTION_UNCONFIRMED、reason=selection_prompt,停止后续地址动作;无唯一提交按钮时仍可检查摘要,不增加改地址前缺少按钮的门禁。
|
||||
- 本次选择历史证明仅用于既有可见性缺口;目标当前可见、可选却未选中,同维无其他选中项且没有已选摘要时,不再由历史证明路径放行,reason=target_visible_unselected。目标不可见时保留原历史证明行为;不暴露选中属性且无摘要的布局可能提前失败。此前规格选择点击、数量设置、改地址前价格/数量复核不变。
|
||||
- 修改地址后的 finalConfirmation 不再比较颜色/尺码值,不要求选中证据,也不核对当前数量;保留地址、提交目标唯一性和单价区间检查。摘要未选提示在此只记录,不拦截。唯一提交按钮的完整文字去空白后匹配 `^(选择|選擇).+(后|後)[,,](提交订单|提交訂單)$` 才新增拒绝 PURCHASE_SPEC_NOT_MATCHED、finalSpec;stage=selection_prompt。
|
||||
- 按钮来源沿用原面板底部行 row 和点击文字节点 chosen;提示只组合该行内可见、自身带文字且没有带文字子孙、最近可点击祖先恰为 row 的节点,按 top/left 排序,不重复拼父子汇总、不跨独立按钮/摘要/规格列表或不可见节点。实际点击目标、行/叶并列选择和支付排除保持不变;enabled=true 不是已选规格的证明。
|
||||
- finalConfirmation 位于 beforeOrderSubmit 之前,拒绝时不写提交边界、不点击,普通失败可让重购继续。submitOrderOnce 重新取屏后的同类提示只作兜底,零点击但边界已写入,仍为 order_result_unknown,重购停止等待人工核对;不移动持久化边界、不增加重试或付款。
|
||||
- 本地 final_confirmation_json 新记录以 targetQuantity 表示下发数量,追加 observedQuantity(唯一可见可用数字数量输入,否则 null)、selectedSummaryPresent 与 summaryPromptPresent。仅记录数字/布尔观察值,不新增页面文字或个人信息;不用于拦截、不上传,旧记录不改写。
|
||||
- 用户明确接受剩余风险:修改地址后规格被换成其他值,或数量重置,但按钮仍正常时会放行;价格与地址检查不能替代规格/数量一致性。观察值仅供事后核对,不证明采购结果正确。缺少提示也不构成 PDD 已选正确的正向证明。
|
||||
- 不新增分享、OCR/VLM、滑动取证、自动重新选规格或付款;合成测试与 APK 构建不代表真实订单验证。安装、真实采购、合并和发布须按相应授权执行。
|
||||
|
||||
验证:19 个相关套件共418项通过,0失败/错误/跳过;Debug APK与release单测源码编译通过。执行器测试模拟提交边界回调,并验证本地JSON序列化;未执行真实SQLite/服务端边界端到端或真实采购验证。不宣称全量Android测试通过(既有图搜返回忙循环用例不在本次回归范围)。
|
||||
|
||||
## Android 采购结果明确拒收恢复(#377)
|
||||
|
||||
实现位于工单分支 fix/377-rejected-purchase-outbox(实现提交 8b75a95,运行占位与互斥补齐 309dce3);仅 Android,未合并 main、未安装或发布,真机恢复尚待独立授权验证。
|
||||
|
||||
- 每轮先发心跳,再在无任务执行锁时恢复中断任务及补传。心跳任务不一致不能挡住拒收收敛;收尾后只额外重发一次心跳。认证错误停止补传,只有最终心跳成功、没有未完成本地任务/待上传结果且收尾正常才领取新任务。不放宽服务端任务身份校验,不修改心跳间隔或离线阈值。
|
||||
- 只有 HTTP 409 且错误码精确属于 PURCHASE_STATE_CONFLICT、PURCHASE_LEASE_EXPIRED、PURCHASE_RESULT_CONFLICT 才视为明确拒收。未知 409、网络异常、5xx 及认证错误不按拒收处理;缺少 retryable 不构成拒收证据。
|
||||
- 本地拒收使用 rejected,不伪装 sent。按 outbox 与 task/attempt 归属事务保存,保留原 payload;不再自动补传该条,继续处理后续结果。拒收不触发上传成功或规格探测交接,交接凭证失效。拒收记录不再充当待上传占位;不改变服务端采购结果和原重购停止边界。
|
||||
- order_created、order_result_unknown 或带订单号的拒收结果在采购页持续提示人工核对。“已核对”只持久隐藏提醒,不删除结果、不调用服务端、不允许再次下单;非订单拒收仍可在设置及任务详情查看。
|
||||
- goauto_purchase.db v2→v3 只追加拒收时间、白名单错误码及人工核对时间三个可空列,旧结果与记录保持不变;v1 升级仍经过原 v2 追加列。旧版 SQLiteOpenHelper 不保证能打开 v3,不能靠卸载清数据回退,优先使用保留 v3 schema 的修复版本。
|
||||
- 设置页区分同步中、真实心跳失败、已连接但有拒收;新说明采用固定文案,不展示任意服务端错误消息。本单只修复明确拒收后的持续阻塞,不代表最初心跳中断的原因已查明,历史诊断仍属于 #378。
|
||||
|
||||
- 拒收后只在执行锁空闲、当前运行标记属于同一采购任务且本地当前 attempt 已拒收时清理恢复占位,不清除其他采集/采购或新 attempt;手动当前页采集与同步共用既有 working/任务锁门禁。服务停止写 STOPPED,原服务实例的迟到状态更新不覆盖它。
|
||||
|
||||
## Admin 列表导出 Excel(#372,待验收)
|
||||
|
||||
实现绑定 `a4a793f`,分支 `feat/372-list-export`,未合并、未发布。
|
||||
|
||||
- 范围:采购管理和 SYB 商品页的「导出」导出**当前筛选条件下的全部页**,不是只导出当前页;筛选以点击导出时的值为准。总数超过 **5000** 条时提示缩小筛选范围并不导出;任一页读取失败不生成文件;无数据提示且不生成文件。分页读取期间数据变化可能有少量重复或遗漏,以导出时刻近似结果为准。
|
||||
- 格式:时间 `YYYY-MM-DD HH:mm:ss`(浏览器本地时区);金额单位元,写成 Excel **数字单元格**(按分换算,例如 1230 分 → 12.3,可直接求和)并显示为 `0.00`;空值留空,0 写成 0;文件名 `采购导出_YYYYMMDD-HHmmss.xlsx`、`SYB商品导出_YYYYMMDD-HHmmss.xlsx`。不导出收货人、地址、账号等个人信息。
|
||||
- 采购导出列(按顺序):SYB订单号、虾皮商品ID、颜色、尺码(均为 SYB 目标规格快照)、数量、状态(页面同款中文)、PDD单号、下单时间、价格(元)(数字单元格)。价格取 `pddOrderAmountCent / 100`,即 Agent 在 PDD 待付款页读到的应付金额;旧版 Agent 或人工补录前为空则留空。要导出成功采购,先把状态筛为「订单已创建」并按需选择下单日期。
|
||||
- 采购管理新增「下单日期」筛选:含首尾两天,按 `order_submitted_at`,日期边界 UTC+8,与 SYB 商品页创建时间筛选一致;没有下单时间的任务在设置该筛选时不出现。
|
||||
- SYB 商品导出列(按顺序):SYB订单、虾皮商品ID、**SYB售价(TWD)**、SYB颜色、SYB尺码、SYB数量、yeeke颜色、yeeke尺码、yeeke数量、匹配时间。yeeke 颜色/尺码来自该商品有效(`matched` 或 `confirmed`)退货匹配的 `variationName`,按第一个英文或中文逗号拆分,没有逗号时整段放 yeeke 颜色;yeeke 数量为对应退货商品数量;SYB售价(TWD)取商品行 `unitPriceCent / 100`,与页面「售价」列同源(SYB `detail/listByStock` 的 `productPrice`,单件成交单价,元/TWD),数字单元格,无值留空;没有有效匹配时 yeeke 列与匹配时间留空。要导出已用退货,先把处理阶段筛为「已用退货」。
|
||||
- 权限:不新增按钮权限,能打开列表的用户(含采购员)都可导出。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: 6b370764320d1f5148fbc31f0ef6ce999370173d
|
||||
synchronized_at: 2026-10-09T02:06:57Z
|
||||
wiki_revision: af209183f85200ce53f79f82ab682e17bb0aa942
|
||||
synchronized_at: 2026-10-09T10:28:29Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -158,3 +158,21 @@ adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases
|
||||
```
|
||||
|
||||
排查须绑定同一设备、Agent/PDD 版本、任务与规则快照,并对照同次 App 布局。网页竖排与 App 横排不是同一复现场景;当前横排只能做横向兼容性回归。真机测试结果及待验证项以 #370 工单为准。
|
||||
|
||||
## 采购结果拒收后设备持续离线(#377)
|
||||
|
||||
修复绑定工单分支 fix/377-rejected-purchase-outbox 的 8b75a95、309dce3;尚未合并 main、安装或发布。自动化覆盖不等于真实设备验收,不能假定线上手机已具备本节恢复行为。
|
||||
|
||||
旧版同步在心跳之前补传,一条结果被服务端永久拒收就能让每轮跳过心跳及领任务。离线之后本地仍携带旧任务 ID,又可能被心跳的 DEVICE_TASK_MISMATCH 拒绝;仅把心跳前置不足以恢复。#377 先发心跳,仍进行有界拒收收尾,再在任务不一致时重发一次心跳;认证失败不会继续收尾,执行中的任务不会被恢复流程并发接管。
|
||||
|
||||
排查步骤:
|
||||
|
||||
1. 核实 APK 对应提交,不仅看版本名(本次未改 versionCode/versionName)。设置页“正在同步”不代表断线;真实心跳网络、服务端或认证失败使用固定原因说明;任务处理错误不再覆盖此前成功的连接状态。
|
||||
2. “已连接 · 有 N 条采购结果被服务端拒收”表示本地已有 rejected 结果,不等于服务端接收成功。可在设置页“查看拒收结果”和对应采购详情查看;有订单证据的结果还在采购页顶部持续提醒。
|
||||
3. 人工核对 PDD 实际订单及后台任务。需要补录订单时沿用 Admin 原有人工流程;Agent 的“已核对”只隐藏这条本机提醒,不能代替后台保存、恢复回填、解锁重试或完成任务。原结果/订单号与核对时间继续保存。
|
||||
4. 未知 409、网络失败或 5xx 保持 pending;如果仍卡住,核对错误分类及原任务状态,不自动把 pending 改成 sent。不反复重试真实采购来试探是否已下单。
|
||||
5. 早期现场曾经另获授权,通过同时修改 outbox 与本地任务 upload_status=sent 恢复设备,但这不代表服务端已收件,也会把订单号留在手机。它只是历史应急处置,不是新版运维步骤,不应复制到脚本或作为常规修复。
|
||||
|
||||
数据库升级仅限手机私有 goauto_purchase.db:v3 在 purchase_outbox 添加 rejected_at、rejection_error_code、acknowledged_at;保留原始 payload 和任务数据。不得把整库、订单/地址内容上传到工单、Wiki、普通日志或 SynapBus。旧 APK 不保证能够降级打开 v3;回退优先使用保留 v3 的兼容修复构建,不卸载清数据、不擅自降低库版本。
|
||||
|
||||
已验证 SQLite JDBC 执行同一生产迁移/拒收/核对 SQL、事务回滚及文件重开;尚未执行 Android SQLiteOpenHelper 仪器测试、真机界面或真实采购。最初心跳和结果同时中断的原因仍未查明,#378 的诊断计划不属于本修复。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 01a3a9cd968510c9435ec1fb2be0040fe17d0282
|
||||
synchronized_at: 2026-10-09T07:21:53Z
|
||||
wiki_revision: ad212f537b7d153e6337b3f2851e661680bfba20
|
||||
synchronized_at: 2026-10-10T01:17:11Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -365,3 +365,21 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 主Agent独立执行相关测试451项(23套件)零失败/错误,compileReleaseUnitTestKotlin与assembleDebug通过;规格及代码质量两轮独立只读审查通过。覆盖一次性交接/首传回放/上传标记失败/慢读取过期、浏览器出现前旧帧、diag3时序、3秒缓冲/1秒稳定、慢Binder点击冷却与超时零后续动作,以及采购、采集、原诊断相关回归。不是全量测试或真机采购验收。
|
||||
- 修复版的真实采购/重购未执行,页面身份识别限制见业务规则。原debug导航探针依然用于旧判据时序取证,不把其legacy_would_accept日志冒充新版执行器通过证据;新版导航就绪由实际采购执行器及其纯策略判断。
|
||||
- 诊断仍沿用已有本地记录;无需新schema、服务端权限或接口。运维排查先分清实际DIRECT/BROWSER路径、是否发生浏览器前旧页抢跑、是否缓冲/稳定超时,以及复用凭证是否有效;不能仅因startActivity接受就认定商品已加载。
|
||||
|
||||
## #373 + #374 合并 main 与集成构建(2026-10-09)
|
||||
|
||||
- 按用户授权的顺序先合并 #373(`1e924fa41195281a3223003a69c8c16ae37f8aa5`),再合并 #374(`555a954605a83c12457ed1ada86eb732fe3bb023`),保留两单提交历史;main已推送。源码无冲突,三份Wiki镜像选择包含两单内容的较新已导出版本,未手改规则正文解决冲突。
|
||||
- 集成验证25套件478项通过,0失败/错误/跳过;compileReleaseUnitTestKotlin与assembleDebug通过。不是全量单测结论;#374记录的未修改图搜返回测试忙循环未在本次重跑。未进行真机采购。
|
||||
- 集成APK位于本机 `D:/OPC/goauto-worktrees/release-373-374/android/app/build/outputs/apk/debug/app-debug.apk`,版本仍为 `0.9.69-373-fix1 / code82`,**已同时包含#374,不能只靠版本文字区分前一#373单独包**。SHA256 `6e2d0e0ed1eb943b3f168bb16d628d2c2bb4ce3b7c7c8b9e567dcdf8259150f8`;apksigner校验通过,沿用原签名证书 `bf86d7465c6092be74ce9c4187eb30c9a7d045c3b91891a547e2598429766dec`。
|
||||
- 本次仅合并、验证和推送,未覆盖安装、未执行真实采购/付款、未发布或重启服务;不需要业务库迁移。手机仍使用此前安装版本。后续装机须另行授权,保留原签名及用户数据。
|
||||
|
||||
|
||||
## #372 Admin 列表导出发布(2026-10-10)
|
||||
|
||||
- 用户授权合并 main 并发布。#372 以 `--no-ff` 合并为 main `72a15f8`(同时包含此前已在 main 的 #377 Android 提交,本次不构建或安装 Android)。合并时 `docs/03` 镜像冲突,选用包含 #377 与 #372 两段的较新导出版本(Business-Rules-and-Glossary@1c57a86),`sync --check` 通过。
|
||||
- 发布目录 `/home/goauto/releases/20261010-72a15f8-372`,上一目录 `/home/goauto/releases/20261009-e26743c-integrated` 保留供回滚。自上次发布 `e26743c` 以来 Server/Web 的源码变化只有 #372:采购列表新增可选下单日期参数、`return-matches` 列表新增只读 `yeekeQuantity`、两页导出按钮。没有数据库迁移、权限或菜单变更,未执行 migrate。
|
||||
- 切换前复查:采集、采购及 attempt 都没有执行中的任务;有 1 个 pending 采购任务保持原状,没有取消、重置或发起业务。config 从上一发布逐字复制(diff 一致);static/temp/var 沿用同一真实目录;上一发布的 js/css 哈希资源以不覆盖方式保留,便于已打开页面加载旧分块。发布目录 0755、`www` 用户可读 index 已在切换前确认。
|
||||
- Server 为 Linux amd64、CGO_ENABLED=0、go1.26.5 构建,SHA256 `ab3943a1261f438eaad4d4acf006910e8a996cf97afc18a399ad5bee6c8f3a1f`,运行进程二进制一致;Web 入口 index.html SHA256 `c3465960bd6f450d7b0d51994475bfdc126c7b6a47068d92bad9b4ac751d0529`。current 原子切换,goauto.service 重启后 active;Nginx 配置未变,未 reload。
|
||||
- 按内容验收:公网 `/` 与 `/login` 返回同一 index(含 `id="app"`,无欢迎页),10 项入口 JS/CSS 200,`/api/v1/health` 200,验证码 code=200;未登录采购列表(带下单日期参数)与 return-matches 业务码 401;服务日志无 panic/fatal/1146/1054。
|
||||
- 未验证:登录后采购管理、SYB 商品多页导出,以及采购员账号导出,待业务验收。两台采购手机在发布前(10-09 晚)已离线,与本次发布无关。
|
||||
- 回滚:Server/Web 成套把 current 切回 `/home/goauto/releases/20261009-e26743c-integrated` 并重启;无迁移,不需要处理数据库。
|
||||
|
||||
@@ -25,6 +25,46 @@ type AdminListRequest struct {
|
||||
ShopeeOrderNo string
|
||||
ShopeeOrderNos []string
|
||||
PDDOrderNo string
|
||||
// OrderSubmittedFrom/To are inclusive YYYY-MM-DD days (UTC+8, same as the
|
||||
// SYB products createdFrom/createdTo filter) over order_submitted_at.
|
||||
OrderSubmittedFrom string
|
||||
OrderSubmittedTo string
|
||||
}
|
||||
|
||||
// adminOrderDayLocation matches the SYB products page's createdFrom/createdTo
|
||||
// day boundary (sybimport.sybStockLocation) so both pages filter the same way.
|
||||
var adminOrderDayLocation = time.FixedZone("SYB UTC+8", 8*60*60)
|
||||
|
||||
// adminOrderSubmittedRange returns [start, endExclusive); a nil bound means
|
||||
// that side is open. Both days are inclusive for the caller.
|
||||
func adminOrderSubmittedRange(from, to string) (*time.Time, *time.Time, error) {
|
||||
from, to = strings.TrimSpace(from), strings.TrimSpace(to)
|
||||
parse := func(value, label string) (*time.Time, error) {
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, adminOrderDayLocation)
|
||||
if err != nil {
|
||||
return nil, fail(CodeInvalidRequest, label+" 必须是 YYYY-MM-DD")
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
start, err := parse(from, "orderSubmittedFrom")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
endDay, err := parse(to, "orderSubmittedTo")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if start != nil && endDay != nil && start.After(*endDay) {
|
||||
return nil, nil, fail(CodeInvalidRequest, "orderSubmittedFrom 不能晚于 orderSubmittedTo")
|
||||
}
|
||||
if endDay != nil {
|
||||
end := endDay.AddDate(0, 0, 1)
|
||||
endDay = &end
|
||||
}
|
||||
return start, endDay, nil
|
||||
}
|
||||
|
||||
type AdminTaskItem struct {
|
||||
@@ -175,7 +215,17 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if req.TaskType != "" && req.TaskType != models.PurchaseTaskTypeSYBOrder && req.TaskType != models.PurchaseTaskTypeStock {
|
||||
return AdminListResponse{}, fail(CodeInvalidRequest, "taskType 无效")
|
||||
}
|
||||
submittedFrom, submittedTo, err := adminOrderSubmittedRange(req.OrderSubmittedFrom, req.OrderSubmittedTo)
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
query := s.DB.WithContext(ctx).Model(&models.PurchaseTask{})
|
||||
if submittedFrom != nil {
|
||||
query = query.Where("order_submitted_at >= ?", *submittedFrom)
|
||||
}
|
||||
if submittedTo != nil {
|
||||
query = query.Where("order_submitted_at < ?", *submittedTo)
|
||||
}
|
||||
if req.TaskID > 0 {
|
||||
query = query.Where("id = ?", req.TaskID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
)
|
||||
|
||||
// #372: order_submitted_at day filter, inclusive on both ends, UTC+8 days.
|
||||
func TestAdminOrderSubmittedRange(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
|
||||
s := testService(db)
|
||||
base := createAdminQueryTask(t, s, f)
|
||||
loc := time.FixedZone("UTC+8", 8*3600)
|
||||
at := func(day string, h, m int) *time.Time {
|
||||
d, _ := time.ParseInLocation("2006-01-02", day, loc)
|
||||
v := d.Add(time.Duration(h)*time.Hour + time.Duration(m)*time.Minute)
|
||||
return &v
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
status string
|
||||
at *time.Time
|
||||
}{
|
||||
{"before", models.PurchaseTaskStatusOrderCreated, at("2026-10-01", 23, 59)},
|
||||
{"first-day-start", models.PurchaseTaskStatusOrderCreated, at("2026-10-02", 0, 0)},
|
||||
{"middle", models.PurchaseTaskStatusOrderCreated, at("2026-10-03", 12, 0)},
|
||||
{"last-day-end", models.PurchaseTaskStatusOrderCreated, at("2026-10-04", 23, 59)},
|
||||
{"after", models.PurchaseTaskStatusOrderCreated, at("2026-10-05", 0, 0)},
|
||||
{"middle-failed", models.PurchaseTaskStatusFailed, at("2026-10-03", 13, 0)},
|
||||
{"no-time", models.PurchaseTaskStatusOrderCreated, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
row := base
|
||||
row.ID = 0
|
||||
row.CreateRequestID = uuid.NewString()
|
||||
row.ShopeeOrderNoSnapshot = tc.name
|
||||
row.Status = tc.status
|
||||
row.OrderSubmittedAt = tc.at
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
names := func(req AdminListRequest) map[string]bool {
|
||||
t.Helper()
|
||||
out, err := s.AdminList(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, item := range out.Items {
|
||||
got[item.ShopeeOrderNoSnapshot] = true
|
||||
}
|
||||
if int(out.Total) != len(got) {
|
||||
t.Fatalf("total %d != items %d", out.Total, len(got))
|
||||
}
|
||||
return got
|
||||
}
|
||||
both := names(AdminListRequest{OrderSubmittedFrom: "2026-10-02", OrderSubmittedTo: "2026-10-04"})
|
||||
if len(both) != 4 || !both["first-day-start"] || !both["middle"] || !both["last-day-end"] || !both["middle-failed"] {
|
||||
t.Fatalf("both days must be inclusive: %v", both)
|
||||
}
|
||||
combined := names(AdminListRequest{OrderSubmittedFrom: "2026-10-02", OrderSubmittedTo: "2026-10-04", Status: models.PurchaseTaskStatusOrderCreated})
|
||||
if len(combined) != 3 || combined["middle-failed"] {
|
||||
t.Fatalf("must combine with status: %v", combined)
|
||||
}
|
||||
if only := names(AdminListRequest{OrderSubmittedFrom: "2026-10-04"}); len(only) != 2 || !only["last-day-end"] || !only["after"] {
|
||||
t.Fatalf("from only: %v", only)
|
||||
}
|
||||
if only := names(AdminListRequest{OrderSubmittedTo: "2026-10-02"}); len(only) != 2 || !only["before"] || !only["first-day-start"] {
|
||||
t.Fatalf("to only: %v", only)
|
||||
}
|
||||
if one := names(AdminListRequest{OrderSubmittedFrom: "2026-10-03", OrderSubmittedTo: "2026-10-03"}); len(one) != 2 {
|
||||
t.Fatalf("single day: %v", one)
|
||||
}
|
||||
if none := names(AdminListRequest{}); !none["no-time"] {
|
||||
t.Fatalf("no filter must keep rows without order time: %v", none)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrderSubmittedRangeInvalid(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := testDB(t)
|
||||
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
|
||||
s := testService(db)
|
||||
createAdminQueryTask(t, s, f)
|
||||
for _, req := range []AdminListRequest{
|
||||
{OrderSubmittedFrom: "2026/10/02"},
|
||||
{OrderSubmittedTo: "10-02"},
|
||||
{OrderSubmittedFrom: "2026-13-01"},
|
||||
{OrderSubmittedFrom: "2026-10-05", OrderSubmittedTo: "2026-10-04"},
|
||||
} {
|
||||
if _, err := s.AdminList(context.Background(), req); code(err) != CodeInvalidRequest {
|
||||
t.Fatalf("%+v: expected invalid request, got %v", req, err)
|
||||
}
|
||||
}
|
||||
for _, query := range []string{"orderSubmittedFrom=abc", "orderSubmittedTo=2026-02-30"} {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"})
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/v1/purchase-tasks?"+query, nil)
|
||||
(Handler{DB: db}).AdminList(c)
|
||||
if !strings.Contains(w.Body.String(), CodeInvalidRequest) {
|
||||
t.Fatalf("handler accepted %s: %s", query, w.Body.String())
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"})
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/v1/purchase-tasks?orderSubmittedFrom=2026-10-01&orderSubmittedTo=2026-10-02", nil)
|
||||
(Handler{DB: db}).AdminList(c)
|
||||
var result struct {
|
||||
Data AdminListResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil || w.Code != http.StatusOK {
|
||||
t.Fatalf("valid range: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,12 @@ func (h Handler) AdminList(c *gin.Context) {
|
||||
response, err := service.AdminList(c.Request.Context(), AdminListRequest{
|
||||
Page: page, PageSize: pageSize, TaskID: taskID, SYBProductID: sybProductID,
|
||||
Status: strings.TrimSpace(c.Query("status")), ExecutionMode: strings.TrimSpace(c.Query("executionMode")),
|
||||
TaskType: strings.TrimSpace(c.Query("taskType")),
|
||||
ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
|
||||
ShopeeOrderNos: c.Request.URL.Query()["shopeeOrderNos"],
|
||||
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
|
||||
TaskType: strings.TrimSpace(c.Query("taskType")),
|
||||
ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
|
||||
ShopeeOrderNos: c.Request.URL.Query()["shopeeOrderNos"],
|
||||
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
|
||||
OrderSubmittedFrom: c.Query("orderSubmittedFrom"),
|
||||
OrderSubmittedTo: c.Query("orderSubmittedTo"),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
|
||||
@@ -495,9 +495,12 @@ type ListFilter struct {
|
||||
// sn, spec) so it never has to issue a second request per row.
|
||||
type ListItem struct {
|
||||
models.ReturnMatch
|
||||
OrderSN string `json:"orderSn,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
VariationName string `json:"variationName,omitempty"`
|
||||
OrderSN string `json:"orderSn,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
VariationName string `json:"variationName,omitempty"`
|
||||
// YeekeQuantity is the yeeke return item's own quantity (read-only, #372
|
||||
// export); it comes from the same join and is never used for matching.
|
||||
YeekeQuantity int64 `json:"yeekeQuantity"`
|
||||
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
|
||||
// SyncStatus/MissingSince (#338) surface the yeeke return item's own
|
||||
// current availability ("ok"/"missing") so the SYB products match column
|
||||
@@ -538,6 +541,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
|
||||
OrderSN string
|
||||
Image string
|
||||
VariationName string
|
||||
Quantity int64
|
||||
DestroyDeadLine *time.Time
|
||||
SyncStatus string
|
||||
MissingSince *time.Time
|
||||
@@ -545,7 +549,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
|
||||
PackageMissingAt *time.Time `gorm:"column:package_missing_since"`
|
||||
}
|
||||
if err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i").
|
||||
Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line, "+
|
||||
Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, i.quantity AS quantity, p.destroy_dead_line AS destroy_dead_line, "+
|
||||
"i.sync_status AS sync_status, i.missing_since AS missing_since, p.sync_status AS package_sync_status, p.missing_since AS package_missing_since").
|
||||
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
|
||||
Where("i.id IN ?", returnIDs).
|
||||
@@ -556,6 +560,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
|
||||
OrderSN string
|
||||
Image string
|
||||
VariationName string
|
||||
Quantity int64
|
||||
DestroyDeadLine *time.Time
|
||||
SyncStatus string
|
||||
MissingSince *time.Time
|
||||
@@ -569,13 +574,14 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
|
||||
if j.PackageSyncStat != "ok" {
|
||||
syncStatus, missingSince = j.PackageSyncStat, j.PackageMissingAt
|
||||
}
|
||||
byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine, syncStatus, missingSince}
|
||||
byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.Quantity, j.DestroyDeadLine, syncStatus, missingSince}
|
||||
}
|
||||
for i := range items {
|
||||
if extra, ok := byID[items[i].YeekeReturnItemID]; ok {
|
||||
items[i].OrderSN = extra.OrderSN
|
||||
items[i].Image = extra.Image
|
||||
items[i].VariationName = extra.VariationName
|
||||
items[i].YeekeQuantity = extra.Quantity
|
||||
items[i].DestroyDeadline = extra.DestroyDeadLine
|
||||
items[i].SyncStatus = extra.SyncStatus
|
||||
items[i].MissingSince = extra.MissingSince
|
||||
|
||||
@@ -2,6 +2,7 @@ package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -484,3 +485,27 @@ func TestList_EnrichesWithYeekeReturnDisplayFields(t *testing.T) {
|
||||
t.Fatalf("expected enriched list item: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
// #372: List exposes the yeeke return item's quantity (read-only export field).
|
||||
func TestList_ExposesYeekeQuantity(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
syb := seedSYB(t, db, "SYB-Q", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
item := seedReturn(t, db, "白色,L", &deadline)
|
||||
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", item.ID).Update("quantity", 3).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}); err != nil || resp.MatchedCount != 1 {
|
||||
t.Fatalf("setup failed: %v %+v", err, resp)
|
||||
}
|
||||
items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}})
|
||||
if err != nil || len(items) != 1 || items[0].YeekeQuantity != 3 {
|
||||
t.Fatalf("expected yeekeQuantity 3: %+v %v", items, err)
|
||||
}
|
||||
raw, _ := json.Marshal(items[0])
|
||||
if !strings.Contains(string(raw), `"yeekeQuantity":3`) {
|
||||
t.Fatalf("json missing yeekeQuantity: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// #372: export every page of an admin list (current filters) to xlsx.
|
||||
// Pure helpers with injected dependencies so they can be unit tested without a
|
||||
// browser; the pages inject the API calls and the Export2Excel saver.
|
||||
|
||||
export const EXPORT_MAX_ROWS = 5000
|
||||
|
||||
const pad = (value, width = 2) => String(value).padStart(width, '0')
|
||||
|
||||
// YYYY-MM-DD HH:mm:ss in the browser's local time zone; empty for blank/invalid.
|
||||
export function formatExportTime(value) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
// YYYYMMDD-HHmmss for file names.
|
||||
export function formatExportStamp(date = new Date()) {
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
// Cents -> yuan text with two decimals; null/undefined/'' stay empty.
|
||||
export function centToYuan(cent) {
|
||||
if (cent === null || cent === undefined || cent === '') return ''
|
||||
const value = Number(cent)
|
||||
if (!Number.isFinite(value)) return ''
|
||||
const sign = value < 0 ? '-' : ''
|
||||
const abs = Math.abs(Math.round(value))
|
||||
return `${sign}${Math.floor(abs / 100)}.${pad(abs % 100)}`
|
||||
}
|
||||
|
||||
// Cents -> yuan number (1230 -> 12.3) for numeric Excel cells; null/undefined/''/NaN stay empty, 0 stays 0.
|
||||
export function centToYuanNumber(cent) {
|
||||
if (cent === null || cent === undefined || cent === '') return ''
|
||||
const value = Number(cent)
|
||||
if (!Number.isFinite(value)) return ''
|
||||
return Math.round(value) / 100
|
||||
}
|
||||
|
||||
// yeeke only has one variation_name ("color,size"); split at the first English
|
||||
// or Chinese comma. Without a comma the whole text is the color.
|
||||
export function splitYeekeVariation(name) {
|
||||
const text = String(name ?? '').trim()
|
||||
if (!text) return { color: '', size: '' }
|
||||
const index = text.search(/[,,]/)
|
||||
if (index < 0) return { color: text, size: '' }
|
||||
return { color: text.slice(0, index).trim(), size: text.slice(index + 1).trim() }
|
||||
}
|
||||
|
||||
const blank = value => (value === null || value === undefined ? '' : value)
|
||||
|
||||
// Column indexes written as numbers; the saver shows them as 0.00.
|
||||
export const PURCHASE_EXPORT_NUMBER_COLS = [8]
|
||||
export const SYB_PRODUCT_EXPORT_NUMBER_COLS = [2]
|
||||
|
||||
export const PURCHASE_EXPORT_HEADER = ['SYB订单号', '虾皮商品ID', '颜色', '尺码', '数量', '状态', 'PDD单号', '下单时间', '价格(元)']
|
||||
|
||||
// statusLabel: the page's own status -> Chinese label function.
|
||||
export function purchaseExportRow(task, statusLabel = value => value) {
|
||||
return [
|
||||
blank(task.shopeeOrderNoSnapshot), blank(task.shopeeItemIdSnapshot), blank(task.targetColorSnapshot), blank(task.targetSizeSnapshot),
|
||||
blank(task.quantity), task.status ? statusLabel(task.status) : '', blank(task.pddOrderNo),
|
||||
formatExportTime(task.orderSubmittedAt), centToYuanNumber(task.pddOrderAmountCent)
|
||||
]
|
||||
}
|
||||
|
||||
export const SYB_PRODUCT_EXPORT_HEADER = ['SYB订单', '虾皮商品ID', 'SYB售价(TWD)', 'SYB颜色', 'SYB尺码', 'SYB数量', 'yeeke颜色', 'yeeke尺码', 'yeeke数量', '匹配时间']
|
||||
|
||||
export const isActiveReturnMatch = match => Boolean(match) && (match.status === 'matched' || match.status === 'confirmed')
|
||||
|
||||
// match: the active (matched/confirmed) return match of this product, or null.
|
||||
export function sybProductExportRow(product, match) {
|
||||
const active = isActiveReturnMatch(match) ? match : null
|
||||
const yeeke = active ? splitYeekeVariation(active.variationName) : { color: '', size: '' }
|
||||
return [
|
||||
blank(product.orderCode), blank(product.shopeeItemId), centToYuanNumber(product.unitPriceCent), blank(product.targetColor), blank(product.targetSize), blank(product.quantity),
|
||||
yeeke.color, yeeke.size, active ? blank(active.yeekeQuantity) : '', active ? formatExportTime(active.matchedAt) : ''
|
||||
]
|
||||
}
|
||||
|
||||
export function exportFailureMessage(result, maxRows = EXPORT_MAX_ROWS) {
|
||||
switch (result.reason) {
|
||||
case 'empty': return '当前筛选没有数据,未生成文件'
|
||||
case 'too_many': return `共 ${result.total} 条,超过 ${maxRows} 条上限,请缩小筛选范围`
|
||||
case 'page_failed': return `导出失败:第 ${result.page} 页读取失败,未生成文件`
|
||||
case 'enrich_failed': return '导出失败:匹配信息读取失败,未生成文件'
|
||||
default: return '导出失败,未生成文件'
|
||||
}
|
||||
}
|
||||
|
||||
function chunk(items, size) {
|
||||
const result = []
|
||||
for (let i = 0; i < items.length; i += size) result.push(items.slice(i, i + size))
|
||||
return result
|
||||
}
|
||||
|
||||
// Reads page 1 for the total, refuses above maxRows, then reads the remaining
|
||||
// pages. Any failure returns { ok: false } and nothing is saved.
|
||||
// fetchPage(page, pageSize) -> { items, total }
|
||||
// enrich(items) -> decorated items of the same length (optional, per batch)
|
||||
// mapRow(item) -> array of cells
|
||||
// save({ header, data, filename }) -> writes the file (only after success)
|
||||
export async function runListExport({
|
||||
fetchPage, pageSize, maxRows = EXPORT_MAX_ROWS, enrich = null, enrichBatchSize = 200,
|
||||
header, mapRow, filename, save, numberFormatCols = [], onProgress = () => {}
|
||||
}) {
|
||||
let first
|
||||
try {
|
||||
first = await fetchPage(1, pageSize)
|
||||
} catch (error) {
|
||||
return { ok: false, reason: 'page_failed', page: 1, error }
|
||||
}
|
||||
const total = Number(first?.total) || 0
|
||||
if (total === 0) return { ok: false, reason: 'empty', total: 0 }
|
||||
if (total > maxRows) return { ok: false, reason: 'too_many', total }
|
||||
const pages = Math.ceil(total / pageSize)
|
||||
const enrichSteps = enrich ? Math.ceil(total / enrichBatchSize) : 0
|
||||
const steps = pages + enrichSteps
|
||||
let done = 1
|
||||
onProgress(done, steps)
|
||||
let items = [...(first.items || [])]
|
||||
for (let page = 2; page <= pages; page++) {
|
||||
let response
|
||||
try {
|
||||
response = await fetchPage(page, pageSize)
|
||||
} catch (error) {
|
||||
return { ok: false, reason: 'page_failed', page, error }
|
||||
}
|
||||
items = items.concat(response?.items || [])
|
||||
onProgress(++done, steps)
|
||||
}
|
||||
if (enrich) {
|
||||
const decorated = []
|
||||
for (const part of chunk(items, enrichBatchSize)) {
|
||||
try {
|
||||
decorated.push(...await enrich(part))
|
||||
} catch (error) {
|
||||
return { ok: false, reason: 'enrich_failed', error }
|
||||
}
|
||||
onProgress(++done, steps)
|
||||
}
|
||||
items = decorated
|
||||
}
|
||||
if (items.length === 0) return { ok: false, reason: 'empty', total: 0 }
|
||||
const data = items.map(mapRow)
|
||||
await save({ header, data, filename: `${filename}_${formatExportStamp()}`, numberFormatCols })
|
||||
return { ok: true, total, rows: data.length }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Loads the xlsx writer on demand so the list pages do not pull it into their chunk.
|
||||
export async function saveExcel(file) {
|
||||
const { export_json_to_excel: exportJsonToExcel } = await import('@/vendor/Export2Excel')
|
||||
exportJsonToExcel(file)
|
||||
}
|
||||
Vendored
+8
-3
@@ -85,7 +85,11 @@ function sheet_from_array_of_arrays(data, opts) {
|
||||
r: R
|
||||
});
|
||||
|
||||
if (typeof cell.v === 'number') cell.t = 'n';
|
||||
if (typeof cell.v === 'number') {
|
||||
cell.t = 'n';
|
||||
// optional display format for chosen columns (#372); unset keeps the old output
|
||||
if (opts && opts.numberFormatCols && opts.numberFormatCols.indexOf(C) >= 0) cell.z = '0.00';
|
||||
}
|
||||
else if (typeof cell.v === 'boolean') cell.t = 'b';
|
||||
else if (cell.v instanceof Date) {
|
||||
cell.t = 'n';
|
||||
@@ -151,7 +155,8 @@ export function export_json_to_excel({
|
||||
filename,
|
||||
merges = [],
|
||||
autoWidth = true,
|
||||
bookType = 'xlsx'
|
||||
bookType = 'xlsx',
|
||||
numberFormatCols = []
|
||||
} = {}) {
|
||||
/* original data */
|
||||
filename = filename || 'excel-list'
|
||||
@@ -164,7 +169,7 @@ export function export_json_to_excel({
|
||||
|
||||
var ws_name = "SheetJS";
|
||||
var wb = new Workbook(),
|
||||
ws = sheet_from_array_of_arrays(data);
|
||||
ws = sheet_from_array_of_arrays(data, { numberFormatCols: numberFormatCols });
|
||||
|
||||
if (merges.length > 0) {
|
||||
if (!ws['!merges']) ws['!merges'] = [];
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:160px"><el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item label="SYB 商品"><el-input v-model="query.sybProductId" class="syb-input" clearable placeholder="商品 ID" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="PDD 订单号"><el-input v-model="query.pddOrderNo" class="order-input" clearable placeholder="订单号" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" :icon="Search" :disabled="Boolean(orderFilter.error)" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||
<el-form-item label="下单日期"><el-date-picker v-model="query.orderSubmittedRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable style="width:260px" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" :icon="Search" :disabled="Boolean(orderFilter.error)" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button :disabled="exporting || Boolean(orderFilter.error)" @click="exportExcel">{{ exporting ? `导出中 ${exportProgress.done}/${exportProgress.steps}` : '导出' }}</el-button></el-form-item>
|
||||
</el-form>
|
||||
<div class="batch-actions"><span class="selected-copy">已选择 <strong>{{ retrySelection.length }}</strong> 条</span><el-button type="warning" :disabled="retryCandidates.length === 0" :loading="retryDialog.saving" @click="openRetryDialog">重试({{ retryCandidates.length }})</el-button><el-button type="primary" plain :disabled="orderWritebackCandidates.length === 0" :loading="orderWriteback.saving" @click="submitOrderWriteback(orderWritebackCandidates)">批量回填 SYB({{ orderWritebackCandidates.length }})</el-button></div>
|
||||
|
||||
@@ -154,6 +155,8 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import { saveExcel } from '@/utils/save-excel'
|
||||
import { EXPORT_MAX_ROWS, PURCHASE_EXPORT_HEADER, PURCHASE_EXPORT_NUMBER_COLS, exportFailureMessage, purchaseExportRow, runListExport } from '@/utils/list-export'
|
||||
import { listFailureSnapshots } from '@/api/goauto/purchase-failure-snapshots'
|
||||
import FailureSnapshotCell from './FailureSnapshotCell.vue'
|
||||
|
||||
@@ -169,9 +172,10 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
loading: false, loadError: '', tasks: [], total: 0, statuses: [...statusOptions, { value: 'syb_writeback_succeeded', label: '已回填 SYB' }],
|
||||
exporting: false, exportProgress: { done: 0, steps: 0 },
|
||||
detailSession: 0, snapshots: { items: {}, loading: false, error: false },
|
||||
orderWriteback: { open: false, saving: false, items: [] },
|
||||
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' },
|
||||
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] },
|
||||
detail: { open: false, loading: false, task: null, attempts: [], matching: null },
|
||||
matchingAction: { saving: false }, matchingDialog: { open: false, saving: false, color: '', size: '' },
|
||||
retrySelection: [],
|
||||
@@ -227,14 +231,34 @@ export default {
|
||||
try { const r = await listPurchaseTasks(this.cleanQuery()); this.tasks = r.data.items || []; this.total = r.data.total || 0; this.retrySelection = []; this.$nextTick(() => this.$refs.taskTable?.clearSelection()) } catch (error) { this.tasks = []; this.total = 0; this.loadError = error?.message || '采购任务加载失败,请稍后重试' } finally { this.loading = false }
|
||||
},
|
||||
cleanQuery() {
|
||||
const params = Object.fromEntries(Object.entries(this.query).filter(([key, value]) => key !== 'shopeeOrderNo' && value !== ''))
|
||||
const params = Object.fromEntries(Object.entries(this.query).filter(([key, value]) => key !== 'shopeeOrderNo' && key !== 'orderSubmittedRange' && value !== ''))
|
||||
const [orderSubmittedFrom, orderSubmittedTo] = this.query.orderSubmittedRange || []
|
||||
if (orderSubmittedFrom) params.orderSubmittedFrom = orderSubmittedFrom
|
||||
if (orderSubmittedTo) params.orderSubmittedTo = orderSubmittedTo
|
||||
const values = this.orderFilter.values
|
||||
if (values.length === 1) params.shopeeOrderNo = values[0]
|
||||
else if (values.length > 1) params.shopeeOrderNos = values
|
||||
return params
|
||||
},
|
||||
search() { this.query.page = 1; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] }; this.load() },
|
||||
// #372: export every page of the filters as they are at click time (params are copied once).
|
||||
async exportExcel() {
|
||||
if (this.exporting || this.orderFilter.error) return
|
||||
const filters = this.cleanQuery(); delete filters.page; delete filters.pageSize
|
||||
this.exporting = true; this.exportProgress = { done: 0, steps: 0 }
|
||||
try {
|
||||
const result = await runListExport({
|
||||
pageSize: 100, maxRows: EXPORT_MAX_ROWS, header: PURCHASE_EXPORT_HEADER, numberFormatCols: PURCHASE_EXPORT_NUMBER_COLS, filename: '采购导出',
|
||||
fetchPage: async(pageNo, size) => { const r = await listPurchaseTasks({ ...filters, page: pageNo, pageSize: size }); return { items: r.data.items || [], total: r.data.total || 0 } },
|
||||
mapRow: task => purchaseExportRow(task, this.statusLabel),
|
||||
save: saveExcel,
|
||||
onProgress: (done, steps) => { this.exportProgress = { done, steps } }
|
||||
})
|
||||
if (!result.ok) ElMessage.warning(exportFailureMessage(result, EXPORT_MAX_ROWS))
|
||||
else ElMessage.success(`已导出 ${result.rows} 条;导出期间数据变化可能有少量差异`)
|
||||
} catch (error) { ElMessage.error('导出失败,未生成文件') } finally { this.exporting = false }
|
||||
},
|
||||
clearSnapshots() { this.detailSession++; this.snapshots = { items: {}, loading: false, error: false } },
|
||||
async loadSnapshots() {
|
||||
if (!this.isSnapshotAdmin || !this.detail.open || !this.detail.task || this.snapshots.loading) return
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px" @change="onProcessStageChange"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item label="采购类型"><el-select v-model="query.purchaseType" style="width: 132px"><el-option label="需 PDD 采购" value="pdd" /><el-option label="无需 PDD 采购" value="excluded" /><el-option label="全部" value="all" /></el-select></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || matchDetail.saving || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnCancelButtonReason" :disabled="!returnCancelButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnCancelBatchLoading" :disabled="Boolean(returnCancelButtonReason)" @click="runBatchCancelReturns">取消匹配<span class="action-count">{{ returnCancelCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnConfirmButtonReason" :disabled="!returnConfirmButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :loading="returnConfirmBatchLoading" :disabled="Boolean(returnConfirmButtonReason)" @click="runBatchConfirmReturns">使用退货<span class="action-count">{{ returnConfirmCandidates.length }}</span></el-button></span></el-tooltip></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button :disabled="exporting" @click="exportExcel">{{ exporting ? `导出中 ${exportProgress.done}/${exportProgress.steps}` : '导出' }}</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || matchDetail.saving || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnCancelButtonReason" :disabled="!returnCancelButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnCancelBatchLoading" :disabled="Boolean(returnCancelButtonReason)" @click="runBatchCancelReturns">取消匹配<span class="action-count">{{ returnCancelCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnConfirmButtonReason" :disabled="!returnConfirmButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :loading="returnConfirmBatchLoading" :disabled="Boolean(returnConfirmButtonReason)" @click="runBatchConfirmReturns">使用退货<span class="action-count">{{ returnConfirmCandidates.length }}</span></el-button></span></el-tooltip></el-form-item>
|
||||
</div>
|
||||
<div class="search-row search-row-secondary">
|
||||
<el-form-item label="创建时间"><el-date-picker v-model="query.createdAtRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable /></el-form-item>
|
||||
@@ -395,6 +395,8 @@ import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks
|
||||
import { batchCreateCollectionTasks, batchCreateImageSearchCollectionTasks } from '@/api/goauto/collection-tasks'
|
||||
import { listCollectionRules } from '@/api/goauto/collection-rules'
|
||||
import { listReturnMatches, batchMatchReturns, getReturnMatch, confirmReturnMatch, cancelReturnMatch, remarkReturnMatch } from '@/api/goauto/return-matches'
|
||||
import { saveExcel } from '@/utils/save-excel'
|
||||
import { EXPORT_MAX_ROWS, SYB_PRODUCT_EXPORT_HEADER, SYB_PRODUCT_EXPORT_NUMBER_COLS, exportFailureMessage, isActiveReturnMatch, runListExport, sybProductExportRow } from '@/utils/list-export'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
|
||||
import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue'
|
||||
@@ -430,6 +432,8 @@ export default {
|
||||
collectionBatchData: { ruleId: null, deviceId: null },
|
||||
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
||||
query: { page: 1, pageSize: 200, shopIds: [], orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] },
|
||||
exporting: false,
|
||||
exportProgress: { done: 0, steps: 0 },
|
||||
returnMatchByProductId: {},
|
||||
returnMatchLoading: false,
|
||||
returnMatchBatchLoading: false,
|
||||
@@ -529,6 +533,34 @@ export default {
|
||||
}
|
||||
},
|
||||
search() { this.query.page = 1; this.load() },
|
||||
// #372: export every page of the filters as they are at click time (params are copied once);
|
||||
// return matches are read per batch of product ids, never per row.
|
||||
async exportExcel() {
|
||||
if (this.exporting) return
|
||||
const orderCodes = this.normalizedOrderCodes()
|
||||
if (orderCodes === null) return
|
||||
const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
|
||||
const filters = { shopIds: [...this.query.shopIds], orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, purchaseType: this.query.purchaseType, createdFrom, createdTo }
|
||||
this.exporting = true; this.exportProgress = { done: 0, steps: 0 }
|
||||
try {
|
||||
const result = await runListExport({
|
||||
pageSize: 500, maxRows: EXPORT_MAX_ROWS, header: SYB_PRODUCT_EXPORT_HEADER, numberFormatCols: SYB_PRODUCT_EXPORT_NUMBER_COLS, filename: 'SYB商品导出',
|
||||
fetchPage: async(page, size) => { const r = await listSybProducts({ ...filters, page, pageSize: size }, { suppressErrorMessage: true }); return { items: r.data.items || [], total: r.data.total || 0 } },
|
||||
enrichBatchSize: 200,
|
||||
enrich: async items => {
|
||||
const r = await listReturnMatches({ sybProductId: items.map(item => item.id) }, { suppressErrorMessage: true })
|
||||
const byProduct = {}
|
||||
for (const match of (r.data?.items || [])) if (isActiveReturnMatch(match) && !byProduct[match.sybProductId]) byProduct[match.sybProductId] = match
|
||||
return items.map(item => ({ item, match: byProduct[item.id] || null }))
|
||||
},
|
||||
mapRow: ({ item, match }) => sybProductExportRow(item, match),
|
||||
save: saveExcel,
|
||||
onProgress: (done, steps) => { this.exportProgress = { done, steps } }
|
||||
})
|
||||
if (!result.ok) ElMessage.warning(exportFailureMessage(result, EXPORT_MAX_ROWS))
|
||||
else ElMessage.success(`已导出 ${result.rows} 条;导出期间数据变化可能有少量差异`)
|
||||
} catch (error) { ElMessage.error('导出失败,未生成文件') } finally { this.exporting = false }
|
||||
},
|
||||
reset() { this.query = { page: 1, pageSize: 200, shopIds: [], orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] }; this.load() },
|
||||
onPagination() {
|
||||
// Both controls may emit after a size change clamps the old page. Coalesce
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../src/utils/list-export.js'), 'utf8')
|
||||
const lib = new Function(source.replace(/^export /gm, '') + `
|
||||
return { EXPORT_MAX_ROWS, formatExportTime, formatExportStamp, centToYuan, splitYeekeVariation, purchaseExportRow, PURCHASE_EXPORT_HEADER,
|
||||
sybProductExportRow, SYB_PRODUCT_EXPORT_HEADER, centToYuanNumber, PURCHASE_EXPORT_NUMBER_COLS, SYB_PRODUCT_EXPORT_NUMBER_COLS, exportFailureMessage, runListExport }`)()
|
||||
|
||||
test('time and stamp use local time, empty and invalid stay empty', () => {
|
||||
const date = new Date(2026, 9, 9, 8, 5, 3)
|
||||
assert.equal(lib.formatExportTime(date.toISOString()), '2026-10-09 08:05:03')
|
||||
assert.equal(lib.formatExportTime(null), '')
|
||||
assert.equal(lib.formatExportTime(''), '')
|
||||
assert.equal(lib.formatExportTime('not a date'), '')
|
||||
assert.equal(lib.formatExportStamp(date), '20261009-080503')
|
||||
})
|
||||
|
||||
test('cent to yuan keeps two decimals and leaves empty values empty', () => {
|
||||
assert.equal(lib.centToYuan(12345), '123.45')
|
||||
assert.equal(lib.centToYuan(5), '0.05')
|
||||
assert.equal(lib.centToYuan(1000), '10.00')
|
||||
assert.equal(lib.centToYuan(0), '0.00')
|
||||
for (const value of [null, undefined, '']) assert.equal(lib.centToYuan(value), '')
|
||||
})
|
||||
|
||||
test('yeeke variation splits at the first English or Chinese comma, none puts all in color', () => {
|
||||
assert.deepEqual(lib.splitYeekeVariation('白色,L【建議65-75公斤】'), { color: '白色', size: 'L【建議65-75公斤】' })
|
||||
assert.deepEqual(lib.splitYeekeVariation('黑色+白色,簡約,L'), { color: '黑色+白色', size: '簡約,L' })
|
||||
assert.deepEqual(lib.splitYeekeVariation('均碼'), { color: '均碼', size: '' })
|
||||
assert.deepEqual(lib.splitYeekeVariation(''), { color: '', size: '' })
|
||||
assert.deepEqual(lib.splitYeekeVariation(undefined), { color: '', size: '' })
|
||||
})
|
||||
|
||||
test('purchase row maps columns in order with status label, amount and blanks', () => {
|
||||
assert.equal(lib.PURCHASE_EXPORT_HEADER.length, 9)
|
||||
const row = lib.purchaseExportRow({
|
||||
shopeeOrderNoSnapshot: '001', shopeeItemIdSnapshot: '5020', targetColorSnapshot: '白色', targetSizeSnapshot: 'L', quantity: 2,
|
||||
status: 'order_created', pddOrderNo: 'PDD-1', orderSubmittedAt: new Date(2026, 9, 9, 1, 2, 3).toISOString(), pddOrderAmountCent: 3990,
|
||||
receiverName: 'must not leak', address: 'must not leak'
|
||||
}, value => ({ order_created: '订单已创建' })[value])
|
||||
assert.deepEqual(row, ['001', '5020', '白色', 'L', 2, '订单已创建', 'PDD-1', '2026-10-09 01:02:03', 39.9])
|
||||
assert.deepEqual(lib.purchaseExportRow({ quantity: 1 }), ['', '', '', '', 1, '', '', '', ''])
|
||||
assert.equal(lib.purchaseExportRow({ shopeeOrderNoSnapshot: 'A', status: 'failed', pddOrderAmountCent: null })[8], '')
|
||||
assert.equal(lib.purchaseExportRow({ pddOrderAmountCent: undefined })[8], '')
|
||||
assert.equal(typeof row[8], 'number')
|
||||
assert.equal(lib.purchaseExportRow({ pddOrderAmountCent: 0 })[8], 0)
|
||||
assert.deepEqual(lib.PURCHASE_EXPORT_NUMBER_COLS, [8])
|
||||
assert.equal(lib.PURCHASE_EXPORT_HEADER[8], '价格(元)')
|
||||
})
|
||||
|
||||
test('cent to yuan number is numeric, zero stays zero, empty stays empty', () => {
|
||||
assert.equal(lib.centToYuanNumber(1230), 12.3)
|
||||
assert.equal(lib.centToYuanNumber(0), 0)
|
||||
assert.equal(lib.centToYuanNumber('1230'), 12.3)
|
||||
for (const value of [null, undefined, '', 'abc']) assert.equal(lib.centToYuanNumber(value), '')
|
||||
})
|
||||
|
||||
test('syb product row uses only an active match and ignores cancelled ones', () => {
|
||||
const product = { orderCode: 'O1', shopeeItemId: '99', unitPriceCent: 1230, targetColor: '白色', targetSize: 'L', quantity: 1 }
|
||||
const matchedAt = new Date(2026, 9, 9, 9, 0, 0).toISOString()
|
||||
assert.deepEqual(lib.SYB_PRODUCT_EXPORT_HEADER, ['SYB订单', '虾皮商品ID', 'SYB售价(TWD)', 'SYB颜色', 'SYB尺码', 'SYB数量', 'yeeke颜色', 'yeeke尺码', 'yeeke数量', '匹配时间'])
|
||||
assert.deepEqual(lib.SYB_PRODUCT_EXPORT_NUMBER_COLS, [2])
|
||||
assert.deepEqual(lib.sybProductExportRow(product, { status: 'confirmed', variationName: '白色,L', yeekeQuantity: 3, matchedAt }),
|
||||
['O1', '99', 12.3, '白色', 'L', 1, '白色', 'L', 3, '2026-10-09 09:00:00'])
|
||||
assert.deepEqual(lib.sybProductExportRow(product, { status: 'matched', variationName: '均碼', yeekeQuantity: 1, matchedAt }).slice(6, 9), ['均碼', '', 1])
|
||||
assert.deepEqual(lib.sybProductExportRow(product, { status: 'cancelled', variationName: 'x', yeekeQuantity: 1, matchedAt }), ['O1', '99', 12.3, '白色', 'L', 1, '', '', '', ''])
|
||||
assert.deepEqual(lib.sybProductExportRow(product, null), ['O1', '99', 12.3, '白色', 'L', 1, '', '', '', ''])
|
||||
assert.equal(lib.sybProductExportRow({ ...product, unitPriceCent: 0 }, null)[2], 0)
|
||||
for (const value of [null, undefined]) assert.equal(lib.sybProductExportRow({ ...product, unitPriceCent: value }, null)[2], '')
|
||||
})
|
||||
|
||||
// Runs the real Export2Excel writer with file-saver stubbed and reads the xlsx back.
|
||||
function writeWorkbook(options) {
|
||||
const XLSX = require('xlsx')
|
||||
const src = fs.readFileSync(path.join(__dirname, '../../src/vendor/Export2Excel.js'), 'utf8')
|
||||
.replace(/^import .*$/gm, '').replace(/^export function/gm, 'function')
|
||||
let blobParts
|
||||
class FakeBlob { constructor(parts) { blobParts = parts } }
|
||||
new Function('XLSX', 'saveAs', 'Blob', 'opts', src + '\nexport_json_to_excel(opts)')(XLSX, () => {}, FakeBlob, options)
|
||||
return XLSX.read(blobParts[0], { type: 'array', cellNF: true }).Sheets.SheetJS
|
||||
}
|
||||
|
||||
test('excel writer makes numeric cells, 0.00 format only for requested columns', () => {
|
||||
const header = ['a', 'b']
|
||||
const data = [[12.3, 12.3], [0, 0], ['', '']]
|
||||
const withFmt = writeWorkbook({ header, data, filename: 't', numberFormatCols: [0] })
|
||||
assert.equal(withFmt.A2.t, 'n'); assert.equal(withFmt.A2.v, 12.3); assert.equal(withFmt.A2.z, '0.00')
|
||||
assert.equal(withFmt.A3.t, 'n'); assert.equal(withFmt.A3.v, 0)
|
||||
assert.equal(withFmt.B2.t, 'n'); assert.notEqual(withFmt.B2.z, '0.00')
|
||||
const plain = writeWorkbook({ header, data, filename: 't' })
|
||||
assert.equal(plain.A2.t, 'n'); assert.notEqual(plain.A2.z, '0.00')
|
||||
})
|
||||
|
||||
test('real export row types and format end up in the sheet', () => {
|
||||
const header = lib.SYB_PRODUCT_EXPORT_HEADER
|
||||
const rows = [
|
||||
lib.sybProductExportRow({ orderCode: 'O1', unitPriceCent: 1230 }, null),
|
||||
lib.sybProductExportRow({ orderCode: 'O2', unitPriceCent: 0 }, null),
|
||||
lib.sybProductExportRow({ orderCode: 'O3', unitPriceCent: null }, null)
|
||||
]
|
||||
const sheet = writeWorkbook({ header, data: rows, filename: 't', numberFormatCols: lib.SYB_PRODUCT_EXPORT_NUMBER_COLS })
|
||||
assert.equal(sheet.C1.v, 'SYB售价(TWD)')
|
||||
assert.equal(sheet.C2.t, 'n'); assert.equal(sheet.C2.v, 12.3); assert.equal(sheet.C2.z, '0.00')
|
||||
assert.equal(sheet.C3.t, 'n'); assert.equal(sheet.C3.v, 0)
|
||||
assert.equal(sheet.C4.v, ''); assert.notEqual(sheet.C4.t, 'n')
|
||||
})
|
||||
|
||||
function harness(total, options = {}) {
|
||||
const calls = []; const saved = []; const progress = []
|
||||
const pageSize = options.pageSize || 100
|
||||
const run = () => lib.runListExport({
|
||||
pageSize, header: ['id'], filename: '测试', mapRow: item => [item.id],
|
||||
fetchPage: async (page, size) => {
|
||||
calls.push(page)
|
||||
if (options.failPage === page) throw new Error('boom')
|
||||
const start = (page - 1) * size
|
||||
return { total, items: Array.from({ length: Math.max(0, Math.min(size, total - start)) }, (_, i) => ({ id: start + i + 1 })) }
|
||||
},
|
||||
save: async file => { saved.push(file) }, onProgress: (done, steps) => progress.push([done, steps]), ...options.extra
|
||||
})
|
||||
return { run, calls, saved, progress }
|
||||
}
|
||||
|
||||
test('reads every page, reports progress, saves once with a timestamped name', async () => {
|
||||
const h = harness(250)
|
||||
const result = await h.run()
|
||||
assert.deepEqual(result, { ok: true, total: 250, rows: 250 })
|
||||
assert.deepEqual(h.calls, [1, 2, 3])
|
||||
assert.deepEqual(h.progress, [[1, 3], [2, 3], [3, 3]])
|
||||
assert.equal(h.saved.length, 1)
|
||||
assert.equal(h.saved[0].data.length, 250)
|
||||
assert.deepEqual(h.saved[0].data[249], [250])
|
||||
assert.match(h.saved[0].filename, /^测试_\d{8}-\d{6}$/)
|
||||
})
|
||||
|
||||
test('exactly 5000 rows are allowed, 5001 is refused after reading only page 1', async () => {
|
||||
const ok = harness(5000, { pageSize: 500 })
|
||||
assert.equal((await ok.run()).ok, true)
|
||||
assert.equal(ok.calls.length, 10)
|
||||
const over = harness(5001, { pageSize: 500 })
|
||||
const result = await over.run()
|
||||
assert.deepEqual(result, { ok: false, reason: 'too_many', total: 5001 })
|
||||
assert.deepEqual(over.calls, [1])
|
||||
assert.equal(over.saved.length, 0)
|
||||
assert.equal(lib.exportFailureMessage(result), '共 5001 条,超过 5000 条上限,请缩小筛选范围')
|
||||
})
|
||||
|
||||
test('empty result does not save', async () => {
|
||||
const h = harness(0)
|
||||
const result = await h.run()
|
||||
assert.deepEqual(result, { ok: false, reason: 'empty', total: 0 })
|
||||
assert.equal(h.saved.length, 0)
|
||||
assert.match(lib.exportFailureMessage(result), /没有数据/)
|
||||
})
|
||||
|
||||
test('a failing page stops the export without saving', async () => {
|
||||
const h = harness(450, { failPage: 3 })
|
||||
const result = await h.run()
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.reason, 'page_failed')
|
||||
assert.equal(result.page, 3)
|
||||
assert.deepEqual(h.calls, [1, 2, 3])
|
||||
assert.equal(h.saved.length, 0)
|
||||
assert.equal(lib.exportFailureMessage(result), '导出失败:第 3 页读取失败,未生成文件')
|
||||
const first = harness(10, { failPage: 1 })
|
||||
assert.equal((await first.run()).page, 1)
|
||||
assert.equal(first.saved.length, 0)
|
||||
})
|
||||
|
||||
test('enrich runs per batch (not per row), counts in progress, and a failure saves nothing', async () => {
|
||||
const batches = []
|
||||
const h = harness(450, { pageSize: 200, extra: { enrichBatchSize: 200, enrich: async items => { batches.push(items.length); return items.map(item => ({ ...item, id: item.id * 10 })) } } })
|
||||
const result = await h.run()
|
||||
assert.equal(result.ok, true)
|
||||
assert.deepEqual(batches, [200, 200, 50])
|
||||
assert.deepEqual(h.saved[0].data[0], [10])
|
||||
assert.deepEqual(h.progress[h.progress.length - 1], [6, 6])
|
||||
const bad = harness(450, { pageSize: 200, extra: { enrich: async () => { throw new Error('boom') } } })
|
||||
const failed = await bad.run()
|
||||
assert.equal(failed.reason, 'enrich_failed')
|
||||
assert.equal(bad.saved.length, 0)
|
||||
})
|
||||
Reference in New Issue
Block a user