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:
@@ -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 { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier, validatePortalUsername } from '../utils/sanitize.js';
|
||||||
import { ApiError } from '../utils/apiError.js';
|
import { ApiError } from '../utils/apiError.js';
|
||||||
import { canAccessContract, canAccessCustomer } from '../utils/accessControl.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
|
* 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 sanitizedBody = sanitizeContractBody(req.body);
|
||||||
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
|
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
|
// Geänderte Felder ermitteln
|
||||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../utils/accessControl.js';
|
} from '../utils/accessControl.js';
|
||||||
import { validateOptionalIsoDate } from '../utils/sanitize.js';
|
import { validateOptionalIsoDate } from '../utils/sanitize.js';
|
||||||
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.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
|
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
|
||||||
// Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion
|
// Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion
|
||||||
@@ -694,23 +695,11 @@ async function handleContractDocumentUpload(
|
|||||||
data: updateData,
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und
|
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde:
|
||||||
// der Vertrag noch ACTIVE ist → auf CANCELLED umstellen + Audit-Log.
|
// Vertrag ACTIVE → CANCELLED und Vertragsende = Kündigungsdatum.
|
||||||
// "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen.
|
// "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen.
|
||||||
if (fieldName === 'cancellationConfirmationPath' && contract.status === 'ACTIVE') {
|
if (fieldName === 'cancellationConfirmationPath') {
|
||||||
await prisma.contract.update({
|
await maybeCancelOnCancellationConfirmation(contractId, req);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ export interface ReportedMeterReading {
|
|||||||
|
|
||||||
export interface CockpitResult {
|
export interface CockpitResult {
|
||||||
contracts: CockpitContract[];
|
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[];
|
documentAlerts: DocumentAlert[];
|
||||||
reportedReadings: ReportedMeterReading[];
|
reportedReadings: ReportedMeterReading[];
|
||||||
summary: CockpitSummary;
|
summary: CockpitSummary;
|
||||||
@@ -775,8 +778,55 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
// Gemeldete Zählerstände (REPORTED Status)
|
// Gemeldete Zählerstände (REPORTED Status)
|
||||||
const reportedReadings = await getReportedMeterReadings(opts?.customerIds);
|
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 {
|
return {
|
||||||
contracts: cockpitContracts,
|
contracts: cockpitContracts,
|
||||||
|
cancellationConfirmations,
|
||||||
documentAlerts,
|
documentAlerts,
|
||||||
reportedReadings,
|
reportedReadings,
|
||||||
summary,
|
summary,
|
||||||
|
|||||||
@@ -157,6 +157,70 @@ export async function withContractDocumentLock<T>(
|
|||||||
* (SaveAttachmentModal / ContractDetail). Vergleich case-insensitive +
|
* (SaveAttachmentModal / ContractDetail). Vergleich case-insensitive +
|
||||||
* getrimmt zur Robustheit.
|
* 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(
|
export async function maybeActivateOnDeliveryConfirmation(
|
||||||
contractId: number,
|
contractId: number,
|
||||||
documentType: string,
|
documentType: string,
|
||||||
|
|||||||
@@ -97,6 +97,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ 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)
|
- [~] **🧾 Gutschriftsverwaltung (Subventionen am Vertrag) – Phase 1: Backend** (2026-08-06)
|
||||||
- Use-Case: zu einem Vertrag kann eine Subvention gewährt werden – **Geld**
|
- Use-Case: zu einem Vertrag kann eine Subvention gewährt werden – **Geld**
|
||||||
(Überweisung, EUR) oder **Sachwert** (Smartphone/Elektro …, Textfeld).
|
(Überweisung, EUR) oder **Sachwert** (Smartphone/Elektro …, Textfeld).
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ const categoryLabels: Record<string, string> = {
|
|||||||
missingConsents: 'Fehlende Einwilligungen',
|
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() {
|
export default function ContractCockpit() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -238,10 +238,14 @@ export default function ContractCockpit() {
|
|||||||
return contracts.filter(c =>
|
return contracts.filter(c =>
|
||||||
c.issues.some(i => ['missing_consents', 'consent_withdrawn'].includes(i.type))
|
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:
|
default:
|
||||||
return contracts;
|
return contracts;
|
||||||
}
|
}
|
||||||
}, [cockpitData?.data?.contracts, filter]);
|
}, [cockpitData?.data?.contracts, cockpitData?.data?.cancellationConfirmations, filter]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -742,8 +746,9 @@ export default function ContractCockpit() {
|
|||||||
{ value: 'review', label: `Erneute Prüfung (${summary.byCategory.reviewDue || 0})` },
|
{ value: 'review', label: `Erneute Prüfung (${summary.byCategory.reviewDue || 0})` },
|
||||||
{ value: 'invoices', label: `Fehlende Rechnungen (${summary.byCategory.missingInvoices || 0})` },
|
{ value: 'invoices', label: `Fehlende Rechnungen (${summary.byCategory.missingInvoices || 0})` },
|
||||||
{ value: 'consents', label: `Einwilligungen (${summary.byCategory.missingConsents || 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">
|
<span className="text-sm text-gray-500">
|
||||||
{filteredContracts.length} Verträge angezeigt
|
{filteredContracts.length} Verträge angezeigt
|
||||||
|
|||||||
@@ -842,6 +842,7 @@ export interface ReportedMeterReading {
|
|||||||
|
|
||||||
export interface CockpitResult {
|
export interface CockpitResult {
|
||||||
contracts: CockpitContract[];
|
contracts: CockpitContract[];
|
||||||
|
cancellationConfirmations: CockpitContract[];
|
||||||
documentAlerts: DocumentAlert[];
|
documentAlerts: DocumentAlert[];
|
||||||
reportedReadings: ReportedMeterReading[];
|
reportedReadings: ReportedMeterReading[];
|
||||||
summary: CockpitSummary;
|
summary: CockpitSummary;
|
||||||
|
|||||||
Reference in New Issue
Block a user