db->prepare( 'SELECT s.*, t.code AS template_code, t.name AS template_name, t.is_combination AS template_is_combination FROM students s JOIN license_class_templates t ON t.id = s.template_id WHERE s.tenant_id = :tenant_id ORDER BY s.last_name, s.first_name' ); $statement->execute(['tenant_id' => $tenantId]); return $statement->fetchAll(); } public function find(int $tenantId, int $id): ?array { $statement = $this->db->prepare( 'SELECT s.*, t.code AS template_code, t.name AS template_name, t.is_combination AS template_is_combination FROM students s JOIN license_class_templates t ON t.id = s.template_id WHERE s.tenant_id = :tenant_id AND s.id = :id' ); $statement->execute(['tenant_id' => $tenantId, 'id' => $id]); $student = $statement->fetch() ?: null; if ($student !== null) { $student['requirements'] = $this->requirements($id); } return $student; } public function create(int $tenantId, array $data): array { $timestamp = $this->now(); $statement = $this->db->prepare( 'INSERT INTO students (tenant_id, template_id, first_name, last_name, phone, email, needs_glasses, notes, status, created_at, updated_at) VALUES (:tenant_id, :template_id, :first_name, :last_name, :phone, :email, :needs_glasses, :notes, :status, :created_at, :updated_at)' ); $statement->execute([ 'tenant_id' => $tenantId, 'template_id' => $data['template_id'], 'first_name' => $data['first_name'], 'last_name' => $data['last_name'], 'phone' => $data['phone'] ?? '', 'email' => $data['email'] ?? '', 'needs_glasses' => !empty($data['needs_glasses']) ? 1 : 0, 'notes' => $data['notes'] ?? '', 'status' => $data['status'] ?? 'active', 'created_at' => $timestamp, 'updated_at' => $timestamp, ]); $studentId = (int) $this->lastInsertId(); $this->snapshotRequirements($studentId, (int) $data['template_id']); return $this->find($tenantId, $studentId); } public function update(int $tenantId, int $id, array $data): ?array { $statement = $this->db->prepare( 'UPDATE students SET template_id = :template_id, first_name = :first_name, last_name = :last_name, phone = :phone, email = :email, needs_glasses = :needs_glasses, notes = :notes, status = :status, updated_at = :updated_at WHERE tenant_id = :tenant_id AND id = :id' ); $statement->execute([ 'tenant_id' => $tenantId, 'id' => $id, 'template_id' => $data['template_id'], 'first_name' => $data['first_name'], 'last_name' => $data['last_name'], 'phone' => $data['phone'] ?? '', 'email' => $data['email'] ?? '', 'needs_glasses' => !empty($data['needs_glasses']) ? 1 : 0, 'notes' => $data['notes'] ?? '', 'status' => $data['status'] ?? 'active', 'updated_at' => $this->now(), ]); return $this->find($tenantId, $id); } public function delete(int $tenantId, int $id): bool { $student = $this->find($tenantId, $id); if ($student === null) { return false; } $this->db->beginTransaction(); try { $updateAppointments = $this->db->prepare( 'UPDATE appointments SET student_id = NULL, updated_at = :updated_at WHERE tenant_id = :tenant_id AND student_id = :student_id' ); $updateAppointments->execute([ 'tenant_id' => $tenantId, 'student_id' => $id, 'updated_at' => $this->now(), ]); $deleteSnapshots = $this->db->prepare('DELETE FROM student_requirement_snapshots WHERE student_id = :student_id'); $deleteSnapshots->execute(['student_id' => $id]); $deleteStudent = $this->db->prepare('DELETE FROM students WHERE tenant_id = :tenant_id AND id = :id'); $deleteStudent->execute(['tenant_id' => $tenantId, 'id' => $id]); $this->db->commit(); return true; } catch (\Throwable $exception) { $this->db->rollBack(); throw $exception; } } public function requirements(int $studentId): array { $this->syncRequirementSnapshots($studentId); $statement = $this->db->prepare( 'SELECT * FROM student_requirement_snapshots WHERE student_id = :student_id ORDER BY sort_order, phase_label, label' ); $statement->execute(['student_id' => $studentId]); return $statement->fetchAll(); } public function updatePriorCompletedUnits(int $tenantId, int $studentId, array $rows): ?array { $student = $this->find($tenantId, $studentId); if ($student === null) { return null; } $statement = $this->db->prepare( 'UPDATE student_requirement_snapshots SET prior_completed_units = :prior_completed_units WHERE student_id = :student_id AND requirement_key = :requirement_key' ); foreach ($rows as $row) { $statement->execute([ 'student_id' => $studentId, 'requirement_key' => (string) ($row['requirement_key'] ?? ''), 'prior_completed_units' => max(0, (int) ($row['prior_completed_units'] ?? 0)), ]); } return $this->find($tenantId, $studentId); } private function snapshotRequirements(int $studentId, int $templateId): void { $this->syncRequirementSnapshots($studentId, $templateId); } private function syncRequirementSnapshots(int $studentId, ?int $templateId = null): void { if ($templateId === null) { $student = $this->db->prepare('SELECT template_id FROM students WHERE id = :student_id'); $student->execute(['student_id' => $studentId]); $templateId = (int) ($student->fetch()['template_id'] ?? 0); } if ($templateId <= 0) { return; } $requirements = $this->db->prepare( 'SELECT requirement_key, label, required_units, sort_order, phase_label FROM license_template_requirements WHERE template_id = :template_id' ); $requirements->execute(['template_id' => $templateId]); $rows = []; foreach ($requirements->fetchAll() as $row) { $rows[$row['requirement_key']] = $row; } $hasPhasedRequirements = array_reduce( $rows, static fn (bool $carry, array $row): bool => $carry || trim((string) ($row['phase_label'] ?? '')) !== '', false ); $lessonTypes = $this->db->prepare( 'SELECT COALESCE(NULLIF(TRIM(lt.requirement_key), \'\'), \'lesson_type_\' || lt.id) AS requirement_key, lt.name AS label, lt.sort_order FROM lesson_types lt JOIN lesson_type_template_visibility vis ON vis.lesson_type_id = lt.id WHERE vis.template_id = :template_id AND lt.category = \'student\' AND lt.is_counted = 1 ORDER BY lt.sort_order, lt.name' ); $lessonTypes->execute(['template_id' => $templateId]); foreach ($lessonTypes->fetchAll() as $row) { if (isset($rows[$row['requirement_key']])) { continue; } if ($hasPhasedRequirements) { $hasMatchingPhasedRequirement = false; foreach ($rows as $existingRow) { $phaseLabel = trim((string) ($existingRow['phase_label'] ?? '')); if ($phaseLabel === '') { continue; } if (str_ends_with((string) $existingRow['requirement_key'], '::' . $row['requirement_key'])) { $hasMatchingPhasedRequirement = true; break; } } if ($hasMatchingPhasedRequirement) { continue; } } $rows[$row['requirement_key']] = [ 'requirement_key' => $row['requirement_key'], 'label' => $row['label'], 'required_units' => 0, 'sort_order' => $row['sort_order'], ]; } $insert = $this->db->prepare( 'INSERT INTO student_requirement_snapshots (student_id, requirement_key, label, required_units, prior_completed_units, sort_order, phase_label) SELECT :student_id, :requirement_key, :label, :required_units, 0, :sort_order, :phase_label WHERE NOT EXISTS ( SELECT 1 FROM student_requirement_snapshots WHERE student_id = :student_id AND requirement_key = :requirement_key )' ); $update = $this->db->prepare( 'UPDATE student_requirement_snapshots SET label = :label, required_units = :required_units, sort_order = :sort_order, phase_label = :phase_label WHERE student_id = :student_id AND requirement_key = :requirement_key' ); foreach ($rows as $row) { $insert->execute([ 'student_id' => $studentId, 'requirement_key' => $row['requirement_key'], 'label' => $row['label'], 'required_units' => $row['required_units'], 'sort_order' => $row['sort_order'], 'phase_label' => $row['phase_label'] ?? '', ]); $update->execute([ 'student_id' => $studentId, 'requirement_key' => $row['requirement_key'], 'label' => $row['label'], 'required_units' => $row['required_units'], 'sort_order' => $row['sort_order'], 'phase_label' => $row['phase_label'] ?? '', ]); } if ($rows !== []) { $placeholders = implode(', ', array_fill(0, count($rows), '?')); $delete = $this->db->prepare( "DELETE FROM student_requirement_snapshots WHERE student_id = ? AND requirement_key NOT IN ($placeholders) AND prior_completed_units = 0 AND NOT EXISTS ( SELECT 1 FROM appointment_units au JOIN appointments a ON a.id = au.appointment_id WHERE a.student_id = student_requirement_snapshots.student_id AND au.requirement_key = student_requirement_snapshots.requirement_key )" ); $delete->execute(array_merge([$studentId], array_keys($rows))); } } }