From 7c74950c95450ae3eecd375d956092ae80465f1a Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Fri, 21 Aug 2026 12:56:15 +0330 Subject: [PATCH] feat: add professor import support, user account creation, and IBAN/card fields --- components/dataImport/dataImportService.js | 145 ++++++++++++++++++- components/dataImport/importHelpers.js | 12 +- components/dataImport/importHelpers.test.js | 4 + components/professors/professorModel.js | 12 ++ components/professors/professorService.js | 16 ++- components/users/userModel.js | 8 ++ components/users/userService.js | 9 ++ scripts/convert-professors.js | 147 ++++++++++++++++++++ 8 files changed, 344 insertions(+), 9 deletions(-) create mode 100644 scripts/convert-professors.js diff --git a/components/dataImport/dataImportService.js b/components/dataImport/dataImportService.js index 9babf0f..853691b 100644 --- a/components/dataImport/dataImportService.js +++ b/components/dataImport/dataImportService.js @@ -5,6 +5,7 @@ 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'); @@ -13,7 +14,7 @@ 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 { parseImportDate, utcDayRange, toEnglishDigits } = require('../../utils/jalaliDate'); const { namesMatch, normalizePersonName, @@ -38,13 +39,25 @@ 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 })) { + 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 }); @@ -379,18 +392,134 @@ const migrateEmbeddedPaymentTransactions = async () => { } }; +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 } + }); + } + + let existingProf = null; + if (nationalIdCode) { + existingProf = await Professor.findOne({ nationalIdCode }); + } + if (!existingProf && phoneNumber) { + existingProf = await Professor.findOne({ phoneNumber }); + } + if (!existingProf && fullName) { + const allProfs = await Professor.find({}); + existingProf = allProfs.find((p) => namesMatch(`${p.name} ${p.surname}`, fullName) || namesMatch(p.name, name)); + } + + let professorDoc; + if (existingProf) { + if (name) existingProf.name = name; + if (surname) existingProf.surname = surname; + if (cardNumber) existingProf.cardNumber = cardNumber; + if (shabaNumber) existingProf.shabaNumber = shabaNumber; + if (email && !existingProf.email) existingProf.email = email; + if (profInput.bio && !existingProf.bio) existingProf.bio = profInput.bio; + await existingProf.save(); + professorDoc = existingProf; + stats.professorsUpdated += 1; + } else { + professorDoc = await Professor.create({ + name: name || fullName, + surname: surname || '', + nationalIdCode, + phoneNumber, + cardNumber: cardNumber || undefined, + shabaNumber: shabaNumber || undefined, + email: email || undefined, + bio: profInput.bio || undefined, + isActive: true + }); + stats.professorsCreated += 1; + } + + 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(userRole?._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; + } + + return { professor: professorDoc, user }; +}; + 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'); + 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, @@ -411,7 +540,13 @@ const importData = async (rawPayload) => { }; const warnings = []; - for (const courseInput of payload.courses) { + 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; diff --git a/components/dataImport/importHelpers.js b/components/dataImport/importHelpers.js index a515872..ba0ad46 100644 --- a/components/dataImport/importHelpers.js +++ b/components/dataImport/importHelpers.js @@ -2,7 +2,7 @@ const { toEnglishDigits } = require('../../utils/jalaliDate'); -const TITLE_PREFIX = /^(آقای|اقای|خانم|آقا)\s+/; +const TITLE_PREFIX = /^(آقای|اقای|خانم|آقا|سرکار\s+خانم|سرکار)\s+/; const HONORIFIC_MIDDLE = /\s+خ\s+/g; const normalizePersonName = (value) => { @@ -28,14 +28,20 @@ const namesMatch = (left, right) => { 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.startsWith('98') && digits.length >= 12) digits = `0${digits.slice(2)}`; if (digits.length === 10 && digits.startsWith('9')) digits = `0${digits}`; + if (digits.length === 11 && digits.startsWith('9')) digits = `0${digits.slice(0, 10)}`; + if (digits.length > 11 && digits.startsWith('09')) digits = digits.slice(0, 11); return digits; }; const normalizeNationalId = (raw) => { if (raw == null || raw === '') return ''; - return toEnglishDigits(raw).replace(/\D/g, ''); + let digits = toEnglishDigits(raw).replace(/\D/g, ''); + if (digits && digits.length < 10 && /^\d+$/.test(digits)) { + digits = digits.padStart(10, '0'); + } + return digits; }; const mapAttendanceStatus = (raw) => { diff --git a/components/dataImport/importHelpers.test.js b/components/dataImport/importHelpers.test.js index 37b8f96..e4b8483 100644 --- a/components/dataImport/importHelpers.test.js +++ b/components/dataImport/importHelpers.test.js @@ -13,8 +13,12 @@ const { describe('importHelpers', () => { it('normalizes honorifics so spreadsheet names match database users', () => { assert.equal(normalizePersonName('مریم خ پناهیان'), 'مریم پناهیان'); + assert.equal(normalizePersonName('سرکار فاطمه'), 'فاطمه'); + assert.equal(normalizePersonName('سرکار خانم جاذبی'), 'جاذبی'); assert.ok(namesMatch('آقای رضایی', 'آقای رضایی')); assert.equal(normalizePhone(9168942141), '09168942141'); + assert.equal(normalizePhone('93568933888'), '09356893388'); + assert.equal(normalizePhone('09161117529'), '09161117529'); }); it('maps spreadsheet attendance marks', () => { diff --git a/components/professors/professorModel.js b/components/professors/professorModel.js index 94cb65f..22ed16f 100644 --- a/components/professors/professorModel.js +++ b/components/professors/professorModel.js @@ -32,6 +32,18 @@ const professorSchema = new mongoose.Schema({ trim: true, lowercase: true }, + cardNumber: { + type: String, + trim: true + }, + shabaNumber: { + type: String, + trim: true + }, + bio: { + type: String, + trim: true + }, expertise: [{ type: String, trim: true diff --git a/components/professors/professorService.js b/components/professors/professorService.js index 72b2058..01e0770 100644 --- a/components/professors/professorService.js +++ b/components/professors/professorService.js @@ -13,7 +13,10 @@ const createProfessor = async (data) => { surname: String(data.surname || '').trim(), nationalIdCode: String(data.nationalIdCode || data.nationalId || '').trim(), phoneNumber: String(data.phoneNumber || data.phone || '').trim(), - email: data.email ? String(data.email).trim().toLowerCase() : undefined + 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, + bio: data.bio ? String(data.bio).trim() : undefined }; const existing = await Professor.findOne({ @@ -73,6 +76,17 @@ const updateProfessor = async (id, updateData) => { if (payload.email !== undefined) { payload.email = payload.email ? String(payload.email).trim().toLowerCase() : undefined; } + if (payload.cardNumber !== undefined) { + payload.cardNumber = payload.cardNumber ? String(payload.cardNumber).trim() : undefined; + } + if (payload.shabaNumber !== undefined || payload.iban !== undefined) { + const val = payload.shabaNumber || payload.iban; + payload.shabaNumber = val ? String(val).trim() : undefined; + delete payload.iban; + } + if (payload.bio !== undefined) { + payload.bio = payload.bio ? String(payload.bio).trim() : undefined; + } Object.assign(professor, payload); await professor.save(); diff --git a/components/users/userModel.js b/components/users/userModel.js index 658f8a1..af3baca 100644 --- a/components/users/userModel.js +++ b/components/users/userModel.js @@ -79,6 +79,14 @@ const userSchema = new mongoose.Schema({ type: String, trim: true }, + cardNumber: { + type: String, + trim: true + }, + shabaNumber: { + type: String, + trim: true + }, username: { type: String, required: true, diff --git a/components/users/userService.js b/components/users/userService.js index ee58417..20b4830 100644 --- a/components/users/userService.js +++ b/components/users/userService.js @@ -84,6 +84,8 @@ const pickProfileFields = (body = {}) => { birthDate: body.birthDate || undefined, education: body.education, parentPhoneNumber: body.parentPhoneNumber, + cardNumber: body.cardNumber, + shabaNumber: body.shabaNumber || body.iban, preferredMessenger: normalizePreferredMessengers(body.preferredMessenger), adminNotes: normalizeAdminNotes(body.adminNotes) }; @@ -257,6 +259,13 @@ const updateUser = async (id, body) => { if (preferredMessenger !== undefined) { update.preferredMessenger = normalizePreferredMessengers(preferredMessenger) || []; } + if (body.shabaNumber !== undefined || body.iban !== undefined) { + update.shabaNumber = (body.shabaNumber || body.iban || '').trim() || undefined; + delete update.iban; + } + if (body.cardNumber !== undefined) { + update.cardNumber = (body.cardNumber || '').trim() || undefined; + } if (body.adminNotes !== undefined) { update.adminNotes = normalizeAdminNotes(body.adminNotes) || []; } diff --git a/scripts/convert-professors.js b/scripts/convert-professors.js new file mode 100644 index 0000000..b90fb62 --- /dev/null +++ b/scripts/convert-professors.js @@ -0,0 +1,147 @@ +// /scripts/convert-professors.js +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const ODS_FILE = path.resolve(__dirname, '../../raw-data/Untitled spreadsheet.ods'); +const OUTPUT_FILE = path.resolve(__dirname, '../../raw-data/professors-import.json'); + +const pythonExtractScript = ` +import zipfile, xml.etree.ElementTree as ET, json, sys + +ods_path = sys.argv[1] +with zipfile.ZipFile(ods_path) as z: + content = z.read('content.xml') + tree = ET.fromstring(content) + for table in tree.iter('{urn:oasis:names:tc:opendocument:xmlns:table:1.0}table'): + rows = [] + for row in table.iter('{urn:oasis:names:tc:opendocument:xmlns:table:1.0}table-row'): + row_vals = [] + for cell in row.iter('{urn:oasis:names:tc:opendocument:xmlns:table:1.0}table-cell'): + texts = [t.text for t in cell.iter('{urn:oasis:names:tc:opendocument:xmlns:text:1.0}p') if t.text] + repeat = int(cell.attrib.get('{urn:oasis:names:tc:opendocument:xmlns:table:1.0}number-columns-repeated', 1)) + val = ' '.join(texts).strip() if texts else '' + if repeat > 1 and not val: + pass + else: + for _ in range(min(repeat, 20)): + row_vals.append(val) + if any(row_vals): + rows.append(row_vals) + print(json.dumps(rows, ensure_ascii=False)) + break +`; + +const extractOdsRows = (filePath) => { + const output = execSync(`python3 -c "${pythonExtractScript.replace(/"/g, '\\"')}" "${filePath}"`, { + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024 + }); + return JSON.parse(output); +}; + +const toEnglishDigits = (str = '') => + String(str) + .replace(/[۰-۹]/g, (d) => '0123456789'['۰۱۲۳۴۵۶۷۸۹'.indexOf(d)]) + .replace(/[٠-٩]/g, (d) => '0123456789'['٠١٢٣٤٥٦٧٨٩'.indexOf(d)]); + +const cleanPhone = (raw) => { + if (!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}`; + if (digits.length === 11 && digits.startsWith('9')) digits = `0${digits.slice(0, 10)}`; + if (digits.length > 11 && digits.startsWith('09')) digits = digits.slice(0, 11); + return digits; +}; + +const cleanNationalId = (raw) => { + if (!raw) return ''; + let digits = toEnglishDigits(raw).replace(/\D/g, ''); + if (digits && digits.length < 10 && /^\d+$/.test(digits)) { + digits = digits.padStart(10, '0'); + } + return digits; +}; + +const cleanCard = (raw) => { + if (!raw) return ''; + return toEnglishDigits(raw).replace(/\D/g, ''); +}; + +const cleanShaba = (raw) => { + if (!raw) return ''; + let val = toEnglishDigits(raw).replace(/[^0-9A-Za-z]/g, ''); + return val; +}; + +const main = () => { + if (!fs.existsSync(ODS_FILE)) { + console.error(`ODS file not found: ${ODS_FILE}`); + process.exit(1); + } + + const rows = extractOdsRows(ODS_FILE); + if (!rows || rows.length < 2) { + console.error('No rows found in ODS file'); + process.exit(1); + } + + const header = rows[0]; + console.log('Detected headers:', header); + + const professors = []; + + for (let i = 1; i < rows.length; i += 1) { + const r = rows[i]; + // 0: ردیف, 1: نام, 2: نام خانوادگی, 3: کد ملی, 4: شماره تماس, 5: درصد, 6: شماره کارت, 7: شمار شبا + const rawName = r[1] || ''; + const rawSurname = r[2] || ''; + const rawNid = r[3] || ''; + const rawPhone = r[4] || ''; + const rawCard = r[6] || ''; + const rawShaba = r[7] || ''; + + // Strip honorifics like 'سرکار' + const name = rawName.replace(/^(آقای|اقای|خانم|آقا|سرکار\s+خانم|سرکار)\s+/, '').trim(); + const surname = rawSurname.trim(); + + const nationalIdCode = cleanNationalId(rawNid); + const phoneNumber = cleanPhone(rawPhone); + const cardNumber = cleanCard(rawCard); + const shabaNumber = cleanShaba(rawShaba); + + professors.push({ + name, + surname, + nationalIdCode, + phoneNumber, + cardNumber, + shabaNumber + }); + } + + const payload = { + version: 1, + generatedAt: new Date().toISOString(), + source: 'Untitled spreadsheet.ods', + summary: { + professors: professors.length + }, + professors + }; + + fs.writeFileSync(OUTPUT_FILE, JSON.stringify(payload, null, 2), 'utf-8'); + console.log(`Successfully wrote ${professors.length} professors to ${OUTPUT_FILE}`); +}; + +if (require.main === module) { + main(); +} + +module.exports = { + extractOdsRows, + main +};