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 { 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> = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user