* feat(arcim): import Bokio underlag and link to verifikat Adds an optional, re-runnable step that pages the Bokio /uploads, resolves each receipt's target verifikat via the SIE-preserved voucher number, and archives it through the document service linked to the journal entry. Closes the gap where neither the SIE GL import nor the entity import carries the receipts/underlag attached to each verifikat. - lib/providers/bokio: getBytes() binary download + an attachments resource module (uploads list, GUID->voucher index, per-upload download); pageSize capped at 100, file type taken from the upload's contentType since the download is octet-stream - importProviderDocuments: bulk in-memory resolution keyed on (fiscal period, series, number) — scoped per fiscal year because Bokio restarts numbering at V1 each year; idempotent on (company_id, sha256) so re-runs don't duplicate the undeletable BFL-linked rows - POST /import-documents route, kept off the migration critical path because the Bokio document API is rate-limited (200 req/60s) - journal_entry_id link only for v1; reuses the document-service link path (same module as #804) rather than forking it Closes #786 Signed-off-by: Jonas Hagberg <jonas@lindan.se> * fix(arcim): stable pagination order + account for unresolvable receipts Addresses two findings from a Codex review pass on the import step: - Add .order('id') to the paged journal_entries / document_attachments / fiscal_periods reads. fetchAllRows pages with .range(), and PostgREST paging without a deterministic order can skip/repeat rows once a table exceeds one page (journal_entries crosses 1000 across several migrated years), which would defeat both voucher resolution and the sha256 dedup. - Keep every upload carrying a journalEntryId in scope instead of pre-filtering on a resolvable voucher ref, so a receipt whose Bokio entry number didn't parse (or resolves to no verifikat) is counted as unmatched rather than silently dropped from the best-effort report. Tests: add unresolvable-ref and zero-uploads cases; mock now supports .order(). Signed-off-by: Jonas Hagberg <jonas@lindan.se> --------- Signed-off-by: Jonas Hagberg <jonas@lindan.se>
125 lines
3.6 KiB
TypeScript
125 lines
3.6 KiB
TypeScript
import type { BokioClient } from './client';
|
|
|
|
/**
|
|
* Bokio document (upload) resource config + fetchers.
|
|
*
|
|
* Bokio exposes receipts/underlag via two endpoints:
|
|
* - GET /companies/{cid}/uploads — list, carries journalEntryId
|
|
* - GET /companies/{cid}/uploads/{id}/download — raw bytes (octet-stream)
|
|
*
|
|
* The list does NOT include a filename, and the download is served as
|
|
* application/octet-stream — so the real file type comes from the list item's
|
|
* `contentType`, and a filename has to be synthesised by the caller.
|
|
*
|
|
* The link between an upload and a gnubok verifikat is recovered from the
|
|
* Bokio journal entry's human voucher number (e.g. "V342"): the SIE import
|
|
* preserves it on journal_entries.source_voucher_series / source_voucher_number.
|
|
* Bokio restarts numbering at V1 every fiscal year, so the number alone is not
|
|
* unique — callers must scope the match by fiscal year (the entry's date).
|
|
*/
|
|
|
|
const UPLOADS_PATH = '/uploads';
|
|
const JOURNAL_ENTRIES_PATH = '/journal-entries';
|
|
|
|
/** Bokio's pageSize caps at 100. */
|
|
const PAGE_SIZE = 100;
|
|
|
|
/** A `V342`-style voucher number: one or more letters (series) + digits. */
|
|
const VOUCHER_NUMBER_RE = /^([A-Za-z]+)(\d+)$/;
|
|
|
|
export interface BokioUpload {
|
|
id: string;
|
|
description: string | null;
|
|
contentType: string | null;
|
|
journalEntryId: string | null;
|
|
}
|
|
|
|
interface BokioJournalEntry {
|
|
id: string;
|
|
journalEntryNumber: string | null;
|
|
date: string;
|
|
}
|
|
|
|
/** A Bokio voucher reference parsed from its journalEntryNumber. */
|
|
export interface BokioVoucherRef {
|
|
/** Voucher series letter(s), e.g. "V". */
|
|
series: string;
|
|
/** Numeric part of the voucher number, e.g. 342. */
|
|
number: number;
|
|
/** Entry date (YYYY-MM-DD) — used to scope the match by fiscal year. */
|
|
date: string;
|
|
}
|
|
|
|
async function paginate<T>(
|
|
client: BokioClient,
|
|
accessToken: string,
|
|
companyId: string,
|
|
path: string,
|
|
): Promise<T[]> {
|
|
const all: T[] = [];
|
|
let page = 1;
|
|
let totalPages = 1;
|
|
|
|
do {
|
|
const result = await client.getPage<T>(accessToken, companyId, path, {
|
|
page,
|
|
pageSize: PAGE_SIZE,
|
|
});
|
|
all.push(...result.items);
|
|
totalPages = result.totalPages;
|
|
page++;
|
|
} while (page <= totalPages);
|
|
|
|
return all;
|
|
}
|
|
|
|
/** Page every upload (receipt) for a company. */
|
|
export function fetchBokioUploads(
|
|
client: BokioClient,
|
|
accessToken: string,
|
|
companyId: string,
|
|
): Promise<BokioUpload[]> {
|
|
return paginate<BokioUpload>(client, accessToken, companyId, UPLOADS_PATH);
|
|
}
|
|
|
|
/**
|
|
* Build a GUID → voucher-reference index from Bokio's journal entries.
|
|
* An upload only carries the entry's GUID; this resolves it to the human
|
|
* voucher number (and date) that gnubok preserved from the SIE import.
|
|
* Entries with an unparseable number are skipped.
|
|
*/
|
|
export async function fetchBokioVoucherIndex(
|
|
client: BokioClient,
|
|
accessToken: string,
|
|
companyId: string,
|
|
): Promise<Map<string, BokioVoucherRef>> {
|
|
const entries = await paginate<BokioJournalEntry>(
|
|
client,
|
|
accessToken,
|
|
companyId,
|
|
JOURNAL_ENTRIES_PATH,
|
|
);
|
|
|
|
const index = new Map<string, BokioVoucherRef>();
|
|
for (const entry of entries) {
|
|
const match = VOUCHER_NUMBER_RE.exec(entry.journalEntryNumber ?? '');
|
|
if (!match) continue;
|
|
index.set(entry.id, {
|
|
series: match[1],
|
|
number: Number(match[2]),
|
|
date: entry.date,
|
|
});
|
|
}
|
|
return index;
|
|
}
|
|
|
|
/** Download a single upload's bytes. */
|
|
export function downloadBokioUpload(
|
|
client: BokioClient,
|
|
accessToken: string,
|
|
companyId: string,
|
|
uploadId: string,
|
|
): Promise<{ bytes: ArrayBuffer; contentType: string | null }> {
|
|
return client.getBytes(accessToken, companyId, `${UPLOADS_PATH}/${uploadId}/download`);
|
|
}
|