#!/usr/bin/env node 'use strict'; /** * Converts a single class ODS or XLSX spreadsheet (with sheets for اطلاعات, شهریه, حضور و غیاب) * into a full import JSON file ready for gameno-api Data Import. * * Usage: * node scripts/convert-class-file.js [input.ods|xlsx] [output.json] */ const fs = require('fs'); const path = require('path'); const XLSX = require('xlsx'); const MONTHS = { فروردین: 1, اردیبهشت: 2, خرداد: 3, تیر: 4, مرداد: 5, شهریور: 6, مهر: 7, آبان: 8, آذر: 9, دی: 10, بهمن: 11, اسفند: 12 }; const DEFAULT_IN = path.join(__dirname, '../../raw-data/رویت مرداد 1405.ods'); const DEFAULT_OUT = path.join(__dirname, '../../raw-data/revit-mordad-1405-import.json'); const toEnglishDigits = (value) => String(value ?? '') .replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d)) .replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d)); const cleanText = (value) => { if (value == null) return ''; return String(value) .replace(/[\u200e\u200f\u202a-\u202e]/g, '') // remove invisible bidi chars .replace(/\s+/g, ' ') .trim(); }; const normalizeHeader = (value) => cleanText(value).replace(/\n/g, ' '); const cleanPersonName = (raw) => { let name = cleanText(raw) .replace(/ي/g, 'ی') .replace(/ك/g, 'ک') .replace(/[\u200c\u200d]/g, ' ') .replace(/\s+/g, ' ') .trim(); // Strip prefixes like "خ ", "خانم ", "آقای ", "سرکار خانم " name = name .replace(/^(آقای|اقای|خانم|آقا|سرکار\s+خانم|سرکار)\s+/, '') .replace(/^خ\s+/, '') .replace(/\s+خ\s+/g, ' ') .trim(); return name; }; const jalaliToGregorian = (jy, jm, jd) => { const gy = jy <= 979 ? 621 : 1600; jy -= jy <= 979 ? 0 : 979; let days = 365 * jy + Math.floor(jy / 33) * 8 + Math.floor(((jy % 33) + 3) / 4) + 78 + jd + (jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186); let gyOut = gy + 400 * Math.floor(days / 146097); days %= 146097; if (days > 36524) { gyOut += 100 * Math.floor(--days / 36524); days %= 36524; if (days >= 365) days += 1; } gyOut += 4 * Math.floor(days / 1461); days %= 1461; if (days > 365) { gyOut += Math.floor((days - 1) / 365); days = (days - 1) % 365; } let gd = days + 1; const sal_a = [ 0, 31, (gyOut % 4 === 0 && gyOut % 100 !== 0) || gyOut % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ]; let gm = 0; for (gm = 1; gm <= 12 && gd > sal_a[gm]; gm += 1) gd -= sal_a[gm]; return `${gyOut}-${String(gm).padStart(2, '0')}-${String(gd).padStart(2, '0')}`; }; const parseJalaliDate = (raw) => { if (!raw) return null; const text = toEnglishDigits(raw).replace(/[./\-]/g, '/').trim(); const match = text.match(/^(\d{3,4})\/(\d{1,2})\/(\d{1,2})$/); if (!match) return null; const jy = Number(match[1]); const jm = Number(match[2]); const jd = Number(match[3]); if (!jy || !jm || !jd || jm > 12 || jd > 31) return null; try { return jalaliToGregorian(jy, jm, jd); } catch { return null; } }; const formatJalaliSlash = (raw) => { if (!raw) return null; const text = toEnglishDigits(raw).replace(/[./\-]/g, '/').trim(); const match = text.match(/^(\d{3,4})\/(\d{1,2})\/(\d{1,2})$/); if (!match) return null; return `${match[1]}/${String(match[2]).padStart(2, '0')}/${String(match[3]).padStart(2, '0')}`; }; const normalizePhone = (raw) => { if (raw == null || raw === '') return ''; let digits = toEnglishDigits(raw).replace(/[^\d+]/g, ''); if (digits.includes('-') || String(raw).includes('-')) { const parts = toEnglishDigits(raw) .split(/[-–—,\/|\s]+/) .map((p) => p.replace(/\D/g, '')) .filter(Boolean); const candidate = parts.find((p) => /^(0?9\d{9})$/.test(p)) || parts.find((p) => p.length >= 10) || parts[0] || ''; digits = candidate; } digits = digits.replace(/\D/g, ''); if (digits.startsWith('98') && digits.length === 12) digits = `0${digits.slice(2)}`; if (digits.length === 10 && digits.startsWith('9')) digits = `0${digits}`; if (digits.length === 11 && digits.startsWith('09')) return digits; return digits || ''; }; const normalizeNationalId = (raw) => { if (raw == null || raw === '') return ''; let digits = toEnglishDigits(raw).replace(/\D/g, ''); if (digits && digits.length < 10 && /^\d+$/.test(digits)) { digits = digits.padStart(10, '0'); } return digits; }; const parseMoneyToman = (raw) => { if (raw == null || raw === '') return 0; const text = cleanText(raw); if (!text) return 0; let rials = 0; if (/^[\d,.\s]+$/.test(toEnglishDigits(text).replace(/,/g, ''))) { const digits = toEnglishDigits(text).replace(/[^\d]/g, ''); rials = digits ? Number(digits) : 0; } else if (/e\+/i.test(text)) { const n = Number(toEnglishDigits(text)); rials = Number.isFinite(n) ? Math.round(n) : 0; } // Convert Rials to Toman by dropping last zero return rials >= 10 ? Math.floor(rials / 10) : rials; }; const mapAttendanceStatus = (raw) => { const value = cleanText(raw); if (!value) return null; if (value === '*' || value === 'ح' || value === 'حاضر' || /^present$/i.test(value) || /^p$/i.test(value)) { return 'present'; } if (value === 'غ' || value === 'غایب' || /^absent$/i.test(value) || /^a$/i.test(value)) { return 'absent'; } if (value === 'ت' || value === 'تأخیر' || value === 'تاخیر' || /^late$/i.test(value)) { return 'late'; } if (value === 'م' || value === 'موجه' || /^excused$/i.test(value)) { return 'excused'; } return null; }; const pickSheet = (wb, hints) => { for (const hint of hints) { const found = wb.SheetNames.find((n) => { const norm = normalizeHeader(n); return norm === hint || norm.includes(hint); }); if (found) return found; } return null; }; const parseFilenameMeta = (filePath) => { const base = path.basename(filePath, path.extname(filePath)); const cleaned = cleanText(base).replace(/^\d+\s*[-–—.]?\s*/, ''); const monthNames = Object.keys(MONTHS).join('|'); const monthMatch = cleaned.match(new RegExp(`(${monthNames})\\s+(\\d{4})$`)); let className = cleaned; let courseTitle = cleaned; let jalaliYear = null; let jalaliMonth = null; let startDate = null; if (monthMatch) { const monthName = monthMatch[1]; jalaliYear = Number(monthMatch[2]); jalaliMonth = MONTHS[monthName]; className = cleaned; courseTitle = cleanText(cleaned.slice(0, monthMatch.index)); startDate = jalaliToGregorian(jalaliYear, jalaliMonth, 1); } const isPrivate = /خصوصی/.test(courseTitle) || /خصوصی/.test(className); if (isPrivate) courseTitle = cleanText(courseTitle.replace(/خصوصی/g, '')); return { courseTitle: courseTitle || className, className, type: isPrivate ? 'Private' : 'General', jalaliYear, jalaliMonth, startDate }; }; const extractInfoStudents = (sheet) => { const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' }); let headerIdx = -1; const colMap = {}; for (let i = 0; i < Math.min(rows.length, 10); i += 1) { const r = rows[i] || []; const text = r.map(normalizeHeader).join('|'); if (text.includes('نام') && (text.includes('تلفن') || text.includes('کد ملی') || text.includes('همراه'))) { headerIdx = i; r.forEach((cell, cIdx) => { const h = normalizeHeader(cell); if (h === 'نام' || h === 'نام ') colMap.firstName = cIdx; else if (h.includes('نام خانوادگی') && !h.includes('نام و')) colMap.lastName = cIdx; else if (h.includes('نام و نام خانوادگی')) colMap.fullName = cIdx; else if (h.includes('تلفن همراه') || h.includes('شماره تماس') || h.includes('شماره همراه')) colMap.phone = cIdx; else if (h.includes('کد ملی')) colMap.nationalId = cIdx; else if (h.includes('شماره شناسنامه')) colMap.birthCertificateNumber = cIdx; else if (h.includes('کد پستی')) colMap.postalCode = cIdx; else if (h.includes('محل صدور')) colMap.placeOfIssue = cIdx; else if (h.includes('نام پدر')) colMap.fatherName = cIdx; else if (h.includes('تاریخ تولد')) colMap.birthDate = cIdx; else if (h.includes('تحصیلات')) colMap.education = cIdx; else if (h.includes('آدرس')) colMap.address = cIdx; }); break; } } if (headerIdx < 0) return []; const students = []; for (let i = headerIdx + 1; i < rows.length; i += 1) { const row = rows[i]; if (!row || row.every((c) => cleanText(c) === '')) continue; const rawFullName = colMap.fullName != null ? row[colMap.fullName] : ''; const rawFirstName = colMap.firstName != null ? row[colMap.firstName] : ''; const rawLastName = colMap.lastName != null ? row[colMap.lastName] : ''; const rawNameCombined = rawFullName || `${rawFirstName} ${rawLastName}`; const name = cleanPersonName(rawNameCombined); const rawPhone = colMap.phone != null ? cleanText(row[colMap.phone]) : ''; const isMotherPhone = rawPhone.includes('مادر'); const phone = normalizePhone(rawPhone); const nationalId = colMap.nationalId != null ? normalizeNationalId(row[colMap.nationalId]) : ''; const postalCode = colMap.postalCode != null ? cleanText(toEnglishDigits(row[colMap.postalCode])).replace(/\D/g, '') : ''; const birthCert = colMap.birthCertificateNumber != null ? cleanText(toEnglishDigits(row[colMap.birthCertificateNumber])) : ''; const placeOfIssue = colMap.placeOfIssue != null ? cleanText(row[colMap.placeOfIssue]) : ''; const fatherName = colMap.fatherName != null ? cleanText(row[colMap.fatherName]) : ''; const birthDateRaw = colMap.birthDate != null ? cleanText(row[colMap.birthDate]) : ''; const birthDateIso = parseJalaliDate(birthDateRaw); const education = colMap.education != null ? cleanText(row[colMap.education]) : ''; const address = colMap.address != null ? cleanText(row[colMap.address]) : ''; if (!name && !phone && !nationalId) continue; const student = { name, phoneNumber: isMotherPhone ? undefined : (phone || undefined), parentPhoneNumber: isMotherPhone ? phone : undefined, nationalIdCode: nationalId || undefined, birthCertificateNumber: birthCert || undefined, postalCode: postalCode || undefined, placeOfIssue: placeOfIssue || undefined, fatherName: fatherName || undefined, birthDate: birthDateIso || undefined, birthDateJalali: birthDateRaw ? formatJalaliSlash(birthDateRaw) : undefined, education: education || undefined, address: address || undefined }; Object.keys(student).forEach((k) => { if (student[k] === undefined || student[k] === '') delete student[k]; }); students.push(student); } return students; }; const extractPayments = (sheet, students) => { const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' }); let headerIdx = -1; const colMap = {}; for (let i = 0; i < Math.min(rows.length, 10); i += 1) { const r = rows[i] || []; const text = r.map(normalizeHeader).join('|'); if (text.includes('شهریه') || text.includes('هزینه') || text.includes('قسط') || text.includes('شماره تماس')) { headerIdx = i; r.forEach((cell, cIdx) => { const h = normalizeHeader(cell); if (h === 'نام' || h === 'نام ') colMap.firstName = cIdx; else if (h.includes('نام خانوادگی') && !h.includes('نام و')) colMap.lastName = cIdx; else if (h.includes('نام و نام خانوادگی')) colMap.fullName = cIdx; else if (h.includes('شماره تماس') || h.includes('تلفن همراه')) colMap.phone = cIdx; else if ((h.includes('هزینه کل دوره') || h === 'هزینه کل') && !h.includes('قسط') && colMap.totalCost == null) { colMap.totalCost = cIdx; } }); break; } } if (headerIdx < 0) return []; // Identify installment columns: "قسط اول", "قسط دوم", etc. and date columns right next to them const header = rows[headerIdx]; const installments = []; for (let c = 0; c < header.length; c += 1) { const h = normalizeHeader(header[c]); if (h.includes('قسط') || h.includes('واریز') || h.includes('پیش پرداخت')) { installments.push({ amountCol: c, dateCol: c + 1, label: h }); } } const payments = []; for (let i = headerIdx + 1; i < rows.length; i += 1) { const row = rows[i]; if (!row || row.every((c) => cleanText(c) === '')) continue; const rawFullName = colMap.fullName != null ? row[colMap.fullName] : ''; const rawFirstName = colMap.firstName != null ? row[colMap.firstName] : ''; const rawLastName = colMap.lastName != null ? row[colMap.lastName] : ''; const rawName = cleanPersonName(rawFullName || `${rawFirstName} ${rawLastName}`); const phone = colMap.phone != null ? normalizePhone(row[colMap.phone]) : ''; // Match student from students list to enrich identity: PRIORITIZE NAME MATCH FIRST let matchedStudent = null; if (rawName) { matchedStudent = students.find((s) => s.name === rawName || s.name.includes(rawName) || rawName.includes(s.name)); } if (!matchedStudent && phone) { matchedStudent = students.find((s) => s.phoneNumber === phone || s.parentPhoneNumber === phone); } const studentName = matchedStudent?.name || rawName; const studentPhone = matchedStudent?.phoneNumber || phone; const nationalId = matchedStudent?.nationalIdCode; // Enrich matched student's phone if missing if (matchedStudent && !matchedStudent.phoneNumber && phone) { matchedStudent.phoneNumber = phone; } const totalCostToman = colMap.totalCost != null ? parseMoneyToman(row[colMap.totalCost]) : 0; const transactions = []; for (const inst of installments) { const amountToman = parseMoneyToman(row[inst.amountCol]); if (amountToman > 0) { const rawDate = cleanText(row[inst.dateCol]); const dateIso = parseJalaliDate(rawDate); transactions.push({ amount: amountToman, date: dateIso || undefined, dateJalali: rawDate ? formatJalaliSlash(rawDate) : undefined, status: 'paid', type: 'installment', notes: inst.label }); } } const paidTotal = transactions.reduce((sum, t) => sum + t.amount, 0); const payment = { name: studentName, phoneNumber: studentPhone || undefined, nationalIdCode: nationalId || undefined, amount: totalCostToman || paidTotal, paidAmount: paidTotal, transactions }; Object.keys(payment).forEach((k) => { if (payment[k] === undefined || payment[k] === '') delete payment[k]; }); payments.push(payment); } return payments; }; const extractAttendance = (sheet, students) => { const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' }); let dateRowIdx = -1; let weekdayRowIdx = -1; let nameColIdx = -1; for (let i = 0; i < Math.min(rows.length, 10); i += 1) { const r = rows[i] || []; const text = r.map(normalizeHeader).join('|'); if (text.includes('تاریخ') || r.some((c) => parseJalaliDate(c))) { dateRowIdx = i; if (i > 0 && (rows[i - 1].some((c) => /شنبه/i.test(c)) || normalizeHeader(rows[i - 1][1]).includes('ایام'))) { weekdayRowIdx = i - 1; } break; } } if (dateRowIdx < 0) return { sessions: [], days: [], startDate: null }; const dateRow = rows[dateRowIdx]; const weekdayRow = weekdayRowIdx >= 0 ? rows[weekdayRowIdx] : []; // Identify session columns with Jalali dates const sessionCols = []; for (let c = 0; c < dateRow.length; c += 1) { const rawDate = cleanText(dateRow[c]); const isoDate = parseJalaliDate(rawDate); if (isoDate) { sessionCols.push({ col: c, sessionNo: sessionCols.length + 1, rawDate, isoDate, weekday: cleanText(weekdayRow[c]) || undefined }); } } // Find student row start & name column let studentStartIdx = dateRowIdx + 1; for (let c = 0; c < 5; c += 1) { const sample = rows.slice(studentStartIdx, studentStartIdx + 4).map((r) => cleanText(r[c])); if (sample.some((s) => s && !/^\d+$/.test(s) && !/تاریخ|جلسه|ردیف/i.test(s))) { nameColIdx = c; break; } } const DAY_NAME_MAP = { شنبه: 'Saturday', یکشنبه: 'Sunday', 'یک شنبه': 'Sunday', دوشنبه: 'Monday', 'دو شنبه': 'Monday', سهشنبه: 'Tuesday', 'سه شنبه': 'Tuesday', چهارشنبه: 'Wednesday', 'چهار شنبه': 'Wednesday', پنجشنبه: 'Thursday', 'پنج شنبه': 'Thursday', جمعه: 'Friday' }; const detectedWeekdays = new Set(); sessionCols.forEach((s) => { if (s.weekday && DAY_NAME_MAP[s.weekday]) { detectedWeekdays.add(DAY_NAME_MAP[s.weekday]); } }); const DAY_NUM_MAP = { Saturday: 6, Sunday: 0, Monday: 1, Tuesday: 2, Wednesday: 3, Thursday: 4, Friday: 5 }; const dayNumbers = Array.from(detectedWeekdays).map((d) => DAY_NUM_MAP[d]).filter((n) => n != null); const sessions = sessionCols.map((s) => ({ topic: `جلسه ${s.sessionNo}`, day: s.isoDate, dayJalali: formatJalaliSlash(s.rawDate), startTime: '19:00', endTime: '20:30', status: 'scheduled', attendance: [] })); for (let i = studentStartIdx; i < rows.length; i += 1) { const row = rows[i]; if (!row || row.every((c) => cleanText(c) === '')) continue; const rawName = cleanPersonName(row[nameColIdx]); if (!rawName) continue; const matched = students.find((s) => s.name === rawName || s.name.includes(rawName) || rawName.includes(s.name)); const studentName = matched?.name || rawName; const phoneNumber = matched?.phoneNumber; const nationalIdCode = matched?.nationalIdCode; for (const sCol of sessionCols) { const mark = cleanText(row[sCol.col]); const status = mapAttendanceStatus(mark); if (status) { const session = sessions.find((s) => s.topic === `جلسه ${sCol.sessionNo}`); if (session) { const rec = { name: studentName, phoneNumber: phoneNumber || undefined, nationalIdCode: nationalIdCode || undefined, status }; Object.keys(rec).forEach((k) => { if (rec[k] === undefined || rec[k] === '') delete rec[k]; }); session.attendance.push(rec); } } } } // Mark session status as 'held' if it has attendance records sessions.forEach((s) => { if (s.attendance.length > 0) { s.status = 'held'; } }); return { sessions, days: dayNumbers.length ? dayNumbers : [4, 1], startDate: sessionCols[0]?.isoDate || null }; }; const convertSpreadsheet = (filePath) => { const wb = XLSX.readFile(filePath, { raw: false, cellDates: false }); const meta = parseFilenameMeta(filePath); const infoSheetName = pickSheet(wb, ['اطلاعات', 'مشخصات']); const paymentSheetName = pickSheet(wb, ['شهریه']); const attendanceSheetName = pickSheet(wb, ['حضور و غیاب', 'حضور غیاب']); const students = infoSheetName ? extractInfoStudents(wb.Sheets[infoSheetName]) : []; const payments = paymentSheetName ? extractPayments(wb.Sheets[paymentSheetName], students) : []; const attendance = attendanceSheetName ? extractAttendance(wb.Sheets[attendanceSheetName], students) : { sessions: [], days: [], startDate: null }; const classTuition = payments[0]?.amount || 11000000; const doc = { version: 1, generatedAt: new Date().toISOString(), source: path.basename(filePath), summary: { courses: 1, classes: 1, students: students.length, sessions: attendance.sessions.length, attendanceRecords: attendance.sessions.reduce((acc, s) => acc + s.attendance.length, 0), payments: payments.length }, courses: [ { title: meta.courseTitle, type: meta.type, price: classTuition, classes: [ { name: meta.className, startDate: attendance.startDate || meta.startDate, tuitionFee: classTuition, days: attendance.days.length ? attendance.days : ['Thursday', 'Monday'], startTime: '19:00', endTime: '20:30', numberOfSessions: attendance.sessions.length || 10, students, sessions: attendance.sessions, payments } ] } ] }; return doc; }; const main = () => { const inputPath = path.resolve(process.argv[2] || DEFAULT_IN); const outputPath = path.resolve(process.argv[3] || DEFAULT_OUT); if (!fs.existsSync(inputPath)) { console.error(`Input file not found: ${inputPath}`); process.exit(1); } const doc = convertSpreadsheet(inputPath); fs.writeFileSync(outputPath, JSON.stringify(doc, null, 2), 'utf8'); console.log(`Successfully generated import file: ${outputPath}`); console.log(JSON.stringify(doc.summary, null, 2)); }; if (require.main === module) { main(); } module.exports = { convertSpreadsheet, main };