fix(migration): Visma pagination + chunk-insert resilience (the '300 misslyckades' case) (#1455)

* fix(providers): paginate Visma eAccounting with $page/$pagesize

eAccounting silently ignores OData $top/$skip, so every request returned
page 1 and getPaginated appended the first page TotalNumberOfPages times:
customers were imported in triplicate and invoice chunks hit unique
violations. Also stop on an empty page so a stale Meta can never loop or
duplicate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migration): survive bad rows in entity imports instead of failing whole chunks

One PostgREST insert per 500-row chunk is all-or-nothing, so a single
duplicate reported every row as failed ('300 misslyckades') with no cause
shown. Now: dedupe repeats within the fetched data (paging faults, source
duplicates), fall back to per-row inserts when a chunk is rejected, store
empty invoice numbers as NULL instead of colliding '', surface the first
DB error in the result UI, and mark all-failed steps with an error icon.
Sales invoices also carry remaining_amount so open invoices no longer
land as settled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migration): never per-row retry after a successful bulk insert with short read-back

A succeeded statement whose .select() returns fewer rows than sent means
the rows ARE in the table; retrying them one by one would duplicate every
unreturned row. Pair what came back and report the tail instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migration): count stub-insert casualties as failed and sample enrichment errors

Review follow-ups: invoices dropped because their customer/supplier stub
insert errored are DB failures, not matching misses; classifying them as
noMatch rendered a green result row with the database error hidden.
Enrichment failures now also feed errorSample.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-08 10:45:02 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent cf373e9dd1
commit a49d75db77
9 changed files with 577 additions and 132 deletions
@@ -120,10 +120,10 @@ interface SkipReasons {
interface MigrationResults {
companyInfo?: { imported: boolean }
customers?: { total: number; imported: number; updated?: number; skipped: number; skipReasons?: SkipReasons }
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
customers?: { total: number; imported: number; updated?: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
}
import AccountMappingStep from '@/components/import/AccountMappingStep'
import type { AccountMapping, ImportResult, ParsedSIEFile } from '@/lib/import/types'
@@ -1643,38 +1643,38 @@ function ResultStep({
<EntityResultRow
icon={<Users className="h-4 w-4" />}
label="Kunder"
status="success"
status={entityRowStatus(results.customers!.imported, results.customers!.skipReasons)}
statusText={results.customers!.updated
? `${results.customers!.imported} importerade, ${results.customers!.updated} kompletterade`
: `${results.customers!.imported} importerade`}
detail={results.customers!.skipped > 0 ? formatSkipReasons(results.customers!.skipReasons, 'customer') ?? `${results.customers!.skipped} hoppades över` : undefined}
detail={results.customers!.skipped > 0 ? formatSkipReasons(results.customers!.skipReasons, 'customer', results.customers!.errorSample) ?? `${results.customers!.skipped} hoppades över` : undefined}
/>
)}
{hasSuppliers && (
<EntityResultRow
icon={<Truck className="h-4 w-4" />}
label="Leverantörer"
status="success"
status={entityRowStatus(results.suppliers!.imported, results.suppliers!.skipReasons)}
statusText={`${results.suppliers!.imported} importerade`}
detail={results.suppliers!.skipped > 0 ? formatSkipReasons(results.suppliers!.skipReasons, 'supplier') ?? `${results.suppliers!.skipped} hoppades över` : undefined}
detail={results.suppliers!.skipped > 0 ? formatSkipReasons(results.suppliers!.skipReasons, 'supplier', results.suppliers!.errorSample) ?? `${results.suppliers!.skipped} hoppades över` : undefined}
/>
)}
{hasSalesInvoices && (
<EntityResultRow
icon={<FileText className="h-4 w-4" />}
label="Kundfakturor"
status="success"
status={entityRowStatus(results.salesInvoices!.imported, results.salesInvoices!.skipReasons)}
statusText={`${results.salesInvoices!.imported} importerade`}
detail={results.salesInvoices!.skipped > 0 ? formatSkipReasons(results.salesInvoices!.skipReasons, 'invoice') ?? `${results.salesInvoices!.skipped} hoppades över` : undefined}
detail={results.salesInvoices!.skipped > 0 ? formatSkipReasons(results.salesInvoices!.skipReasons, 'invoice', results.salesInvoices!.errorSample) ?? `${results.salesInvoices!.skipped} hoppades över` : undefined}
/>
)}
{hasSupplierInvoices && (
<EntityResultRow
icon={<FileText className="h-4 w-4" />}
label="Leverantörsfakturor"
status="success"
status={entityRowStatus(results.supplierInvoices!.imported, results.supplierInvoices!.skipReasons)}
statusText={`${results.supplierInvoices!.imported} importerade`}
detail={results.supplierInvoices!.skipped > 0 ? formatSkipReasons(results.supplierInvoices!.skipReasons, 'invoice') ?? `${results.supplierInvoices!.skipped} hoppades över` : undefined}
detail={results.supplierInvoices!.skipped > 0 ? formatSkipReasons(results.supplierInvoices!.skipReasons, 'invoice', results.supplierInvoices!.errorSample) ?? `${results.supplierInvoices!.skipped} hoppades över` : undefined}
/>
)}
</div>
@@ -1742,7 +1742,11 @@ function ResultStep({
)
}
function formatSkipReasons(reasons?: SkipReasons, entityType?: 'customer' | 'supplier' | 'invoice'): string | undefined {
function formatSkipReasons(
reasons?: SkipReasons,
entityType?: 'customer' | 'supplier' | 'invoice',
errorSample?: string,
): string | undefined {
if (!reasons) return undefined
const parts: string[] = []
if (reasons.duplicate) parts.push(`${reasons.duplicate} fanns redan`)
@@ -1751,10 +1755,21 @@ function formatSkipReasons(reasons?: SkipReasons, entityType?: 'customer' | 'sup
const matchLabel = entityType === 'invoice' ? 'utan matchning' : 'utan matchning'
parts.push(`${reasons.noMatch} ${matchLabel}`)
}
if (reasons.failed) parts.push(`${reasons.failed} misslyckades`)
if (reasons.failed) {
parts.push(
errorSample
? `${reasons.failed} misslyckades: ${errorSample.slice(0, 140)}`
: `${reasons.failed} misslyckades`
)
}
return parts.length > 0 ? parts.join(', ') : undefined
}
/** A step that failed everything it tried is an error, not a green checkmark. */
function entityRowStatus(imported: number, reasons?: SkipReasons): 'success' | 'error' {
return imported === 0 && (reasons?.failed ?? 0) > 0 ? 'error' : 'success'
}
/** Simple row for non-SIE entity results (customers, invoices, etc.) */
function EntityResultRow({
icon,
@@ -1765,7 +1780,7 @@ function EntityResultRow({
}: {
icon: React.ReactNode
label: string
status: 'success' | 'skipped'
status: 'success' | 'skipped' | 'error'
statusText: string
detail?: string
}) {
@@ -1777,7 +1792,7 @@ function EntityResultRow({
<p className="text-sm text-muted-foreground">{statusText}</p>
{detail && <p className="text-sm text-muted-foreground/70">{detail}</p>}
</div>
<StatusIcon status={status === 'success' ? 'success' : 'warning'} />
<StatusIcon status={status === 'skipped' ? 'warning' : status} />
</div>
)
}