// /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 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 } = 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 })) { i += 1; candidate = `${base.slice(0, 7)}${String(i).padStart(3, '0')}`.slice(0, 10); } return candidate; }; const findExistingUser = async ({ nationalIdCode, phoneNumber, name }) => { if (nationalIdCode) { const byId = await User.findOne({ nationalIdCode }); if (byId) return byId; } if (phoneNumber) { const byPhone = await User.findOne({ phoneNumber }); if (byPhone) return byPhone; } 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; } 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) => { const 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 (!phoneNumber) { warnings.push({ reason: 'missing_phone', student: { name: student.name, nationalIdCode } }); stats.studentsSkipped += 1; return null; } if (!nationalIdCode) { nationalIdCode = await allocatePlaceholderNationalId(phoneNumber); warnings.push({ reason: 'placeholder_national_id', student: { name: student.name, phoneNumber, nationalIdCode } }); } const conflictById = await User.findOne({ nationalIdCode }); if (conflictById) { applyStudentProfile(conflictById, student); await conflictById.save(); stats.studentsUpdated += 1; return conflictById; } 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 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 = 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)) 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 importData = async (rawPayload) => { const payload = normalizeImportPayload(rawPayload); if (!payload || !Array.isArray(payload.courses)) { throw new AppError('VALIDATION_FAILED', null, 'Invalid import payload: courses array required'); } const userRole = await Role.findOne({ name: 'User' }); if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND'); await migrateEmbeddedPaymentTransactions(); const stats = { 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 = []; for (const courseInput of payload.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: Array.isArray(classInput.days) ? 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 };