From ada1b624b13d1267ccf4e4f82686ab6f6894b529 Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Fri, 21 Aug 2026 15:17:41 +0330 Subject: [PATCH] fix(payments): prevent auto transaction creation, enable sms by default, and fix transaction sms context --- components/payments/paymentService.js | 27 +- config/config.js | 2 +- scripts/convert-class-file.js | 635 ++++++++++++++++++++++++++ 3 files changed, 645 insertions(+), 19 deletions(-) create mode 100644 scripts/convert-class-file.js diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js index 35ba5cc..240d738 100644 --- a/components/payments/paymentService.js +++ b/components/payments/paymentService.js @@ -41,7 +41,7 @@ const PAYMENT_STATUS_LABELS = { }; const formatPaymentContext = async (payment) => { - const user = await User.findById(payment.user).select('name phoneNumber email').lean(); + const user = await User.findById(payment.user?._id || payment.user).select('name phoneNumber email').lean(); let courseName = ''; let schedule = { classStartDate: '', classDays: '', courseTime: '' }; @@ -97,9 +97,9 @@ const notifyPaymentStatusChanged = async (payment, newStatus, oldStatus) => { } }; -const notifyTransactionRecorded = async (payment, transaction) => { +const notifyTransactionRecorded = async (payment, transaction, source = {}) => { try { - const notify = await resolveNotifyFlags({}, 'transactionRecorded'); + const notify = await resolveNotifyFlags(source, 'transactionRecorded'); if (!notify.sms && !notify.email && !notify.bot) return; const { user, invoiceCode } = await formatPaymentContext(payment); @@ -118,13 +118,15 @@ const notifyTransactionRecorded = async (payment, transaction) => { invoiceCode, transactionCode: transaction.uniqueCode || '', receiptNumber: transaction.receiptNumber || '' - }, user._id) + }, user._id), + requestSource: source }); } catch (err) { logger.error(`[notifyTransactionRecorded] Failed for transaction ${transaction._id}: ${err.message}`); } }; + const populatePayment = (query) => query .populate({ path: 'user', select: 'name phoneNumber' }) .populate({ path: 'classes', select: 'name tuitionFee' }) @@ -195,16 +197,10 @@ const reconcilePendingTransactions = async (payment, { adjustAmount = true } = { if (pending.length > 1) { await Transaction.deleteMany({ _id: { $in: pending.slice(1).map((row) => row._id) } }); } - return; } - - await Transaction.create(buildTransactionPayload(payment, { - amount: remaining, - status: 'pending', - dueDate: payment.dueDate || new Date() - })); }; + const buildTransactionPayload = (payment, trxData, actorId = null) => { const status = trxData.status === 'pending' ? 'pending' : (trxData.status === 'paid' || trxData.date ? 'paid' : 'pending'); const paidDate = parseDate(trxData.date) || (status === 'paid' ? new Date() : undefined); @@ -314,12 +310,6 @@ const createPayment = async (body, actorId = null) => { if (incomingTransactions?.length) { await createTransactionsForPayment(payment, incomingTransactions, actorId); - } else if (payment.dueDate && getPayableAmount(payment) > 0) { - await Transaction.create(buildTransactionPayload(payment, { - amount: getPayableAmount(payment), - status: 'pending', - dueDate: payment.dueDate - }, actorId)); } await refreshPaymentTotals(payment); @@ -416,12 +406,13 @@ const addTransaction = async (paymentId, trxData, actorId = null) => { userId: payment.user, actorId }); - await notifyTransactionRecorded(payment, transaction); + await notifyTransactionRecorded(payment, transaction, trxData); await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId); return getPaymentById(paymentId); }; + const updateTransaction = async (transactionId, body, actorId = null) => { const trx = await Transaction.findById(transactionId); if (!trx) throw new AppError('TRANSACTION_NOT_FOUND'); diff --git a/config/config.js b/config/config.js index 79a959f..05fd9cd 100644 --- a/config/config.js +++ b/config/config.js @@ -103,7 +103,7 @@ const config = { EMAIL_ENABLED: parseBool(process.env.EMAIL_ENABLED, true), // SMS Provider Settings (sms.ir) - SMS_ENABLED: parseBool(process.env.SMS_ENABLED, false), + SMS_ENABLED: parseBool(process.env.SMS_ENABLED, true), SMS_PANEL_TOKEN: process.env.SMS_PANEL_TOKEN || process.env.SMS_API_KEY || '', SMS_API_KEY: process.env.SMS_PANEL_TOKEN || process.env.SMS_API_KEY || 'mock_sms_key', SMS_SENDER_NUMBER: process.env.SMS_SENDER_NUMBER || '10001000', diff --git a/scripts/convert-class-file.js b/scripts/convert-class-file.js new file mode 100644 index 0000000..0bc4c6e --- /dev/null +++ b/scripts/convert-class-file.js @@ -0,0 +1,635 @@ +#!/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 +};