Files
gameno-api/components/dataImport/dataImportService.js
T

657 lines
22 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 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 }
});
}
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(targetRole._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;
}
let professorDoc = await Professor.findOne({ user: user._id });
if (!professorDoc) {
professorDoc = await Professor.findOne({
$or: [
{ nationalIdCode },
{ phoneNumber }
]
});
}
if (professorDoc) {
professorDoc.user = user._id;
if (profInput.bio && !professorDoc.bio) professorDoc.bio = profInput.bio;
await professorDoc.save();
stats.professorsUpdated += 1;
} else {
professorDoc = await Professor.create({
user: user._id,
bio: profInput.bio || undefined,
isActive: true
});
stats.professorsCreated += 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
};