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:
2026-08-16 07:04:16 +03:30
parent 72be01fee3
commit 3348ca5798
7 changed files with 445 additions and 37 deletions
+194 -35
View File
@@ -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();
}
}
+84
View File
@@ -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'
]);
});
});
-1
View File
@@ -41,7 +41,6 @@ const sessionSchema = new mongoose.Schema({
professor: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Professor',
required: true,
index: true
},
day: {