f33628f005
* feat(import): say in the SIE wizard that the chart and fiscal year come along The preview scored the file's accounts against the BAS reference and said "matchas mot din kontoplan", so a consultant with a 41-account seeded company read "150 mappade" as "the file's chart replaces mine". A fiscal-year overlap with a non-empty period was only refused after the mapping step. - Parse route adds preview.chart (accounts new to THIS company vs already present, with a sample) via planChartChanges, and preview.fiscalYear from precheckFiscalPeriod: the containment/overlap verdict extracted out of ensureFiscalPeriod, which now consumes it, so preview and import cannot drift. - Preview card renamed to Kontoplan with the counts and the fiscal-year verdict (match / create / conflict with the import's own refusal text). - Review step lists the chart among "Vad händer när du importerar?". - executeSIEImport reports accountsCreated from the account sync; the result grid gets a Konton skapade card. No import logic changed; no migration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MgxEaU52nJgDQA41svdtC * fix(import): preview refuses what the import refuses, and the chart card survives "Skapa saknade konton" Skeptic pass on #2307 refuted the first cut twice: - "Skapas vid import" was shown for a #RAR the import then refuses under BFL 3 kap. (19 months, non-month-end finish, mid-month start after an earlier year). The shape rules move into precheckFiscalPeriod as a fourth verdict 'invalid' with the same refusal text; ensureFiscalPeriod stays a consumer of one verdict, same query order. - The Kontoplan card counted unmapped sources under "Läggs till" and kept listing them after the create button, while the result said 0 created. planChartChanges (now client-safe in lib/import/chart-plan.ts) counts mapped targets only; the create button moves those accounts from "Ej mappade" to "Finns redan" in place. - The review line claimed existing accounts keep their name unless you opt in; the switch defaults to on. Reworded to match. - Sample names follow the file for identity mappings, as the sync does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MgxEaU52nJgDQA41svdtC --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
211 lines
7.2 KiB
TypeScript
211 lines
7.2 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import {
|
|
parseSIEFile,
|
|
validateSIEFile,
|
|
detectEncoding,
|
|
decodeBuffer,
|
|
calculateFileHash,
|
|
} from '@/lib/import/sie-parser'
|
|
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
|
|
import { planChartChanges } from '@/lib/import/chart-plan'
|
|
import { scanSieForCp1252Artifacts, formatSieArtifactWarning } from '@/lib/import/sie-artifact-scan'
|
|
import {
|
|
generateImportPreview,
|
|
checkDuplicateImport,
|
|
checkDuplicatePeriodImport,
|
|
precheckFiscalPeriod,
|
|
} from '@/lib/import/sie-import'
|
|
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import type { SIEAccountMappingRecord } from '@/lib/import/types'
|
|
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
|
|
|
/**
|
|
* POST /api/import/sie/parse
|
|
* Parse an uploaded SIE file and return preview data.
|
|
*/
|
|
export const POST = withRouteContext(
|
|
'sie_import.parse',
|
|
async (request, ctx) => {
|
|
const { supabase, companyId, log, requestId } = ctx
|
|
|
|
const formData = await request.formData()
|
|
const file = formData.get('file') as File | null
|
|
|
|
if (!file) {
|
|
return errorResponseFromCode('SIE_PARSE_NO_FILE', log, { requestId })
|
|
}
|
|
|
|
const filename = file.name.toLowerCase()
|
|
if (!filename.endsWith('.sie') && !filename.endsWith('.se')) {
|
|
return errorResponseFromCode('SIE_PARSE_INVALID_TYPE', log, {
|
|
requestId,
|
|
details: { filename: file.name },
|
|
})
|
|
}
|
|
|
|
const MAX_FILE_SIZE = 50 * 1024 * 1024
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
return errorResponseFromCode('SIE_PARSE_FILE_TOO_LARGE', log, {
|
|
requestId,
|
|
details: { sizeMb: +(file.size / 1024 / 1024).toFixed(1) },
|
|
})
|
|
}
|
|
|
|
if (file.size === 0) {
|
|
return errorResponseFromCode('SIE_PARSE_EMPTY', log, { requestId })
|
|
}
|
|
|
|
const opLog = log.child({ filename: file.name, sizeBytes: file.size })
|
|
|
|
try {
|
|
const arrayBuffer = await file.arrayBuffer()
|
|
const encoding = detectEncoding(arrayBuffer)
|
|
const content = decodeBuffer(arrayBuffer, encoding)
|
|
|
|
const duplicate = await checkDuplicateImport(supabase, companyId!, content)
|
|
if (duplicate) {
|
|
return errorResponseFromCode('SIE_DUPLICATE_FILE', opLog, {
|
|
requestId,
|
|
details: {
|
|
importId: duplicate.id,
|
|
importedAt: duplicate.imported_at,
|
|
},
|
|
})
|
|
}
|
|
|
|
const parsed = parseSIEFile(content)
|
|
|
|
// Mojibake tripwire (warn, never block): CP437 bytes decoded as
|
|
// windows-1252 somewhere upstream leave C1 specials mid-word in account
|
|
// names and voucher texts. Surface it as a parse-issue warning, the
|
|
// preview's existing warnings card, so the user can abort before import.
|
|
const artifactScan = scanSieForCp1252Artifacts(parsed)
|
|
if (artifactScan.flagged) {
|
|
const contentLines = content.split(/\r?\n/)
|
|
const firstSample = artifactScan.samples[0]
|
|
const sampleLine = firstSample
|
|
? contentLines.findIndex((l) => l.includes(firstSample))
|
|
: -1
|
|
parsed.issues.push({
|
|
severity: 'warning',
|
|
line: sampleLine >= 0 ? sampleLine + 1 : 1,
|
|
message: formatSieArtifactWarning(artifactScan),
|
|
})
|
|
opLog.warn('sie parse: CP1252 mojibake artifacts in decoded content', {
|
|
encoding,
|
|
artifactCount: artifactScan.artifactCount,
|
|
samples: artifactScan.samples,
|
|
})
|
|
}
|
|
|
|
if (parsed.stats.fiscalYearStart && parsed.stats.fiscalYearEnd) {
|
|
const periodDuplicate = await checkDuplicatePeriodImport(
|
|
supabase,
|
|
companyId!,
|
|
parsed.stats.fiscalYearStart,
|
|
parsed.stats.fiscalYearEnd,
|
|
)
|
|
if (periodDuplicate) {
|
|
return errorResponseFromCode('SIE_DUPLICATE_PERIOD', opLog, {
|
|
requestId,
|
|
details: {
|
|
importId: periodDuplicate.id,
|
|
fiscalYearStart: periodDuplicate.fiscal_year_start,
|
|
fiscalYearEnd: periodDuplicate.fiscal_year_end,
|
|
importedAt: periodDuplicate.imported_at,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
if (!validation.valid) {
|
|
return errorResponseFromCode('SIE_PARSE_VALIDATION_FAILED', opLog, {
|
|
requestId,
|
|
details: { errors: validation.errors, warnings: validation.warnings },
|
|
})
|
|
}
|
|
|
|
const excludedSystemAccounts = parsed.accounts
|
|
.filter((a) => isSystemAccount(a.number))
|
|
.map((a) => ({ number: a.number, name: a.name }))
|
|
const bookkeepingAccounts = parsed.accounts.filter((a) => !isSystemAccount(a.number))
|
|
|
|
const { data: storedMappings } = await supabase
|
|
.from('sie_account_mappings')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
|
|
const mappings = suggestMappings(
|
|
bookkeepingAccounts,
|
|
BAS_REFERENCE,
|
|
(storedMappings as SIEAccountMappingRecord[]) || undefined,
|
|
)
|
|
|
|
const preview = generateImportPreview(parsed, mappings)
|
|
preview.excludedSystemAccounts = excludedSystemAccounts
|
|
preview.accountCount = bookkeepingAccounts.length
|
|
|
|
// The mapping stats above score the file against the BAS reference. A
|
|
// consultant with a 41-account seeded company reads "150 mappade" as
|
|
// "your chart replaces mine", so also say what happens to THIS company's
|
|
// chart: how many of the file's accounts are new here, how many exist.
|
|
const chartRows = await fetchAllRows<{ account_number: string }>(({ from, to }) =>
|
|
supabase
|
|
.from('chart_of_accounts')
|
|
.select('account_number')
|
|
.eq('company_id', companyId)
|
|
.order('account_number')
|
|
.range(from, to),
|
|
)
|
|
preview.chart = planChartChanges(
|
|
mappings,
|
|
new Set(chartRows.map((r) => r.account_number)),
|
|
)
|
|
|
|
// Same containment/overlap verdict the import runs (ensureFiscalPeriod),
|
|
// so a fiscal-year conflict shows here instead of after the mapping step.
|
|
if (parsed.stats.fiscalYearStart && parsed.stats.fiscalYearEnd) {
|
|
preview.fiscalYear = await precheckFiscalPeriod(
|
|
supabase,
|
|
companyId!,
|
|
parsed.stats.fiscalYearStart,
|
|
parsed.stats.fiscalYearEnd,
|
|
)
|
|
}
|
|
|
|
const fileHash = await calculateFileHash(content)
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
encoding,
|
|
fileHash,
|
|
parsed: {
|
|
header: parsed.header,
|
|
accounts: parsed.accounts,
|
|
stats: parsed.stats,
|
|
issues: parsed.issues,
|
|
},
|
|
mappings,
|
|
mappingStats: getMappingStats(mappings),
|
|
preview,
|
|
validation: {
|
|
valid: validation.valid,
|
|
errors: validation.errors,
|
|
warnings: validation.warnings,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
opLog.error('sie parse failed', err as Error)
|
|
return errorResponseFromCode('SIE_PARSE_FAILED', opLog, {
|
|
requestId,
|
|
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
|
|
})
|
|
}
|
|
},
|
|
)
|