Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/(dashboard)/sync/sync-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,10 @@ export function SyncClient({ settings: init, statusMappings, logs, shoppingCrede
<span className="text-sm">Import completed</span>
</div>
<p className="text-xs text-muted-foreground">{importProgress.message}</p>
<Button size="sm" variant="outline" onClick={handleStartInitialImport} disabled={importStarting}>
{importStarting ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <RefreshCw className="h-3 w-3 mr-1" />}
Re-import active orders
</Button>
</div>
) : importProgress?.status === 'error' ? (
<div className="space-y-2">
Expand Down
19 changes: 18 additions & 1 deletion app/actions/wc-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ const SYNC_SETTING_KEYS = [
'wc_fx_push_enabled', 'last_wc_fx_push_at',
]

// Machine-managed sync state — written by the import / sync / webhook jobs, never
// by the settings form. Excluded from saveSyncSettings so a settings save can't
// silently wipe the initial-import-completed flag or the sync cursors (which would
// turn live order sync back off).
const MACHINE_MANAGED_SYNC_KEYS = new Set<string>([
'wc_initial_import_completed',
'wc_webhook_last_received_at',
'wc_order_webhook_last_received_at',
'wc_product_webhook_last_received_at',
'last_wc_order_sync_at',
'last_wc_order_reconcile_at',
'last_wc_product_sync_at',
'last_wc_product_reconcile_at',
'last_wc_stock_sync_at',
'last_wc_fx_push_at',
])

const SYNC_DEFAULTS: WcSyncSettings = {
wc_sync_enabled: 'false',
wc_sync_order_statuses: '["processing"]',
Expand Down Expand Up @@ -210,7 +227,7 @@ export async function saveWcSyncSettings(data: Partial<WcSyncSettings>): Promise
})
if (!gate.ok) return { success: false, error: gate.error }
const ops = Object.entries(data)
.filter((entry): entry is [string, string] => SYNC_SETTING_KEYS.includes(entry[0]) && typeof entry[1] === 'string')
.filter((entry): entry is [string, string] => SYNC_SETTING_KEYS.includes(entry[0]) && !MACHINE_MANAGED_SYNC_KEYS.has(entry[0]) && typeof entry[1] === 'string')
.map(([k, v]) =>
db.setting.upsert({
where: { key: k },
Expand Down
50 changes: 50 additions & 0 deletions lib/connectors/woocommerce/sync/initial-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ export type InitialImportProgress = {

const JOB_KEY = 'initial_order_import_job'

/**
* Decide whether an initial-import pass counts as COMPLETE (which unlocks ongoing
* live order sync) or FAILED. A pass that errored on every order and imported
* nothing must NOT count as complete — otherwise the UI shows a dead-end
* "completed" with no orders, live sync stays silently gated off, and there's no
* way to retry. A pass that imported/reconciled at least one order, or had no
* active orders to import, is complete (per-order errors are surfaced but don't
* block, since live sync of new orders can still proceed).
*/
export function decideInitialImportOutcome(input: {
imported: number
skipped: number
errorCount: number
}): 'complete' | 'failed' {
const madeProgress = input.imported > 0 || input.skipped > 0
return input.errorCount > 0 && !madeProgress ? 'failed' : 'complete'
}

const INITIAL_PROGRESS: InitialImportProgress = {
status: 'idle',
message: '',
Expand Down Expand Up @@ -164,6 +182,38 @@ async function runInitialImport(progress: InitialImportProgress) {
// -----------------------------------------------------------------------
// Completion
// -----------------------------------------------------------------------
const outcome = decideInitialImportOutcome({
imported: progress.activeOrdersImported,
skipped: progress.activeOrdersSkipped,
errorCount: progress.errors.length,
})

if (outcome === 'failed') {
// Every order errored and nothing was imported \u2014 a systemic failure (e.g.
// no storefront-synced warehouse). Do NOT mark the import complete: that
// would falsely unlock live sync and leave a dead-end "done" state with no
// retry. Surface it as an error so the UI shows Retry and live order sync
// stays gated off until a real import succeeds.
progress.status = 'error'
progress.message = `Import failed \u2014 0 of ${progress.totalOrders} order${progress.totalOrders === 1 ? '' : 's'} imported (${progress.errors.length} error${progress.errors.length === 1 ? '' : 's'}). Resolve the cause and retry; live order sync stays off until the initial import succeeds.`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Ensure failed imports retry partial orders

When this failed path is hit for the reported warehouse-allocation error, importWcOrder may already have created the SalesOrder and WooCommerce shopping link before returning success: false after auto-allocation throws. On the next retry, runInitialImport preloads shoppingOrderLink ids and skips those same orders, so the retry can mark the import complete without ever re-running allocation or repairing the partially imported orders. This leaves the affected active orders in the broken state while unlocking live sync; the failure path needs to roll back/clean up partial links or make retries process those existing linked orders.

Useful? React with 👍 / 👎.

await saveProgress(progress)

await logActivity({
entityType: 'IMPORT',
tag: 'import',
action: 'failed',
description: `Active WC order import failed: ${progress.message}`,
resolveUser: false,
})
notify({
type: 'error',
title: 'Active Order Import Failed',
message: progress.message,
actionUrl: '/sync',
})
return
}

await db.setting.upsert({
where: { key: 'wc_initial_import_completed' },
create: { key: 'wc_initial_import_completed', value: 'true' },
Expand Down
21 changes: 21 additions & 0 deletions tests/wc-initial-import-outcome.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { decideInitialImportOutcome } from '../lib/connectors/woocommerce/sync/initial-import.ts'

test('a pass that imported nothing and errored on everything is FAILED (no false-complete)', () => {
// The reported bug: 6 orders, all errored ("no storefront-synced warehouse"),
// 0 imported. Must NOT count as complete — otherwise live sync silently stays
// off with a dead-end "completed" and no retry.
assert.equal(decideInitialImportOutcome({ imported: 0, skipped: 0, errorCount: 6 }), 'failed')
assert.equal(decideInitialImportOutcome({ imported: 0, skipped: 0, errorCount: 1 }), 'failed')
})

test('no active orders to import is COMPLETE (legitimately ready for live sync)', () => {
assert.equal(decideInitialImportOutcome({ imported: 0, skipped: 0, errorCount: 0 }), 'complete')
})

test('any real progress makes it COMPLETE even with some per-order errors', () => {
assert.equal(decideInitialImportOutcome({ imported: 4, skipped: 0, errorCount: 2 }), 'complete') // partial import
assert.equal(decideInitialImportOutcome({ imported: 0, skipped: 3, errorCount: 2 }), 'complete') // all already imported
assert.equal(decideInitialImportOutcome({ imported: 5, skipped: 0, errorCount: 0 }), 'complete') // clean import
})
Loading