Backend: Datumsbewusste Zimmerverfuegbarkeits-Pruefung (Plausibilitaetsabfrage) fuer Vertragsneuanla

This commit is contained in:
Giuseppe Lombardo 2026-08-13 17:14:47 +00:00
parent 0d8401819a
commit e6b16ea74e

View File

@ -5,6 +5,31 @@ import { generateContractPdf } from '../services/contractDocumentGenerator';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
/**
* Prueft, ob auf einem Zimmer bereits ein aktiver Vertrag existiert, dessen
* Zeitraum sich mit [startDate, endDate] ueberschneidet. endDate=null steht
* fuer unbefristet (offenes Ende). Grundlage fuer die Plausibilitaetspruefung
* bei Neuvergabe/Einladung eines Zimmers.
*/
export async function findOverlappingContract(
client: PrismaClient,
roomId: string,
startDate: Date,
endDate: Date | null,
) {
const activeContracts = await client.contract.findMany({
where: { roomId, isActive: true },
});
return (
activeContracts.find((c) => {
const otherEnd = c.endDate;
const overlaps = (!otherEnd || otherEnd >= startDate) && (!endDate || endDate >= c.startDate);
return overlaps;
}) || null
);
}
/** /**
* Router für Kündigungsfristen-Erinnerungen. * Router für Kündigungsfristen-Erinnerungen.
* *
@ -342,12 +367,66 @@ contractsRouter.get('/contracts/archive', requireAuth, requireRole('LANDLORD', '
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Zimmer ohne aktiven Vertrag — für die Auswahl beim Wiederaufnehmen. // Zimmer ohne aktiven Vertrag — für die Auswahl beim Wiederaufnehmen.
contractsRouter.get('/contracts/vacant-rooms', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (_req: AuthedRequest, res: Response) => { // Optional datumsbewusst: mit startDate/endDate werden auch Zimmer mit
const rooms = await prisma.room.findMany({ // befristetem/gekuendigtem Vertrag beruecksichtigt, sofern deren Zeitraum
where: { contracts: { none: { isActive: true } } }, // nicht mit dem angefragten Zeitraum ueberlappt. Ohne Parameter bleibt das
// bisherige (rein isActive-basierte) Verhalten unveraendert.
contractsRouter.get('/contracts/vacant-rooms', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => {
const { startDate, endDate } = req.query as { startDate?: string; endDate?: string };
if (!startDate || Number.isNaN(Date.parse(startDate))) {
const rooms = await prisma.room.findMany({
where: { contracts: { none: { isActive: true } } },
select: { id: true, roomNumber: true, sizeSqm: true, baseRent: true, utilityPauschal: true },
orderBy: { roomNumber: 'asc' },
});
return res.status(200).json({ rooms });
}
const start = new Date(startDate);
const end = endDate && !Number.isNaN(Date.parse(endDate)) ? new Date(endDate) : null;
const allRooms = await prisma.room.findMany({
select: { id: true, roomNumber: true, sizeSqm: true, baseRent: true, utilityPauschal: true }, select: { id: true, roomNumber: true, sizeSqm: true, baseRent: true, utilityPauschal: true },
orderBy: { roomNumber: 'asc' }, orderBy: { roomNumber: 'asc' },
}); });
const rooms = [];
for (const room of allRooms) {
const conflict = await findOverlappingContract(prisma, room.id, start, end);
if (!conflict) rooms.push(room);
}
res.status(200).json({ rooms });
});
// Datumsbewusste Verfuegbarkeitspruefung ALLER Zimmer (auch belegte werden
// zurueckgegeben, aber mit available:false + Grund) — Basis fuer die
// Plausibilitaetspruefung im Frontend bei Einladung/Wiederaufnahme.
contractsRouter.get('/contracts/room-availability', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => {
const { startDate, endDate } = req.query as { startDate?: string; endDate?: string };
if (!startDate || Number.isNaN(Date.parse(startDate))) {
return res.status(400).json({ error: 'startDate ist erforderlich' });
}
const start = new Date(startDate);
const end = endDate && !Number.isNaN(Date.parse(endDate)) ? new Date(endDate) : null;
const allRooms = await prisma.room.findMany({
select: { id: true, roomNumber: true, baseRent: true, utilityPauschal: true },
orderBy: { roomNumber: 'asc' },
});
const rooms = [];
for (const room of allRooms) {
const conflict = await findOverlappingContract(prisma, room.id, start, end);
const conflictUntil = conflict ? (conflict.endDate ? conflict.endDate.toLocaleDateString('de-DE') : 'unbefristet') : null;
rooms.push({
...room,
available: !conflict,
conflictReason: conflict ? `Zimmer ist im gewaehlten Zeitraum bereits vergeben (belegt bis ${conflictUntil}).` : undefined,
});
}
res.status(200).json({ rooms }); res.status(200).json({ rooms });
}); });
@ -360,11 +439,14 @@ contractsRouter.post(
requireAuth, requireAuth,
requireRole('LANDLORD', 'ADMIN'), requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => { async (req: AuthedRequest, res: Response) => {
const { userId, roomId, startDate, totalWarmRent, depositAmount, paymentDueDay, noticePeriodMonths } = req.body || {}; const { userId, roomId, startDate, endDate, totalWarmRent, depositAmount, paymentDueDay, noticePeriodMonths } = req.body || {};
if (!userId || typeof userId !== 'string') return res.status(400).json({ error: 'userId ist erforderlich' }); if (!userId || typeof userId !== 'string') return res.status(400).json({ error: 'userId ist erforderlich' });
if (!roomId || typeof roomId !== 'string') return res.status(400).json({ error: 'roomId ist erforderlich' }); if (!roomId || typeof roomId !== 'string') return res.status(400).json({ error: 'roomId ist erforderlich' });
if (!startDate || Number.isNaN(Date.parse(startDate))) return res.status(400).json({ error: 'startDate ist erforderlich' }); if (!startDate || Number.isNaN(Date.parse(startDate))) return res.status(400).json({ error: 'startDate ist erforderlich' });
if (endDate !== undefined && endDate !== null && Number.isNaN(Date.parse(endDate))) {
return res.status(400).json({ error: 'endDate ist ungueltig' });
}
if (!Number.isFinite(Number(totalWarmRent)) || Number(totalWarmRent) <= 0) { if (!Number.isFinite(Number(totalWarmRent)) || Number(totalWarmRent) <= 0) {
return res.status(400).json({ error: 'totalWarmRent muss eine positive Zahl sein' }); return res.status(400).json({ error: 'totalWarmRent muss eine positive Zahl sein' });
} }
@ -385,12 +467,23 @@ contractsRouter.post(
if (activeForUser) return res.status(409).json({ error: 'Mieter hat bereits einen aktiven Vertrag' }); if (activeForUser) return res.status(409).json({ error: 'Mieter hat bereits einen aktiven Vertrag' });
if (activeForRoom) return res.status(409).json({ error: 'Zimmer ist bereits belegt' }); if (activeForRoom) return res.status(409).json({ error: 'Zimmer ist bereits belegt' });
const parsedStart = new Date(startDate);
const parsedEnd = endDate ? new Date(endDate) : null;
const overlap = await findOverlappingContract(prisma, roomId, parsedStart, parsedEnd);
if (overlap) {
const overlapUntil = overlap.endDate ? overlap.endDate.toLocaleDateString('de-DE') : 'unbefristet';
return res.status(409).json({
error: `Das Zimmer ist im gewählten Zeitraum bereits vergeben (belegt bis ${overlapUntil}).`,
});
}
const [contract] = await prisma.$transaction([ const [contract] = await prisma.$transaction([
prisma.contract.create({ prisma.contract.create({
data: { data: {
userId, userId,
roomId, roomId,
startDate: new Date(startDate), startDate: parsedStart,
endDate: parsedEnd,
totalWarmRent: Number(totalWarmRent), totalWarmRent: Number(totalWarmRent),
depositAmount: Number(depositAmount), depositAmount: Number(depositAmount),
paymentDueDay: Number.isFinite(Number(paymentDueDay)) ? Number(paymentDueDay) : 3, paymentDueDay: Number.isFinite(Number(paymentDueDay)) ? Number(paymentDueDay) : 3,