Beim Upload einer Lieferbestätigung mit Datum wurde startDate nur gesetzt, wenn es noch leer war (if (!contract.startDate)). Hatte der Vertrag schon ein geschätztes Beginndatum, blieb es trotz eingetragenem Lieferdatum stehen. Fix in maybeActivateOnDeliveryConfirmation: ein explizit eingegebenes Lieferdatum übernimmt jetzt IMMER den Vertragsbeginn (die Liefer- bestätigung ist das maßgebliche tatsächliche Startdatum), auch überschreibend. Der Fallback "heute" (kein Datum angegeben) füllt weiterhin nur ein leeres Feld, damit ein echtes Datum nicht versehentlich mit heute überschrieben wird. No-op-Guard + Audit-Log unverändert. Frontend schickte das Datum bereits mit und lädt den Vertrag nach dem Upload neu – kein Frontend-Change nötig. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
224 lines
7.5 KiB
TypeScript
224 lines
7.5 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.
|
||
*/
|
||
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,
|
||
});
|
||
}
|