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>
This commit is contained in:
2026-08-13 18:14:34 +02:00
co-authored by Claude Opus 4.8
parent a5b8922dd0
commit fc3131059b
14 changed files with 150 additions and 33 deletions
@@ -259,8 +259,8 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
// Wenn über das Formular ein (neues/geändertes) Kündigungsbestätigungs-
// datum gesetzt wurde: Vertrag ACTIVE → CANCELLED + Vertragsende =
// Kündigungsdatum. Nur bei tatsächlicher Änderung des Datums auslösen,
// datum gesetzt wurde: Vertrag → CANCELLED_CONFIRMED ("Gekündigt / bestätigt")
// + Vertragsende = Kündigungsdatum. Nur bei tatsächlicher Änderung auslösen,
// damit manuelle Status-Korrekturen nicht sofort zurückgesetzt werden.
{
const asDay = (d: unknown) =>
+9 -4
View File
@@ -14,7 +14,7 @@ import {
} from '../utils/accessControl.js';
import { validateOptionalIsoDate } from '../utils/sanitize.js';
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
import { maybeCancelOnCancellationConfirmation } from '../services/contractStatusScheduler.service.js';
import { maybeCancelOnCancellationConfirmation, maybeMarkAwaitingConfirmationOnLetter } from '../services/contractStatusScheduler.service.js';
import { deleteFileAbsolute, deleteUploadByRelativePath } from '../utils/fileCleanup.js';
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
@@ -691,12 +691,17 @@ async function handleContractDocumentUpload(
data: updateData,
});
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde:
// Vertrag ACTIVE → CANCELLED und Vertragsende = Kündigungsdatum.
// "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen.
// Kündigungsbestätigung (nicht "Optionen") hochgeladen → Vertrag auf
// "Gekündigt / bestätigt" (CANCELLED_CONFIRMED) + Vertragsende = Kündigungs-
// datum. "Optionen" ist für Vertrags-Änderungen gedacht, nicht für Kündigungen.
if (fieldName === 'cancellationConfirmationPath') {
await maybeCancelOnCancellationConfirmation(contractId, req);
}
// Kündigungsschreiben hochgeladen → Vertrag auf "Gekündigt / Bestätigung
// abwarten" (CANCELLED), sofern noch nicht (bestätigt) gekündigt.
if (fieldName === 'cancellationLetterPath') {
await maybeMarkAwaitingConfirmationOnLetter(contractId, req);
}
res.json({
success: true,
@@ -207,7 +207,7 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
const contracts = await prisma.contract.findMany({
where: {
status: {
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED', 'ONGOING'],
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'CANCELLED_CONFIRMED', 'DEACTIVATED', 'EXPIRED', 'ONGOING'],
},
...customerScopeFilter,
},
@@ -363,8 +363,10 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
// Fristen semantisch keinen Sinn ergeben:
// - ONGOING : "Läuft weiter auf unbestimmte Zeit" → kein Ende
// - DEACTIVATED : manuell beendet/archiviert → nichts mehr zu erinnern
// - CANCELLED : Kündigung schon eingereicht → Vertragsende ist
// die Konsequenz der Kündigung, keine Frist mehr
// - CANCELLED_CONFIRMED : Kündigung vom Anbieter bestätigt →
// Vertragsende steht fest, keine Frist mehr.
// (CANCELLED = nur "Bestätigung abwarten" skippt NICHT,
// damit die "Kündigungsbestätigung fehlt"-Warnung greift.)
// Die Schlussrechnung-Fehlt-Warnung (Block 13a) bleibt aktiv, weil
// sie WEGEN dieser Status feuert (kein separater Guard). DSGVO-/
// Consent-Checks (Block 8) sind pro Kunde, nicht pro Vertrag, und
@@ -375,7 +377,7 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
const skipDeadlines =
contract.status === 'ONGOING'
|| contract.status === 'DEACTIVATED'
|| contract.status === 'CANCELLED';
|| contract.status === 'CANCELLED_CONFIRMED';
// 1. KÜNDIGUNGSFRIST (nur wenn kein aktiver Folgevertrag UND Snooze nicht aktiv)
// Snooze unterdrückt NUR Fristen-bezogene Warnungen!
@@ -628,9 +630,11 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
const now = new Date();
now.setHours(0, 0, 0, 0);
// 13a. SCHLUSSRECHNUNG FEHLT (nur wenn Vertrag gekündigt/deaktiviert ist)
// "Beendet" = CANCELLED oder DEACTIVATED (nicht nur Laufzeit abgelaufen!)
const isContractTerminated = contract.status === 'CANCELLED' || contract.status === 'DEACTIVATED';
// 13a. SCHLUSSRECHNUNG FEHLT (nur wenn Vertrag bestätigt gekündigt/deaktiviert)
// "Beendet" = CANCELLED_CONFIRMED (Kündigung bestätigt) oder DEACTIVATED
// (nicht nur Laufzeit abgelaufen; NICHT "Bestätigung abwarten"/CANCELLED,
// da dort das Vertragsende noch nicht feststeht).
const isContractTerminated = contract.status === 'CANCELLED_CONFIRMED' || contract.status === 'DEACTIVATED';
if (isContractTerminated) {
const hasFinalInvoice = invoices.some(inv => inv.invoiceType === 'FINAL');
@@ -783,7 +787,7 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
// CANCELLED/EXPIRED. Eigene Liste, weil solche Verträge (v.a. bereits
// CANCELLED/EXPIRED) sonst mangels „Issue" nicht in der Cockpit-Liste
// auftauchen.
const CANCEL_CONF_STATUSES: ContractStatus[] = ['ACTIVE', 'DRAFT', 'CANCELLED', 'EXPIRED'];
const CANCEL_CONF_STATUSES: ContractStatus[] = ['ACTIVE', 'DRAFT', 'CANCELLED', 'CANCELLED_CONFIRMED', 'EXPIRED'];
const cancellationConfirmations: CockpitContract[] = contracts
.filter(
(c) =>
@@ -195,9 +195,13 @@ export async function maybeCancelOnCancellationConfirmation(
const updateData: Record<string, unknown> = {};
const changes: Record<string, { vorher: unknown; nachher: unknown }> = {};
if (c.status === 'ACTIVE') {
updateData.status = 'CANCELLED';
changes.status = { vorher: 'ACTIVE', nachher: 'CANCELLED' };
// 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.
@@ -227,6 +231,45 @@ export async function maybeCancelOnCancellationConfirmation(
});
}
/**
* 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,