Compare commits

..
Author SHA1 Message Date
QiuSW fcb7dad878 docs: record reveal swipe result policy and version boundary (#348) 2026-09-29 17:52:32 +08:00
QiuSW 6550b9fd45 fix(android): skip reveal swipe without a scroll target (#348) 2026-09-29 17:43:46 +08:00
QiuSW 7a2c2d1eac merge: SYB excluded-product marking and recompute (#340) 2026-09-29 16:32:35 +08:00
QiuSW d01540b9fd fix(web): label SYB sync filter counts as hits (#340) 2026-09-29 16:31:54 +08:00
QiuSWandClaude Opus 5.5 ec010b42fc docs: SYB excluded-product marking and recompute contract (#340)
Mirrors of Wiki revisions Architecture-and-Code-Map 4bf8482e,
Business-Rules-and-Glossary c0b1a842, SYB-ERP-Interface-Contract 5979a142,
Android-Agent-API-Contract 92e9f856: filter hits are stored and marked
(first creation only), PDD isolation, 无需采购 stage position, purchaseType
list filter, recompute preview/execute with fingerprint and 409, hit vs
markedCount wording. SYB-ERP page also restores #343's unified-session
paragraph (was only in the mirror) and corrects the structure rule to #286's
single `-#` all-characters rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-29 16:21:47 +08:00
QiuSWandClaude Opus 5.5 a56df9d908 fix(syb): #340 phase 4 — fingerprint includes rule evidence + MySQL IT
1. recomputeFingerprint now hashes a JSON-serialized (not naive string-
   concatenated, to avoid delimiter-collision) sorted list of
   {id, direction, ruleId, ruleKind, ruleKeyword} per planned change — the
   rule kind/keyword are exactly what gets written into
   excluded_rule_kind/excluded_rule_keyword, so a plan that affects the
   same ids/directions via a since-edited rule must now be rejected as
   stale, not silently accepted. New tests:
   TestRecomputeFingerprintChangesWhenRuleEvidenceChanges (edits the rule
   row directly between preview and execute, since the API has no edit
   endpoint, and asserts RECOMPUTE_PREVIEW_STALE with nothing written and
   no log row) and TestRecomputeFingerprintStableAcrossUnchangedPreviews
   (two previews of the same data yield the same fingerprint and execute
   succeeds).

2. New server/app/goauto/sybproductfilter/recompute_mysql_integration_test.go,
   gated on GOAUTO_IT_MYSQL_DSN (t.Skip when unset, so `go test` is
   unaffected normally). It creates a uniquely named throwaway database
   (zz_goauto_it_340_<ts>), migrates it, and drops it in t.Cleanup — never
   touches an existing database. Two real-MySQL, two-connection scenarios
   reproduce the exact race the phase-3 fix closes: connection A takes its
   REPEATABLE-READ snapshot via the planning step, connection B takes the
   row's FOR UPDATE lock and holds it (confirmed via a channel) while A's
   write phase is proven to actually block on that same lock (asserted via
   a wait window), B then inserts a purchase_task / active return_match
   and commits, and A is asserted to unblock, see it, and skip the row.
   Verified locally against the dev MySQL server (this session never
   printed the password: read via a shell one-liner into an env var,
   exported only for the go test invocation): both tests PASS with the
   phase-3 fix in place. Temporarily reverted purchaseTaskLockedQuery to a
   plain (non-locking) read (not committed) and reran —
   TestRecomputeConcurrentPurchaseTaskUnderRealMySQL correctly FAILED
   ("expected A to skip the row ... got {PDDToExcluded:1 SkippedHasTask:0}"),
   proving the test is meaningful; restored and diffed byte-identical
   against a backup before rerunning to confirm both tests pass again.
   Confirmed via `SHOW DATABASES LIKE 'zz_goauto_it_%'` (empty) that every
   throwaway database, across all these runs, was actually dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-29 16:05:06 +08:00
QiuSWandClaude Opus 5.5 cd4bd6acb1 fix(syb): #340 phase 3 review — recheck reads must be locking, not plain
writeRecomputeChanges' purchase_task/return_match rechecks (added in the
previous phase 3 commit) used plain SELECT COUNT(*). On MySQL 8.4 under
REPEATABLE-READ, a plain read inside a transaction reuses the snapshot
taken at that transaction's first read (recomputeChanges' own SELECT), so
a purchase_task or return_match row committed by another connection AFTER
that snapshot was invisible to the recheck even after the syb_product row's
FOR UPDATE lock was granted — the row lock only serializes writers against
each other, it doesn't force a later plain read to see newer committed
data. Confirmed against a real local MySQL 8.4 server with two connections:
after the other transaction committed a task, plain COUNT(*) returned 0
while COUNT(*) ... FOR SHARE correctly returned 1. Every existing test
passed anyway because this package's tests run on SQLite, which has no
multi-connection snapshot isolation to reproduce the race at all.

Fix: both rechecks now use clause.Locking{Strength: "SHARE"} (a locking/
"current" read is enough since they only need to observe committed rows,
not lock them further). Split the three per-row queries (row FOR UPDATE,
purchase_task FOR SHARE, return_match FOR SHARE) into small query-builder
helpers (sybProductRowLockQuery / purchaseTaskLockedQuery /
returnMatchLockedQuery) so writeRecomputeChanges consumes them and a test
can independently assert their generated SQL.

New test: TestRecheckQueriesUseLockingReads opens a DryRun gorm session
against the MySQL dialector (mysql.New with SkipInitializeWithVersion,
DisableAutomaticPing — no real network connection is ever made) and pins
the exact SQL shape via db.ToSQL:
  SELECT * FROM `syb_product` WHERE id = 1 FOR UPDATE
  SELECT count(*) FROM `purchase_task` WHERE syb_product_id = 1 FOR SHARE
  SELECT count(*) FROM `return_match` WHERE syb_product_id = 1
    AND active_syb_product_id IS NOT NULL FOR SHARE
The test's own comment documents why SQLite cannot reproduce this race
(gorm's SQLite driver drops clause.Locking entirely; SQLite also has no
multi-connection REPEATABLE-READ snapshot semantics to begin with).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-29 15:43:37 +08:00
QiuSWandClaude Opus 5.5 baa20af530 fix(syb): #340 phase 3 — recompute race protection, fingerprint, wording
Merge origin/main (1e582cd, #350/#351 inner-code fixes) — no conflicts.

sybproductfilter/recompute.go:
1. Row-level protection in RecomputeExecute: the write phase is split out
   into writeRecomputeChanges(ctx, tx, planned), independently testable.
   For every planned change it takes the same clause.Locking{Strength:
   "UPDATE"} row lock purchase.Service.create and returnmatch's
   matchOneWithLock take, then re-checks under that lock: a purchase task
   or active return match that appeared after planning skips the row
   (counted), and a row already at its target mark is left alone. The
   execute response and audit log now report ACTUAL writes/skips
   (plan-time skips + write-time skips), not the initial plan.
2. Preview/execute binding: RecomputePreview returns a `fingerprint`
   (sha256 over the sorted id:direction:ruleId list). RecomputeExecute now
   requires it, recomputes the plan inside the same transaction and
   compares before writing; a mismatch returns RECOMPUTE_PREVIEW_STALE
   (HTTP 409, "数据或规则已变化,请重新预览后再执行") and writes nothing.
   Frontend passes the preview's fingerprint to execute and re-previews
   automatically on that error.
3. syb-product-filters List gains a per-rule `markedCount` (one grouped
   COUNT(*)...GROUP BY excluded_rule_id query, no N+1): the REAL current
   count of syb_product rows marked by that rule. The disable-structure-
   rule confirm dialog now quotes this instead of the stale lastHitCount
   sync snapshot; the 上次同步命中 column still shows lastHitCount.
4. recomputeChanges now selects only id/order_code/shopee_item_id/
   raw_json/pdd_purchase_excluded instead of full syb_product rows.

Wording (user-approved deviation from the prototype text):
- syb-sync-runs detail: 其中无需采购 N 条 -> 其中本次规则命中 N 条.
- syb-product-filters: 上次同步标记 reverted back to 上次同步命中 (both
  tables); the confirm-dialog text now cites markedCount, not lastHitCount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-29 15:34:20 +08:00
QiuSW f54ace7ccf Merge remote-tracking branch 'origin/main' into feat/340-syb-excluded-products 2026-09-29 15:25:46 +08:00
QiuSW 1e582cd868 merge: filter and freeze eligible inner-code apply selection (#351) 2026-09-29 15:00:04 +08:00
QiuSW d72d77ddf4 docs: bind mixed-selection apply confirmation rules (#351) 2026-09-29 14:59:57 +08:00
QiuSW ee8f80f987 fix: submit only frozen eligible inner-code apply selections (#351) 2026-09-29 14:57:23 +08:00
QiuSW 4959f3d17f merge: inner-code layout comparison regression fix (#350) 2026-09-29 14:15:12 +08:00
QiuSW 15d331bee9 fix: normalize layout controls in inner-code match evidence (#350) 2026-09-29 14:15:04 +08:00
QiuSW 897c1817d2 merge: restore inner-code matching and batch retry (#350) 2026-09-29 12:02:14 +08:00
QiuSW 5729ba048f docs: bind inner-code matching recovery contract #350 2026-09-29 11:49:32 +08:00
QiuSW 9c5e20e249 fix: recover inner-code matching and enable batch rematch #350 2026-09-29 11:45:46 +08:00
QiuSWandClaude Opus 5.5 8c95790135 feat(syb): #340 sync-run detail wording + excluded count (phase 2 cont.)
Rewords 结构过滤命中/关键词过滤命中 -> 结构过滤标记/关键词过滤标记 on the
sync-run detail (SYB 同步记录), and shows "其中无需采购 N 条" next to 商品明细,
computed client-side as charFilterSkipped + keywordFilterSkipped (exactly
the rows this run marked pdd_purchase_excluded) — no backend field needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-29 11:00:15 +08:00
QiuSWandClaude Opus 5.5 316d5aa412 feat(syb): #340 phase 2 — merge main, recompute samples, frontend UI
Merge origin/main (through #345/e7c049d) into feat/340-syb-excluded-products.
Resolved conflicts in sybimport/service.go, handler.go, service_test.go
(kept #342's createdFrom/createdTo AND #340's purchaseType, all combined
with processStage), and took origin/main's syb-products/index.vue as the
base for the new UI work below. Renamed the migration version file from
1789801100000 to 1789801500000 (next free slot after main's highest,
1789801400000) — content unchanged, version comes from the filename.

Backend: sybproductfilter recompute preview now also returns up to 20
sample rows (order code, shopee item id, change direction, matched rule)
alongside the existing counts; execute stays count + audit-log only.

Frontend (feat/340 issue "## 设计证据"/"## 页面", prototype v1):
- SYB 订单商品页: 采购类型 filter (需 PDD 采购 default / 无需 PDD 采购 / 全部),
  处理阶段 gains 无需采购 (pdd_excluded); selecting 退货待确认/已用退货/无需采购
  auto-switches 采购类型 to 全部; pdd_excluded rows are tickable only for
  匹配退货 (never collection/purchase/AI-match/image-search); rows show the
  stage tag plus 规则:<kind> <keyword>; detail drawer shows 采购类型 and rule.
- SYB 过滤规则页: 跳过导入 -> 标记为无需 PDD 采购 wording, 上次同步命中 ->
  上次同步标记, rewritten scope note, admin-only 按当前规则重算 button with
  preview dialog (4 counts + up to 20 samples) -> confirm -> execute.
- SYB 同步记录: 结构/关键词过滤命中 -> 结构/关键词过滤标记 (see #340 point 8;
  the run-level "其中无需采购 N 条" count is derivable client-side from the
  existing char/keyword counts, no backend field added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-29 10:59:12 +08:00
QiuSW e55645ff66 Merge remote-tracking branch 'origin/main' into feat/340-syb-excluded-products
# Conflicts:
#	server/app/goauto/sybimport/handler.go
#	server/app/goauto/sybimport/service.go
#	server/app/goauto/sybimport/service_test.go
#	web/src/views/goauto/syb-products/index.vue
2026-09-29 10:50:54 +08:00
QiuSW dd9160ec42 merge: batch cancel and use return matches (#347 #349) 2026-09-29 10:30:30 +08:00
QiuSWandClaude Opus 5.5 30c6b2ab50 feat(syb): hard-isolate pdd_purchase_excluded rows from PDD flows (#340)
SYB rows that hit a product filter rule are no longer skipped at import;
they are stored with a pdd_purchase_excluded mark (rule snapshot fixed at
row-creation time, never changed by a later resync), get a new
pdd_excluded process stage ("无需采购"), are hard-rejected by every PDD
entry point (purchase create/batch create/batch preview, collection and
AI-match eligibility, image search batch collection, quick-replace via
the new stage), and can still participate in #338 return matching.

Adds an admin-only recompute preview/execute pair in sybproductfilter to
re-derive marks against the current rules, skipping any row with a
purchase task or an active return match, with an audit log row per run.

sybimport.List gets a purchaseType filter (pdd/excluded/all, AND with
processStage).

Migration: cmd/migrate/migration/version-local/1789801100000 (additive
columns/table, all existing rows default to "needs PDD purchase").

Web UI is out of scope, pending prototype approval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 17:06:43 +08:00
QiuSW 27d9560f0a Merge remote-tracking branch 'origin/feat/338-return-match' into feat/339-syb-page-size 2026-09-24 16:27:43 +08:00
QiuSW 018e3566c0 Merge remote-tracking branch 'origin/feat/338-return-match' into feat/339-syb-page-size 2026-09-24 16:15:01 +08:00
QiuSW ddd875d989 Merge remote-tracking branch 'origin/feat/338-return-match' into feat/339-syb-page-size 2026-09-24 15:59:14 +08:00
QiuSWandClaude Opus 5.5 8b4db9c89e style(web): narrow SYB order-code filter input to 80% width (#339)
210px -> 168px, requested while trying the 500-rows page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 14:39:47 +08:00
QiuSWandClaude Opus 5.5 00fb274fd4 feat(syb): support 200/500 rows per page on SYB products list (#339)
Bump the syb-products page-size options to 20/50/100/200/500 with a new
100 default, cap the server-side sybimport.List page size at 500, chunk
the per-page purchase-readiness preview into <=100-id requests, and add
per-button selection limits (with disabled+tooltip) for AI 匹配, 创建采购,
创建采集, 图搜采集 and 匹配退货 so a larger page never silently exceeds a
batch endpoint's cap. 创建采购's 100-item server cap is left untouched.
Also caps returnmatch.BatchMatch at 500 ids (INVALID_REQUEST beyond that).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 14:35:53 +08:00
53 changed files with 3304 additions and 104 deletions
+2 -2
View File
@@ -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"
@@ -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 {
@@ -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)
}
@@ -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) {
+30 -2
View File
@@ -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: 9b4a1a06b8e2f6a8dfdbaef87817d840dfa45389
synchronized_at: 2026-09-29T02:23:54Z
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 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
@@ -570,3 +572,29 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 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、数据库、任务租约及下单/支付边界不变。无滑动不代表规格探测成功。
+40 -4
View File
@@ -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: 9a7317728132c450c7abf244c3e5eaac7bfb71bc
synchronized_at: 2026-09-29T02:23:57Z
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;不弱化面板验证、精确选中、地址、价格、任务租约、创建订单边界或禁止支付规则。
@@ -732,3 +735,36 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
- 只操作冻结的原 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。后续规格探测、精确选择与最终复核全部照常,缺规格或探测无结果仍失败,不猜测、不新增点击目标或支付。
+38 -7
View File
@@ -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: 102d40fb2d960b05684e2a82e85729362070fdb5
synchronized_at: 2026-09-29T02:24:16Z
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 仍按原策略执行,不能将本契约描述当作旧设备已获得兼容。
规格探测与精确选择自行负责按需有界滚动,原始候选、精确点击和选中复核不变。跳过预滑动不作为规格探测成功或订单创建证据。
@@ -1417,3 +1432,19 @@ Web 批量入口冻结原匹配 ID 并顺序调用;HTTP 409 状态冲突跳过
- 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/规则快照绑定与结果提交字段不变。不记录节点文本、规格、地址、订单、坐标、树、整屏截图或凭据。
+18 -7
View File
@@ -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/
验证码等任何顺运宝请求之前停止,并且不推进覆盖游标。该过滤只影响后续入库,
+9 -2
View File
@@ -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、未部署,正式环境仍需发布后启用。
+1
View File
@@ -44,6 +44,7 @@ func MigratedModels() []any {
&models.SYBSessionAuthLease{},
&models.SYBShop{},
&models.SYBProductFilter{},
&models.SYBProductFilterRecomputeLog{},
&models.SYBSyncRun{},
&models.YeekeSession{},
&models.YeekeReturnPackage{},
+33
View File
@@ -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("未解析出需要采购的颜色或尺码")
}
+8
View File
@@ -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])
}
}
+14 -1
View File
@@ -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
}
+3
View File
@@ -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, "该商品尚未关联蝦皮商品")
}
+8 -1
View File
@@ -41,7 +41,14 @@ const (
// tasks for such a product until the match is cancelled. Resuming or
// reparsing an existing task is not affected.
CodeReturnMatched = "PURCHASE_RETURN_MATCHED"
CodeInternal = "INTERNAL_ERROR"
// CodePDDExcluded is returned by every PDD entry point (purchase create,
// collection task creation, AI spec match, image search collection,
// quick-replace, ...) when the SYB product is marked
// pdd_purchase_excluded (#340): it hit a product filter rule at import
// time and is recorded as 「无需 PDD 采购」, so it must never start any
// PDD flow, regardless of the rest of its pipeline state.
CodePDDExcluded = "PURCHASE_PDD_EXCLUDED"
CodeInternal = "INTERNAL_ERROR"
)
type ServiceError struct {
@@ -0,0 +1,33 @@
package returnmatch
import (
"context"
"testing"
"time"
"go-admin/app/goauto/models"
)
// #340: a pdd_purchase_excluded SYB product still needs no PDD purchase but
// must remain eligible to take part in return matching.
func TestBatchMatch_ExcludedStageParticipates(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-EXCL-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).
Update("pdd_purchase_excluded", true).Error; err != nil {
t.Fatal(err)
}
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
if err != nil {
t.Fatal(err)
}
if resp.MatchedCount != 1 || len(resp.Items) != 1 || !resp.Items[0].Matched {
t.Fatalf("excluded-stage row must still be eligible for return matching: %+v", resp)
}
}
+6
View File
@@ -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 {
+33
View File
@@ -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.
+1 -1
View File
@@ -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)
}
}
+23
View File
@@ -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 {
+17 -6
View File
@@ -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",
+65 -1
View File
@@ -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)
}
}
+89 -11
View File
@@ -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)
}
}
}
+24 -5
View File
@@ -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)
}
+53 -7
View File
@@ -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"`
+5
View File
@@ -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])
}
}
+3
View File
@@ -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
})
}
@@ -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 } }) }
+65 -13
View File
@@ -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>
+31 -9
View File
@@ -12,7 +12,8 @@
</el-form-item>
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px" @change="onProcessStageChange"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="采购类型"><el-select v-model="query.purchaseType" style="width: 132px"><el-option label="需 PDD 采购" value="pdd" /><el-option label="无需 PDD 采购" value="excluded" /><el-option label="全部" value="all" /></el-select></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || matchDetail.saving || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnCancelButtonReason" :disabled="!returnCancelButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnCancelBatchLoading" :disabled="Boolean(returnCancelButtonReason)" @click="runBatchCancelReturns">取消匹配<span class="action-count">{{ returnCancelCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnConfirmButtonReason" :disabled="!returnConfirmButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :loading="returnConfirmBatchLoading" :disabled="Boolean(returnConfirmButtonReason)" @click="runBatchConfirmReturns">使用退货<span class="action-count">{{ returnConfirmCandidates.length }}</span></el-button></span></el-tooltip></el-form-item>
</div>
<div class="search-row search-row-secondary">
@@ -37,6 +38,7 @@
<el-tag :type="processMeta(purchaseReady(row).processStage).type">{{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}</el-tag>
<div class="purchase-reason">{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}</div>
<div v-if="purchaseReady(row).processStage === 'purchase_ready'" class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div>
<div v-if="purchaseReady(row).processStage === 'pdd_excluded'" class="muted">规则:{{ excludedRuleText(row) }}</div>
<div class="quick-link-actions">
<el-button v-if="purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction })" type="primary" link @click="runPurchaseNextAction(row, { ...purchaseReady(row), nextAction: purchaseReady(row).processNextAction })">{{ purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction }) }}</el-button>
<el-button-group v-if="quickCanLink(row)">
@@ -326,6 +328,10 @@
<el-descriptions-item label="解析状态"><el-tag :type="parseMeta(detail.item.parseStatus).type">{{ parseMeta(detail.item.parseStatus).label }}</el-tag></el-descriptions-item>
<el-descriptions-item label="解析说明">{{ detail.item.parseNote || '—' }}</el-descriptions-item>
<el-descriptions-item label="人工确认">{{ detail.item.manuallyConfirmed ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="采购类型">
<el-tag :type="detail.item.pddPurchaseExcluded ? 'info' : 'success'">{{ detail.item.pddPurchaseExcluded ? '无需 PDD 采购' : '需 PDD 采购' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="detail.item.pddPurchaseExcluded" label="命中规则">{{ excludedRuleText(detail.item) }}</el-descriptions-item>
</el-descriptions>
<div class="drawer-actions">
<el-button @click="openReparseSingle">重新解析</el-button>
@@ -404,7 +410,7 @@ export default {
imageSearchBatch: this.emptyImageSearchBatch(),
collectionBatchData: { ruleId: null, deviceId: null },
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] },
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] },
returnMatchByProductId: {},
returnMatchLoading: false,
returnMatchBatchLoading: false,
@@ -420,7 +426,7 @@ export default {
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length },
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] },
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }, { value: 'pdd_excluded', label: '无需采购' }] },
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) },
aiMatchCandidates() { return this.selectedProducts.filter(row => this.isAIMatchCandidate(row)) },
aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
@@ -478,7 +484,7 @@ export default {
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
try {
const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, createdFrom, createdTo }, requestOptions)
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, purchaseType: this.query.purchaseType, createdFrom, createdTo }, requestOptions)
if (generation !== this.loadGeneration) return
this.products = r.data.items
this.total = r.data.total
@@ -501,7 +507,15 @@ export default {
}
},
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }; this.load() },
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] }; this.load() },
// #340: 选择「退货待确认/已用退货/无需采购」这些不属于「需 PDD 采购」默认范围
// 的阶段时,自动把采购类型切到「全部」,否则处理阶段过滤会和采购类型过滤
// 的 AND 组合出空列表;用户仍可再手动切回。
onProcessStageChange(value) {
if (['return_pending', 'return_used', 'pdd_excluded'].includes(value) && this.query.purchaseType !== 'all') {
this.query.purchaseType = 'all'
}
},
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
async ensureShopOptions() {
if (this.shopOptionsLoaded) return
@@ -531,10 +545,13 @@ export default {
// 图搜采集的用途就是给未关联 PDD 的虾皮商品找到 PDD 商品,所以它的可勾选条件
// 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品
// 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。
isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && Boolean(row.shopeeProductId && row.imageUrl) },
// #338: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && !this.isPDDExcluded(row) && Boolean(row.shopeeProductId && row.imageUrl) },
// #340: pdd_purchase_excluded 商品仍可参与退货匹配(服务端 returnmatch 把
// pdd_excluded 加入了 participatingStages),但不参与其他任何 PDD 相关操作。
isPDDExcluded(row) { return this.purchaseReady(row).processStage === 'pdd_excluded' },
// #338/#340: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
isReturnMatchCandidate(row) {
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready'])
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready', 'pdd_excluded'])
return participating.has(this.purchaseReady(row).processStage)
},
isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' },
@@ -543,8 +560,13 @@ export default {
isSelectableCandidate(row) { return this.purchaseReady(row).processStage === 'return_pending' || this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) },
rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(row) },
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
// #340: 规则快照固定在标记时刻,与当前规则是否被改名/停用无关。
excludedRuleText(item) {
if (!item || !item.excludedRuleKeyword) return '(规则快照缺失)'
return `${item.excludedRuleKind === 'char' ? '结构' : '关键词'} ${item.excludedRuleKeyword}`
},
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }, pdd_excluded: { label: '无需采购', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
// #339: the page can now show up to 500 rows, but the preview endpoint
// (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still
+5 -1
View File
@@ -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()
})
+5 -1
View File
@@ -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)
})