Fixed user issues (#559)
* Fixed user issues * feat: add personal_number column to customers for individual identification * feat: add personal_number field to makeCustomer function for enhanced customer identification * feat: add personal_number column with constraint check for customer identification
This commit is contained in:
@@ -261,6 +261,11 @@ export const CreateCustomerSchema = z.object({
|
||||
country: z.string().optional(),
|
||||
org_number: z.string().optional(),
|
||||
vat_number: z.string().optional(),
|
||||
personal_number: z
|
||||
.string()
|
||||
.regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, 'Invalid personal number')
|
||||
.optional()
|
||||
.nullable(),
|
||||
default_payment_terms: z.number().int().positive().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -135,6 +135,11 @@ describe('generateFullArchive', () => {
|
||||
file_name: 'receipt.pdf',
|
||||
storage_path: 'documents/user-1/receipt.pdf',
|
||||
journal_entry_id: 'entry-1',
|
||||
journal_entries: {
|
||||
voucher_number: 17,
|
||||
voucher_series: 'A',
|
||||
entry_date: '2024-03-15',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -162,6 +167,10 @@ describe('generateFullArchive', () => {
|
||||
expect(manifest[0].status).toBe('error')
|
||||
expect(manifest[0].error).toBe('File not found')
|
||||
expect(manifest[0].fiscal_period_id).toBe(PERIOD_2024.id)
|
||||
// New manifest fields populated even on error (path is computed before download)
|
||||
expect(manifest[0].voucher_number).toBe('A17')
|
||||
expect(manifest[0].entry_date).toBe('2024-03-15')
|
||||
expect(manifest[0].zip_path).toBe('dokument/2024/A17_receipt.pdf')
|
||||
})
|
||||
|
||||
it('skips documents when include_documents is false', async () => {
|
||||
@@ -264,8 +273,20 @@ describe('generateFullArchive', () => {
|
||||
{ data: [PERIOD_2023, PERIOD_2024] },
|
||||
{
|
||||
data: [
|
||||
{ id: 'doc-2023', file_name: 'r23.pdf', storage_path: 'p/r23.pdf', journal_entry_id: 'e-2023' },
|
||||
{ id: 'doc-2024', file_name: 'r24.pdf', storage_path: 'p/r24.pdf', journal_entry_id: 'e-2024' },
|
||||
{
|
||||
id: 'doc-2023',
|
||||
file_name: 'r23.pdf',
|
||||
storage_path: 'p/r23.pdf',
|
||||
journal_entry_id: 'e-2023',
|
||||
journal_entries: { voucher_number: 7, voucher_series: 'A', entry_date: '2023-06-01' },
|
||||
},
|
||||
{
|
||||
id: 'doc-2024',
|
||||
file_name: 'r24.pdf',
|
||||
storage_path: 'p/r24.pdf',
|
||||
journal_entry_id: 'e-2024',
|
||||
journal_entries: { voucher_number: 12, voucher_series: 'B', entry_date: '2024-08-20' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -287,13 +308,94 @@ describe('generateFullArchive', () => {
|
||||
const manifest = JSON.parse(await manifestFile!.async('text'))
|
||||
expect(manifest).toHaveLength(2)
|
||||
const byId = Object.fromEntries(
|
||||
(manifest as Array<{ document_id: string; fiscal_period_id: string | null }>).map((m) => [
|
||||
m.document_id,
|
||||
m.fiscal_period_id,
|
||||
])
|
||||
(
|
||||
manifest as Array<{
|
||||
document_id: string
|
||||
fiscal_period_id: string | null
|
||||
voucher_number: string | null
|
||||
zip_path: string | null
|
||||
}>
|
||||
).map((m) => [m.document_id, m])
|
||||
)
|
||||
expect(byId['doc-2023']).toBe(PERIOD_2023.id)
|
||||
expect(byId['doc-2024']).toBe(PERIOD_2024.id)
|
||||
expect(byId['doc-2023'].fiscal_period_id).toBe(PERIOD_2023.id)
|
||||
expect(byId['doc-2024'].fiscal_period_id).toBe(PERIOD_2024.id)
|
||||
expect(byId['doc-2023'].voucher_number).toBe('A7')
|
||||
expect(byId['doc-2024'].voucher_number).toBe('B12')
|
||||
expect(byId['doc-2023'].zip_path).toBe('dokument/2023/A7_r23.pdf')
|
||||
expect(byId['doc-2024'].zip_path).toBe('dokument/2024/B12_r24.pdf')
|
||||
|
||||
// Files actually written under the new path
|
||||
expect(zip.file('dokument/2023/A7_r23.pdf')).not.toBeNull()
|
||||
expect(zip.file('dokument/2024/B12_r24.pdf')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('routes draft entries and orphans to dokument/_okopplade and disambiguates collisions', async () => {
|
||||
enqueueMany([
|
||||
{ data: COMPANY_ROW },
|
||||
{ data: [PERIOD_2024] },
|
||||
{
|
||||
data: [
|
||||
// Draft entry — journal_entry_id present but voucher_number is null
|
||||
{
|
||||
id: 'doc-draft',
|
||||
file_name: 'invoice.pdf',
|
||||
storage_path: 'p/invoice.pdf',
|
||||
journal_entry_id: 'e-draft',
|
||||
journal_entries: { voucher_number: null, voucher_series: 'A', entry_date: null },
|
||||
},
|
||||
// Two posted docs that collide on the same voucher+filename
|
||||
{
|
||||
id: 'doc-collide-1234abcd-ee',
|
||||
file_name: 'kvitto.pdf',
|
||||
storage_path: 'p/kvitto-1.pdf',
|
||||
journal_entry_id: 'e-posted',
|
||||
journal_entries: { voucher_number: 5, voucher_series: 'A', entry_date: '2024-05-01' },
|
||||
},
|
||||
{
|
||||
id: 'doc-collide-5678efgh-ff',
|
||||
file_name: 'kvitto.pdf',
|
||||
storage_path: 'p/kvitto-2.pdf',
|
||||
journal_entry_id: 'e-posted',
|
||||
journal_entries: { voucher_number: 5, voucher_series: 'A', entry_date: '2024-05-01' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// entryIdToPeriodId map — draft and posted both resolve to PERIOD_2024
|
||||
{
|
||||
data: [
|
||||
{ id: 'e-draft', fiscal_period_id: PERIOD_2024.id },
|
||||
{ id: 'e-posted', fiscal_period_id: PERIOD_2024.id },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const buffer = await generateFullArchive(supabase as any, 'company-1', {
|
||||
scope: 'all',
|
||||
})
|
||||
|
||||
const zip = await JSZip.loadAsync(buffer)
|
||||
const manifest = JSON.parse(await zip.file('dokument/manifest.json')!.async('text')) as Array<{
|
||||
document_id: string
|
||||
voucher_number: string | null
|
||||
zip_path: string | null
|
||||
}>
|
||||
const byId = Object.fromEntries(manifest.map((m) => [m.document_id, m]))
|
||||
|
||||
// Draft -> _okopplade, no voucher prefix
|
||||
expect(byId['doc-draft'].voucher_number).toBeNull()
|
||||
expect(byId['doc-draft'].zip_path).toBe('dokument/_okopplade/invoice.pdf')
|
||||
|
||||
// First posted doc gets the canonical path
|
||||
expect(byId['doc-collide-1234abcd-ee'].zip_path).toBe('dokument/2024/A5_kvitto.pdf')
|
||||
// Second posted doc gets the id-suffix disambiguation before the extension
|
||||
expect(byId['doc-collide-5678efgh-ff'].zip_path).toBe(
|
||||
'dokument/2024/A5_kvitto_doc-coll.pdf'
|
||||
)
|
||||
|
||||
// Both files exist in the ZIP
|
||||
expect(zip.file('dokument/_okopplade/invoice.pdf')).not.toBeNull()
|
||||
expect(zip.file('dokument/2024/A5_kvitto.pdf')).not.toBeNull()
|
||||
expect(zip.file('dokument/2024/A5_kvitto_doc-coll.pdf')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('throws when no fiscal periods exist', async () => {
|
||||
|
||||
@@ -30,6 +30,10 @@ interface DocumentManifestEntry {
|
||||
upload_source: string | null
|
||||
mime_type: string | null
|
||||
file_size_bytes: number | null
|
||||
// New fields (added to make ZIP entries sortable by verifikatnummer)
|
||||
voucher_number: string | null
|
||||
entry_date: string | null
|
||||
zip_path: string | null
|
||||
status: 'downloaded' | 'missing' | 'error'
|
||||
error?: string
|
||||
}
|
||||
@@ -58,6 +62,14 @@ interface DocumentRow {
|
||||
upload_source: string | null
|
||||
mime_type: string | null
|
||||
file_size_bytes: number | null
|
||||
// Joined from journal_entries via journal_entry_id. May be null when the
|
||||
// entry is a draft (no voucher_number yet) or when the doc is orphaned.
|
||||
// PostgREST returns a single row as an object, not an array, when the FK
|
||||
// is many-to-one — but we tolerate both shapes defensively.
|
||||
journal_entries?:
|
||||
| { voucher_number: number | null; voucher_series: string | null; entry_date: string | null }
|
||||
| { voucher_number: number | null; voucher_series: string | null; entry_date: string | null }[]
|
||||
| null
|
||||
}
|
||||
|
||||
interface PeriodReports {
|
||||
@@ -324,7 +336,7 @@ async function writeDocuments(
|
||||
supabase
|
||||
.from('document_attachments')
|
||||
.select(
|
||||
'id, file_name, storage_path, journal_entry_id, sha256_hash, version, digitization_date, upload_source, mime_type, file_size_bytes'
|
||||
'id, file_name, storage_path, journal_entry_id, sha256_hash, version, digitization_date, upload_source, mime_type, file_size_bytes, journal_entries:journal_entry_id(voucher_number, voucher_series, entry_date)'
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
@@ -339,12 +351,20 @@ async function writeDocuments(
|
||||
? documents.filter((d) => d.journal_entry_id && entryIdToPeriodId.has(d.journal_entry_id))
|
||||
: documents.filter((d) => d.journal_entry_id) // all-mode: keep every linked doc
|
||||
|
||||
// Track used paths so we can disambiguate collisions (two documents with
|
||||
// identical voucher prefix + filename) by appending a short id suffix.
|
||||
const usedPaths = new Set<string>()
|
||||
|
||||
for (const doc of inScopeDocuments) {
|
||||
const fiscalPeriodId = doc.journal_entry_id
|
||||
? entryIdToPeriodId.get(doc.journal_entry_id) ?? null
|
||||
: null
|
||||
|
||||
const baseManifest = {
|
||||
const entryInfo = extractJoinedEntry(doc.journal_entries)
|
||||
const voucherLabel = formatVoucherLabel(entryInfo)
|
||||
const zipPath = buildDocumentZipPath(doc, voucherLabel, entryInfo?.entry_date ?? null, usedPaths)
|
||||
|
||||
const baseManifest: Omit<DocumentManifestEntry, 'status'> = {
|
||||
document_id: doc.id,
|
||||
file_name: doc.file_name,
|
||||
storage_path: doc.storage_path,
|
||||
@@ -356,6 +376,9 @@ async function writeDocuments(
|
||||
upload_source: doc.upload_source,
|
||||
mime_type: doc.mime_type,
|
||||
file_size_bytes: doc.file_size_bytes,
|
||||
voucher_number: voucherLabel,
|
||||
entry_date: entryInfo?.entry_date ?? null,
|
||||
zip_path: zipPath,
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -373,8 +396,10 @@ async function writeDocuments(
|
||||
}
|
||||
|
||||
const buffer = await fileData.arrayBuffer()
|
||||
const zipFileName = `${doc.id}_${doc.file_name}`
|
||||
dokument.file(zipFileName, buffer)
|
||||
// zipPath is fully qualified (`dokument/<year>/<voucher>_<file>` etc.),
|
||||
// so write at the archive root — calling `dokument.file(zipPath)`
|
||||
// would double-prefix to `dokument/dokument/...`.
|
||||
zip.file(zipPath, buffer)
|
||||
manifest.push({ ...baseManifest, status: 'downloaded' })
|
||||
} catch (err) {
|
||||
manifest.push({
|
||||
@@ -392,6 +417,75 @@ async function writeDocuments(
|
||||
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgREST returns a many-to-one embedded resource as either an object or an
|
||||
* array depending on schema introspection (FK is unique vs not). Normalize.
|
||||
*/
|
||||
function extractJoinedEntry(
|
||||
raw: DocumentRow['journal_entries']
|
||||
): { voucher_number: number | null; voucher_series: string | null; entry_date: string | null } | null {
|
||||
if (!raw) return null
|
||||
if (Array.isArray(raw)) return raw[0] ?? null
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the voucher label as `<series><number>` (e.g. `A23`, `B12`). Returns
|
||||
* null if the entry is a draft (no voucher_number assigned yet), in which case
|
||||
* the doc is treated as orphaned in the ZIP layout.
|
||||
*/
|
||||
function formatVoucherLabel(
|
||||
entry: { voucher_number: number | null; voucher_series: string | null } | null
|
||||
): string | null {
|
||||
if (!entry || entry.voucher_number == null) return null
|
||||
const series = entry.voucher_series ?? ''
|
||||
return `${series}${entry.voucher_number}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the in-ZIP path for a document.
|
||||
*
|
||||
* - Linked to a posted entry with a date: `dokument/<year>/<voucher>_<file>`
|
||||
* - Linked to a posted entry without a date (defensive): `dokument/_okant-ar/<voucher>_<file>`
|
||||
* - Orphan (no entry) or draft (no voucher_number): `dokument/_okopplade/<file>`
|
||||
*
|
||||
* Collisions are resolved by appending `_<short-id>` before the file extension.
|
||||
*/
|
||||
function buildDocumentZipPath(
|
||||
doc: { id: string; file_name: string },
|
||||
voucherLabel: string | null,
|
||||
entryDate: string | null,
|
||||
usedPaths: Set<string>
|
||||
): string {
|
||||
const safeName = sanitizeFileName(doc.file_name || `${doc.id}.bin`)
|
||||
|
||||
let folder: string
|
||||
let prefix: string
|
||||
if (voucherLabel) {
|
||||
const year = entryDate ? new Date(entryDate).getUTCFullYear() : NaN
|
||||
folder = Number.isFinite(year) ? `dokument/${year}` : 'dokument/_okant-ar'
|
||||
prefix = `${voucherLabel}_`
|
||||
} else {
|
||||
folder = 'dokument/_okopplade'
|
||||
prefix = ''
|
||||
}
|
||||
|
||||
const candidate = `${folder}/${prefix}${safeName}`
|
||||
if (!usedPaths.has(candidate)) {
|
||||
usedPaths.add(candidate)
|
||||
return candidate
|
||||
}
|
||||
|
||||
// Collision — disambiguate with a short id suffix before the extension.
|
||||
const dotIdx = safeName.lastIndexOf('.')
|
||||
const stem = dotIdx > 0 ? safeName.slice(0, dotIdx) : safeName
|
||||
const ext = dotIdx > 0 ? safeName.slice(dotIdx) : ''
|
||||
const suffix = doc.id.slice(0, 8)
|
||||
const disambiguated = `${folder}/${prefix}${stem}_${suffix}${ext}`
|
||||
usedPaths.add(disambiguated)
|
||||
return disambiguated
|
||||
}
|
||||
|
||||
interface SieImportRow {
|
||||
id: string
|
||||
filename: string | null
|
||||
|
||||
Reference in New Issue
Block a user