fix financial and certificates

This commit is contained in:
root
2026-06-05 01:51:08 -04:00
parent d28d11e2e5
commit ad968eaff7
94 changed files with 9654 additions and 214 deletions
@@ -0,0 +1,87 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
foreach (['students', 'enrollments', 'student_promotion_records'] as $table) {
if (!Schema::hasTable($table)) {
continue;
}
Schema::table($table, function (Blueprint $t) use ($table) {
if (!Schema::hasColumn($table, 'lifecycle_mode')) {
$t->string('lifecycle_mode', 20)->default('legacy');
}
if (!Schema::hasColumn($table, 'lifecycle_policy_version')) {
$t->string('lifecycle_policy_version', 30)->default('legacy_v1');
}
});
}
if (!Schema::hasTable('section_placement_batches')) {
Schema::create('section_placement_batches', function (Blueprint $table) {
$table->id();
$table->string('from_school_year', 25);
$table->string('to_school_year', 25);
$table->unsignedInteger('from_grade_level_id')->nullable();
$table->unsignedInteger('to_grade_level_id')->nullable();
$table->unsignedInteger('section_capacity_used');
$table->unsignedInteger('total_students')->default(0);
$table->unsignedInteger('section_count')->default(0);
$table->string('algorithm', 100)->default('score_band_balanced_v1');
$table->json('configuration_snapshot_json')->nullable();
$table->json('preview_snapshot_json')->nullable();
$table->unsignedInteger('created_by')->nullable();
$table->unsignedInteger('finalized_by')->nullable();
$table->dateTime('finalized_at')->nullable();
$table->string('status', 30)->default('draft');
$table->timestamps();
$table->index(['from_school_year', 'to_school_year', 'status'], 'placement_batches_year_status_idx');
});
}
if (!Schema::hasTable('section_placement_batch_students')) {
Schema::create('section_placement_batch_students', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('batch_id');
$table->unsignedInteger('student_id');
$table->string('student_type', 30);
$table->unsignedInteger('source_decision_id')->nullable();
$table->unsignedInteger('source_enrollment_id')->nullable();
$table->decimal('final_score', 6, 2)->nullable();
$table->decimal('placement_score', 6, 2)->nullable();
$table->string('score_band', 1);
$table->string('placement_band_reason', 60);
$table->unsignedInteger('planned_section_index')->nullable();
$table->unsignedInteger('assigned_section_id')->nullable();
$table->unsignedInteger('assignment_order')->default(0);
$table->boolean('was_override')->default(false);
$table->text('override_reason')->nullable();
$table->dateTime('created_at')->nullable();
$table->unique(['batch_id', 'student_id'], 'placement_batch_student_unique');
$table->index(['student_id', 'assigned_section_id'], 'placement_batch_student_assignment_idx');
});
}
if (Schema::hasTable('configuration')) {
$exists = DB::table('configuration')->where('config_key', 'promotion.section_capacity')->exists();
if (!$exists) {
DB::table('configuration')->insert([
'config_key' => 'promotion.section_capacity',
'config_value' => '20',
]);
}
}
}
public function down(): void
{
Schema::dropIfExists('section_placement_batch_students');
Schema::dropIfExists('section_placement_batches');
}
};
@@ -0,0 +1,111 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
foreach (['homework', 'quiz', 'project', 'participation', 'midterm_exam', 'final_exam'] as $table) {
if (!Schema::hasTable($table)) {
continue;
}
Schema::table($table, function (Blueprint $tableBlueprint) use ($table) {
if (!Schema::hasColumn($table, 'max_points')) {
$tableBlueprint->decimal('max_points', 8, 2)->default(100)->after('score');
}
if (!Schema::hasColumn($table, 'status')) {
$tableBlueprint->string('status', 30)->nullable()->after('max_points');
}
if (!Schema::hasColumn($table, 'excused_reason')) {
$tableBlueprint->text('excused_reason')->nullable()->after('status');
}
if (!Schema::hasColumn($table, 'locked_at')) {
$tableBlueprint->timestamp('locked_at')->nullable()->after('excused_reason');
}
if (!Schema::hasColumn($table, 'locked_by')) {
$tableBlueprint->unsignedBigInteger('locked_by')->nullable()->after('locked_at');
}
});
DB::table($table)
->whereNull('status')
->update([
'status' => DB::raw("CASE WHEN score IS NULL THEN 'pending' ELSE 'scored' END"),
]);
}
if (Schema::hasTable('semester_scores')) {
Schema::table('semester_scores', function (Blueprint $table) {
if (!Schema::hasColumn('semester_scores', 'calculation_mode')) {
$table->string('calculation_mode', 30)->default('legacy')->after('semester_score');
}
if (!Schema::hasColumn('semester_scores', 'calculation_policy_version')) {
$table->string('calculation_policy_version', 50)->default('legacy_v1')->after('calculation_mode');
}
if (!Schema::hasColumn('semester_scores', 'snapshot_id')) {
$table->unsignedBigInteger('snapshot_id')->nullable()->after('calculation_policy_version');
}
});
DB::table('semester_scores')->whereNull('calculation_mode')->update(['calculation_mode' => 'legacy']);
DB::table('semester_scores')->whereNull('calculation_policy_version')->update(['calculation_policy_version' => 'legacy_v1']);
}
if (!Schema::hasTable('semester_score_snapshots')) {
Schema::create('semester_score_snapshots', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('student_id');
$table->unsignedBigInteger('school_id')->nullable();
$table->unsignedBigInteger('class_section_id');
$table->string('semester', 50);
$table->string('school_year', 50);
$table->unsignedBigInteger('grading_profile_id')->nullable();
$table->string('grading_profile_version', 50)->nullable();
$table->string('calculation_mode', 30)->default('strong');
$table->string('calculation_policy_version', 50)->default('strong_v1');
$table->json('input_json')->nullable();
$table->json('calculation_json')->nullable();
$table->decimal('semester_score', 8, 2)->nullable();
$table->unsignedBigInteger('calculated_by')->nullable();
$table->timestamp('calculated_at')->nullable();
$table->timestamps();
$table->index(['student_id', 'class_section_id', 'semester', 'school_year'], 'semester_snapshot_student_term_idx');
$table->index(['calculation_mode', 'calculation_policy_version'], 'semester_snapshot_policy_idx');
});
}
}
public function down(): void
{
Schema::dropIfExists('semester_score_snapshots');
if (Schema::hasTable('semester_scores')) {
Schema::table('semester_scores', function (Blueprint $table) {
foreach (['snapshot_id', 'calculation_policy_version', 'calculation_mode'] as $column) {
if (Schema::hasColumn('semester_scores', $column)) {
$table->dropColumn($column);
}
}
});
}
foreach (['homework', 'quiz', 'project', 'participation', 'midterm_exam', 'final_exam'] as $tableName) {
if (!Schema::hasTable($tableName)) {
continue;
}
Schema::table($tableName, function (Blueprint $table) use ($tableName) {
foreach (['locked_by', 'locked_at', 'excused_reason', 'status', 'max_points'] as $column) {
if (Schema::hasColumn($tableName, $column)) {
$table->dropColumn($column);
}
}
});
}
}
};
@@ -0,0 +1,168 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('finance_follow_up_notes')) {
Schema::create('finance_follow_up_notes', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('parent_id')->index();
$table->unsignedBigInteger('invoice_id')->nullable()->index();
$table->string('school_year')->nullable()->index();
$table->string('status')->default('not_contacted')->index();
$table->string('contact_method')->nullable();
$table->date('promise_to_pay_date')->nullable();
$table->decimal('promise_to_pay_amount', 12, 2)->nullable();
$table->date('next_follow_up_date')->nullable()->index();
$table->unsignedTinyInteger('escalation_level')->default(0);
$table->text('note')->nullable();
$table->unsignedBigInteger('created_by')->nullable()->index();
$table->timestamps();
});
}
if (!Schema::hasTable('finance_balance_carryforwards')) {
Schema::create('finance_balance_carryforwards', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('parent_id')->index();
$table->unsignedBigInteger('student_id')->nullable()->index();
$table->string('from_school_year')->index();
$table->string('to_school_year')->index();
$table->json('source_invoice_ids_json')->nullable();
$table->decimal('source_balance_amount', 12, 2)->default(0);
$table->decimal('carryforward_amount', 12, 2)->default(0);
$table->decimal('waived_amount', 12, 2)->default(0);
$table->decimal('adjusted_amount', 12, 2)->default(0);
$table->decimal('remaining_amount', 12, 2)->default(0);
$table->string('status')->default('draft')->index();
$table->string('reason')->nullable();
$table->unsignedBigInteger('created_by')->nullable()->index();
$table->unsignedBigInteger('approved_by')->nullable()->index();
$table->timestamp('approved_at')->nullable();
$table->unsignedBigInteger('posted_invoice_id')->nullable()->index();
$table->unsignedBigInteger('posted_invoice_line_item_id')->nullable()->index();
$table->json('metadata_json')->nullable();
$table->timestamps();
$table->unique(['parent_id', 'from_school_year', 'to_school_year'], 'finance_carryforward_unique_parent_years');
});
}
if (!Schema::hasTable('invoice_installment_plans')) {
Schema::create('invoice_installment_plans', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('invoice_id')->index();
$table->unsignedBigInteger('parent_id')->index();
$table->string('school_year')->nullable()->index();
$table->string('plan_name')->nullable();
$table->decimal('total_amount', 12, 2)->default(0);
$table->unsignedInteger('number_of_installments')->default(1);
$table->string('status')->default('draft')->index();
$table->unsignedBigInteger('created_by')->nullable()->index();
$table->unsignedBigInteger('approved_by')->nullable()->index();
$table->timestamps();
});
}
if (!Schema::hasTable('invoice_installments')) {
Schema::create('invoice_installments', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('installment_plan_id')->index();
$table->unsignedBigInteger('invoice_id')->index();
$table->unsignedInteger('sequence');
$table->date('due_date')->index();
$table->decimal('amount_due', 12, 2)->default(0);
$table->decimal('amount_paid', 12, 2)->default(0);
$table->decimal('balance', 12, 2)->default(0);
$table->string('status')->default('pending')->index();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
$table->unique(['installment_plan_id', 'sequence'], 'invoice_installments_plan_sequence_unique');
});
}
if (!Schema::hasTable('payment_installment_allocations')) {
Schema::create('payment_installment_allocations', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('payment_id')->index();
$table->unsignedBigInteger('invoice_id')->index();
$table->unsignedBigInteger('installment_id')->index();
$table->decimal('amount', 12, 2)->default(0);
$table->timestamps();
});
}
if (!Schema::hasTable('finance_notification_logs')) {
Schema::create('finance_notification_logs', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('parent_id')->nullable()->index();
$table->unsignedBigInteger('student_id')->nullable()->index();
$table->unsignedBigInteger('invoice_id')->nullable()->index();
$table->unsignedBigInteger('payment_id')->nullable()->index();
$table->unsignedBigInteger('refund_id')->nullable()->index();
$table->unsignedBigInteger('event_charge_id')->nullable()->index();
$table->unsignedBigInteger('installment_id')->nullable()->index();
$table->string('notification_type')->index();
$table->string('channel')->default('mail')->index();
$table->string('recipient')->nullable();
$table->string('subject')->nullable();
$table->string('status')->default('queued')->index();
$table->timestamp('sent_at')->nullable();
$table->timestamp('failed_at')->nullable();
$table->text('error_message')->nullable();
$table->unsignedBigInteger('created_by')->nullable()->index();
$table->timestamps();
});
}
if (!Schema::hasTable('finance_notification_preferences')) {
Schema::create('finance_notification_preferences', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('parent_id')->unique();
$table->boolean('email_receipts_enabled')->default(true);
$table->boolean('email_reminders_enabled')->default(true);
$table->boolean('sms_receipts_enabled')->default(false);
$table->boolean('sms_reminders_enabled')->default(false);
$table->boolean('statement_notifications_enabled')->default(true);
$table->timestamps();
});
}
if (!Schema::hasTable('finance_receipt_sequences')) {
Schema::create('finance_receipt_sequences', function (Blueprint $table) {
$table->id();
$table->string('school_year')->unique();
$table->string('prefix')->default('R');
$table->unsignedBigInteger('next_number')->default(1);
$table->timestamps();
});
}
if (!Schema::hasTable('payment_carryforward_allocations')) {
Schema::create('payment_carryforward_allocations', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('payment_id')->index();
$table->unsignedBigInteger('carryforward_id')->index();
$table->decimal('amount', 12, 2)->default(0);
$table->timestamps();
});
}
}
public function down(): void
{
Schema::dropIfExists('payment_carryforward_allocations');
Schema::dropIfExists('finance_receipt_sequences');
Schema::dropIfExists('finance_notification_preferences');
Schema::dropIfExists('finance_notification_logs');
Schema::dropIfExists('payment_installment_allocations');
Schema::dropIfExists('invoice_installments');
Schema::dropIfExists('invoice_installment_plans');
Schema::dropIfExists('finance_balance_carryforwards');
Schema::dropIfExists('finance_follow_up_notes');
}
};
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('nav_items')) {
return;
}
$rows = DB::table('nav_items')
->select(['id', 'menu_parent_id', 'label'])
->whereNull('deleted_at')
->orderBy('menu_parent_id')
->orderBy('label')
->orderBy('id')
->get()
->groupBy(fn ($row) => $row->menu_parent_id === null ? 'root' : (string) $row->menu_parent_id);
foreach ($rows as $siblings) {
$ordered = $siblings->values()->all();
usort($ordered, function ($a, $b) {
$result = strnatcasecmp($this->labelKey((string) ($a->label ?? '')), $this->labelKey((string) ($b->label ?? '')));
return $result !== 0 ? $result : ((int) $a->id <=> (int) $b->id);
});
foreach ($ordered as $index => $item) {
DB::table('nav_items')
->where('id', (int) $item->id)
->update([
'sort_order' => ($index + 1) * 10,
'updated_at' => now(),
]);
}
}
}
public function down(): void
{
// Alphabetical sort order normalization is intentionally not reversible.
}
private function labelKey(string $s): string
{
$s = trim(preg_replace('/\s+/', ' ', $s));
$s = preg_replace('/^[^[:alnum:]]+/u', '', $s);
$s = preg_replace('/^(?i)(the|an|a)\s+/', '', $s);
return mb_strtolower($s, 'UTF-8');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('certificate_records') || Schema::hasColumn('certificate_records', 'verification_token')) {
return;
}
Schema::table('certificate_records', function (Blueprint $table): void {
$table->string('verification_token', 64)->nullable()->after('certificate_number');
$table->unique('verification_token');
});
}
public function down(): void
{
if (! Schema::hasTable('certificate_records') || ! Schema::hasColumn('certificate_records', 'verification_token')) {
return;
}
Schema::table('certificate_records', function (Blueprint $table): void {
$table->dropUnique(['verification_token']);
$table->dropColumn('verification_token');
});
}
};