Files
opencrm/backend/src/services/contractStatusScheduler.service.ts
T
duffyduckandClaude Opus 4.8 fc3131059b Neuer Vertragsstatus "Gekuendigt / bestaetigt" + Kuendigungs-Workflow
Bisheriges "Gekuendigt" (CANCELLED) umbenannt in "Gekuendigt / Bestaetigung
abwarten" und wird jetzt automatisch gesetzt, sobald ein Kuendigungsschreiben
hochgeladen wird. Neuer Status CANCELLED_CONFIRMED ("Gekuendigt / bestaetigt")
wird automatisch gesetzt, sobald ein Kuendigungsbestaetigungsdatum vorliegt
(Dokument fuellt das Datum oder manuell) + Vertragsende = Kuendigungsdatum.

Schema: Enum-Wert CANCELLED_CONFIRMED + Migration (idempotentes MODIFY COLUMN);
Daten-Migration hebt bestehende CANCELLED (alte Logik: nur bei Bestaetigung
gesetzt) auf CANCELLED_CONFIRMED.

Backend: neue Trigger maybeMarkAwaitingConfirmationOnLetter (Schreiben->CANCELLED)
im Upload-Handler; maybeCancelOnCancellationConfirmation setzt jetzt
CANCELLED_CONFIRMED (auch aus CANCELLED). Cockpit-Semantik mitgewandert
(Fristen-Skip/"beendet" fuer CANCELLED_CONFIRMED; Ladeliste + Kuendigungs-
bestaetigungs-Filter erweitert).

Frontend: Labels/Farben/Status-Erklaerungen + Status-Dropdown in ContractList,
ContractDetail, ContractForm, ContractDetailModal, CustomerDetail
(CANCELLED orange "abwarten", CANCELLED_CONFIRMED rot).

Verifiziert: tsc+build gruen; Schreiben->CANCELLED, Bestaetigung->
CANCELLED_CONFIRMED+Enddatum; Daten-Migration idempotent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 18:14:34 +02:00

