654 lines
23 KiB
JavaScript
654 lines
23 KiB
JavaScript
// /components/professors/professorService.js
|
|
'use strict';
|
|
|
|
const mongoose = require('mongoose');
|
|
const Professor = require('./professorModel');
|
|
const User = require('../users/userModel');
|
|
const Role = require('../roles/roleModel');
|
|
const Class = require('../classes/classModel');
|
|
const Session = require('../sessions/sessionModel');
|
|
const Payment = require('../payments/paymentModel');
|
|
const AppError = require('../../utils/AppError');
|
|
const eventEmitter = require('../../events/eventEmitter');
|
|
const EVENT_NAMES = require('../../constants/eventNames');
|
|
const { parsePaginationAndSort, calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
|
|
const { allocatePlaceholderNationalId } = require('../../utils/nationalId');
|
|
const { generateUsername, generateSimplePassword } = require('../../utils/credentials');
|
|
const bcrypt = require('bcryptjs');
|
|
const {
|
|
resolveSessionDurationHours,
|
|
calculateProfessorPayout,
|
|
calculateHourlyShare,
|
|
calculatePercentageShare
|
|
} = require('../../utils/professorShare');
|
|
const { formatJalali } = require('../../utils/jalaliDate');
|
|
|
|
const ensureProfessorRole = async () => {
|
|
let role = await Role.findOne({ name: 'Professor' });
|
|
if (!role) {
|
|
role = await Role.create({
|
|
name: 'Professor',
|
|
description: 'استاد و مدرس دورهها',
|
|
permissions: ['professors:read', 'classes:read', 'sessions:read', 'attendances:read'],
|
|
isSystem: true
|
|
});
|
|
}
|
|
return role;
|
|
};
|
|
|
|
const formatProfessorDoc = (doc) => {
|
|
if (!doc) return null;
|
|
const raw = doc.toObject ? doc.toObject({ virtuals: true }) : doc;
|
|
const user = raw.user && typeof raw.user === 'object' ? raw.user : null;
|
|
|
|
const rawName = String(user?.name || raw.name || '').trim();
|
|
const parts = rawName.split(/\s+/);
|
|
let firstName = rawName;
|
|
let surname = '';
|
|
if (parts.length > 1) {
|
|
firstName = parts[0];
|
|
surname = parts.slice(1).join(' ');
|
|
}
|
|
|
|
return {
|
|
_id: raw._id,
|
|
id: raw._id,
|
|
user: user ? (user._id || user) : raw.user,
|
|
userId: user ? (user._id || user) : raw.user,
|
|
name: firstName,
|
|
surname: surname || raw.surname || '',
|
|
fullName: rawName,
|
|
nationalIdCode: user?.nationalIdCode || raw.nationalIdCode || '',
|
|
nationalId: user?.nationalIdCode || raw.nationalIdCode || '',
|
|
phoneNumber: user?.phoneNumber || raw.phoneNumber || '',
|
|
phone: user?.phoneNumber || raw.phoneNumber || '',
|
|
email: user?.email || raw.email || '',
|
|
cardNumber: user?.cardNumber || raw.cardNumber || '',
|
|
shabaNumber: user?.shabaNumber || raw.shabaNumber || '',
|
|
bio: raw.bio || '',
|
|
expertise: Array.isArray(raw.expertise) ? raw.expertise : [],
|
|
courses: raw.courses || [],
|
|
isActive: raw.isActive !== false && (user ? user.isActive !== false : true),
|
|
createdAt: raw.createdAt,
|
|
updatedAt: raw.updatedAt
|
|
};
|
|
};
|
|
|
|
const createProfessor = async (data) => {
|
|
const professorRole = await ensureProfessorRole();
|
|
let user = null;
|
|
|
|
const rawUserId = data.userId || data.user || data.id;
|
|
if (rawUserId && mongoose.Types.ObjectId.isValid(rawUserId)) {
|
|
user = await User.findById(rawUserId);
|
|
}
|
|
|
|
const phoneNumber = String(data.phoneNumber || data.phone || '').trim();
|
|
let nationalIdCode = String(data.nationalIdCode || data.nationalId || '').trim();
|
|
|
|
if (!user && phoneNumber) {
|
|
user = await User.findOne({ phoneNumber });
|
|
}
|
|
if (!user && nationalIdCode) {
|
|
user = await User.findOne({ nationalIdCode });
|
|
}
|
|
|
|
if (!user) {
|
|
if (!nationalIdCode) {
|
|
nationalIdCode = await allocatePlaceholderNationalId(phoneNumber || data.name);
|
|
}
|
|
const fullName = String(data.name || '').trim() + (data.surname ? ` ${String(data.surname).trim()}` : '');
|
|
const username = generateUsername();
|
|
const plainPassword = generateSimplePassword();
|
|
const passwordHash = await bcrypt.hash(plainPassword, 10);
|
|
|
|
user = await User.create({
|
|
name: fullName.trim() || `استاد ${phoneNumber}`,
|
|
nationalIdCode,
|
|
phoneNumber: phoneNumber || `0999${Date.now().toString().slice(-7)}`,
|
|
email: data.email ? String(data.email).trim().toLowerCase() : undefined,
|
|
cardNumber: data.cardNumber ? String(data.cardNumber).trim() : undefined,
|
|
shabaNumber: data.shabaNumber || data.iban ? String(data.shabaNumber || data.iban).trim() : undefined,
|
|
role: professorRole._id,
|
|
username,
|
|
passwordHash,
|
|
isActive: data.isActive !== false
|
|
});
|
|
} else {
|
|
// Promote user role to Professor
|
|
if (String(user.role) !== String(professorRole._id)) {
|
|
user.role = professorRole._id;
|
|
}
|
|
if (data.name) {
|
|
const fullName = String(data.name).trim() + (data.surname ? ` ${String(data.surname).trim()}` : '');
|
|
user.name = fullName.trim();
|
|
}
|
|
if (data.email) user.email = String(data.email).trim().toLowerCase();
|
|
if (data.cardNumber) user.cardNumber = String(data.cardNumber).trim();
|
|
if (data.shabaNumber || data.iban) user.shabaNumber = String(data.shabaNumber || data.iban).trim();
|
|
await user.save();
|
|
}
|
|
|
|
let professor = await Professor.findOne({ user: user._id });
|
|
let expertise = [];
|
|
if (Array.isArray(data.expertise)) {
|
|
expertise = data.expertise.map(String).map((s) => s.trim()).filter(Boolean);
|
|
} else if (typeof data.expertise === 'string' && data.expertise.trim()) {
|
|
expertise = data.expertise.split(',').map((s) => s.trim()).filter(Boolean);
|
|
}
|
|
|
|
if (professor) {
|
|
professor.bio = data.bio != null ? String(data.bio).trim() : professor.bio;
|
|
if (expertise.length > 0) professor.expertise = expertise;
|
|
if (data.courses) professor.courses = data.courses;
|
|
professor.isActive = data.isActive !== false;
|
|
await professor.save();
|
|
} else {
|
|
professor = await Professor.create({
|
|
user: user._id,
|
|
bio: data.bio ? String(data.bio).trim() : undefined,
|
|
expertise,
|
|
courses: data.courses || [],
|
|
isActive: data.isActive !== false
|
|
});
|
|
}
|
|
|
|
eventEmitter.emit(EVENT_NAMES.PROFESSOR_CREATED, {
|
|
professorId: professor._id,
|
|
name: user.name
|
|
});
|
|
|
|
const populated = await Professor.findById(professor._id).populate('user').populate('courses', 'title type price');
|
|
return formatProfessorDoc(populated);
|
|
};
|
|
|
|
const createProfessorFromUser = async (userId, additionalData = {}) => {
|
|
if (!userId) {
|
|
throw new AppError('VALIDATION_FAILED', null, 'شناسه کاربر الزامی است');
|
|
}
|
|
|
|
const user = await User.findById(userId);
|
|
if (!user) {
|
|
throw new AppError('USER_NOT_FOUND');
|
|
}
|
|
|
|
const professorRole = await ensureProfessorRole();
|
|
if (String(user.role) !== String(professorRole._id)) {
|
|
user.role = professorRole._id;
|
|
}
|
|
|
|
let firstName = String(additionalData.name || '').trim();
|
|
let lastName = String(additionalData.surname || '').trim();
|
|
if (firstName || lastName) {
|
|
user.name = `${firstName} ${lastName}`.trim();
|
|
}
|
|
|
|
if (additionalData.email) user.email = String(additionalData.email).trim().toLowerCase();
|
|
if (additionalData.cardNumber) user.cardNumber = String(additionalData.cardNumber).trim();
|
|
if (additionalData.shabaNumber || additionalData.iban) {
|
|
user.shabaNumber = String(additionalData.shabaNumber || additionalData.iban).trim();
|
|
}
|
|
await user.save();
|
|
|
|
let expertise = [];
|
|
if (Array.isArray(additionalData.expertise)) {
|
|
expertise = additionalData.expertise.map(String).map((s) => s.trim()).filter(Boolean);
|
|
} else if (typeof additionalData.expertise === 'string' && additionalData.expertise.trim()) {
|
|
expertise = additionalData.expertise.split(',').map((s) => s.trim()).filter(Boolean);
|
|
}
|
|
|
|
let professor = await Professor.findOne({ user: user._id });
|
|
if (professor) {
|
|
if (additionalData.bio !== undefined) professor.bio = String(additionalData.bio).trim();
|
|
if (expertise.length > 0) professor.expertise = expertise;
|
|
professor.isActive = true;
|
|
await professor.save();
|
|
} else {
|
|
professor = await Professor.create({
|
|
user: user._id,
|
|
bio: additionalData.bio ? String(additionalData.bio).trim() : undefined,
|
|
expertise,
|
|
isActive: true
|
|
});
|
|
}
|
|
|
|
eventEmitter.emit(EVENT_NAMES.PROFESSOR_CREATED, {
|
|
professorId: professor._id,
|
|
name: user.name
|
|
});
|
|
|
|
const populated = await Professor.findById(professor._id).populate('user').populate('courses', 'title type price');
|
|
const updatedUser = await User.findById(user._id)
|
|
.select('-passwordHash -refreshTokens')
|
|
.populate({ path: 'role', select: 'name permissions' })
|
|
.lean();
|
|
|
|
return {
|
|
professor: formatProfessorDoc(populated),
|
|
user: updatedUser
|
|
};
|
|
};
|
|
|
|
const getProfessorById = async (id) => {
|
|
let professor = null;
|
|
if (mongoose.Types.ObjectId.isValid(id)) {
|
|
professor = await Professor.findById(id)
|
|
.populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber role isActive')
|
|
.populate('courses', 'title type price');
|
|
if (!professor) {
|
|
professor = await Professor.findOne({ user: id })
|
|
.populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber role isActive')
|
|
.populate('courses', 'title type price');
|
|
}
|
|
}
|
|
|
|
if (!professor) {
|
|
throw new AppError('PROFESSOR_NOT_FOUND');
|
|
}
|
|
|
|
return formatProfessorDoc(professor);
|
|
};
|
|
|
|
const getAllProfessors = async (queryParams = {}) => {
|
|
const page = parseInt(queryParams.page, 10) || 1;
|
|
const limit = Math.min(parseInt(queryParams.limit, 10) || 20, 200);
|
|
const skip = (page - 1) * limit;
|
|
|
|
const filter = {};
|
|
if (queryParams.isActive !== undefined) {
|
|
filter.isActive = queryParams.isActive === 'true' || queryParams.isActive === true;
|
|
}
|
|
|
|
const searchTerm = getSearchTerm(queryParams);
|
|
if (searchTerm) {
|
|
const searchRegex = new RegExp(escapeRegex(searchTerm), 'i');
|
|
const matchedUsers = await User.find({
|
|
$or: [
|
|
{ name: searchRegex },
|
|
{ phoneNumber: searchRegex },
|
|
{ nationalIdCode: searchRegex },
|
|
{ email: searchRegex }
|
|
]
|
|
}).select('_id').lean();
|
|
|
|
const userIds = matchedUsers.map((u) => u._id);
|
|
filter.$or = [
|
|
{ user: { $in: userIds } },
|
|
{ bio: searchRegex },
|
|
{ expertise: searchRegex }
|
|
];
|
|
}
|
|
|
|
const [professors, totalCount] = await Promise.all([
|
|
Professor.find(filter)
|
|
.populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber role isActive')
|
|
.populate('courses', 'title type price')
|
|
.sort({ createdAt: -1 })
|
|
.skip(skip)
|
|
.limit(limit),
|
|
Professor.countDocuments(filter)
|
|
]);
|
|
|
|
const activeCoursesCounts = await Class.aggregate([
|
|
{ $match: { isDeleted: { $ne: true }, isActive: { $ne: false }, professor: { $in: professors.map((p) => p._id) } } },
|
|
{ $group: { _id: '$professor', count: { $sum: 1 } } }
|
|
]);
|
|
const activeCountMap = Object.fromEntries(activeCoursesCounts.map((c) => [String(c._id), c.count]));
|
|
|
|
const formatted = professors.map((p) => {
|
|
const item = formatProfessorDoc(p);
|
|
return {
|
|
...item,
|
|
activeCoursesCount: activeCountMap[String(p._id)] || 0
|
|
};
|
|
});
|
|
|
|
const meta = calculateMeta(totalCount, page, limit);
|
|
return { data: formatted, meta };
|
|
};
|
|
|
|
const updateProfessor = async (id, updateData = {}) => {
|
|
let professor = await Professor.findById(id).populate('user');
|
|
if (!professor && mongoose.Types.ObjectId.isValid(id)) {
|
|
professor = await Professor.findOne({ user: id }).populate('user');
|
|
}
|
|
if (!professor) {
|
|
throw new AppError('PROFESSOR_NOT_FOUND');
|
|
}
|
|
|
|
if (updateData.bio !== undefined) {
|
|
professor.bio = updateData.bio ? String(updateData.bio).trim() : undefined;
|
|
}
|
|
if (updateData.expertise !== undefined) {
|
|
if (Array.isArray(updateData.expertise)) {
|
|
professor.expertise = updateData.expertise.map(String).map((s) => s.trim()).filter(Boolean);
|
|
} else if (typeof updateData.expertise === 'string') {
|
|
professor.expertise = updateData.expertise.split(',').map((s) => s.trim()).filter(Boolean);
|
|
}
|
|
}
|
|
if (updateData.courses !== undefined) {
|
|
professor.courses = updateData.courses;
|
|
}
|
|
if (updateData.isActive !== undefined) {
|
|
professor.isActive = Boolean(updateData.isActive);
|
|
}
|
|
|
|
// Update User personal details
|
|
if (professor.user) {
|
|
const user = await User.findById(professor.user._id || professor.user);
|
|
if (user) {
|
|
if (updateData.name || updateData.surname) {
|
|
const first = updateData.name !== undefined ? String(updateData.name).trim() : '';
|
|
const last = updateData.surname !== undefined ? String(updateData.surname).trim() : '';
|
|
user.name = `${first} ${last}`.trim() || user.name;
|
|
}
|
|
if (updateData.phoneNumber || updateData.phone) {
|
|
user.phoneNumber = String(updateData.phoneNumber || updateData.phone).trim();
|
|
}
|
|
if (updateData.nationalIdCode || updateData.nationalId) {
|
|
user.nationalIdCode = String(updateData.nationalIdCode || updateData.nationalId).trim();
|
|
}
|
|
if (updateData.email !== undefined) {
|
|
user.email = updateData.email ? String(updateData.email).trim().toLowerCase() : undefined;
|
|
}
|
|
if (updateData.cardNumber !== undefined) {
|
|
user.cardNumber = updateData.cardNumber ? String(updateData.cardNumber).trim() : undefined;
|
|
}
|
|
if (updateData.shabaNumber !== undefined || updateData.iban !== undefined) {
|
|
const val = updateData.shabaNumber || updateData.iban;
|
|
user.shabaNumber = val ? String(val).trim() : undefined;
|
|
}
|
|
await user.save();
|
|
}
|
|
}
|
|
|
|
await professor.save();
|
|
const populated = await Professor.findById(professor._id).populate('user').populate('courses', 'title type price');
|
|
return formatProfessorDoc(populated);
|
|
};
|
|
|
|
const deleteProfessor = async (id) => {
|
|
const professor = await Professor.findById(id);
|
|
if (!professor) {
|
|
throw new AppError('PROFESSOR_NOT_FOUND');
|
|
}
|
|
|
|
await Professor.findByIdAndDelete(id);
|
|
return null;
|
|
};
|
|
|
|
const searchProfessors = async (queryParams) => {
|
|
return getAllProfessors(queryParams);
|
|
};
|
|
|
|
/**
|
|
* Get comprehensive portal data for an authenticated professor (used in gameno-front teaching portal)
|
|
*/
|
|
const getProfessorPortalData = async (userId) => {
|
|
if (!userId) {
|
|
throw new AppError('AUTHENTICATION_FAILED');
|
|
}
|
|
|
|
const user = await User.findById(userId).populate({ path: 'role', select: 'name' }).lean();
|
|
if (!user) {
|
|
throw new AppError('USER_NOT_FOUND');
|
|
}
|
|
|
|
let professor = await Professor.findOne({ user: userId })
|
|
.populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber')
|
|
.populate('courses', 'title type price hoursPerSection sectionCount');
|
|
|
|
// Auto-link professor record if user holds Professor role
|
|
if (!professor) {
|
|
if (user.role?.name === 'Professor') {
|
|
professor = await Professor.create({ user: userId, isActive: true });
|
|
professor = await Professor.findById(professor._id)
|
|
.populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber')
|
|
.populate('courses', 'title type price hoursPerSection sectionCount');
|
|
} else {
|
|
throw new AppError('FORBIDDEN', null, 'شما دسترسی به پنل اساتید را ندارید');
|
|
}
|
|
}
|
|
|
|
const professorId = professor._id;
|
|
|
|
// 1. Fetch all classes taught by this professor
|
|
const classes = await Class.find({ professor: professorId, isDeleted: { $ne: true } })
|
|
.populate({ path: 'course', select: 'title type price hoursPerSection sectionCount description' })
|
|
.populate({ path: 'students', select: 'name phoneNumber nationalIdCode' })
|
|
.sort({ createdAt: -1 })
|
|
.lean();
|
|
|
|
const classIds = classes.map((c) => c._id);
|
|
|
|
// 2. Fetch all sessions for these classes
|
|
const sessions = classIds.length
|
|
? await Session.find({ class: { $in: classIds }, isDeleted: { $ne: true } })
|
|
.populate('class', 'name startDate')
|
|
.populate('course', 'title')
|
|
.sort({ day: 1, startTime: 1 })
|
|
.lean()
|
|
: [];
|
|
|
|
// 3. Fetch all payments for these classes to calculate actual class revenue
|
|
const payments = classIds.length
|
|
? await Payment.find({ classes: { $in: classIds }, isDeleted: { $ne: true } })
|
|
.select('classes amount discount paidAmount')
|
|
.lean()
|
|
: [];
|
|
|
|
const revenueByClass = new Map(classIds.map((id) => [String(id), { expected: 0, received: 0 }]));
|
|
for (const p of payments) {
|
|
const payable = Math.max(0, (p.amount || 0) - (p.discount || 0));
|
|
const paid = Math.max(0, Number(p.paidAmount) || 0);
|
|
for (const cRef of p.classes || []) {
|
|
const key = String(cRef);
|
|
if (revenueByClass.has(key)) {
|
|
const cur = revenueByClass.get(key);
|
|
cur.expected += payable;
|
|
cur.received += paid;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Group sessions by class
|
|
const sessionsByClass = new Map(classIds.map((id) => [String(id), { held: 0, scheduled: 0, cancelled: 0, all: [] }]));
|
|
const now = new Date();
|
|
const upcomingSessionsList = [];
|
|
const heldSessionsList = [];
|
|
|
|
for (const s of sessions) {
|
|
const key = String(s.class?._id || s.class);
|
|
const entry = sessionsByClass.get(key);
|
|
if (entry) {
|
|
entry.all.push(s);
|
|
if (s.status === 'held') entry.held += 1;
|
|
else if (s.status === 'cancelled') entry.cancelled += 1;
|
|
else entry.scheduled += 1;
|
|
}
|
|
|
|
const sessionDate = s.day ? new Date(s.day) : null;
|
|
if (s.status === 'held') {
|
|
heldSessionsList.push(s);
|
|
} else if (sessionDate && sessionDate >= now) {
|
|
upcomingSessionsList.push(s);
|
|
}
|
|
}
|
|
|
|
let totalEarnedPayout = 0;
|
|
let totalPendingPayout = 0;
|
|
let totalSessionsPlanned = 0;
|
|
let totalSessionsHeld = 0;
|
|
const distinctStudentIds = new Set();
|
|
|
|
const classCards = classes.map((cls) => {
|
|
const key = String(cls._id);
|
|
const rev = revenueByClass.get(key) || { expected: 0, received: 0 };
|
|
const sess = sessionsByClass.get(key) || { held: 0, scheduled: 0, cancelled: 0, all: [] };
|
|
const students = cls.students || [];
|
|
students.forEach((st) => distinctStudentIds.add(String(st._id || st)));
|
|
|
|
const plannedSessions = cls.numberOfSessions ?? (cls.course?.sectionCount || sess.all.length || 1);
|
|
totalSessionsPlanned += plannedSessions;
|
|
totalSessionsHeld += sess.held;
|
|
|
|
const durationHours = resolveSessionDurationHours(cls);
|
|
|
|
// Calculate actual payout earned so far (from held sessions or received revenue)
|
|
const earnedPayout = calculateProfessorPayout({
|
|
payoutType: cls.payoutType,
|
|
payoutPercentage: cls.payoutPercentage,
|
|
payoutHourlyRate: cls.payoutHourlyRate,
|
|
revenue: rev.received,
|
|
sessionDurationHours: durationHours,
|
|
sessionsCount: sess.held,
|
|
extraExpensePerSession: cls.extraExpensePerSession,
|
|
serviceFeePerPerson: cls.serviceFeePerPerson,
|
|
studentsCount: students.length
|
|
});
|
|
|
|
// Calculate potential total payout if all sessions held & expected revenue collected
|
|
const maxPotentialPayout = calculateProfessorPayout({
|
|
payoutType: cls.payoutType,
|
|
payoutPercentage: cls.payoutPercentage,
|
|
payoutHourlyRate: cls.payoutHourlyRate,
|
|
revenue: rev.expected || (cls.tuitionFee * students.length),
|
|
sessionDurationHours: durationHours,
|
|
sessionsCount: plannedSessions,
|
|
extraExpensePerSession: cls.extraExpensePerSession,
|
|
serviceFeePerPerson: cls.serviceFeePerPerson,
|
|
studentsCount: students.length
|
|
});
|
|
|
|
totalEarnedPayout += earnedPayout.totalPayout;
|
|
totalPendingPayout += Math.max(0, maxPotentialPayout.totalPayout - earnedPayout.totalPayout);
|
|
|
|
return {
|
|
_id: cls._id,
|
|
name: cls.name,
|
|
courseTitle: cls.course?.title || '—',
|
|
courseType: cls.course?.type || 'General',
|
|
startDate: cls.startDate,
|
|
endDate: cls.endDate,
|
|
days: cls.days || [],
|
|
startTime: cls.startTime || '',
|
|
endTime: cls.endTime || '',
|
|
isActive: cls.isActive !== false,
|
|
capacity: cls.capacity || 20,
|
|
studentsCount: students.length,
|
|
students: students.map((s) => ({
|
|
_id: s._id,
|
|
name: s.name,
|
|
phoneNumber: s.phoneNumber
|
|
})),
|
|
sessionsPlanned: plannedSessions,
|
|
sessionsHeld: sess.held,
|
|
sessionsScheduled: sess.scheduled,
|
|
sessionsCancelled: sess.cancelled,
|
|
payoutType: cls.payoutType || 'percentage',
|
|
payoutPercentage: cls.payoutPercentage || 0,
|
|
payoutHourlyRate: cls.payoutHourlyRate || 0,
|
|
extraExpensePerSession: cls.extraExpensePerSession || 0,
|
|
serviceFeePerPerson: cls.serviceFeePerPerson || 0,
|
|
sessionDurationHours: durationHours,
|
|
earnedPayout: earnedPayout.totalPayout,
|
|
baseShare: earnedPayout.baseShare,
|
|
extraExpenses: earnedPayout.extraExpenses,
|
|
maxPotentialPayout: maxPotentialPayout.totalPayout,
|
|
expectedRevenue: rev.expected,
|
|
receivedRevenue: rev.received
|
|
};
|
|
});
|
|
|
|
// Monthly earnings breakdown (by held session dates)
|
|
const monthlyTimelineMap = new Map();
|
|
for (const s of heldSessionsList) {
|
|
if (!s.day) continue;
|
|
const d = new Date(s.day);
|
|
const monthKey = d.toISOString().slice(0, 7); // YYYY-MM
|
|
const cls = classes.find((c) => String(c._id) === String(s.class?._id || s.class));
|
|
if (!cls) continue;
|
|
|
|
const duration = resolveSessionDurationHours(cls);
|
|
let sessionPayout = 0;
|
|
if (cls.payoutType === 'hourly') {
|
|
sessionPayout = calculateHourlyShare({
|
|
payoutHourlyRate: cls.payoutHourlyRate,
|
|
sessionDurationHours: duration,
|
|
sessionsCount: 1
|
|
}) + (cls.extraExpensePerSession || 0);
|
|
} else {
|
|
const classRev = revenueByClass.get(String(cls._id))?.received || 0;
|
|
const planned = cls.numberOfSessions || 10;
|
|
sessionPayout = (calculatePercentageShare({
|
|
payoutPercentage: cls.payoutPercentage,
|
|
revenue: classRev / planned,
|
|
serviceFeePerPerson: (cls.serviceFeePerPerson || 0) / planned,
|
|
studentsCount: (cls.students || []).length
|
|
})) + (cls.extraExpensePerSession || 0);
|
|
}
|
|
|
|
if (!monthlyTimelineMap.has(monthKey)) {
|
|
monthlyTimelineMap.set(monthKey, {
|
|
monthKey,
|
|
monthLabel: monthKey,
|
|
amount: 0,
|
|
sessionsCount: 0
|
|
});
|
|
}
|
|
const entry = monthlyTimelineMap.get(monthKey);
|
|
entry.amount += Math.round(sessionPayout);
|
|
entry.sessionsCount += 1;
|
|
}
|
|
|
|
const monthlyEarnings = Array.from(monthlyTimelineMap.values()).sort((a, b) => a.monthKey.localeCompare(b.monthKey));
|
|
|
|
return {
|
|
professor: formatProfessorDoc(professor),
|
|
stats: {
|
|
totalClasses: classes.length,
|
|
activeClasses: classes.filter((c) => c.isActive !== false).length,
|
|
totalStudents: distinctStudentIds.size,
|
|
totalSessionsPlanned,
|
|
totalSessionsHeld,
|
|
totalEarnedPayout: Math.round(totalEarnedPayout),
|
|
totalPendingPayout: Math.round(totalPendingPayout)
|
|
},
|
|
classes: classCards,
|
|
upcomingSessions: upcomingSessionsList.slice(0, 15).map((s) => ({
|
|
_id: s._id,
|
|
day: s.day,
|
|
startTime: s.startTime,
|
|
endTime: s.endTime,
|
|
topic: s.topic,
|
|
place: s.place,
|
|
className: s.class?.name || '—',
|
|
courseTitle: s.course?.title || '—'
|
|
})),
|
|
recentSessions: heldSessionsList.slice(-10).reverse().map((s) => ({
|
|
_id: s._id,
|
|
day: s.day,
|
|
startTime: s.startTime,
|
|
endTime: s.endTime,
|
|
topic: s.topic,
|
|
place: s.place,
|
|
className: s.class?.name || '—',
|
|
courseTitle: s.course?.title || '—',
|
|
attendanceCount: (s.attendanceList || []).filter((a) => a.status === 'present').length
|
|
})),
|
|
monthlyEarnings
|
|
};
|
|
};
|
|
|
|
module.exports = {
|
|
createProfessor,
|
|
createProfessorFromUser,
|
|
getProfessorById,
|
|
getAllProfessors,
|
|
updateProfessor,
|
|
deleteProfessor,
|
|
searchProfessors,
|
|
getProfessorPortalData,
|
|
formatProfessorDoc
|
|
};
|