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>
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
/**
|
||
* 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,
|
||
});
|
||
}
|