2605 lines
103 KiB
PHP
2605 lines
103 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Libraries\FinancialStatus;
|
||
use App\Libraries\InvoiceLedgerService;
|
||
use App\Support\Enrollment\DeliberationDecision;
|
||
use App\Support\Enrollment\EnrollmentEligibility;
|
||
use CodeIgniter\Database\BaseConnection;
|
||
use DateTimeImmutable;
|
||
use DateTimeInterface;
|
||
use RuntimeException;
|
||
|
||
final class EnrollmentTransitionService
|
||
{
|
||
private const DECISION_ELIGIBLE = 'ELIGIBLE';
|
||
private const DECISION_ELIGIBLE_WITH_WARNING = 'ELIGIBLE_WITH_WARNING';
|
||
private const DECISION_INELIGIBLE = 'INELIGIBLE';
|
||
private const DECISION_REVIEW_REQUIRED = 'REVIEW_REQUIRED';
|
||
private const DECISION_EXCEPTION_ELIGIBLE = 'EXCEPTION_ELIGIBLE';
|
||
private const DECISION_ALREADY_ENROLLED = 'ALREADY_ENROLLED';
|
||
|
||
/** Admin exceptions may override every eligibility rule except adult-student age. */
|
||
private const NON_OVERRIDABLE_RULE_CODES = [
|
||
'ADULT_STUDENT_PARENT_BLOCKED',
|
||
];
|
||
|
||
/** @var list<string> */
|
||
private const BLOCKING_BALANCE_BEHAVIORS = [
|
||
'submission_blocked_until_payment',
|
||
'admin_approval_required',
|
||
];
|
||
|
||
/** @var array<int, float> */
|
||
private array $resolvedInvoiceBalanceCache = [];
|
||
|
||
private ?InvoiceLedgerService $invoiceLedgerService = null;
|
||
|
||
public function __construct(
|
||
private readonly BaseConnection $db,
|
||
private readonly ?EnrollmentStatusService $statusService = null,
|
||
) {
|
||
}
|
||
|
||
public function evaluateForParent(
|
||
int $parentId,
|
||
int $studentId,
|
||
string $sourceSchoolYear,
|
||
string $targetSchoolYear,
|
||
string $actorRole = 'parent',
|
||
?DateTimeInterface $now = null
|
||
): array {
|
||
$evaluation = $this->evaluate($studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole, $now);
|
||
$this->ensureDecisionFields($evaluation);
|
||
|
||
$student = $this->student($studentId);
|
||
if ($parentId <= 0 || $student === null || (int) ($student['parent_id'] ?? 0) !== $parentId) {
|
||
$this->addBlocker($evaluation, 'STUDENT_NOT_LINKED', 'This student is not linked to the signed-in parent account.');
|
||
}
|
||
|
||
if ($this->schoolYearByName($targetSchoolYear) === null) {
|
||
$this->addBlocker($evaluation, 'TARGET_YEAR_NOT_FOUND', 'Target school year configuration was not found.');
|
||
}
|
||
|
||
if (
|
||
empty($evaluation['first_enrollment'])
|
||
&& ($sourceSchoolYear === '' || $this->sourceAssignment($studentId, $sourceSchoolYear) === null)
|
||
) {
|
||
$this->addBlocker($evaluation, 'SOURCE_YEAR_NOT_FOUND', 'Student does not belong to the closing school year.');
|
||
}
|
||
|
||
$existing = $this->controllingEnrollment($studentId, $targetSchoolYear);
|
||
if ($this->activeEnrollmentBlocksDuplicate($existing)) {
|
||
$this->addBlocker($evaluation, 'ALREADY_ENROLLED', EnrollmentEligibility::ALREADY_ENROLLED_MESSAGE);
|
||
} elseif ($this->deniedOrWithdrawnEnrollmentBlocksStandardEligibility($existing)) {
|
||
$code = strtolower((string) ($existing['admission_status'] ?? '')) === 'denied'
|
||
|| strtolower((string) ($existing['enrollment_status'] ?? '')) === 'denied'
|
||
? 'DENIED'
|
||
: 'WITHDRAWN';
|
||
$this->addBlocker($evaluation, $code, 'Student has a target-year enrollment status that requires administration review.');
|
||
}
|
||
|
||
$this->applyHouseholdLastNameRule($evaluation, $parentId, $sourceSchoolYear);
|
||
$this->applyFinancialRule($evaluation, $parentId, $sourceSchoolYear, $targetSchoolYear);
|
||
$this->applyCarriedForwardLastNameException($evaluation, $parentId, $studentId, $sourceSchoolYear, $targetSchoolYear);
|
||
$this->deriveAcademicRuleCodes($evaluation);
|
||
$this->applyScopedException($evaluation, $parentId, $studentId, $sourceSchoolYear, $targetSchoolYear);
|
||
$this->finalizeParentDecision($evaluation);
|
||
$this->assignPrimaryBlockerFields($evaluation);
|
||
$this->appendFlagsFromRuleCodes($evaluation);
|
||
|
||
return $evaluation;
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
public function statusesRequiringEnrollmentEligibility(): array
|
||
{
|
||
return [
|
||
'admission under review',
|
||
'payment pending',
|
||
'enrolled',
|
||
'waitlist',
|
||
];
|
||
}
|
||
|
||
public function evaluateEnrollmentAdvance(
|
||
int $parentId,
|
||
int $studentId,
|
||
string $targetSchoolYear,
|
||
string $actorRole = 'admin'
|
||
): array {
|
||
$sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||
if ($sourceSchoolYear === null) {
|
||
return [
|
||
'student_id' => $studentId,
|
||
'target_school_year' => $targetSchoolYear,
|
||
'can_enroll' => false,
|
||
'primary_block_reason' => 'SOURCE_YEAR_NOT_FOUND',
|
||
'primary_parent_message' => 'Source school year could not be resolved for eligibility.',
|
||
'blocking_rule_codes' => ['SOURCE_YEAR_NOT_FOUND'],
|
||
];
|
||
}
|
||
|
||
return $this->evaluateForParent($parentId, $studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole);
|
||
}
|
||
|
||
/**
|
||
* Admin operations (class assignment, status advance) may proceed when the student
|
||
* is already enrolled for the target year, or when an admin exception authorizes them.
|
||
* Adult-student blocking remains non-overridable.
|
||
*/
|
||
public function adminMayAdvanceEnrollment(array $evaluation): bool
|
||
{
|
||
if (in_array('ADULT_STUDENT_PARENT_BLOCKED', array_map('strval', $evaluation['blocking_rule_codes'] ?? []), true)) {
|
||
return false;
|
||
}
|
||
|
||
if (($evaluation['can_enroll'] ?? false) === true) {
|
||
return true;
|
||
}
|
||
|
||
$decision = (string) ($evaluation['decision'] ?? '');
|
||
if ($decision === self::DECISION_ALREADY_ENROLLED || $decision === self::DECISION_EXCEPTION_ELIGIBLE) {
|
||
return true;
|
||
}
|
||
|
||
return ($evaluation['admin_exception'] ?? null) !== null;
|
||
}
|
||
|
||
public function logEnrollmentBlock(
|
||
array $evaluation,
|
||
string $context,
|
||
?int $parentId = null,
|
||
?int $performedBy = null
|
||
): void {
|
||
$payload = [
|
||
'context' => $context,
|
||
'student_id' => (int) ($evaluation['student_id'] ?? 0),
|
||
'parent_id' => $parentId ?? ($evaluation['parent_id'] ?? null),
|
||
'school_year' => (string) ($evaluation['target_school_year'] ?? ''),
|
||
'enrollment_status' => $evaluation['enrollment_status'] ?? null,
|
||
'academic_decision' => $evaluation['deliberation_decision'] ?? null,
|
||
'age' => $evaluation['age_on_reference_date'] ?? null,
|
||
'adult_student' => (bool) ($evaluation['adult_student'] ?? false),
|
||
'blocking_rules' => array_values(array_map('strval', $evaluation['blocking_rule_codes'] ?? [])),
|
||
'primary_block_reason' => $evaluation['primary_block_reason'] ?? null,
|
||
'exception_ids' => isset($evaluation['admin_exception']['id']) ? [(int) $evaluation['admin_exception']['id']] : [],
|
||
'financial_summary' => $evaluation['financial_summary'] ?? null,
|
||
'timestamp' => date('Y-m-d H:i:s'),
|
||
];
|
||
|
||
log_message('warning', 'Enrollment blocked [{context}] student={student_id} parent={parent_id} reason={primary_block_reason}', [
|
||
'context' => $context,
|
||
'student_id' => $payload['student_id'],
|
||
'parent_id' => $payload['parent_id'],
|
||
'primary_block_reason' => $payload['primary_block_reason'] ?? 'unknown',
|
||
]);
|
||
|
||
if ($payload['student_id'] <= 0 || ! $this->db->tableExists('enrollment_transition_audits')) {
|
||
return;
|
||
}
|
||
|
||
$this->db->table('enrollment_transition_audits')->insert([
|
||
'student_id' => $payload['student_id'],
|
||
'school_year' => $payload['school_year'] !== '' ? $payload['school_year'] : date('Y'),
|
||
'source_school_year' => $evaluation['source_school_year'] ?? null,
|
||
'action' => 'enrollment_blocked',
|
||
'performed_by' => $performedBy,
|
||
'original_values_json' => null,
|
||
'new_values_json' => json_encode($payload, JSON_UNESCAPED_SLASHES),
|
||
'reason' => $context,
|
||
'created_at' => date('Y-m-d H:i:s'),
|
||
]);
|
||
}
|
||
|
||
public function markExceptionUsed(int $exceptionId, int $enrollmentId): void
|
||
{
|
||
if ($exceptionId <= 0 || $enrollmentId <= 0 || ! $this->db->tableExists('enrollment_exceptions')) {
|
||
return;
|
||
}
|
||
|
||
$this->db->table('enrollment_exceptions')
|
||
->where('id', $exceptionId)
|
||
->where('status', 'active')
|
||
->update([
|
||
'status' => 'used',
|
||
'used_at' => date('Y-m-d H:i:s'),
|
||
'enrollment_id' => $enrollmentId,
|
||
'updated_at' => date('Y-m-d H:i:s'),
|
||
]);
|
||
}
|
||
|
||
public function auditEnrollmentDecision(
|
||
int $studentId,
|
||
string $targetSchoolYear,
|
||
?string $sourceSchoolYear,
|
||
string $action,
|
||
?int $performedBy,
|
||
?array $original,
|
||
array $new,
|
||
string $reason
|
||
): void {
|
||
if (! $this->db->tableExists('enrollment_transition_audits')) {
|
||
return;
|
||
}
|
||
|
||
$this->db->table('enrollment_transition_audits')->insert([
|
||
'student_id' => $studentId,
|
||
'school_year' => $targetSchoolYear,
|
||
'source_school_year' => $sourceSchoolYear,
|
||
'action' => $action,
|
||
'performed_by' => $performedBy,
|
||
'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null,
|
||
'new_values_json' => json_encode($new, JSON_UNESCAPED_SLASHES),
|
||
'reason' => $reason,
|
||
'created_at' => date('Y-m-d H:i:s'),
|
||
]);
|
||
}
|
||
|
||
public function evaluate(
|
||
int $studentId,
|
||
string $sourceSchoolYear,
|
||
string $targetSchoolYear,
|
||
string $actorRole = 'parent',
|
||
?DateTimeInterface $now = null
|
||
): array {
|
||
$now = $now !== null ? DateTimeImmutable::createFromInterface($now) : new DateTimeImmutable('now');
|
||
$targetYear = $this->schoolYearByName($targetSchoolYear);
|
||
$sourceAssignment = $this->sourceAssignment($studentId, $sourceSchoolYear);
|
||
$student = $this->student($studentId);
|
||
$decisionRow = $this->decisionRow($studentId, $sourceSchoolYear);
|
||
$decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null)
|
||
?? DeliberationDecision::normalize($decisionRow['decision'] ?? null);
|
||
$rawDecision = trim((string) ($decisionRow['deliberation_decision_standard'] ?? $decisionRow['decision'] ?? ''));
|
||
|
||
$result = [
|
||
'student_id' => $studentId,
|
||
'source_school_year' => $sourceSchoolYear,
|
||
'target_school_year' => $targetSchoolYear,
|
||
'deliberation_decision' => $decision,
|
||
'decision_label' => DeliberationDecision::display($decisionRow['decision'] ?? ''),
|
||
'source_grade_id' => $sourceAssignment['class_id'] ?? null,
|
||
'source_grade_name' => $sourceAssignment['class_name'] ?? null,
|
||
'source_class_section_id' => $sourceAssignment['class_section_id'] ?? null,
|
||
'source_class_section_name' => $sourceAssignment['class_section_name'] ?? null,
|
||
'assigned_grade_id' => null,
|
||
'assigned_class_section_id' => null,
|
||
'placement_status' => 'not_created',
|
||
'academic_eligible' => false,
|
||
'parent_enrollment_allowed' => false,
|
||
'student_self_enrollment_allowed' => false,
|
||
'administrative_enrollment_allowed' => true,
|
||
'age_reference_date' => $this->ageReferenceDate($targetSchoolYear),
|
||
'age_on_reference_date' => null,
|
||
'adult_student' => false,
|
||
'registration_window_status' => 'unknown',
|
||
'blockers' => [],
|
||
'warnings' => [],
|
||
'flags' => [],
|
||
];
|
||
|
||
if ($student === null) {
|
||
$result['blockers'][] = 'Student record was not found.';
|
||
return $result;
|
||
}
|
||
|
||
$sourceEnrollment = $this->db->tableExists('enrollments')
|
||
? $this->controllingEnrollment($studentId, $sourceSchoolYear)
|
||
: null;
|
||
if (is_array($sourceEnrollment)) {
|
||
$student['enrollment_status'] = $sourceEnrollment['enrollment_status'] ?? ($student['enrollment_status'] ?? null);
|
||
$student['is_withdrawn'] = $sourceEnrollment['is_withdrawn'] ?? ($student['is_withdrawn'] ?? 0);
|
||
}
|
||
|
||
$age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear);
|
||
$result['age_on_reference_date'] = $age;
|
||
$result['adult_student'] = $age !== null && $age >= EnrollmentEligibility::ADULT_STUDENT_MIN_AGE;
|
||
|
||
if ($decision === DeliberationDecision::EXPELLED) {
|
||
$result['blockers'][] = EnrollmentEligibility::EXPELLED_MESSAGE;
|
||
$result['flags'][] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high', ['rule_code' => 'EXPELLED']);
|
||
return $result;
|
||
}
|
||
|
||
if ($result['adult_student']) {
|
||
$this->applyAdultStudentBlock($result, $student, $age);
|
||
return $result;
|
||
}
|
||
|
||
if ($decision === DeliberationDecision::WITHDRAWN || EnrollmentEligibility::isMarkedWithdrawn($student)) {
|
||
$result['blockers'][] = EnrollmentEligibility::WITHDRAWN_MESSAGE;
|
||
$result['flags'][] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal', ['rule_code' => 'WITHDRAWN']);
|
||
return $result;
|
||
}
|
||
|
||
if ($sourceAssignment === null) {
|
||
if ($this->isFirstEnrollmentStudent($studentId, $student, $targetSchoolYear)) {
|
||
$result = array_replace($result, $this->firstEnrollmentPlacement($student, $targetSchoolYear));
|
||
$result['first_enrollment'] = true;
|
||
$result['decision_label'] = 'New student registration';
|
||
$result['academic_eligible'] = true;
|
||
$result['parent_enrollment_allowed'] = true;
|
||
$result['student_self_enrollment_allowed'] = false;
|
||
|
||
$this->applyRegistrationWindow($result, $targetYear, $now, $actorRole);
|
||
$this->applyAgeRules($result, $targetSchoolYear, $age);
|
||
|
||
if ($result['blockers'] !== [] && $actorRole === 'parent') {
|
||
$result['parent_enrollment_allowed'] = false;
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
$result['blockers'][] = 'Student does not belong to the closing school year.';
|
||
return $result;
|
||
}
|
||
|
||
if ($decision === DeliberationDecision::DEFERRED_DECISION) {
|
||
$result['blockers'][] = EnrollmentEligibility::DEFERRED_MESSAGE;
|
||
$result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high', ['rule_code' => 'DEFERRED_DECISION']);
|
||
return $result;
|
||
}
|
||
|
||
if (($decisionRow === null || $decision === null) && $sourceAssignment !== null) {
|
||
$kgDecision = $this->decisionForKgWithoutFinalDecision($sourceAssignment, $age, $rawDecision);
|
||
if ($kgDecision !== null) {
|
||
$decision = $kgDecision;
|
||
$result['deliberation_decision'] = $decision;
|
||
$result['decision_label'] = 'KG age placement';
|
||
$result['kg_missing_decision_eligible'] = true;
|
||
$result['warnings'][] = EnrollmentEligibility::KG_MISSING_DECISION_ELIGIBLE_MESSAGE;
|
||
}
|
||
}
|
||
|
||
if ($decision === null) {
|
||
$result['blockers'][] = EnrollmentEligibility::MISSING_DECISION_MESSAGE;
|
||
$result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high', [
|
||
'rule_code' => $decisionRow === null ? 'NO_FINAL_DECISION' : 'UNRECOGNIZED_DECISION',
|
||
'reason' => 'Missing or unrecognized final deliberation decision.',
|
||
]);
|
||
return $result;
|
||
}
|
||
|
||
$placement = $this->placement($decision, $sourceAssignment, $targetSchoolYear);
|
||
$result = array_replace($result, $placement);
|
||
$result['academic_eligible'] = $placement['placement_status'] !== 'exit_required';
|
||
|
||
if ($placement['placement_status'] === 'exit_required') {
|
||
$result['blockers'][] = 'The student has passed the highest available grade and must follow the school completion or exit process.';
|
||
$result['flags'][] = $this->flag('COMPLETION_OR_EXIT_PROCESS_REQUIRED', 'normal', [
|
||
'source_class' => $sourceAssignment['class_section_name'] ?? null,
|
||
]);
|
||
}
|
||
|
||
$result['parent_enrollment_allowed'] = $result['academic_eligible'];
|
||
$result['student_self_enrollment_allowed'] = false;
|
||
|
||
$this->applyRegistrationWindow($result, $targetYear, $now, $actorRole);
|
||
$this->applyAgeRules($result, $targetSchoolYear, $age);
|
||
|
||
if ($result['blockers'] === [] && $result['academic_eligible']) {
|
||
if ($actorRole === 'parent') {
|
||
$result['parent_enrollment_allowed'] = $result['parent_enrollment_allowed'] && ! $result['adult_student'];
|
||
}
|
||
} elseif ($actorRole === 'parent') {
|
||
$result['parent_enrollment_allowed'] = false;
|
||
} elseif ($actorRole === 'student') {
|
||
$result['student_self_enrollment_allowed'] = false;
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
public function applyInitialTransition(
|
||
int $studentId,
|
||
string $sourceSchoolYear,
|
||
string $targetSchoolYear,
|
||
?int $parentId,
|
||
?int $performedBy = null,
|
||
string $actorRole = 'admin'
|
||
): array {
|
||
$evaluation = $this->evaluate($studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole);
|
||
$student = $this->student($studentId);
|
||
$parentId = $parentId ?? (is_numeric($student['parent_id'] ?? null) ? (int) $student['parent_id'] : 0);
|
||
if ($parentId > 0) {
|
||
$evaluation = $this->evaluateForParent($parentId, $studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole);
|
||
}
|
||
|
||
$this->ensureDecisionFields($evaluation);
|
||
$this->assignPrimaryBlockerFields($evaluation);
|
||
|
||
if (($evaluation['can_enroll'] ?? false) !== true) {
|
||
$this->writeFlags($evaluation, $performedBy);
|
||
$this->logEnrollmentBlock($evaluation, 'apply_initial_transition', $parentId > 0 ? $parentId : null, $performedBy);
|
||
$this->audit($evaluation, 'transition_evaluation_blocked', $performedBy, null, $evaluation);
|
||
|
||
return $evaluation;
|
||
}
|
||
|
||
$this->db->transStart();
|
||
$original = $this->controllingEnrollment($studentId, $targetSchoolYear);
|
||
$parentId = $parentId ?? (is_numeric($student['parent_id'] ?? null) ? (int) $student['parent_id'] : null);
|
||
|
||
$payload = [
|
||
'student_id' => $studentId,
|
||
'parent_id' => $parentId,
|
||
'school_year' => $targetSchoolYear,
|
||
'source_school_year' => $sourceSchoolYear,
|
||
'deliberation_decision' => $evaluation['deliberation_decision'],
|
||
'source_grade_id' => $evaluation['source_grade_id'],
|
||
'assigned_grade_id' => $evaluation['assigned_grade_id'],
|
||
'source_class_section_id' => $evaluation['source_class_section_id'],
|
||
'assigned_class_section_id' => $evaluation['assigned_class_section_id'],
|
||
'class_section_id' => $evaluation['assigned_class_section_id'],
|
||
'placement_status' => $evaluation['placement_status'],
|
||
'age_reference_date' => $evaluation['age_reference_date'],
|
||
'age_on_reference_date' => $evaluation['age_on_reference_date'],
|
||
'adult_student' => $evaluation['adult_student'] ? 1 : 0,
|
||
'parent_enrollment_allowed' => $evaluation['parent_enrollment_allowed'] ? 1 : 0,
|
||
'student_self_enrollment_allowed' => $evaluation['student_self_enrollment_allowed'] ? 1 : 0,
|
||
'exception_required' => $evaluation['flags'] !== [] ? 1 : 0,
|
||
'exception_reason' => $evaluation['flags'] !== [] ? implode(', ', array_column($evaluation['flags'], 'flag_type')) : null,
|
||
'enrollment_date' => date('Y-m-d'),
|
||
'enrollment_status' => 'admission under review',
|
||
'admission_status' => 'pending',
|
||
'updated_at' => date('Y-m-d H:i:s'),
|
||
];
|
||
|
||
if ($original !== null) {
|
||
$payload['id'] = (int) $original['id'];
|
||
} else {
|
||
$payload['created_at'] = date('Y-m-d H:i:s');
|
||
}
|
||
|
||
(new EnrollmentStatusService($this->db))->upsertStatus(
|
||
$payload,
|
||
$performedBy,
|
||
'initial_transition_applied'
|
||
);
|
||
|
||
service('studentYearStatus')->upsert($studentId, $targetSchoolYear, false);
|
||
|
||
if ((int) ($evaluation['assigned_class_section_id'] ?? 0) > 0) {
|
||
$this->upsertStudentClass($studentId, (int) $evaluation['assigned_class_section_id'], $targetSchoolYear, $performedBy);
|
||
}
|
||
|
||
$this->writeFlags($evaluation, $performedBy);
|
||
$this->audit($evaluation, 'initial_transition_applied', $performedBy, $original, $payload);
|
||
$this->db->transComplete();
|
||
|
||
if ($this->db->transStatus() === false) {
|
||
throw new RuntimeException('Unable to apply enrollment transition.');
|
||
}
|
||
|
||
return $evaluation;
|
||
}
|
||
|
||
private function placement(string $decision, array $sourceAssignment, string $targetSchoolYear): array
|
||
{
|
||
$sourceClassId = (int) ($sourceAssignment['class_id'] ?? 0);
|
||
$sourceClassName = trim((string) ($sourceAssignment['class_name'] ?? $sourceAssignment['class_section_name'] ?? ''));
|
||
$sourceSectionName = trim((string) ($sourceAssignment['class_section_name'] ?? ''));
|
||
|
||
if ($decision === DeliberationDecision::PASSED) {
|
||
$targetClass = $this->nextClass($sourceClassName, $targetSchoolYear);
|
||
return [
|
||
'assigned_grade_id' => $targetClass['id'] ?? null,
|
||
'assigned_grade_name' => $targetClass['class_name'] ?? null,
|
||
'assigned_class_section_id' => null,
|
||
'placement_status' => $targetClass === null ? 'exit_required' : 'automatic_distribution_pending',
|
||
'flags' => [],
|
||
];
|
||
}
|
||
|
||
if ($decision === DeliberationDecision::REPEAT_CLASS) {
|
||
$sourceSectionId = (int) ($sourceAssignment['class_section_id'] ?? 0);
|
||
$targetSection = $this->matchingSection($sourceSectionName, $sourceClassId, $targetSchoolYear);
|
||
if ($targetSection === null && $sourceSectionId > 0) {
|
||
$derivedSectionId = $this->repeatClassBaseSectionId($sourceSectionId);
|
||
if ($derivedSectionId > 0) {
|
||
$targetSection = $this->sectionByClassSectionId($derivedSectionId, $targetSchoolYear);
|
||
}
|
||
}
|
||
|
||
return [
|
||
'assigned_grade_id' => $targetSection['class_id'] ?? $this->sameClassInTargetYear($sourceClassName, $targetSchoolYear)['id'] ?? ($sourceClassId ?: null),
|
||
'assigned_grade_name' => $this->classNameForId((int) ($targetSection['class_id'] ?? 0)) ?? $sourceClassName,
|
||
'assigned_class_section_id' => $targetSection['class_section_id'] ?? null,
|
||
'placement_status' => $targetSection === null ? 'manual_class_required' : 'same_class_assigned',
|
||
'flags' => [],
|
||
];
|
||
}
|
||
|
||
if ($decision === DeliberationDecision::MAKE_UP_EXAM) {
|
||
$targetSection = $this->matchingSection($sourceSectionName, $sourceClassId, $targetSchoolYear);
|
||
return [
|
||
'assigned_grade_id' => $targetSection['class_id'] ?? $this->sameClassInTargetYear($sourceClassName, $targetSchoolYear)['id'] ?? ($sourceClassId ?: null),
|
||
'assigned_grade_name' => $this->classNameForId((int) ($targetSection['class_id'] ?? 0)) ?? $sourceClassName,
|
||
'assigned_class_section_id' => $targetSection['class_section_id'] ?? null,
|
||
'placement_status' => $targetSection === null ? 'temporary_manual_class_required' : 'temporary_same_grade',
|
||
'flags' => [
|
||
$this->flag('PENDING_MAKE_UP_EXAM_PROMOTION', 'high', [
|
||
'current_grade_id' => $sourceClassId ?: null,
|
||
'expected_promoted_grade_id' => $this->nextClass($sourceClassName, $targetSchoolYear)['id'] ?? null,
|
||
'current_class_section_id' => $targetSection['class_section_id'] ?? null,
|
||
]),
|
||
],
|
||
];
|
||
}
|
||
|
||
return ['assigned_grade_id' => null, 'assigned_class_section_id' => null, 'placement_status' => 'not_created', 'flags' => []];
|
||
}
|
||
|
||
private function decisionForKgWithoutFinalDecision(array $sourceAssignment, ?int $age, string $rawDecision): ?string
|
||
{
|
||
if ($rawDecision !== '' || $this->classBaseName((string) ($sourceAssignment['class_name'] ?? $sourceAssignment['class_section_name'] ?? '')) !== 'KG') {
|
||
return null;
|
||
}
|
||
|
||
return $age !== null && $age >= 6
|
||
? DeliberationDecision::PASSED
|
||
: DeliberationDecision::REPEAT_CLASS;
|
||
}
|
||
|
||
private function applyRegistrationWindow(array &$result, ?array $targetYear, DateTimeImmutable $now, string $actorRole): void
|
||
{
|
||
if ($targetYear === null) {
|
||
$result['registration_window_status'] = 'missing_school_year';
|
||
$result['blockers'][] = 'Target school year configuration was not found.';
|
||
return;
|
||
}
|
||
|
||
$opensAt = $this->dateTimeFromYear($targetYear, 'registration_opens_at', 'registration_starts_on', false);
|
||
$deadlineAt = $this->dateTimeFromYear($targetYear, 'registration_deadline_at', 'registration_ends_on', true);
|
||
|
||
if ($opensAt !== null && $now < $opensAt) {
|
||
$result['registration_window_status'] = 'not_open';
|
||
if ($actorRole !== 'admin') {
|
||
$result['blockers'][] = 'Registration for the new school year has not opened yet. Registration will be available starting on ' . $opensAt->format('F j, Y g:i A') . '.';
|
||
}
|
||
return;
|
||
}
|
||
|
||
if ($deadlineAt !== null && $now > $deadlineAt && (int) ($targetYear['late_registration_blocked'] ?? 1) === 1 && $actorRole !== 'admin') {
|
||
$result['registration_window_status'] = 'closed';
|
||
$result['blockers'][] = 'The registration deadline was ' . $deadlineAt->format('F j, Y g:i A') . '. Online registration is no longer available. Please contact the school administration if you believe an exception applies.';
|
||
$result['flags'][] = $this->flag('LATE_REGISTRATION_EXCEPTION', 'normal');
|
||
return;
|
||
}
|
||
|
||
$result['registration_window_status'] = 'open';
|
||
}
|
||
|
||
private function applyAgeRules(array &$result, string $targetSchoolYear, ?int $age): void
|
||
{
|
||
if ($age === null || ! $this->db->tableExists('enrollment_age_rules')) {
|
||
return;
|
||
}
|
||
|
||
$builder = $this->db->table('enrollment_age_rules')
|
||
->where('school_year', $targetSchoolYear)
|
||
->groupStart()
|
||
->where('grade_class_id', null)
|
||
->orWhere('grade_class_id', $result['assigned_grade_id'])
|
||
->groupEnd();
|
||
|
||
foreach ($builder->get()->getResultArray() as $rule) {
|
||
$min = is_numeric($rule['minimum_age'] ?? null) ? (int) $rule['minimum_age'] : null;
|
||
$max = is_numeric($rule['maximum_age'] ?? null) ? (int) $rule['maximum_age'] : null;
|
||
$violated = ($min !== null && $age < $min) || ($max !== null && $age > $max);
|
||
if (! $violated) {
|
||
continue;
|
||
}
|
||
|
||
$message = 'Student age does not satisfy a configured age rule for the target placement.';
|
||
if (($rule['behavior'] ?? 'blocking') === 'warning') {
|
||
$result['warnings'][] = $message;
|
||
} else {
|
||
$result['blockers'][] = $message;
|
||
$result['flags'][] = $this->flag('AGE_EXCEPTION_REQUIRED', 'normal', [
|
||
'age_rule_id' => (int) $rule['id'],
|
||
'age_on_reference_date' => $age,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
private function sourceAssignment(int $studentId, string $sourceSchoolYear): ?array
|
||
{
|
||
if (! $this->db->tableExists('student_class')) {
|
||
return $this->sourceEnrollmentAssignment($studentId, $sourceSchoolYear);
|
||
}
|
||
|
||
$assignment = $this->db->table('student_class sc')
|
||
->select('sc.class_section_id, cs.class_section_name, cs.class_id, c.class_name')
|
||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
||
->join('classes c', 'c.id = cs.class_id', 'left')
|
||
->where('sc.student_id', $studentId)
|
||
->where('sc.school_year', $sourceSchoolYear)
|
||
->where('sc.class_section_id IS NOT NULL', null, false)
|
||
->orderBy('sc.updated_at', 'DESC')
|
||
->orderBy('sc.id', 'DESC')
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
return $assignment ?: $this->sourceEnrollmentAssignment($studentId, $sourceSchoolYear);
|
||
}
|
||
|
||
private function sourceEnrollmentAssignment(int $studentId, string $sourceSchoolYear): ?array
|
||
{
|
||
if (! $this->db->tableExists('enrollments')) {
|
||
return null;
|
||
}
|
||
|
||
return $this->db->table('enrollments e')
|
||
->select('e.class_section_id, cs.class_section_name, cs.class_id, c.class_name')
|
||
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
|
||
->join('classes c', 'c.id = cs.class_id', 'left')
|
||
->where('e.student_id', $studentId)
|
||
->where('e.school_year', $sourceSchoolYear)
|
||
->where('e.class_section_id IS NOT NULL', null, false)
|
||
->orderBy('e.updated_at', 'DESC')
|
||
->orderBy('e.id', 'DESC')
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray() ?: null;
|
||
}
|
||
|
||
private function decisionRow(int $studentId, string $sourceSchoolYear): ?array
|
||
{
|
||
if (! $this->db->tableExists('student_decisions')) {
|
||
return null;
|
||
}
|
||
|
||
$select = ['decision', 'source', 'notes', 'class_section_name'];
|
||
if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) {
|
||
$select[] = 'deliberation_decision_standard';
|
||
}
|
||
|
||
return $this->db->table('student_decisions')
|
||
->select($select)
|
||
->where('student_id', $studentId)
|
||
->where('school_year', $sourceSchoolYear)
|
||
->orderBy('updated_at', 'DESC')
|
||
->orderBy('id', 'DESC')
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray() ?: null;
|
||
}
|
||
|
||
private function student(int $studentId): ?array
|
||
{
|
||
$select = ['id', 'firstname', 'lastname', 'dob', 'parent_id', 'registration_grade'];
|
||
if ($this->db->fieldExists('school_year', 'students')) {
|
||
$select[] = 'school_year';
|
||
}
|
||
|
||
return $this->db->table('students')
|
||
->select(implode(', ', $select))
|
||
->where('id', $studentId)
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray() ?: null;
|
||
}
|
||
|
||
private function isFirstEnrollmentStudent(int $studentId, array $student, string $targetSchoolYear): bool
|
||
{
|
||
if ($studentId <= 0 || $targetSchoolYear === '') {
|
||
return false;
|
||
}
|
||
|
||
if ($this->db->tableExists('student_year_status')) {
|
||
$row = $this->db->table('student_year_status')
|
||
->select('is_new')
|
||
->where('student_id', $studentId)
|
||
->where('school_year', $targetSchoolYear)
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
if (is_array($row)) {
|
||
return (int) ($row['is_new'] ?? 1) === 1;
|
||
}
|
||
}
|
||
|
||
if ($this->db->fieldExists('school_year', 'students')) {
|
||
return trim((string) ($student['school_year'] ?? '')) === $targetSchoolYear;
|
||
}
|
||
|
||
return ! $this->studentHasPriorSchoolHistory($studentId, $targetSchoolYear);
|
||
}
|
||
|
||
private function studentHasPriorSchoolHistory(int $studentId, string $targetSchoolYear): bool
|
||
{
|
||
if ($studentId <= 0) {
|
||
return false;
|
||
}
|
||
|
||
foreach (['student_class', 'enrollments', 'student_decisions'] as $table) {
|
||
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
|
||
continue;
|
||
}
|
||
|
||
$count = $this->db->table($table)
|
||
->where('student_id', $studentId)
|
||
->where('school_year !=', $targetSchoolYear)
|
||
->countAllResults();
|
||
|
||
if ($count > 0) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function schoolYearByName(string $schoolYear): ?array
|
||
{
|
||
if (! $this->db->tableExists('school_years')) {
|
||
return null;
|
||
}
|
||
|
||
return $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray() ?: null;
|
||
}
|
||
|
||
private function nextClass(string $sourceClassName, string $targetSchoolYear): ?array
|
||
{
|
||
$base = $this->classBaseName($sourceClassName);
|
||
$target = match (true) {
|
||
$base === 'KG' || str_contains($base, 'KINDERGARTEN') => '1',
|
||
ctype_digit($base) => (string) ((int) $base + 1),
|
||
$base === 'YOUTH' => 'YOUTH',
|
||
default => '',
|
||
};
|
||
|
||
if ($target === '') {
|
||
return null;
|
||
}
|
||
|
||
$targetClass = $this->classByName($target, $targetSchoolYear);
|
||
if ($targetClass !== null) {
|
||
return $targetClass;
|
||
}
|
||
|
||
return ctype_digit($base) && (int) $base >= 9
|
||
? $this->classByName('YOUTH', $targetSchoolYear)
|
||
: null;
|
||
}
|
||
|
||
private function sameClassInTargetYear(string $sourceClassName, string $targetSchoolYear): ?array
|
||
{
|
||
$base = $this->classBaseName($sourceClassName);
|
||
if ($base === '') {
|
||
return null;
|
||
}
|
||
|
||
if (str_contains($base, 'KINDERGARTEN')) {
|
||
$base = 'KG';
|
||
}
|
||
|
||
return $this->classByName($base, $targetSchoolYear);
|
||
}
|
||
|
||
private function classBaseName(string $className): string
|
||
{
|
||
$base = strtoupper(trim((string) preg_replace('/-.+$/', '', $className)));
|
||
$base = preg_replace('/\b(CLASS|GRADE)\b/i', '', $base) ?? $base;
|
||
$base = trim(preg_replace('/\s+/', ' ', $base) ?? $base);
|
||
|
||
if (str_contains($base, 'KINDERGARTEN')) {
|
||
return 'KG';
|
||
}
|
||
|
||
if (preg_match('/\d+/', $base, $matches) === 1) {
|
||
return (string) (int) $matches[0];
|
||
}
|
||
|
||
return $base;
|
||
}
|
||
|
||
private function classByName(string $className, string $schoolYear): ?array
|
||
{
|
||
$builder = $this->db->table('classes')->where('UPPER(class_name)', strtoupper($className));
|
||
if ($this->db->fieldExists('school_year', 'classes')) {
|
||
$builder->where('school_year', $schoolYear);
|
||
}
|
||
|
||
$row = $builder->orderBy('id', 'DESC')->limit(1)->get()->getRowArray();
|
||
if ($row !== null || ! $this->db->fieldExists('school_year', 'classes')) {
|
||
return $row ?: null;
|
||
}
|
||
|
||
return $this->db->table('classes')
|
||
->where('UPPER(class_name)', strtoupper($className))
|
||
->orderBy('id', 'DESC')
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray() ?: null;
|
||
}
|
||
|
||
private function firstEnrollmentPlacement(array $student, string $targetSchoolYear): array
|
||
{
|
||
$grade = $this->classBaseName((string) ($student['registration_grade'] ?? ''));
|
||
|
||
return [
|
||
'assigned_grade_id' => null,
|
||
'assigned_grade_name' => $grade !== '' ? $grade : null,
|
||
'assigned_class_section_id' => null,
|
||
'placement_status' => 'manual_class_required',
|
||
'flags' => [],
|
||
];
|
||
}
|
||
|
||
private function baseSectionForClass(int $classId, string $targetSchoolYear): ?array
|
||
{
|
||
if ($classId <= 0 || ! $this->db->tableExists('classSection')) {
|
||
return null;
|
||
}
|
||
|
||
$builder = $this->db->table('classSection')
|
||
->select('class_section_id, class_id, class_section_name')
|
||
->where('class_id', $classId)
|
||
->where("class_section_name NOT LIKE '%-%'", null, false)
|
||
->orderBy('id', 'ASC');
|
||
|
||
if ($this->db->fieldExists('school_year', 'classSection')) {
|
||
$builder->where('school_year', $targetSchoolYear);
|
||
}
|
||
|
||
return $builder->limit(1)->get()->getRowArray() ?: null;
|
||
}
|
||
|
||
private function classNameForId(int $classId): ?string
|
||
{
|
||
if ($classId <= 0 || ! $this->db->tableExists('classes')) {
|
||
return null;
|
||
}
|
||
|
||
$row = $this->db->table('classes')
|
||
->select('class_name')
|
||
->where('id', $classId)
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
$name = trim((string) ($row['class_name'] ?? ''));
|
||
|
||
return $name !== '' ? $name : null;
|
||
}
|
||
|
||
private function matchingSection(string $sourceSectionName, int $sourceClassId, string $targetSchoolYear): ?array
|
||
{
|
||
$builder = $this->db->table('classSection')->select('class_section_id, class_id, class_section_name')->orderBy('id', 'DESC');
|
||
if ($sourceSectionName !== '') {
|
||
$builder->where('class_section_name', $sourceSectionName);
|
||
} else {
|
||
$builder->where('class_id', $sourceClassId)->where("class_section_name NOT LIKE '%-%'", null, false);
|
||
}
|
||
|
||
if ($this->db->fieldExists('school_year', 'classSection')) {
|
||
$builder->where('school_year', $targetSchoolYear);
|
||
}
|
||
|
||
return $builder->limit(1)->get()->getRowArray() ?: null;
|
||
}
|
||
|
||
/**
|
||
* Repeat-class students keep the same grade. Section codes use the units digit for
|
||
* lettered sections (e.g. 41 = 4-A, 40 = grade 4 base). Zeroing that digit yields the
|
||
* base grade section to assign before redistribution.
|
||
*/
|
||
private function repeatClassBaseSectionId(int $sourceClassSectionId): int
|
||
{
|
||
if ($sourceClassSectionId <= 0) {
|
||
return 0;
|
||
}
|
||
|
||
if ($sourceClassSectionId < 10) {
|
||
return $sourceClassSectionId;
|
||
}
|
||
|
||
return intdiv($sourceClassSectionId, 10) * 10;
|
||
}
|
||
|
||
private function sectionByClassSectionId(int $classSectionId, string $targetSchoolYear): ?array
|
||
{
|
||
if ($classSectionId <= 0) {
|
||
return null;
|
||
}
|
||
|
||
$builder = $this->db->table('classSection')
|
||
->select('class_section_id, class_id, class_section_name')
|
||
->where('class_section_id', $classSectionId)
|
||
->orderBy('id', 'DESC');
|
||
|
||
if ($this->db->fieldExists('school_year', 'classSection')) {
|
||
$builder->where('school_year', $targetSchoolYear);
|
||
}
|
||
|
||
return $builder->limit(1)->get()->getRowArray() ?: null;
|
||
}
|
||
|
||
private function syncRepeatClassReassignmentFlags(
|
||
string $targetSchoolYear,
|
||
string $sourceSchoolYear,
|
||
?int $performedBy
|
||
): int {
|
||
if (! $this->db->tableExists('enrollment_flags')) {
|
||
return 0;
|
||
}
|
||
|
||
$openFlags = $this->db->table('enrollment_flags')
|
||
->where('school_year', $targetSchoolYear)
|
||
->where('flag_type', 'CLASS_REASSIGNMENT_REQUIRED')
|
||
->where('status', 'open')
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$resolved = 0;
|
||
foreach ($openFlags as $flagRow) {
|
||
$studentId = (int) ($flagRow['student_id'] ?? 0);
|
||
if ($studentId <= 0) {
|
||
continue;
|
||
}
|
||
|
||
$sourceAssignment = $this->sourceAssignment($studentId, $sourceSchoolYear);
|
||
$sourceSectionId = (int) ($sourceAssignment['class_section_id'] ?? 0);
|
||
if ($sourceSectionId <= 0) {
|
||
continue;
|
||
}
|
||
|
||
$derivedSectionId = $this->repeatClassBaseSectionId($sourceSectionId);
|
||
$targetSection = $derivedSectionId > 0
|
||
? $this->sectionByClassSectionId($derivedSectionId, $targetSchoolYear)
|
||
: null;
|
||
if ($targetSection === null) {
|
||
continue;
|
||
}
|
||
|
||
$targetSectionId = (int) ($targetSection['class_section_id'] ?? 0);
|
||
if ($targetSectionId <= 0) {
|
||
continue;
|
||
}
|
||
|
||
$this->applyRepeatClassSectionAssignment($studentId, $targetSchoolYear, $targetSection, $performedBy);
|
||
|
||
$this->db->table('enrollment_flags')
|
||
->where('id', (int) ($flagRow['id'] ?? 0))
|
||
->update([
|
||
'status' => 'resolved',
|
||
'resolved_at' => date('Y-m-d H:i:s'),
|
||
'resolution_notes' => 'Automatically assigned to base grade section ' . $targetSectionId
|
||
. ' derived from previous year section ' . $sourceSectionId . '.',
|
||
]);
|
||
|
||
$resolved++;
|
||
}
|
||
|
||
return $resolved;
|
||
}
|
||
|
||
private function applyRepeatClassSectionAssignment(
|
||
int $studentId,
|
||
string $targetSchoolYear,
|
||
array $targetSection,
|
||
?int $performedBy
|
||
): void {
|
||
$sectionId = (int) ($targetSection['class_section_id'] ?? 0);
|
||
if ($sectionId <= 0) {
|
||
return;
|
||
}
|
||
|
||
$this->upsertStudentClass($studentId, $sectionId, $targetSchoolYear, $performedBy);
|
||
|
||
if (! $this->db->tableExists('enrollments')) {
|
||
return;
|
||
}
|
||
|
||
$enrollment = $this->db->table('enrollments')
|
||
->where('student_id', $studentId)
|
||
->where('school_year', $targetSchoolYear)
|
||
->orderBy('updated_at', 'DESC')
|
||
->orderBy('id', 'DESC')
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
if ($enrollment === null) {
|
||
return;
|
||
}
|
||
|
||
$this->db->table('enrollments')
|
||
->where('id', (int) ($enrollment['id'] ?? 0))
|
||
->update([
|
||
'class_section_id' => $sectionId,
|
||
'assigned_class_section_id' => $sectionId,
|
||
'assigned_grade_id' => (int) ($targetSection['class_id'] ?? 0) ?: null,
|
||
'placement_status' => 'same_class_assigned',
|
||
'updated_at' => date('Y-m-d H:i:s'),
|
||
]);
|
||
}
|
||
|
||
private function assignPrimaryBlockerFields(array &$evaluation): void
|
||
{
|
||
if (($evaluation['can_enroll'] ?? false) === true) {
|
||
$evaluation['primary_block_reason'] = null;
|
||
$evaluation['primary_parent_message'] = null;
|
||
|
||
return;
|
||
}
|
||
|
||
$blockers = array_values(array_unique(array_merge(
|
||
array_map('strval', $evaluation['blocking_rule_codes'] ?? []),
|
||
array_map('strval', $evaluation['review_rule_codes'] ?? [])
|
||
)));
|
||
$primary = EnrollmentEligibility::selectPrimaryBlocker($blockers);
|
||
$evaluation['primary_block_reason'] = $primary;
|
||
$evaluation['primary_parent_message'] = $primary !== null
|
||
? EnrollmentEligibility::messageForRuleCode($primary)
|
||
: null;
|
||
}
|
||
|
||
private function latestEnrollment(int $studentId, string $schoolYear): ?array
|
||
{
|
||
return $this->controllingEnrollment($studentId, $schoolYear);
|
||
}
|
||
|
||
private function upsertStudentClass(int $studentId, int $classSectionId, string $schoolYear, ?int $performedBy): void
|
||
{
|
||
$existing = $this->db->table('student_class')
|
||
->where('student_id', $studentId)
|
||
->where('school_year', $schoolYear)
|
||
->orderBy('id', 'DESC')
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
$payload = [
|
||
'student_id' => $studentId,
|
||
'class_section_id' => $classSectionId,
|
||
'school_year' => $schoolYear,
|
||
'updated_by' => $performedBy,
|
||
'updated_at' => date('Y-m-d H:i:s'),
|
||
];
|
||
|
||
if ($existing !== null) {
|
||
$this->db->table('student_class')->where('id', (int) $existing['id'])->update($payload);
|
||
} else {
|
||
$payload['created_at'] = date('Y-m-d H:i:s');
|
||
$this->db->table('student_class')->insert($payload);
|
||
}
|
||
}
|
||
|
||
public function syncDashboardBlockageFlags(string $targetSchoolYear, ?int $performedBy = null): int
|
||
{
|
||
if (! $this->db->tableExists('enrollment_flags')) {
|
||
return 0;
|
||
}
|
||
|
||
$sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||
if ($sourceSchoolYear === null) {
|
||
return 0;
|
||
}
|
||
|
||
$students = $this->sourceYearStudents($sourceSchoolYear);
|
||
if ($students === []) {
|
||
return 0;
|
||
}
|
||
|
||
$enrolledIds = $this->activeTargetEnrollmentStudentIds($targetSchoolYear);
|
||
$targetReviewCodes = $this->targetEnrollmentReviewCodesByStudent($targetSchoolYear);
|
||
$bypassCodesByStudent = $this->activeBypassCodesByStudent($targetSchoolYear);
|
||
$written = 0;
|
||
|
||
foreach ($students as $student) {
|
||
$studentId = (int) ($student['student_id'] ?? 0);
|
||
if ($studentId <= 0) {
|
||
continue;
|
||
}
|
||
|
||
$alreadyEnrolled = isset($enrolledIds[$studentId]);
|
||
$age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear);
|
||
$isAdult = $age !== null && $age >= EnrollmentEligibility::ADULT_STUDENT_MIN_AGE;
|
||
$flags = [];
|
||
|
||
if (! $alreadyEnrolled && $isAdult) {
|
||
$flags[] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [
|
||
'age_on_reference_date' => $age,
|
||
'rule_code' => 'ADULT_STUDENT_PARENT_BLOCKED',
|
||
]);
|
||
} elseif (! $alreadyEnrolled && isset($targetReviewCodes[$studentId])) {
|
||
$review = $targetReviewCodes[$studentId];
|
||
$code = (string) ($review['rule_code'] ?? 'WITHDRAWN');
|
||
$flags[] = $this->flag(
|
||
$code === 'DENIED' ? 'RESTRICTED_ADMINISTRATIVE_REVIEW' : 'WITHDRAWAL_REVIEW_REQUIRED',
|
||
$code === 'DENIED' ? 'high' : 'normal',
|
||
[
|
||
'rule_code' => $code,
|
||
'enrollment_status' => $review['enrollment_status'] ?? null,
|
||
'is_withdrawn' => $review['is_withdrawn'] ?? null,
|
||
]
|
||
);
|
||
} else {
|
||
$parentId = (int) ($student['parent_id'] ?? 0);
|
||
$evaluation = $parentId > 0
|
||
? $this->evaluateForParent($parentId, $studentId, $sourceSchoolYear, $targetSchoolYear, 'admin')
|
||
: $this->evaluate($studentId, $sourceSchoolYear, $targetSchoolYear, 'admin');
|
||
|
||
if ($alreadyEnrolled) {
|
||
$flags = array_values(array_filter(
|
||
$evaluation['flags'] ?? [],
|
||
static fn (array $flag): bool => ($flag['flag_type'] ?? '') === 'PENDING_MAKE_UP_EXAM_PROMOTION'
|
||
));
|
||
} else {
|
||
$flags = $evaluation['flags'] ?? [];
|
||
}
|
||
}
|
||
|
||
foreach ($flags as $flag) {
|
||
if ($this->flagCoveredByException((string) ($flag['flag_type'] ?? ''), $bypassCodesByStudent[$studentId] ?? [])) {
|
||
continue;
|
||
}
|
||
if ($this->upsertOpenFlag($studentId, $targetSchoolYear, $sourceSchoolYear, $flag, $performedBy)) {
|
||
$written++;
|
||
}
|
||
}
|
||
|
||
if ($isAdult) {
|
||
$this->retireWithdrawnFlagsForAdultStudent($studentId, $targetSchoolYear);
|
||
}
|
||
}
|
||
|
||
$written += $this->syncSiblingLastNameFlags($students, $enrolledIds, $bypassCodesByStudent, $targetSchoolYear, $sourceSchoolYear, $performedBy);
|
||
$written += $this->syncFinancialFlags($students, $enrolledIds, $bypassCodesByStudent, $targetSchoolYear, $sourceSchoolYear, $performedBy);
|
||
$written += $this->syncRepeatClassReassignmentFlags($targetSchoolYear, $sourceSchoolYear, $performedBy);
|
||
$this->retireClassReassignmentFlags($targetSchoolYear);
|
||
$this->retireClassCapacityExceptionFlags($targetSchoolYear);
|
||
|
||
return $written;
|
||
}
|
||
|
||
private function writeFlags(array $evaluation, ?int $performedBy): void
|
||
{
|
||
foreach ($evaluation['flags'] ?? [] as $flag) {
|
||
$this->upsertOpenFlag(
|
||
(int) $evaluation['student_id'],
|
||
(string) $evaluation['target_school_year'],
|
||
(string) $evaluation['source_school_year'],
|
||
$flag,
|
||
$performedBy
|
||
);
|
||
}
|
||
}
|
||
|
||
private function upsertOpenFlag(
|
||
int $studentId,
|
||
string $schoolYear,
|
||
string $sourceSchoolYear,
|
||
array $flag,
|
||
?int $performedBy
|
||
): bool {
|
||
if ($studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('enrollment_flags')) {
|
||
return false;
|
||
}
|
||
|
||
$flagType = (string) ($flag['flag_type'] ?? '');
|
||
if ($flagType === '' || in_array($flagType, ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED'], true)) {
|
||
return false;
|
||
}
|
||
|
||
$existing = $this->db->table('enrollment_flags')
|
||
->where('student_id', $studentId)
|
||
->where('school_year', $schoolYear)
|
||
->where('flag_type', $flagType)
|
||
->where('status', 'open')
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
if ($existing !== null) {
|
||
return false;
|
||
}
|
||
|
||
$this->db->table('enrollment_flags')->insert([
|
||
'flag_type' => $flagType,
|
||
'student_id' => $studentId,
|
||
'school_year' => $schoolYear,
|
||
'source_school_year' => $sourceSchoolYear !== '' ? $sourceSchoolYear : null,
|
||
'status' => 'open',
|
||
'priority' => $flag['priority'] ?? 'normal',
|
||
'assigned_to' => $performedBy,
|
||
'details_json' => json_encode($flag['details'] ?? [], JSON_UNESCAPED_SLASHES),
|
||
'created_at' => date('Y-m-d H:i:s'),
|
||
]);
|
||
|
||
return true;
|
||
}
|
||
|
||
private function retireWithdrawnFlagsForAdultStudent(int $studentId, string $targetSchoolYear): void
|
||
{
|
||
if ($studentId <= 0 || $targetSchoolYear === '' || ! $this->db->tableExists('enrollment_flags')) {
|
||
return;
|
||
}
|
||
|
||
$this->db->table('enrollment_flags')
|
||
->where('student_id', $studentId)
|
||
->where('school_year', $targetSchoolYear)
|
||
->where('flag_type', 'WITHDRAWAL_REVIEW_REQUIRED')
|
||
->where('status', 'open')
|
||
->update([
|
||
'status' => 'resolved',
|
||
'resolved_at' => date('Y-m-d H:i:s'),
|
||
'resolution_notes' => 'Adult-student age rule takes priority over withdrawal review.',
|
||
]);
|
||
}
|
||
|
||
private function retireClassReassignmentFlags(string $targetSchoolYear): void
|
||
{
|
||
if (! $this->db->tableExists('enrollment_flags')) {
|
||
return;
|
||
}
|
||
|
||
$builder = $this->db->table('enrollment_flags')
|
||
->where('flag_type', 'CLASS_REASSIGNMENT_REQUIRED')
|
||
->where('status', 'open');
|
||
if ($targetSchoolYear !== '') {
|
||
$builder->where('school_year', $targetSchoolYear);
|
||
}
|
||
|
||
$builder->update([
|
||
'status' => 'resolved',
|
||
'resolved_at' => date('Y-m-d H:i:s'),
|
||
'resolution_notes' => 'Class reassignment flags are no longer used for registration emails.',
|
||
]);
|
||
}
|
||
|
||
private function retireClassCapacityExceptionFlags(string $targetSchoolYear): void
|
||
{
|
||
if (! $this->db->tableExists('enrollment_flags')) {
|
||
return;
|
||
}
|
||
|
||
$builder = $this->db->table('enrollment_flags')
|
||
->where('flag_type', 'CLASS_CAPACITY_EXCEPTION_REQUIRED')
|
||
->where('status', 'open');
|
||
if ($targetSchoolYear !== '') {
|
||
$builder->where('school_year', $targetSchoolYear);
|
||
}
|
||
|
||
$builder->update([
|
||
'status' => 'resolved',
|
||
'resolved_at' => date('Y-m-d H:i:s'),
|
||
'resolution_notes' => 'Class capacity exceptions are no longer used. Class assignment is handled by section autogeneration or enrollment management.',
|
||
]);
|
||
}
|
||
|
||
private function activeBypassCodesByStudent(string $schoolYear): array
|
||
{
|
||
if ($schoolYear === '' || ! $this->db->tableExists('enrollment_exceptions')) {
|
||
return [];
|
||
}
|
||
|
||
$now = date('Y-m-d H:i:s');
|
||
$rows = $this->db->table('enrollment_exceptions')
|
||
->select('student_id, bypassed_rule_codes_json, reason_code')
|
||
->where('school_year', $schoolYear)
|
||
->where('status', 'active')
|
||
->groupStart()
|
||
->where('starts_at IS NULL', null, false)
|
||
->orWhere('starts_at <=', $now)
|
||
->groupEnd()
|
||
->groupStart()
|
||
->where('expires_at IS NULL', null, false)
|
||
->orWhere('expires_at >=', $now)
|
||
->groupEnd()
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$byStudent = [];
|
||
foreach ($rows as $row) {
|
||
$studentId = (int) ($row['student_id'] ?? 0);
|
||
if ($studentId <= 0) {
|
||
continue;
|
||
}
|
||
$codes = json_decode((string) ($row['bypassed_rule_codes_json'] ?? ''), true);
|
||
$codes = is_array($codes) ? array_values(array_filter(array_map('strval', $codes))) : [];
|
||
$reason = strtoupper(trim((string) ($row['reason_code'] ?? '')));
|
||
if ($reason !== '') {
|
||
$codes[] = $reason;
|
||
}
|
||
$byStudent[$studentId] = array_values(array_unique(array_merge($byStudent[$studentId] ?? [], $codes)));
|
||
}
|
||
|
||
return $byStudent;
|
||
}
|
||
|
||
private function flagCoveredByException(string $flagType, array $bypassCodes): bool
|
||
{
|
||
if ($flagType === '' || $bypassCodes === []) {
|
||
return false;
|
||
}
|
||
|
||
$bypassCodes = array_map(static fn ($code): string => strtoupper(trim((string) $code)), $bypassCodes);
|
||
$mapped = match ($flagType) {
|
||
'AGE_EXCEPTION_REQUIRED' => ['AGE_RULE_BLOCKED', 'AGE_EXCEPTION_REQUIRED'],
|
||
'LATE_REGISTRATION_EXCEPTION' => ['REGISTRATION_CLOSED', 'LATE_REGISTRATION_EXCEPTION'],
|
||
'FINANCIAL_REVIEW_REQUIRED' => ['OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED', 'FINANCIAL_REVIEW_REQUIRED'],
|
||
'RESTRICTED_ADMINISTRATIVE_REVIEW' => ['EXPELLED', 'DENIED', 'RESTRICTED_ADMINISTRATIVE_REVIEW'],
|
||
'WITHDRAWAL_REVIEW_REQUIRED' => ['WITHDRAWN', 'WITHDRAWAL_REVIEW_REQUIRED'],
|
||
'DEFERRED_DELIBERATION' => ['NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION', 'DEFERRED_DECISION', 'DEFERRED_DELIBERATION'],
|
||
'SIBLING_LAST_NAME_MISMATCH' => ['SIBLING_LAST_NAME_MISMATCH', 'SIBLING_LAST_NAME_REVIEWED'],
|
||
'ADULT_STUDENT_ACTION_REQUIRED' => ['ADULT_STUDENT_PARENT_BLOCKED', 'ADULT_STUDENT_ACTION_REQUIRED'],
|
||
default => [$flagType],
|
||
};
|
||
|
||
return array_intersect($mapped, $bypassCodes) !== [];
|
||
}
|
||
|
||
private function previousSchoolYearName(string $schoolYear): ?string
|
||
{
|
||
return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)
|
||
? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1)
|
||
: null;
|
||
}
|
||
|
||
private function sourceYearStudents(string $sourceSchoolYear): array
|
||
{
|
||
$byId = [];
|
||
|
||
if ($this->db->tableExists('student_class')) {
|
||
$rows = $this->db->table('student_class sc')
|
||
->select('sc.student_id, s.parent_id, s.firstname, s.lastname, s.dob, cs.class_section_name')
|
||
->join('students s', 's.id = sc.student_id', 'left')
|
||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
||
->where('sc.school_year', $sourceSchoolYear)
|
||
->get()
|
||
->getResultArray();
|
||
foreach ($rows as $row) {
|
||
$studentId = (int) ($row['student_id'] ?? 0);
|
||
if ($studentId > 0) {
|
||
$byId[$studentId] = $row;
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($this->db->tableExists('enrollments')) {
|
||
$rows = $this->db->table('enrollments e')
|
||
->select('e.student_id, s.parent_id, s.firstname, s.lastname, s.dob, cs.class_section_name')
|
||
->join('students s', 's.id = e.student_id', 'left')
|
||
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
|
||
->where('e.school_year', $sourceSchoolYear)
|
||
->get()
|
||
->getResultArray();
|
||
foreach ($rows as $row) {
|
||
$studentId = (int) ($row['student_id'] ?? 0);
|
||
if ($studentId > 0 && ! isset($byId[$studentId])) {
|
||
$byId[$studentId] = $row;
|
||
}
|
||
}
|
||
}
|
||
|
||
return array_values($byId);
|
||
}
|
||
|
||
private function latestDecisionsByStudent(string $sourceSchoolYear): array
|
||
{
|
||
if (! $this->db->tableExists('student_decisions')) {
|
||
return [];
|
||
}
|
||
|
||
$select = ['student_id', 'decision', 'source', 'notes', 'class_section_name', 'updated_at', 'id'];
|
||
if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) {
|
||
$select[] = 'deliberation_decision_standard';
|
||
}
|
||
|
||
$rows = $this->db->table('student_decisions')
|
||
->select($select)
|
||
->where('school_year', $sourceSchoolYear)
|
||
->orderBy('updated_at', 'DESC')
|
||
->orderBy('id', 'DESC')
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$latest = [];
|
||
foreach ($rows as $row) {
|
||
$studentId = (int) ($row['student_id'] ?? 0);
|
||
if ($studentId > 0 && ! isset($latest[$studentId])) {
|
||
$latest[$studentId] = $row;
|
||
}
|
||
}
|
||
|
||
return $latest;
|
||
}
|
||
|
||
private function activeTargetEnrollmentStudentIds(string $targetSchoolYear): array
|
||
{
|
||
if (! $this->db->tableExists('enrollments')) {
|
||
return [];
|
||
}
|
||
|
||
$select = ['student_id', 'enrollment_status', 'admission_status'];
|
||
if ($this->db->fieldExists('is_withdrawn', 'enrollments')) {
|
||
$select[] = 'is_withdrawn';
|
||
}
|
||
|
||
$rows = $this->db->table('enrollments')
|
||
->select(implode(', ', $select))
|
||
->where('school_year', $targetSchoolYear)
|
||
->orderBy('updated_at', 'DESC')
|
||
->orderBy('id', 'DESC')
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$ids = [];
|
||
foreach ($rows as $row) {
|
||
$studentId = (int) ($row['student_id'] ?? 0);
|
||
if ($studentId <= 0 || isset($ids[$studentId])) {
|
||
continue;
|
||
}
|
||
if ($this->activeEnrollmentBlocksDuplicate($row)) {
|
||
$ids[$studentId] = true;
|
||
}
|
||
}
|
||
|
||
return $ids;
|
||
}
|
||
|
||
/**
|
||
* @return array<int, array{rule_code: string, enrollment_status: string, is_withdrawn: int}>
|
||
*/
|
||
private function targetEnrollmentReviewCodesByStudent(string $targetSchoolYear): array
|
||
{
|
||
if (! $this->db->tableExists('enrollments')) {
|
||
return [];
|
||
}
|
||
|
||
$select = ['student_id', 'enrollment_status', 'admission_status'];
|
||
if ($this->db->fieldExists('is_withdrawn', 'enrollments')) {
|
||
$select[] = 'is_withdrawn';
|
||
}
|
||
|
||
$rows = $this->db->table('enrollments')
|
||
->select(implode(', ', $select))
|
||
->where('school_year', $targetSchoolYear)
|
||
->orderBy('updated_at', 'DESC')
|
||
->orderBy('id', 'DESC')
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$reviewCodes = [];
|
||
foreach ($rows as $row) {
|
||
$studentId = (int) ($row['student_id'] ?? 0);
|
||
if ($studentId <= 0 || isset($reviewCodes[$studentId]) || ! $this->deniedOrWithdrawnEnrollmentBlocksStandardEligibility($row)) {
|
||
continue;
|
||
}
|
||
|
||
$status = strtolower(trim((string) ($row['enrollment_status'] ?? '')));
|
||
$admission = strtolower(trim((string) ($row['admission_status'] ?? '')));
|
||
$reviewCodes[$studentId] = [
|
||
'rule_code' => $admission === 'denied' || $status === 'denied' ? 'DENIED' : 'WITHDRAWN',
|
||
'enrollment_status' => (string) ($row['enrollment_status'] ?? ''),
|
||
'is_withdrawn' => (int) ($row['is_withdrawn'] ?? 0),
|
||
];
|
||
}
|
||
|
||
return $reviewCodes;
|
||
}
|
||
|
||
private function syncSiblingLastNameFlags(
|
||
array $students,
|
||
array $enrolledIds,
|
||
array $bypassCodesByStudent,
|
||
string $targetSchoolYear,
|
||
string $sourceSchoolYear,
|
||
?int $performedBy
|
||
): int {
|
||
$parentIds = [];
|
||
$studentsByParent = [];
|
||
foreach ($students as $student) {
|
||
$parentId = (int) ($student['parent_id'] ?? 0);
|
||
$studentId = (int) ($student['student_id'] ?? 0);
|
||
if ($parentId <= 0 || $studentId <= 0) {
|
||
continue;
|
||
}
|
||
$parentIds[$parentId] = $parentId;
|
||
$studentsByParent[$parentId][] = $studentId;
|
||
}
|
||
if ($parentIds === [] || ! $this->db->tableExists('students')) {
|
||
return 0;
|
||
}
|
||
|
||
$linked = $this->db->table('students')
|
||
->select('id, parent_id, lastname')
|
||
->whereIn('parent_id', array_values($parentIds))
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$namesByParent = [];
|
||
foreach ($linked as $row) {
|
||
$parentId = (int) ($row['parent_id'] ?? 0);
|
||
$namesByParent[$parentId][] = $this->normalizeLastName($row['lastname'] ?? null);
|
||
}
|
||
|
||
$mismatchedParents = [];
|
||
foreach ($namesByParent as $parentId => $names) {
|
||
if (count($names) <= 1) {
|
||
continue;
|
||
}
|
||
$unique = array_values(array_unique($names));
|
||
if (in_array('', $unique, true) || count($unique) > 1) {
|
||
$mismatchedParents[$parentId] = true;
|
||
}
|
||
}
|
||
|
||
$written = 0;
|
||
foreach ($mismatchedParents as $parentId => $_) {
|
||
foreach ($studentsByParent[$parentId] ?? [] as $studentId) {
|
||
if (isset($enrolledIds[$studentId])) {
|
||
continue;
|
||
}
|
||
if ($this->flagCoveredByException('SIBLING_LAST_NAME_MISMATCH', $bypassCodesByStudent[$studentId] ?? [])) {
|
||
continue;
|
||
}
|
||
if ($this->upsertOpenFlag($studentId, $targetSchoolYear, $sourceSchoolYear, $this->flag('SIBLING_LAST_NAME_MISMATCH', 'high'), $performedBy)) {
|
||
$written++;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $written;
|
||
}
|
||
|
||
private function syncFinancialFlags(
|
||
array $students,
|
||
array $enrolledIds,
|
||
array $bypassCodesByStudent,
|
||
string $targetSchoolYear,
|
||
string $sourceSchoolYear,
|
||
?int $performedBy
|
||
): int {
|
||
$target = $this->schoolYearByName($targetSchoolYear) ?? [];
|
||
$behavior = (string) ($target['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment');
|
||
if (! in_array($behavior, ['submission_blocked_until_payment', 'admin_approval_required'], true)) {
|
||
return 0;
|
||
}
|
||
if (! $this->db->tableExists('invoices')) {
|
||
return 0;
|
||
}
|
||
|
||
$parentIds = [];
|
||
$studentsByParent = [];
|
||
foreach ($students as $student) {
|
||
$parentId = (int) ($student['parent_id'] ?? 0);
|
||
$studentId = (int) ($student['student_id'] ?? 0);
|
||
if ($parentId <= 0 || $studentId <= 0) {
|
||
continue;
|
||
}
|
||
$parentIds[$parentId] = $parentId;
|
||
$studentsByParent[$parentId][] = $studentId;
|
||
}
|
||
if ($parentIds === []) {
|
||
return 0;
|
||
}
|
||
|
||
$flagType = $behavior === 'admin_approval_required' ? 'FINANCIAL_REVIEW_REQUIRED' : 'FINANCIAL_REVIEW_REQUIRED';
|
||
$written = 0;
|
||
foreach (array_values($parentIds) as $parentId) {
|
||
$summary = $this->getEnrollmentFinancialSummary($parentId, $sourceSchoolYear, $targetSchoolYear);
|
||
$balance = (float) ($summary['carry_forward_balance'] ?? 0.0);
|
||
if ($balance <= 0.0) {
|
||
$this->resolveOpenFinancialReviewFlagsForParent($parentId, $targetSchoolYear, $studentsByParent[$parentId] ?? []);
|
||
|
||
continue;
|
||
}
|
||
foreach ($studentsByParent[$parentId] ?? [] as $studentId) {
|
||
if (isset($enrolledIds[$studentId])) {
|
||
continue;
|
||
}
|
||
if ($this->flagCoveredByException($flagType, $bypassCodesByStudent[$studentId] ?? [])) {
|
||
continue;
|
||
}
|
||
if ($this->upsertOpenFlag(
|
||
$studentId,
|
||
$targetSchoolYear,
|
||
$sourceSchoolYear,
|
||
$this->flag($flagType, 'high', ['carry_over_balance' => $balance]),
|
||
$performedBy
|
||
)) {
|
||
$written++;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $written;
|
||
}
|
||
|
||
public function resolveOpenFinancialReviewFlagsForParent(int $parentId, string $schoolYear, array $studentIds = []): int
|
||
{
|
||
if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('enrollment_flags')) {
|
||
return 0;
|
||
}
|
||
|
||
if ($studentIds === []) {
|
||
$studentIds = array_values(array_filter(array_map(
|
||
static fn (array $row): int => (int) ($row['id'] ?? 0),
|
||
$this->linkedStudentsForParent($parentId)
|
||
)));
|
||
}
|
||
|
||
$studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds))));
|
||
if ($studentIds === []) {
|
||
return 0;
|
||
}
|
||
|
||
$this->db->table('enrollment_flags')
|
||
->whereIn('student_id', $studentIds)
|
||
->where('school_year', $schoolYear)
|
||
->where('flag_type', 'FINANCIAL_REVIEW_REQUIRED')
|
||
->where('status', 'open')
|
||
->update([
|
||
'status' => 'resolved',
|
||
'resolved_at' => date('Y-m-d H:i:s'),
|
||
'resolution_notes' => 'Automatically resolved because no outstanding carry-forward balance remains.',
|
||
]);
|
||
|
||
return (int) $this->db->affectedRows();
|
||
}
|
||
|
||
public function syncParentFinancialReviewFlags(
|
||
int $parentId,
|
||
string $sourceSchoolYear,
|
||
string $targetSchoolYear,
|
||
array $studentIds = []
|
||
): void {
|
||
$summary = $this->getEnrollmentFinancialSummary($parentId, $sourceSchoolYear, $targetSchoolYear);
|
||
if ((float) ($summary['carry_forward_balance'] ?? 0.0) > 0.0) {
|
||
return;
|
||
}
|
||
|
||
$this->resolveOpenFinancialReviewFlagsForParent($parentId, $targetSchoolYear, $studentIds);
|
||
}
|
||
|
||
/**
|
||
* Refresh stored invoice balances after payment so enrollment and finance stay aligned.
|
||
*/
|
||
public function reconcileCarryForwardInvoicesAfterSourcePayment(int $parentId, string $schoolYear): int
|
||
{
|
||
return $this->refreshParentInvoiceBalances($parentId, $schoolYear);
|
||
}
|
||
|
||
public function refreshParentInvoiceBalances(int $parentId, string $schoolYear): int
|
||
{
|
||
if ($parentId <= 0 || ! $this->db->tableExists('invoices')) {
|
||
return 0;
|
||
}
|
||
|
||
$ledger = $this->invoiceLedger();
|
||
$recalculated = 0;
|
||
$invoiceIds = [];
|
||
|
||
if ($schoolYear !== '') {
|
||
$rows = $this->db->table('invoices')
|
||
->select('id')
|
||
->where('parent_id', $parentId)
|
||
->where('school_year', $schoolYear)
|
||
->get()
|
||
->getResultArray();
|
||
foreach ($rows as $row) {
|
||
$invoiceIds[(int) ($row['id'] ?? 0)] = (int) ($row['id'] ?? 0);
|
||
}
|
||
}
|
||
|
||
$carryForwardRows = $this->db->table('invoices')
|
||
->select('id, invoice_number, description')
|
||
->where('parent_id', $parentId)
|
||
->get()
|
||
->getResultArray();
|
||
$sourceToken = $schoolYear !== '' ? (preg_replace('/[^0-9A-Za-z]/', '', $schoolYear) ?? '') : '';
|
||
foreach ($carryForwardRows as $row) {
|
||
if (! $this->isCarryForwardInvoiceRow($row)) {
|
||
continue;
|
||
}
|
||
|
||
if ($schoolYear === '') {
|
||
$invoiceIds[(int) ($row['id'] ?? 0)] = (int) ($row['id'] ?? 0);
|
||
continue;
|
||
}
|
||
|
||
$invoiceNumber = (string) ($row['invoice_number'] ?? '');
|
||
$description = (string) ($row['description'] ?? '');
|
||
if (
|
||
($sourceToken !== '' && str_contains($invoiceNumber, $sourceToken))
|
||
|| str_contains($description, $schoolYear)
|
||
) {
|
||
$invoiceIds[(int) ($row['id'] ?? 0)] = (int) ($row['id'] ?? 0);
|
||
}
|
||
}
|
||
|
||
foreach (array_values(array_filter($invoiceIds)) as $invoiceId) {
|
||
if ($invoiceId <= 0) {
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
$ledger->recalculateInvoice($invoiceId);
|
||
unset($this->resolvedInvoiceBalanceCache[$invoiceId]);
|
||
$recalculated++;
|
||
} catch (\Throwable) {
|
||
// Leave enrollment logic to fall back to stored balances for this invoice.
|
||
}
|
||
}
|
||
|
||
return $recalculated;
|
||
}
|
||
|
||
private function appendFlagsFromRuleCodes(array &$evaluation): void
|
||
{
|
||
$existing = [];
|
||
foreach ($evaluation['flags'] ?? [] as $flag) {
|
||
$type = (string) ($flag['flag_type'] ?? '');
|
||
if ($type !== '') {
|
||
$existing[$type] = true;
|
||
}
|
||
}
|
||
|
||
$codes = array_values(array_unique(array_merge(
|
||
array_map('strval', $evaluation['blocking_rule_codes'] ?? []),
|
||
array_map('strval', $evaluation['review_rule_codes'] ?? [])
|
||
)));
|
||
|
||
$map = [
|
||
'NO_FINAL_DECISION' => ['DEFERRED_DELIBERATION', 'high'],
|
||
'UNRECOGNIZED_DECISION' => ['DEFERRED_DELIBERATION', 'high'],
|
||
'DEFERRED_DECISION' => ['DEFERRED_DELIBERATION', 'high'],
|
||
'EXPELLED' => ['RESTRICTED_ADMINISTRATIVE_REVIEW', 'high'],
|
||
'WITHDRAWN' => ['WITHDRAWAL_REVIEW_REQUIRED', 'normal'],
|
||
'DENIED' => ['RESTRICTED_ADMINISTRATIVE_REVIEW', 'high'],
|
||
'SIBLING_LAST_NAME_MISMATCH' => ['SIBLING_LAST_NAME_MISMATCH', 'high'],
|
||
'OUTSTANDING_BALANCE_BLOCKED' => ['FINANCIAL_REVIEW_REQUIRED', 'high'],
|
||
'FINANCE_APPROVAL_REQUIRED' => ['FINANCIAL_REVIEW_REQUIRED', 'high'],
|
||
'AGE_RULE_BLOCKED' => ['AGE_EXCEPTION_REQUIRED', 'normal'],
|
||
'ADULT_STUDENT_PARENT_BLOCKED' => ['ADULT_STUDENT_ACTION_REQUIRED', 'normal'],
|
||
'EXIT_REQUIRED' => ['COMPLETION_OR_EXIT_PROCESS_REQUIRED', 'normal'],
|
||
'REGISTRATION_CLOSED' => ['LATE_REGISTRATION_EXCEPTION', 'normal'],
|
||
];
|
||
|
||
foreach ($codes as $code) {
|
||
if (! isset($map[$code])) {
|
||
continue;
|
||
}
|
||
[$type, $priority] = $map[$code];
|
||
if (isset($existing[$type])) {
|
||
continue;
|
||
}
|
||
$evaluation['flags'][] = $this->flag($type, $priority, ['rule_code' => $code]);
|
||
$existing[$type] = true;
|
||
}
|
||
}
|
||
|
||
private function audit(array $evaluation, string $action, ?int $performedBy, ?array $original, array $new): void
|
||
{
|
||
if (! $this->db->tableExists('enrollment_transition_audits')) {
|
||
return;
|
||
}
|
||
|
||
$snapshot = [
|
||
'payload' => $new,
|
||
'decision' => $evaluation['decision'] ?? null,
|
||
'rule_codes' => $evaluation['rule_codes'] ?? [],
|
||
'blocking_rule_codes' => $evaluation['blocking_rule_codes'] ?? [],
|
||
'review_rule_codes' => $evaluation['review_rule_codes'] ?? [],
|
||
'warning_rule_codes' => $evaluation['warning_rule_codes'] ?? [],
|
||
'financial_summary' => $evaluation['financial_summary'] ?? null,
|
||
'exception_id' => $evaluation['admin_exception']['id'] ?? null,
|
||
'parent_id' => $evaluation['parent_id'] ?? ($new['parent_id'] ?? null),
|
||
];
|
||
|
||
$this->db->table('enrollment_transition_audits')->insert([
|
||
'student_id' => (int) $evaluation['student_id'],
|
||
'school_year' => (string) $evaluation['target_school_year'],
|
||
'source_school_year' => (string) $evaluation['source_school_year'],
|
||
'action' => $action,
|
||
'performed_by' => $performedBy,
|
||
'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null,
|
||
'new_values_json' => json_encode($snapshot, JSON_UNESCAPED_SLASHES),
|
||
'reason' => implode(' ', $evaluation['blockers'] ?? []) ?: implode(' ', array_map('strval', $evaluation['rule_codes'] ?? [])),
|
||
'created_at' => date('Y-m-d H:i:s'),
|
||
]);
|
||
}
|
||
|
||
private function applyAdultStudentBlock(array &$result, array $student, ?int $age): void
|
||
{
|
||
$result['age_on_reference_date'] = $age;
|
||
$result['adult_student'] = true;
|
||
$result['parent_enrollment_allowed'] = false;
|
||
$result['student_self_enrollment_allowed'] = false;
|
||
$result['academic_eligible'] = false;
|
||
$result['flags'][] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [
|
||
'age_on_reference_date' => $age,
|
||
'rule_code' => 'ADULT_STUDENT_PARENT_BLOCKED',
|
||
]);
|
||
$name = $this->studentName($student);
|
||
$result['blockers'][] = $name === 'The student'
|
||
? EnrollmentEligibility::ADULT_STUDENT_MESSAGE
|
||
: str_replace('This student', $name, EnrollmentEligibility::ADULT_STUDENT_MESSAGE);
|
||
}
|
||
|
||
private function flag(string $type, string $priority, array $details = []): array
|
||
{
|
||
return ['flag_type' => $type, 'priority' => $priority, 'details' => $details];
|
||
}
|
||
|
||
private function ageReferenceDate(string $schoolYear): string
|
||
{
|
||
return preg_match('/^(\d{4})/', $schoolYear, $matches) ? $matches[1] . '-09-01' : date('Y') . '-09-01';
|
||
}
|
||
|
||
private function dateTimeFromYear(array $year, string $dateTimeField, string $dateField, bool $endOfDay): ?DateTimeImmutable
|
||
{
|
||
$value = trim((string) ($year[$dateTimeField] ?? ''));
|
||
if ($value === '') {
|
||
$date = trim((string) ($year[$dateField] ?? ''));
|
||
$value = $date !== '' ? $date . ($endOfDay ? ' 23:59:59' : ' 00:00:00') : '';
|
||
}
|
||
|
||
if ($value === '') {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
return new DateTimeImmutable($value);
|
||
} catch (\Throwable) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private function studentName(array $student): string
|
||
{
|
||
$name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''));
|
||
return $name !== '' ? $name : 'The student';
|
||
}
|
||
|
||
private function ensureDecisionFields(array &$evaluation): void
|
||
{
|
||
$evaluation['rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['rule_codes'] ?? [])));
|
||
$evaluation['blocking_rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['blocking_rule_codes'] ?? [])));
|
||
$evaluation['warning_rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['warning_rule_codes'] ?? [])));
|
||
$evaluation['review_rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['review_rule_codes'] ?? [])));
|
||
$evaluation['admin_exception'] = $evaluation['admin_exception'] ?? null;
|
||
$evaluation['decision'] = $evaluation['decision'] ?? self::DECISION_REVIEW_REQUIRED;
|
||
$evaluation['can_enroll'] = (bool) ($evaluation['parent_enrollment_allowed'] ?? false);
|
||
|
||
foreach ($evaluation['blockers'] ?? [] as $message) {
|
||
$this->addDerivedBlockerCode($evaluation, (string) $message);
|
||
}
|
||
|
||
foreach ($evaluation['warnings'] ?? [] as $message) {
|
||
$this->addRuleCode($evaluation, $this->ruleCodeFromMessage((string) $message), 'warning');
|
||
}
|
||
}
|
||
|
||
private function deriveAcademicRuleCodes(array &$evaluation): void
|
||
{
|
||
$decision = (string) ($evaluation['deliberation_decision'] ?? '');
|
||
if (in_array($decision, [
|
||
DeliberationDecision::PASSED,
|
||
DeliberationDecision::REPEAT_CLASS,
|
||
DeliberationDecision::MAKE_UP_EXAM,
|
||
], true)) {
|
||
$this->addRuleCode($evaluation, $decision, $decision === DeliberationDecision::MAKE_UP_EXAM ? 'warning' : null);
|
||
}
|
||
|
||
if (($evaluation['placement_status'] ?? '') === 'exit_required') {
|
||
$this->addRuleCode($evaluation, 'EXIT_REQUIRED', 'blocking');
|
||
}
|
||
|
||
foreach ($evaluation['flags'] ?? [] as $flag) {
|
||
$flagType = (string) ($flag['flag_type'] ?? '');
|
||
if ($flagType !== '') {
|
||
$this->addRuleCode($evaluation, $flagType, 'warning');
|
||
}
|
||
}
|
||
}
|
||
|
||
private function addDerivedBlockerCode(array &$evaluation, string $message): void
|
||
{
|
||
$code = $this->ruleCodeFromMessage($message);
|
||
$this->addRuleCode($evaluation, $code, $this->reviewCode($code) ? 'review' : 'blocking');
|
||
}
|
||
|
||
private function ruleCodeFromMessage(string $message): string
|
||
{
|
||
$lower = strtolower($message);
|
||
|
||
return match (true) {
|
||
str_contains($lower, 'expelled') => 'EXPELLED',
|
||
str_contains($lower, 'withdrawn') => 'WITHDRAWN',
|
||
str_contains($lower, 'deferred') => 'DEFERRED_DECISION',
|
||
str_contains($lower, 'no final deliberation') || str_contains($lower, 'no final academic') || str_contains($lower, 'no final decision') => 'NO_FINAL_DECISION',
|
||
str_contains($lower, 'unrecognized') => 'UNRECOGNIZED_DECISION',
|
||
str_contains($lower, '18 years old') || str_contains($lower, 'adult-student') || str_contains($lower, 'over the allowed age') => 'ADULT_STUDENT_PARENT_BLOCKED',
|
||
str_contains($lower, 'registration for the new school year has not opened') => 'REGISTRATION_NOT_OPEN',
|
||
str_contains($lower, 'registration deadline') => 'REGISTRATION_CLOSED',
|
||
str_contains($lower, 'age rule') => 'AGE_RULE_BLOCKED',
|
||
str_contains($lower, 'closing school year') => 'SOURCE_YEAR_NOT_FOUND',
|
||
str_contains($lower, 'target school year') => 'TARGET_YEAR_NOT_FOUND',
|
||
str_contains($lower, 'highest available grade') || str_contains($lower, 'exit process') => 'EXIT_REQUIRED',
|
||
default => 'REVIEW_REQUIRED',
|
||
};
|
||
}
|
||
|
||
private function reviewCode(string $code): bool
|
||
{
|
||
return in_array($code, [
|
||
'NO_FINAL_DECISION',
|
||
'UNRECOGNIZED_DECISION',
|
||
'DEFERRED_DECISION',
|
||
'REVIEW_REQUIRED',
|
||
], true);
|
||
}
|
||
|
||
private function addBlocker(array &$evaluation, string $code, string $message): void
|
||
{
|
||
$blockers = array_values(array_filter(array_map('strval', $evaluation['blockers'] ?? [])));
|
||
if (! in_array($message, $blockers, true)) {
|
||
$blockers[] = $message;
|
||
}
|
||
$evaluation['blockers'] = $blockers;
|
||
$this->addRuleCode($evaluation, $code, $this->reviewCode($code) ? 'review' : 'blocking');
|
||
$evaluation['parent_enrollment_allowed'] = false;
|
||
$evaluation['can_enroll'] = false;
|
||
}
|
||
|
||
private function addRuleCode(array &$evaluation, string $code, ?string $bucket = null): void
|
||
{
|
||
$code = strtoupper(trim($code));
|
||
if ($code === '') {
|
||
return;
|
||
}
|
||
|
||
$evaluation['rule_codes'] = array_values(array_unique(array_merge($evaluation['rule_codes'] ?? [], [$code])));
|
||
if ($bucket !== null) {
|
||
$field = $bucket . '_rule_codes';
|
||
$evaluation[$field] = array_values(array_unique(array_merge($evaluation[$field] ?? [], [$code])));
|
||
}
|
||
}
|
||
|
||
private function removeRuleCode(array &$evaluation, string $code): void
|
||
{
|
||
$code = strtoupper(trim($code));
|
||
if ($code === '') {
|
||
return;
|
||
}
|
||
|
||
foreach (['rule_codes', 'blocking_rule_codes', 'warning_rule_codes', 'review_rule_codes'] as $field) {
|
||
$evaluation[$field] = array_values(array_filter(
|
||
array_map('strval', $evaluation[$field] ?? []),
|
||
static fn (string $existing): bool => $existing !== $code
|
||
));
|
||
}
|
||
|
||
$lastNameMessages = [
|
||
'Linked student last-name data must be reviewed by administration before re-enrollment.',
|
||
'Linked siblings have different last names. Please contact administration to review the family record.',
|
||
];
|
||
$evaluation['blockers'] = array_values(array_filter(
|
||
array_map('strval', $evaluation['blockers'] ?? []),
|
||
static fn (string $message): bool => ! in_array($message, $lastNameMessages, true)
|
||
));
|
||
}
|
||
|
||
private function activeEnrollmentBlocksDuplicate(?array $enrollment): bool
|
||
{
|
||
if ($enrollment === null) {
|
||
return false;
|
||
}
|
||
|
||
if ((int) ($enrollment['is_withdrawn'] ?? 0) === 1) {
|
||
return false;
|
||
}
|
||
|
||
$status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? '')));
|
||
$admission = strtolower(trim((string) ($enrollment['admission_status'] ?? '')));
|
||
|
||
return $admission === 'accepted' || in_array($status, [
|
||
'admission under review',
|
||
'review & decision',
|
||
'payment pending',
|
||
'enrolled',
|
||
'waitlist',
|
||
'withdraw under review',
|
||
'refund pending',
|
||
], true);
|
||
}
|
||
|
||
private function deniedOrWithdrawnEnrollmentBlocksStandardEligibility(?array $enrollment): bool
|
||
{
|
||
if ($enrollment === null) {
|
||
return false;
|
||
}
|
||
|
||
$status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? '')));
|
||
$admission = strtolower(trim((string) ($enrollment['admission_status'] ?? '')));
|
||
|
||
return $admission === 'denied'
|
||
|| $status === 'denied'
|
||
|| EnrollmentEligibility::isMarkedWithdrawn($enrollment);
|
||
}
|
||
|
||
private function applyHouseholdLastNameRule(array &$evaluation, int $parentId, ?string $sourceSchoolYear = null): void
|
||
{
|
||
$students = $this->linkedStudentsForParent($parentId);
|
||
|
||
if (count($students) <= 1) {
|
||
$evaluation['family_name_check_ok'] = true;
|
||
return;
|
||
}
|
||
|
||
$normalized = [];
|
||
foreach ($students as $student) {
|
||
$lastName = $this->normalizeLastName($student['lastname'] ?? null);
|
||
if ($lastName === '') {
|
||
$this->addBlocker($evaluation, 'SIBLING_LAST_NAME_MISMATCH', 'Linked student last-name data must be reviewed by administration before re-enrollment.');
|
||
$evaluation['family_name_check_ok'] = false;
|
||
return;
|
||
}
|
||
$normalized[$lastName] = true;
|
||
}
|
||
|
||
$ok = count($normalized) === 1;
|
||
$evaluation['family_name_check_ok'] = $ok;
|
||
if (! $ok) {
|
||
$this->addBlocker($evaluation, 'SIBLING_LAST_NAME_MISMATCH', 'Linked siblings have different last names. Please contact administration to review the family record.');
|
||
}
|
||
}
|
||
|
||
private function applyCarriedForwardLastNameException(
|
||
array &$evaluation,
|
||
int $parentId,
|
||
int $studentId,
|
||
string $sourceSchoolYear,
|
||
string $targetSchoolYear
|
||
): void {
|
||
if (! in_array('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes'] ?? [], true)) {
|
||
return;
|
||
}
|
||
|
||
$financialBlockers = array_values(array_intersect(
|
||
$evaluation['blocking_rule_codes'] ?? [],
|
||
['OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED']
|
||
));
|
||
if ($financialBlockers !== []) {
|
||
return;
|
||
}
|
||
|
||
$prior = $this->priorLastNameException($parentId, $studentId, $targetSchoolYear, $sourceSchoolYear);
|
||
if ($prior === null) {
|
||
return;
|
||
}
|
||
|
||
$approvedStudentIds = $this->exceptionFamilyStudentIds($prior);
|
||
$currentStudentIds = $this->linkedStudentIds($parentId);
|
||
sort($currentStudentIds);
|
||
if ($approvedStudentIds !== [] && $approvedStudentIds !== $currentStudentIds) {
|
||
$evaluation['last_name_exception_carry_forward'] = [
|
||
'eligible' => false,
|
||
'prior_exception_id' => (int) ($prior['id'] ?? 0),
|
||
'prior_school_year' => (string) ($prior['school_year'] ?? ''),
|
||
'approved_student_ids' => $approvedStudentIds,
|
||
'reason' => 'NEW_STUDENT_ADDED',
|
||
];
|
||
return;
|
||
}
|
||
|
||
$this->removeRuleCode($evaluation, 'SIBLING_LAST_NAME_MISMATCH');
|
||
$this->addRuleCode($evaluation, 'LAST_NAME_EXCEPTION_CARRIED_FORWARD', 'warning');
|
||
$evaluation['warnings'] = array_values(array_unique(array_merge(
|
||
array_map('strval', $evaluation['warnings'] ?? []),
|
||
['A previous last-name exception still applies for this student.']
|
||
)));
|
||
$evaluation['family_name_check_ok'] = true;
|
||
$evaluation['last_name_exception_carry_forward'] = [
|
||
'eligible' => true,
|
||
'prior_exception_id' => (int) ($prior['id'] ?? 0),
|
||
'prior_school_year' => (string) ($prior['school_year'] ?? ''),
|
||
'approved_student_ids' => $approvedStudentIds !== [] ? $approvedStudentIds : [(int) ($prior['student_id'] ?? $studentId)],
|
||
];
|
||
}
|
||
|
||
public function linkedStudentIds(int $parentId): array
|
||
{
|
||
return array_values(array_unique(array_filter(array_map(
|
||
static fn (array $student): int => (int) ($student['id'] ?? 0),
|
||
$this->linkedStudentsForParent($parentId)
|
||
), static fn (int $id): bool => $id > 0)));
|
||
}
|
||
|
||
private function priorLastNameException(int $parentId, int $studentId, string $targetSchoolYear, string $sourceSchoolYear): ?array
|
||
{
|
||
if ($parentId <= 0 || $studentId <= 0 || ! $this->db->tableExists('enrollment_exceptions')) {
|
||
return null;
|
||
}
|
||
|
||
$rows = $this->db->table('enrollment_exceptions')
|
||
->where('parent_id', $parentId)
|
||
->where('student_id', $studentId)
|
||
->whereIn('status', ['active', 'used'])
|
||
->orderBy('id', 'DESC')
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$sourceMatch = null;
|
||
$otherPrior = null;
|
||
foreach ($rows as $row) {
|
||
if ((int) ($row['student_id'] ?? 0) !== $studentId) {
|
||
continue;
|
||
}
|
||
$year = (string) ($row['school_year'] ?? '');
|
||
if ($year === '' || $year === $targetSchoolYear || ! $this->exceptionBypassesLastName($row)) {
|
||
continue;
|
||
}
|
||
|
||
if ($year === $sourceSchoolYear && $sourceMatch === null) {
|
||
$sourceMatch = $row;
|
||
} elseif ($otherPrior === null) {
|
||
$otherPrior = $row;
|
||
}
|
||
}
|
||
|
||
return $sourceMatch ?? $otherPrior;
|
||
}
|
||
|
||
private function exceptionBypassesLastName(array $exception): bool
|
||
{
|
||
$reason = strtoupper(trim((string) ($exception['reason_code'] ?? '')));
|
||
if (in_array($reason, ['SIBLING_LAST_NAME_MISMATCH', 'SIBLING_LAST_NAME_REVIEWED'], true)
|
||
|| str_contains($reason, 'LAST_NAME')
|
||
) {
|
||
return true;
|
||
}
|
||
|
||
$codes = json_decode((string) ($exception['bypassed_rule_codes_json'] ?? ''), true);
|
||
if (! is_array($codes)) {
|
||
return false;
|
||
}
|
||
|
||
$codes = array_map(static fn ($code): string => strtoupper(trim((string) $code)), $codes);
|
||
|
||
return in_array('SIBLING_LAST_NAME_MISMATCH', $codes, true);
|
||
}
|
||
|
||
/**
|
||
* @return list<int>
|
||
*/
|
||
private function exceptionFamilyStudentIds(array $exception): array
|
||
{
|
||
$ids = json_decode((string) ($exception['family_student_ids_json'] ?? ''), true);
|
||
if (! is_array($ids)) {
|
||
return [];
|
||
}
|
||
|
||
$ids = array_values(array_unique(array_filter(array_map(
|
||
static fn ($id): int => (int) $id,
|
||
$ids
|
||
), static fn (int $id): bool => $id > 0)));
|
||
sort($ids);
|
||
|
||
return $ids;
|
||
}
|
||
|
||
private function linkedStudentsForParent(int $parentId): array
|
||
{
|
||
if ($parentId <= 0 || ! $this->db->tableExists('students')) {
|
||
return [];
|
||
}
|
||
|
||
return $this->db->table('students')
|
||
->select('id, firstname, lastname, parent_id')
|
||
->where('parent_id', $parentId)
|
||
->orderBy('lastname', 'ASC')
|
||
->orderBy('firstname', 'ASC')
|
||
->get()
|
||
->getResultArray();
|
||
}
|
||
|
||
private function normalizeLastName(mixed $value): string
|
||
{
|
||
$value = strtolower(trim((string) $value));
|
||
$value = str_replace(['’', '`', '´'], "'", $value);
|
||
$value = preg_replace('/[^\p{L}\p{N}\s]+/u', '', $value) ?? '';
|
||
$value = preg_replace('/\s+/u', ' ', $value) ?? '';
|
||
|
||
return trim($value);
|
||
}
|
||
|
||
private function controllingEnrollment(int $studentId, string $schoolYear): ?array
|
||
{
|
||
return $this->enrollmentStatusService()->controllingEnrollment($studentId, $schoolYear);
|
||
}
|
||
|
||
private function enrollmentStatusService(): EnrollmentStatusService
|
||
{
|
||
return $this->statusService ?? new EnrollmentStatusService($this->db);
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
public function getEnrollmentFinancialSummary(int $parentId, string $sourceSchoolYear, string $targetSchoolYear, float $tuitionDue = 0.0): array
|
||
{
|
||
$target = $this->schoolYearByName($targetSchoolYear) ?? [];
|
||
$targetBalances = $this->invoiceBalancesByType($parentId, $targetSchoolYear);
|
||
$carryForward = round((float) ($targetBalances['carry_forward'] ?? 0.0), 2);
|
||
$sourcePositiveOutstanding = $sourceSchoolYear !== ''
|
||
? $this->positiveOutstandingBalance($parentId, $sourceSchoolYear)
|
||
: 0.0;
|
||
$hasTargetYearCarryForwardInvoices = $this->parentHasTargetYearCarryForwardInvoices($parentId, $targetSchoolYear);
|
||
|
||
if ($sourcePositiveOutstanding <= 0.0) {
|
||
// Previous-year invoices are settled (including discounted/paid accounts).
|
||
// Ignore stale carry-forward invoices that may still show an opening balance.
|
||
$carryForward = 0.0;
|
||
} elseif ($carryForward <= 0.0 && $sourceSchoolYear !== '' && ! $hasTargetYearCarryForwardInvoices) {
|
||
$carryForward = round($sourcePositiveOutstanding, 2);
|
||
}
|
||
|
||
$currentYearBalance = round((float) ($targetBalances['current_year'] ?? 0.0), 2);
|
||
$totalExistingBalance = round(max(0.0, $carryForward) + max(0.0, $currentYearBalance), 2);
|
||
$tuitionDue = round(max(0.0, $tuitionDue), 2);
|
||
$behavior = (string) ($target['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment');
|
||
|
||
return [
|
||
'currency' => '$',
|
||
'source_school_year' => $sourceSchoolYear,
|
||
'target_school_year' => $targetSchoolYear,
|
||
'carry_forward_balance' => $carryForward,
|
||
'carry_over_balance' => $carryForward,
|
||
'current_year_balance' => $currentYearBalance,
|
||
'current_balance' => $currentYearBalance,
|
||
'total_existing_balance' => $totalExistingBalance,
|
||
'tuition_due_at_registration' => $tuitionDue,
|
||
'total_enrollment_due' => round($totalExistingBalance + $tuitionDue, 2),
|
||
'amount_due' => round($totalExistingBalance + $tuitionDue, 2),
|
||
'balance_behavior' => $behavior,
|
||
'payment_plan_available' => (bool) ($target['payment_plan_available'] ?? false),
|
||
'evaluated_at' => date('Y-m-d H:i:s'),
|
||
];
|
||
}
|
||
|
||
private function applyFinancialRule(array &$evaluation, int $parentId, string $sourceSchoolYear, string $targetSchoolYear): void
|
||
{
|
||
$summary = $this->getEnrollmentFinancialSummary($parentId, $sourceSchoolYear, $targetSchoolYear);
|
||
$evaluation['financial_summary'] = $summary;
|
||
|
||
$balance = (float) ($summary['carry_forward_balance'] ?? 0.0);
|
||
$behavior = (string) ($summary['balance_behavior'] ?? 'submission_blocked_until_payment');
|
||
if ($balance <= 0.0 || ! in_array($behavior, self::BLOCKING_BALANCE_BEHAVIORS, true)) {
|
||
return;
|
||
}
|
||
|
||
if ($behavior === 'admin_approval_required') {
|
||
$this->addBlocker($evaluation, 'FINANCE_APPROVAL_REQUIRED', EnrollmentEligibility::FINANCE_APPROVAL_PORTAL_MESSAGE);
|
||
|
||
return;
|
||
}
|
||
|
||
$this->addBlocker($evaluation, 'OUTSTANDING_BALANCE_BLOCKED', EnrollmentEligibility::BALANCE_PORTAL_MESSAGE);
|
||
}
|
||
|
||
/**
|
||
* @return array{carry_forward: float, current_year: float}
|
||
*/
|
||
private function invoiceBalancesByType(int $parentId, string $schoolYear): array
|
||
{
|
||
if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('invoices')) {
|
||
return ['carry_forward' => 0.0, 'current_year' => 0.0];
|
||
}
|
||
|
||
$rows = $this->db->table('invoices')
|
||
->select('id, invoice_number, semester, description, balance, status')
|
||
->where('parent_id', $parentId)
|
||
->where('school_year', $schoolYear)
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$carryForward = 0.0;
|
||
$currentYear = 0.0;
|
||
foreach ($rows as $row) {
|
||
$balance = round($this->enrollmentOutstandingBalance($row), 2);
|
||
if ($balance <= 0.0) {
|
||
continue;
|
||
}
|
||
|
||
if ($this->isCarryForwardInvoiceRow($row)) {
|
||
$carryForward += $balance;
|
||
} else {
|
||
$currentYear += $balance;
|
||
}
|
||
}
|
||
|
||
return [
|
||
'carry_forward' => round($carryForward, 2),
|
||
'current_year' => round($currentYear, 2),
|
||
];
|
||
}
|
||
|
||
private function parentHasTargetYearCarryForwardInvoices(int $parentId, string $targetSchoolYear): bool
|
||
{
|
||
if ($parentId <= 0 || $targetSchoolYear === '' || ! $this->db->tableExists('invoices')) {
|
||
return false;
|
||
}
|
||
|
||
$rows = $this->db->table('invoices')
|
||
->select('invoice_number, semester, description')
|
||
->where('parent_id', $parentId)
|
||
->where('school_year', $targetSchoolYear)
|
||
->get()
|
||
->getResultArray();
|
||
|
||
foreach ($rows as $row) {
|
||
if ($this->isCarryForwardInvoiceRow($row)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $invoice
|
||
*/
|
||
private function isCarryForwardInvoiceRow(array $invoice): bool
|
||
{
|
||
$invoiceNumber = (string) ($invoice['invoice_number'] ?? '');
|
||
if (str_starts_with($invoiceNumber, 'CF-')) {
|
||
return true;
|
||
}
|
||
|
||
if (strcasecmp((string) ($invoice['semester'] ?? ''), 'Opening Balance') === 0) {
|
||
return true;
|
||
}
|
||
|
||
$description = strtolower((string) ($invoice['description'] ?? ''));
|
||
|
||
return str_contains($description, 'carried over')
|
||
|| str_contains($description, 'carry-forward')
|
||
|| str_contains($description, 'carry over')
|
||
|| str_contains($description, 'previous school year');
|
||
}
|
||
|
||
private function financialSummary(int $parentId, string $sourceSchoolYear, string $targetSchoolYear): array
|
||
{
|
||
return $this->getEnrollmentFinancialSummary($parentId, $sourceSchoolYear, $targetSchoolYear);
|
||
}
|
||
|
||
private function invoiceBalanceForParent(int $parentId, string $schoolYear): float
|
||
{
|
||
return $this->positiveOutstandingBalance($parentId, $schoolYear);
|
||
}
|
||
|
||
private function positiveOutstandingBalance(int $parentId, string $schoolYear): float
|
||
{
|
||
if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('invoices')) {
|
||
return 0.0;
|
||
}
|
||
|
||
$rows = $this->db->table('invoices')
|
||
->select('id, invoice_number, semester, description, balance, status')
|
||
->where('parent_id', $parentId)
|
||
->where('school_year', $schoolYear)
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$total = 0.0;
|
||
foreach ($rows as $row) {
|
||
$total += round($this->enrollmentOutstandingBalance($row), 2);
|
||
}
|
||
|
||
return round($total, 2);
|
||
}
|
||
|
||
/**
|
||
* Enrollment should not block on invoices already settled in the billing UI, and
|
||
* should not inflate balances when ledger recalculation omits legacy discount rows.
|
||
*
|
||
* @param array<string, mixed> $invoice
|
||
*/
|
||
private function enrollmentOutstandingBalance(array $invoice): float
|
||
{
|
||
$storedBalance = round((float) ($invoice['balance'] ?? 0.0), 2);
|
||
if ($storedBalance <= 0.0) {
|
||
return 0.0;
|
||
}
|
||
|
||
$normalizedStatus = FinancialStatus::normalizeInvoiceStatus((string) ($invoice['status'] ?? ''));
|
||
if (in_array($normalizedStatus, [
|
||
FinancialStatus::INVOICE_PAID,
|
||
FinancialStatus::INVOICE_CREDITED,
|
||
FinancialStatus::INVOICE_OVERPAID,
|
||
FinancialStatus::INVOICE_VOIDED,
|
||
FinancialStatus::INVOICE_CANCELLED,
|
||
], true)) {
|
||
return 0.0;
|
||
}
|
||
|
||
$ledgerBalance = max(0.0, round($this->resolvedInvoiceBalance($invoice), 2));
|
||
|
||
return min($storedBalance, $ledgerBalance);
|
||
}
|
||
|
||
private function isSettledInvoiceStatus(string $status): bool
|
||
{
|
||
$normalizedStatus = FinancialStatus::normalizeInvoiceStatus($status);
|
||
|
||
return in_array($normalizedStatus, [
|
||
FinancialStatus::INVOICE_PAID,
|
||
FinancialStatus::INVOICE_CREDITED,
|
||
FinancialStatus::INVOICE_OVERPAID,
|
||
FinancialStatus::INVOICE_VOIDED,
|
||
FinancialStatus::INVOICE_CANCELLED,
|
||
], true);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $invoice
|
||
*/
|
||
private function resolvedInvoiceBalance(array $invoice): float
|
||
{
|
||
$invoiceId = (int) ($invoice['id'] ?? 0);
|
||
if ($invoiceId <= 0) {
|
||
return round((float) ($invoice['balance'] ?? 0.0), 2);
|
||
}
|
||
|
||
if (! array_key_exists($invoiceId, $this->resolvedInvoiceBalanceCache)) {
|
||
try {
|
||
$calculation = $this->invoiceLedger()->calculateInvoice($invoiceId);
|
||
$this->resolvedInvoiceBalanceCache[$invoiceId] = round((float) ($calculation['balance'] ?? 0.0), 2);
|
||
} catch (\Throwable) {
|
||
$this->resolvedInvoiceBalanceCache[$invoiceId] = round((float) ($invoice['balance'] ?? 0.0), 2);
|
||
}
|
||
}
|
||
|
||
return $this->resolvedInvoiceBalanceCache[$invoiceId];
|
||
}
|
||
|
||
private function invoiceLedger(): InvoiceLedgerService
|
||
{
|
||
return $this->invoiceLedgerService ??= new InvoiceLedgerService();
|
||
}
|
||
|
||
private function applyScopedException(array &$evaluation, int $parentId, int $studentId, string $sourceSchoolYear, string $targetSchoolYear): void
|
||
{
|
||
$blockingCodes = array_values(array_unique(array_map('strval', $evaluation['blocking_rule_codes'] ?? [])));
|
||
$reviewCodes = array_values(array_unique(array_map('strval', $evaluation['review_rule_codes'] ?? [])));
|
||
$failedCodes = array_values(array_unique(array_merge($blockingCodes, $reviewCodes)));
|
||
if ($failedCodes === []) {
|
||
return;
|
||
}
|
||
|
||
$nonOverridable = array_values(array_intersect($failedCodes, self::NON_OVERRIDABLE_RULE_CODES));
|
||
if ($nonOverridable !== []) {
|
||
return;
|
||
}
|
||
|
||
$exception = $this->applicableException($parentId, $studentId, $targetSchoolYear);
|
||
if ($exception === null) {
|
||
return;
|
||
}
|
||
|
||
$overridableFailed = array_values(array_diff($failedCodes, self::NON_OVERRIDABLE_RULE_CODES));
|
||
$recordedCodes = json_decode((string) ($exception['bypassed_rule_codes_json'] ?? ''), true);
|
||
$recordedCodes = is_array($recordedCodes) ? array_values(array_filter(array_map('strval', $recordedCodes))) : [];
|
||
$applicableCodes = array_values(array_intersect($overridableFailed, $recordedCodes));
|
||
if ($applicableCodes === []) {
|
||
return;
|
||
}
|
||
|
||
$evaluation['admin_exception'] = [
|
||
'id' => (int) $exception['id'],
|
||
'reason_code' => (string) ($exception['reason_code'] ?? ''),
|
||
'source_school_year' => $sourceSchoolYear,
|
||
'status' => (string) ($exception['status'] ?? ''),
|
||
'bypassed_rule_codes' => $applicableCodes,
|
||
];
|
||
$evaluation['decision'] = self::DECISION_EXCEPTION_ELIGIBLE;
|
||
$evaluation['parent_enrollment_allowed'] = true;
|
||
$evaluation['can_enroll'] = true;
|
||
}
|
||
|
||
/**
|
||
* Active exceptions authorize enrollment. Used exceptions remain authoritative for the
|
||
* same school year so later admin steps (class assignment, status advances) stay allowed.
|
||
*/
|
||
private function applicableException(int $parentId, int $studentId, string $schoolYear): ?array
|
||
{
|
||
if ($parentId <= 0 || $studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('enrollment_exceptions')) {
|
||
return null;
|
||
}
|
||
|
||
$now = date('Y-m-d H:i:s');
|
||
$rows = $this->db->table('enrollment_exceptions')
|
||
->where('parent_id', $parentId)
|
||
->where('student_id', $studentId)
|
||
->where('school_year', $schoolYear)
|
||
->whereIn('status', ['active', 'used'])
|
||
->orderBy('created_at', 'DESC')
|
||
->orderBy('id', 'DESC')
|
||
->get()
|
||
->getResultArray();
|
||
|
||
foreach ($rows as $row) {
|
||
$status = strtolower(trim((string) ($row['status'] ?? '')));
|
||
if ($status === 'used') {
|
||
return $row;
|
||
}
|
||
|
||
if ($status !== 'active') {
|
||
continue;
|
||
}
|
||
|
||
$startsAt = trim((string) ($row['starts_at'] ?? ''));
|
||
$expiresAt = trim((string) ($row['expires_at'] ?? ''));
|
||
if ($startsAt !== '' && $startsAt > $now) {
|
||
continue;
|
||
}
|
||
if ($expiresAt !== '' && $expiresAt < $now) {
|
||
continue;
|
||
}
|
||
|
||
return $row;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function activeException(int $parentId, int $studentId, string $schoolYear): ?array
|
||
{
|
||
return $this->applicableException($parentId, $studentId, $schoolYear);
|
||
}
|
||
|
||
private function finalizeParentDecision(array &$evaluation): void
|
||
{
|
||
if (($evaluation['admin_exception'] ?? null) !== null && ! in_array('ALREADY_ENROLLED', $evaluation['blocking_rule_codes'] ?? [], true)) {
|
||
$evaluation['decision'] = self::DECISION_EXCEPTION_ELIGIBLE;
|
||
$evaluation['parent_enrollment_allowed'] = true;
|
||
$evaluation['can_enroll'] = true;
|
||
return;
|
||
}
|
||
|
||
if (in_array('ALREADY_ENROLLED', $evaluation['blocking_rule_codes'] ?? [], true)) {
|
||
$evaluation['decision'] = self::DECISION_ALREADY_ENROLLED;
|
||
$evaluation['parent_enrollment_allowed'] = false;
|
||
$evaluation['can_enroll'] = false;
|
||
return;
|
||
}
|
||
|
||
if (($evaluation['blocking_rule_codes'] ?? []) !== []) {
|
||
$evaluation['decision'] = self::DECISION_INELIGIBLE;
|
||
$evaluation['parent_enrollment_allowed'] = false;
|
||
$evaluation['can_enroll'] = false;
|
||
return;
|
||
}
|
||
|
||
if (($evaluation['review_rule_codes'] ?? []) !== []) {
|
||
$evaluation['decision'] = self::DECISION_REVIEW_REQUIRED;
|
||
$evaluation['parent_enrollment_allowed'] = false;
|
||
$evaluation['can_enroll'] = false;
|
||
return;
|
||
}
|
||
|
||
if (! empty($evaluation['warnings']) || ($evaluation['warning_rule_codes'] ?? []) !== []) {
|
||
$evaluation['decision'] = self::DECISION_ELIGIBLE_WITH_WARNING;
|
||
} else {
|
||
$evaluation['decision'] = self::DECISION_ELIGIBLE;
|
||
}
|
||
|
||
$evaluation['parent_enrollment_allowed'] = true;
|
||
$evaluation['can_enroll'] = true;
|
||
}
|
||
}
|