feat(BillingService, BillingView): enhance locale handling for billing PDF generation
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Added methods to normalize and format numbers based on locale in BillingService, improving internationalization support. - Updated _fillSessionRows and _renderBillingPdfFromTemplate methods to accept locale as a parameter for consistent number formatting. - Modified BillingView to pass the current locale when generating billing PDFs, ensuring accurate representation of numerical values.
This commit is contained in:
@@ -30,6 +30,31 @@ const toMonthDate = (value, endOfMonth = false) => {
|
||||
};
|
||||
|
||||
class BillingService {
|
||||
_normalizeLocale(locale) {
|
||||
const fallbackLocale = 'de-DE';
|
||||
const raw = String(locale || '').trim();
|
||||
if (!raw) return fallbackLocale;
|
||||
const normalized = raw.replace('_', '-');
|
||||
try {
|
||||
const [resolved] = Intl.NumberFormat.supportedLocalesOf([normalized]);
|
||||
if (resolved) return resolved;
|
||||
const baseLanguage = normalized.split('-')[0];
|
||||
const [resolvedBase] = Intl.NumberFormat.supportedLocalesOf([baseLanguage]);
|
||||
return resolvedBase || fallbackLocale;
|
||||
} catch (error) {
|
||||
return fallbackLocale;
|
||||
}
|
||||
}
|
||||
|
||||
_formatNumberForLocale(value, locale, digits = 2) {
|
||||
const numberValue = ensureNumber(value, 0);
|
||||
return new Intl.NumberFormat(this._normalizeLocale(locale), {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
useGrouping: false
|
||||
}).format(numberValue);
|
||||
}
|
||||
|
||||
_resolveExistingFilePath(storedPath) {
|
||||
const rawPath = String(storedPath || '').trim();
|
||||
if (!rawPath) return null;
|
||||
@@ -189,14 +214,14 @@ class BillingService {
|
||||
return { found, missing, availableFieldNames };
|
||||
}
|
||||
|
||||
_fillSessionRows(fieldEntries, sessions) {
|
||||
_fillSessionRows(fieldEntries, sessions, locale = 'de-DE') {
|
||||
if (!Array.isArray(sessions) || !sessions.length) return;
|
||||
const rows = sessions.slice(0, 40);
|
||||
for (let idx = 0; idx < rows.length; idx += 1) {
|
||||
const rowNo = idx + 1;
|
||||
const row = rows[idx];
|
||||
const label = row.label || `${row.startTime || ''}-${row.endTime || ''}`.trim();
|
||||
const hours = Number(row.durationHours || 0).toFixed(2);
|
||||
const hours = this._formatNumberForLocale(row.durationHours || 0, locale, 2);
|
||||
|
||||
const dateCandidates = [
|
||||
`date_${rowNo}`, `datum_${rowNo}`, `datum${rowNo}`, `lesson_date_${rowNo}`, `training_date_${rowNo}`, `zeile${rowNo}datum`
|
||||
@@ -215,7 +240,7 @@ class BillingService {
|
||||
}
|
||||
}
|
||||
|
||||
async _renderBillingPdfFromTemplate(templatePath, outputPath, run, computedSessions, hoursTotal, amountTotal) {
|
||||
async _renderBillingPdfFromTemplate(templatePath, outputPath, run, computedSessions, hoursTotal, amountTotal, locale = 'de-DE') {
|
||||
const templateBytes = fs.readFileSync(templatePath);
|
||||
const pdfDoc = await PDFDocument.load(templateBytes);
|
||||
const form = pdfDoc.getForm();
|
||||
@@ -234,12 +259,10 @@ class BillingService {
|
||||
const locationValue = run.omitLocationText ? '' : (run.locationText || '');
|
||||
const dateValue = run.omitDocumentDate ? '' : (run.documentDate || '');
|
||||
const germanDateValue = this._toGermanDate(dateValue);
|
||||
const hoursText = Number(hoursTotal || 0).toFixed(2);
|
||||
const amountNumericText = Number(amountTotal || 0).toFixed(2);
|
||||
const hoursText = this._formatNumberForLocale(hoursTotal || 0, locale, 2);
|
||||
const amountNumericText = this._formatNumberForLocale(amountTotal || 0, locale, 2);
|
||||
const amountText = `${amountNumericText} EUR`;
|
||||
const hourlyRateText = Number(run.hourlyRate || 0).toFixed(2);
|
||||
const hourlyRateTextComma = hourlyRateText.replace('.', ',');
|
||||
const amountNumericTextComma = amountNumericText.replace('.', ',');
|
||||
const hourlyRateText = this._formatNumberForLocale(run.hourlyRate || 0, locale, 2);
|
||||
|
||||
this._setByCandidates(fieldEntries, ['self_recipient_name', 'trainer_name', 'name_des_uebungsleiters', 'uebungsleiter', 'name'], nameValue);
|
||||
this._setByCandidates(fieldEntries, ['iban', 'konto', 'kontonummer'], ibanValue);
|
||||
@@ -261,9 +284,9 @@ class BillingService {
|
||||
this._setByCandidates(fieldEntries, ['hours_total', 'gesamtstunden', 'summe_stunden', 'uestdn', 'u_stdn', 'dauer_summe', 'dauergesamt'], hoursText);
|
||||
this._setByCandidates(fieldEntries, ['hours_total_2', 'gesamtstunden2', 'summe_stunden2', 'dauer_abrechnung', 'dauerabrechnung'], hoursText);
|
||||
// First: explicit exact matches for this TSV Bonames form.
|
||||
const hourlyExactSet = this._setByExactName(fieldEntries, 'gehalt_stunden', hourlyRateTextComma)
|
||||
const hourlyExactSet = this._setByExactName(fieldEntries, 'gehalt_stunden', hourlyRateText)
|
||||
|| this._setByExactName(fieldEntries, 'gehalt_stunden', hourlyRateText);
|
||||
const amountExactSet = this._setByExactName(fieldEntries, 'gehalt_gesamt', amountNumericTextComma)
|
||||
const amountExactSet = this._setByExactName(fieldEntries, 'gehalt_gesamt', amountNumericText)
|
||||
|| this._setByExactName(fieldEntries, 'gehalt_gesamt', amountNumericText);
|
||||
|
||||
if (!hourlyExactSet) {
|
||||
@@ -275,7 +298,7 @@ class BillingService {
|
||||
}
|
||||
this._setByCandidates(fieldEntries, ['same_account_checkbox', 'same_account', 'gleicheskonto', 'gleiches_konto'], Boolean(run.sameAccountCheckbox), 'checkbox');
|
||||
|
||||
this._fillSessionRows(fieldEntries, computedSessions);
|
||||
this._fillSessionRows(fieldEntries, computedSessions, locale);
|
||||
|
||||
if (!diagnostics.found.length) {
|
||||
const fieldPreview = diagnostics.availableFieldNames.slice(0, 30).join(', ');
|
||||
@@ -632,6 +655,7 @@ class BillingService {
|
||||
});
|
||||
return { status: 404, response: { success: false, error: 'Vorlagen-PDF nicht gefunden' } };
|
||||
}
|
||||
const numberLocale = this._normalizeLocale(payload?.locale || payload?.language || payload?.lang);
|
||||
const computed = await this._collectTrainingSessions(run.clubId, run.periodStart, run.periodEnd);
|
||||
const sessionLabel = run.omitSessionLabel ? '' : String(run.sessionLabel || '');
|
||||
const sessionsWithLabel = computed.sessions.map((session) => ({
|
||||
@@ -649,7 +673,8 @@ class BillingService {
|
||||
run,
|
||||
sessionsWithLabel,
|
||||
hoursTotal,
|
||||
amountTotal
|
||||
amountTotal,
|
||||
numberLocale
|
||||
);
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
@@ -1041,7 +1041,8 @@ export default {
|
||||
});
|
||||
const runId = createResponse.data?.run?.id;
|
||||
if (runId) {
|
||||
const generateResponse = await apiClient.post(`/billing/runs/${runId}/generate`, {});
|
||||
const locale = this.$i18n?.locale || 'de-DE';
|
||||
const generateResponse = await apiClient.post(`/billing/runs/${runId}/generate`, { locale });
|
||||
if (!generateResponse.data?.success) {
|
||||
throw new Error(generateResponse.data?.error || 'PDF-Erzeugung fehlgeschlagen');
|
||||
}
|
||||
@@ -1061,7 +1062,8 @@ export default {
|
||||
this.generatingRunIds.push(runId);
|
||||
this.feedback = { message: '', type: 'info' };
|
||||
try {
|
||||
const response = await apiClient.post(`/billing/runs/${runId}/generate`, {});
|
||||
const locale = this.$i18n?.locale || 'de-DE';
|
||||
const response = await apiClient.post(`/billing/runs/${runId}/generate`, { locale });
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.error || 'PDF-Erzeugung fehlgeschlagen');
|
||||
}
|
||||
@@ -1078,7 +1080,8 @@ export default {
|
||||
this.generatingRunIds.push(runId);
|
||||
this.feedback = { message: '', type: 'info' };
|
||||
try {
|
||||
const response = await apiClient.post(`/billing/runs/${runId}/generate`, {});
|
||||
const locale = this.$i18n?.locale || 'de-DE';
|
||||
const response = await apiClient.post(`/billing/runs/${runId}/generate`, { locale });
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.error || 'PDF-Erzeugung fehlgeschlagen');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user