feat(BillingService, BillingView): enhance locale handling for billing PDF generation
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:
Torsten Schulz (local)
2026-04-25 10:07:45 +02:00
parent 2339e12410
commit 725ede8dbf
2 changed files with 43 additions and 15 deletions

View File

@@ -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();

View File

@@ -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');
}