fix(agrarmonitor): gebuchte Belege wieder erkennen, Titel per Template
Build and Push Multi-Platform Images / build-and-push (push) Successful in 18s

- Connector auf 9bda65e: Buchungsstatus aus #bookedStatus lesen. AM zeigt
  hinter dem Status jetzt „Zahlung erfassen“, dadurch war buchungsDatum
  immer leer und das Polling hat keinen Beleg als verbucht markiert.
- Polling: Schritt-Logging pro Dokument über AGRARMONITOR_POLLING_DEBUG,
  Abschlusszeile zählt „noch nicht gebucht“ mit.
- Upload-Check: Titel über das Titel-Template des Dokumenttyps, Fallback
  auf ERG/EGU <Belegnr>. Template-Auflösung als resolveTitelTemplate()
  ausgelagert und im Processor wiederverwendet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-11-01 14:03:27 +01:00
1 parent df50470e53
commit bc2d1cd4b7
8 files changed
+207 -64

No files matched your search

+1
View File
@@ -68,6 +68,7 @@ AGRARMONITOR_COOKIE_PATH=./data/agrarmonitor-cookies.json
AGRARMONITOR_ENCRYPTION_KEY= # optional, 16+ Zeichen für Cookie-Verschlüsselung AGRARMONITOR_ENCRYPTION_KEY= # optional, 16+ Zeichen für Cookie-Verschlüsselung
AGRARMONITOR_POLLING_CRON=0 */30 * * * * # Polling-Intervall (Standard: alle 30 Minuten); leer lassen zum Deaktivieren AGRARMONITOR_POLLING_CRON=0 */30 * * * * # Polling-Intervall (Standard: alle 30 Minuten); leer lassen zum Deaktivieren
AGRARMONITOR_UPLOAD_CHECK_CRON=0 * * * * * # Upload-Check-Intervall (Standard: einmal pro Minute); leer lassen zum Deaktivieren AGRARMONITOR_UPLOAD_CHECK_CRON=0 * * * * * # Upload-Check-Intervall (Standard: einmal pro Minute); leer lassen zum Deaktivieren
AGRARMONITOR_POLLING_DEBUG= # true = jeden Schritt des Polling-Laufs pro Dokument loggen
# --- Täglicher Digest --- # --- Täglicher Digest ---
# Basis-URL der App für klickbare Links in Digest-E-Mails (z.B. https://paperless.example.com) # Basis-URL der App für klickbare Links in Digest-E-Mails (z.B. https://paperless.example.com)
+1
View File
@@ -51,6 +51,7 @@ services:
- AGRARMONITOR_ENCRYPTION_KEY=${AGRARMONITOR_ENCRYPTION_KEY:-} - AGRARMONITOR_ENCRYPTION_KEY=${AGRARMONITOR_ENCRYPTION_KEY:-}
- AGRARMONITOR_POLLING_CRON=${AGRARMONITOR_POLLING_CRON:-} - AGRARMONITOR_POLLING_CRON=${AGRARMONITOR_POLLING_CRON:-}
- AGRARMONITOR_UPLOAD_CHECK_CRON=${AGRARMONITOR_UPLOAD_CHECK_CRON:-} - AGRARMONITOR_UPLOAD_CHECK_CRON=${AGRARMONITOR_UPLOAD_CHECK_CRON:-}
- AGRARMONITOR_POLLING_DEBUG=${AGRARMONITOR_POLLING_DEBUG:-}
- APP_URL=${APP_URL:-} - APP_URL=${APP_URL:-}
- DAILY_DIGEST_CRON=${DAILY_DIGEST_CRON:-} - DAILY_DIGEST_CRON=${DAILY_DIGEST_CRON:-}
volumes: volumes:
+1 -1
View File
@@ -5064,7 +5064,7 @@
}, },
"node_modules/agrarmonitor-connector": { "node_modules/agrarmonitor-connector": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#e6ce1d254bf21522b73a9323bb8f8cd9fadd7eef", "resolved": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#9bda65eec0ec45c98ecce506fe68e36991c0ae61",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.7.9", "axios": "^1.7.9",
@@ -8,6 +8,8 @@ import { PaperlessService } from '../paperless/paperless.service';
import { Setting } from '../database/entities/setting.entity'; import { Setting } from '../database/entities/setting.entity';
import { Client } from '../database/entities/client.entity'; import { Client } from '../database/entities/client.entity';
import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity'; import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity';
import { DocumentType } from '../database/entities/document-type.entity';
import { resolveTitelTemplate } from '../paperless/titel-template.util';
const INTERN_BELEGNUMMER_FIELD_ID = 7; const INTERN_BELEGNUMMER_FIELD_ID = 7;
const EINGANGSDATUM_FIELD_ID = 9; const EINGANGSDATUM_FIELD_ID = 9;
@@ -47,6 +49,10 @@ export interface SyncCorrespondentsResult {
export class AgrarmonitorPollingService implements OnModuleInit { export class AgrarmonitorPollingService implements OnModuleInit {
private readonly logger = new Logger(AgrarmonitorPollingService.name); private readonly logger = new Logger(AgrarmonitorPollingService.name);
private pollingRunning = false; private pollingRunning = false;
/** Schritt-Logging für runPolling(), aktivierbar über AGRARMONITOR_POLLING_DEBUG=true */
private readonly debug = ['1', 'true', 'yes'].includes(
(process.env['AGRARMONITOR_POLLING_DEBUG'] ?? '').toLowerCase(),
);
private uploadCheckRunning = false; private uploadCheckRunning = false;
constructor( constructor(
@@ -57,6 +63,8 @@ export class AgrarmonitorPollingService implements OnModuleInit {
@InjectRepository(Client) private readonly clientRepo: Repository<Client>, @InjectRepository(Client) private readonly clientRepo: Repository<Client>,
@InjectRepository(CorrespondentSetting) @InjectRepository(CorrespondentSetting)
private readonly corrSettingRepo: Repository<CorrespondentSetting>, private readonly corrSettingRepo: Repository<CorrespondentSetting>,
@InjectRepository(DocumentType)
private readonly docTypeRepo: Repository<DocumentType>,
) {} ) {}
async onModuleInit() { async onModuleInit() {
@@ -210,6 +218,10 @@ export class AgrarmonitorPollingService implements OnModuleInit {
const tagFertigId = parseInt(tagFertigSetting?.Wert ?? '4', 10); const tagFertigId = parseInt(tagFertigSetting?.Wert ?? '4', 10);
const tagVerbuchtId = parseInt(tagVerbuchtSetting?.Wert ?? '9', 10); const tagVerbuchtId = parseInt(tagVerbuchtSetting?.Wert ?? '9', 10);
this.trace(
`Konfiguration: Tag fertig=${tagFertigId}, Tag verbucht=${tagVerbuchtId}`,
);
if (isNaN(tagFertigId) || isNaN(tagVerbuchtId)) { if (isNaN(tagFertigId) || isNaN(tagVerbuchtId)) {
const msg = 'Tag-IDs ungültig (keine Zahlen)'; const msg = 'Tag-IDs ungültig (keine Zahlen)';
this.logger.error(msg); this.logger.error(msg);
@@ -236,9 +248,13 @@ export class AgrarmonitorPollingService implements OnModuleInit {
return { ...result, errors: [msg] }; return { ...result, errors: [msg] };
} }
for (const customer of customers.filter( const aktiveLieferanten = customers.filter(
(c) => Number(c['ist_lieferant']) === 1 && Number(c['ist_aktiv']) === 1, (c) => Number(c['ist_lieferant']) === 1 && Number(c['ist_aktiv']) === 1,
)) { );
this.trace(
`${customers.length} Kunden geladen, davon ${aktiveLieferanten.length} aktive Lieferanten — starte Korrespondenten-Sync`,
);
for (const customer of aktiveLieferanten) {
try { try {
await this.getOrCreateCorrespondent(customer, Number(customer.id)); await this.getOrCreateCorrespondent(customer, Number(customer.id));
} catch (err: unknown) { } catch (err: unknown) {
@@ -261,9 +277,14 @@ export class AgrarmonitorPollingService implements OnModuleInit {
); );
} }
this.logger.log(`${docs.length} Dokumente fertig in Agrarmonitor`); this.logger.log(`${docs.length} Dokumente fertig in Agrarmonitor`);
this.trace(
`Paperless meldet ${docsResponse?.count ?? 0} Dokumente mit Tag ${tagFertigId}`,
);
let nichtGebucht = 0;
for (const doc of docs) { for (const doc of docs) {
result.processed++; result.processed++;
const nr = `[${result.processed}/${docs.length}] Dokument ${doc.id as number}`;
const interneBelegnummer = const interneBelegnummer =
(((doc.custom_fields as any[]) ?? []).find( (((doc.custom_fields as any[]) ?? []).find(
@@ -279,6 +300,10 @@ export class AgrarmonitorPollingService implements OnModuleInit {
continue; continue;
} }
this.trace(
`${nr} „${doc.title as string}“: interne Belegnummer ${interneBelegnummer}, Tags [${((doc.tags as number[]) ?? []).join(', ')}] — starte Livesearch`,
);
let amResults: Awaited< let amResults: Awaited<
ReturnType<typeof amClient.eingangsrechnungenLivesearch> ReturnType<typeof amClient.eingangsrechnungenLivesearch>
>; >;
@@ -295,14 +320,17 @@ export class AgrarmonitorPollingService implements OnModuleInit {
break; break;
} }
const msg = `${interneBelegnummer}: Livesearch-Fehler`; const msg = `${interneBelegnummer}: Livesearch-Fehler`;
this.logger.error( this.logger.error(`${msg}: ${fehlerText(err)}`);
`${msg}: ${fehlerText(err)}`,
);
result.errors.push(msg); result.errors.push(msg);
await this.delay(500); await this.delay(500);
continue; continue;
} }
this.trace(
`${nr}: Livesearch lieferte ${amResults.length} Treffer` +
amResults.map((r) => `\n ${this.describeAmDoc(r)}`).join(''),
);
if (amResults.length === 0) { if (amResults.length === 0) {
this.logger.log( this.logger.log(
`${interneBelegnummer} nicht in Agrarmonitor gefunden`, `${interneBelegnummer} nicht in Agrarmonitor gefunden`,
@@ -323,6 +351,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
const amDoc = amResults[0]; const amDoc = amResults[0];
if (!amDoc.interneBelegNummer && interneBelegnummer) { if (!amDoc.interneBelegNummer && interneBelegnummer) {
this.trace(
`${nr}: Lieferscheinnummer in AM leer — setze ${interneBelegnummer}`,
);
try { try {
await amClient.setLieferscheinNummer( await amClient.setLieferscheinNummer(
amDoc.eingangId, amDoc.eingangId,
@@ -339,6 +370,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
const eingangsdatumField = ((doc.custom_fields as any[]) ?? []).find( const eingangsdatumField = ((doc.custom_fields as any[]) ?? []).find(
(cf: any) => cf.field === EINGANGSDATUM_FIELD_ID, (cf: any) => cf.field === EINGANGSDATUM_FIELD_ID,
); );
this.trace(
`${nr}: Eingangsdatum in AM leer — Paperless-Feld ${EINGANGSDATUM_FIELD_ID} = ${JSON.stringify(eingangsdatumField?.value ?? null)}`,
);
if (eingangsdatumField?.value) { if (eingangsdatumField?.value) {
const eingangsdatum = new Date(eingangsdatumField.value as string); const eingangsdatum = new Date(eingangsdatumField.value as string);
if (!isNaN(eingangsdatum.getTime())) { if (!isNaN(eingangsdatum.getTime())) {
@@ -350,7 +384,17 @@ export class AgrarmonitorPollingService implements OnModuleInit {
} }
} }
if (!amDoc.buchungsDatum) {
nichtGebucht++;
this.trace(
`${nr}: kein Buchungsdatum in AM — noch nicht gebucht, keine Änderung`,
);
}
if (amDoc.buchungsDatum) { if (amDoc.buchungsDatum) {
this.trace(
`${nr}: gebucht am ${this.fmtDate(amDoc.buchungsDatum)} — aktualisiere Paperless`,
);
try { try {
let correspondentId: number | undefined; let correspondentId: number | undefined;
const customer = customers.find( const customer = customers.find(
@@ -384,6 +428,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
updateData.correspondent = correspondentId; updateData.correspondent = correspondentId;
if (ownerId !== undefined) updateData.owner = ownerId; if (ownerId !== undefined) updateData.owner = ownerId;
this.trace(
`${nr}: Kunde ${amDoc.kundenId} → Korrespondent ${correspondentId ?? '–'}, Betrieb ${amDoc.betriebId} → Owner ${ownerId ?? '–'}, Update ${JSON.stringify(updateData)}`,
);
await this.paperlessService.updateDocument( await this.paperlessService.updateDocument(
doc.id as number, doc.id as number,
updateData, updateData,
@@ -392,9 +439,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
result.updated++; result.updated++;
} catch (err: unknown) { } catch (err: unknown) {
const msg = `${interneBelegnummer}: Update-Fehler`; const msg = `${interneBelegnummer}: Update-Fehler`;
this.logger.error( this.logger.error(`${msg}: ${fehlerText(err)}`);
`${msg}: ${fehlerText(err)}`,
);
result.errors.push(msg); result.errors.push(msg);
} }
} }
@@ -404,7 +449,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
this.logger.log( this.logger.log(
`Polling abgeschlossen: ${result.processed} verarbeitet, ${result.updated} aktualisiert, ` + `Polling abgeschlossen: ${result.processed} verarbeitet, ${result.updated} aktualisiert, ` +
`${result.skipped} übersprungen, ${result.errors.length} Fehler`, `${nichtGebucht} noch nicht gebucht, ${result.skipped} übersprungen, ${result.errors.length} Fehler`,
); );
} finally { } finally {
this.pollingRunning = false; this.pollingRunning = false;
@@ -525,9 +570,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
break; break;
} }
const msg = `${interneBelegnummer}: Vorhanden-Check fehlgeschlagen`; const msg = `${interneBelegnummer}: Vorhanden-Check fehlgeschlagen`;
this.logger.error( this.logger.error(`${msg}: ${fehlerText(err)}`);
`${msg}: ${fehlerText(err)}`,
);
result.errors.push(msg); result.errors.push(msg);
await this.delay(500); await this.delay(500);
continue; continue;
@@ -552,9 +595,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
} }
// Bei Fehler vorsichtig: nicht verschieben // Bei Fehler vorsichtig: nicht verschieben
const msg = `${interneBelegnummer}: Dateieingang-Check fehlgeschlagen`; const msg = `${interneBelegnummer}: Dateieingang-Check fehlgeschlagen`;
this.logger.error( this.logger.error(`${msg}: ${fehlerText(err)}`);
`${msg}: ${fehlerText(err)}`,
);
result.errors.push(msg); result.errors.push(msg);
await this.delay(500); await this.delay(500);
continue; continue;
@@ -645,9 +686,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
break; break;
} }
const msg = `${interneBelegnummer}: Livesearch-Fehler`; const msg = `${interneBelegnummer}: Livesearch-Fehler`;
this.logger.error( this.logger.error(`${msg}: ${fehlerText(err)}`);
`${msg}: ${fehlerText(err)}`,
);
result.errors.push(msg); result.errors.push(msg);
await this.delay(500); await this.delay(500);
continue; continue;
@@ -718,10 +757,23 @@ export class AgrarmonitorPollingService implements OnModuleInit {
); );
} }
// Titel über das Titel-Template des Dokumenttyps bilden;
// ohne (auflösbares) Template bleibt der bisherige Standardtitel.
const documentTypeId = amDoc.dokumentTyp === 0 ? 1 : 2;
const docTypeConfig = await this.docTypeRepo.findOneBy({
DocumentTypeId: documentTypeId,
});
const title =
resolveTitelTemplate(
docTypeConfig?.TitelTemplate,
existingFields,
amDoc.belegDatum ?? new Date(doc.created as string),
) ??
(amDoc.dokumentTyp === 0 ? 'ERG ' : 'EGU ') + amDoc.belegNummer;
const updateData: Record<string, any> = { const updateData: Record<string, any> = {
title: title,
(amDoc.dokumentTyp === 0 ? 'ERG ' : 'EGU ') + amDoc.belegNummer, document_type: documentTypeId,
document_type: amDoc.dokumentTyp === 0 ? 1 : 2,
tags: newTags, tags: newTags,
custom_fields: existingFields, custom_fields: existingFields,
}; };
@@ -742,9 +794,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
result.updated++; result.updated++;
} catch (err: unknown) { } catch (err: unknown) {
const msg = `${interneBelegnummer}: Update-Fehler`; const msg = `${interneBelegnummer}: Update-Fehler`;
this.logger.error( this.logger.error(`${msg}: ${fehlerText(err)}`);
`${msg}: ${fehlerText(err)}`,
);
result.errors.push(msg); result.errors.push(msg);
} }
@@ -1018,6 +1068,34 @@ export class AgrarmonitorPollingService implements OnModuleInit {
return `${name} (${nummer})`; return `${name} (${nummer})`;
} }
private trace(message: string): void {
if (this.debug) this.logger.log(`[Debug] ${message}`);
}
private fmtDate(d: Date | null | undefined): string {
if (!d) return '–';
return isNaN(d.getTime()) ? 'ungültig' : d.toISOString().slice(0, 10);
}
private describeAmDoc(r: {
eingangId: number;
belegNummer: string;
interneBelegNummer: string;
belegDatum: Date | null;
eingangsDatum: Date | null;
buchungsDatum: Date | null;
kundenId: number;
betriebId: number;
dokumentTyp: number;
}): string {
return (
`eingangId=${r.eingangId} beleg=${r.belegNummer || '–'} intern=${r.interneBelegNummer || '–'} ` +
`typ=${r.dokumentTyp} belegDatum=${this.fmtDate(r.belegDatum)} ` +
`eingangsDatum=${this.fmtDate(r.eingangsDatum)} buchungsDatum=${this.fmtDate(r.buchungsDatum)} ` +
`kunde=${r.kundenId} betrieb=${r.betriebId}`
);
}
private delay(ms: number): Promise<void> { private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
@@ -8,10 +8,16 @@ import { PaperlessModule } from '../paperless/paperless.module';
import { Setting } from '../database/entities/setting.entity'; import { Setting } from '../database/entities/setting.entity';
import { Client } from '../database/entities/client.entity'; import { Client } from '../database/entities/client.entity';
import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity'; import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity';
import { DocumentType } from '../database/entities/document-type.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Setting, Client, CorrespondentSetting]), TypeOrmModule.forFeature([
Setting,
Client,
CorrespondentSetting,
DocumentType,
]),
PaperlessModule, PaperlessModule,
], ],
providers: [ providers: [
@@ -5,6 +5,7 @@ import { Repository } from 'typeorm';
import { DocumentField } from '../database/entities/document-field.entity'; import { DocumentField } from '../database/entities/document-field.entity';
import { DocumentType } from '../database/entities/document-type.entity'; import { DocumentType } from '../database/entities/document-type.entity';
import { PaperlessService } from './paperless.service'; import { PaperlessService } from './paperless.service';
import { resolveTitelTemplate } from './titel-template.util';
import { PostprocessingService } from '../postprocessing/postprocessing.service'; import { PostprocessingService } from '../postprocessing/postprocessing.service';
const PAPERLESSMANAGER_TAG_ID = 16; // Tag "paperlessmanager" const PAPERLESSMANAGER_TAG_ID = 16; // Tag "paperlessmanager"
@@ -220,45 +221,12 @@ export class PaperlessProcessorService {
}; };
if (docTypeConfig.TitelTemplate) { if (docTypeConfig.TitelTemplate) {
let title = docTypeConfig.TitelTemplate; const title = resolveTitelTemplate(
docTypeConfig.TitelTemplate,
// Prüfen ob alle im Template referenzierten Custom Fields ausgefüllt sind newCustomFields,
const placeholderRegex = /\{\{CUSTOM\[(\d+)\]\}\}/g; doc.created ? new Date(doc.created) : new Date(),
let allFilled = true;
let match: RegExpExecArray | null;
while ((match = placeholderRegex.exec(title)) !== null) {
const fieldId = parseInt(match[1], 10);
const cf = newCustomFields.find((f) => f.field === fieldId);
if (!cf || cf.value == null || cf.value === '') {
allFilled = false;
break;
}
}
if (allFilled) {
for (const cf of newCustomFields) {
const placeholder = `{{CUSTOM[${cf.field}]}}`;
if (title.includes(placeholder)) {
title = title.replaceAll(
placeholder,
cf.value != null ? String(cf.value) : '',
); );
} if (title !== null) {
}
if (title.includes('{{DATE}}')) {
const created = doc.created ? new Date(doc.created) : new Date();
const dateStr = `${String(created.getDate()).padStart(2, '0')}.${String(created.getMonth() + 1).padStart(2, '0')}.${created.getFullYear()}`;
title = title.replaceAll('{{DATE}}', dateStr);
}
if (title.includes('{{ZEITSTEMPEL}}')) {
const now = new Date();
const ts = `${String(now.getDate()).padStart(2, '0')}.${String(now.getMonth() + 1).padStart(2, '0')}.${now.getFullYear()} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
title = title.replaceAll('{{ZEITSTEMPEL}}', ts);
}
updatePayload.title = title; updatePayload.title = title;
} else { } else {
this.logger.log( this.logger.log(
@@ -0,0 +1,48 @@
import { resolveTitelTemplate } from './titel-template.util';
describe('resolveTitelTemplate', () => {
const created = new Date(2026, 9, 5); // 05.10.2026
it('ersetzt Custom-Field-Platzhalter und {{DATE}}', () => {
expect(
resolveTitelTemplate(
'ERG {{CUSTOM[3]}} vom {{DATE}}',
[{ field: 3, value: 'RE-42' }],
created,
),
).toBe('ERG RE-42 vom 05.10.2026');
});
it('ersetzt mehrfach vorkommende Platzhalter', () => {
expect(
resolveTitelTemplate(
'{{CUSTOM[3]}}/{{CUSTOM[3]}}',
[{ field: 3, value: 'X' }],
created,
),
).toBe('X/X');
});
it('liefert null, wenn ein referenziertes Feld leer ist oder fehlt', () => {
expect(
resolveTitelTemplate('{{CUSTOM[3]}}', [{ field: 3, value: '' }], created),
).toBeNull();
expect(resolveTitelTemplate('{{CUSTOM[7]}}', [], created)).toBeNull();
});
it('liefert null bei leerem Template', () => {
expect(resolveTitelTemplate('', [], created)).toBeNull();
expect(resolveTitelTemplate(null, [], created)).toBeNull();
});
it('ersetzt {{ZEITSTEMPEL}} mit der übergebenen Uhrzeit', () => {
expect(
resolveTitelTemplate(
'T {{ZEITSTEMPEL}}',
[],
created,
new Date(2026, 0, 2, 3, 4, 5),
),
).toBe('T 02.01.2026 03:04:05');
});
});
@@ -0,0 +1,41 @@
const pad = (n: number) => String(n).padStart(2, '0');
const formatDatum = (d: Date) =>
`${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()}`;
/**
* Löst ein Titel-Template eines Dokumenttyps auf.
* Platzhalter: {{CUSTOM[<feldId>]}}, {{DATE}} (Belegdatum), {{ZEITSTEMPEL}} (jetzt).
* Liefert null, wenn kein Template gesetzt ist oder ein referenziertes
* Custom Field leer ist — der Aufrufer behält dann den bisherigen Titel.
*/
export function resolveTitelTemplate(
template: string | null | undefined,
customFields: Array<{ field: number; value: unknown }>,
created: Date,
now: Date = new Date(),
): string | null {
if (!template) return null;
const placeholderRegex = /\{\{CUSTOM\[(\d+)\]\}\}/g;
let match: RegExpExecArray | null;
while ((match = placeholderRegex.exec(template)) !== null) {
const fieldId = parseInt(match[1], 10);
const cf = customFields.find((f) => f.field === fieldId);
if (!cf || cf.value == null || cf.value === '') return null;
}
let title = template;
for (const cf of customFields) {
title = title.replaceAll(
`{{CUSTOM[${cf.field}]}}`,
cf.value != null ? String(cf.value as string | number | boolean) : '',
);
}
title = title.replaceAll('{{DATE}}', formatDatum(created));
title = title.replaceAll(
'{{ZEITSTEMPEL}}',
`${formatDatum(now)} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
);
return title;
}