Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcb7dad878 | ||
|
|
6550b9fd45 | ||
|
|
7a2c2d1eac | ||
|
|
d01540b9fd | ||
|
|
ec010b42fc | ||
|
|
a56df9d908 | ||
|
|
cd4bd6acb1 | ||
|
|
baa20af530 | ||
|
|
f54ace7ccf | ||
|
|
1e582cd868 | ||
|
|
8c95790135 | ||
|
|
316d5aa412 | ||
|
|
e55645ff66 | ||
|
|
30c6b2ab50 | ||
|
|
27d9560f0a | ||
|
|
018e3566c0 | ||
|
|
ddd875d989 | ||
|
|
8b4db9c89e | ||
|
|
00fb274fd4 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 76
|
||||
versionName = "0.9.63"
|
||||
versionCode = 77
|
||||
versionName = "0.9.64"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+13
-3
@@ -468,17 +468,27 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
}
|
||||
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
val root = rootInActiveWindow ?: return false
|
||||
return swipePurchaseWithEvidence(direction, durationMs).result == PurchaseSwipeResult.SUCCESS
|
||||
}
|
||||
|
||||
override fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome {
|
||||
// Count and resolve against the same live root: a missing root or a
|
||||
// failed dispatch must never masquerade as an empty scrollable list.
|
||||
val root = rootInActiveWindow ?: return PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0)
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node }
|
||||
if (candidates.isEmpty()) return PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0)
|
||||
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
|
||||
val directional = candidates.filter { candidate ->
|
||||
Rect().also(candidate::getBoundsInScreen).let { if (horizontal) it.width() > it.height() else it.height() >= it.width() }
|
||||
}
|
||||
val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate ->
|
||||
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() }
|
||||
} ?: return false
|
||||
return swipeNode(target, direction, durationMs, preferScrollAction = false)
|
||||
} ?: return PurchaseSwipeOutcome(PurchaseSwipeResult.FAILED, candidates.size)
|
||||
val result = if (swipeNode(target, direction, durationMs, preferScrollAction = false)) {
|
||||
PurchaseSwipeResult.SUCCESS
|
||||
} else PurchaseSwipeResult.FAILED
|
||||
return PurchaseSwipeOutcome(result, candidates.size)
|
||||
}
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
|
||||
+43
-10
@@ -3,6 +3,10 @@ package cn.ilapage.goauto.agent.automation
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
|
||||
enum class PurchaseSwipeResult { SUCCESS, NO_SCROLLABLE_TARGET, ROOT_UNAVAILABLE, FAILED }
|
||||
|
||||
data class PurchaseSwipeOutcome(val result: PurchaseSwipeResult, val visibleScrollableCount: Int)
|
||||
|
||||
interface PurchaseUiDriver {
|
||||
fun capture(): UiSnapshot
|
||||
fun clickFresh(target: SnapshotNode): FreshActionResult
|
||||
@@ -32,6 +36,18 @@ interface PurchaseUiDriver {
|
||||
fun specRowSwipeFailureReason(): String = "gestureFailed"
|
||||
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
|
||||
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
|
||||
/** Detailed result only for the rule's reversible spec-panel reveal swipe. */
|
||||
fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome {
|
||||
val snapshot = capture()
|
||||
val count = snapshot.nodes.count { it.visible && it.scrollable }
|
||||
val result = when {
|
||||
snapshot.nodes.isEmpty() -> PurchaseSwipeResult.ROOT_UNAVAILABLE
|
||||
count == 0 -> PurchaseSwipeResult.NO_SCROLLABLE_TARGET
|
||||
swipePurchase(direction, durationMs) -> PurchaseSwipeResult.SUCCESS
|
||||
else -> PurchaseSwipeResult.FAILED
|
||||
}
|
||||
return PurchaseSwipeOutcome(result, count)
|
||||
}
|
||||
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550)
|
||||
fun backPurchase(): Boolean
|
||||
@@ -1150,17 +1166,34 @@ class PurchaseRehearsalExecutor(
|
||||
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
|
||||
action.swipeAfter?.let { swipe ->
|
||||
// The stock purchase rule asks to reveal additional selector rows after
|
||||
// opening the sheet. A fully-evidenced non-scrollable selector has no
|
||||
// scroll target, and treating that absence as an action failure blocks
|
||||
// an otherwise safe exact-spec flow. Keep all other configured swipes
|
||||
// mandatory; this exception is limited to that confirmed panel state.
|
||||
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL &&
|
||||
currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
) return null
|
||||
val reveal = action.type == PurchaseActionType.OPEN_SPEC_PANEL
|
||||
val initialScreen = if (reveal) currentScreen(input) else null
|
||||
if (initialScreen?.specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION) {
|
||||
val count = initialScreen.sourceNodes.count { it.visible && it.scrollable }
|
||||
panelDiagnostic("ruleSwipe;outcome=skipped_confirmed_panel;panel=${initialScreen.specPanelType};scrollables=$count;reveal=true")
|
||||
return null
|
||||
}
|
||||
repeat(swipe.count) { index ->
|
||||
if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) {
|
||||
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败")
|
||||
// Keep other actions on the original Boolean path. Only the
|
||||
// post-open reveal may treat an absent scroll target as a no-op.
|
||||
val outcome = if (reveal) {
|
||||
driver.swipePurchaseWithEvidence(swipe.direction, swipe.durationMs)
|
||||
} else if (driver.swipePurchase(swipe.direction, swipe.durationMs)) {
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.SUCCESS, 0)
|
||||
} else {
|
||||
val count = driver.capture().nodes.count { it.visible && it.scrollable }
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.FAILED, count)
|
||||
}
|
||||
if (outcome.result != PurchaseSwipeResult.SUCCESS) {
|
||||
val screen = currentScreen(input)
|
||||
val evidence = "panel=${screen.specPanelType};scrollables=${outcome.visibleScrollableCount};" +
|
||||
"reveal=$reveal;reason=${outcome.result.name.lowercase()}"
|
||||
if (reveal && outcome.result == PurchaseSwipeResult.NO_SCROLLABLE_TARGET) {
|
||||
panelDiagnostic("ruleSwipe;outcome=skipped_no_scroll_target;$evidence")
|
||||
return null
|
||||
}
|
||||
panelDiagnostic("ruleSwipe;outcome=failed;$evidence")
|
||||
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败 [$evidence]")
|
||||
}
|
||||
if (index < swipe.count - 1 && swipe.intervalMs > 0) pause(swipe.intervalMs)
|
||||
}
|
||||
|
||||
+120
-1
@@ -9,6 +9,8 @@ import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSwipeOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSwipeResult
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.RuleValidationException
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
@@ -766,11 +768,118 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
|
||||
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertTrue(diagnostics.any { it.contains("skipped_confirmed_panel;panel=NON_SCROLLABLE_CONFIRMATION;scrollables=0;reveal=true") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec reveal skips missing scroll target regardless of panel classification`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
panelOverride = SpecPanelFixtures.liveShapedSheet(),
|
||||
purchaseSwipeSucceeds = false,
|
||||
)
|
||||
var probes = 0
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
driver, { driver.browser = true; true }, { probes++; "{}" },
|
||||
pause = {}, panelDiagnostic = diagnostics::add,
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals(outcome.message, "spec_probe_completed", outcome.resultType)
|
||||
assertEquals(1, probes)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertTrue(diagnostics.any { it.contains("panel=REQUIRED_EVIDENCE;scrollables=0;reveal=true") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec reveal with a scroll target still fails on gesture failure`() {
|
||||
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
|
||||
var probes = 0
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
driver, { driver.browser = true; true }, { probes++; "{}" },
|
||||
pause = {}, panelDiagnostic = diagnostics::add,
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertEquals(0, probes)
|
||||
assertEquals(1, driver.swipeCount)
|
||||
assertTrue(outcome.message.contains("panel=NORMAL_SCROLLABLE;scrollables=1;reveal=true;reason=failed"))
|
||||
assertTrue(diagnostics.any { it.startsWith("ruleSwipe;outcome=failed;") })
|
||||
assertFalse(outcome.message.contains("黑色"))
|
||||
assertFalse(outcome.message.contains(input().goodsId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec reveal does not skip a missing accessibility root`() {
|
||||
val driver = FakePurchaseDriver(revealSwipeOutcomes = mutableListOf(
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0),
|
||||
))
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertEquals(0, probes)
|
||||
assertTrue(outcome.message.contains("scrollables=0;reveal=true;reason=root_unavailable"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec reveal stops remaining swipes after target disappears`() {
|
||||
val driver = FakePurchaseDriver(revealSwipeOutcomes = mutableListOf(
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.SUCCESS, 1),
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0),
|
||||
))
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals(1, driver.swipeCount)
|
||||
assertEquals(1, pauses.count { it == 1000L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scrollable spec reveal preserves configured swipe count and interval`() {
|
||||
val driver = FakePurchaseDriver()
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals(2, driver.swipeCount)
|
||||
assertEquals(1, pauses.count { it == 1000L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing scroll target outside spec reveal remains a mandatory action failure`() {
|
||||
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
|
||||
val configured = rule().replace(
|
||||
"\"waitAfterMs\":700}",
|
||||
"\"waitAfterMs\":700,\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500,\"intervalMs\":0}}",
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertTrue(outcome.message.contains("scrollables=0;reveal=false"))
|
||||
assertFalse(driver.panel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skipping a reveal does not skip a failed specification probe`() {
|
||||
val driver = FakePurchaseDriver(panelOverride = SpecPanelFixtures.liveShapedSheet())
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; null }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(1, probes)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1249,6 +1358,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val nonScrollablePanel: Boolean = false,
|
||||
private val unrecognizedPanel: Boolean = false,
|
||||
private val purchaseSwipeSucceeds: Boolean = true,
|
||||
private val panelOverride: UiSnapshot? = null,
|
||||
private val revealSwipeOutcomes: MutableList<PurchaseSwipeOutcome> = mutableListOf(),
|
||||
initiallyInAgent: Boolean = false,
|
||||
private val panelBecomesUnknownAfterSizeProof: Boolean = false,
|
||||
) : PurchaseUiDriver {
|
||||
@@ -1351,6 +1462,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
|
||||
return UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
}
|
||||
panelOverride?.let { return it }
|
||||
if (unrecognizedPanel) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
@@ -1532,6 +1644,13 @@ class PurchaseRehearsalExecutorTest {
|
||||
return purchaseSwipeSucceeds
|
||||
}
|
||||
|
||||
override fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome {
|
||||
if (revealSwipeOutcomes.isEmpty()) return super.swipePurchaseWithEvidence(direction, durationMs)
|
||||
val outcome = revealSwipeOutcomes.removeAt(0)
|
||||
if (outcome.result == PurchaseSwipeResult.SUCCESS) swipePurchase(direction, durationMs)
|
||||
return outcome
|
||||
}
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
swipeInPaths += target.path
|
||||
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 1e87b8832cecd454e58f0ff49dbe05bbf8d61544
|
||||
synchronized_at: 2026-09-29T03:47:39Z
|
||||
wiki_revision: 88ed799e92f64a73b3bd9472ab6c0b06fa080b37
|
||||
synchronized_at: 2026-09-29T09:48:30Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -365,6 +365,8 @@ PddProductDetailCollector
|
||||
|
||||
## 采购规格面板预滑动兼容(#238)
|
||||
|
||||
> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。
|
||||
|
||||
代码基线 `58a6c1c`,Android 0.9.60 / versionCode 73(构建完成不等同于已安装/发布)。`PurchaseRehearsalExecutor.applyPostAction` 对 `openSpecPanel.swipeAfter` 只兼容解析、不执行机械预滑动,`waitAfterMs` 保留;首趟继续原 `probeSpecs` 遍历,第二趟继续原 `selectSpec` 精确查找与容器内有界滚动。其他动作的后置滑动仍沿用既有执行语义,失败不会被统一忽略。
|
||||
|
||||
`GoAutoAccessibilityService.swipePurchase` 的失败分类由 `PurchaseSwipeFailureReason` 枚举提供;共享 `swipeNode` 仅增加可选分类回调,不改变手势目标、轨迹、1500ms 回调等待或其他调用者行为。`GoAutoPurchasePanel` 日志经 `AgentForegroundService` 关联 task、attempt、device 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
|
||||
@@ -578,3 +580,21 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- 原页面默认用户浏览器本地今天范围,工具栏“匹配 N”复用现有 rematch 和任务进度;只处理本页合格项,同日提交,原回写需独立确认。
|
||||
|
||||
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
|
||||
|
||||
## SYB 过滤商品标记与规则重算(#340)
|
||||
|
||||
- 数据:`syb_product` 新增 `pdd_purchase_excluded`(索引)、`excluded_rule_id`、`excluded_rule_kind`、`excluded_rule_keyword`、`excluded_at`;新表 `syb_product_filter_recompute_log`(操作人、两方向变更数、两类跳过数、时间)。迁移 `1789801500000_syb_pdd_purchase_excluded`,只新增,存量行默认需 PDD 采购。
|
||||
- 入库:`sybimport/sync.go applyStockDetail` 命中规则不再 `continue`,把规则快照随明细交给 `apply.go ApplyDetail`;仅新建分支写标记,更新分支保留原标记。
|
||||
- 阶段与隔离:`purchase/process_stage.go` 新增 `pdd_excluded`(「无需采购」);`purchase/return_match_block.go rejectIfPDDExcluded`(`service.create`,覆盖单条/批量)、`batch.go` 预检、`collection_eligibility.go`、`ai_match_eligibility.go`、`task/image_search.go` 统一拒绝;`returnmatch` 的参与阶段包含 `pdd_excluded`。
|
||||
- 列表:`sybimport/service.go List` 的 `purchaseType`(`pdd` 默认 / `excluded` / `all`),与 `processStage`、`createdFrom/To` 为 AND。
|
||||
- 重算:`sybproductfilter/recompute.go`——`recomputeChanges` 规划(只读所需列);`recomputeFingerprint` 对排序后的(id、方向、规则 ID、类型、关键词)JSON 做 sha256;`writeRecomputeChanges` 逐行 `FOR UPDATE` 锁 `syb_product`,并以 `FOR SHARE` 加锁读复查 `purchase_task` / `return_match`(MySQL REPEATABLE-READ 下普通读看不到快照后的提交)。`recompute_mysql_integration_test.go` 在设置 `GOAUTO_IT_MYSQL_DSN` 时于临时库做双连接并发验证。
|
||||
- Web:`syb-products/index.vue`(采购类型筛选、阶段自动切换、规则显示、按钮计数)、`syb-product-filters/index.vue`(命中/标记口径、重算预览与执行)、`syb-sync-runs/index.vue`(「其中本次规则命中 N 条」)。
|
||||
|
||||
## 采购规格面板揭示滑动无目标兼容(#348)
|
||||
|
||||
实现绑定 `6550b9f`,Android 0.9.64 / versionCode 77;基于 main `7a2c2d1`,当前仅分支实现,未合并、未发布、未安装真机。
|
||||
|
||||
- `PurchaseUiDriver.swipePurchaseWithEvidence` 返回 `PurchaseSwipeOutcome`(结果枚举与可见可滚动节点数)。生产 `GoAutoAccessibilityService` 从同一个实时无障碍 root 统计并定位目标;SUCCESS、NO_SCROLLABLE_TARGET、ROOT_UNAVAILABLE、FAILED 明确区分。原 Boolean swipePurchase 返回语义、目标选择与 swipeNode 不变,不修改采集器。
|
||||
- `PurchaseRehearsalExecutor.applyPostAction` 仅在 openSpecPanel 的揭示滑动遇到 NO_SCROLLABLE_TARGET 时跳过剩余次数;保留 NON_SCROLLABLE_CONFIRMATION 原例外。其他动作仍走原 Boolean 必需滑动路径;root 不可用或有目标而手势失败仍 RULE_ACTION_FAILED。
|
||||
- 失败/跳过通过既有 panelDiagnostic 回调写 GoAutoPurchasePanel 结构日志,仅含面板枚举、scrollables 计数、reveal 布尔与固定结果原因;失败摘要带同样标量并沿原任务结果提交。没有节点文字、坐标、树或截图。
|
||||
- probeSpecs、selectSpec、最终复核、规则 JSON、能力、服务端、Admin、数据库、任务租约及下单/支付边界不变。无滑动不代表规格探测成功。
|
||||
|
||||
@@ -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: 32c6b496de47b54ca04e081271114c28ff439e0c
|
||||
synchronized_at: 2026-09-29T06:57:51Z
|
||||
wiki_revision: 5d9810286d76c52a90902c27a5316f39164f36e0
|
||||
synchronized_at: 2026-09-29T09:48:45Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -75,6 +75,7 @@ synchronized_at: 2026-09-22T02:56:27Z
|
||||
- SYB 商品页保留“解析状态”,另按 SYB、蝦皮、PDD、采集任务和采购任务的当前事实动态派生唯一“处理阶段”;不在 `syb_product` 保存容易过期的阶段字段。
|
||||
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。
|
||||
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
|
||||
- #338/#340 后在「已创建任务」之后、「待人工处理」之前依次插入:有效退货匹配(退货待确认 / 已用退货)→ 无需采购(`pdd_purchase_excluded`)。即进行中、已成功及结果不明的采购任务仍优先显示;失败/取消的旧任务不占主阶段时,标记为无需 PDD 采购的行显示「无需采购」。
|
||||
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
|
||||
- 一个 PDD 订单号只能属于一个采购任务,该唯一性在采购任务保存路径上全局强制(#241)。人工处理结果未知、取消及 lifecycle 保存路径撞号时返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`,提示订单号已属于哪个任务,由采购员人工核对,不静默覆盖原值。
|
||||
- 不可逆边界之后的 `order_created` 结果回传是上述规则的例外:此时 PDD 真单已创建,发现订单号已属于其他任务时不回滚、不判失败,而是把任务降级为 `order_result_unknown`,冲突订单号以「读到订单号 X,但该号已属于任务 CG-yy」保存在任务与 attempt 的 `error_message`,`pdd_order_no` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。
|
||||
@@ -488,6 +489,8 @@ synchronized_at: 2026-09-22T02:56:27Z
|
||||
|
||||
## 打开采购规格面板后按需滚动(#238)
|
||||
|
||||
> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。
|
||||
|
||||
- Android 0.9.60 / versionCode 73,代码 `58a6c1c` 起,规则 `openSpecPanel.swipeAfter` 保留格式校验与旧快照兼容,但不执行打开面板后的固定次数预滑动;不以“必须滑两次成功”作为进入规格探测/选择的条件。动作后的 `waitAfterMs` 仍生效。
|
||||
- 首趟规格探测和第二趟精确选择仍使用各自既有的按需横向/纵向、有界与稳定终止策略。取消预滑动不等于不探测隐藏规格,也不等于只看首屏。目标不存在、歧义、页面证据不足或必要的有界查找失败时仍明确失败。
|
||||
- 此调整覆盖所有已经安全识别打开的面板,不再仅特判 NON_SCROLLABLE_CONFIRMATION;不弱化面板验证、精确选中、地址、价格、任务租约、创建订单边界或禁止支付规则。
|
||||
@@ -749,3 +752,19 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
|
||||
### 档口入库码混选回写范围(#351)
|
||||
列表“回写 N”仅统计当前页勾选且 ready 的唯一记录,混选其他状态不阻塞合法项。打开确认弹窗时冻结本次选中数量及合格 ID;只预览、确认提交这些 ID,不随列表刷新或勾选变化扩大范围。弹窗明确显示选中/可回写/跳过数量,入库码、占位及替换旧码指标仅针对待提交记录。
|
||||
预览发现候选状态变化时剔除 blocked 项,最多再进行一次只读预览以刷新指标;无合格项、预览失败/范围不一致或再次变化时禁用确认,提示重新预览。确认提交中防重复,未知提交结果不自动重试,沿用批次查询与只读复核。服务端原 ready 状态、唯一匹配计划及执行校验不变;仅用户人工确认后才进入回写,不自动写入。
|
||||
|
||||
## SYB 过滤商品入库与「无需采购」(#340)
|
||||
|
||||
- 命中 SYB 过滤规则(结构 `-#` 或关键词)的订单明细不再跳过,而是入库并标记「无需 PDD 采购」,同时保存命中规则快照;店铺过滤不变。
|
||||
- 标记只在明细首次入库时确定;重复同步不改标记,规则启停或删除也不自动改变已有标记。历史被跳过的明细不自动补同步。
|
||||
- 带标记的商品**硬性隔离所有 PDD 流程**:创建采购(单条、批量)、采购预检可采购结果、创建采集资格、AI 规格匹配、图搜采集均由服务端拒绝或判为不可用,错误码 `PURCHASE_PDD_EXCLUDED`;前端隐藏只是辅助。
|
||||
- 带标记的商品可参与退货匹配:匹配后「退货待确认」,确认后「已用退货」,取消后回到「无需采购」。
|
||||
- SYB 商品页新增「采购类型」筛选:需 PDD 采购(默认、重置后同)/ 无需 PDD 采购 / 全部,与处理阶段、创建时间等筛选同时生效;选择「退货待确认」「已用退货」「无需采购」阶段时自动切到「全部」,可手动改回。无需采购行显示命中规则,可勾选但只计入「匹配退货」,不显示单条「采购」。
|
||||
- 管理员「按当前规则重算」:先预览(两个方向的变更数、因已有采购任务 / 有效退货匹配跳过的数量、最多 20 条样例)再确认执行。执行必须携带预览指纹,数据、规则或其证据变化时拒绝并提示重新预览,不静默执行另一批。执行时逐行加锁并以加锁读复查:该商品有任何采购任务或有效退货匹配则跳过;审计日志与返回数量按实际写入/跳过计。规则变更从不自动重算。
|
||||
- 统计口径:同步记录与规则列表的「命中」是本次同步命中规则的次数(含已存在明细);「当前带此规则标记的明细 N 条」按数据库实际统计。两者不得混用。
|
||||
|
||||
## 采购规格面板揭示滑动无目标兼容(#348)
|
||||
|
||||
Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装/发布)。已安全打开规格面板后,规则 openSpecPanel.swipeAfter 的某次揭示滑动若没有任何可见可滚动目标,则无需滑动,跳过本动作剩余次数并记录诊断;不再因缺少滚动节点而失败。保留 NON_SCROLLABLE_CONFIRMATION 原跳过行为。
|
||||
|
||||
有可滚动目标却派发失败、被取消或等待失败,以及无障碍 root 不可用,仍明确失败;其他动作的必需滑动不放宽。有可滚动目标时保留规则 count、direction、durationMs、intervalMs 与 waitAfterMs。后续规格探测、精确选择与最终复核全部照常,缺规格或探测无结果仍失败,不猜测、不新增点击目标或支付。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: da26af9e01087c4970b01783a4807c689fd79d51
|
||||
synchronized_at: 2026-09-29T03:47:57Z
|
||||
wiki_revision: 18260ec2725eaeb4dcf2543422d58ee4ede8b981
|
||||
synchronized_at: 2026-09-29T09:50:08Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -87,7 +87,7 @@ PATCH /api/admin/v1/syb-products/{productId}/correction
|
||||
|
||||
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `partial_success` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
|
||||
|
||||
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`),不含原始 JSON;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
|
||||
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`,#340 起另有 `pddPurchaseExcluded`、`excludedRuleId`、`excludedRuleKind`、`excludedRuleKeyword`、`excludedAt`),不含原始 JSON;`purchaseType`(#340)取 `pdd`(默认)/ `excluded` / `all`,其他值 422,与 `processStage`、`createdFrom`/`createdTo` 同时生效;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
|
||||
|
||||
`reparse` 与 `reparse-batch` 只读取已保存的 `rawJson` 重新执行解析规则,不请求 SYB 接口;请求体 `force` 为 `true` 时才覆盖 `manuallyConfirmed` 的行,默认跳过并在批量结果中标记 `skipped_manual`。批量结果逐条返回 `outcome`(`reparsed` / `skipped_manual` / `unchanged`)与解析状态变化,`ids` 中任何一个不存在都会使整个请求返回错误(与虾皮商品批量删除的"部分成功"语义不同:ID 不存在通常是操作员选错了页面)。
|
||||
|
||||
@@ -115,9 +115,11 @@ GET /api/admin/v1/syb-product-filters
|
||||
POST /api/admin/v1/syb-product-filters
|
||||
PATCH /api/admin/v1/syb-product-filters/{id}/enabled
|
||||
DELETE /api/admin/v1/syb-product-filters/{id}
|
||||
GET /api/admin/v1/syb-product-filters/recompute/preview
|
||||
POST /api/admin/v1/syb-product-filters/recompute/execute
|
||||
```
|
||||
|
||||
同步时按 `variationSku` 决定明细是否入库。规则分两类,由 `kind` 区分:`char` 是结构判据
|
||||
同步时按 `variationSku` 判定明细是否「无需 PDD 采购」(#340 起命中者照常入库并打标记,不再跳过)。规则分两类,由 `kind` 区分:`char` 是结构判据
|
||||
(`-`、`#`),`keyword` 是关键词清单。列表支持 `kind`、`keyword` 和分页,返回 `keyword`、
|
||||
`normalizedKeyword`、`enabled`、`lastHitCount`、`lastHitAt`、`note`,`char` 另有
|
||||
`lastChangedBy`、`lastChangedAt`。
|
||||
@@ -126,9 +128,20 @@ DELETE /api/admin/v1/syb-product-filters/{id}
|
||||
`FORBIDDEN`(`结构过滤不可新增` / `结构过滤不可删除`),不得只依赖前端隐藏按钮。
|
||||
`kind=char` 的 `PATCH enabled` 允许,并记录操作人与时间。
|
||||
|
||||
`[必须]` `lastHitCount` 是每条规则各自的命中数,由同步结束时回写;未命中的规则写 0,
|
||||
从未同步过为 `null`。停用 `kind=char` 的二次确认提示条数必须取该行实时的 `lastHitCount`,
|
||||
不得写死——写死的数字在数据变化后会给出错误警告。
|
||||
`[必须]` `lastHitCount` 是每条规则各自在上次同步中的**命中次数**(新建与已存在明细都计),
|
||||
由同步结束时回写;未命中的规则写 0,从未同步过为 `null`。页面称「上次同步命中」。
|
||||
列表另返回 `markedCount`(#340):查询时按数据库实际统计的「当前 `pdd_purchase_excluded=true`
|
||||
且 `excluded_rule_id` 为该规则」的明细数,一次分组查询得出。停用 `kind=char` 的二次确认提示
|
||||
条数必须取实时的 `markedCount`,不得写死,也不得用 `lastHitCount` 冒充已标记数量。
|
||||
|
||||
`recompute/preview`、`recompute/execute` 仅管理员可用(其他角色 403)。预览只读,返回
|
||||
`excludedToPdd`、`pddToExcluded`、`skippedHasTask`、`skippedReturnMatch`、最多 20 条
|
||||
`samples`(`orderCode`、`shopeeItemId`、`direction`=`excluded_to_pdd`/`pdd_to_excluded`、
|
||||
`ruleKind`、`ruleKeyword`)及 `fingerprint`。执行请求体 `{"fingerprint": "..."}` 必填(空值 422);
|
||||
服务端在事务内重新规划并比对指纹(涵盖商品、方向、规则 ID、类型、关键词),不一致返回
|
||||
`409 RECOMPUTE_PREVIEW_STALE`(「数据或规则已变化,请重新预览后再执行」)且不写入。
|
||||
执行逐行加锁复查,有采购任务或有效退货匹配的商品跳过;返回实际写入/跳过数量与 `operator`,
|
||||
并写 `syb_product_filter_recompute_log`。
|
||||
|
||||
关键词归一化与店铺一致:去首尾空白、全角/半角统一、忽略大小写;**不做简繁转换**,SYB 数据
|
||||
为繁体。唯一性按 (`kind`, `normalizedKeyword`) 判定,重复添加返回
|
||||
@@ -1166,6 +1179,8 @@ Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口
|
||||
|
||||
### openSpecPanel 后置滑动兼容与诊断(#238)
|
||||
|
||||
> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。
|
||||
|
||||
版本边界:Android 0.9.60 / versionCode 73,代码 `58a6c1c`。不修改 JSON schema、能力标识、任务接口或已有快照哈希。`openSpecPanel.swipeAfter` 仍按 direction/count/durationMs/intervalMs 原约束校验;解析成功后不执行该准备性滑动,`waitAfterMs` 保留。其他动作后置滑动沿用旧执行语义。旧 Server 可继续下发原快照;旧 APK 仍按原策略执行,不能将本契约描述当作旧设备已获得兼容。
|
||||
|
||||
规格探测与精确选择自行负责按需有界滚动,原始候选、精确点击和选中复核不变。跳过预滑动不作为规格探测成功或订单创建证据。
|
||||
@@ -1425,3 +1440,11 @@ Web 批量入口冻结原匹配 ID 并顺序调用;HTTP 409 状态冲突跳过
|
||||
- batch-delete 在既有阻塞状态基础上增加 matching,409 整批拒绝;避免删除正在匹配的记录。Android/采购/付款流程无变化。
|
||||
|
||||
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
|
||||
|
||||
### openSpecPanel 无滚动目标兼容与诊断(#348)
|
||||
|
||||
版本绑定 `6550b9f`,Android 0.9.64 / versionCode 77(分支实现,未合并、安装或发布)。本次没有 JSON schema、动作参数、能力标识、任务接口或规则快照哈希变化。旧 Server 继续下发相同快照;旧 APK 不因本文更新而改变行为。
|
||||
|
||||
仅 openSpecPanel.swipeAfter 的 NO_SCROLLABLE_TARGET 可跳过剩余揭示滑动;NON_SCROLLABLE_CONFIRMATION 原例外保持。存在目标而手势失败、窗口 root 不可用,以及其他动作的必需滑动失败,仍返回 RULE_ACTION_FAILED;后续 probeSpecs/selectSpec/最终复核不跳过。
|
||||
|
||||
既有本地 GoAutoPurchasePanel 回调增加 ruleSwipe 事件:outcome=skipped_confirmed_panel|skipped_no_scroll_target|failed;panel 为面板枚举,scrollables 为非负计数,reveal 为布尔,失败/无目标 reason=success|no_scrollable_target|root_unavailable|failed 中实际适用的固定值。失败 error message 增加这些标量,原 task/attempt/device/规则快照绑定与结果提交字段不变。不记录节点文本、规格、地址、订单、坐标、树、整屏截图或凭据。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: SYB-ERP-Interface-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:30Z
|
||||
wiki_revision: 5979a14250c96c3c92348a947c2bc8d3a078c9f4
|
||||
synchronized_at: 2026-09-29T08:18:40Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -496,12 +496,14 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
|
||||
店铺过滤只决定明细获取和入库,不能减少原始列表完整性校验范围;已保存不代表整日完整。
|
||||
|
||||
`[必须]` **店铺准入之后再按 `variationSku` 过滤商品(#269)。** 顺序固定为:店铺准入 →
|
||||
结构过滤 → 关键词过滤 → 入库。两类规则都存在 `syb_product_filter`,由 `kind` 区分:
|
||||
结构过滤 → 关键词过滤 → 入库。两类规则都存在 `syb_product_filter`,由 `kind` 区分。
|
||||
自 #340 起命中规则的明细**同样入库**,只在 `syb_product` 上标记「无需 PDD 采购」,不再跳过(见本节末尾):
|
||||
|
||||
- `kind=char`:`-` 和 `#` 两条,命中任意一条即跳过(**OR,不是 AND**)。这两个字符是
|
||||
「档口-供应商#货号」编码格式的判据。2026-09-11 核验线上 13010 行明细:含 `#` 8498 行、
|
||||
含 `-` 6392 行、两者都含 6386 行、任一 8504 行(65%)。用 AND 只命中 6386 行,会漏掉
|
||||
`DD#004`、`300斤牛奶絲圓領#A057` 这类只含 `#` 的 2118 行。
|
||||
- `kind=char`:当前只有一条结构规则 `-#`,`variationSku` 须**同时包含** `-` 和 `#` 才命中
|
||||
(#286 起合并为一条、要求全部字符;代码见 `sybproductfilter.RuleSet.Match` 的 `containsAllRunes`)。
|
||||
这是「档口-供应商#货号」编码格式的判据。`[历史]` #269 初版曾是 `-`、`#` 两条、任一命中
|
||||
(2026-09-11 核验线上 13010 行:含 `#` 8498、含 `-` 6392、两者都含 6386);#286 改为
|
||||
同时包含,只含 `#` 的编码(如 `DD#004`)不再命中结构规则。
|
||||
- `kind=keyword`:关键词清单,匹配 `variationSku`,**不匹配 `productTitle`**。同一次核验
|
||||
中六条初始关键词在 `productTitle` 上命中为 0,且全部已被结构过滤覆盖,净增为 0;保留它
|
||||
是为档口改用不含 `#` 的编码时兜底。
|
||||
@@ -516,6 +518,15 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
|
||||
`[必须]` `kind=char` 不可新增、不可删除,只能停用,且停用需要二次确认并记录操作人与时间。
|
||||
判据本身需要变更(例如档口改用 `/` 或 `@`)属于范围变化,应另建工单评估。
|
||||
|
||||
`[必须]` **命中规则 = 标记无需 PDD 采购,不再跳过入库(#340)。** 启用店铺内命中结构或关键词规则的
|
||||
明细照常写入 `syb_product`,并记录 `pdd_purchase_excluded=true` 与命中规则快照
|
||||
(`excluded_rule_id`、`excluded_rule_kind`、`excluded_rule_keyword`、`excluded_at`)。
|
||||
标记**只在该明细首次创建时**按当次启用规则确定;之后重复同步同一明细不改变标记,
|
||||
规则启停或删除也不会自动改变已有标记,只能由管理员「按当前规则重算」变更。
|
||||
历史上被跳过的明细不自动补同步;需要时按日期手动导入即可补入并打标记。
|
||||
`char_filter_skipped` / `keyword_filter_skipped` 与每条规则的 `last_hit_count` 仍是**本次命中次数**
|
||||
(新建与已存在明细都计),不是「本次被标记的数量」,页面文案须称「命中」。
|
||||
|
||||
`[必须]` 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细
|
||||
响应中的 `shopName` 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/
|
||||
验证码等任何顺运宝请求之前停止,并且不推进覆盖游标。该过滤只影响后续入库,
|
||||
|
||||
@@ -44,6 +44,7 @@ func MigratedModels() []any {
|
||||
&models.SYBSessionAuthLease{},
|
||||
&models.SYBShop{},
|
||||
&models.SYBProductFilter{},
|
||||
&models.SYBProductFilterRecomputeLog{},
|
||||
&models.SYBSyncRun{},
|
||||
&models.YeekeSession{},
|
||||
&models.YeekeReturnPackage{},
|
||||
|
||||
@@ -579,12 +579,45 @@ type SYBProduct struct {
|
||||
// parse-rule change, only the derived fields above may change.
|
||||
RawJSON string `json:"-" gorm:"type:json;not null"`
|
||||
|
||||
// PDDExcluded marks that this row hit an enabled product filter
|
||||
// rule (char/keyword) at the moment it was first created by the sync
|
||||
// (#340). It replaces the old "skip on filter hit" behaviour: a filtered
|
||||
// row is still stored, but every PDD purchase/collection/AI-match entry
|
||||
// point must hard-reject it. The mark is decided once, at creation time
|
||||
// (sybimport.applyStockDetail / ApplyDetail); a later sync of the SAME
|
||||
// existing row never changes it, even if the enabled rules changed in
|
||||
// the meantime — only the admin "按当前规则重新计算" action
|
||||
// (sybproductfilter recompute) may flip it, and only for rows with no
|
||||
// purchase task and no active return match.
|
||||
PDDExcluded bool `json:"pddPurchaseExcluded" gorm:"column:pdd_purchase_excluded;not null;default:false;index"`
|
||||
// ExcludedRuleID/Kind/Keyword are a snapshot of the rule that matched at
|
||||
// mark time, kept even if the rule is later edited or deleted, so the
|
||||
// mark stays explainable in the UI and in return matching (#340).
|
||||
ExcludedRuleID *uint64 `json:"excludedRuleId,omitempty"`
|
||||
ExcludedRuleKind string `json:"excludedRuleKind,omitempty" gorm:"size:16;not null;default:''"`
|
||||
ExcludedRuleKeyword string `json:"excludedRuleKeyword,omitempty" gorm:"size:200;not null;default:''"`
|
||||
ExcludedAt *time.Time `json:"excludedAt,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SYBProduct) TableName() string { return "syb_product" }
|
||||
|
||||
// SYBProductFilterRecomputeLog audits the admin-only "按当前规则重新计算"
|
||||
// action (#340 decision 7). Every execute run writes exactly one row here.
|
||||
type SYBProductFilterRecomputeLog struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Operator string `json:"operator" gorm:"size:128;not null;default:''"`
|
||||
ExcludedToPDD int `json:"excludedToPdd" gorm:"not null;default:0"`
|
||||
PDDToExcluded int `json:"pddToExcluded" gorm:"not null;default:0"`
|
||||
SkippedHasTask int `json:"skippedHasTask" gorm:"not null;default:0"`
|
||||
SkippedReturnMatch int `json:"skippedReturnMatch" gorm:"not null;default:0"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (SYBProductFilterRecomputeLog) TableName() string { return "syb_product_filter_recompute_log" }
|
||||
|
||||
func (SYBSession) TableName() string { return "syb_session" }
|
||||
|
||||
// SYBShop is the list of SYB shops whose shipment orders are imported (#49).
|
||||
|
||||
@@ -131,6 +131,9 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
if !found {
|
||||
return disabled("SYB 商品不存在或已删除")
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
return disabled("该商品已标记为无需 PDD 采购")
|
||||
}
|
||||
if strings.TrimSpace(syb.TargetColor) == "" && strings.TrimSpace(syb.TargetSize) == "" {
|
||||
return disabled("未解析出需要采购的颜色或尺码")
|
||||
}
|
||||
|
||||
@@ -399,6 +399,14 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
|
||||
}
|
||||
item.OrderCode, item.ShopeeProductID, item.ShopeeItemID = syb.OrderCode, syb.ShopeeProductID, syb.ShopeeItemID
|
||||
item.ProductTitle, item.TargetColor, item.TargetSize, item.Quantity = syb.ProductTitle, syb.TargetColor, syb.TargetSize, syb.Quantity
|
||||
// #340: hard-exclude before anything else in the pipeline. This preview
|
||||
// is the single computation shared by BatchPreview, BatchCreate's
|
||||
// pre-check and quick-replace's ValidateQuickReplacement, so this one
|
||||
// check covers all of them.
|
||||
if syb.PDDExcluded {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodePDDExcluded, "该商品已标记为无需 PDD 采购", ""
|
||||
return item
|
||||
}
|
||||
if !sybSpecsTrusted(syb) {
|
||||
item.ReasonCode, item.NextAction = "SYB_PARSE_FAILED", "reparse"
|
||||
if syb.ParseStatus == models.SYBParseStatusUncertain {
|
||||
|
||||
@@ -9,6 +9,10 @@ func (item *BatchPreviewItem) applyCollectionEligibility(id uint64, dataset batc
|
||||
item.CollectionDisabledReason = "SYB 商品不存在或已删除"
|
||||
return
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
item.CollectionDisabledReason = "该商品已标记为无需 PDD 采购"
|
||||
return
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
item.CollectionDisabledReason = "尚未关联蝦皮商品"
|
||||
return
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// #340: a SYB product marked pdd_purchase_excluded must be hard-rejected by
|
||||
// every PDD entry point, mirroring #338's return-match block tests above.
|
||||
|
||||
func TestCreate_RejectsWhenPDDExcluded(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := createLive(t, s, f)
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection, got success")
|
||||
}
|
||||
if se, ok := asServiceError(err); !ok || se.Code != CodePDDExcluded {
|
||||
t.Fatalf("expected CodePDDExcluded, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreview_ExcludedRowReportsNotEligible(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("preview call itself must not fail: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(resp.Items))
|
||||
}
|
||||
item := resp.Items[0]
|
||||
if item.Eligible {
|
||||
t.Fatalf("excluded row must not be eligible: %+v", item)
|
||||
}
|
||||
if item.CollectionEligible {
|
||||
t.Fatalf("excluded row must not be collection-eligible: %+v", item)
|
||||
}
|
||||
if item.AIMatchEligible {
|
||||
t.Fatalf("excluded row must not be AI-match-eligible: %+v", item)
|
||||
}
|
||||
if item.ReasonCode != CodePDDExcluded {
|
||||
t.Fatalf("expected CodePDDExcluded reason, got %+v", item)
|
||||
}
|
||||
if item.ProcessStage != ProcessStagePDDExcluded {
|
||||
t.Fatalf("expected pdd_excluded stage, got %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_SkipsExcludedRowNotWholeBatch(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f1 := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f1.pdd.ID)
|
||||
|
||||
syb2 := models.SYBProduct{OrderCode: "SYB-EXCL-2", DetailID: 2, StockID: 3, ShopeeItemID: f1.shopee.ShopeeItemID, ShopeeProductID: &f1.shopee.ID, ProductTitle: f1.shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 1, UnitPriceCent: 2000, ImageURL: "", ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`}
|
||||
if err := db.Create(&syb2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f1.syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchCreate(context.Background(), BatchCreateRequest{
|
||||
RequestID: uuid.NewString(), SYBProductIDs: []uint64{f1.syb.ID, syb2.ID}, DeviceID: &f1.device.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch call itself must not fail: %v", err)
|
||||
}
|
||||
if resp.CreatedCount != 1 || resp.FailedCount != 1 {
|
||||
t.Fatalf("expected 1 created + 1 failed, got created=%d failed=%d items=%+v", resp.CreatedCount, resp.FailedCount, resp.Items)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
if item.SYBProductID == f1.syb.ID && item.Created {
|
||||
t.Fatalf("excluded row must not be created: %+v", item)
|
||||
}
|
||||
if item.SYBProductID == syb2.ID && !item.Created {
|
||||
t.Fatalf("clean row must still be created: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessStage_ExcludedRowStage covers priority: active return match wins
|
||||
// over the exclusion mark; once the match is cancelled the stage falls back
|
||||
// to pdd_excluded (#340 decision 3).
|
||||
func TestProcessStage_ExcludedRowStagePriority(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stages, err := s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stages[f.syb.ID].Stage != ProcessStagePDDExcluded {
|
||||
t.Fatalf("expected pdd_excluded, got %+v", stages[f.syb.ID])
|
||||
}
|
||||
|
||||
match := models.ReturnMatch{
|
||||
SYBProductID: f.syb.ID, YeekeReturnItemID: 1,
|
||||
ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusMatched,
|
||||
MatchedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&match).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stages, err = s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stages[f.syb.ID].Stage != ProcessStageReturnPending {
|
||||
t.Fatalf("active return match must win over exclusion mark, got %+v", stages[f.syb.ID])
|
||||
}
|
||||
|
||||
// Cancel: no more active match -> falls back to pdd_excluded, not the
|
||||
// normal pipeline.
|
||||
if err := db.Model(&models.ReturnMatch{}).Where("id = ?", match.ID).
|
||||
Updates(map[string]any{"active_syb_product_id": nil, "status": models.ReturnMatchStatusCancelled}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stages, err = s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stages[f.syb.ID].Stage != ProcessStagePDDExcluded {
|
||||
t.Fatalf("after cancel, stage should return to pdd_excluded, got %+v", stages[f.syb.ID])
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,12 @@ const (
|
||||
// to (see rejectIfActiveReturnMatch and processStageFromDataset below).
|
||||
ProcessStageReturnPending = "return_pending"
|
||||
ProcessStageReturnUsed = "return_used"
|
||||
processActionOpenPDDLink = "open_pdd_link"
|
||||
// ProcessStagePDDExcluded is #340's stage for a SYB product marked
|
||||
// pdd_purchase_excluded: it never needs a PDD purchase. It only yields to
|
||||
// an active return match (checked first, same priority order #338 uses
|
||||
// for the pipeline below it) — see processStageFromDataset.
|
||||
ProcessStagePDDExcluded = "pdd_excluded"
|
||||
processActionOpenPDDLink = "open_pdd_link"
|
||||
)
|
||||
|
||||
var processStageLabels = map[string]string{
|
||||
@@ -43,6 +48,7 @@ var processStageLabels = map[string]string{
|
||||
ProcessStageOrderReview: "待人工核对",
|
||||
ProcessStageReturnPending: "退货待确认",
|
||||
ProcessStageReturnUsed: "已用退货",
|
||||
ProcessStagePDDExcluded: "无需采购",
|
||||
}
|
||||
|
||||
type ProcessStageResult struct {
|
||||
@@ -151,6 +157,13 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
|
||||
return stage(ProcessStageReturnPending, "已匹配退货待人工确认,暂不能创建采购任务", "open_return_match")
|
||||
}
|
||||
|
||||
// #340: an excluded row's mark also only overrides after the active-task
|
||||
// and active-return-match checks above, and before every other pipeline
|
||||
// branch below.
|
||||
if syb, ok := dataset.sybByID[id]; ok && syb.PDDExcluded {
|
||||
return stage(ProcessStagePDDExcluded, "顺云宝导入时已按过滤规则标记为无需 PDD 采购", "")
|
||||
}
|
||||
|
||||
syb, ok := dataset.sybByID[id]
|
||||
if !ok {
|
||||
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
|
||||
|
||||
@@ -28,3 +28,14 @@ func rejectIfActiveReturnMatch(tx *gorm.DB, sybProductID uint64) error {
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
|
||||
// rejectIfPDDExcluded implements #340's hard purchase-creation block: a SYB
|
||||
// product marked pdd_purchase_excluded must never get a purchase task,
|
||||
// single or batch (BatchCreate calls s.Create per row, so this one check
|
||||
// point covers both, mirroring rejectIfActiveReturnMatch above).
|
||||
func rejectIfPDDExcluded(syb models.SYBProduct) error {
|
||||
if syb.PDDExcluded {
|
||||
return fail(CodePDDExcluded, "该商品已标记为无需 PDD 采购,不能创建采购任务")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -181,6 +181,9 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
if err := rejectIfActiveReturnMatch(tx, syb.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectIfPDDExcluded(syb); err != nil {
|
||||
return err
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品")
|
||||
}
|
||||
|
||||
@@ -41,7 +41,14 @@ const (
|
||||
// tasks for such a product until the match is cancelled. Resuming or
|
||||
// reparsing an existing task is not affected.
|
||||
CodeReturnMatched = "PURCHASE_RETURN_MATCHED"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
// CodePDDExcluded is returned by every PDD entry point (purchase create,
|
||||
// collection task creation, AI spec match, image search collection,
|
||||
// quick-replace, ...) when the SYB product is marked
|
||||
// pdd_purchase_excluded (#340): it hit a product filter rule at import
|
||||
// time and is recorded as 「无需 PDD 采购」, so it must never start any
|
||||
// PDD flow, regardless of the rest of its pipeline state.
|
||||
CodePDDExcluded = "PURCHASE_PDD_EXCLUDED"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
type ServiceError struct {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// #340: a pdd_purchase_excluded SYB product still needs no PDD purchase but
|
||||
// must remain eligible to take part in return matching.
|
||||
func TestBatchMatch_ExcludedStageParticipates(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) }
|
||||
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-EXCL-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 1 || len(resp.Items) != 1 || !resp.Items[0].Matched {
|
||||
t.Fatalf("excluded-stage row must still be eligible for return matching: %+v", resp)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,12 @@ var participatingStages = map[string]bool{
|
||||
purchase.ProcessStagePDDCollectionFail: true,
|
||||
purchase.ProcessStageColorMapping: true,
|
||||
purchase.ProcessStagePurchaseReady: true,
|
||||
// #340: a pdd_purchase_excluded row still needs no PDD purchase and can
|
||||
// still take part in return matching — matching it just confirms there is
|
||||
// nothing left to buy back. After a cancel it returns to pdd_excluded,
|
||||
// which stays true here (see processStageFromDataset), so the row is
|
||||
// eligible again the same way any other participating stage is.
|
||||
purchase.ProcessStagePDDExcluded: true,
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
@@ -41,6 +42,18 @@ type DetailInput struct {
|
||||
ProductTitle string
|
||||
ProductThumb uint64
|
||||
Raw json.RawMessage
|
||||
|
||||
// Excluded and the ExcludedRule* fields are #340's product-filter mark:
|
||||
// Excluded reports whether this line hit an enabled filter rule during
|
||||
// THIS sync's matching, and the ExcludedRule* fields are a snapshot of
|
||||
// that rule. They are only applied when ApplyDetail is about to CREATE a
|
||||
// new syb_product row (decision 4: a later sync of an existing row must
|
||||
// never change its existing mark, even if the rules or the match outcome
|
||||
// changed since).
|
||||
Excluded bool
|
||||
ExcludedRuleID *uint64
|
||||
ExcludedRuleKind string
|
||||
ExcludedRuleKeyword string
|
||||
}
|
||||
|
||||
// ApplyResult reports what ApplyDetail actually did, for the import-result
|
||||
@@ -138,12 +151,32 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
|
||||
err = tx.Where("order_code = ? AND detail_id = ?", order.Code, detail.ID).First(&existing).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
// #340 decision 4: the mark is set only when the row is first
|
||||
// created, from this sync's own filter match — never on update.
|
||||
if detail.Excluded {
|
||||
now := time.Now().UTC()
|
||||
record.PDDExcluded = true
|
||||
record.ExcludedRuleID = detail.ExcludedRuleID
|
||||
record.ExcludedRuleKind = detail.ExcludedRuleKind
|
||||
record.ExcludedRuleKeyword = detail.ExcludedRuleKeyword
|
||||
record.ExcludedAt = &now
|
||||
}
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.Outcome = OutcomeCreated
|
||||
case err == nil:
|
||||
record.ID = existing.ID
|
||||
// #340 decision 4: the exclusion mark is decided once, at row
|
||||
// creation. A resync of an already-existing row must never
|
||||
// change it (nor is it included in `updates` below), but the
|
||||
// returned/in-memory record must still reflect the existing
|
||||
// mark rather than the zero value this fresh struct starts with.
|
||||
record.PDDExcluded = existing.PDDExcluded
|
||||
record.ExcludedRuleID = existing.ExcludedRuleID
|
||||
record.ExcludedRuleKind = existing.ExcludedRuleKind
|
||||
record.ExcludedRuleKeyword = existing.ExcludedRuleKeyword
|
||||
record.ExcludedAt = existing.ExcludedAt
|
||||
// Human-confirmed target values are authoritative and survive every
|
||||
// source re-import. ParseStatus/ParseNote below still record what the
|
||||
// current deterministic parser observed for audit.
|
||||
|
||||
@@ -47,7 +47,7 @@ func (handler Handler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), ListRequest{
|
||||
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")),
|
||||
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")), PurchaseType: strings.TrimSpace(c.Query("purchaseType")),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package sybimport_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/sybimport"
|
||||
)
|
||||
|
||||
// #340: List's purchaseType filter combines with processStage as AND.
|
||||
func TestServiceListPurchaseType(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
pdd, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA())
|
||||
if err != nil {
|
||||
t.Fatalf("apply pdd row: %v", err)
|
||||
}
|
||||
excludedOrder := realOrder()
|
||||
excludedOrder.Code = "260728EXCL"
|
||||
excludedOrder.StockID++
|
||||
excludedDetail := realDetailB()
|
||||
excludedDetail.ID++
|
||||
excludedDetail.Excluded = true
|
||||
ruleID := uint64(1)
|
||||
excludedDetail.ExcludedRuleID = &ruleID
|
||||
excludedDetail.ExcludedRuleKind = "keyword"
|
||||
excludedDetail.ExcludedRuleKeyword = "档口"
|
||||
excluded, err := sybimport.ApplyDetail(context.Background(), db, excludedOrder, excludedDetail)
|
||||
if err != nil {
|
||||
t.Fatalf("apply excluded row: %v", err)
|
||||
}
|
||||
if !excluded.SYBProduct.PDDExcluded {
|
||||
t.Fatalf("seed row was not marked excluded")
|
||||
}
|
||||
service := sybimport.NewService(db)
|
||||
|
||||
defaultResp, err := service.List(context.Background(), sybimport.ListRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if defaultResp.Total != 1 || defaultResp.Items[0].ID != pdd.SYBProduct.ID {
|
||||
t.Fatalf("default purchaseType must show only pdd rows: %+v", defaultResp)
|
||||
}
|
||||
|
||||
pddResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypePDD})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pddResp.Total != 1 || pddResp.Items[0].ID != pdd.SYBProduct.ID {
|
||||
t.Fatalf("explicit pdd purchaseType mismatch: %+v", pddResp)
|
||||
}
|
||||
|
||||
excludedResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypeExcluded})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if excludedResp.Total != 1 || excludedResp.Items[0].ID != excluded.SYBProduct.ID {
|
||||
t.Fatalf("excluded purchaseType mismatch: %+v", excludedResp)
|
||||
}
|
||||
|
||||
allResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypeAll})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if allResp.Total != 2 {
|
||||
t.Fatalf("all purchaseType must show both rows, got %+v", allResp)
|
||||
}
|
||||
|
||||
if _, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: "bogus"}); err == nil {
|
||||
t.Fatalf("invalid purchaseType must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package sybimport_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
)
|
||||
|
||||
// #340: applyStockDetail no longer skips a filter-hit row; it stores it and
|
||||
// marks it. These tests exercise the same public entry point (ApplyDetail)
|
||||
// applyStockDetail calls, with the Excluded fields it now always passes in.
|
||||
|
||||
func TestApplyDetailMarksExcludedRowOnCreate(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order := sybimport.OrderInput{Code: "EXCL-ORDER", StockID: 1, ShopName: "测试店铺"}
|
||||
ruleID := uint64(9)
|
||||
raw, _ := json.Marshal(map[string]any{"variationSku": "档口-123"})
|
||||
result, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{
|
||||
ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t",
|
||||
Raw: raw,
|
||||
Excluded: true,
|
||||
ExcludedRuleID: &ruleID,
|
||||
ExcludedRuleKind: "keyword",
|
||||
ExcludedRuleKeyword: "档口",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDetail: %v", err)
|
||||
}
|
||||
if !result.SYBProduct.PDDExcluded {
|
||||
t.Fatalf("expected row to be marked excluded")
|
||||
}
|
||||
if result.SYBProduct.ExcludedRuleID == nil || *result.SYBProduct.ExcludedRuleID != ruleID {
|
||||
t.Fatalf("expected excluded rule id snapshot %d, got %v", ruleID, result.SYBProduct.ExcludedRuleID)
|
||||
}
|
||||
if result.SYBProduct.ExcludedRuleKind != "keyword" || result.SYBProduct.ExcludedRuleKeyword != "档口" {
|
||||
t.Fatalf("unexpected rule snapshot: %+v", result.SYBProduct)
|
||||
}
|
||||
if result.SYBProduct.ExcludedAt == nil {
|
||||
t.Fatalf("expected excludedAt to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDetailNonHitRowIsNotMarked(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order := sybimport.OrderInput{Code: "OK-ORDER", StockID: 1, ShopName: "测试店铺"}
|
||||
raw, _ := json.Marshal(map[string]any{"variationSku": ""})
|
||||
result, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{
|
||||
ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t",
|
||||
Raw: raw,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDetail: %v", err)
|
||||
}
|
||||
if result.SYBProduct.PDDExcluded {
|
||||
t.Fatalf("expected row not to be marked excluded")
|
||||
}
|
||||
if result.SYBProduct.ExcludedRuleID != nil || result.SYBProduct.ExcludedAt != nil {
|
||||
t.Fatalf("expected no rule snapshot on a non-hit row: %+v", result.SYBProduct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyDetailResyncKeepsMarkEvenIfRulesChanged is decision 4: the mark
|
||||
// is fixed at row-creation time. A later sync of the SAME existing row must
|
||||
// not flip it even when it is re-applied with a different Excluded value
|
||||
// (representing a rule that started/stopped matching since).
|
||||
func TestApplyDetailResyncKeepsMarkEvenIfRulesChanged(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order := sybimport.OrderInput{Code: "RESYNC-ORDER", StockID: 1, ShopName: "测试店铺"}
|
||||
ruleID := uint64(1)
|
||||
raw, _ := json.Marshal(map[string]any{"variationSku": "档口-123"})
|
||||
first, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{
|
||||
ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t",
|
||||
Raw: raw, Excluded: true, ExcludedRuleID: &ruleID, ExcludedRuleKind: "keyword", ExcludedRuleKeyword: "档口",
|
||||
})
|
||||
if err != nil || !first.SYBProduct.PDDExcluded {
|
||||
t.Fatalf("seed create failed: %v %+v", err, first.SYBProduct)
|
||||
}
|
||||
|
||||
// Re-sync the same detail, this time with the rule disabled (no hit).
|
||||
second, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{
|
||||
ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t",
|
||||
Raw: raw, Excluded: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDetail update: %v", err)
|
||||
}
|
||||
if second.Outcome != sybimport.OutcomeUpdated {
|
||||
t.Fatalf("expected update outcome, got %s", second.Outcome)
|
||||
}
|
||||
if !second.SYBProduct.PDDExcluded {
|
||||
t.Fatalf("expected existing mark to be preserved across resync, got unmarked: %+v", second.SYBProduct)
|
||||
}
|
||||
|
||||
var stored models.SYBProduct
|
||||
if err := db.Where("order_code = ? AND detail_id = ?", order.Code, uint64(1)).First(&stored).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if !stored.PDDExcluded || stored.ExcludedRuleKeyword != "档口" {
|
||||
t.Fatalf("mark was changed by resync: %+v", stored)
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,21 @@ type ListRequest struct {
|
||||
ProcessStage string
|
||||
CreatedFrom string
|
||||
CreatedTo string
|
||||
// PurchaseType is #340's list-side isolation filter: "pdd" (default when
|
||||
// empty) shows only rows that still need a PDD purchase,
|
||||
// "excluded" shows only pdd_purchase_excluded rows, "all" shows both. It
|
||||
// combines with ProcessStage and the created-time range as AND; the
|
||||
// auto-switch to 全部 mentioned in the issue is a front-end behaviour,
|
||||
// not a server default.
|
||||
PurchaseType string
|
||||
}
|
||||
|
||||
const (
|
||||
PurchaseTypePDD = "pdd"
|
||||
PurchaseTypeExcluded = "excluded"
|
||||
PurchaseTypeAll = "all"
|
||||
)
|
||||
|
||||
type ListResponse struct {
|
||||
Items []models.SYBProduct `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
@@ -105,6 +118,16 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
if !purchase.ValidProcessStage(request.ProcessStage) {
|
||||
return ListResponse{}, invalidRequest("processStage 无效")
|
||||
}
|
||||
request.PurchaseType = strings.TrimSpace(request.PurchaseType)
|
||||
switch request.PurchaseType {
|
||||
case "", PurchaseTypePDD:
|
||||
query = query.Where("pdd_purchase_excluded = ?", false)
|
||||
case PurchaseTypeExcluded:
|
||||
query = query.Where("pdd_purchase_excluded = ?", true)
|
||||
case PurchaseTypeAll:
|
||||
default:
|
||||
return ListResponse{}, invalidRequest("purchaseType 无效")
|
||||
}
|
||||
if request.ProcessStage != "" {
|
||||
var candidates []models.SYBProduct
|
||||
if err := query.Order("updated_at DESC, id DESC").Find(&candidates).Error; err != nil {
|
||||
|
||||
@@ -395,12 +395,16 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
|
||||
}
|
||||
for _, item := range detail.Details {
|
||||
variation := stringField(item.Raw, "variationSku")
|
||||
// `[必须]` #340: a filter hit no longer skips the row. It is still
|
||||
// stored, marked with a rule snapshot, and left to the hard PDD
|
||||
// exclusion guards in purchase/task/returnmatch. Char/keyword hit
|
||||
// counts and per-rule hits (#269) keep the same meaning — "marked",
|
||||
// not "skipped" — so the disable-confirmation dialog's figures are
|
||||
// still accurate.
|
||||
var excluded bool
|
||||
var excludedRuleID *uint64
|
||||
var excludedRuleKind, excludedRuleKeyword string
|
||||
if rule := filters.Match(variation); rule != nil {
|
||||
// `[必须]` Count the two kinds separately and per rule. A combined
|
||||
// total would hide a structural rule that stopped matching because
|
||||
// 档口 changed its code format, and a per-kind total would make the
|
||||
// disable-confirmation dialog quote the same figure for "#" and "-"
|
||||
// even though they match very different numbers of rows (#269).
|
||||
if rule.Kind == "char" {
|
||||
report.CharFilterSkipped++
|
||||
} else {
|
||||
@@ -409,7 +413,11 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
|
||||
if report.filterHits != nil {
|
||||
report.filterHits.Add(rule)
|
||||
}
|
||||
continue
|
||||
excluded = true
|
||||
ruleID := rule.ID
|
||||
excludedRuleID = &ruleID
|
||||
excludedRuleKind = rule.Kind
|
||||
excludedRuleKeyword = rule.Keyword
|
||||
}
|
||||
raw, err := json.Marshal(item.Raw)
|
||||
if err != nil {
|
||||
@@ -424,6 +432,9 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
|
||||
ProductTitle: item.ProductTitle,
|
||||
ProductThumb: uint64(item.ProductThumb),
|
||||
Raw: raw,
|
||||
|
||||
Excluded: excluded, ExcludedRuleID: excludedRuleID,
|
||||
ExcludedRuleKind: excludedRuleKind, ExcludedRuleKeyword: excludedRuleKeyword,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入货运单 %s 明细 %d 失败(本次同步停止;已写入的数据保留): %w",
|
||||
|
||||
@@ -68,6 +68,9 @@ type fakeSYB struct {
|
||||
// 用于构造「列表说是 A 店、明细说是 B 店」的不一致。
|
||||
detailShopName string
|
||||
blankDetailShopName bool
|
||||
// detailVariationSku, when non-empty, is used as every detail's
|
||||
// variationSku (#340 filter-marking tests).
|
||||
detailVariationSku string
|
||||
}
|
||||
|
||||
func (f *fakeSYB) shopFor(i int) string {
|
||||
@@ -142,7 +145,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
"shopName": detailShopName,
|
||||
"details": []any{map[string]any{
|
||||
"id": float64(id*10 + 1), "productId": float64(9001),
|
||||
"productTitle": "测试商品", "productSpec": "白色,L",
|
||||
"productTitle": "测试商品", "productSpec": "白色,L", "variationSku": f.detailVariationSku,
|
||||
"productQty": float64(2), "productPrice": 39.5, "productThumb": float64(77),
|
||||
}},
|
||||
})
|
||||
@@ -466,3 +469,64 @@ func TestSyncRejectsBlankShopOnDetailResponse(t *testing.T) {
|
||||
report.DetailCount, report.AcceptedCount, report.ShopSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
// #340: a filter hit no longer skips the row — it is still stored, counted
|
||||
// (as "marked", same counters as before) and left for the hard PDD exclusion
|
||||
// guards elsewhere in the pipeline to enforce.
|
||||
func TestSyncStoresAndMarksFilterHitRowsInsteadOfSkipping(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
if err := db.Create(&models.SYBProductFilter{
|
||||
Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed filter: %v", err)
|
||||
}
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 2}, detailVariationSku: "档口-99"}
|
||||
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
|
||||
if err != nil {
|
||||
t.Fatalf("同步失败: %v", err)
|
||||
}
|
||||
if report.KeywordFilterSkipped != 2 {
|
||||
t.Fatalf("expected 2 marked hits, got %d", report.KeywordFilterSkipped)
|
||||
}
|
||||
if report.DetailCount != 2 || report.Created != 2 {
|
||||
t.Fatalf("filter hits must still be stored: detailCount=%d created=%d", report.DetailCount, report.Created)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.SYBProduct{}).Where("pdd_purchase_excluded = ?", true).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 rows marked excluded, got %d", count)
|
||||
}
|
||||
var filter models.SYBProductFilter
|
||||
if err := db.Where("keyword = ?", "档口").First(&filter).Error; err != nil {
|
||||
t.Fatalf("reload filter: %v", err)
|
||||
}
|
||||
if filter.LastHitCount == nil || *filter.LastHitCount != 2 {
|
||||
t.Fatalf("expected rule's own hit count to be 2, got %v", filter.LastHitCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncNonHitRowsAreNotMarked(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
if err := db.Create(&models.SYBProductFilter{
|
||||
Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed filter: %v", err)
|
||||
}
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 1}, detailVariationSku: "普通-1"}
|
||||
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
|
||||
if err != nil {
|
||||
t.Fatalf("同步失败: %v", err)
|
||||
}
|
||||
if report.KeywordFilterSkipped != 0 {
|
||||
t.Fatalf("unexpected marked hits: %d", report.KeywordFilterSkipped)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.SYBProduct{}).Where("pdd_purchase_excluded = ?", true).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("no rows should be marked excluded, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,46 @@ func (h Handler) SetEnabled(c *gin.Context) {
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": x})
|
||||
}
|
||||
|
||||
// RecomputePreview and RecomputeExecute implement #340 decision 7's admin-only
|
||||
// "按当前规则重新计算" action. Both are gated by middleware.RequireRoleKey
|
||||
// ("admin") at the router, the same admin gate this package already uses for
|
||||
// Create/SetEnabled/Delete.
|
||||
func (h Handler) RecomputePreview(c *gin.Context) {
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
counts, e := s.RecomputePreview(c.Request.Context())
|
||||
if e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": counts})
|
||||
}
|
||||
|
||||
type recomputeExecuteBody struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
func (h Handler) RecomputeExecute(c *gin.Context) {
|
||||
var body recomputeExecuteBody
|
||||
if !decode(c, &body) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := jwt.ExtractClaims(c)
|
||||
name, _ := claims["nice"].(string)
|
||||
result, e := s.RecomputeExecute(c.Request.Context(), name, body.Fingerprint)
|
||||
if e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": result})
|
||||
}
|
||||
func (h Handler) Delete(c *gin.Context) {
|
||||
id, ok := idParam(c)
|
||||
if !ok {
|
||||
@@ -120,6 +160,8 @@ func writeError(c *gin.Context, e error) {
|
||||
status = 422
|
||||
case CodeConflict:
|
||||
status = 409
|
||||
case CodeRecomputeStale:
|
||||
status = 409
|
||||
case CodeNotFound:
|
||||
status = 404
|
||||
case CodeForbidden:
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// RecomputeCounts is shared by preview and execute (#340 decision 7). The
|
||||
// preview's counts are a PLAN computed against an unlocked read; the
|
||||
// execute's counts are what was ACTUALLY written, re-checked row by row
|
||||
// under lock (phase 3 review fix) — the two normally agree, but execute's
|
||||
// counts are authoritative when something changed concurrently between the
|
||||
// two calls (a purchase task created, a return matched, or the same row
|
||||
// recomputed by another admin).
|
||||
type RecomputeCounts struct {
|
||||
ExcludedToPDD int `json:"excludedToPdd"`
|
||||
PDDToExcluded int `json:"pddToExcluded"`
|
||||
SkippedHasTask int `json:"skippedHasTask"`
|
||||
SkippedReturnMatch int `json:"skippedReturnMatch"`
|
||||
}
|
||||
|
||||
type recomputeChange struct {
|
||||
id uint64
|
||||
orderCode string
|
||||
shopeeItemID string
|
||||
toExcluded bool // true: pdd -> excluded; false: excluded -> pdd
|
||||
ruleID *uint64
|
||||
ruleKind, ruleKeyword string
|
||||
}
|
||||
|
||||
// recomputeSourceRow selects only the columns recomputeChanges needs (phase 3
|
||||
// review item 4) instead of the full syb_product row.
|
||||
type recomputeSourceRow struct {
|
||||
ID uint64 `gorm:"column:id"`
|
||||
OrderCode string `gorm:"column:order_code"`
|
||||
ShopeeItemID string `gorm:"column:shopee_item_id"`
|
||||
RawJSON string `gorm:"column:raw_json"`
|
||||
PDDExcluded bool `gorm:"column:pdd_purchase_excluded"`
|
||||
}
|
||||
|
||||
// RecomputeSample is one preview row (#340 phase 2): up to maxRecomputeSamples
|
||||
// changes are shown so an admin can sanity-check the run before executing it.
|
||||
type RecomputeSample struct {
|
||||
OrderCode string `json:"orderCode"`
|
||||
ShopeeItemID string `json:"shopeeItemId"`
|
||||
// Direction is "excluded_to_pdd" or "pdd_to_excluded".
|
||||
Direction string `json:"direction"`
|
||||
// RuleKind/RuleKeyword are empty when Direction is excluded_to_pdd (no
|
||||
// rule matches any more).
|
||||
RuleKind string `json:"ruleKind,omitempty"`
|
||||
RuleKeyword string `json:"ruleKeyword,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
DirectionExcludedToPDD = "excluded_to_pdd"
|
||||
DirectionPDDToExcluded = "pdd_to_excluded"
|
||||
maxRecomputeSamples = 20
|
||||
)
|
||||
|
||||
// recomputeChanges computes, against the CURRENT enabled rules, every
|
||||
// syb_product row whose mark should flip, skipping any row that has ever had
|
||||
// a purchase task or currently has an active return match — those never
|
||||
// change (#340 decision 7). This is a PLAN: preview uses it directly, and
|
||||
// execute uses it as the candidate list, re-checking each row under lock in
|
||||
// writeRecomputeChanges before actually writing (phase 3 review item 1).
|
||||
func recomputeChanges(ctx context.Context, tx *gorm.DB) (RecomputeCounts, []recomputeChange, error) {
|
||||
filters, err := LoadEnabled(ctx, tx)
|
||||
if err != nil {
|
||||
return RecomputeCounts{}, nil, err
|
||||
}
|
||||
var rows []recomputeSourceRow
|
||||
if err := tx.WithContext(ctx).Model(&models.SYBProduct{}).
|
||||
Select("id, order_code, shopee_item_id, raw_json, pdd_purchase_excluded").
|
||||
Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return RecomputeCounts{}, nil, err
|
||||
}
|
||||
hasTask := make(map[uint64]bool)
|
||||
var taskSYBIDs []uint64
|
||||
if err := tx.WithContext(ctx).Table("purchase_task").Distinct("syb_product_id").
|
||||
Where("syb_product_id IS NOT NULL").Pluck("syb_product_id", &taskSYBIDs).Error; err != nil {
|
||||
return RecomputeCounts{}, nil, err
|
||||
}
|
||||
for _, id := range taskSYBIDs {
|
||||
hasTask[id] = true
|
||||
}
|
||||
activeReturnMatch := make(map[uint64]bool)
|
||||
var matchedSYBIDs []uint64
|
||||
if err := tx.WithContext(ctx).Table("return_match").Where("active_syb_product_id IS NOT NULL").Pluck("syb_product_id", &matchedSYBIDs).Error; err != nil {
|
||||
return RecomputeCounts{}, nil, err
|
||||
}
|
||||
for _, id := range matchedSYBIDs {
|
||||
activeReturnMatch[id] = true
|
||||
}
|
||||
|
||||
counts := RecomputeCounts{}
|
||||
changes := make([]recomputeChange, 0)
|
||||
for _, row := range rows {
|
||||
variation := recomputeVariationSku(row.RawJSON)
|
||||
rule := filters.Match(variation)
|
||||
wouldExclude := rule != nil
|
||||
if wouldExclude == row.PDDExcluded {
|
||||
continue
|
||||
}
|
||||
if hasTask[row.ID] {
|
||||
counts.SkippedHasTask++
|
||||
continue
|
||||
}
|
||||
if activeReturnMatch[row.ID] {
|
||||
counts.SkippedReturnMatch++
|
||||
continue
|
||||
}
|
||||
change := recomputeChange{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: wouldExclude}
|
||||
if wouldExclude {
|
||||
ruleID := rule.ID
|
||||
change.ruleID, change.ruleKind, change.ruleKeyword = &ruleID, rule.Kind, rule.Keyword
|
||||
counts.PDDToExcluded++
|
||||
} else {
|
||||
counts.ExcludedToPDD++
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
return counts, changes, nil
|
||||
}
|
||||
|
||||
// recomputeVariationSku mirrors sybimport.stringField(item.Raw, "variationSku")
|
||||
// without importing that package (sybimport already imports this one).
|
||||
func recomputeVariationSku(rawJSON string) string {
|
||||
if strings.TrimSpace(rawJSON) == "" {
|
||||
return ""
|
||||
}
|
||||
var raw map[string]any
|
||||
if json.Unmarshal([]byte(rawJSON), &raw) != nil {
|
||||
return ""
|
||||
}
|
||||
value, _ := raw["variationSku"].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
// recomputeFingerprintEntry is one change's canonical, unambiguous
|
||||
// representation for hashing (#340 phase 4 review item 1). It is JSON, not
|
||||
// naive string concatenation: a naive "id:direction:ruleId" (or any other
|
||||
// delimiter-joined string) can collide between two different plans whenever
|
||||
// a field's own text can contain the delimiter or vary in length — e.g. a
|
||||
// rule keyword containing ":" or newlines could make two distinct plans hash
|
||||
// identically. encoding/json's field ordering for a fixed struct is stable,
|
||||
// so this is both deterministic and injective for our purposes.
|
||||
type recomputeFingerprintEntry struct {
|
||||
ID uint64 `json:"id"`
|
||||
Direction string `json:"direction"`
|
||||
RuleID uint64 `json:"ruleId"`
|
||||
RuleKind string `json:"ruleKind"`
|
||||
RuleKeyword string `json:"ruleKeyword"`
|
||||
}
|
||||
|
||||
// recomputeFingerprint binds a preview to the exact plan it showed, INCLUDING
|
||||
// the rule evidence that will be written to excluded_rule_kind/
|
||||
// excluded_rule_keyword (#340 phase 4 review item 1): two plans that flip the
|
||||
// exact same id+direction but via a different (or since-edited) rule must
|
||||
// hash differently, because RecomputeExecute is about to persist exactly
|
||||
// this rule kind/keyword as this row's excluded_rule_* snapshot — a
|
||||
// fingerprint that ignored them could let a stale plan through unnoticed
|
||||
// whenever a rule's keyword/kind changed between preview and execute but the
|
||||
// set of affected ids/directions happened to stay the same. For the
|
||||
// excluded_to_pdd direction there is no rule (the row is losing its mark),
|
||||
// so RuleID/RuleKind/RuleKeyword are left at their zero values, matching what
|
||||
// gets written (nil/""/"").
|
||||
//
|
||||
// It is a sha256 over the JSON-encoded, sorted (by id, then direction) list
|
||||
// of recomputeFingerprintEntry — sorting the decoded entries themselves
|
||||
// (not pre-serialized strings) keeps the ordering rule obviously correct
|
||||
// regardless of how any field is later escaped.
|
||||
func recomputeFingerprint(changes []recomputeChange) string {
|
||||
entries := make([]recomputeFingerprintEntry, 0, len(changes))
|
||||
for _, change := range changes {
|
||||
entry := recomputeFingerprintEntry{ID: change.id}
|
||||
if change.toExcluded {
|
||||
entry.Direction = DirectionPDDToExcluded
|
||||
if change.ruleID != nil {
|
||||
entry.RuleID = *change.ruleID
|
||||
}
|
||||
entry.RuleKind = change.ruleKind
|
||||
entry.RuleKeyword = change.ruleKeyword
|
||||
} else {
|
||||
entry.Direction = DirectionExcludedToPDD
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].ID != entries[j].ID {
|
||||
return entries[i].ID < entries[j].ID
|
||||
}
|
||||
return entries[i].Direction < entries[j].Direction
|
||||
})
|
||||
// Marshal errors are impossible here (every field is a plain string/uint64
|
||||
// with no cycles), so it is safe to ignore the error and hash whatever
|
||||
// was produced rather than plumb an error return through every caller.
|
||||
payload, _ := json.Marshal(entries)
|
||||
sum := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// RecomputePreviewResult is preview-only: it carries the same RecomputeCounts
|
||||
// execute would produce absent any race, plus up to maxRecomputeSamples
|
||||
// sample rows, and the Fingerprint execute requires to prove nothing moved
|
||||
// between preview and execute.
|
||||
type RecomputePreviewResult struct {
|
||||
RecomputeCounts
|
||||
Samples []RecomputeSample `json:"samples"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
// RecomputePreview is read-only.
|
||||
func (s *Service) RecomputePreview(ctx context.Context) (RecomputePreviewResult, error) {
|
||||
counts, changes, err := recomputeChanges(ctx, s.DB)
|
||||
if err != nil {
|
||||
return RecomputePreviewResult{}, internal(err)
|
||||
}
|
||||
samples := make([]RecomputeSample, 0, maxRecomputeSamples)
|
||||
for _, change := range changes {
|
||||
if len(samples) >= maxRecomputeSamples {
|
||||
break
|
||||
}
|
||||
sample := RecomputeSample{OrderCode: change.orderCode, ShopeeItemID: change.shopeeItemID}
|
||||
if change.toExcluded {
|
||||
sample.Direction = DirectionPDDToExcluded
|
||||
sample.RuleKind, sample.RuleKeyword = change.ruleKind, change.ruleKeyword
|
||||
} else {
|
||||
sample.Direction = DirectionExcludedToPDD
|
||||
}
|
||||
samples = append(samples, sample)
|
||||
}
|
||||
return RecomputePreviewResult{RecomputeCounts: counts, Samples: samples, Fingerprint: recomputeFingerprint(changes)}, nil
|
||||
}
|
||||
|
||||
type RecomputeExecuteResult struct {
|
||||
RecomputeCounts
|
||||
Operator string `json:"operator"`
|
||||
}
|
||||
|
||||
// countPurchaseTasksLocked and countActiveReturnMatchesLocked are the two
|
||||
// rechecks writeRecomputeChanges runs after taking the syb_product row lock.
|
||||
//
|
||||
// `[必须]` They MUST use a locking read (FOR SHARE), not a plain COUNT(*).
|
||||
// Production and local are MySQL 8.4 under REPEATABLE-READ, where a plain
|
||||
// read inside a transaction reuses the snapshot taken at that transaction's
|
||||
// FIRST read (here, recomputeChanges' own SELECT) — so a purchase_task or
|
||||
// return_match row committed by another connection AFTER that snapshot is
|
||||
// invisible to a plain COUNT(*) even after this code has waited for and
|
||||
// obtained the syb_product row's FOR UPDATE lock. The row lock only
|
||||
// serializes writers against each other; it does not by itself make a later
|
||||
// plain read see newer committed data under REPEATABLE-READ. A locking read
|
||||
// (FOR SHARE is enough since these two only need to observe committed rows,
|
||||
// not lock them for update) forces MySQL to use a fresh "current read"
|
||||
// instead of the snapshot, which is exactly what closes the race (verified
|
||||
// against real MySQL 8.4 with two connections: after the other transaction
|
||||
// committed a task, plain COUNT returned 0 while COUNT ... FOR SHARE
|
||||
// correctly returned 1). SQLite — used by this package's tests — drops
|
||||
// locking clauses entirely (gorm.io/driver/sqlite's "FOR" clause builder is
|
||||
// a no-op) and has no multi-connection snapshot semantics to reproduce this
|
||||
// race in the first place, so no SQLite-backed test can catch a regression
|
||||
// here; see TestRecheckQueriesUseLockingReads below for the SQL-shape test
|
||||
// that does.
|
||||
// sybProductRowLockQuery, purchaseTaskLockedQuery and returnMatchLockedQuery
|
||||
// build (but do not execute) the three locking reads writeRecomputeChanges
|
||||
// runs per row. They are split out from the count*/lock helpers below purely
|
||||
// so a test can call db.ToSQL against the exact same query construction the
|
||||
// production code runs, and assert the FOR UPDATE / FOR SHARE clause is
|
||||
// actually present in the generated SQL (#340 phase 3 review follow-up).
|
||||
func sybProductRowLockQuery(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate})
|
||||
}
|
||||
|
||||
func purchaseTaskLockedQuery(tx *gorm.DB, sybID uint64) *gorm.DB {
|
||||
return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthShare}).
|
||||
Model(&models.PurchaseTask{}).Where("syb_product_id = ?", sybID)
|
||||
}
|
||||
|
||||
func returnMatchLockedQuery(tx *gorm.DB, sybID uint64) *gorm.DB {
|
||||
return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthShare}).
|
||||
Table("return_match").Where("syb_product_id = ? AND active_syb_product_id IS NOT NULL", sybID)
|
||||
}
|
||||
|
||||
func countPurchaseTasksLocked(ctx context.Context, tx *gorm.DB, sybID uint64) (int64, error) {
|
||||
var count int64
|
||||
err := purchaseTaskLockedQuery(tx.WithContext(ctx), sybID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func countActiveReturnMatchesLocked(ctx context.Context, tx *gorm.DB, sybID uint64) (int64, error) {
|
||||
var count int64
|
||||
err := returnMatchLockedQuery(tx.WithContext(ctx), sybID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// writeRecomputeChanges is the write phase, kept separate from planning so it
|
||||
// is independently testable (#340 phase 3 review item 1): for every planned
|
||||
// change it takes the SAME row lock purchase.Service.create and returnmatch's
|
||||
// matchOneWithLock take (clause.Locking{Strength:"UPDATE"}), then re-checks
|
||||
// under that lock — a purchase task or active return match created for the
|
||||
// row after planning skips it (counted the same as a plan-time skip), and a
|
||||
// row whose mark already matches the target (e.g. flipped by a concurrent
|
||||
// recompute) is left alone and not counted at all. Only rows that still need
|
||||
// the change are written. The returned counts are what was ACTUALLY done.
|
||||
func writeRecomputeChanges(ctx context.Context, tx *gorm.DB, planned []recomputeChange) (RecomputeCounts, error) {
|
||||
actual := RecomputeCounts{}
|
||||
now := time.Now().UTC()
|
||||
for _, change := range planned {
|
||||
var locked models.SYBProduct
|
||||
if err := sybProductRowLockQuery(tx.WithContext(ctx)).First(&locked, change.id).Error; err != nil {
|
||||
return RecomputeCounts{}, err
|
||||
}
|
||||
taskCount, err := countPurchaseTasksLocked(ctx, tx, change.id)
|
||||
if err != nil {
|
||||
return RecomputeCounts{}, err
|
||||
}
|
||||
if taskCount > 0 {
|
||||
actual.SkippedHasTask++
|
||||
continue
|
||||
}
|
||||
matchCount, err := countActiveReturnMatchesLocked(ctx, tx, change.id)
|
||||
if err != nil {
|
||||
return RecomputeCounts{}, err
|
||||
}
|
||||
if matchCount > 0 {
|
||||
actual.SkippedReturnMatch++
|
||||
continue
|
||||
}
|
||||
if locked.PDDExcluded == change.toExcluded {
|
||||
// Already at the target (e.g. another recompute got there first
|
||||
// under its own lock) — nothing to do, and this is not a
|
||||
// "skipped" outcome in the reported sense since there was never
|
||||
// a real conflict blocking it.
|
||||
continue
|
||||
}
|
||||
updates := map[string]any{"pdd_purchase_excluded": change.toExcluded}
|
||||
if change.toExcluded {
|
||||
updates["excluded_rule_id"] = change.ruleID
|
||||
updates["excluded_rule_kind"] = change.ruleKind
|
||||
updates["excluded_rule_keyword"] = change.ruleKeyword
|
||||
updates["excluded_at"] = now
|
||||
} else {
|
||||
updates["excluded_rule_id"] = nil
|
||||
updates["excluded_rule_kind"] = ""
|
||||
updates["excluded_rule_keyword"] = ""
|
||||
updates["excluded_at"] = nil
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(&models.SYBProduct{}).Where("id = ?", change.id).Updates(updates).Error; err != nil {
|
||||
return RecomputeCounts{}, err
|
||||
}
|
||||
if change.toExcluded {
|
||||
actual.PDDToExcluded++
|
||||
} else {
|
||||
actual.ExcludedToPDD++
|
||||
}
|
||||
}
|
||||
return actual, nil
|
||||
}
|
||||
|
||||
// RecomputeExecute re-validates the plan against a fingerprint the caller
|
||||
// must have gotten from a RecomputePreview call (#340 phase 3 review item 2):
|
||||
// if the current plan's fingerprint no longer matches — the underlying data
|
||||
// or the enabled rules changed since that preview — nothing is written and
|
||||
// CodeRecomputeStale is returned. Otherwise every planned change is written
|
||||
// through writeRecomputeChanges under its own per-row lock, and one audit log
|
||||
// row is written in the same transaction using the ACTUAL counts.
|
||||
func (s *Service) RecomputeExecute(ctx context.Context, operator, fingerprint string) (RecomputeExecuteResult, error) {
|
||||
fingerprint = strings.TrimSpace(fingerprint)
|
||||
if fingerprint == "" {
|
||||
return RecomputeExecuteResult{}, invalid("fingerprint 不能为空,请先调用预览接口")
|
||||
}
|
||||
var result RecomputeExecuteResult
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
planCounts, changes, err := recomputeChanges(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if recomputeFingerprint(changes) != fingerprint {
|
||||
return &ServiceError{Code: CodeRecomputeStale, Message: "数据或规则已变化,请重新预览后再执行"}
|
||||
}
|
||||
written, err := writeRecomputeChanges(ctx, tx, changes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The ACTUAL, reported counts combine what planning already knew was
|
||||
// ineligible (rows never even handed to the write phase) with what
|
||||
// the write phase itself caught under lock — so a plan-time skip and
|
||||
// a write-time race both show up, and Excluded/PDD counts are always
|
||||
// exactly what got written (#340 phase 3 review item 1c).
|
||||
actual := RecomputeCounts{
|
||||
ExcludedToPDD: written.ExcludedToPDD,
|
||||
PDDToExcluded: written.PDDToExcluded,
|
||||
SkippedHasTask: planCounts.SkippedHasTask + written.SkippedHasTask,
|
||||
SkippedReturnMatch: planCounts.SkippedReturnMatch + written.SkippedReturnMatch,
|
||||
}
|
||||
log := models.SYBProductFilterRecomputeLog{
|
||||
Operator: strings.TrimSpace(operator), ExcludedToPDD: actual.ExcludedToPDD,
|
||||
PDDToExcluded: actual.PDDToExcluded, SkippedHasTask: actual.SkippedHasTask,
|
||||
SkippedReturnMatch: actual.SkippedReturnMatch,
|
||||
}
|
||||
if err := tx.Create(&log).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result = RecomputeExecuteResult{RecomputeCounts: actual, Operator: log.Operator}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if se, ok := err.(*ServiceError); ok {
|
||||
return RecomputeExecuteResult{}, se
|
||||
}
|
||||
return RecomputeExecuteResult{}, internal(err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// mysqlDryRunDB opens a gorm session against the MySQL dialector with
|
||||
// DryRun+DisableAutomaticPing, so no real network connection is ever made
|
||||
// (sql.Open is lazy and gorm skips the startup ping) but the SQL gorm would
|
||||
// send to a real MySQL 8.4 server can still be inspected via db.ToSQL.
|
||||
//
|
||||
// `[必须]` This must be the MySQL dialector, not SQLite: gorm.io/driver/
|
||||
// sqlite's own "FOR" clause builder silently drops clause.Locking entirely
|
||||
// (SQLite has no row-level locking), so a SQLite-backed test would show
|
||||
// these queries with no FOR clause at all regardless of whether the
|
||||
// production code asks for one — it would pass even with the bug this test
|
||||
// exists to catch. Only a MySQL-dialect SQL string proves the FOR UPDATE /
|
||||
// FOR SHARE clauses are actually being sent.
|
||||
func mysqlDryRunDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
// SkipInitializeWithVersion is required, not just DisableAutomaticPing:
|
||||
// gorm's MySQL dialector otherwise runs `SELECT VERSION()` against the
|
||||
// DSN's ConnPool during Initialize (Open), before DryRun/ping settings
|
||||
// even come into play, to decide version-gated feature flags such as
|
||||
// DontSupportForShareClause. With it set, sql.Open's lazy connection
|
||||
// pool is never dialed at all.
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{
|
||||
DSN: "user:pass@tcp(127.0.0.1:3306)/goauto_test?parseTime=true", SkipInitializeWithVersion: true,
|
||||
}), &gorm.Config{DryRun: true, DisableAutomaticPing: true})
|
||||
if err != nil {
|
||||
t.Fatalf("open dry-run mysql session: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// TestRecheckQueriesUseLockingReads pins the exact SQL shape behind #340
|
||||
// phase 3's real fix: writeRecomputeChanges' syb_product row lock must be
|
||||
// FOR UPDATE, and its two rechecks (purchase_task, return_match) must be
|
||||
// FOR SHARE — a plain COUNT(*) for either recheck is invisible to a
|
||||
// transaction's already-taken REPEATABLE-READ snapshot on real MySQL 8.4
|
||||
// even after the row's FOR UPDATE lock is granted, which is exactly the race
|
||||
// this test guards against ever regressing to (verified against a real
|
||||
// MySQL 8.4 server with two connections: a purchase_task committed by the
|
||||
// other connection after the snapshot was invisible to plain COUNT(*), but
|
||||
// visible to COUNT(*) ... FOR SHARE). SQLite, which every other test in this
|
||||
// package runs against, cannot reproduce any of this: it has no
|
||||
// multi-connection snapshot isolation and gorm's SQLite driver drops locking
|
||||
// clauses outright, so this test intentionally talks MySQL SQL shape only,
|
||||
// never a real database.
|
||||
func TestRecheckQueriesUseLockingReads(t *testing.T) {
|
||||
db := mysqlDryRunDB(t)
|
||||
|
||||
rowLockSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||
var dest map[string]any
|
||||
return sybProductRowLockQuery(tx).Table("syb_product").Where("id = ?", uint64(1)).Find(&dest)
|
||||
})
|
||||
t.Logf("ROW LOCK SQL: %s", rowLockSQL)
|
||||
if !strings.Contains(rowLockSQL, "FOR UPDATE") {
|
||||
t.Fatalf("expected the syb_product row lock to be FOR UPDATE, got SQL: %s", rowLockSQL)
|
||||
}
|
||||
|
||||
var taskCount int64
|
||||
taskSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||
return purchaseTaskLockedQuery(tx, 1).Count(&taskCount)
|
||||
})
|
||||
t.Logf("PURCHASE TASK RECHECK SQL: %s", taskSQL)
|
||||
if !strings.Contains(taskSQL, "FOR SHARE") {
|
||||
t.Fatalf("expected the purchase_task recheck to be a locking (FOR SHARE) read, got SQL: %s", taskSQL)
|
||||
}
|
||||
if !strings.Contains(taskSQL, "syb_product_id") {
|
||||
t.Fatalf("expected the purchase_task recheck to filter by syb_product_id, got SQL: %s", taskSQL)
|
||||
}
|
||||
|
||||
var matchCount int64
|
||||
matchSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||
return returnMatchLockedQuery(tx, 1).Count(&matchCount)
|
||||
})
|
||||
t.Logf("RETURN MATCH RECHECK SQL: %s", matchSQL)
|
||||
if !strings.Contains(matchSQL, "FOR SHARE") {
|
||||
t.Fatalf("expected the return_match recheck to be a locking (FOR SHARE) read, got SQL: %s", matchSQL)
|
||||
}
|
||||
if !strings.Contains(matchSQL, "active_syb_product_id IS NOT NULL") {
|
||||
t.Fatalf("expected the return_match recheck to filter on an active match, got SQL: %s", matchSQL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// dsnPathReplacer swaps the database name in a Go MySQL DSN of the form
|
||||
// user:pass@tcp(host:port)/dbname?params — used to connect first to the
|
||||
// server (no specific throwaway database yet) and then to the freshly
|
||||
// created throwaway database.
|
||||
var dsnPathReplacer = regexp.MustCompile(`^(.*/)([^/?]*)(\?.*)?$`)
|
||||
|
||||
func dsnWithDatabase(dsn, dbName string) string {
|
||||
if dsnPathReplacer.MatchString(dsn) {
|
||||
return dsnPathReplacer.ReplaceAllString(dsn, "${1}"+dbName+"${3}")
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
// setupMySQLIntegrationDB is #340 phase 4 review item 2's throwaway-database
|
||||
// harness: it never touches an existing database. GOAUTO_IT_MYSQL_DSN must
|
||||
// point at a MySQL SERVER (any connectable path, e.g. the system "mysql"
|
||||
// database) with permission to CREATE/DROP DATABASE; a uniquely named
|
||||
// zz_goauto_it_340_<random> database is created, migrated, and guaranteed
|
||||
// dropped via t.Cleanup even if the test fails or panics.
|
||||
func setupMySQLIntegrationDB(t *testing.T) (dsn string, dbName string) {
|
||||
t.Helper()
|
||||
baseDSN := os.Getenv("GOAUTO_IT_MYSQL_DSN")
|
||||
if baseDSN == "" {
|
||||
t.Skip("GOAUTO_IT_MYSQL_DSN not set; skipping MySQL concurrency integration test")
|
||||
}
|
||||
admin, err := sql.Open("mysql", baseDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("open admin connection: %v", err)
|
||||
}
|
||||
if err := admin.Ping(); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("ping MySQL server: %v", err)
|
||||
}
|
||||
dbName = fmt.Sprintf("zz_goauto_it_340_%d", time.Now().UnixNano())
|
||||
if _, err := admin.Exec("CREATE DATABASE `" + dbName + "`"); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("create throwaway database %s: %v", dbName, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
defer admin.Close()
|
||||
if _, err := admin.Exec("DROP DATABASE IF EXISTS `" + dbName + "`"); err != nil {
|
||||
t.Errorf("failed to drop throwaway database %s (manual cleanup required): %v", dbName, err)
|
||||
}
|
||||
})
|
||||
|
||||
dsn = dsnWithDatabase(baseDSN, dbName)
|
||||
gdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open throwaway database: %v", err)
|
||||
}
|
||||
if err := migrations.Migrate(gdb); err != nil {
|
||||
t.Fatalf("migrate throwaway database: %v", err)
|
||||
}
|
||||
return dsn, dbName
|
||||
}
|
||||
|
||||
func newMySQLIntegrationConn(t *testing.T, dsn string) *gorm.DB {
|
||||
t.Helper()
|
||||
conn, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open MySQL connection: %v", err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
// TestRecomputeConcurrentPurchaseTaskUnderRealMySQL is #340 phase 4 review
|
||||
// item 2: it reproduces, against a real MySQL server under REPEATABLE-READ,
|
||||
// the exact race writeRecomputeChanges' locking rechecks exist to close.
|
||||
//
|
||||
// Timeline:
|
||||
// 1. Connection A begins a transaction and runs the planning step
|
||||
// (recomputeChanges) — this is A's FIRST read, so it fixes A's
|
||||
// REPEATABLE-READ snapshot with zero purchase_task rows.
|
||||
// 2. Connection B, concurrently, takes the SAME row's FOR UPDATE lock,
|
||||
// confirmed via a channel before A is allowed to proceed.
|
||||
// 3. A's write phase (writeRecomputeChanges) is started in a goroutine; it
|
||||
// must BLOCK trying to take the same FOR UPDATE lock B already holds —
|
||||
// the test asserts A has NOT finished after a wait window, proving a
|
||||
// real block happened (not just a fast, uncontended lock grant).
|
||||
// 4. B inserts a purchase_task for the row and commits, releasing the lock.
|
||||
// 5. A's write phase unblocks, re-checks purchase_task under lock, and must
|
||||
// see B's now-committed row and skip — this only holds because the
|
||||
// recheck is a locking (FOR SHARE) read; a plain COUNT(*) would still be
|
||||
// bound to A's step-1 snapshot (zero rows) and would wrongly write.
|
||||
func TestRecomputeConcurrentPurchaseTaskUnderRealMySQL(t *testing.T) {
|
||||
dsn, _ := setupMySQLIntegrationDB(t)
|
||||
seedConn := newMySQLIntegrationConn(t, dsn)
|
||||
|
||||
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
|
||||
if err := seedConn.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := models.SYBProduct{
|
||||
OrderCode: "ORD-IT-TASK", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`,
|
||||
}
|
||||
if err := seedConn.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// purchase_task.pdd_product_id has a real FK (unlike this package's
|
||||
// SQLite-backed tests, which don't enable foreign key enforcement) —
|
||||
// MySQL requires an actual pdd_product row to reference.
|
||||
pdd := models.PDDProduct{GoodsID: "IT-PDD-1", URL: "https://example.invalid/it", SpecsJSON: "[]"}
|
||||
if err := seedConn.Create(&pdd).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
connA := newMySQLIntegrationConn(t, dsn)
|
||||
connB := newMySQLIntegrationConn(t, dsn)
|
||||
ctx := context.Background()
|
||||
|
||||
txA := connA.Begin()
|
||||
// Guard against ANY early return (t.Fatalf, panic) leaving txA open: an
|
||||
// abandoned open transaction holds a connection into this throwaway
|
||||
// database and blocks the DROP DATABASE cleanup indefinitely. Rollback
|
||||
// on an already-committed transaction is a harmless no-op error, which
|
||||
// is why the plain Commit() path below intentionally does not disable
|
||||
// this cleanup.
|
||||
t.Cleanup(func() { txA.Rollback() })
|
||||
_, planned, err := recomputeChanges(ctx, txA)
|
||||
if err != nil {
|
||||
t.Fatalf("plan: %v", err)
|
||||
}
|
||||
if len(planned) != 1 || planned[0].id != row.ID {
|
||||
t.Fatalf("expected exactly the seeded row to be planned, got %+v", planned)
|
||||
}
|
||||
|
||||
txB := connB.Begin()
|
||||
t.Cleanup(func() { txB.Rollback() })
|
||||
|
||||
bHoldingLock := make(chan struct{})
|
||||
bCanCommit := make(chan struct{})
|
||||
bDone := make(chan error, 1)
|
||||
go func() {
|
||||
var locked models.SYBProduct
|
||||
if err := txB.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
|
||||
First(&locked, row.ID).Error; err != nil {
|
||||
bDone <- fmt.Errorf("B lock row: %w", err)
|
||||
return
|
||||
}
|
||||
close(bHoldingLock)
|
||||
<-bCanCommit
|
||||
task := models.PurchaseTask{
|
||||
SYBProductID: &row.ID, PDDProductID: pdd.ID, Quantity: 1, CreateRequestID: "it-race-task",
|
||||
Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive,
|
||||
TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}",
|
||||
}
|
||||
if err := txB.Create(&task).Error; err != nil {
|
||||
bDone <- fmt.Errorf("B insert task: %w", err)
|
||||
return
|
||||
}
|
||||
bDone <- txB.Commit().Error
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-bHoldingLock:
|
||||
case err := <-bDone:
|
||||
t.Fatalf("B failed before taking the row lock: %v", err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for B to take the row lock")
|
||||
}
|
||||
|
||||
aDone := make(chan struct{})
|
||||
var aActual RecomputeCounts
|
||||
var aErr error
|
||||
go func() {
|
||||
aActual, aErr = writeRecomputeChanges(ctx, txA, planned)
|
||||
close(aDone)
|
||||
}()
|
||||
|
||||
// A must still be blocked on B's row lock at this point — this is the
|
||||
// test's proof that a real MySQL row lock, not just program logic, is
|
||||
// what's being exercised.
|
||||
select {
|
||||
case <-aDone:
|
||||
t.Fatal("A's write phase returned before B committed — it should have blocked on the row's FOR UPDATE lock")
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(bCanCommit)
|
||||
if err := <-bDone; err != nil {
|
||||
t.Fatalf("B failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-aDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for A's write phase to unblock after B committed")
|
||||
}
|
||||
if aErr != nil {
|
||||
t.Fatalf("A's write phase failed: %v", aErr)
|
||||
}
|
||||
if err := txA.Commit().Error; err != nil {
|
||||
t.Fatalf("commit A: %v", err)
|
||||
}
|
||||
|
||||
if aActual.SkippedHasTask != 1 || aActual.PDDToExcluded != 0 {
|
||||
t.Fatalf("expected A to skip the row for the concurrently-created task, got %+v", aActual)
|
||||
}
|
||||
var reloaded models.SYBProduct
|
||||
if err := seedConn.First(&reloaded, row.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.PDDExcluded {
|
||||
t.Fatalf("row must not have been marked excluded — the concurrent task should have blocked it: %+v", reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecomputeConcurrentReturnMatchUnderRealMySQL is the same scenario as
|
||||
// TestRecomputeConcurrentPurchaseTaskUnderRealMySQL, with an active
|
||||
// return_match row instead of a purchase_task as B's concurrent write.
|
||||
func TestRecomputeConcurrentReturnMatchUnderRealMySQL(t *testing.T) {
|
||||
dsn, _ := setupMySQLIntegrationDB(t)
|
||||
seedConn := newMySQLIntegrationConn(t, dsn)
|
||||
|
||||
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
|
||||
if err := seedConn.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := models.SYBProduct{
|
||||
OrderCode: "ORD-IT-MATCH", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`,
|
||||
}
|
||||
if err := seedConn.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yeekeItem := models.YeekeReturnItem{PackageID: 0, ExternalKey: "it-race-return", ItemID: "1", VariationName: "档口-1", LastSyncedAt: time.Now()}
|
||||
// A package row is required by the return_match/yeeke schema's foreign
|
||||
// key; seed a minimal one.
|
||||
pkg := models.YeekeReturnPackage{ExternalID: "it-race-pkg", OrderSN: "IT-ORD", TrackingNo: "IT-TRK", LastSyncedAt: time.Now()}
|
||||
if err := seedConn.Create(&pkg).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yeekeItem.PackageID = pkg.ID
|
||||
if err := seedConn.Create(&yeekeItem).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
connA := newMySQLIntegrationConn(t, dsn)
|
||||
connB := newMySQLIntegrationConn(t, dsn)
|
||||
ctx := context.Background()
|
||||
|
||||
txA := connA.Begin()
|
||||
// See TestRecomputeConcurrentPurchaseTaskUnderRealMySQL for why this
|
||||
// unconditional cleanup is necessary regardless of the Commit() below.
|
||||
t.Cleanup(func() { txA.Rollback() })
|
||||
_, planned, err := recomputeChanges(ctx, txA)
|
||||
if err != nil {
|
||||
t.Fatalf("plan: %v", err)
|
||||
}
|
||||
if len(planned) != 1 || planned[0].id != row.ID {
|
||||
t.Fatalf("expected exactly the seeded row to be planned, got %+v", planned)
|
||||
}
|
||||
|
||||
txB := connB.Begin()
|
||||
t.Cleanup(func() { txB.Rollback() })
|
||||
|
||||
bHoldingLock := make(chan struct{})
|
||||
bCanCommit := make(chan struct{})
|
||||
bDone := make(chan error, 1)
|
||||
go func() {
|
||||
var locked models.SYBProduct
|
||||
if err := txB.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
|
||||
First(&locked, row.ID).Error; err != nil {
|
||||
bDone <- fmt.Errorf("B lock row: %w", err)
|
||||
return
|
||||
}
|
||||
close(bHoldingLock)
|
||||
<-bCanCommit
|
||||
match := models.ReturnMatch{
|
||||
SYBProductID: row.ID, YeekeReturnItemID: yeekeItem.ID,
|
||||
ActiveSYBProductID: &row.ID, Status: models.ReturnMatchStatusMatched, MatchedAt: time.Now(),
|
||||
}
|
||||
if err := txB.Create(&match).Error; err != nil {
|
||||
bDone <- fmt.Errorf("B insert match: %w", err)
|
||||
return
|
||||
}
|
||||
bDone <- txB.Commit().Error
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-bHoldingLock:
|
||||
case err := <-bDone:
|
||||
t.Fatalf("B failed before taking the row lock: %v", err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for B to take the row lock")
|
||||
}
|
||||
|
||||
aDone := make(chan struct{})
|
||||
var aActual RecomputeCounts
|
||||
var aErr error
|
||||
go func() {
|
||||
aActual, aErr = writeRecomputeChanges(ctx, txA, planned)
|
||||
close(aDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-aDone:
|
||||
t.Fatal("A's write phase returned before B committed — it should have blocked on the row's FOR UPDATE lock")
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(bCanCommit)
|
||||
if err := <-bDone; err != nil {
|
||||
t.Fatalf("B failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-aDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for A's write phase to unblock after B committed")
|
||||
}
|
||||
if aErr != nil {
|
||||
t.Fatalf("A's write phase failed: %v", aErr)
|
||||
}
|
||||
if err := txA.Commit().Error; err != nil {
|
||||
t.Fatalf("commit A: %v", err)
|
||||
}
|
||||
|
||||
if aActual.SkippedReturnMatch != 1 || aActual.PDDToExcluded != 0 {
|
||||
t.Fatalf("expected A to skip the row for the concurrently-created return match, got %+v", aActual)
|
||||
}
|
||||
var reloaded models.SYBProduct
|
||||
if err := seedConn.First(&reloaded, row.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.PDDExcluded {
|
||||
t.Fatalf("row must not have been marked excluded — the concurrent return match should have blocked it: %+v", reloaded)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestRecomputePreviewMatchesExecute(t *testing.T) {
|
||||
db := testDB(t)
|
||||
// A row that currently needs a PDD purchase but now matches a keyword rule.
|
||||
pddToExcluded := models.SYBProduct{
|
||||
OrderCode: "ORD-1", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`,
|
||||
}
|
||||
if err := db.Create(&pddToExcluded).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A row currently marked excluded whose rule no longer matches.
|
||||
excludedToPDD := models.SYBProduct{
|
||||
OrderCode: "ORD-2", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"普通-2"}`,
|
||||
PDDExcluded: true,
|
||||
}
|
||||
if err := db.Create(&excludedToPDD).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A row that should flip, but has a purchase task -> must be skipped.
|
||||
hasTask := models.SYBProduct{
|
||||
OrderCode: "ORD-3", DetailID: 3, StockID: 3, ShopeeItemID: "3", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-3"}`,
|
||||
}
|
||||
if err := db.Create(&hasTask).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.PurchaseTask{SYBProductID: &hasTask.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "req-3", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A row that should flip, but has an active return match -> must be skipped.
|
||||
hasMatch := models.SYBProduct{
|
||||
OrderCode: "ORD-4", DetailID: 4, StockID: 4, ShopeeItemID: "4", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-4"}`,
|
||||
}
|
||||
if err := db.Create(&hasMatch).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.ReturnMatch{SYBProductID: hasMatch.ID, YeekeReturnItemID: 1, ActiveSYBProductID: &hasMatch.ID, Status: models.ReturnMatchStatusMatched}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewService(db)
|
||||
preview, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.PDDToExcluded != 1 || preview.ExcludedToPDD != 1 || preview.SkippedHasTask != 1 || preview.SkippedReturnMatch != 1 {
|
||||
t.Fatalf("unexpected preview counts: %+v", preview)
|
||||
}
|
||||
|
||||
result, err := s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.RecomputeCounts != preview.RecomputeCounts {
|
||||
t.Fatalf("execute counts must match preview: preview=%+v execute=%+v", preview.RecomputeCounts, result.RecomputeCounts)
|
||||
}
|
||||
if len(preview.Samples) != 2 {
|
||||
t.Fatalf("expected 2 sample rows, got %+v", preview.Samples)
|
||||
}
|
||||
var sawPDDToExcluded, sawExcludedToPDD bool
|
||||
for _, sample := range preview.Samples {
|
||||
switch sample.Direction {
|
||||
case DirectionPDDToExcluded:
|
||||
sawPDDToExcluded = true
|
||||
if sample.OrderCode != "ORD-1" || sample.RuleKeyword != "档口" {
|
||||
t.Fatalf("unexpected pdd_to_excluded sample: %+v", sample)
|
||||
}
|
||||
case DirectionExcludedToPDD:
|
||||
sawExcludedToPDD = true
|
||||
if sample.OrderCode != "ORD-2" || sample.RuleKind != "" || sample.RuleKeyword != "" {
|
||||
t.Fatalf("unexpected excluded_to_pdd sample: %+v", sample)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected direction: %+v", sample)
|
||||
}
|
||||
}
|
||||
if !sawPDDToExcluded || !sawExcludedToPDD {
|
||||
t.Fatalf("expected both directions represented: %+v", preview.Samples)
|
||||
}
|
||||
|
||||
var reloadedPDDToExcluded, reloadedExcludedToPDD, reloadedHasTask, reloadedHasMatch models.SYBProduct
|
||||
db.First(&reloadedPDDToExcluded, pddToExcluded.ID)
|
||||
db.First(&reloadedExcludedToPDD, excludedToPDD.ID)
|
||||
db.First(&reloadedHasTask, hasTask.ID)
|
||||
db.First(&reloadedHasMatch, hasMatch.ID)
|
||||
|
||||
if !reloadedPDDToExcluded.PDDExcluded || reloadedPDDToExcluded.ExcludedRuleKeyword != "档口" {
|
||||
t.Fatalf("expected row 1 to become excluded: %+v", reloadedPDDToExcluded)
|
||||
}
|
||||
if reloadedExcludedToPDD.PDDExcluded || reloadedExcludedToPDD.ExcludedRuleID != nil {
|
||||
t.Fatalf("expected row 2 to become un-excluded: %+v", reloadedExcludedToPDD)
|
||||
}
|
||||
if reloadedHasTask.PDDExcluded {
|
||||
t.Fatalf("row with a purchase task must never change: %+v", reloadedHasTask)
|
||||
}
|
||||
if reloadedHasMatch.PDDExcluded {
|
||||
t.Fatalf("row with an active return match must never change: %+v", reloadedHasMatch)
|
||||
}
|
||||
|
||||
var logs []models.SYBProductFilterRecomputeLog
|
||||
if err := db.Find(&logs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(logs) != 1 || logs[0].Operator != "admin1" || logs[0].PDDToExcluded != 1 || logs[0].ExcludedToPDD != 1 {
|
||||
t.Fatalf("expected exactly one audit log row matching the counts: %+v", logs)
|
||||
}
|
||||
|
||||
// Preview and execute must still agree on a no-op run.
|
||||
secondPreview, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secondPreview.PDDToExcluded != 0 || secondPreview.ExcludedToPDD != 0 {
|
||||
t.Fatalf("expected a no-op second preview, got %+v", secondPreview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputePreviewCapsSamplesAt20(t *testing.T) {
|
||||
db := testDB(t)
|
||||
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 25; i++ {
|
||||
row := models.SYBProduct{
|
||||
OrderCode: fmt.Sprintf("ORD-CAP-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1),
|
||||
ShopeeItemID: fmt.Sprintf("%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess,
|
||||
RawJSON: `{"variationSku":"档口-x"}`,
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s := NewService(db)
|
||||
preview, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.PDDToExcluded != 25 {
|
||||
t.Fatalf("expected 25 counted changes, got %d", preview.PDDToExcluded)
|
||||
}
|
||||
if len(preview.Samples) != 20 {
|
||||
t.Fatalf("expected samples capped at 20, got %d", len(preview.Samples))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeExecuteRejectsStaleFingerprint(t *testing.T) {
|
||||
db := testDB(t)
|
||||
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := models.SYBProduct{OrderCode: "ORD-STALE", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := NewService(db)
|
||||
preview, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.PDDToExcluded != 1 {
|
||||
t.Fatalf("unexpected preview: %+v", preview)
|
||||
}
|
||||
|
||||
// Data changes after the preview: a second row appears that would also
|
||||
// flip, so the plan's fingerprint is now stale.
|
||||
row2 := models.SYBProduct{OrderCode: "ORD-STALE-2", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-2"}`}
|
||||
if err := db.Create(&row2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint)
|
||||
if err == nil {
|
||||
t.Fatalf("expected stale fingerprint to be rejected")
|
||||
}
|
||||
se, ok := err.(*ServiceError)
|
||||
if !ok || se.Code != CodeRecomputeStale {
|
||||
t.Fatalf("expected CodeRecomputeStale, got %v", err)
|
||||
}
|
||||
|
||||
var reloaded, reloaded2 models.SYBProduct
|
||||
db.First(&reloaded, row.ID)
|
||||
db.First(&reloaded2, row2.ID)
|
||||
if reloaded.PDDExcluded || reloaded2.PDDExcluded {
|
||||
t.Fatalf("stale execute must write nothing: %+v %+v", reloaded, reloaded2)
|
||||
}
|
||||
var logCount int64
|
||||
db.Model(&models.SYBProductFilterRecomputeLog{}).Count(&logCount)
|
||||
if logCount != 0 {
|
||||
t.Fatalf("stale execute must not write an audit log, got %d", logCount)
|
||||
}
|
||||
|
||||
freshPreview, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if freshPreview.PDDToExcluded != 2 {
|
||||
t.Fatalf("expected fresh preview to see both rows, got %+v", freshPreview)
|
||||
}
|
||||
if _, err := s.RecomputeExecute(context.Background(), "admin1", freshPreview.Fingerprint); err != nil {
|
||||
t.Fatalf("fresh fingerprint should be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeExecuteRejectsEmptyFingerprint(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
if _, err := s.RecomputeExecute(context.Background(), "admin1", ""); err == nil {
|
||||
t.Fatalf("expected empty fingerprint to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteRecomputeChangesSkipsRowThatGotAPurchaseTaskConcurrently is #340
|
||||
// phase 3 review item 1(a): a purchase task created for a planned row AFTER
|
||||
// planning but BEFORE the write phase must cause that row to be skipped
|
||||
// (counted as SkippedHasTask), not written.
|
||||
func TestWriteRecomputeChangesSkipsRowThatGotAPurchaseTaskConcurrently(t *testing.T) {
|
||||
db := testDB(t)
|
||||
row := models.SYBProduct{OrderCode: "ORD-RACE-TASK", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ruleID := uint64(9)
|
||||
planned := []recomputeChange{{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"}}
|
||||
|
||||
if err := db.Create(&models.PurchaseTask{SYBProductID: &row.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "race-task", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var actual RecomputeCounts
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var writeErr error
|
||||
actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned)
|
||||
return writeErr
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actual.SkippedHasTask != 1 || actual.PDDToExcluded != 0 {
|
||||
t.Fatalf("expected the row to be skipped for having a task, got %+v", actual)
|
||||
}
|
||||
var reloaded models.SYBProduct
|
||||
db.First(&reloaded, row.ID)
|
||||
if reloaded.PDDExcluded {
|
||||
t.Fatalf("row must not have been marked excluded: %+v", reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteRecomputeChangesSkipsRowWithConcurrentReturnMatch is item 1(b):
|
||||
// same race, but with an active return match instead of a purchase task.
|
||||
func TestWriteRecomputeChangesSkipsRowWithConcurrentReturnMatch(t *testing.T) {
|
||||
db := testDB(t)
|
||||
row := models.SYBProduct{OrderCode: "ORD-RACE-MATCH", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ruleID := uint64(9)
|
||||
planned := []recomputeChange{{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"}}
|
||||
|
||||
if err := db.Create(&models.ReturnMatch{SYBProductID: row.ID, YeekeReturnItemID: 1, ActiveSYBProductID: &row.ID, Status: models.ReturnMatchStatusMatched}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var actual RecomputeCounts
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var writeErr error
|
||||
actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned)
|
||||
return writeErr
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actual.SkippedReturnMatch != 1 || actual.PDDToExcluded != 0 {
|
||||
t.Fatalf("expected the row to be skipped for an active return match, got %+v", actual)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteRecomputeChangesReturnsCountsMatchingActualWrites is item 1(c):
|
||||
// given a plan with one row that should write cleanly and one that races
|
||||
// with a concurrently-created purchase task, writeRecomputeChanges' returned
|
||||
// counts must equal exactly what it actually wrote — a real update for the
|
||||
// clean row, and a skip (not a write) for the raced one. This is the same
|
||||
// property RecomputeExecute relies on for its response and audit log
|
||||
// (verified end-to-end, without a race, by TestRecomputePreviewMatchesExecute
|
||||
// above; a race inside one execute call always changes the plan itself, so
|
||||
// it surfaces as a fingerprint mismatch — see
|
||||
// TestRecomputeExecuteRejectsStaleFingerprint — rather than reaching this
|
||||
// path in a real request).
|
||||
func TestWriteRecomputeChangesReturnsCountsMatchingActualWrites(t *testing.T) {
|
||||
db := testDB(t)
|
||||
rowA := models.SYBProduct{OrderCode: "ORD-ACTUAL-A", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
|
||||
rowB := models.SYBProduct{OrderCode: "ORD-ACTUAL-B", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-2"}`}
|
||||
if err := db.Create(&rowA).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&rowB).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ruleID := uint64(9)
|
||||
planned := []recomputeChange{
|
||||
{id: rowA.ID, orderCode: rowA.OrderCode, shopeeItemID: rowA.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"},
|
||||
{id: rowB.ID, orderCode: rowB.OrderCode, shopeeItemID: rowB.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"},
|
||||
}
|
||||
// Simulate a purchase task created for rowB strictly between planning
|
||||
// and this write phase call.
|
||||
if err := db.Create(&models.PurchaseTask{SYBProductID: &rowB.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "actual-task", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var actual RecomputeCounts
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var writeErr error
|
||||
actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned)
|
||||
return writeErr
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actual.PDDToExcluded != 1 || actual.SkippedHasTask != 1 {
|
||||
t.Fatalf("expected one written and one skipped, got %+v", actual)
|
||||
}
|
||||
var reloadedA, reloadedB models.SYBProduct
|
||||
db.First(&reloadedA, rowA.ID)
|
||||
db.First(&reloadedB, rowB.ID)
|
||||
if !reloadedA.PDDExcluded {
|
||||
t.Fatalf("rowA should have been marked excluded")
|
||||
}
|
||||
if reloadedB.PDDExcluded {
|
||||
t.Fatalf("rowB must not have been marked excluded (has a task)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkedCountsByRule(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ruleA := models.SYBProductFilter{Kind: "keyword", Keyword: "档口A", NormalizedKeyword: "档口a", Enabled: true}
|
||||
ruleB := models.SYBProductFilter{Kind: "keyword", Keyword: "档口B", NormalizedKeyword: "档口b", Enabled: true}
|
||||
if err := db.Create(&ruleA).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&ruleB).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ruleAID, ruleBID := ruleA.ID, ruleB.ID
|
||||
for i := 0; i < 3; i++ {
|
||||
row := models.SYBProduct{OrderCode: fmt.Sprintf("ORD-MARKED-A-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1), ShopeeItemID: fmt.Sprintf("a%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}", PDDExcluded: true, ExcludedRuleID: &ruleAID}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
row := models.SYBProduct{OrderCode: "ORD-MARKED-B-0", DetailID: 100, StockID: 100, ShopeeItemID: "b0", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}", PDDExcluded: true, ExcludedRuleID: &ruleBID}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unmarked := models.SYBProduct{OrderCode: "ORD-MARKED-NONE", DetailID: 200, StockID: 200, ShopeeItemID: "none", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}"}
|
||||
if err := db.Create(&unmarked).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewService(db)
|
||||
resp, err := s.List(context.Background(), ListRequest{Page: 1, PageSize: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byID := make(map[uint64]int64, len(resp.Items))
|
||||
for _, item := range resp.Items {
|
||||
byID[item.ID] = item.MarkedCount
|
||||
}
|
||||
if byID[ruleAID] != 3 {
|
||||
t.Fatalf("expected rule A marked count 3, got %d", byID[ruleAID])
|
||||
}
|
||||
if byID[ruleBID] != 1 {
|
||||
t.Fatalf("expected rule B marked count 1, got %d", byID[ruleBID])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecomputeFingerprintChangesWhenRuleEvidenceChanges is #340 phase 4
|
||||
// review item 1: the fingerprint must depend on the rule's kind/keyword, not
|
||||
// just its id — because those are exactly what RecomputeExecute is about to
|
||||
// write into excluded_rule_kind/excluded_rule_keyword. Same rule id, same
|
||||
// affected product, same direction, but the rule's own keyword changed
|
||||
// between preview and execute (simulated by editing the row directly since
|
||||
// the API has no edit endpoint) must be rejected as stale.
|
||||
func TestRecomputeFingerprintChangesWhenRuleEvidenceChanges(t *testing.T) {
|
||||
db := testDB(t)
|
||||
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := models.SYBProduct{OrderCode: "ORD-RULE-EDIT", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewService(db)
|
||||
preview, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.PDDToExcluded != 1 || len(preview.Samples) != 1 || preview.Samples[0].RuleKeyword != "档口" {
|
||||
t.Fatalf("unexpected preview: %+v", preview)
|
||||
}
|
||||
|
||||
// The rule's own keyword and normalized_keyword change (same id, same
|
||||
// kind, still matches the same variationSku prefix) — the plan's set of
|
||||
// affected ids/directions is unchanged, but the evidence that would be
|
||||
// written is not.
|
||||
if err := db.Model(&models.SYBProductFilter{}).Where("id = ?", rule.ID).
|
||||
Updates(map[string]any{"keyword": "档口新", "normalized_keyword": "档口"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint)
|
||||
if err == nil {
|
||||
t.Fatalf("expected the changed rule evidence to be rejected as stale")
|
||||
}
|
||||
se, ok := err.(*ServiceError)
|
||||
if !ok || se.Code != CodeRecomputeStale {
|
||||
t.Fatalf("expected CodeRecomputeStale, got %v", err)
|
||||
}
|
||||
|
||||
var reloaded models.SYBProduct
|
||||
db.First(&reloaded, row.ID)
|
||||
if reloaded.PDDExcluded {
|
||||
t.Fatalf("nothing should have been written: %+v", reloaded)
|
||||
}
|
||||
var logCount int64
|
||||
db.Model(&models.SYBProductFilterRecomputeLog{}).Count(&logCount)
|
||||
if logCount != 0 {
|
||||
t.Fatalf("no audit log row should have been written, got %d", logCount)
|
||||
}
|
||||
|
||||
// A fresh preview reflects the new keyword and executes normally.
|
||||
freshPreview, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if freshPreview.Samples[0].RuleKeyword != "档口新" {
|
||||
t.Fatalf("expected fresh preview to see the new keyword, got %+v", freshPreview.Samples)
|
||||
}
|
||||
if _, err := s.RecomputeExecute(context.Background(), "admin1", freshPreview.Fingerprint); err != nil {
|
||||
t.Fatalf("fresh fingerprint should be accepted: %v", err)
|
||||
}
|
||||
db.First(&reloaded, row.ID)
|
||||
if !reloaded.PDDExcluded || reloaded.ExcludedRuleKeyword != "档口新" {
|
||||
t.Fatalf("expected the row to be excluded with the new keyword snapshot: %+v", reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecomputeFingerprintStableAcrossUnchangedPreviews is the companion
|
||||
// regression: an unchanged dataset must give the SAME fingerprint on two
|
||||
// consecutive previews (map/slice iteration order must never leak into the
|
||||
// hash), and that fingerprint must still execute successfully.
|
||||
func TestRecomputeFingerprintStableAcrossUnchangedPreviews(t *testing.T) {
|
||||
db := testDB(t)
|
||||
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
row := models.SYBProduct{OrderCode: fmt.Sprintf("ORD-STABLE-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1), ShopeeItemID: fmt.Sprintf("s%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-x"}`}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
s := NewService(db)
|
||||
first, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Fingerprint == "" || first.Fingerprint != second.Fingerprint {
|
||||
t.Fatalf("expected a stable, non-empty fingerprint across two previews of the same data: %q vs %q", first.Fingerprint, second.Fingerprint)
|
||||
}
|
||||
if _, err := s.RecomputeExecute(context.Background(), "admin1", second.Fingerprint); err != nil {
|
||||
t.Fatalf("unchanged-data fingerprint should execute successfully: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,6 @@ func InitRouter(e *gin.Engine, a *jwt.GinJWTMiddleware) {
|
||||
g.POST("", middleware.RequireRoleKey("admin"), Handler{}.Create)
|
||||
g.PATCH("/:filterId/enabled", middleware.RequireRoleKey("admin"), Handler{}.SetEnabled)
|
||||
g.DELETE("/:filterId", middleware.RequireRoleKey("admin"), Handler{}.Delete)
|
||||
g.GET("/recompute/preview", middleware.RequireRoleKey("admin"), Handler{}.RecomputePreview)
|
||||
g.POST("/recompute/execute", middleware.RequireRoleKey("admin"), Handler{}.RecomputeExecute)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ const (
|
||||
CodeNotFound = "SYB_PRODUCT_FILTER_NOT_FOUND"
|
||||
CodeConflict = "SYB_PRODUCT_FILTER_DUPLICATE"
|
||||
CodeForbidden = "FORBIDDEN"
|
||||
// CodeRecomputeStale is returned by RecomputeExecute when the plan its
|
||||
// fingerprint describes no longer matches the current data/rules (#340
|
||||
// phase 3 review item 2): the caller must re-preview before executing.
|
||||
CodeRecomputeStale = "RECOMPUTE_PREVIEW_STALE"
|
||||
)
|
||||
|
||||
func invalid(m string) error { return &ServiceError{Code: CodeInvalidRequest, Message: m} }
|
||||
@@ -46,12 +50,24 @@ type ListRequest struct {
|
||||
Kind, Keyword string
|
||||
EnabledOnly bool
|
||||
}
|
||||
|
||||
// FilterItem adds MarkedCount to the stored rule: the REAL, live count of
|
||||
// syb_product rows currently marked pdd_purchase_excluded by this rule
|
||||
// (#340 phase 3 review item 3), as opposed to LastHitCount which is a
|
||||
// point-in-time snapshot written back by the most recent sync run and never
|
||||
// updated by a recompute. It is deliberately not a model field — it is
|
||||
// derived, never stored.
|
||||
type FilterItem struct {
|
||||
models.SYBProductFilter
|
||||
MarkedCount int64 `json:"markedCount"`
|
||||
}
|
||||
|
||||
type ListResponse struct {
|
||||
Items []models.SYBProductFilter `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
EnabledCount int64 `json:"enabledCount"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Items []FilterItem `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
EnabledCount int64 `json:"enabledCount"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, r ListRequest) (ListResponse, error) {
|
||||
@@ -75,16 +91,46 @@ func (s *Service) List(ctx context.Context, r ListRequest) (ListResponse, error)
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
items := make([]models.SYBProductFilter, 0, r.PageSize)
|
||||
if err := q.Order("kind ASC, id ASC").Offset((r.Page - 1) * r.PageSize).Limit(r.PageSize).Find(&items).Error; err != nil {
|
||||
rows := make([]models.SYBProductFilter, 0, r.PageSize)
|
||||
if err := q.Order("kind ASC, id ASC").Offset((r.Page - 1) * r.PageSize).Limit(r.PageSize).Find(&rows).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
if err := s.DB.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("enabled = ?", true).Count(&enabled).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
marked, err := s.markedCountsByRule(ctx)
|
||||
if err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
items := make([]FilterItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, FilterItem{SYBProductFilter: row, MarkedCount: marked[row.ID]})
|
||||
}
|
||||
return ListResponse{Items: items, Total: total, EnabledCount: enabled, Page: r.Page, PageSize: r.PageSize}, nil
|
||||
}
|
||||
|
||||
// markedCountsByRule is the single grouped query (no N+1) behind MarkedCount:
|
||||
// COUNT(*) FROM syb_product WHERE pdd_purchase_excluded = 1, grouped by
|
||||
// excluded_rule_id, covering every rule at once regardless of page size.
|
||||
func (s *Service) markedCountsByRule(ctx context.Context) (map[uint64]int64, error) {
|
||||
type row struct {
|
||||
ExcludedRuleID uint64
|
||||
Count int64
|
||||
}
|
||||
var rows []row
|
||||
if err := s.DB.WithContext(ctx).Model(&models.SYBProduct{}).
|
||||
Select("excluded_rule_id, COUNT(*) AS count").
|
||||
Where("pdd_purchase_excluded = ? AND excluded_rule_id IS NOT NULL", true).
|
||||
Group("excluded_rule_id").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[uint64]int64, len(rows))
|
||||
for _, r := range rows {
|
||||
result[r.ExcludedRuleID] = r.Count
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
Keyword string `json:"keyword"`
|
||||
|
||||
@@ -158,6 +158,11 @@ func (service *Service) BatchCreateImageSearch(ctx context.Context, request Imag
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "SYB_PRODUCT_UNAVAILABLE", Message: "SYB 商品不存在或未关联蝦皮商品"})
|
||||
continue
|
||||
}
|
||||
// #340: hard-exclude before grouping/creating any image search task.
|
||||
if row.PDDExcluded {
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "PURCHASE_PDD_EXCLUDED", Message: "该商品已标记为无需 PDD 采购"})
|
||||
continue
|
||||
}
|
||||
if index, ok := groups[*row.ShopeeProductID]; ok {
|
||||
response.Items[index].SYBProductIDs = append(response.Items[index].SYBProductIDs, id)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// #340: image search collection is a PDD entry point keyed by sybProductIds;
|
||||
// an excluded row must be rejected without touching the batch's other rows.
|
||||
func TestBatchCreateImageSearchRejectsExcludedRow(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
rule := models.CollectionRule{Name: "image-search-excluded", ContentJSON: v2TaskRuleSnapshot()}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopee := models.ShopeeProduct{ShopeeItemID: "excl-1", Title: "t", Currency: "CNY", SpecsJSON: "[]"}
|
||||
if err := db.Create(&shopee).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
syb := models.SYBProduct{OrderCode: "ORD-EXCL", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ImageURL: "https://example.invalid/excl.jpg", ParseStatus: "success", RawJSON: "{}", PDDExcluded: true}
|
||||
if err := db.Create(&syb).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
service.FetchImageSearchImage = func(ctx context.Context, url string) (ImageSearchImage, error) {
|
||||
t.Fatalf("must not fetch image for an excluded row")
|
||||
return ImageSearchImage{}, nil
|
||||
}
|
||||
request := ImageSearchBatchRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{syb.ID}, RuleID: rule.ID}
|
||||
response, err := service.BatchCreateImageSearch(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("batch call itself must not fail: %v", err)
|
||||
}
|
||||
if response.SuccessCount != 0 || len(response.Items) != 1 {
|
||||
t.Fatalf("expected the excluded row to fail, got %+v", response)
|
||||
}
|
||||
if response.Items[0].Code != "PURCHASE_PDD_EXCLUDED" {
|
||||
t.Fatalf("expected PURCHASE_PDD_EXCLUDED, got %+v", response.Items[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// #340: adds syb_product's pdd_purchase_excluded/excluded_rule_*/excluded_at
|
||||
// columns (additive, all default to "需 PDD 采购") and creates
|
||||
// syb_product_filter_recompute_log (registered in migrations.MigratedModels)
|
||||
// on databases whose earlier versions are already recorded in sys_migration.
|
||||
func init() {
|
||||
_, f, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(f), migrateSYBPDDPurchaseExcluded)
|
||||
}
|
||||
func migrateSYBPDDPurchaseExcluded(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := migrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -4,3 +4,5 @@ export function listSybProductFilters(params) { return request({ url: base, meth
|
||||
export function createSybProductFilter(data) { return request({ url: base, method: 'post', data }) }
|
||||
export function setSybProductFilterEnabled(id, data) { return request({ url: `${base}/${id}/enabled`, method: 'patch', data }) }
|
||||
export function deleteSybProductFilter(id) { return request({ url: `${base}/${id}`, method: 'delete' }) }
|
||||
export function previewSybProductFilterRecompute() { return request({ url: `${base}/recompute/preview`, method: 'get' }) }
|
||||
export function executeSybProductFilterRecompute(fingerprint) { return request({ url: `${base}/recompute/execute`, method: 'post', data: { fingerprint } }) }
|
||||
|
||||
@@ -28,8 +28,15 @@
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h3>结构过滤(不可增删,仅可停用)</h3>
|
||||
<p class="muted">variationSku 需要<strong>同时包含</strong>下方列出的每一个字符,才会被当成档口商品不入库;只包含其中一个的会正常入库。需要变更判据请另行建单评估。</p>
|
||||
<div class="section-head">
|
||||
<h3>结构过滤(不可增删,仅可停用)</h3>
|
||||
<el-tooltip v-if="isAdmin" :disabled="!recomputeButtonReason" :content="recomputeButtonReason" placement="bottom">
|
||||
<span>
|
||||
<el-button :disabled="Boolean(recomputeButtonReason)" @click="openRecompute">按当前规则重算</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<p class="muted">variationSku 需要<strong>同时包含</strong>下方列出的每一个字符,才会被标记为无需 PDD 采购;只包含其中一个的仍按需要 PDD 采购处理。需要变更判据请另行建单评估。</p>
|
||||
<el-table v-loading="loading" :data="chars" row-key="id" border stripe>
|
||||
<el-table-column label="必需字符" prop="keyword" width="180" />
|
||||
<el-table-column label="状态" width="150">
|
||||
@@ -116,7 +123,7 @@
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<p class="scope-note">停用或删除只影响后续同步,已经入库的商品明细不会被删除。</p>
|
||||
<p class="scope-note">停用或删除规则只影响之后的同步;已经标记的商品明细不会自动变化,需要点击「按当前规则重算」才会按新规则重新判定。</p>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="editor.open" title="新增关键词" width="520px" :close-on-click-modal="false" @closed="resetEditor">
|
||||
@@ -140,6 +147,57 @@
|
||||
<el-button type="primary" :loading="editor.saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="recompute.open" title="按当前规则重算" width="720px" :close-on-click-modal="false" @closed="resetRecompute">
|
||||
<div v-loading="recompute.loading">
|
||||
<el-alert
|
||||
v-if="recompute.error"
|
||||
:title="recompute.error"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
<template v-else-if="recompute.loaded">
|
||||
<el-alert
|
||||
title="重算只按当前启用规则重新判定标记;已有采购任务或正处于有效退货匹配的商品不会被改变。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
<div class="recompute-counts">
|
||||
<div class="count-item"><div class="count-value">{{ recompute.result.pddToExcluded }}</div><div class="count-label">将标记为无需 PDD 采购</div></div>
|
||||
<div class="count-item"><div class="count-value">{{ recompute.result.excludedToPdd }}</div><div class="count-label">将恢复为需要 PDD 采购</div></div>
|
||||
<div class="count-item"><div class="count-value">{{ recompute.result.skippedHasTask }}</div><div class="count-label">已有采购任务,跳过</div></div>
|
||||
<div class="count-item"><div class="count-value">{{ recompute.result.skippedReturnMatch }}</div><div class="count-label">有效退货匹配中,跳过</div></div>
|
||||
</div>
|
||||
<p v-if="recomputeTotalChanges === 0" class="muted">按当前规则重算,没有需要变更的商品。</p>
|
||||
<template v-else>
|
||||
<p class="muted">变更示例(最多显示 20 条,实际变更数为 {{ recomputeTotalChanges }} 条):</p>
|
||||
<el-table :data="recompute.result.samples" border size="small" empty-text="无样例">
|
||||
<el-table-column label="订单号" prop="orderCode" min-width="140" />
|
||||
<el-table-column label="蝦皮商品" prop="shopeeItemId" min-width="120" />
|
||||
<el-table-column label="变更方向" width="150">
|
||||
<template #default="{ row }">{{ row.direction === 'pdd_to_excluded' ? '需采购 → 无需采购' : '无需采购 → 需采购' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="命中规则" min-width="160">
|
||||
<template #default="{ row }">{{ row.ruleKeyword ? `${row.ruleKind === 'char' ? '结构' : '关键词'}:${row.ruleKeyword}` : '(不再命中任何规则)' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button :disabled="recompute.executing" @click="recompute.open = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="recompute.executing"
|
||||
:disabled="!recompute.loaded || Boolean(recompute.error) || recomputeTotalChanges === 0"
|
||||
@click="confirmRecompute"
|
||||
>确认执行</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
@@ -150,9 +208,15 @@ import {
|
||||
listSybProductFilters,
|
||||
createSybProductFilter,
|
||||
setSybProductFilterEnabled,
|
||||
deleteSybProductFilter
|
||||
deleteSybProductFilter,
|
||||
previewSybProductFilterRecompute,
|
||||
executeSybProductFilterRecompute
|
||||
} from '@/api/goauto/syb-product-filters'
|
||||
|
||||
function emptyRecomputeResult() {
|
||||
return { pddToExcluded: 0, excludedToPdd: 0, skippedHasTask: 0, skippedReturnMatch: 0, samples: [], fingerprint: '' }
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybProductFilters',
|
||||
data() {
|
||||
@@ -161,12 +225,26 @@ export default {
|
||||
chars: [],
|
||||
keywords: [],
|
||||
query: { keyword: '' },
|
||||
editor: { open: false, saving: false, keyword: '', note: '' }
|
||||
editor: { open: false, saving: false, keyword: '', note: '' },
|
||||
recompute: {
|
||||
open: false,
|
||||
loading: false,
|
||||
executing: false,
|
||||
loaded: false,
|
||||
error: '',
|
||||
result: emptyRecomputeResult()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdmin() {
|
||||
return (this.$store.getters.roles || []).includes('admin')
|
||||
},
|
||||
recomputeButtonReason() {
|
||||
return this.isAdmin ? '' : '只有管理员可以重算'
|
||||
},
|
||||
recomputeTotalChanges() {
|
||||
return this.recompute.result.pddToExcluded + this.recompute.result.excludedToPdd
|
||||
}
|
||||
},
|
||||
created() {
|
||||
@@ -205,12 +283,15 @@ export default {
|
||||
const who = row.lastChangedBy || '-'
|
||||
return `${this.formatTime(row.lastChangedAt)} ${who} ${row.enabled ? '启用' : '停用'}`
|
||||
},
|
||||
// 停用结构规则会让大量档口明细恢复入库,所以必须二次确认。
|
||||
// 条数取该行实时的 lastHitCount,不能写死:数据会变,写死的警告会骗人(#269)。
|
||||
// 停用结构规则不会撤销已有标记,所以必须二次确认。
|
||||
// #340 phase 3:警告文字改用该规则当前实时的 markedCount(服务端按
|
||||
// excluded_rule_id 分组统计的真实数量),不再用 lastHitCount——后者只是
|
||||
// 上一次同步的快照,规则改动或重算之后会和当前标记数脱节,继续用它会
|
||||
// 把过期数字当成现状讲给管理员听(#269 沿用的旧口径在 #340 后不再准确)。
|
||||
async confirmDisableChar(row) {
|
||||
const scale = row.lastHitCount == null ? '尚不清楚有多少' : `约 ${row.lastHitCount} 条`
|
||||
const scale = row.markedCount == null ? '尚不清楚有多少' : `当前约 ${row.markedCount} 条`
|
||||
await ElMessageBox.confirm(
|
||||
`停用后,SYB 同步将不再过滤任何档口商品(结构过滤只有这一条)。按最近一次同步数据估算,${scale}明细会恢复入库。`,
|
||||
`停用后,SYB 同步将不再把任何档口商品标记为无需 PDD 采购(结构过滤只有这一条)。这只影响之后的同步,${scale}明细带此规则的无需采购标记不会自动变化,需要使用「按当前规则重算」才会更新。`,
|
||||
`确认停用结构过滤「${row.keyword}」?`,
|
||||
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
|
||||
)
|
||||
@@ -267,6 +348,46 @@ export default {
|
||||
await deleteSybProductFilter(row.id)
|
||||
ElMessage.success('关键词已删除')
|
||||
this.load()
|
||||
},
|
||||
resetRecompute() {
|
||||
this.recompute = { open: false, loading: false, executing: false, loaded: false, error: '', result: emptyRecomputeResult() }
|
||||
},
|
||||
async openRecompute() {
|
||||
this.recompute.open = true
|
||||
this.recompute.loading = true
|
||||
this.recompute.loaded = false
|
||||
this.recompute.error = ''
|
||||
try {
|
||||
const res = await previewSybProductFilterRecompute()
|
||||
this.recompute.result = { ...emptyRecomputeResult(), ...(res.data || {}) }
|
||||
this.recompute.loaded = true
|
||||
} catch (e) {
|
||||
this.recompute.error = (e && e.message) || '预览失败,请重试'
|
||||
} finally {
|
||||
this.recompute.loading = false
|
||||
}
|
||||
},
|
||||
// #340 phase 3:执行必须带上预览返回的 fingerprint;服务端会在写入前用
|
||||
// 当前数据重新计算一次并比对,不一致(数据或规则在预览之后变化了)会
|
||||
// 返回 RECOMPUTE_PREVIEW_STALE(409),此时不写入任何数据,这里提示后
|
||||
// 自动重新预览,而不是让用户对着过期的计划再点一次「确认执行」。
|
||||
async confirmRecompute() {
|
||||
this.recompute.executing = true
|
||||
try {
|
||||
await executeSybProductFilterRecompute(this.recompute.result.fingerprint)
|
||||
ElMessage.success('已按当前规则重新计算')
|
||||
this.recompute.open = false
|
||||
this.load()
|
||||
} catch (e) {
|
||||
if (e?.response?.data?.code === 'RECOMPUTE_PREVIEW_STALE' || e?.data?.code === 'RECOMPUTE_PREVIEW_STALE') {
|
||||
ElMessage.warning('数据或规则已变化,请重新预览后再执行')
|
||||
this.openRecompute()
|
||||
return
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
this.recompute.executing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,4 +433,26 @@ h3 {
|
||||
.scope-note {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.recompute-counts {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.count-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.count-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.count-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
|
||||
<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"><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.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>
|
||||
</div>
|
||||
<div class="search-row search-row-secondary">
|
||||
@@ -37,6 +38,7 @@
|
||||
<el-tag :type="processMeta(purchaseReady(row).processStage).type">{{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}</el-tag>
|
||||
<div class="purchase-reason">{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}</div>
|
||||
<div v-if="purchaseReady(row).processStage === 'purchase_ready'" class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div>
|
||||
<div v-if="purchaseReady(row).processStage === 'pdd_excluded'" class="muted">规则:{{ excludedRuleText(row) }}</div>
|
||||
<div class="quick-link-actions">
|
||||
<el-button v-if="purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction })" type="primary" link @click="runPurchaseNextAction(row, { ...purchaseReady(row), nextAction: purchaseReady(row).processNextAction })">{{ purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction }) }}</el-button>
|
||||
<el-button-group v-if="quickCanLink(row)">
|
||||
@@ -326,6 +328,10 @@
|
||||
<el-descriptions-item label="解析状态"><el-tag :type="parseMeta(detail.item.parseStatus).type">{{ parseMeta(detail.item.parseStatus).label }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="解析说明">{{ detail.item.parseNote || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="人工确认">{{ detail.item.manuallyConfirmed ? '是' : '否' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="采购类型">
|
||||
<el-tag :type="detail.item.pddPurchaseExcluded ? 'info' : 'success'">{{ detail.item.pddPurchaseExcluded ? '无需 PDD 采购' : '需 PDD 采购' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.item.pddPurchaseExcluded" label="命中规则">{{ excludedRuleText(detail.item) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="drawer-actions">
|
||||
<el-button @click="openReparseSingle">重新解析</el-button>
|
||||
@@ -404,7 +410,7 @@ export default {
|
||||
imageSearchBatch: this.emptyImageSearchBatch(),
|
||||
collectionBatchData: { ruleId: null, deviceId: null },
|
||||
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
||||
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] },
|
||||
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] },
|
||||
returnMatchByProductId: {},
|
||||
returnMatchLoading: false,
|
||||
returnMatchBatchLoading: false,
|
||||
@@ -420,7 +426,7 @@ export default {
|
||||
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
|
||||
purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }, { value: 'pdd_excluded', label: '无需采购' }] },
|
||||
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) },
|
||||
aiMatchCandidates() { return this.selectedProducts.filter(row => this.isAIMatchCandidate(row)) },
|
||||
aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
|
||||
@@ -478,7 +484,7 @@ export default {
|
||||
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
|
||||
try {
|
||||
const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
|
||||
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, createdFrom, createdTo }, requestOptions)
|
||||
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, purchaseType: this.query.purchaseType, createdFrom, createdTo }, requestOptions)
|
||||
if (generation !== this.loadGeneration) return
|
||||
this.products = r.data.items
|
||||
this.total = r.data.total
|
||||
@@ -501,7 +507,15 @@ export default {
|
||||
}
|
||||
},
|
||||
search() { this.query.page = 1; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] }; this.load() },
|
||||
// #340: 选择「退货待确认/已用退货/无需采购」这些不属于「需 PDD 采购」默认范围
|
||||
// 的阶段时,自动把采购类型切到「全部」,否则处理阶段过滤会和采购类型过滤
|
||||
// 的 AND 组合出空列表;用户仍可再手动切回。
|
||||
onProcessStageChange(value) {
|
||||
if (['return_pending', 'return_used', 'pdd_excluded'].includes(value) && this.query.purchaseType !== 'all') {
|
||||
this.query.purchaseType = 'all'
|
||||
}
|
||||
},
|
||||
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
|
||||
async ensureShopOptions() {
|
||||
if (this.shopOptionsLoaded) return
|
||||
@@ -531,10 +545,13 @@ export default {
|
||||
// 图搜采集的用途就是给未关联 PDD 的虾皮商品找到 PDD 商品,所以它的可勾选条件
|
||||
// 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品
|
||||
// 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。
|
||||
isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && Boolean(row.shopeeProductId && row.imageUrl) },
|
||||
// #338: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
|
||||
isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && !this.isPDDExcluded(row) && Boolean(row.shopeeProductId && row.imageUrl) },
|
||||
// #340: pdd_purchase_excluded 商品仍可参与退货匹配(服务端 returnmatch 把
|
||||
// pdd_excluded 加入了 participatingStages),但不参与其他任何 PDD 相关操作。
|
||||
isPDDExcluded(row) { return this.purchaseReady(row).processStage === 'pdd_excluded' },
|
||||
// #338/#340: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
|
||||
isReturnMatchCandidate(row) {
|
||||
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready'])
|
||||
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready', 'pdd_excluded'])
|
||||
return participating.has(this.purchaseReady(row).processStage)
|
||||
},
|
||||
isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' },
|
||||
@@ -543,8 +560,13 @@ export default {
|
||||
isSelectableCandidate(row) { return this.purchaseReady(row).processStage === 'return_pending' || this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) },
|
||||
rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(row) },
|
||||
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
|
||||
// #340: 规则快照固定在标记时刻,与当前规则是否被改名/停用无关。
|
||||
excludedRuleText(item) {
|
||||
if (!item || !item.excludedRuleKeyword) return '(规则快照缺失)'
|
||||
return `${item.excludedRuleKind === 'char' ? '结构' : '关键词'} ${item.excludedRuleKeyword}`
|
||||
},
|
||||
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
|
||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }, pdd_excluded: { label: '无需采购', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
|
||||
// #339: the page can now show up to 500 rows, but the preview endpoint
|
||||
// (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<el-descriptions-item label="导入范围">{{ detail.item.dateFrom }} 至 {{ detail.item.dateTo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="进度">{{ detail.item.daysProcessed }}/{{ detail.item.daysTotal }} 天({{ detail.item.progressPercent }}%)</el-descriptions-item>
|
||||
<el-descriptions-item label="货运单">{{ detail.item.orderCount }} 张</el-descriptions-item>
|
||||
<el-descriptions-item label="商品明细">{{ detail.item.detailCount }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="商品明细">{{ detail.item.detailCount }} 条(其中本次规则命中 {{ excludedMarkedCount(detail.item) }} 条)</el-descriptions-item>
|
||||
<el-descriptions-item label="符合店铺">{{ detail.item.acceptedCount }} 张</el-descriptions-item>
|
||||
<el-descriptions-item label="跳过店铺">{{ detail.item.shopSkipped }} 张</el-descriptions-item>
|
||||
<el-descriptions-item label="结构过滤命中">{{ detail.item.charFilterSkipped || 0 }} 条</el-descriptions-item>
|
||||
@@ -97,6 +97,10 @@ export default {
|
||||
methods: {
|
||||
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, partial_success: { label: '部分成功', type: 'warning' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
|
||||
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
|
||||
// #340: 自本次改动起,命中过滤规则的明细不再跳过导入,而是入库并标记为
|
||||
// 无需 PDD 采购;这个数量就是本次运行标记的行数,等于两类命中之和,
|
||||
// 无需服务端新增字段。
|
||||
excludedMarkedCount(item) { return (item?.charFilterSkipped || 0) + (item?.keywordFilterSkipped || 0) },
|
||||
async load() {
|
||||
this.loading = true; this.loadError = ''
|
||||
try {
|
||||
|
||||
@@ -8,7 +8,7 @@ const syncMenu = [{ path: '/syb-sync-runs', component: 'Layout', visible: '0', m
|
||||
|
||||
test('部分成功可筛选并查看已保存数量和补齐提示', async ({ page, context }) => {
|
||||
await authenticate(context);
|
||||
const item = { id: 239, status: 'partial_success', dateFrom: '2026-08-01', dateTo: '2026-08-02', daysProcessed: 1, daysTotal: 2, progressPercent: 50, created: 2, updated: 1, orderCount: 3, detailCount: 3, acceptedCount: 3, shopSkipped: 0, errorMessage: '2026-08-01 第 2 页读取失败', shopBreakdown: [] };
|
||||
const item = { id: 239, status: 'partial_success', dateFrom: '2026-08-01', dateTo: '2026-08-02', daysProcessed: 1, daysTotal: 2, progressPercent: 50, created: 2, updated: 1, orderCount: 3, detailCount: 3, acceptedCount: 3, shopSkipped: 0, charFilterSkipped: 1, keywordFilterSkipped: 2, errorMessage: '2026-08-01 第 2 页读取失败', shopBreakdown: [] };
|
||||
let selected = '';
|
||||
await page.route('**/api/**', route => {
|
||||
const url = new URL(route.request().url());
|
||||
@@ -28,6 +28,10 @@ test('部分成功可筛选并查看已保存数量和补齐提示', async ({ pa
|
||||
await expect(page.getByText(item.errorMessage)).toBeVisible();
|
||||
await expect(page.getByText('已保存的数据会保留。', { exact: false })).toBeVisible();
|
||||
await expect(page.locator('.el-drawer').getByText('2 / 1', { exact: true })).toBeVisible();
|
||||
await expect(page.locator('.el-drawer').getByText('结构过滤命中', { exact: true })).toBeVisible();
|
||||
await expect(page.locator('.el-drawer').getByText('关键词过滤命中', { exact: true })).toBeVisible();
|
||||
await expect(page.locator('.el-drawer').getByText('其中本次规则命中 3 条', { exact: false })).toBeVisible();
|
||||
await expect(page.locator('.el-drawer').getByText(/结构过滤标记|关键词过滤标记/)).toHaveCount(0);
|
||||
await expect.poll(async () => {
|
||||
const box = await page.locator('.el-drawer').boundingBox();
|
||||
return box ? Math.round(box.x + box.width) : 0;
|
||||
|
||||
Reference in New Issue
Block a user