Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf06b2d500 | ||
|
|
fcb7dad878 | ||
|
|
6550b9fd45 | ||
|
|
7a2c2d1eac | ||
|
|
d01540b9fd | ||
|
|
ec010b42fc | ||
|
|
a56df9d908 | ||
|
|
cd4bd6acb1 | ||
|
|
baa20af530 | ||
|
|
f54ace7ccf | ||
|
|
1e582cd868 | ||
|
|
d72d77ddf4 | ||
|
|
ee8f80f987 | ||
|
|
4959f3d17f | ||
|
|
15d331bee9 | ||
|
|
897c1817d2 | ||
|
|
5729ba048f | ||
|
|
9c5e20e249 | ||
|
|
8c95790135 | ||
|
|
316d5aa412 | ||
|
|
e55645ff66 | ||
|
|
dd9160ec42 | ||
|
|
14231c62a7 | ||
|
|
4280115e11 | ||
|
|
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: 0e0ad0b3312c524d90e063167dd2e945fb22b0e9
|
||||
synchronized_at: 2026-09-29T01:36:01Z
|
||||
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 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
|
||||
@@ -561,3 +563,38 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- Web `syb-products/return-cancel.js` 与列表组件复用单条取消接口;冻结当前页勾选的 SYB 商品 ID 与原匹配 ID,一次确认后串行请求,独立汇总成功、跳过及失败。取消结果不会驱动已有采购或采集任务。
|
||||
- `returnmatch.Service.Cancel` 在锁定原匹配记录的事务中校验可选状态前提,再释放有效占用指针并写取消审计;事务内审计失败则整体回滚。无额外数据库迁移、权限、批量后台任务或外部写入。
|
||||
- 元数据加载失败时禁止批量取消;执行时互斥匹配、确认、取消。网络不确定时只读回原匹配,不自动重复写入;登录失效停止后续请求。列表刷新与失败项勾选恢复受查询 generation 约束,不跨页、不覆盖较新的筛选结果。
|
||||
|
||||
## SYB 当前页批量使用退货(#349)
|
||||
|
||||
实现绑定 4280115(2026-09-29),feat/349-batch-return-use;以 #347 a4c7b5e 为基线,未合并 main、未发布或真实业务验收。
|
||||
|
||||
- Web syb-products/index.vue 新增“使用退货 N”,紧跟“取消匹配”;return-confirm.js 冻结当前页原 SYB / match 身份,去重后一次确认、串行复用既有 confirm API,结果独立汇总。return-cancel.js 和单条操作接入使用期间互斥,不重构取消逻辑。
|
||||
- return-matches.js 的 confirmReturnMatch 增加可选客户端请求选项,仅用于批量汇总时抑制逐项错误弹窗;无新增 HTTP 字段。Server 生产代码、确认锁内 matched 检查、ConfirmedBy/ConfirmedAt、审计和占用指针不变,无迁移或权限变化。
|
||||
- 未知写结果只读回原 match,回读确认不宣称为本次成功;登录失效停止余项。刷新保持筛选与页码,失败选择恢复受 generation、当前页及原 matched 身份约束。未调用 Yeeke 发货或修改采购任务。
|
||||
- 回归入口:Web tests/unit/syb-return-confirm.test.cjs、tests/e2e/syb-return-confirm.spec.ts;Server returnmatch/confirm_batch_test.go。测试使用合成数据和隔离 SQLite,不代表生产 MySQL 并发或真实退货验证。
|
||||
|
||||
## 档口入库码匹配容错与手动批量重匹配(#350)
|
||||
- `sybinnercode/match.go` 在 SKU/档口证据筛选与分配结果为空时明确受限,不访问空数组;只读单条规划通过 `safePlanRecord` 隔离异常,下一条继续处理,不调用远端写接口。
|
||||
- `failMatchJob` 用独立 10 秒清理上下文收敛中断批次及其 pending/matching 记录;成功计划和回写记录保留。`cmd/api/server.go` 在服务启动时调用 `RecoverInterruptedMatches`,不自动重跑匹配或写入。
|
||||
- rematch 在原行锁事务内检查资格和活动批次占用,排队记录改为 matching,阻止重叠请求;matching 记录不可删除。
|
||||
- 原页面默认用户浏览器本地今天范围,工具栏“匹配 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: e95c53cec27080e073e6f7a4aaed46d5421dddf5
|
||||
synchronized_at: 2026-09-29T01:36:04Z
|
||||
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` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。
|
||||
@@ -335,11 +336,11 @@ synchronized_at: 2026-09-22T02:56:27Z
|
||||
## SYB 档口入库码导入、匹配与回写
|
||||
|
||||
- 只接受不超过 10 MB、最多 5000 条非空数据行且包含“标签入库码映射”工作表的 Excel。一次文件只能包含一个营业日期;同日单件入库码必须唯一,同一业务键按营业日期、订单号、档口和规范化规格合并并保留 Excel 顺序。
|
||||
- 导入事务提交后自动创建并启动只读匹配任务,不提供独立“匹配”按钮。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。
|
||||
- 导入事务提交后自动创建并启动只读匹配任务,手动批量重匹配入口见 #350 的版本绑定规则。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。
|
||||
- 匹配必须按精确订单号取得唯一货运单,再以规格、原始 SKU 和档口证据确定唯一商品;歧义、冲突或数量不一致均不猜测。匹配阶段不能调用任何 SYB 写接口。
|
||||
- 回写前弹窗展示业务记录数、入库码总数、预计占位明细数和替换旧码数。只有 `ready` 记录可提交;所有服务实例共用数据库租约全局串行执行。
|
||||
- 每个远端写动作只发送一次,动作前重读整张货运单并校验匹配计划未漂移;每件写入后重读确认目标码唯一位于预期明细。超时、5xx、响应无法确认或服务在写入期间重启时转为 `needs_check`,禁止自动重试;“只读复核”只能读取远端状态。
|
||||
- 列表支持勾选后批量物理删除。删除必须再次确认;选中项包含 `queued`、`applying` 或 `needs_check` 时整批拒绝,不做部分删除。其余选中业务记录、逐件码、计划和终态执行证据在同一事务中物理删除;已写入 SYB 的远端值不会撤销。
|
||||
- 列表支持勾选后批量物理删除。删除必须再次确认;选中项包含 `matching`、`queued`、`applying` 或 `needs_check` 时整批拒绝,不做部分删除。其余选中业务记录、逐件码、计划和终态执行证据在同一事务中物理删除;已写入 SYB 的远端值不会撤销。
|
||||
|
||||
## PDD 失效或售罄商品替换(#129)
|
||||
|
||||
@@ -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;不弱化面板验证、精确选中、地址、价格、任务租约、创建订单边界或禁止支付规则。
|
||||
@@ -722,3 +725,46 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
|
||||
- 一次确认后按原匹配编号逐条取消。并发确认、已经取消等状态冲突跳过;其他错误保留失败。网络异常先回读原记录,不盲目重试,不追随后来重新匹配的新记录;登录失效停止剩余写入。
|
||||
- 成功后重新计算采购准备状态,并按当前退货状态与同步可用性判断退货池资格,不保证所有商品立即可采购,不改变既有采购或采集任务。保持查询条件刷新;仅保留仍在当前页、仍为原匹配且可取消的失败项勾选,其余失败项保留在结果表。
|
||||
- 原有单条确认及取消兼容:未携带状态前提的单条取消仍允许取消已确认匹配;本次不新增权限或迁移,不自动创建采购或执行付款。
|
||||
|
||||
## SYB 当前页批量使用退货(#349)
|
||||
|
||||
实现绑定 4280115(2026-09-29),尚未合并/发布,待验收。
|
||||
|
||||
- 管理员、采购员及售后沿用既有权限,工具栏为“匹配退货 → 取消匹配 → 使用退货”。仅当前页勾选且处理阶段 return_pending、原有效匹配 matched 的明细计入使用数量;已用退货与未匹配行不参与。
|
||||
- 一次确认后逐条标记 confirmed / 已用退货,保留有效占用;不是 Yeeke 发货或重出,不修改已有采购/采集任务,不自动创建采购或支付,不扩大原候选资格。
|
||||
- 只操作冻结的原 match ID。并发确认或取消冲突跳过,不追随后来的替代匹配。其他错误失败,各项不互相回滚;登录失效停止后续写入并明确未执行。
|
||||
- 网络结果未知先回读原记录:已确认/已取消显示回读状态而不是本次成功;仍为原 matched 可人工重试;回读失败保留不确定结果,不自动重复提交。
|
||||
- 完成保持筛选、页码和结果明细。成功项在退货待确认筛选下移出;仅当前页仍为原 matched 的可重试失败项恢复勾选,离页结果不丢失、不跨页操作。操作中互斥匹配、使用、取消及单条操作,无新增审批或权限。
|
||||
|
||||
## 档口入库码手动匹配与中断恢复(#350)
|
||||
- 页面首次进入默认本地今天~今天;清除筛选明确取消日期限制。可勾选当前页 pending/failed/skipped 记录点击“匹配 N”;混选只提交合格项,跨营业日期提示分日期处理。加载/列表失败/提交/活动匹配期间禁用匹配。
|
||||
- 手动匹配仍按原精确订单、规格、SKU/档口和数量证据,不可强行选近似商品。空候选明确 skipped;单条规划异常 failed 并继续后续记录;匹配成功仅 ready,不自动回写。
|
||||
- 原 rematch 可接受同日 pending/failed/skipped;已有活动匹配任务占用或其他状态均拒绝;同 requestId 幂等重放,同记录排队为 matching。matching 期间不可删除,成功/回写/需复核记录不能重新匹配。
|
||||
- 超时/中断以及启动恢复把未完成 pending/matching 改为明确失败并将旧批次结束;既有 ready/updated/回写证据和范围外记录不修改,用户可手动重试。恢复不调用 SYB 写接口,不自动回写、不删除成功结果。
|
||||
|
||||
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
|
||||
|
||||
|
||||
### 档口入库码排版字符兼容(#350 v2)
|
||||
匹配比较时,双方原始 SKU、variationSku 及档口名称仅去除 CR/LF/TAB 排版控制字符,并沿用首尾空白 trim;普通内部空格、大小写、标点与货号不变。导入原始值和回写计划中的 SYB 原始证据保持原样。归一化后重复候选、SKU/档口冲突仍拒绝,原精确规格、货号、数量与占用校验不放宽。成功匹配只形成可回写计划,不自动执行 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: fff074b921368be646cda1bf0a434608fc5082c8
|
||||
synchronized_at: 2026-09-29T01:36:20Z
|
||||
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 仍按原策略执行,不能将本契约描述当作旧设备已获得兼容。
|
||||
|
||||
规格探测与精确选择自行负责按需有界滚动,原始候选、精确点击和选中复核不变。跳过预滑动不作为规格探测成功或订单创建证据。
|
||||
@@ -1408,3 +1423,28 @@ GET /api/admin/v1/purchase-tasks 的既有 status 参数新增查询专用值 sy
|
||||
携带前提时,服务端在锁定 `:id` 原记录的事务中检查状态仍为 `matched` 且有效占用指针存在,否则 HTTP 409 / `STATE_CONFLICT`,不取消已确认或已取消的匹配。不存在返回 HTTP 404 / `NOT_FOUND`。成功仍为 `{code:200,data:{item:ReturnMatch}}`,释放占用并记录 CancelledBy / CancelledAt 和审计日志。权限沿用 admin / purchaser / after_sales,没有新增批量接口或权限点。
|
||||
|
||||
Web 批量入口冻结原匹配 ID 并顺序调用;HTTP 409 状态冲突跳过,权限/网络错误失败,HTTP 401 或登录失效停止后续操作。写入结果未知时只允许只读回读原记录,不自动重发取消;不以 SYB ID 重新查询替代匹配并取消。
|
||||
|
||||
## Admin 批量使用退货复用确认接口(#349)
|
||||
|
||||
实现绑定 4280115(2026-09-29),未合并/发布;仅补充管理端调用规则,Android Agent 契约不变。
|
||||
|
||||
- Web 当前页批量使用复用 POST /api/admin/v1/return-matches/:id/confirm,无新增请求体、批量接口、状态或权限。Server 沿用锁定原记录后仅接受 matched;成功为 {code:200,data:{item:ReturnMatch}},写 confirmed、确认人/时间及审计,保留 active 占用。
|
||||
- HTTP 409 / STATE_CONFLICT 跳过,HTTP 404 / NOT_FOUND 或其他处理错误明确失败。沿用 admin / purchaser / after_sales 权限;HTTP 401、业务认证错误及旧拦截器 false 结果均停止余项,不能误计为成功。
|
||||
- 写结果不明确时仅 GET /api/admin/v1/return-matches/:id 回读冻结原记录,验证 match 身份;已 confirmed/cancelled 显示回读已处理,仍 matched 才允许手动重试。不得自动重发 POST 或改查新匹配后使用。
|
||||
- 无数据库、Server 生产行为、Agent 协议或外部 Yeeke 写入变化。真实业务确认及部署另需授权。
|
||||
|
||||
## Admin 档口入库码 rematch 补充(#350,Agent 接口不变)
|
||||
- `POST /api/admin/v1/syb-inner-codes/rematch` 保留 `{requestId, ids}` 与响应 `{matchJobId, queued}`;原权限与客户端密钥 match 功能权限不变,不新增权限项或迁移。
|
||||
- 同日 pending/failed/skipped 可以排队;matching、ready、updated、already_filled、queued、applying、needs_check 和活动匹配占用拒绝 409。排队事务记录 matching,避免新 requestId 重叠提交;同 requestId 返回原结果。
|
||||
- 任务正常结束沿用 succeeded + ready/failed 分项计数;批次中断/服务启动恢复为 failed 并提供安全说明与 finishedAt,未完成记录 failed 可重新匹配,已完成结果不回滚。匹配仅只读 SYB,成功需另行 apply 确认。
|
||||
- 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/
|
||||
验证码等任何顺运宝请求之前停止,并且不推进覆盖游标。该过滤只影响后续入库,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 3a76e16d43c940425f68cc4748944213e980ad60
|
||||
synchronized_at: 2026-09-28T08:10:00Z
|
||||
wiki_revision: 6d3c599a45813ad844395e93a02832f0d28dd3f5
|
||||
synchronized_at: 2026-09-29T03:47:47Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -239,3 +239,10 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 线上服务由 185.216.248.75 迁移到 122.228.200.167(发布目录 `20260928-344-9dace6a`)。
|
||||
- 迁移后 `http://122.228.200.167:9527/` 显示 go-admin 欢迎页:新机 9527 vhost 写成了全部 `proxy_pass`,缺少 `root /home/goauto/current/dist` 与 `location = /`。已按「Nginx 入口(9527)」标准配置修复(原文件备份为 `goauto-9527.conf.bak-20260928150822`),`nginx -t` 通过后 reload;按内容验收首页、前端路由、静态资源、验证码与未登录接口均通过。
|
||||
- 代码侧根治见 #346:存在 dist 时服务端 `GET /` 也返回 SPA index.html,即使 Nginx 误配为全部转发也不再出现欢迎页。
|
||||
|
||||
## 档口入库码匹配中断排错与恢复(#350)
|
||||
- 匹配后台总时限仍为 20 分钟,SYB 客户端单请求 60 秒;候选数组越界造成的进程退出不能通过增大时间解决。受控日志查看运行服务的 panic/退出/重启时间,结合匹配 processed/total 与记录状态判断中断;不导出原始生产记录。
|
||||
- 修复版本启动时在接受新任务前将旧 pending/running 匹配批次终结,未完成 pending/matching 记录设为失败,已成功结果保留;仅状态恢复,不自动匹配或 SYB 回写。
|
||||
- 旧版本出现崩溃现场,必须先确认原 worker 已退出、目标记录没有匹配计划和远端写执行证据,再按授权做精确条件事务收敛;禁止无条件整日重置或重试仍会崩溃的旧接口。修复上线后手动勾选失败项匹配,ready 后另行确认回写。
|
||||
|
||||
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
|
||||
|
||||
@@ -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,103 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// #349 reuses the existing single-record transaction; no production batch API.
|
||||
func TestConfirmBatchExistingHTTPContract(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, status, role string
|
||||
code int
|
||||
}{
|
||||
{"admin", "matched", "admin", 200},
|
||||
{"purchaser", "matched", "purchaser", 200},
|
||||
{"after_sales", "matched", "after_sales", 200},
|
||||
{"already_confirmed", "confirmed", "after_sales", 409},
|
||||
{"cancelled", "cancelled", "purchaser", 409},
|
||||
{"forbidden", "matched", "viewer", 403},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
m := seedCancelMatch(t, db, tc.status)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", fmt.Sprintf("/return-matches/%d/confirm", m.ID), nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(m.ID)}}
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": tc.role, "username": "confirm-tester"})
|
||||
Handler{DB: db}.Confirm(c)
|
||||
if w.Code != tc.code {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var saved models.ReturnMatch
|
||||
db.First(&saved, m.ID)
|
||||
var logs int64
|
||||
db.Model(&models.ReturnMatchLog{}).Where("match_id = ?", m.ID).Count(&logs)
|
||||
if tc.code == 200 {
|
||||
if saved.Status != "confirmed" || saved.ConfirmedBy != "confirm-tester" || saved.ConfirmedAt == nil || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil || logs != 1 {
|
||||
t.Fatalf("confirmation audit or occupancy lost: %+v logs=%d", saved, logs)
|
||||
}
|
||||
} else if saved.Status != tc.status || logs != 0 {
|
||||
t.Fatalf("rejected confirm changed state: %+v logs=%d", saved, logs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmBatchOriginalRecordStateChanges(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
if _, err := s.Confirm(context.Background(), 99999, "tester"); err != gorm.ErrRecordNotFound {
|
||||
t.Fatalf("missing=%v", err)
|
||||
}
|
||||
m := seedCancelMatch(t, db, "matched")
|
||||
if _, err := s.Cancel(context.Background(), m.ID, "other"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simulate a later rematch with the same product/return, but a new identity.
|
||||
replacement := m
|
||||
replacement.ID = 0
|
||||
replacement.CancelledAt = nil
|
||||
replacement.CancelledBy = ""
|
||||
if err := db.Create(&replacement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Confirm(context.Background(), m.ID, "batch"); err != errStateConflict {
|
||||
t.Fatalf("stale original=%v", err)
|
||||
}
|
||||
if _, err := s.Confirm(context.Background(), replacement.ID, "reviewer"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Confirm(context.Background(), replacement.ID, "repeat"); err != errStateConflict {
|
||||
t.Fatalf("duplicate=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmBatchAuditFailureRollsBack(t *testing.T) {
|
||||
db := testDB(t)
|
||||
m := seedCancelMatch(t, db, "matched")
|
||||
if err := db.Callback().Create().Before("gorm:create").Register("test:reject_confirm_log", func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "ReturnMatchLog" {
|
||||
tx.AddError(errors.New("synthetic audit failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewService(db).Confirm(context.Background(), m.ID, "tester"); err == nil {
|
||||
t.Fatal("expected audit failure")
|
||||
}
|
||||
var saved models.ReturnMatch
|
||||
db.First(&saved, m.ID)
|
||||
if saved.Status != "matched" || saved.ConfirmedAt != nil || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil {
|
||||
t.Fatalf("confirm did not roll back: %+v", saved)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type MatchReader interface {
|
||||
@@ -45,12 +46,19 @@ func (m Matcher) runBackground(db *gorm.DB, jobID string) {
|
||||
err = RunMatchJob(ctx, db, reader, jobID)
|
||||
}
|
||||
if err != nil {
|
||||
now := time.Now().UTC()
|
||||
_ = db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status IN ?", jobID, []string{"pending", "running"}).Updates(map[string]any{"status": "failed", "error_message": compact(err.Error(), 1000), "finished_at": now}).Error
|
||||
_ = failMatchJob(db, jobID, "匹配任务中断,请重新匹配")
|
||||
}
|
||||
}
|
||||
|
||||
func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID string) error {
|
||||
func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID string) (runErr error) {
|
||||
claimedJob := false
|
||||
defer func() {
|
||||
if runErr != nil && claimedJob {
|
||||
if err := failMatchJob(db, jobID, "匹配任务中断,请重新匹配"); err != nil {
|
||||
runErr = errors.Join(runErr, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
now := time.Now().UTC()
|
||||
claimed := db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status = ?", jobID, "pending").Updates(map[string]any{"status": "running", "started_at": now})
|
||||
if claimed.Error != nil {
|
||||
@@ -66,6 +74,7 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
|
||||
}
|
||||
return conflict("匹配任务状态不允许执行")
|
||||
}
|
||||
claimedJob = true
|
||||
var job models.SYBInnerCodeMatchJob
|
||||
if err := db.WithContext(ctx).First(&job, "id = ?", jobID).Error; err != nil {
|
||||
return err
|
||||
@@ -75,7 +84,7 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
|
||||
return fmt.Errorf("匹配任务记录范围无效")
|
||||
}
|
||||
var records []models.SYBInnerCodeRecord
|
||||
if err := db.WithContext(ctx).Preload("Items", func(q *gorm.DB) *gorm.DB { return q.Order("ordinal ASC") }).Where("id IN ? AND business_date = ? AND status IN ?", recordIDs, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}).Order("source_row,id").Find(&records).Error; err != nil {
|
||||
if err := db.WithContext(ctx).Preload("Items", func(q *gorm.DB) *gorm.DB { return q.Order("ordinal ASC") }).Where("id IN ? AND business_date = ? AND status IN ?", recordIDs, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeMatching, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}).Order("source_row,id").Find(&records).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
used, err := loadReservedDetails(ctx, db, records)
|
||||
@@ -84,7 +93,10 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
|
||||
}
|
||||
ready, failed := 0, 0
|
||||
for _, record := range records {
|
||||
plan, status, message, planErr := planRecord(ctx, reader, record, used)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
plan, status, message, planErr := safePlanRecord(ctx, reader, record, used)
|
||||
if planErr != nil {
|
||||
status = models.SYBInnerCodeFailed
|
||||
message = "读取 SYB 失败:" + compact(planErr.Error(), 900)
|
||||
@@ -111,12 +123,62 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", jobID).Updates(map[string]any{"processed": gorm.Expr("processed + 1"), "ready": ready, "failed": failed})
|
||||
if err := db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", jobID).Updates(map[string]any{"processed": gorm.Expr("processed + 1"), "ready": ready, "failed": failed}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
finished := time.Now().UTC()
|
||||
return db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status = ?", jobID, "running").Updates(map[string]any{"status": "succeeded", "finished_at": finished, "ready": ready, "failed": failed}).Error
|
||||
}
|
||||
|
||||
// Only the read/plan step is isolated: no remote write is retried here.
|
||||
func safePlanRecord(ctx context.Context, reader MatchReader, record models.SYBInnerCodeRecord, used map[int64]bool) (plan *models.SYBInnerCodePlan, status, message string, err error) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
plan, status, message = nil, models.SYBInnerCodeFailed, "匹配处理异常,请重新匹配"
|
||||
err = nil
|
||||
}
|
||||
}()
|
||||
return planRecord(ctx, reader, record, used)
|
||||
}
|
||||
|
||||
// Cleanup must not inherit an expired job or HTTP request context.
|
||||
func failMatchJob(db *gorm.DB, jobID, message string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var job models.SYBInnerCodeMatchJob
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&job, "id = ?", jobID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if job.Status != "pending" && job.Status != "running" {
|
||||
return nil
|
||||
}
|
||||
var ids []uint64
|
||||
if err := json.Unmarshal([]byte(job.RecordIDsJSON), &ids); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ? AND business_date = ? AND status IN ?", ids, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeMatching}).Updates(map[string]any{"status": models.SYBInnerCodeFailed, "result_message": message}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&job).Updates(map[string]any{"status": "failed", "error_message": message, "finished_at": time.Now().UTC()}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Called at startup before new jobs can be submitted; never resumes remote writes.
|
||||
func RecoverInterruptedMatches(db *gorm.DB) error {
|
||||
var jobs []models.SYBInnerCodeMatchJob
|
||||
if err := db.Where("status IN ?", []string{"pending", "running"}).Find(&jobs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, job := range jobs {
|
||||
if err := failMatchJob(db, job.ID, "服务重启,匹配任务中断,请重新匹配"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadReservedDetails(ctx context.Context, db *gorm.DB, selected []models.SYBInnerCodeRecord) (map[int64]bool, error) {
|
||||
selectedIDs := map[uint64]bool{}
|
||||
for _, r := range selected {
|
||||
@@ -186,6 +248,9 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
|
||||
if reason != "" {
|
||||
return nil, models.SYBInnerCodeSkipped, reason, nil
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return nil, models.SYBInnerCodeSkipped, "相同规格候选的原始 SKU 或档口货号未匹配", nil
|
||||
}
|
||||
count := len(record.Items)
|
||||
if count == 0 {
|
||||
return nil, models.SYBInnerCodeSkipped, "记录没有入库码", nil
|
||||
@@ -213,6 +278,9 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
|
||||
} else {
|
||||
return nil, models.SYBInnerCodeSkipped, fmt.Sprintf("SYB 商品数量与入库码数量不一致(%d/%d)", matches[0].ProductQty, count), nil
|
||||
}
|
||||
if len(chosen) == 0 {
|
||||
return nil, models.SYBInnerCodeSkipped, "未形成唯一的商品分配,不能自动选择", nil
|
||||
}
|
||||
primary := chosen[0]
|
||||
items := make([]plannedRemoteItem, 0, count)
|
||||
placeholder := 0
|
||||
@@ -349,12 +417,18 @@ func matchSpec(spec string, items []sybclient.DetailItem) []sybclient.DetailItem
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Compare layout-only controls without changing stored evidence or ordinary spaces.
|
||||
func normalizeMatchLayout(value string) string {
|
||||
return strings.TrimSpace(strings.NewReplacer("\r", "", "\n", "", "\t", "").Replace(value))
|
||||
}
|
||||
|
||||
func matchEvidence(stall, sourceSKU string, items []sybclient.DetailItem) ([]sybclient.DetailItem, string) {
|
||||
sourceSKU = strings.TrimSpace(sourceSKU)
|
||||
sourceSKU = normalizeMatchLayout(sourceSKU)
|
||||
if sourceSKU != "" {
|
||||
skuMatches := []sybclient.DetailItem{}
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(rawText(item.Raw["sku"])) == sourceSKU || strings.TrimSpace(rawText(item.Raw["variationSku"])) == sourceSKU {
|
||||
if normalizeMatchLayout(rawText(item.Raw["sku"])) == sourceSKU || normalizeMatchLayout(rawText(item.Raw["variationSku"])) == sourceSKU {
|
||||
skuMatches = append(skuMatches, item)
|
||||
}
|
||||
}
|
||||
@@ -385,7 +459,7 @@ func matchEvidence(stall, sourceSKU string, items []sybclient.DetailItem) ([]syb
|
||||
return fallback, ""
|
||||
}
|
||||
func strictStall(stall string, items []sybclient.DetailItem) []sybclient.DetailItem {
|
||||
stall = strings.TrimSpace(stall)
|
||||
stall = normalizeMatchLayout(stall)
|
||||
if stall == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -403,8 +477,12 @@ func strictStall(stall string, items []sybclient.DetailItem) []sybclient.DetailI
|
||||
// 候选中同时包含档口名才允许前导零等价(如 "067"≡"67");非数字货号要求精确
|
||||
// token 匹配;ProductSpec 只在以货号开头时才算命中;货号为空时回退为档口名包含判断。
|
||||
func stallMatches(stall string, item sybclient.DetailItem) bool {
|
||||
sku := rawText(item.Raw["sku"])
|
||||
variation := rawText(item.Raw["variationSku"])
|
||||
stall = normalizeMatchLayout(stall)
|
||||
if stall == "" {
|
||||
return false
|
||||
}
|
||||
sku := normalizeMatchLayout(rawText(item.Raw["sku"]))
|
||||
variation := normalizeMatchLayout(rawText(item.Raw["variationSku"]))
|
||||
blob := sku + " " + variation + " " + item.ProductSpec
|
||||
if strings.Contains(blob, stall) {
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package sybinnercode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
)
|
||||
|
||||
func TestMatchEvidenceNormalizesLayoutOnBothSides(t *testing.T) {
|
||||
for _, layout := range []string{"\n", "\r", "\r\n", "\t"} {
|
||||
for _, sourceHasLayout := range []bool{true, false} {
|
||||
source, candidate := "市场-档口甲#731", "市场-档口甲#731"
|
||||
if sourceHasLayout {
|
||||
source = "市场-" + layout + "档口甲#731"
|
||||
} else {
|
||||
candidate = "市场-" + layout + "档口甲#731"
|
||||
}
|
||||
item := detail(1, "黑色,L", 1, "", candidate, "")
|
||||
matches, reason := matchEvidence(source, source, []sybclient.DetailItem{item})
|
||||
if reason != "" || len(matches) != 1 || !stallMatches(source, item) {
|
||||
t.Fatalf("layout=%q sourceLayout=%v: matches=%v reason=%s", layout, sourceHasLayout, matches, reason)
|
||||
}
|
||||
if item.Raw["variationSku"] != candidate {
|
||||
t.Fatal("comparison modified raw candidate evidence")
|
||||
}
|
||||
}
|
||||
}
|
||||
item := detail(1, "黑色,L", 1, "SKU-\n1", "", "")
|
||||
if matches, reason := matchEvidence("", "SKU-1", []sybclient.DetailItem{item}); reason != "" || len(matches) != 1 {
|
||||
t.Fatalf("raw sku layout did not match: %v %s", matches, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchLayoutPreservesIdentityAndAmbiguityChecks(t *testing.T) {
|
||||
for _, candidate := range []string{"市场-档口乙#731", "市场-档口甲#732", "市场-档 口甲#731", "市场-档口甲#PDD7319"} {
|
||||
item := detail(1, "黑色,L", 1, "", candidate, "")
|
||||
matches, _ := matchEvidence("市场-\n档口甲#731", "市场-\n档口甲#731", []sybclient.DetailItem{item})
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("different identity unexpectedly matched: %q", candidate)
|
||||
}
|
||||
}
|
||||
items := []sybclient.DetailItem{detail(1, "黑色,L", 1, "SKU-1", "", ""), detail(2, "黑色,L", 1, "SKU-\n1", "", "")}
|
||||
if matches, reason := matchEvidence("", "SKU-1", items); len(matches) != 0 || reason == "" {
|
||||
t.Fatal("layout-equivalent duplicate SKUs must remain ambiguous")
|
||||
}
|
||||
items = []sybclient.DetailItem{detail(1, "黑色,L", 1, "SKU-1", "市场-档口乙#732", ""), detail(2, "黑色,L", 1, "OTHER", "市场-档口甲#731", "")}
|
||||
if matches, reason := matchEvidence("市场-\n档口甲#731", "SKU-\t1", items); len(matches) != 0 || reason == "" {
|
||||
t.Fatal("SKU/stall conflict must remain rejected")
|
||||
}
|
||||
if normalizeMatchLayout("A B") == normalizeMatchLayout("AB") || normalizeMatchLayout("a") == normalizeMatchLayout("A") {
|
||||
t.Fatal("ordinary spaces and case must remain significant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRecordWithLayoutEvidenceKeepsOriginalValues(t *testing.T) {
|
||||
record := matchRecord("ORDER-1", "市场-\n档口甲#731", "市场-\n档口甲#731", "IC-1")
|
||||
item := detail(20, "黑色,L", 1, "", "市场-档口甲#731", "")
|
||||
reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Details: []sybclient.DetailItem{item}}}}
|
||||
plan, status, reason, err := planRecord(context.Background(), reader, record, map[int64]bool{})
|
||||
if err != nil || status != models.SYBInnerCodeReady || plan == nil || plan.DetailID != 20 {
|
||||
t.Fatalf("expected unique ready plan, got %v %s %s %v", plan, status, reason, err)
|
||||
}
|
||||
if record.Stall != "市场-\n档口甲#731" || record.SourceSKURaw != record.Stall || plan.SYBVariationSKU != "市场-档口甲#731" {
|
||||
t.Fatal("original import and plan evidence must remain unchanged")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package sybinnercode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
)
|
||||
|
||||
type interruptingReader struct {
|
||||
*fakeMatchReader
|
||||
panicOrder string
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (r interruptingReader) ListByOrderNumber(ctx context.Context, order string) ([]sybclient.StockRow, error) {
|
||||
if order == r.panicOrder {
|
||||
panic("must not escape or be persisted")
|
||||
}
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return r.fakeMatchReader.ListByOrderNumber(ctx, order)
|
||||
}
|
||||
|
||||
func TestEmptyEvidenceDoesNotPanicAndNextRecordContinues(t *testing.T) {
|
||||
db := testDB(t)
|
||||
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("BAD", "NO-SKU", "NO#9", "IC-1"), matchRecord("GOOD", "SKU-A", "A#1", "IC-2")})
|
||||
r := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"BAD": {{ID: 10}}, "GOOD": {{ID: 11}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Details: []sybclient.DetailItem{detail(20, "黑色,L", 1, "SKU-A", "A#1", "")}}, 11: {ID: 11, Details: []sybclient.DetailItem{detail(21, "黑色,L", 1, "SKU-A", "A#1", "")}}}}
|
||||
if err := RunMatchJob(context.Background(), db, r, job); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Order("id").Find(&rows)
|
||||
if rows[0].Status != models.SYBInnerCodeSkipped || rows[1].Status != models.SYBInnerCodeReady {
|
||||
t.Fatal("empty evidence must be limited, next row ready")
|
||||
}
|
||||
var plans int64
|
||||
db.Model(&models.SYBInnerCodePlan{}).Count(&plans)
|
||||
if plans != 1 {
|
||||
t.Fatalf("unexpected plans=%d", plans)
|
||||
}
|
||||
var j models.SYBInnerCodeMatchJob
|
||||
db.First(&j, "id = ?", job)
|
||||
if j.Processed != 2 || j.Ready != 1 || j.Failed != 1 || j.Status != "succeeded" {
|
||||
t.Fatalf("counts=%+v", j)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleRecordPanicIsIsolated(t *testing.T) {
|
||||
db := testDB(t)
|
||||
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("PANIC", "S", "A", "IC-1"), matchRecord("NEXT", "S", "A", "IC-2")})
|
||||
r := interruptingReader{fakeMatchReader: &fakeMatchReader{rows: map[string][]sybclient.StockRow{}}, panicOrder: "PANIC"}
|
||||
if err := RunMatchJob(context.Background(), db, r, job); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Order("id").Find(&rows)
|
||||
if rows[0].Status != models.SYBInnerCodeFailed || rows[0].ResultMessage != "匹配处理异常,请重新匹配" || rows[1].Status != models.SYBInnerCodeFailed {
|
||||
t.Fatal("panic was not safely persisted or next row not processed")
|
||||
}
|
||||
var j models.SYBInnerCodeMatchJob
|
||||
db.First(&j, "id = ?", job)
|
||||
if j.Processed != 2 {
|
||||
t.Fatal("remaining row was not processed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelledMatchPersistsFailureWithIndependentContext(t *testing.T) {
|
||||
db := testDB(t)
|
||||
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "IC-1"), matchRecord("B", "S", "B", "IC-2")})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
err := RunMatchJob(ctx, db, interruptingReader{fakeMatchReader: &fakeMatchReader{}, cancel: cancel}, job)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Find(&rows)
|
||||
for _, r := range rows {
|
||||
if r.Status != models.SYBInnerCodeFailed {
|
||||
t.Fatal("cancel left pending row")
|
||||
}
|
||||
}
|
||||
var j models.SYBInnerCodeMatchJob
|
||||
db.First(&j, "id = ?", job)
|
||||
if j.Status != "failed" || j.FinishedAt == nil {
|
||||
t.Fatal("cancel left running job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartRecoveryPreservesSuccessfulRecordsAndScope(t *testing.T) {
|
||||
db := testDB(t)
|
||||
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "IC-1"), matchRecord("B", "S", "B", "IC-2")})
|
||||
db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", job).Update("status", "running")
|
||||
db.Model(&models.SYBInnerCodeRecord{}).Where("order_number = ?", "A").Update("status", models.SYBInnerCodeUpdated)
|
||||
outside := matchRecord("OUTSIDE", "S", "C", "IC-3")
|
||||
db.Create(&outside)
|
||||
if err := RecoverInterruptedMatches(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RecoverInterruptedMatches(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Order("id").Find(&rows)
|
||||
if rows[0].Status != models.SYBInnerCodeUpdated || rows[1].Status != models.SYBInnerCodeFailed || rows[2].Status != models.SYBInnerCodePending {
|
||||
t.Fatal("recovery altered success or outside scope")
|
||||
}
|
||||
var j models.SYBInnerCodeMatchJob
|
||||
db.First(&j, "id = ?", job)
|
||||
if j.Status != "failed" || j.FinishedAt == nil {
|
||||
t.Fatal("recovery left running job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchRematchClaimsPendingFailedSkippedAndIsIdempotent(t *testing.T) {
|
||||
db := testDB(t)
|
||||
statuses := []string{models.SYBInnerCodePending, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}
|
||||
ids := []uint64{}
|
||||
for i, status := range statuses {
|
||||
r := matchRecord(string(rune('A'+i)), "S", "A", string(rune('X'+i)))
|
||||
r.Status = status
|
||||
if err := db.Create(&r).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
s := NewService(db)
|
||||
req := RematchRequest{RequestID: uuid.NewString(), IDs: ids}
|
||||
result, err := s.QueueRematch(context.Background(), req)
|
||||
if err != nil || result.Queued != 3 {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
replay, err := s.QueueRematch(context.Background(), req)
|
||||
if err != nil || replay.MatchJobID != result.MatchJobID {
|
||||
t.Fatal("idempotent replay failed")
|
||||
}
|
||||
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: ids}); err == nil {
|
||||
t.Fatal("overlapping job accepted")
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Find(&rows)
|
||||
for _, r := range rows {
|
||||
if r.Status != models.SYBInnerCodeMatching {
|
||||
t.Fatal("not claimed")
|
||||
}
|
||||
}
|
||||
if _, err := s.Delete(context.Background(), 1, DeleteRequest{RequestID: uuid.NewString(), IDs: ids}); err == nil {
|
||||
t.Fatal("matching records can be deleted during execution")
|
||||
}
|
||||
if err := RunMatchJob(context.Background(), db, &fakeMatchReader{rows: map[string][]sybclient.StockRow{}}, result.MatchJobID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var job models.SYBInnerCodeMatchJob
|
||||
db.First(&job, "id = ?", result.MatchJobID)
|
||||
if job.Processed != 3 || job.Status != "succeeded" {
|
||||
t.Fatal("claimed matching records were not executed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRematchRejectsPendingOwnedByImportAndProtectedStates(t *testing.T) {
|
||||
db := testDB(t)
|
||||
createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "X")})
|
||||
var r models.SYBInnerCodeRecord
|
||||
db.First(&r)
|
||||
s := NewService(db)
|
||||
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: []uint64{r.ID}}); err == nil {
|
||||
t.Fatal("active import overlapped")
|
||||
}
|
||||
for _, status := range []string{models.SYBInnerCodeReady, models.SYBInnerCodeUpdated, models.SYBInnerCodeAlreadyFilled, models.SYBInnerCodeQueued, models.SYBInnerCodeApplying, models.SYBInnerCodeNeedsCheck} {
|
||||
db.Model(&r).Update("status", status)
|
||||
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: []uint64{r.ID}}); err == nil {
|
||||
t.Fatalf("protected status accepted: %s", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,8 +200,8 @@ func (s *Service) QueueRematch(ctx context.Context, request RematchRequest) (Rem
|
||||
return conflict("部分记录不存在")
|
||||
}
|
||||
for _, record := range records {
|
||||
if record.Status != models.SYBInnerCodeFailed && record.Status != models.SYBInnerCodeSkipped {
|
||||
return conflict("只有读取失败或匹配受限记录可以重新匹配")
|
||||
if record.Status != models.SYBInnerCodePending && record.Status != models.SYBInnerCodeFailed && record.Status != models.SYBInnerCodeSkipped {
|
||||
return conflict("只有待匹配、读取失败或匹配受限记录可以重新匹配")
|
||||
}
|
||||
if date == "" {
|
||||
date = record.BusinessDate
|
||||
@@ -209,10 +209,29 @@ func (s *Service) QueueRematch(ctx context.Context, request RematchRequest) (Rem
|
||||
return conflict("重新匹配记录必须属于同一营业日期")
|
||||
}
|
||||
}
|
||||
var activeJobs []models.SYBInnerCodeMatchJob
|
||||
if err := tx.Where("business_date = ? AND status IN ?", date, []string{"pending", "running"}).Find(&activeJobs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
selected := make(map[uint64]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
selected[id] = true
|
||||
}
|
||||
for _, job := range activeJobs {
|
||||
var jobIDs []uint64
|
||||
if err := json.Unmarshal([]byte(job.RecordIDsJSON), &jobIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range jobIDs {
|
||||
if selected[id] {
|
||||
return conflict("选中记录已有匹配任务,请等待任务结束")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Where("record_id IN ?", ids).Delete(&models.SYBInnerCodePlan{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ?", ids).Updates(map[string]any{"status": models.SYBInnerCodePending, "result_message": "等待重新匹配"}).Error; err != nil {
|
||||
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ?", ids).Updates(map[string]any{"status": models.SYBInnerCodeMatching, "result_message": "等待重新匹配"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
recordIDsJSON, _ := json.Marshal(ids)
|
||||
@@ -267,13 +286,13 @@ func (s *Service) Delete(ctx context.Context, actor uint64, request DeleteReques
|
||||
return conflict("部分记录不存在,未删除任何数据")
|
||||
}
|
||||
for _, record := range records {
|
||||
if record.Status == models.SYBInnerCodeQueued || record.Status == models.SYBInnerCodeApplying || record.Status == models.SYBInnerCodeNeedsCheck {
|
||||
if record.Status == models.SYBInnerCodeMatching || record.Status == models.SYBInnerCodeQueued || record.Status == models.SYBInnerCodeApplying || record.Status == models.SYBInnerCodeNeedsCheck {
|
||||
result.Blocked = append(result.Blocked, BlockedRecord{ID: record.ID, Status: record.Status})
|
||||
}
|
||||
}
|
||||
if len(result.Blocked) > 0 {
|
||||
sort.Slice(result.Blocked, func(i, j int) bool { return result.Blocked[i].ID < result.Blocked[j].ID })
|
||||
return &ServiceError{Code: CodeConflict, Message: "选中记录包含排队中、回写中或需复核状态,未删除任何数据", Details: map[string]any{"blocked": result.Blocked}}
|
||||
return &ServiceError{Code: CodeConflict, Message: "选中记录包含匹配中、排队中、回写中或需复核状态,未删除任何数据", Details: map[string]any{"blocked": result.Blocked}}
|
||||
}
|
||||
// The state gate above is the dynamic restriction for active writeback
|
||||
// evidence. Terminal evidence belongs to imported data and is physically
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,9 @@ func run() error {
|
||||
if err := goautosybinnercode.RecoverInterrupted(db); err != nil {
|
||||
return fmt.Errorf("recover interrupted SYB inner-code writes: %w", err)
|
||||
}
|
||||
if err := goautosybinnercode.RecoverInterruptedMatches(db); err != nil {
|
||||
return fmt.Errorf("recover interrupted SYB inner-code matches: %w", err)
|
||||
}
|
||||
goautoreplacement.RecoverMatching(db)
|
||||
goautopurchase.RecoverPurchaseMatching(db)
|
||||
goautopurchase.RecoverOrderWritebacks(db)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -12,8 +12,8 @@ export function batchMatchReturns(data) {
|
||||
return request({ url: '/api/admin/v1/return-matches/batch-match', method: 'post', data })
|
||||
}
|
||||
|
||||
export function confirmReturnMatch(matchId) {
|
||||
return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post' })
|
||||
export function confirmReturnMatch(matchId, options = {}) {
|
||||
return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post', ...options })
|
||||
}
|
||||
|
||||
export function cancelReturnMatch(matchId, data, options = {}) {
|
||||
|
||||
@@ -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 } }) }
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
<div class="page-heading"><div><h1>档口入库码</h1><p>导入 Excel 后自动匹配 SYB 商品;确认后逐件回写,结果不明确时只读复核。</p></div></div>
|
||||
<div class="toolbar" role="search" aria-label="档口入库码筛选与批量操作">
|
||||
<el-upload ref="upload" :auto-upload="false" :limit="1" accept=".xlsx" :show-file-list="false" :on-change="selectFile" :on-exceed="replaceFile"><el-button :icon="FolderOpened">选择 Excel</el-button></el-upload>
|
||||
<el-button type="primary" :icon="Upload" :loading="importing" :disabled="!selectedFile || importing" @click="submitImport">导入</el-button>
|
||||
<el-button type="primary" :icon="Upload" :loading="importing" :disabled="!selectedFile || importing || matchingBusy" @click="submitImport">导入</el-button>
|
||||
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" class="date-range" />
|
||||
<el-input v-model="query.keyword" clearable placeholder="订单号或入库码" class="keyword" @keyup.enter="search" />
|
||||
<el-button :icon="RefreshLeft" @click="clearFilters">清除</el-button>
|
||||
<el-button type="primary" :icon="Search" @click="search">搜索</el-button>
|
||||
<span class="toolbar-spacer" />
|
||||
<el-button type="primary" :disabled="selected.length === 0 || busy" @click="openApply">回写({{ applyEligibleCount }})</el-button>
|
||||
<el-button type="danger" plain :disabled="selected.length === 0 || busy" @click="openDelete">删除({{ deleteEligibleCount }})</el-button>
|
||||
<el-button :loading="matchSubmitting" :disabled="!matchEligibleCount || loading || !!loadError || importing || busy || matchingBusy || !!rowActionId" @click="matchSelected">匹配 {{ matchEligibleCount }}</el-button>
|
||||
<el-button type="primary" :disabled="!applyEligibleCount || busy || importing || matchingBusy || loading || !!loadError || applyDialog.open" @click="openApply">回写({{ applyEligibleCount }})</el-button>
|
||||
<el-button type="danger" plain :disabled="selected.length === 0 || busy || matchingBusy" @click="openDelete">删除({{ deleteEligibleCount }})</el-button>
|
||||
</div>
|
||||
<div class="file-state" aria-live="polite"><template v-if="selectedFile">已选择:<strong>{{ selectedFile.name }}</strong>({{ fileSize(selectedFile.size) }})</template><template v-else>请选择“标签入库码映射”Excel 文件,最大 10MB。</template></div>
|
||||
|
||||
@@ -29,18 +30,19 @@
|
||||
<el-table-column label="本地规格" prop="specRaw" min-width="170" />
|
||||
<el-table-column label="SYB 规格" min-width="170"><template #default="{ row }"><span v-if="row.plan">{{ row.plan.sybSpec || '已读取,规格为空' }}</span><span v-else class="muted">{{ sybSpecPlaceholder(row.status) }}</span></template></el-table-column>
|
||||
<el-table-column label="状态" min-width="180"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag><div v-if="row.resultMessage" class="status-note">{{ row.resultMessage }}</div></template></el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="['failed', 'skipped'].includes(row.status)" type="primary" link :loading="rowActionId === row.id" @click="rematch(row)">重新匹配</el-button><el-button v-if="row.status === 'needs_check'" type="warning" link :loading="rowActionId === row.id" @click="recheck(row)">只读复核</el-button></template></el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="['pending', 'failed', 'skipped'].includes(row.status)" type="primary" link :loading="rowActionId === row.id" :disabled="matchingBusy || busy || importing || loading || !!rowActionId" @click="rematch(row)">重新匹配</el-button><el-button v-if="row.status === 'needs_check'" type="warning" link :loading="rowActionId === row.id" @click="recheck(row)">只读复核</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :page-sizes="[20, 50, 100, 200]" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="applyDialog.open" :title="`确认回写已选择的 ${selected.length} 条业务记录吗?`" width="640px" :close-on-click-modal="false">
|
||||
<el-dialog v-model="applyDialog.open" :title="`确认回写 ${applyDialog.ids.length} 条业务记录吗?`" width="640px" :close-on-click-modal="false" :close-on-press-escape="!applyDialog.saving" :show-close="!applyDialog.saving">
|
||||
<div v-loading="applyDialog.loading">
|
||||
<el-alert title="远端写入将全局串行执行;超时或结果不明确时不会自动重试,只能只读复核。" type="warning" show-icon :closable="false" class="notice" />
|
||||
<p aria-live="polite">选中 {{ applyDialog.selectedCount }} 条,可回写 {{ applyDialog.ids.length }} 条,跳过 {{ applyDialog.selectedCount - applyDialog.ids.length }} 条;只提交本次确认的可回写记录。</p>
|
||||
<div class="metric-grid" aria-live="polite"><div><span>业务记录数</span><strong>{{ applyDialog.preview.records || 0 }}</strong></div><div><span>入库码总数</span><strong>{{ applyDialog.preview.inboundCodes || 0 }}</strong></div><div><span>预计占位明细数</span><strong>{{ applyDialog.preview.placeholderDetails || 0 }}</strong></div><div><span>替换旧码数</span><strong>{{ applyDialog.preview.replaceOldCodes || 0 }}</strong></div></div>
|
||||
<el-alert v-if="applyDialog.preview.blocked?.length" :title="`${applyDialog.preview.blocked.length} 条当前不可回写,请返回列表重新选择。`" type="error" show-icon :closable="false" />
|
||||
<el-alert v-if="applyDialog.error" :title="applyDialog.error" type="error" show-icon :closable="false" />
|
||||
</div>
|
||||
<template #footer><el-button :disabled="applyDialog.saving" @click="applyDialog.open = false">取消</el-button><el-button type="primary" :loading="applyDialog.saving" :disabled="applyDialog.loading || !!applyDialog.preview.blocked?.length" @click="confirmApply">回写 {{ applyDialog.preview.records || 0 }} 条</el-button></template>
|
||||
<template #footer><el-button :disabled="applyDialog.saving" @click="applyDialog.open = false">取消</el-button><el-button type="primary" :loading="applyDialog.saving" :disabled="applyDialog.loading || applyDialog.saving || !applyDialog.ready || !applyDialog.ids.length" @click="confirmApply">回写 {{ applyDialog.ids.length }} 条</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="deleteDialog.open" :title="`确定删除已选择的 ${selected.length} 条数据吗?`" width="600px" :close-on-click-modal="false">
|
||||
@@ -66,10 +68,15 @@ import { createRequestId } from '@/utils/request-id'
|
||||
export default {
|
||||
name: 'GoAutoSybInnerCodes',
|
||||
setup() { return { FolderOpened, RefreshLeft, Search, Upload } },
|
||||
data() { return { loading: false, importing: false, busy: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: null, rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: '', dateTo: '', keyword: '' }, applyDialog: { open: false, loading: false, saving: false, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
|
||||
data() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return { loading: false, importing: false, busy: false, matchSubmitting: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: [today, today], rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: today, dateTo: today, keyword: '' }, applyDialog: { open: false, loading: false, saving: false, ready: false, error: '', ids: [], selectedCount: 0, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
|
||||
computed: {
|
||||
applyEligibleCount() { return this.selected.filter(item => item.status === 'ready').length },
|
||||
deleteBlocked() { return this.selected.filter(item => ['queued', 'applying', 'needs_check'].includes(item.status)) },
|
||||
matchingBusy() { return this.matchSubmitting || ['pending', 'running'].includes(this.activeJob?.status) },
|
||||
matchEligibleRows() { const ids = new Set(this.selected.map(item => item.id)); return this.items.filter(item => ids.has(item.id) && ['pending', 'failed', 'skipped'].includes(item.status)) },
|
||||
matchEligibleCount() { return this.matchEligibleRows.length },
|
||||
applySelectionRows() { const ids = new Set(this.selected.map(item => item.id)); return this.items.filter(item => ids.has(item.id)) },
|
||||
applyEligibleRows() { return this.applySelectionRows.filter(item => item.status === 'ready') },
|
||||
applyEligibleCount() { return this.applyEligibleRows.length },
|
||||
deleteBlocked() { return this.selected.filter(item => ['matching', 'queued', 'applying', 'needs_check'].includes(item.status)) },
|
||||
deleteEligibleCount() { return this.deleteBlocked.length ? 0 : this.selected.length },
|
||||
jobPercent() { return this.activeJob?.total ? Math.round(this.activeJob.processed * 100 / this.activeJob.total) : 0 },
|
||||
jobTitle() { const job = this.activeJob; return job?.status === 'failed' ? '自动匹配失败' : `自动匹配:${job?.processed || 0}/${job?.total || 0},可回写 ${job?.ready || 0},异常 ${job?.failed || 0}` },
|
||||
@@ -85,11 +92,56 @@ export default {
|
||||
async load() { this.loading = true; this.loadError = ''; try { const r = await listSybInnerCodes(this.query); this.items = r.data.items; this.total = r.data.total } catch (error) { this.loadError = error?.response?.data?.message || error?.message || '列表加载失败' } finally { this.loading = false } },
|
||||
search() { [this.query.dateFrom, this.query.dateTo] = this.dateRange || ['', '']; this.query.page = 1; this.load() }, clearFilters() { this.dateRange = null; Object.assign(this.query, { page: 1, dateFrom: '', dateTo: '', keyword: '' }); this.load() },
|
||||
async submitImport() { if (!this.selectedFile) return; this.importing = true; try { const r = await importSybInnerCodes(this.selectedFile, createRequestId()); ElMessage.success(`导入 ${r.data.recordCount} 条业务记录,已自动开始匹配`); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: r.data.recordCount, processed: 0, ready: 0, failed: 0 }; this.startPolling(); await this.load() } finally { this.importing = false } },
|
||||
async openApply() { this.applyDialog = { open: true, loading: true, saving: false, preview: {}}; try { const r = await previewSybInnerCodeApply(this.selected.map(item => item.id)); this.applyDialog.preview = r.data } finally { this.applyDialog.loading = false } },
|
||||
async confirmApply() { this.applyDialog.saving = true; this.busy = true; try { const r = await applySybInnerCodes({ requestId: createRequestId(), ids: this.selected.map(item => item.id) }); this.activeBatch = { id: r.data.batchId, status: 'queued', requested: r.data.queued, processed: 0 }; this.applyDialog.open = false; ElMessage.success('回写已提交,页面可以继续使用'); this.startPolling(); await this.load() } finally { this.applyDialog.saving = false; this.busy = false } },
|
||||
async openApply() {
|
||||
if (!this.applyEligibleCount || this.busy || this.importing || this.matchingBusy || this.loading || this.loadError || this.applyDialog.open) return
|
||||
this.applyDialog = { open: true, loading: true, saving: false, ready: false, error: '', ids: [...new Set(this.applyEligibleRows.map(item => item.id))], selectedCount: new Set(this.applySelectionRows.map(item => item.id)).size, preview: {}}
|
||||
const dialog = this.applyDialog
|
||||
try {
|
||||
// One read-only refresh handles candidates whose status changed since listing.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const r = await previewSybInnerCodeApply([...dialog.ids])
|
||||
if (this.applyDialog !== dialog || !dialog.open) return
|
||||
const preview = r.data
|
||||
const blocked = preview?.blocked || []
|
||||
if (preview?.records !== dialog.ids.length || !Array.isArray(blocked) || blocked.some(item => !dialog.ids.includes(item.id))) throw new Error('回写预览范围不一致,请关闭后重新选择')
|
||||
if (!blocked.length) { dialog.preview = preview; dialog.ready = true; return }
|
||||
const blockedIds = new Set(blocked.map(item => item.id))
|
||||
dialog.ids = dialog.ids.filter(id => !blockedIds.has(id))
|
||||
if (!dialog.ids.length) { dialog.error = '当前没有可回写记录,请刷新后重新选择'; return }
|
||||
}
|
||||
dialog.error = '记录状态再次变化,请关闭后重新预览'
|
||||
} catch (error) {
|
||||
if (this.applyDialog === dialog && dialog.open) dialog.error = error?.response?.data?.message || error?.message || '回写预览失败,请关闭后重试'
|
||||
} finally { dialog.loading = false }
|
||||
},
|
||||
async confirmApply() {
|
||||
const dialog = this.applyDialog
|
||||
if (!dialog.open || dialog.loading || dialog.saving || !dialog.ready || !dialog.ids.length || this.busy) return
|
||||
dialog.saving = true; this.busy = true
|
||||
try {
|
||||
const r = await applySybInnerCodes({ requestId: createRequestId(), ids: [...dialog.ids] })
|
||||
this.activeBatch = { id: r.data.batchId, status: 'queued', requested: r.data.queued, processed: 0 }
|
||||
dialog.open = false; ElMessage.success('回写已提交,页面可以继续使用'); this.startPolling(); await this.load()
|
||||
} catch (error) {
|
||||
dialog.ready = false
|
||||
dialog.error = error?.response?.data?.message || '回写提交未确认,请刷新列表或只读复核;不会自动重试'
|
||||
} finally { dialog.saving = false; this.busy = false }
|
||||
},
|
||||
openDelete() { this.deleteDialog.open = true }, async confirmDelete() { this.deleteDialog.saving = true; this.busy = true; try { const r = await deleteSybInnerCodes({ requestId: createRequestId(), ids: this.selected.map(item => item.id) }); ElMessage.success(`已物理删除 ${r.data.deleted} 条数据`); this.deleteDialog.open = false; await this.load() } finally { this.deleteDialog.saving = false; this.busy = false } },
|
||||
async recheck(row) { this.rowActionId = row.id; try { await recheckSybInnerCode(row.id); ElMessage.success('只读复核完成'); await this.load() } finally { this.rowActionId = 0 } },
|
||||
async rematch(row) { this.rowActionId = row.id; try { const r = await rematchSybInnerCodes({ requestId: createRequestId(), ids: [row.id] }); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: 1, processed: 0, ready: 0, failed: 0 }; ElMessage.success('已开始重新匹配'); this.startPolling(); await this.load() } finally { this.rowActionId = 0 } },
|
||||
async matchSelected() { await this.startMatch(this.matchEligibleRows) },
|
||||
async rematch(row) { if (this.matchingBusy || this.rowActionId) return; this.rowActionId = row.id; try { await this.startMatch([row]) } finally { this.rowActionId = 0 } },
|
||||
async startMatch(rows) {
|
||||
if (!rows.length || this.matchingBusy || this.busy || this.importing || this.loading || this.loadError) return
|
||||
if (new Set(rows.map(row => row.businessDate)).size !== 1) { ElMessage.warning('请按同一营业日期分别匹配'); return }
|
||||
this.matchSubmitting = true
|
||||
try {
|
||||
const r = await rematchSybInnerCodes({ requestId: createRequestId(), ids: [...new Set(rows.map(row => row.id))] })
|
||||
this.activeJob = { id: r.data.matchJobId, status: 'pending', total: r.data.queued, processed: 0, ready: 0, failed: 0 }
|
||||
ElMessage.success(`已开始匹配 ${r.data.queued} 条记录;成功后请另行确认回写`)
|
||||
this.startPolling(); await this.load()
|
||||
} finally { this.matchSubmitting = false }
|
||||
},
|
||||
async openDetail(id) { this.detail = { open: true, loading: true, item: null }; try { const r = await getSybInnerCode(id); this.detail.item = r.data.item } finally { this.detail.loading = false } },
|
||||
startPolling() { if (!this.pollTimer) this.pollTimer = window.setInterval(this.poll, 2000); this.poll() }, stopPolling() { if (this.pollTimer) window.clearInterval(this.pollTimer); this.pollTimer = null },
|
||||
async poll() { let active = false; if (this.activeJob && ['pending', 'running'].includes(this.activeJob.status)) { const r = await getSybInnerCodeMatchJob(this.activeJob.id); this.activeJob = r.data.item; active = ['pending', 'running'].includes(this.activeJob.status) || active } if (this.activeBatch && ['queued', 'running'].includes(this.activeBatch.status)) { const r = await getSybInnerCodeApplyBatch(this.activeBatch.id); this.activeBatch = r.data.batch; active = ['queued', 'running'].includes(this.activeBatch.status) || active } await this.load(); if (!active) this.stopPolling() }
|
||||
|
||||
@@ -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,8 +12,9 @@
|
||||
</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 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 || 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-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">
|
||||
<el-form-item label="创建时间"><el-date-picker v-model="query.createdAtRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable /></el-form-item>
|
||||
@@ -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)">
|
||||
@@ -72,7 +74,7 @@
|
||||
<div class="quick-link-actions">
|
||||
<el-button type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id)">查看对比</el-button>
|
||||
<el-button v-if="canPurchase" type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id, true)">备注</el-button>
|
||||
<el-button v-if="canPurchase" type="danger" link :disabled="returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="quickCancelMatch(returnMatchByProductId[row.id].id)">取消匹配</el-button>
|
||||
<el-button v-if="canPurchase" type="danger" link :disabled="returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="quickCancelMatch(returnMatchByProductId[row.id].id)">取消匹配</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="muted">未匹配</span>
|
||||
@@ -209,6 +211,25 @@
|
||||
<template #footer><el-button type="primary" :disabled="returnCancelBatchLoading" @click="returnCancelResult.open = false">返回列表</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="returnConfirmResult.open" :title="returnConfirmBatchLoading ? '正在使用退货' : '批量使用退货结果'" width="820px" :close-on-click-modal="false" :close-on-press-escape="!returnConfirmBatchLoading" :show-close="!returnConfirmBatchLoading">
|
||||
<div aria-live="polite" class="result-summary">
|
||||
<span>已处理 {{ returnConfirmResult.processed }} / {{ returnConfirmResult.total }} 条</span>
|
||||
<span class="success-text">成功 {{ returnConfirmResult.successCount }} 条</span>
|
||||
<span>跳过 {{ returnConfirmResult.skippedCount }} 条</span>
|
||||
<span class="danger-text">失败 {{ returnConfirmResult.failedCount }} 条</span>
|
||||
</div>
|
||||
<el-progress v-if="returnConfirmBatchLoading" :percentage="returnConfirmResult.total ? Math.round(returnConfirmResult.processed / returnConfirmResult.total * 100) : 0" />
|
||||
<el-alert v-if="returnConfirmResult.refreshError" :title="returnConfirmResult.refreshError" type="warning" :closable="false" show-icon />
|
||||
<p class="muted">仅标记已用退货,不修改已有采购任务,也不会自动让 Yeeke 发货。失败项仅在当前页仍为原待确认匹配时保留勾选;离页失败项仍保留在下表。</p>
|
||||
<el-table :data="returnConfirmResult.items" border size="small" max-height="420" empty-text="正在处理,请稍候">
|
||||
<el-table-column label="SYB 订单" prop="orderCode" min-width="180" />
|
||||
<el-table-column label="原匹配编号" prop="matchId" width="100" />
|
||||
<el-table-column label="结果" width="90"><template #default="{ row }">{{ { success: '成功', skipped: '跳过', failed: '失败' }[row.status] }}</template></el-table-column>
|
||||
<el-table-column label="说明" prop="reason" min-width="280" />
|
||||
</el-table>
|
||||
<template #footer><el-button type="primary" :disabled="returnConfirmBatchLoading" @click="returnConfirmResult.open = false">返回列表</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="returnMatchBatchResult.open" title="批量匹配退货结果" width="820px">
|
||||
<div class="purchase-summary" aria-live="polite">
|
||||
<span class="success-text">成功匹配 <strong>{{ returnMatchBatchResult.matchedCount }}</strong> 条</span>
|
||||
@@ -266,7 +287,7 @@
|
||||
<h3 class="section-title">备注</h3>
|
||||
<el-input ref="matchRemarkInput" v-model="matchDetail.remarkText" type="textarea" :rows="2" maxlength="500" show-word-limit :disabled="!canPurchase" />
|
||||
<div class="drawer-actions">
|
||||
<el-button v-if="canPurchase" :loading="matchDetail.saving" @click="saveMatchRemark">保存备注</el-button>
|
||||
<el-button v-if="canPurchase" :loading="matchDetail.saving" :disabled="returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading" @click="saveMatchRemark">保存备注</el-button>
|
||||
</div>
|
||||
<h3 class="section-title">操作日志</h3>
|
||||
<el-table :data="matchDetail.data.logs" border size="small" max-height="220" empty-text="暂无日志">
|
||||
@@ -279,8 +300,8 @@
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="matchDetail.open = false">关闭</el-button>
|
||||
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status === 'matched'" type="success" :disabled="returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="confirmMatchDetail">确认</el-button>
|
||||
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status !== 'cancelled'" type="danger" :disabled="returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="cancelMatchDetail">取消匹配</el-button>
|
||||
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status === 'matched'" type="success" :disabled="returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="confirmMatchDetail">确认</el-button>
|
||||
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status !== 'cancelled'" type="danger" :disabled="returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="cancelMatchDetail">取消匹配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -307,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>
|
||||
@@ -344,6 +369,7 @@ import { ElMessage } from 'element-plus'
|
||||
import { Search, RefreshLeft, ArrowDown } from '@element-plus/icons-vue'
|
||||
import quickLink from './quick-link'
|
||||
import returnCancel from './return-cancel'
|
||||
import returnConfirm from './return-confirm'
|
||||
import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products'
|
||||
import { listSybShops } from '@/api/goauto/syb-shops'
|
||||
import { listDevices } from '@/api/goauto/devices'
|
||||
@@ -368,7 +394,7 @@ const RETURN_MATCH_BATCH_MAX = 500
|
||||
export default {
|
||||
name: 'GoAutoSybProducts',
|
||||
components: { ShopeeProductDetailDrawer, PddProductDetailDrawer },
|
||||
mixins: [quickLink, returnCancel],
|
||||
mixins: [quickLink, returnCancel, returnConfirm],
|
||||
setup() { return { Search, RefreshLeft, ArrowDown } },
|
||||
data() {
|
||||
return {
|
||||
@@ -384,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,
|
||||
@@ -400,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 },
|
||||
@@ -458,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
|
||||
@@ -481,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
|
||||
@@ -511,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' },
|
||||
@@ -523,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
|
||||
@@ -638,7 +680,7 @@ export default {
|
||||
},
|
||||
async runBatchMatchReturns() {
|
||||
const ids = this.returnMatchCandidateIds
|
||||
if (!ids.length || this.returnMatchBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return
|
||||
if (!ids.length || this.returnMatchBatchLoading || this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return
|
||||
this.returnMatchBatchLoading = true
|
||||
try {
|
||||
const r = await batchMatchReturns({ sybProductIds: ids })
|
||||
@@ -673,7 +715,7 @@ export default {
|
||||
await this.load()
|
||||
},
|
||||
async saveMatchRemark() {
|
||||
if (this.returnCancelBatchLoading || this.returnTransitionLoading || this.returnMatchBatchLoading || this.matchDetail.saving) return
|
||||
if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.returnMatchBatchLoading || this.matchDetail.saving) return
|
||||
this.matchDetail.saving = true
|
||||
try {
|
||||
await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText })
|
||||
|
||||
@@ -18,7 +18,7 @@ export default {
|
||||
returnCancelButtonReason() {
|
||||
if (this.loading || this.purchaseReadinessLoading || this.returnMatchLoading) return '正在读取处理阶段与退货匹配'
|
||||
if (this.returnMatchLoadError) return '退货匹配读取失败,请刷新重试'
|
||||
if (this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return '正在处理退货匹配,请稍候'
|
||||
if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return '正在处理退货匹配,请稍候'
|
||||
if (!this.returnCancelCandidates.length) return '请勾选退货待确认的已匹配商品;已用退货不参与批量取消'
|
||||
return ''
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export default {
|
||||
return this.purchaseReady(row).processStage === 'return_pending' && match?.status === 'matched' && Number(match.id) > 0
|
||||
},
|
||||
async runReturnTransition(action) {
|
||||
if (this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return
|
||||
if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return
|
||||
this.returnTransitionLoading = true
|
||||
try { await action() } finally { this.returnTransitionLoading = false }
|
||||
},
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { confirmReturnMatch, getReturnMatch } from '@/api/goauto/return-matches'
|
||||
|
||||
const requestOptions = { suppressErrorMessage: true, suppressNetworkError: true }
|
||||
function isUnauthorized(error) {
|
||||
return error?.response?.status === 401 || error?.response?.data?.code === 401 || error?.message === 'Unauthorized'
|
||||
}
|
||||
const emptyResult = () => ({ open: false, items: [], total: 0, processed: 0, successCount: 0, skippedCount: 0, failedCount: 0, refreshError: '' })
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return { returnConfirmBatchLoading: false, returnConfirmStopped: false, returnConfirmResult: emptyResult() }
|
||||
},
|
||||
computed: {
|
||||
returnConfirmCandidates() { return this.returnConfirmSnapshot().filter(item => item.eligible) },
|
||||
returnConfirmButtonReason() {
|
||||
if (this.loading || this.purchaseReadinessLoading || this.returnMatchLoading) return '正在读取处理阶段与退货匹配'
|
||||
if (this.returnMatchLoadError) return '退货匹配读取失败,请刷新重试'
|
||||
if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return '正在处理退货匹配,请稍候'
|
||||
if (!this.returnConfirmCandidates.length) return '请勾选当前页退货待确认的已匹配商品;已用退货不参与批量使用'
|
||||
return ''
|
||||
}
|
||||
},
|
||||
beforeUnmount() { this.returnConfirmStopped = true },
|
||||
methods: {
|
||||
returnConfirmSnapshot() {
|
||||
const current = new Set(this.products.map(row => row.id))
|
||||
const seen = new Set()
|
||||
return this.selectedProducts.filter(row => {
|
||||
if (!current.has(row.id) || seen.has(row.id)) return false
|
||||
seen.add(row.id); return true
|
||||
}).map(row => ({ sybProductId: row.id, orderCode: row.orderCode, matchId: this.returnMatchByProductId[row.id]?.id, eligible: this.isReturnCancelCandidate(row) }))
|
||||
},
|
||||
async runBatchConfirmReturns() {
|
||||
if (!this.canPurchase || this.returnConfirmButtonReason) return
|
||||
// Freeze original identities before asking. Never follow a later replacement match.
|
||||
const snapshot = this.returnConfirmSnapshot()
|
||||
const count = snapshot.filter(item => item.eligible).length
|
||||
this.returnConfirmBatchLoading = true
|
||||
try {
|
||||
try {
|
||||
await this.$confirm(`确认使用选中的 ${count} 条退货商品?确认后将标记为已用退货,不再创建采购任务。不修改已有采购任务,也不会自动让 Yeeke 发货。`, '使用退货', { type: 'warning', confirmButtonText: '确认使用', cancelButtonText: '返回' })
|
||||
} catch { return }
|
||||
if (this.returnConfirmStopped) return
|
||||
this.returnConfirmResult = { ...emptyResult(), open: true, total: snapshot.length }
|
||||
const result = this.returnConfirmResult
|
||||
let authExpired = false
|
||||
for (const original of snapshot) {
|
||||
const item = { ...original, status: 'skipped', reason: '', retryable: false }
|
||||
if (!original.eligible) item.reason = '非退货待确认的原匹配,跳过'
|
||||
else if (authExpired || this.returnConfirmStopped) { item.status = 'failed'; item.reason = '登录失效或已离开页面,未继续执行' } else {
|
||||
try {
|
||||
const response = await confirmReturnMatch(original.matchId, requestOptions)
|
||||
if (response === false) throw new Error('Unauthorized')
|
||||
item.status = 'success'; item.reason = '已用退货;不修改已有采购任务,不自动发货'
|
||||
} catch (error) {
|
||||
if (isUnauthorized(error)) {
|
||||
authExpired = true; item.status = 'failed'; item.reason = '登录失效,请重新登录后检查原匹配'
|
||||
} else if (error?.response?.status === 409 && error?.response?.data?.code === 'STATE_CONFLICT') {
|
||||
item.reason = '原匹配已确认或已取消,未重复使用'
|
||||
} else {
|
||||
item.status = 'failed'
|
||||
item.reason = error?.response ? '使用失败,请刷新检查原匹配' : '网络异常,使用结果未确认'
|
||||
// Unknown write outcomes are recovered by reading only, not by repeating POST.
|
||||
try {
|
||||
const response = await getReturnMatch(original.matchId, requestOptions)
|
||||
if (response === false) throw new Error('Unauthorized')
|
||||
const match = response.data?.match
|
||||
const sameOriginal = Number(match?.id) === Number(original.matchId) && Number(match?.sybProductId) === Number(original.sybProductId)
|
||||
if (sameOriginal && ['confirmed', 'cancelled'].includes(match.status)) {
|
||||
item.status = 'skipped'; item.reason = `回读原匹配已${match.status === 'confirmed' ? '确认使用' : '取消'},未重复操作`
|
||||
} else {
|
||||
item.retryable = sameOriginal && match.status === 'matched'
|
||||
item.reason += item.retryable ? ';原匹配仍待确认,可手动重试' : ';原匹配无法确认,请刷新检查'
|
||||
}
|
||||
} catch (readError) {
|
||||
if (isUnauthorized(readError)) authExpired = true
|
||||
item.reason += ';回读失败,请刷新检查,不自动重试'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.items.push(item); result.processed++
|
||||
result[`${item.status}Count`]++
|
||||
}
|
||||
if (this.returnConfirmStopped || authExpired) return
|
||||
const generation = this.loadGeneration + 1
|
||||
try {
|
||||
await this.load(false, true)
|
||||
if (generation !== this.loadGeneration || this.returnConfirmStopped) return
|
||||
if (this.returnMatchLoadError || Object.values(this.purchaseReadiness).some(ready => ready.processNextAction === 'refresh')) {
|
||||
result.refreshError = '刷新处理阶段或退货匹配失败,请刷新列表后重试'; return
|
||||
}
|
||||
const failed = new Map(result.items.filter(item => item.status === 'failed' && item.retryable).map(item => [item.sybProductId, item.matchId]))
|
||||
await this.$nextTick()
|
||||
if (generation !== this.loadGeneration || this.returnConfirmStopped) return
|
||||
this.$refs.productTable?.clearSelection()
|
||||
this.products.filter(row => failed.get(row.id) === this.returnMatchByProductId[row.id]?.id && this.isReturnCancelCandidate(row)).forEach(row => this.$refs.productTable?.toggleRowSelection(row, true))
|
||||
} catch { result.refreshError = '列表刷新失败;执行结果已保留,请手动刷新,勿盲目重复使用' }
|
||||
} finally { this.returnConfirmBatchLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect, test, type Page, type BrowserContext } from '@playwright/test'
|
||||
|
||||
async function setup(page: Page, context: BrowserContext, mode = 'success') {
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
|
||||
const previews: number[][] = []; const writes: number[][] = []
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url()); const pathname = url.pathname
|
||||
if (pathname.startsWith('/src/api/')) return route.continue()
|
||||
const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
|
||||
if (pathname.endsWith('/api/v1/menurole')) return reply([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-inner-codes', component: '/goauto/syb-inner-codes/index', menuName: 'GoAutoSybInnerCodes', title: '档口入库码', visible: '0' }] }])
|
||||
if (pathname.endsWith('/api/v1/getinfo')) return reply({ roles: ['purchaser'], name: '模拟采购员', avatar: '', permissions: [] })
|
||||
if (pathname.endsWith('/syb-inner-codes') && route.request().method() === 'GET') return reply({ items: ['updated', 'ready', 'failed', 'ready'].map((status, i) => ({ id: i + 1, businessDate: '2026-09-29', orderNumber: `MOCK-${i + 1}`, status, items: [{ code: `MOCK-CODE-${i}`, ordinal: 1 }], specRaw: '模拟规格' })), total: 4 })
|
||||
if (pathname.endsWith('/apply-preview')) {
|
||||
const ids = route.request().postDataJSON().ids; previews.push(ids)
|
||||
if (mode === 'preview-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟预览失败' } })
|
||||
return reply({ records: ids.length, inboundCodes: ids.length === 1 ? 3 : 2, blocked: mode === 'changed' && ids.length === 2 ? [{ id: 4, status: 'updated' }] : [] })
|
||||
}
|
||||
if (pathname.endsWith('/apply')) {
|
||||
writes.push(route.request().postDataJSON().ids)
|
||||
return reply({ batchId: 'mock-batch', queued: writes.at(-1)!.length })
|
||||
}
|
||||
if (pathname.includes('/apply-batches/')) return reply({ batch: { id: 'mock-batch', status: 'running', requested: writes.at(-1)!.length, processed: 0 } })
|
||||
return reply({ items: [], total: 0 })
|
||||
})
|
||||
await page.goto('/#/syb-inner-codes')
|
||||
await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible()
|
||||
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: '回写(2)', exact: true }).click()
|
||||
return { previews, writes, dialog: page.getByRole('dialog') }
|
||||
}
|
||||
|
||||
test('mixed selection reports skipped items and submits only ready records', async ({ page, context }) => {
|
||||
const { previews, writes, dialog } = await setup(page, context)
|
||||
await expect(dialog.getByText('选中 4 条,可回写 2 条,跳过 2 条;只提交本次确认的可回写记录。')).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: '回写 2 条', exact: true })).toBeEnabled()
|
||||
expect(previews).toEqual([[2, 4]]); expect(writes).toEqual([])
|
||||
await dialog.getByRole('button', { name: '回写 2 条', exact: true }).click()
|
||||
await expect.poll(() => writes.length).toBe(1); expect(writes[0]).toEqual([2, 4])
|
||||
await expect(dialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('preview status changes update count and only submit remaining candidate', async ({ page, context }) => {
|
||||
const { previews, writes, dialog } = await setup(page, context, 'changed')
|
||||
await expect(dialog.getByText('选中 4 条,可回写 1 条,跳过 3 条;只提交本次确认的可回写记录。')).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: '回写 1 条', exact: true })).toBeEnabled()
|
||||
expect(previews).toEqual([[2, 4], [2]])
|
||||
await dialog.getByRole('button', { name: '回写 1 条', exact: true }).click()
|
||||
await expect.poll(() => writes.length).toBe(1); expect(writes[0]).toEqual([2])
|
||||
})
|
||||
|
||||
test('preview failure is visible and cannot submit', async ({ page, context }) => {
|
||||
const { writes, dialog } = await setup(page, context, 'preview-error')
|
||||
await expect(dialog.locator('.el-alert--error')).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: '回写 2 条', exact: true })).toBeDisabled()
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test, type Page, type BrowserContext } from '@playwright/test'
|
||||
|
||||
async function setup(page: Page, context: BrowserContext, mode = 'success') {
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
|
||||
const lists: URL[] = []; const writes: number[][] = []; const otherWrites: string[] = []
|
||||
let submitted = false
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url()); const pathname = url.pathname
|
||||
if (pathname.startsWith('/src/api/')) return route.continue()
|
||||
const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
|
||||
if (pathname.endsWith('/api/v1/menurole')) return reply([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-inner-codes', component: '/goauto/syb-inner-codes/index', menuName: 'GoAutoSybInnerCodes', title: '档口入库码', visible: '0' }] }])
|
||||
if (pathname.endsWith('/api/v1/getinfo')) return reply({ roles: ['purchaser'], name: '模拟采购员', avatar: '', permissions: [] })
|
||||
if (pathname.endsWith('/syb-inner-codes') && route.request().method() === 'GET') {
|
||||
lists.push(url)
|
||||
if (mode === 'list-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟加载失败' } })
|
||||
const statuses = ['pending', 'failed', 'skipped', 'updated']
|
||||
return reply({ items: statuses.map((status, i) => ({ id: i + 1, businessDate: mode === 'mixed-dates' && i === 1 ? '2026-09-28' : '2026-09-29', orderNumber: `MOCK-${i + 1}`, status: submitted && i < 3 ? 'matching' : status, items: [{ code: `MOCK-CODE-${i}`, ordinal: 1 }], specRaw: '模拟规格' })), total: 4 })
|
||||
}
|
||||
if (pathname.endsWith('/rematch')) {
|
||||
writes.push(route.request().postDataJSON().ids)
|
||||
if (mode === 'submit-error') return route.fulfill({ status: 409, json: { code: 'CONFLICT', message: '模拟状态已变化' } })
|
||||
submitted = true
|
||||
return reply({ matchJobId: 'isolated-job', queued: writes.at(-1)!.length })
|
||||
}
|
||||
if (pathname.includes('/match-jobs/')) return reply({ item: { id: 'isolated-job', status: 'running', total: 3, processed: 1, ready: 1, failed: 0 } })
|
||||
if (route.request().method() !== 'GET') otherWrites.push(pathname)
|
||||
return reply({ items: [], total: 0 })
|
||||
})
|
||||
await page.goto('/#/syb-inner-codes')
|
||||
if (mode !== 'list-error') await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible()
|
||||
return { lists, writes, otherWrites }
|
||||
}
|
||||
|
||||
test('today default, mixed selection only matches eligible rows and never writes SYB', async ({ page, context }) => {
|
||||
const { lists, writes, otherWrites } = await setup(page, context)
|
||||
const today = await page.evaluate(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` })
|
||||
expect(lists[0].searchParams.get('dateFrom')).toBe(today); expect(lists[0].searchParams.get('dateTo')).toBe(today)
|
||||
await expect(page.getByRole('button', { name: '匹配 0', exact: true })).toBeDisabled()
|
||||
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: '匹配 3', exact: true }).click()
|
||||
await expect.poll(() => writes.length).toBe(1); expect(writes[0]).toEqual([1, 2, 3])
|
||||
await expect(page.getByText('自动匹配:1/3,可回写 1,异常 0')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /^匹配 \d+$/ })).toBeDisabled()
|
||||
expect(otherWrites).toEqual([])
|
||||
})
|
||||
|
||||
test('explicitly clear filters removes date range', async ({ page, context }) => {
|
||||
const { lists } = await setup(page, context)
|
||||
await page.getByRole('button', { name: '清除', exact: true }).click()
|
||||
await expect.poll(() => lists.length).toBeGreaterThan(1)
|
||||
expect(lists.at(-1)!.searchParams.get('dateFrom')).toBe('')
|
||||
})
|
||||
|
||||
test('mixed dates warn without submitting', async ({ page, context }) => {
|
||||
const { writes } = await setup(page, context, 'mixed-dates')
|
||||
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: '匹配 3', exact: true }).click()
|
||||
await expect(page.getByText('请按同一营业日期分别匹配')).toBeVisible(); expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
test('submission failure keeps retry available without automatic retries', async ({ page, context }) => {
|
||||
const { writes } = await setup(page, context, 'submit-error')
|
||||
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: '匹配 3', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '匹配 3', exact: true })).toBeEnabled()
|
||||
expect(writes.length).toBe(1)
|
||||
})
|
||||
|
||||
test('list failure disables matching', async ({ page, context }) => {
|
||||
await setup(page, context, 'list-error')
|
||||
await expect(page.getByRole('button', { name: '匹配 0', exact: true })).toBeDisabled()
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { expect, test, type Page, type BrowserContext } from '@playwright/test'
|
||||
|
||||
const menus = [{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }]
|
||||
async function setup(page: Page, context: BrowserContext, mode = 'success', role = 'after_sales') {
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
|
||||
const writes: number[] = []
|
||||
const confirmed = new Set<number>()
|
||||
const lists: URL[] = []
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url()); const pathname = url.pathname
|
||||
if (pathname.startsWith('/src/api/')) return route.continue()
|
||||
const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
|
||||
if (pathname.endsWith('/api/v1/menurole')) return reply(menus)
|
||||
if (pathname.endsWith('/api/v1/getinfo')) return reply({ roles: [role], name: '模拟售后', avatar: '', permissions: [] })
|
||||
if (pathname.endsWith('/api/admin/v1/syb-products')) {
|
||||
lists.push(url)
|
||||
const items = [1, 2, 3, 4].filter(id => url.searchParams.get('processStage') !== 'return_pending' || (id <= 2 && !confirmed.has(id))).map(id => ({ id, orderCode: `MOCK-${id}`, shopeeItemId: String(100 + id), shopeeProductId: 100 + id, imageUrl: '', productTitle: '模拟商品', quantity: 1, parseStatus: 'success' }))
|
||||
return reply({ items, total: items.length })
|
||||
}
|
||||
if (pathname.endsWith('/purchase-tasks/batch-preview')) return reply({ items: [1, 2, 3, 4].map(id => ({ sybProductId: id, processStage: confirmed.has(id) ? 'return_used' : id <= 2 ? 'return_pending' : id === 3 ? 'return_used' : 'purchase_ready', processStageLabel: confirmed.has(id) ? '已用退货' : id <= 2 ? '退货待确认' : id === 3 ? '已用退货' : '可创建采购', eligible: id === 4, collectionEligible: false, aiMatchEligible: false })) })
|
||||
if (pathname.endsWith('/return-matches')) {
|
||||
if (mode === 'metadata-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL' } })
|
||||
return reply({ items: [1, 2, 3].map(id => ({ id: id + 10, sybProductId: id, status: id <= 2 && !confirmed.has(id) ? 'matched' : 'confirmed', variationName: '模拟规格' })) })
|
||||
}
|
||||
if (/\/return-matches\/\d+\/confirm$/.test(pathname)) {
|
||||
const id = Number(pathname.split('/').at(-2)); writes.push(id)
|
||||
expect(route.request().method()).toBe('POST')
|
||||
if (mode === 'readback-confirmed') return route.fulfill({ status: 504, json: { code: 'TIMEOUT' } })
|
||||
if (mode === 'partial' && id === 12) return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟使用失败' } })
|
||||
if (mode === 'auth') return route.fulfill({ status: 401, json: { code: 401, message: '模拟登录失效' } })
|
||||
if (mode === 'conflict' && id === 12) return route.fulfill({ status: 409, json: { code: 'STATE_CONFLICT' } })
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
confirmed.add(id - 10); return reply({ item: { id, status: 'confirmed' } })
|
||||
}
|
||||
if (/\/return-matches\/\d+$/.test(pathname)) {
|
||||
const id = Number(pathname.split('/').at(-1)); return reply({ match: { id, sybProductId: id - 10, status: mode === 'readback-confirmed' || confirmed.has(id - 10) ? 'confirmed' : 'matched' }, logs: [] })
|
||||
}
|
||||
return reply({ items: [], total: 0 })
|
||||
})
|
||||
await page.goto('/#/syb-products')
|
||||
await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible()
|
||||
return { writes, lists }
|
||||
}
|
||||
|
||||
test('v2 button order, one confirmation, no write on return, progress and mutual exclusion', async ({ page, context }) => {
|
||||
const { writes } = await setup(page, context)
|
||||
const use = page.getByRole('button', { name: /使用退货\s*0/ })
|
||||
await expect(use).toBeDisabled()
|
||||
const toolbar = await page.locator('.toolbar-actions button').allTextContents()
|
||||
expect(toolbar.findIndex(t => t.includes('匹配退货'))).toBeLessThan(toolbar.findIndex(t => t.includes('取消匹配')))
|
||||
expect(toolbar.findIndex(t => t.includes('取消匹配'))).toBeLessThan(toolbar.findIndex(t => t.includes('使用退货')))
|
||||
await page.locator('thead .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: /使用退货\s*2/ }).click()
|
||||
await expect(page.getByText(/不会自动让 Yeeke 发货/, { exact: false }).last()).toBeVisible()
|
||||
await page.getByRole('button', { name: '返回', exact: true }).click()
|
||||
expect(writes).toEqual([])
|
||||
await page.getByRole('button', { name: /使用退货\s*2/ }).click()
|
||||
await page.getByRole('button', { name: '确认使用', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeDisabled()
|
||||
await expect(page.getByRole('button', { name: /取消匹配\s*2/ }).first()).toBeDisabled()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
|
||||
expect(writes).toEqual([11, 12])
|
||||
await expect(page.getByText('成功 2 条', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('跳过 1 条', { exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
|
||||
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const mode of ['partial', 'conflict', 'auth', 'readback-confirmed']) {
|
||||
test(`${mode}: independent outcomes, failed selection, auth stop and readback without retry`, async ({ page, context }) => {
|
||||
const { writes } = await setup(page, context, mode)
|
||||
await page.locator('thead .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: /使用退货\s*2/ }).click()
|
||||
await page.getByRole('button', { name: '确认使用', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
|
||||
expect(writes).toEqual(mode === 'auth' ? [11] : [11, 12])
|
||||
const summary = mode === 'auth' ? '失败 2 条' : mode === 'partial' ? '失败 1 条' : mode === 'conflict' ? '跳过 2 条' : '跳过 3 条'
|
||||
await expect(page.getByText(summary, { exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
|
||||
if (mode === 'partial') {
|
||||
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /使用退货\s*1/ })).toBeEnabled()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('metadata load error disables use', async ({ page, context }) => {
|
||||
await setup(page, context, 'metadata-error')
|
||||
await page.locator('thead .el-checkbox').first().click()
|
||||
await expect(page.getByRole('button', { name: /使用退货\s*0/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('viewer cannot see use button or selection', async ({ page, context }) => {
|
||||
await setup(page, context, 'success', 'viewer')
|
||||
await expect(page.getByRole('button', { name: /使用退货/ })).toHaveCount(0)
|
||||
await expect(page.locator('thead .el-checkbox')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('pending filter and page size retained; used rows disappear', async ({ page, context }) => {
|
||||
const { lists } = await setup(page, context)
|
||||
await page.locator('.el-form-item').filter({ hasText: '处理阶段' }).locator('.el-select__wrapper').click()
|
||||
await page.getByRole('option', { name: '退货待确认', exact: true }).click()
|
||||
await page.getByRole('button', { name: '查询', exact: true }).click()
|
||||
await expect(page.getByRole('cell', { name: 'MOCK-4', exact: true })).toHaveCount(0)
|
||||
await page.locator('thead .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: /使用退货\s*2/ }).click()
|
||||
await page.getByRole('button', { name: '确认使用', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
|
||||
expect(lists.at(-1)?.searchParams.get('processStage')).toBe('return_pending')
|
||||
expect(lists.at(-1)?.searchParams.get('pageSize')).toBe('200')
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
|
||||
await expect(page.getByText('暂无 SYB 商品明细', { exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-inner-codes/index.vue'), 'utf8').split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
|
||||
|
||||
function instance(preview = async ids => ({ data: { records: ids.length, inboundCodes: ids.length, blocked: [] } }), apply = async body => ({ data: { batchId: 'mock-batch', queued: body.ids.length } })) {
|
||||
const previews = []; const writes = []
|
||||
const options = new Function('previewSybInnerCodeApply', 'applySybInnerCodes', 'createRequestId', 'ElMessage', source)(async ids => { previews.push(ids); return preview(ids) }, async body => { writes.push(body); return apply(body) }, () => 'mock-request', { success() {} })
|
||||
const vm = { ...options.data(), ...options.methods, load: async () => {}, startPolling() {} }
|
||||
for (const [key, get] of Object.entries(options.computed)) Object.defineProperty(vm, key, { get: () => get.call(vm) })
|
||||
vm.items = ['updated', 'ready', 'failed', 'ready'].map((status, i) => ({ id: i + 1, status }))
|
||||
return { vm, previews, writes }
|
||||
}
|
||||
|
||||
test('mixed selection only previews and submits frozen current-page ready IDs', async () => {
|
||||
const { vm, previews, writes } = instance()
|
||||
vm.selected = [...vm.items, vm.items[1], { id: 999, status: 'ready' }]
|
||||
assert.equal(vm.applyEligibleCount, 2)
|
||||
await vm.openApply()
|
||||
assert.deepEqual(previews, [[2, 4]])
|
||||
assert.equal(vm.applyDialog.selectedCount, 4)
|
||||
assert.equal(vm.applyDialog.ready, true)
|
||||
vm.selected = [{ id: 123, status: 'ready' }]; vm.items = vm.selected
|
||||
await vm.confirmApply()
|
||||
assert.deepEqual(writes[0].ids, [2, 4]); assert.equal(writes.length, 1)
|
||||
assert.equal(vm.applyDialog.open, false)
|
||||
})
|
||||
|
||||
test('blocked preview candidates are removed and metrics reloaded for remaining IDs', async () => {
|
||||
const { vm, previews, writes } = instance(async ids => ({ data: { records: ids.length, inboundCodes: ids.length === 2 ? 7 : 3, blocked: ids.length === 2 ? [{ id: 4, status: 'updated' }] : [] } }))
|
||||
vm.selected = vm.items
|
||||
await vm.openApply()
|
||||
assert.deepEqual(previews, [[2, 4], [2]])
|
||||
assert.deepEqual(vm.applyDialog.ids, [2]); assert.equal(vm.applyDialog.preview.inboundCodes, 3)
|
||||
assert.equal(vm.applyDialog.selectedCount - vm.applyDialog.ids.length, 3)
|
||||
await vm.confirmApply(); assert.deepEqual(writes[0].ids, [2])
|
||||
})
|
||||
|
||||
test('all blocked candidates or preview failures cannot submit', async () => {
|
||||
for (const preview of [async ids => ({ data: { records: ids.length, blocked: ids.map(id => ({ id })) } }), async () => { throw new Error('mock network error') }, async () => ({ data: { records: 999, blocked: [] } })]) {
|
||||
const { vm, writes } = instance(preview); vm.selected = vm.items
|
||||
await vm.openApply(); assert.equal(vm.applyDialog.ready, false); assert.ok(vm.applyDialog.error)
|
||||
await vm.confirmApply(); assert.equal(writes.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('zero eligible, loading, error, busy and active matching do not preview', async () => {
|
||||
const empty = instance(); await empty.vm.openApply(); assert.equal(empty.previews.length, 0)
|
||||
for (const field of ['loading', 'loadError', 'busy', 'importing', 'matchSubmitting']) {
|
||||
const { vm, previews } = instance(); vm.selected = vm.items; vm[field] = field === 'loadError' ? 'mock' : true
|
||||
await vm.openApply(); assert.equal(previews.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('canceled preview cannot authorize a later dialog and double clicks do not submit twice', async () => {
|
||||
let resolvePreview
|
||||
const { vm, writes } = instance(() => new Promise(resolve => { resolvePreview = resolve }))
|
||||
vm.selected = vm.items; const pending = vm.openApply(); const original = vm.applyDialog
|
||||
original.open = false; vm.applyDialog = { ...original, ready: false }
|
||||
resolvePreview({ data: { records: 2, blocked: [] } }); await pending
|
||||
assert.equal(vm.applyDialog.ready, false); await vm.confirmApply(); assert.equal(writes.length, 0)
|
||||
|
||||
let resolveApply
|
||||
const running = instance(undefined, () => new Promise(resolve => { resolveApply = resolve }))
|
||||
running.vm.selected = running.vm.items; await running.vm.openApply()
|
||||
const submit = running.vm.confirmApply(); await running.vm.confirmApply()
|
||||
assert.equal(running.writes.length, 1)
|
||||
resolveApply({ data: { batchId: 'mock-batch', queued: 2 } }); await submit
|
||||
})
|
||||
|
||||
test('submission conflict or unknown result disables retry without changing selection', async () => {
|
||||
const { vm, writes } = instance(undefined, async () => { throw new Error('mock unknown result') })
|
||||
vm.selected = vm.items; await vm.openApply(); await vm.confirmApply(); await vm.confirmApply()
|
||||
assert.equal(writes.length, 1); assert.equal(vm.applyDialog.ready, false); assert.ok(vm.applyDialog.error)
|
||||
assert.equal(vm.busy, false); assert.equal(vm.applyDialog.saving, false); assert.equal(vm.selected.length, 4)
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-inner-codes/index.vue'), 'utf8').split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
|
||||
function instance(write = async () => ({ data: { matchJobId: 'test-job', queued: 2 } })) {
|
||||
const writes = []; const messages = []
|
||||
const options = new Function('rematchSybInnerCodes', 'createRequestId', 'ElMessage', source)(async body => { writes.push(body); return write(body) }, () => 'isolated-id', { success: m => messages.push(m), warning: m => messages.push(m) })
|
||||
const vm = { ...options.data(), ...options.methods, load: async () => {}, startPolling: () => {} }
|
||||
for (const [key, get] of Object.entries(options.computed)) Object.defineProperty(vm, key, { get: () => get.call(vm) })
|
||||
vm.items = ['pending', 'failed', 'skipped', 'updated', 'ready', 'needs_check', 'matching'].map((status, i) => ({ id: i + 1, status, businessDate: '2026-09-29' }))
|
||||
return { vm, writes, messages }
|
||||
}
|
||||
test('initial range and request use local today, clear explicitly removes it', () => {
|
||||
const { vm } = instance(); const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
|
||||
assert.deepEqual(vm.dateRange, [today, today]); assert.equal(vm.query.dateFrom, today); assert.equal(vm.query.dateTo, today)
|
||||
vm.clearFilters(); assert.equal(vm.dateRange, null); assert.equal(vm.query.dateFrom, '')
|
||||
})
|
||||
test('mixed selection submits only current page eligible rows, does not auto apply', async () => {
|
||||
const { vm, writes } = instance(); vm.selected = [...vm.items, { id: 999, status: 'failed' }]
|
||||
assert.equal(vm.matchEligibleCount, 3); await vm.matchSelected(); assert.deepEqual(writes[0].ids, [1, 2, 3]); assert.equal(writes.length, 1); assert.equal(vm.activeJob.id, 'test-job'); assert.equal(vm.activeBatch, null)
|
||||
await vm.matchSelected(); assert.equal(writes.length, 1)
|
||||
})
|
||||
test('busy, loading, error and empty selection do not submit', async () => {
|
||||
for (const field of ['busy', 'importing', 'loading', 'loadError']) {
|
||||
const { vm, writes } = instance(); vm.selected = [vm.items[0]]; vm[field] = field === 'loadError' ? 'error' : true
|
||||
await vm.matchSelected(); assert.equal(writes.length, 0)
|
||||
}
|
||||
const { vm, writes } = instance(); await vm.matchSelected(); assert.equal(writes.length, 0)
|
||||
})
|
||||
test('mixed dates warn without writes', async () => {
|
||||
const { vm, writes, messages } = instance(); vm.items[1].businessDate = '2026-09-28'; vm.selected = vm.items.slice(0, 2)
|
||||
await vm.matchSelected(); assert.equal(writes.length, 0); assert.equal(messages.length, 1)
|
||||
})
|
||||
test('submission error retains selection and releases busy flag', async () => {
|
||||
const { vm } = instance(async () => { throw new Error('test-only error') }); vm.selected = [vm.items[0]]
|
||||
await assert.rejects(vm.matchSelected()); assert.equal(vm.matchSubmitting, false); assert.equal(vm.selected.length, 1); assert.equal(vm.activeJob, null)
|
||||
})
|
||||
@@ -100,7 +100,7 @@ test('legacy interceptor false/6401 result is auth failure, not cancellation suc
|
||||
|
||||
test('pending rows can be selected for cancel but never become collection, purchase, image-search or AI candidates', () => {
|
||||
const script = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products/index.vue'), 'utf8').split('<script>')[1].split('</script>')[0].replace(/import[^\n]+\n/g, '').replace('export default', 'return')
|
||||
const options = new Function('quickLink', 'returnCancel', 'ShopeeProductDetailDrawer', 'PddProductDetailDrawer', script)({}, {}, {}, {})
|
||||
const options = new Function('quickLink', 'returnCancel', 'returnConfirm', 'ShopeeProductDetailDrawer', 'PddProductDetailDrawer', script)({}, {}, {}, {}, {})
|
||||
const row = { id: 1, shopeeProductId: 11, imageUrl: 'mock-image' }
|
||||
const ready = { processStage: 'return_pending', eligible: true, collectionEligible: true, aiMatchEligible: true, pddProductId: 9 }
|
||||
const vm = { ...options.methods, selectedProducts: [row], purchaseReadiness: { 1: ready }, canPurchase: true }
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const readOptions = (file, dependencies) => new Function(...Object.keys(dependencies), fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products', file), 'utf8').replace(/import[^\n]+\n/g, '').replace('export default', 'return'))(...Object.values(dependencies))
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve))
|
||||
function instance(overrides = {}) {
|
||||
const writes = []; const reads = []; const selected = []; const confirmations = []
|
||||
const dependencies = {
|
||||
confirmReturnMatch: async (id, options) => { writes.push({ id, options }); return { code: 200 } },
|
||||
getReturnMatch: async id => { reads.push(id); return { data: { match: { id, sybProductId: id - 10, status: 'matched' } } } }, ...overrides
|
||||
}
|
||||
const options = readOptions('return-confirm.js', dependencies)
|
||||
const cancel = readOptions('return-cancel.js', { cancelReturnMatch: async () => {}, getReturnMatch: dependencies.getReturnMatch })
|
||||
const vm = { ...cancel.methods, ...options.methods, ...cancel.data(), ...options.data(), canPurchase: true, loading: false, returnMatchLoading: false, purchaseReadinessLoading: false, matchDetail: {}, loadGeneration: 0,
|
||||
products: [1, 2, 3].map(id => ({ id, orderCode: `TEST-${id}` })),
|
||||
purchaseReadiness: { 1: { processStage: 'return_pending' }, 2: { processStage: 'return_pending' }, 3: { processStage: 'return_used' } },
|
||||
returnMatchByProductId: { 1: { id: 11, status: 'matched' }, 2: { id: 12, status: 'matched' }, 3: { id: 13, status: 'confirmed' } },
|
||||
purchaseReady(row) { return this.purchaseReadiness[row.id] || {} },
|
||||
$confirm: async text => { confirmations.push(text) }, $nextTick: async () => {},
|
||||
$refs: { productTable: { clearSelection: () => selected.splice(0), toggleRowSelection: row => selected.push(row.id) } },
|
||||
load: async function() { this.loadGeneration++ }
|
||||
}
|
||||
vm.selectedProducts = [...vm.products]
|
||||
Object.entries({ ...cancel.computed, ...options.computed }).forEach(([key, fn]) => Object.defineProperty(vm, key, { get: () => fn.call(vm) }))
|
||||
return { vm, options, writes, reads, selected, confirmations }
|
||||
}
|
||||
test('current-page count, deduplication, mixed rows, original confirm API and one confirmation', async () => {
|
||||
const { vm, writes, confirmations } = instance()
|
||||
vm.selectedProducts.push(vm.products[0], { id: 99 })
|
||||
assert.equal(vm.returnConfirmCandidates.length, 2)
|
||||
await vm.runBatchConfirmReturns()
|
||||
assert.deepEqual(writes.map(w => w.id), [11, 12])
|
||||
assert.ok(writes.every(w => w.options.suppressErrorMessage && w.options.suppressNetworkError))
|
||||
assert.equal(confirmations.length, 1); assert.match(confirmations[0], /2 条/); assert.match(confirmations[0], /不会自动让 Yeeke 发货/)
|
||||
assert.equal(vm.returnConfirmResult.successCount, 2); assert.equal(vm.returnConfirmResult.skippedCount, 1)
|
||||
assert.equal(vm.returnConfirmBatchLoading, false)
|
||||
})
|
||||
test('permission, metadata, busy and empty states prohibit writes', async () => {
|
||||
for (const [key, value] of [['canPurchase', false], ['loading', true], ['returnMatchLoadError', true], ['returnMatchLoading', true], ['purchaseReadinessLoading', true], ['returnTransitionLoading', true], ['returnMatchBatchLoading', true], ['returnCancelBatchLoading', true]]) {
|
||||
const { vm, writes } = instance(); vm[key] = value; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0, key)
|
||||
}
|
||||
const { vm, writes } = instance(); vm.matchDetail.saving = true; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0)
|
||||
vm.matchDetail.saving = false; vm.selectedProducts = []; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0)
|
||||
})
|
||||
test('return dismisses without a write or result; confirm busy blocks cancel and single transitions', async () => {
|
||||
const { vm, writes } = instance(); vm.$confirm = async () => { throw new Error('cancel') }; await vm.runBatchConfirmReturns()
|
||||
assert.equal(writes.length, 0); assert.equal(vm.returnConfirmResult.open, false); assert.equal(vm.returnConfirmBatchLoading, false)
|
||||
vm.returnConfirmBatchLoading = true; let ran = false
|
||||
assert.match(vm.returnCancelButtonReason, /正在处理/)
|
||||
await vm.runReturnTransition(async () => { ran = true }); assert.equal(ran, false)
|
||||
})
|
||||
test('serial, frozen identities and no duplicate execution while waiting', async () => {
|
||||
let release; const wait = new Promise(resolve => { release = resolve }); const writes = []
|
||||
const { vm } = instance({ confirmReturnMatch: async id => { writes.push(id); if (id === 11) await wait } })
|
||||
const running = vm.runBatchConfirmReturns(); await tick()
|
||||
vm.returnMatchByProductId[2].id = 99; vm.selectedProducts = []; await vm.runBatchConfirmReturns()
|
||||
assert.deepEqual(writes, [11]); release(); await running; assert.deepEqual(writes, [11, 12])
|
||||
})
|
||||
test('partial business failure keeps only readback-proven current-page failed selection', async () => {
|
||||
const { vm, selected, reads } = instance({ confirmReturnMatch: async id => { if (id === 12) throw { response: { status: 500 } } } })
|
||||
await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 1); assert.deepEqual(reads, [12]); assert.deepEqual(selected, [2])
|
||||
})
|
||||
test('state conflict skips original; neither readback nor replacement write', async () => {
|
||||
const { vm, reads } = instance({ confirmReturnMatch: async () => { throw { response: { status: 409, data: { code: 'STATE_CONFLICT' } } } } })
|
||||
await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.skippedCount, 3); assert.equal(reads.length, 0)
|
||||
})
|
||||
for (const status of ['confirmed', 'cancelled', 'matched']) {
|
||||
test(`unknown outcome reads original ${status} once, never resends`, async () => {
|
||||
let writes = 0
|
||||
const { vm, reads, selected } = instance({ confirmReturnMatch: async () => { writes++; throw new Error('timeout') }, getReturnMatch: async id => { reads.push(id); return { data: { match: { id, sybProductId: id - 10, status } } } } })
|
||||
await vm.runBatchConfirmReturns(); assert.equal(writes, 2); assert.deepEqual(reads, [11, 12])
|
||||
assert.equal(vm.returnConfirmResult.successCount, 0)
|
||||
assert.deepEqual(selected, status === 'matched' ? [1, 2] : [])
|
||||
assert.equal(vm.returnConfirmResult.skippedCount, status === 'matched' ? 1 : 3)
|
||||
})
|
||||
}
|
||||
test('unreadable/mismatched original never treated as confirmed or retryable', async () => {
|
||||
for (const getReturnMatch of [async () => { throw new Error('offline') }, async id => ({ data: { match: { id: id + 1, sybProductId: 999, status: 'confirmed' } } })]) {
|
||||
const { vm, selected } = instance({ confirmReturnMatch: async () => { throw new Error('timeout') }, getReturnMatch })
|
||||
await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.deepEqual(selected, [])
|
||||
}
|
||||
})
|
||||
for (const response of [false, new Error('Unauthorized'), { response: { status: 401 } }]) {
|
||||
test(`auth failure/legacy false stops remaining writes (${String(response)})`, async () => {
|
||||
let writes = 0
|
||||
const { vm } = instance({ confirmReturnMatch: async () => { writes++; if (response === false) return false; throw response } })
|
||||
await vm.runBatchConfirmReturns(); assert.equal(writes, 1); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.equal(vm.returnConfirmBatchLoading, false)
|
||||
assert.match(vm.returnConfirmResult.items[1].reason, /未继续执行/)
|
||||
})
|
||||
}
|
||||
test('login failure during readback stops remaining POSTs', async () => {
|
||||
let writes = 0
|
||||
const { vm } = instance({ confirmReturnMatch: async () => { writes++; throw new Error('timeout') }, getReturnMatch: async () => { throw new Error('Unauthorized') } })
|
||||
await vm.runBatchConfirmReturns(); assert.equal(writes, 1); assert.equal(vm.returnConfirmResult.failedCount, 2)
|
||||
})
|
||||
test('new generation, replaced original and vanished failed row cannot restore old selection', async () => {
|
||||
for (const load of [async function() { this.loadGeneration += 2 }, async function() { this.loadGeneration++; this.products = [] }, async function() { this.loadGeneration++; this.returnMatchByProductId[1].id = 99; this.returnMatchByProductId[2].id = 98 }]) {
|
||||
const { vm, selected } = instance({ confirmReturnMatch: async () => { throw new Error('timeout') } }); vm.load = load
|
||||
await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.deepEqual(selected, [])
|
||||
}
|
||||
})
|
||||
test('refresh failure keeps completed result and does not write again', async () => {
|
||||
const { vm, writes } = instance(); vm.load = async () => { throw new Error('offline') }; await vm.runBatchConfirmReturns()
|
||||
assert.match(vm.returnConfirmResult.refreshError, /刷新失败/); assert.equal(vm.returnConfirmResult.successCount, 2); assert.equal(writes.length, 2)
|
||||
})
|
||||
test('unmount stops remaining writes after in-flight response', async () => {
|
||||
let release; const wait = new Promise(resolve => { release = resolve }); let writes = 0
|
||||
const { vm, options } = instance({ confirmReturnMatch: async () => { writes++; await wait } })
|
||||
const running = vm.runBatchConfirmReturns(); await tick(); options.beforeUnmount.call(vm); release(); await running
|
||||
assert.equal(writes, 1); assert.equal(vm.returnConfirmBatchLoading, false)
|
||||
})
|
||||
Reference in New Issue
Block a user