Fügt Unterstützung für Team-Dokumente hinzu. Aktualisiert die Backend-Modelle und -Routen, um Team-Dokumente zu verwalten, einschließlich Upload- und Parsing-Funktionen für Code- und Pin-Listen. Ergänzt die Benutzeroberfläche in TeamManagementView.vue zur Anzeige und Verwaltung von Team-Dokumenten sowie zur Integration von PDF-Parsing. Aktualisiert die Match-Modelle, um zusätzliche Felder für Spiel-Codes und PINs zu berücksichtigen.

This commit is contained in:
Torsten Schulz (local)
2025-10-02 09:04:19 +02:00
parent a6493990d3
commit 1c70ca97bb
15 changed files with 1973 additions and 19 deletions

View File

@@ -0,0 +1,233 @@
import multer from 'multer';
import path from 'path';
import TeamDocumentService from '../services/teamDocumentService.js';
import PDFParserService from '../services/pdfParserService.js';
import { getUserByToken } from '../utils/userUtils.js';
import { devLog } from '../utils/logger.js';
// Multer-Konfiguration für Datei-Uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/temp/');
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 10 * 1024 * 1024 // 10MB Limit
},
fileFilter: (req, file, cb) => {
// Erlaube nur PDF, DOC, DOCX, TXT, CSV Dateien
const allowedTypes = /pdf|doc|docx|txt|csv/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Nur PDF, DOC, DOCX, TXT und CSV Dateien sind erlaubt!'));
}
}
});
export const uploadMiddleware = upload.single('document');
export const uploadDocument = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubteamid: clubTeamId } = req.params;
const { documentType } = req.body;
devLog('[uploadDocument] - Uploading document for club team:', clubTeamId, 'type:', documentType);
const user = await getUserByToken(token);
if (!req.file) {
return res.status(400).json({ error: "nofile" });
}
if (!documentType || !['code_list', 'pin_list'].includes(documentType)) {
return res.status(400).json({ error: "invaliddocumenttype" });
}
const document = await TeamDocumentService.uploadDocument(req.file, clubTeamId, documentType);
devLog('[uploadDocument] - Document uploaded successfully:', document.id);
res.status(201).json(document);
} catch (error) {
console.error('[uploadDocument] - Error:', error);
// Lösche temporäre Datei bei Fehler
if (req.file && req.file.path) {
try {
const fs = await import('fs');
fs.unlinkSync(req.file.path);
} catch (cleanupError) {
console.error('Fehler beim Löschen der temporären Datei:', cleanupError);
}
}
if (error.message === 'Club-Team nicht gefunden') {
return res.status(404).json({ error: "clubteamnotfound" });
}
res.status(500).json({ error: "internalerror" });
}
};
export const getDocuments = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubteamid: clubTeamId } = req.params;
devLog('[getDocuments] - Getting documents for club team:', clubTeamId);
const user = await getUserByToken(token);
const documents = await TeamDocumentService.getDocumentsByClubTeam(clubTeamId);
devLog('[getDocuments] - Found documents:', documents.length);
res.status(200).json(documents);
} catch (error) {
console.error('[getDocuments] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const getDocument = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { documentid: documentId } = req.params;
devLog('[getDocument] - Getting document:', documentId);
const user = await getUserByToken(token);
const document = await TeamDocumentService.getDocumentById(documentId);
if (!document) {
return res.status(404).json({ error: "notfound" });
}
res.status(200).json(document);
} catch (error) {
console.error('[getDocument] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const downloadDocument = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { documentid: documentId } = req.params;
devLog('[downloadDocument] - Downloading document:', documentId);
const user = await getUserByToken(token);
const document = await TeamDocumentService.getDocumentById(documentId);
if (!document) {
return res.status(404).json({ error: "notfound" });
}
const filePath = await TeamDocumentService.getDocumentPath(documentId);
if (!filePath) {
return res.status(404).json({ error: "filenotfound" });
}
// Prüfe ob Datei existiert
const fs = await import('fs');
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: "filenotfound" });
}
// Setze Headers für Inline-Anzeige (PDF-Viewer)
res.setHeader('Content-Disposition', `inline; filename="${document.originalFileName}"`);
res.setHeader('Content-Type', document.mimeType);
// Sende die Datei
res.sendFile(filePath);
} catch (error) {
console.error('[downloadDocument] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const deleteDocument = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { documentid: documentId } = req.params;
devLog('[deleteDocument] - Deleting document:', documentId);
const user = await getUserByToken(token);
const success = await TeamDocumentService.deleteDocument(documentId);
if (!success) {
return res.status(404).json({ error: "notfound" });
}
res.status(200).json({ message: "Document deleted successfully" });
} catch (error) {
console.error('[deleteDocument] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const parsePDF = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { documentid: documentId } = req.params;
const { leagueid: leagueId } = req.query;
devLog('[parsePDF] - Parsing PDF document:', documentId, 'league:', leagueId);
const user = await getUserByToken(token);
if (!leagueId) {
return res.status(400).json({ error: "missingleagueid" });
}
// Hole Dokument-Informationen
const document = await TeamDocumentService.getDocumentById(documentId);
if (!document) {
return res.status(404).json({ error: "documentnotfound" });
}
// Prüfe ob es eine PDF- oder TXT-Datei ist
if (!document.mimeType.includes('pdf') && !document.mimeType.includes('text/plain')) {
return res.status(400).json({ error: "notapdfortxt" });
}
// Parse PDF
const parseResult = await PDFParserService.parsePDF(document.filePath, document.clubTeam.clubId);
// Speichere Matches in Datenbank
const saveResult = await PDFParserService.saveMatchesToDatabase(parseResult.matches, parseInt(leagueId));
devLog('[parsePDF] - PDF parsed successfully:', {
matchesFound: parseResult.matches.length,
created: saveResult.created,
updated: saveResult.updated,
errors: saveResult.errors.length
});
res.status(200).json({
message: "PDF parsed successfully",
parseResult: {
matchesFound: parseResult.matches.length,
errors: parseResult.errors,
metadata: parseResult.metadata,
debugInfo: parseResult.debugInfo,
allLines: parseResult.allLines,
rawText: parseResult.rawText
},
saveResult: {
created: saveResult.created,
updated: saveResult.updated,
errors: saveResult.errors
}
});
} catch (error) {
console.error('[parsePDF] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};

View File

@@ -60,6 +60,21 @@ const Match = sequelize.define('Match', {
},
allowNull: false,
},
code: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Spiel-Code aus PDF-Parsing'
},
homePin: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Pin-Code für Heimteam aus PDF-Parsing'
},
guestPin: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Pin-Code für Gastteam aus PDF-Parsing'
},
}, {
underscored: true,
tableName: 'match',

View File

@@ -0,0 +1,52 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import ClubTeam from './ClubTeam.js';
const TeamDocument = sequelize.define('TeamDocument', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
fileName: {
type: DataTypes.STRING,
allowNull: false,
},
originalFileName: {
type: DataTypes.STRING,
allowNull: false,
},
filePath: {
type: DataTypes.STRING,
allowNull: false,
},
fileSize: {
type: DataTypes.INTEGER,
allowNull: false,
},
mimeType: {
type: DataTypes.STRING,
allowNull: false,
},
documentType: {
type: DataTypes.ENUM('code_list', 'pin_list'),
allowNull: false,
},
clubTeamId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: ClubTeam,
key: 'id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
}, {
underscored: true,
tableName: 'team_document',
timestamps: true,
});
export default TeamDocument;

