feat(arcim-migration): import the Fortnox asset register during migration (#1999)
The Fortnox migration now imports the asset register (GET /3/assets + /3/assets/types) as local register rows via createAsset: category from the type's anskaffningskonto BAS class, useful life from the source's depreciation window (K2 schablon fallback), never any journal entries (values arrived via SIE; the source's depreciated-to date is recorded in notes for review of the first proposal). Sold/scrapped/voided assets are skipped, re-runs dedupe, one bad asset counts as skipped. Gated behind FORTNOX_ASSET_SCOPES_APPROVED=false until the portal registration for integration 39254 carries the Assets scope, so hosted consents are unchanged and the wizard shows an honest skipped row. Co-authored-by: pgronberg <pgronberg@users.noreply.github.com>
This commit is contained in:
co-authored by
pgronberg
parent
6ac9679fb5
commit
8b0aa80ea0
@@ -219,40 +219,15 @@ function apiErrorCode(data: unknown): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
interface SkipReasons {
|
||||
duplicate?: number
|
||||
inactive?: number
|
||||
failed?: number
|
||||
noMatch?: number
|
||||
}
|
||||
|
||||
interface MigrationStepError {
|
||||
step: 'companyInfo' | 'customers' | 'suppliers' | 'salesInvoices' | 'supplierInvoices' | 'registrationLinks' | 'reconciliation'
|
||||
code: string | null
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Mirrors MigrationResults.registrationLinks in extensions/general/arcim-migration/types.ts. */
|
||||
interface RegistrationLinkCounts {
|
||||
scanned: number
|
||||
linked: number
|
||||
noRef: number
|
||||
refNotFetched: number
|
||||
unresolved: number
|
||||
ambiguous: number
|
||||
amountMismatch: number
|
||||
alreadyLinked: number
|
||||
}
|
||||
|
||||
interface MigrationResults {
|
||||
companyInfo?: { imported: boolean }
|
||||
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 }
|
||||
registrationLinks?: RegistrationLinkCounts
|
||||
stepErrors?: MigrationStepError[]
|
||||
}
|
||||
// The migration result contract is owned by the extension: a second
|
||||
// hand-written copy here would drift from the API. Type-only import, same
|
||||
// pattern as the other extension workspaces.
|
||||
import type {
|
||||
MigrationResults,
|
||||
MigrationStepError,
|
||||
SkipReasons,
|
||||
AssetSkipReasons,
|
||||
} from '@/extensions/general/arcim-migration/types'
|
||||
import AccountMappingStep from '@/components/import/AccountMappingStep'
|
||||
import ArcimMigrationTheater from '@/components/extensions/general/ArcimMigrationTheater'
|
||||
import TheaterCanvas from '@/components/import/TheaterCanvas'
|
||||
@@ -290,6 +265,7 @@ interface MigrationOptions {
|
||||
importSuppliers: boolean
|
||||
importSalesInvoices: boolean
|
||||
importSupplierInvoices: boolean
|
||||
importAssets: boolean
|
||||
voucherSeries: string
|
||||
}
|
||||
|
||||
@@ -300,6 +276,7 @@ const DEFAULT_OPTIONS: MigrationOptions = {
|
||||
importSuppliers: true,
|
||||
importSalesInvoices: true,
|
||||
importSupplierInvoices: true,
|
||||
importAssets: true,
|
||||
voucherSeries: 'B',
|
||||
}
|
||||
|
||||
@@ -327,6 +304,10 @@ interface PreviewData {
|
||||
transactionCount: number
|
||||
fiscalYears: number[]
|
||||
} | null
|
||||
assetStats: {
|
||||
total: number
|
||||
importable: number
|
||||
} | null
|
||||
hasSieData: boolean
|
||||
}
|
||||
|
||||
@@ -924,19 +905,33 @@ function PreviewStep({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SIE stats: one quiet statline, the same grammar as the import
|
||||
reveal, instead of a boxed summary. */}
|
||||
{preview?.sieAvailable && preview.sieStats && (
|
||||
<p className="animate-fade-in mt-3 text-[13px] text-muted-foreground tabular-nums">
|
||||
{preview.sieStats.accountCount.toLocaleString('sv-SE')} konton
|
||||
{' · '}
|
||||
{preview.sieStats.transactionCount.toLocaleString('sv-SE')} verifikationer
|
||||
{' · '}
|
||||
{preview.sieStats.fiscalYears.length === 1
|
||||
? `räkenskapsåret ${preview.sieStats.fiscalYears[0]}`
|
||||
: `${preview.sieStats.fiscalYears.length} räkenskapsår: ${preview.sieStats.fiscalYears.join(', ')}`}
|
||||
</p>
|
||||
)}
|
||||
{/* SIE + asset stats: one quiet statline, the same grammar as the
|
||||
import reveal, instead of a boxed summary. The asset count renders
|
||||
on its own when the SIE fetch failed: the preview endpoint sets
|
||||
them independently. */}
|
||||
{(() => {
|
||||
const sieStats = preview?.sieAvailable ? preview.sieStats : null
|
||||
const assetCount = preview?.assetStats?.importable ?? 0
|
||||
if (!sieStats && assetCount === 0) return null
|
||||
const parts: string[] = []
|
||||
if (sieStats) {
|
||||
parts.push(`${sieStats.accountCount.toLocaleString('sv-SE')} konton`)
|
||||
parts.push(`${sieStats.transactionCount.toLocaleString('sv-SE')} verifikationer`)
|
||||
parts.push(
|
||||
sieStats.fiscalYears.length === 1
|
||||
? `räkenskapsåret ${sieStats.fiscalYears[0]}`
|
||||
: `${sieStats.fiscalYears.length} räkenskapsår: ${sieStats.fiscalYears.join(', ')}`,
|
||||
)
|
||||
}
|
||||
if (assetCount > 0) {
|
||||
parts.push(`${assetCount.toLocaleString('sv-SE')} anläggningstillgångar`)
|
||||
}
|
||||
return (
|
||||
<p className="animate-fade-in mt-3 text-[13px] text-muted-foreground tabular-nums">
|
||||
{parts.join(' · ')}
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
|
||||
{preview && !preview.sieAvailable && !isLoading && preview.hasSieData && (
|
||||
<p className="animate-fade-in mt-3 text-[13px] text-muted-foreground">
|
||||
@@ -1111,6 +1106,7 @@ function OptionsStep({
|
||||
if (options.importSuppliers) selectedItems.push('Leverantörer')
|
||||
if (options.importSalesInvoices) selectedItems.push('Kundfakturor')
|
||||
if (options.importSupplierInvoices) selectedItems.push('Leverantörsfakturor')
|
||||
if (provider === 'fortnox' && options.importAssets) selectedItems.push('Anläggningstillgångar')
|
||||
|
||||
// Entities without the SIE-derived ledger leave an incomplete bokföring:
|
||||
// POST /migrate refuses with PROVIDER_SIE_IMPORT_REQUIRED unless a completed
|
||||
@@ -1230,6 +1226,14 @@ function OptionsStep({
|
||||
checked={options.importSupplierInvoices}
|
||||
onChange={() => toggleOption('importSupplierInvoices')}
|
||||
/>
|
||||
{provider === 'fortnox' && (
|
||||
<OptionRow
|
||||
label="Anläggningstillgångar"
|
||||
description="Anläggningsregistret med avskrivningsplaner. Bokförda värden kommer via SIE; registret gör att avskrivningarna fortsätter automatiskt."
|
||||
checked={options.importAssets}
|
||||
onChange={() => toggleOption('importAssets')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sieRequiredButUnchecked && (
|
||||
@@ -1764,7 +1768,8 @@ function ResultStep({
|
||||
(results.customers && (results.customers.imported > 0 || (results.customers.updated ?? 0) > 0 || results.customers.skipped > 0)) ||
|
||||
(results.suppliers && (results.suppliers.imported > 0 || results.suppliers.skipped > 0)) ||
|
||||
(results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0)) ||
|
||||
(results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0))
|
||||
(results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0)) ||
|
||||
(results.assets && (results.assets.imported > 0 || results.assets.skipped > 0 || results.assets.scopesMissing))
|
||||
)
|
||||
|
||||
// Steps that failed against the provider API. An empty sync with failed
|
||||
@@ -1784,7 +1789,7 @@ function ResultStep({
|
||||
? fyCount === 1
|
||||
? `${totalJournalEntries.toLocaleString('sv-SE')} verifikationer är på plats.`
|
||||
: `${totalJournalEntries.toLocaleString('sv-SE')} verifikationer över ${fyCount} räkenskapsår är på plats.`
|
||||
: !allSieSucceeded || totalErrors > 0
|
||||
: (sieResults.length > 0 && !allSieSucceeded) || totalErrors > 0
|
||||
? 'Migreringen är klar, med anmärkningar.'
|
||||
: 'Migreringen är klar.'
|
||||
|
||||
@@ -1867,6 +1872,19 @@ function ResultStep({
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
if (results.assets && (results.assets.imported > 0 || results.assets.skipped > 0 || results.assets.scopesMissing)) {
|
||||
entityLines.push({
|
||||
label: 'Anläggningstillgångar',
|
||||
value: results.assets.scopesMissing ? 'Hoppades över' : `${results.assets.imported} importerade`,
|
||||
detail: results.assets.scopesMissing
|
||||
? 'Fortnox-anslutningen saknar behörighet till anläggningsregistret (assets-scope). Bokförda värden är ändå med via SIE.'
|
||||
: results.assets.skipped > 0
|
||||
? formatSkipReasons(results.assets.skipReasons, 'asset', results.assets.errorSample) ?? `${results.assets.skipped} hoppades över`
|
||||
: undefined,
|
||||
failed: !results.assets.scopesMissing &&
|
||||
entityRowStatus(results.assets.imported, results.assets.skipReasons) === 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -2006,6 +2024,7 @@ const STEP_ERROR_LABELS: Record<MigrationStepError['step'], string> = {
|
||||
suppliers: 'Leverantörer',
|
||||
salesInvoices: 'Kundfakturor',
|
||||
supplierInvoices: 'Leverantörsfakturor',
|
||||
assets: 'Anläggningstillgångar',
|
||||
registrationLinks: 'Koppling till verifikationer',
|
||||
reconciliation: 'Avstämning av betalningar',
|
||||
}
|
||||
@@ -2025,14 +2044,21 @@ function groupStepErrors(errors: MigrationStepError[]): { message: string; steps
|
||||
}
|
||||
|
||||
function formatSkipReasons(
|
||||
reasons?: SkipReasons,
|
||||
entityType?: 'customer' | 'supplier' | 'invoice',
|
||||
reasons?: AssetSkipReasons,
|
||||
entityType?: 'customer' | 'supplier' | 'invoice' | 'asset',
|
||||
errorSample?: string,
|
||||
): string | undefined {
|
||||
if (!reasons) return undefined
|
||||
const parts: string[] = []
|
||||
if (reasons.duplicate) parts.push(`${reasons.duplicate} fanns redan`)
|
||||
if (reasons.inactive) parts.push(`${reasons.inactive} inaktiv${reasons.inactive > 1 ? 'a' : ''}`)
|
||||
if (reasons.inactive) {
|
||||
parts.push(
|
||||
entityType === 'asset'
|
||||
? `${reasons.inactive} avyttrad${reasons.inactive > 1 ? 'e' : ''} eller annullerad${reasons.inactive > 1 ? 'e' : ''}`
|
||||
: `${reasons.inactive} inaktiv${reasons.inactive > 1 ? 'a' : ''}`,
|
||||
)
|
||||
}
|
||||
if (reasons.unsupported) parts.push(`${reasons.unsupported} kunde inte tolkas`)
|
||||
if (reasons.noMatch) {
|
||||
const matchLabel = entityType === 'invoice' ? 'utan matchning' : 'utan matchning'
|
||||
parts.push(`${reasons.noMatch} ${matchLabel}`)
|
||||
@@ -2852,11 +2878,19 @@ export default function ArcimMigrationWorkspace({
|
||||
}
|
||||
|
||||
// ── Phase 2: API import (customers, suppliers, invoices) ──
|
||||
// The asset toggle is only rendered for Fortnox (the one provider with
|
||||
// an asset register API), but its DEFAULT_OPTIONS value stays true for
|
||||
// everyone. Gate it on the provider here too, so a hidden option can
|
||||
// never be the reason /migrate starts for a user who deselected every
|
||||
// visible API import.
|
||||
const effectiveImportAssets =
|
||||
selectedProvider === 'fortnox' && migrationOptions.importAssets
|
||||
const hasApiImport = migrationOptions.importCompanyInfo ||
|
||||
migrationOptions.importCustomers ||
|
||||
migrationOptions.importSuppliers ||
|
||||
migrationOptions.importSalesInvoices ||
|
||||
migrationOptions.importSupplierInvoices
|
||||
migrationOptions.importSupplierInvoices ||
|
||||
effectiveImportAssets
|
||||
|
||||
let hadStepErrors = false
|
||||
if (hasApiImport) {
|
||||
@@ -2873,6 +2907,7 @@ export default function ArcimMigrationWorkspace({
|
||||
importSuppliers: migrationOptions.importSuppliers,
|
||||
importSalesInvoices: migrationOptions.importSalesInvoices,
|
||||
importSupplierInvoices: migrationOptions.importSupplierInvoices,
|
||||
importAssets: effectiveImportAssets,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user