diff --git a/docs/demo-data-seeding-system.md b/docs/demo-data-seeding-system.md index cfb988c6..2ff16381 100644 --- a/docs/demo-data-seeding-system.md +++ b/docs/demo-data-seeding-system.md @@ -131,6 +131,36 @@ LEVEL 4 — Behavior & Finance school_schema.accounts ──► transactions ──► transaction_lines ``` +### 3.3 Realistic Coverage Contract + +For a demo school to feel production-like, the seed must respect the same setup chronology the product expects in real use. + +**Must exist after a successful seed:** + +- School baseline: `schools`, `school_years`, `terms`, `classrooms` +- Academic setup: `school_subjects`, `classes`, `class_subjects` +- Organization: `users`, `user_schools`, `user_roles`, `teachers`, `teacher_subjects`, `students`, `parents`, `student_parents` +- Enrollment and operations: `enrollments`, `student_attendance`, `student_grades`, `conduct_records` +- Finance setup and lifecycle: `accounts`, `fee_types`, `fee_structures`, `payment_plan_templates`, `student_fees`, `payment_plans`, `installments` + +**Must not be empty for a healthy seeded school:** + +- `school_subjects` +- `classes` +- `class_subjects` +- `teachers` +- `students` +- `enrollments` +- `fee_types` +- `fee_structures` +- `payment_plan_templates` + +**Configuration rule:** + +- Demo profiles must declare their intended grade span explicitly. +- Class generation must use the profile grade span rather than a hardcoded list. +- If a configured grade is missing from the platform catalog, the seed should surface that mismatch as a realism defect. + --- ## 4. Scenario Engine (Task Distributor) diff --git a/docs/superpowers/plans/2026-03-30-realistic-demo-seeding-status.md b/docs/superpowers/plans/2026-03-30-realistic-demo-seeding-status.md new file mode 100644 index 00000000..4f13eae8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-realistic-demo-seeding-status.md @@ -0,0 +1,184 @@ +# Realistic Demo Seeding Status + +Date: 2026-03-30 +Worktree: `/home/darius-kassi/Projects/Yeko/.worktrees/realistic-demo-seeding` + +## Mission + +Make demo seeding behave like a real production school that: + +- is configured through the normal product setup flow +- has realistic academic catalog, classes, teachers, students, enrollment, timetables, attendance, grades, conduct, reporting, and finance +- can seed successfully against the real Neon HTTP path, not only in helper tests +- fails fast when critical production-like tables stay empty + +## What Has Been Done + +### Lifecycle and catalog realism + +- Reworked the seed around a production-like lifecycle instead of isolated domain inserts. +- Added academic catalog seeding so `school_subjects` are created before teachers and classes depend on them. +- Added `school_subject_coefficients` seeding and realism validation. +- Added curriculum fallback support from lesson-progress XLSX files under `/home/darius-kassi/Documents/Lesson Progress/xslx`. + +### Class, teacher, and student realism + +- Added explicit grade-span coverage in the demo config. +- Reworked class generation to cover all configured grades instead of one implicit level. +- Made `class_subjects` grade-aware and derived from actual curriculum/program applicability. +- Fixed teacher subject assignment so specialties come from active school offerings and baseline coverage is guaranteed. +- Batched student and parent provisioning, including deterministic demo matricules. + +### Operational realism + +- Added operation seeders for: + - `attendance_settings` + - `message_templates` + - `report_card_templates` + - `staff` + - `timetable_sessions` + - `class_sessions` + - `curriculum_progress` + - `chapter_completions` + - `student_averages` + - `teacher_comments` + - `teacher_messages` + - `teacher_notifications` + - `report_cards` +- Made attendance session-linked to generated class sessions. +- Added load-aware timetable scheduling to avoid class, teacher, and classroom collisions. +- Made report-card comments and lifecycle states more realistic. + +### Finance realism + +- Added accounting setup, fiscal years, accounts, fee structures, payment plans, installments, payments, allocations, receipts, transactions, and transaction lines in the seed design and implementation. +- Added finance helper coverage for allocation and accounting-line balancing. +- Hardened finance writes for Neon HTTP with retry-aware chunking. +- Fixed a root-cause bug in finance setup: + all seeded `fee_types` were being created with `feeTypeTemplateId = null`, but the schema has a unique `(school_id, fee_type_template_id)` constraint with `nullsNotDistinct()`. + This collapsed four seeded fee types down to one row and blocked all downstream finance seeding. +- Added template resolution so finance fee blueprints now attach real active `fee_type_templates` before insert. +- Fixed the next live finance blocker: + account balances were being persisted with one `update accounts ...` query per transaction line. + That pattern failed on Neon HTTP under finance volume. + Balance updates now accumulate in memory during finance seeding and flush once at the end with retry-aware chunking. + +### Neon reliability hardening + +- Added retry-aware chunk helpers and adaptive chunk splitting for large inserts. +- Hardened: + - reset deletes + - grade inserts + - attendance inserts + - conduct inserts + - large operations inserts + - finance insert/query hot paths + +### Tests and validation + +- Added targeted helper tests for academic realism, catalog realism, classes, operations, finance, resilience, reporting, students, matricules, and engagement. +- Added direct KPI metric tests so dashboard-facing outputs like `attendanceRate`, `collectionRate`, and count summaries are validated outside live-only reruns. +- Added profile KPI expectation tests so the named demo profiles keep their intended relative ordering for attendance health, finance health, and incident pressure. +- Added a realism gate so seeded demo runs fail if critical production-like tables are empty. +- Switched test setup to use `.env` instead of `.env.test`. + +## Latest Verified Results + +### Passing verification + +- `pnpm exec vitest run src/tests/demo-finance-helpers.test.ts` +- `pnpm exec vitest run src/tests/demo-metrics-helpers.test.ts src/tests/demo-academic-realism-helpers.test.ts src/tests/demo-operations-helpers.test.ts` +- `pnpm exec vitest run src/tests/demo-profile-kpi-helpers.test.ts` +- `pnpm exec vitest run src/tests/demo-academic-realism-helpers.test.ts src/tests/demo-operations-helpers.test.ts` +- `pnpm typecheck` + +### End-to-end live success + +The realistic demo seed completed successfully on Neon HTTP and returned: + +- `totalStudents: 240` +- `totalClasses: 14` +- `totalTeachers: 18` +- `averageGrade: 11.934641216850215` +- `attendanceRate: 97.1` +- `collectionRate: 0.27631461903590754` +- `overdueCount: 91` +- `openIncidents: 21` + +Additional completed-run table evidence: + +- `teacherMessages: 650` +- `teacherNotifications: 285` +- `feeTypes: 4` +- `payments: 526` +- `transactions: 766` +- `reportCards: 480` +- `teacherComments: 5412` + +Interpretation: + +- the full realistic seed now runs end to end on the live Neon path +- the earlier operations and finance bottlenecks are no longer blocking completion +- the latest attendance realism follow-up has also been validated on a fresh live rerun + +### Attendance realism validation + +- The initial successful live dataset only contained `present` and `late` session-linked attendance rows. +- That made the computed `attendanceRate` unrealistically report `100`. +- The attendance seeding path now generates scenario-based absences through `absentStudentIds`. +- A fresh live Neon rerun completed successfully with the updated attendance mix: + - `absent: 439` + - `late: 608` + - `present: 14241` + - `attendanceRate: 97.1` + +Interpretation: + +- the seeded school no longer reports a fake perfect attendance rate +- attendance data now looks like a school that has actually been used over time + +### Final live outcome + +The current realistic demo seed is end-to-end complete on Neon HTTP and has been observed to populate: + +- `school_subjects` +- `school_subject_coefficients` +- `classes` +- `class_subjects` +- `enrollments` +- `student_grades` +- `student_attendance` +- `conduct_records` +- `timetable_sessions` +- `class_sessions` +- `curriculum_progress` +- `chapter_completions` +- `student_averages` +- `report_cards` +- `teacher_comments` +- `teacher_messages` +- `teacher_notifications` +- `fee_types` +- `fee_structures` +- `payment_plan_templates` +- `payment_plans` +- `installments` +- `payments` +- `payment_allocations` +- `receipts` +- `transactions` +- `transaction_lines` + +## Remaining Work + +There is no known correctness blocker left in the realistic demo seed itself. + +The remaining work is optional follow-up: + +1. Commit the final attendance-realism follow-up. +2. Decide whether to tune seeded KPI distributions further: + - attendance rates by profile + - collection-rate spread + - overdue balance distributions + - communication volume by school size +3. If desired, extend KPI coverage beyond aggregate/profile expectations into dashboard segmentation by term/class. diff --git a/docs/superpowers/plans/seeded-table-coverage-checklist.md b/docs/superpowers/plans/seeded-table-coverage-checklist.md new file mode 100644 index 00000000..2378de98 --- /dev/null +++ b/docs/superpowers/plans/seeded-table-coverage-checklist.md @@ -0,0 +1,67 @@ +# Seeded Table Coverage Checklist + +This checklist tracks the minimum realistic surface area for demo-school seeding. + +## Setup + +- [ ] `schools` +- [ ] `school_years` +- [ ] `terms` +- [ ] `classrooms` +- [ ] `attendance_settings` +- [ ] `report_card_templates` +- [ ] `message_templates` + +## Academic Catalog + +- [ ] `school_subjects` +- [ ] `school_subject_coefficients` if required by app flows + +## Organization + +- [ ] `users` +- [ ] `user_schools` +- [ ] `user_roles` +- [ ] `staff` +- [ ] `teachers` +- [ ] `teacher_subjects` +- [ ] `students` +- [ ] `parents` +- [ ] `student_parents` + +## Enrollment and Class Planning + +- [ ] `classes` +- [ ] `class_subjects` +- [ ] `enrollments` +- [ ] `timetable_sessions` +- [ ] `class_sessions` + +## Daily Operations + +- [ ] `student_attendance` +- [ ] `teacher_attendance` if required by app flows +- [ ] `student_grades` +- [ ] `conduct_records` + +## Finance + +- [ ] `accounts` +- [ ] `fee_types` +- [ ] `fee_structures` +- [ ] `payment_plan_templates` +- [ ] `student_fees` +- [ ] `payment_plans` +- [ ] `installments` +- [ ] `payments` +- [ ] `payment_allocations` +- [ ] `receipts` +- [ ] `transactions` +- [ ] `transaction_lines` + +## Reporting and Messaging + +- [ ] `report_cards` or explicit report-card-ready state +- [ ] `teacher_messages` +- [ ] `teacher_notifications` +- [ ] KPI-supporting records needed by demo dashboards diff --git a/packages/data-ops/src/seed/demo/config.ts b/packages/data-ops/src/seed/demo/config.ts index 75e45e29..47294350 100644 --- a/packages/data-ops/src/seed/demo/config.ts +++ b/packages/data-ops/src/seed/demo/config.ts @@ -66,6 +66,7 @@ export interface DemoSeedConfig { teacherCount: number studentCount: number classesPerGrade: number + gradeNames: readonly string[] termType: 'trimester' | 'semester' currency: string // "XOF" paymentPlanType: 'monthly' | 'trimester' | 'custom' @@ -92,6 +93,16 @@ export interface SeedResult { durationMs: number } +export const defaultDemoGradeNames = [ + '6ème', + '5ème', + '4ème', + '3ème', + '2nde', + '1ère', + 'Terminale', +] as const + // Named profiles export const smallSchool: DemoSeedConfig = { seed: 1001, @@ -100,6 +111,7 @@ export const smallSchool: DemoSeedConfig = { teacherCount: 8, studentCount: 60, classesPerGrade: 1, + gradeNames: defaultDemoGradeNames, termType: 'trimester', currency: 'XOF', paymentPlanType: 'trimester', @@ -114,6 +126,7 @@ export const premiumSchool: DemoSeedConfig = { teacherCount: 25, studentCount: 350, classesPerGrade: 2, + gradeNames: defaultDemoGradeNames, termType: 'semester', currency: 'XOF', paymentPlanType: 'monthly', @@ -128,6 +141,7 @@ export const problematicSchool: DemoSeedConfig = { teacherCount: 14, studentCount: 140, classesPerGrade: 2, + gradeNames: defaultDemoGradeNames, termType: 'trimester', currency: 'XOF', paymentPlanType: 'trimester', @@ -142,6 +156,7 @@ export const realisticSchool: DemoSeedConfig = { teacherCount: 18, studentCount: 240, classesPerGrade: 2, + gradeNames: defaultDemoGradeNames, termType: 'trimester', currency: 'XOF', paymentPlanType: 'trimester', diff --git a/packages/data-ops/src/seed/demo/index.ts b/packages/data-ops/src/seed/demo/index.ts index 90d72fcd..d7bc520d 100644 --- a/packages/data-ops/src/seed/demo/index.ts +++ b/packages/data-ops/src/seed/demo/index.ts @@ -12,10 +12,14 @@ import { seedGrades } from './seeders/grades.seeder' import { seedAttendance } from './seeders/attendance.seeder' import { seedConduct } from './seeders/conduct.seeder' import { seedFinance } from './seeders/finance.seeder' +import { seedAcademicCatalog } from './seeders/academic-catalog.seeder' +import { seedOperations } from './seeders/operations.seeder' +import { summarizeRealisticDemoCoverage } from './seeders/catalog-realism-helpers' +import { buildDemoMetrics } from './seeders/demo-metrics-helpers' import { buildStories } from './story-builder' import * as schoolSchema from '../../drizzle/school-schema' import * as coreSchema from '../../drizzle/core-schema' -import { eq, sql } from 'drizzle-orm' +import { and, eq, inArray, sql } from 'drizzle-orm' import { validateDistribution } from './scenarios/picker' /** @@ -32,20 +36,148 @@ async function computeDemoStats(db: Database, schoolId: string): Promise { + const schoolSubjects = await db + .select({ count: sql`count(*)` }) + .from(schoolSchema.schoolSubjects) + .where(eq(schoolSchema.schoolSubjects.schoolId, schoolId)) + + const schoolSubjectCoefficients = await db + .select({ count: sql`count(*)` }) + .from(schoolSchema.schoolSubjectCoefficients) + .where(eq(schoolSchema.schoolSubjectCoefficients.schoolId, schoolId)) + + const classSubjects = await db + .select({ count: sql`count(*)` }) + .from(schoolSchema.classSubjects) + .innerJoin(schoolSchema.classes, eq(schoolSchema.classSubjects.classId, schoolSchema.classes.id)) + .where(eq(schoolSchema.classes.schoolId, schoolId)) + + const timetableSessions = await db + .select({ count: sql`count(*)` }) + .from(schoolSchema.timetableSessions) + .where(eq(schoolSchema.timetableSessions.schoolId, schoolId)) + + const classSessions = await db + .select({ count: sql`count(*)` }) + .from(schoolSchema.classSessions) + .innerJoin(schoolSchema.classes, eq(schoolSchema.classSessions.classId, schoolSchema.classes.id)) + .where(eq(schoolSchema.classes.schoolId, schoolId)) + + const curriculumProgress = await db + .select({ count: sql`count(*)` }) + .from(schoolSchema.curriculumProgress) + .innerJoin(schoolSchema.classes, eq(schoolSchema.curriculumProgress.classId, schoolSchema.classes.id)) + .where(eq(schoolSchema.classes.schoolId, schoolId)) + + const reportCards = await db + .select({ count: sql`count(*)` }) + .from(schoolSchema.reportCards) + .where(eq(schoolSchema.reportCards.schoolYearId, schoolYearId)) + + const teacherComments = await db + .select({ count: sql`count(*)` }) + .from(schoolSchema.teacherComments) + .innerJoin(schoolSchema.reportCards, eq(schoolSchema.teacherComments.reportCardId, schoolSchema.reportCards.id)) + .where(eq(schoolSchema.reportCards.schoolYearId, schoolYearId)) + + const teacherMessages = await db + .select({ count: sql`count(*)` }) + .from(schoolSchema.teacherMessages) + .where(eq(schoolSchema.teacherMessages.schoolId, schoolId)) + + const teacherNotifications = await db + .select({ count: sql`count(*)` }) + .from(schoolSchema.teacherNotifications) + .innerJoin(schoolSchema.teachers, eq(schoolSchema.teacherNotifications.teacherId, schoolSchema.teachers.id)) + .where(eq(schoolSchema.teachers.schoolId, schoolId)) + + const studentAverages = await db + .select({ count: sql`count(*)` }) + .from(schoolSchema.studentAverages) + .innerJoin(schoolSchema.classes, eq(schoolSchema.studentAverages.classId, schoolSchema.classes.id)) + .where(eq(schoolSchema.classes.schoolId, schoolId)) + + const payments = await db + .select({ count: sql`count(*)` }) + .from(schoolSchema.payments) + .where(eq(schoolSchema.payments.schoolId, schoolId)) + + const coverage = summarizeRealisticDemoCoverage({ + schoolSubjects: Number(schoolSubjects[0]?.count || 0), + schoolSubjectCoefficients: Number(schoolSubjectCoefficients[0]?.count || 0), + classSubjects: Number(classSubjects[0]?.count || 0), + timetableSessions: Number(timetableSessions[0]?.count || 0), + classSessions: Number(classSessions[0]?.count || 0), + curriculumProgress: Number(curriculumProgress[0]?.count || 0), + studentAverages: Number(studentAverages[0]?.count || 0), + reportCards: Number(reportCards[0]?.count || 0), + teacherComments: Number(teacherComments[0]?.count || 0), + teacherMessages: Number(teacherMessages[0]?.count || 0), + teacherNotifications: Number(teacherNotifications[0]?.count || 0), + payments: Number(payments[0]?.count || 0), + }) + + if (!coverage.isValid) { + throw new Error(`Realistic demo seed is incomplete. Missing table coverage: ${coverage.missingTables.join(', ')}`) } } @@ -132,6 +264,15 @@ export async function seedDemoData(db: Database, config: DemoSeedConfig): R.Resu ), // 5. Seed Teachers + R.andThen((ctx: ChainState) => { + if (!ctx.schoolContext) return R.fail(new DatabaseError('INTERNAL_ERROR', 'Missing school context')) + return R.pipe( + seedAcademicCatalog(db, ctx.schoolContext, ctx.config), + R.map(() => ctx) + ) + }), + + // 6. Seed Teachers R.andThen((ctx: ChainState) => { if (!ctx.demoContext || !ctx.schoolContext) return R.fail(new DatabaseError('INTERNAL_ERROR', 'Missing context')) return R.pipe( @@ -140,7 +281,7 @@ export async function seedDemoData(db: Database, config: DemoSeedConfig): R.Resu ) }), - // 6. Seed Students & Parents + // 7. Seed Students & Parents R.andThen((ctx: ChainState) => { if (!ctx.demoContext || !ctx.schoolContext) return R.fail(new DatabaseError('INTERNAL_ERROR', 'Missing context')) return R.pipe( @@ -149,7 +290,7 @@ export async function seedDemoData(db: Database, config: DemoSeedConfig): R.Resu ) }), - // 7. Seed Classes & Enrollments + // 8. Seed Classes & Enrollments R.andThen((ctx: ChainState) => { if (!ctx.demoContext || !ctx.schoolContext || !ctx.teachers || !ctx.studentContext) return R.fail(new DatabaseError('INTERNAL_ERROR', 'Missing context')) return R.pipe( @@ -158,7 +299,7 @@ export async function seedDemoData(db: Database, config: DemoSeedConfig): R.Resu ) }), - // 8. Seed Academic Data (Grades) + // 9. Seed Academic Data (Grades) R.andThen((ctx: ChainState) => { if (!ctx.demoContext || !ctx.schoolContext || !ctx.classContext || !ctx.studentContext) return R.fail(new DatabaseError('INTERNAL_ERROR', 'Missing context')) return R.pipe( @@ -167,7 +308,7 @@ export async function seedDemoData(db: Database, config: DemoSeedConfig): R.Resu ) }), - // 9. Seed Attendance + // 10. Seed Attendance R.andThen((ctx: ChainState) => { if (!ctx.schoolContext || !ctx.classContext || !ctx.studentContext || !ctx.teachers) return R.fail(new DatabaseError('INTERNAL_ERROR', 'Missing context')) return R.pipe( @@ -176,7 +317,7 @@ export async function seedDemoData(db: Database, config: DemoSeedConfig): R.Resu ) }), - // 10. Seed Conduct (Incidents) + // 11. Seed Conduct (Incidents) R.andThen((ctx: ChainState) => { if (!ctx.schoolContext || !ctx.classContext || !ctx.studentContext || !ctx.teachers) return R.fail(new DatabaseError('INTERNAL_ERROR', 'Missing context')) return R.pipe( @@ -185,7 +326,16 @@ export async function seedDemoData(db: Database, config: DemoSeedConfig): R.Resu ) }), - // 11. Seed Finance (Invoices, Payments) + // 12. Seed Operations (staff, timetable, sessions, report cards) + R.andThen((ctx: ChainState) => { + if (!ctx.demoContext || !ctx.schoolContext || !ctx.classContext || !ctx.studentContext || !ctx.teachers) return R.fail(new DatabaseError('INTERNAL_ERROR', 'Missing context')) + return R.pipe( + seedOperations(db, ctx.demoContext, ctx.schoolContext, ctx.classContext, ctx.studentContext, ctx.teachers), + R.map(() => ctx) + ) + }), + + // 13. Seed Finance (Invoices, Payments) R.andThen((ctx: ChainState) => { if (!ctx.demoContext || !ctx.schoolContext || !ctx.classContext || !ctx.studentContext || !ctx.teachers) return R.fail(new DatabaseError('INTERNAL_ERROR', 'Missing context')) return R.pipe( @@ -194,7 +344,7 @@ export async function seedDemoData(db: Database, config: DemoSeedConfig): R.Resu ) }), - // 12. Build AI-Driven Stories + // 14. Build AI-Driven Stories R.andThen((ctx: ChainState) => { if (!ctx.schoolContext || !ctx.classContext || !ctx.studentContext) return R.fail(new DatabaseError('INTERNAL_ERROR', 'Missing context')) return R.pipe( @@ -203,6 +353,19 @@ export async function seedDemoData(db: Database, config: DemoSeedConfig): R.Resu ) }), + // 15. Validate production-like demo coverage + R.andThen((ctx: ChainState) => R.try({ + try: async () => { + if (!ctx.schoolId || !ctx.schoolContext) { + throw new Error('Missing school context for realistic coverage validation') + } + + await validateRealisticDemoCoverage(db, ctx.schoolId, ctx.schoolContext.schoolYear.id) + return ctx + }, + catch: (err) => DatabaseError.from(err, 'INTERNAL_ERROR', 'Failed to validate realistic demo coverage') + })), + // Final Success Step R.andThen((ctx: ChainState) => R.try({ try: async () => { diff --git a/packages/data-ops/src/seed/demo/reset.ts b/packages/data-ops/src/seed/demo/reset.ts index e38a9110..9cfd47ca 100644 --- a/packages/data-ops/src/seed/demo/reset.ts +++ b/packages/data-ops/src/seed/demo/reset.ts @@ -4,6 +4,28 @@ import { databaseLogger, tapLogErr } from '@repo/logger' import { DatabaseError } from '@repo/data-ops/errors' import type { Database } from '@repo/data-ops/database/setup' import * as schoolSchema from '@repo/data-ops/drizzle/school-schema' +import { executeWithRetry, runChunkedWithRetry } from './seeders/seed-resilience-helpers' + +const RESET_DELETE_CHUNK_SIZE = 200 +const RESET_DELETE_MAX_ATTEMPTS = 4 + +async function deleteIdsInChunks( + ids: string[], + deleter: (chunk: string[]) => Promise, +) { + if (ids.length === 0) { + return + } + + await runChunkedWithRetry(ids, { + chunkSize: RESET_DELETE_CHUNK_SIZE, + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + handler: async (chunk) => { + await deleter(chunk) + }, + }) +} /** * Robust reset of school demo data before re-seeding. @@ -18,168 +40,220 @@ export function resetDemoData( try: async () => { databaseLogger.info(`Resetting demo data for school: ${schoolId}`) - // --- Prepare Common Subqueries for Scoping --- - const schoolStudents = db - .select({ id: schoolSchema.students.id }) - .from(schoolSchema.students) - .where(eq(schoolSchema.students.schoolId, schoolId)) - - const schoolTeachers = db - .select({ id: schoolSchema.teachers.id }) - .from(schoolSchema.teachers) - .where(eq(schoolSchema.teachers.schoolId, schoolId)) - - const schoolClasses = db - .select({ id: schoolSchema.classes.id }) - .from(schoolSchema.classes) - .where(eq(schoolSchema.classes.schoolId, schoolId)) - - const schoolPayments = db - .select({ id: schoolSchema.payments.id }) - .from(schoolSchema.payments) - .where(eq(schoolSchema.payments.schoolId, schoolId)) - - const schoolAccounts = db - .select({ id: schoolSchema.accounts.id }) - .from(schoolSchema.accounts) - .where(eq(schoolSchema.accounts.schoolId, schoolId)) - - const schoolPaymentPlans = db - .select({ id: schoolSchema.paymentPlans.id }) - .from(schoolSchema.paymentPlans) - .where(inArray(schoolSchema.paymentPlans.studentId, schoolStudents)) - - const schoolHomework = db - .select({ id: schoolSchema.homework.id }) - .from(schoolSchema.homework) - .where(eq(schoolSchema.homework.schoolId, schoolId)) - - const schoolConductRecords = db - .select({ id: schoolSchema.conductRecords.id }) - .from(schoolSchema.conductRecords) - .where(eq(schoolSchema.conductRecords.schoolId, schoolId)) - - const schoolSchoolYears = db - .select({ id: schoolSchema.schoolYears.id }) - .from(schoolSchema.schoolYears) - .where(eq(schoolSchema.schoolYears.schoolId, schoolId)) + const [ + studentRows, + teacherRows, + staffRows, + classroomRows, + schoolYearRows, + homeworkRows, + conductRecordRows, + paymentRows, + transactionRows, + studentAttendanceRows, + classSessionRows, + curriculumProgressRows, + chapterCompletionRows, + studentAverageRows, + gradeRows, + reportCardRows, + studentParentRows, + demoUserRows, + ] = await Promise.all([ + db.select({ id: schoolSchema.students.id }).from(schoolSchema.students).where(eq(schoolSchema.students.schoolId, schoolId)), + db.select({ id: schoolSchema.teachers.id }).from(schoolSchema.teachers).where(eq(schoolSchema.teachers.schoolId, schoolId)), + db.select({ id: schoolSchema.staff.id }).from(schoolSchema.staff).where(eq(schoolSchema.staff.schoolId, schoolId)), + db.select({ id: schoolSchema.classrooms.id }).from(schoolSchema.classrooms).where(eq(schoolSchema.classrooms.schoolId, schoolId)), + db.select({ id: schoolSchema.schoolYears.id }).from(schoolSchema.schoolYears).where(eq(schoolSchema.schoolYears.schoolId, schoolId)), + db.select({ id: schoolSchema.homework.id }).from(schoolSchema.homework).where(eq(schoolSchema.homework.schoolId, schoolId)), + db.select({ id: schoolSchema.conductRecords.id }).from(schoolSchema.conductRecords).where(eq(schoolSchema.conductRecords.schoolId, schoolId)), + db.select({ id: schoolSchema.payments.id }).from(schoolSchema.payments).where(eq(schoolSchema.payments.schoolId, schoolId)), + db.select({ id: schoolSchema.transactions.id }).from(schoolSchema.transactions).where(eq(schoolSchema.transactions.schoolId, schoolId)), + db.select({ id: schoolSchema.studentAttendance.id }).from(schoolSchema.studentAttendance).where(eq(schoolSchema.studentAttendance.schoolId, schoolId)), + db.select({ id: schoolSchema.classSessions.id }).from(schoolSchema.classSessions).where(inArray(schoolSchema.classSessions.classId, db.select({ id: schoolSchema.classes.id }).from(schoolSchema.classes).where(eq(schoolSchema.classes.schoolId, schoolId)))), + db.select({ id: schoolSchema.curriculumProgress.id }).from(schoolSchema.curriculumProgress).where(inArray(schoolSchema.curriculumProgress.classId, db.select({ id: schoolSchema.classes.id }).from(schoolSchema.classes).where(eq(schoolSchema.classes.schoolId, schoolId)))), + db.select({ id: schoolSchema.chapterCompletions.id }).from(schoolSchema.chapterCompletions).where(inArray(schoolSchema.chapterCompletions.classId, db.select({ id: schoolSchema.classes.id }).from(schoolSchema.classes).where(eq(schoolSchema.classes.schoolId, schoolId)))), + db.select({ id: schoolSchema.studentAverages.id }).from(schoolSchema.studentAverages).where(inArray(schoolSchema.studentAverages.studentId, db.select({ id: schoolSchema.students.id }).from(schoolSchema.students).where(eq(schoolSchema.students.schoolId, schoolId)))), + db.select({ id: schoolSchema.studentGrades.id }).from(schoolSchema.studentGrades).where(inArray(schoolSchema.studentGrades.studentId, db.select({ id: schoolSchema.students.id }).from(schoolSchema.students).where(eq(schoolSchema.students.schoolId, schoolId)))), + db.select({ id: schoolSchema.reportCards.id }).from(schoolSchema.reportCards).where(inArray(schoolSchema.reportCards.studentId, db.select({ id: schoolSchema.students.id }).from(schoolSchema.students).where(eq(schoolSchema.students.schoolId, schoolId)))), + db.select({ parentId: schoolSchema.studentParents.parentId }).from(schoolSchema.studentParents).where(inArray(schoolSchema.studentParents.studentId, db.select({ id: schoolSchema.students.id }).from(schoolSchema.students).where(eq(schoolSchema.students.schoolId, schoolId)))), + db.select({ userId: schoolSchema.userSchools.userId }).from(schoolSchema.userSchools).where(eq(schoolSchema.userSchools.schoolId, schoolId)), + ]) - const schoolStudentParents = db - .select({ parentId: schoolSchema.studentParents.parentId }) - .from(schoolSchema.studentParents) - .where(inArray(schoolSchema.studentParents.studentId, schoolStudents)) + const studentIds = studentRows.map(row => row.id) + const teacherIds = teacherRows.map(row => row.id) + const staffIds = staffRows.map(row => row.id) + const classroomIds = classroomRows.map(row => row.id) + const schoolYearIds = schoolYearRows.map(row => row.id) + const homeworkIds = homeworkRows.map(row => row.id) + const conductRecordIds = conductRecordRows.map(row => row.id) + const paymentIds = paymentRows.map(row => row.id) + const transactionIds = transactionRows.map(row => row.id) + const attendanceIds = studentAttendanceRows.map(row => row.id) + const classSessionIds = classSessionRows.map(row => row.id) + const curriculumProgressIds = curriculumProgressRows.map(row => row.id) + const chapterCompletionIds = chapterCompletionRows.map(row => row.id) + const studentAverageIds = studentAverageRows.map(row => row.id) + const gradeIds = gradeRows.map(row => row.id) + const reportCardIds = reportCardRows.map(row => row.id) + const parentIds = [...new Set(studentParentRows.map(row => row.parentId))] + const demoUserIds = [...new Set(demoUserRows.map(row => row.userId))] - const schoolReportCards = db - .select({ id: schoolSchema.reportCards.id }) - .from(schoolSchema.reportCards) - .where(inArray(schoolSchema.reportCards.studentId, schoolStudents)) + await deleteIdsInChunks(transactionIds, chunk => + db.delete(schoolSchema.transactions).where(inArray(schoolSchema.transactions.id, chunk)).execute(), + ) + await deleteIdsInChunks(gradeIds, chunk => + db.delete(schoolSchema.studentGrades).where(inArray(schoolSchema.studentGrades.id, chunk)).execute(), + ) + await deleteIdsInChunks(reportCardIds, chunk => + db.delete(schoolSchema.reportCards).where(inArray(schoolSchema.reportCards.id, chunk)).execute(), + ) + await deleteIdsInChunks(attendanceIds, chunk => + db.delete(schoolSchema.studentAttendance).where(inArray(schoolSchema.studentAttendance.id, chunk)).execute(), + ) + await deleteIdsInChunks(classSessionIds, chunk => + db.delete(schoolSchema.classSessions).where(inArray(schoolSchema.classSessions.id, chunk)).execute(), + ) + await deleteIdsInChunks(curriculumProgressIds, chunk => + db.delete(schoolSchema.curriculumProgress).where(inArray(schoolSchema.curriculumProgress.id, chunk)).execute(), + ) + await deleteIdsInChunks(chapterCompletionIds, chunk => + db.delete(schoolSchema.chapterCompletions).where(inArray(schoolSchema.chapterCompletions.id, chunk)).execute(), + ) + await deleteIdsInChunks(studentAverageIds, chunk => + db.delete(schoolSchema.studentAverages).where(inArray(schoolSchema.studentAverages.id, chunk)).execute(), + ) + await deleteIdsInChunks(homeworkIds, chunk => + db.delete(schoolSchema.homework).where(inArray(schoolSchema.homework.id, chunk)).execute(), + ) + await deleteIdsInChunks(conductRecordIds, chunk => + db.delete(schoolSchema.conductRecords).where(inArray(schoolSchema.conductRecords.id, chunk)).execute(), + ) + await deleteIdsInChunks(paymentIds, chunk => + db.delete(schoolSchema.payments).where(inArray(schoolSchema.payments.id, chunk)).execute(), + ) - const schoolGrades = db - .select({ id: schoolSchema.studentGrades.id }) - .from(schoolSchema.studentGrades) - .where(inArray(schoolSchema.studentGrades.studentId, schoolStudents)) + await executeWithRetry(async () => { await db.delete(schoolSchema.teacherMessages).where(eq(schoolSchema.teacherMessages.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.refunds).where(eq(schoolSchema.refunds.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.teacherAttendance).where(eq(schoolSchema.teacherAttendance.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.attendanceAlerts).where(eq(schoolSchema.attendanceAlerts.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.teacherNotifications).where(inArray(schoolSchema.teacherNotifications.teacherId, teacherIds.length > 0 ? teacherIds : ['__no_teacher__'])).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.academicIntentions).where(eq(schoolSchema.academicIntentions.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.participationGrades).where(inArray(schoolSchema.participationGrades.studentId, studentIds.length > 0 ? studentIds : ['__no_student__'])).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.matriculeHistory).where(inArray(schoolSchema.matriculeHistory.studentId, studentIds.length > 0 ? studentIds : ['__no_student__'])).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.matriculeSequences).where(eq(schoolSchema.matriculeSequences.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.schoolSubjectCoefficients).where(eq(schoolSchema.schoolSubjectCoefficients.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) - // --- Level 1: Leaf Tables (Dependencies) --- + await deleteIdsInChunks(schoolYearIds, chunk => + db.delete(schoolSchema.schoolYears).where(inArray(schoolSchema.schoolYears.id, chunk)).execute(), + ) + await deleteIdsInChunks(studentIds, chunk => + db.delete(schoolSchema.students).where(inArray(schoolSchema.students.id, chunk)).execute(), + ) + await deleteIdsInChunks(parentIds, chunk => + db.delete(schoolSchema.parents).where(inArray(schoolSchema.parents.id, chunk)).execute(), + ) + await deleteIdsInChunks(classroomIds, chunk => + db.delete(schoolSchema.classrooms).where(inArray(schoolSchema.classrooms.id, chunk)).execute(), + ) + await deleteIdsInChunks(teacherIds, chunk => + db.delete(schoolSchema.teachers).where(inArray(schoolSchema.teachers.id, chunk)).execute(), + ) + await deleteIdsInChunks(staffIds, chunk => + db.delete(schoolSchema.staff).where(inArray(schoolSchema.staff.id, chunk)).execute(), + ) - // Academic Progress & Results - await db.delete(schoolSchema.homeworkSubmissions).where(inArray(schoolSchema.homeworkSubmissions.homeworkId, schoolHomework)).execute() - await db.delete(schoolSchema.chapterCompletions).where(inArray(schoolSchema.chapterCompletions.classId, schoolClasses)).execute() - await db.delete(schoolSchema.curriculumProgress).where(inArray(schoolSchema.curriculumProgress.classId, schoolClasses)).execute() - await db.delete(schoolSchema.studentAverages).where(inArray(schoolSchema.studentAverages.studentId, schoolStudents)).execute() - await db.delete(schoolSchema.gradeValidations).where(inArray(schoolSchema.gradeValidations.gradeId, schoolGrades)).execute() - await db.delete(schoolSchema.studentGrades).where(inArray(schoolSchema.studentGrades.studentId, schoolStudents)).execute() - await db.delete(schoolSchema.participationGrades).where(inArray(schoolSchema.participationGrades.studentId, schoolStudents)).execute() - - await db.delete(schoolSchema.teacherComments).where(inArray(schoolSchema.teacherComments.reportCardId, schoolReportCards)).execute() - await db.delete(schoolSchema.reportCards).where(inArray(schoolSchema.reportCards.studentId, schoolStudents)).execute() - - // Scheduling & Attendance - await db.delete(schoolSchema.classSessions).where(inArray(schoolSchema.classSessions.classId, schoolClasses)).execute() - await db.delete(schoolSchema.studentAttendance).where(eq(schoolSchema.studentAttendance.schoolId, schoolId)).execute() - await db.delete(schoolSchema.teacherAttendance).where(eq(schoolSchema.teacherAttendance.schoolId, schoolId)).execute() - await db.delete(schoolSchema.attendanceAlerts).where(eq(schoolSchema.attendanceAlerts.schoolId, schoolId)).execute() - await db.delete(schoolSchema.timetableSessions).where(eq(schoolSchema.timetableSessions.schoolId, schoolId)).execute() + await executeWithRetry(async () => { await db.delete(schoolSchema.schoolSubjects).where(eq(schoolSchema.schoolSubjects.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.paymentPlanTemplates).where(eq(schoolSchema.paymentPlanTemplates.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.feeTypes).where(eq(schoolSchema.feeTypes.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.discounts).where(eq(schoolSchema.discounts.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.accounts).where(eq(schoolSchema.accounts.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.reportCardTemplates).where(eq(schoolSchema.reportCardTemplates.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.messageTemplates).where(eq(schoolSchema.messageTemplates.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.attendanceSettings).where(eq(schoolSchema.attendanceSettings.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.schoolFiles).where(eq(schoolSchema.schoolFiles.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.trackingEvents).where(eq(schoolSchema.trackingEvents.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) + await executeWithRetry(async () => { await db.delete(schoolSchema.receiptSequences).where(eq(schoolSchema.receiptSequences.schoolId, schoolId)).execute() }, { + maxAttempts: RESET_DELETE_MAX_ATTEMPTS, + delayMs: 250, + }) - // Financial Allocations - await db.delete(schoolSchema.paymentAllocations).where(inArray(schoolSchema.paymentAllocations.paymentId, schoolPayments)).execute() - await db.delete(schoolSchema.transactionLines).where(inArray(schoolSchema.transactionLines.accountId, schoolAccounts)).execute() - await db.delete(schoolSchema.installments).where(inArray(schoolSchema.installments.paymentPlanId, schoolPaymentPlans)).execute() - await db.delete(schoolSchema.studentFees).where(inArray(schoolSchema.studentFees.studentId, schoolStudents)).execute() - await db.delete(schoolSchema.studentDiscounts).where(inArray(schoolSchema.studentDiscounts.studentId, schoolStudents)).execute() - - // Communication & Conduct - await db.delete(schoolSchema.teacherNotifications).where(inArray(schoolSchema.teacherNotifications.teacherId, schoolTeachers)).execute() - await db.delete(schoolSchema.conductFollowUps).where(inArray(schoolSchema.conductFollowUps.conductRecordId, schoolConductRecords)).execute() - - // --- Level 2: Core Records --- - - await db.delete(schoolSchema.homework).where(eq(schoolSchema.homework.schoolId, schoolId)).execute() - await db.delete(schoolSchema.enrollments).where(inArray(schoolSchema.enrollments.studentId, schoolStudents)).execute() - await db.delete(schoolSchema.teacherMessages).where(eq(schoolSchema.teacherMessages.schoolId, schoolId)).execute() - await db.delete(schoolSchema.conductRecords).where(eq(schoolSchema.conductRecords.schoolId, schoolId)).execute() - await db.delete(schoolSchema.receipts).where(inArray(schoolSchema.receipts.paymentId, schoolPayments)).execute() - await db.delete(schoolSchema.refunds).where(eq(schoolSchema.refunds.schoolId, schoolId)).execute() - await db.delete(schoolSchema.payments).where(eq(schoolSchema.payments.schoolId, schoolId)).execute() - await db.delete(schoolSchema.paymentPlans).where(inArray(schoolSchema.paymentPlans.studentId, schoolStudents)).execute() - await db.delete(schoolSchema.classSubjects).where(inArray(schoolSchema.classSubjects.classId, schoolClasses)).execute() - await db.delete(schoolSchema.teacherSubjects).where(inArray(schoolSchema.teacherSubjects.teacherId, schoolTeachers)).execute() - - // --- Level 3: Definitions & Infrastructure --- - - await db.delete(schoolSchema.academicIntentions).where(eq(schoolSchema.academicIntentions.schoolId, schoolId)).execute() - await db.delete(schoolSchema.matriculeHistory).where(inArray(schoolSchema.matriculeHistory.studentId, schoolStudents)).execute() - await db.delete(schoolSchema.matriculeSequences).where(eq(schoolSchema.matriculeSequences.schoolId, schoolId)).execute() - await db.delete(schoolSchema.studentParents).where(inArray(schoolSchema.studentParents.studentId, schoolStudents)).execute() - - const schoolParents = db - .select({ id: schoolSchema.parents.id }) - .from(schoolSchema.parents) - .where(inArray(schoolSchema.parents.id, schoolStudentParents)) - - await db.delete(schoolSchema.parents).where(inArray(schoolSchema.parents.id, schoolParents)).execute() - await db.delete(schoolSchema.students).where(eq(schoolSchema.students.schoolId, schoolId)).execute() - await db.delete(schoolSchema.classes).where(eq(schoolSchema.classes.schoolId, schoolId)).execute() - await db.delete(schoolSchema.terms).where(inArray(schoolSchema.terms.schoolYearId, schoolSchoolYears)).execute() - await db.delete(schoolSchema.schoolYears).where(eq(schoolSchema.schoolYears.schoolId, schoolId)).execute() - await db.delete(schoolSchema.classrooms).where(eq(schoolSchema.classrooms.schoolId, schoolId)).execute() - - await db.delete(schoolSchema.teachers).where(eq(schoolSchema.teachers.schoolId, schoolId)).execute() - await db.delete(schoolSchema.staff).where(eq(schoolSchema.staff.schoolId, schoolId)).execute() - - // Financial Settings - await db.delete(schoolSchema.paymentPlanTemplates).where(eq(schoolSchema.paymentPlanTemplates.schoolId, schoolId)).execute() - await db.delete(schoolSchema.feeTypes).where(eq(schoolSchema.feeTypes.schoolId, schoolId)).execute() - await db.delete(schoolSchema.discounts).where(eq(schoolSchema.discounts.schoolId, schoolId)).execute() - await db.delete(schoolSchema.accounts).where(eq(schoolSchema.accounts.schoolId, schoolId)).execute() - - // Reports & Global Settings - await db.delete(schoolSchema.reportCardTemplates).where(eq(schoolSchema.reportCardTemplates.schoolId, schoolId)).execute() - await db.delete(schoolSchema.messageTemplates).where(eq(schoolSchema.messageTemplates.schoolId, schoolId)).execute() - await db.delete(schoolSchema.attendanceSettings).where(eq(schoolSchema.attendanceSettings.schoolId, schoolId)).execute() - await db.delete(schoolSchema.schoolFiles).where(eq(schoolSchema.schoolFiles.schoolId, schoolId)).execute() - await db.delete(schoolSchema.trackingEvents).where(eq(schoolSchema.trackingEvents.schoolId, schoolId)).execute() + await db.delete(schoolSchema.userRoles).where(eq(schoolSchema.userRoles.schoolId, schoolId)).execute() + await db.delete(schoolSchema.userSchools).where(eq(schoolSchema.userSchools.schoolId, schoolId)).execute() - // --- IAM & Users (Final step) --- - const demoUserIdsResult = await db + const remainingUserRows = demoUserIds.length > 0 + ? await db .select({ userId: schoolSchema.userSchools.userId }) .from(schoolSchema.userSchools) - .where(eq(schoolSchema.userSchools.schoolId, schoolId)) + .where(inArray(schoolSchema.userSchools.userId, demoUserIds)) .execute() + : [] - const demoUserIds = demoUserIdsResult.map(r => r.userId) - - await db.delete(schoolSchema.userRoles).where(eq(schoolSchema.userRoles.schoolId, schoolId)).execute() - await db.delete(schoolSchema.userSchools).where(eq(schoolSchema.userSchools.schoolId, schoolId)).execute() - - // Only delete users that are truly orphaned (not in any other school) - if (demoUserIds.length > 0) { - const orphanedUsers = db - .select({ id: schoolSchema.users.id }) - .from(schoolSchema.users) - .where(inArray(schoolSchema.users.id, demoUserIds)) - - await db.delete(schoolSchema.users).where(inArray(schoolSchema.users.id, orphanedUsers)).execute() - } + const survivingUserIds = new Set(remainingUserRows.map(row => row.userId)) + const orphanedUserIds = demoUserIds.filter(userId => !survivingUserIds.has(userId)) + await deleteIdsInChunks(orphanedUserIds, chunk => + db.delete(schoolSchema.users).where(inArray(schoolSchema.users.id, chunk)).execute(), + ) databaseLogger.info(`Successfully reset demo data for school: ${schoolId}`) return undefined diff --git a/packages/data-ops/src/seed/demo/seeders/academic-catalog.seeder.ts b/packages/data-ops/src/seed/demo/seeders/academic-catalog.seeder.ts new file mode 100644 index 00000000..3aab7fb2 --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/academic-catalog.seeder.ts @@ -0,0 +1,194 @@ +import { Result as R } from '@praha/byethrow' +import { and, eq, inArray } from 'drizzle-orm' +import { databaseLogger, tapLogErr } from '@repo/logger' +import { DatabaseError } from '@repo/data-ops/errors' +import type { Database } from '@repo/data-ops/database/setup' +import * as coreSchema from '@repo/data-ops/drizzle/core-schema' +import * as schoolSchema from '@repo/data-ops/drizzle/school-schema' +import type { DemoSeedConfig, SchoolContext } from '../config' +import { + buildFallbackProgramTemplateChapters, + buildImportedProgramTemplateChapters, + buildFallbackProgramTemplates, + buildSchoolCoefficientOverrides, +} from './catalog-realism-helpers' +import { loadLessonProgressRows } from './curriculum-library' + +/** + * Seed school-specific academic offerings from platform curriculum templates. + * This mirrors the real product flow where subjects are activated for a school + * after the academic year exists and before teachers / classes rely on them. + */ +export function seedAcademicCatalog( + db: Database, + schoolContext: SchoolContext, + config: DemoSeedConfig, +): R.ResultAsync<{ schoolSubjects: number, schoolSubjectCoefficients: number }, DatabaseError> { + return R.pipe( + R.try({ + try: async () => { + const { school, schoolYear } = schoolContext + + const targetGrades = await db + .select({ id: coreSchema.grades.id, name: coreSchema.grades.name }) + .from(coreSchema.grades) + .where(inArray(coreSchema.grades.name, [...config.gradeNames])) + + if (targetGrades.length === 0) { + throw new Error('No configured demo grades found in core schema.') + } + + const targetGradeIds = targetGrades.map(grade => grade.id) + + const subjectRows = await db + .select({ id: coreSchema.subjects.id, name: coreSchema.subjects.name }) + .from(coreSchema.subjects) + + const coefficientTemplates = await db + .select({ + id: coreSchema.coefficientTemplates.id, + subjectId: coreSchema.coefficientTemplates.subjectId, + gradeId: coreSchema.coefficientTemplates.gradeId, + seriesId: coreSchema.coefficientTemplates.seriesId, + weight: coreSchema.coefficientTemplates.weight, + }) + .from(coreSchema.coefficientTemplates) + .where(and( + eq(coreSchema.coefficientTemplates.schoolYearTemplateId, schoolYear.schoolYearTemplateId), + inArray(coreSchema.coefficientTemplates.gradeId, targetGradeIds), + )) + + if (coefficientTemplates.length === 0) { + throw new Error('No coefficient templates found for the configured demo grades.') + } + + let programs = await db + .select({ + subjectId: coreSchema.programTemplates.subjectId, + }) + .from(coreSchema.programTemplates) + .where(and( + eq(coreSchema.programTemplates.schoolYearTemplateId, schoolYear.schoolYearTemplateId), + inArray(coreSchema.programTemplates.gradeId, targetGradeIds), + )) + + if (programs.length === 0) { + const fallbackPrograms = buildFallbackProgramTemplates({ + schoolYearTemplateId: schoolYear.schoolYearTemplateId, + targetGradeIds, + targetGrades, + subjects: subjectRows, + coefficientTemplates, + }) + + if (fallbackPrograms.length === 0) { + throw new Error('No curriculum templates or coefficient-backed fallback programs found for the configured demo grades.') + } + + await db + .insert(coreSchema.programTemplates) + .values( + fallbackPrograms.map(program => ({ + id: program.id, + name: program.name, + schoolYearTemplateId: program.schoolYearTemplateId, + gradeId: program.gradeId, + subjectId: program.subjectId, + status: program.status, + createdAt: new Date(), + updatedAt: new Date(), + })), + ) + .onConflictDoNothing() + + const fallbackChapters = buildFallbackProgramTemplateChapters(fallbackPrograms) + const importedChapters = buildImportedProgramTemplateChapters({ + programTemplates: fallbackPrograms, + gradeNamesById: new Map(targetGrades.map(grade => [grade.id, grade.name])), + subjectNamesById: new Map(subjectRows.map(subject => [subject.id, subject.name])), + lessonRows: loadLessonProgressRows(), + }) + const chaptersToInsert = importedChapters.length > 0 ? importedChapters : fallbackChapters + + await db + .insert(coreSchema.programTemplateChapters) + .values( + chaptersToInsert.map(chapter => ({ + id: chapter.id, + title: chapter.title, + objectives: chapter.objectives, + order: chapter.order, + durationHours: chapter.durationHours, + programTemplateId: chapter.programTemplateId, + createdAt: new Date(), + updatedAt: new Date(), + })), + ) + .onConflictDoNothing() + + programs = await db + .select({ + subjectId: coreSchema.programTemplates.subjectId, + }) + .from(coreSchema.programTemplates) + .where(and( + eq(coreSchema.programTemplates.schoolYearTemplateId, schoolYear.schoolYearTemplateId), + inArray(coreSchema.programTemplates.gradeId, targetGradeIds), + )) + } + + const uniqueSubjectIds = [...new Set(programs.map(program => program.subjectId))] + + const insertedSubjects = await db + .insert(schoolSchema.schoolSubjects) + .values( + uniqueSubjectIds.map(subjectId => ({ + id: crypto.randomUUID(), + schoolId: school.id, + subjectId, + schoolYearId: schoolYear.id, + status: 'active' as const, + createdAt: new Date(), + updatedAt: new Date(), + })), + ) + .onConflictDoNothing() + .returning() + + const activeCoefficientTemplates = coefficientTemplates.filter(template => uniqueSubjectIds.includes(template.subjectId)) + + if (activeCoefficientTemplates.length === 0) { + throw new Error('No coefficient templates found for the configured demo grades and subjects.') + } + + const insertedCoefficients = await db + .insert(schoolSchema.schoolSubjectCoefficients) + .values( + buildSchoolCoefficientOverrides({ + schoolId: school.id, + targetGradeIds, + activeSubjectIds: uniqueSubjectIds, + coefficientTemplates: activeCoefficientTemplates, + }).map(override => ({ + id: crypto.randomUUID(), + schoolId: override.schoolId, + coefficientTemplateId: override.coefficientTemplateId, + weightOverride: override.weightOverride, + createdAt: new Date(), + updatedAt: new Date(), + })), + ) + .onConflictDoNothing() + .returning() + + databaseLogger.info(`Seeded ${insertedSubjects.length} school subjects and ${insertedCoefficients.length} school coefficient rows for ${school.id}`) + return { + schoolSubjects: insertedSubjects.length, + schoolSubjectCoefficients: insertedCoefficients.length, + } + }, + catch: err => DatabaseError.from(err, 'INTERNAL_ERROR', 'Failed to seed academic catalog'), + }), + R.mapError(tapLogErr(databaseLogger, { context: 'seedAcademicCatalog', schoolId: schoolContext.school.id })), + ) +} diff --git a/packages/data-ops/src/seed/demo/seeders/academic-realism-helpers.ts b/packages/data-ops/src/seed/demo/seeders/academic-realism-helpers.ts new file mode 100644 index 00000000..e2f481bb --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/academic-realism-helpers.ts @@ -0,0 +1,459 @@ +import type { + MessageSenderType, + ProgressStatus, + StudentAttendanceStatus, +} from '../../../drizzle/school-schema' + +export interface GradeProgramOffering { + programTemplateId: string + gradeId: string + subjectId: string + subjectName: string + coefficient?: number | null +} + +export interface TeacherSpecialty { + teacherId: string + subjectId: string +} + +export interface GradeAwareClassSubjectDraft { + classId: string + subjectId: string + teacherId: string | null + coefficient: number + hoursPerWeek: number + programTemplateId: string +} + +export function toMiddayIso(date: string): string { + return `${date}T12:00:00.000Z` +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)) +} + +export function deriveWeeklyHoursFromCoefficient(coefficient?: number | null): number { + const normalized = coefficient ?? 2 + return clamp(Math.round(normalized), 2, 5) +} + +export function buildGradeAwareClassSubjects(args: { + classId: string + gradeId: string + offerings: GradeProgramOffering[] + teacherSpecialties: TeacherSpecialty[] +}): GradeAwareClassSubjectDraft[] { + const relevantOfferings = args.offerings + .filter(offering => offering.gradeId === args.gradeId) + .sort((left, right) => { + const coefficientDelta = (right.coefficient ?? 0) - (left.coefficient ?? 0) + if (coefficientDelta !== 0) { + return coefficientDelta + } + + return left.subjectName.localeCompare(right.subjectName) + }) + + const uniqueOfferings = new Map() + for (const offering of relevantOfferings) { + if (!uniqueOfferings.has(offering.subjectId)) { + uniqueOfferings.set(offering.subjectId, offering) + } + } + + const specialtyQueues = new Map() + for (const specialty of args.teacherSpecialties) { + const existing = specialtyQueues.get(specialty.subjectId) ?? [] + specialtyQueues.set(specialty.subjectId, [...existing, specialty.teacherId]) + } + + return Array.from(uniqueOfferings.values()).map((offering) => { + const candidates = specialtyQueues.get(offering.subjectId) ?? [] + const teacherId = candidates.length > 0 ? candidates[0]! : null + + if (candidates.length > 1) { + specialtyQueues.set(offering.subjectId, [...candidates.slice(1), candidates[0]!]) + } + + const coefficient = offering.coefficient ?? 2 + + return { + classId: args.classId, + subjectId: offering.subjectId, + teacherId, + coefficient, + hoursPerWeek: deriveWeeklyHoursFromCoefficient(coefficient), + programTemplateId: offering.programTemplateId, + } + }) +} + +export interface CurriculumProgramRef { + programTemplateId: string + gradeId: string + subjectId: string +} + +export interface CurriculumChapterRef { + id: string + programTemplateId: string + title: string + order: number +} + +export interface CurriculumSessionRef { + id: string + classId: string + subjectId: string + teacherId: string + date: string + status: 'scheduled' | 'completed' | 'cancelled' | 'rescheduled' +} + +export interface CurriculumTermRef { + id: string + startDate: string + endDate: string +} + +function formatPercentage(value: number): string { + return value.toFixed(2) +} + +function calculateExpectedPercentage(termStartDate: string, termEndDate: string, calculatedAt: string): number { + if (calculatedAt >= termEndDate) { + return 100 + } + + if (calculatedAt <= termStartDate) { + return 0 + } + + const start = new Date(`${termStartDate}T00:00:00.000Z`).getTime() + const end = new Date(`${termEndDate}T23:59:59.999Z`).getTime() + const current = new Date(`${calculatedAt}T12:00:00.000Z`).getTime() + + return ((current - start) / (end - start)) * 100 +} + +function deriveProgressStatus(variance: number): ProgressStatus { + if (variance <= -20) { + return 'significantly_behind' + } + + if (variance <= -5) { + return 'slightly_behind' + } + + if (variance >= 10) { + return 'ahead' + } + + return 'on_track' +} + +export function buildCurriculumArtifacts(args: { + classId: string + gradeId: string + subjectId: string + terms: CurriculumTermRef[] + programs: CurriculumProgramRef[] + chapters: CurriculumChapterRef[] + sessions: CurriculumSessionRef[] + calculatedAt: string +}): { + sessionChapterAssignments: Array<{ + sessionId: string + chapterId: string + topic: string + objectives: string + }> + chapterCompletions: Array<{ + classId: string + subjectId: string + chapterId: string + classSessionId: string + teacherId: string + completedAt: string + notes: string + }> + progressRecords: Array<{ + classId: string + subjectId: string + programTemplateId: string + termId: string + totalChapters: number + completedChapters: number + progressPercentage: string + expectedPercentage: string + variance: string + status: ProgressStatus + lastChapterCompletedAt: string | null + calculatedAt: string + }> +} { + const program = args.programs.find(item => item.gradeId === args.gradeId && item.subjectId === args.subjectId) + if (!program) { + return { + sessionChapterAssignments: [], + chapterCompletions: [], + progressRecords: [], + } + } + + const chapters = args.chapters + .filter(chapter => chapter.programTemplateId === program.programTemplateId) + .sort((left, right) => left.order - right.order) + + const completedSessions = args.sessions + .filter(session => session.classId === args.classId && session.subjectId === args.subjectId && session.status === 'completed') + .sort((left, right) => left.date.localeCompare(right.date)) + .slice(0, chapters.length) + + const sessionChapterAssignments = completedSessions.map((session, index) => ({ + sessionId: session.id, + chapterId: chapters[index]!.id, + topic: chapters[index]!.title, + objectives: `Achever le chapitre ${chapters[index]!.title}.`, + })) + + const chapterCompletions = completedSessions.map((session, index) => ({ + classId: args.classId, + subjectId: args.subjectId, + chapterId: chapters[index]!.id, + classSessionId: session.id, + teacherId: session.teacherId, + completedAt: toMiddayIso(session.date), + notes: 'Progression demo: chapitre couvert pendant la seance.', + })) + + const calculatedAtIso = toMiddayIso(args.calculatedAt) + const progressRecords = args.terms + .map((term) => { + const eligibleCompletions = chapterCompletions.filter(completion => + completion.completedAt >= toMiddayIso(term.startDate) + && completion.completedAt <= toMiddayIso(term.endDate), + ) + + if (eligibleCompletions.length === 0) { + return null + } + + const totalChapters = chapters.length + const completedChapters = eligibleCompletions.length + const progressPercentage = totalChapters > 0 ? (completedChapters / totalChapters) * 100 : 0 + const expectedPercentage = calculateExpectedPercentage(term.startDate, term.endDate, args.calculatedAt) + const variance = progressPercentage - expectedPercentage + + return { + classId: args.classId, + subjectId: args.subjectId, + programTemplateId: program.programTemplateId, + termId: term.id, + totalChapters, + completedChapters, + progressPercentage: formatPercentage(progressPercentage), + expectedPercentage: formatPercentage(expectedPercentage), + variance: formatPercentage(variance), + status: deriveProgressStatus(variance), + lastChapterCompletedAt: eligibleCompletions.at(-1)?.completedAt ?? null, + calculatedAt: calculatedAtIso, + } + }) + .filter((record): record is NonNullable => record !== null) + + return { + sessionChapterAssignments, + chapterCompletions, + progressRecords, + } +} + +export function buildSessionAttendanceRows(args: { + schoolId: string + classId: string + classSessionId: string + date: string + recordedBy: string | null + studentIds: string[] + existingRows?: Array<{ + studentId: string + status: StudentAttendanceStatus + notes?: string | null + }> + absentStudentIds?: string[] + lateStudentIds?: string[] +}): Array<{ + studentId: string + classId: string + schoolId: string + classSessionId: string + date: string + status: StudentAttendanceStatus + parentNotified?: boolean + notificationMethod?: 'email' | 'sms' | 'in_app' + notes?: string + lateMinutes?: number + recordedBy: string | null +}> { + const existingByStudentId = new Map((args.existingRows ?? []).map(row => [row.studentId, row])) + const absentStudentIds = new Set(args.absentStudentIds ?? []) + const lateStudentIds = new Set(args.lateStudentIds ?? []) + + return args.studentIds.map((studentId) => { + const existing = existingByStudentId.get(studentId) + if (existing) { + return { + studentId, + classId: args.classId, + schoolId: args.schoolId, + classSessionId: args.classSessionId, + date: args.date, + status: existing.status, + parentNotified: existing.status === 'absent' ? true : undefined, + notificationMethod: existing.status === 'absent' ? 'sms' : undefined, + notes: existing.notes ?? undefined, + recordedBy: args.recordedBy, + } + } + + if (lateStudentIds.has(studentId)) { + return { + studentId, + classId: args.classId, + schoolId: args.schoolId, + classSessionId: args.classSessionId, + date: args.date, + status: 'late', + lateMinutes: 11, + recordedBy: args.recordedBy, + } + } + + if (absentStudentIds.has(studentId)) { + return { + studentId, + classId: args.classId, + schoolId: args.schoolId, + classSessionId: args.classSessionId, + date: args.date, + status: 'absent', + parentNotified: true, + notificationMethod: 'sms', + recordedBy: args.recordedBy, + } + } + + return { + studentId, + classId: args.classId, + schoolId: args.schoolId, + classSessionId: args.classSessionId, + date: args.date, + status: 'present', + recordedBy: args.recordedBy, + } + }) +} + +interface TeacherMessageDraft { + schoolId: string + senderType: MessageSenderType + senderId: string + recipientType: MessageSenderType + recipientId: string + studentId: string + classId: string + threadId: string + replyToId: string | null + subject: string + content: string + isRead: boolean + isArchived: boolean + isStarred: boolean + createdAt: string +} + +export function buildTeacherMessageThread(args: { + threadId: string + schoolId: string + teacherId: string + parentId: string + studentId: string + classId: string + signal: 'attendance' | 'grades' | 'praise' | 'finance' | 'report_card' + studentName: string + subjectName: string + startedAt: string +}): TeacherMessageDraft[] { + const lowerSubject = args.subjectName.toLowerCase() + + const subjectBySignal = { + attendance: `Absences repetees de ${args.studentName}`, + grades: `Baisse de niveau en ${args.subjectName} pour ${args.studentName}`, + praise: `Felicitations pour ${args.studentName}`, + finance: `Rappel de scolarite pour ${args.studentName}`, + report_card: `Bulletin disponible pour ${args.studentName}`, + } as const + + const teacherContentBySignal = { + attendance: `Bonjour, ${args.studentName} a cumule plusieurs absences recentes en ${lowerSubject}. Merci de nous confirmer la situation et les mesures prises.`, + grades: `Bonjour, nous constatons une baisse recente des resultats de ${args.studentName} en ${lowerSubject}. Merci de prevoir un suivi a la maison.`, + praise: `Bonjour, ${args.studentName} s est distingue(e) ces dernieres semaines en ${lowerSubject}. Nous souhaitions vous partager cette belle progression.`, + finance: `Bonjour, plusieurs echeances de scolarite concernant ${args.studentName} restent a regulariser. Merci de passer au service comptable ou de nous confirmer votre plan de paiement.`, + report_card: `Bonjour, le bulletin recent de ${args.studentName} est disponible. Merci d en prendre connaissance et de nous signaler toute question.`, + } as const + + const parentReplyBySignal = { + attendance: 'Bonjour professeur, nous avons bien recu le message et nous allons regulariser la situation cette semaine.', + grades: 'Bonjour professeur, merci pour le retour. Nous allons renforcer le suivi a domicile et rester en contact.', + praise: 'Bonjour professeur, merci pour ce retour encourageant. Nous allons continuer a soutenir ses efforts.', + finance: 'Bonjour, nous avons bien pris note du rappel et nous allons regulariser la situation rapidement.', + report_card: 'Bonjour professeur, merci. Nous avons consulte le bulletin et restons disponibles pour echanger si besoin.', + } as const + + const startedAtDate = new Date(args.startedAt) + const repliedAt = new Date(startedAtDate) + repliedAt.setUTCHours(repliedAt.getUTCHours() + 4) + + const subject = subjectBySignal[args.signal] + + return [ + { + schoolId: args.schoolId, + senderType: 'teacher', + senderId: args.teacherId, + recipientType: 'parent', + recipientId: args.parentId, + studentId: args.studentId, + classId: args.classId, + threadId: args.threadId, + replyToId: null, + subject, + content: teacherContentBySignal[args.signal], + isRead: true, + isArchived: false, + isStarred: args.signal !== 'praise', + createdAt: startedAtDate.toISOString(), + }, + { + schoolId: args.schoolId, + senderType: 'parent', + senderId: args.parentId, + recipientType: 'teacher', + recipientId: args.teacherId, + studentId: args.studentId, + classId: args.classId, + threadId: args.threadId, + replyToId: `${args.threadId}-msg-1`, + subject: `Re: ${subject}`, + content: parentReplyBySignal[args.signal], + isRead: true, + isArchived: false, + isStarred: false, + createdAt: repliedAt.toISOString(), + }, + ] +} diff --git a/packages/data-ops/src/seed/demo/seeders/attendance.seeder.ts b/packages/data-ops/src/seed/demo/seeders/attendance.seeder.ts index 8a5d9e0f..0a0b8ba1 100644 --- a/packages/data-ops/src/seed/demo/seeders/attendance.seeder.ts +++ b/packages/data-ops/src/seed/demo/seeders/attendance.seeder.ts @@ -6,7 +6,7 @@ import type { Database } from '../../../database/setup' import * as schoolSchema from '../../../drizzle/school-schema' import { DemoContext, SchoolContext, StudentContext, ClassContext, DemoSeedConfig, TeacherContext } from '../config' import { SeededRandom } from '../utils/random' -import { addDays, formatDate } from '../utils/date-helpers' +import { buildSeededAttendanceRows } from './student-activity-helpers' /** * Seed attendance for students @@ -42,31 +42,22 @@ export function seedAttendance( const schoolDays = 50 // last 50 days const startDate = new Date(currentTerm.startDate) - for (const enrollment of enrollments) { - const scenario = studentScenarioMap.get(enrollment.studentId) - if (!scenario) continue + const attendanceRows = buildSeededAttendanceRows({ + schoolId: school.id, + recordedBy: firstTeacherUserId, + schoolDays, + startDate, + enrollments: enrollments.map(enrollment => ({ + studentId: enrollment.studentId, + classId: enrollment.classId, + })), + studentScenarioMap, + nextRandom: () => random.random(), + createId: () => crypto.randomUUID(), + }) - const absenceProb = scenario === 'excellent' ? 0.01 : scenario === 'average' ? 0.05 : 0.15 - - for (let d = 0; d < schoolDays; d++) { - const date = addDays(startDate, d) - // skip weekends - if (date.getDay() === 0 || date.getDay() === 6) continue - - // Randomly mark as absent based on scenario - if (random.random() < absenceProb) { - await db.insert(schoolSchema.studentAttendance).values({ - id: crypto.randomUUID(), - studentId: enrollment.studentId, - classId: enrollment.classId, - schoolId: school.id, - date: formatDate(date), - status: 'absent', - recordedBy: firstTeacherUserId, - createdAt: new Date(), - }) - } - } + if (attendanceRows.length > 0) { + await db.insert(schoolSchema.studentAttendance).values(attendanceRows).onConflictDoNothing() } return undefined diff --git a/packages/data-ops/src/seed/demo/seeders/catalog-realism-helpers.ts b/packages/data-ops/src/seed/demo/seeders/catalog-realism-helpers.ts new file mode 100644 index 00000000..bc3a169c --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/catalog-realism-helpers.ts @@ -0,0 +1,333 @@ +export interface CoefficientTemplateRef { + id: string + subjectId: string + gradeId: string + seriesId: string | null + weight: number +} + +export interface SchoolCoefficientOverrideDraft { + schoolId: string + coefficientTemplateId: string + weightOverride: number +} + +export interface GradeRef { + id: string + name: string +} + +export interface SubjectRef { + id: string + name: string +} + +export interface FallbackProgramTemplateDraft { + id: string + name: string + schoolYearTemplateId: string + gradeId: string + subjectId: string + status: 'published' +} + +export interface FallbackProgramTemplateChapterDraft { + id: string + title: string + objectives: string + order: number + durationHours: number + programTemplateId: string +} + +export interface ImportedLessonRow { + gradeLabel: string + subjectLabel: string + lesson: string + lessonOrder: number + series: string | null + sessionsCount: number +} + +export function buildSchoolCoefficientOverrides(args: { + schoolId: string + targetGradeIds: string[] + activeSubjectIds: string[] + coefficientTemplates: CoefficientTemplateRef[] +}): SchoolCoefficientOverrideDraft[] { + const activeSubjects = new Set(args.activeSubjectIds) + const targetGrades = new Set(args.targetGradeIds) + + return args.coefficientTemplates + .filter(template => activeSubjects.has(template.subjectId) && targetGrades.has(template.gradeId)) + .toSorted((left, right) => { + const gradeCompare = left.gradeId.localeCompare(right.gradeId) + if (gradeCompare !== 0) { + return gradeCompare + } + + const subjectCompare = left.subjectId.localeCompare(right.subjectId) + if (subjectCompare !== 0) { + return subjectCompare + } + + return (left.seriesId ?? '').localeCompare(right.seriesId ?? '') + }) + .map(template => ({ + schoolId: args.schoolId, + coefficientTemplateId: template.id, + weightOverride: template.weight, + })) +} + +export function buildFallbackProgramTemplates(args: { + schoolYearTemplateId: string + targetGradeIds: string[] + targetGrades: GradeRef[] + subjects: SubjectRef[] + coefficientTemplates: CoefficientTemplateRef[] +}): FallbackProgramTemplateDraft[] { + const targetGradesById = new Map( + args.targetGrades + .filter(grade => args.targetGradeIds.includes(grade.id)) + .map(grade => [grade.id, grade]), + ) + const subjectsById = new Map(args.subjects.map(subject => [subject.id, subject])) + + const keys = new Set() + const drafts: FallbackProgramTemplateDraft[] = [] + + for (const template of args.coefficientTemplates) { + if (!targetGradesById.has(template.gradeId)) { + continue + } + + const grade = targetGradesById.get(template.gradeId) + const subject = subjectsById.get(template.subjectId) + if (!grade || !subject) { + continue + } + + const id = `demo-program-${args.schoolYearTemplateId}-${template.gradeId}-${template.subjectId}` + if (keys.has(id)) { + continue + } + + keys.add(id) + drafts.push({ + id, + name: `Programme ${subject.name} - ${grade.name}`, + schoolYearTemplateId: args.schoolYearTemplateId, + gradeId: grade.id, + subjectId: subject.id, + status: 'published', + }) + } + + return drafts.toSorted((left, right) => { + const gradeCompare = left.gradeId.localeCompare(right.gradeId) + if (gradeCompare !== 0) { + return gradeCompare + } + + return left.subjectId.localeCompare(right.subjectId) + }) +} + +const fallbackChapterBlueprints = [ + { + title: 'Bases et consolidation', + objectives: 'Structurer les fondamentaux et lancer une progression coherente pour la demo.', + durationHours: 6, + }, + { + title: 'Applications guidees', + objectives: 'Approfondir les notions avec des activites guidees et des exercices progressifs.', + durationHours: 8, + }, + { + title: 'Evaluation et remediations', + objectives: 'Consolider les acquis, evaluer les competences et preparer les remediations.', + durationHours: 6, + }, +] as const + +export function buildFallbackProgramTemplateChapters( + programTemplates: FallbackProgramTemplateDraft[], +): FallbackProgramTemplateChapterDraft[] { + return programTemplates.flatMap(programTemplate => + fallbackChapterBlueprints.map((chapter, index) => ({ + id: `${programTemplate.id}-chapter-${index + 1}`, + title: chapter.title, + objectives: chapter.objectives, + order: index + 1, + durationHours: chapter.durationHours, + programTemplateId: programTemplate.id, + })), + ) +} + +function normalizeText(value: string): string { + return value + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() +} + +function normalizeGradeLabel(value: string): string { + const normalized = normalizeText(value) + + if (normalized === 'tle' || normalized === 'terminale') { + return 'terminale' + } + + if (normalized === '1ere' || normalized === '1er e' || normalized === '1 re') { + return '1ere' + } + + if (normalized === '2nde' || normalized === '2nd' || normalized === 'seconde') { + return '2nde' + } + + return normalized + .replace(/\b3e\b/g, '3eme') + .replace(/\b4e\b/g, '4eme') + .replace(/\b5e\b/g, '5eme') + .replace(/\b6e\b/g, '6eme') +} + +function normalizeSubjectLabel(value: string): string { + const normalized = normalizeText(value) + const compact = normalized.replace(/\s+/g, '') + + if (normalized.includes('droits de l homme') || normalized.includes('edhc') || compact === 'edhc') { + return 'edhc' + } + + if (normalized.includes('histoire geographie') || normalized === 'hg') { + return 'histoire geographie' + } + + if (normalized.includes('mathematiques') || normalized.includes('maths')) { + return 'mathematiques' + } + + if (normalized.includes('physique chimie')) { + return 'physique chimie' + } + + if (normalized.includes('francais')) { + return 'francais' + } + + if (normalized.includes('anglais')) { + return 'anglais' + } + + if (normalized === 'eps' || normalized.includes('education physique')) { + return 'eps' + } + + return normalized +} + +export function buildImportedProgramTemplateChapters(args: { + programTemplates: FallbackProgramTemplateDraft[] + gradeNamesById: Map + subjectNamesById: Map + lessonRows: ImportedLessonRow[] +}): FallbackProgramTemplateChapterDraft[] { + const rowsByProgramKey = new Map() + + for (const row of args.lessonRows) { + const key = `${normalizeGradeLabel(row.gradeLabel)}::${normalizeSubjectLabel(row.subjectLabel)}` + const existing = rowsByProgramKey.get(key) ?? [] + existing.push(row) + rowsByProgramKey.set(key, existing) + } + + return args.programTemplates.flatMap((programTemplate) => { + const gradeName = args.gradeNamesById.get(programTemplate.gradeId) + const subjectName = args.subjectNamesById.get(programTemplate.subjectId) + if (!gradeName || !subjectName) { + return [] + } + + const key = `${normalizeGradeLabel(gradeName)}::${normalizeSubjectLabel(subjectName)}` + const rows = (rowsByProgramKey.get(key) ?? []) + .toSorted((left, right) => left.lessonOrder - right.lessonOrder) + + return rows.map((row) => { + const suffix = row.series ? `. Serie ${row.series}.` : '.' + return { + id: `${programTemplate.id}-chapter-${row.lessonOrder}`, + title: row.lesson, + objectives: `Progression importee: ${row.sessionsCount} seances prevues${suffix}`, + order: row.lessonOrder, + durationHours: row.sessionsCount, + programTemplateId: programTemplate.id, + } + }) + }) +} + +const realisticCoverageTableOrder = [ + 'school_subjects', + 'school_subject_coefficients', + 'class_subjects', + 'timetable_sessions', + 'class_sessions', + 'curriculum_progress', + 'student_averages', + 'report_cards', + 'teacher_comments', + 'teacher_messages', + 'teacher_notifications', + 'payments', +] as const + +export type RealisticCoverageTable = typeof realisticCoverageTableOrder[number] + +export interface RealisticDemoCoverageSnapshot { + schoolSubjects: number + schoolSubjectCoefficients: number + classSubjects: number + timetableSessions: number + classSessions: number + curriculumProgress: number + studentAverages: number + reportCards: number + teacherComments: number + teacherMessages: number + teacherNotifications: number + payments: number +} + +export function summarizeRealisticDemoCoverage(snapshot: RealisticDemoCoverageSnapshot): { + isValid: boolean + missingTables: RealisticCoverageTable[] +} { + const countsByTable: Record = { + school_subjects: snapshot.schoolSubjects, + school_subject_coefficients: snapshot.schoolSubjectCoefficients, + class_subjects: snapshot.classSubjects, + timetable_sessions: snapshot.timetableSessions, + class_sessions: snapshot.classSessions, + curriculum_progress: snapshot.curriculumProgress, + student_averages: snapshot.studentAverages, + report_cards: snapshot.reportCards, + teacher_comments: snapshot.teacherComments, + teacher_messages: snapshot.teacherMessages, + teacher_notifications: snapshot.teacherNotifications, + payments: snapshot.payments, + } + + const missingTables = realisticCoverageTableOrder.filter(table => (countsByTable[table] ?? 0) <= 0) + + return { + isValid: missingTables.length === 0, + missingTables, + } +} diff --git a/packages/data-ops/src/seed/demo/seeders/classes-helpers.ts b/packages/data-ops/src/seed/demo/seeders/classes-helpers.ts new file mode 100644 index 00000000..8cdccb18 --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/classes-helpers.ts @@ -0,0 +1,125 @@ +import type * as schoolSchema from '../../../drizzle/school-schema' +import type { GradeAwareClassSubjectDraft } from './academic-realism-helpers' + +type GradeRef = { + id: string + name: string +} + +type MinimalClassroomRef = { + id: string +} + +type MinimalTeacherRef = { + id: string +} + +type MinimalStudentRef = { + id: string +} + +function toSeedId(...parts: string[]) { + return parts + .join('-') + .toLowerCase() + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +export function planSeededClassArtifacts(args: { + schoolId: string + schoolYearId: string + enrollmentDate: string + classesPerGrade: number + configuredGradeNames: readonly string[] + grades: GradeRef[] + classrooms: MinimalClassroomRef[] + teachers: MinimalTeacherRef[] + students: MinimalStudentRef[] + gradeAwareAssignmentsByGradeId: Map +}): { + classes: Array + classSubjects: Array + enrollments: Array +} { + const classes: Array = [] + const classSubjects: Array = [] + const enrollments: Array = [] + + const sections = Array.from( + { length: args.classesPerGrade }, + (_, index) => String.fromCharCode(65 + index), + ) + + let classCounter = 0 + for (const gradeName of args.configuredGradeNames) { + const grade = args.grades.find(item => item.name === gradeName) + if (!grade) { + continue + } + + const gradeAssignments = args.gradeAwareAssignmentsByGradeId.get(grade.id) ?? [] + for (const section of sections) { + const classId = toSeedId('demo-class', args.schoolYearId, grade.id, section) + const homeroomTeacherId = args.teachers.length > 0 + ? args.teachers[classCounter % args.teachers.length]?.id ?? null + : null + const classroomId = args.classrooms.length > 0 + ? args.classrooms[classCounter % args.classrooms.length]?.id ?? null + : null + + classes.push({ + id: classId, + schoolId: args.schoolId, + schoolYearId: args.schoolYearId, + gradeId: grade.id, + section, + classroomId, + homeroomTeacherId, + maxStudents: 40, + status: 'active' as const, + }) + + classSubjects.push(...gradeAssignments.map(assignment => ({ + id: toSeedId('demo-class-subject', classId, assignment.subjectId), + classId, + subjectId: assignment.subjectId, + teacherId: assignment.teacherId, + coefficient: assignment.coefficient, + hoursPerWeek: assignment.hoursPerWeek, + status: 'active' as const, + }))) + + classCounter += 1 + } + } + + const rollNumbersByClassId = new Map() + args.students.forEach((student, index) => { + const targetClass = classes[index % classes.length] + if (!targetClass) { + return + } + + const rollNumber = (rollNumbersByClassId.get(targetClass.id) ?? 0) + 1 + rollNumbersByClassId.set(targetClass.id, rollNumber) + + enrollments.push({ + id: toSeedId('demo-enrollment', args.schoolYearId, student.id), + studentId: student.id, + classId: targetClass.id, + schoolYearId: args.schoolYearId, + status: 'confirmed' as const, + enrollmentDate: args.enrollmentDate, + rollNumber, + }) + }) + + return { + classes, + classSubjects, + enrollments, + } +} diff --git a/packages/data-ops/src/seed/demo/seeders/classes.seeder.ts b/packages/data-ops/src/seed/demo/seeders/classes.seeder.ts index 644a98ac..a01fb89a 100644 --- a/packages/data-ops/src/seed/demo/seeders/classes.seeder.ts +++ b/packages/data-ops/src/seed/demo/seeders/classes.seeder.ts @@ -5,8 +5,10 @@ import * as schoolSchema from '../../../drizzle/school-schema' import * as coreSchema from '../../../drizzle/core-schema' import type { Database } from '../../../database/setup' import { DemoContext, StudentContext, ClassContext, TeacherContext, SchoolContext } from '../config' -import { eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { formatDate } from '../utils/date-helpers' +import { buildGradeAwareClassSubjects } from './academic-realism-helpers' +import { planSeededClassArtifacts } from './classes-helpers' export function seedClasses( db: Database, @@ -31,104 +33,109 @@ export function seedClasses( enrollments: [], } - // Identify target subjects - const schoolSubjects = await db - .select() - .from(schoolSchema.schoolSubjects) - .where(eq(schoolSchema.schoolSubjects.schoolId, schoolId)) const grades = await db.select().from(coreSchema.grades) - - // Create Classes (one per grade level) - const gradeLevels = ['CP1', 'CP2', 'CE1', 'CE2', 'CM1', 'CM2'] - - for (const level of gradeLevels) { - const classId = crypto.randomUUID() - const grade = grades.find(g => g.name === level) || grades[0] - if (!grade) { - console.warn(`Grade ${level} not found. Skipping class creation.`) - continue - } - - await db - .insert(schoolSchema.classes) - .values({ - id: classId, - schoolId, - schoolYearId, - gradeId: grade.id, - section: 'A', - maxStudents: 40, - status: 'active', + const configuredGrades = grades.filter(grade => demoContext.config.gradeNames.includes(grade.name)) + const configuredGradeIds = configuredGrades.map(grade => grade.id) + + const programOfferings = configuredGradeIds.length > 0 + ? await db.select({ + programTemplateId: coreSchema.programTemplates.id, + gradeId: coreSchema.programTemplates.gradeId, + subjectId: coreSchema.programTemplates.subjectId, + subjectName: coreSchema.subjects.name, + coefficient: coreSchema.coefficientTemplates.weight, }) - .onConflictDoNothing() - - const insertedClass = await db.query.classes.findFirst({ - where: eq(schoolSchema.classes.id, classId), - }) - if (insertedClass) - context.classes.push(insertedClass) - - const teachers = teacherCtx.teachers - if (teachers.length === 0) { - console.warn('No teachers available to assign to class subjects.') - continue // Skip creating class subjects if no teachers - } - let teacherIdx = 0 - - // Add subjects to class - for (const ss of schoolSubjects) { - const teacher = teachers[teacherIdx % teachers.length] - if (!teacher) { - console.warn(`No teacher found for index ${teacherIdx % teachers.length}. Skipping class subject assignment.`) - teacherIdx++ - continue - } - - const classSubjectId = crypto.randomUUID() - await db - .insert(schoolSchema.classSubjects) - .values({ - id: classSubjectId, - classId, - subjectId: ss.subjectId, - teacherId: teacher.id, - coefficient: 1, - }) - .onConflictDoNothing() - - const insertedCS = await db.query.classSubjects.findFirst({ - where: eq(schoolSchema.classSubjects.id, classSubjectId), + .from(coreSchema.programTemplates) + .innerJoin(coreSchema.subjects, eq(coreSchema.programTemplates.subjectId, coreSchema.subjects.id)) + .innerJoin(schoolSchema.schoolSubjects, and( + eq(schoolSchema.schoolSubjects.subjectId, coreSchema.programTemplates.subjectId), + eq(schoolSchema.schoolSubjects.schoolId, schoolId), + eq(schoolSchema.schoolSubjects.schoolYearId, schoolYearId), + eq(schoolSchema.schoolSubjects.status, 'active'), + )) + .leftJoin(coreSchema.coefficientTemplates, and( + eq(coreSchema.coefficientTemplates.schoolYearTemplateId, schoolContext.schoolYear.schoolYearTemplateId), + eq(coreSchema.coefficientTemplates.gradeId, coreSchema.programTemplates.gradeId), + eq(coreSchema.coefficientTemplates.subjectId, coreSchema.programTemplates.subjectId), + )) + .where(and( + eq(coreSchema.programTemplates.schoolYearTemplateId, schoolContext.schoolYear.schoolYearTemplateId), + inArray(coreSchema.programTemplates.gradeId, configuredGradeIds), + )) + : [] + + const teacherSpecialties = teacherCtx.teachers.length > 0 + ? await db.select({ + teacherId: schoolSchema.teacherSubjects.teacherId, + subjectId: schoolSchema.teacherSubjects.subjectId, }) - if (insertedCS) - context.classSubjects.push(insertedCS) - } + .from(schoolSchema.teacherSubjects) + .where(inArray(schoolSchema.teacherSubjects.teacherId, teacherCtx.teachers.map(teacher => teacher.id))) + : [] + + const gradeAwareAssignmentsByGradeId = new Map( + configuredGrades.map(grade => [ + grade.id, + buildGradeAwareClassSubjects({ + classId: '', + gradeId: grade.id, + offerings: programOfferings, + teacherSpecialties, + }), + ]), + ) + + if (teacherCtx.teachers.length === 0) { + console.warn('No teachers available to assign to class subjects.') + } - // Enroll students (distribute them) - // For simplicity in this demo seeder, just assign a chunk of students to each class - const studentsPerClass = 20 - const startIndex = context.classes.length * studentsPerClass - const classStudents = studentCtx.students.slice(startIndex, startIndex + studentsPerClass) + const plannedArtifacts = planSeededClassArtifacts({ + schoolId, + schoolYearId, + enrollmentDate: formatDate(new Date()), + classesPerGrade: demoContext.config.classesPerGrade, + configuredGradeNames: demoContext.config.gradeNames, + grades: configuredGrades.map(grade => ({ + id: grade.id, + name: grade.name, + })), + classrooms: schoolContext.classrooms.map(classroom => ({ id: classroom.id })), + teachers: teacherCtx.teachers.map(teacher => ({ id: teacher.id })), + students: studentCtx.students.map(student => ({ id: student.id })), + gradeAwareAssignmentsByGradeId, + }) + + if (plannedArtifacts.classes.length > 0) { + await db.insert(schoolSchema.classes).values(plannedArtifacts.classes).onConflictDoNothing() + const rows = await db.select() + .from(schoolSchema.classes) + .where(inArray(schoolSchema.classes.id, plannedArtifacts.classes.map(item => item.id))) + const rowMap = new Map(rows.map(item => [item.id, item])) + context.classes = plannedArtifacts.classes + .map(item => rowMap.get(item.id)) + .filter((item): item is typeof rows[number] => item !== undefined) + } - for (const student of classStudents) { - const enrollmentId = crypto.randomUUID() - await db - .insert(schoolSchema.enrollments) - .values({ - id: enrollmentId, - studentId: student.id, - classId, - schoolYearId, - status: 'confirmed', - enrollmentDate: formatDate(new Date()), - }) - .onConflictDoNothing() + if (plannedArtifacts.classSubjects.length > 0) { + await db.insert(schoolSchema.classSubjects).values(plannedArtifacts.classSubjects).onConflictDoNothing() + const rows = await db.select() + .from(schoolSchema.classSubjects) + .where(inArray(schoolSchema.classSubjects.id, plannedArtifacts.classSubjects.map(item => item.id))) + const rowMap = new Map(rows.map(item => [item.id, item])) + context.classSubjects = plannedArtifacts.classSubjects + .map(item => rowMap.get(item.id)) + .filter((item): item is typeof rows[number] => item !== undefined) + } - const insertedEnrollment = await db.query.enrollments.findFirst({ - where: eq(schoolSchema.enrollments.id, enrollmentId), - }) - if (insertedEnrollment) - context.enrollments.push(insertedEnrollment) - } + if (plannedArtifacts.enrollments.length > 0) { + await db.insert(schoolSchema.enrollments).values(plannedArtifacts.enrollments).onConflictDoNothing() + const rows = await db.select() + .from(schoolSchema.enrollments) + .where(inArray(schoolSchema.enrollments.id, plannedArtifacts.enrollments.map(item => item.id))) + const rowMap = new Map(rows.map(item => [item.id, item])) + context.enrollments = plannedArtifacts.enrollments + .map(item => rowMap.get(item.id)) + .filter((item): item is typeof rows[number] => item !== undefined) } return context diff --git a/packages/data-ops/src/seed/demo/seeders/conduct.seeder.ts b/packages/data-ops/src/seed/demo/seeders/conduct.seeder.ts index b2a62514..033352af 100644 --- a/packages/data-ops/src/seed/demo/seeders/conduct.seeder.ts +++ b/packages/data-ops/src/seed/demo/seeders/conduct.seeder.ts @@ -5,7 +5,7 @@ import type { Database } from '../../../database/setup' import * as schoolSchema from '../../../drizzle/school-schema' import { DemoSeedConfig, SchoolContext, ClassContext, StudentContext, TeacherContext } from '../config' import { SeededRandom } from '../utils/random' -import { addDays, formatDate } from '../utils/date-helpers' +import { buildSeededConductRows } from './student-activity-helpers' /** * Seed conduct for students @@ -28,35 +28,26 @@ export function seedConduct( const adminId = teachers[0]?.userId || '' const random = new SeededRandom(config.seed + 600) + const incidentDate = new Date().toISOString().split('T')[0] ?? '2026-03-29' console.log('--- Seeding Conduct ---') - for (const enrollment of enrollments) { - const scenario = studentScenarioMap.get(enrollment.studentId) - if (!scenario) continue - - const incidentProb = scenario === 'excellent' ? 0.02 : scenario === 'average' ? 0.08 : 0.20 - - if (random.random() < incidentProb) { - const comment = scenario === 'struggling' - ? 'Perturbe le cours fréquemment.' - : 'Manque d’attention ponctuel.' - - await db.insert(schoolSchema.conductRecords).values({ - id: crypto.randomUUID(), - studentId: enrollment.studentId, - schoolId: school.id, - classId: enrollment.classId, - schoolYearId: schoolYear.id, - type: 'incident', - category: 'behavior', - title: scenario === 'struggling' ? 'Avertissement de conduite' : 'Note de participation', - description: comment, - incidentDate: new Date().toISOString().split('T')[0], - recordedBy: adminId, - status: 'open', - }) - } + const conductRows = buildSeededConductRows({ + schoolId: school.id, + schoolYearId: schoolYear.id, + recordedBy: adminId, + incidentDate, + enrollments: enrollments.map(enrollment => ({ + studentId: enrollment.studentId, + classId: enrollment.classId, + })), + studentScenarioMap, + nextRandom: () => random.random(), + createId: () => crypto.randomUUID(), + }) + + if (conductRows.length > 0) { + await db.insert(schoolSchema.conductRecords).values(conductRows).onConflictDoNothing() } return undefined diff --git a/packages/data-ops/src/seed/demo/seeders/curriculum-library.ts b/packages/data-ops/src/seed/demo/seeders/curriculum-library.ts new file mode 100644 index 00000000..a18b6829 --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/curriculum-library.ts @@ -0,0 +1,72 @@ +import fs from 'node:fs' +import path from 'node:path' +import XLSX from 'xlsx' +import type { ImportedLessonRow } from './catalog-realism-helpers' + +interface RawLessonRow { + level?: string | null + subjectName?: string | null + lesson?: string | null + lessonOrder?: number | null + series?: string | null + sessionsCount?: number | null +} + +const defaultCurriculumLibraryDir = '/home/darius-kassi/Documents/Lesson Progress/xslx' + +function toNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null + } + + return null +} + +export function loadLessonProgressRows(curriculumDir = process.env.DEMO_CURRICULUM_LIBRARY_DIR ?? defaultCurriculumLibraryDir): ImportedLessonRow[] { + if (!fs.existsSync(curriculumDir)) { + return [] + } + + const files = fs.readdirSync(curriculumDir) + .filter(file => file.toLowerCase().endsWith('.xlsx')) + .map(file => path.join(curriculumDir, file)) + + const rows: ImportedLessonRow[] = [] + + for (const filePath of files) { + const workbook = XLSX.readFile(filePath) + + for (const sheetName of workbook.SheetNames) { + const sheet = workbook.Sheets[sheetName] + if (!sheet) { + continue + } + + const rawRows = XLSX.utils.sheet_to_json(sheet, { defval: null }) + for (const row of rawRows) { + const lessonOrder = toNumber(row.lessonOrder) + const sessionsCount = toNumber(row.sessionsCount) + + if (!row.level || !row.subjectName || !row.lesson || lessonOrder === null || sessionsCount === null) { + continue + } + + rows.push({ + gradeLabel: row.level, + subjectLabel: row.subjectName, + lesson: row.lesson, + lessonOrder, + series: row.series ?? null, + sessionsCount, + }) + } + } + } + + return rows +} diff --git a/packages/data-ops/src/seed/demo/seeders/demo-metrics-helpers.ts b/packages/data-ops/src/seed/demo/seeders/demo-metrics-helpers.ts new file mode 100644 index 00000000..c775f6ac --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/demo-metrics-helpers.ts @@ -0,0 +1,34 @@ +import type { DemoMetrics } from '../config' + +export interface DemoMetricsAggregateInput { + totalStudents: number + totalClasses: number + totalTeachers: number + averageGrade: number + attendanceRows: number + absentRows: number + totalPlannedAmount: number | string | null + totalPaidAmount: number | string | null + overdueCount: number + openIncidents: number +} + +export function buildDemoMetrics(args: DemoMetricsAggregateInput): DemoMetrics { + const attendanceRate = args.attendanceRows > 0 + ? ((args.attendanceRows - args.absentRows) / args.attendanceRows) * 100 + : 0 + + const plannedAmount = Number(args.totalPlannedAmount || 0) + const paidAmount = Number(args.totalPaidAmount || 0) + + return { + totalStudents: args.totalStudents, + totalClasses: args.totalClasses, + totalTeachers: args.totalTeachers, + averageGrade: args.averageGrade, + attendanceRate: Number(attendanceRate.toFixed(1)), + collectionRate: plannedAmount > 0 ? paidAmount / plannedAmount : 0, + overdueCount: args.overdueCount, + openIncidents: args.openIncidents, + } +} diff --git a/packages/data-ops/src/seed/demo/seeders/engagement-helpers.ts b/packages/data-ops/src/seed/demo/seeders/engagement-helpers.ts new file mode 100644 index 00000000..4950ec90 --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/engagement-helpers.ts @@ -0,0 +1,196 @@ +import type { NotificationType } from '../../../drizzle/school-schema' + +export type EngagementSignal = 'attendance' | 'finance' | 'grades' | 'report_card' | 'praise' + +export interface OperationalStudentSignal { + studentId: string + classId: string + parentId: string + teacherId: string + studentName: string + subjectName: string + absentCount: number + weightedAverage: number | null + overdueInstallmentCount: number + reportCardStatus: 'draft' | 'generating' | 'generated' | 'sent' | 'delivered' | 'viewed' +} + +export interface OperationalThreadPlan { + signal: EngagementSignal + studentId: string + classId: string + parentId: string + teacherId: string + studentName: string + subjectName: string + startedAt: string +} + +export interface OperationalNotificationDraft { + teacherId: string + type: NotificationType + title: string + body: string + actionType: string + actionData: { route?: string, params?: Record } + relatedType: string + relatedId: string + isRead: boolean + createdAt: string +} + +function addHours(isoDate: string, hours: number) { + const next = new Date(isoDate) + next.setUTCHours(next.getUTCHours() + hours) + return next.toISOString() +} + +function deriveSignals(signal: OperationalStudentSignal): EngagementSignal[] { + const signals: EngagementSignal[] = [] + + if (signal.absentCount >= 3) { + signals.push('attendance') + } + + if (signal.overdueInstallmentCount > 0) { + signals.push('finance') + } + + if (signal.weightedAverage !== null && signal.weightedAverage < 10) { + signals.push('grades') + } + + if ( + signal.reportCardStatus === 'generating' + || signal.reportCardStatus === 'generated' + || signal.reportCardStatus === 'sent' + || signal.reportCardStatus === 'delivered' + ) { + signals.push('report_card') + } + + if ( + signal.weightedAverage !== null + && signal.weightedAverage >= 14 + && signal.overdueInstallmentCount === 0 + && signal.absentCount <= 1 + ) { + signals.push('praise') + } + + return signals +} + +function buildNotificationFromSignal( + signal: EngagementSignal, + studentSignal: OperationalStudentSignal, + createdAt: string, +): OperationalNotificationDraft | null { + if (signal === 'grades') { + return null + } + + const shared = { + teacherId: studentSignal.teacherId, + actionData: { + route: '/messages', + params: { + studentId: studentSignal.studentId, + classId: studentSignal.classId, + }, + }, + relatedId: studentSignal.studentId, + isRead: false, + createdAt, + } + + if (signal === 'attendance') { + return { + ...shared, + type: 'attendance_alert', + title: `Absences a suivre pour ${studentSignal.studentName}`, + body: `${studentSignal.studentName} totalise ${studentSignal.absentCount} absences recentes.`, + actionType: 'open_attendance_case', + relatedType: 'student_attendance', + } + } + + if (signal === 'finance') { + return { + ...shared, + type: 'reminder', + title: `Relance scolarite pour ${studentSignal.studentName}`, + body: `${studentSignal.overdueInstallmentCount} echeance(s) restent en retard pour ${studentSignal.studentName}.`, + actionType: 'open_finance_followup', + relatedType: 'payment_plan', + } + } + + if (signal === 'report_card') { + return { + ...shared, + type: 'message', + title: `Bulletin a transmettre pour ${studentSignal.studentName}`, + body: `Le bulletin de ${studentSignal.studentName} est pret pour suivi parent.`, + actionType: 'open_report_card_thread', + relatedType: 'report_card', + } + } + + return { + ...shared, + type: 'message', + title: `Parent informe des felicitations pour ${studentSignal.studentName}`, + body: `Un message positif a ete envoye au parent de ${studentSignal.studentName}.`, + actionType: 'open_message_thread', + relatedType: 'teacher_message', + } +} + +export function buildOperationalEngagementArtifacts(args: { + schoolId: string + currentDate: string + studentSignals: OperationalStudentSignal[] +}): { + messageThreads: OperationalThreadPlan[] + notifications: OperationalNotificationDraft[] +} { + const orderedSignals = args.studentSignals.toSorted((left, right) => { + const rightRisk = (right.absentCount * 10) + (right.overdueInstallmentCount * 8) + (right.weightedAverage !== null && right.weightedAverage < 10 ? 6 : 0) + const leftRisk = (left.absentCount * 10) + (left.overdueInstallmentCount * 8) + (left.weightedAverage !== null && left.weightedAverage < 10 ? 6 : 0) + + if (rightRisk !== leftRisk) { + return rightRisk - leftRisk + } + + return left.studentId.localeCompare(right.studentId) + }) + + const messageThreads: OperationalThreadPlan[] = [] + const notifications: OperationalNotificationDraft[] = [] + + for (const [studentIndex, studentSignal] of orderedSignals.entries()) { + const signals = deriveSignals(studentSignal) + + for (const [signalIndex, signal] of signals.entries()) { + const startedAt = addHours(args.currentDate, -((studentIndex * 6) + signalIndex + 1)) + messageThreads.push({ + signal, + studentId: studentSignal.studentId, + classId: studentSignal.classId, + parentId: studentSignal.parentId, + teacherId: studentSignal.teacherId, + studentName: studentSignal.studentName, + subjectName: studentSignal.subjectName, + startedAt, + }) + + const notification = buildNotificationFromSignal(signal, studentSignal, startedAt) + if (notification) { + notifications.push(notification) + } + } + } + + return { messageThreads, notifications } +} diff --git a/packages/data-ops/src/seed/demo/seeders/finance-helpers.ts b/packages/data-ops/src/seed/demo/seeders/finance-helpers.ts new file mode 100644 index 00000000..763ca79c --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/finance-helpers.ts @@ -0,0 +1,222 @@ +export interface FinanceChargeAllocationCandidate { + studentFeeId: string + installmentId?: string + feeTypeName: string + receivableAccountId: string + revenueAccountId: string + outstandingBalance: number +} + +export interface FinanceChargeAllocation { + studentFeeId: string + installmentId?: string + feeTypeName: string + receivableAccountId: string + revenueAccountId: string + amount: number +} + +export interface FinanceTransactionLineDraft { + accountId: string + description: string + debitAmount: string + creditAmount: string +} + +export interface FinanceAccountBalanceState { + id: string + balance: string | number | null | undefined + normalBalance: 'debit' | 'credit' + updatedAt?: Date +} + +function normalizeLabel(value: string): string { + return value + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() +} + +function roundMoney(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100 +} + +export function formatMoney(value: number): string { + return roundMoney(value).toFixed(2) +} + +function buildGroupedLines( + entries: Array<{ + accountId: string + description: string + debitAmount: number + creditAmount: number + }>, +): FinanceTransactionLineDraft[] { + const grouped = new Map() + + for (const entry of entries) { + const existing = grouped.get(entry.accountId) + if (existing) { + existing.debitAmount = roundMoney(existing.debitAmount + entry.debitAmount) + existing.creditAmount = roundMoney(existing.creditAmount + entry.creditAmount) + continue + } + + grouped.set(entry.accountId, { ...entry }) + } + + return Array.from(grouped.values()).map(entry => ({ + accountId: entry.accountId, + description: entry.description, + debitAmount: entry.debitAmount > 0 ? formatMoney(entry.debitAmount) : '0', + creditAmount: entry.creditAmount > 0 ? formatMoney(entry.creditAmount) : '0', + })) +} + +export function allocatePaymentToCharges(args: { + amount: number + charges: FinanceChargeAllocationCandidate[] +}): { + allocations: FinanceChargeAllocation[] + updatedCharges: FinanceChargeAllocationCandidate[] + remainingAmount: number +} { + let remainingAmount = roundMoney(args.amount) + const updatedCharges = args.charges.map(charge => ({ ...charge })) + const allocations: FinanceChargeAllocation[] = [] + + for (const charge of updatedCharges) { + if (remainingAmount <= 0) { + break + } + + if (charge.outstandingBalance <= 0) { + continue + } + + const allocatedAmount = roundMoney(Math.min(remainingAmount, charge.outstandingBalance)) + if (allocatedAmount <= 0) { + continue + } + + allocations.push({ + studentFeeId: charge.studentFeeId, + installmentId: charge.installmentId, + feeTypeName: charge.feeTypeName, + receivableAccountId: charge.receivableAccountId, + revenueAccountId: charge.revenueAccountId, + amount: allocatedAmount, + }) + + charge.outstandingBalance = roundMoney(charge.outstandingBalance - allocatedAmount) + remainingAmount = roundMoney(remainingAmount - allocatedAmount) + } + + return { + allocations, + updatedCharges, + remainingAmount, + } +} + +export function buildChargeTransactionLines(charges: Array<{ + feeTypeName: string + receivableAccountId: string + revenueAccountId: string + amount: number +}>): FinanceTransactionLineDraft[] { + const debitEntries = charges.map(charge => ({ + accountId: charge.receivableAccountId, + description: `Creance frais ${charge.feeTypeName}`, + debitAmount: charge.amount, + creditAmount: 0, + })) + const creditEntries = charges.map(charge => ({ + accountId: charge.revenueAccountId, + description: `Produit frais ${charge.feeTypeName}`, + debitAmount: 0, + creditAmount: charge.amount, + })) + + return buildGroupedLines([...debitEntries, ...creditEntries]) +} + +export function buildPaymentTransactionLines(args: { + cashAccountId: string + paymentMethodLabel: string + allocations: FinanceChargeAllocation[] +}): FinanceTransactionLineDraft[] { + const totalAmount = roundMoney(args.allocations.reduce((sum, allocation) => sum + allocation.amount, 0)) + const entries = [ + { + accountId: args.cashAccountId, + description: `Encaissement ${args.paymentMethodLabel}`, + debitAmount: totalAmount, + creditAmount: 0, + }, + ...args.allocations.map(allocation => ({ + accountId: allocation.receivableAccountId, + description: `Reglement frais ${allocation.feeTypeName}`, + debitAmount: 0, + creditAmount: allocation.amount, + })), + ] + + return buildGroupedLines(entries) +} + +export function applyTransactionLinesToAccountMap(args: { + accountMap: Map + lines: FinanceTransactionLineDraft[] + updatedAt: Date +}) { + for (const line of args.lines) { + const account = args.accountMap.get(line.accountId) + if (!account) { + continue + } + + const debit = roundMoney(Number.parseFloat(line.debitAmount || '0')) + const credit = roundMoney(Number.parseFloat(line.creditAmount || '0')) + const currentBalance = roundMoney(Number.parseFloat(String(account.balance ?? 0))) + const delta = account.normalBalance === 'debit' ? debit - credit : credit - debit + const updatedBalance = roundMoney(currentBalance + delta) + + args.accountMap.set(account.id, { + ...account, + balance: formatMoney(updatedBalance), + updatedAt: args.updatedAt, + }) + } +} + +export function resolveFeeTypeTemplateIds(args: { + blueprints: Array<{ + code: string + name: string + category: string + }> + templates: Array<{ + id: string + code: string + name: string + category: string + isActive?: boolean | null + }> +}): Map { + const activeTemplates = args.templates.filter(template => template.isActive !== false) + const templatesByCategory = new Map(activeTemplates.map(template => [template.category, template])) + const templatesByCode = new Map(activeTemplates.map(template => [normalizeLabel(template.code), template])) + const templatesByName = new Map(activeTemplates.map(template => [normalizeLabel(template.name), template])) + + return new Map(args.blueprints.flatMap((blueprint) => { + const resolvedTemplate = templatesByCategory.get(blueprint.category) + ?? templatesByCode.get(normalizeLabel(blueprint.code)) + ?? templatesByName.get(normalizeLabel(blueprint.name)) + + return resolvedTemplate ? [[blueprint.code, resolvedTemplate.id] as const] : [] + })) +} diff --git a/packages/data-ops/src/seed/demo/seeders/finance.seeder.ts b/packages/data-ops/src/seed/demo/seeders/finance.seeder.ts index 1bfe647c..53758107 100644 --- a/packages/data-ops/src/seed/demo/seeders/finance.seeder.ts +++ b/packages/data-ops/src/seed/demo/seeders/finance.seeder.ts @@ -1,70 +1,444 @@ +import { and, desc, eq, inArray, like, sql } from 'drizzle-orm' import { Result as R } from '@praha/byethrow' import { databaseLogger, tapLogErr } from '@repo/logger' import { DatabaseError } from '@repo/data-ops/errors' +import type { Database } from '@repo/data-ops/database/setup' import * as schoolSchema from '@repo/data-ops/drizzle/school-schema' import * as coreSchema from '@repo/data-ops/drizzle/core-schema' -import { - feeStatuses, - installmentStatuses, - paymentPlanStatuses +import type { + AccountType, + FeeStatus, + InstallmentStatus, + NormalBalance, + PaymentMethod, + PaymentPlanStatus, } from '@repo/data-ops/drizzle/school-schema' -import { feeTypeCategories } from '@repo/data-ops/drizzle/core-schema' -import type { Database } from '@repo/data-ops/database/setup' -import { DemoContext, SchoolContext, StudentContext, ClassContext, TeacherContext } from '../config' +import type { DemoContext, SchoolContext, StudentContext, ClassContext, TeacherContext } from '../config' import { financeScenarios } from '../scenarios/presets' -import { eq } from 'drizzle-orm' -import { formatDate, addDays } from '../utils/date-helpers' +import { addDays, formatDate } from '../utils/date-helpers' import { SeededRandom } from '../utils/random' -import { z } from 'zod' - -// --- Schemas for type safety --- -const feeTypeInsertSchema = z.object({ - id: z.string().uuid(), - schoolId: z.string().uuid(), - code: z.string(), - name: z.string(), - category: z.enum(feeTypeCategories), - isMandatory: z.boolean(), -}) - -const feeStructureInsertSchema = z.object({ - id: z.string().uuid(), - schoolId: z.string().uuid(), - schoolYearId: z.string().uuid(), - feeTypeId: z.string().uuid(), - gradeId: z.string().uuid(), - amount: z.string(), - currency: z.string(), -}) - -const paymentPlanInsertSchema = z.object({ - id: z.string().uuid(), - studentId: z.string().uuid(), - schoolYearId: z.string().uuid(), - templateId: z.string().uuid().optional(), - totalAmount: z.string(), - paidAmount: z.string().optional(), - balance: z.string(), - status: z.enum(paymentPlanStatuses), - createdBy: z.string(), -}) - -const installmentInsertSchema = z.object({ - id: z.string().uuid(), - paymentPlanId: z.string().uuid(), - installmentNumber: z.number(), - label: z.string(), - amount: z.string(), - paidAmount: z.string(), - balance: z.string(), - dueDate: z.string(), - status: z.enum(installmentStatuses), - paidAt: z.date().nullable(), - daysOverdue: z.number(), -}) +import { + applyTransactionLinesToAccountMap, + allocatePaymentToCharges, + buildChargeTransactionLines, + buildPaymentTransactionLines, + formatMoney, + resolveFeeTypeTemplateIds, + type FinanceChargeAllocation, + type FinanceChargeAllocationCandidate, + type FinanceTransactionLineDraft, +} from './finance-helpers' +import { executeWithRetry, runAdaptiveChunkedWithRetry } from './seed-resilience-helpers' + +const methodAccountCodeMap: Record = { + cash: '1010', + bank_transfer: '1020', + mobile_money: '1030', + card: '1020', + check: '1020', + other: '1010', +} + +const methodLabels: Record = { + cash: 'cash', + bank_transfer: 'virement', + mobile_money: 'mobile_money', + card: 'carte', + check: 'cheque', + other: 'autre', +} + +const paymentMethodPool: PaymentMethod[] = ['cash', 'mobile_money', 'bank_transfer', 'check'] + +const accountBlueprints: Array<{ + code: string + name: string + type: AccountType + normalBalance: NormalBalance + isHeader?: boolean + parentCode?: string +}> = [ + { code: '1000', name: 'Actifs', type: 'asset', normalBalance: 'debit', isHeader: true }, + { code: '1010', name: 'Caisse', type: 'asset', normalBalance: 'debit', parentCode: '1000' }, + { code: '1020', name: 'Banque', type: 'asset', normalBalance: 'debit', parentCode: '1000' }, + { code: '1030', name: 'Mobile Money', type: 'asset', normalBalance: 'debit', parentCode: '1000' }, + { code: '1100', name: 'Creances eleves', type: 'asset', normalBalance: 'debit', isHeader: true, parentCode: '1000' }, + { code: '1110', name: 'Creances inscription', type: 'asset', normalBalance: 'debit', parentCode: '1100' }, + { code: '1120', name: 'Creances scolarite', type: 'asset', normalBalance: 'debit', parentCode: '1100' }, + { code: '1130', name: 'Creances cantine', type: 'asset', normalBalance: 'debit', parentCode: '1100' }, + { code: '1140', name: 'Creances transport', type: 'asset', normalBalance: 'debit', parentCode: '1100' }, + { code: '4000', name: 'Produits', type: 'revenue', normalBalance: 'credit', isHeader: true }, + { code: '4110', name: 'Produits inscription', type: 'revenue', normalBalance: 'credit', parentCode: '4000' }, + { code: '4120', name: 'Produits scolarite', type: 'revenue', normalBalance: 'credit', parentCode: '4000' }, + { code: '4130', name: 'Produits cantine', type: 'revenue', normalBalance: 'credit', parentCode: '4000' }, + { code: '4140', name: 'Produits transport', type: 'revenue', normalBalance: 'credit', parentCode: '4000' }, +] + +const feeBlueprints = [ + { + code: 'INS', + name: 'Inscription', + category: 'registration' as const, + isMandatory: true, + isRecurring: false, + receivableAccountCode: '1110', + revenueAccountCode: '4110', + amountForGradeOrder: (_gradeOrder: number) => 50_000, + }, + { + code: 'TUI', + name: 'Scolarite', + category: 'tuition' as const, + isMandatory: true, + isRecurring: true, + receivableAccountCode: '1120', + revenueAccountCode: '4120', + amountForGradeOrder: (gradeOrder: number) => (gradeOrder <= 4 ? 180_000 : 260_000), + }, + { + code: 'CAN', + name: 'Cantine', + category: 'meals' as const, + isMandatory: false, + isRecurring: true, + receivableAccountCode: '1130', + revenueAccountCode: '4130', + amountForGradeOrder: (_gradeOrder: number) => 120_000, + }, + { + code: 'BUS', + name: 'Transport', + category: 'transport' as const, + isMandatory: false, + isRecurring: true, + receivableAccountCode: '1140', + revenueAccountCode: '4140', + amountForGradeOrder: (_gradeOrder: number) => 90_000, + }, +] as const + +interface PaymentPlanScheduleItem { + number: number + label: string + percentage: number + dueDaysFromStart: number +} + +function parseMoney(value: string | number | null | undefined): number { + if (typeof value === 'number') { + return value + } + + if (!value) { + return 0 + } + + return Number.parseFloat(value) +} + +function roundMoney(value: number): number { + return Number.parseFloat(formatMoney(value)) +} + +function getPaymentSchedule(planType: DemoContext['config']['paymentPlanType']): PaymentPlanScheduleItem[] { + if (planType === 'monthly') { + return Array.from({ length: 10 }, (_, index) => ({ + number: index + 1, + label: `Mensualite ${index + 1}`, + percentage: 10, + dueDaysFromStart: 15 + (index * 30), + })) + } + + if (planType === 'custom') { + return [ + { number: 1, label: 'Tranche 1', percentage: 35, dueDaysFromStart: 15 }, + { number: 2, label: 'Tranche 2', percentage: 25, dueDaysFromStart: 75 }, + { number: 3, label: 'Tranche 3', percentage: 20, dueDaysFromStart: 150 }, + { number: 4, label: 'Tranche 4', percentage: 20, dueDaysFromStart: 240 }, + ] + } + + return [ + { number: 1, label: 'Tranche 1', percentage: 40, dueDaysFromStart: 30 }, + { number: 2, label: 'Tranche 2', percentage: 30, dueDaysFromStart: 120 }, + { number: 3, label: 'Tranche 3', percentage: 30, dueDaysFromStart: 210 }, + ] +} + +function pickPaymentMethod(random: SeededRandom): PaymentMethod { + const index = Math.floor(random.next() * paymentMethodPool.length) + return paymentMethodPool[Math.min(index, paymentMethodPool.length - 1)]! +} + +function splitPaymentAmount(amount: number, scenarioKey: keyof typeof financeScenarios, random: SeededRandom): number[] { + const normalized = roundMoney(amount) + if (normalized <= 0) { + return [] + } + + const shouldSplit = scenarioKey !== 'onTime' && normalized >= 75_000 && random.next() < 0.45 + if (!shouldSplit) { + return [normalized] + } + + const firstAmount = roundMoney(normalized * (0.4 + (random.next() * 0.2))) + const secondAmount = roundMoney(normalized - firstAmount) + + if (firstAmount <= 0 || secondAmount <= 0) { + return [normalized] + } + + return [firstAmount, secondAmount] +} + +async function insertRowsInChunks( + insertFn: (chunk: T[]) => Promise, + rows: T[], + chunkSize = 100, +) { + await runAdaptiveChunkedWithRetry(rows, { + chunkSize, + minChunkSize: 10, + maxAttempts: 4, + delayMs: 250, + handler: async (chunk) => { + await insertFn(chunk) + }, + }) +} + +async function nextReceiptNumber(db: Database, schoolId: string, paymentDate: string): Promise { + const year = Number.parseInt(paymentDate.slice(0, 4), 10) + const prefix = `REC-${year}-` + + const updatedSequence = await executeWithRetry(async () => { + const [sequence] = await db + .update(schoolSchema.receiptSequences) + .set({ + lastNumber: sql`${schoolSchema.receiptSequences.lastNumber} + 1`, + updatedAt: new Date(), + }) + .where(and( + eq(schoolSchema.receiptSequences.schoolId, schoolId), + eq(schoolSchema.receiptSequences.sequenceYear, year), + )) + .returning() + + return sequence + }) + + if (updatedSequence) { + return `${prefix}${String(updatedSequence.lastNumber).padStart(5, '0')}` + } + + await executeWithRetry(async () => { + await db.insert(schoolSchema.receiptSequences).values({ + id: crypto.randomUUID(), + schoolId, + sequenceYear: year, + prefix, + lastNumber: 1, + }).onConflictDoNothing() + }) + + return `${prefix}00001` +} + +async function nextTransactionNumber(db: Database, schoolId: string, effectiveDate: string): Promise { + const year = Number.parseInt(effectiveDate.slice(0, 4), 10) + const prefix = `TXN-${year}-` + + const lastTransaction = await executeWithRetry(async () => { + const [transaction] = await db + .select({ transactionNumber: schoolSchema.transactions.transactionNumber }) + .from(schoolSchema.transactions) + .where(and( + eq(schoolSchema.transactions.schoolId, schoolId), + like(schoolSchema.transactions.transactionNumber, `${prefix}%`), + )) + .orderBy(desc(schoolSchema.transactions.transactionNumber)) + .limit(1) + + return transaction + }) + + const lastNumber = lastTransaction?.transactionNumber + ? Number.parseInt(lastTransaction.transactionNumber.replace(prefix, ''), 10) + : 0 + + return `${prefix}${String(Number.isNaN(lastNumber) ? 1 : lastNumber + 1).padStart(5, '0')}` +} + +async function insertTransactionWithLines(args: { + db: Database + schoolId: string + fiscalYearId: string + date: string + type: typeof schoolSchema.transactionTypes[number] + description: string + reference?: string + totalAmount: number + studentId?: string + paymentId?: string + createdBy: string + lines: FinanceTransactionLineDraft[] + accountMap: Map +}): Promise { + const { db, schoolId, fiscalYearId, date, type, description, reference, totalAmount, studentId, paymentId, createdBy, lines, accountMap } = args + + if (totalAmount <= 0 || lines.length === 0) { + return + } + + const transactionId = crypto.randomUUID() + const transactionNumber = await nextTransactionNumber(db, schoolId, date) + + await executeWithRetry(async () => { + await db.insert(schoolSchema.transactions).values({ + id: transactionId, + schoolId, + fiscalYearId, + transactionNumber, + date, + type, + description, + reference, + totalAmount: formatMoney(totalAmount), + currency: 'XOF', + studentId, + paymentId, + status: 'posted', + createdBy, + }) + }) + + const transactionLineRows = lines.map((line, index) => ({ + id: crypto.randomUUID(), + transactionId, + accountId: line.accountId, + lineNumber: index + 1, + description: line.description, + debitAmount: line.debitAmount, + creditAmount: line.creditAmount, + })) + + if (transactionLineRows.length > 0) { + await insertRowsInChunks( + chunk => db.insert(schoolSchema.transactionLines).values(chunk), + transactionLineRows, + 50, + ) + } + applyTransactionLinesToAccountMap({ + accountMap, + lines, + updatedAt: new Date(), + }) +} + +async function flushAccountBalances(args: { + db: Database + accountMap: Map +}) { + const accountRows = Array.from(args.accountMap.values()) + await runAdaptiveChunkedWithRetry(accountRows, { + chunkSize: 25, + minChunkSize: 5, + maxAttempts: 4, + delayMs: 250, + handler: async (chunk) => { + for (const account of chunk) { + await executeWithRetry(async () => { + await args.db.update(schoolSchema.accounts) + .set({ balance: account.balance, updatedAt: account.updatedAt ?? new Date() }) + .where(eq(schoolSchema.accounts.id, account.id)) + }) + } + }, + }) +} + +async function ensureAccountingSetup(args: { + db: Database + schoolId: string + schoolYear: SchoolContext['schoolYear'] +}): Promise<{ + fiscalYear: typeof schoolSchema.fiscalYears.$inferSelect + accountMapById: Map + accountMapByCode: Map +}> { + const { db, schoolId, schoolYear } = args + + const existingFiscalYear = await executeWithRetry(async () => { + const [fiscalYear] = await db + .select() + .from(schoolSchema.fiscalYears) + .where(and( + eq(schoolSchema.fiscalYears.schoolId, schoolId), + eq(schoolSchema.fiscalYears.schoolYearId, schoolYear.id), + )) + .limit(1) + + return fiscalYear + }) + + const fiscalYear = existingFiscalYear ?? await executeWithRetry(async () => { + const [createdFiscalYear] = await db.insert(schoolSchema.fiscalYears).values({ + id: crypto.randomUUID(), + schoolId, + schoolYearId: schoolYear.id, + name: `FY ${schoolYear.startDate.slice(0, 4)}-${schoolYear.endDate.slice(0, 4)}`, + startDate: schoolYear.startDate, + endDate: schoolYear.endDate, + status: 'open', + }).returning() + + return createdFiscalYear + }) + + if (!fiscalYear) { + throw new Error('Unable to initialize fiscal year for demo finance seeding') + } + + const blueprintIds = new Map(accountBlueprints.map(blueprint => [blueprint.code, crypto.randomUUID()])) + const accountValues = accountBlueprints.map(blueprint => ({ + id: blueprintIds.get(blueprint.code)!, + schoolId, + code: blueprint.code, + name: blueprint.name, + type: blueprint.type, + level: blueprint.parentCode ? 2 : 1, + parentId: blueprint.parentCode ? blueprintIds.get(blueprint.parentCode) : null, + isHeader: blueprint.isHeader ?? false, + balance: '0.00', + normalBalance: blueprint.normalBalance, + status: 'active' as const, + isSystem: true, + })) + + await insertRowsInChunks( + chunk => db.insert(schoolSchema.accounts).values(chunk).onConflictDoNothing(), + accountValues, + 50, + ) + + const accounts = await executeWithRetry(async () => ( + db + .select() + .from(schoolSchema.accounts) + .where(eq(schoolSchema.accounts.schoolId, schoolId)) + )) + + return { + fiscalYear, + accountMapById: new Map(accounts.map(account => [account.id, account])), + accountMapByCode: new Map(accounts.map(account => [account.code, account])), + } +} /** - * Seed financial data for students + * Seed financial data for students with actual payments, receipts, and accounting entries. */ export function seedFinance( db: Database, @@ -75,226 +449,526 @@ export function seedFinance( teacherContext: TeacherContext, ): R.ResultAsync { const { config, schoolId } = demoContext - const { schoolYear } = schoolContext + const { school, schoolYear } = schoolContext const random = new SeededRandom(config.seed + 700) return R.pipe( R.try({ try: async () => { - const schoolYearId = schoolYear.id - - if (!schoolId || !schoolYearId) { - throw new Error('School or SchoolYear context missing') - } - const creatorId = teacherContext.teachers[0]?.userId if (!creatorId) { throw new Error('No teachers available to act as record creator') } - databaseLogger.info(`Starting finance seeding for school: ${schoolId}`) - - // 1. Create Fee Types - const feeTypesToInsert = [ - { code: 'INS', name: 'Inscription', category: 'registration' as const, isMandatory: true }, - { code: 'TUI', name: 'Scolarité', category: 'tuition' as const, isMandatory: true }, - { code: 'CAN', name: 'Cantine', category: 'meals' as const, isMandatory: false }, - { code: 'BUS', name: 'Transport', category: 'transport' as const, isMandatory: false }, - ].map(ft => feeTypeInsertSchema.parse({ - id: crypto.randomUUID(), + const accountingSetup = await ensureAccountingSetup({ + db, schoolId, - ...ft - })) - - for (const ft of feeTypesToInsert) { - await db.insert(schoolSchema.feeTypes).values(ft).onConflictDoNothing() - } + schoolYear, + }) - const dbFeeTypes = await db.query.feeTypes.findMany({ - where: eq(schoolSchema.feeTypes.schoolId, schoolId), + const feeTypeTemplates = await db + .select() + .from(coreSchema.feeTypeTemplates) + .where(eq(coreSchema.feeTypeTemplates.isActive, true)) + + const feeTypeTemplateIds = resolveFeeTypeTemplateIds({ + blueprints: feeBlueprints.map(fee => ({ + code: fee.code, + name: fee.name, + category: fee.category, + })), + templates: feeTypeTemplates.map(template => ({ + id: template.id, + code: template.code, + name: template.name, + category: template.category, + isActive: template.isActive, + })), }) - // 2. Create Fee Structures (by grade level) - const grades = await db.select().from(coreSchema.grades) - const structuresToInsert: z.infer[] = [] - - for (const grade of grades) { - // Simple logic: higher grades pay more - const baseAmount = grade.order <= 6 ? 150000 : 250000 - - for (const ft of dbFeeTypes) { - let amount = 0 - switch (ft.code) { - case 'INS': amount = 50000; break - case 'TUI': amount = baseAmount; break - case 'CAN': amount = 120000; break - case 'BUS': amount = 90000; break - } + const configuredGrades = await db + .select() + .from(coreSchema.grades) + .where(inArray(coreSchema.grades.name, [...config.gradeNames])) - structuresToInsert.push(feeStructureInsertSchema.parse({ - id: crypto.randomUUID(), - schoolId, - schoolYearId, - feeTypeId: ft.id, - gradeId: grade.id, - amount: String(amount), - currency: 'XOF', - })) - } + if (configuredGrades.length === 0) { + throw new Error('No configured grades found for finance seeding') } - if (structuresToInsert.length > 0) { - await db.insert(schoolSchema.feeStructures).values(structuresToInsert).onConflictDoNothing() - } + const feeTypesToInsert = feeBlueprints.map(fee => { + const revenueAccount = accountingSetup.accountMapByCode.get(fee.revenueAccountCode) + const receivableAccount = accountingSetup.accountMapByCode.get(fee.receivableAccountCode) + + if (!revenueAccount || !receivableAccount) { + throw new Error(`Missing accounting mapping for fee type ${fee.code}`) + } + + const feeTypeTemplateId = feeTypeTemplateIds.get(fee.code) + if (!feeTypeTemplateId) { + throw new Error(`Missing active fee type template mapping for fee type ${fee.code}`) + } - const dbFeeStructures = await db.query.feeStructures.findMany({ - where: eq(schoolSchema.feeStructures.schoolId, schoolId), + return { + id: crypto.randomUUID(), + schoolId, + feeTypeTemplateId, + code: fee.code, + name: fee.name, + category: fee.category, + isMandatory: fee.isMandatory, + isRecurring: fee.isRecurring, + revenueAccountId: revenueAccount.id, + receivableAccountId: receivableAccount.id, + displayOrder: 0, + status: 'active' as const, + } }) - // 3. Create Payment Plan Template (Trimester) - const templateId = crypto.randomUUID() - await db.insert(schoolSchema.paymentPlanTemplates).values({ - id: templateId, + await insertRowsInChunks( + chunk => db.insert(schoolSchema.feeTypes).values(chunk).onConflictDoNothing(), + feeTypesToInsert, + 50, + ) + + const dbFeeTypes = await db + .select() + .from(schoolSchema.feeTypes) + .where(eq(schoolSchema.feeTypes.schoolId, schoolId)) + + const feeTypeMap = new Map(dbFeeTypes.map(feeType => [feeType.code, feeType])) + const structuresToInsert = configuredGrades.flatMap(grade => feeBlueprints.map(fee => { + const feeType = feeTypeMap.get(fee.code) + if (!feeType) { + throw new Error(`Fee type ${fee.code} missing after insert`) + } + + return { + id: crypto.randomUUID(), + schoolId, + schoolYearId: schoolYear.id, + feeTypeId: feeType.id, + gradeId: grade.id, + amount: formatMoney(fee.amountForGradeOrder(grade.order)), + currency: config.currency, + } + })) + + await insertRowsInChunks( + chunk => db.insert(schoolSchema.feeStructures).values(chunk).onConflictDoNothing(), + structuresToInsert, + 50, + ) + + const dbFeeStructures = await db + .select() + .from(schoolSchema.feeStructures) + .where(and( + eq(schoolSchema.feeStructures.schoolId, schoolId), + eq(schoolSchema.feeStructures.schoolYearId, schoolYear.id), + )) + + const schedule = getPaymentSchedule(config.paymentPlanType) + const [paymentPlanTemplate] = await db.insert(schoolSchema.paymentPlanTemplates).values({ + id: crypto.randomUUID(), schoolId, - schoolYearId, - name: 'Trimestriel Standard', - installmentsCount: 3, - schedule: [ - { number: 1, label: 'Tranche 1', percentage: 40, dueDaysFromStart: 30 }, - { number: 2, label: 'Tranche 2', percentage: 30, dueDaysFromStart: 120 }, - { number: 3, label: 'Tranche 3', percentage: 30, dueDaysFromStart: 210 }, - ], + schoolYearId: schoolYear.id, + name: config.paymentPlanType === 'monthly' ? 'Mensuel Standard' : 'Echeancier Standard', + installmentsCount: schedule.length, + schedule, isDefault: true, status: 'active', - }).onConflictDoNothing() + }).onConflictDoNothing().returning() + + const activeTemplate = paymentPlanTemplate ?? (await db.query.paymentPlanTemplates.findFirst({ + where: and( + eq(schoolSchema.paymentPlanTemplates.schoolId, schoolId), + eq(schoolSchema.paymentPlanTemplates.schoolYearId, schoolYear.id), + eq(schoolSchema.paymentPlanTemplates.isDefault, true), + ), + })) - // 4. Assign Fees and Plans to Students - for (const enrollment of classContext.enrollments) { - const student = studentContext.students.find(s => s.id === enrollment.studentId) - if (!student) continue + if (!activeTemplate) { + throw new Error('Unable to initialize payment plan template') + } - const financeScenario = studentContext.financeScenarioMap.get(student.id) ?? 'onTime' - const fScenarioConfig = financeScenarios[financeScenario] + const referenceDate = addDays(new Date(`${schoolYear.startDate}T00:00:00Z`), 5) + const today = new Date() - const targetClass = classContext.classes.find(c => c.id === enrollment.classId) - if (!targetClass) continue + for (const enrollment of classContext.enrollments) { + const student = studentContext.students.find(item => item.id === enrollment.studentId) + const targetClass = classContext.classes.find(item => item.id === enrollment.classId) + if (!student || !targetClass) { + continue + } - const studentStructures = dbFeeStructures.filter(fs => fs.gradeId === targetClass.gradeId) + const targetGrade = configuredGrades.find(item => item.id === targetClass.gradeId) - let totalAmount = 0 - const studentFeesToInsert: (typeof schoolSchema.studentFees.$inferInsert)[] = [] + const financeScenarioKey = studentContext.financeScenarioMap.get(student.id) ?? 'onTime' + const financeScenario = financeScenarios[financeScenarioKey] + const feeStructures = dbFeeStructures.filter(structure => structure.gradeId === targetClass.gradeId) - for (const fs of studentStructures) { - const feeType = dbFeeTypes.find(ft => ft.id === fs.feeTypeId) - if (!feeType) continue + const studentFeeStates: Array = [] - // Mandatory fees are always added, others are random - if (feeType.isMandatory || random.next() > 0.5) { - const amount = parseFloat(fs.amount) - totalAmount += amount + for (const structure of feeStructures) { + const feeType = dbFeeTypes.find(item => item.id === structure.feeTypeId) + if (!feeType || !feeType.revenueAccountId || !feeType.receivableAccountId) { + continue + } - studentFeesToInsert.push({ - id: crypto.randomUUID(), - studentId: student.id, - enrollmentId: enrollment.id, - feeStructureId: fs.id, - originalAmount: fs.amount, - finalAmount: fs.amount, - balance: fs.amount, - status: 'pending', - }) + if (!feeType.isMandatory && random.next() < 0.45) { + continue } - } - if (studentFeesToInsert.length > 0) { - await db.insert(schoolSchema.studentFees).values(studentFeesToInsert).onConflictDoNothing() + const amount = roundMoney(parseMoney(structure.amount)) + studentFeeStates.push({ + id: crypto.randomUUID(), + studentFeeId: '', + feeTypeName: feeType.name, + receivableAccountId: feeType.receivableAccountId, + revenueAccountId: feeType.revenueAccountId, + outstandingBalance: amount, + originalAmount: amount, + finalAmount: amount, + paidAmount: 0, + }) + studentFeeStates[studentFeeStates.length - 1]!.studentFeeId = studentFeeStates[studentFeeStates.length - 1]!.id } - const planId = crypto.randomUUID() - await db.insert(schoolSchema.paymentPlans).values(paymentPlanInsertSchema.parse({ - id: planId, - studentId: student.id, - schoolYearId, - templateId, - totalAmount: String(totalAmount), - balance: String(totalAmount), - status: 'active', - createdBy: creatorId, - })).onConflictDoNothing() - - // 5. Create Installments - const referenceDate = new Date() - referenceDate.setMonth(referenceDate.getMonth() - 5) // Simulate history - - const installmentsToInsert: z.infer[] = [] - const schedules = [ - { number: 1, label: 'Tranche 1', pct: 0.4, days: 30 }, - { number: 2, label: 'Tranche 2', pct: 0.3, days: 120 }, - { number: 3, label: 'Tranche 3', pct: 0.3, days: 210 }, - ] + if (studentFeeStates.length === 0) { + continue + } - let totalPaid = 0 + const totalAmount = roundMoney(studentFeeStates.reduce((sum, fee) => sum + fee.finalAmount, 0)) + const paymentPlanId = crypto.randomUUID() - for (const s of schedules) { - const instAmount = Math.round(totalAmount * s.pct) - const dueDate = addDays(referenceDate, s.days) - const isPast = dueDate < new Date() + const installmentStates = schedule.map((item, index) => { + const exactAmount = index === schedule.length - 1 + ? roundMoney(totalAmount - schedule.slice(0, index).reduce((sum, current) => sum + roundMoney(totalAmount * (current.percentage / 100)), 0)) + : roundMoney(totalAmount * (item.percentage / 100)) + const dueDate = addDays(referenceDate, item.dueDaysFromStart) + const isPastDue = dueDate < today let paidAmount = 0 - let status: 'pending' | 'paid' | 'partial' = 'pending' let paidAt: Date | null = null - if (isPast) { - if (random.next() > fScenarioConfig.skipProbability) { - const isPartial = random.next() < fScenarioConfig.partialPaymentProbability - if (isPartial) { - paidAmount = Math.round(instAmount * (random.next() * 0.6 + 0.2)) - status = 'partial' - } else { - paidAmount = instAmount - status = 'paid' - const delay = random.nextRange(fScenarioConfig.daysOffsetRange[0], fScenarioConfig.daysOffsetRange[1]) - paidAt = addDays(dueDate, delay) - } + if (isPastDue && random.next() > financeScenario.skipProbability) { + const isPartial = random.next() < financeScenario.partialPaymentProbability + const delay = Math.max(financeScenario.daysOffsetRange[0], 0) + Math.round(random.next() * Math.max(financeScenario.daysOffsetRange[1] - Math.max(financeScenario.daysOffsetRange[0], 0), 0)) + paidAt = addDays(dueDate, delay) + paidAmount = isPartial + ? roundMoney(exactAmount * (0.3 + (random.next() * 0.35))) + : exactAmount + + if (paidAmount > exactAmount) { + paidAmount = exactAmount } } - totalPaid += paidAmount + const balance = roundMoney(exactAmount - paidAmount) + const status = balance <= 0 + ? 'paid' + : isPastDue + ? 'overdue' + : paidAmount > 0 + ? 'partial' + : 'pending' - installmentsToInsert.push(installmentInsertSchema.parse({ + return { id: crypto.randomUUID(), - paymentPlanId: planId, - installmentNumber: s.number, - label: s.label, - amount: String(instAmount), - paidAmount: String(paidAmount), - balance: String(instAmount - paidAmount), - dueDate: formatDate(dueDate), - status, + paymentPlanId, + installmentNumber: item.number, + label: item.label, + amount: exactAmount, + paidAmount, + balance, + dueDate, paidAt, - daysOverdue: status !== 'paid' && isPast ? Math.floor((Date.now() - dueDate.getTime()) / (1000 * 3600 * 24)) : 0, - })) + status, + } + }) + + let chargeAllocatorState = studentFeeStates.map(fee => ({ + studentFeeId: fee.id, + feeTypeName: fee.feeTypeName, + receivableAccountId: fee.receivableAccountId, + revenueAccountId: fee.revenueAccountId, + outstandingBalance: fee.finalAmount, + })) + + const paymentsToInsert: Array = [] + const allocationsToInsert: Array = [] + const receiptsToInsert: Array = [] + const paymentTransactions: Array<{ + paymentId: string + paymentDate: string + description: string + reference: string + totalAmount: number + lines: FinanceTransactionLineDraft[] + }> = [] + + for (const installment of installmentStates) { + if (installment.paidAmount <= 0) { + continue + } + + const paymentChunks = splitPaymentAmount(installment.paidAmount, financeScenarioKey, random) + + for (const [chunkIndex, chunkAmount] of paymentChunks.entries()) { + const paymentMethod = pickPaymentMethod(random) + const cashAccount = accountingSetup.accountMapByCode.get(methodAccountCodeMap[paymentMethod]) + if (!cashAccount) { + throw new Error(`Missing cash account for payment method ${paymentMethod}`) + } + + const chunkDateBase = installment.paidAt ?? installment.dueDate + const paymentDate = formatDate(addDays( + chunkDateBase instanceof Date ? chunkDateBase : new Date(chunkDateBase), + chunkIndex - (paymentChunks.length - 1), + )) + + const allocationResult = allocatePaymentToCharges({ + amount: chunkAmount, + charges: chargeAllocatorState.map(charge => ({ + ...charge, + installmentId: installment.id, + })), + }) + + chargeAllocatorState = allocationResult.updatedCharges.map(({ installmentId: _installmentId, ...charge }) => charge) + if (allocationResult.allocations.length === 0) { + continue + } + + if (allocationResult.remainingAmount > 0.01) { + throw new Error(`Unallocated payment remainder detected for student ${student.id}`) + } + + const receiptNumber = await nextReceiptNumber(db, schoolId, paymentDate) + const paymentId = crypto.randomUUID() + const totalPaymentAmount = roundMoney(allocationResult.allocations.reduce((sum, allocation) => sum + allocation.amount, 0)) + + paymentsToInsert.push({ + id: paymentId, + schoolId, + studentId: student.id, + paymentPlanId, + receiptNumber, + amount: formatMoney(totalPaymentAmount), + currency: config.currency, + method: paymentMethod, + reference: `${config.schoolCode}-${student.matricule}-${receiptNumber}`, + bankName: paymentMethod === 'bank_transfer' || paymentMethod === 'check' ? 'Banque Atlantique' : null, + mobileProvider: paymentMethod === 'mobile_money' ? 'orange' : null, + paymentDate, + payerName: student.emergencyContact || `Parent de ${student.lastName} ${student.firstName}`, + payerPhone: student.emergencyPhone, + notes: `Paiement ${installment.label.toLowerCase()}`, + status: 'completed', + processedBy: creatorId, + }) + + allocationsToInsert.push(...allocationResult.allocations.map(allocation => ({ + id: crypto.randomUUID(), + paymentId, + studentFeeId: allocation.studentFeeId, + installmentId: allocation.installmentId, + amount: formatMoney(allocation.amount), + }))) + + const feeDetails = Array.from( + allocationResult.allocations.reduce((map, allocation) => { + map.set(allocation.feeTypeName, roundMoney((map.get(allocation.feeTypeName) ?? 0) + allocation.amount)) + return map + }, new Map()), + ).map(([feeType, amount]) => ({ + feeType, + amount, + })) + + receiptsToInsert.push({ + id: crypto.randomUUID(), + paymentId, + receiptNumber, + studentName: `${student.lastName} ${student.firstName}`, + studentMatricule: student.matricule, + className: targetGrade ? `${targetGrade.name} ${targetClass.section}` : `Classe ${targetClass.section}`, + amount: formatMoney(totalPaymentAmount), + amountInWords: null, + paymentMethod: paymentMethod, + paymentReference: `${config.schoolCode}-${receiptNumber}`, + paymentDate, + feeDetails, + schoolName: school.name, + schoolAddress: school.address, + schoolPhone: school.phone, + schoolLogoUrl: school.logoUrl, + issuedBy: 'Equipe finance', + }) + + paymentTransactions.push({ + paymentId, + paymentDate, + description: `Encaissement ${installment.label.toLowerCase()} - ${student.lastName} ${student.firstName}`, + reference: receiptNumber, + totalAmount: totalPaymentAmount, + lines: buildPaymentTransactionLines({ + cashAccountId: cashAccount.id, + paymentMethodLabel: methodLabels[paymentMethod], + allocations: allocationResult.allocations, + }), + }) + } } - if (installmentsToInsert.length > 0) { - await db.insert(schoolSchema.installments).values(installmentsToInsert).onConflictDoNothing() + for (const feeState of studentFeeStates) { + const remaining = chargeAllocatorState.find(charge => charge.studentFeeId === feeState.id)?.outstandingBalance ?? feeState.finalAmount + feeState.paidAmount = roundMoney(feeState.finalAmount - remaining) + feeState.outstandingBalance = remaining } - // Update Plan status - await db.update(schoolSchema.paymentPlans) - .set({ - paidAmount: String(totalPaid), - balance: String(totalAmount - totalPaid), - status: totalPaid >= totalAmount ? 'completed' : 'active', + const studentFeesToInsert: Array = studentFeeStates.map(feeState => { + const feeStructure = feeStructures.find(structure => { + const feeType = dbFeeTypes.find(item => item.id === structure.feeTypeId) + return feeType?.name === feeState.feeTypeName }) - .where(eq(schoolSchema.paymentPlans.id, planId)) + + if (!feeStructure) { + throw new Error(`Missing fee structure for ${feeState.feeTypeName}`) + } + + return { + id: feeState.id, + studentId: student.id, + enrollmentId: enrollment.id, + feeStructureId: feeStructure.id, + originalAmount: formatMoney(feeState.originalAmount), + finalAmount: formatMoney(feeState.finalAmount), + paidAmount: formatMoney(feeState.paidAmount), + balance: formatMoney(feeState.outstandingBalance), + status: (feeState.outstandingBalance <= 0 + ? 'paid' + : feeState.paidAmount > 0 + ? 'partial' + : 'pending') satisfies FeeStatus, + } + }) + + const totalPaid = roundMoney(studentFeeStates.reduce((sum, fee) => sum + fee.paidAmount, 0)) + const totalBalance = roundMoney(totalAmount - totalPaid) + const hasOverdueBalance = installmentStates.some(installment => installment.status === 'overdue') + const paymentPlanStatus: PaymentPlanStatus = totalBalance <= 0 ? 'completed' : hasOverdueBalance ? 'defaulted' : 'active' + + await insertRowsInChunks( + chunk => db.insert(schoolSchema.studentFees).values(chunk).onConflictDoNothing(), + studentFeesToInsert, + 50, + ) + + await insertTransactionWithLines({ + db, + schoolId, + fiscalYearId: accountingSetup.fiscalYear.id, + date: schoolYear.startDate, + type: 'journal', + description: `Constatation des frais - ${student.lastName} ${student.firstName}`, + reference: student.matricule, + totalAmount, + studentId: student.id, + createdBy: creatorId, + lines: buildChargeTransactionLines(studentFeeStates.map(feeState => ({ + feeTypeName: feeState.feeTypeName, + receivableAccountId: feeState.receivableAccountId, + revenueAccountId: feeState.revenueAccountId, + amount: feeState.finalAmount, + }))), + accountMap: accountingSetup.accountMapById, + }) + + await db.insert(schoolSchema.paymentPlans).values({ + id: paymentPlanId, + studentId: student.id, + schoolYearId: schoolYear.id, + templateId: activeTemplate.id, + totalAmount: formatMoney(totalAmount), + paidAmount: formatMoney(totalPaid), + balance: formatMoney(totalBalance), + status: paymentPlanStatus, + createdBy: creatorId, + }).onConflictDoNothing() + + const installmentsToInsert: Array = installmentStates.map(installment => ({ + id: installment.id, + paymentPlanId, + installmentNumber: installment.installmentNumber, + label: installment.label, + amount: formatMoney(installment.amount), + paidAmount: formatMoney(installment.paidAmount), + balance: formatMoney(installment.balance), + dueDate: formatDate(installment.dueDate), + status: installment.status as InstallmentStatus, + paidAt: installment.paidAt, + daysOverdue: installment.status === 'overdue' + ? Math.max(0, Math.floor((today.getTime() - installment.dueDate.getTime()) / (1000 * 60 * 60 * 24))) + : 0, + })) + await insertRowsInChunks( + chunk => db.insert(schoolSchema.installments).values(chunk).onConflictDoNothing(), + installmentsToInsert, + 50, + ) + + if (paymentsToInsert.length > 0) { + await insertRowsInChunks( + chunk => db.insert(schoolSchema.payments).values(chunk).onConflictDoNothing(), + paymentsToInsert, + 50, + ) + await insertRowsInChunks( + chunk => db.insert(schoolSchema.paymentAllocations).values(chunk).onConflictDoNothing(), + allocationsToInsert, + 75, + ) + await insertRowsInChunks( + chunk => db.insert(schoolSchema.receipts).values(chunk).onConflictDoNothing(), + receiptsToInsert, + 50, + ) + + for (const paymentTransaction of paymentTransactions) { + await insertTransactionWithLines({ + db, + schoolId, + fiscalYearId: accountingSetup.fiscalYear.id, + date: paymentTransaction.paymentDate, + type: 'payment', + description: paymentTransaction.description, + reference: paymentTransaction.reference, + totalAmount: paymentTransaction.totalAmount, + studentId: student.id, + paymentId: paymentTransaction.paymentId, + createdBy: creatorId, + lines: paymentTransaction.lines, + accountMap: accountingSetup.accountMapById, + }) + } + } } - databaseLogger.info(`Successfully seeded financial data for ${classContext.enrollments.length} enrollments.`) - return undefined + await flushAccountBalances({ + db, + accountMap: accountingSetup.accountMapById, + }) + + databaseLogger.info(`Successfully seeded realistic finance data for ${classContext.enrollments.length} enrollments.`) }, catch: (err) => DatabaseError.from(err, 'INTERNAL_ERROR', 'Failed to seed finance'), }), - R.mapError(tapLogErr(databaseLogger, { context: 'seedFinance', schoolId })) + R.mapError(tapLogErr(databaseLogger, { context: 'seedFinance', schoolId })), ) } diff --git a/packages/data-ops/src/seed/demo/seeders/grades-helpers.ts b/packages/data-ops/src/seed/demo/seeders/grades-helpers.ts new file mode 100644 index 00000000..77c4c2ef --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/grades-helpers.ts @@ -0,0 +1,60 @@ +import type * as schoolSchema from '../../../drizzle/school-schema' + +function toSeedId(parts: Array) { + return parts + .join('-') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +export function buildSeededGradeId(args: { + studentId: string + classId: string + subjectId: string + termId: string + gradeIndex: number +}) { + return toSeedId([ + 'demo-grade', + args.studentId, + args.classId, + args.subjectId, + args.termId, + args.gradeIndex + 1, + ]) +} + +export function buildSeededGradeRow(args: { + studentId: string + classId: string + subjectId: string + termId: string + teacherId: string + value: number + type: 'quiz' | 'test' | 'homework' + gradeDate: string + gradeIndex: number +}): typeof schoolSchema.studentGrades.$inferInsert { + return { + id: buildSeededGradeId({ + studentId: args.studentId, + classId: args.classId, + subjectId: args.subjectId, + termId: args.termId, + gradeIndex: args.gradeIndex, + }), + studentId: args.studentId, + classId: args.classId, + subjectId: args.subjectId, + termId: args.termId, + teacherId: args.teacherId, + value: args.value.toString(), + type: args.type, + weight: args.type === 'quiz' ? 1 : 2, + maxPoints: 20, + status: 'validated', + gradeDate: args.gradeDate, + createdAt: new Date(), + } +} diff --git a/packages/data-ops/src/seed/demo/seeders/grades.seeder.ts b/packages/data-ops/src/seed/demo/seeders/grades.seeder.ts index 89bdffaf..e52b605d 100644 --- a/packages/data-ops/src/seed/demo/seeders/grades.seeder.ts +++ b/packages/data-ops/src/seed/demo/seeders/grades.seeder.ts @@ -7,6 +7,8 @@ import * as schoolSchema from '../../../drizzle/school-schema' import * as coreSchema from '../../../drizzle/core-schema' import { DemoContext, SchoolContext, StudentContext, ClassContext } from '../config' import { SeededRandom } from '../utils/random' +import { buildSeededGradeRow } from './grades-helpers' +import { runChunkedWithRetry } from './seed-resilience-helpers' /** @@ -32,11 +34,16 @@ export function seedGrades( console.log('--- Seeding Grades ---') const dbSubjects = await db.select().from(coreSchema.subjects) const subjectsMap = new Map(dbSubjects.map(s => [s.id, s])) + const classSubjectsByClassId = new Map() + for (const classSubject of classSubjects) { + const existing = classSubjectsByClassId.get(classSubject.classId) ?? [] + classSubjectsByClassId.set(classSubject.classId, [...existing, classSubject]) + } const gradesToInsert: (typeof schoolSchema.studentGrades.$inferInsert)[] = [] for (const enrollment of enrollments) { const scenario = studentScenarioMap.get(enrollment.studentId) || 'average' - const studentClassSubjects = classSubjects.filter(cs => cs.classId === enrollment.classId) + const studentClassSubjects = classSubjectsByClassId.get(enrollment.classId) ?? [] for (const term of terms) { for (const cs of studentClassSubjects) { @@ -63,31 +70,31 @@ export function seedGrades( continue } - gradesToInsert.push({ - id: crypto.randomUUID(), + gradesToInsert.push(buildSeededGradeRow({ studentId: enrollment.studentId, classId: enrollment.classId, subjectId: cs.subjectId, termId: term.id, teacherId, - value: rawValue.toString(), + value: rawValue, type, - weight: type === 'quiz' ? 1 : 2, - maxPoints: 20, - status: 'validated', gradeDate: term.startDate, - createdAt: new Date(), - }) + gradeIndex: g, + })) } } } } console.log(`Inserting ${gradesToInsert.length} student grades...`) - const chunkSize = 500 - for (let i = 0; i < gradesToInsert.length; i += chunkSize) { - await db.insert(schoolSchema.studentGrades).values(gradesToInsert.slice(i, i + chunkSize)).onConflictDoNothing() - } + await runChunkedWithRetry(gradesToInsert, { + chunkSize: 200, + maxAttempts: 4, + delayMs: 250, + handler: async (chunk) => { + await db.insert(schoolSchema.studentGrades).values(chunk).onConflictDoNothing() + }, + }) return undefined }, catch: err => DatabaseError.from(err, 'INTERNAL_ERROR', 'Failed to seed grades'), diff --git a/packages/data-ops/src/seed/demo/seeders/operations-helpers.ts b/packages/data-ops/src/seed/demo/seeders/operations-helpers.ts new file mode 100644 index 00000000..9cbb6b6f --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/operations-helpers.ts @@ -0,0 +1,281 @@ +import type { StudentAttendanceStatus } from '../../../drizzle/school-schema' + +export interface TimetableSlot { + dayOfWeek: number + startTime: string + endTime: string +} + +export function chunkValues(values: T[], chunkSize: number): T[][] { + if (chunkSize <= 0) { + throw new Error('chunkSize must be greater than 0') + } + + const chunks: T[][] = [] + for (let index = 0; index < values.length; index += chunkSize) { + chunks.push(values.slice(index, index + chunkSize)) + } + + return chunks +} + +export interface TimetableSlotOccupancy { + teacherId?: string + classroomId?: string | null + slot: TimetableSlot +} + +export interface TimetableClassSubjectNeed { + classId: string + subjectId: string + teacherId: string + hoursPerWeek: number + preferredClassroomId?: string | null +} + +export interface TimetableAssignment { + classId: string + subjectId: string + teacherId: string + classroomId: string | null + dayOfWeek: number + startTime: string + endTime: string +} + +export function buildRecurringSlots(args: { + hoursPerWeek: number + slotPool: TimetableSlot[] + preferredStartIndex?: number +}): TimetableSlot[] { + if (args.hoursPerWeek <= 0 || args.slotPool.length === 0) { + return [] + } + + const startIndex = args.preferredStartIndex ?? 0 + const orderedSlots = [ + ...args.slotPool.slice(startIndex), + ...args.slotPool.slice(0, startIndex), + ] + + const selected: TimetableSlot[] = [] + const usedDays = new Set() + + for (const slot of orderedSlots) { + if (selected.length >= args.hoursPerWeek) { + break + } + + if (usedDays.has(slot.dayOfWeek)) { + continue + } + + selected.push(slot) + usedDays.add(slot.dayOfWeek) + } + + if (selected.length >= args.hoursPerWeek) { + return selected + } + + for (const slot of orderedSlots) { + if (selected.length >= args.hoursPerWeek) { + break + } + + const exists = selected.some(item => + item.dayOfWeek === slot.dayOfWeek + && item.startTime === slot.startTime + && item.endTime === slot.endTime, + ) + + if (!exists) { + selected.push(slot) + } + } + + return selected +} + +function sameSlot(left: TimetableSlot, right: TimetableSlot) { + return left.dayOfWeek === right.dayOfWeek && left.startTime === right.startTime && left.endTime === right.endTime +} + +function slotKey(slot: TimetableSlot) { + return `${slot.dayOfWeek}:${slot.startTime}:${slot.endTime}` +} + +function countDayLoad(assignments: TimetableSlot[], dayOfWeek: number) { + return assignments.filter(slot => slot.dayOfWeek === dayOfWeek).length +} + +export function assignTimetableSlots(args: { + slotPool: TimetableSlot[] + classroomIds: string[] + classSubjects: TimetableClassSubjectNeed[] + existingTeacherSlots?: TimetableSlotOccupancy[] + existingClassroomSlots?: TimetableSlotOccupancy[] +}): TimetableAssignment[] { + const inputOrder = new Map(args.classSubjects.map((need, index) => [`${need.classId}:${need.subjectId}:${need.teacherId}`, index])) + const classBusySlots = new Map() + const teacherBusySlots = new Map() + const classroomBusySlots = new Map() + const assignments: TimetableAssignment[] = [] + + for (const occupancy of args.existingTeacherSlots ?? []) { + if (!occupancy.teacherId) { + continue + } + + const existing = teacherBusySlots.get(occupancy.teacherId) ?? [] + teacherBusySlots.set(occupancy.teacherId, [...existing, occupancy.slot]) + } + + for (const occupancy of args.existingClassroomSlots ?? []) { + if (!occupancy.classroomId) { + continue + } + + const existing = classroomBusySlots.get(occupancy.classroomId) ?? [] + classroomBusySlots.set(occupancy.classroomId, [...existing, occupancy.slot]) + } + + for (const [index, need] of args.classSubjects.entries()) { + const preferredStartIndex = (index * 2) % Math.max(args.slotPool.length, 1) + const orderedSlots = [ + ...args.slotPool.slice(preferredStartIndex), + ...args.slotPool.slice(0, preferredStartIndex), + ] + const preferredCandidates = buildRecurringSlots({ + hoursPerWeek: Math.min(need.hoursPerWeek, Math.max(args.slotPool.length, 1)), + slotPool: orderedSlots, + }) + const fallbackCandidates = orderedSlots.filter(slot => + !preferredCandidates.some(candidate => sameSlot(candidate, slot)), + ) + const preferredKeys = new Set(preferredCandidates.map(candidate => slotKey(candidate))) + + let assignedHours = 0 + while (assignedHours < need.hoursPerWeek) { + const classTaken = classBusySlots.get(need.classId) ?? [] + const teacherTaken = teacherBusySlots.get(need.teacherId) ?? [] + const availableCandidates = [...preferredCandidates, ...fallbackCandidates] + .filter((candidate) => { + if (classTaken.some(existing => sameSlot(existing, candidate))) { + return false + } + + if (teacherTaken.some(existing => sameSlot(existing, candidate))) { + return false + } + + return true + }) + .map((candidate) => { + const orderedClassrooms = need.preferredClassroomId + ? [need.preferredClassroomId, ...args.classroomIds.filter(id => id !== need.preferredClassroomId)] + : args.classroomIds + + const availableClassroomId = orderedClassrooms.find((classroomId) => { + const classroomTaken = classroomBusySlots.get(classroomId) ?? [] + return !classroomTaken.some(existing => sameSlot(existing, candidate)) + }) ?? null + + if (!availableClassroomId) { + return null + } + + return { + candidate, + classroomId: availableClassroomId, + isPreferred: preferredKeys.has(slotKey(candidate)), + teacherDayLoad: countDayLoad(teacherTaken, candidate.dayOfWeek), + classDayLoad: countDayLoad(classTaken, candidate.dayOfWeek), + } + }) + .filter((candidate): candidate is NonNullable => candidate !== null) + .toSorted((left, right) => { + if (left.teacherDayLoad !== right.teacherDayLoad) { + return left.teacherDayLoad - right.teacherDayLoad + } + + if (left.classDayLoad !== right.classDayLoad) { + return left.classDayLoad - right.classDayLoad + } + + if (left.isPreferred !== right.isPreferred) { + return Number(right.isPreferred) - Number(left.isPreferred) + } + + const dayCompare = left.candidate.dayOfWeek - right.candidate.dayOfWeek + if (dayCompare !== 0) { + return dayCompare + } + + return left.candidate.startTime.localeCompare(right.candidate.startTime) + }) + + const nextCandidate = availableCandidates[0] + if (!nextCandidate) { + break + } + + assignments.push({ + classId: need.classId, + subjectId: need.subjectId, + teacherId: need.teacherId, + classroomId: nextCandidate.classroomId, + dayOfWeek: nextCandidate.candidate.dayOfWeek, + startTime: nextCandidate.candidate.startTime, + endTime: nextCandidate.candidate.endTime, + }) + + teacherBusySlots.set(need.teacherId, [...teacherTaken, nextCandidate.candidate]) + classBusySlots.set(need.classId, [...classTaken, nextCandidate.candidate]) + const classroomTaken = classroomBusySlots.get(nextCandidate.classroomId) ?? [] + classroomBusySlots.set(nextCandidate.classroomId, [...classroomTaken, nextCandidate.candidate]) + assignedHours += 1 + } + } + + return assignments.toSorted((left, right) => { + const classCompare = left.classId.localeCompare(right.classId) + if (classCompare !== 0) { + return classCompare + } + + const leftOrder = inputOrder.get(`${left.classId}:${left.subjectId}:${left.teacherId}`) ?? Number.MAX_SAFE_INTEGER + const rightOrder = inputOrder.get(`${right.classId}:${right.subjectId}:${right.teacherId}`) ?? Number.MAX_SAFE_INTEGER + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder + } + + const dayCompare = left.dayOfWeek - right.dayOfWeek + if (dayCompare !== 0) { + return dayCompare + } + + return left.startTime.localeCompare(right.startTime) + }) +} + +export function summarizeAttendanceHistory(args: { + totalSchoolDays: number + attendanceRecords: Array<{ status: StudentAttendanceStatus }> +}): { + totalDays: number + presentDays: number + absentDays: number + lateDays: number +} { + const absentDays = args.attendanceRecords.filter(record => record.status === 'absent').length + const lateDays = args.attendanceRecords.filter(record => record.status === 'late').length + const presentDays = Math.max(0, args.totalSchoolDays - absentDays - lateDays) + + return { + totalDays: args.totalSchoolDays, + presentDays, + absentDays, + lateDays, + } +} diff --git a/packages/data-ops/src/seed/demo/seeders/operations.seeder.ts b/packages/data-ops/src/seed/demo/seeders/operations.seeder.ts new file mode 100644 index 00000000..3d53419f --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/operations.seeder.ts @@ -0,0 +1,1101 @@ +import { and, eq, inArray, lte } from 'drizzle-orm' +import { Result as R } from '@praha/byethrow' +import { faker } from '@faker-js/faker' +import { databaseLogger, tapLogErr } from '@repo/logger' +import { DatabaseError } from '@repo/data-ops/errors' +import type { Database } from '../../../database/setup' +import * as schoolSchema from '../../../drizzle/school-schema' +import * as coreSchema from '../../../drizzle/core-schema' +import { buildReportCardTemplateVersion } from '../../../services/report-card-freshness' +import type { + MessageCategory, + ReportCardStatus, +} from '../../../drizzle/school-schema' +import type { ClassContext, DemoContext, SchoolContext, StudentContext, TeacherContext } from '../config' +import { SeededRandom } from '../utils/random' +import { addDays, formatDate } from '../utils/date-helpers' +import { assignTimetableSlots, summarizeAttendanceHistory, type TimetableSlot } from './operations-helpers' +import { + buildCurriculumArtifacts, + buildSessionAttendanceRows, + buildTeacherMessageThread, +} from './academic-realism-helpers' +import { buildOperationalEngagementArtifacts } from './engagement-helpers' +import { runAdaptiveChunkedWithRetry } from './seed-resilience-helpers' +import { + buildReportCardLifecycle, + buildReportCardNarrative, + buildSeededTeacherComments, + buildStudentAverages, +} from './reporting-helpers' + +const slotPool: TimetableSlot[] = [ + { dayOfWeek: 1, startTime: '07:30', endTime: '08:30' }, + { dayOfWeek: 1, startTime: '08:40', endTime: '09:40' }, + { dayOfWeek: 1, startTime: '10:00', endTime: '11:00' }, + { dayOfWeek: 1, startTime: '11:10', endTime: '12:10' }, + { dayOfWeek: 2, startTime: '07:30', endTime: '08:30' }, + { dayOfWeek: 2, startTime: '08:40', endTime: '09:40' }, + { dayOfWeek: 2, startTime: '10:00', endTime: '11:00' }, + { dayOfWeek: 2, startTime: '11:10', endTime: '12:10' }, + { dayOfWeek: 3, startTime: '07:30', endTime: '08:30' }, + { dayOfWeek: 3, startTime: '08:40', endTime: '09:40' }, + { dayOfWeek: 3, startTime: '10:00', endTime: '11:00' }, + { dayOfWeek: 3, startTime: '11:10', endTime: '12:10' }, + { dayOfWeek: 4, startTime: '07:30', endTime: '08:30' }, + { dayOfWeek: 4, startTime: '08:40', endTime: '09:40' }, + { dayOfWeek: 4, startTime: '10:00', endTime: '11:00' }, + { dayOfWeek: 4, startTime: '11:10', endTime: '12:10' }, + { dayOfWeek: 5, startTime: '07:30', endTime: '08:30' }, + { dayOfWeek: 5, startTime: '08:40', endTime: '09:40' }, + { dayOfWeek: 5, startTime: '10:00', endTime: '11:00' }, + { dayOfWeek: 5, startTime: '11:10', endTime: '12:10' }, +] + +const staffBlueprints: Array<{ + roleSlug: string + position: string + department: string +}> = [ + { roleSlug: 'school_director', position: 'Directeur', department: 'Direction' }, + { roleSlug: 'school_censor', position: 'Censeur', department: 'Vie scolaire' }, + { roleSlug: 'secretary', position: 'Secretaire', department: 'Administration' }, + { roleSlug: 'accountant', position: 'Comptable', department: 'Finance' }, + { roleSlug: 'cashier', position: 'Caissier', department: 'Finance' }, +] + +const messageTemplateBlueprints: Array<{ + name: string + category: MessageCategory + subject: string + content: string + placeholders: string[] +}> = [ + { + name: 'Absence eleve', + category: 'attendance', + subject: 'Absence de {studentName}', + content: 'Bonjour, {studentName} etait absent(e) le {date}. Merci de justifier cette absence.', + placeholders: ['studentName', 'date'], + }, + { + name: 'Resultats trimestriels', + category: 'grades', + subject: 'Bulletin disponible pour {studentName}', + content: 'Le bulletin de {studentName} pour {termName} est disponible sur Yeko.', + placeholders: ['studentName', 'termName'], + }, + { + name: 'Rappel paiement', + category: 'reminder', + subject: 'Rappel de paiement pour {studentName}', + content: 'Un solde de {balance} reste a regler pour {studentName}. Merci de passer en caisse.', + placeholders: ['studentName', 'balance'], + }, + { + name: 'Felicitations', + category: 'congratulations', + subject: 'Felicitations a {studentName}', + content: '{studentName} s est distingue(e) ce mois-ci par ses excellents resultats et son assiduite.', + placeholders: ['studentName'], + }, +] + +async function insertRowsInChunks( + insertFn: (chunk: T[]) => Promise, + rows: T[], + chunkSize = 250, +) { + await runAdaptiveChunkedWithRetry(rows, { + chunkSize, + minChunkSize: 10, + maxAttempts: 4, + delayMs: 250, + handler: async (chunk) => { + await insertFn(chunk) + }, + }) +} + +function countWeekdaysBetween(startDate: Date, endDate: Date) { + let total = 0 + + for (const date = new Date(startDate); date <= endDate; date.setUTCDate(date.getUTCDate() + 1)) { + const day = date.getUTCDay() + if (day !== 0 && day !== 6) { + total += 1 + } + } + + return total +} + +async function ensureUser( + db: Database, + email: string, + name: string, + phone: string, + cache: Map, +) { + const cached = cache.get(email) + if (cached) { + return cached + } + + const existing = await db.query.users.findFirst({ + where: eq(schoolSchema.users.email, email), + columns: { id: true }, + }) + + if (existing) { + cache.set(email, existing.id) + return existing.id + } + + const userId = crypto.randomUUID() + await db.insert(schoolSchema.users).values({ + id: userId, + name, + email, + phone, + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + }).onConflictDoNothing() + + cache.set(email, userId) + return userId +} + +export function seedOperations( + db: Database, + demoContext: DemoContext, + schoolContext: SchoolContext, + classContext: ClassContext, + studentContext: StudentContext, + teacherContext: TeacherContext, +): R.ResultAsync { + const random = new SeededRandom(demoContext.config.seed + 800) + + return R.pipe( + R.try({ + try: async () => { + const { school, schoolYear, terms, classrooms } = schoolContext + const { students, parents } = studentContext + const userCache = new Map() + + const roles = await db.query.roles.findMany({ + where: inArray(schoolSchema.roles.slug, staffBlueprints.map(item => item.roleSlug)), + }) + const roleMap = new Map(roles.map(role => [role.slug, role])) + + const existingAttendanceSettings = await db.query.attendanceSettings.findFirst({ + where: eq(schoolSchema.attendanceSettings.schoolId, school.id), + }) + + if (!existingAttendanceSettings) { + await db.insert(schoolSchema.attendanceSettings).values({ + id: crypto.randomUUID(), + schoolId: school.id, + teacherExpectedArrival: '07:30', + teacherLateThresholdMinutes: 15, + teacherLatenessAlertCount: 3, + studentLateThresholdMinutes: 10, + chronicAbsenceThresholdPercent: '12.50', + notifyParentOnAbsence: true, + notifyParentOnLate: true, + workingDays: [1, 2, 3, 4, 5], + notificationMethods: ['email', 'sms'], + }) + } + + const existingTemplates = await db.select({ id: schoolSchema.messageTemplates.id }) + .from(schoolSchema.messageTemplates) + .where(eq(schoolSchema.messageTemplates.schoolId, school.id)) + + if (existingTemplates.length === 0) { + await db.insert(schoolSchema.messageTemplates).values(messageTemplateBlueprints.map(template => ({ + id: crypto.randomUUID(), + schoolId: school.id, + name: template.name, + category: template.category, + subject: template.subject, + content: template.content, + placeholders: template.placeholders, + isSystem: true, + isActive: true, + createdBy: teacherContext.teachers[0]?.userId ?? null, + }))) + } + + const [reportCardTemplate] = await db.insert(schoolSchema.reportCardTemplates).values({ + id: crypto.randomUUID(), + schoolId: school.id, + name: 'Modele standard trimestriel', + isDefault: true, + config: { + showRank: true, + showAttendance: true, + showConduct: true, + showComments: true, + showCoefficients: true, + showAnnualAverage: true, + sections: ['academics', 'attendance', 'conduct', 'comments'], + annualAverageFormula: 'general_trimester', + signatureLabels: { + principal: 'Directeur', + homeroomTeacher: 'Professeur principal', + academicDirector: 'Censeur', + }, + }, + primaryColor: '#1f4b99', + fontFamily: 'DM Sans', + }).onConflictDoNothing().returning() + + const activeReportCardTemplate = reportCardTemplate ?? await db.query.reportCardTemplates.findFirst({ + where: and( + eq(schoolSchema.reportCardTemplates.schoolId, school.id), + eq(schoolSchema.reportCardTemplates.isDefault, true), + ), + }) + + if (!activeReportCardTemplate) { + throw new Error('Failed to initialize report card template') + } + + const templateVersion = buildReportCardTemplateVersion(activeReportCardTemplate) + + for (const blueprint of staffBlueprints) { + const role = roleMap.get(blueprint.roleSlug) + if (!role) { + continue + } + + const firstName = faker.person.firstName() + const lastName = faker.person.lastName() + const fullName = `${firstName} ${lastName}` + const email = faker.internet.email({ firstName, lastName }).toLowerCase() + const phone = faker.phone.number() + const userId = await ensureUser(db, email, fullName, phone, userCache) + + await db.insert(schoolSchema.userSchools).values({ + id: crypto.randomUUID(), + userId, + schoolId: school.id, + }).onConflictDoNothing() + + await db.insert(schoolSchema.userRoles).values({ + id: crypto.randomUUID(), + userId, + roleId: role.id, + schoolId: school.id, + }).onConflictDoNothing() + + await db.insert(schoolSchema.staff).values({ + id: crypto.randomUUID(), + userId, + schoolId: school.id, + position: blueprint.position, + department: blueprint.department, + hireDate: schoolYear.startDate, + status: 'active', + }).onConflictDoNothing() + } + + const subjectRows = await db.select({ + id: coreSchema.subjects.id, + name: coreSchema.subjects.name, + }).from(coreSchema.subjects) + + const classSubjectRows = await db.select({ + id: schoolSchema.classSubjects.id, + classId: schoolSchema.classSubjects.classId, + subjectId: schoolSchema.classSubjects.subjectId, + teacherId: schoolSchema.classSubjects.teacherId, + hoursPerWeek: schoolSchema.classSubjects.hoursPerWeek, + coefficient: schoolSchema.classSubjects.coefficient, + gradeId: schoolSchema.classes.gradeId, + classSection: schoolSchema.classes.section, + subjectName: coreSchema.subjects.name, + }) + .from(schoolSchema.classSubjects) + .innerJoin(schoolSchema.classes, eq(schoolSchema.classSubjects.classId, schoolSchema.classes.id)) + .innerJoin(coreSchema.subjects, eq(schoolSchema.classSubjects.subjectId, coreSchema.subjects.id)) + .where(inArray(schoolSchema.classSubjects.classId, classContext.classes.map(item => item.id))) + + const programRows = await db.select({ + programTemplateId: coreSchema.programTemplates.id, + gradeId: coreSchema.programTemplates.gradeId, + subjectId: coreSchema.programTemplates.subjectId, + }) + .from(coreSchema.programTemplates) + .where(eq(coreSchema.programTemplates.schoolYearTemplateId, schoolYear.schoolYearTemplateId)) + + const chapterRows = await db.select({ + id: coreSchema.programTemplateChapters.id, + programTemplateId: coreSchema.programTemplateChapters.programTemplateId, + title: coreSchema.programTemplateChapters.title, + order: coreSchema.programTemplateChapters.order, + }) + .from(coreSchema.programTemplateChapters) + + const existingTimetable = await db.select({ id: schoolSchema.timetableSessions.id }) + .from(schoolSchema.timetableSessions) + .where(eq(schoolSchema.timetableSessions.schoolId, school.id)) + + const timetableRows: Array = [] + + if (existingTimetable.length === 0) { + const assignedSlots = assignTimetableSlots({ + slotPool, + classroomIds: classrooms.map(classroom => classroom.id), + classSubjects: classContext.classes.flatMap(currentClass => + classSubjectRows + .filter(row => row.classId === currentClass.id && row.teacherId) + .slice(0, 8) + .map(row => ({ + classId: currentClass.id, + subjectId: row.subjectId, + teacherId: row.teacherId!, + hoursPerWeek: Math.min(2, row.hoursPerWeek ?? 2), + preferredClassroomId: currentClass.classroomId ?? null, + })), + ), + }) + + timetableRows.push(...assignedSlots.map(assignment => ({ + id: crypto.randomUUID(), + schoolId: school.id, + schoolYearId: schoolYear.id, + classId: assignment.classId, + subjectId: assignment.subjectId, + teacherId: assignment.teacherId, + classroomId: assignment.classroomId, + dayOfWeek: assignment.dayOfWeek, + startTime: assignment.startTime, + endTime: assignment.endTime, + effectiveFrom: schoolYear.startDate, + effectiveUntil: schoolYear.endDate, + isRecurring: true, + notes: 'Genere automatiquement pour la demo', + }))) + + if (timetableRows.length > 0) { + await db.insert(schoolSchema.timetableSessions).values(timetableRows).onConflictDoNothing() + } + } + + const timetableSessions = await db.select() + .from(schoolSchema.timetableSessions) + .where(eq(schoolSchema.timetableSessions.schoolId, school.id)) + + const existingClassSessions = await db.select({ id: schoolSchema.classSessions.id }) + .from(schoolSchema.classSessions) + .where(inArray(schoolSchema.classSessions.classId, classContext.classes.map(item => item.id))) + + if (existingClassSessions.length === 0) { + type PreservedAttendanceRow = { + studentId: string + status: 'present' | 'late' | 'absent' | 'excused' + notes?: string | null + } + + const classEnrollmentMap = new Map( + classContext.classes.map(currentClass => [ + currentClass.id, + classContext.enrollments.filter(enrollment => enrollment.classId === currentClass.id), + ]), + ) + const legacyAttendanceRows = await db.select({ + studentId: schoolSchema.studentAttendance.studentId, + classId: schoolSchema.studentAttendance.classId, + date: schoolSchema.studentAttendance.date, + status: schoolSchema.studentAttendance.status, + notes: schoolSchema.studentAttendance.notes, + }) + .from(schoolSchema.studentAttendance) + .where(eq(schoolSchema.studentAttendance.schoolId, school.id)) + + const recentWindowStart = addDays(new Date(), -42) + const sessionRows: Array = [] + const sessionAttendanceRows: Array = [] + const chapterCompletionRows: Array = [] + const curriculumProgressRows: Array = [] + const classRowsById = new Map(classContext.classes.map(currentClass => [currentClass.id, currentClass])) + const sessionDraftsByClassSubject = new Map>() + const legacyAttendanceBySessionKey = new Map() + + for (const row of legacyAttendanceRows) { + const key = `${row.classId}:${row.date}` + const existing = legacyAttendanceBySessionKey.get(key) ?? [] + existing.push({ + studentId: row.studentId, + status: row.status ?? 'absent', + notes: row.notes, + }) + legacyAttendanceBySessionKey.set(key, existing) + } + + for (const timetableSession of timetableSessions) { + let generated = 0 + + for (const date = new Date(recentWindowStart); date <= new Date(); date.setUTCDate(date.getUTCDate() + 1)) { + const jsDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay() + if (jsDay !== timetableSession.dayOfWeek) { + continue + } + + const formattedDate = formatDate(date) + if (formattedDate < (timetableSession.effectiveFrom ?? schoolYear.startDate) || formattedDate > (timetableSession.effectiveUntil ?? schoolYear.endDate)) { + continue + } + + const subjectName = subjectRows.find(subject => subject.id === timetableSession.subjectId)?.name ?? 'Cours' + const sessionId = crypto.randomUUID() + + sessionRows.push({ + id: sessionId, + classId: timetableSession.classId, + subjectId: timetableSession.subjectId, + teacherId: timetableSession.teacherId, + timetableSessionId: timetableSession.id, + date: formattedDate, + startTime: timetableSession.startTime, + endTime: timetableSession.endTime, + topic: `Sequence ${generated + 1} - ${subjectName}`, + objectives: `Consolider les acquis en ${subjectName.toLowerCase()}.`, + homework: random.next() > 0.35 ? `Exercices de consolidation en ${subjectName.toLowerCase()}.` : null, + status: 'completed', + completedAt: addDays(new Date(`${formattedDate}T00:00:00Z`), 0), + notes: generated % 3 === 0 ? 'Classe participative et rythme satisfaisant.' : null, + }) + + const classSubjectKey = `${timetableSession.classId}:${timetableSession.subjectId}` + const existingDrafts = sessionDraftsByClassSubject.get(classSubjectKey) ?? [] + existingDrafts.push({ + id: sessionId, + classId: timetableSession.classId, + subjectId: timetableSession.subjectId, + teacherId: timetableSession.teacherId, + date: formattedDate, + status: 'completed', + }) + sessionDraftsByClassSubject.set(classSubjectKey, existingDrafts) + + generated += 1 + if (generated >= 6) { + break + } + } + } + + for (const classSubject of classSubjectRows.filter(row => row.teacherId)) { + const matchingClass = classRowsById.get(classSubject.classId) + if (!matchingClass) { + continue + } + + const artifacts = buildCurriculumArtifacts({ + classId: classSubject.classId, + gradeId: classSubject.gradeId, + subjectId: classSubject.subjectId, + terms: terms.map(term => ({ + id: term.id, + startDate: term.startDate, + endDate: term.endDate, + })), + programs: programRows, + chapters: chapterRows, + sessions: sessionDraftsByClassSubject.get(`${classSubject.classId}:${classSubject.subjectId}`) ?? [], + calculatedAt: formatDate(new Date()), + }) + + const assignmentMap = new Map(artifacts.sessionChapterAssignments.map(item => [item.sessionId, item])) + for (const sessionRow of sessionRows) { + const assignment = assignmentMap.get(sessionRow.id) + if (!assignment) { + continue + } + + sessionRow.chapterId = assignment.chapterId + sessionRow.topic = assignment.topic + sessionRow.objectives = assignment.objectives + } + + for (const completion of artifacts.chapterCompletions) { + chapterCompletionRows.push({ + id: crypto.randomUUID(), + classId: completion.classId, + subjectId: completion.subjectId, + chapterId: completion.chapterId, + classSessionId: completion.classSessionId, + teacherId: completion.teacherId, + completedAt: new Date(completion.completedAt), + notes: completion.notes, + }) + } + + for (const progress of artifacts.progressRecords) { + curriculumProgressRows.push({ + id: crypto.randomUUID(), + classId: progress.classId, + subjectId: progress.subjectId, + programTemplateId: progress.programTemplateId, + termId: progress.termId, + totalChapters: progress.totalChapters, + completedChapters: progress.completedChapters, + progressPercentage: progress.progressPercentage, + expectedPercentage: progress.expectedPercentage, + variance: progress.variance, + status: progress.status, + lastChapterCompletedAt: progress.lastChapterCompletedAt ? new Date(progress.lastChapterCompletedAt) : null, + calculatedAt: new Date(progress.calculatedAt), + }) + } + + const sessionDrafts = sessionDraftsByClassSubject.get(`${classSubject.classId}:${classSubject.subjectId}`) ?? [] + const classStudentIds = (classEnrollmentMap.get(classSubject.classId) ?? []).map(enrollment => enrollment.studentId) + + for (const session of sessionDrafts) { + const preservedRows = legacyAttendanceBySessionKey.get(`${classSubject.classId}:${session.date}`) ?? [] + const absentStudentIds = classStudentIds.filter((studentId) => { + if (preservedRows.some(row => row.studentId === studentId)) { + return false + } + + const scenario = studentContext.studentScenarioMap.get(studentId) + const absenceProbability = scenario === 'excellent' ? 0.005 : scenario === 'struggling' ? 0.06 : 0.025 + return random.next() < absenceProbability + }) + const lateStudentIds = classStudentIds.filter((studentId) => { + if (preservedRows.some(row => row.studentId === studentId) || absentStudentIds.includes(studentId)) { + return false + } + + const scenario = studentContext.studentScenarioMap.get(studentId) + const lateProbability = scenario === 'excellent' ? 0.01 : scenario === 'struggling' ? 0.08 : 0.04 + return random.next() < lateProbability + }) + + const attendanceRows = buildSessionAttendanceRows({ + schoolId: school.id, + classId: classSubject.classId, + classSessionId: session.id, + date: session.date, + recordedBy: teacherContext.teachers[0]?.userId ?? null, + studentIds: classStudentIds, + existingRows: preservedRows, + absentStudentIds, + lateStudentIds, + }) + + const absentCount = attendanceRows.filter(row => row.status === 'absent').length + const presentCount = attendanceRows.filter(row => row.status !== 'absent').length + const targetSession = sessionRows.find(row => row.id === session.id) + if (targetSession) { + targetSession.studentsAbsent = absentCount + targetSession.studentsPresent = presentCount + } + + sessionAttendanceRows.push(...attendanceRows.map(row => ({ + id: crypto.randomUUID(), + studentId: row.studentId, + classId: row.classId, + schoolId: row.schoolId, + classSessionId: row.classSessionId, + date: row.date, + status: row.status, + parentNotified: row.parentNotified ?? false, + notificationMethod: row.notificationMethod, + notes: row.notes, + lateMinutes: row.lateMinutes, + recordedBy: row.recordedBy, + }))) + } + } + + if (sessionRows.length > 0) { + await insertRowsInChunks( + chunk => db.insert(schoolSchema.classSessions).values(chunk).onConflictDoNothing(), + sessionRows, + 200, + ) + } + + await db.delete(schoolSchema.studentAttendance).where(eq(schoolSchema.studentAttendance.schoolId, school.id)) + + if (sessionAttendanceRows.length > 0) { + await insertRowsInChunks( + chunk => db.insert(schoolSchema.studentAttendance).values(chunk).onConflictDoNothing(), + sessionAttendanceRows, + 75, + ) + } + + if (chapterCompletionRows.length > 0) { + await insertRowsInChunks( + chunk => db.insert(schoolSchema.chapterCompletions).values(chunk).onConflictDoNothing(), + chapterCompletionRows, + 200, + ) + } + + if (curriculumProgressRows.length > 0) { + await insertRowsInChunks( + chunk => db.insert(schoolSchema.curriculumProgress).values(chunk).onConflictDoNothing(), + curriculumProgressRows, + 200, + ) + } + } + + const completedTerms = terms.filter(term => new Date(`${term.endDate}T00:00:00Z`) <= new Date()) + const currentOrCompletedTerms = completedTerms.length > 0 ? completedTerms : terms.slice(0, 1) + const attendanceRows = await db.select({ + studentId: schoolSchema.studentAttendance.studentId, + date: schoolSchema.studentAttendance.date, + status: schoolSchema.studentAttendance.status, + }) + .from(schoolSchema.studentAttendance) + .where(eq(schoolSchema.studentAttendance.schoolId, school.id)) + + const conductRows = await db.select({ + studentId: schoolSchema.conductRecords.studentId, + incidentDate: schoolSchema.conductRecords.incidentDate, + title: schoolSchema.conductRecords.title, + status: schoolSchema.conductRecords.status, + }) + .from(schoolSchema.conductRecords) + .where(eq(schoolSchema.conductRecords.schoolId, school.id)) + + const gradeRows = await db.select({ + studentId: schoolSchema.studentGrades.studentId, + classId: schoolSchema.studentGrades.classId, + subjectId: schoolSchema.studentGrades.subjectId, + value: schoolSchema.studentGrades.value, + weight: schoolSchema.studentGrades.weight, + status: schoolSchema.studentGrades.status, + gradeDate: schoolSchema.studentGrades.gradeDate, + }) + .from(schoolSchema.studentGrades) + .innerJoin(schoolSchema.classes, eq(schoolSchema.studentGrades.classId, schoolSchema.classes.id)) + .where(eq(schoolSchema.classes.schoolId, school.id)) + + const studentParentRows = await db.select({ + studentId: schoolSchema.studentParents.studentId, + parentId: schoolSchema.studentParents.parentId, + isPrimary: schoolSchema.studentParents.isPrimary, + }) + .from(schoolSchema.studentParents) + .where(inArray(schoolSchema.studentParents.studentId, students.map(item => item.id))) + + const overdueInstallmentRows = await db.select({ + studentId: schoolSchema.paymentPlans.studentId, + overdueCount: schoolSchema.installments.id, + }) + .from(schoolSchema.installments) + .innerJoin(schoolSchema.paymentPlans, eq(schoolSchema.installments.paymentPlanId, schoolSchema.paymentPlans.id)) + .innerJoin(schoolSchema.students, eq(schoolSchema.paymentPlans.studentId, schoolSchema.students.id)) + .where(and( + eq(schoolSchema.students.schoolId, school.id), + eq(schoolSchema.installments.status, 'overdue'), + )) + + const existingStudentAverages = await db.select({ id: schoolSchema.studentAverages.id }) + .from(schoolSchema.studentAverages) + .where(inArray(schoolSchema.studentAverages.classId, classContext.classes.map(item => item.id))) + + if (existingStudentAverages.length === 0) { + const averageRows: Array = [] + + for (const term of currentOrCompletedTerms) { + const termStart = new Date(`${term.startDate}T00:00:00Z`) + const termEnd = new Date(`${term.endDate}T00:00:00Z`) + + for (const currentClass of classContext.classes) { + const classGrades = gradeRows + .filter(row => + row.classId === currentClass.id + && new Date(`${row.gradeDate}T00:00:00Z`) >= termStart + && new Date(`${row.gradeDate}T00:00:00Z`) <= termEnd, + ) + .map(row => ({ + studentId: row.studentId, + subjectId: row.subjectId, + value: row.value, + weight: row.weight, + status: row.status, + })) + + const classCoefficientMap = new Map( + classSubjectRows + .filter(row => row.classId === currentClass.id) + .map(row => [row.subjectId, { weight: row.coefficient ?? 1, isFacultative: false }]), + ) + + const drafts = buildStudentAverages({ + classId: currentClass.id, + termId: term.id, + gradeRows: classGrades, + coefficientMap: classCoefficientMap, + calculatedAt: new Date(`${term.endDate}T12:00:00.000Z`).toISOString(), + }) + + averageRows.push(...drafts.map(draft => ({ + id: crypto.randomUUID(), + studentId: draft.studentId, + termId: draft.termId, + subjectId: draft.subjectId, + classId: draft.classId, + average: draft.average, + weightedAverage: draft.weightedAverage, + gradeCount: draft.gradeCount, + rankInClass: draft.rankInClass, + rankInGrade: draft.rankInGrade, + calculatedAt: new Date(draft.calculatedAt), + isFinal: draft.isFinal, + }))) + } + } + + if (averageRows.length > 0) { + await insertRowsInChunks( + chunk => db.insert(schoolSchema.studentAverages).values(chunk).onConflictDoNothing(), + averageRows, + 200, + ) + } + } + + const studentAverageRows = await db.select({ + studentId: schoolSchema.studentAverages.studentId, + termId: schoolSchema.studentAverages.termId, + classId: schoolSchema.studentAverages.classId, + subjectId: schoolSchema.studentAverages.subjectId, + average: schoolSchema.studentAverages.average, + weightedAverage: schoolSchema.studentAverages.weightedAverage, + gradeCount: schoolSchema.studentAverages.gradeCount, + rankInClass: schoolSchema.studentAverages.rankInClass, + subjectName: coreSchema.subjects.name, + }) + .from(schoolSchema.studentAverages) + .leftJoin(coreSchema.subjects, eq(schoolSchema.studentAverages.subjectId, coreSchema.subjects.id)) + .where(and( + inArray(schoolSchema.studentAverages.classId, classContext.classes.map(item => item.id)), + inArray(schoolSchema.studentAverages.termId, currentOrCompletedTerms.map(term => term.id)), + )) + + const existingReportCards = await db.select({ + studentId: schoolSchema.reportCards.studentId, + termId: schoolSchema.reportCards.termId, + }) + .from(schoolSchema.reportCards) + .where(and( + eq(schoolSchema.reportCards.schoolYearId, schoolYear.id), + inArray(schoolSchema.reportCards.classId, classContext.classes.map(item => item.id)), + )) + + const existingReportCardKeys = new Set( + existingReportCards.map(row => `${row.studentId}:${row.termId}`), + ) + + const reportCardRows: Array = [] + const teacherCommentRows: Array = [] + + for (const term of currentOrCompletedTerms) { + const termStart = new Date(`${term.startDate}T00:00:00Z`) + const termEnd = new Date(`${term.endDate}T00:00:00Z`) + const totalSchoolDays = countWeekdaysBetween(termStart, termEnd) + + for (const enrollment of classContext.enrollments) { + const student = students.find(item => item.id === enrollment.studentId) + if (!student) { + continue + } + + const reportCardKey = `${student.id}:${term.id}` + if (existingReportCardKeys.has(reportCardKey)) { + continue + } + + const studentAttendance = attendanceRows.filter(row => + row.studentId === student.id + && new Date(`${row.date}T00:00:00Z`) >= termStart + && new Date(`${row.date}T00:00:00Z`) <= termEnd, + ) + + const attendanceSummary = summarizeAttendanceHistory({ + totalSchoolDays, + attendanceRecords: studentAttendance, + }) + + const studentConduct = conductRows.filter(row => + row.studentId === student.id + && row.incidentDate + && new Date(`${row.incidentDate}T00:00:00Z`) >= termStart + && new Date(`${row.incidentDate}T00:00:00Z`) <= termEnd, + ) + + const classSize = classContext.enrollments.filter(item => item.classId === enrollment.classId).length + const overallAverage = studentAverageRows.find(row => + row.studentId === student.id + && row.classId === enrollment.classId + && row.termId === term.id + && row.subjectId === null, + ) + const reportNarrative = buildReportCardNarrative({ + studentDisplayName: `${student.firstName} ${student.lastName}`, + weightedAverage: overallAverage?.weightedAverage ? Number(overallAverage.weightedAverage) : null, + rankInClass: overallAverage?.rankInClass ?? null, + totalStudents: classSize, + absentDays: attendanceSummary.absentDays ?? 0, + lateDays: attendanceSummary.lateDays ?? 0, + totalDays: attendanceSummary.totalDays ?? totalSchoolDays, + conductIncidentCount: studentConduct.length, + }) + const linkedParentIds = studentParentRows + .filter(row => row.studentId === student.id) + .sort((left, right) => Number(right.isPrimary) - Number(left.isPrimary)) + .map(row => row.parentId) + const parent = linkedParentIds.length > 0 + ? parents.find(currentParent => currentParent.id === linkedParentIds[0]) + : undefined + const lifecycle = buildReportCardLifecycle({ + termEndDate: term.endDate, + currentDate: new Date().toISOString(), + hasParentEmail: Boolean(parent?.email), + rankInClass: overallAverage?.rankInClass ?? null, + }) + const reportCardId = crypto.randomUUID() + reportCardRows.push({ + id: reportCardId, + studentId: student.id, + classId: enrollment.classId, + termId: term.id, + schoolYearId: schoolYear.id, + templateId: activeReportCardTemplate.id, + status: lifecycle.status as ReportCardStatus, + generatedAt: lifecycle.generatedAt ? new Date(lifecycle.generatedAt) : null, + generatedBy: teacherContext.teachers[0]?.userId ?? null, + pdfUrl: lifecycle.pdfUrl, + sentAt: lifecycle.sentAt ? new Date(lifecycle.sentAt) : null, + sentTo: lifecycle.sentTo === 'email' ? parent?.email ?? null : null, + deliveryMethod: lifecycle.deliveryMethod, + deliveredAt: lifecycle.deliveredAt ? new Date(lifecycle.deliveredAt) : null, + viewedAt: lifecycle.viewedAt ? new Date(lifecycle.viewedAt) : null, + homeroomComment: reportNarrative.homeroomComment, + conductSummary: reportNarrative.conductSummary, + attendanceSummary, + templateVersion, + }) + existingReportCardKeys.add(reportCardKey) + + const classSubjects = classSubjectRows + .filter(row => row.classId === enrollment.classId && row.teacherId) + + if (classSubjects.length === 0) { + continue + } + + teacherCommentRows.push(...buildSeededTeacherComments({ + reportCardId, + subjectAverages: studentAverageRows + .filter(row => + row.studentId === student.id + && row.classId === enrollment.classId + && row.termId === term.id + && row.subjectId !== null, + ) + .map(row => ({ + subjectId: row.subjectId!, + subjectName: row.subjectName ?? 'Matiere', + average: row.average, + weightedAverage: row.weightedAverage, + rankInClass: row.rankInClass, + gradeCount: row.gradeCount, + })), + teacherAssignments: classSubjects.map(classSubject => ({ + subjectId: classSubject.subjectId, + teacherId: classSubject.teacherId!, + subjectName: classSubject.subjectName, + })), + }).map(comment => ({ + id: crypto.randomUUID(), + reportCardId, + subjectId: comment.subjectId, + teacherId: comment.teacherId, + comment: comment.comment, + }))) + } + } + + if (reportCardRows.length > 0) { + await insertRowsInChunks( + chunk => db.insert(schoolSchema.reportCards).values(chunk).onConflictDoNothing(), + reportCardRows, + 200, + ) + } + + if (teacherCommentRows.length > 0) { + await insertRowsInChunks( + chunk => db.insert(schoolSchema.teacherComments).values(chunk).onConflictDoNothing(), + teacherCommentRows, + 200, + ) + } + + const existingMessages = await db.select({ id: schoolSchema.teacherMessages.id }) + .from(schoolSchema.teacherMessages) + .where(eq(schoolSchema.teacherMessages.schoolId, school.id)) + + if (existingMessages.length === 0) { + const attendanceByStudent = new Map>() + for (const row of attendanceRows) { + const existing = attendanceByStudent.get(row.studentId) ?? [] + existing.push(row) + attendanceByStudent.set(row.studentId, existing) + } + + const overdueByStudent = new Map() + for (const row of overdueInstallmentRows) { + overdueByStudent.set(row.studentId, (overdueByStudent.get(row.studentId) ?? 0) + 1) + } + + const reportCardsByStudent = await db.select({ + studentId: schoolSchema.reportCards.studentId, + classId: schoolSchema.reportCards.classId, + status: schoolSchema.reportCards.status, + }) + .from(schoolSchema.reportCards) + .where(eq(schoolSchema.reportCards.schoolYearId, schoolYear.id)) + + const reportCardStatusByStudentClass = new Map( + reportCardsByStudent.map(row => [`${row.studentId}:${row.classId}`, row.status]), + ) + + const messagesToInsert: Array = [] + const engagementArtifacts = buildOperationalEngagementArtifacts({ + schoolId: school.id, + currentDate: new Date().toISOString(), + studentSignals: classContext.enrollments + .map((enrollment) => { + const student = students.find(item => item.id === enrollment.studentId) + const linkedParents = studentParentRows + .filter(row => row.studentId === enrollment.studentId) + .sort((left, right) => Number(right.isPrimary) - Number(left.isPrimary)) + .map(row => parents.find(parent => parent.id === row.parentId)) + .filter((parent): parent is NonNullable => !!parent) + const classTeacher = classSubjectRows.find(row => row.classId === enrollment.classId && row.teacherId) + const overallAverage = studentAverageRows.find(row => + row.studentId === enrollment.studentId + && row.classId === enrollment.classId + && row.subjectId === null, + ) + + if (!student || linkedParents.length === 0 || !classTeacher?.teacherId) { + return null + } + + return { + studentId: student.id, + classId: enrollment.classId, + parentId: linkedParents[0]!.id, + teacherId: classTeacher.teacherId, + studentName: `${student.firstName} ${student.lastName}`, + subjectName: classTeacher.subjectName, + absentCount: (attendanceByStudent.get(student.id) ?? []).filter(row => row.status === 'absent').length, + weightedAverage: overallAverage?.weightedAverage ? Number(overallAverage.weightedAverage) : null, + overdueInstallmentCount: overdueByStudent.get(student.id) ?? 0, + reportCardStatus: reportCardStatusByStudentClass.get(`${student.id}:${enrollment.classId}`) ?? 'draft', + } + }) + .filter((item): item is NonNullable => item !== null), + }) + + for (const [index, plan] of engagementArtifacts.messageThreads.entries()) { + const threadId = `engagement-${index + 1}-${plan.studentId}` + const thread = buildTeacherMessageThread({ + threadId, + schoolId: school.id, + teacherId: plan.teacherId, + parentId: plan.parentId, + studentId: plan.studentId, + classId: plan.classId, + signal: plan.signal, + studentName: plan.studentName, + subjectName: plan.subjectName, + startedAt: plan.startedAt, + }) + + messagesToInsert.push(...thread.map((message, messageIndex) => ({ + id: messageIndex === 0 ? `${threadId}-msg-1` : `${threadId}-msg-2`, + schoolId: message.schoolId, + senderType: message.senderType, + senderId: message.senderId, + recipientType: message.recipientType, + recipientId: message.recipientId, + studentId: message.studentId, + classId: message.classId, + threadId: message.threadId, + replyToId: message.replyToId, + subject: message.subject, + content: message.content, + isRead: message.isRead, + isArchived: message.isArchived, + isStarred: message.isStarred, + createdAt: new Date(message.createdAt), + }))) + } + + if (messagesToInsert.length > 0) { + await insertRowsInChunks( + chunk => db.insert(schoolSchema.teacherMessages).values(chunk).onConflictDoNothing(), + messagesToInsert, + 200, + ) + } + + const existingNotifications = await db.select({ id: schoolSchema.teacherNotifications.id }) + .from(schoolSchema.teacherNotifications) + .where(inArray(schoolSchema.teacherNotifications.teacherId, teacherContext.teachers.map(item => item.id))) + + if (existingNotifications.length === 0) { + const notifications: Array = engagementArtifacts.notifications.map((notification, index) => ({ + id: `engagement-notification-${index + 1}`, + teacherId: notification.teacherId, + type: notification.type, + title: notification.title, + body: notification.body, + actionType: notification.actionType, + actionData: notification.actionData, + relatedType: notification.relatedType, + relatedId: notification.relatedId, + isRead: notification.isRead, + createdAt: new Date(notification.createdAt), + })) + + if (notifications.length > 0) { + await insertRowsInChunks( + chunk => db.insert(schoolSchema.teacherNotifications).values(chunk).onConflictDoNothing(), + notifications, + 200, + ) + } + } + } + + databaseLogger.info(`Operational demo setup seeded for school ${school.id}.`) + }, + catch: (err) => DatabaseError.from(err, 'INTERNAL_ERROR', 'Failed to seed demo operations'), + }), + R.mapError(tapLogErr(databaseLogger, { context: 'seedOperations', schoolId: demoContext.schoolId })), + ) +} diff --git a/packages/data-ops/src/seed/demo/seeders/profile-kpi-helpers.ts b/packages/data-ops/src/seed/demo/seeders/profile-kpi-helpers.ts new file mode 100644 index 00000000..5618d99c --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/profile-kpi-helpers.ts @@ -0,0 +1,49 @@ +import type { DemoSeedConfig, FinanceScenarioDistribution, StudentScenarioDistribution } from '../config' +import { financeScenarios, studentScenarios } from '../scenarios/presets' + +export interface EstimatedProfileKpis { + expectedAttendanceRate: number + expectedCollectionHealth: number + expectedIncidentPressure: number +} + +function weightedStudentAttendance(distribution: StudentScenarioDistribution) { + return (distribution.excellent * studentScenarios.excellent.attendanceRate) + + (distribution.average * studentScenarios.average.attendanceRate) + + (distribution.struggling * studentScenarios.struggling.attendanceRate) +} + +function weightedIncidentPressure(distribution: StudentScenarioDistribution) { + return (distribution.excellent * studentScenarios.excellent.conductIncidentProbability) + + (distribution.average * studentScenarios.average.conductIncidentProbability) + + (distribution.struggling * studentScenarios.struggling.conductIncidentProbability) +} + +function weightedCollectionHealth(distribution: FinanceScenarioDistribution) { + const onTimeScore = 1 - ( + financeScenarios.onTime.partialPaymentProbability * 0.35 + + financeScenarios.onTime.skipProbability + ) + const lateScore = 1 - ( + financeScenarios.late.partialPaymentProbability * 0.35 + + financeScenarios.late.skipProbability + + 0.15 + ) + const criticalScore = 1 - ( + financeScenarios.critical.partialPaymentProbability * 0.35 + + financeScenarios.critical.skipProbability + + 0.3 + ) + + return (distribution.onTime * onTimeScore) + + (distribution.late * lateScore) + + (distribution.critical * criticalScore) +} + +export function estimateProfileKpis(config: DemoSeedConfig): EstimatedProfileKpis { + return { + expectedAttendanceRate: weightedStudentAttendance(config.studentDistribution), + expectedCollectionHealth: weightedCollectionHealth(config.financeDistribution), + expectedIncidentPressure: weightedIncidentPressure(config.studentDistribution), + } +} diff --git a/packages/data-ops/src/seed/demo/seeders/reporting-helpers.ts b/packages/data-ops/src/seed/demo/seeders/reporting-helpers.ts new file mode 100644 index 00000000..07775b81 --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/reporting-helpers.ts @@ -0,0 +1,366 @@ +export interface DemoGradeRow { + studentId: string + subjectId: string + value: string + weight: number + status: 'draft' | 'submitted' | 'validated' | 'rejected' +} + +export interface DemoCoefficientConfig { + weight: number + isFacultative: boolean +} + +export interface DemoStudentAverageDraft { + studentId: string + classId: string + termId: string + subjectId: string | null + average: string + weightedAverage: string + gradeCount: number + rankInClass: number | null + rankInGrade: number | null + isFinal: boolean + calculatedAt: string +} + +export interface DemoSeededSubjectAverage { + subjectId: string + subjectName: string + average: string + weightedAverage: string | null + rankInClass: number | null + gradeCount: number +} + +export interface DemoTeacherAssignment { + subjectId: string + teacherId: string + subjectName: string +} + +export interface DemoReportCardLifecycle { + status: 'draft' | 'generated' | 'sent' | 'delivered' | 'viewed' + generatedAt: string | null + sentAt: string | null + sentTo: 'email' | null + deliveryMethod: 'email' | null + deliveredAt: string | null + viewedAt: string | null + pdfUrl: string | null +} + +function formatDecimal(value: number) { + return value.toFixed(2) +} + +function addUtcDays(date: Date, days: number, hours = 12) { + const next = new Date(date) + next.setUTCDate(next.getUTCDate() + days) + next.setUTCHours(hours, 0, 0, 0) + return next +} + +function normalizeRankLabel(rankInClass: number | null) { + if (rankInClass === null || rankInClass <= 0) { + return null + } + + return `${rankInClass}e` +} + +function buildSubjectComment(subjectName: string, average: number, rankInClass: number | null) { + const normalizedSubject = subjectName.toLowerCase() + const rankLabel = normalizeRankLabel(rankInClass) + + if (average >= 15) { + return rankLabel + ? `Tres bonne progression en ${normalizedSubject}. L eleve se distingue dans la classe et maintient un travail serieux.` + : `Tres bonne progression en ${normalizedSubject}. Le travail est regulier et bien maitrise.` + } + + if (average >= 12) { + return rankLabel + ? `Bons acquis en ${normalizedSubject}. La progression reste solide et la place actuelle dans la classe est encourageante.` + : `Bons acquis en ${normalizedSubject}. La progression reste reguliere sur la periode.` + } + + if (average >= 10) { + return `Resultats encore fragiles en ${normalizedSubject}. Plus de constance permettrait de gagner en assurance.` + } + + return `Des difficultes persistent en ${normalizedSubject}. Des exercices de remediation reguliers sont recommandes.` +} + +export function buildSeededTeacherComments(args: { + reportCardId: string + subjectAverages: DemoSeededSubjectAverage[] + teacherAssignments: DemoTeacherAssignment[] +}) { + const averagesBySubjectId = new Map( + args.subjectAverages.map(average => [average.subjectId, average]), + ) + + return args.teacherAssignments + .map((assignment) => { + const average = averagesBySubjectId.get(assignment.subjectId) + if (!average || average.gradeCount <= 0) { + return null + } + + return { + reportCardId: args.reportCardId, + subjectId: assignment.subjectId, + teacherId: assignment.teacherId, + comment: buildSubjectComment( + average.subjectName || assignment.subjectName, + Number(average.weightedAverage ?? average.average), + average.rankInClass, + ), + } + }) + .filter((row): row is NonNullable => row !== null) + .toSorted((left, right) => left.subjectId.localeCompare(right.subjectId)) +} + +export function buildReportCardNarrative(args: { + studentDisplayName: string + weightedAverage: number | null + rankInClass: number | null + totalStudents: number + absentDays: number + lateDays: number + totalDays: number + conductIncidentCount: number +}) { + const baseComment = args.weightedAverage !== null && args.weightedAverage >= 15 + ? `${args.studentDisplayName} confirme un excellent niveau d ensemble.` + : args.weightedAverage !== null && args.weightedAverage >= 12 + ? `${args.studentDisplayName} maintient un bon niveau d ensemble.` + : args.weightedAverage !== null && args.weightedAverage >= 10 + ? `${args.studentDisplayName} conserve un niveau globalement correct.` + : `${args.studentDisplayName} reste en dessous des attentes sur plusieurs apprentissages.` + + const rankComment = args.rankInClass !== null && args.totalStudents > 0 + ? ` Classement actuel: ${args.rankInClass}e sur ${args.totalStudents}.` + : '' + + const attendanceRatio = args.totalDays > 0 + ? (args.absentDays + (args.lateDays * 0.5)) / args.totalDays + : 0 + + const attendanceComment = attendanceRatio <= 0.08 + ? ' Assiduite satisfaisante sur la periode.' + : attendanceRatio <= 0.15 + ? ' Assiduite a surveiller sur la periode.' + : ' L assiduite doit etre renforcee sur la periode.' + + const conductSummary = args.conductIncidentCount > 0 + ? `${args.conductIncidentCount} fait(s) de vie scolaire signale(s) sur la periode.` + : 'Comportement satisfaisant sur la periode.' + + return { + homeroomComment: `${baseComment}${rankComment}${attendanceComment}`, + conductSummary, + } +} + +export function buildReportCardLifecycle(args: { + termEndDate: string + currentDate: string + hasParentEmail: boolean + rankInClass: number | null +}): DemoReportCardLifecycle { + const termEnd = new Date(`${args.termEndDate}T00:00:00.000Z`) + const currentDate = new Date(args.currentDate) + + if (termEnd.getTime() > currentDate.getTime()) { + return { + status: 'draft', + generatedAt: null, + sentAt: null, + sentTo: null, + deliveryMethod: null, + deliveredAt: null, + viewedAt: null, + pdfUrl: null, + } + } + + const daysSinceTermEnd = Math.floor((currentDate.getTime() - termEnd.getTime()) / (1000 * 60 * 60 * 24)) + const generatedAt = addUtcDays(termEnd, 7, 12) + const pdfBase = `/demo/report-cards/${args.termEndDate}` + + if (!args.hasParentEmail) { + return { + status: 'generated', + generatedAt: generatedAt.toISOString(), + sentAt: null, + sentTo: null, + deliveryMethod: null, + deliveredAt: null, + viewedAt: null, + pdfUrl: `${pdfBase}/generated.pdf`, + } + } + + const sentAt = addUtcDays(termEnd, 8, 9) + const deliveredAt = addUtcDays(termEnd, 8, 10) + const viewedAt = addUtcDays(termEnd, 9, args.rankInClass !== null && args.rankInClass <= 5 ? 18 : 20) + + if (daysSinceTermEnd >= 45) { + return { + status: 'viewed', + generatedAt: generatedAt.toISOString(), + sentAt: sentAt.toISOString(), + sentTo: 'email', + deliveryMethod: 'email', + deliveredAt: deliveredAt.toISOString(), + viewedAt: viewedAt.toISOString(), + pdfUrl: `${pdfBase}/viewed.pdf`, + } + } + + if (daysSinceTermEnd >= 20) { + return { + status: 'delivered', + generatedAt: generatedAt.toISOString(), + sentAt: sentAt.toISOString(), + sentTo: 'email', + deliveryMethod: 'email', + deliveredAt: deliveredAt.toISOString(), + viewedAt: null, + pdfUrl: `${pdfBase}/delivered.pdf`, + } + } + + if (daysSinceTermEnd >= 10) { + return { + status: 'sent', + generatedAt: generatedAt.toISOString(), + sentAt: sentAt.toISOString(), + sentTo: 'email', + deliveryMethod: 'email', + deliveredAt: null, + viewedAt: null, + pdfUrl: `${pdfBase}/sent.pdf`, + } + } + + return { + status: 'generated', + generatedAt: generatedAt.toISOString(), + sentAt: null, + sentTo: null, + deliveryMethod: null, + deliveredAt: null, + viewedAt: null, + pdfUrl: `${pdfBase}/generated.pdf`, + } +} + +export function buildStudentAverages(args: { + classId: string + termId: string + gradeRows: DemoGradeRow[] + coefficientMap: Map + calculatedAt: string +}): DemoStudentAverageDraft[] { + const validatedGrades = args.gradeRows.filter(row => row.status === 'validated') + const studentGradesMap = new Map>() + + for (const row of validatedGrades) { + const subjectMap = studentGradesMap.get(row.studentId) ?? new Map() + const subjectGrades = subjectMap.get(row.subjectId) ?? [] + subjectMap.set(row.subjectId, [...subjectGrades, row]) + studentGradesMap.set(row.studentId, subjectMap) + } + + const subjectRows: DemoStudentAverageDraft[] = [] + const overallRows: DemoStudentAverageDraft[] = [] + + for (const [studentId, subjectMap] of studentGradesMap.entries()) { + let totalWeightedSum = 0 + let totalCoefficients = 0 + let simpleSum = 0 + let subjectCount = 0 + + const orderedSubjects = [...subjectMap.entries()].toSorted(([left], [right]) => left.localeCompare(right)) + + for (const [subjectId, grades] of orderedSubjects) { + const weightedSum = grades.reduce((sum, grade) => sum + (Number(grade.value) * grade.weight), 0) + const totalWeight = grades.reduce((sum, grade) => sum + grade.weight, 0) + if (totalWeight <= 0) { + continue + } + + const average = Number((weightedSum / totalWeight).toFixed(2)) + const coefficient = args.coefficientMap.get(subjectId) ?? { weight: 1, isFacultative: false } + + subjectRows.push({ + studentId, + classId: args.classId, + termId: args.termId, + subjectId, + average: formatDecimal(average), + weightedAverage: formatDecimal(average), + gradeCount: grades.length, + rankInClass: null, + rankInGrade: null, + isFinal: false, + calculatedAt: args.calculatedAt, + }) + + if (coefficient.isFacultative) { + if (average > 10) { + totalWeightedSum += (average - 10) * coefficient.weight + } + } + else { + totalWeightedSum += average * coefficient.weight + totalCoefficients += coefficient.weight + } + + simpleSum += average + subjectCount += 1 + } + + if (subjectCount === 0) { + continue + } + + const simpleAverage = simpleSum / subjectCount + const weightedAverage = totalCoefficients > 0 ? totalWeightedSum / totalCoefficients : 0 + + overallRows.push({ + studentId, + classId: args.classId, + termId: args.termId, + subjectId: null, + average: formatDecimal(simpleAverage), + weightedAverage: formatDecimal(weightedAverage), + gradeCount: subjectCount, + rankInClass: null, + rankInGrade: null, + isFinal: false, + calculatedAt: args.calculatedAt, + }) + } + + const rankedOverallRows = overallRows + .toSorted((left, right) => Number(right.weightedAverage) - Number(left.weightedAverage)) + .map((row, index, ordered) => { + const previous = ordered[index - 1] + const rank = previous && previous.weightedAverage === row.weightedAverage + ? previous.rankInClass ?? index + 1 + : index + 1 + + return { + ...row, + rankInClass: rank, + } + }) + + return [...subjectRows, ...rankedOverallRows] +} diff --git a/packages/data-ops/src/seed/demo/seeders/school.seeder.ts b/packages/data-ops/src/seed/demo/seeders/school.seeder.ts index 05d4f5d5..2ce737c9 100644 --- a/packages/data-ops/src/seed/demo/seeders/school.seeder.ts +++ b/packages/data-ops/src/seed/demo/seeders/school.seeder.ts @@ -73,16 +73,38 @@ export function seedSchoolStructure(db: Database, context: { config: DemoSeedCon where: eq(coreSchema.termTemplates.schoolYearTemplateId, yearTemplate.id), }) - const today = new Date().toISOString().split('T')[0] as string - const termValues = termTemplates - .filter((tt) => tt.type === config.termType) - .map((tt) => ({ + const filteredTemplates = termTemplates + .filter(tt => tt.type === config.termType) + .sort((left, right) => left.order - right.order) + + const schoolYearStart = new Date(`${actualSchoolYear.startDate}T00:00:00Z`) + const schoolYearEnd = new Date(`${actualSchoolYear.endDate}T00:00:00Z`) + const totalDays = Math.max( + 1, + Math.floor((schoolYearEnd.getTime() - schoolYearStart.getTime()) / (1000 * 60 * 60 * 24)) + 1, + ) + const daysPerTerm = Math.max(1, Math.floor(totalDays / Math.max(filteredTemplates.length, 1))) + + const termValues = filteredTemplates.map((tt, index) => { + const startDate = new Date(schoolYearStart) + startDate.setUTCDate(startDate.getUTCDate() + (index * daysPerTerm)) + + const endDate = new Date(startDate) + if (index === filteredTemplates.length - 1) { + endDate.setTime(schoolYearEnd.getTime()) + } + else { + endDate.setUTCDate(endDate.getUTCDate() + daysPerTerm - 1) + } + + return { id: crypto.randomUUID(), schoolYearId: actualSchoolYear.id, termTemplateId: tt.id, - startDate: today, - endDate: today, - })) + startDate: startDate.toISOString().split('T')[0]!, + endDate: endDate.toISOString().split('T')[0]!, + } + }) if (termValues.length > 0) { await db diff --git a/packages/data-ops/src/seed/demo/seeders/seed-resilience-helpers.ts b/packages/data-ops/src/seed/demo/seeders/seed-resilience-helpers.ts new file mode 100644 index 00000000..1aa13cb5 --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/seed-resilience-helpers.ts @@ -0,0 +1,145 @@ +import { chunkValues } from './operations-helpers' + +function sleep(delayMs: number) { + if (delayMs <= 0) { + return Promise.resolve() + } + + return new Promise(resolve => setTimeout(resolve, delayMs)) +} + +function walkErrorMessages(error: unknown, collector: string[]) { + if (!error || typeof error !== 'object') { + if (typeof error === 'string') { + collector.push(error) + } + return + } + + if ('message' in error && typeof error.message === 'string') { + collector.push(error.message) + } + + if ('code' in error && typeof error.code === 'string') { + collector.push(error.code) + } + + if ('cause' in error) { + walkErrorMessages(error.cause, collector) + } +} + +export function isTransientSeedError(error: unknown) { + const messages: string[] = [] + walkErrorMessages(error, messages) + const combined = messages.join(' ').toLowerCase() + + return [ + 'fetch failed', + 'etimedout', + 'enetunreach', + 'econnreset', + 'socket hang up', + 'timed out', + 'connection terminated unexpectedly', + 'network', + ].some(fragment => combined.includes(fragment)) +} + +export async function executeWithRetry( + operation: () => Promise, + options?: { + maxAttempts?: number + delayMs?: number + shouldRetry?: (error: unknown) => boolean + }, +): Promise { + const maxAttempts = options?.maxAttempts ?? 3 + const delayMs = options?.delayMs ?? 250 + const shouldRetry = options?.shouldRetry ?? isTransientSeedError + + let lastError: unknown + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + return await operation() + } + catch (error) { + lastError = error + const canRetry = attempt < maxAttempts && shouldRetry(error) + if (!canRetry) { + throw error + } + + await sleep(delayMs * attempt) + } + } + + throw lastError +} + +export async function runChunkedWithRetry( + values: T[], + options: { + chunkSize: number + handler: (chunk: T[], chunkIndex: number) => Promise + maxAttempts?: number + delayMs?: number + shouldRetry?: (error: unknown) => boolean + }, +): Promise { + const chunks = chunkValues(values, options.chunkSize) + for (const [chunkIndex, chunk] of chunks.entries()) { + await executeWithRetry( + () => options.handler(chunk, chunkIndex), + { + maxAttempts: options.maxAttempts, + delayMs: options.delayMs, + shouldRetry: options.shouldRetry, + }, + ) + } +} + +export async function runAdaptiveChunkedWithRetry( + values: T[], + options: { + chunkSize: number + minChunkSize?: number + handler: (chunk: T[], chunkIndex: number) => Promise + maxAttempts?: number + delayMs?: number + shouldRetry?: (error: unknown) => boolean + }, +): Promise { + const minChunkSize = Math.max(1, options.minChunkSize ?? 1) + + async function processChunk(chunk: T[], chunkIndex: number, currentChunkSize: number): Promise { + try { + await executeWithRetry( + () => options.handler(chunk, chunkIndex), + { + maxAttempts: options.maxAttempts, + delayMs: options.delayMs, + shouldRetry: options.shouldRetry, + }, + ) + } + catch (error) { + if (chunk.length <= minChunkSize || currentChunkSize <= minChunkSize) { + throw error + } + + const nextChunkSize = Math.max(minChunkSize, Math.floor(currentChunkSize / 2)) + const smallerChunks = chunkValues(chunk, nextChunkSize) + + for (const [nestedIndex, nestedChunk] of smallerChunks.entries()) { + await processChunk(nestedChunk, (chunkIndex * 1000) + nestedIndex, nextChunkSize) + } + } + } + + const chunks = chunkValues(values, options.chunkSize) + for (const [chunkIndex, chunk] of chunks.entries()) { + await processChunk(chunk, chunkIndex, options.chunkSize) + } +} diff --git a/packages/data-ops/src/seed/demo/seeders/student-activity-helpers.ts b/packages/data-ops/src/seed/demo/seeders/student-activity-helpers.ts new file mode 100644 index 00000000..60071362 --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/student-activity-helpers.ts @@ -0,0 +1,121 @@ +import type * as schoolSchema from '../../../drizzle/school-schema' +import type { StudentScenario } from '../scenarios/types' +import { addDays, formatDate } from '../utils/date-helpers' + +type EnrollmentRef = { + studentId: string + classId: string +} + +function getAbsenceProbability(scenario: StudentScenario) { + if (scenario === 'excellent') { + return 0.01 + } + + if (scenario === 'average') { + return 0.05 + } + + return 0.15 +} + +function getIncidentProbability(scenario: StudentScenario) { + if (scenario === 'excellent') { + return 0.02 + } + + if (scenario === 'average') { + return 0.08 + } + + return 0.2 +} + +export function buildSeededAttendanceRows(args: { + schoolId: string + recordedBy: string + schoolDays: number + startDate: Date + enrollments: EnrollmentRef[] + studentScenarioMap: Map + nextRandom: () => number + createId: () => string +}): Array { + const rows: Array = [] + + for (const enrollment of args.enrollments) { + const scenario = args.studentScenarioMap.get(enrollment.studentId) + if (!scenario) { + continue + } + + const absenceProbability = getAbsenceProbability(scenario) + for (let dayIndex = 0; dayIndex < args.schoolDays; dayIndex += 1) { + const date = addDays(args.startDate, dayIndex) + const weekday = date.getUTCDay() + if (weekday === 0 || weekday === 6) { + continue + } + + if (args.nextRandom() >= absenceProbability) { + continue + } + + rows.push({ + id: args.createId(), + studentId: enrollment.studentId, + classId: enrollment.classId, + schoolId: args.schoolId, + date: formatDate(date), + status: 'absent', + recordedBy: args.recordedBy, + createdAt: new Date(), + }) + } + } + + return rows +} + +export function buildSeededConductRows(args: { + schoolId: string + schoolYearId: string + recordedBy: string + incidentDate: string + enrollments: EnrollmentRef[] + studentScenarioMap: Map + nextRandom: () => number + createId: () => string +}): Array { + const rows: Array = [] + + for (const enrollment of args.enrollments) { + const scenario = args.studentScenarioMap.get(enrollment.studentId) + if (!scenario) { + continue + } + + if (args.nextRandom() >= getIncidentProbability(scenario)) { + continue + } + + rows.push({ + id: args.createId(), + studentId: enrollment.studentId, + schoolId: args.schoolId, + classId: enrollment.classId, + schoolYearId: args.schoolYearId, + type: 'incident', + category: 'behavior', + title: scenario === 'struggling' ? 'Avertissement de conduite' : 'Note de participation', + description: scenario === 'struggling' + ? 'Perturbe le cours fréquemment.' + : 'Manque d’attention ponctuel.', + incidentDate: args.incidentDate, + recordedBy: args.recordedBy, + status: 'open', + }) + } + + return rows +} diff --git a/packages/data-ops/src/seed/demo/seeders/students-helpers.ts b/packages/data-ops/src/seed/demo/seeders/students-helpers.ts new file mode 100644 index 00000000..a25eceb8 --- /dev/null +++ b/packages/data-ops/src/seed/demo/seeders/students-helpers.ts @@ -0,0 +1,43 @@ +export interface UserContactDraft { + email: string + name: string + phone: string +} + +export interface UserInsertDraft extends UserContactDraft { + id: string + status: 'active' +} + +export function planUserProvisioning(args: { + contacts: UserContactDraft[] + existingUsersByEmail: Map + createId: () => string +}): { + resolvedUserIdsByEmail: Map + usersToInsert: UserInsertDraft[] +} { + const resolvedUserIdsByEmail = new Map(args.existingUsersByEmail) + const usersToInsert: UserInsertDraft[] = [] + + for (const contact of args.contacts) { + if (resolvedUserIdsByEmail.has(contact.email)) { + continue + } + + const userId = args.createId() + resolvedUserIdsByEmail.set(contact.email, userId) + usersToInsert.push({ + id: userId, + email: contact.email, + name: contact.name, + phone: contact.phone, + status: 'active', + }) + } + + return { + resolvedUserIdsByEmail, + usersToInsert, + } +} diff --git a/packages/data-ops/src/seed/demo/seeders/students.seeder.ts b/packages/data-ops/src/seed/demo/seeders/students.seeder.ts index 9fe32e5b..69e155f2 100644 --- a/packages/data-ops/src/seed/demo/seeders/students.seeder.ts +++ b/packages/data-ops/src/seed/demo/seeders/students.seeder.ts @@ -14,11 +14,12 @@ import { } from '@repo/data-ops/drizzle/school-schema' import { DemoContext, SchoolContext, StudentContext } from '../config' import { SeededRandom } from '../utils/random' -import { generateStudentMatricule } from '../utils/matricule' +import { generateSeededStudentMatricule } from '../utils/matricule' import { firstNames } from '../data/first-names-fr' import { lastNames } from '../data/last-names-ci' import { pickStudentScenario, pickFinanceScenario } from '../scenarios/picker' import type { StudentScenario, FinanceScenario } from '../scenarios/types' +import { planUserProvisioning } from './students-helpers' import { z } from 'zod' // --- Schemas for type safety --- @@ -48,46 +49,6 @@ const parentInsertSchema = z.object({ updatedAt: z.date().optional(), }) -/** - * Ensure a user exists by email. If not in cache, check DB, then insert if needed. - */ -async function ensureUser( - db: Database, - email: string, - name: string, - phone: string, - cache: Map, -): Promise { - const cached = cache.get(email) - if (cached) return cached - - // Check DB first - const existing = await db.query.users.findFirst({ - where: eq(schoolSchema.users.email, email), - columns: { id: true }, - }) - - if (existing) { - cache.set(email, existing.id) - return existing.id - } - - // Insert new user - const userId = crypto.randomUUID() - await db.insert(schoolSchema.users).values({ - id: userId, - name, - email, - phone, - status: 'active', - createdAt: new Date(), - updatedAt: new Date(), - }).onConflictDoNothing() - - cache.set(email, userId) - return userId -} - /** * Seed students and parents for the school */ @@ -122,12 +83,21 @@ export function seedStudents( const studentParentsToInsert: (typeof schoolSchema.studentParents.$inferInsert)[] = [] const userSchoolsToInsert: (typeof schoolSchema.userSchools.$inferInsert)[] = [] const userRolesToInsert: (typeof schoolSchema.userRoles.$inferInsert)[] = [] + const parentDrafts: Array<{ + parentId: string + studentId: string + relationship: typeof relationshipTypes[number] + isPrimary: boolean + firstName: string + lastName: string + email: string + phone: string + }> = [] const studentScenarios = new Map() const financeScenarios = new Map() const processedUserSchools = new Set() const processedUserRoles = new Set() - const emailToUserId = new Map() databaseLogger.info(`Starting student generation for school: ${school.id} (count: ${config.studentCount})`) @@ -151,7 +121,7 @@ export function seedStudents( lastName: studentLastName, gender: gender === 'male' ? 'M' : 'F', dob: faker.date.birthdate({ min: 6, max: 18, mode: 'age' }).toISOString().split('T')[0] ?? '2010-01-01', - matricule: generateStudentMatricule(random, 2024), + matricule: generateSeededStudentMatricule(2024, i + 1), status: 'active', bloodType: random.pick(bloodTypes), createdAt: new Date(), @@ -166,53 +136,111 @@ export function seedStudents( const parentLastName = relationshipType === 'father' ? studentLastName : (random.pick(lastNames) ?? 'User') const parentEmail = faker.internet.email({ firstName: parentFirstName, lastName: parentLastName }).toLowerCase() const parentPhone = faker.phone.number() - - const userId = await ensureUser(db, parentEmail, `${parentFirstName} ${parentLastName}`, parentPhone, emailToUserId) - const parentId = crypto.randomUUID() - parentsToInsert.push(parentInsertSchema.parse({ - id: parentId, - userId, + parentDrafts.push({ + parentId: crypto.randomUUID(), + studentId, + relationship: relationshipType, + isPrimary: p === 0, firstName: parentFirstName, lastName: parentLastName, email: parentEmail, phone: parentPhone, - invitationStatus: 'pending', + }) + } + } + + const parentEmails = [...new Set(parentDrafts.map(parent => parent.email))] + const existingUsers = parentEmails.length > 0 + ? await db.select({ + id: schoolSchema.users.id, + email: schoolSchema.users.email, + }) + .from(schoolSchema.users) + .where(inArray(schoolSchema.users.email, parentEmails)) + : [] + + const existingUsersByEmail = new Map(existingUsers.map(user => [user.email, user.id])) + const userProvisioning = planUserProvisioning({ + contacts: parentDrafts.map(parent => ({ + email: parent.email, + name: `${parent.firstName} ${parent.lastName}`, + phone: parent.phone, + })), + existingUsersByEmail, + createId: () => crypto.randomUUID(), + }) + + if (userProvisioning.usersToInsert.length > 0) { + await db.insert(schoolSchema.users).values( + userProvisioning.usersToInsert.map(user => ({ + id: user.id, + name: user.name, + email: user.email, + phone: user.phone, + status: user.status, createdAt: new Date(), - })) + updatedAt: new Date(), + })), + ).onConflictDoNothing() + } - // Deduplicate: only one userSchool per (userId, schoolId) - const userSchoolKey = `${userId}:${school.id}` - if (!processedUserSchools.has(userSchoolKey)) { - processedUserSchools.add(userSchoolKey) - userSchoolsToInsert.push({ - id: crypto.randomUUID(), - userId, - schoolId: school.id, - }) - } + const persistedParentUsers = parentEmails.length > 0 + ? await db.select({ + id: schoolSchema.users.id, + email: schoolSchema.users.email, + }) + .from(schoolSchema.users) + .where(inArray(schoolSchema.users.email, parentEmails)) + : [] + const persistedUserIdsByEmail = new Map(persistedParentUsers.map(user => [user.email, user.id])) + + for (const parent of parentDrafts) { + const userId = persistedUserIdsByEmail.get(parent.email) + if (!userId) { + throw new Error(`User provisioning failed for parent email ${parent.email}`) + } - // Deduplicate: only one userRole per (userId, roleId, schoolId) - const userRoleKey = `${userId}:${parentRoleId}:${school.id}` - if (!processedUserRoles.has(userRoleKey)) { - processedUserRoles.add(userRoleKey) - userRolesToInsert.push({ - id: crypto.randomUUID(), - userId, - roleId: parentRoleId, - schoolId: school.id, - }) - } + parentsToInsert.push(parentInsertSchema.parse({ + id: parent.parentId, + userId, + firstName: parent.firstName, + lastName: parent.lastName, + email: parent.email, + phone: parent.phone, + invitationStatus: 'pending', + createdAt: new Date(), + })) - studentParentsToInsert.push({ + const userSchoolKey = `${userId}:${school.id}` + if (!processedUserSchools.has(userSchoolKey)) { + processedUserSchools.add(userSchoolKey) + userSchoolsToInsert.push({ id: crypto.randomUUID(), - studentId, - parentId, - relationship: relationshipType, - isPrimary: p === 0, - createdAt: new Date(), + userId, + schoolId: school.id, + }) + } + + const userRoleKey = `${userId}:${parentRoleId}:${school.id}` + if (!processedUserRoles.has(userRoleKey)) { + processedUserRoles.add(userRoleKey) + userRolesToInsert.push({ + id: crypto.randomUUID(), + userId, + roleId: parentRoleId, + schoolId: school.id, }) } + + studentParentsToInsert.push({ + id: crypto.randomUUID(), + studentId: parent.studentId, + parentId: parent.parentId, + relationship: parent.relationship, + isPrimary: parent.isPrimary, + createdAt: new Date(), + }) } // 4. Batch Inserts @@ -228,6 +256,19 @@ export function seedStudents( if (studentsToInsert.length > 0) { await db.insert(schoolSchema.students).values(studentsToInsert).onConflictDoNothing() } + + const plannedStudentIds = studentsToInsert.map(student => student.id) + const persistedStudents = plannedStudentIds.length > 0 + ? await db.select({ id: schoolSchema.students.id }) + .from(schoolSchema.students) + .where(inArray(schoolSchema.students.id, plannedStudentIds)) + : [] + const persistedStudentIds = new Set(persistedStudents.map(student => student.id)) + const missingStudentIds = plannedStudentIds.filter(studentId => !persistedStudentIds.has(studentId)) + if (missingStudentIds.length > 0) { + throw new Error(`Student provisioning failed for ${missingStudentIds.length} drafted students`) + } + if (studentParentsToInsert.length > 0) { await db.insert(schoolSchema.studentParents).values(studentParentsToInsert).onConflictDoNothing() } diff --git a/packages/data-ops/src/seed/demo/seeders/teachers.seeder.ts b/packages/data-ops/src/seed/demo/seeders/teachers.seeder.ts index e4ddd034..2fcdc2f5 100644 --- a/packages/data-ops/src/seed/demo/seeders/teachers.seeder.ts +++ b/packages/data-ops/src/seed/demo/seeders/teachers.seeder.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { Result as R } from '@praha/byethrow' import { faker } from '@faker-js/faker' import { databaseLogger, tapLogErr } from '@repo/logger' @@ -91,7 +91,18 @@ export function seedTeachers( try: async () => { // 1. Get Subject List and Teacher Role const [dbSubjects, dbRoles] = await Promise.all([ - db.select().from(coreSchema.subjects), + db + .select({ + id: coreSchema.subjects.id, + name: coreSchema.subjects.name, + }) + .from(schoolSchema.schoolSubjects) + .innerJoin(coreSchema.subjects, eq(schoolSchema.schoolSubjects.subjectId, coreSchema.subjects.id)) + .where(and( + eq(schoolSchema.schoolSubjects.schoolId, school.id), + eq(schoolSchema.schoolSubjects.schoolYearId, schoolContext.schoolYear.id), + eq(schoolSchema.schoolSubjects.status, 'active'), + )), db.query.roles.findMany({ where: eq(schoolSchema.roles.slug, 'teacher'), }), @@ -103,7 +114,7 @@ export function seedTeachers( } if (dbSubjects.length === 0) { - throw new Error('No subjects found in core schema. Run base seed first.') + throw new Error('No school subjects found for the active demo year. Seed academic catalog first.') } const teachersToInsert: (typeof schoolSchema.teachers.$inferInsert)[] = [] @@ -160,15 +171,41 @@ export function seedTeachers( updatedAt: new Date(), })) - // 3. Assign 1-3 random subjects to each teacher - const subjectCount = random.nextRange(1, 3) - const selectedSubjects = random.pickMultiple(dbSubjects, subjectCount) + } + + const teacherIds = teachersToInsert.map(teacher => teacher.id) + const subjectsByTeacher = new Map>() + + for (const teacherId of teacherIds) { + subjectsByTeacher.set(teacherId, new Set()) + } + + for (const [index, subject] of dbSubjects.entries()) { + const teacherId = teacherIds[index % Math.max(teacherIds.length, 1)] + if (!teacherId) { + continue + } + + subjectsByTeacher.get(teacherId)?.add(subject.id) + } + + for (const teacherId of teacherIds) { + const coverage = subjectsByTeacher.get(teacherId) + if (!coverage) { + continue + } + + const extraCount = random.nextRange(0, 2) + const extraSubjects = random.pickMultiple(dbSubjects, extraCount) + for (const subject of extraSubjects) { + coverage.add(subject.id) + } - for (const sub of selectedSubjects) { + for (const subjectId of coverage) { teacherSubjectsToInsert.push(teacherSubjectInsertSchema.parse({ id: crypto.randomUUID(), teacherId, - subjectId: sub.id, + subjectId, createdAt: new Date(), })) } @@ -195,7 +232,7 @@ export function seedTeachers( const insertedSubjects = insertedTeachers.length > 0 ? await db.query.teacherSubjects.findMany({ - where: eq(schoolSchema.teacherSubjects.teacherId, insertedTeachers[0]!.id), + where: inArray(schoolSchema.teacherSubjects.teacherId, insertedTeachers.map(teacher => teacher.id)), }) : [] diff --git a/packages/data-ops/src/seed/demo/utils/matricule.ts b/packages/data-ops/src/seed/demo/utils/matricule.ts index d83acfac..7b96bf01 100644 --- a/packages/data-ops/src/seed/demo/utils/matricule.ts +++ b/packages/data-ops/src/seed/demo/utils/matricule.ts @@ -33,6 +33,10 @@ export function generateStudentMatricule(rng: SeededRandom, entryYear: number): return `${yearSuffix}${prefix}${number}${suffix}` } +export function generateSeededStudentMatricule(entryYear: number, ordinal: number): string { + return `${entryYear.toString().slice(-2)}D${ordinal.toString().padStart(5, '0')}S` +} + export function generateTeacherMatricule(rng: SeededRandom, entryYear: number): string { const number = rng.int(100000, 999999).toString() const suffix = rng.pick(['P', 'R', 'S', 'T']) diff --git a/packages/data-ops/src/tests/demo-academic-realism-helpers.test.ts b/packages/data-ops/src/tests/demo-academic-realism-helpers.test.ts new file mode 100644 index 00000000..089ca8c1 --- /dev/null +++ b/packages/data-ops/src/tests/demo-academic-realism-helpers.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, test } from 'vitest' +import { + buildCurriculumArtifacts, + buildGradeAwareClassSubjects, + buildSessionAttendanceRows, + buildTeacherMessageThread, +} from '../seed/demo/seeders/academic-realism-helpers' + +describe('demo academic realism helpers', () => { + test('buildGradeAwareClassSubjects should keep only the current grade programs and assign eligible teachers', () => { + const assignments = buildGradeAwareClassSubjects({ + classId: 'class-6a', + gradeId: 'grade-6', + offerings: [ + { + programTemplateId: 'program-math-6', + gradeId: 'grade-6', + subjectId: 'subject-math', + subjectName: 'Mathematiques', + coefficient: 4, + }, + { + programTemplateId: 'program-french-6', + gradeId: 'grade-6', + subjectId: 'subject-french', + subjectName: 'Francais', + coefficient: 3, + }, + { + programTemplateId: 'program-physics-5', + gradeId: 'grade-5', + subjectId: 'subject-physics', + subjectName: 'Physique', + coefficient: 5, + }, + ], + teacherSpecialties: [ + { teacherId: 'teacher-math-1', subjectId: 'subject-math' }, + { teacherId: 'teacher-math-2', subjectId: 'subject-math' }, + { teacherId: 'teacher-french-1', subjectId: 'subject-french' }, + ], + }) + + expect(assignments).toStrictEqual([ + { + classId: 'class-6a', + subjectId: 'subject-math', + teacherId: 'teacher-math-1', + coefficient: 4, + hoursPerWeek: 4, + programTemplateId: 'program-math-6', + }, + { + classId: 'class-6a', + subjectId: 'subject-french', + teacherId: 'teacher-french-1', + coefficient: 3, + hoursPerWeek: 3, + programTemplateId: 'program-french-6', + }, + ]) + }) + + test('buildCurriculumArtifacts should map completed sessions to chapters and derive progress status by term', () => { + const artifacts = buildCurriculumArtifacts({ + classId: 'class-6a', + gradeId: 'grade-6', + subjectId: 'subject-math', + terms: [ + { id: 'term-1', startDate: '2026-01-01', endDate: '2026-03-31' }, + { id: 'term-2', startDate: '2026-04-01', endDate: '2026-06-30' }, + ], + programs: [ + { + programTemplateId: 'program-math-6', + gradeId: 'grade-6', + subjectId: 'subject-math', + }, + ], + chapters: [ + { id: 'chapter-1', programTemplateId: 'program-math-6', title: 'Nombres entiers', order: 1 }, + { id: 'chapter-2', programTemplateId: 'program-math-6', title: 'Fractions', order: 2 }, + { id: 'chapter-3', programTemplateId: 'program-math-6', title: 'Geometrie', order: 3 }, + { id: 'chapter-4', programTemplateId: 'program-math-6', title: 'Proportionnalite', order: 4 }, + ], + sessions: [ + { id: 'session-1', classId: 'class-6a', subjectId: 'subject-math', teacherId: 'teacher-math-1', date: '2026-01-12', status: 'completed' }, + { id: 'session-2', classId: 'class-6a', subjectId: 'subject-math', teacherId: 'teacher-math-1', date: '2026-02-02', status: 'completed' }, + { id: 'session-3', classId: 'class-6a', subjectId: 'subject-math', teacherId: 'teacher-math-1', date: '2026-03-05', status: 'completed' }, + ], + calculatedAt: '2026-03-31', + }) + + expect(artifacts.chapterCompletions).toStrictEqual([ + { + classId: 'class-6a', + subjectId: 'subject-math', + chapterId: 'chapter-1', + classSessionId: 'session-1', + teacherId: 'teacher-math-1', + completedAt: '2026-01-12T12:00:00.000Z', + notes: 'Progression demo: chapitre couvert pendant la seance.', + }, + { + classId: 'class-6a', + subjectId: 'subject-math', + chapterId: 'chapter-2', + classSessionId: 'session-2', + teacherId: 'teacher-math-1', + completedAt: '2026-02-02T12:00:00.000Z', + notes: 'Progression demo: chapitre couvert pendant la seance.', + }, + { + classId: 'class-6a', + subjectId: 'subject-math', + chapterId: 'chapter-3', + classSessionId: 'session-3', + teacherId: 'teacher-math-1', + completedAt: '2026-03-05T12:00:00.000Z', + notes: 'Progression demo: chapitre couvert pendant la seance.', + }, + ]) + + expect(artifacts.sessionChapterAssignments).toStrictEqual([ + { + sessionId: 'session-1', + chapterId: 'chapter-1', + topic: 'Nombres entiers', + objectives: 'Achever le chapitre Nombres entiers.', + }, + { + sessionId: 'session-2', + chapterId: 'chapter-2', + topic: 'Fractions', + objectives: 'Achever le chapitre Fractions.', + }, + { + sessionId: 'session-3', + chapterId: 'chapter-3', + topic: 'Geometrie', + objectives: 'Achever le chapitre Geometrie.', + }, + ]) + + expect(artifacts.progressRecords).toStrictEqual([ + { + classId: 'class-6a', + subjectId: 'subject-math', + programTemplateId: 'program-math-6', + termId: 'term-1', + totalChapters: 4, + completedChapters: 3, + progressPercentage: '75.00', + expectedPercentage: '100.00', + variance: '-25.00', + status: 'significantly_behind', + lastChapterCompletedAt: '2026-03-05T12:00:00.000Z', + calculatedAt: '2026-03-31T12:00:00.000Z', + }, + ]) + }) + + test('buildSessionAttendanceRows should preserve existing absences and fill remaining students as absent, late, or present', () => { + const attendanceRows = buildSessionAttendanceRows({ + schoolId: 'school-1', + classId: 'class-6a', + classSessionId: 'session-1', + date: '2026-03-03', + recordedBy: 'user-teacher-1', + studentIds: ['student-1', 'student-2', 'student-3', 'student-4'], + existingRows: [ + { + studentId: 'student-1', + status: 'absent', + notes: 'Absent deja signale', + }, + ], + lateStudentIds: ['student-2'], + absentStudentIds: ['student-3'], + }) + + expect(attendanceRows).toStrictEqual([ + { + studentId: 'student-1', + classId: 'class-6a', + schoolId: 'school-1', + classSessionId: 'session-1', + date: '2026-03-03', + status: 'absent', + parentNotified: true, + notificationMethod: 'sms', + notes: 'Absent deja signale', + recordedBy: 'user-teacher-1', + }, + { + studentId: 'student-2', + classId: 'class-6a', + schoolId: 'school-1', + classSessionId: 'session-1', + date: '2026-03-03', + status: 'late', + lateMinutes: 11, + recordedBy: 'user-teacher-1', + }, + { + studentId: 'student-3', + classId: 'class-6a', + schoolId: 'school-1', + classSessionId: 'session-1', + date: '2026-03-03', + status: 'absent', + parentNotified: true, + notificationMethod: 'sms', + recordedBy: 'user-teacher-1', + }, + { + studentId: 'student-4', + classId: 'class-6a', + schoolId: 'school-1', + classSessionId: 'session-1', + date: '2026-03-03', + status: 'present', + recordedBy: 'user-teacher-1', + }, + ]) + }) + + test('buildTeacherMessageThread should create a realistic teacher-parent exchange for attendance follow-up', () => { + const thread = buildTeacherMessageThread({ + threadId: 'thread-1', + schoolId: 'school-1', + teacherId: 'teacher-1', + parentId: 'parent-1', + studentId: 'student-1', + classId: 'class-6a', + signal: 'attendance', + studentName: 'Awa Kone', + subjectName: 'Mathematiques', + startedAt: '2026-03-04T09:00:00.000Z', + }) + + expect(thread).toStrictEqual([ + { + schoolId: 'school-1', + senderType: 'teacher', + senderId: 'teacher-1', + recipientType: 'parent', + recipientId: 'parent-1', + studentId: 'student-1', + classId: 'class-6a', + threadId: 'thread-1', + replyToId: null, + subject: 'Absences repetees de Awa Kone', + content: 'Bonjour, Awa Kone a cumule plusieurs absences recentes en mathematiques. Merci de nous confirmer la situation et les mesures prises.', + isRead: true, + isArchived: false, + isStarred: true, + createdAt: '2026-03-04T09:00:00.000Z', + }, + { + schoolId: 'school-1', + senderType: 'parent', + senderId: 'parent-1', + recipientType: 'teacher', + recipientId: 'teacher-1', + studentId: 'student-1', + classId: 'class-6a', + threadId: 'thread-1', + replyToId: 'thread-1-msg-1', + subject: 'Re: Absences repetees de Awa Kone', + content: 'Bonjour professeur, nous avons bien recu le message et nous allons regulariser la situation cette semaine.', + isRead: true, + isArchived: false, + isStarred: false, + createdAt: '2026-03-04T13:00:00.000Z', + }, + ]) + }) + + test('buildTeacherMessageThread should support finance reminders and report-card follow-up', () => { + const financeThread = buildTeacherMessageThread({ + threadId: 'thread-finance', + schoolId: 'school-1', + teacherId: 'teacher-1', + parentId: 'parent-1', + studentId: 'student-1', + classId: 'class-6a', + signal: 'finance', + studentName: 'Awa Kone', + subjectName: 'Mathematiques', + startedAt: '2026-03-28T09:00:00.000Z', + }) + + const reportCardThread = buildTeacherMessageThread({ + threadId: 'thread-report', + schoolId: 'school-1', + teacherId: 'teacher-1', + parentId: 'parent-1', + studentId: 'student-1', + classId: 'class-6a', + signal: 'report_card', + studentName: 'Awa Kone', + subjectName: 'Mathematiques', + startedAt: '2026-03-28T09:00:00.000Z', + }) + + expect(financeThread[0]?.subject).toBe('Rappel de scolarite pour Awa Kone') + expect(financeThread[0]?.content).toContain('regulariser') + expect(reportCardThread[0]?.subject).toBe('Bulletin disponible pour Awa Kone') + expect(reportCardThread[1]?.subject).toBe('Re: Bulletin disponible pour Awa Kone') + }) +}) diff --git a/packages/data-ops/src/tests/demo-catalog-realism-helpers.test.ts b/packages/data-ops/src/tests/demo-catalog-realism-helpers.test.ts new file mode 100644 index 00000000..ba51cdaf --- /dev/null +++ b/packages/data-ops/src/tests/demo-catalog-realism-helpers.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from 'vitest' +import { + buildFallbackProgramTemplateChapters, + buildFallbackProgramTemplates, + buildImportedProgramTemplateChapters, + buildSchoolCoefficientOverrides, + summarizeRealisticDemoCoverage, +} from '../seed/demo/seeders/catalog-realism-helpers' + +describe('demo catalog realism helpers', () => { + test('buildSchoolCoefficientOverrides should mirror only active template coefficients for configured grades', () => { + const overrides = buildSchoolCoefficientOverrides({ + schoolId: 'school-1', + targetGradeIds: ['grade-6', 'grade-5'], + activeSubjectIds: ['subject-math', 'subject-french'], + coefficientTemplates: [ + { + id: 'coeff-math-6', + subjectId: 'subject-math', + gradeId: 'grade-6', + seriesId: null, + weight: 4, + }, + { + id: 'coeff-french-5', + subjectId: 'subject-french', + gradeId: 'grade-5', + seriesId: null, + weight: 3, + }, + { + id: 'coeff-physics-6', + subjectId: 'subject-physics', + gradeId: 'grade-6', + seriesId: null, + weight: 2, + }, + { + id: 'coeff-history-4', + subjectId: 'subject-history', + gradeId: 'grade-4', + seriesId: null, + weight: 2, + }, + ], + }) + + expect(overrides).toStrictEqual([ + { + schoolId: 'school-1', + coefficientTemplateId: 'coeff-french-5', + weightOverride: 3, + }, + { + schoolId: 'school-1', + coefficientTemplateId: 'coeff-math-6', + weightOverride: 4, + }, + ]) + }) + + test('buildFallbackProgramTemplates should derive deterministic published curriculum templates from coefficient coverage', () => { + const templates = buildFallbackProgramTemplates({ + schoolYearTemplateId: 'year-template-1', + targetGradeIds: ['grade-6', 'grade-5'], + targetGrades: [ + { id: 'grade-6', name: '6ème' }, + { id: 'grade-5', name: '5ème' }, + ], + subjects: [ + { id: 'subject-math', name: 'Mathématiques' }, + { id: 'subject-french', name: 'Français' }, + ], + coefficientTemplates: [ + { id: 'coeff-1', subjectId: 'subject-math', gradeId: 'grade-6', seriesId: null, weight: 4 }, + { id: 'coeff-2', subjectId: 'subject-french', gradeId: 'grade-6', seriesId: null, weight: 3 }, + { id: 'coeff-3', subjectId: 'subject-math', gradeId: 'grade-5', seriesId: null, weight: 4 }, + { id: 'coeff-4', subjectId: 'subject-history', gradeId: 'grade-4', seriesId: null, weight: 2 }, + ], + }) + + expect(templates).toStrictEqual([ + { + id: 'demo-program-year-template-1-grade-5-subject-math', + name: 'Programme Mathématiques - 5ème', + schoolYearTemplateId: 'year-template-1', + gradeId: 'grade-5', + subjectId: 'subject-math', + status: 'published', + }, + { + id: 'demo-program-year-template-1-grade-6-subject-french', + name: 'Programme Français - 6ème', + schoolYearTemplateId: 'year-template-1', + gradeId: 'grade-6', + subjectId: 'subject-french', + status: 'published', + }, + { + id: 'demo-program-year-template-1-grade-6-subject-math', + name: 'Programme Mathématiques - 6ème', + schoolYearTemplateId: 'year-template-1', + gradeId: 'grade-6', + subjectId: 'subject-math', + status: 'published', + }, + ]) + }) + + test('buildFallbackProgramTemplateChapters should create deterministic chapter scaffolds for each generated program', () => { + const chapters = buildFallbackProgramTemplateChapters([ + { + id: 'demo-program-year-template-1-grade-6-subject-math', + name: 'Programme Mathématiques - 6ème', + schoolYearTemplateId: 'year-template-1', + gradeId: 'grade-6', + subjectId: 'subject-math', + status: 'published', + }, + ]) + + expect(chapters).toStrictEqual([ + { + id: 'demo-program-year-template-1-grade-6-subject-math-chapter-1', + title: 'Bases et consolidation', + objectives: 'Structurer les fondamentaux et lancer une progression coherente pour la demo.', + order: 1, + durationHours: 6, + programTemplateId: 'demo-program-year-template-1-grade-6-subject-math', + }, + { + id: 'demo-program-year-template-1-grade-6-subject-math-chapter-2', + title: 'Applications guidees', + objectives: 'Approfondir les notions avec des activites guidees et des exercices progressifs.', + order: 2, + durationHours: 8, + programTemplateId: 'demo-program-year-template-1-grade-6-subject-math', + }, + { + id: 'demo-program-year-template-1-grade-6-subject-math-chapter-3', + title: 'Evaluation et remediations', + objectives: 'Consolider les acquis, evaluer les competences et preparer les remediations.', + order: 3, + durationHours: 6, + programTemplateId: 'demo-program-year-template-1-grade-6-subject-math', + }, + ]) + }) + + test('buildImportedProgramTemplateChapters should map lesson-progress rows onto seeded programs using normalized grade and subject names', () => { + const chapters = buildImportedProgramTemplateChapters({ + programTemplates: [ + { + id: 'program-edhc-3e', + name: 'Programme E.D.H.C. - 3ème', + schoolYearTemplateId: 'year-template-1', + gradeId: 'grade-3e', + subjectId: 'subject-edhc', + status: 'published', + }, + { + id: 'program-philo-tle', + name: 'Programme Philosophie - Terminale', + schoolYearTemplateId: 'year-template-1', + gradeId: 'grade-tle', + subjectId: 'subject-philo', + status: 'published', + }, + ], + gradeNamesById: new Map([ + ['grade-3e', '3ème'], + ['grade-tle', 'Terminale'], + ]), + subjectNamesById: new Map([ + ['subject-edhc', 'E.D.H.C.'], + ['subject-philo', 'Philosophie'], + ]), + lessonRows: [ + { + gradeLabel: '3e', + subjectLabel: 'Éducation aux Droits de l\'Homme et à la Citoyenneté (EDHC)', + lesson: 'Les partis politiques', + lessonOrder: 4, + series: null, + sessionsCount: 2, + }, + { + gradeLabel: '3ème', + subjectLabel: 'EDHC', + lesson: 'Les institutions juridictionnelle', + lessonOrder: 5, + series: null, + sessionsCount: 2, + }, + { + gradeLabel: 'Tle', + subjectLabel: 'Philosophie', + lesson: 'La dissertation philosophique', + lessonOrder: 1, + series: 'A', + sessionsCount: 8, + }, + ], + }) + + expect(chapters).toStrictEqual([ + { + id: 'program-edhc-3e-chapter-4', + title: 'Les partis politiques', + objectives: 'Progression importee: 2 seances prevues.', + order: 4, + durationHours: 2, + programTemplateId: 'program-edhc-3e', + }, + { + id: 'program-edhc-3e-chapter-5', + title: 'Les institutions juridictionnelle', + objectives: 'Progression importee: 2 seances prevues.', + order: 5, + durationHours: 2, + programTemplateId: 'program-edhc-3e', + }, + { + id: 'program-philo-tle-chapter-1', + title: 'La dissertation philosophique', + objectives: 'Progression importee: 8 seances prevues. Serie A.', + order: 1, + durationHours: 8, + programTemplateId: 'program-philo-tle', + }, + ]) + }) + + test('summarizeRealisticDemoCoverage should flag empty operational tables that break the production-like story', () => { + const coverage = summarizeRealisticDemoCoverage({ + schoolSubjects: 28, + schoolSubjectCoefficients: 0, + classSubjects: 84, + timetableSessions: 0, + classSessions: 72, + curriculumProgress: 18, + studentAverages: 0, + reportCards: 120, + teacherComments: 0, + teacherMessages: 0, + teacherNotifications: 0, + payments: 80, + }) + + expect(coverage).toStrictEqual({ + isValid: false, + missingTables: [ + 'school_subject_coefficients', + 'timetable_sessions', + 'student_averages', + 'teacher_comments', + 'teacher_messages', + 'teacher_notifications', + ], + }) + }) + + test('summarizeRealisticDemoCoverage should accept a fully populated realistic school surface', () => { + const coverage = summarizeRealisticDemoCoverage({ + schoolSubjects: 28, + schoolSubjectCoefficients: 156, + classSubjects: 164, + timetableSessions: 148, + classSessions: 888, + curriculumProgress: 78, + studentAverages: 5892, + reportCards: 480, + teacherComments: 5412, + teacherMessages: 650, + teacherNotifications: 285, + payments: 526, + }) + + expect(coverage).toStrictEqual({ + isValid: true, + missingTables: [], + }) + }) +}) diff --git a/packages/data-ops/src/tests/demo-classes-helpers.test.ts b/packages/data-ops/src/tests/demo-classes-helpers.test.ts new file mode 100644 index 00000000..f851444a --- /dev/null +++ b/packages/data-ops/src/tests/demo-classes-helpers.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from 'vitest' +import { planSeededClassArtifacts } from '../seed/demo/seeders/classes-helpers' + +describe('demo classes helpers', () => { + test('planSeededClassArtifacts should build deterministic class, class-subject, and enrollment drafts in bulk', () => { + const plan = planSeededClassArtifacts({ + schoolId: 'school-1', + schoolYearId: 'school-year-1', + enrollmentDate: '2026-03-29', + classesPerGrade: 2, + configuredGradeNames: ['6ème', '5ème'], + grades: [ + { id: 'grade-6', name: '6ème' }, + { id: 'grade-5', name: '5ème' }, + ], + classrooms: [ + { id: 'room-a' }, + { id: 'room-b' }, + { id: 'room-c' }, + ], + teachers: [ + { id: 'teacher-1' }, + { id: 'teacher-2' }, + { id: 'teacher-3' }, + ], + students: [ + { id: 'student-1' }, + { id: 'student-2' }, + { id: 'student-3' }, + { id: 'student-4' }, + { id: 'student-5' }, + ], + gradeAwareAssignmentsByGradeId: new Map([ + ['grade-6', [ + { + classId: '', + subjectId: 'subject-math', + teacherId: 'teacher-1', + coefficient: 4, + hoursPerWeek: 4, + programTemplateId: 'program-6-math', + }, + { + classId: '', + subjectId: 'subject-french', + teacherId: 'teacher-2', + coefficient: 3, + hoursPerWeek: 3, + programTemplateId: 'program-6-french', + }, + ]], + ['grade-5', [ + { + classId: '', + subjectId: 'subject-history', + teacherId: 'teacher-3', + coefficient: 2, + hoursPerWeek: 2, + programTemplateId: 'program-5-history', + }, + ]], + ]), + }) + + expect(plan.classes.map(item => ({ + id: item.id, + gradeId: item.gradeId, + section: item.section, + classroomId: item.classroomId, + homeroomTeacherId: item.homeroomTeacherId, + }))).toStrictEqual([ + { + id: 'demo-class-school-year-1-grade-6-a', + gradeId: 'grade-6', + section: 'A', + classroomId: 'room-a', + homeroomTeacherId: 'teacher-1', + }, + { + id: 'demo-class-school-year-1-grade-6-b', + gradeId: 'grade-6', + section: 'B', + classroomId: 'room-b', + homeroomTeacherId: 'teacher-2', + }, + { + id: 'demo-class-school-year-1-grade-5-a', + gradeId: 'grade-5', + section: 'A', + classroomId: 'room-c', + homeroomTeacherId: 'teacher-3', + }, + { + id: 'demo-class-school-year-1-grade-5-b', + gradeId: 'grade-5', + section: 'B', + classroomId: 'room-a', + homeroomTeacherId: 'teacher-1', + }, + ]) + + expect(plan.classSubjects.map(item => ({ + id: item.id, + classId: item.classId, + subjectId: item.subjectId, + teacherId: item.teacherId, + }))).toStrictEqual([ + { + id: 'demo-class-subject-demo-class-school-year-1-grade-6-a-subject-math', + classId: 'demo-class-school-year-1-grade-6-a', + subjectId: 'subject-math', + teacherId: 'teacher-1', + }, + { + id: 'demo-class-subject-demo-class-school-year-1-grade-6-a-subject-french', + classId: 'demo-class-school-year-1-grade-6-a', + subjectId: 'subject-french', + teacherId: 'teacher-2', + }, + { + id: 'demo-class-subject-demo-class-school-year-1-grade-6-b-subject-math', + classId: 'demo-class-school-year-1-grade-6-b', + subjectId: 'subject-math', + teacherId: 'teacher-1', + }, + { + id: 'demo-class-subject-demo-class-school-year-1-grade-6-b-subject-french', + classId: 'demo-class-school-year-1-grade-6-b', + subjectId: 'subject-french', + teacherId: 'teacher-2', + }, + { + id: 'demo-class-subject-demo-class-school-year-1-grade-5-a-subject-history', + classId: 'demo-class-school-year-1-grade-5-a', + subjectId: 'subject-history', + teacherId: 'teacher-3', + }, + { + id: 'demo-class-subject-demo-class-school-year-1-grade-5-b-subject-history', + classId: 'demo-class-school-year-1-grade-5-b', + subjectId: 'subject-history', + teacherId: 'teacher-3', + }, + ]) + + expect(plan.enrollments.map(item => ({ + id: item.id, + studentId: item.studentId, + classId: item.classId, + rollNumber: item.rollNumber, + }))).toStrictEqual([ + { + id: 'demo-enrollment-school-year-1-student-1', + studentId: 'student-1', + classId: 'demo-class-school-year-1-grade-6-a', + rollNumber: 1, + }, + { + id: 'demo-enrollment-school-year-1-student-2', + studentId: 'student-2', + classId: 'demo-class-school-year-1-grade-6-b', + rollNumber: 1, + }, + { + id: 'demo-enrollment-school-year-1-student-3', + studentId: 'student-3', + classId: 'demo-class-school-year-1-grade-5-a', + rollNumber: 1, + }, + { + id: 'demo-enrollment-school-year-1-student-4', + studentId: 'student-4', + classId: 'demo-class-school-year-1-grade-5-b', + rollNumber: 1, + }, + { + id: 'demo-enrollment-school-year-1-student-5', + studentId: 'student-5', + classId: 'demo-class-school-year-1-grade-6-a', + rollNumber: 2, + }, + ]) + }) +}) diff --git a/packages/data-ops/src/tests/demo-engagement-helpers.test.ts b/packages/data-ops/src/tests/demo-engagement-helpers.test.ts new file mode 100644 index 00000000..90256a30 --- /dev/null +++ b/packages/data-ops/src/tests/demo-engagement-helpers.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'vitest' +import { buildOperationalEngagementArtifacts } from '../seed/demo/seeders/engagement-helpers' + +describe('demo engagement helpers', () => { + test('buildOperationalEngagementArtifacts should create signal-based parent threads and aligned teacher notifications', () => { + const artifacts = buildOperationalEngagementArtifacts({ + schoolId: 'school-1', + currentDate: '2026-03-29T10:00:00.000Z', + studentSignals: [ + { + studentId: 'student-a', + classId: 'class-6a', + parentId: 'parent-a', + teacherId: 'teacher-1', + studentName: 'Aminata Diallo', + subjectName: 'Mathematiques', + absentCount: 4, + weightedAverage: 9.4, + overdueInstallmentCount: 2, + reportCardStatus: 'generated', + }, + { + studentId: 'student-b', + classId: 'class-6a', + parentId: 'parent-b', + teacherId: 'teacher-2', + studentName: 'Moussa Traore', + subjectName: 'Francais', + absentCount: 0, + weightedAverage: 15.8, + overdueInstallmentCount: 0, + reportCardStatus: 'viewed', + }, + ], + }) + + expect(artifacts.messageThreads).toHaveLength(5) + expect(artifacts.messageThreads.map(thread => ({ + signal: thread.signal, + studentId: thread.studentId, + teacherId: thread.teacherId, + }))).toStrictEqual([ + { signal: 'attendance', studentId: 'student-a', teacherId: 'teacher-1' }, + { signal: 'finance', studentId: 'student-a', teacherId: 'teacher-1' }, + { signal: 'grades', studentId: 'student-a', teacherId: 'teacher-1' }, + { signal: 'report_card', studentId: 'student-a', teacherId: 'teacher-1' }, + { signal: 'praise', studentId: 'student-b', teacherId: 'teacher-2' }, + ]) + + expect(artifacts.notifications.map(notification => ({ + teacherId: notification.teacherId, + type: notification.type, + title: notification.title, + }))).toStrictEqual([ + { + teacherId: 'teacher-1', + type: 'attendance_alert', + title: 'Absences a suivre pour Aminata Diallo', + }, + { + teacherId: 'teacher-1', + type: 'reminder', + title: 'Relance scolarite pour Aminata Diallo', + }, + { + teacherId: 'teacher-1', + type: 'message', + title: 'Bulletin a transmettre pour Aminata Diallo', + }, + { + teacherId: 'teacher-2', + type: 'message', + title: 'Parent informe des felicitations pour Moussa Traore', + }, + ]) + }) +}) diff --git a/packages/data-ops/src/tests/demo-finance-helpers.test.ts b/packages/data-ops/src/tests/demo-finance-helpers.test.ts new file mode 100644 index 00000000..5396f51e --- /dev/null +++ b/packages/data-ops/src/tests/demo-finance-helpers.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from 'vitest' +import { + allocatePaymentToCharges, + applyTransactionLinesToAccountMap, + buildChargeTransactionLines, + buildPaymentTransactionLines, + resolveFeeTypeTemplateIds, +} from '../seed/demo/seeders/finance-helpers' + +describe('demo finance helpers', () => { + test('allocatePaymentToCharges should consume outstanding balances in order without over-allocating', () => { + const charges = [ + { + studentFeeId: 'fee-registration', + installmentId: 'inst-1', + feeTypeName: 'Inscription', + receivableAccountId: 'ar-registration', + revenueAccountId: 'rev-registration', + outstandingBalance: 50_000, + }, + { + studentFeeId: 'fee-tuition', + installmentId: 'inst-1', + feeTypeName: 'Scolarite', + receivableAccountId: 'ar-tuition', + revenueAccountId: 'rev-tuition', + outstandingBalance: 150_000, + }, + ] + + const allocation = allocatePaymentToCharges({ + amount: 90_000, + charges, + }) + + expect(allocation.remainingAmount).toBe(0) + expect(allocation.allocations).toStrictEqual([ + { + studentFeeId: 'fee-registration', + installmentId: 'inst-1', + feeTypeName: 'Inscription', + receivableAccountId: 'ar-registration', + revenueAccountId: 'rev-registration', + amount: 50_000, + }, + { + studentFeeId: 'fee-tuition', + installmentId: 'inst-1', + feeTypeName: 'Scolarite', + receivableAccountId: 'ar-tuition', + revenueAccountId: 'rev-tuition', + amount: 40_000, + }, + ]) + expect(allocation.updatedCharges.map(charge => charge.outstandingBalance)).toStrictEqual([ + 0, + 110_000, + ]) + }) + + test('buildChargeTransactionLines should stay balanced while recognizing receivable and revenue', () => { + const lines = buildChargeTransactionLines([ + { + feeTypeName: 'Inscription', + receivableAccountId: 'ar-registration', + revenueAccountId: 'rev-registration', + amount: 50_000, + }, + { + feeTypeName: 'Scolarite', + receivableAccountId: 'ar-tuition', + revenueAccountId: 'rev-tuition', + amount: 150_000, + }, + ]) + + expect(lines).toStrictEqual([ + { + accountId: 'ar-registration', + description: 'Creance frais Inscription', + debitAmount: '50000.00', + creditAmount: '0', + }, + { + accountId: 'ar-tuition', + description: 'Creance frais Scolarite', + debitAmount: '150000.00', + creditAmount: '0', + }, + { + accountId: 'rev-registration', + description: 'Produit frais Inscription', + debitAmount: '0', + creditAmount: '50000.00', + }, + { + accountId: 'rev-tuition', + description: 'Produit frais Scolarite', + debitAmount: '0', + creditAmount: '150000.00', + }, + ]) + }) + + test('buildPaymentTransactionLines should debit cash and credit receivables using payment allocations', () => { + const lines = buildPaymentTransactionLines({ + cashAccountId: 'asset-cash', + paymentMethodLabel: 'cash', + allocations: [ + { + studentFeeId: 'fee-registration', + installmentId: 'inst-1', + feeTypeName: 'Inscription', + receivableAccountId: 'ar-registration', + revenueAccountId: 'rev-registration', + amount: 40_000, + }, + { + studentFeeId: 'fee-tuition', + installmentId: 'inst-1', + feeTypeName: 'Scolarite', + receivableAccountId: 'ar-tuition', + revenueAccountId: 'rev-tuition', + amount: 60_000, + }, + ], + }) + + expect(lines).toStrictEqual([ + { + accountId: 'asset-cash', + description: 'Encaissement cash', + debitAmount: '100000.00', + creditAmount: '0', + }, + { + accountId: 'ar-registration', + description: 'Reglement frais Inscription', + debitAmount: '0', + creditAmount: '40000.00', + }, + { + accountId: 'ar-tuition', + description: 'Reglement frais Scolarite', + debitAmount: '0', + creditAmount: '60000.00', + }, + ]) + }) + + test('resolveFeeTypeTemplateIds should attach distinct template ids for seeded finance fee blueprints', () => { + const resolved = resolveFeeTypeTemplateIds({ + blueprints: [ + { code: 'INS', name: 'Inscription', category: 'registration' }, + { code: 'TUI', name: 'Scolarite', category: 'tuition' }, + { code: 'CAN', name: 'Cantine', category: 'meals' }, + { code: 'BUS', name: 'Transport', category: 'transport' }, + ], + templates: [ + { id: 'tpl-registration', code: 'REGISTRATION', name: 'Frais d\'Inscription', category: 'registration', isActive: true }, + { id: 'tpl-tuition', code: 'TUITION', name: 'Frais de Scolarité', category: 'tuition', isActive: true }, + { id: 'tpl-meals', code: 'MEALS', name: 'Frais de Cantine', category: 'meals', isActive: true }, + { id: 'tpl-transport', code: 'TRANSPORT', name: 'Frais de Transport', category: 'transport', isActive: true }, + ], + }) + + expect(resolved).toStrictEqual(new Map([ + ['INS', 'tpl-registration'], + ['TUI', 'tpl-tuition'], + ['CAN', 'tpl-meals'], + ['BUS', 'tpl-transport'], + ])) + }) + + test('applyTransactionLinesToAccountMap should accumulate account balances in memory without per-line persistence', () => { + const accountMap = new Map([ + ['cash', { id: 'cash', balance: '0.00', normalBalance: 'debit' as const, updatedAt: new Date('2026-01-01T00:00:00.000Z') }], + ['receivable', { id: 'receivable', balance: '0.00', normalBalance: 'debit' as const, updatedAt: new Date('2026-01-01T00:00:00.000Z') }], + ['revenue', { id: 'revenue', balance: '0.00', normalBalance: 'credit' as const, updatedAt: new Date('2026-01-01T00:00:00.000Z') }], + ]) + + applyTransactionLinesToAccountMap({ + accountMap, + lines: [ + { accountId: 'receivable', description: 'Creance', debitAmount: '180000.00', creditAmount: '0' }, + { accountId: 'revenue', description: 'Produit', debitAmount: '0', creditAmount: '180000.00' }, + { accountId: 'cash', description: 'Encaissement', debitAmount: '50000.00', creditAmount: '0' }, + { accountId: 'receivable', description: 'Reglement', debitAmount: '0', creditAmount: '50000.00' }, + ], + updatedAt: new Date('2026-03-30T00:00:00.000Z'), + }) + + expect(accountMap.get('cash')).toMatchObject({ + balance: '50000.00', + normalBalance: 'debit', + }) + expect(accountMap.get('receivable')).toMatchObject({ + balance: '130000.00', + normalBalance: 'debit', + }) + expect(accountMap.get('revenue')).toMatchObject({ + balance: '180000.00', + normalBalance: 'credit', + }) + }) +}) diff --git a/packages/data-ops/src/tests/demo-grades-helpers.test.ts b/packages/data-ops/src/tests/demo-grades-helpers.test.ts new file mode 100644 index 00000000..99a4760b --- /dev/null +++ b/packages/data-ops/src/tests/demo-grades-helpers.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'vitest' +import { buildSeededGradeId, buildSeededGradeRow } from '../seed/demo/seeders/grades-helpers' + +describe('demo grades helpers', () => { + test('buildSeededGradeId should be deterministic for the same student-term-subject slot', () => { + expect(buildSeededGradeId({ + studentId: 'student-1', + classId: 'class-6a', + subjectId: 'subject-math', + termId: 'term-1', + gradeIndex: 1, + })).toBe('demo-grade-student-1-class-6a-subject-math-term-1-2') + }) + + test('buildSeededGradeRow should build validated grade rows with deterministic ids and weights', () => { + const row = buildSeededGradeRow({ + studentId: 'student-1', + classId: 'class-6a', + subjectId: 'subject-math', + termId: 'term-1', + teacherId: 'teacher-1', + value: 14.5, + type: 'test', + gradeDate: '2026-03-29', + gradeIndex: 0, + }) + + expect({ + id: row.id, + studentId: row.studentId, + classId: row.classId, + subjectId: row.subjectId, + termId: row.termId, + teacherId: row.teacherId, + value: row.value, + type: row.type, + weight: row.weight, + status: row.status, + gradeDate: row.gradeDate, + maxPoints: row.maxPoints, + }).toStrictEqual({ + id: 'demo-grade-student-1-class-6a-subject-math-term-1-1', + studentId: 'student-1', + classId: 'class-6a', + subjectId: 'subject-math', + termId: 'term-1', + teacherId: 'teacher-1', + value: '14.5', + type: 'test', + weight: 2, + status: 'validated', + gradeDate: '2026-03-29', + maxPoints: 20, + }) + }) +}) diff --git a/packages/data-ops/src/tests/demo-matricule.test.ts b/packages/data-ops/src/tests/demo-matricule.test.ts new file mode 100644 index 00000000..f3a4051c --- /dev/null +++ b/packages/data-ops/src/tests/demo-matricule.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'vitest' +import { generateSeededStudentMatricule } from '../seed/demo/utils/matricule' + +describe('demo matricule utils', () => { + test('generateSeededStudentMatricule should produce deterministic unique matricules per ordinal', () => { + expect([ + generateSeededStudentMatricule(2024, 1), + generateSeededStudentMatricule(2024, 2), + generateSeededStudentMatricule(2024, 120), + ]).toStrictEqual([ + '24D00001S', + '24D00002S', + '24D00120S', + ]) + }) +}) diff --git a/packages/data-ops/src/tests/demo-metrics-helpers.test.ts b/packages/data-ops/src/tests/demo-metrics-helpers.test.ts new file mode 100644 index 00000000..bf25aef7 --- /dev/null +++ b/packages/data-ops/src/tests/demo-metrics-helpers.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'vitest' +import { buildDemoMetrics } from '../seed/demo/seeders/demo-metrics-helpers' + +describe('demo metrics helpers', () => { + test('buildDemoMetrics should derive attendance and collection KPIs from aggregate counts', () => { + const metrics = buildDemoMetrics({ + totalStudents: 240, + totalClasses: 14, + totalTeachers: 18, + averageGrade: 11.934641216850215, + attendanceRows: 15288, + absentRows: 439, + totalPlannedAmount: '19050000.00', + totalPaidAmount: '5263500.00', + overdueCount: 91, + openIncidents: 21, + }) + + expect(metrics).toStrictEqual({ + totalStudents: 240, + totalClasses: 14, + totalTeachers: 18, + averageGrade: 11.934641216850215, + attendanceRate: 97.1, + collectionRate: 0.2762992125984252, + overdueCount: 91, + openIncidents: 21, + }) + }) + + test('buildDemoMetrics should default attendance and collection KPIs to zero when there is no denominator', () => { + const metrics = buildDemoMetrics({ + totalStudents: 0, + totalClasses: 0, + totalTeachers: 0, + averageGrade: 0, + attendanceRows: 0, + absentRows: 0, + totalPlannedAmount: null, + totalPaidAmount: null, + overdueCount: 0, + openIncidents: 0, + }) + + expect(metrics).toStrictEqual({ + totalStudents: 0, + totalClasses: 0, + totalTeachers: 0, + averageGrade: 0, + attendanceRate: 0, + collectionRate: 0, + overdueCount: 0, + openIncidents: 0, + }) + }) +}) diff --git a/packages/data-ops/src/tests/demo-operations-helpers.test.ts b/packages/data-ops/src/tests/demo-operations-helpers.test.ts new file mode 100644 index 00000000..25b20ba1 --- /dev/null +++ b/packages/data-ops/src/tests/demo-operations-helpers.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from 'vitest' +import { + assignTimetableSlots, + buildRecurringSlots, + chunkValues, + summarizeAttendanceHistory, +} from '../seed/demo/seeders/operations-helpers' + +describe('demo operations helpers', () => { + test('buildRecurringSlots should distribute weekly hours across school days without collisions', () => { + const slots = buildRecurringSlots({ + hoursPerWeek: 4, + slotPool: [ + { dayOfWeek: 1, startTime: '08:00', endTime: '09:00' }, + { dayOfWeek: 1, startTime: '09:00', endTime: '10:00' }, + { dayOfWeek: 2, startTime: '10:00', endTime: '11:00' }, + { dayOfWeek: 3, startTime: '08:00', endTime: '09:00' }, + { dayOfWeek: 4, startTime: '11:00', endTime: '12:00' }, + ], + preferredStartIndex: 1, + }) + + expect(slots).toStrictEqual([ + { dayOfWeek: 1, startTime: '09:00', endTime: '10:00' }, + { dayOfWeek: 2, startTime: '10:00', endTime: '11:00' }, + { dayOfWeek: 3, startTime: '08:00', endTime: '09:00' }, + { dayOfWeek: 4, startTime: '11:00', endTime: '12:00' }, + ]) + }) + + test('chunkValues should split large arrays into deterministic fixed-size batches', () => { + expect(chunkValues([1, 2, 3, 4, 5], 2)).toStrictEqual([ + [1, 2], + [3, 4], + [5], + ]) + }) + + test('summarizeAttendanceHistory should derive report-card totals from absences and lateness', () => { + const summary = summarizeAttendanceHistory({ + totalSchoolDays: 40, + attendanceRecords: [ + { status: 'absent' }, + { status: 'absent' }, + { status: 'late' }, + { status: 'late' }, + { status: 'late' }, + ], + }) + + expect(summary).toStrictEqual({ + totalDays: 40, + presentDays: 35, + absentDays: 2, + lateDays: 3, + }) + }) + + test('assignTimetableSlots should avoid teacher and classroom collisions while spreading load', () => { + const assignments = assignTimetableSlots({ + slotPool: [ + { dayOfWeek: 1, startTime: '07:30', endTime: '08:30' }, + { dayOfWeek: 1, startTime: '08:40', endTime: '09:40' }, + { dayOfWeek: 2, startTime: '07:30', endTime: '08:30' }, + { dayOfWeek: 2, startTime: '08:40', endTime: '09:40' }, + { dayOfWeek: 3, startTime: '07:30', endTime: '08:30' }, + ], + classroomIds: ['room-a', 'room-b'], + existingTeacherSlots: [ + { + teacherId: 'teacher-1', + slot: { dayOfWeek: 1, startTime: '07:30', endTime: '08:30' }, + }, + ], + existingClassroomSlots: [ + { + classroomId: 'room-a', + slot: { dayOfWeek: 1, startTime: '08:40', endTime: '09:40' }, + }, + ], + classSubjects: [ + { + classId: 'class-6a', + subjectId: 'subject-math', + teacherId: 'teacher-1', + hoursPerWeek: 2, + preferredClassroomId: 'room-a', + }, + { + classId: 'class-6a', + subjectId: 'subject-french', + teacherId: 'teacher-2', + hoursPerWeek: 2, + preferredClassroomId: 'room-a', + }, + ], + }) + + expect(assignments).toStrictEqual([ + { + classId: 'class-6a', + subjectId: 'subject-math', + teacherId: 'teacher-1', + classroomId: 'room-a', + dayOfWeek: 2, + startTime: '07:30', + endTime: '08:30', + }, + { + classId: 'class-6a', + classroomId: 'room-a', + dayOfWeek: 3, + startTime: '07:30', + endTime: '08:30', + subjectId: 'subject-math', + teacherId: 'teacher-1', + }, + { + classId: 'class-6a', + subjectId: 'subject-french', + teacherId: 'teacher-2', + classroomId: 'room-a', + dayOfWeek: 1, + startTime: '07:30', + endTime: '08:30', + }, + { + classId: 'class-6a', + subjectId: 'subject-french', + teacherId: 'teacher-2', + classroomId: 'room-a', + dayOfWeek: 2, + startTime: '08:40', + endTime: '09:40', + }, + ]) + }) + + test('assignTimetableSlots should prefer spreading a teacher load across distinct days before stacking the same day', () => { + const assignments = assignTimetableSlots({ + slotPool: [ + { dayOfWeek: 1, startTime: '07:30', endTime: '08:30' }, + { dayOfWeek: 1, startTime: '08:40', endTime: '09:40' }, + { dayOfWeek: 2, startTime: '07:30', endTime: '08:30' }, + { dayOfWeek: 2, startTime: '08:40', endTime: '09:40' }, + { dayOfWeek: 3, startTime: '07:30', endTime: '08:30' }, + { dayOfWeek: 3, startTime: '08:40', endTime: '09:40' }, + ], + classroomIds: ['room-a', 'room-b'], + classSubjects: [ + { + classId: 'class-6a', + subjectId: 'subject-math', + teacherId: 'teacher-1', + hoursPerWeek: 2, + preferredClassroomId: 'room-a', + }, + { + classId: 'class-5a', + subjectId: 'subject-physics', + teacherId: 'teacher-1', + hoursPerWeek: 2, + preferredClassroomId: 'room-b', + }, + ], + }) + + expect(assignments).toStrictEqual([ + { + classId: 'class-5a', + subjectId: 'subject-physics', + teacherId: 'teacher-1', + classroomId: 'room-b', + dayOfWeek: 1, + startTime: '08:40', + endTime: '09:40', + }, + { + classId: 'class-5a', + subjectId: 'subject-physics', + teacherId: 'teacher-1', + classroomId: 'room-b', + dayOfWeek: 3, + startTime: '07:30', + endTime: '08:30', + }, + { + classId: 'class-6a', + subjectId: 'subject-math', + teacherId: 'teacher-1', + classroomId: 'room-a', + dayOfWeek: 1, + startTime: '07:30', + endTime: '08:30', + }, + { + classId: 'class-6a', + subjectId: 'subject-math', + teacherId: 'teacher-1', + classroomId: 'room-a', + dayOfWeek: 2, + startTime: '07:30', + endTime: '08:30', + }, + ]) + }) +}) diff --git a/packages/data-ops/src/tests/demo-profile-kpi-helpers.test.ts b/packages/data-ops/src/tests/demo-profile-kpi-helpers.test.ts new file mode 100644 index 00000000..e2603371 --- /dev/null +++ b/packages/data-ops/src/tests/demo-profile-kpi-helpers.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'vitest' +import { premiumSchool, problematicSchool, realisticSchool, smallSchool } from '../seed/demo/config' +import { estimateProfileKpis } from '../seed/demo/seeders/profile-kpi-helpers' + +describe('demo profile KPI helpers', () => { + test('estimateProfileKpis should preserve intended attendance ordering across profiles', () => { + const premium = estimateProfileKpis(premiumSchool) + const realistic = estimateProfileKpis(realisticSchool) + const small = estimateProfileKpis(smallSchool) + const problematic = estimateProfileKpis(problematicSchool) + + expect(premium.expectedAttendanceRate).toBeGreaterThan(realistic.expectedAttendanceRate) + expect(realistic.expectedAttendanceRate).toBeGreaterThan(problematic.expectedAttendanceRate) + expect(small.expectedAttendanceRate).toBeGreaterThan(problematic.expectedAttendanceRate) + }) + + test('estimateProfileKpis should preserve intended finance health ordering across profiles', () => { + const premium = estimateProfileKpis(premiumSchool) + const realistic = estimateProfileKpis(realisticSchool) + const small = estimateProfileKpis(smallSchool) + const problematic = estimateProfileKpis(problematicSchool) + + expect(premium.expectedCollectionHealth).toBeGreaterThan(small.expectedCollectionHealth) + expect(small.expectedCollectionHealth).toBeGreaterThan(realistic.expectedCollectionHealth) + expect(realistic.expectedCollectionHealth).toBeGreaterThan(problematic.expectedCollectionHealth) + }) + + test('estimateProfileKpis should keep all derived KPI expectations in plausible ranges', () => { + for (const profile of [smallSchool, premiumSchool, problematicSchool, realisticSchool]) { + const estimate = estimateProfileKpis(profile) + + expect(estimate.expectedAttendanceRate).toBeGreaterThanOrEqual(0) + expect(estimate.expectedAttendanceRate).toBeLessThanOrEqual(1) + expect(estimate.expectedCollectionHealth).toBeGreaterThanOrEqual(0) + expect(estimate.expectedCollectionHealth).toBeLessThanOrEqual(1) + expect(estimate.expectedIncidentPressure).toBeGreaterThanOrEqual(0) + expect(estimate.expectedIncidentPressure).toBeLessThanOrEqual(1) + } + }) +}) diff --git a/packages/data-ops/src/tests/demo-reporting-helpers.test.ts b/packages/data-ops/src/tests/demo-reporting-helpers.test.ts new file mode 100644 index 00000000..7ad11587 --- /dev/null +++ b/packages/data-ops/src/tests/demo-reporting-helpers.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, test } from 'vitest' +import { + buildReportCardLifecycle, + buildReportCardNarrative, + buildSeededTeacherComments, + buildStudentAverages, +} from '../seed/demo/seeders/reporting-helpers' + +describe('demo reporting helpers', () => { + test('buildStudentAverages should create subject and term averages with ranking from weighted coefficients', () => { + const rows = buildStudentAverages({ + classId: 'class-6a', + termId: 'term-1', + gradeRows: [ + { studentId: 'student-1', subjectId: 'math', value: '14', weight: 2, status: 'validated' }, + { studentId: 'student-1', subjectId: 'math', value: '16', weight: 1, status: 'validated' }, + { studentId: 'student-1', subjectId: 'french', value: '12', weight: 1, status: 'validated' }, + { studentId: 'student-1', subjectId: 'music', value: '15', weight: 1, status: 'validated' }, + { studentId: 'student-2', subjectId: 'math', value: '10', weight: 1, status: 'validated' }, + { studentId: 'student-2', subjectId: 'french', value: '14', weight: 1, status: 'validated' }, + { studentId: 'student-2', subjectId: 'music', value: '8', weight: 1, status: 'validated' }, + ], + coefficientMap: new Map([ + ['math', { weight: 4, isFacultative: false }], + ['french', { weight: 3, isFacultative: false }], + ['music', { weight: 2, isFacultative: true }], + ]), + calculatedAt: '2026-03-29T12:00:00.000Z', + }) + + expect(rows).toStrictEqual([ + { + studentId: 'student-1', + classId: 'class-6a', + termId: 'term-1', + subjectId: 'french', + average: '12.00', + weightedAverage: '12.00', + gradeCount: 1, + rankInClass: null, + rankInGrade: null, + isFinal: false, + calculatedAt: '2026-03-29T12:00:00.000Z', + }, + { + studentId: 'student-1', + classId: 'class-6a', + termId: 'term-1', + subjectId: 'math', + average: '14.67', + weightedAverage: '14.67', + gradeCount: 2, + rankInClass: null, + rankInGrade: null, + isFinal: false, + calculatedAt: '2026-03-29T12:00:00.000Z', + }, + { + studentId: 'student-1', + classId: 'class-6a', + termId: 'term-1', + subjectId: 'music', + average: '15.00', + weightedAverage: '15.00', + gradeCount: 1, + rankInClass: null, + rankInGrade: null, + isFinal: false, + calculatedAt: '2026-03-29T12:00:00.000Z', + }, + { + studentId: 'student-2', + classId: 'class-6a', + termId: 'term-1', + subjectId: 'french', + average: '14.00', + weightedAverage: '14.00', + gradeCount: 1, + rankInClass: null, + rankInGrade: null, + isFinal: false, + calculatedAt: '2026-03-29T12:00:00.000Z', + }, + { + studentId: 'student-2', + classId: 'class-6a', + termId: 'term-1', + subjectId: 'math', + average: '10.00', + weightedAverage: '10.00', + gradeCount: 1, + rankInClass: null, + rankInGrade: null, + isFinal: false, + calculatedAt: '2026-03-29T12:00:00.000Z', + }, + { + studentId: 'student-2', + classId: 'class-6a', + termId: 'term-1', + subjectId: 'music', + average: '8.00', + weightedAverage: '8.00', + gradeCount: 1, + rankInClass: null, + rankInGrade: null, + isFinal: false, + calculatedAt: '2026-03-29T12:00:00.000Z', + }, + { + studentId: 'student-1', + classId: 'class-6a', + termId: 'term-1', + subjectId: null, + average: '13.89', + weightedAverage: '14.95', + gradeCount: 3, + rankInClass: 1, + rankInGrade: null, + isFinal: false, + calculatedAt: '2026-03-29T12:00:00.000Z', + }, + { + studentId: 'student-2', + classId: 'class-6a', + termId: 'term-1', + subjectId: null, + average: '10.67', + weightedAverage: '11.71', + gradeCount: 3, + rankInClass: 2, + rankInGrade: null, + isFinal: false, + calculatedAt: '2026-03-29T12:00:00.000Z', + }, + ]) + }) + + test('buildSeededTeacherComments should derive subject comments from stored averages and teacher assignments', () => { + const comments = buildSeededTeacherComments({ + reportCardId: 'report-1', + subjectAverages: [ + { + subjectId: 'math', + subjectName: 'Mathematiques', + average: '15.40', + weightedAverage: '15.40', + rankInClass: 1, + gradeCount: 4, + }, + { + subjectId: 'history', + subjectName: 'Histoire', + average: '9.20', + weightedAverage: '9.20', + rankInClass: 12, + gradeCount: 3, + }, + ], + teacherAssignments: [ + { + subjectId: 'math', + teacherId: 'teacher-math', + subjectName: 'Mathematiques', + }, + { + subjectId: 'history', + teacherId: 'teacher-history', + subjectName: 'Histoire', + }, + { + subjectId: 'music', + teacherId: 'teacher-music', + subjectName: 'Musique', + }, + ], + }) + + expect(comments).toStrictEqual([ + { + reportCardId: 'report-1', + subjectId: 'history', + teacherId: 'teacher-history', + comment: 'Des difficultes persistent en histoire. Des exercices de remediation reguliers sont recommandes.', + }, + { + reportCardId: 'report-1', + subjectId: 'math', + teacherId: 'teacher-math', + comment: 'Tres bonne progression en mathematiques. L eleve se distingue dans la classe et maintient un travail serieux.', + }, + ]) + }) + + test('buildReportCardNarrative should reflect overall average, attendance, and conduct in the homeroom summary', () => { + const narrative = buildReportCardNarrative({ + studentDisplayName: 'Aminata Diallo', + weightedAverage: 14.95, + rankInClass: 2, + totalStudents: 32, + absentDays: 1, + lateDays: 2, + totalDays: 58, + conductIncidentCount: 0, + }) + + expect(narrative).toStrictEqual({ + homeroomComment: 'Aminata Diallo maintient un bon niveau d ensemble. Classement actuel: 2e sur 32. Assiduite satisfaisante sur la periode.', + conductSummary: 'Comportement satisfaisant sur la periode.', + }) + }) + + test('buildReportCardLifecycle should produce realistic delivery states from term timing and parent channel availability', () => { + expect(buildReportCardLifecycle({ + termEndDate: '2025-12-20', + currentDate: '2026-03-29T10:00:00.000Z', + hasParentEmail: true, + rankInClass: 2, + })).toStrictEqual({ + status: 'viewed', + generatedAt: '2025-12-27T12:00:00.000Z', + sentAt: '2025-12-28T09:00:00.000Z', + sentTo: 'email', + deliveryMethod: 'email', + deliveredAt: '2025-12-28T10:00:00.000Z', + viewedAt: '2025-12-29T18:00:00.000Z', + pdfUrl: '/demo/report-cards/2025-12-20/viewed.pdf', + }) + + expect(buildReportCardLifecycle({ + termEndDate: '2026-03-20', + currentDate: '2026-03-29T10:00:00.000Z', + hasParentEmail: false, + rankInClass: 17, + })).toStrictEqual({ + status: 'generated', + generatedAt: '2026-03-27T12:00:00.000Z', + sentAt: null, + sentTo: null, + deliveryMethod: null, + deliveredAt: null, + viewedAt: null, + pdfUrl: '/demo/report-cards/2026-03-20/generated.pdf', + }) + + expect(buildReportCardLifecycle({ + termEndDate: '2026-04-15', + currentDate: '2026-03-29T10:00:00.000Z', + hasParentEmail: true, + rankInClass: 5, + })).toStrictEqual({ + status: 'draft', + generatedAt: null, + sentAt: null, + sentTo: null, + deliveryMethod: null, + deliveredAt: null, + viewedAt: null, + pdfUrl: null, + }) + }) +}) diff --git a/packages/data-ops/src/tests/demo-seed-resilience-helpers.test.ts b/packages/data-ops/src/tests/demo-seed-resilience-helpers.test.ts new file mode 100644 index 00000000..8cc08a4a --- /dev/null +++ b/packages/data-ops/src/tests/demo-seed-resilience-helpers.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from 'vitest' +import { + executeWithRetry, + isTransientSeedError, + runAdaptiveChunkedWithRetry, + runChunkedWithRetry, +} from '../seed/demo/seeders/seed-resilience-helpers' + +describe('demo seed resilience helpers', () => { + test('isTransientSeedError should detect Neon HTTP timeout-style failures through nested causes', () => { + const error = new Error('Query failed', { + cause: new Error('fetch failed', { + cause: new Error('connect ETIMEDOUT 104.26.7.55:443'), + }), + }) + + expect(isTransientSeedError(error)).toBe(true) + }) + + test('executeWithRetry should retry transient failures until the operation succeeds', async () => { + let attempts = 0 + + const result = await executeWithRetry(async () => { + attempts += 1 + if (attempts < 3) { + throw new Error('fetch failed', { + cause: new Error('socket hang up'), + }) + } + + return 'ok' + }, { + maxAttempts: 3, + delayMs: 0, + }) + + expect(result).toBe('ok') + expect(attempts).toBe(3) + }) + + test('executeWithRetry should not retry non-transient failures', async () => { + let attempts = 0 + + await expect(executeWithRetry(async () => { + attempts += 1 + throw new Error('foreign key violation') + }, { + maxAttempts: 3, + delayMs: 0, + })).rejects.toThrow('foreign key violation') + + expect(attempts).toBe(1) + }) + + test('runChunkedWithRetry should preserve chunk order while retrying transient chunk failures', async () => { + const processed: number[][] = [] + let failingChunkAttempts = 0 + + await runChunkedWithRetry([1, 2, 3, 4, 5], { + chunkSize: 2, + maxAttempts: 2, + delayMs: 0, + handler: async (chunk) => { + if (chunk[0] === 3 && failingChunkAttempts === 0) { + failingChunkAttempts += 1 + throw new Error('fetch failed', { + cause: new Error('ETIMEDOUT'), + }) + } + + processed.push(chunk) + }, + }) + + expect(processed).toStrictEqual([ + [1, 2], + [3, 4], + [5], + ]) + }) + + test('runAdaptiveChunkedWithRetry should split a failing chunk until smaller batches succeed', async () => { + const processed: number[][] = [] + + await runAdaptiveChunkedWithRetry([1, 2, 3, 4, 5], { + chunkSize: 4, + minChunkSize: 1, + maxAttempts: 1, + delayMs: 0, + handler: async (chunk) => { + if (chunk.length > 2) { + throw new Error('payload too large') + } + + processed.push(chunk) + }, + }) + + expect(processed).toStrictEqual([ + [1, 2], + [3, 4], + [5], + ]) + }) +}) diff --git a/packages/data-ops/src/tests/demo-student-activity-helpers.test.ts b/packages/data-ops/src/tests/demo-student-activity-helpers.test.ts new file mode 100644 index 00000000..51d86581 --- /dev/null +++ b/packages/data-ops/src/tests/demo-student-activity-helpers.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from 'vitest' +import { buildSeededAttendanceRows, buildSeededConductRows } from '../seed/demo/seeders/student-activity-helpers' + +describe('demo student activity helpers', () => { + test('buildSeededAttendanceRows should skip weekends and only emit absences when draw is below the scenario threshold', () => { + const rows = buildSeededAttendanceRows({ + schoolId: 'school-1', + recordedBy: 'teacher-user-1', + schoolDays: 7, + startDate: new Date('2026-01-05T00:00:00.000Z'), + enrollments: [ + { studentId: 'student-1', classId: 'class-1' }, + { studentId: 'student-2', classId: 'class-1' }, + ], + studentScenarioMap: new Map([ + ['student-1', 'excellent'], + ['student-2', 'struggling'], + ]), + nextRandom: (() => { + const draws = [0.001, 0.8, 0.8, 0.8, 0.8, 0.1, 0.25, 0.1, 0.05, 0.3] + let index = 0 + return () => draws[index++] ?? 0.99 + })(), + createId: (() => { + let index = 0 + return () => `attendance-${++index}` + })(), + }) + + expect(rows.map(row => ({ + id: row.id, + studentId: row.studentId, + date: row.date, + status: row.status, + }))).toStrictEqual([ + { + id: 'attendance-1', + studentId: 'student-1', + date: '2026-01-05', + status: 'absent', + }, + { + id: 'attendance-2', + studentId: 'student-2', + date: '2026-01-05', + status: 'absent', + }, + { + id: 'attendance-3', + studentId: 'student-2', + date: '2026-01-07', + status: 'absent', + }, + { + id: 'attendance-4', + studentId: 'student-2', + date: '2026-01-08', + status: 'absent', + }, + ]) + }) + + test('buildSeededConductRows should emit only the scenario-driven incidents', () => { + const rows = buildSeededConductRows({ + schoolId: 'school-1', + schoolYearId: 'school-year-1', + recordedBy: 'teacher-user-1', + incidentDate: '2026-03-29', + enrollments: [ + { studentId: 'student-1', classId: 'class-1' }, + { studentId: 'student-2', classId: 'class-1' }, + { studentId: 'student-3', classId: 'class-2' }, + ], + studentScenarioMap: new Map([ + ['student-1', 'excellent'], + ['student-2', 'average'], + ['student-3', 'struggling'], + ]), + nextRandom: (() => { + const draws = [0.5, 0.07, 0.1] + let index = 0 + return () => draws[index++] ?? 0.99 + })(), + createId: (() => { + let index = 0 + return () => `conduct-${++index}` + })(), + }) + + expect(rows.map(row => ({ + id: row.id, + studentId: row.studentId, + title: row.title, + description: row.description, + }))).toStrictEqual([ + { + id: 'conduct-1', + studentId: 'student-2', + title: 'Note de participation', + description: 'Manque d’attention ponctuel.', + }, + { + id: 'conduct-2', + studentId: 'student-3', + title: 'Avertissement de conduite', + description: 'Perturbe le cours fréquemment.', + }, + ]) + }) +}) diff --git a/packages/data-ops/src/tests/demo-students-helpers.test.ts b/packages/data-ops/src/tests/demo-students-helpers.test.ts new file mode 100644 index 00000000..f3b9e801 --- /dev/null +++ b/packages/data-ops/src/tests/demo-students-helpers.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest' +import { planUserProvisioning } from '../seed/demo/seeders/students-helpers' + +describe('demo students helpers', () => { + test('planUserProvisioning should reuse existing users and deduplicate new user inserts by email', () => { + const plan = planUserProvisioning({ + contacts: [ + { + email: 'mama@example.com', + name: 'Mama Doe', + phone: '01020304', + }, + { + email: 'papa@example.com', + name: 'Papa Doe', + phone: '05060708', + }, + { + email: 'mama@example.com', + name: 'Mama Doe', + phone: '99999999', + }, + ], + existingUsersByEmail: new Map([ + ['papa@example.com', 'existing-user-2'], + ]), + createId: () => 'new-user-1', + }) + + expect(plan).toStrictEqual({ + resolvedUserIdsByEmail: new Map([ + ['mama@example.com', 'new-user-1'], + ['papa@example.com', 'existing-user-2'], + ]), + usersToInsert: [ + { + id: 'new-user-1', + email: 'mama@example.com', + name: 'Mama Doe', + phone: '01020304', + status: 'active', + }, + ], + }) + }) +}) diff --git a/packages/data-ops/src/tests/setup.ts b/packages/data-ops/src/tests/setup.ts index 6cdaaf15..d1b30b81 100644 --- a/packages/data-ops/src/tests/setup.ts +++ b/packages/data-ops/src/tests/setup.ts @@ -6,15 +6,15 @@ import { initDatabase } from '../database/setup' import { cleanupDatabase } from './db-cleanup' // Load environment variables -const envTestPath = path.resolve(__dirname, '../../.env.test') const envPath = path.resolve(__dirname, '../../.env') +const sharedEnvPath = path.resolve(__dirname, '../../../../../../packages/data-ops/.env') -if (fs.existsSync(envTestPath)) { - dotenv.config({ path: envTestPath }) -} -else { +if (fs.existsSync(envPath)) { dotenv.config({ path: envPath }) } +else if (fs.existsSync(sharedEnvPath)) { + dotenv.config({ path: sharedEnvPath }) +} // Initialize database before all tests beforeAll(async () => {