Move embedded payment rows into Transaction documents with due dates so remaining tuition can be tracked separately from paid amounts.
479 lines
16 KiB
JavaScript
479 lines
16 KiB
JavaScript
// /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
|
|
};
|