Files
gameno-api/scripts/convert-class-file.js
T

636 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
};