337 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Scheduler für automatische Vertrags-Status-Übergänge.
*
* Einmal täglich um 02:00: alle Verträge mit status=ACTIVE und
* endDate < heute werden auf EXPIRED umgestellt (+ Audit-Log).
*
* Läuft zusätzlich 60 Sekunden nach Server-Start als Catch-up falls
* der Prozess zum 02:00-Slot neu gestartet wurde.
*/
import cron from 'node-cron';
import prisma from '../lib/prisma.js';
import { createAuditLog, logChange } from './audit.service.js';
import { ApiError } from '../utils/apiError.js';
async function runExpireCheck(): Promise<void> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const expiring = await prisma.contract.findMany({
where: {
status: 'ACTIVE',
endDate: { not: null, lt: today },
},
select: {
id: true,
contractNumber: true,
customerId: true,
endDate: true,
},
});
if (expiring.length === 0) {
console.log('[ContractStatusScheduler] Keine abgelaufenen Verträge.');
return;
}
console.log(`[ContractStatusScheduler] ${expiring.length} Vertrag/Verträge auf EXPIRED setzen.`);
for (const c of expiring) {
try {
await prisma.contract.update({
where: { id: c.id },
data: { status: 'EXPIRED' },
});
await createAuditLog({
userEmail: 'system',
userRole: 'System',
action: 'UPDATE',
resourceType: 'Contract',
resourceId: c.id.toString(),
resourceLabel: `Vertrag ${c.contractNumber} automatisch auf EXPIRED gesetzt (Laufzeit überschritten)`,
endpoint: 'scheduler:contract-status',
httpMethod: 'SYSTEM',
ipAddress: 'localhost',
dataSubjectId: c.customerId,
changesBefore: { status: 'ACTIVE' },
changesAfter: { status: 'EXPIRED', endDate: c.endDate?.toISOString() },
});
} catch (err) {
console.error(`[ContractStatusScheduler] Fehler bei Vertrag #${c.id}:`, err);
}
}
console.log('[ContractStatusScheduler] Fertig.');
}
export function startContractStatusScheduler(): void {
// Täglich um 02:00 Uhr (Server-Zeit)
cron.schedule('0 2 * * *', () => {
runExpireCheck().catch((err) =>
console.error('[ContractStatusScheduler] Daily run failed:', err),
);
});
// Catch-up 60 Sekunden nach Start
setTimeout(() => {
runExpireCheck().catch((err) =>
console.error('[ContractStatusScheduler] Catch-up run failed:', err),
);
}, 60_000);
console.log('[ContractStatusScheduler] Gestartet täglich um 02:00 + Catch-up nach 60s');
}
export { runExpireCheck };
/**
* Pentest 55.4 (LOW, 2026-06-01): 5 parallele Lieferbestätigung-Requests
* erzeugten 5 ContractDocuments. Application-Lock per (contractId,
* documentType) verhindert das in der Praxis (single-instance) und bietet
* für Cluster wenigstens eine deutliche Verzögerung gegen Spam-Sprays.
*
* Plus DB-Check „kürzlich angelegt": rejected, falls innerhalb der
* letzten 10 s schon ein Eintrag mit gleichem Typ existiert. Schließt
* den größten Teil des Race-Windows und unterscheidet Spam-Attacks von
* legitimen Sekunden-später-Updates.
*/
const docCreateLocks = new Map<string, Promise<void>>();
export async function assertNoRecentDuplicateDocument(
contractId: number,
documentType: string,
): Promise<void> {
const recent = await prisma.contractDocument.findFirst({
where: {
contractId,
documentType,
createdAt: { gte: new Date(Date.now() - 10_000) },
},
select: { id: true },
});
if (recent) {
// Pentest 64.1: ApiError(400) statt generischem Error Caller
// mappt das auf 400 Bad Request statt pauschal 500.
throw new ApiError(400, 'Ein Dokument dieses Typs wurde vor wenigen Sekunden bereits angelegt bitte kurz warten und Seite neu laden.');
}
}
export async function withContractDocumentLock<T>(
contractId: number,
documentType: string,
fn: () => Promise<T>,
): Promise<T> {
const key = `${contractId}|${documentType.trim().toLowerCase()}`;
const previous = docCreateLocks.get(key);
let release: () => void = () => {};
const slot = new Promise<void>((resolve) => { release = resolve; });
docCreateLocks.set(key, (previous ?? Promise.resolve()).then(() => slot));
if (previous) await previous;
try {
await assertNoRecentDuplicateDocument(contractId, documentType);
return await fn();
} finally {
release();
// Map-Aufräumen: wenn niemand mehr in der Kette wartet
if (docCreateLocks.get(key) === (previous ?? Promise.resolve()).then(() => slot)) {
docCreateLocks.delete(key);
}
}
}
/**
* Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine
* Lieferbestätigung ist:
* - Contract.status von DRAFT auf ACTIVE setzen (falls DRAFT)
* - Contract.startDate auf das Lieferdatum setzen:
* * Explizit eingegebenes deliveryDate → IMMER als Vertragsbeginn
* übernehmen, auch wenn schon ein Datum gesetzt war. Die
* Lieferbestätigung ist das maßgebliche tatsächliche Startdatum
* und korrigiert ein evtl. vorher geschätztes Beginndatum.
* * Kein deliveryDate angegeben → Fallback "heute", aber NUR wenn
* startDate noch leer ist. Ein bestehendes (echtes) Datum darf
* nicht versehentlich mit "heute" überschrieben werden.
*
* Schreibweise "Lieferbestätigung" stammt aus dem Frontend-Dropdown
* (SaveAttachmentModal / ContractDetail). Vergleich case-insensitive +
* getrimmt zur Robustheit.
*/
/**
* Wird aufgerufen, wenn zu einem Vertrag eine Kündigungsbestätigung
* hinzugefügt wird entweder als Datum (`cancellationConfirmationDate`,
* z.B. über das Vertragsformular) und/oder als Dokument
* (`cancellationConfirmationPath`, Upload). Effekt:
* - Vertrag ACTIVE → CANCELLED (nur aus ACTIVE; andere Status werden
* bewusst nicht angetastet).
* - Berechnetes Vertragsende (`endDate`) = Kündigungs(bestätigungs)datum,
* sofern eines vorliegt.
* Idempotent: läuft nur, wenn tatsächlich eine Bestätigung vorhanden ist,
* und schreibt nur bei echten Änderungen.
*/
export async function maybeCancelOnCancellationConfirmation(
contractId: number,
req: unknown,
): Promise<void> {
const c = await prisma.contract.findUnique({
where: { id: contractId },
select: {
status: true,
endDate: true,
contractNumber: true,
customerId: true,
cancellationConfirmationPath: true,
cancellationConfirmationDate: true,
},
});
if (!c) return;
const hasConfirmation = !!c.cancellationConfirmationPath || !!c.cancellationConfirmationDate;
if (!hasConfirmation) return;
const asDay = (d: Date | null | undefined) =>
d ? new Date(d).toISOString().split('T')[0] : null;
const updateData: Record<string, unknown> = {};
const changes: Record<string, { vorher: unknown; nachher: unknown }> = {};
// Kündigungsbestätigung liegt vor (Datum/Dokument) → "Gekündigt / bestätigt".
// Von jedem noch "lebenden" Status heben auch aus "Gekündigt / Bestätigung
// abwarten" (CANCELLED) aber NIE aus DRAFT (Vorlage) oder DEACTIVATED
// (archiviert). Ein bereits bestätigter Vertrag bleibt bestätigt (No-op).
if (['ACTIVE', 'PENDING', 'ONGOING', 'EXPIRED', 'CANCELLED'].includes(c.status)) {
updateData.status = 'CANCELLED_CONFIRMED';
changes.status = { vorher: c.status, nachher: 'CANCELLED_CONFIRMED' };
}
// Vertragsende = Kündigungsdatum (Bestätigungsdatum), falls vorhanden.
// NICHT bei DRAFT: ein Entwurf ist nur eine Vorlage und bekommt kein
// berechnetes Vertragsende (Pentest R138, Hygiene-Punkt).
if (
c.status !== 'DRAFT' &&
c.cancellationConfirmationDate &&
asDay(c.endDate) !== asDay(c.cancellationConfirmationDate)
) {
updateData.endDate = c.cancellationConfirmationDate;
changes.endDate = { vorher: asDay(c.endDate), nachher: asDay(c.cancellationConfirmationDate) };
}
if (Object.keys(updateData).length === 0) return;
await prisma.contract.update({ where: { id: contractId }, data: updateData });
await logChange({
req,
action: 'UPDATE',
resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${c.contractNumber} automatisch aktualisiert (Kündigungsbestätigung)`,
details: { ...changes, trigger: 'Kündigungsbestätigung' },
customerId: c.customerId,
});
}
/**
* Setzt einen Vertrag automatisch auf "Gekündigt / Bestätigung abwarten"
* (CANCELLED), sobald ein Kündigungsschreiben hinterlegt wurde. Hebt nur noch
* "lebende" Verträge (ACTIVE/PENDING/ONGOING/EXPIRED) an ein bereits bestätigt
* gekündigter Vertrag (CANCELLED_CONFIRMED) wird NICHT zurückgestuft, ein
* Entwurf (DRAFT) oder archivierter (DEACTIVATED) nicht angefasst.
*/
export async function maybeMarkAwaitingConfirmationOnLetter(
contractId: number,
req: unknown,
): Promise<void> {
const c = await prisma.contract.findUnique({
where: { id: contractId },
select: {
status: true,
contractNumber: true,
customerId: true,
cancellationLetterPath: true,
},
});
if (!c || !c.cancellationLetterPath) return;
if (!['ACTIVE', 'PENDING', 'ONGOING', 'EXPIRED'].includes(c.status)) return;
await prisma.contract.update({
where: { id: contractId },
data: { status: 'CANCELLED' },
});
await logChange({
req,
action: 'UPDATE',
resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${c.contractNumber} automatisch auf "Gekündigt / Bestätigung abwarten" gesetzt (Kündigungsschreiben)`,
details: { status: { vorher: c.status, nachher: 'CANCELLED' }, trigger: 'Kündigungsschreiben' },
customerId: c.customerId,
});
}
export async function maybeActivateOnDeliveryConfirmation(
contractId: number,
documentType: string,
req: unknown,
deliveryDate?: Date | string | null,
): Promise<void> {
if (!documentType || typeof documentType !== 'string') return;
if (documentType.trim().toLowerCase() !== 'lieferbestätigung') return;
const contract = await prisma.contract.findUnique({
where: { id: contractId },
select: { status: true, contractNumber: true, customerId: true, startDate: true },
});
if (!contract) return;
// Explizit eingegebenes Lieferdatum parsen (null = keins angegeben).
let parsedDate: Date | null = null;
if (deliveryDate) {
const parsed = new Date(deliveryDate);
if (!isNaN(parsed.getTime())) parsedDate = parsed;
}
const updateData: Record<string, unknown> = {};
const changes: Record<string, { vorher: unknown; nachher: unknown }> = {};
if (contract.status === 'DRAFT') {
updateData.status = 'ACTIVE';
changes.status = { vorher: 'DRAFT', nachher: 'ACTIVE' };
}
const asDay = (d: Date | null | undefined) =>
d ? new Date(d).toISOString().split('T')[0] : null;
if (parsedDate) {
// Explizites Lieferdatum: als Vertragsbeginn übernehmen, auch überschreibend.
// No-op vermeiden, wenn der Tag schon exakt passt.
if (asDay(contract.startDate) !== asDay(parsedDate)) {
updateData.startDate = parsedDate;
changes.startDate = { vorher: asDay(contract.startDate), nachher: asDay(parsedDate) };
}
} else if (!contract.startDate) {
// Kein Datum angegeben → Fallback heute, nur bei leerem Startdatum.
const today = new Date();
updateData.startDate = today;
changes.startDate = { vorher: null, nachher: asDay(today) };
}
if (Object.keys(updateData).length === 0) return;
await prisma.contract.update({
where: { id: contractId },
data: updateData,
});
await logChange({
req,
action: 'UPDATE',
resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${contract.contractNumber} automatisch aktualisiert (Lieferbestätigung hochgeladen)`,
details: { ...changes, trigger: 'Lieferbestätigung-Upload' },
customerId: contract.customerId,
});
}