UNPKG

@accounter/server

Version:
562 lines • 22.4 kB
/** * API for parsing SHAAM uniform format files */ import { z } from 'zod'; import { parseA000, parseA000Sum } from '../generator/records/index.js'; import { parseDataFile } from '../parser/data-parser.js'; import { AccountSchema, BusinessMetadataSchema, DocumentSchema, InventoryItemSchema, JournalEntrySchema, } from '../types/index.js'; /** * Parses SHAAM uniform format files (INI.TXT and BKMVDATA.TXT) * back into structured JSON objects with comprehensive validation. * * @param iniContent - Content of the INI.TXT file * @param dataContent - Content of the BKMVDATA.TXT file * @param options - Parse options for validation and error handling * @returns Parsed report input data with validation summary */ export function parseUniformFormatFiles(iniContent, dataContent, options = {}) { const { validationMode = 'lenient', skipUnknownRecords = true, allowPartialData = true, } = options; const errors = []; const recordCounts = {}; // Parse files independently const parsedData = parseFilesSeparately(iniContent, dataContent, errors, recordCounts, { validationMode, skipUnknownRecords, }); // Convert parsed records to structured data const structuredData = convertToStructuredData(parsedData, errors, validationMode); // Perform individual validation performRecordsValidation(structuredData, errors, validationMode); // Perform cross-validation const crossValidationPassed = performCrossFileValidation(parsedData, errors, validationMode); // Handle validation errors based on mode if (validationMode === 'strict' && errors.some(e => e.severity === 'error') && !allowPartialData) { throw new Error(`Validation failed: ${errors.map(e => e.message).join(', ')}`); } const totalRecords = Object.values(recordCounts).reduce((sum, count) => sum + count, 0); return { data: structuredData, summary: { totalRecords, perType: recordCounts, errors, crossValidationPassed, }, }; } /** * Parse INI and data files separately and extract all records */ function parseFilesSeparately(iniContent, dataContent, errors, recordCounts, options) { const parsedData = { iniData: { a000: null, a000Sum: [], }, dataRecords: { a100: null, b100: [], b110: [], c100: [], d110: [], d120: [], m100: [], z900: null, }, }; // Parse INI file const iniLines = iniContent.split('\n').filter(line => line.trim().length > 0); for (let i = 0; i < iniLines.length; i++) { const line = iniLines[i]; if (line.length < 4) continue; const recordType = line.substring(0, 4); const cleanLine = line.replace(/\r?\n?$/, '').replace(/\r$/, ''); try { if (recordType === 'A000') { parsedData.iniData.a000 = parseA000(cleanLine); recordCounts['A000'] = (recordCounts['A000'] || 0) + 1; } else if (cleanLine.length === 19) { // A000Sum records const summaryRecord = parseA000Sum(cleanLine); parsedData.iniData.a000Sum.push(summaryRecord); recordCounts['A000Sum'] = (recordCounts['A000Sum'] || 0) + 1; } else if (!options.skipUnknownRecords) { errors.push({ recordType: 'UNKNOWN_INI', recordIndex: i, field: 'recordType', message: `Unknown INI record type: ${recordType}`, severity: options.validationMode === 'strict' ? 'error' : 'warning', }); } } catch (error) { errors.push({ recordType, recordIndex: i, field: 'parsing', message: `Failed to parse ${recordType} record: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: 'error', }); } } // Parse data file const parsedDataFile = parseDataFile(dataContent); parsedData.dataRecords = parsedDataFile.records; for (const [recordType, count] of Object.entries(parsedDataFile.summary.perType)) { recordCounts[recordType] = (recordCounts[recordType] || 0) + count; } for (const parseError of parsedDataFile.errors) { errors.push({ recordType: parseError.recordType, recordIndex: parseError.lineNumber - 1, field: 'parsing', message: `Failed to parse ${parseError.recordType} record: ${parseError.message}`, severity: 'error', }); } if (!options.skipUnknownRecords) { for (const unknownRecord of parsedDataFile.unknownRecords) { errors.push({ recordType: 'UNKNOWN_DATA', recordIndex: unknownRecord.lineNumber - 1, field: 'recordType', message: `Unknown data record type: ${unknownRecord.recordType}`, severity: options.validationMode === 'strict' ? 'error' : 'warning', }); } } return parsedData; } /** * Convert parsed records to structured ReportInput format */ function convertToStructuredData(parsedData, errors, validationMode) { const result = { business: { businessId: 'unknown', name: 'Unknown Business', taxId: '0', reportingPeriod: { startDate: '1900-01-01', endDate: '1900-01-01', }, }, documents: [], journalEntries: [], accounts: [], inventory: [], }; // Extract business metadata from A000 or A100 if (parsedData.iniData.a000) { const a000 = parsedData.iniData.a000; let startDate = '1900-01-01'; let endDate = '1900-01-01'; try { startDate = formatDateFromShaam(a000.startDate); } catch (error) { errors.push({ recordType: 'A000', recordIndex: 0, field: 'startDate', message: `Failed to convert A000 startDate: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: validationMode === 'strict' ? 'error' : 'warning', }); } try { endDate = formatDateFromShaam(a000.endDate); } catch (error) { errors.push({ recordType: 'A000', recordIndex: 0, field: 'endDate', message: `Failed to convert A000 endDate: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: validationMode === 'strict' ? 'error' : 'warning', }); } result.business = { businessId: a000.primaryIdentifier, name: a000.businessName || 'Unknown Business', taxId: a000.vatId, reportingPeriod: { startDate, endDate, }, }; // Add business address if available if (a000.businessStreet || a000.businessHouseNum || a000.businessCity || a000.businessZip) { result.business.address = { street: a000.businessStreet || undefined, houseNumber: a000.businessHouseNum || undefined, city: a000.businessCity || undefined, zip: a000.businessZip || undefined, }; } } else if (parsedData.dataRecords.a100) { const a100 = parsedData.dataRecords.a100; result.business = { ...result.business, businessId: a100.primaryIdentifier.toString(), taxId: a100.vatId, }; } // Convert journal entries for (const b100 of parsedData.dataRecords.b100) { try { const journalEntry = { id: `JE_${b100.transactionNumber.toString()}`, date: formatDateFromShaam(b100.date), amount: b100.transactionAmount, // Use transactionAmount as-is (it already contains the correct sign) accountId: b100.accountKey, description: b100.details, // Include extended B100 fields transactionNumber: b100.transactionNumber, transactionLineNumber: b100.transactionLineNumber, batchNumber: b100.batchNumber, transactionType: b100.transactionType, referenceDocument: b100.referenceDocument, referenceDocumentType: b100.referenceDocumentType, referenceDocument2: b100.referenceDocument2, referenceDocumentType2: b100.referenceDocumentType2, valueDate: b100.valueDate ? formatDateFromShaam(b100.valueDate) : undefined, counterAccountKey: b100.counterAccountKey, debitCreditIndicator: b100.debitCreditIndicator, currencyCode: b100.currencyCode, transactionAmount: b100.transactionAmount, // Preserve original signed amount foreignCurrencyAmount: b100.foreignCurrencyAmount, quantityField: b100.quantityField, matchingField1: b100.matchingField1, matchingField2: b100.matchingField2, branchId: b100.branchId, entryDate: b100.entryDate ? formatDateFromShaam(b100.entryDate) : undefined, operatorUsername: b100.operatorUsername, reserved: b100.reserved, }; result.journalEntries.push(journalEntry); } catch (error) { errors.push({ recordType: 'B100', recordIndex: result.journalEntries.length, field: 'conversion', message: `Failed to convert B100 to JournalEntry: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: validationMode === 'strict' ? 'error' : 'warning', }); } } // Convert accounts for (const b110 of parsedData.dataRecords.b110) { try { const account = { id: b110.accountKey, name: b110.accountName?.trim(), sortCode: { key: b110.trialBalanceCode, name: b110.trialBalanceCodeDescription, }, accountOpeningBalance: b110.accountOpeningBalance, // Include extended fields if available countryCode: b110.countryCode, parentAccountKey: b110.parentAccountKey, vatId: b110.supplierCustomerTaxId, totalDebits: b110.totalDebits, totalCredits: b110.totalCredits, accountingClassificationCode: b110.accountingClassificationCode?.toString(), branchId: b110.branchId, openingBalanceForeignCurrency: b110.openingBalanceForeignCurrency, foreignCurrencyCode: b110.foreignCurrencyCode, }; // Add address if available if (b110.customerSupplierAddressStreet || b110.customerSupplierAddressCity || b110.customerSupplierAddressZip || b110.customerSupplierAddressCountry) { account.address = { street: b110.customerSupplierAddressStreet, houseNumber: b110.customerSupplierAddressHouseNumber, city: b110.customerSupplierAddressCity, zip: b110.customerSupplierAddressZip, country: b110.customerSupplierAddressCountry, }; } result.accounts.push(account); } catch (error) { errors.push({ recordType: 'B110', recordIndex: result.accounts.length, field: 'conversion', message: `Failed to convert B110 to Account: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: validationMode === 'strict' ? 'error' : 'warning', }); } } // Convert documents for (const c100 of parsedData.dataRecords.c100) { try { // Find the corresponding D110 record for this document const d110 = parsedData.dataRecords.d110.find(d => d.documentType === c100.documentType && d.documentNumber === c100.documentId); const document = { id: c100.documentId, type: c100.documentType, date: formatDateFromShaam(c100.documentIssueDate), amount: parseFloat(c100.amountIncludingVat || '0'), description: d110?.goodsServiceDescription || c100.customerName || 'Document', }; result.documents.push(document); } catch (error) { errors.push({ recordType: 'C100', recordIndex: result.documents.length, field: 'conversion', message: `Failed to convert C100 to Document: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: validationMode === 'strict' ? 'error' : 'warning', }); } } // Convert inventory for (const m100 of parsedData.dataRecords.m100) { try { const inventoryItem = { id: m100.internalItemCode, name: m100.itemName, quantity: parseInt(m100.totalStockOut || '0', 10), }; result.inventory.push(inventoryItem); } catch (error) { errors.push({ recordType: 'M100', recordIndex: result.inventory.length, field: 'conversion', message: `Failed to convert M100 to InventoryItem: ${error instanceof Error ? error.message : 'Unknown error'}`, severity: validationMode === 'strict' ? 'error' : 'warning', }); } } return result; } /** * Perform record-level validation using Zod schemas */ function performRecordsValidation(data, errors, validationMode) { if (validationMode === 'none') return; // Validate business metadata try { BusinessMetadataSchema.parse(data.business); } catch (error) { if (error instanceof z.ZodError) { for (const issue of error.issues) { errors.push({ recordType: 'BusinessMetadata', recordIndex: 0, field: issue.path.join('.'), message: issue.message, severity: validationMode === 'strict' ? 'error' : 'warning', }); } } } // Validate documents for (let i = 0; i < data.documents.length; i++) { try { DocumentSchema.parse(data.documents[i]); } catch (error) { if (error instanceof z.ZodError) { for (const issue of error.issues) { errors.push({ recordType: 'Document', recordIndex: i, field: issue.path.join('.'), message: issue.message, severity: validationMode === 'strict' ? 'error' : 'warning', }); } } } } // Validate journal entries for (let i = 0; i < data.journalEntries.length; i++) { try { JournalEntrySchema.parse(data.journalEntries[i]); } catch (error) { if (error instanceof z.ZodError) { for (const issue of error.issues) { errors.push({ recordType: 'JournalEntry', recordIndex: i, field: issue.path.join('.'), message: issue.message, severity: validationMode === 'strict' ? 'error' : 'warning', }); } } } } // Validate accounts for (let i = 0; i < data.accounts.length; i++) { try { AccountSchema.parse(data.accounts[i]); } catch (error) { if (error instanceof z.ZodError) { for (const issue of error.issues) { errors.push({ recordType: 'Account', recordIndex: i, field: issue.path.join('.'), message: issue.message, severity: validationMode === 'strict' ? 'error' : 'warning', }); } } } } // Validate inventory for (let i = 0; i < data.inventory.length; i++) { try { InventoryItemSchema.parse(data.inventory[i]); } catch (error) { if (error instanceof z.ZodError) { for (const issue of error.issues) { errors.push({ recordType: 'InventoryItem', recordIndex: i, field: issue.path.join('.'), message: issue.message, severity: validationMode === 'strict' ? 'error' : 'warning', }); } } } } } /** * Perform cross-validation between INI and data files */ function performCrossFileValidation(parsedData, errors, validationMode) { if (validationMode === 'none') return true; let crossValidationPassed = true; // Validate record counts from A000Sum against actual data for (const summaryRecord of parsedData.iniData.a000Sum) { const expectedCount = parseInt(summaryRecord.recordCount); let actualCount; switch (summaryRecord.code) { case 'A100': actualCount = parsedData.dataRecords.a100 ? 1 : 0; break; case 'B100': actualCount = parsedData.dataRecords.b100.length; break; case 'B110': actualCount = parsedData.dataRecords.b110.length; break; case 'C100': actualCount = parsedData.dataRecords.c100.length; break; case 'D110': actualCount = parsedData.dataRecords.d110.length; break; case 'D120': actualCount = parsedData.dataRecords.d120.length; break; case 'M100': actualCount = parsedData.dataRecords.m100.length; break; case 'Z900': actualCount = parsedData.dataRecords.z900 ? 1 : 0; break; default: // Unknown record type in summary errors.push({ recordType: 'A000Sum', recordIndex: 0, field: 'code', message: `Unknown record type in summary: ${summaryRecord.code}`, severity: validationMode === 'strict' ? 'error' : 'warning', }); crossValidationPassed = false; continue; } if (expectedCount !== actualCount) { errors.push({ recordType: 'CrossValidation', recordIndex: 0, field: `${summaryRecord.code}_count`, message: `Record count mismatch for ${summaryRecord.code}: expected ${expectedCount}, found ${actualCount}`, severity: validationMode === 'strict' ? 'error' : 'warning', }); crossValidationPassed = false; } } // Validate Z900 total records count if (parsedData.dataRecords.z900) { const z900TotalRecords = parsedData.dataRecords.z900.totalRecords; const actualDataRecords = (parsedData.dataRecords.a100 ? 1 : 0) + parsedData.dataRecords.b100.length + parsedData.dataRecords.b110.length + parsedData.dataRecords.c100.length + parsedData.dataRecords.d110.length + parsedData.dataRecords.d120.length + parsedData.dataRecords.m100.length; // Z900 doesn't count itself if (z900TotalRecords !== actualDataRecords) { errors.push({ recordType: 'CrossValidation', recordIndex: 0, field: 'z900_total_records', message: `Z900 total records mismatch: expected ${z900TotalRecords}, found ${actualDataRecords}`, severity: validationMode === 'strict' ? 'error' : 'warning', }); crossValidationPassed = false; } } // Validate business data consistency between A000 and A100 if (parsedData.iniData.a000 && parsedData.dataRecords.a100) { const a000 = parsedData.iniData.a000; const a100 = parsedData.dataRecords.a100; if (a000.vatId !== a100.vatId) { errors.push({ recordType: 'CrossValidation', recordIndex: 0, field: 'vat_id_consistency', message: `VAT ID mismatch between A000 (${a000.vatId}) and A100 (${a100.vatId})`, severity: validationMode === 'strict' ? 'error' : 'warning', }); crossValidationPassed = false; } } return crossValidationPassed; } /** * Formats a SHAAM date string (YYYYMMDD) to ISO format (YYYY-MM-DD) */ function formatDateFromShaam(shaamDate) { // if shaamDate is not 8 digits string, throw an error if (!shaamDate || typeof shaamDate !== 'string' || shaamDate.length !== 8 || !/^\d{8}$/.test(shaamDate)) { throw new Error(`Invalid SHAAM date format: "${shaamDate}" (expected YYYYMMDD)`); } const year = shaamDate.substring(0, 4); const month = shaamDate.substring(4, 6); const day = shaamDate.substring(6, 8); return `${year}-${month}-${day}`; } //# sourceMappingURL=parse-files.js.map