View File

@@ -20,6 +20,7 @@ import Match from './Match.js';
import League from './League.js';
import Team from './Team.js';
import ClubTeam from './ClubTeam.js';
import TeamDocument from './TeamDocument.js';
import Season from './Season.js';
import Location from './Location.js';
import Group from './Group.js';
@@ -135,6 +136,10 @@ ClubTeam.belongsTo(League, { foreignKey: 'leagueId', as: 'league' });
Season.hasMany(ClubTeam, { foreignKey: 'seasonId', as: 'clubTeams' });
ClubTeam.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' });
// TeamDocument relationships
ClubTeam.hasMany(TeamDocument, { foreignKey: 'clubTeamId', as: 'documents' });
TeamDocument.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' });
Match.belongsTo(Location, { foreignKey: 'locationId', as: 'location' });
Location.hasMany(Match, { foreignKey: 'locationId', as: 'matches' });
@@ -246,6 +251,7 @@ export {
League,
Team,
ClubTeam,
TeamDocument,
Group,
GroupActivity,
Tournament,

View File

@@ -0,0 +1,33 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import {
uploadMiddleware,
uploadDocument,
getDocuments,
getDocument,
downloadDocument,
deleteDocument,
parsePDF
} from '../controllers/teamDocumentController.js';
const router = express.Router();
// Upload eines Dokuments für ein Club-Team
router.post('/club-team/:clubteamid/upload', authenticate, uploadMiddleware, uploadDocument);
// Alle Dokumente für ein Club-Team abrufen
router.get('/club-team/:clubteamid', authenticate, getDocuments);
// Ein spezifisches Dokument abrufen
router.get('/:documentid', authenticate, getDocument);
// Ein Dokument herunterladen
router.get('/:documentid/download', authenticate, downloadDocument);
// Ein Dokument löschen
router.delete('/:documentid', authenticate, deleteDocument);
// PDF parsen und Matches extrahieren
router.post('/:documentid/parse', authenticate, parsePDF);
export default router;

View File

@@ -6,7 +6,7 @@ import cors from 'cors';
import {
User, Log, Club, UserClub, Member, DiaryDate, Participant, Activity, MemberNote,
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, Group,
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis
} from './models/index.js';
@@ -36,6 +36,7 @@ import officialTournamentRoutes from './routes/officialTournamentRoutes.js';
import myTischtennisRoutes from './routes/myTischtennisRoutes.js';
import teamRoutes from './routes/teamRoutes.js';
import clubTeamRoutes from './routes/clubTeamRoutes.js';
import teamDocumentRoutes from './routes/teamDocumentRoutes.js';
import seasonRoutes from './routes/seasonRoutes.js';
const app = express();
@@ -84,6 +85,7 @@ app.use('/api/official-tournaments', officialTournamentRoutes);
app.use('/api/mytischtennis', myTischtennisRoutes);
app.use('/api/teams', teamRoutes);
app.use('/api/club-teams', clubTeamRoutes);
app.use('/api/team-documents', teamDocumentRoutes);
app.use('/api/seasons', seasonRoutes);
app.use(express.static(path.join(__dirname, '../frontend/dist')));

View File

@@ -171,6 +171,9 @@ class MatchService {
guestTeamId: match.guestTeamId,
locationId: match.locationId,
leagueId: match.leagueId,
code: match.code,
homePin: match.homePin,
guestPin: match.guestPin,
homeTeam: { name: 'Unbekannt' },
guestTeam: { name: 'Unbekannt' },
location: { name: 'Unbekannt', address: '', city: '', zip: '' },
@@ -228,6 +231,9 @@ class MatchService {
guestTeamId: match.guestTeamId,
locationId: match.locationId,
leagueId: match.leagueId,
code: match.code,
homePin: match.homePin,
guestPin: match.guestPin,
homeTeam: { name: 'Unbekannt' },
guestTeam: { name: 'Unbekannt' },
location: { name: 'Unbekannt', address: '', city: '', zip: '' },

View File

@@ -0,0 +1,690 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pdfParse = require('pdf-parse/lib/pdf-parse.js');
import { Op } from 'sequelize';
import Match from '../models/Match.js';
import Team from '../models/Team.js';
import ClubTeam from '../models/ClubTeam.js';
import League from '../models/League.js';
import Location from '../models/Location.js';
import { devLog } from '../utils/logger.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
class PDFParserService {
/**
* Parst eine PDF-Datei und extrahiert Spiel-Daten
* @param {string} filePath - Pfad zur PDF-Datei
* @param {number} clubId - ID des Vereins
* @returns {Promise<Object>} Geparste Spiel-Daten
*/
static async parsePDF(filePath, clubId) {
try {
devLog('[PDFParserService.parsePDF] - Parsing PDF:', filePath);
if (!fs.existsSync(filePath)) {
throw new Error('PDF-Datei nicht gefunden');
}
// Bestimme Dateityp basierend auf Dateiendung
const fileExtension = path.extname(filePath).toLowerCase();
let fileContent;
if (fileExtension === '.pdf') {
// Echte PDF-Parsing
const pdfBuffer = fs.readFileSync(filePath);
const pdfData = await pdfParse(pdfBuffer);
fileContent = pdfData.text;
devLog('[PDFParserService.parsePDF] - PDF parsed, text length:', fileContent.length);
} else {
// Fallback für TXT-Dateien (für Tests)
fileContent = fs.readFileSync(filePath, 'utf8');
devLog('[PDFParserService.parsePDF] - Text file loaded, text length:', fileContent.length);
}
// Parse den Text nach Spiel-Daten
const parsedData = this.extractMatchData(fileContent, clubId);
devLog('[PDFParserService.parsePDF] - Extracted matches:', parsedData.matches.length);
return parsedData;
} catch (error) {
console.error('[PDFParserService.parsePDF] - Error:', error);
throw error;
}
}
/**
* Extrahiert Spiel-Daten aus dem PDF-Text
* @param {string} text - Der extrahierte Text aus der PDF
* @param {number} clubId - ID des Vereins
* @returns {Object} Geparste Daten mit Matches und Metadaten
*/
static extractMatchData(text, clubId) {
const matches = [];
const errors = [];
const metadata = {
totalLines: 0,
parsedMatches: 0,
errors: 0
};
try {
// Teile Text in Zeilen auf
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
metadata.totalLines = lines.length;
devLog('[PDFParserService.extractMatchData] - Processing lines:', lines.length);
// Verschiedene Parsing-Strategien je nach PDF-Format
const strategies = [
{ name: 'Standard Format', fn: this.parseStandardFormat },
{ name: 'Table Format', fn: this.parseTableFormat },
{ name: 'List Format', fn: this.parseListFormat }
];
devLog('[PDFParserService.extractMatchData] - Trying parsing strategies...');
for (const strategy of strategies) {
try {
devLog(`[PDFParserService.extractMatchData] - Trying ${strategy.name}...`);
const result = strategy.fn(lines, clubId);
devLog(`[PDFParserService.extractMatchData] - ${strategy.name} found ${result.matches.length} matches`);
if (result.matches.length > 0) {
matches.push(...result.matches);
metadata.parsedMatches += result.matches.length;
devLog(`[PDFParserService.extractMatchData] - Using ${strategy.name} strategy`);
break; // Erste erfolgreiche Strategie verwenden
}
} catch (strategyError) {
devLog(`[PDFParserService.extractMatchData] - ${strategy.name} failed:`, strategyError.message);
errors.push(`Strategy ${strategy.name} failed: ${strategyError.message}`);
}
}
metadata.errors = errors.length;
return {
matches,
errors,
metadata,
rawText: text.substring(0, 1000), // Erste 1000 Zeichen für Debugging
allLines: lines, // Alle Zeilen für Debugging
debugInfo: {
totalTextLength: text.length,
totalLines: lines.length,
firstFewLines: lines.slice(0, 10),
lastFewLines: lines.slice(-5)
}
};
} catch (error) {
console.error('[PDFParserService.extractMatchData] - Error:', error);
throw error;
}
}
/**
* Standard-Format Parser (Datum, Zeit, Heimteam, Gastteam, Code, Pins)
* @param {Array} lines - Textzeilen
* @param {number} clubId - ID des Vereins
* @returns {Object} Geparste Matches
*/
static parseStandardFormat(lines, clubId) {
const matches = [];
devLog('[PDFParserService.parseStandardFormat] - Starting standard format parsing...');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Suche nach Datum-Pattern (dd.mm.yyyy oder dd/mm/yyyy)
const dateMatch = line.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})/);
if (dateMatch) {
devLog(`[PDFParserService.parseStandardFormat] - Found date in line ${i + 1}: "${line}"`);
// Debug: Zeige die gesamte Zeile mit sichtbaren Whitespaces
const debugLine = line.replace(/\s/g, (match) => {
if (match === ' ') return '·'; // Mittelpunkt für normales Leerzeichen
if (match === '\t') return '→'; // Pfeil für Tab
if (match === '\n') return '↵'; // Enter-Zeichen
if (match === '\r') return '⏎'; // Carriage Return
return `[${match.charCodeAt(0)}]`; // Zeichencode für andere Whitespaces
});
devLog(`[PDFParserService.parseStandardFormat] - Full line with visible whitespaces: "${debugLine}"`);
try {
const [, day, month, year] = dateMatch;
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
// Suche nach Zeit-Pattern direkt nach dem Datum (hh:mm) - Format: Wt.dd.mm.yyyyhh:MM
const timeMatch = line.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})(\d{1,2}):(\d{2})/);
let time = null;
if (timeMatch) {
time = `${timeMatch[4].padStart(2, '0')}:${timeMatch[5]}`;
}
devLog(`[PDFParserService.parseStandardFormat] - Parsed date: ${date.toISOString().split('T')[0]}, time: ${time}`);
// Entferne Datum und Zeit vom Anfang der Zeile
const cleanLine = line.replace(/^[A-Za-z]{2}\.(\d{1,2})[./](\d{1,2})[./](\d{4})(\d{1,2}):(\d{2})\s*/, '');
devLog(`[PDFParserService.parseStandardFormat] - Clean line after date/time removal: "${cleanLine}"`);
// Entferne Nummerierung am Anfang (z.B. "(1)")
const cleanLine2 = cleanLine.replace(/^\(\d+\)/, '');
devLog(`[PDFParserService.parseStandardFormat] - Clean line after numbering removal: "${cleanLine2}"`);
// Entferne alle Inhalte in Klammern (z.B. "(J11)")
const cleanLine3 = cleanLine2.replace(/\([^)]*\)/g, '');
devLog(`[PDFParserService.parseStandardFormat] - Clean line after removing brackets: "${cleanLine3}"`);
// Suche nach Code (12 Zeichen) oder PIN (4 Ziffern) am Ende
const codeMatch = cleanLine3.match(/([A-Z0-9]{12})$/);
const pinMatch = cleanLine3.match(/(\d{4})$/);
let code = null;
let homePin = null;
let guestPin = null;
let teamsPart = cleanLine3;
if (codeMatch) {
// Code gefunden (12 Zeichen)
code = codeMatch[1];
teamsPart = cleanLine3.substring(0, cleanLine3.length - code.length).trim();
devLog(`[PDFParserService.parseStandardFormat] - Found code: "${code}"`);
} else if (pinMatch) {
// PIN gefunden (4 Ziffern)
const pin = pinMatch[1];
teamsPart = cleanLine3.substring(0, cleanLine3.length - pin.length).trim();
// PIN gehört zu dem Team, das direkt vor der PIN steht
// Analysiere die Position der PIN in der ursprünglichen Zeile
const pinIndex = cleanLine3.lastIndexOf(pin);
const teamsPartIndex = cleanLine3.indexOf(teamsPart);
// Wenn PIN direkt nach dem Teams-Part steht, gehört sie zur Heimmannschaft
// Wenn PIN zwischen den Teams steht, gehört sie zur Gastmannschaft
if (pinIndex === teamsPartIndex + teamsPart.length) {
// PIN steht direkt nach den Teams -> Heimmannschaft
homePin = pin;
devLog(`[PDFParserService.parseStandardFormat] - Found PIN: "${pin}" -> Home team (at end)`);
} else {
// PIN steht zwischen den Teams -> Gastmannschaft
guestPin = pin;
devLog(`[PDFParserService.parseStandardFormat] - Found PIN: "${pin}" -> Guest team (between teams)`);
}
}
if (code || pinMatch) {
devLog(`[PDFParserService.parseStandardFormat] - Teams part: "${teamsPart}"`);
// Debug: Zeige Whitespaces als lesbare Zeichen
const debugTeamsPart = teamsPart.replace(/\s/g, (match) => {
if (match === ' ') return '·'; // Mittelpunkt für normales Leerzeichen
if (match === '\t') return '→'; // Pfeil für Tab
return `[${match.charCodeAt(0)}]`; // Zeichencode für andere Whitespaces
});
devLog(`[PDFParserService.parseStandardFormat] - Teams part with visible whitespaces: "${debugTeamsPart}"`);
// Neue Strategie: Teile die Zeile durch mehrere Leerzeichen (wie in der Tabelle)
// Die Struktur ist: Heimmannschaft Gastmannschaft Code
const parts = teamsPart.split(/\s{2,}/); // Mindestens 2 Leerzeichen als Trenner
devLog(`[PDFParserService.parseStandardFormat] - Split by multiple spaces:`, parts);
let homeTeamName = '';
let guestTeamName = '';
if (parts.length >= 2) {
homeTeamName = parts[0].trim();
guestTeamName = parts[1].trim();
// Entferne noch verbleibende Klammern aus den Team-Namen
homeTeamName = homeTeamName.replace(/\([^)]*\)/g, '').trim();
guestTeamName = guestTeamName.replace(/\([^)]*\)/g, '').trim();
devLog(`[PDFParserService.parseStandardFormat] - After bracket removal: "${homeTeamName}" vs "${guestTeamName}"`);
// Erkenne römische Ziffern am Ende der Team-Namen
// Römische Ziffern: I, II, III, IV, V, VI, VII, VIII, IX, X, XI, XII, etc.
const romanNumeralPattern = /\s+(I{1,3}|IV|V|VI{0,3}|IX|X|XI{0,2})$/;
// Prüfe Heimteam auf römische Ziffern
const homeRomanMatch = homeTeamName.match(romanNumeralPattern);
if (homeRomanMatch) {
const romanNumeral = homeRomanMatch[1];
const baseName = homeTeamName.replace(romanNumeralPattern, '').trim();
homeTeamName = `${baseName} ${romanNumeral}`;
devLog(`[PDFParserService.parseStandardFormat] - Home team roman numeral found: "${romanNumeral}" -> "${homeTeamName}"`);
}
// Prüfe Gastteam auf römische Ziffern
const guestRomanMatch = guestTeamName.match(romanNumeralPattern);
if (guestRomanMatch) {
const romanNumeral = guestRomanMatch[1];
const baseName = guestTeamName.replace(romanNumeralPattern, '').trim();
guestTeamName = `${baseName} ${romanNumeral}`;
devLog(`[PDFParserService.parseStandardFormat] - Guest team roman numeral found: "${romanNumeral}" -> "${guestTeamName}"`);
}
devLog(`[PDFParserService.parseStandardFormat] - Final teams: "${homeTeamName}" vs "${guestTeamName}"`);
} else {
// Fallback: Versuche mit einzelnen Leerzeichen zu trennen
devLog(`[PDFParserService.parseStandardFormat] - Fallback: trying single space split`);
// Strategie 1: Suche nach "Harheimer TC" als Heimteam
if (teamsPart.includes('Harheimer TC')) {
const harheimerIndex = teamsPart.indexOf('Harheimer TC');
homeTeamName = 'Harheimer TC';
guestTeamName = teamsPart.substring(harheimerIndex + 'Harheimer TC'.length).trim();
// Entferne Klammern aus Gastteam
guestTeamName = guestTeamName.replace(/\([^)]*\)/g, '').trim();
devLog(`[PDFParserService.parseStandardFormat] - Fallback Strategy 1: "${homeTeamName}" vs "${guestTeamName}"`);
} else {
// Strategie 2: Suche nach Großbuchstaben am Anfang des zweiten Teams
const teamSplitMatch = teamsPart.match(/^([A-Za-z0-9\s\-\.]+?)\s+([A-Z][A-Za-z0-9\s\-\.]+)$/);
if (teamSplitMatch) {
homeTeamName = teamSplitMatch[1].trim();
guestTeamName = teamSplitMatch[2].trim();
devLog(`[PDFParserService.parseStandardFormat] - Fallback Strategy 2: Split teams: "${homeTeamName}" vs "${guestTeamName}"`);
} else {
devLog(`[PDFParserService.parseStandardFormat] - Could not split teams from: "${teamsPart}"`);
continue;
}
}
}
if (homeTeamName && guestTeamName) {
let debugInfo;
if (code) {
debugInfo = `code: "${code}"`;
} else if (homePin && guestPin) {
debugInfo = `homePin: "${homePin}", guestPin: "${guestPin}"`;
} else if (homePin) {
debugInfo = `homePin: "${homePin}"`;
} else if (guestPin) {
debugInfo = `guestPin: "${guestPin}"`;
}
devLog(`[PDFParserService.parseStandardFormat] - Final parsed teams: "${homeTeamName}" vs "${guestTeamName}", ${debugInfo}`);
matches.push({
date: date,
time: time,
homeTeamName: homeTeamName,
guestTeamName: guestTeamName,
code: code,
homePin: homePin,
guestPin: guestPin,
clubId: clubId,
rawLine: line
});
}
} else {
devLog(`[PDFParserService.parseStandardFormat] - Could not find code pattern in: "${cleanLine3}"`);
}
} catch (parseError) {
devLog('[PDFParserService.parseStandardFormat] - Parse error for line:', line, parseError.message);
}
}
}
return { matches };
}
/**
* Tabellen-Format Parser
* @param {Array} lines - Textzeilen
* @param {number} clubId - ID des Vereins
* @returns {Object} Geparste Matches
*/
static parseTableFormat(lines, clubId) {
const matches = [];
// Suche nach Tabellen-Header
let headerIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes('datum') &&
lines[i].toLowerCase().includes('zeit') &&
lines[i].toLowerCase().includes('heim') &&
lines[i].toLowerCase().includes('gast')) {
headerIndex = i;
break;
}
}
if (headerIndex >= 0) {
// Parse Tabellen-Zeilen
for (let i = headerIndex + 1; i < lines.length; i++) {
const line = lines[i];
const columns = line.split(/\s{2,}|\t/); // Split bei mehreren Leerzeichen oder Tabs
if (columns.length >= 4) {
try {
const dateStr = columns[0];
const timeStr = columns[1];
const homeTeam = columns[2];
const guestTeam = columns[3];
const code = columns[4] || null;
const homePin = columns[5] || null;
const guestPin = columns[6] || null;
// Parse Datum
const dateMatch = dateStr.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})/);
if (dateMatch) {
const [, day, month, year] = dateMatch;
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
matches.push({
date: date,
time: timeStr || null,
homeTeamName: homeTeam.trim(),
guestTeamName: guestTeam.trim(),
code: code ? code.trim() : null,
homePin: homePin ? homePin.trim() : null,
guestPin: guestPin ? guestPin.trim() : null,
clubId: clubId,
rawLine: line
});
}
} catch (parseError) {
devLog('[PDFParserService.parseTableFormat] - Parse error for line:', line, parseError.message);
}
}
}
}
return { matches };
}
/**
* Listen-Format Parser
* @param {Array} lines - Textzeilen
* @param {number} clubId - ID des Vereins
* @returns {Object} Geparste Matches
*/
static parseListFormat(lines, clubId) {
const matches = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Suche nach Nummerierten Listen (1., 2., etc.)
const listMatch = line.match(/^\d+\.\s*(.+)/);
if (listMatch) {
const content = listMatch[1];
// Versuche verschiedene Formate zu parsen
const patterns = [
/(\d{1,2}[./]\d{1,2}[./]\d{4})\s+(\d{1,2}:\d{2})?\s+(.+?)\s+vs?\s+(.+?)(?:\s+code[:\s]*([A-Za-z0-9]+))?(?:\s+home[:\s]*pin[:\s]*([A-Za-z0-9]+))?(?:\s+guest[:\s]*pin[:\s]*([A-Za-z0-9]+))?/i,
/(\d{1,2}[./]\d{1,2}[./]\d{4})\s+(\d{1,2}:\d{2})?\s+(.+?)\s+-\s+(.+?)(?:\s+code[:\s]*([A-Za-z0-9]+))?(?:\s+heim[:\s]*pin[:\s]*([A-Za-z0-9]+))?(?:\s+gast[:\s]*pin[:\s]*([A-Za-z0-9]+))?/i
];
for (const pattern of patterns) {
const match = content.match(pattern);
if (match) {
try {
const [, dateStr, timeStr, homeTeam, guestTeam, code, homePin, guestPin] = match;
// Parse Datum
const dateMatch = dateStr.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})/);
if (dateMatch) {
const [, day, month, year] = dateMatch;
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
matches.push({
date: date,
time: timeStr || null,
homeTeamName: homeTeam.trim(),
guestTeamName: guestTeam.trim(),
code: code ? code.trim() : null,
homePin: homePin ? homePin.trim() : null,
guestPin: guestPin ? guestPin.trim() : null,
clubId: clubId,
rawLine: line
});
break; // Erste erfolgreiche Pattern verwenden
}
} catch (parseError) {
devLog('[PDFParserService.parseListFormat] - Parse error for line:', line, parseError.message);
}
}
}
}
}
return { matches };
}
/**
* Speichert geparste Matches in der Datenbank
* @param {Array} matches - Array von Match-Objekten
* @param {number} leagueId - ID der Liga
* @returns {Promise<Object>} Ergebnis der Speicherung
*/
static async saveMatchesToDatabase(matches, leagueId) {
try {
devLog('[PDFParserService.saveMatchesToDatabase] - Saving matches:', matches.length);
const results = {
created: 0,
updated: 0,
errors: []
};
for (const matchData of matches) {
try {
let debugInfo;
if (matchData.code) {
debugInfo = `Code: ${matchData.code}`;
} else if (matchData.homePin && matchData.guestPin) {
debugInfo = `HomePin: ${matchData.homePin}, GuestPin: ${matchData.guestPin}`;
} else if (matchData.homePin) {
debugInfo = `HomePin: ${matchData.homePin}`;
} else if (matchData.guestPin) {
debugInfo = `GuestPin: ${matchData.guestPin}`;
}
devLog(`[PDFParserService.saveMatchesToDatabase] - Processing match: ${matchData.date.toISOString().split('T')[0]} ${matchData.time} - ${matchData.homeTeamName} vs ${matchData.guestTeamName} (${debugInfo})`);
// Lade alle Matches für das Datum und die Liga
console.log('matchData', matchData);
// Konvertiere das Datum zu einem Datum ohne Zeit für den Vergleich
const dateOnly = new Date(matchData.date.getFullYear(), matchData.date.getMonth(), matchData.date.getDate());
const nextDay = new Date(dateOnly);
nextDay.setDate(nextDay.getDate() + 1);
const existingMatches = await Match.findAll({
where: {
date: {
[Op.gte]: dateOnly, // Größer oder gleich dem Datum
[Op.lt]: nextDay // Kleiner als der nächste Tag
},
leagueId: leagueId,
...(matchData.time && { time: matchData.time }) // Füge Zeit hinzu wenn vorhanden
},
include: [
{
model: Team,
as: 'homeTeam',
attributes: ['id', 'name']
},
{
model: Team,
as: 'guestTeam',
attributes: ['id', 'name']
}
]
});
console.log('existingMatches', JSON.parse(JSON.stringify(existingMatches, null, 2)));
const timeFilter = matchData.time ? ` and time ${matchData.time}` : '';
devLog(`[PDFParserService.saveMatchesToDatabase] - Found ${existingMatches.length} existing matches for date ${matchData.date.toISOString().split('T')[0]}${timeFilter} and league ${leagueId}`);
// Debug: Zeige alle gefundenen Matches und lade Teams manuell
for (let i = 0; i < existingMatches.length; i++) {
const match = existingMatches[i];
// Lade Teams manuell
const homeTeam = await Team.findByPk(match.homeTeamId);
const guestTeam = await Team.findByPk(match.guestTeamId);
devLog(`[PDFParserService.saveMatchesToDatabase] - Match ${i + 1}: ${homeTeam?.name || 'Unknown'} vs ${guestTeam?.name || 'Unknown'} (${match.time || 'no time'})`);
// Füge die Teams zum Match-Objekt hinzu
match.homeTeam = homeTeam;
match.guestTeam = guestTeam;
}
// Suche nach dem passenden Match basierend auf Gastmannschaft
const matchingMatch = existingMatches.find(match => {
if (!match.guestTeam) return false;
const guestTeamName = match.guestTeam.name.toLowerCase();
const searchGuestName = matchData.guestTeamName.toLowerCase();
// Exakte Übereinstimmung oder Teilstring-Match
return guestTeamName === searchGuestName ||
guestTeamName.includes(searchGuestName) ||
searchGuestName.includes(guestTeamName);
});
if (matchingMatch) {
devLog(`[PDFParserService.saveMatchesToDatabase] - Found matching match: ${matchingMatch.homeTeam.name} vs ${matchingMatch.guestTeam.name}`);
// Update das bestehende Match mit Code und Pins
// Erstelle Update-Objekt nur mit vorhandenen Feldern
const updateData = {};
if (matchData.code) {
updateData.code = matchData.code;
}
if (matchData.homePin) {
updateData.homePin = matchData.homePin;
}
if (matchData.guestPin) {
updateData.guestPin = matchData.guestPin;
}
await matchingMatch.update(updateData);
results.updated++;
let updateInfo;
if (matchData.code) {
updateInfo = `code: ${matchData.code}`;
} else if (matchData.homePin && matchData.guestPin) {
updateInfo = `homePin: ${matchData.homePin}, guestPin: ${matchData.guestPin}`;
} else if (matchData.homePin) {
updateInfo = `homePin: ${matchData.homePin}`;
} else if (matchData.guestPin) {
updateInfo = `guestPin: ${matchData.guestPin}`;
}
devLog(`[PDFParserService.saveMatchesToDatabase] - Updated match with ${updateInfo}`);
// Lade das aktualisierte Match neu, um die aktuellen Werte zu zeigen
await matchingMatch.reload();
const currentValues = [];
if (matchingMatch.code) currentValues.push(`code: ${matchingMatch.code}`);
if (matchingMatch.homePin) currentValues.push(`homePin: ${matchingMatch.homePin}`);
if (matchingMatch.guestPin) currentValues.push(`guestPin: ${matchingMatch.guestPin}`);
devLog(`[PDFParserService.saveMatchesToDatabase] - Match now has: ${currentValues.join(', ')}`);
} else {
devLog(`[PDFParserService.saveMatchesToDatabase] - No matching match found for guest team: "${matchData.guestTeamName}"`);
// Fallback: Versuche Teams direkt zu finden
const homeTeam = await Team.findOne({
where: {
name: matchData.homeTeamName,
clubId: matchData.clubId
}
});
const guestTeam = await Team.findOne({
where: {
name: matchData.guestTeamName,
clubId: matchData.clubId
}
});
// Debug: Zeige alle verfügbaren Teams für diesen Club
if (!homeTeam || !guestTeam) {
const allTeams = await Team.findAll({
where: { clubId: matchData.clubId },
attributes: ['id', 'name']
});
devLog(`[PDFParserService.saveMatchesToDatabase] - Available teams for club ${matchData.clubId}:`, allTeams.map(t => t.name));
devLog(`[PDFParserService.saveMatchesToDatabase] - Looking for home team: "${matchData.homeTeamName}"`);
devLog(`[PDFParserService.saveMatchesToDatabase] - Looking for guest team: "${matchData.guestTeamName}"`);
// Versuche Fuzzy-Matching für Team-Namen
const homeTeamFuzzy = allTeams.find(t =>
t.name.toLowerCase().includes(matchData.homeTeamName.toLowerCase()) ||
matchData.homeTeamName.toLowerCase().includes(t.name.toLowerCase())
);
const guestTeamFuzzy = allTeams.find(t =>
t.name.toLowerCase().includes(matchData.guestTeamName.toLowerCase()) ||
matchData.guestTeamName.toLowerCase().includes(t.name.toLowerCase())
);
if (homeTeamFuzzy) {
devLog(`[PDFParserService.saveMatchesToDatabase] - Fuzzy match found for home team: "${homeTeamFuzzy.name}"`);
}
if (guestTeamFuzzy) {
devLog(`[PDFParserService.saveMatchesToDatabase] - Fuzzy match found for guest team: "${guestTeamFuzzy.name}"`);
}
}
if (!homeTeam || !guestTeam) {
let errorInfo;
if (matchData.code) {
errorInfo = `Code: ${matchData.code}`;
} else if (matchData.homePin && matchData.guestPin) {
errorInfo = `HomePin: ${matchData.homePin}, GuestPin: ${matchData.guestPin}`;
} else if (matchData.homePin) {
errorInfo = `HomePin: ${matchData.homePin}`;
} else if (matchData.guestPin) {
errorInfo = `GuestPin: ${matchData.guestPin}`;
}
results.errors.push(`Teams nicht gefunden: "${matchData.homeTeamName}" oder "${matchData.guestTeamName}" (Datum: ${matchData.date.toISOString().split('T')[0]}, Zeit: ${matchData.time}, ${errorInfo})`);
continue;
}
// Erstelle neues Match (Fallback)
await Match.create({
date: matchData.date,
time: matchData.time,
homeTeamId: homeTeam.id,
guestTeamId: guestTeam.id,
leagueId: leagueId,
clubId: matchData.clubId,
code: matchData.code,
homePin: matchData.homePin,
guestPin: matchData.guestPin,
locationId: 1 // Default Location, kann später angepasst werden
});
results.created++;
devLog(`[PDFParserService.saveMatchesToDatabase] - Created new match: ${matchData.homeTeamName} vs ${matchData.guestTeamName}`);
}
} catch (matchError) {
console.error('[PDFParserService.saveMatchesToDatabase] - Error:', matchError);
results.errors.push(`Fehler beim Speichern von Match: ${matchData.rawLine} - ${matchError.message}`);
}
}
devLog('[PDFParserService.saveMatchesToDatabase] - Results:', results);
return results;
} catch (error) {
console.error('[PDFParserService.saveMatchesToDatabase] - Error:', error);
throw error;
}
}
}
export default PDFParserService;

