Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9619c38ae3 | ||
|
|
037459391a | ||
|
|
74b71e2b5a | ||
|
|
2f24b4b5b4 | ||
|
|
d8b4f3a9d5 | ||
|
|
23dafcf73f | ||
|
|
3d5e9646f6 | ||
|
|
e351a69912 | ||
|
|
db64a49e69 | ||
|
|
575d5f34cc | ||
|
|
4ad59bd231 | ||
|
|
d528c74c57 | ||
|
|
d8d9ab9edd | ||
|
|
919744ec05 | ||
|
|
72a15f8d9f | ||
|
|
b311885ea8 | ||
|
|
a4a793f449 |
+244
-21
@@ -69,6 +69,9 @@ interface PddCollectorDriver {
|
||||
reason = FreshClickReason.UNKNOWN,
|
||||
)
|
||||
fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode? = null): Boolean
|
||||
// Why the latest swipeSpec returned false (anchorMissing / anchorAmbiguous
|
||||
// mean the gesture anchor no longer resolves to exactly one node).
|
||||
fun specRowSwipeFailureReason(): String = "unknown"
|
||||
fun pullDownSpecPanel(anchor: SnapshotNode): Boolean = swipeSpec(SwipeDirection.DOWN, anchor)
|
||||
fun pullDownGoodsPage(): Boolean
|
||||
fun back(): Boolean
|
||||
@@ -1412,6 +1415,14 @@ class PddProductDetailCollector(
|
||||
// These signatures stay in memory; only the aggregate tri-state is saved.
|
||||
val diagnosticHorizontalRows = mutableMapOf<Set<String>, Boolean?>()
|
||||
var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND
|
||||
// Keep the earliest abnormal reason; later normal ends must not hide it.
|
||||
fun terminate(reason: AgentDiagnosticReason) {
|
||||
if (diagnosticTermination == AgentDiagnosticReason.COLOR_FOUND ||
|
||||
diagnosticTermination == AgentDiagnosticReason.COLOR_EDGE_REACHED
|
||||
) {
|
||||
diagnosticTermination = reason
|
||||
}
|
||||
}
|
||||
val imageAttempts = mutableSetOf<String>()
|
||||
|
||||
fun collectVisibleImages(values: List<VisibleSpecValue>) {
|
||||
@@ -1479,7 +1490,9 @@ class PddProductDetailCollector(
|
||||
}
|
||||
|
||||
moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it }
|
||||
moveColorsToStart(goodsId, config, evidence, deadline)?.let { return it }
|
||||
val colorStart = moveColorsToStart(goodsId, config, evidence, deadline)
|
||||
colorStart.failure?.let { return it }
|
||||
val colorStartConfirmed = colorStart.confirmed
|
||||
val initial = parse(goodsId, config, evidence)
|
||||
initial.problem?.let { return failure(it.code, it.message) }
|
||||
diagnosticInitialSelectedColors = initial.dimensions.filter { it.key == "color" }.flatMap { it.values }
|
||||
@@ -1530,6 +1543,7 @@ class PddProductDetailCollector(
|
||||
FreshActionResult.BLOCKED -> return sizeAdviceFailure()
|
||||
FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件")
|
||||
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> {
|
||||
traceColorClickMiss(screen, value, rows, clickResult)
|
||||
missing += "selection:${value.text}"
|
||||
continue
|
||||
}
|
||||
@@ -1561,7 +1575,18 @@ class PddProductDetailCollector(
|
||||
// established left/right paging behavior.
|
||||
if (rows.any { it.size > 1 }) {
|
||||
val rowSeeds = rows.map { row -> row.map(VisibleSpecValue::text).toSet() }
|
||||
// Frame-to-frame row tracking: rows are followed by overlap with
|
||||
// their previous frame, not by the pass's starting frame.
|
||||
val tracks = rows.map(::colorRowTrack).toMutableList()
|
||||
val coveredRows = mutableSetOf<Int>()
|
||||
var coveredIncomplete: Boolean? = null
|
||||
rowSeeds.forEachIndexed { rowIndex, seed ->
|
||||
if (rowIndex in coveredRows) {
|
||||
// A shared container already swept this row's values.
|
||||
diagnosticHorizontalRows[seed] = coveredIncomplete
|
||||
?: diagnosticHorizontalRows[seed]?.takeIf { it }
|
||||
return@forEachIndexed
|
||||
}
|
||||
val moveRight = rowIndex % 2 == 0
|
||||
val horizontalSignatureReads = mutableMapOf<List<String>, Int>()
|
||||
var diagnosticPreviousSignature: List<String>? = null
|
||||
@@ -1569,11 +1594,15 @@ class PddProductDetailCollector(
|
||||
var diagnosticMoved = false
|
||||
var diagnosticRowUncertain = false
|
||||
var diagnosticRowIncomplete: Boolean? = null
|
||||
var sharedMoves = 0
|
||||
var sharedBroken = tracks.size < 2
|
||||
var rowEnd = "limit"
|
||||
var rowSkipNote = "none"
|
||||
for (horizontalPass in 0..config.limits.getValue("specHorizontalSwipes")) {
|
||||
var diagnosticBeforeClicks: List<String>? = null
|
||||
collectVisibleColors { observedRows ->
|
||||
diagnosticBeforeClicks = observedRows.filter { row -> row.any { it.text in seed } }
|
||||
.singleOrNull()?.sortedBy { it.node.bounds.left }
|
||||
diagnosticBeforeClicks = matchColorRow(tracks[rowIndex], observedRows, tracks.size, requireUniqueOverlap = true)
|
||||
?.sortedBy { it.node.bounds.left }
|
||||
?.let { if (moveRight) it else it.reversed() }?.let(::optionSignature)
|
||||
}?.let { return it }
|
||||
screen = parse(goodsId, config, evidence)
|
||||
@@ -1581,11 +1610,37 @@ class PddProductDetailCollector(
|
||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
rows = colorRows(screen)
|
||||
observeColorDiscovery(screen, rows)
|
||||
val matchedRow = rows.maxByOrNull { row -> row.count { it.text in seed } }
|
||||
?.takeIf { row -> row.any { it.text in seed } }
|
||||
val matches = tracks.map { matchColorRow(it, rows, tracks.size) }
|
||||
val matchedRow = matches[rowIndex]
|
||||
val matchKind = colorRowMatchKind(tracks[rowIndex], rows, tracks.size)
|
||||
val rowMoved = matchedRow != null && positionSignature(matchedRow) != tracks[rowIndex].signature
|
||||
val othersDesc = tracks.indices.filter { it != rowIndex }.joinToString(",") { other ->
|
||||
val match = matches[other]
|
||||
when {
|
||||
match == null -> "none"
|
||||
positionSignature(match) != tracks[other].signature -> "moved"
|
||||
else -> "unchanged"
|
||||
}
|
||||
}
|
||||
if (rowMoved && matchedRow != null) {
|
||||
sharedMoves++
|
||||
// Position (text + bounds), not the text set: a partial
|
||||
// last swipe moves options without changing which are visible.
|
||||
val everyOtherRowMoved = tracks.indices.all { other ->
|
||||
other == rowIndex || matches[other]?.let { match ->
|
||||
positionSignature(match) != tracks[other].signature
|
||||
} == true
|
||||
}
|
||||
if (!everyOtherRowMoved) sharedBroken = true
|
||||
}
|
||||
matches.forEachIndexed { index, match ->
|
||||
match?.let { tracks[index] = colorRowTrack(it) }
|
||||
}
|
||||
if (matchedRow == null) {
|
||||
trace("color-row row=$rowIndex dir=${if (moveRight) "LEFT" else "RIGHT"} match=$matchKind moved=$rowMoved others=$othersDesc sharedMoves=$sharedMoves sharedBroken=$sharedBroken")
|
||||
rowEnd = "reflow"
|
||||
diagnosticHorizontalUnknown = true
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED
|
||||
terminate(AgentDiagnosticReason.COLOR_ROW_REFLOWED)
|
||||
break
|
||||
}
|
||||
val currentRow = matchedRow.sortedBy { it.node.bounds.left }
|
||||
@@ -1605,24 +1660,56 @@ class PddProductDetailCollector(
|
||||
diagnosticPreviousSignature = signature
|
||||
val signatureReads = (horizontalSignatureReads[signature] ?: 0) + 1
|
||||
horizontalSignatureReads[signature] = signatureReads
|
||||
trace("color-row row=$rowIndex dir=${if (moveRight) "LEFT" else "RIGHT"} match=$matchKind moved=$rowMoved others=$othersDesc sharedMoves=$sharedMoves sharedBroken=$sharedBroken uncertain=$diagnosticRowUncertain signatureReads=$signatureReads")
|
||||
if (signatureReads > config.limits.getValue("stableEdgeReads")) {
|
||||
rowEnd = "edge"
|
||||
if (!diagnosticRowUncertain && diagnosticStableReads >= config.limits.getValue("stableEdgeReads")) {
|
||||
diagnosticRowIncomplete = false
|
||||
}
|
||||
// The skip depends only on reaching this row's edge
|
||||
// with every other row having followed every move,
|
||||
// not on the diagnostic certainty: a click-induced
|
||||
// layout change must not force a redundant sweep.
|
||||
val rest = (rowIndex + 1 until tracks.size).toList()
|
||||
if (rest.isNotEmpty()) {
|
||||
// The reverse sweep is the only thing that recovers
|
||||
// values left of an unconfirmed start, so skipping it
|
||||
// needs a sweep that began at a confirmed left edge.
|
||||
val skipOrigin = when {
|
||||
!colorStartConfirmed -> "start_unconfirmed"
|
||||
verticalPass != 0 -> "later_vertical_pass"
|
||||
rowIndex != 0 -> "not_first_row"
|
||||
else -> null
|
||||
}
|
||||
if (skipOrigin != null) {
|
||||
rowSkipNote = "not_skipped reason=$skipOrigin"
|
||||
} else if (!sharedBroken && sharedMoves > 0) {
|
||||
coveredRows += rest
|
||||
coveredIncomplete = diagnosticRowIncomplete
|
||||
rowSkipNote = "skipped=${rest.joinToString(",")}"
|
||||
} else {
|
||||
rowSkipNote = "not_skipped reason=${if (sharedMoves == 0) "no_shared_moves" else "shared_broken"}"
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) {
|
||||
rowEnd = "limit"
|
||||
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
|
||||
break
|
||||
}
|
||||
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
|
||||
if (!driver.swipeSpec(direction, currentRow.first().node)) {
|
||||
rowEnd = "swipe_failed"
|
||||
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
|
||||
break
|
||||
}
|
||||
diagnosticHorizontalSwipes++
|
||||
pause(350)
|
||||
lastHorizontalSwipeDoneAt = now()
|
||||
settleColorGrid(goodsId, config, evidence)
|
||||
}
|
||||
trace("color-row-end row=$rowIndex end=$rowEnd skip=$rowSkipNote")
|
||||
// An uncertain revisit cannot erase previously observed
|
||||
// unresolved movement; only reliable stability clears it.
|
||||
diagnosticHorizontalRows[seed] = diagnosticRowIncomplete
|
||||
@@ -1640,7 +1727,7 @@ class PddProductDetailCollector(
|
||||
diagnosticTrailingEmptyReads = if (rows.isEmpty()) diagnosticTrailingEmptyReads + 1 else 0
|
||||
if (rows.isEmpty()) diagnosticHorizontalUnknown = true
|
||||
if (!discoverVertically) {
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
|
||||
terminate(AgentDiagnosticReason.COLOR_EDGE_REACHED)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1652,21 +1739,21 @@ class PddProductDetailCollector(
|
||||
}
|
||||
previousVerticalSignature = verticalSignature
|
||||
if (verticalStable >= config.limits.getValue("stableEdgeReads")) {
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
|
||||
terminate(AgentDiagnosticReason.COLOR_EDGE_REACHED)
|
||||
break
|
||||
}
|
||||
if (verticalPass == config.limits.getValue("specVerticalSwipes")) {
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_SCAN_LIMIT
|
||||
terminate(AgentDiagnosticReason.COLOR_SCAN_LIMIT)
|
||||
break
|
||||
}
|
||||
val anchor = rows.flatten().firstOrNull()?.node ?: specPanelContainer
|
||||
if (anchor == null) {
|
||||
diagnosticHorizontalUnknown = true
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE
|
||||
terminate(AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE)
|
||||
break
|
||||
}
|
||||
if (!driver.swipeSpec(SwipeDirection.UP, anchor)) {
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED
|
||||
terminate(AgentDiagnosticReason.COLOR_SWIPE_FAILED)
|
||||
break
|
||||
}
|
||||
diagnosticVerticalSwipes++
|
||||
@@ -1678,6 +1765,60 @@ class PddProductDetailCollector(
|
||||
|
||||
private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口")
|
||||
|
||||
private var lastHorizontalSwipeDoneAt = -1L
|
||||
|
||||
/**
|
||||
* After a horizontal swipe the grid may still be coasting, so positions
|
||||
* parsed now can differ from the ones at click time. Re-read the color rows
|
||||
* until two adjacent reads agree (text + bounds), at most four more reads
|
||||
* 200ms apart. Never fails the task and never clicks.
|
||||
*/
|
||||
private fun settleColorGrid(goodsId: String, config: PddCollectorConfig, evidence: PageEvidence) {
|
||||
val startedAt = now()
|
||||
fun read(): List<String>? {
|
||||
val screen = parse(goodsId, config, evidence)
|
||||
if (screen.problem != null || !screen.pageEvidenceMatched) return null
|
||||
return optionSignature(colorRows(screen).flatten())
|
||||
}
|
||||
var previous = read()
|
||||
var reads = 0
|
||||
var stable = false
|
||||
while (previous != null && reads < 4) {
|
||||
pause(200)
|
||||
reads++
|
||||
val current = read() ?: break
|
||||
if (current == previous) {
|
||||
stable = true
|
||||
break
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
trace("color-settle reads=$reads stable=$stable waitedMs=${now() - startedAt}")
|
||||
}
|
||||
|
||||
private fun traceColorClickMiss(screen: ParsedPddScreen, value: VisibleSpecValue, rows: List<List<VisibleSpecValue>>, result: FreshActionResult) {
|
||||
val rowIndex = rows.indexOfFirst { row -> row.any { it.text == value.text } }
|
||||
val column = rows.getOrNull(rowIndex).orEmpty().sortedBy { it.node.bounds.left }.indexOfFirst { it.text == value.text }
|
||||
val bounds = value.node.bounds
|
||||
val width = bounds.right - bounds.left
|
||||
val byPath = screen.sourceNodes.associateBy(SnapshotNode::path)
|
||||
var container: SnapshotNode? = byPath[value.node.parentPath]
|
||||
var hops = 0
|
||||
while (container != null && !container.scrollable && hops < 6) {
|
||||
container = container.parentPath?.let { byPath[it] }
|
||||
hops++
|
||||
}
|
||||
val exposed = if (width > 0 && container != null) {
|
||||
val shown = (minOf(bounds.right, container.bounds.right) - maxOf(bounds.left, container.bounds.left)).coerceAtLeast(0)
|
||||
"${shown * 100 / width}%"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
val containerText = container?.let { "${it.bounds}" } ?: "unknown"
|
||||
val sinceSwipe = if (lastHorizontalSwipeDoneAt < 0) "none" else "${now() - lastHorizontalSwipeDoneAt}ms"
|
||||
trace("color-click-miss color=${traceLabel(value.text)} result=$result row=$rowIndex column=$column bounds=$bounds container=$containerText exposed=$exposed sinceSwipe=$sinceSwipe")
|
||||
}
|
||||
|
||||
private fun clickSpecTarget(target: SnapshotNode, stage: AgentDiagnosticStage): FreshActionResult {
|
||||
val outcome = driver.clickFreshDetailed(target)
|
||||
if (taskId > 0) runCatching { diagnostic(specClickDiagnostic(taskId, stage, target, outcome)) }
|
||||
@@ -1837,31 +1978,66 @@ class PddProductDetailCollector(
|
||||
return null
|
||||
}
|
||||
|
||||
private class ColorStart(val failure: PddCollectorResult?, val confirmed: Boolean)
|
||||
|
||||
private fun moveColorsToStart(
|
||||
goodsId: String,
|
||||
config: PddCollectorConfig,
|
||||
evidence: PageEvidence,
|
||||
deadline: Long,
|
||||
): PddCollectorResult? {
|
||||
): ColorStart {
|
||||
var previous = emptyList<String>()
|
||||
var stable = 0
|
||||
repeat(config.limits.getValue("specHorizontalSwipes")) {
|
||||
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
|
||||
var swipes = 0
|
||||
var anchorRetries = 0
|
||||
var anchorFailures = 0
|
||||
var confirmed = false
|
||||
var end = "limit"
|
||||
for (attempt in 0 until config.limits.getValue("specHorizontalSwipes")) {
|
||||
if (now() > deadline) return ColorStart(failure("RULE_NOT_MATCHED", "采集超过规则总超时"), false)
|
||||
val screen = parse(goodsId, config, evidence)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
screen.problem?.let { return ColorStart(failure(it.code, it.message), false) }
|
||||
if (!screen.pageEvidenceMatched) return ColorStart(failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页"), false)
|
||||
val rows = colorRows(screen)
|
||||
if (rows.size > 1 && rows.all { it.size == 1 }) return null
|
||||
if (rows.size > 1 && rows.all { it.size == 1 }) {
|
||||
end = "single_column"
|
||||
break
|
||||
}
|
||||
val values = rows.flatten()
|
||||
val signature = optionSignature(values)
|
||||
stable = if (signature == previous) stable + 1 else 0
|
||||
previous = signature
|
||||
if (stable >= config.limits.getValue("stableEdgeReads")) return null
|
||||
val anchor = values.firstOrNull()?.node ?: return null
|
||||
if (!driver.swipeSpec(SwipeDirection.RIGHT, anchor)) return null
|
||||
if (stable >= config.limits.getValue("stableEdgeReads")) {
|
||||
confirmed = true
|
||||
end = "stable"
|
||||
break
|
||||
}
|
||||
// After an anchor failure, try a different color node of this frame.
|
||||
val anchor = values.getOrNull(anchorFailures)?.node ?: values.firstOrNull()?.node
|
||||
if (anchor == null) {
|
||||
end = "no_anchor"
|
||||
break
|
||||
}
|
||||
if (!driver.swipeSpec(SwipeDirection.RIGHT, anchor)) {
|
||||
val reason = driver.specRowSwipeFailureReason()
|
||||
anchorFailures++
|
||||
// A failed gesture says nothing about the position, so the next
|
||||
// equal read must not count as stability.
|
||||
previous = emptyList()
|
||||
stable = 0
|
||||
if ((reason == "anchorMissing" || reason == "anchorAmbiguous") && anchorFailures <= 2) {
|
||||
anchorRetries++
|
||||
continue
|
||||
}
|
||||
end = "swipe_failed"
|
||||
break
|
||||
}
|
||||
anchorFailures = 0
|
||||
swipes++
|
||||
pause(350)
|
||||
}
|
||||
return null
|
||||
trace("color-start confirmed=$confirmed swipes=$swipes anchorRetries=$anchorRetries end=$end")
|
||||
return ColorStart(null, confirmed)
|
||||
}
|
||||
|
||||
private fun collectSizes(
|
||||
@@ -2219,6 +2395,53 @@ class PddProductDetailCollector(
|
||||
const val COLOR_IMAGE_MAX_TOTAL_BYTES = 8 * 1024 * 1024
|
||||
}
|
||||
|
||||
/** Last observed frame of one color row, used to follow it across horizontal swipes. */
|
||||
private class ColorRowTrack(val texts: Set<String>, val centerY: Int, val tolerance: Int, val signature: List<String>)
|
||||
|
||||
private fun colorRowTrack(row: List<VisibleSpecValue>) = ColorRowTrack(
|
||||
row.map(VisibleSpecValue::text).toSet(),
|
||||
row.map { it.node.bounds.centerY }.average().toInt(),
|
||||
(row.first().node.bounds.height / 2).coerceIn(24, 80),
|
||||
positionSignature(row),
|
||||
)
|
||||
|
||||
private fun colorRowMatchKind(
|
||||
track: ColorRowTrack,
|
||||
rows: List<List<VisibleSpecValue>>,
|
||||
trackedRowCount: Int,
|
||||
): String = when {
|
||||
rows.any { row -> row.any { it.text in track.texts } } -> "overlap"
|
||||
matchColorRow(track, rows, trackedRowCount) != null -> "position"
|
||||
else -> "none"
|
||||
}
|
||||
|
||||
private fun positionSignature(row: List<VisibleSpecValue>): List<String> =
|
||||
optionSignature(row.sortedBy { it.node.bounds.left })
|
||||
|
||||
/**
|
||||
* Finds the current-frame row for a tracked row. Overlap with the previous
|
||||
* frame wins. With no overlap (a whole-page move) a multi-row grid whose
|
||||
* row count is unchanged may match by vertical position, but only when
|
||||
* exactly one current row sits within the colorRows tolerance. Anything
|
||||
* else is not the same row and stays a reflow.
|
||||
*/
|
||||
private fun matchColorRow(
|
||||
track: ColorRowTrack,
|
||||
rows: List<List<VisibleSpecValue>>,
|
||||
trackedRowCount: Int,
|
||||
requireUniqueOverlap: Boolean = false,
|
||||
): List<VisibleSpecValue>? {
|
||||
val overlapping = rows.filter { row -> row.any { it.text in track.texts } }
|
||||
if (overlapping.isNotEmpty()) {
|
||||
if (requireUniqueOverlap && overlapping.size > 1) return null
|
||||
return overlapping.maxByOrNull { row -> row.count { it.text in track.texts } }
|
||||
}
|
||||
if (trackedRowCount < 2 || rows.size != trackedRowCount) return null
|
||||
return rows.filter { row ->
|
||||
kotlin.math.abs(row.map { it.node.bounds.centerY }.average().toInt() - track.centerY) <= track.tolerance
|
||||
}.singleOrNull()
|
||||
}
|
||||
|
||||
private fun colorRows(screen: ParsedPddScreen): List<List<VisibleSpecValue>> {
|
||||
val values = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }
|
||||
.sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left }))
|
||||
|
||||
+375
-11
@@ -9,6 +9,7 @@ import cn.ilapage.goauto.agent.automation.NodeSelector
|
||||
import cn.ilapage.goauto.agent.automation.PageEvidence
|
||||
import cn.ilapage.goauto.agent.automation.PddCollectorConfig
|
||||
import cn.ilapage.goauto.agent.automation.PddCollectorDriver
|
||||
import cn.ilapage.goauto.agent.automation.PddCollectorResult
|
||||
import cn.ilapage.goauto.agent.automation.PddProductDetailCollector
|
||||
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchasePanelContext
|
||||
@@ -2092,6 +2093,288 @@ class PddProductDetailCollectorTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun gridColors(count: Int) = (1..count).map { "款%02d色".format(it) }
|
||||
|
||||
private fun gridConfig() = config().copy(
|
||||
timeoutsMs = config().timeoutsMs + ("overall" to 600_000),
|
||||
limits = config().limits + mapOf("specHorizontalSwipes" to 12, "specVerticalSwipes" to 3, "stableEdgeReads" to 2),
|
||||
)
|
||||
|
||||
private fun sharedGridCollects47Colors(step: Int) {
|
||||
// 47 colors in 2 rows x 24 columns sharing one horizontal container.
|
||||
val colors = gridColors(47)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
assertEquals(listOf(24, 23), grid.map { it.size })
|
||||
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
prices = prices,
|
||||
horizontalGrid = grid,
|
||||
gridVisibleColumns = 4,
|
||||
gridStep = step,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed", payload.status)
|
||||
assertEquals(colors.toSet(), payload.dimensions.first { it.key == "color" }.values.toSet())
|
||||
assertEquals(47, payload.colorPrices.size)
|
||||
assertEquals(prices, payload.colorPrices.associate { it.color to it.priceCent })
|
||||
// 47 colors x 2 sizes, each SKU carrying its own color's price.
|
||||
assertEquals(94, payload.skus.size)
|
||||
assertEquals(setOf("S", "M"), payload.skus.map { it.specs.getValue("size") }.toSet())
|
||||
payload.skus.forEach { assertEquals(prices.getValue(it.specs.getValue("color")), it.priceCent) }
|
||||
assertTrue(payload.missing.isEmpty())
|
||||
// The shared container is swept once: no reverse sweep away from an edge.
|
||||
assertTrue(driver.gridSwipeLog.none { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
assertTrue(driver.gridSwipeLog.count { it.first == SwipeDirection.LEFT } <= 12)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(47, event.clickableColorCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared container two row grid collects all colors when swipes overlap`() = sharedGridCollects47Colors(step = 3)
|
||||
|
||||
@Test
|
||||
fun `shared container two row grid collects all colors when swipes move whole pages`() = sharedGridCollects47Colors(step = 4)
|
||||
|
||||
private fun sharedPixelGridCollects(count: Int, step: Int, maxSwipes: Int, jitter: Boolean = false) {
|
||||
// Shared container, ~3.5 columns per screen, pixel-sized swipes whose
|
||||
// last move is shorter than one column; partly visible columns count.
|
||||
val colors = gridColors(count)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
prices = prices,
|
||||
horizontalGrid = grid,
|
||||
gridPixelStep = step,
|
||||
gridClickJitter = jitter,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed", payload.status)
|
||||
assertEquals(colors.toSet(), payload.dimensions.first { it.key == "color" }.values.toSet())
|
||||
assertEquals(prices, payload.colorPrices.associate { it.color to it.priceCent })
|
||||
assertTrue(payload.missing.isEmpty())
|
||||
assertTrue(driver.gridSwipeLog.none { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
val horizontal = driver.gridSwipeLog.size
|
||||
assertTrue("horizontal swipes $horizontal > $maxSwipes ${driver.gridSwipeLog}", horizontal <= maxSwipes)
|
||||
assertEquals(count, payload.colorPrices.size)
|
||||
assertEquals(count * 2, payload.skus.size)
|
||||
payload.skus.forEach { assertEquals(prices.getValue(it.specs.getValue("color")), it.priceCent) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared container with click layout jitter still skips the reverse sweep`() =
|
||||
sharedPixelGridCollects(count = 47, step = 640, maxSwipes = 12, jitter = true)
|
||||
|
||||
@Test
|
||||
fun `shared container nine colors with click layout jitter does not sweep back`() =
|
||||
sharedPixelGridCollects(count = 9, step = 200, maxSwipes = 6, jitter = true)
|
||||
|
||||
private fun midStartGrid(failAttempts: Set<Int>): Pair<FakeCollectorDriver, PddCollectorResult> {
|
||||
val colors = gridColors(47)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
prices = prices,
|
||||
horizontalGrid = grid,
|
||||
gridPixelStep = 640,
|
||||
gridInitialOffset = 2000,
|
||||
gridRightFailAttempts = failAttempts,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
return driver to result
|
||||
}
|
||||
|
||||
private fun assertAll47Collected(result: PddCollectorResult) {
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed", payload.status)
|
||||
val collected = payload.dimensions.first { it.key == "color" }.values
|
||||
assertEquals("collected ${collected.size}/47 colors", gridColors(47).toSet(), collected.toSet())
|
||||
assertEquals(47, payload.colorPrices.size)
|
||||
assertTrue(payload.missing.isEmpty())
|
||||
}
|
||||
|
||||
private class SettleRun(val driver: FakeCollectorDriver, val result: PddCollectorResult, val traces: List<String>)
|
||||
|
||||
private fun inertiaGrid(inertia: Int): SettleRun {
|
||||
val colors = gridColors(47)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
prices = prices,
|
||||
horizontalGrid = grid,
|
||||
gridPixelStep = 640,
|
||||
gridInertiaCaptures = inertia,
|
||||
gridStrictClickPosition = true,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
val traces = mutableListOf<String>()
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add, taskId = 370)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
return SettleRun(driver, result, traces)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color grid is settled after a horizontal swipe so coasting never misses a click`() {
|
||||
val calm = inertiaGrid(inertia = 0)
|
||||
val coasting = inertiaGrid(inertia = 2)
|
||||
|
||||
assertAll47Collected(coasting.result)
|
||||
assertTrue(coasting.traces.none { it.startsWith("color-click-miss") })
|
||||
// Waiting adds no clicks and never exceeds four extra reads.
|
||||
assertEquals(calm.driver.clickCount, coasting.driver.clickCount)
|
||||
val settles = coasting.traces.filter { it.startsWith("color-settle") }
|
||||
assertTrue(settles.isNotEmpty())
|
||||
settles.forEach { line ->
|
||||
val reads = Regex("reads=([0-9]+)").find(line)!!.groupValues[1].toInt()
|
||||
assertTrue(line, reads <= 4)
|
||||
}
|
||||
assertTrue(settles.any { it.contains("stable=true") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mid start with one anchor failure while resetting retries and still skips the reverse sweep`() {
|
||||
val (driver, result) = midStartGrid(failAttempts = setOf(2))
|
||||
|
||||
assertAll47Collected(result)
|
||||
// The retry used a different color node than the failed attempt.
|
||||
val rights = driver.swipes.filter { it.first == SwipeDirection.RIGHT }
|
||||
assertTrue(rights[0].second?.label != rights[1].second?.label)
|
||||
// Reset reached the left edge, then the sweep ran left-to-right once.
|
||||
val lastRightWithOffset = driver.gridSwipeLog.indexOfLast { it.first == SwipeDirection.RIGHT && it.second > 0 }
|
||||
val firstLeft = driver.gridSwipeLog.indexOfFirst { it.first == SwipeDirection.LEFT }
|
||||
assertTrue("no reverse sweep after the first LEFT", lastRightWithOffset < firstLeft)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mid start whose reset cannot be confirmed keeps the full snake sweep`() {
|
||||
val (driver, result) = midStartGrid(failAttempts = setOf(1, 2, 3))
|
||||
|
||||
assertAll47Collected(result)
|
||||
// Reverse sweep from the right edge happened after the LEFT sweep.
|
||||
val firstLeft = driver.gridSwipeLog.indexOfFirst { it.first == SwipeDirection.LEFT }
|
||||
assertTrue(driver.gridSwipeLog.drop(firstLeft).any { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared container with a row that fails to follow keeps the full snake sweep`() {
|
||||
val colors = gridColors(47)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
horizontalGrid = grid,
|
||||
gridPixelStep = 640,
|
||||
gridSwipeWhereOnlyFirstRowMoves = 3,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertTrue(driver.gridSwipeLog.any { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared container nine colors with partial last swipe does not sweep back`() =
|
||||
sharedPixelGridCollects(count = 9, step = 200, maxSwipes = 6)
|
||||
|
||||
@Test
|
||||
fun `shared container forty seven colors with partial last swipe stays within twelve swipes`() =
|
||||
sharedPixelGridCollects(count = 47, step = 640, maxSwipes = 12)
|
||||
|
||||
@Test
|
||||
fun `independent row grid keeps the snake sweep and does not claim a shared container`() {
|
||||
val colors = gridColors(20)
|
||||
val grid = listOf(colors.take(10), colors.drop(10))
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
horizontalGrid = grid,
|
||||
gridVisibleColumns = 4,
|
||||
gridStep = 3,
|
||||
gridSharedContainer = false,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val collected = requireNotNull(result.payload).dimensions.first { it.key == "color" }.values
|
||||
// Row 0 is swept to its end; row 1 only exposes its first window because
|
||||
// it starts at its own left edge, exactly as before.
|
||||
assertEquals((colors.take(10) + colors.drop(10).take(4)).toSet(), collected.toSet())
|
||||
assertEquals(14, collected.size)
|
||||
assertTrue(driver.gridSwipeLog.any { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `row disappearing keeps reflow reason after later empty vertical reads`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色", "E色", "F色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
colorPages = listOf(colors.take(4), colors.takeLast(2)),
|
||||
rowSize = 2,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(AgentDiagnosticReason.COLOR_ROW_REFLOWED, event.reason)
|
||||
assertTrue(requireNotNull(event.colorTrailingEmptyReadCount) > 0)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single row disjoint reflow keeps reflow reason`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2))),
|
||||
{ clock }, { clock += it }, taskId = 370, diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(AgentDiagnosticReason.COLOR_ROW_REFLOWED, events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }.reason)
|
||||
}
|
||||
|
||||
private class FakeCollectorDriver(
|
||||
colors: List<String> = listOf("红色"),
|
||||
sizes: List<String> = listOf("S"),
|
||||
@@ -2146,6 +2429,32 @@ class PddProductDetailCollectorTest {
|
||||
// restored to top. Heading count drops but color/size values are
|
||||
// untouched, so this must NOT be treated as a collapsed panel.
|
||||
private val specPanelDropsExtraDimensionAfterTopSwipe: Boolean = false,
|
||||
// #370: horizontal multi-row color grid. Each swipe moves gridStep
|
||||
// columns; gridSharedContainer moves all rows together, otherwise only
|
||||
// the anchored row. Left/right swipes are logged with the largest
|
||||
// column offset before the gesture.
|
||||
private val horizontalGrid: List<List<String>>? = null,
|
||||
private val gridVisibleColumns: Int = 4,
|
||||
private val gridStep: Int = 3,
|
||||
private val gridSharedContainer: Boolean = true,
|
||||
// #370: when set, shared-container swipes move this many pixels (not whole
|
||||
// columns) and columns partly inside the 835px viewport count as visible.
|
||||
private val gridPixelStep: Int? = null,
|
||||
// #370: after each click the grid nodes shift a few pixels, like the
|
||||
// small layout change seen on a real device after selecting a color.
|
||||
private val gridClickJitter: Boolean = false,
|
||||
// #370: on this (1-based) horizontal swipe only the first row moves.
|
||||
private val gridSwipeWhereOnlyFirstRowMoves: Int = 0,
|
||||
// #370: the shared grid starts scrolled this many pixels to the right.
|
||||
private val gridInitialOffset: Int = 0,
|
||||
// #370: 1-based RIGHT swipe attempts that fail with anchorMissing.
|
||||
private val gridRightFailAttempts: Set<Int> = emptySet(),
|
||||
// #370: after a horizontal swipe that moved the grid, the next N captures
|
||||
// still show it coasting (nodes drift 30px per remaining capture).
|
||||
private val gridInertiaCaptures: Int = 0,
|
||||
// #370: a click is NOT_FOUND when the node is not where the last capture
|
||||
// placed it (position at click time differs from the parsed target).
|
||||
private val gridStrictClickPosition: Boolean = false,
|
||||
) : PddCollectorDriver {
|
||||
var captureCount = 0
|
||||
var clickCount = 0
|
||||
@@ -2155,6 +2464,12 @@ class PddProductDetailCollectorTest {
|
||||
var backCount = 0
|
||||
var entryClickCount = 0
|
||||
var restoreGestures = 0
|
||||
val gridSwipeLog = mutableListOf<Pair<SwipeDirection, Int>>()
|
||||
private val gridOffsets = IntArray(horizontalGrid?.size ?: 0) { gridInitialOffset }
|
||||
private var rightAttempts = 0
|
||||
private var inertiaRemaining = 0
|
||||
private var currentDrift = 0
|
||||
private var lastSwipeFailure = "none"
|
||||
private var selected: String? = initialSelectedColor
|
||||
private var previousSelected: String? = null
|
||||
private var horizontalPage = 0
|
||||
@@ -2166,8 +2481,12 @@ class PddProductDetailCollectorTest {
|
||||
private var quickConfirmationRecovered = false
|
||||
private var adviceOpen = false
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
override fun capture(): UiSnapshot = captureFrame(advanceInertia = true)
|
||||
|
||||
private fun captureFrame(advanceInertia: Boolean): UiSnapshot {
|
||||
captureCount++
|
||||
currentDrift = inertiaRemaining * 30
|
||||
if (advanceInertia && inertiaRemaining > 0) inertiaRemaining--
|
||||
fixedSnapshot?.let { return it }
|
||||
if (adviceOpen) return UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
@@ -2224,21 +2543,33 @@ class PddProductDetailCollectorTest {
|
||||
val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0
|
||||
val panelCollapsedNow = specPanelCollapsesAfterTopSwipe && downSwipeCount >= 1
|
||||
val hideColorNow = specPanelHidesColorInitially && downSwipeCount == 0
|
||||
val visibleColors = colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
|
||||
val gridPlaced = horizontalGrid?.flatMapIndexed { r, row ->
|
||||
if (gridPixelStep != null) {
|
||||
row.mapIndexed { c, color -> Triple(color, r, c) }.filter { (_, _, c) ->
|
||||
val left = 30 + c * 230 - gridOffsets[r]
|
||||
left < GRID_VIEWPORT_RIGHT && left + 190 > 0
|
||||
}
|
||||
} else {
|
||||
row.drop(gridOffsets[r]).take(gridVisibleColumns).mapIndexed { c, color -> Triple(color, r, c) }
|
||||
}
|
||||
}
|
||||
val visibleColors = gridPlaced?.map { it.first }
|
||||
?: colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
|
||||
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
|
||||
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
|
||||
val colorRowCount = horizontalGrid?.size ?: ((visibleColors.size + rowSize - 1) / rowSize)
|
||||
val placedColors = gridPlaced ?: visibleColors
|
||||
.filterNot { hideSelectedColorOption && it == selected }
|
||||
.mapIndexed { index, color -> Triple(color, index / rowSize, index % rowSize) }
|
||||
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
|
||||
if (!continuationPage && !panelCollapsedNow && !(hideColorHeadingAfterFirstVerticalPage && verticalPage > 0)) {
|
||||
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
|
||||
}
|
||||
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
|
||||
visibleColors
|
||||
.filterNot { hideSelectedColorOption && it == selected }
|
||||
.forEachIndexed { index, color ->
|
||||
val row = index / rowSize
|
||||
val column = index % rowSize
|
||||
placedColors.forEach { (color, row, column) ->
|
||||
val path = "scroll/color-$color-$captureCount"
|
||||
val left = 30 + column * 230
|
||||
val shift = if (gridPixelStep != null) gridOffsets[row] else 0
|
||||
val jitter = if (gridClickJitter && horizontalGrid != null) (clickCount % 4) * 2 else 0
|
||||
val left = 30 + column * 230 - shift + jitter + currentDrift
|
||||
val top = 470 + row * 90
|
||||
if (imageColorCards) {
|
||||
nodes += SnapshotNode(
|
||||
@@ -2254,7 +2585,7 @@ class PddProductDetailCollectorTest {
|
||||
)
|
||||
} else {
|
||||
nodes += node(
|
||||
path, color, left, top, 220 + column * 230, top + 70,
|
||||
path, color, left, top, 220 + column * 230 - shift + jitter + currentDrift, top + 70,
|
||||
clickable = true, selected = displayedSelected() == color, parentPath = "scroll",
|
||||
)
|
||||
}
|
||||
@@ -2314,9 +2645,10 @@ class PddProductDetailCollectorTest {
|
||||
clickCount++
|
||||
clickedLabels += target.label
|
||||
if (target.label in failedClicks) return FreshActionResult.FAILED
|
||||
val current = capture().nodes.filter { it.label == target.label && (!imageColorCards || it.clickable) }
|
||||
val current = captureFrame(advanceInertia = false).nodes.filter { it.label == target.label && (!imageColorCards || it.clickable) }
|
||||
if (current.isEmpty()) return FreshActionResult.NOT_FOUND
|
||||
if (current.size > 1) return FreshActionResult.AMBIGUOUS
|
||||
if (gridStrictClickPosition && current.single().bounds != target.bounds) return FreshActionResult.NOT_FOUND
|
||||
if (!panelOpen && target.label == "单独购买") {
|
||||
entryClickCount++
|
||||
when {
|
||||
@@ -2355,8 +2687,39 @@ class PddProductDetailCollectorTest {
|
||||
private fun displayedSelected(): String? =
|
||||
if (staleSelectionEvidence && previousSelected != null) previousSelected else selected
|
||||
|
||||
override fun specRowSwipeFailureReason(): String = lastSwipeFailure
|
||||
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
|
||||
swipes += direction to anchor
|
||||
lastSwipeFailure = "none"
|
||||
if (direction == SwipeDirection.RIGHT && horizontalGrid != null) {
|
||||
rightAttempts++
|
||||
if (rightAttempts in gridRightFailAttempts) {
|
||||
lastSwipeFailure = "anchorMissing"
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (horizontalGrid != null && (direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT)) {
|
||||
gridSwipeLog += direction to (gridOffsets.maxOrNull() ?: 0)
|
||||
val unit = gridPixelStep ?: gridStep
|
||||
val delta = if (direction == SwipeDirection.LEFT) unit else -unit
|
||||
val moved = if (gridSharedContainer) horizontalGrid.indices.toList()
|
||||
else listOf((((anchor?.bounds?.top ?: 470) - 470) / 90).coerceIn(0, horizontalGrid.lastIndex))
|
||||
val sharedMax = if (gridPixelStep != null) {
|
||||
30 + (horizontalGrid.maxOf { it.size } - 1) * 230 + 190 - GRID_VIEWPORT_RIGHT
|
||||
} else {
|
||||
horizontalGrid.maxOf { it.size } - gridVisibleColumns
|
||||
}
|
||||
val brokenSwipe = gridSwipeWhereOnlyFirstRowMoves > 0 &&
|
||||
gridSwipeLog.size == gridSwipeWhereOnlyFirstRowMoves
|
||||
val offsetsBefore = gridOffsets.toList()
|
||||
moved.filter { !brokenSwipe || it == 0 }.forEach { r ->
|
||||
val max = if (gridSharedContainer) sharedMax else horizontalGrid[r].size - gridVisibleColumns
|
||||
gridOffsets[r] = (gridOffsets[r] + delta).coerceIn(0, maxOf(0, max))
|
||||
}
|
||||
if (gridOffsets.toList() != offsetsBefore) inertiaRemaining = gridInertiaCaptures
|
||||
return true
|
||||
}
|
||||
if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false
|
||||
when (direction) {
|
||||
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
|
||||
@@ -2393,6 +2756,7 @@ class PddProductDetailCollectorTest {
|
||||
|
||||
companion object {
|
||||
private const val GOODS_ID = "719834019024"
|
||||
private const val GRID_VIEWPORT_RIGHT = 835
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
private const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
|
||||
|
||||
|
||||
@@ -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: 84e5a638893e7136b70bfdf7b0ce0a5186cd4ee4
|
||||
synchronized_at: 2026-10-10T03:10:34Z
|
||||
wiki_revision: d427a46283561df71714929c13b7156ee42debea
|
||||
synchronized_at: 2026-10-10T03:47:23Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -955,7 +955,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
- 格式:时间 `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 列与匹配时间留空。要导出已用退货,先把处理阶段筛为「已用退货」。
|
||||
- SYB 商品导出列(按顺序):SYB订单、**店铺**、虾皮商品ID、**SYB售价(TWD)**、SYB颜色、SYB尺码、SYB数量、yeeke颜色、yeeke尺码、yeeke数量、匹配时间。店铺取商品行 `shopName`(SYB 订单的店铺,与页面「店铺」列同值,文本单元格,无值留空,不使用店铺筛选下拉的显示名);yeeke 颜色/尺码来自该商品有效(`matched` 或 `confirmed`)退货匹配的 `variationName`,按第一个英文或中文逗号拆分,没有逗号时整段放 yeeke 颜色;yeeke 数量为对应退货商品数量;SYB售价(TWD)取商品行 `unitPriceCent / 100`,与页面「售价」列同源(SYB `detail/listByStock` 的 `productPrice`,单件成交单价,元/TWD),数字单元格,无值留空;没有有效匹配时 yeeke 列与匹配时间留空。要导出已用退货,先把处理阶段筛为「已用退货」。
|
||||
- 权限:不新增按钮权限,能打开列表的用户(含采购员)都可导出。
|
||||
|
||||
## 设备离线诊断的事务边界(#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: b50250e0d61eff0b33f468e7cdf74139fe76bb65
|
||||
synchronized_at: 2026-10-10T03:10:43Z
|
||||
wiki_revision: d08a0dfbb8356f9cd7212d495e4f11beca59e293
|
||||
synchronized_at: 2026-10-10T03:56:35Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -409,3 +409,13 @@ Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;
|
||||
- 本地Android验证(cwd仓库根,JDK17及现有Android SDK):`./android/gradlew.bat -p android :app:testDebugUnitTest --tests '*HeartbeatReport*Test' --tests '*AgentTransportTimeoutTest' --tests '*SyncDiagnostic*Test' --tests '*HandledRecoveryDiagnosticTest' --tests '*AgentDiagnosticStoreMigrationTest' --tests '*AgentSyncCycleTest' :app:assembleDebug :app:compileReleaseUnitTestKotlin`。发布前还应执行#377受影响的outbox、运行占位、互斥、重购等回归;SQLite JDBC验证不等同于真机升级。
|
||||
- Android兼容性测试在`android/build/diagnostics378-client-reports.json`生成纯合成HTTP捕获报告;在server目录将该绝对路径注入`GOAUTO_378_ANDROID_REPORTS`,执行`go test ./app/goauto/device -run TestAndroidGeneratedHeartbeatReports -count=1 -v`,使用真实validator/handler验证三类报告与重放。未设置路径时该项明确skip,不得据此宣称跨端通过;产物不提交Git。
|
||||
- 线上迁移、发布、重启、装机仍需单独授权;本地构建/夹具不代表真机或线上30分钟心跳验收。
|
||||
|
||||
|
||||
## #372 v3 导出店铺列的纯 Web 发布(2026-10-10)
|
||||
|
||||
- 用户授权合并 #372 v3 并部署线上。`feat/372-export-shop` 以 `--no-ff` 合并为 main `3d5e964`;Web 单元测试 93/93 通过,`build:prod` 通过。相对上一线上 Web(`4ad59bd`),Web 只有 v3 的导出改动。
|
||||
- **main 已含 #378 的 Server 代码和迁移,本次没有部署**:线上 Server 仍是 `72a15f8` 构建的二进制。#378 的 Server 发布和迁移需另行授权。
|
||||
- 发布目录 `/home/goauto/releases/20261010-3d5e964-372v3-web`,只替换 dist。goauto-server 与 config 从上一发布逐字复制(cmp/diff 一致),static/temp/var 沿用原目录,旧 js/css 哈希资源保留。index.html SHA256 `b900a40ec4691f0fb2c3d89e1e20826e7ca3205db311a33c24087767a515979a`。
|
||||
- 只原子切换 current,不重启 GoAuto/Nginx;切换前后 PID 4786 不变,服务 active。
|
||||
- 按内容验收:`/` 与 `/login` 返回新 index,10 项 JS/CSS 200,线上导出模块的 SYB 表头含「店铺」,health 200,验证码 code=200,未登录业务接口 401。登录后的实际导出待业务验收。
|
||||
- 回滚:current 原子切回 `/home/goauto/releases/20261010-4ad59bd-372v2-web`,不需要重启。
|
||||
|
||||
@@ -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 = [3]
|
||||
|
||||
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.shopName), 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,187 @@
|
||||
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', shopName: 'shop-a', 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, [3])
|
||||
assert.deepEqual(lib.sybProductExportRow(product, { status: 'confirmed', variationName: '白色,L', yeekeQuantity: 3, matchedAt }),
|
||||
['O1', 'shop-a', '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(7, 10), ['均碼', '', 1])
|
||||
assert.deepEqual(lib.sybProductExportRow(product, { status: 'cancelled', variationName: 'x', yeekeQuantity: 1, matchedAt }), ['O1', 'shop-a', '99', 12.3, '白色', 'L', 1, '', '', '', ''])
|
||||
assert.deepEqual(lib.sybProductExportRow(product, null), ['O1', 'shop-a', '99', 12.3, '白色', 'L', 1, '', '', '', ''])
|
||||
assert.equal(lib.sybProductExportRow({ ...product, unitPriceCent: 0 }, null)[3], 0)
|
||||
for (const value of [null, undefined]) assert.equal(lib.sybProductExportRow({ ...product, unitPriceCent: value }, null)[3], '')
|
||||
for (const value of [null, undefined]) assert.equal(lib.sybProductExportRow({ ...product, shopName: value }, null)[1], '')
|
||||
})
|
||||
|
||||
// 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', shopName: 'shop-a', 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.B1.v, '店铺')
|
||||
assert.equal(sheet.B2.t, 's'); assert.equal(sheet.B2.v, 'shop-a'); assert.notEqual(sheet.B2.z, '0.00')
|
||||
assert.equal(sheet.D1.v, 'SYB售价(TWD)')
|
||||
assert.equal(sheet.D2.t, 'n'); assert.equal(sheet.D2.v, 12.3); assert.equal(sheet.D2.z, '0.00')
|
||||
assert.equal(sheet.D3.t, 'n'); assert.equal(sheet.D3.v, 0)
|
||||
assert.equal(sheet.D4.v, ''); assert.notEqual(sheet.D4.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