|
|
|
|
@@ -0,0 +1,619 @@
|
|
|
|
|
import { createRequire } from 'module';
|
|
|
|
|
const require = createRequire(import.meta.url);
|
|
|
|
|
const pdfParse = require('pdf-parse/lib/pdf-parse.js');
|
|
|
|
|
import { checkAccess } from '../utils/userUtils.js';
|
|
|
|
|
import OfficialTournament from '../models/OfficialTournament.js';
|
|
|
|
|
import OfficialCompetition from '../models/OfficialCompetition.js';
|
|
|
|
|
import OfficialCompetitionMember from '../models/OfficialCompetitionMember.js';
|
|
|
|
|
import Member from '../models/Member.js';
|
|
|
|
|
import { Op } from 'sequelize';
|
|
|
|
|
|
|
|
|
|
// In-Memory Store (einfacher Start); später DB-Modell
|
|
|
|
|
const parsedTournaments = new Map(); // key: id, value: { id, clubId, rawText, parsedData }
|
|
|
|
|
let seq = 1;
|
|
|
|
|
|
|
|
|
|
export const uploadTournamentPdf = async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { authcode: userToken } = req.headers;
|
|
|
|
|
const { clubId } = req.params;
|
|
|
|
|
await checkAccess(userToken, clubId);
|
|
|
|
|
if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'No pdf provided' });
|
|
|
|
|
const data = await pdfParse(req.file.buffer);
|
|
|
|
|
const parsed = parseTournamentText(data.text);
|
|
|
|
|
const t = await OfficialTournament.create({
|
|
|
|
|
clubId,
|
|
|
|
|
title: parsed.title || null,
|
|
|
|
|
eventDate: parsed.termin || null,
|
|
|
|
|
organizer: null,
|
|
|
|
|
host: null,
|
|
|
|
|
venues: JSON.stringify(parsed.austragungsorte || []),
|
|
|
|
|
competitionTypes: JSON.stringify(parsed.konkurrenztypen || []),
|
|
|
|
|
registrationDeadlines: JSON.stringify(parsed.meldeschluesse || []),
|
|
|
|
|
entryFees: JSON.stringify(parsed.entryFees || {}),
|
|
|
|
|
});
|
|
|
|
|
// competitions persistieren
|
|
|
|
|
for (const c of parsed.competitions || []) {
|
|
|
|
|
// Korrigiere Fehlzuordnung: Wenn die Zeile mit "Stichtag" fälschlich in performanceClass steht
|
|
|
|
|
let performanceClass = c.leistungsklasse || c.performanceClass || null;
|
|
|
|
|
let cutoffDate = c.stichtag || c.cutoffDate || null;
|
|
|
|
|
if (performanceClass && /^stichtag\b/i.test(performanceClass)) {
|
|
|
|
|
cutoffDate = performanceClass.replace(/^stichtag\s*:?\s*/i, '').trim();
|
|
|
|
|
performanceClass = null;
|
|
|
|
|
}
|
|
|
|
|
await OfficialCompetition.create({
|
|
|
|
|
tournamentId: t.id,
|
|
|
|
|
ageClassCompetition: c.altersklasseWettbewerb || c.ageClassCompetition || null,
|
|
|
|
|
performanceClass,
|
|
|
|
|
startTime: c.startzeit || c.startTime || null,
|
|
|
|
|
registrationDeadlineDate: c.meldeschlussDatum || c.registrationDeadlineDate || null,
|
|
|
|
|
registrationDeadlineOnline: c.meldeschlussOnline || c.registrationDeadlineOnline || null,
|
|
|
|
|
cutoffDate,
|
|
|
|
|
ttrRelevant: c.ttrRelevant || null,
|
|
|
|
|
openTo: c.offenFuer || c.openTo || null,
|
|
|
|
|
preliminaryRound: c.vorrunde || c.preliminaryRound || null,
|
|
|
|
|
finalRound: c.endrunde || c.finalRound || null,
|
|
|
|
|
maxParticipants: c.maxTeilnehmer || c.maxParticipants || null,
|
|
|
|
|
entryFee: c.startgeld || c.entryFee || null,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
res.status(201).json({ id: String(t.id) });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('[uploadTournamentPdf] Error:', e);
|
|
|
|
|
res.status(500).json({ error: 'Failed to parse pdf' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const getParsedTournament = async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { authcode: userToken } = req.headers;
|
|
|
|
|
const { clubId, id } = req.params;
|
|
|
|
|
await checkAccess(userToken, clubId);
|
|
|
|
|
const t = await OfficialTournament.findOne({ where: { id, clubId } });
|
|
|
|
|
if (!t) return res.status(404).json({ error: 'not found' });
|
|
|
|
|
const comps = await OfficialCompetition.findAll({ where: { tournamentId: id } });
|
|
|
|
|
const entries = await OfficialCompetitionMember.findAll({ where: { tournamentId: id } });
|
|
|
|
|
const competitions = comps.map((c) => {
|
|
|
|
|
const j = c.toJSON();
|
|
|
|
|
return {
|
|
|
|
|
id: j.id,
|
|
|
|
|
tournamentId: j.tournamentId,
|
|
|
|
|
ageClassCompetition: j.ageClassCompetition || null,
|
|
|
|
|
performanceClass: j.performanceClass || null,
|
|
|
|
|
startTime: j.startTime || null,
|
|
|
|
|
registrationDeadlineDate: j.registrationDeadlineDate || null,
|
|
|
|
|
registrationDeadlineOnline: j.registrationDeadlineOnline || null,
|
|
|
|
|
cutoffDate: j.cutoffDate || null,
|
|
|
|
|
ttrRelevant: j.ttrRelevant || null,
|
|
|
|
|
openTo: j.openTo || null,
|
|
|
|
|
preliminaryRound: j.preliminaryRound || null,
|
|
|
|
|
finalRound: j.finalRound || null,
|
|
|
|
|
maxParticipants: j.maxParticipants || null,
|
|
|
|
|
entryFee: j.entryFee || null,
|
|
|
|
|
// Legacy Felder zusätzlich, falls Frontend sie noch nutzt
|
|
|
|
|
altersklasseWettbewerb: j.ageClassCompetition || null,
|
|
|
|
|
leistungsklasse: j.performanceClass || null,
|
|
|
|
|
startzeit: j.startTime || null,
|
|
|
|
|
meldeschlussDatum: j.registrationDeadlineDate || null,
|
|
|
|
|
meldeschlussOnline: j.registrationDeadlineOnline || null,
|
|
|
|
|
stichtag: j.cutoffDate || null,
|
|
|
|
|
offenFuer: j.openTo || null,
|
|
|
|
|
vorrunde: j.preliminaryRound || null,
|
|
|
|
|
endrunde: j.finalRound || null,
|
|
|
|
|
maxTeilnehmer: j.maxParticipants || null,
|
|
|
|
|
startgeld: j.entryFee || null,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
res.status(200).json({
|
|
|
|
|
id: String(t.id),
|
|
|
|
|
clubId: String(t.clubId),
|
|
|
|
|
parsedData: {
|
|
|
|
|
title: t.title,
|
|
|
|
|
termin: t.eventDate,
|
|
|
|
|
austragungsorte: JSON.parse(t.venues || '[]'),
|
|
|
|
|
konkurrenztypen: JSON.parse(t.competitionTypes || '[]'),
|
|
|
|
|
meldeschluesse: JSON.parse(t.registrationDeadlines || '[]'),
|
|
|
|
|
entryFees: JSON.parse(t.entryFees || '{}'),
|
|
|
|
|
competitions,
|
|
|
|
|
},
|
|
|
|
|
participation: entries.map(e => ({
|
|
|
|
|
id: e.id,
|
|
|
|
|
tournamentId: e.tournamentId,
|
|
|
|
|
competitionId: e.competitionId,
|
|
|
|
|
memberId: e.memberId,
|
|
|
|
|
wants: !!e.wants,
|
|
|
|
|
registered: !!e.registered,
|
|
|
|
|
participated: !!e.participated,
|
|
|
|
|
placement: e.placement || null,
|
|
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
res.status(500).json({ error: 'Failed to fetch parsed tournament' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const upsertCompetitionMember = async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { authcode: userToken } = req.headers;
|
|
|
|
|
const { clubId, id } = req.params; // id = tournamentId
|
|
|
|
|
await checkAccess(userToken, clubId);
|
|
|
|
|
const { competitionId, memberId, wants, registered, participated, placement } = req.body;
|
|
|
|
|
if (!competitionId || !memberId) return res.status(400).json({ error: 'competitionId and memberId required' });
|
|
|
|
|
const [row] = await OfficialCompetitionMember.findOrCreate({
|
|
|
|
|
where: { competitionId, memberId },
|
|
|
|
|
defaults: {
|
|
|
|
|
tournamentId: id,
|
|
|
|
|
competitionId,
|
|
|
|
|
memberId,
|
|
|
|
|
wants: !!wants,
|
|
|
|
|
registered: !!registered,
|
|
|
|
|
participated: !!participated,
|
|
|
|
|
placement: placement || null,
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
row.wants = wants !== undefined ? !!wants : row.wants;
|
|
|
|
|
row.registered = registered !== undefined ? !!registered : row.registered;
|
|
|
|
|
row.participated = participated !== undefined ? !!participated : row.participated;
|
|
|
|
|
if (placement !== undefined) row.placement = placement;
|
|
|
|
|
await row.save();
|
|
|
|
|
return res.status(200).json({ success: true, id: row.id });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('[upsertCompetitionMember] Error:', e);
|
|
|
|
|
res.status(500).json({ error: 'Failed to save participation' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const updateParticipantStatus = async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { authcode: userToken } = req.headers;
|
|
|
|
|
const { clubId, id } = req.params; // id = tournamentId
|
|
|
|
|
await checkAccess(userToken, clubId);
|
|
|
|
|
const { competitionId, memberId, action } = req.body;
|
|
|
|
|
|
|
|
|
|
if (!competitionId || !memberId || !action) {
|
|
|
|
|
return res.status(400).json({ error: 'competitionId, memberId and action required' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [row] = await OfficialCompetitionMember.findOrCreate({
|
|
|
|
|
where: { competitionId, memberId },
|
|
|
|
|
defaults: {
|
|
|
|
|
tournamentId: id,
|
|
|
|
|
competitionId,
|
|
|
|
|
memberId,
|
|
|
|
|
wants: false,
|
|
|
|
|
registered: false,
|
|
|
|
|
participated: false,
|
|
|
|
|
placement: null,
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Status-Update basierend auf Aktion
|
|
|
|
|
switch (action) {
|
|
|
|
|
case 'register':
|
|
|
|
|
// Von "möchte teilnehmen" zu "angemeldet"
|
|
|
|
|
row.wants = true;
|
|
|
|
|
row.registered = true;
|
|
|
|
|
row.participated = false;
|
|
|
|
|
break;
|
|
|
|
|
case 'participate':
|
|
|
|
|
// Von "angemeldet" zu "hat gespielt"
|
|
|
|
|
row.wants = true;
|
|
|
|
|
row.registered = true;
|
|
|
|
|
row.participated = true;
|
|
|
|
|
break;
|
|
|
|
|
case 'reset':
|
|
|
|
|
// Zurück zu "möchte teilnehmen"
|
|
|
|
|
row.wants = true;
|
|
|
|
|
row.registered = false;
|
|
|
|
|
row.participated = false;
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
return res.status(400).json({ error: 'Invalid action. Use: register, participate, or reset' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await row.save();
|
|
|
|
|
return res.status(200).json({
|
|
|
|
|
success: true,
|
|
|
|
|
id: row.id,
|
|
|
|
|
status: {
|
|
|
|
|
wants: row.wants,
|
|
|
|
|
registered: row.registered,
|
|
|
|
|
participated: row.participated,
|
|
|
|
|
placement: row.placement
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('[updateParticipantStatus] Error:', e);
|
|
|
|
|
res.status(500).json({ error: 'Failed to update participant status' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const listOfficialTournaments = async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { authcode: userToken } = req.headers;
|
|
|
|
|
const { clubId } = req.params;
|
|
|
|
|
await checkAccess(userToken, clubId);
|
|
|
|
|
const list = await OfficialTournament.findAll({ where: { clubId } });
|
|
|
|
|
res.status(200).json(list);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
res.status(500).json({ error: 'Failed to list tournaments' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const listClubParticipations = async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { authcode: userToken } = req.headers;
|
|
|
|
|
const { clubId } = req.params;
|
|
|
|
|
await checkAccess(userToken, clubId);
|
|
|
|
|
const tournaments = await OfficialTournament.findAll({ where: { clubId } });
|
|
|
|
|
if (!tournaments || tournaments.length === 0) return res.status(200).json([]);
|
|
|
|
|
const tournamentIds = tournaments.map(t => t.id);
|
|
|
|
|
|
|
|
|
|
const rows = await OfficialCompetitionMember.findAll({
|
|
|
|
|
where: { tournamentId: { [Op.in]: tournamentIds }, participated: true },
|
|
|
|
|
include: [
|
|
|
|
|
{ model: OfficialCompetition, as: 'competition', attributes: ['id', 'tournamentId', 'ageClassCompetition', 'startTime'] },
|
|
|
|
|
{ model: OfficialTournament, as: 'tournament', attributes: ['id', 'title', 'eventDate'] },
|
|
|
|
|
{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] },
|
|
|
|
|
]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const parseDmy = (s) => {
|
|
|
|
|
if (!s) return null;
|
|
|
|
|
const m = String(s).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
|
|
|
|
if (!m) return null;
|
|
|
|
|
const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
|
|
|
|
|
return isNaN(d.getTime()) ? null : d;
|
|
|
|
|
};
|
|
|
|
|
const fmtDmy = (d) => {
|
|
|
|
|
const dd = String(d.getDate()).padStart(2, '0');
|
|
|
|
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
|
|
|
const yyyy = d.getFullYear();
|
|
|
|
|
return `${dd}.${mm}.${yyyy}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const byTournament = new Map();
|
|
|
|
|
for (const r of rows) {
|
|
|
|
|
const t = r.tournament;
|
|
|
|
|
const c = r.competition;
|
|
|
|
|
const m = r.member;
|
|
|
|
|
if (!t || !c || !m) continue;
|
|
|
|
|
if (!byTournament.has(t.id)) {
|
|
|
|
|
byTournament.set(t.id, {
|
|
|
|
|
tournamentId: String(t.id),
|
|
|
|
|
title: t.title || null,
|
|
|
|
|
startDate: null,
|
|
|
|
|
endDate: null,
|
|
|
|
|
entries: [],
|
|
|
|
|
_dates: [],
|
|
|
|
|
_eventDate: t.eventDate || null,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const bucket = byTournament.get(t.id);
|
|
|
|
|
const compDate = parseDmy(c.startTime || '') || null;
|
|
|
|
|
if (compDate) bucket._dates.push(compDate);
|
|
|
|
|
bucket.entries.push({
|
|
|
|
|
memberId: m.id,
|
|
|
|
|
memberName: `${m.firstName || ''} ${m.lastName || ''}`.trim(),
|
|
|
|
|
competitionId: c.id,
|
|
|
|
|
competitionName: c.ageClassCompetition || '',
|
|
|
|
|
placement: r.placement || null,
|
|
|
|
|
date: compDate ? fmtDmy(compDate) : null,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const out = [];
|
|
|
|
|
for (const t of tournaments) {
|
|
|
|
|
const bucket = byTournament.get(t.id) || {
|
|
|
|
|
tournamentId: String(t.id),
|
|
|
|
|
title: t.title || null,
|
|
|
|
|
startDate: null,
|
|
|
|
|
endDate: null,
|
|
|
|
|
entries: [],
|
|
|
|
|
_dates: [],
|
|
|
|
|
_eventDate: t.eventDate || null,
|
|
|
|
|
};
|
|
|
|
|
// Ableiten Start/Ende
|
|
|
|
|
if (bucket._dates.length) {
|
|
|
|
|
bucket._dates.sort((a, b) => a - b);
|
|
|
|
|
bucket.startDate = fmtDmy(bucket._dates[0]);
|
|
|
|
|
bucket.endDate = fmtDmy(bucket._dates[bucket._dates.length - 1]);
|
|
|
|
|
} else if (bucket._eventDate) {
|
|
|
|
|
const all = String(bucket._eventDate).match(/(\d{1,2}\.\d{1,2}\.\d{4})/g) || [];
|
|
|
|
|
if (all.length >= 1) {
|
|
|
|
|
const d1 = parseDmy(all[0]);
|
|
|
|
|
const d2 = all.length >= 2 ? parseDmy(all[1]) : d1;
|
|
|
|
|
if (d1) bucket.startDate = fmtDmy(d1);
|
|
|
|
|
if (d2) bucket.endDate = fmtDmy(d2);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Sort entries: Mitglied, dann Konkurrenz
|
|
|
|
|
bucket.entries.sort((a, b) => {
|
|
|
|
|
const mcmp = (a.memberName || '').localeCompare(b.memberName || '', 'de', { sensitivity: 'base' });
|
|
|
|
|
if (mcmp !== 0) return mcmp;
|
|
|
|
|
return (a.competitionName || '').localeCompare(b.competitionName || '', 'de', { sensitivity: 'base' });
|
|
|
|
|
});
|
|
|
|
|
delete bucket._dates;
|
|
|
|
|
delete bucket._eventDate;
|
|
|
|
|
out.push(bucket);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.status(200).json(out);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
res.status(500).json({ error: 'Failed to list club participations' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const deleteOfficialTournament = async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { authcode: userToken } = req.headers;
|
|
|
|
|
const { clubId, id } = req.params;
|
|
|
|
|
await checkAccess(userToken, clubId);
|
|
|
|
|
const t = await OfficialTournament.findOne({ where: { id, clubId } });
|
|
|
|
|
if (!t) return res.status(404).json({ error: 'not found' });
|
|
|
|
|
await OfficialCompetition.destroy({ where: { tournamentId: id } });
|
|
|
|
|
await OfficialTournament.destroy({ where: { id } });
|
|
|
|
|
res.status(204).send();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
res.status(500).json({ error: 'Failed to delete tournament' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function parseTournamentText(text) {
|
|
|
|
|
const lines = text.split(/\r?\n/);
|
|
|
|
|
const normLines = lines.map(l => l.replace(/\s+/g, ' ').trim());
|
|
|
|
|
|
|
|
|
|
const findTitle = () => {
|
|
|
|
|
const idx = normLines.findIndex(l => /Kreiseinzelmeisterschaften/i.test(l));
|
|
|
|
|
return idx >= 0 ? normLines[idx] : null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Neue Funktion: Teilnahmegebühren pro Spielklasse extrahieren
|
|
|
|
|
const extractEntryFees = () => {
|
|
|
|
|
const entryFees = {};
|
|
|
|
|
|
|
|
|
|
// Verschiedene Patterns für Teilnahmegebühren suchen
|
|
|
|
|
const feePatterns = [
|
|
|
|
|
// Pattern 1: "Startgeld: U12: 5€, U14: 7€, U16: 10€"
|
|
|
|
|
/startgeld\s*:?\s*(.+)/i,
|
|
|
|
|
// Pattern 2: "Teilnahmegebühr: U12: 5€, U14: 7€"
|
|
|
|
|
/teilnahmegebühr\s*:?\s*(.+)/i,
|
|
|
|
|
// Pattern 3: "Gebühr: U12: 5€, U14: 7€"
|
|
|
|
|
/gebühr\s*:?\s*(.+)/i,
|
|
|
|
|
// Pattern 4: "Einschreibegebühr: U12: 5€, U14: 7€"
|
|
|
|
|
/einschreibegebühr\s*:?\s*(.+)/i,
|
|
|
|
|
// Pattern 5: "Anmeldegebühr: U12: 5€, U14: 7€"
|
|
|
|
|
/anmeldegebühr\s*:?\s*(.+)/i
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const pattern of feePatterns) {
|
|
|
|
|
for (let i = 0; i < normLines.length; i++) {
|
|
|
|
|
const line = normLines[i];
|
|
|
|
|
const match = line.match(pattern);
|
|
|
|
|
if (match) {
|
|
|
|
|
const feeText = match[1];
|
|
|
|
|
|
|
|
|
|
// Extrahiere Gebühren aus dem Text
|
|
|
|
|
// Unterstützt verschiedene Formate:
|
|
|
|
|
// "U12: 5€, U14: 7€, U16: 10€"
|
|
|
|
|
// "U12: 5 Euro, U14: 7 Euro"
|
|
|
|
|
// "U12 5€, U14 7€"
|
|
|
|
|
// "U12: 5,00€, U14: 7,00€"
|
|
|
|
|
const feeMatches = feeText.matchAll(/(U\d+|AK\s*\d+)\s*:?\s*(\d+(?:[,.]\d+)?)\s*(?:€|Euro|EUR)?/gi);
|
|
|
|
|
|
|
|
|
|
for (const feeMatch of feeMatches) {
|
|
|
|
|
const ageClass = feeMatch[1].toUpperCase().replace(/\s+/g, '');
|
|
|
|
|
const amount = feeMatch[2].replace(',', '.');
|
|
|
|
|
const numericAmount = parseFloat(amount);
|
|
|
|
|
|
|
|
|
|
if (!isNaN(numericAmount)) {
|
|
|
|
|
entryFees[ageClass] = {
|
|
|
|
|
amount: numericAmount,
|
|
|
|
|
currency: '€',
|
|
|
|
|
rawText: feeMatch[0]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wenn wir Gebühren gefunden haben, brechen wir ab
|
|
|
|
|
if (Object.keys(entryFees).length > 0) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (Object.keys(entryFees).length > 0) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return entryFees;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const extractBlockAfter = (labels, multiline = false) => {
|
|
|
|
|
const idx = normLines.findIndex(l => labels.some(lb => l.toLowerCase().startsWith(lb)));
|
|
|
|
|
if (idx === -1) return multiline ? [] : null;
|
|
|
|
|
const line = normLines[idx];
|
|
|
|
|
const afterColon = line.includes(':') ? line.split(':').slice(1).join(':').trim() : '';
|
|
|
|
|
if (!multiline) {
|
|
|
|
|
if (afterColon) return afterColon;
|
|
|
|
|
// sonst nächste nicht-leere Zeile
|
|
|
|
|
for (let i = idx + 1; i < normLines.length; i++) {
|
|
|
|
|
if (normLines[i]) return normLines[i];
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
// multiline bis zur nächsten Leerzeile oder nächsten bekannten Section
|
|
|
|
|
const out = [];
|
|
|
|
|
if (afterColon) out.push(afterColon);
|
|
|
|
|
for (let i = idx + 1; i < normLines.length; i++) {
|
|
|
|
|
const ln = normLines[i];
|
|
|
|
|
if (!ln) break;
|
|
|
|
|
if (/^(termin|austragungsort|austragungsorte|konkurrenz|konkurrenzen|konkurrenztypen|meldeschluss|altersklassen|startzeiten)/i.test(ln)) break;
|
|
|
|
|
out.push(ln);
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const extractAllMatches = (regex) => {
|
|
|
|
|
const results = [];
|
|
|
|
|
for (const l of normLines) {
|
|
|
|
|
const m = l.match(regex);
|
|
|
|
|
if (m) results.push(m);
|
|
|
|
|
}
|
|
|
|
|
return results;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const title = findTitle();
|
|
|
|
|
const termin = extractBlockAfter(['termin', 'termin '], false);
|
|
|
|
|
const austragungsorte = extractBlockAfter(['austragungsort', 'austragungsorte'], true);
|
|
|
|
|
let konkurrenzRaw = extractBlockAfter(['konkurrenz', 'konkurrenzen', 'konkurrenztypen'], true);
|
|
|
|
|
if (konkurrenzRaw && !Array.isArray(konkurrenzRaw)) konkurrenzRaw = [konkurrenzRaw];
|
|
|
|
|
const konkurrenztypen = (konkurrenzRaw || []).flatMap(l => l.split(/[;,]/)).map(s => s.trim()).filter(Boolean);
|
|
|
|
|
|
|
|
|
|
// Meldeschlüsse mit Position und Zuordnung zu AK ermitteln
|
|
|
|
|
const meldeschluesseRaw = [];
|
|
|
|
|
for (let i = 0; i < normLines.length; i++) {
|
|
|
|
|
const l = normLines[i];
|
|
|
|
|
const m = l.match(/meldeschluss\s*:?\s*(.+)$/i);
|
|
|
|
|
if (m) meldeschluesseRaw.push({ line: i, value: m[1].trim() });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let altersRaw = extractBlockAfter(['altersklassen', 'altersklasse'], true);
|
|
|
|
|
if (altersRaw && !Array.isArray(altersRaw)) altersRaw = [altersRaw];
|
|
|
|
|
const altersklassen = (altersRaw || []).flatMap(l => l.split(/[;,]/)).map(s => s.trim()).filter(Boolean);
|
|
|
|
|
|
|
|
|
|
// Wettbewerbe/Konkurrenzen parsen (Block ab "3. Konkurrenzen")
|
|
|
|
|
const competitions = [];
|
|
|
|
|
const konkIdx = normLines.findIndex(l => /^\s*3\.?\s+Konkurrenzen/i.test(l) || /^Konkurrenzen\b/i.test(l));
|
|
|
|
|
// Bestimme Start-Sektionsnummer (z. B. 3 bei "3. Konkurrenzen"), fallback 3
|
|
|
|
|
const startSectionNum = (() => {
|
|
|
|
|
if (konkIdx === -1) return 3;
|
|
|
|
|
const m = normLines[konkIdx].match(/^\s*(\d+)\./);
|
|
|
|
|
return m ? parseInt(m[1], 10) : 3;
|
|
|
|
|
})();
|
|
|
|
|
const nextSectionIdx = () => {
|
|
|
|
|
for (let i = konkIdx + 1; i < normLines.length; i++) {
|
|
|
|
|
const m = normLines[i].match(/^\s*(\d+)\.\s+/);
|
|
|
|
|
if (m) {
|
|
|
|
|
const num = parseInt(m[1], 10);
|
|
|
|
|
if (!Number.isNaN(num) && num > startSectionNum) return i;
|
|
|
|
|
}
|
|
|
|
|
// Hinweis: Seitenfußzeilen wie "nu.Dokument ..." ignorieren wir, damit mehrseitige Blöcke nicht abbrechen
|
|
|
|
|
}
|
|
|
|
|
return normLines.length;
|
|
|
|
|
};
|
|
|
|
|
if (konkIdx !== -1) {
|
|
|
|
|
const endIdx = nextSectionIdx();
|
|
|
|
|
let i = konkIdx + 1;
|
|
|
|
|
while (i < endIdx) {
|
|
|
|
|
const line = normLines[i];
|
|
|
|
|
if (/^Altersklasse\/Wettbewerb\s*:/i.test(line)) {
|
|
|
|
|
const comp = {};
|
|
|
|
|
comp.altersklasseWettbewerb = line.split(':').slice(1).join(':').trim();
|
|
|
|
|
i++;
|
|
|
|
|
while (i < endIdx && !/^Altersklasse\/Wettbewerb\s*:/i.test(normLines[i])) {
|
|
|
|
|
const ln = normLines[i];
|
|
|
|
|
const m = ln.match(/^([^:]+):\s*(.*)$/);
|
|
|
|
|
if (m) {
|
|
|
|
|
const key = m[1].trim().toLowerCase();
|
|
|
|
|
const val = m[2].trim();
|
|
|
|
|
if (key.startsWith('leistungsklasse')) comp.leistungsklasse = val;
|
|
|
|
|
else if (key === 'startzeit') {
|
|
|
|
|
// Erwartet: 20.09.2025 13:30 Uhr -> wir extrahieren Datum+Zeit
|
|
|
|
|
const sm = val.match(/(\d{2}\.\d{2}\.\d{4})\s+(\d{1,2}:\d{2})/);
|
|
|
|
|
comp.startzeit = sm ? `${sm[1]} ${sm[2]}` : val;
|
|
|
|
|
}
|
|
|
|
|
else if (key.startsWith('meldeschluss datum')) comp.meldeschlussDatum = val;
|
|
|
|
|
else if (key.startsWith('meldeschluss online')) comp.meldeschlussOnline = val;
|
|
|
|
|
else if (key === 'stichtag') comp.stichtag = val;
|
|
|
|
|
else if (key === 'ttr-relevant') comp.ttrRelevant = val;
|
|
|
|
|
else if (key === 'offen für') comp.offenFuer = val;
|
|
|
|
|
else if (key.startsWith('austragungssys. vorrunde')) comp.vorrunde = val;
|
|
|
|
|
else if (key.startsWith('austragungssys. endrunde')) comp.endrunde = val;
|
|
|
|
|
else if (key.startsWith('max. teilnehmerzahl')) comp.maxTeilnehmer = val;
|
|
|
|
|
else if (key === 'startgeld') {
|
|
|
|
|
comp.startgeld = val;
|
|
|
|
|
// Versuche auch spezifische Gebühren für diese Altersklasse zu extrahieren
|
|
|
|
|
const ageClassMatch = comp.altersklasseWettbewerb?.match(/(U\d+|AK\s*\d+)/i);
|
|
|
|
|
if (ageClassMatch) {
|
|
|
|
|
const ageClass = ageClassMatch[1].toUpperCase().replace(/\s+/g, '');
|
|
|
|
|
const feeMatch = val.match(/(\d+(?:[,.]\d+)?)\s*(?:€|Euro|EUR)?/);
|
|
|
|
|
if (feeMatch) {
|
|
|
|
|
const amount = feeMatch[1].replace(',', '.');
|
|
|
|
|
const numericAmount = parseFloat(amount);
|
|
|
|
|
if (!isNaN(numericAmount)) {
|
|
|
|
|
comp.entryFeeDetails = {
|
|
|
|
|
amount: numericAmount,
|
|
|
|
|
currency: '€',
|
|
|
|
|
ageClass: ageClass
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
i++;
|
|
|
|
|
}
|
|
|
|
|
competitions.push(comp);
|
|
|
|
|
continue; // schon auf nächster Zeile
|
|
|
|
|
}
|
|
|
|
|
i++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Altersklassen-Positionen im Text (zur Zuordnung von Meldeschlüssen)
|
|
|
|
|
const akPositions = [];
|
|
|
|
|
for (let i = 0; i < normLines.length; i++) {
|
|
|
|
|
const l = normLines[i];
|
|
|
|
|
const m = l.match(/\b(U\d+|AK\s*\d+)\b/i);
|
|
|
|
|
if (m) akPositions.push({ line: i, ak: m[1].toUpperCase().replace(/\s+/g, '') });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const meldeschluesseByAk = {};
|
|
|
|
|
for (const ms of meldeschluesseRaw) {
|
|
|
|
|
// Nächste AK im Umkreis von 3 Zeilen suchen
|
|
|
|
|
let best = null;
|
|
|
|
|
let bestDist = Infinity;
|
|
|
|
|
for (const ak of akPositions) {
|
|
|
|
|
const dist = Math.abs(ak.line - ms.line);
|
|
|
|
|
if (dist < bestDist && dist <= 3) { best = ak; bestDist = dist; }
|
|
|
|
|
}
|
|
|
|
|
if (best) {
|
|
|
|
|
if (!meldeschluesseByAk[best.ak]) meldeschluesseByAk[best.ak] = new Set();
|
|
|
|
|
meldeschluesseByAk[best.ak].add(ms.value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dedup global
|
|
|
|
|
const meldeschluesse = Array.from(new Set(meldeschluesseRaw.map(x => x.value)));
|
|
|
|
|
// Sets zu Arrays
|
|
|
|
|
const meldeschluesseByAkOut = Object.fromEntries(Object.entries(meldeschluesseByAk).map(([k,v]) => [k, Array.from(v)]));
|
|
|
|
|
|
|
|
|
|
// Vorhandene einfache Personenerkennung (optional, zu Analysezwecken)
|
|
|
|
|
const entries = [];
|
|
|
|
|
for (const l of normLines) {
|
|
|
|
|
const m = l.match(/^([A-Za-zÄÖÜäöüß\-\s']{3,})(?:\s+\((m|w|d)\))?$/i);
|
|
|
|
|
if (m && /\s/.test(m[1])) {
|
|
|
|
|
entries.push({ name: m[1].trim(), genderHint: m[2] || null });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extrahiere Teilnahmegebühren
|
|
|
|
|
const entryFees = extractEntryFees();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
title,
|
|
|
|
|
termin,
|
|
|
|
|
austragungsorte,
|
|
|
|
|
konkurrenztypen,
|
|
|
|
|
meldeschluesse,
|
|
|
|
|
meldeschluesseByAk: meldeschluesseByAkOut,
|
|
|
|
|
altersklassen,
|
|
|
|
|
startzeiten: {},
|
|
|
|
|
competitions,
|
|
|
|
|
entries,
|
|
|
|
|
entryFees, // Neue: Teilnahmegebühren pro Spielklasse
|
|
|
|
|
debug: { normLines },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|