View File

@@ -0,0 +1,204 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import TeamDocument from '../models/TeamDocument.js';
import ClubTeam from '../models/ClubTeam.js';
import { devLog } from '../utils/logger.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
class TeamDocumentService {
/**
* Speichert ein hochgeladenes Dokument für ein Club-Team
* @param {Object} file - Das hochgeladene File-Objekt (von multer)
* @param {number} clubTeamId - Die ID des Club-Teams
* @param {string} documentType - Der Typ des Dokuments ('code_list' oder 'pin_list')
* @returns {Promise<TeamDocument>} Das erstellte TeamDocument
*/
static async uploadDocument(file, clubTeamId, documentType) {
try {
devLog('[TeamDocumentService.uploadDocument] - Uploading document:', {
fileName: file.originalname,
clubTeamId,
documentType,
size: file.size
});
// Prüfe ob das Club-Team existiert
const clubTeam = await ClubTeam.findByPk(clubTeamId);
if (!clubTeam) {
throw new Error('Club-Team nicht gefunden');
}
// Generiere einen eindeutigen Dateinamen
const fileExtension = path.extname(file.originalname);
const uniqueFileName = `${clubTeamId}_${documentType}_${Date.now()}${fileExtension}`;
// Zielverzeichnis für Team-Dokumente
const uploadDir = path.join(__dirname, '..', 'uploads', 'team-documents');
// Erstelle Upload-Verzeichnis falls es nicht existiert
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const filePath = path.join(uploadDir, uniqueFileName);
// Verschiebe die Datei vom temporären Verzeichnis zum finalen Speicherort
fs.renameSync(file.path, filePath);
// Lösche alte Dokumente des gleichen Typs für dieses Team
await this.deleteDocumentsByType(clubTeamId, documentType);
// Erstelle Datenbankeintrag
const teamDocument = await TeamDocument.create({
fileName: uniqueFileName,
originalFileName: file.originalname,
filePath: filePath,
fileSize: file.size,
mimeType: file.mimetype,
documentType: documentType,
clubTeamId: clubTeamId
});
devLog('[TeamDocumentService.uploadDocument] - Document uploaded successfully:', teamDocument.id);
return teamDocument;
} catch (error) {
console.error('[TeamDocumentService.uploadDocument] - Error:', error);
throw error;
}
}
/**
* Holt alle Dokumente für ein Club-Team
* @param {number} clubTeamId - Die ID des Club-Teams
* @returns {Promise<Array<TeamDocument>>} Liste der Dokumente
*/
static async getDocumentsByClubTeam(clubTeamId) {
try {
devLog('[TeamDocumentService.getDocumentsByClubTeam] - Getting documents for club team:', clubTeamId);
const documents = await TeamDocument.findAll({
where: { clubTeamId },
order: [['createdAt', 'DESC']]
});
devLog('[TeamDocumentService.getDocumentsByClubTeam] - Found documents:', documents.length);
return documents;
} catch (error) {
console.error('[TeamDocumentService.getDocumentsByClubTeam] - Error:', error);
throw error;
}
}
/**
* Holt ein spezifisches Dokument
* @param {number} documentId - Die ID des Dokuments
* @returns {Promise<TeamDocument|null>} Das Dokument oder null
*/
static async getDocumentById(documentId) {
try {
devLog('[TeamDocumentService.getDocumentById] - Getting document:', documentId);
const document = await TeamDocument.findByPk(documentId, {
include: [{
model: ClubTeam,
as: 'clubTeam',
attributes: ['id', 'name', 'clubId']
}]
});
devLog('[TeamDocumentService.getDocumentById] - Found document:', document ? 'yes' : 'no');
return document;
} catch (error) {
console.error('[TeamDocumentService.getDocumentById] - Error:', error);
throw error;
}
}
/**
* Löscht ein Dokument
* @param {number} documentId - Die ID des Dokuments
* @returns {Promise<boolean>} True wenn gelöscht, sonst false
*/
static async deleteDocument(documentId) {
try {
devLog('[TeamDocumentService.deleteDocument] - Deleting document:', documentId);
const document = await TeamDocument.findByPk(documentId);
if (!document) {
return false;
}
// Lösche die physische Datei
if (fs.existsSync(document.filePath)) {
fs.unlinkSync(document.filePath);
}
// Lösche den Datenbankeintrag
const deletedRows = await TeamDocument.destroy({
where: { id: documentId }
});
devLog('[TeamDocumentService.deleteDocument] - Deleted rows:', deletedRows);
return deletedRows > 0;
} catch (error) {
console.error('[TeamDocumentService.deleteDocument] - Error:', error);
throw error;
}
}
/**
* Löscht alle Dokumente eines bestimmten Typs für ein Club-Team
* @param {number} clubTeamId - Die ID des Club-Teams
* @param {string} documentType - Der Typ des Dokuments
* @returns {Promise<number>} Anzahl der gelöschten Dokumente
*/
static async deleteDocumentsByType(clubTeamId, documentType) {
try {
devLog('[TeamDocumentService.deleteDocumentsByType] - Deleting documents by type:', clubTeamId, documentType);
const documents = await TeamDocument.findAll({
where: { clubTeamId, documentType }
});
let deletedCount = 0;
for (const document of documents) {
// Lösche die physische Datei
if (fs.existsSync(document.filePath)) {
fs.unlinkSync(document.filePath);
}
deletedCount++;
}
// Lösche die Datenbankeinträge
const deletedRows = await TeamDocument.destroy({
where: { clubTeamId, documentType }
});
devLog('[TeamDocumentService.deleteDocumentsByType] - Deleted documents:', deletedRows);
return deletedRows;
} catch (error) {
console.error('[TeamDocumentService.deleteDocumentsByType] - Error:', error);
throw error;
}
}
/**
* Holt den Dateipfad für ein Dokument
* @param {number} documentId - Die ID des Dokuments
* @returns {Promise<string|null>} Der Dateipfad oder null
*/
static async getDocumentPath(documentId) {
try {
const document = await TeamDocument.findByPk(documentId);
return document ? document.filePath : null;
} catch (error) {
console.error('[TeamDocumentService.getDocumentPath] - Error:', error);
throw error;
}
}
}
export default TeamDocumentService;

View File

@@ -0,0 +1,7 @@
Spielplan 2025/2026 - Test Liga
1. 15.01.2025 19:00 Team Alpha vs Team Beta code: ABC123 home pin: PIN001 guest pin: PIN002
2. 22.01.2025 20:00 Team Gamma gegen Team Delta code: DEF456 heim pin: PIN003 gast pin: PIN004
3. 29.01.2025 18:30 Team Epsilon - Team Zeta code: GHI789 home pin: PIN005 guest pin: PIN006
4. 05.02.2025 19:30 Team Alpha vs Team Gamma code: JKL012 home pin: PIN007 guest pin: PIN008
5. 12.02.2025 20:00 Team Beta gegen Team Delta code: MNO345 heim pin: PIN009 gast pin: PIN010