// /components/dataImport/dataImportService.js 'use strict'; const bcrypt = require('bcryptjs'); const Course = require('../courses/courseModel'); const Class = require('../classes/classModel'); const User = require('../users/userModel'); const Professor = require('../professors/professorModel'); const Role = require('../roles/roleModel'); const Session = require('../sessions/sessionModel'); const Payment = require('../payments/paymentModel'); const Transaction = require('../payments/transactionModel'); const paymentService = require('../payments/paymentService'); const AppError = require('../../utils/AppError'); const { generateUsername, generateSimplePassword } = require('../../utils/credentials'); const { mergeFullName, normalizeGender } = require('../../utils/userProfile'); const { parseImportDate, utcDayRange, toEnglishDigits } = require('../../utils/jalaliDate'); const { namesMatch, normalizePersonName, normalizePhone, normalizeNationalId, mapAttendanceStatus } = require('./importHelpers'); const ATTENDANCE_STATUSES = new Set(['present', 'absent', 'late', 'excused']); const escapeRegex = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const allocateUniqueUsername = async () => { for (let attempt = 0; attempt < 20; attempt += 1) { const username = generateUsername(); if (!(await User.exists({ username }))) return username; } throw new AppError('INTERNAL_SERVER_ERROR', null, 'Could not generate a unique username'); }; const allocatePlaceholderNationalId = async (phoneNumber) => { const base = `TMP${(phoneNumber || '').replace(/\D/g, '').slice(-10) || Date.now().toString().slice(-10)}`; let candidate = base.padEnd(10, '0').slice(0, 10); let i = 0; while ((await User.exists({ nationalIdCode: candidate })) || (await Professor.exists({ nationalIdCode: candidate }))) { i += 1; candidate = `${base.slice(0, 7)}${String(i).padStart(3, '0')}`.slice(0, 10); } return candidate; }; const allocatePlaceholderPhone = async (seed) => { const baseDigits = (seed || Date.now().toString()).replace(/\D/g, ''); const suffix = baseDigits.slice(-7).padStart(7, '0'); let candidate = `0999${suffix}`.slice(0, 11); let i = 0; while ((await User.exists({ phoneNumber: candidate })) || (await Professor.exists({ phoneNumber: candidate }))) { i += 1; candidate = `0999${String(i).padStart(7, '0')}`.slice(0, 11); } return candidate; }; const findExistingUser = async ({ nationalIdCode, phoneNumber, name }) => { if (nationalIdCode) { const byId = await User.findOne({ nationalIdCode }); if (byId) return byId; } const target = normalizePersonName(name); if (target) { const firstToken = target.split(' ')[0]; const candidates = await User.find({ name: new RegExp(escapeRegex(firstToken), 'i') }).limit(50); const match = candidates.find((user) => namesMatch(user.name, target)); if (match) return match; } if (phoneNumber) { const byPhone = await User.findOne({ phoneNumber }); if (byPhone) { if (!nationalIdCode || !byPhone.nationalIdCode || byPhone.nationalIdCode === nationalIdCode) { return byPhone; } } } return null; }; const applyStudentProfile = (user, student) => { const name = mergeFullName(student.name, student.surname); if (name) user.name = name; const gender = normalizeGender(student.gender); if (gender) user.gender = gender; const optionalFields = [ 'address', 'birthCertificateNumber', 'postalCode', 'placeOfIssue', 'fatherName', 'education', 'parentPhoneNumber' ]; for (const field of optionalFields) { if (student[field] && !user[field]) { user[field] = String(student[field]).trim(); } } if (student.birthDate && !user.birthDate) { const date = new Date(student.birthDate); if (!Number.isNaN(date.getTime())) user.birthDate = date; } }; const upsertStudent = async (student, userRole, stats, warnings) => { let phoneNumber = normalizePhone(student.phoneNumber || student.phone || student.parentPhoneNumber); let nationalIdCode = normalizeNationalId(student.nationalIdCode || student.nationalId); if (!phoneNumber && !nationalIdCode) { warnings.push({ reason: 'missing_identity', student: { name: student.name } }); stats.studentsSkipped += 1; return null; } let user = await findExistingUser({ nationalIdCode, phoneNumber, name: student.name }); if (user) { applyStudentProfile(user, student); if (phoneNumber && !user.phoneNumber) user.phoneNumber = phoneNumber; await user.save(); stats.studentsUpdated += 1; return user; } if (!nationalIdCode) { nationalIdCode = await allocatePlaceholderNationalId(phoneNumber || student.name); warnings.push({ reason: 'placeholder_national_id', student: { name: student.name, phoneNumber, nationalIdCode } }); } if (!phoneNumber) { phoneNumber = await allocatePlaceholderPhone(nationalIdCode || student.name); warnings.push({ reason: 'placeholder_phone', student: { name: student.name, nationalIdCode, placeholderPhone: phoneNumber } }); } const conflictById = await User.findOne({ nationalIdCode }); if (conflictById) { applyStudentProfile(conflictById, student); await conflictById.save(); stats.studentsUpdated += 1; return conflictById; } const conflictByPhone = await User.findOne({ phoneNumber }); if (conflictByPhone) { const originalPhone = phoneNumber; phoneNumber = await allocatePlaceholderPhone(nationalIdCode || student.name); if (!student.parentPhoneNumber) student.parentPhoneNumber = originalPhone; warnings.push({ reason: 'phone_conflict_placeholder_allocated', student: { name: student.name, originalPhone, placeholderPhone: phoneNumber } }); } const username = await allocateUniqueUsername(); const plainPassword = generateSimplePassword(); const passwordHash = await bcrypt.hash(plainPassword, 10); user = await User.create({ name: mergeFullName(student.name, student.surname) || `کاربر ${phoneNumber}`, gender: normalizeGender(student.gender), nationalIdCode, phoneNumber, address: student.address, birthCertificateNumber: student.birthCertificateNumber, postalCode: student.postalCode, placeOfIssue: student.placeOfIssue, fatherName: student.fatherName, birthDate: student.birthDate ? new Date(student.birthDate) : undefined, education: student.education, parentPhoneNumber: student.parentPhoneNumber ? normalizePhone(student.parentPhoneNumber) : undefined, username, passwordHash, role: userRole._id, preferredMessenger: ['SMS'] }); stats.studentsCreated += 1; return user; }; const enrollUserInClass = async (user, course, cls, stats) => { if (!user.courses.map(String).includes(String(course._id))) { user.courses.push(course._id); await user.save(); } if (!cls.students.map(String).includes(String(user._id))) { cls.students.push(user._id); stats.enrollmentsAdded += 1; } }; const WEEKDAY_STR_TO_NUM = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, 'یکشنبه': 0, 'یک شنبه': 0, 'دوشنبه': 1, 'دو شنبه': 1, 'سه شنبه': 2, 'سه‌شنبه': 2, 'سهشنبه': 2, 'چهارشنبه': 3, 'چهار شنبه': 3, 'پنجشنبه': 4, 'پنج شنبه': 4, 'جمعه': 5, 'شنبه': 6 }; const normalizeWeekdaysInput = (days) => { if (!Array.isArray(days)) return []; const res = []; for (const d of days) { if (typeof d === 'number' && d >= 0 && d <= 6) { if (!res.includes(d)) res.push(d); } else if (typeof d === 'string') { const lower = d.trim().toLowerCase(); if (WEEKDAY_STR_TO_NUM[lower] != null) { const num = WEEKDAY_STR_TO_NUM[lower]; if (!res.includes(num)) res.push(num); } else if (!Number.isNaN(Number(d))) { const num = Number(d); if (num >= 0 && num <= 6 && !res.includes(num)) res.push(num); } } } return res; }; const applyClassSchedule = (cls, classInput) => { if (classInput.startDate && !cls.startDate) { const startDate = parseImportDate(classInput.startDate); if (startDate) cls.startDate = startDate; } if (Array.isArray(classInput.days) && classInput.days.length) { cls.days = normalizeWeekdaysInput(classInput.days); } if (classInput.startTime) cls.startTime = String(classInput.startTime).trim(); if (classInput.endTime) cls.endTime = String(classInput.endTime).trim(); if (classInput.tuitionFee != null && !cls.tuitionFee) { cls.tuitionFee = Number(classInput.tuitionFee) || 0; } }; const resolveLinkedUser = async (record, warnings, reason, statsSkipKey, stats) => { const phoneNumber = normalizePhone(record.phoneNumber || record.phone); const nationalIdCode = normalizeNationalId(record.nationalIdCode || record.nationalId); const user = await findExistingUser({ nationalIdCode, phoneNumber, name: record.name }); if (user) return user; warnings.push({ reason, student: { name: record.name, phoneNumber, nationalIdCode } }); stats[statsSkipKey] += 1; return null; }; const importSessions = async (classInput, course, cls, stats, warnings) => { const sessions = Array.isArray(classInput.sessions) ? classInput.sessions : []; const defaultStart = classInput.startTime || cls.startTime || '19:00'; const defaultEnd = classInput.endTime || cls.endTime || '20:30'; for (const sessionInput of sessions) { const range = utcDayRange(sessionInput.day || sessionInput.date); if (!range) { warnings.push({ reason: 'invalid_session_date', session: { topic: sessionInput.topic, day: sessionInput.day } }); continue; } const attendanceSource = Array.isArray(sessionInput.attendance) ? sessionInput.attendance : (sessionInput.attendanceList || []); const attendanceList = []; for (const record of attendanceSource) { const mapped = mapAttendanceStatus(record.status) || String(record.status || '').trim(); if (!ATTENDANCE_STATUSES.has(mapped)) continue; const user = await resolveLinkedUser(record, warnings, 'attendance_user_not_found', 'attendanceSkipped', stats); if (!user) continue; await enrollUserInClass(user, course, cls, stats); attendanceList.push({ user: user._id, status: mapped, note: record.note || '' }); stats.attendanceRecords += 1; } const payload = { course: course._id, class: cls._id, day: range.start, startTime: sessionInput.startTime || defaultStart, endTime: sessionInput.endTime || defaultEnd, topic: sessionInput.topic || '', place: sessionInput.place || '', note: sessionInput.note || '', status: sessionInput.status || (attendanceList.length ? 'held' : 'scheduled'), attendanceList }; if (cls.professor) payload.professor = cls.professor; let session = await Session.findOne({ class: cls._id, day: { $gte: range.start, $lt: range.end } }); if (!session) { await Session.create(payload); stats.sessionsCreated += 1; } else { Object.assign(session, payload); await session.save(); stats.sessionsReused += 1; } } }; const importPayments = async (classInput, course, cls, stats, warnings) => { const payments = Array.isArray(classInput.payments) ? classInput.payments : []; for (const paymentInput of payments) { const amount = Number(paymentInput.amount) || 0; if (!amount) { stats.paymentsSkipped += 1; continue; } const user = await resolveLinkedUser(paymentInput, warnings, 'payment_user_not_found', 'paymentsSkipped', stats); if (!user) continue; await enrollUserInClass(user, course, cls, stats); const transactions = Array.isArray(paymentInput.transactions) ? paymentInput.transactions : []; const pendingDue = transactions.find((trx) => trx.status === 'pending')?.dueDate; const payload = { user: user._id, classes: [cls._id], course: course._id, amount, discount: Number(paymentInput.discount) || 0, paidAmount: 0, notes: paymentInput.notes || '', dueDate: parseImportDate(paymentInput.dueDate) || parseImportDate(pendingDue) || undefined }; const existing = await Payment.findOne({ user: user._id, classes: cls._id }); const existingTrxCount = existing ? await Transaction.countDocuments({ payment: existing._id }) : 0; if (!existing) { const payment = await Payment.create(payload); const created = await paymentService.createTransactionsForPayment(payment, transactions); await paymentService.refreshPaymentTotals(payment); stats.paymentsCreated += 1; stats.transactionsCreated += created.length; } else if (existingTrxCount === 0) { Object.assign(existing, payload); await existing.save(); const created = await paymentService.createTransactionsForPayment(existing, transactions); await paymentService.refreshPaymentTotals(existing); stats.paymentsUpdated += 1; stats.transactionsCreated += created.length; } else { stats.paymentsReused += 1; } } }; const normalizeImportPayload = (payload) => { if (!payload || Array.isArray(payload.courses) || Array.isArray(payload.professors)) return payload; if (!Array.isArray(payload.payments)) return payload; const useTopLevelTransactions = Array.isArray(payload.transactions); const payments = payload.payments.map((payment, index) => { const id = String(payment.id || payment.paymentId || payment.phoneNumber || index); return { ...payment, id, transactions: useTopLevelTransactions ? [] : (Array.isArray(payment.transactions) ? payment.transactions : []) }; }); const byId = new Map(payments.map((payment) => [payment.id, payment])); if (useTopLevelTransactions) { for (const trx of payload.transactions) { const payment = byId.get(String(trx.paymentId || trx.payment || '')); if (payment) payment.transactions.push(trx); } } return { ...payload, courses: [{ title: String(payload.courseTitle || payload.course || '').trim(), type: 'General', classes: [{ name: String(payload.className || payload.class || '').trim(), payments }] }] }; }; const migrateEmbeddedPaymentTransactions = async () => { const docs = await Payment.collection.find({ 'transactions.0': { $exists: true } }).toArray(); for (const doc of docs) { const existingCount = await Transaction.countDocuments({ payment: doc._id }); if (existingCount === 0) { await paymentService.createTransactionsForPayment( { _id: doc._id, user: doc.user, dueDate: doc.dueDate }, (doc.transactions || []).map((trx) => ({ ...trx, status: trx.status || 'paid', dueDate: trx.dueDate || trx.date || doc.dueDate })) ); const payment = await Payment.findById(doc._id); if (payment) await paymentService.refreshPaymentTotals(payment); } await Payment.collection.updateOne({ _id: doc._id }, { $unset: { transactions: 1 } }); } }; const upsertProfessorAndUser = async (profInput, professorRole, userRole, stats, warnings) => { const name = normalizePersonName(profInput.name); const surname = normalizePersonName(profInput.surname); const fullName = mergeFullName(name, surname); let phoneNumber = normalizePhone(profInput.phoneNumber || profInput.phone); let nationalIdCode = normalizeNationalId(profInput.nationalIdCode || profInput.nationalId); const cardNumber = profInput.cardNumber ? String(profInput.cardNumber).replace(/\D/g, '').trim() : undefined; const shabaNumber = (profInput.shabaNumber || profInput.iban) ? String(profInput.shabaNumber || profInput.iban).replace(/[^0-9A-Za-z]/g, '').trim() : undefined; const email = profInput.email ? String(profInput.email).trim().toLowerCase() : undefined; if (!fullName && !phoneNumber && !nationalIdCode) { warnings.push({ reason: 'missing_identity', professor: profInput }); stats.professorsSkipped += 1; return null; } if (!nationalIdCode) { nationalIdCode = await allocatePlaceholderNationalId(phoneNumber || name); warnings.push({ reason: 'placeholder_national_id', professor: { name: fullName, phoneNumber, nationalIdCode } }); } if (!phoneNumber) { phoneNumber = await allocatePlaceholderPhone(nationalIdCode || name); warnings.push({ reason: 'placeholder_phone', professor: { name: fullName, phoneNumber, nationalIdCode } }); } let existingProf = null; if (nationalIdCode) { existingProf = await Professor.findOne({ nationalIdCode }); } if (!existingProf && phoneNumber) { existingProf = await Professor.findOne({ phoneNumber }); } if (!existingProf && fullName) { const allProfs = await Professor.find({}); existingProf = allProfs.find((p) => namesMatch(`${p.name} ${p.surname}`, fullName) || namesMatch(p.name, name)); } let professorDoc; if (existingProf) { if (name) existingProf.name = name; if (surname) existingProf.surname = surname; if (cardNumber) existingProf.cardNumber = cardNumber; if (shabaNumber) existingProf.shabaNumber = shabaNumber; if (email && !existingProf.email) existingProf.email = email; if (profInput.bio && !existingProf.bio) existingProf.bio = profInput.bio; await existingProf.save(); professorDoc = existingProf; stats.professorsUpdated += 1; } else { professorDoc = await Professor.create({ name: name || fullName, surname: surname || '', nationalIdCode, phoneNumber, cardNumber: cardNumber || undefined, shabaNumber: shabaNumber || undefined, email: email || undefined, bio: profInput.bio || undefined, isActive: true }); stats.professorsCreated += 1; } const targetRole = professorRole || userRole; let user = await findExistingUser({ nationalIdCode, phoneNumber, name: fullName }); if (user) { if (fullName && !user.name) user.name = fullName; if (cardNumber && !user.cardNumber) user.cardNumber = cardNumber; if (shabaNumber && !user.shabaNumber) user.shabaNumber = shabaNumber; if (email && !user.email) user.email = email; if (targetRole && String(user.role) === String(userRole?._id)) { user.role = targetRole._id; } await user.save(); stats.studentsUpdated += 1; } else { const username = await allocateUniqueUsername(); const plainPassword = generateSimplePassword(); const passwordHash = await bcrypt.hash(plainPassword, 10); user = await User.create({ name: fullName || `استاد ${phoneNumber}`, nationalIdCode, phoneNumber, cardNumber: cardNumber || undefined, shabaNumber: shabaNumber || undefined, email: email || undefined, username, passwordHash, role: targetRole._id, preferredMessenger: ['SMS'], isActive: true }); stats.studentsCreated += 1; } return { professor: professorDoc, user }; }; const importData = async (rawPayload) => { const payload = normalizeImportPayload(rawPayload); if (!payload || (!Array.isArray(payload.courses) && !Array.isArray(payload.professors))) { throw new AppError('VALIDATION_FAILED', null, 'Invalid import payload: courses or professors array required'); } const userRole = await Role.findOne({ name: 'User' }); if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND'); let professorRole = await Role.findOne({ name: 'Professor' }); if (!professorRole) { professorRole = userRole; } await migrateEmbeddedPaymentTransactions(); const stats = { professorsCreated: 0, professorsUpdated: 0, professorsSkipped: 0, coursesCreated: 0, coursesReused: 0, classesCreated: 0, classesReused: 0, studentsCreated: 0, studentsUpdated: 0, studentsSkipped: 0, enrollmentsAdded: 0, sessionsCreated: 0, sessionsReused: 0, attendanceRecords: 0, attendanceSkipped: 0, paymentsCreated: 0, paymentsUpdated: 0, paymentsReused: 0, paymentsSkipped: 0, transactionsCreated: 0 }; const warnings = []; const professors = Array.isArray(payload.professors) ? payload.professors : []; for (const profInput of professors) { await upsertProfessorAndUser(profInput, professorRole, userRole, stats, warnings); } const courses = Array.isArray(payload.courses) ? payload.courses : []; for (const courseInput of courses) { const title = String(courseInput.title || '').trim(); if (!title) continue; const type = courseInput.type === 'Private' ? 'Private' : 'General'; let course = await Course.findOne({ title }); if (!course) { course = await Course.create({ title, type, price: Number(courseInput.price) || 0, showOnFrontend: false, description: courseInput.description || '' }); stats.coursesCreated += 1; } else { stats.coursesReused += 1; if (type === 'Private' && course.type !== 'Private') { course.type = 'Private'; await course.save(); } } const classes = Array.isArray(courseInput.classes) ? courseInput.classes : []; for (const classInput of classes) { const className = String(classInput.name || '').trim(); if (!className) continue; let cls = await Class.findOne({ name: className, course: course._id }); if (!cls) { cls = await Class.create({ name: className, course: course._id, startDate: parseImportDate(classInput.startDate) || undefined, tuitionFee: Number(classInput.tuitionFee) || Number(courseInput.price) || 0, days: normalizeWeekdaysInput(classInput.days), startTime: classInput.startTime || '', endTime: classInput.endTime || '', isActive: true }); stats.classesCreated += 1; } else { stats.classesReused += 1; applyClassSchedule(cls, classInput); } const students = Array.isArray(classInput.students) ? classInput.students : []; for (const student of students) { const user = await upsertStudent(student, userRole, stats, warnings); if (!user) continue; await enrollUserInClass(user, course, cls, stats); } await importSessions(classInput, course, cls, stats, warnings); await importPayments(classInput, course, cls, stats, warnings); await cls.save(); } } return { stats, warnings }; }; module.exports = { importData };