Vertrag: Auto-CANCELLED bei Kuendigungsbestaetigung + Cockpit-Filter

1) Auto-Status: Wird eine Kuendigungsbestaetigung hinzugefuegt (Datum
   ueber Formular und/oder Dokument-Upload), wird der Vertrag von ACTIVE
   auf CANCELLED gesetzt und das Vertragsende = Kuendigungsdatum.
   Zentrale Funktion maybeCancelOnCancellationConfirmation (idempotent,
   nur aus ACTIVE). Upload-Route ersetzt die alte Inline-Logik (setzt
   jetzt auch endDate); Update-Controller triggert nur bei neu/geaendertem
   Bestaetigungsdatum (manuelle Status-Korrekturen bleiben unangetastet).

2) Cockpit-Filter 'Kuendigungsbestaetigung': neue Liste
   cancellationConfirmations (Vertraege mit Bestaetigung in Status
   ACTIVE/DRAFT/CANCELLED) + Filter-Option im Cockpit-Dropdown. Eigene
   Liste, weil bereits CANCELLED-Vertraege mangels Issue sonst nicht in
   der Cockpit-Liste auftauchen.

Beides lokal verifiziert (Helper: ACTIVE->CANCELLED + endDate; Cockpit:
Vertrag erscheint in der Liste mit korrektem Status).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 15:13:05 +02:00
co-authored by Claude Opus 4.8
parent 5423c83009
commit 8169c74d8e
7 changed files with 159 additions and 20 deletions
+15 -1
View File
@@ -11,7 +11,7 @@ import { logChange } from '../services/audit.service.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier, validatePortalUsername } from '../utils/sanitize.js';
import { ApiError } from '../utils/apiError.js';
import { canAccessContract, canAccessCustomer } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
import { maybeActivateOnDeliveryConfirmation, maybeCancelOnCancellationConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
/**
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
@@ -232,6 +232,20 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
const sanitizedBody = sanitizeContractBody(req.body);
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,
// damit manuelle Status-Korrekturen nicht sofort zurückgesetzt werden.
{
const asDay = (d: unknown) =>
d ? new Date(d as string).toISOString().split('T')[0] : null;
const beforeConf = asDay((before as any)?.cancellationConfirmationDate);
const afterConf = asDay((sanitizedBody as any)?.cancellationConfirmationDate);
if (afterConf && afterConf !== beforeConf) {
await maybeCancelOnCancellationConfirmation(contractId, req);
}
}
// Geänderte Felder ermitteln
const changes: Record<string, { von: unknown; nach: unknown }> = {};
const fieldLabels: Record<string, string> = {
+5 -16
View File
@@ -14,6 +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';
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
// Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion
@@ -694,23 +695,11 @@ async function handleContractDocumentUpload(
data: updateData,
});
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und
// der Vertrag noch ACTIVE ist → auf CANCELLED umstellen + Audit-Log.
// 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.
if (fieldName === 'cancellationConfirmationPath' && contract.status === 'ACTIVE') {
await prisma.contract.update({
where: { id: contractId },
data: { status: 'CANCELLED' },
});
await logChange({
req,
action: 'UPDATE',
resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${contract.contractNumber} automatisch auf CANCELLED gesetzt (Kündigungsbestätigung hochgeladen)`,
details: { vorher: 'ACTIVE', nachher: 'CANCELLED', trigger: 'cancellationConfirmation-Upload' },
customerId: contract.customerId,
});
if (fieldName === 'cancellationConfirmationPath') {
await maybeCancelOnCancellationConfirmation(contractId, req);
}
res.json({
@@ -102,6 +102,9 @@ export interface ReportedMeterReading {
export interface CockpitResult {
contracts: CockpitContract[];
// Verträge mit hinterlegter Kündigungsbestätigung (Dokument und/oder Datum)
// in Status ACTIVE/DRAFT/CANCELLED als eigener Filter im Cockpit.
cancellationConfirmations: CockpitContract[];
documentAlerts: DocumentAlert[];
reportedReadings: ReportedMeterReading[];
summary: CockpitSummary;
@@ -775,8 +778,55 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
// Gemeldete Zählerstände (REPORTED Status)
const reportedReadings = await getReportedMeterReadings(opts?.customerIds);
// Filter „Kündigungsbestätigung vorhanden": alle Verträge mit
// cancellationConfirmationPath und/oder -Date in Status ACTIVE/DRAFT/
// CANCELLED. Eigene Liste, weil solche Verträge (v.a. bereits CANCELLED)
// sonst mangels „Issue" nicht in der Cockpit-Liste auftauchen.
const CANCEL_CONF_STATUSES: ContractStatus[] = ['ACTIVE', 'DRAFT', 'CANCELLED'];
const cancellationConfirmations: CockpitContract[] = contracts
.filter(
(c) =>
(c.cancellationConfirmationPath || c.cancellationConfirmationDate) &&
CANCEL_CONF_STATUSES.includes(c.status),
)
.map((c) => {
const customerName =
c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`;
const dateStr = c.cancellationConfirmationDate
? new Date(c.cancellationConfirmationDate).toLocaleDateString('de-DE')
: null;
const parts: string[] = [];
if (c.cancellationConfirmationPath) parts.push('Dokument');
if (dateStr) parts.push(`Datum ${dateStr}`);
return {
id: c.id,
contractNumber: c.contractNumber,
type: c.type,
status: c.status,
customer: {
id: c.customer.id,
customerNumber: c.customer.customerNumber,
name: customerName,
},
provider: c.provider ? { id: c.provider.id, name: c.provider.name } : undefined,
tariff: c.tariff ? { id: c.tariff.id, name: c.tariff.name } : undefined,
providerName: c.providerName || undefined,
tariffName: c.tariffName || undefined,
issues: [
{
type: 'has_cancellation_confirmation',
label: 'Kündigungsbestätigung vorhanden',
urgency: 'none' as UrgencyLevel,
details: parts.join(' · ') || 'Kündigungsbestätigung hinterlegt',
},
],
highestUrgency: 'none' as UrgencyLevel,
};
});
return {
contracts: cockpitContracts,
cancellationConfirmations,
documentAlerts,
reportedReadings,
summary,
@@ -157,6 +157,70 @@ export async function withContractDocumentLock<T>(
* (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 }> = {};
if (c.status === 'ACTIVE') {
updateData.status = 'CANCELLED';
changes.status = { vorher: 'ACTIVE', nachher: 'CANCELLED' };
}
// Vertragsende = Kündigungsdatum (Bestätigungsdatum), falls vorhanden.
if (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,
});
}
export async function maybeActivateOnDeliveryConfirmation(
contractId: number,
documentType: string,
+16
View File
@@ -97,6 +97,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🚫 Auto-Kündigung + Cockpit-Filter Kündigungsbestätigung** (2026-08-07)
- **1) Auto-Status bei Kündigungsbestätigung:** Wird zu einem Vertrag eine
Kündigungsbestätigung hinzugefügt als **Datum** (`cancellationConfirmationDate`
übers Formular) und/oder als **Dokument** (Upload) wird der Vertrag von
ACTIVE → **CANCELLED** gesetzt und das **Vertragsende = Kündigungsdatum**.
Zentrale Funktion `maybeCancelOnCancellationConfirmation` (idempotent, nur
aus ACTIVE, nur echte Änderungen); genutzt vom Upload (ersetzt die alte
Inline-Logik, jetzt auch endDate) und vom Update-Controller (nur bei
neu/geändertem Bestätigungsdatum, damit manuelle Status-Korrekturen nicht
zurückgesetzt werden). Verifiziert.
- **2) Cockpit-Filter „Kündigungsbestätigung":** listet alle Verträge mit
hinterlegter Kündigungsbestätigung (Dokument und/oder Datum) in Status
ACTIVE/DRAFT/CANCELLED. Eigene Liste `cancellationConfirmations` im
Cockpit-Result (weil bereits CANCELLED-Verträge mangels „Issue" sonst nicht
auftauchen) + neue Filter-Option im Cockpit-Dropdown. Verifiziert.
- [~] **🧾 Gutschriftsverwaltung (Subventionen am Vertrag) Phase 1: Backend** (2026-08-06)
- Use-Case: zu einem Vertrag kann eine Subvention gewährt werden **Geld**
(Überweisung, EUR) oder **Sachwert** (Smartphone/Elektro …, Textfeld).
@@ -112,7 +112,7 @@ const categoryLabels: Record<string, string> = {
missingConsents: 'Fehlende Einwilligungen',
};
type FilterType = 'all' | 'critical' | 'warning' | 'ok' | 'deadlines' | 'credentials' | 'data' | 'tasks' | 'review' | 'invoices' | 'consents';
type FilterType = 'all' | 'critical' | 'warning' | 'ok' | 'deadlines' | 'credentials' | 'data' | 'tasks' | 'review' | 'invoices' | 'consents' | 'cancellation_confirmations';
export default function ContractCockpit() {
const [searchParams, setSearchParams] = useSearchParams();
@@ -238,10 +238,14 @@ export default function ContractCockpit() {
return contracts.filter(c =>
c.issues.some(i => ['missing_consents', 'consent_withdrawn'].includes(i.type))
);
case 'cancellation_confirmations':
// Eigene Liste vom Backend (Verträge mit Kündigungsbestätigung in
// Status ACTIVE/DRAFT/CANCELLED), nicht die issue-basierte Liste.
return cockpitData?.data?.cancellationConfirmations ?? [];
default:
return contracts;
}
}, [cockpitData?.data?.contracts, filter]);
}, [cockpitData?.data?.contracts, cockpitData?.data?.cancellationConfirmations, filter]);
if (isLoading) {
return (
@@ -742,8 +746,9 @@ export default function ContractCockpit() {
{ value: 'review', label: `Erneute Prüfung (${summary.byCategory.reviewDue || 0})` },
{ value: 'invoices', label: `Fehlende Rechnungen (${summary.byCategory.missingInvoices || 0})` },
{ value: 'consents', label: `Einwilligungen (${summary.byCategory.missingConsents || 0})` },
{ value: 'cancellation_confirmations', label: `Kündigungsbestätigung (${cockpitData.data.cancellationConfirmations?.length || 0})` },
]}
className="w-64"
className="w-72"
/>
<span className="text-sm text-gray-500">
{filteredContracts.length} Verträge angezeigt
+1
View File
@@ -842,6 +842,7 @@ export interface ReportedMeterReading {
export interface CockpitResult {
contracts: CockpitContract[];
cancellationConfirmations: CockpitContract[];
documentAlerts: DocumentAlert[];
reportedReadings: ReportedMeterReading[];
summary: CockpitSummary;