feat: import class sessions, attendance, and payments from JSON
Match students by name when national ID or phone is missing, parse Jalali dates, and allow sessions without a professor.
This commit is contained in:
@@ -6,27 +6,24 @@ 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 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 toEnglishDigits = (value) =>
|
||||
String(value ?? '')
|
||||
.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d))
|
||||
.replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d));
|
||||
const ATTENDANCE_STATUSES = new Set(['present', 'absent', 'late', 'excused']);
|
||||
const PAYMENT_METHODS = new Set(['online', 'card', 'cash']);
|
||||
|
||||
const normalizePhone = (raw) => {
|
||||
if (raw == null || raw === '') return '';
|
||||
let digits = toEnglishDigits(raw).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}`;
|
||||
return digits;
|
||||
};
|
||||
|
||||
const normalizeNationalId = (raw) => {
|
||||
if (raw == null || raw === '') return '';
|
||||
return toEnglishDigits(raw).replace(/\D/g, '');
|
||||
};
|
||||
const escapeRegex = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const allocateUniqueUsername = async () => {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
@@ -47,7 +44,7 @@ const allocatePlaceholderNationalId = async (phoneNumber) => {
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const findExistingUser = async ({ nationalIdCode, phoneNumber }) => {
|
||||
const findExistingUser = async ({ nationalIdCode, phoneNumber, name }) => {
|
||||
if (nationalIdCode) {
|
||||
const byId = await User.findOne({ nationalIdCode });
|
||||
if (byId) return byId;
|
||||
@@ -56,6 +53,15 @@ const findExistingUser = async ({ nationalIdCode, 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;
|
||||
};
|
||||
|
||||
@@ -100,7 +106,7 @@ const upsertStudent = async (student, userRole, stats, warnings) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
let user = await findExistingUser({ nationalIdCode, phoneNumber });
|
||||
let user = await findExistingUser({ nationalIdCode, phoneNumber, name: student.name });
|
||||
if (user) {
|
||||
applyStudentProfile(user, student);
|
||||
if (phoneNumber && !user.phoneNumber) user.phoneNumber = phoneNumber;
|
||||
@@ -126,7 +132,6 @@ const upsertStudent = async (student, userRole, stats, warnings) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Collision: national id exists with different phone, or reverse
|
||||
const conflictById = await User.findOne({ nationalIdCode });
|
||||
if (conflictById) {
|
||||
applyStudentProfile(conflictById, student);
|
||||
@@ -164,6 +169,159 @@ const upsertStudent = async (student, userRole, stats, warnings) => {
|
||||
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 : [])
|
||||
.map((trx) => ({
|
||||
amount: Number(trx.amount) || 0,
|
||||
method: PAYMENT_METHODS.has(trx.method) ? trx.method : 'card',
|
||||
receiptNumber: trx.receiptNumber != null && trx.receiptNumber !== '' ? String(trx.receiptNumber) : '',
|
||||
notes: trx.notes || '',
|
||||
date: parseImportDate(trx.date) || new Date()
|
||||
}))
|
||||
.filter((trx) => trx.amount > 0);
|
||||
|
||||
const payload = {
|
||||
user: user._id,
|
||||
classes: [cls._id],
|
||||
course: course._id,
|
||||
amount,
|
||||
discount: Number(paymentInput.discount) || 0,
|
||||
paidAmount: transactions.reduce((sum, trx) => sum + trx.amount, 0),
|
||||
transactions,
|
||||
notes: paymentInput.notes || '',
|
||||
dueDate: parseImportDate(paymentInput.dueDate) || undefined
|
||||
};
|
||||
|
||||
const existing = await Payment.findOne({ user: user._id, classes: cls._id });
|
||||
if (!existing) {
|
||||
await Payment.create(payload);
|
||||
stats.paymentsCreated += 1;
|
||||
} else if (!existing.transactions?.length) {
|
||||
Object.assign(existing, payload);
|
||||
await existing.save();
|
||||
stats.paymentsUpdated += 1;
|
||||
} else {
|
||||
stats.paymentsReused += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const importData = async (payload) => {
|
||||
if (!payload || !Array.isArray(payload.courses)) {
|
||||
throw new AppError('VALIDATION_FAILED', null, 'Invalid import payload: courses array required');
|
||||
@@ -180,7 +338,15 @@ const importData = async (payload) => {
|
||||
studentsCreated: 0,
|
||||
studentsUpdated: 0,
|
||||
studentsSkipped: 0,
|
||||
enrollmentsAdded: 0
|
||||
enrollmentsAdded: 0,
|
||||
sessionsCreated: 0,
|
||||
sessionsReused: 0,
|
||||
attendanceRecords: 0,
|
||||
attendanceSkipped: 0,
|
||||
paymentsCreated: 0,
|
||||
paymentsUpdated: 0,
|
||||
paymentsReused: 0,
|
||||
paymentsSkipped: 0
|
||||
};
|
||||
const warnings = [];
|
||||
|
||||
@@ -217,35 +383,28 @@ const importData = async (payload) => {
|
||||
cls = await Class.create({
|
||||
name: className,
|
||||
course: course._id,
|
||||
startDate: classInput.startDate ? new Date(classInput.startDate) : undefined,
|
||||
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;
|
||||
if (!cls.startDate && classInput.startDate) {
|
||||
cls.startDate = new Date(classInput.startDate);
|
||||
await cls.save();
|
||||
}
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
await enrollUserInClass(user, course, cls, stats);
|
||||
}
|
||||
|
||||
await importSessions(classInput, course, cls, stats, warnings);
|
||||
await importPayments(classInput, course, cls, stats, warnings);
|
||||
await cls.save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
const { toEnglishDigits } = require('../../utils/jalaliDate');
|
||||
|
||||
const TITLE_PREFIX = /^(آقای|اقای|خانم|آقا)\s+/;
|
||||
const HONORIFIC_MIDDLE = /\s+خ\s+/g;
|
||||
|
||||
const normalizePersonName = (value) => {
|
||||
const text = String(value ?? '')
|
||||
.replace(/ي/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(/[\u200c\u200d]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(TITLE_PREFIX, '')
|
||||
.replace(HONORIFIC_MIDDLE, ' ')
|
||||
.replace(/^خ\s+/, '')
|
||||
.trim();
|
||||
return text;
|
||||
};
|
||||
|
||||
const namesMatch = (left, right) => {
|
||||
const a = normalizePersonName(left);
|
||||
const b = normalizePersonName(right);
|
||||
return Boolean(a) && a === b;
|
||||
};
|
||||
|
||||
const normalizePhone = (raw) => {
|
||||
if (raw == null || raw === '') return '';
|
||||
let digits = toEnglishDigits(raw).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}`;
|
||||
return digits;
|
||||
};
|
||||
|
||||
const normalizeNationalId = (raw) => {
|
||||
if (raw == null || raw === '') return '';
|
||||
return toEnglishDigits(raw).replace(/\D/g, '');
|
||||
};
|
||||
|
||||
const mapAttendanceStatus = (raw) => {
|
||||
const value = String(raw ?? '').trim();
|
||||
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 projectSessionDates = (existingIsoDates, totalCount) => {
|
||||
const dates = [...existingIsoDates].filter(Boolean).sort();
|
||||
if (!dates.length || dates.length >= totalCount) return dates.slice(0, totalCount);
|
||||
|
||||
const weekdaySet = new Set(dates.map((iso) => new Date(`${iso}T12:00:00.000Z`).getUTCDay()));
|
||||
const last = new Date(`${dates[dates.length - 1]}T12:00:00.000Z`);
|
||||
const projected = [...dates];
|
||||
const cursor = new Date(last);
|
||||
|
||||
while (projected.length < totalCount) {
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
if (weekdaySet.has(cursor.getUTCDay())) {
|
||||
projected.push(cursor.toISOString().slice(0, 10));
|
||||
}
|
||||
}
|
||||
return projected;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
normalizePersonName,
|
||||
namesMatch,
|
||||
normalizePhone,
|
||||
normalizeNationalId,
|
||||
mapAttendanceStatus,
|
||||
projectSessionDates
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
normalizePersonName,
|
||||
namesMatch,
|
||||
normalizePhone,
|
||||
mapAttendanceStatus,
|
||||
projectSessionDates
|
||||
} = require('./importHelpers');
|
||||
|
||||
describe('importHelpers', () => {
|
||||
it('normalizes honorifics so spreadsheet names match database users', () => {
|
||||
assert.equal(normalizePersonName('مریم خ پناهیان'), 'مریم پناهیان');
|
||||
assert.ok(namesMatch('آقای رضایی', 'آقای رضایی'));
|
||||
assert.equal(normalizePhone(9168942141), '09168942141');
|
||||
});
|
||||
|
||||
it('maps spreadsheet attendance marks', () => {
|
||||
assert.equal(mapAttendanceStatus('*'), 'present');
|
||||
assert.equal(mapAttendanceStatus('غ'), 'absent');
|
||||
assert.equal(mapAttendanceStatus(''), null);
|
||||
});
|
||||
|
||||
it('projects remaining Teksa sessions from Sunday/Tuesday pattern', () => {
|
||||
const firstNine = [
|
||||
'2026-07-14',
|
||||
'2026-07-19',
|
||||
'2026-07-21',
|
||||
'2026-07-26',
|
||||
'2026-07-28',
|
||||
'2026-08-02',
|
||||
'2026-08-04',
|
||||
'2026-08-09',
|
||||
'2026-08-11'
|
||||
];
|
||||
const all = projectSessionDates(firstNine, 16);
|
||||
assert.equal(all.length, 16);
|
||||
assert.deepEqual(all.slice(0, 9), firstNine);
|
||||
assert.deepEqual(all.slice(9), [
|
||||
'2026-08-16',
|
||||
'2026-08-18',
|
||||
'2026-08-23',
|
||||
'2026-08-25',
|
||||
'2026-08-30',
|
||||
'2026-09-01',
|
||||
'2026-09-06'
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,6 @@ const sessionSchema = new mongoose.Schema({
|
||||
professor: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Professor',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
day: {
|
||||
|
||||
Reference in New Issue
Block a user