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
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>
)
}
@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest'
import { mapSalesInvoice, mapSupplierInvoice } from '../entity-mapper'
import type { SalesInvoiceDto, SupplierInvoiceDto, PartyDto } from '@/lib/providers/dto'
/**
* Guards two migration hardenings:
* - empty invoice numbers from a provider payload must be stored as NULL,
* never '': the unique indexes on (company_id, invoice_number) and
* (company_id, supplier_id, supplier_invoice_number) treat NULLs as
* distinct but collide on repeated empty strings;
* - sales invoices must carry remaining_amount (NOT NULL DEFAULT 0), or
* every migrated open invoice looks fully settled in AR aging.
*/
const party: PartyDto = { name: 'Motpart AB', identifications: [] }
function makeSalesDto(over: { invoiceNumber?: string; paid?: boolean; balance?: number; total?: number }): SalesInvoiceDto {
const total = over.total ?? 1000
return {
id: 'inv-1',
invoiceNumber: over.invoiceNumber ?? 'F-100',
issueDate: '2026-01-10',
dueDate: '2026-02-10',
currencyCode: 'SEK',
status: 'sent',
supplier: party,
customer: party,
lines: [],
legalMonetaryTotal: {
lineExtensionAmount: { value: total, currencyCode: 'SEK' },
payableAmount: { value: total, currencyCode: 'SEK' },
},
paymentStatus: {
paid: over.paid ?? false,
balance: { value: over.balance ?? total, currencyCode: 'SEK' },
},
}
}
function makeSupplierDto(invoiceNumber: string): SupplierInvoiceDto {
return {
id: 'sinv-1',
invoiceNumber,
issueDate: '2026-01-10',
currencyCode: 'SEK',
status: 'booked',
supplier: party,
buyer: party,
lines: [],
legalMonetaryTotal: {
lineExtensionAmount: { value: 500, currencyCode: 'SEK' },
payableAmount: { value: 500, currencyCode: 'SEK' },
},
paymentStatus: { paid: false, balance: { value: 500, currencyCode: 'SEK' } },
}
}
describe('invoice number nulling', () => {
it('sales invoice: empty invoiceNumber becomes NULL, real one is kept', () => {
const empty = mapSalesInvoice(makeSalesDto({ invoiceNumber: '' }), 'u', 'c', 'cust').invoice
expect(empty.invoice_number).toBeNull()
const real = mapSalesInvoice(makeSalesDto({ invoiceNumber: 'F-7' }), 'u', 'c', 'cust').invoice
expect(real.invoice_number).toBe('F-7')
})
it('supplier invoice: empty invoiceNumber becomes NULL', () => {
const empty = mapSupplierInvoice(makeSupplierDto(''), 'u', 'c', 'sup').invoice
expect(empty.supplier_invoice_number).toBeNull()
const real = mapSupplierInvoice(makeSupplierDto('LF-9'), 'u', 'c', 'sup').invoice
expect(real.supplier_invoice_number).toBe('LF-9')
})
})
describe('sales invoice remaining_amount', () => {
it('open invoice keeps its full balance as remaining', () => {
const inv = mapSalesInvoice(makeSalesDto({ paid: false, balance: 1000, total: 1000 }), 'u', 'c', 'cust').invoice
expect(inv.remaining_amount).toBe(1000)
expect(inv.paid_amount).toBe(0)
})
it('partially paid invoice: remaining mirrors the provider balance', () => {
const inv = mapSalesInvoice(makeSalesDto({ paid: false, balance: 250.5, total: 1000 }), 'u', 'c', 'cust').invoice
expect(inv.remaining_amount).toBe(250.5)
expect(inv.paid_amount).toBe(749.5)
})
it('paid invoice: remaining is 0', () => {
const inv = mapSalesInvoice(makeSalesDto({ paid: true, balance: 0, total: 1000 }), 'u', 'c', 'cust').invoice
expect(inv.remaining_amount).toBe(0)
expect(inv.paid_amount).toBe(1000)
})
it('negative provider balance never yields negative remaining', () => {
const inv = mapSalesInvoice(makeSalesDto({ paid: false, balance: -3, total: 1000 }), 'u', 'c', 'cust').invoice
expect(inv.remaining_amount).toBe(0)
})
})
@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { insertWithPerRowFallback } from '../insert-fallback'
/**
* The chunked migration inserts are one PostgREST statement per chunk, so a
* single bad row used to reject the whole chunk and report every row as
* failed ("300 misslyckades"). The fallback must keep the bulk fast path,
* retry per row only after a bulk failure, and attribute results by index.
*/
type InsertResult = { data: Record<string, unknown>[] | null; error: { message: string } | null }
/** Minimal supabase stand-in: scripted responses per insert call, in order. */
function makeSupabase(script: InsertResult[]) {
let call = 0
const inserted: unknown[] = []
const from = vi.fn(() => ({
insert: (rows: unknown) => {
inserted.push(rows)
const result = script[Math.min(call, script.length - 1)]
call++
return { select: () => Promise.resolve(result) }
},
}))
return { supabase: { from } as unknown as SupabaseClient, inserted, calls: () => call }
}
const row = (n: number) => ({ invoice_number: `F-${n}` })
describe('insertWithPerRowFallback', () => {
it('bulk success: one statement, results paired by index', async () => {
const { supabase, calls } = makeSupabase([
{ data: [{ id: 'a' }, { id: 'b' }], error: null },
])
const outcome = await insertWithPerRowFallback(supabase, 'invoices', [row(1), row(2)], 'id')
expect(calls()).toBe(1)
expect(outcome.failedCount).toBe(0)
expect(outcome.firstError).toBeNull()
expect(outcome.returned).toEqual([{ id: 'a' }, { id: 'b' }])
})
it('bulk failure: retries per row, healthy rows land, offender is counted with its message', async () => {
const dup = 'duplicate key value violates unique constraint "idx_invoices_company_invoice_number"'
const { supabase, calls } = makeSupabase([
{ data: null, error: { message: dup } }, // bulk statement
{ data: [{ id: 'a' }], error: null }, // row 0
{ data: null, error: { message: dup } }, // row 1 (the offender)
{ data: [{ id: 'c' }], error: null }, // row 2
])
const outcome = await insertWithPerRowFallback(supabase, 'invoices', [row(1), row(2), row(3)], 'id')
expect(calls()).toBe(4)
expect(outcome.returned).toEqual([{ id: 'a' }, null, { id: 'c' }])
expect(outcome.failedCount).toBe(1)
expect(outcome.firstError).toBe(dup)
})
it('bulk success with short read-back: never retries (rows are already in the table)', async () => {
const { supabase, calls } = makeSupabase([
{ data: [{ id: 'a' }], error: null }, // succeeded, but returned 1 of 2
])
const outcome = await insertWithPerRowFallback(supabase, 'customers', [row(1), row(2)], 'id')
expect(calls()).toBe(1)
expect(outcome.returned).toEqual([{ id: 'a' }, null])
expect(outcome.failedCount).toBe(1)
expect(outcome.firstError).toMatch(/could not be paired/)
})
it('empty input: no statements at all', async () => {
const { supabase, calls } = makeSupabase([{ data: [], error: null }])
const outcome = await insertWithPerRowFallback(supabase, 'invoices', [], 'id')
expect(calls()).toBe(0)
expect(outcome).toEqual({ returned: [], failedCount: 0, firstError: null })
})
})
@@ -517,7 +517,10 @@ export function mapSalesInvoice(
user_id: userId,
company_id: companyId,
customer_id: customerId,
invoice_number: dto.invoiceNumber,
// Empty string must become NULL: the UNIQUE (company_id, invoice_number)
// index is partial on NOT NULL, so '' from a provider payload missing the
// field would collide on the second invoice and reject the insert.
invoice_number: dto.invoiceNumber || null,
invoice_date: dto.issueDate,
due_date: dto.dueDate || dto.issueDate,
status: statusMap[dto.status] || 'sent',
@@ -540,6 +543,9 @@ export function mapSalesInvoice(
document_type: isCreditNote ? 'credit_note' : 'invoice',
paid_at: dto.paymentStatus.paid ? dto.paymentStatus.lastPaymentDate || dto.issueDate : null,
paid_amount: dto.paymentStatus.paid ? total : round2(total - dto.paymentStatus.balance.value),
// remaining_amount is NOT NULL DEFAULT 0, so omitting it makes every
// migrated open invoice look fully settled in AR aging.
remaining_amount: dto.paymentStatus.paid ? 0 : Math.max(0, round2(dto.paymentStatus.balance.value)),
}
const items = dto.lines.map((line, idx) => mapSalesInvoiceLine(line, idx))
@@ -625,7 +631,11 @@ export function mapSupplierInvoice(
user_id: userId,
company_id: companyId,
supplier_id: supplierId,
supplier_invoice_number: dto.invoiceNumber,
// Empty string must become NULL: with '' every number-less invoice from
// the same supplier collides on the UNIQUE
// (company_id, supplier_id, supplier_invoice_number) index, while NULLs
// are treated as distinct.
supplier_invoice_number: dto.invoiceNumber || null,
invoice_date: dto.issueDate,
due_date: dto.dueDate || dto.issueDate,
received_date: dto.issueDate,
@@ -0,0 +1,77 @@
/**
* Chunked inserts during migration are one PostgREST statement per chunk,
* which Postgres treats as all-or-nothing: a single bad row (usually a
* 23505 unique violation) rejects every row in the chunk. Historically that
* surfaced as "300 misslyckades" with zero explanation of which row broke.
*
* This helper keeps the fast path (one bulk insert) and, only when the bulk
* statement fails, retries the same rows one at a time so healthy rows still
* land and the real per-row error becomes visible.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
export interface PerRowInsertOutcome {
/**
* Selected columns of each inserted row, index-aligned with the input.
* null = that row failed to insert.
*/
returned: (Record<string, unknown> | null)[]
failedCount: number
/** First database error message, for result summaries and logs. */
firstError: string | null
}
export async function insertWithPerRowFallback(
supabase: SupabaseClient,
table: string,
rows: Record<string, unknown>[],
select: string,
): Promise<PerRowInsertOutcome> {
if (rows.length === 0) {
return { returned: [], failedCount: 0, firstError: null }
}
const bulk = await supabase.from(table).insert(rows).select(select)
if (!bulk.error) {
const data = (bulk.data ?? []) as unknown as Record<string, unknown>[]
// PostgREST returns inserted rows in input order, so a full-length result
// pairs 1:1 with the input.
if (data.length === rows.length) {
return { returned: data, failedCount: 0, firstError: null }
}
// The statement SUCCEEDED but returned fewer rows than sent (an RLS
// read-back gap). The rows ARE in the table, so retrying per row would
// duplicate them: pair what came back and report the tail unpaired.
const returned: (Record<string, unknown> | null)[] = new Array(rows.length).fill(null)
for (let i = 0; i < data.length; i++) returned[i] = data[i]
return {
returned,
failedCount: rows.length - data.length,
firstError: `insert returned ${data.length} of ${rows.length} rows; unreturned rows were inserted but could not be paired`,
}
}
const returned: (Record<string, unknown> | null)[] = new Array(rows.length).fill(null)
let failedCount = 0
let firstError: string | null = bulk.error?.message ?? null
for (let i = 0; i < rows.length; i++) {
const single = await supabase.from(table).insert(rows[i]).select(select)
if (single.error) {
failedCount++
firstError ??= single.error.message
// Keep the first PER-ROW error too: the bulk message is often the same,
// but when it isn't, the row-level one names the actual offender.
if (failedCount === 1 && single.error.message) {
firstError = single.error.message
}
continue
}
const data = (single.data ?? []) as unknown as Record<string, unknown>[]
returned[i] = data[0] ?? null
if (!returned[i]) failedCount++
}
return { returned, failedCount, firstError }
}
@@ -38,6 +38,7 @@ import {
type CustomerMetadataEnrichment,
type ExistingCustomerMetadata,
} from './customer-metadata'
import { insertWithPerRowFallback } from './insert-fallback'
import {
mapCustomer,
mapSupplier,
@@ -217,6 +218,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
let updated = 0
let skipped = 0
const skipReasons: SkipReasons = {}
let errorSample: string | null = null
type PendingCustomer = {
dto: CustomerDto
@@ -224,6 +226,11 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
const pending: PendingCustomer[] = []
const pendingEnrichments: { id: string; changes: CustomerMetadataEnrichment }[] = []
// Providers can hand back the same record more than once (a paging
// fault upstream, or genuine source duplicates). The DB-backed maps
// above only know rows that existed BEFORE this run, so without an
// in-run key set every repeat would be inserted again.
const pendingCustomerKeys = new Set<string>()
for (const customer of customers) {
if (!customer.active) {
@@ -258,32 +265,40 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
continue
}
const pendingKey = (orgNumber ?? `name:${customer.party.name?.toLowerCase() ?? ''}`).trim()
if (pendingCustomerKeys.has(pendingKey)) {
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
}
pendingCustomerKeys.add(pendingKey)
pending.push({ dto: customer, row: mapCustomer(customer, userId, companyId) })
}
for (const batch of chunk(pending, INSERT_CHUNK_SIZE)) {
const rows = batch.map((p) => p.row)
const { data: inserted, error } = await supabase
.from('customers')
.insert(rows)
.select('id, org_number, name')
const outcome = await insertWithPerRowFallback(
supabase, 'customers', batch.map((p) => p.row), 'id, org_number, name'
)
if (error) {
console.error(`[migration] Customer batch insert failed (${batch.length} rows):`, error.message)
skipReasons.failed = (skipReasons.failed ?? 0) + batch.length
skipped += batch.length
continue
if (outcome.failedCount > 0) {
console.error(
`[migration] Customer insert failed for ${outcome.failedCount} of ${batch.length} rows:`,
outcome.firstError
)
skipReasons.failed = (skipReasons.failed ?? 0) + outcome.failedCount
skipped += outcome.failedCount
errorSample ??= outcome.firstError
}
// PostgREST returns inserted rows in the same order as supplied,
// so we can pair them up by index to recover the provider id.
const insertedRows = inserted ?? []
for (let i = 0; i < batch.length && i < insertedRows.length; i++) {
for (let i = 0; i < batch.length; i++) {
const insertedRow = outcome.returned[i]
if (!insertedRow) continue
const providerId = batch[i].dto.id
const newId = insertedRows[i].id
const newId = insertedRow.id as string
customerIdMap.set(providerId, newId)
if (insertedRows[i].org_number) orgNumberToCustomerId.set(insertedRows[i].org_number!, newId)
if (insertedRows[i].name) nameToCustomerId.set(insertedRows[i].name!, newId)
if (insertedRow.org_number) orgNumberToCustomerId.set(insertedRow.org_number as string, newId)
if (insertedRow.name) nameToCustomerId.set(insertedRow.name as string, newId)
imported++
}
}
@@ -313,6 +328,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (outcome.error || !outcome.data) {
if (outcome.error) {
console.error('[migration] Customer metadata enrichment failed:', outcome.error.message)
errorSample ??= outcome.error.message
}
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
@@ -322,7 +338,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
results.customers = { total: customers.length, imported, updated, skipped, skipReasons }
results.customers = { total: customers.length, imported, updated, skipped, skipReasons, errorSample: errorSample ?? undefined }
} catch (err) {
console.error('Failed to import customers:', err)
}
@@ -354,9 +370,12 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
let imported = 0
let skipped = 0
const skipReasons: SkipReasons = {}
let errorSample: string | null = null
type PendingSupplier = { dto: SupplierDto; row: Record<string, unknown> }
const pending: PendingSupplier[] = []
// Same in-run repeat guard as customers.
const pendingSupplierKeys = new Set<string>()
for (const supplier of suppliers) {
if (!supplier.active) {
@@ -380,35 +399,45 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
continue
}
const pendingKey = (orgNumber ?? `name:${supplier.party.name?.toLowerCase() ?? ''}`).trim()
if (pendingSupplierKeys.has(pendingKey)) {
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
}
pendingSupplierKeys.add(pendingKey)
pending.push({ dto: supplier, row: mapSupplier(supplier, userId, companyId) })
}
for (const batch of chunk(pending, INSERT_CHUNK_SIZE)) {
const rows = batch.map((p) => p.row)
const { data: inserted, error } = await supabase
.from('suppliers')
.insert(rows)
.select('id, org_number, name')
const outcome = await insertWithPerRowFallback(
supabase, 'suppliers', batch.map((p) => p.row), 'id, org_number, name'
)
if (error) {
console.error(`[migration] Supplier batch insert failed (${batch.length} rows):`, error.message)
skipReasons.failed = (skipReasons.failed ?? 0) + batch.length
skipped += batch.length
continue
if (outcome.failedCount > 0) {
console.error(
`[migration] Supplier insert failed for ${outcome.failedCount} of ${batch.length} rows:`,
outcome.firstError
)
skipReasons.failed = (skipReasons.failed ?? 0) + outcome.failedCount
skipped += outcome.failedCount
errorSample ??= outcome.firstError
}
const insertedRows = inserted ?? []
for (let i = 0; i < batch.length && i < insertedRows.length; i++) {
for (let i = 0; i < batch.length; i++) {
const insertedRow = outcome.returned[i]
if (!insertedRow) continue
const providerId = batch[i].dto.id
const newId = insertedRows[i].id
const newId = insertedRow.id as string
supplierIdMap.set(providerId, newId)
if (insertedRows[i].org_number) orgNumberToSupplierId.set(insertedRows[i].org_number!, newId)
if (insertedRows[i].name) nameToSupplierId.set(insertedRows[i].name!, newId)
if (insertedRow.org_number) orgNumberToSupplierId.set(insertedRow.org_number as string, newId)
if (insertedRow.name) nameToSupplierId.set(insertedRow.name as string, newId)
imported++
}
}
results.suppliers = { total: suppliers.length, imported, skipped, skipReasons }
results.suppliers = { total: suppliers.length, imported, skipped, skipReasons, errorSample: errorSample ?? undefined }
} catch (err) {
console.error('Failed to import suppliers:', err)
}
@@ -434,6 +463,13 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
let imported = 0
let skipped = 0
const skipReasons: SkipReasons = {}
let errorSample: string | null = null
// invoice_number carries a UNIQUE (company_id, invoice_number) index,
// so a repeated number WITHIN the fetched set (paging fault or source
// duplicate) must be skipped here: inside one insert statement it
// would reject the whole chunk. Empty numbers are exempt: they are
// stored as NULL, which the partial index does not cover.
const seenInvoiceNumbers = new Set<string>()
// Phase A: resolve customer for each invoice; collect those that
// need a minimal customer record to be created on-the-fly.
@@ -455,6 +491,14 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
skipped++
continue
}
if (inv.invoiceNumber) {
if (seenInvoiceNumbers.has(inv.invoiceNumber)) {
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
}
seenInvoiceNumbers.add(inv.invoiceNumber)
}
const customerOrgNumber = getOrgNumberFromParty(inv.customer)
let customerId: string | null = null
@@ -503,30 +547,30 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (stubByKey.size > 0) {
const stubList = [...stubByKey.values()]
for (const batch of chunk(stubList, INSERT_CHUNK_SIZE)) {
const { data: inserted, error } = await supabase
.from('customers')
.insert(batch.map((s) => s.row))
.select('id, org_number, name')
const outcome = await insertWithPerRowFallback(
supabase, 'customers', batch.map((s) => s.row), 'id, org_number, name'
)
if (error) {
if (outcome.failedCount > 0) {
console.error(
`[migration] Sales invoice customer stub insert failed (${batch.length} rows):`,
error.message
`[migration] Sales invoice customer stub insert failed for ${outcome.failedCount} of ${batch.length} rows:`,
outcome.firstError
)
// Mark invoices waiting on failed stubs as no-match
for (const s of batch) {
for (const idx of s.waitingInvoiceIndices) {
resolved[idx] = { ...resolved[idx], customerId: '__FAILED__' }
}
}
continue
errorSample ??= outcome.firstError
}
const insertedRows = inserted ?? []
for (let i = 0; i < batch.length && i < insertedRows.length; i++) {
const newId = insertedRows[i].id
if (insertedRows[i].org_number) orgNumberToCustomerId.set(insertedRows[i].org_number!, newId)
if (insertedRows[i].name) nameToCustomerId.set(insertedRows[i].name!, newId)
for (let i = 0; i < batch.length; i++) {
const insertedRow = outcome.returned[i]
if (!insertedRow) {
// Mark invoices waiting on this failed stub as no-match
for (const idx of batch[i].waitingInvoiceIndices) {
resolved[idx] = { ...resolved[idx], customerId: '__FAILED__' }
}
continue
}
const newId = insertedRow.id as string
if (insertedRow.org_number) orgNumberToCustomerId.set(insertedRow.org_number as string, newId)
if (insertedRow.name) nameToCustomerId.set(insertedRow.name as string, newId)
for (const idx of batch[i].waitingInvoiceIndices) {
resolved[idx] = { ...resolved[idx], customerId: newId }
}
@@ -534,10 +578,13 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
// Drop invoices whose customer couldn't be created.
// Drop invoices whose customer couldn't be created. That is a DB
// failure (the stub insert errored, errorSample carries it), not a
// matching miss: counting it as noMatch would render a green result
// row with the database error hidden.
const ready = resolved.filter((r) => {
if (r.customerId === '__FAILED__') {
skipReasons.noMatch = (skipReasons.noMatch ?? 0) + 1
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
return false
}
@@ -562,22 +609,25 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
dto: r.dto,
}))
const { data: insertedInvoices, error: invErr } = await supabase
.from('invoices')
.insert(mappedBatch.map((m) => m.invoice))
.select('id')
const outcome = await insertWithPerRowFallback(
supabase, 'invoices', mappedBatch.map((m) => m.invoice), 'id'
)
if (invErr) {
console.error(`[migration] Sales invoice batch insert failed (${batch.length}):`, invErr.message)
skipReasons.failed = (skipReasons.failed ?? 0) + batch.length
skipped += batch.length
continue
if (outcome.failedCount > 0) {
console.error(
`[migration] Sales invoice insert failed for ${outcome.failedCount} of ${batch.length} rows:`,
outcome.firstError
)
skipReasons.failed = (skipReasons.failed ?? 0) + outcome.failedCount
skipped += outcome.failedCount
errorSample ??= outcome.firstError
}
const invoiceRows = insertedInvoices ?? []
const allItems: Record<string, unknown>[] = []
for (let i = 0; i < mappedBatch.length && i < invoiceRows.length; i++) {
const invoiceId = invoiceRows[i].id
for (let i = 0; i < mappedBatch.length; i++) {
const insertedRow = outcome.returned[i]
if (!insertedRow) continue
const invoiceId = insertedRow.id
for (const item of mappedBatch[i].items) {
allItems.push({ ...item, invoice_id: invoiceId })
}
@@ -599,7 +649,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved }
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, errorSample: errorSample ?? undefined }
} catch (err) {
console.error('Failed to import sales invoices:', err)
}
@@ -644,6 +694,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
let imported = 0
let skipped = 0
const skipReasons: SkipReasons = {}
let errorSample: string | null = null
type ResolvedSupplierInvoice = { dto: SupplierInvoiceDto; supplierId: string }
const resolved: ResolvedSupplierInvoice[] = []
@@ -704,29 +755,29 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (stubByKey.size > 0) {
const stubList = [...stubByKey.values()]
for (const batch of chunk(stubList, INSERT_CHUNK_SIZE)) {
const { data: inserted, error } = await supabase
.from('suppliers')
.insert(batch.map((s) => s.row))
.select('id, org_number, name')
const outcome = await insertWithPerRowFallback(
supabase, 'suppliers', batch.map((s) => s.row), 'id, org_number, name'
)
if (error) {
if (outcome.failedCount > 0) {
console.error(
`[migration] Supplier invoice supplier stub insert failed (${batch.length}):`,
error.message
`[migration] Supplier invoice supplier stub insert failed for ${outcome.failedCount} of ${batch.length} rows:`,
outcome.firstError
)
for (const s of batch) {
for (const idx of s.waitingInvoiceIndices) {
resolved[idx] = { ...resolved[idx], supplierId: '__FAILED__' }
}
}
continue
errorSample ??= outcome.firstError
}
const insertedRows = inserted ?? []
for (let i = 0; i < batch.length && i < insertedRows.length; i++) {
const newId = insertedRows[i].id
if (insertedRows[i].org_number) orgNumberToSupplierId.set(insertedRows[i].org_number!, newId)
if (insertedRows[i].name) nameToSupplierId.set(insertedRows[i].name!, newId)
for (let i = 0; i < batch.length; i++) {
const insertedRow = outcome.returned[i]
if (!insertedRow) {
for (const idx of batch[i].waitingInvoiceIndices) {
resolved[idx] = { ...resolved[idx], supplierId: '__FAILED__' }
}
continue
}
const newId = insertedRow.id as string
if (insertedRow.org_number) orgNumberToSupplierId.set(insertedRow.org_number as string, newId)
if (insertedRow.name) nameToSupplierId.set(insertedRow.name as string, newId)
for (const idx of batch[i].waitingInvoiceIndices) {
resolved[idx] = { ...resolved[idx], supplierId: newId }
}
@@ -735,20 +786,30 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
// After stubs, do a final dedupe pass against existing supplier invoices
// using the now-resolved supplierId.
// using the now-resolved supplierId. The in-run key set catches the
// same (supplier, number) pair appearing twice in the fetched data,
// which the UNIQUE (company_id, supplier_id, supplier_invoice_number)
// index would otherwise reject mid-insert. NULL/empty numbers are
// exempt: the index treats NULLs as distinct.
const seenSuppInvKeys = new Set<string>()
const ready = resolved.filter((r) => {
if (r.supplierId === '__FAILED__' || !r.supplierId) {
// Failed stub insert = DB failure with errorSample set, so count
// it as failed; noMatch would hide the error in the result row.
if (r.supplierId === '__FAILED__') {
skipReasons.noMatch = (skipReasons.noMatch ?? 0) + 1
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
}
return false
}
const dupKey = `${r.supplierId}::${r.dto.invoiceNumber}`
if (existingSuppInvKeys.has(dupKey)) {
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
return false
if (r.dto.invoiceNumber) {
const dupKey = `${r.supplierId}::${r.dto.invoiceNumber}`
if (existingSuppInvKeys.has(dupKey) || seenSuppInvKeys.has(dupKey)) {
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
return false
}
seenSuppInvKeys.add(dupKey)
}
return true
})
@@ -770,24 +831,29 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
return { invoice, items, fxUnresolved: fx, dto: r.dto }
})
const { data: insertedInvoices, error: invErr } = await supabase
.from('supplier_invoices')
.insert(mappedBatch.map((m) => m.invoice))
.select('id')
const outcome = await insertWithPerRowFallback(
supabase, 'supplier_invoices', mappedBatch.map((m) => m.invoice), 'id'
)
if (invErr) {
console.error(`[migration] Supplier invoice batch insert failed (${batch.length}):`, invErr.message)
skipReasons.failed = (skipReasons.failed ?? 0) + batch.length
skipped += batch.length
// Roll the counter back so we don't leave a huge gap on retry.
nextArrivalNumber -= batch.length
continue
if (outcome.failedCount > 0) {
console.error(
`[migration] Supplier invoice insert failed for ${outcome.failedCount} of ${batch.length} rows:`,
outcome.firstError
)
skipReasons.failed = (skipReasons.failed ?? 0) + outcome.failedCount
skipped += outcome.failedCount
errorSample ??= outcome.firstError
// A failed row leaves a hole in the arrival numbering. That is
// acceptable: ankomstnummer is an internal sequence, not a
// verifikationsnummer, and rewinding the counter after a PARTIAL
// success would hand out numbers that already landed.
}
const invoiceRows = insertedInvoices ?? []
const allItems: Record<string, unknown>[] = []
for (let i = 0; i < mappedBatch.length && i < invoiceRows.length; i++) {
const invoiceId = invoiceRows[i].id
for (let i = 0; i < mappedBatch.length; i++) {
const insertedRow = outcome.returned[i]
if (!insertedRow) continue
const invoiceId = insertedRow.id
for (const item of mappedBatch[i].items) {
allItems.push({ ...item, supplier_invoice_id: invoiceId })
}
@@ -809,7 +875,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
results.supplierInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved }
results.supplierInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, errorSample: errorSample ?? undefined }
} catch (err) {
console.error('Failed to import supplier invoices:', err)
}
+4 -4
View File
@@ -78,10 +78,10 @@ export interface SkipReasons {
*/
export 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; fxUnresolved?: number }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; fxUnresolved?: number }
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; fxUnresolved?: number; errorSample?: string }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; fxUnresolved?: number; errorSample?: string }
/**
* Auto-reconciliation of imported supplier invoices to the GL payment
* vouchers that the separate SIE import already posted. `autoLinked` invoices
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { VismaClient } from '../client';
/**
* Guards the eAccounting pagination convention: the API paginates with
* $page/$pagesize and silently IGNORES OData $top/$skip. When the client sent
* $top/$skip, every request returned page 1, so getPaginated appended the
* first page TotalNumberOfPages times: N-fold duplicate customers, and
* whole-chunk unique violations on invoice import (the "300 misslyckades"
* support case).
*/
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function page(items: unknown[], totalPages: number, totalCount?: number): Response {
return jsonResponse({
Meta: {
TotalNumberOfPages: totalPages,
TotalNumberOfResults: totalCount ?? items.length,
},
Data: items,
});
}
describe('VismaClient pagination', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
function requestedUrl(callIndex: number): URL {
const [input] = fetchSpy.mock.calls[callIndex];
return new URL(String(input));
}
it('getPage sends $page/$pagesize, never $top/$skip', async () => {
fetchSpy.mockResolvedValueOnce(page([{ Id: 'a' }], 1));
const client = new VismaClient();
await client.getPage('token', '/customers', { page: 2, pageSize: 100 });
const url = requestedUrl(0);
expect(url.searchParams.get('$page')).toBe('2');
expect(url.searchParams.get('$pagesize')).toBe('100');
expect(url.searchParams.has('$top')).toBe(false);
expect(url.searchParams.has('$skip')).toBe(false);
});
it('getPaginated walks every page once and concatenates in order', async () => {
fetchSpy
.mockResolvedValueOnce(page([{ Id: 'a' }, { Id: 'b' }], 3, 5))
.mockResolvedValueOnce(page([{ Id: 'c' }, { Id: 'd' }], 3, 5))
.mockResolvedValueOnce(page([{ Id: 'e' }], 3, 5));
const client = new VismaClient();
const items = await client.getPaginated<{ Id: string }>('token', '/customerinvoices');
expect(items.map((i) => i.Id)).toEqual(['a', 'b', 'c', 'd', 'e']);
expect(fetchSpy).toHaveBeenCalledTimes(3);
expect(requestedUrl(0).searchParams.get('$page')).toBe('1');
expect(requestedUrl(1).searchParams.get('$page')).toBe('2');
expect(requestedUrl(2).searchParams.get('$page')).toBe('3');
});
it('getPaginated stops on an empty page even if Meta promises more', async () => {
fetchSpy
.mockResolvedValueOnce(page([{ Id: 'a' }], 99))
.mockResolvedValueOnce(page([], 99));
const client = new VismaClient();
const items = await client.getPaginated<{ Id: string }>('token', '/customers');
expect(items).toHaveLength(1);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
});
+11 -2
View File
@@ -88,8 +88,12 @@ export class VismaClient {
const page = options?.page ?? 1;
const params = new URLSearchParams();
params.set('$top', String(pageSize));
params.set('$skip', String((page - 1) * pageSize));
// eAccounting paginates with $page/$pagesize (default 50, max 1000).
// OData-style $top/$skip are silently IGNORED by the API: every request
// returns page 1, so a multi-page fetch yields the first page N times
// and never reaches the rest of the collection.
params.set('$page', String(page));
params.set('$pagesize', String(pageSize));
if (options?.modifiedSince && options?.modifiedField) {
params.set(
@@ -135,6 +139,11 @@ export class VismaClient {
modifiedField: options?.modifiedField,
});
// An empty page means the collection is exhausted regardless of what
// Meta claims; trusting a stale/buggy TotalNumberOfPages here would
// re-append duplicate rows or loop for nothing.
if (result.items.length === 0) break;
allItems.push(...result.items);
totalPages = result.totalPages;
page++;