Compare commits

...
Author SHA1 Message Date
QiuSW 6752b149d8 feat(#353): filter SYB products by managed shops 2026-10-05 11:54:32 +08:00
QiuSW a38688cdd3 docs(#353): sync SYB shop filter contract 2026-10-05 11:51:55 +08:00
QiuSW c6c7fbe2d4 feat(#354): add top pagination to SYB products 2026-10-05 11:12:48 +08:00
QiuSW 1f3ce610b9 test(#352): verify additive source-time migration 2026-09-30 10:02:32 +08:00
QiuSW 31ff045e8c docs(#352): mirror SYB source-date API contract 2026-09-30 10:00:19 +08:00
QiuSW b4186a9228 docs(#352): mirror SYB source-time rules and architecture 2026-09-30 09:58:14 +08:00
QiuSW f9c48a018a fix(#352): filter SYB products by source stock created time 2026-09-30 09:46:57 +08:00
QiuSW bf06b2d500 Merge #348: distinguish absent reveal swipe targets 2026-09-29 18:03:52 +08:00
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
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
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
54 changed files with 3275 additions and 144 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) {
+23 -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: 1e87b8832cecd454e58f0ff49dbe05bbf8d61544
synchronized_at: 2026-09-29T03:47:39Z
wiki_revision: 04a4bcf81f2f2912d101d1aa1c2d8eb802c5719e
synchronized_at: 2026-09-30T01:50:27Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -137,6 +137,7 @@ Android Portal/Agent
| 虾皮商品档案增量迁移 | `server/cmd/migrate/migration/version-local/1786700600000_shopee_product_archive.go` |
| SYB 商品明细导入、解析与虾皮档案合并 | `server/app/goauto/sybimport/`(解析、幂等落库、同步编排、导入端点、管理端 API 和 Admin 页面均已实现) |
| 顺云宝(SYB)ERP HTTP 客户端与登录会话 | `server/app/goauto/sybclient/`(登录、OCR 验证码、会话缓存、列表与明细读取;见 [SYB-ERP-Interface-Contract](SYB-ERP-Interface-Contract)) |
| SYB 来源货运单创建时间(#352,待迁移与发布) | `syb_product.syb_stock_created_at` 为可空且带索引的 `t_stock.created`,由 `sybimport/sync.go` 从列表取值,经 `apply.go` 写入;`service.go` 的日期筛选使用该字段。迁移 `server/cmd/migrate/migration/version-local/1789810000000_syb_stock_created_at.go` 仅加列和索引,不改历史行;历史由有界 SYB 重同步补齐。 |
| SYB 商品明细增量迁移 | `server/cmd/migrate/migration/version-local/1786700700000_syb_product_import.go` |
| SYB 店铺准入、发现与过滤 | `server/app/goauto/sybshop/`、`server/app/goauto/sybimport/`;迁移 `server/cmd/migrate/migration/version-local/1786700900000_syb_shop.go` |
| SYB 后台导入任务、进度、单任务互斥与启动恢复 | `server/app/goauto/sybimport/sync_run.go`、`sync_run_handler.go`;表 `syb_sync_run`,迁移 `server/cmd/migrate/migration/version-local/1786701000000_syb_sync_run.go` |
@@ -365,6 +366,8 @@ PddProductDetailCollector
## 采购规格面板预滑动兼容(#238)
> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。
代码基线 `58a6c1c`,Android 0.9.60 / versionCode 73(构建完成不等同于已安装/发布)。`PurchaseRehearsalExecutor.applyPostAction` 对 `openSpecPanel.swipeAfter` 只兼容解析、不执行机械预滑动,`waitAfterMs` 保留;首趟继续原 `probeSpecs` 遍历,第二趟继续原 `selectSpec` 精确查找与容器内有界滚动。其他动作的后置滑动仍沿用既有执行语义,失败不会被统一忽略。
`GoAutoAccessibilityService.swipePurchase` 的失败分类由 `PurchaseSwipeFailureReason` 枚举提供;共享 `swipeNode` 仅增加可选分类回调,不改变手势目标、轨迹、1500ms 回调等待或其他调用者行为。`GoAutoPurchasePanel` 日志经 `AgentForegroundService` 关联 task、attempt、device 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
@@ -578,3 +581,21 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 原页面默认用户浏览器本地今天范围,工具栏“匹配 N”复用现有 rematch 和任务进度;只处理本页合格项,同日提交,原回写需独立确认。
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
## SYB 过滤商品标记与规则重算(#340)
- 数据:`syb_product` 新增 `pdd_purchase_excluded`(索引)、`excluded_rule_id`、`excluded_rule_kind`、`excluded_rule_keyword`、`excluded_at`;新表 `syb_product_filter_recompute_log`(操作人、两方向变更数、两类跳过数、时间)。迁移 `1789801500000_syb_pdd_purchase_excluded`,只新增,存量行默认需 PDD 采购。
- 入库:`sybimport/sync.go applyStockDetail` 命中规则不再 `continue`,把规则快照随明细交给 `apply.go ApplyDetail`;仅新建分支写标记,更新分支保留原标记。
- 阶段与隔离:`purchase/process_stage.go` 新增 `pdd_excluded`(「无需采购」);`purchase/return_match_block.go rejectIfPDDExcluded`(`service.create`,覆盖单条/批量)、`batch.go` 预检、`collection_eligibility.go`、`ai_match_eligibility.go`、`task/image_search.go` 统一拒绝;`returnmatch` 的参与阶段包含 `pdd_excluded`。
- 列表:`sybimport/service.go List` 的 `purchaseType`(`pdd` 默认 / `excluded` / `all`),与 `processStage`、`createdFrom/To` 为 AND。
- 重算:`sybproductfilter/recompute.go`——`recomputeChanges` 规划(只读所需列);`recomputeFingerprint` 对排序后的(id、方向、规则 ID、类型、关键词)JSON 做 sha256;`writeRecomputeChanges` 逐行 `FOR UPDATE` 锁 `syb_product`,并以 `FOR SHARE` 加锁读复查 `purchase_task` / `return_match`(MySQL REPEATABLE-READ 下普通读看不到快照后的提交)。`recompute_mysql_integration_test.go` 在设置 `GOAUTO_IT_MYSQL_DSN` 时于临时库做双连接并发验证。
- Web:`syb-products/index.vue`(采购类型筛选、阶段自动切换、规则显示、按钮计数)、`syb-product-filters/index.vue`(命中/标记口径、重算预览与执行)、`syb-sync-runs/index.vue`(「其中本次规则命中 N 条」)。
## 采购规格面板揭示滑动无目标兼容(#348)
实现绑定 `6550b9f`,Android 0.9.64 / versionCode 77;基于 main `7a2c2d1`,当前仅分支实现,未合并、未发布、未安装真机。
- `PurchaseUiDriver.swipePurchaseWithEvidence` 返回 `PurchaseSwipeOutcome`(结果枚举与可见可滚动节点数)。生产 `GoAutoAccessibilityService` 从同一个实时无障碍 root 统计并定位目标;SUCCESS、NO_SCROLLABLE_TARGET、ROOT_UNAVAILABLE、FAILED 明确区分。原 Boolean swipePurchase 返回语义、目标选择与 swipeNode 不变,不修改采集器。
- `PurchaseRehearsalExecutor.applyPostAction` 仅在 openSpecPanel 的揭示滑动遇到 NO_SCROLLABLE_TARGET 时跳过剩余次数;保留 NON_SCROLLABLE_CONFIRMATION 原例外。其他动作仍走原 Boolean 必需滑动路径;root 不可用或有目标而手势失败仍 RULE_ACTION_FAILED。
- 失败/跳过通过既有 panelDiagnostic 回调写 GoAutoPurchasePanel 结构日志,仅含面板枚举、scrollables 计数、reveal 布尔与固定结果原因;失败摘要带同样标量并沿原任务结果提交。没有节点文字、坐标、树或截图。
- probeSpecs、selectSpec、最终复核、规则 JSON、能力、服务端、Admin、数据库、任务租约及下单/支付边界不变。无滑动不代表规格探测成功。
+31 -3
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: c483a1f3fff3144dfad1ae24e6ca0b1de643ac12
synchronized_at: 2026-09-29T06:14:25Z
wiki_revision: 93335c279bcd660ceb5179ea4809ae8a2d90210c
synchronized_at: 2026-10-05T03:50:11Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -57,6 +57,10 @@ synchronized_at: 2026-09-22T02:56:27Z
## SYB 商品明细
### 来源创建时间(#352 目标契约,待迁移与发布)
Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(UTC+8 本地日期)筛选关联商品明细,起止日均包含;这不是商品明细独立创建时间。同步新增或重同步读取列表 `created` 并存入 `syb_product.syb_stock_created_at`;`syb_product.created_at` 保留 GoAuto 首次入库审计时间。来源值缺失或格式异常时明确报错,不能用订单号或入库时间推断。历史空值不命中日期筛选;迁移后按不超过 31 天且受货运单数量上限约束的区间重同步补齐,先后统计空值数量,失败区间可幂等重跑,仍无法取得来源值的记录保持空值并报告。
- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。
- `productSpec` 自由文本解析:按最后一个逗号切分为颜色/尺码 → 剥离【】备注 → 判断残留分隔符;解析状态分 `success`/`uncertain`/`failed`,永不猜测原文中不存在的颜色或尺码。
@@ -66,7 +70,7 @@ synchronized_at: 2026-09-22T02:56:27Z
- 关联虾皮商品档案:命中存活记录直接关联;命中软删除记录则复活并保留原有人工映射;均不存在时创建最小档案。命中既有档案时只补写为空的参考图与售价,不覆盖人工修改过的标题、店铺和映射。
- 重新解析只读取已保存的原始 JSON,不请求 SYB 接口,永不改写 `RawJSON`;标记 `manuallyConfirmed` 的明细默认跳过重新解析,除非显式指定强制覆盖。
- 人工修正的颜色/尺码视为与解析成功同等可信,同样会合并进虾皮商品档案;标记为已人工确认。
- Admin 商品列表的订单号筛选每行一个、去空去重后精确匹配,最多 100 个且单项最长 64 字符;店铺名称使用包含匹配。店铺、订单号与解析状态之间使用 AND,不再用该筛选框搜索蝦皮商品 ID 或商品标题。
- #353: The Admin SYB product page offers multi-select shops exclusively from managed SYB shops (including disabled shops), up to 50. Selected shops are OR-combined, then AND-combined with order code, parse status, process stage, purchase type, source creation-date and pagination filters. Order-code search accepts up to 100 distinct nonblank exact codes of at most 64 characters each; it does not search Shopee item IDs or titles. Matching uses the same edge-trim/full-width ASCII/case folding as SYB shop sync, without collapsing interior spaces. Legacy API clients can still use shopName substring search, but Admin cannot enter free-form or deleted shops. If a managed shop is renamed, historical products retaining its old name may no longer be found under the new selection; accepted limitation.
- SYB 商品数据本身不保存 PDD 订单号、快递单号、支付或有效订单状态;Admin 的采购任务创建入口位于 SYB 商品列表,任务事实仍写入独立采购域。
@@ -75,6 +79,7 @@ synchronized_at: 2026-09-22T02:56:27Z
- SYB 商品页保留“解析状态”,另按 SYB、蝦皮、PDD、采集任务和采购任务的当前事实动态派生唯一“处理阶段”;不在 `syb_product` 保存容易过期的阶段字段。
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
- #338/#340 后在「已创建任务」之后、「待人工处理」之前依次插入:有效退货匹配(退货待确认 / 已用退货)→ 无需采购(`pdd_purchase_excluded`)。即进行中、已成功及结果不明的采购任务仍优先显示;失败/取消的旧任务不占主阶段时,标记为无需 PDD 采购的行显示「无需采购」。
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
- 一个 PDD 订单号只能属于一个采购任务,该唯一性在采购任务保存路径上全局强制(#241)。人工处理结果未知、取消及 lifecycle 保存路径撞号时返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`,提示订单号已属于哪个任务,由采购员人工核对,不静默覆盖原值。
- 不可逆边界之后的 `order_created` 结果回传是上述规则的例外:此时 PDD 真单已创建,发现订单号已属于其他任务时不回滚、不判失败,而是把任务降级为 `order_result_unknown`,冲突订单号以「读到订单号 X,但该号已属于任务 CG-yy」保存在任务与 attempt 的 `error_message`,`pdd_order_no` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。
@@ -488,6 +493,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;不弱化面板验证、精确选中、地址、价格、任务租约、创建订单边界或禁止支付规则。
@@ -744,3 +751,24 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
### 档口入库码排版字符兼容(#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。后续规格探测、精确选择与最终复核全部照常,缺规格或探测无结果仍失败,不猜测、不新增点击目标或支付。
+33 -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: da26af9e01087c4970b01783a4807c689fd79d51
synchronized_at: 2026-09-29T03:47:57Z
wiki_revision: efd822778c102c9d3c4fd4ea48bda6b59368d698
synchronized_at: 2026-10-05T03:46:26Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -83,12 +83,15 @@ POST /api/admin/v1/syb-products/reparse-batch
PATCH /api/admin/v1/syb-products/{productId}/correction
```
#353 SYB product list shop filter: Admin sends repeated shopIds query parameters (1-50 managed-shop IDs, OR within the set). shopIds cannot be combined with legacy shopName; malformed, missing, or soft-deleted IDs return 422. Selected IDs resolve against the managed SYB shop list, including disabled shops, and are compared with persisted SYB product shop names using the same trim/full-width ASCII/case normalization used by shop sync. Other filters remain AND conditions, with pagination after filtering. Zero matches return an empty list. The legacy shopName substring filter remains available only to older API clients; the Admin UI uses managed IDs only. Renaming a managed shop may leave historical products under its old name unfindable by that shop selection; this limitation is accepted for #353.
`import` 允许已认证的管理员(admin)和采购员(purchaser)调用(#236),仍须通过 Casbin 权限校验;其他角色返回 403。采购员的 POST 权限由既有启动权限对账写入,不需要新增数据库迁移。操作人取已认证 claims,不接受客户端冒名。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
`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[]` 元素,未做任何改写)。
#352 目标契约(待迁移与发布):SYB 商品列表沿用 `createdFrom` / `createdTo` 的 `YYYY-MM-DD` 入参和 Admin「创建时间」标签,但条件改为所属货运单 `t_stock.created` 的 UTC+8 日期,使用左闭右开区间并包含结束日。该条件与店铺、订单号、解析状态、处理阶段、采购类型及分页组合;`sybStockCreatedAt` 是新增可空响应字段,来自 `syb_product.syb_stock_created_at`,不等于 GoAuto 审计字段 `createdAt`。历史来源时间为空者在设置日期条件时不返回,不得回退到 `createdAt`;无日期条件仍可返回。
`reparse` 与 `reparse-batch` 只读取已保存的 `rawJson` 重新执行解析规则,不请求 SYB 接口;请求体 `force` 为 `true` 时才覆盖 `manuallyConfirmed` 的行,默认跳过并在批量结果中标记 `skipped_manual`。批量结果逐条返回 `outcome`(`reparsed` / `skipped_manual` / `unchanged`)与解析状态变化,`ids` 中任何一个不存在都会使整个请求返回错误(与虾皮商品批量删除的"部分成功"语义不同:ID 不存在通常是操作员选错了页面)。
`correction` 提交 `targetColor`、`targetSize`,覆盖解析结果并标记 `manuallyConfirmed=true`;非空的人工修正与解析成功同等可信,会合并进已关联的虾皮商品档案。
@@ -115,9 +118,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 +131,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 +1182,8 @@ Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口
### openSpecPanel 后置滑动兼容与诊断(#238)
> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。
版本边界:Android 0.9.60 / versionCode 73,代码 `58a6c1c`。不修改 JSON schema、能力标识、任务接口或已有快照哈希。`openSpecPanel.swipeAfter` 仍按 direction/count/durationMs/intervalMs 原约束校验;解析成功后不执行该准备性滑动,`waitAfterMs` 保留。其他动作后置滑动沿用旧执行语义。旧 Server 可继续下发原快照;旧 APK 仍按原策略执行,不能将本契约描述当作旧设备已获得兼容。
规格探测与精确选择自行负责按需有界滚动,原始候选、精确点击和选中复核不变。跳过预滑动不作为规格探测成功或订单创建证据。
@@ -1425,3 +1443,11 @@ Web 批量入口冻结原匹配 ID 并顺序调用;HTTP 409 状态冲突跳过
- batch-delete 在既有阻塞状态基础上增加 matching,409 整批拒绝;避免删除正在匹配的记录。Android/采购/付款流程无变化。
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
### openSpecPanel 无滚动目标兼容与诊断(#348)
版本绑定 `6550b9f`,Android 0.9.64 / versionCode 77(分支实现,未合并、安装或发布)。本次没有 JSON schema、动作参数、能力标识、任务接口或规则快照哈希变化。旧 Server 继续下发相同快照;旧 APK 不因本文更新而改变行为。
仅 openSpecPanel.swipeAfter 的 NO_SCROLLABLE_TARGET 可跳过剩余揭示滑动;NON_SCROLLABLE_CONFIRMATION 原例外保持。存在目标而手势失败、窗口 root 不可用,以及其他动作的必需滑动失败,仍返回 RULE_ACTION_FAILED;后续 probeSpecs/selectSpec/最终复核不跳过。
既有本地 GoAutoPurchasePanel 回调增加 ruleSwipe 事件:outcome=skipped_confirmed_panel|skipped_no_scroll_target|failed;panel 为面板枚举,scrollables 为非负计数,reveal 为布尔,失败/无目标 reason=success|no_scrollable_target|root_unavailable|failed 中实际适用的固定值。失败 error message 增加这些标量,原 task/attempt/device/规则快照绑定与结果提交字段不变。不记录节点文本、规格、地址、订单、坐标、树、整屏截图或凭据。
+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/
验证码等任何顺运宝请求之前停止,并且不推进覆盖游标。该过滤只影响后续入库,
+1
View File
@@ -44,6 +44,7 @@ func MigratedModels() []any {
&models.SYBSessionAuthLease{},
&models.SYBShop{},
&models.SYBProductFilter{},
&models.SYBProductFilterRecomputeLog{},
&models.SYBSyncRun{},
&models.YeekeSession{},
&models.YeekeReturnPackage{},
+35
View File
@@ -579,12 +579,47 @@ 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"`
// SYBStockCreatedAt is t_stock.created, not GoAuto's first-import CreatedAt.
SYBStockCreatedAt *time.Time `json:"sybStockCreatedAt,omitempty" gorm:"index"`
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 {
+44 -4
View File
@@ -8,6 +8,7 @@ import (
"math"
"strconv"
"strings"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
@@ -21,9 +22,10 @@ import (
// exist in this repo yet — see the package doc), so callers decode SYB's
// response themselves and hand in only what a detail line requires.
type OrderInput struct {
Code string // SYB stock/list `code`, e.g. 260728TB95MJTQ
StockID uint64 // SYB stock/list internal `id`, e.g. 75104587
ShopName string
Code string // SYB stock/list `code`, e.g. 260728TB95MJTQ
StockID uint64 // SYB stock/list internal `id`, e.g. 75104587
ShopName string
SYBStockCreatedAt *time.Time // SYB stock/list `created`; nil for legacy/manual test inputs
}
// DetailInput mirrors one element of SYB's `details[]` array from
@@ -41,6 +43,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
@@ -124,7 +138,8 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
record := models.SYBProduct{
OrderCode: order.Code, DetailID: detail.ID, StockID: order.StockID, ShopName: order.ShopName,
ShopeeItemID: shopeeItemID, ProductTitle: detail.ProductTitle,
SYBStockCreatedAt: order.SYBStockCreatedAt,
ShopeeItemID: shopeeItemID, ProductTitle: detail.ProductTitle,
TargetColor: parsed.Color, TargetSize: parsed.Size,
Quantity: detail.ProductQty, UnitPriceCent: roundCents(detail.ProductPrice),
ImageURL: buildImageURL(detail.ProductThumb),
@@ -138,12 +153,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.
@@ -158,6 +193,11 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
"quantity": record.Quantity, "unit_price_cent": record.UnitPriceCent, "image_url": record.ImageURL,
"parse_status": record.ParseStatus, "parse_note": record.ParseNote, "raw_json": record.RawJSON,
}
if order.SYBStockCreatedAt != nil {
updates["syb_stock_created_at"] = *order.SYBStockCreatedAt
} else {
record.SYBStockCreatedAt = existing.SYBStockCreatedAt
}
// An identical re-import keeps a valid AI decision. Changed source,
// link, or a newly deterministic parse invalidates it atomically.
preserveAI := existing.AIConfirmed && !existing.ManuallyConfirmed && parsed.Status != models.SYBParseStatusSuccess &&
+12
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"strings"
"testing"
"time"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
@@ -128,11 +129,15 @@ func TestApplyDetailKeepsMultiUnitQuantityAsOneRow(t *testing.T) {
func TestApplyDetailIsIdempotentOnOrderCodeAndDetailID(t *testing.T) {
db := openTestDB(t)
order, detail := realOrder(), realDetailA()
firstSource := time.Date(2026, 9, 27, 23, 59, 59, 0, time.FixedZone("SYB", 8*3600))
order.SYBStockCreatedAt = &firstSource
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatalf("first apply: %v", err)
}
detail.ProductQty = 2 // simulate the source value changing between imports
newSource := firstSource.Add(time.Second)
order.SYBStockCreatedAt = &newSource
second, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatalf("second apply: %v", err)
@@ -143,6 +148,13 @@ func TestApplyDetailIsIdempotentOnOrderCodeAndDetailID(t *testing.T) {
if second.SYBProduct.ID != first.SYBProduct.ID {
t.Fatal("re-import must update the same row, not create a new one")
}
var stored models.SYBProduct
if err := db.First(&stored, first.SYBProduct.ID).Error; err != nil {
t.Fatal(err)
}
if stored.SYBStockCreatedAt == nil || !stored.SYBStockCreatedAt.Equal(newSource) || !stored.CreatedAt.Equal(first.SYBProduct.CreatedAt) {
t.Fatalf("re-import must update source time, preserve GoAuto creation: %+v", stored)
}
var count int64
db.Model(&models.SYBProduct{}).Where("order_code = ? AND detail_id = ?", order.Code, detail.ID).Count(&count)
if count != 1 {
+17 -1
View File
@@ -32,6 +32,22 @@ func (handler Handler) logInternalFailure(c *gin.Context, stage string, err erro
}
func (handler Handler) List(c *gin.Context) {
var shopIDs []uint64
if values, present := c.Request.URL.Query()["shopIds"]; present {
if len(values) == 0 || len(values) > 50 {
writeError(c, invalidRequest("shopIds 数量必须为 1 到 50 个"))
return
}
shopIDs = make([]uint64, 0, len(values))
for _, value := range values {
id, err := strconv.ParseUint(value, 10, 64)
if err != nil || id == 0 {
writeError(c, invalidRequest("shopIds 必须是有效的店铺 ID"))
return
}
shopIDs = append(shopIDs, id)
}
}
page, err := queryInt(c.Query("page"), 1)
if err != nil {
writeError(c, invalidRequest("page 必须是正整数"))
@@ -47,7 +63,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"), ShopIDs: shopIDs, 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)
}
}
+71 -6
View File
@@ -9,6 +9,7 @@ import (
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
"go-admin/app/goauto/sybshop"
"gorm.io/gorm"
)
@@ -42,13 +43,27 @@ func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
type ListRequest struct {
Page, PageSize int
ShopName string
ShopIDs []uint64
OrderCodes []string
ParseStatus string
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"`
@@ -77,12 +92,52 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
return ListResponse{}, err
}
if createdFrom != nil {
query = query.Where("created_at >= ?", *createdFrom)
query = query.Where("syb_stock_created_at >= ?", *createdFrom)
}
if createdTo != nil {
query = query.Where("created_at < ?", *createdTo)
query = query.Where("syb_stock_created_at < ?", *createdTo)
}
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" && len(request.ShopIDs) > 0 {
return ListResponse{}, invalidRequest("shopName 和 shopIds 不能同时使用")
}
if len(request.ShopIDs) > 0 {
if len(request.ShopIDs) > 50 {
return ListResponse{}, invalidRequest("一次最多选择 50 个店铺")
}
selected := make(map[uint64]struct{}, len(request.ShopIDs))
for _, id := range request.ShopIDs {
if id == 0 {
return ListResponse{}, invalidRequest("shopIds 必须是有效的店铺 ID")
}
selected[id] = struct{}{}
}
var shops []models.SYBShop
if err := service.DB.WithContext(ctx).Where("id IN ?", request.ShopIDs).Find(&shops).Error; err != nil {
return ListResponse{}, internalError(err)
}
if len(shops) != len(selected) {
return ListResponse{}, invalidRequest("所选店铺不存在或已删除")
}
shopNames := make(map[string]struct{}, len(shops))
for _, shop := range shops {
shopNames[shop.NormalizedName] = struct{}{}
}
var sourceNames []string
if err := service.DB.WithContext(ctx).Model(&models.SYBProduct{}).Distinct("shop_name").Pluck("shop_name", &sourceNames).Error; err != nil {
return ListResponse{}, internalError(err)
}
matchedNames := make([]string, 0)
for _, name := range sourceNames {
if _, ok := shopNames[sybshop.Normalize(name)]; ok {
matchedNames = append(matchedNames, name)
}
}
if len(matchedNames) == 0 {
query = query.Where("1 = 0")
} else {
query = query.Where("shop_name IN ?", matchedNames)
}
} else if request.ShopName != "" {
if len([]rune(request.ShopName)) > 255 {
return ListResponse{}, invalidRequest("店铺名称不能超过 255 个字符")
}
@@ -105,6 +160,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 {
@@ -156,8 +221,8 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
}
// createdAtRange turns inclusive YYYY-MM-DD bounds into a half-open time
// range. The bounds are interpreted in the server's local timezone, matching
// the timestamps written by GORM for this service.
// range. SYB stock.created is an Asia/Shanghai wall-clock timestamp; use that
// timezone explicitly rather than depending on the server process timezone.
func createdAtRange(from, to string) (*time.Time, *time.Time, error) {
from = strings.TrimSpace(from)
to = strings.TrimSpace(to)
@@ -168,7 +233,7 @@ func createdAtRange(from, to string) (*time.Time, *time.Time, error) {
if value == "" {
return nil, nil
}
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
parsed, err := time.ParseInLocation("2006-01-02", value, sybStockLocation)
if err != nil {
return nil, invalidRequest(label + " 必须是 YYYY-MM-DD")
}
+80 -10
View File
@@ -74,6 +74,57 @@ func TestServiceListFiltersByOrderCodesShopAndParseStatus(t *testing.T) {
}
}
func TestServiceListFiltersByManagedShopIDs(t *testing.T) {
db := openTestDB(t)
orders := []struct{ name, code string }{
{" ABC店 ", "ORDER-A"}, {"第二店", "ORDER-B"}, {"其他店", "ORDER-C"},
}
for i, fixture := range orders {
order := realOrder()
order.Code, order.ShopName, order.StockID = fixture.code, fixture.name, order.StockID+uint64(i)
detail := realDetailA()
detail.ID += uint64(i)
if _, err := sybimport.ApplyDetail(context.Background(), db, order, detail); err != nil {
t.Fatal(err)
}
}
shops := []models.SYBShop{
{DisplayName: "ABC店", NormalizedName: "abc店", Enabled: true},
{DisplayName: "第二店", NormalizedName: "第二店", Enabled: false},
{DisplayName: "未出现店", NormalizedName: "未出现店", Enabled: true},
}
if err := db.Create(&shops).Error; err != nil {
t.Fatal(err)
}
service := sybimport.NewService(db)
result, err := service.List(context.Background(), sybimport.ListRequest{ShopIDs: []uint64{shops[0].ID, shops[1].ID}, OrderCodes: []string{"ORDER-A", "ORDER-B", "ORDER-C"}})
if err != nil || result.Total != 2 {
t.Fatalf("multi-shop OR and order-code AND: result=%+v err=%v", result, err)
}
result, err = service.List(context.Background(), sybimport.ListRequest{ShopIDs: []uint64{shops[2].ID}})
if err != nil || result.Total != 0 {
t.Fatalf("unmatched managed shop must return empty: result=%+v err=%v", result, err)
}
for _, request := range []sybimport.ListRequest{
{ShopIDs: []uint64{shops[0].ID}, ShopName: "ABC"},
{ShopIDs: []uint64{shops[0].ID, 999999}},
{ShopIDs: []uint64{0}},
{ShopIDs: make([]uint64, 51)},
} {
_, err := service.List(context.Background(), request)
if code := serviceErrCode(t, err); code != sybimport.CodeInvalidRequest {
t.Fatalf("request %+v: %s", request, code)
}
}
if err := db.Delete(&shops[0]).Error; err != nil {
t.Fatal(err)
}
_, err = service.List(context.Background(), sybimport.ListRequest{ShopIDs: []uint64{shops[0].ID}})
if code := serviceErrCode(t, err); code != sybimport.CodeInvalidRequest {
t.Fatalf("deleted shop: %s", code)
}
}
func TestServiceListRejectsTooManyOrTooLongOrderCodes(t *testing.T) {
service := sybimport.NewService(openTestDB(t))
tooMany := make([]string, 101)
@@ -127,39 +178,58 @@ func TestServiceListCapsPageSizeAt500(t *testing.T) {
func TestServiceListFiltersByCreatedDateInclusive(t *testing.T) {
db := openTestDB(t)
order := realOrder()
for i, day := range []string{"2026-09-01", "2026-09-02", "2026-09-03"} {
for i, stamp := range []string{"2026-09-27 23:59:59", "2026-09-28 00:00:00", "2026-09-29 23:59:59", "2026-09-30 00:00:00"} {
rowOrder := order
rowOrder.Code = fmt.Sprintf("CREATED-%d", i)
rowOrder.StockID += uint64(i)
sourceCreated, err := time.ParseInLocation("2006-01-02 15:04:05", stamp, time.FixedZone("SYB", 8*60*60))
if err != nil {
t.Fatal(err)
}
rowOrder.SYBStockCreatedAt = &sourceCreated
detail := realDetailA()
detail.ID += uint64(i)
result, err := sybimport.ApplyDetail(context.Background(), db, rowOrder, detail)
if err != nil {
t.Fatal(err)
}
created, err := time.ParseInLocation("2006-01-02", day, time.Local)
if err != nil {
t.Fatal(err)
}
if err := db.Model(&models.SYBProduct{}).Where("id = ?", result.SYBProduct.ID).Update("created_at", created).Error; err != nil {
// Import time must not determine whether the row matches source dates.
imported := time.Date(2026, 9, 28, 0, 5, 0, 0, time.FixedZone("SYB", 8*60*60))
if err := db.Model(&models.SYBProduct{}).Where("id = ?", result.SYBProduct.ID).Update("created_at", imported).Error; err != nil {
t.Fatal(err)
}
}
missing := realOrder()
missing.Code = "CREATED-MISSING"
missing.StockID += 100
missingDetail := realDetailA()
missingDetail.ID += 100
if _, err := sybimport.ApplyDetail(context.Background(), db, missing, missingDetail); err != nil {
t.Fatal(err)
}
service := sybimport.NewService(db)
between, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-01", CreatedTo: "2026-09-02"})
between, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-28", CreatedTo: "2026-09-29"})
if err != nil || between.Total != 2 {
t.Fatalf("inclusive created date range should return two rows, total=%d err=%v", between.Total, err)
}
fromOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-03"})
combined, err := service.List(context.Background(), sybimport.ListRequest{
CreatedFrom: "2026-09-28", CreatedTo: "2026-09-29", ShopName: order.ShopName,
OrderCodes: []string{"CREATED-1", "CREATED-3"}, ParseStatus: models.SYBParseStatusSuccess,
Page: 1, PageSize: 1,
})
if err != nil || combined.Total != 1 || len(combined.Items) != 1 || combined.Items[0].OrderCode != "CREATED-1" {
t.Fatalf("date and existing filters must compose before pagination: %+v err=%v", combined, err)
}
fromOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-30"})
if err != nil || fromOnly.Total != 1 {
t.Fatalf("created-from filter should return one row, total=%d err=%v", fromOnly.Total, err)
}
toOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedTo: "2026-09-01"})
toOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedTo: "2026-09-27"})
if err != nil || toOnly.Total != 1 {
t.Fatalf("created-to filter should return one row, total=%d err=%v", toOnly.Total, err)
}
if _, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-04", CreatedTo: "2026-09-01"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest {
if _, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-30", CreatedTo: "2026-09-27"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest {
t.Fatalf("reversed created date range should be rejected: %v", err)
}
}
@@ -0,0 +1,36 @@
package sybimport_test
import (
"net/http"
"net/http/httptest"
"testing"
"go-admin/app/goauto/sybimport"
"github.com/gin-gonic/gin"
)
func TestListHandlerShopIDsValidation(t *testing.T) {
db := openTestDB(t)
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/syb-products", (sybimport.Handler{DB: db}).List)
for _, target := range []string{
"/syb-products?shopIds=",
"/syb-products?shopIds=abc",
"/syb-products?shopIds=0",
"/syb-products?shopIds=1&shopName=test",
"/syb-products?shopIds=999999",
} {
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, target, nil))
if response.Code != http.StatusUnprocessableEntity {
t.Fatalf("%s: expected 422, got %d %s", target, response.Code, response.Body.String())
}
}
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/syb-products?shopName=legacy", nil))
if response.Code != http.StatusOK {
t.Fatalf("legacy shopName: %d %s", response.Code, response.Body.String())
}
}
+44 -9
View File
@@ -45,6 +45,22 @@ const (
maxSyncDays = 31
)
var sybStockLocation = time.FixedZone("SYB UTC+8", 8*60*60)
// parseSYBStockCreatedAt reads the same t_stock.created value used by SYB's
// date filter. Never derive it from the order code or local import timestamp.
func parseSYBStockCreatedAt(raw map[string]any) (*time.Time, error) {
value, ok := raw["created"].(string)
if !ok || strings.TrimSpace(value) == "" {
return nil, errors.New("SYB 货运单缺少来源创建时间")
}
created, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(value), sybStockLocation)
if err != nil || created.IsZero() {
return nil, errors.New("SYB 货运单来源创建时间格式无效")
}
return &created, nil
}
// SyncReport summarises one sync run.
type SyncReport struct {
From string `json:"from"`
@@ -381,26 +397,38 @@ func validateDetailBatch(requested []int64, details []sybclient.StockDetail) err
// ApplyDetail call and its own transaction — a several-thousand-row sync must
// not sit in one long-held transaction.
func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, detail sybclient.StockDetail, report *SyncReport, sets ...sybproductfilter.RuleSet) error {
if row.ID != detail.ID {
return fmt.Errorf("货运单列表与明细 ID 不一致: %d/%d", row.ID, detail.ID)
}
stockCreated, err := parseSYBStockCreatedAt(row.Raw)
if err != nil {
return fmt.Errorf("货运单 %d: %w", row.ID, err)
}
var filters sybproductfilter.RuleSet
if len(sets) > 0 {
filters = sets[0]
}
order := OrderInput{
Code: detail.Code,
StockID: uint64(detail.ID),
ShopName: stringField(row.Raw, "shopName"),
Code: detail.Code,
StockID: uint64(detail.ID),
ShopName: stringField(row.Raw, "shopName"),
SYBStockCreatedAt: stockCreated,
}
if order.Code == "" {
order.Code = row.Code
}
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 +437,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 +456,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",
+88 -2
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 {
@@ -106,6 +109,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
"id": float64(1000 + i),
"code": fmt.Sprintf("ORD%s%03d", strings.ReplaceAll(date, "-", ""), i),
"shopName": f.shopFor(i),
"created": date + " 10:00:00",
})
}
if f.shortPageAtIndex > 0 && start/pageSize+1 == f.shortPageAtIndex && len(rows) > 0 {
@@ -115,7 +119,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
if ids := f.pageIDs(date, start, len(f.listCalls)); ids != nil {
rows = nil
for _, id := range ids {
rows = append(rows, map[string]any{"id": id, "code": "TEST", "shopName": f.shopFor(0)})
rows = append(rows, map[string]any{"id": id, "code": "TEST", "shopName": f.shopFor(0), "created": date + " 10:00:00"})
}
}
}
@@ -142,7 +146,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),
}},
})
@@ -236,6 +240,27 @@ func TestSyncIsIdempotentAcrossRuns(t *testing.T) {
if count != 5 {
t.Fatalf("重跑不应产生重复行,实际 %d 行", count)
}
var first models.SYBProduct
if err := db.Order("id").First(&first).Error; err != nil {
t.Fatal(err)
}
if first.SYBStockCreatedAt == nil || first.SYBStockCreatedAt.In(sybStockLocation).Format("2006-01-02 15:04:05") != "2026-08-01 10:00:00" {
t.Fatalf("source timestamp not saved from stock list: %+v", first.SYBStockCreatedAt)
}
}
func TestParseSYBStockCreatedAtRequiresSourceProof(t *testing.T) {
for _, raw := range []map[string]any{nil, {}, {"created": ""}, {"created": "2026/09/28"}, {"created": 123}} {
if _, err := parseSYBStockCreatedAt(raw); err == nil {
t.Fatalf("invalid source timestamp accepted: %v", raw)
}
}
for _, stamp := range []string{"2026-09-27 23:59:59", "2026-09-28 00:00:00"} {
got, err := parseSYBStockCreatedAt(map[string]any{"created": stamp})
if err != nil || got.In(sybStockLocation).Format("2006-01-02 15:04:05") != stamp {
t.Fatalf("parse %s: %v %v", stamp, got, err)
}
}
}
// 超过上限必须在**写库之前**失败,否则会留下一半数据还报错。
@@ -466,3 +491,64 @@ func TestSyncRejectsBlankShopOnDetailResponse(t *testing.T) {
report.DetailCount, report.AcceptedCount, report.ShopSkipped)
}
}
// #340: a filter hit no longer skips the row — it is still stored, counted
// (as "marked", same counters as before) and left for the hard PDD exclusion
// guards elsewhere in the pipeline to enforce.
func TestSyncStoresAndMarksFilterHitRowsInsteadOfSkipping(t *testing.T) {
db := newSyncTestDB(t)
if err := db.Create(&models.SYBProductFilter{
Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true,
}).Error; err != nil {
t.Fatalf("seed filter: %v", err)
}
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 2}, detailVariationSku: "档口-99"}
report, err := Sync(context.Background(), db, newSyncClient(t, f),
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
if err != nil {
t.Fatalf("同步失败: %v", err)
}
if report.KeywordFilterSkipped != 2 {
t.Fatalf("expected 2 marked hits, got %d", report.KeywordFilterSkipped)
}
if report.DetailCount != 2 || report.Created != 2 {
t.Fatalf("filter hits must still be stored: detailCount=%d created=%d", report.DetailCount, report.Created)
}
var count int64
db.Model(&models.SYBProduct{}).Where("pdd_purchase_excluded = ?", true).Count(&count)
if count != 2 {
t.Fatalf("expected 2 rows marked excluded, got %d", count)
}
var filter models.SYBProductFilter
if err := db.Where("keyword = ?", "档口").First(&filter).Error; err != nil {
t.Fatalf("reload filter: %v", err)
}
if filter.LastHitCount == nil || *filter.LastHitCount != 2 {
t.Fatalf("expected rule's own hit count to be 2, got %v", filter.LastHitCount)
}
}
func TestSyncNonHitRowsAreNotMarked(t *testing.T) {
db := newSyncTestDB(t)
if err := db.Create(&models.SYBProductFilter{
Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true,
}).Error; err != nil {
t.Fatalf("seed filter: %v", err)
}
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 1}, detailVariationSku: "普通-1"}
report, err := Sync(context.Background(), db, newSyncClient(t, f),
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
if err != nil {
t.Fatalf("同步失败: %v", err)
}
if report.KeywordFilterSkipped != 0 {
t.Fatalf("unexpected marked hits: %d", report.KeywordFilterSkipped)
}
var count int64
db.Model(&models.SYBProduct{}).Where("pdd_purchase_excluded = ?", true).Count(&count)
if count != 0 {
t.Fatalf("no rows should be marked excluded, got %d", count)
}
}
@@ -77,6 +77,46 @@ func (h Handler) SetEnabled(c *gin.Context) {
}
c.JSON(200, gin.H{"code": 200, "data": x})
}
// RecomputePreview and RecomputeExecute implement #340 decision 7's admin-only
// "按当前规则重新计算" action. Both are gated by middleware.RequireRoleKey
// ("admin") at the router, the same admin gate this package already uses for
// Create/SetEnabled/Delete.
func (h Handler) RecomputePreview(c *gin.Context) {
s, ok := h.service(c)
if !ok {
return
}
counts, e := s.RecomputePreview(c.Request.Context())
if e != nil {
writeError(c, e)
return
}
c.JSON(200, gin.H{"code": 200, "data": counts})
}
type recomputeExecuteBody struct {
Fingerprint string `json:"fingerprint"`
}
func (h Handler) RecomputeExecute(c *gin.Context) {
var body recomputeExecuteBody
if !decode(c, &body) {
return
}
s, ok := h.service(c)
if !ok {
return
}
claims := jwt.ExtractClaims(c)
name, _ := claims["nice"].(string)
result, e := s.RecomputeExecute(c.Request.Context(), name, body.Fingerprint)
if e != nil {
writeError(c, e)
return
}
c.JSON(200, gin.H{"code": 200, "data": result})
}
func (h Handler) Delete(c *gin.Context) {
id, ok := idParam(c)
if !ok {
@@ -120,6 +160,8 @@ func writeError(c *gin.Context, e error) {
status = 422
case CodeConflict:
status = 409
case CodeRecomputeStale:
status = 409
case CodeNotFound:
status = 404
case CodeForbidden:
@@ -0,0 +1,423 @@
package sybproductfilter
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"strings"
"time"
"go-admin/app/goauto/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RecomputeCounts is shared by preview and execute (#340 decision 7). The
// preview's counts are a PLAN computed against an unlocked read; the
// execute's counts are what was ACTUALLY written, re-checked row by row
// under lock (phase 3 review fix) — the two normally agree, but execute's
// counts are authoritative when something changed concurrently between the
// two calls (a purchase task created, a return matched, or the same row
// recomputed by another admin).
type RecomputeCounts struct {
ExcludedToPDD int `json:"excludedToPdd"`
PDDToExcluded int `json:"pddToExcluded"`
SkippedHasTask int `json:"skippedHasTask"`
SkippedReturnMatch int `json:"skippedReturnMatch"`
}
type recomputeChange struct {
id uint64
orderCode string
shopeeItemID string
toExcluded bool // true: pdd -> excluded; false: excluded -> pdd
ruleID *uint64
ruleKind, ruleKeyword string
}
// recomputeSourceRow selects only the columns recomputeChanges needs (phase 3
// review item 4) instead of the full syb_product row.
type recomputeSourceRow struct {
ID uint64 `gorm:"column:id"`
OrderCode string `gorm:"column:order_code"`
ShopeeItemID string `gorm:"column:shopee_item_id"`
RawJSON string `gorm:"column:raw_json"`
PDDExcluded bool `gorm:"column:pdd_purchase_excluded"`
}
// RecomputeSample is one preview row (#340 phase 2): up to maxRecomputeSamples
// changes are shown so an admin can sanity-check the run before executing it.
type RecomputeSample struct {
OrderCode string `json:"orderCode"`
ShopeeItemID string `json:"shopeeItemId"`
// Direction is "excluded_to_pdd" or "pdd_to_excluded".
Direction string `json:"direction"`
// RuleKind/RuleKeyword are empty when Direction is excluded_to_pdd (no
// rule matches any more).
RuleKind string `json:"ruleKind,omitempty"`
RuleKeyword string `json:"ruleKeyword,omitempty"`
}
const (
DirectionExcludedToPDD = "excluded_to_pdd"
DirectionPDDToExcluded = "pdd_to_excluded"
maxRecomputeSamples = 20
)
// recomputeChanges computes, against the CURRENT enabled rules, every
// syb_product row whose mark should flip, skipping any row that has ever had
// a purchase task or currently has an active return match — those never
// change (#340 decision 7). This is a PLAN: preview uses it directly, and
// execute uses it as the candidate list, re-checking each row under lock in
// writeRecomputeChanges before actually writing (phase 3 review item 1).
func recomputeChanges(ctx context.Context, tx *gorm.DB) (RecomputeCounts, []recomputeChange, error) {
filters, err := LoadEnabled(ctx, tx)
if err != nil {
return RecomputeCounts{}, nil, err
}
var rows []recomputeSourceRow
if err := tx.WithContext(ctx).Model(&models.SYBProduct{}).
Select("id, order_code, shopee_item_id, raw_json, pdd_purchase_excluded").
Order("id ASC").Find(&rows).Error; err != nil {
return RecomputeCounts{}, nil, err
}
hasTask := make(map[uint64]bool)
var taskSYBIDs []uint64
if err := tx.WithContext(ctx).Table("purchase_task").Distinct("syb_product_id").
Where("syb_product_id IS NOT NULL").Pluck("syb_product_id", &taskSYBIDs).Error; err != nil {
return RecomputeCounts{}, nil, err
}
for _, id := range taskSYBIDs {
hasTask[id] = true
}
activeReturnMatch := make(map[uint64]bool)
var matchedSYBIDs []uint64
if err := tx.WithContext(ctx).Table("return_match").Where("active_syb_product_id IS NOT NULL").Pluck("syb_product_id", &matchedSYBIDs).Error; err != nil {
return RecomputeCounts{}, nil, err
}
for _, id := range matchedSYBIDs {
activeReturnMatch[id] = true
}
counts := RecomputeCounts{}
changes := make([]recomputeChange, 0)
for _, row := range rows {
variation := recomputeVariationSku(row.RawJSON)
rule := filters.Match(variation)
wouldExclude := rule != nil
if wouldExclude == row.PDDExcluded {
continue
}
if hasTask[row.ID] {
counts.SkippedHasTask++
continue
}
if activeReturnMatch[row.ID] {
counts.SkippedReturnMatch++
continue
}
change := recomputeChange{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: wouldExclude}
if wouldExclude {
ruleID := rule.ID
change.ruleID, change.ruleKind, change.ruleKeyword = &ruleID, rule.Kind, rule.Keyword
counts.PDDToExcluded++
} else {
counts.ExcludedToPDD++
}
changes = append(changes, change)
}
return counts, changes, nil
}
// recomputeVariationSku mirrors sybimport.stringField(item.Raw, "variationSku")
// without importing that package (sybimport already imports this one).
func recomputeVariationSku(rawJSON string) string {
if strings.TrimSpace(rawJSON) == "" {
return ""
}
var raw map[string]any
if json.Unmarshal([]byte(rawJSON), &raw) != nil {
return ""
}
value, _ := raw["variationSku"].(string)
return value
}
// recomputeFingerprintEntry is one change's canonical, unambiguous
// representation for hashing (#340 phase 4 review item 1). It is JSON, not
// naive string concatenation: a naive "id:direction:ruleId" (or any other
// delimiter-joined string) can collide between two different plans whenever
// a field's own text can contain the delimiter or vary in length — e.g. a
// rule keyword containing ":" or newlines could make two distinct plans hash
// identically. encoding/json's field ordering for a fixed struct is stable,
// so this is both deterministic and injective for our purposes.
type recomputeFingerprintEntry struct {
ID uint64 `json:"id"`
Direction string `json:"direction"`
RuleID uint64 `json:"ruleId"`
RuleKind string `json:"ruleKind"`
RuleKeyword string `json:"ruleKeyword"`
}
// recomputeFingerprint binds a preview to the exact plan it showed, INCLUDING
// the rule evidence that will be written to excluded_rule_kind/
// excluded_rule_keyword (#340 phase 4 review item 1): two plans that flip the
// exact same id+direction but via a different (or since-edited) rule must
// hash differently, because RecomputeExecute is about to persist exactly
// this rule kind/keyword as this row's excluded_rule_* snapshot — a
// fingerprint that ignored them could let a stale plan through unnoticed
// whenever a rule's keyword/kind changed between preview and execute but the
// set of affected ids/directions happened to stay the same. For the
// excluded_to_pdd direction there is no rule (the row is losing its mark),
// so RuleID/RuleKind/RuleKeyword are left at their zero values, matching what
// gets written (nil/""/"").
//
// It is a sha256 over the JSON-encoded, sorted (by id, then direction) list
// of recomputeFingerprintEntry — sorting the decoded entries themselves
// (not pre-serialized strings) keeps the ordering rule obviously correct
// regardless of how any field is later escaped.
func recomputeFingerprint(changes []recomputeChange) string {
entries := make([]recomputeFingerprintEntry, 0, len(changes))
for _, change := range changes {
entry := recomputeFingerprintEntry{ID: change.id}
if change.toExcluded {
entry.Direction = DirectionPDDToExcluded
if change.ruleID != nil {
entry.RuleID = *change.ruleID
}
entry.RuleKind = change.ruleKind
entry.RuleKeyword = change.ruleKeyword
} else {
entry.Direction = DirectionExcludedToPDD
}
entries = append(entries, entry)
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].ID != entries[j].ID {
return entries[i].ID < entries[j].ID
}
return entries[i].Direction < entries[j].Direction
})
// Marshal errors are impossible here (every field is a plain string/uint64
// with no cycles), so it is safe to ignore the error and hash whatever
// was produced rather than plumb an error return through every caller.
payload, _ := json.Marshal(entries)
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])
}
// RecomputePreviewResult is preview-only: it carries the same RecomputeCounts
// execute would produce absent any race, plus up to maxRecomputeSamples
// sample rows, and the Fingerprint execute requires to prove nothing moved
// between preview and execute.
type RecomputePreviewResult struct {
RecomputeCounts
Samples []RecomputeSample `json:"samples"`
Fingerprint string `json:"fingerprint"`
}
// RecomputePreview is read-only.
func (s *Service) RecomputePreview(ctx context.Context) (RecomputePreviewResult, error) {
counts, changes, err := recomputeChanges(ctx, s.DB)
if err != nil {
return RecomputePreviewResult{}, internal(err)
}
samples := make([]RecomputeSample, 0, maxRecomputeSamples)
for _, change := range changes {
if len(samples) >= maxRecomputeSamples {
break
}
sample := RecomputeSample{OrderCode: change.orderCode, ShopeeItemID: change.shopeeItemID}
if change.toExcluded {
sample.Direction = DirectionPDDToExcluded
sample.RuleKind, sample.RuleKeyword = change.ruleKind, change.ruleKeyword
} else {
sample.Direction = DirectionExcludedToPDD
}
samples = append(samples, sample)
}
return RecomputePreviewResult{RecomputeCounts: counts, Samples: samples, Fingerprint: recomputeFingerprint(changes)}, nil
}
type RecomputeExecuteResult struct {
RecomputeCounts
Operator string `json:"operator"`
}
// countPurchaseTasksLocked and countActiveReturnMatchesLocked are the two
// rechecks writeRecomputeChanges runs after taking the syb_product row lock.
//
// `[必须]` They MUST use a locking read (FOR SHARE), not a plain COUNT(*).
// Production and local are MySQL 8.4 under REPEATABLE-READ, where a plain
// read inside a transaction reuses the snapshot taken at that transaction's
// FIRST read (here, recomputeChanges' own SELECT) — so a purchase_task or
// return_match row committed by another connection AFTER that snapshot is
// invisible to a plain COUNT(*) even after this code has waited for and
// obtained the syb_product row's FOR UPDATE lock. The row lock only
// serializes writers against each other; it does not by itself make a later
// plain read see newer committed data under REPEATABLE-READ. A locking read
// (FOR SHARE is enough since these two only need to observe committed rows,
// not lock them for update) forces MySQL to use a fresh "current read"
// instead of the snapshot, which is exactly what closes the race (verified
// against real MySQL 8.4 with two connections: after the other transaction
// committed a task, plain COUNT returned 0 while COUNT ... FOR SHARE
// correctly returned 1). SQLite — used by this package's tests — drops
// locking clauses entirely (gorm.io/driver/sqlite's "FOR" clause builder is
// a no-op) and has no multi-connection snapshot semantics to reproduce this
// race in the first place, so no SQLite-backed test can catch a regression
// here; see TestRecheckQueriesUseLockingReads below for the SQL-shape test
// that does.
// sybProductRowLockQuery, purchaseTaskLockedQuery and returnMatchLockedQuery
// build (but do not execute) the three locking reads writeRecomputeChanges
// runs per row. They are split out from the count*/lock helpers below purely
// so a test can call db.ToSQL against the exact same query construction the
// production code runs, and assert the FOR UPDATE / FOR SHARE clause is
// actually present in the generated SQL (#340 phase 3 review follow-up).
func sybProductRowLockQuery(tx *gorm.DB) *gorm.DB {
return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate})
}
func purchaseTaskLockedQuery(tx *gorm.DB, sybID uint64) *gorm.DB {
return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthShare}).
Model(&models.PurchaseTask{}).Where("syb_product_id = ?", sybID)
}
func returnMatchLockedQuery(tx *gorm.DB, sybID uint64) *gorm.DB {
return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthShare}).
Table("return_match").Where("syb_product_id = ? AND active_syb_product_id IS NOT NULL", sybID)
}
func countPurchaseTasksLocked(ctx context.Context, tx *gorm.DB, sybID uint64) (int64, error) {
var count int64
err := purchaseTaskLockedQuery(tx.WithContext(ctx), sybID).Count(&count).Error
return count, err
}
func countActiveReturnMatchesLocked(ctx context.Context, tx *gorm.DB, sybID uint64) (int64, error) {
var count int64
err := returnMatchLockedQuery(tx.WithContext(ctx), sybID).Count(&count).Error
return count, err
}
// writeRecomputeChanges is the write phase, kept separate from planning so it
// is independently testable (#340 phase 3 review item 1): for every planned
// change it takes the SAME row lock purchase.Service.create and returnmatch's
// matchOneWithLock take (clause.Locking{Strength:"UPDATE"}), then re-checks
// under that lock — a purchase task or active return match created for the
// row after planning skips it (counted the same as a plan-time skip), and a
// row whose mark already matches the target (e.g. flipped by a concurrent
// recompute) is left alone and not counted at all. Only rows that still need
// the change are written. The returned counts are what was ACTUALLY done.
func writeRecomputeChanges(ctx context.Context, tx *gorm.DB, planned []recomputeChange) (RecomputeCounts, error) {
actual := RecomputeCounts{}
now := time.Now().UTC()
for _, change := range planned {
var locked models.SYBProduct
if err := sybProductRowLockQuery(tx.WithContext(ctx)).First(&locked, change.id).Error; err != nil {
return RecomputeCounts{}, err
}
taskCount, err := countPurchaseTasksLocked(ctx, tx, change.id)
if err != nil {
return RecomputeCounts{}, err
}
if taskCount > 0 {
actual.SkippedHasTask++
continue
}
matchCount, err := countActiveReturnMatchesLocked(ctx, tx, change.id)
if err != nil {
return RecomputeCounts{}, err
}
if matchCount > 0 {
actual.SkippedReturnMatch++
continue
}
if locked.PDDExcluded == change.toExcluded {
// Already at the target (e.g. another recompute got there first
// under its own lock) — nothing to do, and this is not a
// "skipped" outcome in the reported sense since there was never
// a real conflict blocking it.
continue
}
updates := map[string]any{"pdd_purchase_excluded": change.toExcluded}
if change.toExcluded {
updates["excluded_rule_id"] = change.ruleID
updates["excluded_rule_kind"] = change.ruleKind
updates["excluded_rule_keyword"] = change.ruleKeyword
updates["excluded_at"] = now
} else {
updates["excluded_rule_id"] = nil
updates["excluded_rule_kind"] = ""
updates["excluded_rule_keyword"] = ""
updates["excluded_at"] = nil
}
if err := tx.WithContext(ctx).Model(&models.SYBProduct{}).Where("id = ?", change.id).Updates(updates).Error; err != nil {
return RecomputeCounts{}, err
}
if change.toExcluded {
actual.PDDToExcluded++
} else {
actual.ExcludedToPDD++
}
}
return actual, nil
}
// RecomputeExecute re-validates the plan against a fingerprint the caller
// must have gotten from a RecomputePreview call (#340 phase 3 review item 2):
// if the current plan's fingerprint no longer matches — the underlying data
// or the enabled rules changed since that preview — nothing is written and
// CodeRecomputeStale is returned. Otherwise every planned change is written
// through writeRecomputeChanges under its own per-row lock, and one audit log
// row is written in the same transaction using the ACTUAL counts.
func (s *Service) RecomputeExecute(ctx context.Context, operator, fingerprint string) (RecomputeExecuteResult, error) {
fingerprint = strings.TrimSpace(fingerprint)
if fingerprint == "" {
return RecomputeExecuteResult{}, invalid("fingerprint 不能为空,请先调用预览接口")
}
var result RecomputeExecuteResult
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
planCounts, changes, err := recomputeChanges(ctx, tx)
if err != nil {
return err
}
if recomputeFingerprint(changes) != fingerprint {
return &ServiceError{Code: CodeRecomputeStale, Message: "数据或规则已变化,请重新预览后再执行"}
}
written, err := writeRecomputeChanges(ctx, tx, changes)
if err != nil {
return err
}
// The ACTUAL, reported counts combine what planning already knew was
// ineligible (rows never even handed to the write phase) with what
// the write phase itself caught under lock — so a plan-time skip and
// a write-time race both show up, and Excluded/PDD counts are always
// exactly what got written (#340 phase 3 review item 1c).
actual := RecomputeCounts{
ExcludedToPDD: written.ExcludedToPDD,
PDDToExcluded: written.PDDToExcluded,
SkippedHasTask: planCounts.SkippedHasTask + written.SkippedHasTask,
SkippedReturnMatch: planCounts.SkippedReturnMatch + written.SkippedReturnMatch,
}
log := models.SYBProductFilterRecomputeLog{
Operator: strings.TrimSpace(operator), ExcludedToPDD: actual.ExcludedToPDD,
PDDToExcluded: actual.PDDToExcluded, SkippedHasTask: actual.SkippedHasTask,
SkippedReturnMatch: actual.SkippedReturnMatch,
}
if err := tx.Create(&log).Error; err != nil {
return err
}
result = RecomputeExecuteResult{RecomputeCounts: actual, Operator: log.Operator}
return nil
})
if err != nil {
if se, ok := err.(*ServiceError); ok {
return RecomputeExecuteResult{}, se
}
return RecomputeExecuteResult{}, internal(err)
}
return result, nil
}
@@ -0,0 +1,89 @@
package sybproductfilter
import (
"strings"
"testing"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// mysqlDryRunDB opens a gorm session against the MySQL dialector with
// DryRun+DisableAutomaticPing, so no real network connection is ever made
// (sql.Open is lazy and gorm skips the startup ping) but the SQL gorm would
// send to a real MySQL 8.4 server can still be inspected via db.ToSQL.
//
// `[必须]` This must be the MySQL dialector, not SQLite: gorm.io/driver/
// sqlite's own "FOR" clause builder silently drops clause.Locking entirely
// (SQLite has no row-level locking), so a SQLite-backed test would show
// these queries with no FOR clause at all regardless of whether the
// production code asks for one — it would pass even with the bug this test
// exists to catch. Only a MySQL-dialect SQL string proves the FOR UPDATE /
// FOR SHARE clauses are actually being sent.
func mysqlDryRunDB(t *testing.T) *gorm.DB {
t.Helper()
// SkipInitializeWithVersion is required, not just DisableAutomaticPing:
// gorm's MySQL dialector otherwise runs `SELECT VERSION()` against the
// DSN's ConnPool during Initialize (Open), before DryRun/ping settings
// even come into play, to decide version-gated feature flags such as
// DontSupportForShareClause. With it set, sql.Open's lazy connection
// pool is never dialed at all.
db, err := gorm.Open(mysql.New(mysql.Config{
DSN: "user:pass@tcp(127.0.0.1:3306)/goauto_test?parseTime=true", SkipInitializeWithVersion: true,
}), &gorm.Config{DryRun: true, DisableAutomaticPing: true})
if err != nil {
t.Fatalf("open dry-run mysql session: %v", err)
}
return db
}
// TestRecheckQueriesUseLockingReads pins the exact SQL shape behind #340
// phase 3's real fix: writeRecomputeChanges' syb_product row lock must be
// FOR UPDATE, and its two rechecks (purchase_task, return_match) must be
// FOR SHARE — a plain COUNT(*) for either recheck is invisible to a
// transaction's already-taken REPEATABLE-READ snapshot on real MySQL 8.4
// even after the row's FOR UPDATE lock is granted, which is exactly the race
// this test guards against ever regressing to (verified against a real
// MySQL 8.4 server with two connections: a purchase_task committed by the
// other connection after the snapshot was invisible to plain COUNT(*), but
// visible to COUNT(*) ... FOR SHARE). SQLite, which every other test in this
// package runs against, cannot reproduce any of this: it has no
// multi-connection snapshot isolation and gorm's SQLite driver drops locking
// clauses outright, so this test intentionally talks MySQL SQL shape only,
// never a real database.
func TestRecheckQueriesUseLockingReads(t *testing.T) {
db := mysqlDryRunDB(t)
rowLockSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
var dest map[string]any
return sybProductRowLockQuery(tx).Table("syb_product").Where("id = ?", uint64(1)).Find(&dest)
})
t.Logf("ROW LOCK SQL: %s", rowLockSQL)
if !strings.Contains(rowLockSQL, "FOR UPDATE") {
t.Fatalf("expected the syb_product row lock to be FOR UPDATE, got SQL: %s", rowLockSQL)
}
var taskCount int64
taskSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
return purchaseTaskLockedQuery(tx, 1).Count(&taskCount)
})
t.Logf("PURCHASE TASK RECHECK SQL: %s", taskSQL)
if !strings.Contains(taskSQL, "FOR SHARE") {
t.Fatalf("expected the purchase_task recheck to be a locking (FOR SHARE) read, got SQL: %s", taskSQL)
}
if !strings.Contains(taskSQL, "syb_product_id") {
t.Fatalf("expected the purchase_task recheck to filter by syb_product_id, got SQL: %s", taskSQL)
}
var matchCount int64
matchSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
return returnMatchLockedQuery(tx, 1).Count(&matchCount)
})
t.Logf("RETURN MATCH RECHECK SQL: %s", matchSQL)
if !strings.Contains(matchSQL, "FOR SHARE") {
t.Fatalf("expected the return_match recheck to be a locking (FOR SHARE) read, got SQL: %s", matchSQL)
}
if !strings.Contains(matchSQL, "active_syb_product_id IS NOT NULL") {
t.Fatalf("expected the return_match recheck to filter on an active match, got SQL: %s", matchSQL)
}
}
@@ -0,0 +1,351 @@
package sybproductfilter
import (
"context"
"database/sql"
"fmt"
"os"
"regexp"
"testing"
"time"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
_ "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
// dsnPathReplacer swaps the database name in a Go MySQL DSN of the form
// user:pass@tcp(host:port)/dbname?params — used to connect first to the
// server (no specific throwaway database yet) and then to the freshly
// created throwaway database.
var dsnPathReplacer = regexp.MustCompile(`^(.*/)([^/?]*)(\?.*)?$`)
func dsnWithDatabase(dsn, dbName string) string {
if dsnPathReplacer.MatchString(dsn) {
return dsnPathReplacer.ReplaceAllString(dsn, "${1}"+dbName+"${3}")
}
return dsn
}
// setupMySQLIntegrationDB is #340 phase 4 review item 2's throwaway-database
// harness: it never touches an existing database. GOAUTO_IT_MYSQL_DSN must
// point at a MySQL SERVER (any connectable path, e.g. the system "mysql"
// database) with permission to CREATE/DROP DATABASE; a uniquely named
// zz_goauto_it_340_<random> database is created, migrated, and guaranteed
// dropped via t.Cleanup even if the test fails or panics.
func setupMySQLIntegrationDB(t *testing.T) (dsn string, dbName string) {
t.Helper()
baseDSN := os.Getenv("GOAUTO_IT_MYSQL_DSN")
if baseDSN == "" {
t.Skip("GOAUTO_IT_MYSQL_DSN not set; skipping MySQL concurrency integration test")
}
admin, err := sql.Open("mysql", baseDSN)
if err != nil {
t.Fatalf("open admin connection: %v", err)
}
if err := admin.Ping(); err != nil {
admin.Close()
t.Fatalf("ping MySQL server: %v", err)
}
dbName = fmt.Sprintf("zz_goauto_it_340_%d", time.Now().UnixNano())
if _, err := admin.Exec("CREATE DATABASE `" + dbName + "`"); err != nil {
admin.Close()
t.Fatalf("create throwaway database %s: %v", dbName, err)
}
t.Cleanup(func() {
defer admin.Close()
if _, err := admin.Exec("DROP DATABASE IF EXISTS `" + dbName + "`"); err != nil {
t.Errorf("failed to drop throwaway database %s (manual cleanup required): %v", dbName, err)
}
})
dsn = dsnWithDatabase(baseDSN, dbName)
gdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open throwaway database: %v", err)
}
if err := migrations.Migrate(gdb); err != nil {
t.Fatalf("migrate throwaway database: %v", err)
}
return dsn, dbName
}
func newMySQLIntegrationConn(t *testing.T, dsn string) *gorm.DB {
t.Helper()
conn, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open MySQL connection: %v", err)
}
return conn
}
// TestRecomputeConcurrentPurchaseTaskUnderRealMySQL is #340 phase 4 review
// item 2: it reproduces, against a real MySQL server under REPEATABLE-READ,
// the exact race writeRecomputeChanges' locking rechecks exist to close.
//
// Timeline:
// 1. Connection A begins a transaction and runs the planning step
// (recomputeChanges) — this is A's FIRST read, so it fixes A's
// REPEATABLE-READ snapshot with zero purchase_task rows.
// 2. Connection B, concurrently, takes the SAME row's FOR UPDATE lock,
// confirmed via a channel before A is allowed to proceed.
// 3. A's write phase (writeRecomputeChanges) is started in a goroutine; it
// must BLOCK trying to take the same FOR UPDATE lock B already holds —
// the test asserts A has NOT finished after a wait window, proving a
// real block happened (not just a fast, uncontended lock grant).
// 4. B inserts a purchase_task for the row and commits, releasing the lock.
// 5. A's write phase unblocks, re-checks purchase_task under lock, and must
// see B's now-committed row and skip — this only holds because the
// recheck is a locking (FOR SHARE) read; a plain COUNT(*) would still be
// bound to A's step-1 snapshot (zero rows) and would wrongly write.
func TestRecomputeConcurrentPurchaseTaskUnderRealMySQL(t *testing.T) {
dsn, _ := setupMySQLIntegrationDB(t)
seedConn := newMySQLIntegrationConn(t, dsn)
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
if err := seedConn.Create(&rule).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{
OrderCode: "ORD-IT-TASK", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`,
}
if err := seedConn.Create(&row).Error; err != nil {
t.Fatal(err)
}
// purchase_task.pdd_product_id has a real FK (unlike this package's
// SQLite-backed tests, which don't enable foreign key enforcement) —
// MySQL requires an actual pdd_product row to reference.
pdd := models.PDDProduct{GoodsID: "IT-PDD-1", URL: "https://example.invalid/it", SpecsJSON: "[]"}
if err := seedConn.Create(&pdd).Error; err != nil {
t.Fatal(err)
}
connA := newMySQLIntegrationConn(t, dsn)
connB := newMySQLIntegrationConn(t, dsn)
ctx := context.Background()
txA := connA.Begin()
// Guard against ANY early return (t.Fatalf, panic) leaving txA open: an
// abandoned open transaction holds a connection into this throwaway
// database and blocks the DROP DATABASE cleanup indefinitely. Rollback
// on an already-committed transaction is a harmless no-op error, which
// is why the plain Commit() path below intentionally does not disable
// this cleanup.
t.Cleanup(func() { txA.Rollback() })
_, planned, err := recomputeChanges(ctx, txA)
if err != nil {
t.Fatalf("plan: %v", err)
}
if len(planned) != 1 || planned[0].id != row.ID {
t.Fatalf("expected exactly the seeded row to be planned, got %+v", planned)
}
txB := connB.Begin()
t.Cleanup(func() { txB.Rollback() })
bHoldingLock := make(chan struct{})
bCanCommit := make(chan struct{})
bDone := make(chan error, 1)
go func() {
var locked models.SYBProduct
if err := txB.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
First(&locked, row.ID).Error; err != nil {
bDone <- fmt.Errorf("B lock row: %w", err)
return
}
close(bHoldingLock)
<-bCanCommit
task := models.PurchaseTask{
SYBProductID: &row.ID, PDDProductID: pdd.ID, Quantity: 1, CreateRequestID: "it-race-task",
Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive,
TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}",
}
if err := txB.Create(&task).Error; err != nil {
bDone <- fmt.Errorf("B insert task: %w", err)
return
}
bDone <- txB.Commit().Error
}()
select {
case <-bHoldingLock:
case err := <-bDone:
t.Fatalf("B failed before taking the row lock: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for B to take the row lock")
}
aDone := make(chan struct{})
var aActual RecomputeCounts
var aErr error
go func() {
aActual, aErr = writeRecomputeChanges(ctx, txA, planned)
close(aDone)
}()
// A must still be blocked on B's row lock at this point — this is the
// test's proof that a real MySQL row lock, not just program logic, is
// what's being exercised.
select {
case <-aDone:
t.Fatal("A's write phase returned before B committed — it should have blocked on the row's FOR UPDATE lock")
case <-time.After(300 * time.Millisecond):
}
close(bCanCommit)
if err := <-bDone; err != nil {
t.Fatalf("B failed: %v", err)
}
select {
case <-aDone:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for A's write phase to unblock after B committed")
}
if aErr != nil {
t.Fatalf("A's write phase failed: %v", aErr)
}
if err := txA.Commit().Error; err != nil {
t.Fatalf("commit A: %v", err)
}
if aActual.SkippedHasTask != 1 || aActual.PDDToExcluded != 0 {
t.Fatalf("expected A to skip the row for the concurrently-created task, got %+v", aActual)
}
var reloaded models.SYBProduct
if err := seedConn.First(&reloaded, row.ID).Error; err != nil {
t.Fatal(err)
}
if reloaded.PDDExcluded {
t.Fatalf("row must not have been marked excluded — the concurrent task should have blocked it: %+v", reloaded)
}
}
// TestRecomputeConcurrentReturnMatchUnderRealMySQL is the same scenario as
// TestRecomputeConcurrentPurchaseTaskUnderRealMySQL, with an active
// return_match row instead of a purchase_task as B's concurrent write.
func TestRecomputeConcurrentReturnMatchUnderRealMySQL(t *testing.T) {
dsn, _ := setupMySQLIntegrationDB(t)
seedConn := newMySQLIntegrationConn(t, dsn)
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
if err := seedConn.Create(&rule).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{
OrderCode: "ORD-IT-MATCH", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`,
}
if err := seedConn.Create(&row).Error; err != nil {
t.Fatal(err)
}
yeekeItem := models.YeekeReturnItem{PackageID: 0, ExternalKey: "it-race-return", ItemID: "1", VariationName: "档口-1", LastSyncedAt: time.Now()}
// A package row is required by the return_match/yeeke schema's foreign
// key; seed a minimal one.
pkg := models.YeekeReturnPackage{ExternalID: "it-race-pkg", OrderSN: "IT-ORD", TrackingNo: "IT-TRK", LastSyncedAt: time.Now()}
if err := seedConn.Create(&pkg).Error; err != nil {
t.Fatal(err)
}
yeekeItem.PackageID = pkg.ID
if err := seedConn.Create(&yeekeItem).Error; err != nil {
t.Fatal(err)
}
connA := newMySQLIntegrationConn(t, dsn)
connB := newMySQLIntegrationConn(t, dsn)
ctx := context.Background()
txA := connA.Begin()
// See TestRecomputeConcurrentPurchaseTaskUnderRealMySQL for why this
// unconditional cleanup is necessary regardless of the Commit() below.
t.Cleanup(func() { txA.Rollback() })
_, planned, err := recomputeChanges(ctx, txA)
if err != nil {
t.Fatalf("plan: %v", err)
}
if len(planned) != 1 || planned[0].id != row.ID {
t.Fatalf("expected exactly the seeded row to be planned, got %+v", planned)
}
txB := connB.Begin()
t.Cleanup(func() { txB.Rollback() })
bHoldingLock := make(chan struct{})
bCanCommit := make(chan struct{})
bDone := make(chan error, 1)
go func() {
var locked models.SYBProduct
if err := txB.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
First(&locked, row.ID).Error; err != nil {
bDone <- fmt.Errorf("B lock row: %w", err)
return
}
close(bHoldingLock)
<-bCanCommit
match := models.ReturnMatch{
SYBProductID: row.ID, YeekeReturnItemID: yeekeItem.ID,
ActiveSYBProductID: &row.ID, Status: models.ReturnMatchStatusMatched, MatchedAt: time.Now(),
}
if err := txB.Create(&match).Error; err != nil {
bDone <- fmt.Errorf("B insert match: %w", err)
return
}
bDone <- txB.Commit().Error
}()
select {
case <-bHoldingLock:
case err := <-bDone:
t.Fatalf("B failed before taking the row lock: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for B to take the row lock")
}
aDone := make(chan struct{})
var aActual RecomputeCounts
var aErr error
go func() {
aActual, aErr = writeRecomputeChanges(ctx, txA, planned)
close(aDone)
}()
select {
case <-aDone:
t.Fatal("A's write phase returned before B committed — it should have blocked on the row's FOR UPDATE lock")
case <-time.After(300 * time.Millisecond):
}
close(bCanCommit)
if err := <-bDone; err != nil {
t.Fatalf("B failed: %v", err)
}
select {
case <-aDone:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for A's write phase to unblock after B committed")
}
if aErr != nil {
t.Fatalf("A's write phase failed: %v", aErr)
}
if err := txA.Commit().Error; err != nil {
t.Fatalf("commit A: %v", err)
}
if aActual.SkippedReturnMatch != 1 || aActual.PDDToExcluded != 0 {
t.Fatalf("expected A to skip the row for the concurrently-created return match, got %+v", aActual)
}
var reloaded models.SYBProduct
if err := seedConn.First(&reloaded, row.ID).Error; err != nil {
t.Fatal(err)
}
if reloaded.PDDExcluded {
t.Fatalf("row must not have been marked excluded — the concurrent return match should have blocked it: %+v", reloaded)
}
}
@@ -0,0 +1,496 @@
package sybproductfilter
import (
"context"
"fmt"
"testing"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func TestRecomputePreviewMatchesExecute(t *testing.T) {
db := testDB(t)
// A row that currently needs a PDD purchase but now matches a keyword rule.
pddToExcluded := models.SYBProduct{
OrderCode: "ORD-1", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`,
}
if err := db.Create(&pddToExcluded).Error; err != nil {
t.Fatal(err)
}
// A row currently marked excluded whose rule no longer matches.
excludedToPDD := models.SYBProduct{
OrderCode: "ORD-2", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"普通-2"}`,
PDDExcluded: true,
}
if err := db.Create(&excludedToPDD).Error; err != nil {
t.Fatal(err)
}
// A row that should flip, but has a purchase task -> must be skipped.
hasTask := models.SYBProduct{
OrderCode: "ORD-3", DetailID: 3, StockID: 3, ShopeeItemID: "3", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-3"}`,
}
if err := db.Create(&hasTask).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.PurchaseTask{SYBProductID: &hasTask.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "req-3", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil {
t.Fatal(err)
}
// A row that should flip, but has an active return match -> must be skipped.
hasMatch := models.SYBProduct{
OrderCode: "ORD-4", DetailID: 4, StockID: 4, ShopeeItemID: "4", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-4"}`,
}
if err := db.Create(&hasMatch).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.ReturnMatch{SYBProductID: hasMatch.ID, YeekeReturnItemID: 1, ActiveSYBProductID: &hasMatch.ID, Status: models.ReturnMatchStatusMatched}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
preview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if preview.PDDToExcluded != 1 || preview.ExcludedToPDD != 1 || preview.SkippedHasTask != 1 || preview.SkippedReturnMatch != 1 {
t.Fatalf("unexpected preview counts: %+v", preview)
}
result, err := s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint)
if err != nil {
t.Fatal(err)
}
if result.RecomputeCounts != preview.RecomputeCounts {
t.Fatalf("execute counts must match preview: preview=%+v execute=%+v", preview.RecomputeCounts, result.RecomputeCounts)
}
if len(preview.Samples) != 2 {
t.Fatalf("expected 2 sample rows, got %+v", preview.Samples)
}
var sawPDDToExcluded, sawExcludedToPDD bool
for _, sample := range preview.Samples {
switch sample.Direction {
case DirectionPDDToExcluded:
sawPDDToExcluded = true
if sample.OrderCode != "ORD-1" || sample.RuleKeyword != "档口" {
t.Fatalf("unexpected pdd_to_excluded sample: %+v", sample)
}
case DirectionExcludedToPDD:
sawExcludedToPDD = true
if sample.OrderCode != "ORD-2" || sample.RuleKind != "" || sample.RuleKeyword != "" {
t.Fatalf("unexpected excluded_to_pdd sample: %+v", sample)
}
default:
t.Fatalf("unexpected direction: %+v", sample)
}
}
if !sawPDDToExcluded || !sawExcludedToPDD {
t.Fatalf("expected both directions represented: %+v", preview.Samples)
}
var reloadedPDDToExcluded, reloadedExcludedToPDD, reloadedHasTask, reloadedHasMatch models.SYBProduct
db.First(&reloadedPDDToExcluded, pddToExcluded.ID)
db.First(&reloadedExcludedToPDD, excludedToPDD.ID)
db.First(&reloadedHasTask, hasTask.ID)
db.First(&reloadedHasMatch, hasMatch.ID)
if !reloadedPDDToExcluded.PDDExcluded || reloadedPDDToExcluded.ExcludedRuleKeyword != "档口" {
t.Fatalf("expected row 1 to become excluded: %+v", reloadedPDDToExcluded)
}
if reloadedExcludedToPDD.PDDExcluded || reloadedExcludedToPDD.ExcludedRuleID != nil {
t.Fatalf("expected row 2 to become un-excluded: %+v", reloadedExcludedToPDD)
}
if reloadedHasTask.PDDExcluded {
t.Fatalf("row with a purchase task must never change: %+v", reloadedHasTask)
}
if reloadedHasMatch.PDDExcluded {
t.Fatalf("row with an active return match must never change: %+v", reloadedHasMatch)
}
var logs []models.SYBProductFilterRecomputeLog
if err := db.Find(&logs).Error; err != nil {
t.Fatal(err)
}
if len(logs) != 1 || logs[0].Operator != "admin1" || logs[0].PDDToExcluded != 1 || logs[0].ExcludedToPDD != 1 {
t.Fatalf("expected exactly one audit log row matching the counts: %+v", logs)
}
// Preview and execute must still agree on a no-op run.
secondPreview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if secondPreview.PDDToExcluded != 0 || secondPreview.ExcludedToPDD != 0 {
t.Fatalf("expected a no-op second preview, got %+v", secondPreview)
}
}
func TestRecomputePreviewCapsSamplesAt20(t *testing.T) {
db := testDB(t)
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 25; i++ {
row := models.SYBProduct{
OrderCode: fmt.Sprintf("ORD-CAP-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1),
ShopeeItemID: fmt.Sprintf("%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess,
RawJSON: `{"variationSku":"档口-x"}`,
}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
s := NewService(db)
preview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if preview.PDDToExcluded != 25 {
t.Fatalf("expected 25 counted changes, got %d", preview.PDDToExcluded)
}
if len(preview.Samples) != 20 {
t.Fatalf("expected samples capped at 20, got %d", len(preview.Samples))
}
}
func TestRecomputeExecuteRejectsStaleFingerprint(t *testing.T) {
db := testDB(t)
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{OrderCode: "ORD-STALE", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
preview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if preview.PDDToExcluded != 1 {
t.Fatalf("unexpected preview: %+v", preview)
}
// Data changes after the preview: a second row appears that would also
// flip, so the plan's fingerprint is now stale.
row2 := models.SYBProduct{OrderCode: "ORD-STALE-2", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-2"}`}
if err := db.Create(&row2).Error; err != nil {
t.Fatal(err)
}
_, err = s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint)
if err == nil {
t.Fatalf("expected stale fingerprint to be rejected")
}
se, ok := err.(*ServiceError)
if !ok || se.Code != CodeRecomputeStale {
t.Fatalf("expected CodeRecomputeStale, got %v", err)
}
var reloaded, reloaded2 models.SYBProduct
db.First(&reloaded, row.ID)
db.First(&reloaded2, row2.ID)
if reloaded.PDDExcluded || reloaded2.PDDExcluded {
t.Fatalf("stale execute must write nothing: %+v %+v", reloaded, reloaded2)
}
var logCount int64
db.Model(&models.SYBProductFilterRecomputeLog{}).Count(&logCount)
if logCount != 0 {
t.Fatalf("stale execute must not write an audit log, got %d", logCount)
}
freshPreview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if freshPreview.PDDToExcluded != 2 {
t.Fatalf("expected fresh preview to see both rows, got %+v", freshPreview)
}
if _, err := s.RecomputeExecute(context.Background(), "admin1", freshPreview.Fingerprint); err != nil {
t.Fatalf("fresh fingerprint should be accepted: %v", err)
}
}
func TestRecomputeExecuteRejectsEmptyFingerprint(t *testing.T) {
db := testDB(t)
s := NewService(db)
if _, err := s.RecomputeExecute(context.Background(), "admin1", ""); err == nil {
t.Fatalf("expected empty fingerprint to be rejected")
}
}
// TestWriteRecomputeChangesSkipsRowThatGotAPurchaseTaskConcurrently is #340
// phase 3 review item 1(a): a purchase task created for a planned row AFTER
// planning but BEFORE the write phase must cause that row to be skipped
// (counted as SkippedHasTask), not written.
func TestWriteRecomputeChangesSkipsRowThatGotAPurchaseTaskConcurrently(t *testing.T) {
db := testDB(t)
row := models.SYBProduct{OrderCode: "ORD-RACE-TASK", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
ruleID := uint64(9)
planned := []recomputeChange{{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"}}
if err := db.Create(&models.PurchaseTask{SYBProductID: &row.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "race-task", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil {
t.Fatal(err)
}
var actual RecomputeCounts
err := db.Transaction(func(tx *gorm.DB) error {
var writeErr error
actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned)
return writeErr
})
if err != nil {
t.Fatal(err)
}
if actual.SkippedHasTask != 1 || actual.PDDToExcluded != 0 {
t.Fatalf("expected the row to be skipped for having a task, got %+v", actual)
}
var reloaded models.SYBProduct
db.First(&reloaded, row.ID)
if reloaded.PDDExcluded {
t.Fatalf("row must not have been marked excluded: %+v", reloaded)
}
}
// TestWriteRecomputeChangesSkipsRowWithConcurrentReturnMatch is item 1(b):
// same race, but with an active return match instead of a purchase task.
func TestWriteRecomputeChangesSkipsRowWithConcurrentReturnMatch(t *testing.T) {
db := testDB(t)
row := models.SYBProduct{OrderCode: "ORD-RACE-MATCH", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
ruleID := uint64(9)
planned := []recomputeChange{{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"}}
if err := db.Create(&models.ReturnMatch{SYBProductID: row.ID, YeekeReturnItemID: 1, ActiveSYBProductID: &row.ID, Status: models.ReturnMatchStatusMatched}).Error; err != nil {
t.Fatal(err)
}
var actual RecomputeCounts
err := db.Transaction(func(tx *gorm.DB) error {
var writeErr error
actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned)
return writeErr
})
if err != nil {
t.Fatal(err)
}
if actual.SkippedReturnMatch != 1 || actual.PDDToExcluded != 0 {
t.Fatalf("expected the row to be skipped for an active return match, got %+v", actual)
}
}
// TestWriteRecomputeChangesReturnsCountsMatchingActualWrites is item 1(c):
// given a plan with one row that should write cleanly and one that races
// with a concurrently-created purchase task, writeRecomputeChanges' returned
// counts must equal exactly what it actually wrote — a real update for the
// clean row, and a skip (not a write) for the raced one. This is the same
// property RecomputeExecute relies on for its response and audit log
// (verified end-to-end, without a race, by TestRecomputePreviewMatchesExecute
// above; a race inside one execute call always changes the plan itself, so
// it surfaces as a fingerprint mismatch — see
// TestRecomputeExecuteRejectsStaleFingerprint — rather than reaching this
// path in a real request).
func TestWriteRecomputeChangesReturnsCountsMatchingActualWrites(t *testing.T) {
db := testDB(t)
rowA := models.SYBProduct{OrderCode: "ORD-ACTUAL-A", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
rowB := models.SYBProduct{OrderCode: "ORD-ACTUAL-B", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-2"}`}
if err := db.Create(&rowA).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&rowB).Error; err != nil {
t.Fatal(err)
}
ruleID := uint64(9)
planned := []recomputeChange{
{id: rowA.ID, orderCode: rowA.OrderCode, shopeeItemID: rowA.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"},
{id: rowB.ID, orderCode: rowB.OrderCode, shopeeItemID: rowB.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"},
}
// Simulate a purchase task created for rowB strictly between planning
// and this write phase call.
if err := db.Create(&models.PurchaseTask{SYBProductID: &rowB.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "actual-task", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil {
t.Fatal(err)
}
var actual RecomputeCounts
err := db.Transaction(func(tx *gorm.DB) error {
var writeErr error
actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned)
return writeErr
})
if err != nil {
t.Fatal(err)
}
if actual.PDDToExcluded != 1 || actual.SkippedHasTask != 1 {
t.Fatalf("expected one written and one skipped, got %+v", actual)
}
var reloadedA, reloadedB models.SYBProduct
db.First(&reloadedA, rowA.ID)
db.First(&reloadedB, rowB.ID)
if !reloadedA.PDDExcluded {
t.Fatalf("rowA should have been marked excluded")
}
if reloadedB.PDDExcluded {
t.Fatalf("rowB must not have been marked excluded (has a task)")
}
}
func TestMarkedCountsByRule(t *testing.T) {
db := testDB(t)
ruleA := models.SYBProductFilter{Kind: "keyword", Keyword: "档口A", NormalizedKeyword: "档口a", Enabled: true}
ruleB := models.SYBProductFilter{Kind: "keyword", Keyword: "档口B", NormalizedKeyword: "档口b", Enabled: true}
if err := db.Create(&ruleA).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&ruleB).Error; err != nil {
t.Fatal(err)
}
ruleAID, ruleBID := ruleA.ID, ruleB.ID
for i := 0; i < 3; i++ {
row := models.SYBProduct{OrderCode: fmt.Sprintf("ORD-MARKED-A-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1), ShopeeItemID: fmt.Sprintf("a%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}", PDDExcluded: true, ExcludedRuleID: &ruleAID}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
row := models.SYBProduct{OrderCode: "ORD-MARKED-B-0", DetailID: 100, StockID: 100, ShopeeItemID: "b0", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}", PDDExcluded: true, ExcludedRuleID: &ruleBID}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
unmarked := models.SYBProduct{OrderCode: "ORD-MARKED-NONE", DetailID: 200, StockID: 200, ShopeeItemID: "none", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}"}
if err := db.Create(&unmarked).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
resp, err := s.List(context.Background(), ListRequest{Page: 1, PageSize: 50})
if err != nil {
t.Fatal(err)
}
byID := make(map[uint64]int64, len(resp.Items))
for _, item := range resp.Items {
byID[item.ID] = item.MarkedCount
}
if byID[ruleAID] != 3 {
t.Fatalf("expected rule A marked count 3, got %d", byID[ruleAID])
}
if byID[ruleBID] != 1 {
t.Fatalf("expected rule B marked count 1, got %d", byID[ruleBID])
}
}
// TestRecomputeFingerprintChangesWhenRuleEvidenceChanges is #340 phase 4
// review item 1: the fingerprint must depend on the rule's kind/keyword, not
// just its id — because those are exactly what RecomputeExecute is about to
// write into excluded_rule_kind/excluded_rule_keyword. Same rule id, same
// affected product, same direction, but the rule's own keyword changed
// between preview and execute (simulated by editing the row directly since
// the API has no edit endpoint) must be rejected as stale.
func TestRecomputeFingerprintChangesWhenRuleEvidenceChanges(t *testing.T) {
db := testDB(t)
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{OrderCode: "ORD-RULE-EDIT", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
preview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if preview.PDDToExcluded != 1 || len(preview.Samples) != 1 || preview.Samples[0].RuleKeyword != "档口" {
t.Fatalf("unexpected preview: %+v", preview)
}
// The rule's own keyword and normalized_keyword change (same id, same
// kind, still matches the same variationSku prefix) — the plan's set of
// affected ids/directions is unchanged, but the evidence that would be
// written is not.
if err := db.Model(&models.SYBProductFilter{}).Where("id = ?", rule.ID).
Updates(map[string]any{"keyword": "档口新", "normalized_keyword": "档口"}).Error; err != nil {
t.Fatal(err)
}
_, err = s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint)
if err == nil {
t.Fatalf("expected the changed rule evidence to be rejected as stale")
}
se, ok := err.(*ServiceError)
if !ok || se.Code != CodeRecomputeStale {
t.Fatalf("expected CodeRecomputeStale, got %v", err)
}
var reloaded models.SYBProduct
db.First(&reloaded, row.ID)
if reloaded.PDDExcluded {
t.Fatalf("nothing should have been written: %+v", reloaded)
}
var logCount int64
db.Model(&models.SYBProductFilterRecomputeLog{}).Count(&logCount)
if logCount != 0 {
t.Fatalf("no audit log row should have been written, got %d", logCount)
}
// A fresh preview reflects the new keyword and executes normally.
freshPreview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if freshPreview.Samples[0].RuleKeyword != "档口新" {
t.Fatalf("expected fresh preview to see the new keyword, got %+v", freshPreview.Samples)
}
if _, err := s.RecomputeExecute(context.Background(), "admin1", freshPreview.Fingerprint); err != nil {
t.Fatalf("fresh fingerprint should be accepted: %v", err)
}
db.First(&reloaded, row.ID)
if !reloaded.PDDExcluded || reloaded.ExcludedRuleKeyword != "档口新" {
t.Fatalf("expected the row to be excluded with the new keyword snapshot: %+v", reloaded)
}
}
// TestRecomputeFingerprintStableAcrossUnchangedPreviews is the companion
// regression: an unchanged dataset must give the SAME fingerprint on two
// consecutive previews (map/slice iteration order must never leak into the
// hash), and that fingerprint must still execute successfully.
func TestRecomputeFingerprintStableAcrossUnchangedPreviews(t *testing.T) {
db := testDB(t)
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 5; i++ {
row := models.SYBProduct{OrderCode: fmt.Sprintf("ORD-STABLE-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1), ShopeeItemID: fmt.Sprintf("s%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-x"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
s := NewService(db)
first, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
second, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if first.Fingerprint == "" || first.Fingerprint != second.Fingerprint {
t.Fatalf("expected a stable, non-empty fingerprint across two previews of the same data: %q vs %q", first.Fingerprint, second.Fingerprint)
}
if _, err := s.RecomputeExecute(context.Background(), "admin1", second.Fingerprint); err != nil {
t.Fatalf("unchanged-data fingerprint should execute successfully: %v", err)
}
}
@@ -12,4 +12,6 @@ func InitRouter(e *gin.Engine, a *jwt.GinJWTMiddleware) {
g.POST("", middleware.RequireRoleKey("admin"), Handler{}.Create)
g.PATCH("/:filterId/enabled", middleware.RequireRoleKey("admin"), Handler{}.SetEnabled)
g.DELETE("/:filterId", middleware.RequireRoleKey("admin"), Handler{}.Delete)
g.GET("/recompute/preview", middleware.RequireRoleKey("admin"), Handler{}.RecomputePreview)
g.POST("/recompute/execute", middleware.RequireRoleKey("admin"), Handler{}.RecomputeExecute)
}
+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])
}
}
@@ -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
})
}
@@ -0,0 +1,33 @@
package version_local
import (
"runtime"
"go-admin/app/goauto/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// #352 adds the nullable, indexed SYB source timestamp. Historical rows are
// intentionally left NULL until a bounded SYB re-sync supplies source proof.
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBStockCreatedAt)
}
func migrateSYBStockCreatedAt(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if !tx.Migrator().HasColumn(&models.SYBProduct{}, "SYBStockCreatedAt") {
if err := tx.Migrator().AddColumn(&models.SYBProduct{}, "SYBStockCreatedAt"); err != nil {
return err
}
}
if !tx.Migrator().HasIndex(&models.SYBProduct{}, "SYBStockCreatedAt") {
if err := tx.Migrator().CreateIndex(&models.SYBProduct{}, "SYBStockCreatedAt"); err != nil {
return err
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,43 @@
package version_local
import (
"testing"
"time"
"go-admin/app/goauto/models"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestMigrateSYBStockCreatedAtPreservesLegacyRows(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:syb_stock_created_migration?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.Exec("CREATE TABLE syb_product (id integer primary key, created_at datetime)").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("INSERT INTO syb_product (id, created_at) VALUES (1, '2026-09-28 00:05:00')").Error; err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
if err := migrateSYBStockCreatedAt(db, "test_syb_stock_created_at"); err != nil {
t.Fatal(err)
}
if !db.Migrator().HasColumn(&models.SYBProduct{}, "SYBStockCreatedAt") || !db.Migrator().HasIndex(&models.SYBProduct{}, "SYBStockCreatedAt") {
t.Fatal("migration must add the source time column and index")
}
var row struct {
CreatedAt time.Time
SYBStockCreatedAt *string
}
if err := db.Table("syb_product").Where("id = 1").Take(&row).Error; err != nil {
t.Fatal(err)
}
if row.CreatedAt.Format("2006-01-02 15:04:05") != "2026-09-28 00:05:00" || row.SYBStockCreatedAt != nil {
t.Fatalf("migration must preserve import time and leave source time unknown: %+v", row)
}
}
@@ -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 } }) }
+8 -1
View File
@@ -1,7 +1,14 @@
import request from '@/utils/request'
export function listSybProducts(params, options = {}) {
return request({ url: '/api/admin/v1/syb-products', method: 'get', params, ...options })
return request({ url: '/api/admin/v1/syb-products', method: 'get', params, paramsSerializer: { serialize(values) {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(values)) {
if (Array.isArray(value)) value.forEach(item => query.append(key, String(item)))
else if (value !== undefined && value !== null && value !== '') query.append(key, String(value))
}
return query.toString()
} }, ...options })
}
export function getSybProduct(productId) {
+44 -8
View File
@@ -12,7 +12,7 @@
<el-button type="primary" :icon="Search" @click="search">搜索</el-button>
<span class="toolbar-spacer" />
<el-button :loading="matchSubmitting" :disabled="!matchEligibleCount || loading || !!loadError || importing || busy || matchingBusy || !!rowActionId" @click="matchSelected">匹配 {{ matchEligibleCount }}</el-button>
<el-button type="primary" :disabled="selected.length === 0 || busy" @click="openApply">回写({{ applyEligibleCount }})</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>
@@ -35,13 +35,14 @@
<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">
@@ -67,12 +68,14 @@ import { createRequestId } from '@/utils/request-id'
export default {
name: 'GoAutoSybInnerCodes',
setup() { return { FolderOpened, RefreshLeft, Search, Upload } },
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, 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: {
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 },
applyEligibleCount() { return this.selected.filter(item => item.status === 'ready').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 },
@@ -89,8 +92,41 @@ 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 matchSelected() { await this.startMatch(this.matchEligibleRows) },
@@ -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>
+89 -29
View File
@@ -4,22 +4,38 @@
<el-card class="page-card" shadow="never">
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<div class="search-row search-row-primary">
<el-form-item v-if="canPurchase"><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
<el-form-item label="店铺">
<el-autocomplete v-model="query.shopName" class="shop-input" placeholder="选择或输入店铺" clearable value-key="displayName" :fetch-suggestions="suggestSybShops">
<template #default="{ item }"><div class="shop-option" :title="item.displayName"><span class="shop-option-name">{{ item.displayName }}</span><el-tag v-if="!item.enabled" size="small" type="info" effect="plain">已停用</el-tag></div></template>
</el-autocomplete>
<el-select
v-model="query.shopIds"
class="shop-input"
multiple
filterable
collapse-tags
collapse-tags-tooltip
clearable
:multiple-limit="50"
:loading="shopOptionsLoading"
placeholder="选择店铺"
@visible-change="onShopVisible"
>
<el-option v-for="shop in shopOptions" :key="shop.id" :value="shop.id" :label="shop.displayName"><div class="shop-option" :title="shop.displayName"><span class="shop-option-name">{{ shop.displayName }}</span><el-tag v-if="!shop.enabled" size="small" type="info" effect="plain">已停用</el-tag></div></el-option>
<template #empty><div class="shop-empty"><template v-if="shopOptionsLoading">正在加载店铺…</template><template v-else-if="shopOptionsError">店铺加载失败 <el-button link type="primary" @click.stop="ensureShopOptions">重试</el-button></template><template v-else>暂无店铺</template></div></template>
</el-select>
</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">
<el-form-item label="创建时间"><el-date-picker v-model="query.createdAtRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable /></el-form-item>
</div>
</el-form>
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
<div class="top-pagination-row">
<span v-if="canPurchase" class="selection-count" aria-live="polite">本页选择 <strong>{{ selectedProducts.length }}</strong> 条</span>
<pagination v-show="total > 0" v-model:page="query.page" v-model:limit="query.pageSize" class="top-pagination" :auto-scroll="false" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="onPagination" />
</div>
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
<el-table-column v-if="canPurchase" type="selection" width="48" :selectable="rowSelectable" />
<el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column>
@@ -37,6 +53,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)">
@@ -80,7 +97,7 @@
</el-table-column>
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(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, 500]" :total="total" @pagination="load" />
<pagination v-show="total > 0" v-model:page="query.page" v-model:limit="query.pageSize" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="onPagination" />
</el-card>
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
@@ -326,6 +343,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>
@@ -394,9 +415,10 @@ export default {
return {
loading: false, products: [], selectedProducts: [], total: 0,
purchaseReadiness: {}, purchaseReadinessLoading: false, specMatchLoading: false, loadGeneration: 0,
paginationTimer: null, loadedPage: 1, loadedPageSize: 200,
purchaseDevices: [],
purchasePreviewGeneration: 0,
shopOptions: [], shopOptionsLoaded: false, shopOptionsPromise: null,
shopOptions: [], shopOptionsLoaded: false, shopOptionsLoading: false, shopOptionsError: false, shopOptionsPromise: null,
purchaseDialog: { open: false, loading: false, saving: false, ids: [], selectedCount: 0, deviceId: null, items: [], eligibleCount: 0, skippedCount: 0, previewError: '' },
purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
specMatchResult: { open: false, items: [], selectedIDs: [], autoConfirmedCount: 0, pendingCount: 0, failedCount: 0, skippedCount: 0 },
@@ -404,7 +426,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, shopIds: [], orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] },
returnMatchByProductId: {},
returnMatchLoading: false,
returnMatchBatchLoading: false,
@@ -420,7 +442,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 },
@@ -467,10 +489,13 @@ export default {
}
},
created() { this.load() },
beforeUnmount() { if (this.paginationTimer !== null) clearTimeout(this.paginationTimer) },
methods: {
async load(allowNetworkRetry = true, waitForReadiness = false) {
const orderCodes = this.normalizedOrderCodes()
if (orderCodes === null) return
this.loadedPage = this.query.page
this.loadedPageSize = this.query.pageSize
const generation = ++this.loadGeneration
this.loading = true
this.selectedProducts = []
@@ -478,7 +503,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, shopIds: this.query.shopIds, 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,23 +526,46 @@ export default {
}
},
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }; this.load() },
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
reset() { this.query = { page: 1, pageSize: 200, shopIds: [], orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] }; this.load() },
onPagination() {
// Both controls may emit after a size change clamps the old page. Coalesce
// those events and keep the user on page one with exactly one list load.
if (this.paginationTimer !== null) clearTimeout(this.paginationTimer)
this.paginationTimer = setTimeout(() => {
this.paginationTimer = null
if (this.query.pageSize !== this.loadedPageSize) this.query.page = 1
if (this.query.page !== this.loadedPage || this.query.pageSize !== this.loadedPageSize) this.load()
}, 0)
},
// #340: 选择「退货待确认/已用退货/无需采购」这些不属于「需 PDD 采购」默认范围
// 的阶段时,自动把采购类型切到「全部」,否则处理阶段过滤会和采购类型过滤
// 的 AND 组合出空列表;用户仍可再手动切回。
onProcessStageChange(value) {
if (['return_pending', 'return_used', 'pdd_excluded'].includes(value) && this.query.purchaseType !== 'all') {
this.query.purchaseType = 'all'
}
},
async ensureShopOptions() {
if (this.shopOptionsLoaded) return
if (this.shopOptionsPromise) return this.shopOptionsPromise
this.shopOptionsPromise = listSybShops({ page: 1, pageSize: 200 }, { suppressNetworkError: true, suppressErrorMessage: true })
.then((response) => { this.shopOptions = response.data.items || [] })
.catch(() => { this.shopOptions = []; ElMessage.warning('店铺列表加载失败,仍可直接输入店铺名称查询') })
.finally(() => { this.shopOptionsLoaded = true; this.shopOptionsPromise = null })
this.shopOptionsLoading = true
this.shopOptionsError = false
this.shopOptionsPromise = (async() => {
const options = []
for (let page = 1; ; page += 1) {
const response = await listSybShops({ page, pageSize: 200 }, { suppressNetworkError: true, suppressErrorMessage: true })
options.push(...(response.data.items || []))
if (options.length >= response.data.total || !response.data.items?.length) break
}
this.shopOptions = options
this.shopOptionsLoaded = true
})().catch(() => {
this.shopOptionsError = true
ElMessage.warning('店铺列表加载失败,请重试')
}).finally(() => { this.shopOptionsLoading = false; this.shopOptionsPromise = null })
return this.shopOptionsPromise
},
async suggestSybShops(value, callback) {
await this.ensureShopOptions()
const keyword = this.normalizeShopName(value)
const matches = keyword ? this.shopOptions.filter(item => this.normalizeShopName(item.displayName).includes(keyword)) : this.shopOptions
callback(matches.slice(0, 50))
},
onShopVisible(visible) { if (visible) this.ensureShopOptions() },
normalizedOrderCodes() {
const values = [...new Set(this.query.orderCodesText.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]
if (values.length > 100) { ElMessage.warning('一次最多查询 100 个订单号'); return null }
@@ -531,10 +579,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 +594,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
@@ -920,9 +976,10 @@ export default {
.search-row-secondary{justify-content:flex-start}
.search-form :deep(.el-form-item){margin-right:0;margin-bottom:12px}
.search-form :deep(.el-form-item__content){flex-wrap:nowrap}
.shop-input{width:130px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
.shop-input{width:210px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
.shop-option{display:flex;align-items:center;justify-content:space-between;gap:12px}
.shop-option-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.shop-empty{padding:8px 12px;color:var(--el-text-color-secondary)}
.toolbar-actions :deep(.el-form-item__content){gap:8px}
.toolbar-actions :deep(.el-button + .el-button){margin-left:0}
.action-button-wrap{display:inline-flex}
@@ -930,6 +987,10 @@ export default {
.action-count-primary{background:rgba(255,255,255,.2);color:inherit}
.selection-count{display:inline-flex;align-items:center;min-height:32px;white-space:nowrap;color:#606266}
.selection-count strong{margin:0 4px;font-variant-numeric:tabular-nums;color:#1e40af}
.top-pagination-row{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:8px 16px;margin-bottom:12px}
.top-pagination-row .selection-count{margin-right:auto}
.top-pagination{max-width:100%;min-width:0;overflow-x:auto}
.top-pagination.pagination-container{padding:4px 0}
.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}
.missing{color:#b91c1c}
.quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0}
@@ -939,7 +1000,6 @@ export default {
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.notice{margin-bottom:16px}
.return-missing-alert{margin:6px 0}
.compact-notice{margin-bottom:12px}
.purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.warning-text{color:#b45309}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle}
.spec-match-summary{gap:8px 24px}
.purchase-note{margin:0 0 16px}
+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([])
})
+97 -15
View File
@@ -22,7 +22,7 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
await expect(page.getByRole('button', { name: '导入', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '同步记录', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '从 SYB 导入', exact: true })).toHaveCount(0)
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '重新解析', exact: true })).toHaveCount(0)
await expect(page.getByRole('columnheader', { name: '蝦皮关联' })).toHaveCount(0)
await expect(page.getByText('本页不展示采购任务、PDD 订单号、快递单号和支付状态。', { exact: true })).toHaveCount(0)
@@ -34,42 +34,46 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
await expect(shopeeTitle).toHaveAttribute('title', '用于验证加宽后的虾皮商品标题区域')
await expect(shopeeTitle).toHaveCSS('text-overflow', 'ellipsis')
const selectionCount = await page.getByText('已选择 0 条', { exact: true }).boundingBox()
const selectionCount = await page.getByText('本页选择 0 条', { exact: true }).boundingBox()
const queryButton = await page.getByRole('button', { name: '查询', exact: true }).boundingBox()
const reset = await page.getByRole('button', { name: '重置' }).boundingBox()
const purchase = await page.getByRole('button', { name: /创建采购\s*0/ }).boundingBox()
expect(selectionCount).not.toBeNull(); expect(queryButton).not.toBeNull(); expect(reset).not.toBeNull(); expect(purchase).not.toBeNull()
expect(Math.abs((selectionCount?.y || 0) - (queryButton?.y || 0))).toBeLessThan(2)
expect((selectionCount?.y || 0)).toBeGreaterThan((queryButton?.y || 0))
expect(Math.abs((reset?.y || 0) - (purchase?.y || 0))).toBeLessThan(2)
expect(purchase?.x || 0).toBeGreaterThan((reset?.x || 0) + (reset?.width || 0))
await expect(page.getByRole('button', { name: /创建采购\s*0/ })).toBeDisabled()
const shopControl = page.getByPlaceholder('选择或输入店铺')
const shopControl = page.getByRole('combobox', { name: '店铺' })
const shopInput = await shopControl.boundingBox()
const orderInput = await page.getByPlaceholder('每行一个订单号,最多 100 个').boundingBox()
expect(shopInput).not.toBeNull(); expect(orderInput).not.toBeNull()
expect(shopInput?.x || 0).toBeLessThan(orderInput?.x || 0)
await shopControl.click()
await page.locator('.shop-input').click()
await expect(page.getByText('大碼臻選', { exact: true }).last()).toBeVisible()
await expect(page.getByText('歷史店鋪', { exact: true }).last()).toBeVisible()
await expect(page.getByText('已停用', { exact: true })).toBeVisible()
await page.locator('.el-table__body-wrapper tbody tr').first().locator('.el-checkbox').click()
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled()
await expect(page.getByRole('button', { name: /创建采集\s*1/ })).toBeEnabled()
const widths = await page.locator('.el-table__header-wrapper colgroup col').evaluateAll(cols => cols.map(col => Number(col.getAttribute('width'))))
expect(widths[3]).toBe(260)
expect(widths[9]).toBeGreaterThanOrEqual(210)
expect(widths[10]).toBe(132)
expect(widths[11]).toBe(132)
await shopControl.fill('大碼')
await page.locator('.shop-input').click()
await page.getByRole('option', { name: '大碼臻選' }).click()
await page.locator('.shop-input').click()
await page.getByRole('option', { name: /歷史店鋪/ }).click()
await page.getByPlaceholder('每行一个订单号,最多 100 个').fill(' SYB001\n\nSYB002\nSYB001 ')
await page.getByPlaceholder('每行一个订单号,最多 100 个').press('Control+Enter')
await expect.poll(() => listURLs.length).toBeGreaterThan(1)
const latest = listURLs.at(-1)
expect(latest?.searchParams.get('shopName')).toBe('大碼')
expect(latest?.searchParams.getAll('shopIds')).toEqual(['1', '2'])
expect(latest?.searchParams.has('shopName')).toBe(false)
expect(latest?.searchParams.get('orderCodes')).toBe('SYB001,SYB002')
await page.locator('.el-form-item').filter({ hasText: '处理阶段' }).locator('.el-select').click()
@@ -78,6 +82,53 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
await expect.poll(() => listURLs.at(-1)?.searchParams.get('processStage')).toBe('pdd_pending')
})
test('SYB 商品上下分页同步且每次操作只请求一次列表', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
const listURLs: URL[] = []
await page.route('**/api/**', async route => {
const url = new URL(route.request().url())
if (url.pathname.startsWith('/src/api/')) return route.continue()
if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: sybProductRoute }})
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
if (url.pathname.endsWith('/api/admin/v1/syb-products')) {
listURLs.push(url)
const currentPage = Number(url.searchParams.get('page') || 1)
return route.fulfill({ json: { code: 200, data: { items: [{ id: currentPage, orderCode: `SYB-${currentPage}`, shopeeItemId: '1001', shopeeProductId: 1, productTitle: '分页验证', shopName: '测试店铺', quantity: 1, unitPriceCent: 1000, parseStatus: 'success' }], total: 980, page: currentPage, pageSize: Number(url.searchParams.get('pageSize') || 200) }}})
}
if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) {
return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: Number(listURLs.at(-1)?.searchParams.get('page') || 1), pddProductId: 1, eligible: true, processStage: 'purchase_ready', processStageLabel: '可创建采购' }], eligibleCount: 1, skippedCount: 0 }}})
}
return route.fulfill({ json: { code: 200, data: [] }})
})
await page.goto('/#/syb-products')
await expect.poll(() => listURLs.length).toBe(1)
await expect(page.locator('.top-pagination .el-pagination')).toBeVisible()
await expect(page.locator('.pagination-container')).toHaveCount(2)
await expect(page.getByText('先完成并保存规格匹配')).toHaveCount(0)
await page.locator('.el-table__body-wrapper tbody tr').first().locator('.el-checkbox').click()
await expect(page.getByText('本页选择 1 条', { exact: true })).toBeVisible()
await page.locator('.top-pagination .el-pager .number').filter({ hasText: '5' }).click()
await expect.poll(() => listURLs.length).toBe(2)
expect(listURLs.at(-1)?.searchParams.get('page')).toBe('5')
await expect(page.getByText('本页选择 0 条', { exact: true })).toBeVisible()
await expect(page.locator('.pagination-container .el-pager .number.is-active')).toHaveCount(2)
await page.locator('.top-pagination .el-pagination__sizes').click()
await page.getByRole('option', { name: /500/ }).click()
await expect.poll(() => listURLs.length).toBe(3)
expect(listURLs.at(-1)?.searchParams.get('page')).toBe('1')
expect(listURLs.at(-1)?.searchParams.get('pageSize')).toBe('500')
await page.waitForTimeout(100)
expect(listURLs).toHaveLength(3)
await expect(page.locator('.pagination-container .el-pager .number.is-active')).toHaveCount(2)
await page.locator('.pagination-container:not(.top-pagination) .el-pager .number').filter({ hasText: '2' }).click()
await expect.poll(() => listURLs.length).toBe(4)
expect(listURLs.at(-1)?.searchParams.get('page')).toBe('2')
expect(listURLs.at(-1)?.searchParams.get('pageSize')).toBe('500')
})
test('批量 AI 匹配展示混合结果并在关闭后刷新采购候选', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
let matched = false
@@ -127,7 +178,7 @@ test('批量 AI 匹配展示混合结果并在关闭后刷新采购候选', asyn
await expect(page.getByRole('button', { name: /AI 匹配\s*1/ })).toBeEnabled()
})
test('店铺选项加载失败时仍可输入历史店铺查询', async({ page, context }) => {
test('店铺选项加载失败时提示重试且不能输入任意店铺', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
const listURLs: URL[] = []
await page.route('**/api/**', async route => {
@@ -141,13 +192,44 @@ test('店铺选项加载失败时仍可输入历史店铺查询', async({ page,
})
await page.goto('/#/syb-products')
const shopControl = page.getByPlaceholder('选择或输入店铺')
await shopControl.click()
await expect(page.getByText('店铺列表加载失败,仍可直接输入店铺名称查询', { exact: true })).toBeVisible()
const shopControl = page.getByRole('combobox', { name: '店铺' })
await page.locator('.shop-input').click()
await expect(page.getByRole('tooltip', { name: /店铺加载失败/ })).toBeVisible()
await expect(page.getByRole('button', { name: '重试' })).toBeVisible()
await shopControl.fill('已删除店铺')
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => listURLs.length).toBeGreaterThan(1)
expect(listURLs.at(-1)?.searchParams.get('shopName')).toBe('已删除店铺')
expect(listURLs.at(-1)?.searchParams.has('shopIds')).toBe(false)
expect(listURLs.at(-1)?.searchParams.has('shopName')).toBe(false)
})
test('店铺管理超过 200 家时仍可搜索并选择后续页店铺', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
const shopPages: number[] = []
const listURLs: URL[] = []
await page.route('**/api/**', async route => {
const url = new URL(route.request().url())
if (url.pathname.startsWith('/src/api/')) return route.continue()
if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: sybProductRoute }})
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
if (url.pathname.endsWith('/api/admin/v1/syb-shops')) {
const currentPage = Number(url.searchParams.get('page'))
shopPages.push(currentPage)
const start = (currentPage - 1) * 200 + 1
const count = currentPage === 1 ? 200 : 1
const items = Array.from({ length: count }, (_, index) => ({ id: start + index, displayName: `店铺${start + index}`, enabled: true }))
return route.fulfill({ json: { code: 200, data: { items, total: 201, page: currentPage, pageSize: 200 }}})
}
if (url.pathname.endsWith('/api/admin/v1/syb-products')) { listURLs.push(url); return route.fulfill({ json: { code: 200, data: { items: [], total: 0, page: 1, pageSize: 200 }}}) }
return route.fulfill({ json: { code: 200, data: [] }})
})
await page.goto('/#/syb-products')
await page.locator('.shop-input').click()
await expect.poll(() => shopPages).toEqual([1, 2])
await page.getByRole('combobox', { name: '店铺' }).fill('店铺201')
await page.getByRole('option', { name: '店铺201' }).click()
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => listURLs.at(-1)?.searchParams.getAll('shopIds')).toEqual(['201'])
})
test('PDD 待采集的下一步在 SYB 列表上方打开对应 PDD 商品抽屉', async({ page, context }) => {
@@ -257,7 +339,7 @@ test('混选后采购与采集分别提交候选子集且采集按 PDD 去重',
const rows = page.locator('.el-table__body-wrapper tbody tr')
await expect(rows).toHaveCount(3)
for (let index = 0; index < 3; index += 1) await rows.nth(index).locator('.el-checkbox').click()
await expect(page.getByText('已选择 3 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 3 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled()
await expect(page.getByRole('button', { name: /创建采集\s*1/ })).toBeEnabled()
+2 -2
View File
@@ -61,7 +61,7 @@ test('one confirmation, mixed selection, progress, success and filter retention'
expect(writes).toEqual([11, 12])
expect(lists.at(-1)?.searchParams.get('pageSize')).toBe('200')
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 0 条', { exact: true })).toBeVisible()
})
for (const mode of ['partial', 'conflict', 'auth']) {
@@ -75,7 +75,7 @@ for (const mode of ['partial', 'conflict', 'auth']) {
await expect(page.getByText(mode === 'auth' ? '失败 2 条' : mode === 'partial' ? '失败 1 条' : '跳过 2 条', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '返回列表', exact: true }).click()
if (mode === 'partial') {
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /取消匹配\s*1/ }).first()).toBeEnabled()
}
})
+2 -2
View File
@@ -65,7 +65,7 @@ test('v2 button order, one confirmation, no write on return, progress and mutual
await expect(page.getByText('跳过 1 条', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 0 条', { exact: true })).toBeVisible()
})
for (const mode of ['partial', 'conflict', 'auth', 'readback-confirmed']) {
@@ -81,7 +81,7 @@ for (const mode of ['partial', 'conflict', 'auth', 'readback-confirmed']) {
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
if (mode === 'partial') {
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /使用退货\s*1/ })).toBeEnabled()
}
})
+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)
})