Files

948 lines
34 KiB
JavaScript

/**
* 파일 업로드 컨트롤러
* 이벤트 관련 파일 업로드 및 처리를 담당하는 컨트롤러
*/
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const xlsx = require('xlsx');
const csv = require('csv-parser');
const Tesseract = require('tesseract.js');
const sharp = require('sharp');
const models = require('../models');
const { Event, EventParticipant, Member, EventScore, Club, EventTeam, ClubMember, EventTeamMember } = models;
const { Op, Sequelize } = require('sequelize');
const { sequelize } = require('../models');
const averageService = require('../services/averageService');
// 업로드 디렉토리 설정
const uploadDir = path.join(__dirname, '../uploads');
// 업로드 디렉토리가 없으면 생성
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 파일 업로드를 위한 multer 설정
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, uploadDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
}
});
// 파일 필터 (허용된 파일 형식만 업로드)
const fileFilter = (req, file, cb) => {
const allowedTypes = [
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv',
'image/jpeg',
'image/png'
];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('지원되지 않는 파일 형식입니다. Excel, CSV, JPG, PNG 파일만 업로드 가능합니다.'), false);
}
};
// 업로드 저장 롤백: 이벤트 및 관련 데이터 삭제 (옵션: 게스트 정리)
exports.rollbackEventImport = async (req, res) => {
try {
const { eventId, clubId, includeGuests } = req.body || {};
const eid = Number(eventId);
const cid = Number(clubId);
if (!eid || !cid) {
return res.status(400).json({ message: '유효한 eventId, clubId가 필요합니다.' });
}
await sequelize.transaction(async (t) => {
// 이벤트 검증 (동일 클럽)
const event = await Event.findOne({ where: { id: eid, clubId: cid }, transaction: t });
if (!event) {
throw new Error('대상 이벤트를 찾을 수 없습니다.');
}
// 관련 팀멤버 삭제
const teams = await EventTeam.findAll({ where: { eventId: eid }, transaction: t });
const teamIds = teams.map((tm) => tm.id);
if (teamIds.length > 0) {
await EventTeamMember.destroy({ where: { eventTeamId: teamIds }, transaction: t });
}
// 점수 삭제
await EventScore.destroy({ where: { eventId: eid }, transaction: t });
// 참가자 삭제 전, 게스트 후보 수집
let guestMemberIds = [];
if (includeGuests) {
const participants = await EventParticipant.findAll({ where: { eventId: eid }, transaction: t });
const memberIds = participants.map((p) => p.memberId).filter(Boolean);
if (memberIds.length > 0) {
const members = await Member.findAll({ where: { id: memberIds, clubId: cid, memberType: 'guest' }, transaction: t });
guestMemberIds = members.map((m) => m.id);
}
}
// 참가자 삭제
await EventParticipant.destroy({ where: { eventId: eid }, transaction: t });
// 팀 삭제
await EventTeam.destroy({ where: { eventId: eid }, transaction: t });
// 이벤트 삭제
await Event.destroy({ where: { id: eid, clubId: cid }, transaction: t });
// 게스트 정리: 다른 참가 기록이 없고, 동일 클럽의 조인만 있는 경우 제거
if (includeGuests && guestMemberIds.length > 0) {
// 다른 참가 기록이 없는 멤버만 필터
const others = await EventParticipant.findAll({ where: { memberId: guestMemberIds }, transaction: t });
const stillUsed = new Set(others.map((p) => p.memberId));
const deletable = guestMemberIds.filter((id) => !stillUsed.has(id));
if (deletable.length > 0) {
// 클럽 조인 제거
const club = await Club.findOne({ where: { id: cid }, transaction: t });
if (club) {
// removeMember 지원 여부에 따라 destroy 직접 수행
await sequelize.models.ClubMembers?.destroy?.({ where: { clubId: cid, memberId: deletable }, transaction: t }).catch(() => {});
}
// 멤버 삭제
await Member.destroy({ where: { id: deletable, clubId: cid, memberType: 'guest' }, transaction: t });
}
}
});
res.json({ message: '롤백이 완료되었습니다.' });
} catch (error) {
console.error('롤백 오류:', error);
res.status(500).json({ message: `롤백 중 오류가 발생했습니다: ${error.message}` });
}
};
const upload = multer({
storage: storage,
fileFilter: fileFilter,
limits: { fileSize: 10 * 1024 * 1024 } // 10MB 제한
});
// 파일 업로드 처리
exports.uploadEventFile = (req, res) => {
upload.single('file')(req, res, async function (err) {
if (err instanceof multer.MulterError) {
// Multer 에러 처리
return res.status(400).json({ message: `파일 업로드 오류: ${err.message}` });
} else if (err) {
// 기타 에러 처리
return res.status(400).json({ message: err.message });
}
// 파일이 없는 경우
if (!req.file) {
return res.status(400).json({ message: '업로드할 파일이 없습니다.' });
}
// 클럽 ID 확인
const clubId = req.body.clubId;
if (!clubId) {
// 파일 삭제
if (fs.existsSync(req.file.path)) {
fs.unlinkSync(req.file.path);
}
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
}
try {
// 클럽 존재 여부 확인
const club = await Club.findOne({
where: { id: clubId, status: 'active' }
});
if (!club) {
// 파일 삭제
if (fs.existsSync(req.file.path)) {
fs.unlinkSync(req.file.path);
}
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
}
// 이미지 파일 전처리 (OCR용)
let ocrImagePath = req.file.path;
if (req.file.mimetype.startsWith('image/')) {
const preprocessedPath = req.file.path.replace(/(\.[^.]+)$/, '_preprocessed$1');
await sharp(req.file.path)
.grayscale()
.sharpen()
.toFile(preprocessedPath);
ocrImagePath = preprocessedPath;
}
// 파일 정보 반환
res.json({
message: '파일이 업로드되었습니다.',
file: {
filename: req.file.filename,
originalname: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size,
path: req.file.path,
clubId: clubId
}
});
} catch (error) {
// 오류 발생 시 파일 삭제
if (fs.existsSync(req.file.path)) {
fs.unlinkSync(req.file.path);
}
console.error('파일 업로드 오류:', error);
res.status(500).json({ message: `파일 업로드 중 오류가 발생했습니다: ${error.message}` });
}
});
};
// 업로드된 파일 파싱 및 미리보기 데이터 제공
exports.parseEventFile = async (req, res) => {
try {
const { filename, clubId, sheetName } = req.body;
if (!filename) {
return res.status(400).json({ message: '파일명이 필요합니다.' });
}
if (!clubId) {
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
}
const filePath = path.join(uploadDir, filename);
// 파일 존재 여부 확인
if (!fs.existsSync(filePath)) {
return res.status(404).json({ message: '파일을 찾을 수 없습니다.' });
}
// 파일 확장자 확인
const fileExt = path.extname(filename).toLowerCase();
let parsedData = [];
// 클럽 회원 목록 조회 (참가자 매핑용)
const club = await Club.findOne({
where: { id: clubId, status: 'active' }
});
if (!club) {
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
}
const clubMembers = await club.getMembers({
attributes: ['id', 'name', 'gender', 'handicap'],
through: { where: { status: 'active' } }
});
// 각 회원의 동적 에버리지 계산
const memberMap = new Map();
for (const member of clubMembers) {
if (member && member.name) {
let dynamicAverage = 0;
try {
const averageData = await averageService.calculateMemberAverageByClubSetting(
member.id,
clubId
);
dynamicAverage = averageData.average;
} catch (error) {
console.error(`회원 ${member.id}의 에버리지 계산 오류:`, error);
}
memberMap.set(member.name.trim(), {
id: member.id,
name: member.name,
gender: member.gender,
average: dynamicAverage,
handicap: member.handicap
});
}
};
// 파일 형식에 따른 처리
if (fileExt === '.xlsx' || fileExt === '.xls') {
// Excel 파일 처리
parsedData = await parseExcelFile(filePath, memberMap, sheetName);
} else if (fileExt === '.csv') {
// CSV 파일 처리
parsedData = await parseCsvFile(filePath, memberMap);
} else if (fileExt === '.jpg' || fileExt === '.jpeg' || fileExt === '.png') {
// 이미지 파일 OCR 처리
parsedData = await parseImageFile(filePath, memberMap);
} else {
return res.status(400).json({ message: '지원되지 않는 파일 형식입니다.' });
}
// 참가자별 동적 에버리지 계산 호출 (memberId가 있을 때만)
if (parsedData && parsedData.participants) {
for (const p of parsedData.participants) {
if (p.memberId) {
await averageService.calculateMemberAverageByClubSetting(p.memberId, clubId);
}
}
}
res.json({
message: '파일 파싱 완료',
data: parsedData
});
} catch (error) {
console.error('파일 파싱 오류:', error);
res.status(500).json({ message: `파일 파싱 중 오류가 발생했습니다: ${error.message}` });
}
};
// Excel 파일 파싱 함수
async function parseExcelFile(filePath, memberMap, requestedSheetName = null) {
const workbook = xlsx.readFile(filePath, { type: 'binary', cellDates: true, codepage: 949 });
// 요청된 시트명이 있고 해당 시트가 존재하면 그것을 사용, 아니면 첫 번째 시트 사용
const sheetName = (requestedSheetName && workbook.SheetNames.includes(requestedSheetName))
? requestedSheetName
: workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = xlsx.utils.sheet_to_json(worksheet, { header: 1, defval: '' });
return processRawData(jsonData, memberMap);
}
// CSV 파일 파싱 함수
async function parseCsvFile(filePath, memberMap) {
return new Promise((resolve, reject) => {
const results = [];
fs.createReadStream(filePath, { encoding: 'utf8' })
.pipe(csv({ headers: false, skipEmptyLines: true }))
.on('data', (data) => {
// 각 행을 배열로 변환
results.push(Object.values(data).map(cell => (cell || '').trim()));
})
.on('end', () => {
try {
const processedData = processRawData(results, memberMap);
resolve(processedData);
} catch (error) {
reject(error);
}
})
.on('error', (error) => reject(error));
});
}
// 이미지 파일 OCR 처리 함수
async function parseImageFile(filePath, memberMap) {
try {
// 한글 인식을 위한 Tesseract 설정
const { data } = await Tesseract.recognize(filePath, 'kor+eng', {
logger: m => console.log(m),
tessedit_char_whitelist: '0123456789가-힣이름팀핸디총핀에버Gg.,:()[]- ',
preserve_interword_spaces: 1,
psm: 6 // 표/블록: 6, 한줄: 7, 단어: 8. 상황에 따라 실험 가능
});
// OCR 원본 결과 로그
console.log('[OCR 원본 결과]', data.text);
// OCR 결과 텍스트 처리
const lines = data.text.split('\n')
.filter(line => line.trim() !== '')
.map(line => line.split(/\s+/));
return processRawData(lines, memberMap);
} catch (error) {
console.error('OCR 처리 오류:', error);
throw new Error('이미지 OCR 처리 중 오류가 발생했습니다.');
}
}
// 원시 데이터 처리 및 정규화 함수
function processRawData(rawData, memberMap) {
// 이벤트 정보 추론
const inferredEventInfo = inferEventInfo(rawData);
// 다양한 표 헤더 키워드 정의
const headerKeywords = {
name: ['이름', '성명', '참가자'],
team: ['팀', '팀명'],
handicap: ['핸디', '핸디캡'],
total: ['총핀', '총점', '합계'],
avg: ['에버', '평균'],
// 점수 컬럼: 1게임, 2게임, 1G, 2G, 16, 26 등 다양한 형태
score: [/^(\d+)[Gg]$/, /^(\d{2,})$/, /^\d+게임$/]
};
// 헤더 행 찾기 및 컬럼 인덱스 매핑
let headerRow = -1;
let colMap = {};
for (let i = 0; i < Math.min(10, rawData.length); i++) {
const row = rawData[i];
row.forEach((cell, idx) => {
if (typeof cell !== 'string') return;
for (const key in headerKeywords) {
const patterns = headerKeywords[key];
for (const pattern of patterns) {
if ((typeof pattern === 'string' && cell.replace(/\s/g, '').includes(pattern)) ||
(pattern instanceof RegExp && pattern.test(cell.replace(/\s/g, '')))) {
if (key === 'score') {
if (!colMap.scores) colMap.scores = [];
colMap.scores.push(idx);
} else {
colMap[key] = idx;
}
headerRow = i;
break;
}
}
}
});
// 헤더가 최소 이름, 점수, 팀 중 하나라도 있으면 헤더로 간주
if (Object.keys(colMap).length > 1) break;
}
if (headerRow === -1) return { participants: [], gameCount: 0, inferredEventInfo };
// 데이터 행 파싱
const participants = [];
for (let i = headerRow + 1; i < rawData.length; i++) {
const row = rawData[i];
if (row.length < 2) continue;
// 이름: 한글 2자 이상
const name = (colMap.name !== undefined ? row[colMap.name] : row.find(cell => /^[-힣a-z]{2,}$/i.test(cell)))?.toString().trim();
if (!name) continue;
const teamNumber = colMap.team !== undefined ? row[colMap.team] : '';
const scores = (colMap.scores || []).map(idx => parseInt(row[idx], 10)).filter(n => !isNaN(n));
const handicap = colMap.handicap !== undefined ? parseInt(row[colMap.handicap], 10) : null;
const total = colMap.total !== undefined ? parseInt(row[colMap.total], 10) : null;
const avg = colMap.avg !== undefined ? parseFloat(row[colMap.avg]) : null;
// 회원 매핑 (이름 기준)
const memberMatch = memberMap && memberMap.get && name ? memberMap.get(name.trim()) : null;
participants.push({
name,
teamNumber,
scores,
handicap,
total,
avg,
memberId: memberMatch ? memberMatch.id : null,
memberInfo: memberMatch || null
});
}
return {
participants,
gameCount: Math.max(...participants.map(p => p.scores.length), 0),
inferredEventInfo
};
}
// 파일에서 이벤트 정보 추론
function inferEventInfo(rawData) {
const result = {
title: null,
titleCandidates: [],
startDate: null,
location: null
};
// 파일의 처음 10줄을 검색하여 이벤트 정보 추론
const maxRows = Math.min(10, rawData.length);
// 제목 추론 패턴 (키워드 포함 전체 구문 추출)
const titlePatterns = [
/(.+?정기전)/i,
/(.+?대회)/i,
/(.+?이벤트)/i,
/(.+?토너먼트)/i,
/(.+?경기)/i,
/(.+?오픈)/i,
/제\s*\d+\s*회\s*.+/i,
/제\s*\d+\s*차\s*.+/i,
/\d+\s*회\s*.+/i,
/\d+\s*차\s*.+/i,
/정기전\s*[:\-]?\s*.+/i,
/대회\s*[:\-]?\s*.+/i,
/이벤트\s*[:\-]?\s*.+/i
];
// 날짜 추론 패턴
const datePatterns = [
// YYYY-MM-DD 형식
/(\d{4})[-\/\.]\s*(\d{1,2})[-\/\.]\s*(\d{1,2})/,
// YYYY년 MM월 DD일 형식
/(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/,
// MM월 DD일 형식 (현재 연도 가정)
/(\d{1,2})\s*월\s*(\d{1,2})\s*일/,
// 날짜: YYYY-MM-DD 형식
/날짜\s*[:\-]?\s*(\d{4})[-\/\.]\s*(\d{1,2})[-\/\.]\s*(\d{1,2})/,
// 일시: YYYY-MM-DD 형식
/일시\s*[:\-]?\s*(\d{4})[-\/\.]\s*(\d{1,2})[-\/\.]\s*(\d{1,2})/,
// 시작일: YYYY-MM-DD 형식
/시작일\s*[:\-]?\s*(\d{4})[-\/\.]\s*(\d{1,2})[-\/\.]\s*(\d{1,2})/,
// YYYYMMDD 형식
/\s*(\d{4})\s*(\d{2})\s*(\d{2})\s*/,
// YYMMDD, YY.MM.DD, YY-MM-DD 형식 (25.02.04, 25-02-04, 250204)
/(\d{2})[.\-\/\s]?(\d{2})[.\-\/\s]?(\d{2})/
];
// 파일명에서 정보 추론 (originalname이 있는 경우)
if (rawData.originalname) {
const filename = rawData.originalname;
// 제목 추론 (모든 후보 수집)
for (const pattern of titlePatterns) {
const match = filename.match(pattern);
if (match && match[1]) {
const candidate = match[1].trim();
if (!result.titleCandidates.includes(candidate)) {
result.titleCandidates.push(candidate);
}
}
}
if (result.titleCandidates.length > 0) {
result.title = result.titleCandidates[0];
}
// 날짜 추론
for (const pattern of datePatterns) {
const match = filename.match(pattern);
if (match) {
// 매칭 그룹의 수에 따라 다르게 처리
if (match.length >= 4) { // YYYY-MM-DD 형식
const year = parseInt(match[1]);
const month = parseInt(match[2]) - 1; // JavaScript의 월은 0부터 시작
const day = parseInt(match[3]);
result.startDate = new Date(year, month, day);
} else if (match.length >= 3) { // MM월 DD일 형식
const currentYear = new Date().getFullYear();
const month = parseInt(match[1]) - 1;
const day = parseInt(match[2]);
result.startDate = new Date(currentYear, month, day);
}
break;
}
}
}
// 파일 내용에서 정보 추론
for (let i = 0; i < maxRows; i++) {
const row = rawData[i];
if (!row || !Array.isArray(row)) continue;
// 행을 문자열로 변환하여 패턴 매칭
const rowText = row.join(' ');
// 제목 후보 수집
for (const pattern of titlePatterns) {
const match = rowText.match(pattern);
if (match && match[1]) {
const candidate = match[1].trim();
if (!result.titleCandidates.includes(candidate)) {
result.titleCandidates.push(candidate);
}
}
}
if (!result.title && result.titleCandidates.length > 0) {
result.title = result.titleCandidates[0];
}
// 날짜가 아직 추론되지 않은 경우
if (!result.startDate) {
for (const pattern of datePatterns) {
const match = rowText.match(pattern);
if (match) {
// 매칭 그룹의 수에 따라 다르게 처리
if (match.length >= 4) {
// YYYY-MM-DD, YYYYMMDD, YYMMDD, YY.MM.DD, YY-MM-DD 모두 처리
let year = parseInt(match[1], 10);
const month = parseInt(match[2], 10) - 1;
const day = parseInt(match[3], 10);
// 2자리 연도라면 보정
if (match[1].length === 2) {
year += 2000;
}
result.startDate = new Date(year, month, day);
} else if (match.length === 3) {
// MM월 DD일 형식 (현재 연도 가정)
const currentYear = new Date().getFullYear();
const month = parseInt(match[1]) - 1;
const day = parseInt(match[2]);
result.startDate = new Date(currentYear, month, day);
}
break;
}
}
}
// 모든 정보가 추론되었다면 중단
if (result.title && result.startDate) {
break;
}
}
return result;
}
// 이벤트에 파일 데이터 저장
exports.saveEventFileData = async (req, res) => {
const { eventData, participants, clubId } = req.body;
if (!eventData || !participants || !clubId) {
return res.status(400).json({ message: '이벤트 정보, 참가자 정보, 클럽 ID가 필요합니다.' });
}
// clubId 타입 보정 (문자열로 들어온 경우 Number로 변환)
const cid = Number(clubId);
if (!cid || Number.isNaN(cid)) {
return res.status(400).json({ message: '유효한 클럽 ID가 필요합니다.' });
}
try {
// 트랜잭션 시작
const result = await sequelize.transaction(async (t) => {
// 프런트에서 전달할 수 있는 핸디 포함 여부 플래그 (기본: true)
const scoresIncludeHandicap = (
(typeof req.body.scoresIncludeHandicap === 'boolean') ? req.body.scoresIncludeHandicap
: (eventData && typeof eventData.scoresIncludeHandicap === 'boolean') ? eventData.scoresIncludeHandicap
: true
);
// 이벤트 타입/상태 정규화 (모델 enum 호환)
const normalizeEventType = (v) => {
if (!v) return 'regular';
const s = String(v).toLowerCase();
if (['regular','일반'].includes(s)) return 'regular';
if (['lightning','번개'].includes(s)) return 'lightning';
if (['practice','연습'].includes(s)) return 'practice';
if (['exchange','교류전'].includes(s)) return 'exchange';
if (['event','행사'].includes(s)) return 'event';
return 'other';
};
const normalizeStatus = (v) => {
if (!v) return 'completed';
const s = String(v).toLowerCase();
if (['active','활성'].includes(s)) return 'active';
if (['ready','대기','준비','draft'].includes(s)) return 'ready';
if (['completed','완료','done','finished'].includes(s)) return 'completed';
if (['canceled','cancelled','취소'].includes(s)) return 'canceled';
if (['deleted','삭제'].includes(s)) return 'deleted';
return 'completed';
};
// 1. 이벤트 생성 또는 업데이트
let event;
if (eventData.id) {
// 기존 이벤트 업데이트
event = await Event.findOne({
where: { id: eventData.id, clubId: cid },
transaction: t
});
if (!event) {
throw new Error('이벤트를 찾을 수 없습니다.');
}
await event.update({
title: eventData.title,
description: eventData.description || '',
// 비즈니스 규칙: 이벤트 유형은 정기전, 상태는 완료로 고정
eventType: 'regular',
startDate: new Date(eventData.startDate),
endDate: eventData.endDate ? new Date(eventData.endDate) : null,
location: eventData.location || '',
gameCount: eventData.gameCount || 3,
maxParticipants: eventData.maxParticipants || 0,
status: 'completed'
}, { transaction: t });
} else {
// 새 이벤트 생성
event = await Event.create({
clubId: cid,
title: eventData.title,
description: eventData.description || '',
// 비즈니스 규칙: 이벤트 유형은 정기전으로 저장
eventType: 'regular',
startDate: new Date(eventData.startDate),
endDate: eventData.endDate ? new Date(eventData.endDate) : null,
location: eventData.location || '',
gameCount: eventData.gameCount || 3,
maxParticipants: eventData.maxParticipants || 0,
// 비즈니스 규칙: 상태는 완료로 저장
status: 'completed',
publicHash: generateRandomHash()
}, { transaction: t });
}
// 이벤트에서 최종 클럽ID 확정(추가 안정성)
const effectiveClubId = Number(event.clubId);
// 클럽 인스턴스 로드 (조인 테이블 조작용)
const clubInstance = await Club.findOne({ where: { id: effectiveClubId, status: 'active' }, transaction: t });
// 1.5 팀 정보 처리 - 참가자 처리 전에 팀 생성
// 팀 번호별로 그룹화
const teamGroups = {};
participants.forEach(p => {
if (p.teamNumber) {
if (!teamGroups[p.teamNumber]) {
teamGroups[p.teamNumber] = [];
}
teamGroups[p.teamNumber].push(p);
}
});
// 팀 생성
const createdTeams = {};
for (const teamNumber in teamGroups) {
// 기존 팀 확인
let eventTeam = await EventTeam.findOne({
where: {
eventId: event.id,
teamNumber: parseInt(teamNumber)
},
transaction: t
});
// 팀이 없으면 생성
if (!eventTeam) {
eventTeam = await EventTeam.create({
eventId: event.id,
teamNumber: parseInt(teamNumber),
name: `팀 ${teamNumber}`,
handicap: 0 // 기본 핸디캡 값
}, { transaction: t });
}
createdTeams[teamNumber] = eventTeam;
}
// 2. 참가자 및 점수 처리
const affectedMemberIds = new Set();
for (const participant of participants) {
let eventParticipant;
// 참가자 생성 또는 업데이트
if (participant.memberId) {
// 기존 회원인 경우
const existingParticipant = await EventParticipant.findOne({
where: {
eventId: event.id,
memberId: participant.memberId
},
transaction: t
});
// 팀 ID 가져오기
let teamId = null;
if (participant.teamNumber && createdTeams[participant.teamNumber]) {
teamId = createdTeams[participant.teamNumber].id;
}
if (existingParticipant) {
// 기존 참가자 업데이트 (팀 정보 포함)
await existingParticipant.update({
teamId: teamId
}, { transaction: t });
eventParticipant = existingParticipant;
} else {
eventParticipant = await EventParticipant.create({
eventId: event.id,
memberId: participant.memberId,
status: 'confirmed',
paymentStatus: 'paid',
teamId: teamId,
comment: ''
}, { transaction: t });
}
affectedMemberIds.add(Number(participant.memberId));
} else {
// 새 회원 생성 및 참가자 등록
// 클럽 여성 기본 핸디캡 기준으로 성별/핸디캡 보정
const femaleBase = Number(clubInstance?.femaleHandicap ?? 0) || 0;
let parsedHandicap = null;
if (participant.handicap !== undefined && participant.handicap !== null) {
const ph = Number(participant.handicap);
if (Number.isFinite(ph)) parsedHandicap = ph;
}
// 점수 객체에 handicap이 개별 제공되는 경우 최댓값을 사용
if (parsedHandicap == null && Array.isArray(participant.scores)) {
for (const s of participant.scores) {
if (s && typeof s === 'object' && s.handicap !== undefined) {
const sh = Number(s.handicap);
if (Number.isFinite(sh)) {
parsedHandicap = parsedHandicap == null ? sh : Math.max(parsedHandicap, sh);
}
}
}
}
const isFemaleByRule = (parsedHandicap != null && parsedHandicap >= femaleBase) ||
(Array.isArray(participant.scores) && participant.scores.some((s) => Number(s?.handicap) >= femaleBase));
const genderNormalized = (participant.gender === 'female' || participant.gender === '여성') ? 'female' : 'male';
const finalGender = isFemaleByRule ? 'female' : genderNormalized;
const newMember = await Member.create({
clubId: effectiveClubId, // required by Member model
name: participant.name,
gender: finalGender,
memberType: 'guest',
status: 'active',
handicap: parsedHandicap ?? 0
}, { transaction: t });
// 클럽 회원으로 등록 (조인 테이블을 association으로 처리)
if (!clubInstance) {
throw new Error('클럽을 찾을 수 없습니다.');
}
await clubInstance.addMember(newMember, {
through: { joinDate: new Date(), status: 'active' },
transaction: t,
});
// 팀 ID 가져오기
let teamId = null;
if (participant.teamNumber && createdTeams[participant.teamNumber]) {
teamId = createdTeams[participant.teamNumber].id;
}
eventParticipant = await EventParticipant.create({
eventId: event.id,
memberId: newMember.id,
status: 'confirmed',
paymentStatus: 'paid',
teamId: teamId,
comment: ''
}, { transaction: t });
affectedMemberIds.add(Number(newMember.id));
}
// 팀에 속한 참가자인 경우 팀 멤버로 등록
if (participant.teamNumber && createdTeams[participant.teamNumber]) {
// 기존 팀 멤버 확인
const existingTeamMember = await EventTeamMember.findOne({
where: {
eventTeamId: createdTeams[participant.teamNumber].id,
eventParticipantId: eventParticipant.id
},
transaction: t
});
// 팀 멤버가 없으면 생성
if (!existingTeamMember) {
await EventTeamMember.create({
eventTeamId: createdTeams[participant.teamNumber].id,
eventParticipantId: eventParticipant.id,
order: 1
}, { transaction: t });
}
}
// 점수 저장
if (participant.scores && participant.scores.length > 0) {
// 기존 점수 삭제
await EventScore.destroy({
where: {
eventId: event.id,
participantId: eventParticipant.id
},
transaction: t
});
// 새 점수 등록 (숫자 배열/객체 배열 모두 지원)
const scoreItems = Array.isArray(participant.scores)
? participant.scores.map((s, idx) => (
typeof s === 'number'
? { gameNumber: idx + 1, score: s }
: s
))
: [];
for (const s of scoreItems) {
const gameNumber = Number(s.gameNumber ?? 0) || 0;
const handicap = s.handicap !== undefined ? Number(s.handicap) : Number(participant.handicap || 0);
// scoresIncludeHandicap 플래그에 따라 저장 방식 결정
const provided = s.totalScore !== undefined ? Number(s.totalScore) : Number(s.score ?? 0) || 0;
const totalScore = scoresIncludeHandicap
? provided
: Math.max(0, provided + (Number.isFinite(handicap) ? handicap : 0));
const baseScore = scoresIncludeHandicap
? Math.max(0, provided - (Number.isFinite(handicap) ? handicap : 0))
: provided;
if (gameNumber <= 0) continue;
await EventScore.create({
eventId: event.id,
participantId: eventParticipant.id,
gameNumber,
score: baseScore,
handicap,
totalScore,
teamNumber: participant.teamNumber || null
}, { transaction: t });
}
}
}
// 트랜잭션 내부에서는 목록만 반환 (커밋 후 워밍업)
return { event, affectedMemberIds: Array.from(affectedMemberIds), effectiveClubId };
});
// 커밋 이후 평균 캐시 무효화 및 워밍업 (신규 점수가 커밋된 뒤 계산되도록)
if (result && Array.isArray(result.affectedMemberIds)) {
for (const mid of result.affectedMemberIds) {
try {
await averageService.invalidateMemberAverageCache(mid);
await averageService.calculateMemberAverageByClubSetting(mid, result.effectiveClubId);
} catch (e) {
console.error('평균 캐시 갱신 실패:', e);
}
}
}
res.json({
message: '이벤트 데이터가 성공적으로 저장되었습니다.',
event: {
id: result.event.id,
title: result.event.title,
eventType: result.event.eventType,
startDate: result.event.startDate
},
rollbackToken: {
eventId: result.event.id,
clubId: Number(result.event.clubId)
}
});
} catch (error) {
console.error('이벤트 데이터 저장 오류:', error);
res.status(500).json({ message: `이벤트 데이터 저장 중 오류가 발생했습니다: ${error.message}` });
}
};
// 랜덤 해시 생성 함수
function generateRandomHash() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
// 임시 파일 삭제
exports.deleteUploadedFile = async (req, res) => {
try {
const { filename } = req.body;
if (!filename) {
return res.status(400).json({ message: '파일명이 필요합니다.' });
}
const filePath = path.join(uploadDir, filename);
// 파일 존재 여부 확인
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
res.json({ message: '파일이 삭제되었습니다.' });
} catch (error) {
console.error('파일 삭제 오류:', error);
res.status(500).json({ message: `파일 삭제 중 오류가 발생했습니다: ${error.message}` });
}
};