fix(payments): prevent auto transaction creation, enable sms by default, and fix transaction sms context
This commit is contained in:
@@ -41,7 +41,7 @@ const PAYMENT_STATUS_LABELS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatPaymentContext = async (payment) => {
|
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 courseName = '';
|
||||||
let schedule = { classStartDate: '', classDays: '', courseTime: '' };
|
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 {
|
try {
|
||||||
const notify = await resolveNotifyFlags({}, 'transactionRecorded');
|
const notify = await resolveNotifyFlags(source, 'transactionRecorded');
|
||||||
if (!notify.sms && !notify.email && !notify.bot) return;
|
if (!notify.sms && !notify.email && !notify.bot) return;
|
||||||
|
|
||||||
const { user, invoiceCode } = await formatPaymentContext(payment);
|
const { user, invoiceCode } = await formatPaymentContext(payment);
|
||||||
@@ -118,13 +118,15 @@ const notifyTransactionRecorded = async (payment, transaction) => {
|
|||||||
invoiceCode,
|
invoiceCode,
|
||||||
transactionCode: transaction.uniqueCode || '',
|
transactionCode: transaction.uniqueCode || '',
|
||||||
receiptNumber: transaction.receiptNumber || ''
|
receiptNumber: transaction.receiptNumber || ''
|
||||||
}, user._id)
|
}, user._id),
|
||||||
|
requestSource: source
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`[notifyTransactionRecorded] Failed for transaction ${transaction._id}: ${err.message}`);
|
logger.error(`[notifyTransactionRecorded] Failed for transaction ${transaction._id}: ${err.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const populatePayment = (query) => query
|
const populatePayment = (query) => query
|
||||||
.populate({ path: 'user', select: 'name phoneNumber' })
|
.populate({ path: 'user', select: 'name phoneNumber' })
|
||||||
.populate({ path: 'classes', select: 'name tuitionFee' })
|
.populate({ path: 'classes', select: 'name tuitionFee' })
|
||||||
@@ -195,16 +197,10 @@ const reconcilePendingTransactions = async (payment, { adjustAmount = true } = {
|
|||||||
if (pending.length > 1) {
|
if (pending.length > 1) {
|
||||||
await Transaction.deleteMany({ _id: { $in: pending.slice(1).map((row) => row._id) } });
|
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 buildTransactionPayload = (payment, trxData, actorId = null) => {
|
||||||
const status = trxData.status === 'pending' ? 'pending' : (trxData.status === 'paid' || trxData.date ? 'paid' : 'pending');
|
const status = trxData.status === 'pending' ? 'pending' : (trxData.status === 'paid' || trxData.date ? 'paid' : 'pending');
|
||||||
const paidDate = parseDate(trxData.date) || (status === 'paid' ? new Date() : undefined);
|
const paidDate = parseDate(trxData.date) || (status === 'paid' ? new Date() : undefined);
|
||||||
@@ -314,12 +310,6 @@ const createPayment = async (body, actorId = null) => {
|
|||||||
|
|
||||||
if (incomingTransactions?.length) {
|
if (incomingTransactions?.length) {
|
||||||
await createTransactionsForPayment(payment, incomingTransactions, actorId);
|
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);
|
await refreshPaymentTotals(payment);
|
||||||
@@ -416,12 +406,13 @@ const addTransaction = async (paymentId, trxData, actorId = null) => {
|
|||||||
userId: payment.user,
|
userId: payment.user,
|
||||||
actorId
|
actorId
|
||||||
});
|
});
|
||||||
await notifyTransactionRecorded(payment, transaction);
|
await notifyTransactionRecorded(payment, transaction, trxData);
|
||||||
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
|
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
|
||||||
|
|
||||||
return getPaymentById(paymentId);
|
return getPaymentById(paymentId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const updateTransaction = async (transactionId, body, actorId = null) => {
|
const updateTransaction = async (transactionId, body, actorId = null) => {
|
||||||
const trx = await Transaction.findById(transactionId);
|
const trx = await Transaction.findById(transactionId);
|
||||||
if (!trx) throw new AppError('TRANSACTION_NOT_FOUND');
|
if (!trx) throw new AppError('TRANSACTION_NOT_FOUND');
|
||||||
|
|||||||
+1
-1
@@ -103,7 +103,7 @@ const config = {
|
|||||||
EMAIL_ENABLED: parseBool(process.env.EMAIL_ENABLED, true),
|
EMAIL_ENABLED: parseBool(process.env.EMAIL_ENABLED, true),
|
||||||
|
|
||||||
// SMS Provider Settings (sms.ir)
|
// 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_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_API_KEY: process.env.SMS_PANEL_TOKEN || process.env.SMS_API_KEY || 'mock_sms_key',
|
||||||
SMS_SENDER_NUMBER: process.env.SMS_SENDER_NUMBER || '10001000',
|
SMS_SENDER_NUMBER: process.env.SMS_SENDER_NUMBER || '10001000',
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user