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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
cf373e9dd1
commit
a49d75db77
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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++;
|
||||
|
||||
Reference in New Issue
Block a user