fix financial and certificates
This commit is contained in:
@@ -24,6 +24,9 @@ use App\Services\Promotions\PromotionEnrollmentService;
|
||||
use App\Services\Promotions\PromotionQueryService;
|
||||
use App\Services\Promotions\PromotionReminderService;
|
||||
use App\Services\Promotions\PromotionStatusService;
|
||||
use App\Services\Promotions\Placement\SectionPlacementPreviewService;
|
||||
use App\Models\SectionPlacementBatch;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -42,7 +45,8 @@ class AdministratorPromotionController extends BaseApiController
|
||||
private PromotionEnrollmentService $enrollmentService,
|
||||
private PromotionReminderService $reminders,
|
||||
private PromotionAuditService $audit,
|
||||
private LevelProgressionService $progression
|
||||
private LevelProgressionService $progression,
|
||||
private SectionPlacementPreviewService $sectionPlacement
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -133,6 +137,69 @@ class AdministratorPromotionController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function createPlacementPreview(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'from_school_year' => ['required', 'string', 'max:25'],
|
||||
'to_school_year' => ['required', 'string', 'max:25'],
|
||||
'from_grade_level_id' => ['nullable', 'integer', 'min:1'],
|
||||
'to_grade_level_id' => ['required', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$batch = $this->sectionPlacement->createDraft(
|
||||
$payload['from_school_year'],
|
||||
$payload['to_school_year'],
|
||||
$payload['from_grade_level_id'] ?? null,
|
||||
$payload['to_grade_level_id'] ?? null,
|
||||
$this->getCurrentUserId()
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Section placement preview generation failed', ['exception' => $e]);
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->sectionPlacement->previewPayload($batch),
|
||||
]);
|
||||
}
|
||||
|
||||
public function showPlacementBatch(int $batchId): JsonResponse
|
||||
{
|
||||
$batch = SectionPlacementBatch::query()->find($batchId);
|
||||
if (!$batch) {
|
||||
return response()->json(['ok' => false, 'message' => 'Placement batch not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->sectionPlacement->previewPayload($batch),
|
||||
]);
|
||||
}
|
||||
|
||||
public function finalizePlacementBatch(int $batchId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$batch = $this->sectionPlacement->finalize($batchId, $this->getCurrentUserId());
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Section placement finalization failed', ['batch_id' => $batchId, 'exception' => $e]);
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->sectionPlacement->previewPayload($batch),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateStatus(UpdatePromotionStatusRequest $request, int $promotionId): JsonResponse
|
||||
{
|
||||
$record = $this->findOrFail($promotionId);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Administrator;
|
||||
|
||||
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||
use App\Http\Requests\Administrator\TrophyReportRequest;
|
||||
use App\Services\Administrator\Trophy\TrophyReportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class TrophyController extends BaseApiController
|
||||
{
|
||||
public function __construct(private TrophyReportService $service)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function index(TrophyReportRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->projection(
|
||||
$payload['school_year'] ?? null,
|
||||
isset($payload['percentile']) ? (float) $payload['percentile'] : null
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function winners(TrophyReportRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->winners(
|
||||
$payload['school_year'] ?? null,
|
||||
isset($payload['percentile']) ? (float) $payload['percentile'] : null
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function final(TrophyReportRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->final(
|
||||
$payload['school_year'] ?? null,
|
||||
isset($payload['percentile']) ? (float) $payload['percentile'] : null
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,149 +5,141 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\Certificates;
|
||||
|
||||
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||
use App\Models\Configuration;
|
||||
use App\Services\Certificates\CertificateAdminService;
|
||||
use App\Services\Certificates\CertificatePdfService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CertificateController extends BaseApiController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
public function __construct(
|
||||
private CertificateAdminService $service,
|
||||
private CertificatePdfService $pdfService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/administrator/certificates/form-options
|
||||
*/
|
||||
public function formOptions(Request $request): JsonResponse
|
||||
public function dashboard(Request $request): JsonResponse
|
||||
{
|
||||
$schoolYearParam = $request->query('school_year');
|
||||
if ($schoolYearParam !== null) {
|
||||
$schoolYear = trim((string) $schoolYearParam);
|
||||
} else {
|
||||
try {
|
||||
$schoolYear = trim((string) (Configuration::getConfig('school_year') ?? ''));
|
||||
} catch (\Throwable) {
|
||||
$schoolYear = '';
|
||||
}
|
||||
}
|
||||
$classSectionId = $request->query('class_section_id');
|
||||
|
||||
try {
|
||||
$classSections = DB::table('classSection')
|
||||
->select('class_section_id', 'class_section_name')
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->get()
|
||||
->map(fn ($r) => (array) $r)
|
||||
->all();
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->dashboard($request->query('school_year')),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Certificate formOptions class query failed: ' . $e->getMessage());
|
||||
return $this->error('Failed to load classes: ' . $e->getMessage(), 500);
|
||||
Log::error('Certificate dashboard failed', ['exception' => $e]);
|
||||
|
||||
return $this->error('Failed to load certificate dashboard.', 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function formOptions(Request $request): JsonResponse
|
||||
{
|
||||
return $this->dashboard($request);
|
||||
}
|
||||
|
||||
public function auditLog(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->auditLog($request->query('school_year')),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Certificate audit log failed', ['exception' => $e]);
|
||||
|
||||
return $this->error('Failed to load certificate audit log.', 500);
|
||||
}
|
||||
|
||||
$students = [];
|
||||
if ($classSectionId) {
|
||||
try {
|
||||
$studentQuery = DB::table('student_class as sc')
|
||||
->select('s.id', 's.firstname', 's.lastname', 'cs.class_section_name as grade')
|
||||
->join('students as s', 's.id', '=', 'sc.student_id')
|
||||
->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->where('sc.class_section_id', (int) $classSectionId)
|
||||
->where('s.is_active', 1)
|
||||
->orderBy('s.lastname')
|
||||
->orderBy('s.firstname');
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$studentQuery->where('sc.school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$students = $studentQuery->get()->map(fn ($r) => (array) $r)->all();
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Certificate formOptions student query failed: ' . $e->getMessage());
|
||||
return $this->error('Failed to load students: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'class_sections' => $classSections,
|
||||
'students' => $students,
|
||||
'school_year' => $schoolYear,
|
||||
'selected_class_id' => $classSectionId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/administrator/certificates/generate
|
||||
*/
|
||||
public function generate(Request $request): Response|JsonResponse
|
||||
{
|
||||
$studentIds = $request->input('student_ids', []);
|
||||
$certDate = trim((string) ($request->input('cert_date') ?? date('m/d/Y')));
|
||||
$studentIds = $request->input('student_ids', []);
|
||||
$certDate = trim((string) ($request->input('cert_date') ?? date('m/d/Y')));
|
||||
$classSectionId = $request->input('class_section_id');
|
||||
|
||||
if (empty($studentIds) || !is_array($studentIds)) {
|
||||
return $this->error('Please select at least one student.', 422);
|
||||
}
|
||||
|
||||
$studentIds = array_values(array_filter(array_map('intval', $studentIds)));
|
||||
$certDate = preg_replace('/[^0-9\/\-]/', '', $certDate) ?? date('m/d/Y');
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return $this->error('Invalid student selection.', 422);
|
||||
}
|
||||
|
||||
$students = [];
|
||||
foreach ($studentIds as $id) {
|
||||
$row = null;
|
||||
|
||||
if ($classSectionId) {
|
||||
$row = DB::table('student_class as sc')
|
||||
->select('s.id', 's.firstname', 's.lastname', 'cs.class_section_name as grade')
|
||||
->join('students as s', 's.id', '=', 'sc.student_id')
|
||||
->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->where('sc.class_section_id', (int) $classSectionId)
|
||||
->where('s.id', $id)
|
||||
->first();
|
||||
}
|
||||
|
||||
if (!$row) {
|
||||
$row = DB::table('students')
|
||||
->select('id', 'firstname', 'lastname', 'registration_grade as grade')
|
||||
->where('id', $id)
|
||||
->first();
|
||||
}
|
||||
|
||||
if ($row) {
|
||||
$students[] = (array) $row;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($students)) {
|
||||
return $this->error('No valid students found.', 422);
|
||||
}
|
||||
$schoolYear = $request->input('school_year');
|
||||
|
||||
try {
|
||||
$pdfService = app(CertificatePdfService::class);
|
||||
$pdfContent = $pdfService->generate($students, $certDate);
|
||||
$payload = $this->service->issueCertificates(
|
||||
is_array($studentIds) ? $studentIds : [],
|
||||
$certDate,
|
||||
$classSectionId !== null && $classSectionId !== '' ? (int) $classSectionId : null,
|
||||
is_string($schoolYear) ? $schoolYear : null,
|
||||
$this->getCurrentUserId()
|
||||
);
|
||||
$pdfContent = $this->pdfService->generate(
|
||||
$payload['students'],
|
||||
$payload['cert_date_display']
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage(), 422);
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Certificate PDF generation failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||
Log::error('Certificate generation failed', ['exception' => $e]);
|
||||
|
||||
return $this->error('Failed to generate certificates.', 500);
|
||||
}
|
||||
|
||||
$filename = 'Certificates_' . date('Ymd_His') . '.pdf';
|
||||
$filename = 'Certificates_'.date('Ymd_His').'.pdf';
|
||||
|
||||
return response($pdfContent, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="' . $filename . '"',
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.$filename.'"',
|
||||
]);
|
||||
}
|
||||
|
||||
private function currentSchoolYear(): string
|
||||
public function reprint(string $certificateNumber): Response|JsonResponse
|
||||
{
|
||||
return trim((string) (Configuration::getConfig('school_year') ?? ''));
|
||||
try {
|
||||
$payload = $this->service->reprintPayload($certificateNumber);
|
||||
if ($payload === null) {
|
||||
return $this->error('Certificate not found: '.$certificateNumber, 404);
|
||||
}
|
||||
|
||||
$pdfContent = $this->pdfService->generate(
|
||||
[$payload['student']],
|
||||
$payload['cert_date_display']
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Certificate reprint failed', [
|
||||
'certificate_number' => $certificateNumber,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return $this->error('Failed to reprint certificate.', 500);
|
||||
}
|
||||
|
||||
$filename = 'Certificate_'.strtoupper($certificateNumber).'.pdf';
|
||||
|
||||
return response($pdfContent, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.$filename.'"',
|
||||
]);
|
||||
}
|
||||
|
||||
public function verify(string $token): JsonResponse
|
||||
{
|
||||
try {
|
||||
$payload = $this->service->verifyPayload($token);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Certificate verify failed', ['token' => $token, 'exception' => $e]);
|
||||
|
||||
return $this->error('Failed to verify certificate.', 500);
|
||||
}
|
||||
|
||||
if ($payload === null) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Certificate not found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $payload,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Finance\CarryforwardAdjustmentRequest;
|
||||
use App\Http\Requests\Finance\CarryforwardFinalizeRequest;
|
||||
use App\Http\Requests\Finance\CarryforwardPreviewRequest;
|
||||
use App\Services\Finance\PriorYearBalanceCarryforwardService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class BalanceCarryforwardController extends Controller
|
||||
{
|
||||
public function __construct(private PriorYearBalanceCarryforwardService $service) {}
|
||||
|
||||
public function preview(CarryforwardPreviewRequest $request): JsonResponse
|
||||
{
|
||||
return response()->json(['ok' => true, 'preview' => $this->service->preview($request->validated())]);
|
||||
}
|
||||
|
||||
public function storeDraft(CarryforwardFinalizeRequest $request): JsonResponse
|
||||
{
|
||||
return response()->json(['ok' => true, 'result' => $this->service->storeDrafts($request->validated(), optional($request->user())->id)]);
|
||||
}
|
||||
|
||||
public function approve(Request $request, int $carryforward): JsonResponse
|
||||
{
|
||||
return response()->json(['ok' => true, 'carryforward' => $this->service->approve($carryforward, optional($request->user())->id)]);
|
||||
}
|
||||
|
||||
public function postToNewYear(Request $request, int $carryforward): JsonResponse
|
||||
{
|
||||
return response()->json(['ok' => true, 'carryforward' => $this->service->postToNewYear($carryforward, optional($request->user())->id)]);
|
||||
}
|
||||
|
||||
public function waive(CarryforwardAdjustmentRequest $request, int $carryforward): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
return response()->json(['ok' => true, 'carryforward' => $this->service->waive($carryforward, (float) $data['amount'], $data['reason'] ?? null, optional($request->user())->id)]);
|
||||
}
|
||||
|
||||
public function adjust(CarryforwardAdjustmentRequest $request, int $carryforward): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
return response()->json(['ok' => true, 'carryforward' => $this->service->adjust($carryforward, (float) $data['amount'], $data['reason'] ?? null)]);
|
||||
}
|
||||
|
||||
public function report(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json(['ok' => true, 'report' => $this->service->report($request->query())]);
|
||||
}
|
||||
|
||||
public function reportCsv(Request $request): StreamedResponse
|
||||
{
|
||||
$rows = $this->service->csvRows($this->service->report($request->query()));
|
||||
return response()->streamDownload(function () use ($rows) {
|
||||
$out = fopen('php://output', 'w');
|
||||
foreach ($rows as $row) fputcsv($out, $row);
|
||||
fclose($out);
|
||||
}, 'carryforward_report_' . date('Ymd_His') . '.csv', ['Content-Type' => 'text/csv']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Finance\EventChargeLifecycleRequest;
|
||||
use App\Models\EventCharge;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class EventChargeController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$q = EventCharge::query();
|
||||
foreach (['parent_id','student_id','event_id','school_year','semester'] as $field) {
|
||||
if ($request->filled($field)) $q->where($field, $request->query($field));
|
||||
}
|
||||
if ($request->filled('status')) {
|
||||
$status = $request->query('status');
|
||||
if (Schema::hasColumn('event_charges', 'status')) $q->where('status', $status);
|
||||
elseif ($status === 'paid') $q->where('event_paid', 1);
|
||||
elseif ($status === 'pending') $q->where(function ($qq) { $qq->whereNull('event_paid')->orWhere('event_paid', 0); });
|
||||
}
|
||||
return response()->json(['ok' => true, 'event_charges' => $q->orderByDesc('id')->paginate((int) $request->query('per_page', 25))]);
|
||||
}
|
||||
|
||||
public function store(EventChargeLifecycleRequest $request): JsonResponse
|
||||
{
|
||||
$data = $this->normalizePayload($request->validated());
|
||||
$data['created_by'] = optional($request->user())->id;
|
||||
if (Schema::hasColumn('event_charges', 'status')) $data['status'] = $data['status'] ?? 'draft';
|
||||
$charge = EventCharge::query()->create($data);
|
||||
return response()->json(['ok' => true, 'event_charge' => $charge], 201);
|
||||
}
|
||||
|
||||
public function show(int $eventCharge): JsonResponse
|
||||
{
|
||||
return response()->json(['ok' => true, 'event_charge' => EventCharge::query()->findOrFail($eventCharge)]);
|
||||
}
|
||||
|
||||
public function update(EventChargeLifecycleRequest $request, int $eventCharge): JsonResponse
|
||||
{
|
||||
$charge = EventCharge::query()->findOrFail($eventCharge);
|
||||
$charge->fill($this->normalizePayload($request->validated()))->save();
|
||||
return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]);
|
||||
}
|
||||
|
||||
public function destroy(int $eventCharge): JsonResponse
|
||||
{
|
||||
return $this->void($eventCharge);
|
||||
}
|
||||
|
||||
public function approve(int $eventCharge): JsonResponse
|
||||
{
|
||||
$charge = EventCharge::query()->findOrFail($eventCharge);
|
||||
if (Schema::hasColumn('event_charges', 'status')) $charge->status = 'approved';
|
||||
if (Schema::hasColumn('event_charges', 'charged')) $charge->charged = 1;
|
||||
$charge->save();
|
||||
return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]);
|
||||
}
|
||||
|
||||
public function void(int $eventCharge): JsonResponse
|
||||
{
|
||||
$charge = EventCharge::query()->findOrFail($eventCharge);
|
||||
if (Schema::hasColumn('event_charges', 'status')) $charge->status = 'voided';
|
||||
if (Schema::hasColumn('event_charges', 'charged')) $charge->charged = 0;
|
||||
$charge->save();
|
||||
return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]);
|
||||
}
|
||||
|
||||
public function attachToInvoice(Request $request, int $eventCharge): JsonResponse
|
||||
{
|
||||
$request->validate(['invoice_id' => ['required','integer']]);
|
||||
$charge = EventCharge::query()->findOrFail($eventCharge);
|
||||
$invoiceId = (int) $request->input('invoice_id');
|
||||
abort_if(!DB::table('invoices')->where('id', $invoiceId)->exists(), 404, 'Invoice not found.');
|
||||
if (Schema::hasColumn('event_charges', 'invoice_id')) $charge->invoice_id = $invoiceId;
|
||||
if (Schema::hasColumn('event_charges', 'status')) $charge->status = 'invoiced';
|
||||
if (Schema::hasColumn('event_charges', 'charged')) $charge->charged = 1;
|
||||
$charge->save();
|
||||
return response()->json(['ok' => true, 'event_charge' => $charge->fresh(), 'warning' => Schema::hasColumn('event_charges','invoice_id') ? null : 'invoice_id column does not exist; charge marked as charged only.']);
|
||||
}
|
||||
|
||||
private function normalizePayload(array $data): array
|
||||
{
|
||||
if (isset($data['title']) && !isset($data['event_name'])) $data['event_name'] = $data['title'];
|
||||
unset($data['title'], $data['invoice_id']);
|
||||
return array_filter($data, fn($v) => $v !== null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Finance\FinanceNotificationLogRequest;
|
||||
use App\Services\Finance\FinanceNotificationLogService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FinanceNotificationController extends Controller
|
||||
{
|
||||
public function __construct(private FinanceNotificationLogService $service) {}
|
||||
|
||||
public function sendPaymentReceipt(Request $request, int $payment): JsonResponse
|
||||
{
|
||||
$p = DB::table('payments')->where('id', $payment)->first();
|
||||
abort_if(!$p, 404, 'Payment not found.');
|
||||
$receipt = $this->service->nextReceiptNumber($p->school_year ?? date('Y'));
|
||||
$log = $this->service->log([
|
||||
'parent_id' => $p->parent_id ?? null,
|
||||
'invoice_id' => $p->invoice_id ?? null,
|
||||
'payment_id' => $payment,
|
||||
'notification_type' => 'payment_receipt',
|
||||
'channel' => 'database',
|
||||
'recipient' => (string) ($p->parent_id ?? ''),
|
||||
'subject' => 'Payment receipt ' . $receipt,
|
||||
], optional($request->user())->id);
|
||||
return response()->json(['ok' => true, 'receipt_number' => $receipt, 'notification_log' => $log]);
|
||||
}
|
||||
|
||||
public function sendRefundReceipt(Request $request, int $refund): JsonResponse
|
||||
{
|
||||
$log = $this->service->log(['refund_id' => $refund, 'notification_type' => 'refund_receipt', 'channel' => 'database', 'subject' => 'Refund receipt'], optional($request->user())->id);
|
||||
return response()->json(['ok' => true, 'notification_log' => $log]);
|
||||
}
|
||||
|
||||
public function sendInvoiceStatement(Request $request, int $invoice): JsonResponse
|
||||
{
|
||||
$inv = DB::table('invoices')->where('id', $invoice)->first();
|
||||
abort_if(!$inv, 404, 'Invoice not found.');
|
||||
$log = $this->service->log(['parent_id' => $inv->parent_id ?? null, 'invoice_id' => $invoice, 'notification_type' => 'statement_available', 'channel' => 'database', 'subject' => 'Statement available'], optional($request->user())->id);
|
||||
return response()->json(['ok' => true, 'notification_log' => $log]);
|
||||
}
|
||||
|
||||
public function sendOverdueReminder(Request $request, int $parent): JsonResponse
|
||||
{
|
||||
$log = $this->service->log(['parent_id' => $parent, 'notification_type' => 'overdue_balance_reminder', 'channel' => 'database', 'subject' => 'Overdue balance reminder'], optional($request->user())->id);
|
||||
return response()->json(['ok' => true, 'notification_log' => $log]);
|
||||
}
|
||||
|
||||
public function sendInstallmentReminder(Request $request, int $installment): JsonResponse
|
||||
{
|
||||
$log = $this->service->log(['installment_id' => $installment, 'notification_type' => 'installment_reminder', 'channel' => 'database', 'subject' => 'Installment reminder'], optional($request->user())->id);
|
||||
return response()->json(['ok' => true, 'notification_log' => $log]);
|
||||
}
|
||||
|
||||
public function logs(FinanceNotificationLogRequest $request): JsonResponse
|
||||
{
|
||||
return response()->json(['ok' => true, 'logs' => $this->service->list($request->validated())]);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ namespace App\Http\Controllers\Api\Finance;
|
||||
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||
use App\Http\Requests\Finance\FinancialReportRequest;
|
||||
use App\Http\Requests\Finance\FinancialSummaryRequest;
|
||||
use App\Http\Requests\Finance\StakeholderFinancialAnalysisRequest;
|
||||
use App\Http\Requests\Finance\FinancialUnpaidParentsRequest;
|
||||
use App\Http\Requests\Finance\ParentPaymentFollowUpRequest;
|
||||
use App\Http\Requests\Finance\FollowUpNoteRequest;
|
||||
use App\Http\Resources\Finance\FinancialReportResource;
|
||||
use App\Http\Resources\Finance\FinancialSummaryResource;
|
||||
use App\Http\Resources\Finance\FinancialUnpaidParentResource;
|
||||
@@ -13,7 +16,9 @@ use App\Services\Finance\FinancialPdfReportService;
|
||||
use App\Services\Finance\FinancialReportService;
|
||||
use App\Services\Finance\FinancialSchoolYearService;
|
||||
use App\Services\Finance\FinancialSummaryService;
|
||||
use App\Services\Finance\StakeholderFinancialAnalysisService;
|
||||
use App\Services\Finance\FinancialUnpaidParentsService;
|
||||
use App\Services\Finance\ParentPaymentFollowUpService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
@@ -24,7 +29,9 @@ class FinancialController extends BaseApiController
|
||||
private FinancialSummaryService $summaryService,
|
||||
private FinancialUnpaidParentsService $unpaidParentsService,
|
||||
private FinancialSchoolYearService $schoolYears,
|
||||
private FinancialPdfReportService $pdfService
|
||||
private FinancialPdfReportService $pdfService,
|
||||
private StakeholderFinancialAnalysisService $stakeholderAnalysisService,
|
||||
private ParentPaymentFollowUpService $parentPaymentFollowUpService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -75,6 +82,95 @@ class FinancialController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public function parentPaymentFollowups(ParentPaymentFollowUpRequest $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'followups' => $this->parentPaymentFollowUpService->report($request->validated()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function parentPaymentFollowupsCsv(ParentPaymentFollowUpRequest $request): StreamedResponse
|
||||
{
|
||||
$report = $this->parentPaymentFollowUpService->report($request->validated());
|
||||
$rows = $this->parentPaymentFollowUpService->csvRows($report);
|
||||
|
||||
return response()->streamDownload(function () use ($rows) {
|
||||
$out = fopen('php://output', 'w');
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($out, $row);
|
||||
}
|
||||
fclose($out);
|
||||
}, 'parent_payment_followups_' . ($report['schoolYear'] ?? 'report') . '_' . date('Ymd_His') . '.csv', ['Content-Type' => 'text/csv']);
|
||||
}
|
||||
|
||||
public function storeParentFollowUpNote(FollowUpNoteRequest $request, int $parent): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'note' => $this->parentPaymentFollowUpService->storeNote($parent, $request->validated(), optional($request->user())->id),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function markParentContacted(FollowUpNoteRequest $request, int $parent): JsonResponse
|
||||
{
|
||||
$payload = array_merge($request->validated(), ['status' => $request->input('status', 'contacted')]);
|
||||
return response()->json(['ok' => true, 'note' => $this->parentPaymentFollowUpService->storeNote($parent, $payload, optional($request->user())->id)], 201);
|
||||
}
|
||||
|
||||
public function storePromiseToPay(FollowUpNoteRequest $request, int $parent): JsonResponse
|
||||
{
|
||||
$payload = array_merge($request->validated(), ['status' => 'promise_to_pay']);
|
||||
return response()->json(['ok' => true, 'note' => $this->parentPaymentFollowUpService->storeNote($parent, $payload, optional($request->user())->id)], 201);
|
||||
}
|
||||
|
||||
public function resolveParentFollowUp(FollowUpNoteRequest $request, int $parent): JsonResponse
|
||||
{
|
||||
$payload = array_merge($request->validated(), ['status' => 'resolved']);
|
||||
return response()->json(['ok' => true, 'note' => $this->parentPaymentFollowUpService->storeNote($parent, $payload, optional($request->user())->id)], 201);
|
||||
}
|
||||
|
||||
public function stakeholderAnalysis(StakeholderFinancialAnalysisRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
|
||||
$analysis = $this->stakeholderAnalysisService->analyze(
|
||||
$payload['date_from'] ?? null,
|
||||
$payload['date_to'] ?? null,
|
||||
$payload['school_year'] ?? null,
|
||||
$payload['compare_school_year'] ?? null
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'analysis' => $analysis,
|
||||
]);
|
||||
}
|
||||
|
||||
public function stakeholderAnalysisCsv(StakeholderFinancialAnalysisRequest $request): StreamedResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$analysis = $this->stakeholderAnalysisService->analyze(
|
||||
$payload['date_from'] ?? null,
|
||||
$payload['date_to'] ?? null,
|
||||
$payload['school_year'] ?? null,
|
||||
$payload['compare_school_year'] ?? null
|
||||
);
|
||||
$rows = $this->stakeholderAnalysisService->csvRows($analysis);
|
||||
$schoolYear = $analysis['schoolYear'] ?? 'report';
|
||||
|
||||
return response()->streamDownload(function () use ($rows) {
|
||||
$out = fopen('php://output', 'w');
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($out, $row);
|
||||
}
|
||||
fclose($out);
|
||||
}, 'stakeholder_financial_analysis_' . $schoolYear . '_' . date('Ymd_His') . '.csv', ['Content-Type' => 'text/csv']);
|
||||
}
|
||||
|
||||
public function downloadCsv(FinancialReportRequest $request): StreamedResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Finance\InstallmentPlanRequest;
|
||||
use App\Http\Requests\Finance\PaymentAllocationRequest;
|
||||
use App\Services\Finance\InstallmentPlanService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class InstallmentPlanController extends Controller
|
||||
{
|
||||
public function __construct(private InstallmentPlanService $service) {}
|
||||
|
||||
public function index(Request $request): JsonResponse { return response()->json(['ok' => true, 'plans' => $this->service->list($request->query())]); }
|
||||
public function store(InstallmentPlanRequest $request, int $invoice): JsonResponse { return response()->json(['ok' => true, 'plan' => $this->service->createForInvoice($invoice, $request->validated(), optional($request->user())->id)], 201); }
|
||||
public function show(int $plan): JsonResponse { return response()->json(['ok' => true, 'plan' => $this->service->show($plan)]); }
|
||||
public function activate(Request $request, int $plan): JsonResponse { return response()->json(['ok' => true, 'plan' => $this->service->activate($plan, optional($request->user())->id)]); }
|
||||
public function cancel(int $plan): JsonResponse { return response()->json(['ok' => true, 'plan' => $this->service->cancel($plan)]); }
|
||||
public function restructure(InstallmentPlanRequest $request, int $plan): JsonResponse
|
||||
{
|
||||
$old = $this->service->cancel($plan);
|
||||
return response()->json(['ok' => true, 'old_plan' => $old, 'message' => 'Old plan cancelled. Create a replacement plan against the invoice to preserve audit history.']);
|
||||
}
|
||||
public function allocatePayment(PaymentAllocationRequest $request, int $payment): JsonResponse { return response()->json(['ok' => true, 'result' => $this->service->allocatePayment($payment, $request->validated())]); }
|
||||
public function due(): JsonResponse { return response()->json(['ok' => true, 'installments' => $this->service->due(false)]); }
|
||||
public function overdue(): JsonResponse { $this->service->markOverdue(); return response()->json(['ok' => true, 'installments' => $this->service->due(true)]); }
|
||||
}
|
||||
@@ -128,6 +128,23 @@ class InvoiceController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function preview(int $invoiceId): JsonResponse
|
||||
{
|
||||
$data = $this->pdfService->previewData($invoiceId);
|
||||
|
||||
if (isset($data['error'])) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => $data['error'],
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
...$data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function pdf(int $invoiceId): StreamedResponse
|
||||
{
|
||||
$pdfBytes = $this->pdfService->buildPdf($invoiceId);
|
||||
|
||||
@@ -35,6 +35,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ReimbursementController extends BaseApiController
|
||||
{
|
||||
@@ -287,7 +288,7 @@ class ReimbursementController extends BaseApiController
|
||||
return response()->json(['ok' => true, 'message' => 'Email sent successfully.']);
|
||||
}
|
||||
|
||||
public function export(ReimbursementExportRequest $request): Response
|
||||
public function export(ReimbursementExportRequest $request): StreamedResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$type = $payload['type'] ?? 'processed';
|
||||
@@ -310,7 +311,7 @@ class ReimbursementController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportBatch(ReimbursementExportBatchRequest $request): Response
|
||||
public function exportBatch(ReimbursementExportBatchRequest $request): StreamedResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$csv = $this->exportService->buildBatchCsv((int) $payload['batch_id']);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Administrator;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
class TrophyReportRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'school_year' => ['nullable', 'string', 'max:50'],
|
||||
'percentile' => ['nullable', 'numeric', 'min:1', 'max:99'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Finance;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CarryforwardAdjustmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool { return true; }
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required','numeric','min:0'],
|
||||
'reason' => ['nullable','string','max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Finance;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CarryforwardFinalizeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool { return true; }
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'from_school_year' => ['required_without:id','string','max:20'],
|
||||
'to_school_year' => ['required_without:id','string','max:20'],
|
||||
'parent_id' => ['nullable','integer'],
|
||||
'reason' => ['nullable','string','max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Finance;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CarryforwardPreviewRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool { return true; }
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'from_school_year' => ['required','string','max:20'],
|
||||
'to_school_year' => ['required','string','max:20'],
|
||||
'parent_id' => ['nullable','integer'],
|
||||
'minimum_balance' => ['nullable','numeric','min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Finance;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class EventChargeLifecycleRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool { return true; }
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'event_id' => ['nullable','integer'],
|
||||
'parent_id' => ['nullable','integer'],
|
||||
'student_id' => ['nullable','integer'],
|
||||
'school_year' => ['nullable','string','max:20'],
|
||||
'semester' => ['nullable','string','max:40'],
|
||||
'event_name' => ['nullable','string','max:255'],
|
||||
'title' => ['nullable','string','max:255'],
|
||||
'description' => ['nullable','string'],
|
||||
'amount' => ['nullable','numeric','min:0'],
|
||||
'invoice_id' => ['nullable','integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Finance;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class FinanceNotificationLogRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool { return true; }
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['nullable','string','max:40'],
|
||||
'notification_type' => ['nullable','string','max:80'],
|
||||
'parent_id' => ['nullable','integer'],
|
||||
'date_from' => ['nullable','date'],
|
||||
'date_to' => ['nullable','date'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Finance;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class FollowUpNoteRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool { return true; }
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'invoice_id' => ['nullable','integer'],
|
||||
'school_year' => ['nullable','string','max:20'],
|
||||
'status' => ['nullable','string','max:60'],
|
||||
'contact_method' => ['nullable','string','max:60'],
|
||||
'promise_to_pay_date' => ['nullable','date'],
|
||||
'promise_to_pay_amount' => ['nullable','numeric','min:0'],
|
||||
'next_follow_up_date' => ['nullable','date'],
|
||||
'escalation_level' => ['nullable','integer','min:0','max:10'],
|
||||
'note' => ['nullable','string','max:10000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Finance;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InstallmentPlanRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool { return true; }
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'plan_name' => ['nullable','string','max:120'],
|
||||
'total_amount' => ['nullable','numeric','min:0'],
|
||||
'number_of_installments' => ['required','integer','min:1','max:24'],
|
||||
'first_due_date' => ['required','date'],
|
||||
'interval_months' => ['nullable','integer','min:1','max:12'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Finance;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ParentPaymentFollowUpRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool { return true; }
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'school_year' => ['nullable','string','max:20'],
|
||||
'date_from' => ['nullable','date'],
|
||||
'date_to' => ['nullable','date'],
|
||||
'minimum_balance' => ['nullable','numeric','min:0'],
|
||||
'aging_bucket' => ['nullable','string','max:40'],
|
||||
'parent_id' => ['nullable','integer'],
|
||||
'student_id' => ['nullable','integer'],
|
||||
'follow_up_status' => ['nullable','string','max:60'],
|
||||
'last_contacted_before' => ['nullable','date'],
|
||||
'next_follow_up_due_before' => ['nullable','date'],
|
||||
'promise_to_pay_due_before' => ['nullable','date'],
|
||||
'include_paid_invoices' => ['nullable','boolean'],
|
||||
'include_inactive_students' => ['nullable','boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Finance;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PaymentAllocationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool { return true; }
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'installments' => ['nullable','array'],
|
||||
'installments.*.installment_id' => ['required_with:installments','integer'],
|
||||
'installments.*.amount' => ['required_with:installments','numeric','min:0.01'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Finance;
|
||||
|
||||
class StakeholderFinancialAnalysisRequest extends FinancialReportRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return array_merge(parent::rules(), [
|
||||
'compare_school_year' => ['nullable', 'string', 'max:20'],
|
||||
'include_monthly_trend' => ['nullable', 'boolean'],
|
||||
'include_parent_risk' => ['nullable', 'boolean'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ class GradingScoreUpdateRequest extends ApiFormRequest
|
||||
'score_ids' => ['nullable', 'array'],
|
||||
'scores' => ['nullable', 'array'],
|
||||
'comments' => ['nullable', 'array'],
|
||||
'score' => ['nullable', 'numeric'],
|
||||
'score' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
'comment' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,26 +9,26 @@ class ExpenseResource extends JsonResource
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this['id'] ?? $this->id,
|
||||
'category' => $this['category'] ?? $this->category,
|
||||
'amount' => $this['amount'] ?? $this->amount,
|
||||
'receipt_path' => $this['receipt_path'] ?? $this->receipt_path,
|
||||
'receipt_url' => $this['receipt_url'] ?? null,
|
||||
'description' => $this['description'] ?? $this->description,
|
||||
'retailor' => $this['retailor'] ?? $this->retailor,
|
||||
'date_of_purchase' => $this['date_of_purchase'] ?? $this->date_of_purchase,
|
||||
'purchased_by' => $this['purchased_by'] ?? $this->purchased_by,
|
||||
'added_by' => $this['added_by'] ?? $this->added_by,
|
||||
'school_year' => $this['school_year'] ?? $this->school_year,
|
||||
'semester' => $this['semester'] ?? $this->semester,
|
||||
'status' => $this['status'] ?? $this->status,
|
||||
'status_reason' => $this['status_reason'] ?? $this->status_reason,
|
||||
'approved_by' => $this['approved_by'] ?? $this->approved_by,
|
||||
'updated_by' => $this['updated_by'] ?? $this->updated_by,
|
||||
'purchaser_firstname' => $this['purchaser_firstname'] ?? null,
|
||||
'purchaser_lastname' => $this['purchaser_lastname'] ?? null,
|
||||
'approver_firstname' => $this['approver_firstname'] ?? null,
|
||||
'approver_lastname' => $this['approver_lastname'] ?? null,
|
||||
'id' => data_get($this->resource, 'id'),
|
||||
'category' => data_get($this->resource, 'category'),
|
||||
'amount' => data_get($this->resource, 'amount'),
|
||||
'receipt_path' => data_get($this->resource, 'receipt_path'),
|
||||
'receipt_url' => data_get($this->resource, 'receipt_url'),
|
||||
'description' => data_get($this->resource, 'description'),
|
||||
'retailor' => data_get($this->resource, 'retailor'),
|
||||
'date_of_purchase' => data_get($this->resource, 'date_of_purchase'),
|
||||
'purchased_by' => data_get($this->resource, 'purchased_by'),
|
||||
'added_by' => data_get($this->resource, 'added_by'),
|
||||
'school_year' => data_get($this->resource, 'school_year'),
|
||||
'semester' => data_get($this->resource, 'semester'),
|
||||
'status' => data_get($this->resource, 'status'),
|
||||
'status_reason' => data_get($this->resource, 'status_reason'),
|
||||
'approved_by' => data_get($this->resource, 'approved_by'),
|
||||
'updated_by' => data_get($this->resource, 'updated_by'),
|
||||
'purchaser_firstname' => data_get($this->resource, 'purchaser_firstname'),
|
||||
'purchaser_lastname' => data_get($this->resource, 'purchaser_lastname'),
|
||||
'approver_firstname' => data_get($this->resource, 'approver_firstname'),
|
||||
'approver_lastname' => data_get($this->resource, 'approver_lastname'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class CertificateRecord extends BaseModel
|
||||
{
|
||||
protected $table = 'certificate_records';
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
protected $fillable = [
|
||||
'certificate_number',
|
||||
'verification_token',
|
||||
'student_id',
|
||||
'student_name',
|
||||
'grade',
|
||||
'cert_date',
|
||||
'school_year',
|
||||
'class_section_id',
|
||||
'issued_by',
|
||||
'issued_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'student_id' => 'integer',
|
||||
'class_section_id' => 'integer',
|
||||
'issued_by' => 'integer',
|
||||
'cert_date' => 'date',
|
||||
'issued_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@@ -20,6 +20,11 @@ class FinalExam extends BaseModel
|
||||
'class_section_id',
|
||||
'updated_by',
|
||||
'score',
|
||||
'max_points',
|
||||
'status',
|
||||
'excused_reason',
|
||||
'locked_at',
|
||||
'locked_by',
|
||||
'comment',
|
||||
'semester',
|
||||
'school_year',
|
||||
@@ -35,6 +40,9 @@ class FinalExam extends BaseModel
|
||||
|
||||
// score is often numeric; adjust if yours is integer
|
||||
'score' => 'decimal:2',
|
||||
'max_points' => 'decimal:2',
|
||||
'locked_by' => 'integer',
|
||||
'locked_at' => 'datetime',
|
||||
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class FinanceBalanceCarryforward extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $table = 'finance_balance_carryforwards';
|
||||
protected $casts = ['source_invoice_ids_json' => 'array', 'metadata_json' => 'array'];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class FinanceFollowUpNote extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $table = 'finance_follow_up_notes';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class FinanceNotificationLog extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $table = 'finance_notification_logs';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class FinanceNotificationPreference extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $table = 'finance_notification_preferences';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class FinanceReceiptSequence extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $table = 'finance_receipt_sequences';
|
||||
}
|
||||
@@ -22,6 +22,11 @@ class Homework extends BaseModel
|
||||
'updated_by',
|
||||
'homework_index',
|
||||
'score',
|
||||
'max_points',
|
||||
'status',
|
||||
'excused_reason',
|
||||
'locked_at',
|
||||
'locked_by',
|
||||
'comment',
|
||||
'semester',
|
||||
'school_year',
|
||||
@@ -38,6 +43,9 @@ class Homework extends BaseModel
|
||||
|
||||
// keep numeric; change to 'integer' if your scores are whole numbers
|
||||
'score' => 'decimal:2',
|
||||
'max_points' => 'decimal:2',
|
||||
'locked_by' => 'integer',
|
||||
'locked_at' => 'datetime',
|
||||
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class InvoiceInstallment extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $table = 'invoice_installments';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class InvoiceInstallmentPlan extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $table = 'invoice_installment_plans';
|
||||
}
|
||||
@@ -20,6 +20,11 @@ class MidtermExam extends BaseModel
|
||||
'class_section_id',
|
||||
'updated_by',
|
||||
'score',
|
||||
'max_points',
|
||||
'status',
|
||||
'excused_reason',
|
||||
'locked_at',
|
||||
'locked_by',
|
||||
'comment',
|
||||
'semester',
|
||||
'school_year',
|
||||
@@ -35,6 +40,9 @@ class MidtermExam extends BaseModel
|
||||
|
||||
// score can be decimal; change to 'integer' if it's whole number
|
||||
'score' => 'decimal:2',
|
||||
'max_points' => 'decimal:2',
|
||||
'locked_by' => 'integer',
|
||||
'locked_at' => 'datetime',
|
||||
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
|
||||
@@ -50,7 +50,8 @@ class NavItem extends BaseModel
|
||||
return static::query()
|
||||
->where('menu_parent_id', $parentId)
|
||||
->where('is_enabled', 1)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->orderBy('label', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ class Participation extends BaseModel
|
||||
'class_section_id',
|
||||
'updated_by',
|
||||
'score',
|
||||
'max_points',
|
||||
'status',
|
||||
'excused_reason',
|
||||
'locked_at',
|
||||
'locked_by',
|
||||
'comment',
|
||||
'semester',
|
||||
'school_year',
|
||||
@@ -35,6 +40,9 @@ class Participation extends BaseModel
|
||||
|
||||
// score is numeric; change to 'integer' if it is whole number
|
||||
'score' => 'decimal:2',
|
||||
'max_points' => 'decimal:2',
|
||||
'locked_by' => 'integer',
|
||||
'locked_at' => 'datetime',
|
||||
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class PaymentCarryforwardAllocation extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $table = 'payment_carryforward_allocations';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class PaymentInstallmentAllocation extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $table = 'payment_installment_allocations';
|
||||
}
|
||||
@@ -23,6 +23,11 @@ class Project extends BaseModel
|
||||
'updated_by',
|
||||
'project_index',
|
||||
'score',
|
||||
'max_points',
|
||||
'status',
|
||||
'excused_reason',
|
||||
'locked_at',
|
||||
'locked_by',
|
||||
'comment',
|
||||
'semester',
|
||||
'school_year',
|
||||
@@ -45,7 +50,10 @@ class Project extends BaseModel
|
||||
'class_section_id' => 'integer',
|
||||
'updated_by' => 'integer',
|
||||
'project_index' => 'integer',
|
||||
'score' => 'decimal:2', // or 'float' if you prefer
|
||||
'score' => 'decimal:2',
|
||||
'max_points' => 'decimal:2',
|
||||
'locked_by' => 'integer',
|
||||
'locked_at' => 'datetime', // or 'float' if you prefer
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
@@ -17,6 +17,11 @@ class Quiz extends BaseModel
|
||||
'updated_by',
|
||||
'quiz_index',
|
||||
'score',
|
||||
'max_points',
|
||||
'status',
|
||||
'excused_reason',
|
||||
'locked_at',
|
||||
'locked_by',
|
||||
'comment',
|
||||
'semester',
|
||||
'school_year',
|
||||
@@ -31,6 +36,9 @@ class Quiz extends BaseModel
|
||||
'updated_by' => 'integer',
|
||||
'quiz_index' => 'integer',
|
||||
'score' => 'decimal:2',
|
||||
'max_points' => 'decimal:2',
|
||||
'locked_by' => 'integer',
|
||||
'locked_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SectionPlacementBatch extends BaseModel
|
||||
{
|
||||
protected $table = 'section_placement_batches';
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_FINALIZED = 'finalized';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_SUPERSEDED = 'superseded';
|
||||
|
||||
protected $fillable = [
|
||||
'from_school_year',
|
||||
'to_school_year',
|
||||
'from_grade_level_id',
|
||||
'to_grade_level_id',
|
||||
'section_capacity_used',
|
||||
'total_students',
|
||||
'section_count',
|
||||
'algorithm',
|
||||
'configuration_snapshot_json',
|
||||
'preview_snapshot_json',
|
||||
'created_by',
|
||||
'finalized_by',
|
||||
'finalized_at',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'from_grade_level_id' => 'integer',
|
||||
'to_grade_level_id' => 'integer',
|
||||
'section_capacity_used' => 'integer',
|
||||
'total_students' => 'integer',
|
||||
'section_count' => 'integer',
|
||||
'created_by' => 'integer',
|
||||
'finalized_by' => 'integer',
|
||||
'finalized_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function students(): HasMany
|
||||
{
|
||||
return $this->hasMany(SectionPlacementBatchStudent::class, 'batch_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SectionPlacementBatchStudent extends BaseModel
|
||||
{
|
||||
protected $table = 'section_placement_batch_students';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'batch_id',
|
||||
'student_id',
|
||||
'student_type',
|
||||
'source_decision_id',
|
||||
'source_enrollment_id',
|
||||
'final_score',
|
||||
'placement_score',
|
||||
'score_band',
|
||||
'placement_band_reason',
|
||||
'planned_section_index',
|
||||
'assigned_section_id',
|
||||
'assignment_order',
|
||||
'was_override',
|
||||
'override_reason',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'batch_id' => 'integer',
|
||||
'student_id' => 'integer',
|
||||
'source_decision_id' => 'integer',
|
||||
'source_enrollment_id' => 'integer',
|
||||
'final_score' => 'float',
|
||||
'placement_score' => 'float',
|
||||
'planned_section_index' => 'integer',
|
||||
'assigned_section_id' => 'integer',
|
||||
'assignment_order' => 'integer',
|
||||
'was_override' => 'boolean',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function batch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SectionPlacementBatch::class, 'batch_id');
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ class SemesterScore extends BaseModel
|
||||
'ptap_score',
|
||||
'test_avg',
|
||||
'semester_score',
|
||||
'calculation_mode',
|
||||
'calculation_policy_version',
|
||||
'snapshot_id',
|
||||
'semester',
|
||||
'school_year',
|
||||
];
|
||||
@@ -52,6 +55,7 @@ class SemesterScore extends BaseModel
|
||||
'ptap_score' => 'decimal:2',
|
||||
'test_avg' => 'decimal:2',
|
||||
'semester_score' => 'decimal:2',
|
||||
'snapshot_id' => 'integer',
|
||||
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
@@ -169,6 +173,9 @@ class SemesterScore extends BaseModel
|
||||
'ptap_score' => ['nullable', 'numeric', 'min:0'],
|
||||
'test_avg' => ['nullable', 'numeric', 'min:0'],
|
||||
'semester_score' => ['nullable', 'numeric', 'min:0'],
|
||||
'calculation_mode' => ['nullable', 'string', 'in:legacy,strong'],
|
||||
'calculation_policy_version' => ['nullable', 'string', 'max:50'],
|
||||
'snapshot_id' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\BaseModel;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SemesterScoreSnapshot extends BaseModel
|
||||
{
|
||||
protected $table = 'semester_score_snapshots';
|
||||
|
||||
protected $fillable = [
|
||||
'student_id',
|
||||
'school_id',
|
||||
'class_section_id',
|
||||
'semester',
|
||||
'school_year',
|
||||
'grading_profile_id',
|
||||
'grading_profile_version',
|
||||
'calculation_mode',
|
||||
'calculation_policy_version',
|
||||
'input_json',
|
||||
'calculation_json',
|
||||
'semester_score',
|
||||
'calculated_by',
|
||||
'calculated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'student_id' => 'integer',
|
||||
'school_id' => 'integer',
|
||||
'class_section_id' => 'integer',
|
||||
'grading_profile_id' => 'integer',
|
||||
'input_json' => 'array',
|
||||
'calculation_json' => 'array',
|
||||
'semester_score' => 'decimal:2',
|
||||
'calculated_by' => 'integer',
|
||||
'calculated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function semesterScore(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SemesterScore::class, 'snapshot_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Administrator\Trophy;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Ports the legacy CodeIgniter trophy projection + winner reports to
|
||||
* Laravel JSON payloads for the SPA/admin API.
|
||||
*/
|
||||
class TrophyReportService
|
||||
{
|
||||
public function projection(?string $schoolYear = null, ?float $percentile = null): array
|
||||
{
|
||||
$selectedYear = $this->resolveSchoolYear($schoolYear);
|
||||
$selectedPercentile = $this->sanitizePercentile($percentile);
|
||||
$sections = $this->sectionMap($this->studentRows($selectedYear));
|
||||
|
||||
$classResults = [];
|
||||
|
||||
foreach ($sections as $sectionId => $section) {
|
||||
$students = $this->sortByScore($section['students'], 'fall_score');
|
||||
$scores = array_column($students, 'fall_score');
|
||||
$calc = $this->calculateThreshold($scores, $selectedPercentile);
|
||||
$threshold = $calc['threshold'];
|
||||
|
||||
$students = array_map(function (array $student) use ($threshold): array {
|
||||
$student['projected_trophy'] = $threshold !== null
|
||||
&& $student['fall_score'] !== null
|
||||
&& $student['fall_score'] >= $threshold;
|
||||
|
||||
return $student;
|
||||
}, $students);
|
||||
|
||||
$scoredCount = count(array_filter(
|
||||
array_column($students, 'fall_score'),
|
||||
static fn ($score): bool => $score !== null
|
||||
));
|
||||
|
||||
[$boys, $girls] = $this->splitByBinaryGender($students);
|
||||
$trophyBoys = array_values(array_filter($boys, static fn (array $student): bool => ! empty($student['projected_trophy'])));
|
||||
$trophyGirls = array_values(array_filter($girls, static fn (array $student): bool => ! empty($student['projected_trophy'])));
|
||||
$studentCount = count($students);
|
||||
|
||||
$classResults[] = [
|
||||
'section_id' => $sectionId,
|
||||
'section_name' => $section['name'],
|
||||
'students' => $students,
|
||||
'threshold' => $threshold,
|
||||
'trophy_count' => $calc['winners'],
|
||||
'student_count' => $studentCount,
|
||||
'scored_count' => $scoredCount,
|
||||
'method' => $calc['method'],
|
||||
'boys' => count($boys),
|
||||
'girls' => count($girls),
|
||||
'trophy_boys' => count($trophyBoys),
|
||||
'trophy_girls' => count($trophyGirls),
|
||||
'pct_boys' => $studentCount > 0 ? round(count($boys) / $studentCount * 100) : 0,
|
||||
'pct_girls' => $studentCount > 0 ? round(count($girls) / $studentCount * 100) : 0,
|
||||
'pct_trophy_boys' => count($boys) > 0 ? round(count($trophyBoys) / count($boys) * 100) : 0,
|
||||
'pct_trophy_girls' => count($girls) > 0 ? round(count($trophyGirls) / count($girls) * 100) : 0,
|
||||
'pct_trophy_total' => $studentCount > 0 ? round($calc['winners'] / $studentCount * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'selected_year' => $selectedYear,
|
||||
'selected_percentile' => $selectedPercentile,
|
||||
'years' => $this->schoolYears(),
|
||||
'class_results' => $classResults,
|
||||
'summary' => $this->projectionSummary($classResults),
|
||||
];
|
||||
}
|
||||
|
||||
public function winners(?string $schoolYear = null, ?float $percentile = null): array
|
||||
{
|
||||
$selectedYear = $this->resolveSchoolYear($schoolYear);
|
||||
$selectedPercentile = $this->sanitizePercentile($percentile);
|
||||
$sections = $this->sectionMap($this->studentRows($selectedYear));
|
||||
|
||||
$classResults = [];
|
||||
|
||||
foreach ($sections as $sectionId => $section) {
|
||||
$students = $this->sortByScore($section['students'], 'fall_score');
|
||||
$calc = $this->calculateThreshold(array_column($students, 'fall_score'), $selectedPercentile);
|
||||
$threshold = $calc['threshold'];
|
||||
|
||||
$winners = array_values(array_filter($students, static function (array $student) use ($threshold): bool {
|
||||
return $threshold !== null
|
||||
&& $student['fall_score'] !== null
|
||||
&& $student['fall_score'] >= $threshold;
|
||||
}));
|
||||
|
||||
if ($winners === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$boys, $girls] = $this->splitByBinaryGender($students);
|
||||
[$winnerBoys, $winnerGirls] = $this->splitByBinaryGender($winners);
|
||||
$studentCount = count($students);
|
||||
|
||||
$classResults[] = [
|
||||
'section_id' => $sectionId,
|
||||
'section_name' => $section['name'],
|
||||
'threshold' => $threshold,
|
||||
'winners' => $winners,
|
||||
'student_count' => $studentCount,
|
||||
'boys' => count($boys),
|
||||
'girls' => count($girls),
|
||||
'trophy_boys' => count($winnerBoys),
|
||||
'trophy_girls' => count($winnerGirls),
|
||||
'pct_boys' => $studentCount > 0 ? round(count($boys) / $studentCount * 100) : 0,
|
||||
'pct_girls' => $studentCount > 0 ? round(count($girls) / $studentCount * 100) : 0,
|
||||
'pct_trophy_boys' => count($boys) > 0 ? round(count($winnerBoys) / count($boys) * 100) : 0,
|
||||
'pct_trophy_girls' => count($girls) > 0 ? round(count($winnerGirls) / count($girls) * 100) : 0,
|
||||
'pct_trophy_total' => $studentCount > 0 ? round(count($winners) / $studentCount * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'selected_year' => $selectedYear,
|
||||
'selected_percentile' => $selectedPercentile,
|
||||
'years' => $this->schoolYears(),
|
||||
'class_results' => $classResults,
|
||||
'summary' => $this->winnersSummary($classResults),
|
||||
];
|
||||
}
|
||||
|
||||
public function final(?string $schoolYear = null, ?float $percentile = null): array
|
||||
{
|
||||
$selectedYear = $this->resolveSchoolYear($schoolYear);
|
||||
$selectedPercentile = $this->sanitizePercentile($percentile);
|
||||
$sections = $this->sectionMap($this->studentRows($selectedYear));
|
||||
|
||||
$classResults = [];
|
||||
$allStudents = [];
|
||||
$passStudents = [];
|
||||
$winnerStudents = [];
|
||||
$winnerStickers = [];
|
||||
|
||||
foreach ($sections as $sectionId => $section) {
|
||||
$students = $section['students'];
|
||||
$fallCalc = $this->calculateThreshold(array_column($students, 'fall_score'), $selectedPercentile);
|
||||
$yearCalc = $this->calculateThreshold(array_column($students, 'year_score'), $selectedPercentile);
|
||||
$fallThreshold = $fallCalc['threshold'];
|
||||
$yearThreshold = $yearCalc['threshold'];
|
||||
|
||||
$annotated = array_map(function (array $student) use ($fallThreshold, $yearThreshold): array {
|
||||
$predicted = $fallThreshold !== null
|
||||
&& $student['fall_score'] !== null
|
||||
&& $student['fall_score'] >= $fallThreshold;
|
||||
$actual = $yearThreshold !== null
|
||||
&& $student['year_score'] !== null
|
||||
&& $student['year_score'] >= $yearThreshold;
|
||||
$status = match (true) {
|
||||
$predicted && $actual => 'confirmed',
|
||||
! $predicted && $actual => 'surprise',
|
||||
$predicted && ! $actual => 'missed',
|
||||
default => 'none',
|
||||
};
|
||||
|
||||
return $student + compact('predicted', 'actual', 'status');
|
||||
}, $students);
|
||||
|
||||
$annotated = $this->sortByScore($annotated, 'year_score');
|
||||
|
||||
foreach ($annotated as $student) {
|
||||
$allStudents[] = $student;
|
||||
|
||||
if ($student['year_score'] !== null && $student['year_score'] >= 60.0) {
|
||||
$passStudents[] = $student;
|
||||
}
|
||||
|
||||
if (! empty($student['actual'])) {
|
||||
$winnerStudents[] = $student;
|
||||
$winnerStickers[] = [
|
||||
'name' => $student['name'],
|
||||
'section' => $section['name'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$confirmed = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'confirmed'));
|
||||
$surprises = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'surprise'));
|
||||
$missed = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'missed'));
|
||||
$predictedCount = count(array_filter($annotated, static fn (array $student): bool => ! empty($student['predicted'])));
|
||||
$actualCount = count(array_filter($annotated, static fn (array $student): bool => ! empty($student['actual'])));
|
||||
$studentCount = count($annotated);
|
||||
|
||||
$classResults[] = [
|
||||
'section_id' => $sectionId,
|
||||
'section_name' => $section['name'],
|
||||
'students' => $annotated,
|
||||
'student_count' => $studentCount,
|
||||
'fall_threshold' => $fallThreshold,
|
||||
'year_threshold' => $yearThreshold,
|
||||
'predicted_count' => $predictedCount,
|
||||
'actual_count' => $actualCount,
|
||||
'confirmed' => $confirmed,
|
||||
'surprises' => $surprises,
|
||||
'missed' => $missed,
|
||||
'accuracy' => $predictedCount > 0 ? round($confirmed / $predictedCount * 100) : ($actualCount === 0 ? 100 : 0),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'selected_year' => $selectedYear,
|
||||
'selected_percentile' => $selectedPercentile,
|
||||
'years' => $this->schoolYears(),
|
||||
'class_results' => $classResults,
|
||||
'summary' => $this->finalSummary($classResults),
|
||||
'winner_gender_summary' => $this->genderBreakdown($winnerStudents),
|
||||
'all_gender_summary' => $this->genderBreakdown($allStudents),
|
||||
'pass_gender_summary' => $this->genderBreakdown($passStudents),
|
||||
'winner_stickers' => array_values(array_filter($winnerStickers, static fn (array $sticker): bool => trim((string) ($sticker['name'] ?? '')) !== '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float|int|string|null> $scores
|
||||
* @return array{threshold: float|null, winners: int, method: string}
|
||||
*/
|
||||
public function calculateThreshold(array $scores, float $percentile = 75.0): array
|
||||
{
|
||||
$scores = array_values(array_filter(
|
||||
$scores,
|
||||
static fn ($value): bool => is_numeric($value) && $value !== null
|
||||
));
|
||||
$scores = array_map('floatval', $scores);
|
||||
sort($scores);
|
||||
|
||||
$count = count($scores);
|
||||
|
||||
if ($count === 0) {
|
||||
return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
|
||||
}
|
||||
|
||||
$minWinners = 3;
|
||||
$maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100)));
|
||||
|
||||
$threshold = $this->empiricalPercentile($scores, $percentile);
|
||||
$winners = $this->countAtOrAbove($scores, $threshold);
|
||||
|
||||
if ($winners < $minWinners) {
|
||||
$target = min($minWinners, $count);
|
||||
$descending = array_reverse($scores);
|
||||
$threshold = $descending[$target - 1];
|
||||
$winners = $this->countAtOrAbove($scores, $threshold);
|
||||
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
|
||||
}
|
||||
|
||||
if ($winners <= $maxWinners) {
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
|
||||
}
|
||||
|
||||
$result = $this->capByRank($scores, $maxWinners);
|
||||
|
||||
if ($result['winners'] < $minWinners) {
|
||||
$target = min($minWinners, $count);
|
||||
$descending = array_reverse($scores);
|
||||
$threshold = $descending[$target - 1];
|
||||
$winners = $this->countAtOrAbove($scores, $threshold);
|
||||
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function resolveSchoolYear(?string $schoolYear): string
|
||||
{
|
||||
$selected = trim((string) $schoolYear);
|
||||
|
||||
if ($selected !== '') {
|
||||
return $selected;
|
||||
}
|
||||
|
||||
return (string) (Configuration::getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
private function sanitizePercentile(?float $percentile): float
|
||||
{
|
||||
$value = $percentile ?? 75.0;
|
||||
|
||||
return max(1.0, min(99.0, $value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function schoolYears(): array
|
||||
{
|
||||
return DB::table('student_class')
|
||||
->select('school_year')
|
||||
->distinct()
|
||||
->orderBy('school_year', 'DESC')
|
||||
->pluck('school_year')
|
||||
->filter(static fn ($value): bool => $value !== null && trim((string) $value) !== '')
|
||||
->map(static fn ($value): string => (string) $value)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function studentRows(string $schoolYear): Collection
|
||||
{
|
||||
$fallScores = DB::table('semester_scores')
|
||||
->select(
|
||||
'student_id',
|
||||
'class_section_id',
|
||||
DB::raw('MAX(semester_score) AS fall_score')
|
||||
)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereRaw('LOWER(semester) = ?', ['fall'])
|
||||
->groupBy('student_id', 'class_section_id');
|
||||
|
||||
$springScores = DB::table('semester_scores')
|
||||
->select(
|
||||
'student_id',
|
||||
'class_section_id',
|
||||
DB::raw('MAX(semester_score) AS spring_score')
|
||||
)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereRaw('LOWER(semester) = ?', ['spring'])
|
||||
->groupBy('student_id', 'class_section_id');
|
||||
|
||||
return DB::table('student_class as sc')
|
||||
->select(
|
||||
'sc.student_id',
|
||||
'sc.class_section_id',
|
||||
'cs.class_section_name',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.school_id',
|
||||
's.gender',
|
||||
DB::raw('MAX(sf.fall_score) AS fall_score'),
|
||||
DB::raw('MAX(sp.spring_score) AS spring_score')
|
||||
)
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->leftJoin('students as s', 's.id', '=', 'sc.student_id')
|
||||
->leftJoinSub($fallScores, 'sf', function ($join): void {
|
||||
$join->on('sf.student_id', '=', 'sc.student_id')
|
||||
->on('sf.class_section_id', '=', 'sc.class_section_id');
|
||||
})
|
||||
->leftJoinSub($springScores, 'sp', function ($join): void {
|
||||
$join->on('sp.student_id', '=', 'sc.student_id')
|
||||
->on('sp.class_section_id', '=', 'sc.class_section_id');
|
||||
})
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->groupBy(
|
||||
'sc.student_id',
|
||||
'sc.class_section_id',
|
||||
'cs.class_section_name',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.school_id',
|
||||
's.gender'
|
||||
)
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->get()
|
||||
->map(function (object $row): array {
|
||||
$student = (array) $row;
|
||||
$fall = is_numeric($student['fall_score'] ?? null) ? round((float) $student['fall_score'], 1) : null;
|
||||
$spring = is_numeric($student['spring_score'] ?? null) ? round((float) $student['spring_score'], 1) : null;
|
||||
$year = ($fall !== null && $spring !== null)
|
||||
? round(($fall + $spring) / 2, 1)
|
||||
: ($fall ?? $spring);
|
||||
$firstname = trim((string) ($student['firstname'] ?? ''));
|
||||
$lastname = trim((string) ($student['lastname'] ?? ''));
|
||||
$name = trim($firstname.' '.$lastname);
|
||||
|
||||
return [
|
||||
'student_id' => (int) ($student['student_id'] ?? 0),
|
||||
'class_section_id' => (int) ($student['class_section_id'] ?? 0),
|
||||
'section_name' => (string) ($student['class_section_name'] ?? ''),
|
||||
'name' => $name !== '' ? $name : 'Student #'.($student['student_id'] ?? ''),
|
||||
'firstname' => $firstname !== '' ? $firstname : null,
|
||||
'lastname' => $lastname !== '' ? $lastname : null,
|
||||
'school_id' => $student['school_id'] ?? null,
|
||||
'gender' => (string) ($student['gender'] ?? ''),
|
||||
'fall_score' => $fall,
|
||||
'spring_score' => $spring,
|
||||
'year_score' => $year,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<int, array<string, mixed>> $rows
|
||||
* @return array<int, array{name: string, students: list<array<string, mixed>>}>
|
||||
*/
|
||||
private function sectionMap(Collection $rows): array
|
||||
{
|
||||
$sections = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$sectionId = (int) ($row['class_section_id'] ?? 0);
|
||||
$name = trim((string) ($row['section_name'] ?? '')) ?: 'Section '.$sectionId;
|
||||
|
||||
if (! isset($sections[$sectionId])) {
|
||||
$sections[$sectionId] = [
|
||||
'name' => $name,
|
||||
'students' => [],
|
||||
];
|
||||
}
|
||||
|
||||
unset($row['section_name']);
|
||||
$sections[$sectionId]['students'][] = $row;
|
||||
}
|
||||
|
||||
return $sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $students
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function sortByScore(array $students, string $scoreKey): array
|
||||
{
|
||||
usort($students, static function (array $left, array $right) use ($scoreKey): int {
|
||||
$leftScore = $left[$scoreKey] ?? null;
|
||||
$rightScore = $right[$scoreKey] ?? null;
|
||||
|
||||
if ($leftScore === null && $rightScore === null) {
|
||||
return strcmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
}
|
||||
|
||||
if ($leftScore === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($rightScore === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return $rightScore <=> $leftScore ?: strcmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
});
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $students
|
||||
* @return array{0: list<array<string, mixed>>, 1: list<array<string, mixed>>}
|
||||
*/
|
||||
private function splitByBinaryGender(array $students): array
|
||||
{
|
||||
$boys = array_values(array_filter($students, fn (array $student): bool => $this->genderKey($student['gender'] ?? '') === 'boys'));
|
||||
$girls = array_values(array_filter($students, fn (array $student): bool => $this->genderKey($student['gender'] ?? '') === 'girls'));
|
||||
|
||||
return [$boys, $girls];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $classResults
|
||||
* @return array<string, int|float>
|
||||
*/
|
||||
private function projectionSummary(array $classResults): array
|
||||
{
|
||||
$totalStudents = array_sum(array_column($classResults, 'student_count'));
|
||||
$totalScored = array_sum(array_column($classResults, 'scored_count'));
|
||||
$totalTrophies = array_sum(array_column($classResults, 'trophy_count'));
|
||||
$totalBoys = array_sum(array_column($classResults, 'boys'));
|
||||
$totalGirls = array_sum(array_column($classResults, 'girls'));
|
||||
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
|
||||
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
|
||||
|
||||
return [
|
||||
'classes' => count($classResults),
|
||||
'students' => $totalStudents,
|
||||
'scored' => $totalScored,
|
||||
'trophies' => $totalTrophies,
|
||||
'boys' => $totalBoys,
|
||||
'girls' => $totalGirls,
|
||||
'trophy_boys' => $totalTrophyBoys,
|
||||
'trophy_girls' => $totalTrophyGirls,
|
||||
'pct_scored' => $totalStudents > 0 ? round($totalScored / $totalStudents * 100) : 0,
|
||||
'pct_boys' => $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0,
|
||||
'pct_girls' => $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0,
|
||||
'pct_trophies' => $totalStudents > 0 ? round($totalTrophies / $totalStudents * 100) : 0,
|
||||
'pct_trophy_boys' => $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0,
|
||||
'pct_trophy_girls' => $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $classResults
|
||||
* @return array<string, int|float>
|
||||
*/
|
||||
private function winnersSummary(array $classResults): array
|
||||
{
|
||||
$totalWinners = array_sum(array_map(static fn (array $classResult): int => count($classResult['winners'] ?? []), $classResults));
|
||||
$totalStudents = array_sum(array_column($classResults, 'student_count'));
|
||||
$totalBoys = array_sum(array_column($classResults, 'boys'));
|
||||
$totalGirls = array_sum(array_column($classResults, 'girls'));
|
||||
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
|
||||
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
|
||||
|
||||
return [
|
||||
'classes' => count($classResults),
|
||||
'students' => $totalStudents,
|
||||
'winners' => $totalWinners,
|
||||
'boys' => $totalBoys,
|
||||
'girls' => $totalGirls,
|
||||
'trophy_boys' => $totalTrophyBoys,
|
||||
'trophy_girls' => $totalTrophyGirls,
|
||||
'pct_winners' => $totalStudents > 0 ? round($totalWinners / $totalStudents * 100) : 0,
|
||||
'pct_boys' => $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0,
|
||||
'pct_girls' => $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0,
|
||||
'pct_trophy_boys' => $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0,
|
||||
'pct_trophy_girls' => $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $classResults
|
||||
* @return array<string, int|float>
|
||||
*/
|
||||
private function finalSummary(array $classResults): array
|
||||
{
|
||||
$totalStudents = array_sum(array_column($classResults, 'student_count'));
|
||||
$totalPredicted = array_sum(array_column($classResults, 'predicted_count'));
|
||||
$totalActual = array_sum(array_column($classResults, 'actual_count'));
|
||||
$totalConfirmed = array_sum(array_column($classResults, 'confirmed'));
|
||||
$totalSurprises = array_sum(array_column($classResults, 'surprises'));
|
||||
$totalMissed = array_sum(array_column($classResults, 'missed'));
|
||||
$overallAccuracy = $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : ($totalActual === 0 ? 100 : 0);
|
||||
|
||||
return [
|
||||
'classes' => count($classResults),
|
||||
'students' => $totalStudents,
|
||||
'predicted' => $totalPredicted,
|
||||
'actual' => $totalActual,
|
||||
'confirmed' => $totalConfirmed,
|
||||
'surprises' => $totalSurprises,
|
||||
'missed' => $totalMissed,
|
||||
'accuracy' => $overallAccuracy,
|
||||
'pct_predicted' => $totalStudents > 0 ? round($totalPredicted / $totalStudents * 100) : 0,
|
||||
'pct_actual' => $totalStudents > 0 ? round($totalActual / $totalStudents * 100) : 0,
|
||||
'pct_confirmed_of_predicted' => $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $students
|
||||
* @return array<string, int|float>
|
||||
*/
|
||||
private function genderBreakdown(array $students): array
|
||||
{
|
||||
$stats = ['boys' => 0, 'girls' => 0, 'other' => 0];
|
||||
|
||||
foreach ($students as $student) {
|
||||
$stats[$this->genderKey($student['gender'] ?? '')]++;
|
||||
}
|
||||
|
||||
$total = array_sum($stats);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'boys' => $stats['boys'],
|
||||
'girls' => $stats['girls'],
|
||||
'other' => $stats['other'],
|
||||
'pct_boys' => $total > 0 ? round(($stats['boys'] / $total) * 100, 1) : 0.0,
|
||||
'pct_girls' => $total > 0 ? round(($stats['girls'] / $total) * 100, 1) : 0.0,
|
||||
'pct_other' => $total > 0 ? round(($stats['other'] / $total) * 100, 1) : 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
private function genderKey(?string $gender): string
|
||||
{
|
||||
$value = strtolower(trim((string) $gender));
|
||||
|
||||
return match (true) {
|
||||
in_array($value, ['male', 'm', 'boy', 'boys'], true) => 'boys',
|
||||
in_array($value, ['female', 'f', 'girl', 'girls'], true) => 'girls',
|
||||
default => 'other',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<float> $sortedScores
|
||||
* @return array{threshold: float, winners: int, method: string}
|
||||
*/
|
||||
private function capByRank(array $sortedScores, int $max): array
|
||||
{
|
||||
$descending = array_reverse($sortedScores);
|
||||
$threshold = $descending[$max - 1];
|
||||
$winners = $this->countAtOrAbove($sortedScores, $threshold);
|
||||
|
||||
if ($winners <= $max) {
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
|
||||
}
|
||||
|
||||
$uniqueCandidates = array_values(array_unique(array_filter(
|
||||
$sortedScores,
|
||||
static fn (float $score): bool => $score > $threshold
|
||||
)));
|
||||
sort($uniqueCandidates);
|
||||
|
||||
foreach ($uniqueCandidates as $candidate) {
|
||||
$winnerCount = $this->countAtOrAbove($sortedScores, $candidate);
|
||||
|
||||
if ($winnerCount <= $max) {
|
||||
return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<float> $sortedScores
|
||||
*/
|
||||
private function empiricalPercentile(array $sortedScores, float $percentile): float
|
||||
{
|
||||
$count = count($sortedScores);
|
||||
|
||||
if ($count === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$index = ($percentile / 100.0) * ($count - 1);
|
||||
$lower = (int) floor($index);
|
||||
$upper = (int) ceil($index);
|
||||
|
||||
if ($lower === $upper) {
|
||||
return $sortedScores[$lower];
|
||||
}
|
||||
|
||||
return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<float> $scores
|
||||
*/
|
||||
private function countAtOrAbove(array $scores, float $threshold): int
|
||||
{
|
||||
return count(array_filter($scores, static fn (float $score): bool => $score >= $threshold));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Certificates;
|
||||
|
||||
use App\Models\CertificateRecord;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\StudentDecision;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CertificateAdminService
|
||||
{
|
||||
private bool $hasVerificationTokenColumn;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->hasVerificationTokenColumn = Schema::hasTable('certificate_records')
|
||||
&& Schema::hasColumn('certificate_records', 'verification_token');
|
||||
}
|
||||
|
||||
public function dashboard(?string $schoolYear = null): array
|
||||
{
|
||||
$selectedYear = $this->resolveSchoolYear($schoolYear);
|
||||
|
||||
$allEnrolled = DB::table('student_class as sc')
|
||||
->select(
|
||||
's.id as student_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
'sc.class_section_id',
|
||||
'cs.class_section_name'
|
||||
)
|
||||
->join('students as s', 's.id', '=', 'sc.student_id')
|
||||
->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.school_year', $selectedYear)
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$studentIds = array_values(array_unique(array_map(
|
||||
static fn (array $row): int => (int) ($row['student_id'] ?? 0),
|
||||
$allEnrolled
|
||||
)));
|
||||
$studentIds = array_values(array_filter($studentIds, static fn (int $id): bool => $id > 0));
|
||||
|
||||
$decisionsByStudent = [];
|
||||
if ($studentIds !== []) {
|
||||
$decisionRows = StudentDecision::query()
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('school_year', $selectedYear)
|
||||
->get();
|
||||
|
||||
foreach ($decisionRows as $decisionRow) {
|
||||
$sid = (int) ($decisionRow->student_id ?? 0);
|
||||
if ($sid <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decision = trim((string) ($decisionRow->decision ?? ''));
|
||||
$source = trim((string) ($decisionRow->source ?? ''));
|
||||
if ($source === '') {
|
||||
$source = $decision === '' ? 'pending' : 'manual';
|
||||
}
|
||||
|
||||
$decisionsByStudent[$sid][] = [
|
||||
'decision' => $decision,
|
||||
'source' => $source,
|
||||
'notes' => (string) ($decisionRow->notes ?? ''),
|
||||
'year_score' => is_numeric($decisionRow->year_score) ? round((float) $decisionRow->year_score, 2) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$certsByStudent = [];
|
||||
if ($studentIds !== []) {
|
||||
$certRows = DB::table('certificate_records')
|
||||
->select('student_id', 'certificate_number', 'issued_at')
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('school_year', $selectedYear)
|
||||
->orderByDesc('issued_at')
|
||||
->get();
|
||||
|
||||
foreach ($certRows as $record) {
|
||||
$sid = (int) ($record->student_id ?? 0);
|
||||
if ($sid > 0 && ! isset($certsByStudent[$sid])) {
|
||||
$certsByStudent[$sid] = (string) ($record->certificate_number ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$statsPerClass = [];
|
||||
$studentsByClass = [];
|
||||
|
||||
foreach ($allEnrolled as $row) {
|
||||
$sid = (int) ($row['student_id'] ?? 0);
|
||||
$classSectionId = (int) ($row['class_section_id'] ?? 0);
|
||||
$classSectionName = (string) ($row['class_section_name'] ?? ('Section '.$classSectionId));
|
||||
|
||||
if (! isset($statsPerClass[$classSectionId])) {
|
||||
$statsPerClass[$classSectionId] = [
|
||||
'name' => $classSectionName,
|
||||
'total' => 0,
|
||||
'pass' => 0,
|
||||
'cert' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$statsPerClass[$classSectionId]['total']++;
|
||||
|
||||
$studentDecisionRows = $decisionsByStudent[$sid] ?? [];
|
||||
$decisionState = $this->normalizeDecisionState($studentDecisionRows);
|
||||
|
||||
if ($decisionState['eligible']) {
|
||||
$statsPerClass[$classSectionId]['pass']++;
|
||||
}
|
||||
|
||||
$certificateNumber = $certsByStudent[$sid] ?? null;
|
||||
if ($certificateNumber !== null && $certificateNumber !== '') {
|
||||
$statsPerClass[$classSectionId]['cert']++;
|
||||
}
|
||||
|
||||
$studentsByClass[$classSectionId][] = [
|
||||
'student_id' => $sid,
|
||||
'firstname' => (string) ($row['firstname'] ?? ''),
|
||||
'lastname' => (string) ($row['lastname'] ?? ''),
|
||||
'class_section_id' => $classSectionId,
|
||||
'class_section_name' => $classSectionName,
|
||||
'year_score' => $decisionState['year_score'],
|
||||
'eligible' => $decisionState['eligible'],
|
||||
'decision_state' => $decisionState['status'],
|
||||
'decision_labels' => $decisionState['display_decisions'],
|
||||
'decision_rows' => $studentDecisionRows,
|
||||
'certificate_number' => $certificateNumber,
|
||||
];
|
||||
}
|
||||
|
||||
$gradeGroups = $this->buildGradeGroups($statsPerClass, $studentsByClass);
|
||||
|
||||
return [
|
||||
'school_year' => $selectedYear,
|
||||
'cert_date' => date('m/d/Y'),
|
||||
'grade_groups' => array_values($gradeGroups),
|
||||
'stats_per_class' => $statsPerClass,
|
||||
'default_group_key' => array_key_first($gradeGroups),
|
||||
];
|
||||
}
|
||||
|
||||
public function auditLog(?string $schoolYear = null): array
|
||||
{
|
||||
$selectedYear = trim((string) ($schoolYear ?? ''));
|
||||
|
||||
$recordsQuery = DB::table('certificate_records as cr')
|
||||
->select(
|
||||
'cr.*',
|
||||
'u.firstname as admin_firstname',
|
||||
'u.lastname as admin_lastname'
|
||||
)
|
||||
->leftJoin('users as u', 'u.id', '=', 'cr.issued_by')
|
||||
->orderByDesc('cr.issued_at');
|
||||
|
||||
if ($selectedYear !== '') {
|
||||
$recordsQuery->where('cr.school_year', $selectedYear);
|
||||
}
|
||||
|
||||
$records = $recordsQuery->get()->map(fn ($row) => (array) $row)->all();
|
||||
|
||||
$yearSummary = DB::table('certificate_records')
|
||||
->select('school_year', DB::raw('COUNT(*) as total'))
|
||||
->groupBy('school_year')
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
return [
|
||||
'school_year' => $selectedYear,
|
||||
'records' => $records,
|
||||
'year_summary' => $yearSummary,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $studentIds
|
||||
* @return array{students: list<array<string, mixed>>, cert_date_display: string, school_year: string}
|
||||
*/
|
||||
public function issueCertificates(
|
||||
array $studentIds,
|
||||
string $certDate,
|
||||
?int $classSectionId,
|
||||
?string $schoolYear,
|
||||
?int $issuedBy
|
||||
): array {
|
||||
$studentIds = array_values(array_filter(array_map('intval', $studentIds), static fn (int $id): bool => $id > 0));
|
||||
if ($studentIds === []) {
|
||||
throw new \InvalidArgumentException('Please select at least one student.');
|
||||
}
|
||||
|
||||
$selectedYear = $this->resolveSchoolYear($schoolYear);
|
||||
$certDateDisplay = $this->normalizeDisplayCertDate($certDate);
|
||||
$certDateStorage = $this->parseCertDate($certDateDisplay);
|
||||
$students = $this->fetchStudentsForIssuance($studentIds, $classSectionId, $selectedYear);
|
||||
|
||||
if ($students === []) {
|
||||
throw new \RuntimeException('No valid students found.');
|
||||
}
|
||||
|
||||
$rosterMap = [];
|
||||
foreach ($students as $student) {
|
||||
$rosterMap[(int) $student['id']] = $student;
|
||||
}
|
||||
|
||||
$existingByStudent = DB::table('certificate_records')
|
||||
->whereIn('student_id', array_keys($rosterMap))
|
||||
->where('school_year', $selectedYear)
|
||||
->orderByDesc('issued_at')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->groupBy('student_id')
|
||||
->map(fn ($rows) => $rows[0])
|
||||
->all();
|
||||
|
||||
$baseCount = (int) DB::table('certificate_records')
|
||||
->where('school_year', $selectedYear)
|
||||
->count();
|
||||
$nextOffset = 0;
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
foreach ($students as &$student) {
|
||||
$sid = (int) ($student['id'] ?? 0);
|
||||
$existing = $existingByStudent[$sid] ?? null;
|
||||
|
||||
if (is_array($existing)) {
|
||||
$student['cert_number'] = (string) ($existing['certificate_number'] ?? '');
|
||||
$student['verify_token'] = $this->ensureVerificationToken($existing);
|
||||
} else {
|
||||
$nextOffset++;
|
||||
$certificateNumber = $this->formatCertificateNumber($selectedYear, $baseCount + $nextOffset);
|
||||
$verificationToken = $this->hasVerificationTokenColumn ? $this->generateVerificationToken() : null;
|
||||
$grade = $this->formatGrade((string) ($student['grade'] ?? ''));
|
||||
$studentName = trim((string) ($student['firstname'] ?? '').' '.(string) ($student['lastname'] ?? ''));
|
||||
|
||||
$payload = [
|
||||
'certificate_number' => $certificateNumber,
|
||||
'student_id' => $sid,
|
||||
'student_name' => $studentName,
|
||||
'grade' => $grade,
|
||||
'cert_date' => $certDateStorage,
|
||||
'school_year' => $selectedYear,
|
||||
'class_section_id' => $classSectionId,
|
||||
'issued_by' => $issuedBy,
|
||||
'issued_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
if ($this->hasVerificationTokenColumn) {
|
||||
$payload['verification_token'] = $verificationToken;
|
||||
}
|
||||
|
||||
DB::table('certificate_records')->insert($payload);
|
||||
|
||||
$student['cert_number'] = $certificateNumber;
|
||||
$student['verify_token'] = $verificationToken;
|
||||
}
|
||||
|
||||
$student['grade'] = $this->formatGrade((string) ($student['grade'] ?? ''));
|
||||
$student['verify_url'] = ! empty($student['verify_token'])
|
||||
? url('/api/certificates/verify/'.rawurlencode((string) $student['verify_token']))
|
||||
: null;
|
||||
}
|
||||
unset($student);
|
||||
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return [
|
||||
'students' => $students,
|
||||
'cert_date_display' => $certDateDisplay,
|
||||
'school_year' => $selectedYear,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{student: array<string, mixed>, cert_date_display: string}|null
|
||||
*/
|
||||
public function reprintPayload(string $certificateNumber): ?array
|
||||
{
|
||||
$record = DB::table('certificate_records')
|
||||
->where('certificate_number', strtoupper(trim($certificateNumber)))
|
||||
->first();
|
||||
|
||||
if (! $record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = (array) $record;
|
||||
$studentName = trim((string) ($row['student_name'] ?? ''));
|
||||
[$firstname, $lastname] = $this->splitStudentName($studentName);
|
||||
$verifyToken = $this->ensureVerificationToken($row);
|
||||
|
||||
return [
|
||||
'student' => [
|
||||
'id' => (int) ($row['student_id'] ?? 0),
|
||||
'firstname' => $firstname,
|
||||
'lastname' => $lastname,
|
||||
'grade' => (string) ($row['grade'] ?? ''),
|
||||
'cert_number' => (string) ($row['certificate_number'] ?? ''),
|
||||
'verify_token' => $verifyToken,
|
||||
'verify_url' => $verifyToken !== '' ? url('/api/certificates/verify/'.rawurlencode($verifyToken)) : null,
|
||||
],
|
||||
'cert_date_display' => $this->displayDateFromStorage($row['cert_date'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function verifyPayload(string $tokenOrNumber): ?array
|
||||
{
|
||||
$lookup = strtoupper(trim($tokenOrNumber));
|
||||
|
||||
$query = DB::table('certificate_records as cr')
|
||||
->select(
|
||||
'cr.certificate_number',
|
||||
'cr.student_name',
|
||||
'cr.grade',
|
||||
'cr.cert_date',
|
||||
'cr.school_year',
|
||||
'cr.issued_at',
|
||||
'u.firstname as admin_firstname',
|
||||
'u.lastname as admin_lastname'
|
||||
)
|
||||
->leftJoin('users as u', 'u.id', '=', 'cr.issued_by');
|
||||
|
||||
if ($this->hasVerificationTokenColumn) {
|
||||
$query->where(function ($builder) use ($tokenOrNumber, $lookup): void {
|
||||
$builder->where('cr.verification_token', trim($tokenOrNumber))
|
||||
->orWhere('cr.certificate_number', $lookup);
|
||||
});
|
||||
} else {
|
||||
$query->where('cr.certificate_number', $lookup);
|
||||
}
|
||||
|
||||
$record = $query->first();
|
||||
if (! $record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (array) $record;
|
||||
}
|
||||
|
||||
private function resolveSchoolYear(?string $schoolYear): string
|
||||
{
|
||||
$selectedYear = trim((string) ($schoolYear ?? ''));
|
||||
|
||||
if ($selectedYear !== '') {
|
||||
return $selectedYear;
|
||||
}
|
||||
|
||||
return trim((string) (Configuration::getConfig('school_year') ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, total: int, pass: int, cert: int}> $statsPerClass
|
||||
* @param array<int, list<array<string, mixed>>> $studentsByClass
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function buildGradeGroups(array $statsPerClass, array $studentsByClass): array
|
||||
{
|
||||
$gradeGroups = [];
|
||||
|
||||
foreach ($statsPerClass as $classSectionId => $stats) {
|
||||
[$label, $sortKey, $slug] = $this->classGroupMeta((string) ($stats['name'] ?? ''));
|
||||
|
||||
if (! isset($gradeGroups[$sortKey])) {
|
||||
$gradeGroups[$sortKey] = [
|
||||
'key' => $sortKey,
|
||||
'label' => $label,
|
||||
'slug' => $slug,
|
||||
'total' => 0,
|
||||
'pass' => 0,
|
||||
'cert' => 0,
|
||||
'sections' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$total = (int) ($stats['total'] ?? 0);
|
||||
$pass = (int) ($stats['pass'] ?? 0);
|
||||
$cert = (int) ($stats['cert'] ?? 0);
|
||||
|
||||
$gradeGroups[$sortKey]['total'] += $total;
|
||||
$gradeGroups[$sortKey]['pass'] += $pass;
|
||||
$gradeGroups[$sortKey]['cert'] += $cert;
|
||||
$gradeGroups[$sortKey]['sections'][] = [
|
||||
'section_id' => (int) $classSectionId,
|
||||
'section_name' => (string) ($stats['name'] ?? ''),
|
||||
'student_count' => $total,
|
||||
'pass_count' => $pass,
|
||||
'cert_count' => $cert,
|
||||
'remaining_count' => max(0, $pass - $cert),
|
||||
'students' => $studentsByClass[$classSectionId] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
ksort($gradeGroups);
|
||||
|
||||
foreach ($gradeGroups as &$group) {
|
||||
$group['fully_done'] = $group['pass'] > 0 && $group['cert'] >= $group['pass'];
|
||||
$group['has_pass'] = $group['pass'] > 0;
|
||||
}
|
||||
unset($group);
|
||||
|
||||
return $gradeGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $studentDecisionRows
|
||||
* @return array{eligible: bool, status: string, display_decisions: list<string>, year_score: float|null}
|
||||
*/
|
||||
private function normalizeDecisionState(array $studentDecisionRows): array
|
||||
{
|
||||
if ($studentDecisionRows === []) {
|
||||
return [
|
||||
'eligible' => false,
|
||||
'status' => 'pending',
|
||||
'display_decisions' => [],
|
||||
'year_score' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$allDecisions = array_map(
|
||||
static fn (array $row): string => trim((string) ($row['decision'] ?? '')),
|
||||
$studentDecisionRows
|
||||
);
|
||||
|
||||
$hasPending = in_array('', $allDecisions, true);
|
||||
$unique = array_values(array_unique(array_filter(
|
||||
$allDecisions,
|
||||
static fn (string $decision): bool => $decision !== ''
|
||||
)));
|
||||
|
||||
$eligible = ! $hasPending && $unique === ['Pass'];
|
||||
$displayDecisions = $eligible
|
||||
? ['Pass']
|
||||
: array_values(array_filter($unique, static fn (string $decision): bool => $decision !== 'Pass'));
|
||||
|
||||
$yearScore = null;
|
||||
foreach ($studentDecisionRows as $row) {
|
||||
if (array_key_exists('year_score', $row) && $row['year_score'] !== null && $row['year_score'] !== '') {
|
||||
$yearScore = round((float) $row['year_score'], 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'eligible' => $eligible,
|
||||
'status' => ($hasPending || $displayDecisions === []) ? 'pending' : ($eligible ? 'pass' : 'decision'),
|
||||
'display_decisions' => $displayDecisions,
|
||||
'year_score' => $yearScore,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $studentIds
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function fetchStudentsForIssuance(array $studentIds, ?int $classSectionId, string $schoolYear): array
|
||||
{
|
||||
$dashboard = $this->dashboard($schoolYear);
|
||||
$eligibleMap = [];
|
||||
|
||||
foreach ($dashboard['grade_groups'] as $group) {
|
||||
foreach (($group['sections'] ?? []) as $section) {
|
||||
foreach (($section['students'] ?? []) as $student) {
|
||||
$sid = (int) ($student['student_id'] ?? 0);
|
||||
if ($sid > 0) {
|
||||
$eligibleMap[$sid] = $student;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$filtered = [];
|
||||
foreach ($studentIds as $studentId) {
|
||||
$student = $eligibleMap[$studentId] ?? null;
|
||||
if (! is_array($student) || empty($student['eligible'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($classSectionId !== null && (int) ($student['class_section_id'] ?? 0) !== $classSectionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered[] = [
|
||||
'id' => $studentId,
|
||||
'firstname' => (string) ($student['firstname'] ?? ''),
|
||||
'lastname' => (string) ($student['lastname'] ?? ''),
|
||||
'grade' => (string) ($student['class_section_name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
private function formatGrade(string $raw): string
|
||||
{
|
||||
$clean = trim($raw);
|
||||
$lower = strtolower($clean);
|
||||
|
||||
if (preg_match('/^grade\s*(\d+)/i', $clean, $matches)) {
|
||||
return 'Grade '.(int) $matches[1];
|
||||
}
|
||||
|
||||
if ($lower === 'youth') {
|
||||
return 'Youth';
|
||||
}
|
||||
|
||||
if ($lower === 'kg' || $lower === 'kindergarten') {
|
||||
return 'Kindergarten';
|
||||
}
|
||||
|
||||
if (preg_match('/^(\d+)([- ][A-Za-z0-9]+)?$/', $clean, $matches)) {
|
||||
return 'Grade '.(int) $matches[1];
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
private function normalizeDisplayCertDate(string $certDate): string
|
||||
{
|
||||
$clean = trim($certDate);
|
||||
if ($clean === '') {
|
||||
return date('m/d/Y');
|
||||
}
|
||||
|
||||
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $clean) === 1) {
|
||||
$timestamp = strtotime($clean);
|
||||
if ($timestamp !== false) {
|
||||
return date('m/d/Y', $timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^\d{2}/\d{2}/\d{4}$#', $clean) === 1) {
|
||||
return $clean;
|
||||
}
|
||||
|
||||
return date('m/d/Y');
|
||||
}
|
||||
|
||||
private function parseCertDate(string $certDate): ?string
|
||||
{
|
||||
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $certDate, $matches) === 1) {
|
||||
return $matches[3].'-'.$matches[1].'-'.$matches[2];
|
||||
}
|
||||
|
||||
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $certDate) === 1) {
|
||||
return $certDate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function displayDateFromStorage(mixed $value): string
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return date('m/d/Y');
|
||||
}
|
||||
|
||||
$timestamp = strtotime((string) $value);
|
||||
|
||||
return $timestamp !== false ? date('m/d/Y', $timestamp) : date('m/d/Y');
|
||||
}
|
||||
|
||||
private function formatCertificateNumber(string $schoolYear, int $sequence): string
|
||||
{
|
||||
return 'ARSS-'.$schoolYear.'-'.str_pad((string) $sequence, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function generateVerificationToken(): string
|
||||
{
|
||||
if (! $this->hasVerificationTokenColumn) {
|
||||
return '';
|
||||
}
|
||||
|
||||
do {
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$exists = DB::table('certificate_records')
|
||||
->where('verification_token', $token)
|
||||
->exists();
|
||||
} while ($exists);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $record
|
||||
*/
|
||||
private function ensureVerificationToken(array $record): string
|
||||
{
|
||||
if (! $this->hasVerificationTokenColumn) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$token = trim((string) ($record['verification_token'] ?? ''));
|
||||
if ($token !== '') {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$recordId = (int) ($record['id'] ?? 0);
|
||||
if ($recordId <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$token = $this->generateVerificationToken();
|
||||
DB::table('certificate_records')
|
||||
->where('id', $recordId)
|
||||
->update([
|
||||
'verification_token' => $token,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string, 2: string}
|
||||
*/
|
||||
private function classGroupMeta(string $name): array
|
||||
{
|
||||
$lower = strtolower(trim($name));
|
||||
|
||||
if (preg_match('/grade\s*(\d+)/i', $name, $matches) === 1) {
|
||||
$grade = (int) $matches[1];
|
||||
return [
|
||||
'Grade '.$grade,
|
||||
'1_'.str_pad((string) $grade, 3, '0', STR_PAD_LEFT),
|
||||
'grade'.$grade,
|
||||
];
|
||||
}
|
||||
|
||||
if (str_contains($lower, 'kg') || str_contains($lower, 'kindergarten')) {
|
||||
return ['KG', '0_kg', 'kg'];
|
||||
}
|
||||
|
||||
if (str_contains($lower, 'arabic')) {
|
||||
return ['Arabic', '2_arabic', 'arabic'];
|
||||
}
|
||||
|
||||
if (str_contains($lower, 'youth')) {
|
||||
return ['Youth', '5_youth', 'youth'];
|
||||
}
|
||||
|
||||
return [
|
||||
$name,
|
||||
'3_'.$lower,
|
||||
(string) preg_replace('/[^a-z0-9]+/', '-', $lower),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function splitStudentName(string $studentName): array
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim($studentName), 2) ?: [];
|
||||
|
||||
return [
|
||||
$parts[0] ?? '',
|
||||
$parts[1] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ class CertificatePdfService
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array{firstname: string, lastname: string, grade: string}> $students
|
||||
* @param array<array<string, mixed>> $students
|
||||
*/
|
||||
public function generate(array $students, string $certDate): string
|
||||
{
|
||||
@@ -41,7 +41,22 @@ class CertificatePdfService
|
||||
$pdf->AddPage();
|
||||
$name = $student['firstname'] . ' ' . $student['lastname'];
|
||||
$grade = $this->formatGrade((string) ($student['grade'] ?? ''));
|
||||
$this->drawCertificate($pdf, $W, $H, $name, $grade, $certDate, $edwardianFont, $garamondBold, $ebGaramond);
|
||||
$certNumber = (string) ($student['cert_number'] ?? '');
|
||||
$verifyUrl = is_string($student['verify_url'] ?? null) ? $student['verify_url'] : null;
|
||||
|
||||
$this->drawCertificate(
|
||||
$pdf,
|
||||
$W,
|
||||
$H,
|
||||
$name,
|
||||
$grade,
|
||||
$certDate,
|
||||
$certNumber,
|
||||
$verifyUrl,
|
||||
$edwardianFont,
|
||||
$garamondBold,
|
||||
$ebGaramond
|
||||
);
|
||||
}
|
||||
|
||||
return (string) $pdf->Output('', 'S');
|
||||
@@ -54,28 +69,37 @@ class CertificatePdfService
|
||||
string $name,
|
||||
string $grade,
|
||||
string $certDate,
|
||||
string $certNumber,
|
||||
?string $verifyUrl,
|
||||
string $edwardianFont,
|
||||
string $garamondBold,
|
||||
string $ebGaramond
|
||||
): void {
|
||||
$pdf->Image($this->imgDir . 'title.png', 126, 0, 600);
|
||||
$pdf->Image($this->imgDir . 'background.png', 280, 176, 291, 340);
|
||||
$pdf->Image($this->imgDir . 'signature.png', 140, 399, 90, 90);
|
||||
$pdf->Image($this->imgDir . 'signature.png', 140, 410, 90, 80);
|
||||
|
||||
$pdf->SetFont('times', 'B', 24);
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
$pdf->SetXY(0, 151);
|
||||
$pdf->Cell($W, 24, 'Presented to:', 0, 0, 'C');
|
||||
|
||||
if (! empty($verifyUrl)) {
|
||||
$style = [
|
||||
'border' => false,
|
||||
'padding' => 0,
|
||||
'fgcolor' => [0, 0, 0],
|
||||
'bgcolor' => false,
|
||||
];
|
||||
$pdf->write2DBarcode($verifyUrl, 'QRCODE,L', 120, 162, 42, 42, $style, 'N');
|
||||
}
|
||||
|
||||
$pdf->SetFont($edwardianFont, '', 38);
|
||||
$pdf->SetDrawColor(0, 0, 0);
|
||||
$pdf->setTextRenderingMode(0.4, true, false);
|
||||
$pdf->SetXY(0, 219);
|
||||
$pdf->Cell($W, 38, $name, 0, 0, 'C');
|
||||
$pdf->setTextRenderingMode(0, true, false);
|
||||
$nameX = ($W - $pdf->GetStringWidth($name)) / 2;
|
||||
$this->drawGradientText($pdf, $edwardianFont, 38, $name, $nameX, 221.5);
|
||||
|
||||
$pdf->SetFont('times', '', 20);
|
||||
$pdf->SetXY(0, 243);
|
||||
$pdf->SetXY(0, 236);
|
||||
$pdf->Cell(600, 20, '___________________________________', 0, 0, 'R');
|
||||
|
||||
$pdf->SetFont($ebGaramond, '', 20);
|
||||
@@ -94,7 +118,7 @@ class CertificatePdfService
|
||||
$pdf->SetXY(0, 375);
|
||||
$pdf->Cell($W, 20, 'Al Rahma Sunday School', 0, 0, 'C');
|
||||
|
||||
$this->drawGradientText($pdf, $edwardianFont, 26, $certDate, 598, 453);
|
||||
$this->drawGradientText($pdf, $edwardianFont, 26, $certDate, 586, 456);
|
||||
|
||||
$pdf->SetFont('times', '', 20);
|
||||
$pdf->SetXY(0, 463);
|
||||
@@ -106,6 +130,14 @@ class CertificatePdfService
|
||||
$pdf->Cell(200, 20, '_________________', 0, 0, 'L');
|
||||
$pdf->SetXY(106, 492);
|
||||
$pdf->Cell(168, 20, 'Signature', 0, 0, 'C');
|
||||
|
||||
if ($certNumber !== '') {
|
||||
$pdf->SetFont('helvetica', '', 8);
|
||||
$pdf->SetTextColor(150, 150, 150);
|
||||
$pdf->SetXY(70.86, $H - 39.68);
|
||||
$pdf->Cell(200, 12, 'Certificate No. ' . $certNumber, 0, 0, 'L');
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private function drawGradientText(\TCPDF $pdf, string $fontName, float $fontSize, string $text, float $x, float $y): void
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Services\Expenses;
|
||||
|
||||
use App\Models\Expense;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExpenseQueryService
|
||||
{
|
||||
@@ -12,7 +11,7 @@ class ExpenseQueryService
|
||||
return $this->baseQuery()
|
||||
->orderByDesc('expenses.created_at')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->map(fn ($row) => $row->toArray())
|
||||
->all();
|
||||
}
|
||||
|
||||
@@ -22,7 +21,7 @@ class ExpenseQueryService
|
||||
->where('expenses.id', $id)
|
||||
->first();
|
||||
|
||||
return $row ? (array) $row : null;
|
||||
return $row ? $row->toArray() : null;
|
||||
}
|
||||
|
||||
private function baseQuery()
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Models\FinanceNotificationLog;
|
||||
use App\Models\FinanceReceiptSequence;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FinanceNotificationLogService
|
||||
{
|
||||
public function log(array $payload, ?int $actorId, string $status = 'sent'): array
|
||||
{
|
||||
$log = FinanceNotificationLog::query()->create(array_merge($payload, [
|
||||
'status' => $status,
|
||||
'sent_at' => $status === 'sent' ? now() : null,
|
||||
'failed_at' => $status === 'failed' ? now() : null,
|
||||
'created_by' => $actorId,
|
||||
]));
|
||||
return $log->toArray();
|
||||
}
|
||||
|
||||
public function list(array $filters): array
|
||||
{
|
||||
$q = FinanceNotificationLog::query();
|
||||
foreach (['status','notification_type','parent_id'] as $field) if (!empty($filters[$field])) $q->where($field, $filters[$field]);
|
||||
if (!empty($filters['date_from'])) $q->whereDate('created_at', '>=', $filters['date_from']);
|
||||
if (!empty($filters['date_to'])) $q->whereDate('created_at', '<=', $filters['date_to']);
|
||||
return ['rows' => $q->orderByDesc('id')->paginate((int)($filters['per_page'] ?? 25))];
|
||||
}
|
||||
|
||||
public function nextReceiptNumber(string $schoolYear): string
|
||||
{
|
||||
return DB::transaction(function () use ($schoolYear) {
|
||||
$seq = FinanceReceiptSequence::query()->lockForUpdate()->firstOrCreate(['school_year' => $schoolYear], ['prefix' => 'R', 'next_number' => 1]);
|
||||
$number = $seq->prefix . '-' . $schoolYear . '-' . str_pad((string) $seq->next_number, 6, '0', STR_PAD_LEFT);
|
||||
$seq->next_number = $seq->next_number + 1;
|
||||
$seq->save();
|
||||
return $number;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Models\InvoiceInstallment;
|
||||
use App\Models\InvoiceInstallmentPlan;
|
||||
use App\Models\PaymentInstallmentAllocation;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class InstallmentPlanService
|
||||
{
|
||||
public function list(array $filters): array
|
||||
{
|
||||
$q = InvoiceInstallmentPlan::query();
|
||||
foreach (['invoice_id','parent_id','school_year','status'] as $field) {
|
||||
if (!empty($filters[$field])) $q->where($field, $filters[$field]);
|
||||
}
|
||||
return ['rows' => $q->orderByDesc('id')->paginate((int)($filters['per_page'] ?? 25))];
|
||||
}
|
||||
|
||||
public function createForInvoice(int $invoiceId, array $payload, ?int $actorId): array
|
||||
{
|
||||
return DB::transaction(function () use ($invoiceId, $payload, $actorId) {
|
||||
$invoice = DB::table('invoices')->where('id', $invoiceId)->first();
|
||||
abort_if(!$invoice, 404, 'Invoice not found.');
|
||||
$total = (float) ($payload['total_amount'] ?? ($invoice->balance ?? $invoice->total_amount ?? 0));
|
||||
$count = (int) $payload['number_of_installments'];
|
||||
$plan = InvoiceInstallmentPlan::query()->create([
|
||||
'invoice_id' => $invoiceId,
|
||||
'parent_id' => $invoice->parent_id,
|
||||
'school_year' => $invoice->school_year ?? null,
|
||||
'plan_name' => $payload['plan_name'] ?? ('Invoice #' . $invoiceId . ' installment plan'),
|
||||
'total_amount' => $total,
|
||||
'number_of_installments' => $count,
|
||||
'status' => 'draft',
|
||||
'created_by' => $actorId,
|
||||
]);
|
||||
|
||||
$firstDue = Carbon::parse($payload['first_due_date']);
|
||||
$interval = (int) ($payload['interval_months'] ?? 1);
|
||||
$base = floor(($total / $count) * 100) / 100;
|
||||
$allocated = 0.0;
|
||||
for ($i = 1; $i <= $count; $i++) {
|
||||
$amount = $i === $count ? round($total - $allocated, 2) : $base;
|
||||
$allocated += $amount;
|
||||
InvoiceInstallment::query()->create([
|
||||
'installment_plan_id' => $plan->id,
|
||||
'invoice_id' => $invoiceId,
|
||||
'sequence' => $i,
|
||||
'due_date' => $firstDue->copy()->addMonths(($i - 1) * $interval)->toDateString(),
|
||||
'amount_due' => $amount,
|
||||
'amount_paid' => 0,
|
||||
'balance' => $amount,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->show($plan->id);
|
||||
});
|
||||
}
|
||||
|
||||
public function show(int $id): array
|
||||
{
|
||||
$plan = InvoiceInstallmentPlan::query()->findOrFail($id)->toArray();
|
||||
$plan['installments'] = InvoiceInstallment::query()->where('installment_plan_id', $id)->orderBy('sequence')->get()->toArray();
|
||||
return $plan;
|
||||
}
|
||||
|
||||
public function activate(int $id, ?int $actorId): array
|
||||
{
|
||||
$plan = InvoiceInstallmentPlan::query()->findOrFail($id);
|
||||
$plan->fill(['status' => 'active', 'approved_by' => $actorId])->save();
|
||||
InvoiceInstallment::query()->where('installment_plan_id', $id)->where('status', 'pending')->update(['status' => 'due']);
|
||||
return $this->show($id);
|
||||
}
|
||||
|
||||
public function cancel(int $id): array
|
||||
{
|
||||
InvoiceInstallmentPlan::query()->whereKey($id)->update(['status' => 'cancelled']);
|
||||
InvoiceInstallment::query()->where('installment_plan_id', $id)->whereNotIn('status', ['paid'])->update(['status' => 'cancelled']);
|
||||
return $this->show($id);
|
||||
}
|
||||
|
||||
public function allocatePayment(int $paymentId, array $payload): array
|
||||
{
|
||||
return DB::transaction(function () use ($paymentId, $payload) {
|
||||
$payment = DB::table('payments')->where('id', $paymentId)->first();
|
||||
abort_if(!$payment, 404, 'Payment not found.');
|
||||
$allocations = $payload['installments'] ?? null;
|
||||
if (!$allocations) {
|
||||
$remaining = (float) ($payment->paid_amount ?? 0);
|
||||
$installments = InvoiceInstallment::query()->where('invoice_id', $payment->invoice_id)->where('balance', '>', 0)->orderBy('due_date')->lockForUpdate()->get();
|
||||
$allocations = [];
|
||||
foreach ($installments as $inst) {
|
||||
if ($remaining <= 0) break;
|
||||
$amount = min($remaining, (float) $inst->balance);
|
||||
$allocations[] = ['installment_id' => $inst->id, 'amount' => $amount];
|
||||
$remaining -= $amount;
|
||||
}
|
||||
}
|
||||
|
||||
$created = [];
|
||||
foreach ($allocations as $a) {
|
||||
$inst = InvoiceInstallment::query()->lockForUpdate()->findOrFail($a['installment_id']);
|
||||
$amount = min((float) $a['amount'], (float) $inst->balance);
|
||||
if ($amount <= 0) continue;
|
||||
$created[] = PaymentInstallmentAllocation::query()->create([
|
||||
'payment_id' => $paymentId,
|
||||
'invoice_id' => $inst->invoice_id,
|
||||
'installment_id' => $inst->id,
|
||||
'amount' => $amount,
|
||||
])->toArray();
|
||||
$inst->amount_paid = (float) $inst->amount_paid + $amount;
|
||||
$inst->balance = max(0, (float) $inst->amount_due - (float) $inst->amount_paid);
|
||||
$inst->status = $inst->balance <= 0 ? 'paid' : 'partially_paid';
|
||||
$inst->paid_at = $inst->balance <= 0 ? now() : null;
|
||||
$inst->save();
|
||||
}
|
||||
return ['allocations' => $created];
|
||||
});
|
||||
}
|
||||
|
||||
public function due(bool $overdue = false): array
|
||||
{
|
||||
$q = InvoiceInstallment::query()->where('balance', '>', 0);
|
||||
$overdue ? $q->whereDate('due_date', '<', now()->toDateString()) : $q->whereDate('due_date', '>=', now()->toDateString());
|
||||
return ['rows' => $q->orderBy('due_date')->get()->toArray()];
|
||||
}
|
||||
|
||||
public function markOverdue(): int
|
||||
{
|
||||
return InvoiceInstallment::query()->where('balance', '>', 0)->whereDate('due_date', '<', now()->toDateString())->whereNotIn('status', ['paid','cancelled','waived'])->update(['status' => 'overdue']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\FinanceFollowUpNote;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ParentPaymentFollowUpService
|
||||
{
|
||||
public function report(array $filters): array
|
||||
{
|
||||
$schoolYear = $filters['school_year'] ?? (Configuration::getConfig('school_year') ?? date('Y'));
|
||||
$minimumBalance = (float) ($filters['minimum_balance'] ?? 0);
|
||||
$includePaid = filter_var($filters['include_paid_invoices'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
$invoiceRows = $this->invoiceRows($schoolYear, $filters);
|
||||
$paidByInvoice = $this->paymentTotalsByInvoice($schoolYear);
|
||||
$discountByInvoice = $this->discountTotalsByInvoice();
|
||||
$refundByInvoice = $this->refundTotalsByInvoice();
|
||||
$latestNotes = $this->latestNotesByParent($schoolYear);
|
||||
$students = $this->studentsByParent();
|
||||
|
||||
$parents = [];
|
||||
foreach ($invoiceRows as $invoice) {
|
||||
$invoiceId = (int) ($invoice->id ?? 0);
|
||||
$parentId = (int) ($invoice->parent_id ?? 0);
|
||||
if ($parentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$total = (float) ($invoice->total_amount ?? 0);
|
||||
$paid = (float) ($paidByInvoice[$invoiceId] ?? 0);
|
||||
$discounted = (float) ($discountByInvoice[$invoiceId] ?? 0);
|
||||
$refunded = (float) ($refundByInvoice[$invoiceId] ?? 0);
|
||||
$open = max(0, $total - $paid - $discounted - $refunded);
|
||||
if (!$includePaid && $open <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($parents[$parentId])) {
|
||||
$parents[$parentId] = [
|
||||
'parent_id' => $parentId,
|
||||
'parent_name' => $invoice->parent_name ?? null,
|
||||
'parent_email' => $invoice->parent_email ?? null,
|
||||
'parent_phone' => $invoice->parent_phone ?? null,
|
||||
'student_names' => $students[$parentId] ?? [],
|
||||
'school_year' => $schoolYear,
|
||||
'invoice_count' => 0,
|
||||
'open_invoice_count' => 0,
|
||||
'total_invoiced' => 0.0,
|
||||
'total_paid' => 0.0,
|
||||
'total_discounted' => 0.0,
|
||||
'total_refunded' => 0.0,
|
||||
'open_balance' => 0.0,
|
||||
'oldest_unpaid_invoice_date' => null,
|
||||
'days_overdue' => 0,
|
||||
'aging_bucket' => 'current',
|
||||
'last_payment_date' => null,
|
||||
'last_payment_amount' => 0.0,
|
||||
'last_contacted_at' => null,
|
||||
'last_contacted_by' => null,
|
||||
'follow_up_status' => 'not_contacted',
|
||||
'next_follow_up_date' => null,
|
||||
'promise_to_pay_date' => null,
|
||||
'promise_to_pay_amount' => null,
|
||||
'escalation_level' => 0,
|
||||
'notes_count' => 0,
|
||||
'risk_flags' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$row =& $parents[$parentId];
|
||||
$row['invoice_count']++;
|
||||
$row['total_invoiced'] += $total;
|
||||
$row['total_paid'] += $paid;
|
||||
$row['total_discounted'] += $discounted;
|
||||
$row['total_refunded'] += $refunded;
|
||||
$row['open_balance'] += $open;
|
||||
if ($open > 0) {
|
||||
$row['open_invoice_count']++;
|
||||
$due = $invoice->due_date ?? $invoice->issue_date ?? $invoice->created_at ?? null;
|
||||
if ($due && ($row['oldest_unpaid_invoice_date'] === null || strtotime((string) $due) < strtotime((string) $row['oldest_unpaid_invoice_date']))) {
|
||||
$row['oldest_unpaid_invoice_date'] = substr((string) $due, 0, 10);
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
}
|
||||
|
||||
foreach ($parents as $parentId => &$row) {
|
||||
$note = $latestNotes[$parentId] ?? null;
|
||||
if ($note) {
|
||||
$row['last_contacted_at'] = $note->created_at ? (string) $note->created_at : null;
|
||||
$row['last_contacted_by'] = $note->created_by ?? null;
|
||||
$row['follow_up_status'] = $note->status ?? 'not_contacted';
|
||||
$row['next_follow_up_date'] = $note->next_follow_up_date ?? null;
|
||||
$row['promise_to_pay_date'] = $note->promise_to_pay_date ?? null;
|
||||
$row['promise_to_pay_amount'] = $note->promise_to_pay_amount !== null ? (float) $note->promise_to_pay_amount : null;
|
||||
$row['escalation_level'] = (int) ($note->escalation_level ?? 0);
|
||||
}
|
||||
$row['notes_count'] = FinanceFollowUpNote::query()->where('parent_id', $parentId)->when($schoolYear, fn($q) => $q->where('school_year', $schoolYear))->count();
|
||||
$row['days_overdue'] = $this->daysOverdue($row['oldest_unpaid_invoice_date']);
|
||||
$row['aging_bucket'] = $this->agingBucket($row['days_overdue']);
|
||||
$row['risk_flags'] = $this->riskFlags($row);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$rows = array_values(array_filter($parents, function (array $row) use ($filters, $minimumBalance) {
|
||||
if ($row['open_balance'] < $minimumBalance) return false;
|
||||
if (!empty($filters['aging_bucket']) && $row['aging_bucket'] !== $filters['aging_bucket']) return false;
|
||||
if (!empty($filters['follow_up_status']) && $row['follow_up_status'] !== $filters['follow_up_status']) return false;
|
||||
if (!empty($filters['next_follow_up_due_before']) && (empty($row['next_follow_up_date']) || strtotime($row['next_follow_up_date']) > strtotime($filters['next_follow_up_due_before']))) return false;
|
||||
if (!empty($filters['promise_to_pay_due_before']) && (empty($row['promise_to_pay_date']) || strtotime($row['promise_to_pay_date']) > strtotime($filters['promise_to_pay_due_before']))) return false;
|
||||
return true;
|
||||
}));
|
||||
|
||||
usort($rows, fn($a, $b) => [$b['open_balance'], $b['days_overdue']] <=> [$a['open_balance'], $a['days_overdue']]);
|
||||
|
||||
return ['schoolYear' => $schoolYear, 'generatedAt' => now()->toISOString(), 'results' => $rows];
|
||||
}
|
||||
|
||||
public function storeNote(int $parentId, array $payload, ?int $actorId): array
|
||||
{
|
||||
$note = FinanceFollowUpNote::query()->create([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $payload['invoice_id'] ?? null,
|
||||
'school_year' => $payload['school_year'] ?? null,
|
||||
'status' => $payload['status'] ?? 'contacted',
|
||||
'contact_method' => $payload['contact_method'] ?? null,
|
||||
'promise_to_pay_date' => $payload['promise_to_pay_date'] ?? null,
|
||||
'promise_to_pay_amount' => $payload['promise_to_pay_amount'] ?? null,
|
||||
'next_follow_up_date' => $payload['next_follow_up_date'] ?? null,
|
||||
'escalation_level' => $payload['escalation_level'] ?? 0,
|
||||
'note' => $payload['note'] ?? null,
|
||||
'created_by' => $actorId,
|
||||
]);
|
||||
|
||||
return $note->toArray();
|
||||
}
|
||||
|
||||
public function csvRows(array $report): array
|
||||
{
|
||||
$rows = [['Parent ID','Parent','Email','Phone','Students','School Year','Invoices','Open Invoices','Total Invoiced','Total Paid','Open Balance','Oldest Unpaid','Days Overdue','Aging','Status','Next Follow-Up','Promise Date','Promise Amount','Risk Flags']];
|
||||
foreach ($report['results'] as $r) {
|
||||
$rows[] = [
|
||||
$r['parent_id'], $r['parent_name'], $r['parent_email'], $r['parent_phone'], implode('; ', $r['student_names'] ?? []), $r['school_year'], $r['invoice_count'], $r['open_invoice_count'], $r['total_invoiced'], $r['total_paid'], $r['open_balance'], $r['oldest_unpaid_invoice_date'], $r['days_overdue'], $r['aging_bucket'], $r['follow_up_status'], $r['next_follow_up_date'], $r['promise_to_pay_date'], $r['promise_to_pay_amount'], implode('; ', $r['risk_flags'] ?? []),
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function invoiceRows(string $schoolYear, array $filters)
|
||||
{
|
||||
$q = DB::table('invoices')->where('invoices.school_year', $schoolYear);
|
||||
if (!empty($filters['parent_id'])) $q->where('invoices.parent_id', $filters['parent_id']);
|
||||
if (!empty($filters['date_from'])) $q->whereDate('invoices.created_at', '>=', $filters['date_from']);
|
||||
if (!empty($filters['date_to'])) $q->whereDate('invoices.created_at', '<=', $filters['date_to']);
|
||||
if (Schema::hasTable('users')) {
|
||||
$name = $this->nameExpression('users');
|
||||
$q->leftJoin('users', 'users.id', '=', 'invoices.parent_id')
|
||||
->addSelect('invoices.*')
|
||||
->addSelect(DB::raw($name . ' as parent_name'))
|
||||
->addSelect('users.email as parent_email');
|
||||
if (Schema::hasColumn('users', 'phone')) $q->addSelect('users.phone as parent_phone');
|
||||
else $q->addSelect(DB::raw('NULL as parent_phone'));
|
||||
} else {
|
||||
$q->select('invoices.*', DB::raw('NULL as parent_name'), DB::raw('NULL as parent_email'), DB::raw('NULL as parent_phone'));
|
||||
}
|
||||
return $q->get();
|
||||
}
|
||||
|
||||
private function paymentTotalsByInvoice(string $schoolYear): array
|
||||
{
|
||||
if (!Schema::hasTable('payments')) return [];
|
||||
return DB::table('payments')->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) total'), DB::raw('MAX(payment_date) last_payment_date'))
|
||||
->where('school_year', $schoolYear)->whereNotIn('status', ['void','voided','failed','cancelled','canceled','reversed'])
|
||||
->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn($v) => (float) $v)->all();
|
||||
}
|
||||
|
||||
private function discountTotalsByInvoice(): array
|
||||
{
|
||||
if (!Schema::hasTable('discount_usages')) return [];
|
||||
$col = Schema::hasColumn('discount_usages', 'discount_amount') ? 'discount_amount' : 'amount';
|
||||
return DB::table('discount_usages')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
|
||||
}
|
||||
|
||||
private function refundTotalsByInvoice(): array
|
||||
{
|
||||
if (!Schema::hasTable('refunds')) return [];
|
||||
$col = Schema::hasColumn('refunds', 'refund_amount') ? 'refund_amount' : (Schema::hasColumn('refunds', 'amount') ? 'amount' : null);
|
||||
if (!$col) return [];
|
||||
return DB::table('refunds')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->whereNotIn('status', ['void','voided','cancelled','canceled'])->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
|
||||
}
|
||||
|
||||
private function latestNotesByParent(string $schoolYear): array
|
||||
{
|
||||
return FinanceFollowUpNote::query()->where('school_year', $schoolYear)->orderByDesc('created_at')->get()->unique('parent_id')->keyBy('parent_id')->all();
|
||||
}
|
||||
|
||||
private function studentsByParent(): array
|
||||
{
|
||||
if (!Schema::hasTable('students') || !Schema::hasColumn('students', 'parent_id')) return [];
|
||||
$name = $this->nameExpression('students');
|
||||
return DB::table('students')->select('parent_id', DB::raw($name.' as student_name'))->whereNotNull('parent_id')->get()->groupBy('parent_id')->map(fn($rows) => $rows->pluck('student_name')->filter()->values()->all())->all();
|
||||
}
|
||||
|
||||
private function nameExpression(string $table): string
|
||||
{
|
||||
if (Schema::hasColumn($table, 'name')) return $table.'.name';
|
||||
if (Schema::hasColumn($table, 'full_name')) return $table.'.full_name';
|
||||
$driver = DB::connection()->getDriverName();
|
||||
$concat = $driver === 'sqlite' ? '%s || \' \' || %s' : "CONCAT(%s, ' ', %s)";
|
||||
if (Schema::hasColumn($table, 'firstname') && Schema::hasColumn($table, 'lastname')) return sprintf($concat, $table.'.firstname', $table.'.lastname');
|
||||
if (Schema::hasColumn($table, 'first_name') && Schema::hasColumn($table, 'last_name')) return sprintf($concat, $table.'.first_name', $table.'.last_name');
|
||||
return "CAST($table.id AS CHAR)";
|
||||
}
|
||||
|
||||
private function daysOverdue(?string $date): int
|
||||
{
|
||||
if (!$date) return 0;
|
||||
return max(0, now()->startOfDay()->diffInDays(\Carbon\Carbon::parse($date)->startOfDay(), false) * -1);
|
||||
}
|
||||
|
||||
private function agingBucket(int $days): string
|
||||
{
|
||||
return match (true) { $days <= 0 => 'current', $days <= 30 => '1-30 days overdue', $days <= 60 => '31-60 days overdue', $days <= 90 => '61-90 days overdue', default => '90+ days overdue' };
|
||||
}
|
||||
|
||||
private function riskFlags(array $row): array
|
||||
{
|
||||
$flags = [];
|
||||
if ($row['days_overdue'] > 90) $flags[] = '90+ days overdue';
|
||||
if ($row['open_balance'] > 1000) $flags[] = 'high balance';
|
||||
if ($row['promise_to_pay_date'] && strtotime($row['promise_to_pay_date']) < strtotime(date('Y-m-d'))) $flags[] = 'missed promise-to-pay';
|
||||
if (!$row['parent_email'] && !$row['parent_phone']) $flags[] = 'missing contact info';
|
||||
return $flags;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Models\FinanceBalanceCarryforward;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class PriorYearBalanceCarryforwardService
|
||||
{
|
||||
public function preview(array $filters): array
|
||||
{
|
||||
$from = $filters['from_school_year'];
|
||||
$to = $filters['to_school_year'];
|
||||
$minimum = (float) ($filters['minimum_balance'] ?? 0);
|
||||
$parentId = $filters['parent_id'] ?? null;
|
||||
|
||||
$payments = $this->paymentTotals($from);
|
||||
$discounts = $this->discountTotals();
|
||||
$refunds = $this->refundTotals();
|
||||
|
||||
$q = DB::table('invoices')->where('school_year', $from);
|
||||
if ($parentId) $q->where('parent_id', $parentId);
|
||||
$invoices = $q->get();
|
||||
|
||||
$byParent = [];
|
||||
foreach ($invoices as $invoice) {
|
||||
$invoiceId = (int) $invoice->id;
|
||||
$balance = max(0, (float) ($invoice->total_amount ?? 0) - (float) ($payments[$invoiceId] ?? 0) - (float) ($discounts[$invoiceId] ?? 0) - (float) ($refunds[$invoiceId] ?? 0));
|
||||
if ($balance <= 0) continue;
|
||||
$pid = (int) $invoice->parent_id;
|
||||
if (!isset($byParent[$pid])) {
|
||||
$existing = FinanceBalanceCarryforward::query()->where('parent_id', $pid)->where('from_school_year', $from)->where('to_school_year', $to)->first();
|
||||
$byParent[$pid] = [
|
||||
'parent_id' => $pid,
|
||||
'from_school_year' => $from,
|
||||
'to_school_year' => $to,
|
||||
'source_invoice_ids' => [],
|
||||
'source_balance_amount' => 0.0,
|
||||
'proposed_carryforward_amount' => 0.0,
|
||||
'existing_carryforward_id' => $existing?->id,
|
||||
'warnings' => [],
|
||||
];
|
||||
if ($existing) $byParent[$pid]['warnings'][] = 'source invoice already carried forward';
|
||||
}
|
||||
$byParent[$pid]['source_invoice_ids'][] = $invoiceId;
|
||||
$byParent[$pid]['source_balance_amount'] += $balance;
|
||||
$byParent[$pid]['proposed_carryforward_amount'] += $balance;
|
||||
}
|
||||
|
||||
$rows = array_values(array_filter($byParent, fn($r) => $r['source_balance_amount'] >= $minimum));
|
||||
usort($rows, fn($a, $b) => $b['source_balance_amount'] <=> $a['source_balance_amount']);
|
||||
|
||||
return ['fromSchoolYear' => $from, 'toSchoolYear' => $to, 'generatedAt' => now()->toISOString(), 'rows' => $rows];
|
||||
}
|
||||
|
||||
public function storeDrafts(array $filters, ?int $actorId): array
|
||||
{
|
||||
$preview = $this->preview($filters);
|
||||
$created = [];
|
||||
foreach ($preview['rows'] as $row) {
|
||||
if (!empty($row['existing_carryforward_id'])) continue;
|
||||
$created[] = FinanceBalanceCarryforward::query()->create([
|
||||
'parent_id' => $row['parent_id'],
|
||||
'from_school_year' => $row['from_school_year'],
|
||||
'to_school_year' => $row['to_school_year'],
|
||||
'source_invoice_ids_json' => $row['source_invoice_ids'],
|
||||
'source_balance_amount' => $row['source_balance_amount'],
|
||||
'carryforward_amount' => $row['proposed_carryforward_amount'],
|
||||
'remaining_amount' => $row['proposed_carryforward_amount'],
|
||||
'status' => 'draft',
|
||||
'reason' => $filters['reason'] ?? null,
|
||||
'created_by' => $actorId,
|
||||
'metadata_json' => ['warnings' => $row['warnings']],
|
||||
])->toArray();
|
||||
}
|
||||
return ['created' => $created, 'skipped' => count($preview['rows']) - count($created)];
|
||||
}
|
||||
|
||||
public function approve(int $id, ?int $actorId): array
|
||||
{
|
||||
$cf = FinanceBalanceCarryforward::query()->findOrFail($id);
|
||||
$cf->fill(['status' => 'approved', 'approved_by' => $actorId, 'approved_at' => now()])->save();
|
||||
return $cf->fresh()->toArray();
|
||||
}
|
||||
|
||||
public function waive(int $id, float $amount, ?string $reason, ?int $actorId): array
|
||||
{
|
||||
$cf = FinanceBalanceCarryforward::query()->findOrFail($id);
|
||||
$waived = min((float) $cf->remaining_amount, $amount);
|
||||
$cf->waived_amount = (float) $cf->waived_amount + $waived;
|
||||
$cf->remaining_amount = max(0, (float) $cf->remaining_amount - $waived);
|
||||
$cf->status = $cf->remaining_amount <= 0 ? 'waived' : $cf->status;
|
||||
$cf->reason = $reason ?: $cf->reason;
|
||||
$cf->approved_by = $cf->approved_by ?: $actorId;
|
||||
$cf->save();
|
||||
return $cf->fresh()->toArray();
|
||||
}
|
||||
|
||||
public function adjust(int $id, float $amount, ?string $reason): array
|
||||
{
|
||||
$cf = FinanceBalanceCarryforward::query()->findOrFail($id);
|
||||
$cf->adjusted_amount = $amount;
|
||||
$cf->remaining_amount = max(0, (float) $cf->carryforward_amount - (float) $cf->waived_amount + $amount);
|
||||
$cf->reason = $reason ?: $cf->reason;
|
||||
$cf->save();
|
||||
return $cf->fresh()->toArray();
|
||||
}
|
||||
|
||||
public function postToNewYear(int $id, ?int $actorId): array
|
||||
{
|
||||
return DB::transaction(function () use ($id, $actorId) {
|
||||
$cf = FinanceBalanceCarryforward::query()->lockForUpdate()->findOrFail($id);
|
||||
if (!in_array($cf->status, ['approved','draft','pending_review'], true)) {
|
||||
return $cf->toArray() + ['warning' => 'Carryforward is not in a postable status.'];
|
||||
}
|
||||
|
||||
$amount = max(0, (float) $cf->carryforward_amount - (float) $cf->waived_amount + (float) $cf->adjusted_amount);
|
||||
$invoiceId = null;
|
||||
if (Schema::hasTable('invoices')) {
|
||||
$invoiceId = DB::table('invoices')->insertGetId([
|
||||
'parent_id' => $cf->parent_id,
|
||||
'invoice_number' => 'CF-' . $cf->to_school_year . '-' . $cf->id,
|
||||
'total_amount' => $amount,
|
||||
'balance' => $amount,
|
||||
'paid_amount' => 0,
|
||||
'school_year' => $cf->to_school_year,
|
||||
'issue_date' => now()->toDateString(),
|
||||
'status' => $amount > 0 ? 'unpaid' : 'paid',
|
||||
'description' => 'Previous year balance from ' . $cf->from_school_year,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'updated_by' => $actorId,
|
||||
]);
|
||||
}
|
||||
$cf->posted_invoice_id = $invoiceId;
|
||||
$cf->status = 'posted_to_new_year';
|
||||
$cf->remaining_amount = $amount;
|
||||
$cf->save();
|
||||
return $cf->fresh()->toArray();
|
||||
});
|
||||
}
|
||||
|
||||
public function report(array $filters): array
|
||||
{
|
||||
$q = FinanceBalanceCarryforward::query();
|
||||
foreach (['from_school_year','to_school_year','parent_id','status'] as $field) {
|
||||
if (!empty($filters[$field])) $q->where($field, $filters[$field]);
|
||||
}
|
||||
return ['generatedAt' => now()->toISOString(), 'rows' => $q->orderByDesc('remaining_amount')->get()->toArray()];
|
||||
}
|
||||
|
||||
public function csvRows(array $report): array
|
||||
{
|
||||
$rows = [['ID','Parent ID','From Year','To Year','Source Invoices','Source Balance','Carryforward','Waived','Adjusted','Remaining','Status','Posted Invoice']];
|
||||
foreach ($report['rows'] as $r) {
|
||||
$rows[] = [$r['id'],$r['parent_id'],$r['from_school_year'],$r['to_school_year'],json_encode($r['source_invoice_ids_json'] ?? []),$r['source_balance_amount'],$r['carryforward_amount'],$r['waived_amount'],$r['adjusted_amount'],$r['remaining_amount'],$r['status'],$r['posted_invoice_id'] ?? null];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function paymentTotals(string $schoolYear): array
|
||||
{
|
||||
if (!Schema::hasTable('payments')) return [];
|
||||
return DB::table('payments')->where('school_year', $schoolYear)->whereNotIn('status', ['void','voided','failed','cancelled','canceled','reversed'])->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
|
||||
}
|
||||
private function discountTotals(): array
|
||||
{
|
||||
if (!Schema::hasTable('discount_usages')) return [];
|
||||
$col = Schema::hasColumn('discount_usages','discount_amount') ? 'discount_amount' : 'amount';
|
||||
return DB::table('discount_usages')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
|
||||
}
|
||||
private function refundTotals(): array
|
||||
{
|
||||
if (!Schema::hasTable('refunds')) return [];
|
||||
$col = Schema::hasColumn('refunds','refund_amount') ? 'refund_amount' : (Schema::hasColumn('refunds','amount') ? 'amount' : null);
|
||||
if (!$col) return [];
|
||||
return DB::table('refunds')->whereNotIn('status', ['void','voided','cancelled','canceled'])->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class StakeholderFinancialAnalysisService
|
||||
{
|
||||
private array $excludedPaymentStatuses = [
|
||||
'void', 'voided', 'failed', 'refunded', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
|
||||
];
|
||||
|
||||
private array $excludedChargeStatuses = ['void', 'voided', 'cancelled', 'canceled'];
|
||||
|
||||
public function analyze(?string $dateFrom, ?string $dateTo, ?string $schoolYear, ?string $compareSchoolYear = null): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?: (Configuration::getConfig('school_year') ?? date('Y'));
|
||||
[$rangeFrom, $rangeTo] = $this->resolveDateRange($schoolYear, $dateFrom, $dateTo);
|
||||
|
||||
$current = $this->periodMetrics($schoolYear, $rangeFrom, $rangeTo);
|
||||
$comparison = null;
|
||||
if (!empty($compareSchoolYear)) {
|
||||
[$compareFrom, $compareTo] = $this->resolveDateRange($compareSchoolYear, null, null);
|
||||
$comparison = $this->periodMetrics($compareSchoolYear, $compareFrom, $compareTo);
|
||||
}
|
||||
|
||||
return [
|
||||
'schoolYear' => $schoolYear,
|
||||
'dateFrom' => $rangeFrom,
|
||||
'dateTo' => $rangeTo,
|
||||
'generatedAt' => now()->toISOString(),
|
||||
'audience' => 'stakeholders',
|
||||
'status' => 'read_only_report',
|
||||
'executiveSummary' => $this->executiveSummary($current, $comparison),
|
||||
'keyMetrics' => $current['keyMetrics'],
|
||||
'cashFlow' => $current['cashFlow'],
|
||||
'receivables' => $current['receivables'],
|
||||
'expenseAnalysis' => $current['expenseAnalysis'],
|
||||
'revenueAnalysis' => $current['revenueAnalysis'],
|
||||
'paymentMethodMix' => $current['paymentMethodMix'],
|
||||
'monthlyTrend' => $current['monthlyTrend'],
|
||||
'riskFlags' => $current['riskFlags'],
|
||||
'comparison' => $comparison ? [
|
||||
'schoolYear' => $compareSchoolYear,
|
||||
'keyMetrics' => $comparison['keyMetrics'],
|
||||
'deltas' => $this->deltas($current['keyMetrics'], $comparison['keyMetrics']),
|
||||
] : null,
|
||||
'notes' => [
|
||||
'This report is additive and read-only. It does not mutate invoices, payments, refunds, expenses, reimbursements, or legacy PayPal records.',
|
||||
'Revenue is based on invoice and charge records; cash collection is based on non-voided payment records.',
|
||||
'Ratios are directional management indicators, not audited financial statements.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function csvRows(array $analysis): array
|
||||
{
|
||||
$rows = [];
|
||||
$rows[] = ['Section', 'Metric', 'Value'];
|
||||
foreach (($analysis['keyMetrics'] ?? []) as $key => $value) {
|
||||
$rows[] = ['Key Metrics', $key, is_scalar($value) ? $value : json_encode($value)];
|
||||
}
|
||||
foreach (($analysis['cashFlow'] ?? []) as $key => $value) {
|
||||
$rows[] = ['Cash Flow', $key, is_scalar($value) ? $value : json_encode($value)];
|
||||
}
|
||||
foreach (($analysis['receivables'] ?? []) as $key => $value) {
|
||||
if ($key === 'agingBuckets') {
|
||||
foreach ($value as $bucket => $amount) {
|
||||
$rows[] = ['Receivables Aging', $bucket, $amount];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$rows[] = ['Receivables', $key, is_scalar($value) ? $value : json_encode($value)];
|
||||
}
|
||||
foreach (($analysis['expenseAnalysis']['byCategory'] ?? []) as $category => $amount) {
|
||||
$rows[] = ['Expense By Category', $category, $amount];
|
||||
}
|
||||
foreach (($analysis['paymentMethodMix'] ?? []) as $method => $amount) {
|
||||
$rows[] = ['Payment Method Mix', $method, $amount];
|
||||
}
|
||||
foreach (($analysis['monthlyTrend'] ?? []) as $month => $data) {
|
||||
$rows[] = ['Monthly Trend', $month . ' charges', $data['charges'] ?? 0];
|
||||
$rows[] = ['Monthly Trend', $month . ' collections', $data['collections'] ?? 0];
|
||||
$rows[] = ['Monthly Trend', $month . ' expenses', $data['expenses'] ?? 0];
|
||||
$rows[] = ['Monthly Trend', $month . ' netCash', $data['netCash'] ?? 0];
|
||||
}
|
||||
foreach (($analysis['riskFlags'] ?? []) as $flag) {
|
||||
$rows[] = ['Risk Flag', $flag['level'] ?? '', $flag['message'] ?? ''];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function periodMetrics(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
$invoiceTotals = $this->invoiceTotals($schoolYear, $dateFrom, $dateTo);
|
||||
$payments = $this->paymentTotals($schoolYear, $dateFrom, $dateTo);
|
||||
$refunds = $this->refundTotals($schoolYear, $dateFrom, $dateTo);
|
||||
$discounts = $this->discountTotals($schoolYear, $dateFrom, $dateTo);
|
||||
$additionalCharges = $this->additionalChargeTotals($schoolYear, $dateFrom, $dateTo);
|
||||
$eventCharges = $this->eventChargeTotals($schoolYear, $dateFrom, $dateTo);
|
||||
$expenses = $this->expenseTotals($schoolYear, $dateFrom, $dateTo);
|
||||
$reimbursements = $this->reimbursementTotals($schoolYear, $dateFrom, $dateTo);
|
||||
|
||||
$grossRevenue = $invoiceTotals['total'] + $additionalCharges['total'] + $eventCharges['total'];
|
||||
$netRevenue = max(0.0, $grossRevenue - $discounts['total'] - $refunds['total']);
|
||||
$cashCollected = $payments['total'];
|
||||
$cashOut = $expenses['total'] + $reimbursements['total'] + $refunds['total'];
|
||||
$netCash = $cashCollected - $cashOut;
|
||||
$receivables = max(0.0, $netRevenue - $cashCollected);
|
||||
$collectionRate = $netRevenue > 0 ? ($cashCollected / $netRevenue) * 100 : 0.0;
|
||||
$expenseRatio = $cashCollected > 0 ? (($expenses['total'] + $reimbursements['total']) / $cashCollected) * 100 : 0.0;
|
||||
$operatingMargin = $netRevenue > 0 ? (($netRevenue - $expenses['total'] - $reimbursements['total']) / $netRevenue) * 100 : 0.0;
|
||||
|
||||
$aging = $this->receivableAging($schoolYear, $dateFrom, $dateTo);
|
||||
|
||||
$keyMetrics = [
|
||||
'grossRevenue' => $this->money($grossRevenue),
|
||||
'netRevenue' => $this->money($netRevenue),
|
||||
'cashCollected' => $this->money($cashCollected),
|
||||
'refunds' => $this->money($refunds['total']),
|
||||
'discounts' => $this->money($discounts['total']),
|
||||
'expenses' => $this->money($expenses['total']),
|
||||
'reimbursements' => $this->money($reimbursements['total']),
|
||||
'netCash' => $this->money($netCash),
|
||||
'receivables' => $this->money($receivables),
|
||||
'collectionRatePct' => $this->percent($collectionRate),
|
||||
'expenseRatioPct' => $this->percent($expenseRatio),
|
||||
'operatingMarginPct' => $this->percent($operatingMargin),
|
||||
'invoiceCount' => $invoiceTotals['count'],
|
||||
'payingFamilyCount' => $payments['familyCount'],
|
||||
];
|
||||
|
||||
return [
|
||||
'keyMetrics' => $keyMetrics,
|
||||
'cashFlow' => [
|
||||
'cashIn' => $this->money($cashCollected),
|
||||
'cashOut' => $this->money($cashOut),
|
||||
'netCash' => $this->money($netCash),
|
||||
'cashOutBreakdown' => [
|
||||
'expenses' => $this->money($expenses['total']),
|
||||
'reimbursements' => $this->money($reimbursements['total']),
|
||||
'refunds' => $this->money($refunds['total']),
|
||||
],
|
||||
],
|
||||
'receivables' => [
|
||||
'totalOpenReceivables' => $this->money($aging['total']),
|
||||
'agingBuckets' => array_map(fn ($v) => $this->money($v), $aging['buckets']),
|
||||
'largestOpenInvoices' => $aging['largestOpenInvoices'],
|
||||
],
|
||||
'expenseAnalysis' => [
|
||||
'totalExpenses' => $this->money($expenses['total']),
|
||||
'byCategory' => array_map(fn ($v) => $this->money($v), $expenses['byCategory']),
|
||||
],
|
||||
'revenueAnalysis' => [
|
||||
'invoiceCharges' => $this->money($invoiceTotals['total']),
|
||||
'additionalCharges' => $this->money($additionalCharges['total']),
|
||||
'eventCharges' => $this->money($eventCharges['total']),
|
||||
'discounts' => $this->money($discounts['total']),
|
||||
'refunds' => $this->money($refunds['total']),
|
||||
'netRevenue' => $this->money($netRevenue),
|
||||
],
|
||||
'paymentMethodMix' => array_map(fn ($v) => $this->money($v), $payments['byMethod']),
|
||||
'monthlyTrend' => $this->monthlyTrend($schoolYear, $dateFrom, $dateTo),
|
||||
'riskFlags' => $this->riskFlags($collectionRate, $expenseRatio, $aging['buckets'], $netCash),
|
||||
];
|
||||
}
|
||||
|
||||
private function invoiceTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
if (!Schema::hasTable('invoices')) {
|
||||
return ['total' => 0.0, 'count' => 0];
|
||||
}
|
||||
$q = DB::table('invoices')->where('school_year', $schoolYear);
|
||||
$this->applyDateRange($q, $this->dateColumn('invoices', ['issue_date', 'created_at']), $dateFrom, $dateTo);
|
||||
$row = (array) $q->selectRaw('COALESCE(SUM(total_amount),0) as total, COUNT(*) as count')->first();
|
||||
return ['total' => (float) ($row['total'] ?? 0), 'count' => (int) ($row['count'] ?? 0)];
|
||||
}
|
||||
|
||||
private function paymentTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
if (!Schema::hasTable('payments')) {
|
||||
return ['total' => 0.0, 'familyCount' => 0, 'byMethod' => []];
|
||||
}
|
||||
$base = DB::table('payments')->where('school_year', $schoolYear);
|
||||
$this->excludeStatuses($base, 'payments', $this->excludedPaymentStatuses);
|
||||
$this->applyDateRange($base, $this->dateColumn('payments', ['payment_date', 'created_at']), $dateFrom, $dateTo);
|
||||
$total = (float) ((array) (clone $base)->selectRaw('COALESCE(SUM(paid_amount),0) as total')->first())['total'];
|
||||
$familyCount = (int) ((array) (clone $base)->selectRaw('COUNT(DISTINCT parent_id) as count')->first())['count'];
|
||||
$methodRows = (clone $base)
|
||||
->selectRaw("COALESCE(NULLIF(payment_method,''), 'unknown') as method, COALESCE(SUM(paid_amount),0) as total")
|
||||
->groupBy('method')
|
||||
->orderByDesc('total')
|
||||
->get();
|
||||
$byMethod = [];
|
||||
foreach ($methodRows as $row) {
|
||||
$arr = (array) $row;
|
||||
$byMethod[(string) ($arr['method'] ?? 'unknown')] = (float) ($arr['total'] ?? 0);
|
||||
}
|
||||
return ['total' => $total, 'familyCount' => $familyCount, 'byMethod' => $byMethod];
|
||||
}
|
||||
|
||||
private function refundTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
if (!Schema::hasTable('refunds')) {
|
||||
return ['total' => 0.0];
|
||||
}
|
||||
$q = DB::table('refunds')->where('school_year', $schoolYear);
|
||||
if (Schema::hasColumn('refunds', 'status')) {
|
||||
$q->whereIn('status', ['paid', 'partially_paid', 'approved', 'Paid', 'Partially Paid', 'Approved']);
|
||||
}
|
||||
$this->applyDateRange($q, $this->dateColumn('refunds', ['refunded_at', 'approved_at', 'created_at']), $dateFrom, $dateTo);
|
||||
$amountColumn = Schema::hasColumn('refunds', 'refund_paid_amount') ? 'refund_paid_amount' : 'refund_amount';
|
||||
$row = (array) $q->selectRaw('COALESCE(SUM(' . $amountColumn . '),0) as total')->first();
|
||||
return ['total' => (float) ($row['total'] ?? 0)];
|
||||
}
|
||||
|
||||
private function discountTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
if (!Schema::hasTable('discount_usages')) {
|
||||
return ['total' => 0.0];
|
||||
}
|
||||
$q = DB::table('discount_usages as du');
|
||||
if (Schema::hasTable('invoices')) {
|
||||
$q->leftJoin('invoices as i', 'i.id', '=', 'du.invoice_id')->where('i.school_year', $schoolYear);
|
||||
}
|
||||
$this->excludeStatuses($q, 'discount_usages', $this->excludedChargeStatuses, 'du.status');
|
||||
$this->applyDateRange($q, Schema::hasColumn('discount_usages', 'created_at') ? 'du.created_at' : null, $dateFrom, $dateTo);
|
||||
$amountColumn = Schema::hasColumn('discount_usages', 'discount_amount') ? 'discount_amount' : 'amount';
|
||||
$row = (array) $q->selectRaw('COALESCE(SUM(du.' . $amountColumn . '),0) as total')->first();
|
||||
return ['total' => (float) ($row['total'] ?? 0)];
|
||||
}
|
||||
|
||||
private function additionalChargeTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
if (!Schema::hasTable('additional_charges')) {
|
||||
return ['total' => 0.0];
|
||||
}
|
||||
$q = DB::table('additional_charges')->where('school_year', $schoolYear);
|
||||
$this->excludeStatuses($q, 'additional_charges', $this->excludedChargeStatuses);
|
||||
$this->applyDateRange($q, $this->dateColumn('additional_charges', ['due_date', 'created_at']), $dateFrom, $dateTo);
|
||||
$row = (array) $q->selectRaw('COALESCE(SUM(amount),0) as total')->first();
|
||||
return ['total' => (float) ($row['total'] ?? 0)];
|
||||
}
|
||||
|
||||
private function eventChargeTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
$table = Schema::hasTable('event_charges') ? 'event_charges' : (Schema::hasTable('event_charge') ? 'event_charge' : null);
|
||||
if ($table === null) {
|
||||
return ['total' => 0.0];
|
||||
}
|
||||
$q = DB::table($table);
|
||||
if (Schema::hasColumn($table, 'school_year')) {
|
||||
$q->where('school_year', $schoolYear);
|
||||
}
|
||||
$this->excludeStatuses($q, $table, $this->excludedChargeStatuses);
|
||||
$amountColumn = Schema::hasColumn($table, 'amount') ? 'amount' : (Schema::hasColumn($table, 'charge_amount') ? 'charge_amount' : null);
|
||||
if ($amountColumn === null) {
|
||||
return ['total' => 0.0];
|
||||
}
|
||||
$this->applyDateRange($q, $this->dateColumn($table, ['charge_date', 'created_at']), $dateFrom, $dateTo);
|
||||
$row = (array) $q->selectRaw('COALESCE(SUM(' . $amountColumn . '),0) as total')->first();
|
||||
return ['total' => (float) ($row['total'] ?? 0)];
|
||||
}
|
||||
|
||||
private function expenseTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
if (!Schema::hasTable('expenses')) {
|
||||
return ['total' => 0.0, 'byCategory' => []];
|
||||
}
|
||||
$q = DB::table('expenses')->where('school_year', $schoolYear);
|
||||
$this->excludeStatuses($q, 'expenses', ['void', 'voided', 'rejected', 'cancelled', 'canceled']);
|
||||
$this->applyDateRange($q, $this->dateColumn('expenses', ['expense_date', 'date', 'created_at']), $dateFrom, $dateTo);
|
||||
$amountColumn = Schema::hasColumn('expenses', 'amount') ? 'amount' : 'total_amount';
|
||||
$total = (float) ((array) (clone $q)->selectRaw('COALESCE(SUM(' . $amountColumn . '),0) as total')->first())['total'];
|
||||
$categoryColumn = Schema::hasColumn('expenses', 'category') ? 'category' : (Schema::hasColumn('expenses', 'expense_category') ? 'expense_category' : null);
|
||||
$byCategory = [];
|
||||
if ($categoryColumn) {
|
||||
foreach ((clone $q)->selectRaw("COALESCE(NULLIF($categoryColumn,''), 'uncategorized') as category, COALESCE(SUM($amountColumn),0) as total")->groupBy('category')->orderByDesc('total')->get() as $row) {
|
||||
$arr = (array) $row;
|
||||
$byCategory[(string) ($arr['category'] ?? 'uncategorized')] = (float) ($arr['total'] ?? 0);
|
||||
}
|
||||
}
|
||||
return ['total' => $total, 'byCategory' => $byCategory];
|
||||
}
|
||||
|
||||
private function reimbursementTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
if (!Schema::hasTable('reimbursements')) {
|
||||
return ['total' => 0.0];
|
||||
}
|
||||
$q = DB::table('reimbursements')->where('school_year', $schoolYear);
|
||||
if (Schema::hasColumn('reimbursements', 'status')) {
|
||||
$q->whereNotIn('status', ['void', 'voided', 'rejected', 'cancelled', 'canceled']);
|
||||
}
|
||||
$this->applyDateRange($q, $this->dateColumn('reimbursements', ['reimbursed_at', 'created_at']), $dateFrom, $dateTo);
|
||||
$amountColumn = Schema::hasColumn('reimbursements', 'amount') ? 'amount' : (Schema::hasColumn('reimbursements', 'total_amount') ? 'total_amount' : 'reimbursement_amount');
|
||||
$row = (array) $q->selectRaw('COALESCE(SUM(' . $amountColumn . '),0) as total')->first();
|
||||
return ['total' => (float) ($row['total'] ?? 0)];
|
||||
}
|
||||
|
||||
private function receivableAging(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
$buckets = ['current' => 0.0, '1_30_days' => 0.0, '31_60_days' => 0.0, '61_90_days' => 0.0, 'over_90_days' => 0.0];
|
||||
$largest = [];
|
||||
if (!Schema::hasTable('invoices')) {
|
||||
return ['total' => 0.0, 'buckets' => $buckets, 'largestOpenInvoices' => []];
|
||||
}
|
||||
$q = DB::table('invoices')->where('school_year', $schoolYear);
|
||||
$this->applyDateRange($q, $this->dateColumn('invoices', ['issue_date', 'created_at']), $dateFrom, $dateTo);
|
||||
$rows = $q->select(['id', 'invoice_number', 'parent_id', 'total_amount', 'paid_amount', 'balance', 'due_date', 'created_at'])->get();
|
||||
$today = now()->startOfDay();
|
||||
$total = 0.0;
|
||||
foreach ($rows as $row) {
|
||||
$arr = (array) $row;
|
||||
$balance = (float) ($arr['balance'] ?? 0);
|
||||
if ($balance <= 0) {
|
||||
$totalAmount = (float) ($arr['total_amount'] ?? 0);
|
||||
$paidAmount = (float) ($arr['paid_amount'] ?? 0);
|
||||
$balance = max(0.0, $totalAmount - $paidAmount);
|
||||
}
|
||||
if ($balance <= 0) {
|
||||
continue;
|
||||
}
|
||||
$total += $balance;
|
||||
$dueRaw = $arr['due_date'] ?? $arr['created_at'] ?? null;
|
||||
$days = 0;
|
||||
try {
|
||||
if (!empty($dueRaw)) {
|
||||
$days = max(0, (int) \Illuminate\Support\Carbon::parse($dueRaw)->startOfDay()->diffInDays($today, false));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$days = 0;
|
||||
}
|
||||
$bucket = $days <= 0 ? 'current' : ($days <= 30 ? '1_30_days' : ($days <= 60 ? '31_60_days' : ($days <= 90 ? '61_90_days' : 'over_90_days')));
|
||||
$buckets[$bucket] += $balance;
|
||||
$largest[] = [
|
||||
'invoiceId' => (int) ($arr['id'] ?? 0),
|
||||
'invoiceNumber' => (string) ($arr['invoice_number'] ?? ''),
|
||||
'parentId' => (int) ($arr['parent_id'] ?? 0),
|
||||
'balance' => $this->money($balance),
|
||||
'daysPastDue' => $days,
|
||||
];
|
||||
}
|
||||
usort($largest, fn ($a, $b) => (float) $b['balance'] <=> (float) $a['balance']);
|
||||
return ['total' => $total, 'buckets' => $buckets, 'largestOpenInvoices' => array_slice($largest, 0, 10)];
|
||||
}
|
||||
|
||||
private function monthlyTrend(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
$months = [];
|
||||
$invoiceMonth = $this->monthlySum('invoices', 'total_amount', $schoolYear, $this->dateColumn('invoices', ['issue_date', 'created_at']), $dateFrom, $dateTo);
|
||||
$paymentMonth = $this->monthlySum('payments', 'paid_amount', $schoolYear, $this->dateColumn('payments', ['payment_date', 'created_at']), $dateFrom, $dateTo, $this->excludedPaymentStatuses);
|
||||
$expenseColumn = Schema::hasTable('expenses') && Schema::hasColumn('expenses', 'amount') ? 'amount' : 'total_amount';
|
||||
$expenseMonth = $this->monthlySum('expenses', $expenseColumn, $schoolYear, $this->dateColumn('expenses', ['expense_date', 'date', 'created_at']), $dateFrom, $dateTo, ['void', 'voided', 'rejected', 'cancelled', 'canceled']);
|
||||
|
||||
foreach (array_unique(array_merge(array_keys($invoiceMonth), array_keys($paymentMonth), array_keys($expenseMonth))) as $month) {
|
||||
$charges = $invoiceMonth[$month] ?? 0.0;
|
||||
$collections = $paymentMonth[$month] ?? 0.0;
|
||||
$expenses = $expenseMonth[$month] ?? 0.0;
|
||||
$months[$month] = [
|
||||
'charges' => $this->money($charges),
|
||||
'collections' => $this->money($collections),
|
||||
'expenses' => $this->money($expenses),
|
||||
'netCash' => $this->money($collections - $expenses),
|
||||
];
|
||||
}
|
||||
ksort($months);
|
||||
return $months;
|
||||
}
|
||||
|
||||
private function monthlySum(string $table, string $amountColumn, string $schoolYear, ?string $dateColumn, ?string $dateFrom, ?string $dateTo, array $excludedStatuses = []): array
|
||||
{
|
||||
if (!Schema::hasTable($table) || !Schema::hasColumn($table, $amountColumn) || $dateColumn === null) {
|
||||
return [];
|
||||
}
|
||||
$q = DB::table($table);
|
||||
if (Schema::hasColumn($table, 'school_year')) {
|
||||
$q->where('school_year', $schoolYear);
|
||||
}
|
||||
$this->excludeStatuses($q, $table, $excludedStatuses);
|
||||
$this->applyDateRange($q, $dateColumn, $dateFrom, $dateTo);
|
||||
$driver = DB::connection()->getDriverName();
|
||||
$monthExpr = $driver === 'sqlite'
|
||||
? "strftime('%Y-%m', $dateColumn)"
|
||||
: "DATE_FORMAT($dateColumn, '%Y-%m')";
|
||||
$rows = $q->selectRaw($monthExpr . ' as month, COALESCE(SUM(' . $amountColumn . '),0) as total')
|
||||
->groupBy('month')
|
||||
->orderBy('month')
|
||||
->get();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$arr = (array) $row;
|
||||
if (!empty($arr['month'])) {
|
||||
$out[(string) $arr['month']] = (float) ($arr['total'] ?? 0);
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function executiveSummary(array $current, ?array $comparison): array
|
||||
{
|
||||
$summary = [
|
||||
'headline' => 'Stakeholder financial analysis generated from existing Laravel finance tables.',
|
||||
'collectionRatePct' => $current['keyMetrics']['collectionRatePct'] ?? '0.00',
|
||||
'netCash' => $current['keyMetrics']['netCash'] ?? '0.00',
|
||||
'receivables' => $current['keyMetrics']['receivables'] ?? '0.00',
|
||||
];
|
||||
if ($comparison) {
|
||||
$summary['comparison'] = $this->deltas($current['keyMetrics'], $comparison['keyMetrics']);
|
||||
}
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function riskFlags(float $collectionRate, float $expenseRatio, array $agingBuckets, float $netCash): array
|
||||
{
|
||||
$flags = [];
|
||||
if ($collectionRate < 85.0) {
|
||||
$flags[] = ['level' => 'warning', 'message' => 'Collection rate is below 85%. Review receivables and follow-up cadence.'];
|
||||
}
|
||||
if (($agingBuckets['over_90_days'] ?? 0.0) > 0) {
|
||||
$flags[] = ['level' => 'warning', 'message' => 'There are receivables more than 90 days past due.'];
|
||||
}
|
||||
if ($expenseRatio > 80.0) {
|
||||
$flags[] = ['level' => 'watch', 'message' => 'Expenses and reimbursements exceed 80% of collections.'];
|
||||
}
|
||||
if ($netCash < 0) {
|
||||
$flags[] = ['level' => 'critical', 'message' => 'Net cash for the selected period is negative.'];
|
||||
}
|
||||
if (empty($flags)) {
|
||||
$flags[] = ['level' => 'ok', 'message' => 'No automatic risk flags were triggered for the selected period.'];
|
||||
}
|
||||
return $flags;
|
||||
}
|
||||
|
||||
private function deltas(array $current, array $previous): array
|
||||
{
|
||||
$keys = ['grossRevenue', 'netRevenue', 'cashCollected', 'expenses', 'netCash', 'receivables'];
|
||||
$out = [];
|
||||
foreach ($keys as $key) {
|
||||
$cur = (float) ($current[$key] ?? 0);
|
||||
$prev = (float) ($previous[$key] ?? 0);
|
||||
$out[$key] = [
|
||||
'current' => $this->money($cur),
|
||||
'previous' => $this->money($prev),
|
||||
'change' => $this->money($cur - $prev),
|
||||
'changePct' => $prev != 0.0 ? $this->percent((($cur - $prev) / abs($prev)) * 100) : null,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function resolveDateRange(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
if ((!$dateFrom || !$dateTo) && preg_match('/^(\d{4})\D?(\d{4})$/', $schoolYear, $m)) {
|
||||
$dateFrom = $dateFrom ?: $m[1] . '-01-01';
|
||||
$dateTo = $dateTo ?: $m[2] . '-12-31';
|
||||
}
|
||||
return [$dateFrom, $dateTo];
|
||||
}
|
||||
|
||||
private function applyDateRange($query, ?string $column, ?string $dateFrom, ?string $dateTo): void
|
||||
{
|
||||
if ($column === null) {
|
||||
return;
|
||||
}
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(' . $column . ') >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(' . $column . ') <= ?', [$dateTo]);
|
||||
}
|
||||
}
|
||||
|
||||
private function excludeStatuses($query, string $table, array $statuses, ?string $qualifiedColumn = null): void
|
||||
{
|
||||
if (empty($statuses) || !Schema::hasColumn($table, 'status')) {
|
||||
return;
|
||||
}
|
||||
$column = $qualifiedColumn ?: 'status';
|
||||
$query->where(function ($q) use ($column, $statuses) {
|
||||
$q->whereNotIn($column, $statuses)->orWhereNull($column);
|
||||
});
|
||||
}
|
||||
|
||||
private function dateColumn(string $table, array $candidates): ?string
|
||||
{
|
||||
if (!Schema::hasTable($table)) {
|
||||
return null;
|
||||
}
|
||||
foreach ($candidates as $column) {
|
||||
if (Schema::hasColumn($table, $column)) {
|
||||
return $column;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function money(float $value): string
|
||||
{
|
||||
return number_format(round($value, 2), 2, '.', '');
|
||||
}
|
||||
|
||||
private function percent(float $value): string
|
||||
{
|
||||
return number_format(round($value, 2), 2, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Grading\Display;
|
||||
|
||||
use App\Models\SemesterScore;
|
||||
use App\Models\SemesterScoreSnapshot;
|
||||
|
||||
class GradeCalculationDisplayResolver
|
||||
{
|
||||
public function resolve(SemesterScore $score): array
|
||||
{
|
||||
$mode = (string) ($score->calculation_mode ?: 'legacy');
|
||||
$version = (string) ($score->calculation_policy_version ?: 'legacy_v1');
|
||||
|
||||
if ($mode === 'strong' && $score->snapshot_id) {
|
||||
$snapshot = SemesterScoreSnapshot::query()->find($score->snapshot_id);
|
||||
if ($snapshot) {
|
||||
return [
|
||||
'mode' => 'strong',
|
||||
'policy_version' => $version,
|
||||
'label' => 'Strong Calculation',
|
||||
'score' => $score,
|
||||
'snapshot' => $snapshot,
|
||||
'inputs' => $snapshot->input_json,
|
||||
'calculation' => $snapshot->calculation_json,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'mode' => 'legacy',
|
||||
'policy_version' => $version ?: 'legacy_v1',
|
||||
'label' => 'Legacy Calculation',
|
||||
'score' => $score,
|
||||
'snapshot' => null,
|
||||
'legacy_note' => 'This score is displayed from stored legacy semester_scores values. Legacy averages ignored blank scores, PTAP used legacy dynamic weighting, and attendance includes one-absence grace.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,19 @@ namespace App\Services\Grading;
|
||||
use App\Models\GradingLock;
|
||||
use App\Models\ClassSection;
|
||||
use RuntimeException;
|
||||
use App\Services\Grading\Policy\GradingPolicyResolver;
|
||||
use App\Services\Grading\Validation\GradebookFinalizationValidator;
|
||||
|
||||
class GradingLockService
|
||||
{
|
||||
public function __construct(
|
||||
private ?GradingPolicyResolver $policyResolver = null,
|
||||
private ?GradebookFinalizationValidator $finalizationValidator = null
|
||||
) {
|
||||
$this->policyResolver = $this->policyResolver ?? new GradingPolicyResolver();
|
||||
$this->finalizationValidator = $this->finalizationValidator ?? new GradebookFinalizationValidator();
|
||||
}
|
||||
|
||||
public function toggle(int $classSectionId, string $semester, string $schoolYear, ?int $userId): bool
|
||||
{
|
||||
if ($classSectionId <= 0 || $semester === '' || $schoolYear === '') {
|
||||
@@ -25,6 +35,8 @@ class GradingLockService
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->assertCanLock($classSectionId, $semester, $schoolYear);
|
||||
|
||||
if ($existing) {
|
||||
$existing->update([
|
||||
'is_locked' => 1,
|
||||
@@ -83,6 +95,8 @@ class GradingLockService
|
||||
$count = 0;
|
||||
|
||||
foreach ($sectionIds as $sid) {
|
||||
$this->assertCanLock($sid, $semester, $schoolYear);
|
||||
|
||||
if (!empty($existingBySection[$sid])) {
|
||||
if ($existingBySection[$sid]->is_locked) {
|
||||
continue;
|
||||
@@ -115,4 +129,18 @@ class GradingLockService
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function assertCanLock(int $classSectionId, string $semester, string $schoolYear): void
|
||||
{
|
||||
$mode = $this->policyResolver->calculationMode($classSectionId, $semester, $schoolYear);
|
||||
if ($mode !== GradingPolicyResolver::STRONG_MODE) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->finalizationValidator->validateClassSectionBeforeLock($classSectionId, $semester, $schoolYear);
|
||||
if (!$result['valid']) {
|
||||
$count = count($result['errors']);
|
||||
throw new RuntimeException("Cannot lock class section {$classSectionId}; strong grading validation failed with {$count} error(s).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Models\ScoreComment;
|
||||
use App\Models\SemesterScore;
|
||||
use App\Models\Student;
|
||||
use App\Models\ClassSection;
|
||||
use App\Services\Grading\Validation\ScoreValueValidator;
|
||||
use App\Services\Scores\SemesterScoreService;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -72,18 +73,26 @@ class GradingScoreService
|
||||
$scores = $payload['scores'] ?? [];
|
||||
$comments = $payload['comments'] ?? [];
|
||||
|
||||
$validator = new ScoreValueValidator();
|
||||
foreach ($scoreIds as $i => $id) {
|
||||
$normalizedScore = $validator->normalizeNullable($scores[$i] ?? null, ScoreValueValidator::DEFAULT_MAX_POINTS, ucfirst($type) . ' score');
|
||||
$model->newQuery()->whereKey($id)->update([
|
||||
'score' => $scores[$i] ?? null,
|
||||
'score' => $normalizedScore,
|
||||
'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS,
|
||||
'status' => $validator->inferStatus($normalizedScore, false),
|
||||
'comment' => $comments[$i] ?? null,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
} elseif (in_array($type, ['midterm', 'final', 'test'], true)) {
|
||||
$score = $payload['score'] ?? null;
|
||||
$validator = new ScoreValueValidator();
|
||||
$normalizedScore = $validator->normalizeNullable($score, ScoreValueValidator::DEFAULT_MAX_POINTS, ucfirst($type) . ' score');
|
||||
|
||||
$data = [
|
||||
'score' => $score,
|
||||
'score' => $normalizedScore,
|
||||
'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS,
|
||||
'status' => $validator->inferStatus($normalizedScore, false),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Grading\Policy;
|
||||
|
||||
use App\Models\Configuration;
|
||||
|
||||
class GradingPolicyResolver
|
||||
{
|
||||
public const LEGACY_MODE = 'legacy';
|
||||
public const STRONG_MODE = 'strong';
|
||||
public const LEGACY_VERSION = 'legacy_v1';
|
||||
public const STRONG_VERSION = 'strong_v1';
|
||||
|
||||
public function calculationMode(?int $classSectionId = null, ?string $semester = null, ?string $schoolYear = null): string
|
||||
{
|
||||
$enabled = strtolower((string) (Configuration::getConfig('strong_grading_enabled') ?? ''));
|
||||
if (!in_array($enabled, ['1', 'true', 'yes', 'on'], true)) {
|
||||
return self::LEGACY_MODE;
|
||||
}
|
||||
|
||||
$allowedSections = trim((string) (Configuration::getConfig('strong_grading_class_sections') ?? ''));
|
||||
if ($allowedSections === '' || $allowedSections === '*') {
|
||||
return self::STRONG_MODE;
|
||||
}
|
||||
|
||||
$ids = array_filter(array_map('trim', explode(',', $allowedSections)), fn ($v) => $v !== '');
|
||||
return $classSectionId !== null && in_array((string) $classSectionId, $ids, true)
|
||||
? self::STRONG_MODE
|
||||
: self::LEGACY_MODE;
|
||||
}
|
||||
|
||||
public function policyVersion(string $mode): string
|
||||
{
|
||||
return $mode === self::STRONG_MODE ? self::STRONG_VERSION : self::LEGACY_VERSION;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Grading\Snapshots;
|
||||
|
||||
use App\Models\SemesterScore;
|
||||
use App\Models\SemesterScoreSnapshot;
|
||||
|
||||
class SemesterScoreSnapshotService
|
||||
{
|
||||
public function createForScore(SemesterScore $score, array $input, array $calculation, ?int $actorId = null): SemesterScoreSnapshot
|
||||
{
|
||||
return SemesterScoreSnapshot::query()->create([
|
||||
'student_id' => $score->student_id,
|
||||
'school_id' => $score->school_id,
|
||||
'class_section_id' => $score->class_section_id,
|
||||
'semester' => $score->semester,
|
||||
'school_year' => $score->school_year,
|
||||
'grading_profile_id' => null,
|
||||
'grading_profile_version' => $score->calculation_policy_version ?: 'strong_v1',
|
||||
'calculation_mode' => $score->calculation_mode ?: 'strong',
|
||||
'calculation_policy_version' => $score->calculation_policy_version ?: 'strong_v1',
|
||||
'input_json' => $input,
|
||||
'calculation_json' => $calculation,
|
||||
'semester_score' => $score->semester_score,
|
||||
'calculated_by' => $actorId,
|
||||
'calculated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Grading;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StrongCategoryAverageCalculator
|
||||
{
|
||||
public function calculate(string $table, int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array
|
||||
{
|
||||
$query = DB::table($table)
|
||||
->where('student_id', $studentId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if ($classSectionId !== null) {
|
||||
$query->where('class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$rows = $query->get();
|
||||
$included = [];
|
||||
$pending = [];
|
||||
$excluded = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$status = (string) ($row->status ?? 'pending');
|
||||
$score = $row->score ?? null;
|
||||
$maxPoints = isset($row->max_points) && is_numeric($row->max_points) && (float) $row->max_points > 0
|
||||
? (float) $row->max_points
|
||||
: 100.0;
|
||||
|
||||
if ($status === 'pending' || $status === '') {
|
||||
$pending[] = $row;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($status, ['excused', 'not_assigned'], true)) {
|
||||
$excluded[] = $row;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status === 'missing') {
|
||||
$included[] = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status === 'scored' && $score !== null && is_numeric($score)) {
|
||||
$included[] = ((float) $score / $maxPoints) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'average' => count($included) > 0 ? round(array_sum($included) / count($included), 2) : null,
|
||||
'included_count' => count($included),
|
||||
'excluded_count' => count($excluded),
|
||||
'pending_count' => count($pending),
|
||||
'has_pending' => count($pending) > 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Grading\Validation;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GradebookFinalizationValidator
|
||||
{
|
||||
/**
|
||||
* Strong-mode validation only. Legacy mode deliberately remains displayable/lockable
|
||||
* so historical records are not reinterpreted by the new policy.
|
||||
*/
|
||||
public function validateClassSectionBeforeLock(int $classSectionId, string $semester, string $schoolYear): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach ($this->scoreTables() as $table => $meta) {
|
||||
if (!DB::getSchemaBuilder()->hasTable($table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$query = DB::table($table)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if (DB::getSchemaBuilder()->hasColumn($table, 'status')) {
|
||||
$pending = (clone $query)->where(function ($q) {
|
||||
$q->whereNull('status')->orWhere('status', 'pending');
|
||||
})->get();
|
||||
|
||||
foreach ($pending as $row) {
|
||||
$errors[] = $this->error($table, $meta['category'], $row, 'pending_score', 'Score must be marked scored, missing, excused, or not_assigned before strong-mode lock.');
|
||||
}
|
||||
}
|
||||
|
||||
if (DB::getSchemaBuilder()->hasColumn($table, 'max_points')) {
|
||||
$invalidMax = (clone $query)->where(function ($q) {
|
||||
$q->whereNull('max_points')->orWhere('max_points', '<=', 0);
|
||||
})->get();
|
||||
|
||||
foreach ($invalidMax as $row) {
|
||||
$errors[] = $this->error($table, $meta['category'], $row, 'invalid_max_points', 'Max points must be greater than 0.');
|
||||
}
|
||||
|
||||
$invalidScores = (clone $query)
|
||||
->whereNotNull('score')
|
||||
->where(function ($q) {
|
||||
$q->where('score', '<', 0)->orWhereRaw('score > max_points');
|
||||
})
|
||||
->get();
|
||||
|
||||
foreach ($invalidScores as $row) {
|
||||
$errors[] = $this->error($table, $meta['category'], $row, 'score_out_of_range', 'Score must be between 0 and max_points.');
|
||||
}
|
||||
} else {
|
||||
$invalidScores = (clone $query)
|
||||
->whereNotNull('score')
|
||||
->where(function ($q) {
|
||||
$q->where('score', '<', 0)->orWhere('score', '>', 100);
|
||||
})
|
||||
->get();
|
||||
|
||||
foreach ($invalidScores as $row) {
|
||||
$errors[] = $this->error($table, $meta['category'], $row, 'score_out_of_range', 'Score must be between 0 and 100.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => empty($errors),
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
private function scoreTables(): array
|
||||
{
|
||||
return [
|
||||
'homework' => ['category' => 'homework'],
|
||||
'quiz' => ['category' => 'quiz'],
|
||||
'project' => ['category' => 'project'],
|
||||
'participation' => ['category' => 'participation'],
|
||||
'midterm_exam' => ['category' => 'midterm_exam'],
|
||||
'final_exam' => ['category' => 'final_exam'],
|
||||
];
|
||||
}
|
||||
|
||||
private function error(string $table, string $category, object $row, string $code, string $message): array
|
||||
{
|
||||
return [
|
||||
'table' => $table,
|
||||
'category' => $category,
|
||||
'student_id' => isset($row->student_id) ? (int) $row->student_id : null,
|
||||
'item_index' => $row->homework_index ?? $row->quiz_index ?? $row->project_index ?? null,
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Grading\Validation;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class ScoreValueValidator
|
||||
{
|
||||
public const DEFAULT_MAX_POINTS = 100.0;
|
||||
|
||||
public function normalizeNullable(mixed $value, float|int|null $maxPoints = self::DEFAULT_MAX_POINTS, string $label = 'Score'): ?float
|
||||
{
|
||||
if ($value === null || (is_string($value) && trim($value) === '')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$max = $this->normalizeMaxPoints($maxPoints, $label);
|
||||
|
||||
if (!is_numeric($value)) {
|
||||
throw new InvalidArgumentException("{$label} must be numeric.");
|
||||
}
|
||||
|
||||
$score = (float) $value;
|
||||
if ($score < 0 || $score > $max) {
|
||||
throw new InvalidArgumentException("{$label} must be between 0 and {$max}.");
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
public function normalizeMaxPoints(float|int|null $maxPoints = self::DEFAULT_MAX_POINTS, string $label = 'Score'): float
|
||||
{
|
||||
$max = $maxPoints === null ? self::DEFAULT_MAX_POINTS : (float) $maxPoints;
|
||||
if ($max <= 0) {
|
||||
throw new InvalidArgumentException("{$label} max points must be greater than 0.");
|
||||
}
|
||||
return $max;
|
||||
}
|
||||
|
||||
public function inferStatus(?float $score, bool $missingAllowed = false): string
|
||||
{
|
||||
if ($score !== null) {
|
||||
return 'scored';
|
||||
}
|
||||
|
||||
return $missingAllowed ? 'excused' : 'pending';
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,11 @@ class InvoicePdfService
|
||||
return $this->renderPdfInvoice($data);
|
||||
}
|
||||
|
||||
public function previewData(int $invoiceId): array
|
||||
{
|
||||
return $this->prepareInvoiceData($invoiceId);
|
||||
}
|
||||
|
||||
private function prepareInvoiceData(int $invoiceId): array
|
||||
{
|
||||
$invoice = Invoice::query()->find($invoiceId);
|
||||
|
||||
@@ -43,7 +43,11 @@ class NavBuilderService
|
||||
$this->sortTreeAlpha($tree);
|
||||
|
||||
$flatAlpha = $all;
|
||||
usort($flatAlpha, fn ($a, $b) => strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')));
|
||||
usort($flatAlpha, function ($a, $b) {
|
||||
$result = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? ''));
|
||||
|
||||
return $result !== 0 ? $result : ((int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0));
|
||||
});
|
||||
|
||||
$roleAssignments = $this->buildRoleAssignments();
|
||||
$flattened = $this->flattenTreeForResponse($tree, $roleAssignments);
|
||||
@@ -109,6 +113,8 @@ class NavBuilderService
|
||||
'nav_item_id' => $navItemId,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->normalizeSortOrdersAlphabetically();
|
||||
});
|
||||
|
||||
$this->navbarService->clearCache();
|
||||
@@ -118,7 +124,16 @@ class NavBuilderService
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$deleted = (bool) NavItem::query()->whereKey($id)->delete();
|
||||
$deleted = false;
|
||||
|
||||
DB::transaction(function () use ($id, &$deleted): void {
|
||||
$deleted = (bool) NavItem::query()->whereKey($id)->delete();
|
||||
|
||||
if ($deleted) {
|
||||
$this->normalizeSortOrdersAlphabetically();
|
||||
}
|
||||
});
|
||||
|
||||
$this->navbarService->clearCache();
|
||||
|
||||
return $deleted;
|
||||
@@ -126,15 +141,39 @@ class NavBuilderService
|
||||
|
||||
public function reorder(array $orders): void
|
||||
{
|
||||
DB::transaction(function () use ($orders): void {
|
||||
foreach ($orders as $id => $order) {
|
||||
NavItem::query()->whereKey((int) $id)->update(['sort_order' => (int) $order]);
|
||||
}
|
||||
DB::transaction(function (): void {
|
||||
$this->normalizeSortOrdersAlphabetically();
|
||||
});
|
||||
|
||||
$this->navbarService->clearCache();
|
||||
}
|
||||
|
||||
private function normalizeSortOrdersAlphabetically(): void
|
||||
{
|
||||
$rows = NavItem::query()
|
||||
->select(['id', 'menu_parent_id', 'label'])
|
||||
->orderBy('menu_parent_id', 'asc')
|
||||
->orderBy('label', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->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) {
|
||||
NavItem::query()
|
||||
->whereKey((int) $item->id)
|
||||
->update(['sort_order' => ($index + 1) * 10]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function buildRoleAssignments(): array
|
||||
{
|
||||
$rows = RoleNavItem::query()
|
||||
@@ -205,7 +244,11 @@ class NavBuilderService
|
||||
|
||||
private function sortTreeAlpha(array &$nodes): void
|
||||
{
|
||||
usort($nodes, fn ($a, $b) => strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')));
|
||||
usort($nodes, function ($a, $b) {
|
||||
$result = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? ''));
|
||||
|
||||
return $result !== 0 ? $result : ((int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0));
|
||||
});
|
||||
foreach ($nodes as &$node) {
|
||||
if (!empty($node['children'])) {
|
||||
$this->sortTreeAlpha($node['children']);
|
||||
|
||||
@@ -21,7 +21,9 @@ class NavbarService
|
||||
|
||||
$rows = NavItem::query()
|
||||
->where('is_enabled', 1)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->orderBy('menu_parent_id', 'asc')
|
||||
->orderBy('label', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
@@ -44,6 +46,8 @@ class NavbarService
|
||||
}
|
||||
unset($node);
|
||||
|
||||
$this->sortTreeAlpha($tree);
|
||||
|
||||
return $tree;
|
||||
});
|
||||
}
|
||||
@@ -52,4 +56,29 @@ class NavbarService
|
||||
{
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
private function sortTreeAlpha(array &$nodes): void
|
||||
{
|
||||
usort($nodes, function ($a, $b) {
|
||||
$result = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? ''));
|
||||
|
||||
return $result !== 0 ? $result : ((int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0));
|
||||
});
|
||||
|
||||
foreach ($nodes as &$node) {
|
||||
if (!empty($node['children'])) {
|
||||
$this->sortTreeAlpha($node['children']);
|
||||
}
|
||||
}
|
||||
unset($node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Promotions\Placement;
|
||||
|
||||
class BalancedSectionPlacementService
|
||||
{
|
||||
public const ALGORITHM = 'score_band_balanced_v1';
|
||||
|
||||
public function assign(array $students, int $capacity): array
|
||||
{
|
||||
$total = count($students);
|
||||
if ($total === 0) {
|
||||
return [
|
||||
'section_count' => 0,
|
||||
'target_sizes' => [],
|
||||
'sections' => [],
|
||||
'assignments' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$sectionCount = $total <= $capacity ? 1 : (int) ceil($total / $capacity);
|
||||
$targetSizes = $this->targetSizes($total, $sectionCount);
|
||||
$sections = [];
|
||||
for ($i = 1; $i <= $sectionCount; $i++) {
|
||||
$sections[$i] = [
|
||||
'section_index' => $i,
|
||||
'target_size' => $targetSizes[$i],
|
||||
'total' => 0,
|
||||
'bands' => ['A' => 0, 'B' => 0, 'C' => 0, 'D' => 0],
|
||||
'students' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$grouped = ['A' => [], 'B' => [], 'C' => [], 'D' => []];
|
||||
foreach ($students as $student) {
|
||||
$band = $student['score_band'] ?? 'D';
|
||||
$grouped[$band][] = $student;
|
||||
}
|
||||
|
||||
foreach (array_keys($grouped) as $band) {
|
||||
usort($grouped[$band], function (array $a, array $b): int {
|
||||
$scoreA = $a['final_score'] ?? $a['placement_score'] ?? -1;
|
||||
$scoreB = $b['final_score'] ?? $b['placement_score'] ?? -1;
|
||||
if ($scoreA !== $scoreB) {
|
||||
return $scoreB <=> $scoreA;
|
||||
}
|
||||
$name = strcmp((string) ($a['student_name'] ?? ''), (string) ($b['student_name'] ?? ''));
|
||||
return $name !== 0 ? $name : ((int) $a['student_id'] <=> (int) $b['student_id']);
|
||||
});
|
||||
}
|
||||
|
||||
$assignments = [];
|
||||
$order = 1;
|
||||
foreach (['A', 'B', 'C', 'D'] as $band) {
|
||||
foreach ($grouped[$band] as $student) {
|
||||
$sectionIndex = $this->selectSection($sections, $band);
|
||||
$sections[$sectionIndex]['total']++;
|
||||
$sections[$sectionIndex]['bands'][$band]++;
|
||||
$sections[$sectionIndex]['students'][] = (int) $student['student_id'];
|
||||
$student['planned_section_index'] = $sectionIndex;
|
||||
$student['assignment_order'] = $order++;
|
||||
$assignments[] = $student;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'section_count' => $sectionCount,
|
||||
'target_sizes' => $targetSizes,
|
||||
'sections' => array_values($sections),
|
||||
'assignments' => $assignments,
|
||||
];
|
||||
}
|
||||
|
||||
private function targetSizes(int $total, int $sectionCount): array
|
||||
{
|
||||
$base = intdiv($total, $sectionCount);
|
||||
$remainder = $total % $sectionCount;
|
||||
$sizes = [];
|
||||
for ($i = 1; $i <= $sectionCount; $i++) {
|
||||
$sizes[$i] = $base + ($i <= $remainder ? 1 : 0);
|
||||
}
|
||||
return $sizes;
|
||||
}
|
||||
|
||||
private function selectSection(array $sections, string $band): int
|
||||
{
|
||||
$eligible = array_filter($sections, fn ($s) => $s['total'] < $s['target_size']);
|
||||
if (empty($eligible)) {
|
||||
$eligible = $sections;
|
||||
}
|
||||
|
||||
uasort($eligible, function (array $a, array $b) use ($band): int {
|
||||
$cmp = $a['total'] <=> $b['total'];
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
$cmp = ($a['bands'][$band] ?? 0) <=> ($b['bands'][$band] ?? 0);
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
return $a['section_index'] <=> $b['section_index'];
|
||||
});
|
||||
|
||||
return (int) array_key_first($eligible);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Promotions\Placement;
|
||||
|
||||
use App\Models\StudentPromotionRecord;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PlacementPoolBuilder
|
||||
{
|
||||
public function __construct(private ScoreBandClassifier $bands)
|
||||
{
|
||||
}
|
||||
|
||||
public function build(string $fromSchoolYear, string $toSchoolYear, ?int $toGradeLevelId = null): array
|
||||
{
|
||||
$pool = [];
|
||||
$exceptions = $this->exceptionBuckets($fromSchoolYear, $toSchoolYear);
|
||||
|
||||
$returning = StudentPromotionRecord::query()
|
||||
->from('student_promotion_records as r')
|
||||
->join('students as s', 's.id', '=', 'r.student_id')
|
||||
->leftJoin('student_decisions as d', function ($join) use ($fromSchoolYear) {
|
||||
$join->on('d.student_id', '=', 'r.student_id')
|
||||
->where('d.school_year', '=', $fromSchoolYear);
|
||||
})
|
||||
->where('r.current_school_year', $fromSchoolYear)
|
||||
->where('r.next_school_year', $toSchoolYear)
|
||||
->where('r.enrollment_status', StudentPromotionRecord::ENROLLMENT_COMPLETED)
|
||||
->where('r.promotion_status', StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED)
|
||||
->when($toGradeLevelId !== null, fn ($q) => $q->where('r.promoted_level_id', $toGradeLevelId))
|
||||
->selectRaw('r.*, s.firstname, s.lastname, s.is_active, d.id as decision_id, d.decision, d.year_score')
|
||||
->orderBy('s.lastname')
|
||||
->orderBy('s.firstname')
|
||||
->orderBy('s.id')
|
||||
->get();
|
||||
|
||||
foreach ($returning as $row) {
|
||||
$studentId = (int) $row->student_id;
|
||||
if ((int) ($row->is_active ?? 1) === 0) {
|
||||
$exceptions['withdrawn_or_inactive_student'][] = $studentId;
|
||||
continue;
|
||||
}
|
||||
if ($this->alreadyPlaced($studentId, $toSchoolYear)) {
|
||||
$exceptions['duplicate_target_year_placement'][] = $studentId;
|
||||
continue;
|
||||
}
|
||||
if (!$this->isPromotedDecision((string) ($row->decision ?? ''))) {
|
||||
$bucket = $row->decision === null ? 'missing_student_decision' : 'decision_conflicts_with_enrollment_request';
|
||||
$exceptions[$bucket][] = $studentId;
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = $row->year_score !== null ? (float) $row->year_score : ($row->final_average !== null ? (float) $row->final_average : null);
|
||||
if ($score === null) {
|
||||
$exceptions['missing_student_decision'][] = $studentId;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$band = $this->bands->fromScore($score, true);
|
||||
} catch (\RuntimeException) {
|
||||
$exceptions['failed_retained'][] = $studentId;
|
||||
continue;
|
||||
}
|
||||
|
||||
$pool[] = [
|
||||
'student_id' => $studentId,
|
||||
'student_type' => 'returning',
|
||||
'student_name' => trim((string) $row->lastname . ', ' . (string) $row->firstname),
|
||||
'source_decision_id' => $row->decision_id ? (int) $row->decision_id : null,
|
||||
'source_enrollment_id' => $row->enrollment_id ? (int) $row->enrollment_id : null,
|
||||
'final_score' => $score,
|
||||
'placement_score' => null,
|
||||
'score_band' => $band,
|
||||
'placement_band_reason' => 'returning_student_final_score',
|
||||
];
|
||||
}
|
||||
|
||||
$newStudents = DB::table('enrollments as e')
|
||||
->join('students as s', 's.id', '=', 'e.student_id')
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'e.class_section_id')
|
||||
->where('e.school_year', $toSchoolYear)
|
||||
->where('s.is_new', 1)
|
||||
->where('e.admission_status', 'accepted')
|
||||
->whereIn('e.enrollment_status', ['enrolled', 'payment pending'])
|
||||
->when($toGradeLevelId !== null, function ($q) use ($toGradeLevelId) {
|
||||
$q->where(function ($inner) use ($toGradeLevelId) {
|
||||
$inner->whereNull('e.class_section_id')
|
||||
->orWhere('cs.class_id', $toGradeLevelId);
|
||||
});
|
||||
})
|
||||
->selectRaw('e.id as enrollment_id, e.student_id, s.firstname, s.lastname, s.is_active')
|
||||
->orderBy('s.lastname')
|
||||
->orderBy('s.firstname')
|
||||
->orderBy('s.id')
|
||||
->get();
|
||||
|
||||
foreach ($newStudents as $row) {
|
||||
$studentId = (int) $row->student_id;
|
||||
if ((int) ($row->is_active ?? 1) === 0) {
|
||||
$exceptions['withdrawn_or_inactive_student'][] = $studentId;
|
||||
continue;
|
||||
}
|
||||
if ($this->alreadyPlaced($studentId, $toSchoolYear)) {
|
||||
$exceptions['duplicate_target_year_placement'][] = $studentId;
|
||||
continue;
|
||||
}
|
||||
|
||||
$pool[] = [
|
||||
'student_id' => $studentId,
|
||||
'student_type' => 'new',
|
||||
'student_name' => trim((string) $row->lastname . ', ' . (string) $row->firstname),
|
||||
'source_decision_id' => null,
|
||||
'source_enrollment_id' => (int) $row->enrollment_id,
|
||||
'final_score' => null,
|
||||
'placement_score' => null,
|
||||
'score_band' => 'D',
|
||||
'placement_band_reason' => 'new_student_default_band',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'pool' => $pool,
|
||||
'exceptions' => array_map(fn ($ids) => array_values(array_unique($ids)), $exceptions),
|
||||
];
|
||||
}
|
||||
|
||||
private function exceptionBuckets(string $fromSchoolYear, string $toSchoolYear): array
|
||||
{
|
||||
$buckets = [
|
||||
'passed_but_parent_enrollment_missing' => [],
|
||||
'passed_and_parent_enrollment_completed' => [],
|
||||
'failed_retained' => [],
|
||||
'missing_student_decision' => [],
|
||||
'pending_student_decision' => [],
|
||||
'decision_conflicts_with_enrollment_request' => [],
|
||||
'withdrawn_or_inactive_student' => [],
|
||||
'duplicate_target_year_placement' => [],
|
||||
];
|
||||
|
||||
$rows = DB::table('student_decisions as d')
|
||||
->leftJoin('student_promotion_records as r', function ($join) use ($toSchoolYear) {
|
||||
$join->on('r.student_id', '=', 'd.student_id')
|
||||
->where('r.next_school_year', '=', $toSchoolYear);
|
||||
})
|
||||
->where('d.school_year', $fromSchoolYear)
|
||||
->select('d.student_id', 'd.decision', 'r.enrollment_status')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$decision = strtolower(trim((string) $row->decision));
|
||||
$studentId = (int) $row->student_id;
|
||||
if ($this->isPromotedDecision($decision)) {
|
||||
if ($row->enrollment_status === StudentPromotionRecord::ENROLLMENT_COMPLETED) {
|
||||
$buckets['passed_and_parent_enrollment_completed'][] = $studentId;
|
||||
} else {
|
||||
$buckets['passed_but_parent_enrollment_missing'][] = $studentId;
|
||||
}
|
||||
} elseif (in_array($decision, ['failed', 'fail', 'retained', 'repeat', 'repeated_level'], true)) {
|
||||
$buckets['failed_retained'][] = $studentId;
|
||||
} elseif ($decision === '' || $decision === 'pending') {
|
||||
$buckets['pending_student_decision'][] = $studentId;
|
||||
}
|
||||
}
|
||||
|
||||
return $buckets;
|
||||
}
|
||||
|
||||
private function alreadyPlaced(int $studentId, string $schoolYear): bool
|
||||
{
|
||||
return DB::table('student_class')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('is_event_only', 0)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function isPromotedDecision(string $decision): bool
|
||||
{
|
||||
$normalized = strtolower(trim($decision));
|
||||
return in_array($normalized, ['passed', 'pass', 'promoted', 'promote', 'eligible_to_continue'], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Promotions\Placement;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
class PromotionSectionCapacityService
|
||||
{
|
||||
public const CONFIG_KEY = 'promotion.section_capacity';
|
||||
public const DEFAULT_CAPACITY = 20;
|
||||
|
||||
public function capacity(): array
|
||||
{
|
||||
$raw = Configuration::getConfigValueByKey(self::CONFIG_KEY);
|
||||
$usedDefault = false;
|
||||
|
||||
if ($raw === null || trim((string) $raw) === '') {
|
||||
$usedDefault = true;
|
||||
Log::warning('Missing promotion section capacity configuration; using fallback.', [
|
||||
'config_key' => self::CONFIG_KEY,
|
||||
'fallback' => self::DEFAULT_CAPACITY,
|
||||
]);
|
||||
$value = self::DEFAULT_CAPACITY;
|
||||
} elseif (!ctype_digit((string) $raw)) {
|
||||
throw new RuntimeException('promotion.section_capacity must be a positive integer.');
|
||||
} else {
|
||||
$value = (int) $raw;
|
||||
}
|
||||
|
||||
if ($value <= 0) {
|
||||
throw new RuntimeException('promotion.section_capacity must be greater than zero.');
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => self::CONFIG_KEY,
|
||||
'value' => $value,
|
||||
'used_default' => $usedDefault,
|
||||
'warnings' => $usedDefault ? ['promotion.section_capacity missing; fallback capacity 20 used.'] : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Promotions\Placement;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class ScoreBandClassifier
|
||||
{
|
||||
public function fromScore(?float $score, bool $allowNull = false): ?string
|
||||
{
|
||||
if ($score === null) {
|
||||
return $allowNull ? null : 'D';
|
||||
}
|
||||
if ($score >= 90.0 && $score <= 100.0) {
|
||||
return 'A';
|
||||
}
|
||||
if ($score >= 80.0) {
|
||||
return 'B';
|
||||
}
|
||||
if ($score >= 70.0) {
|
||||
return 'C';
|
||||
}
|
||||
if ($score >= 60.0) {
|
||||
return 'D';
|
||||
}
|
||||
|
||||
throw new RuntimeException('Scores below 60 are not eligible for automatic placement.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Promotions\Placement;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\SectionPlacementBatch;
|
||||
use App\Models\SectionPlacementBatchStudent;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class SectionPlacementPreviewService
|
||||
{
|
||||
public function __construct(
|
||||
private PromotionSectionCapacityService $capacityService,
|
||||
private PlacementPoolBuilder $poolBuilder,
|
||||
private BalancedSectionPlacementService $placementService
|
||||
) {
|
||||
}
|
||||
|
||||
public function createDraft(
|
||||
string $fromSchoolYear,
|
||||
string $toSchoolYear,
|
||||
?int $fromGradeLevelId = null,
|
||||
?int $toGradeLevelId = null,
|
||||
?int $createdBy = null
|
||||
): SectionPlacementBatch {
|
||||
$capacityInfo = $this->capacityService->capacity();
|
||||
$poolResult = $this->poolBuilder->build($fromSchoolYear, $toSchoolYear, $toGradeLevelId);
|
||||
$placement = $this->placementService->assign($poolResult['pool'], (int) $capacityInfo['value']);
|
||||
|
||||
$snapshot = [
|
||||
'capacity' => $capacityInfo,
|
||||
'target_section_sizes' => $placement['target_sizes'],
|
||||
'sections' => $placement['sections'],
|
||||
'excluded_students' => $poolResult['exceptions'],
|
||||
'warnings' => $capacityInfo['warnings'],
|
||||
'new_students_defaulted_to_d_band' => array_values(array_map(
|
||||
fn ($s) => $s['student_id'],
|
||||
array_filter($placement['assignments'], fn ($s) => ($s['placement_band_reason'] ?? null) === 'new_student_default_band')
|
||||
)),
|
||||
];
|
||||
|
||||
return DB::transaction(function () use ($fromSchoolYear, $toSchoolYear, $fromGradeLevelId, $toGradeLevelId, $createdBy, $capacityInfo, $placement, $snapshot) {
|
||||
$batch = SectionPlacementBatch::query()->create([
|
||||
'from_school_year' => $fromSchoolYear,
|
||||
'to_school_year' => $toSchoolYear,
|
||||
'from_grade_level_id' => $fromGradeLevelId,
|
||||
'to_grade_level_id' => $toGradeLevelId,
|
||||
'section_capacity_used' => (int) $capacityInfo['value'],
|
||||
'total_students' => count($placement['assignments']),
|
||||
'section_count' => (int) $placement['section_count'],
|
||||
'algorithm' => BalancedSectionPlacementService::ALGORITHM,
|
||||
'configuration_snapshot_json' => json_encode([
|
||||
PromotionSectionCapacityService::CONFIG_KEY => (int) $capacityInfo['value'],
|
||||
'placement.algorithm' => BalancedSectionPlacementService::ALGORITHM,
|
||||
]),
|
||||
'preview_snapshot_json' => json_encode($snapshot),
|
||||
'created_by' => $createdBy,
|
||||
'status' => SectionPlacementBatch::STATUS_DRAFT,
|
||||
]);
|
||||
|
||||
foreach ($placement['assignments'] as $assignment) {
|
||||
SectionPlacementBatchStudent::query()->create([
|
||||
'batch_id' => $batch->getKey(),
|
||||
'student_id' => $assignment['student_id'],
|
||||
'student_type' => $assignment['student_type'],
|
||||
'source_decision_id' => $assignment['source_decision_id'],
|
||||
'source_enrollment_id' => $assignment['source_enrollment_id'],
|
||||
'final_score' => $assignment['final_score'],
|
||||
'placement_score' => $assignment['placement_score'],
|
||||
'score_band' => $assignment['score_band'],
|
||||
'placement_band_reason' => $assignment['placement_band_reason'],
|
||||
'planned_section_index' => $assignment['planned_section_index'],
|
||||
'assignment_order' => $assignment['assignment_order'],
|
||||
'was_override' => false,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $batch->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function previewPayload(SectionPlacementBatch $batch): array
|
||||
{
|
||||
return [
|
||||
'batch' => $batch->toArray(),
|
||||
'preview' => $batch->preview_snapshot_json ? json_decode((string) $batch->preview_snapshot_json, true) : null,
|
||||
'students' => $batch->students()->orderBy('planned_section_index')->orderBy('assignment_order')->get()->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
public function finalize(int $batchId, ?int $userId = null): SectionPlacementBatch
|
||||
{
|
||||
$batch = SectionPlacementBatch::query()->findOrFail($batchId);
|
||||
if ($batch->status !== SectionPlacementBatch::STATUS_DRAFT) {
|
||||
throw new RuntimeException('Only draft placement batches can be finalized.');
|
||||
}
|
||||
if ((int) $batch->total_students !== $batch->students()->count()) {
|
||||
throw new RuntimeException('Draft batch student count does not match batch total.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($batch, $userId) {
|
||||
$semester = Configuration::getConfigValueByKey('semester') ?: 'Fall';
|
||||
$sectionMap = $this->ensureSections($batch, $semester);
|
||||
|
||||
$seen = [];
|
||||
$students = $batch->students()->orderBy('assignment_order')->get();
|
||||
foreach ($students as $student) {
|
||||
$studentId = (int) $student->student_id;
|
||||
if (isset($seen[$studentId])) {
|
||||
throw new RuntimeException('Student appears more than once in placement batch.');
|
||||
}
|
||||
$seen[$studentId] = true;
|
||||
|
||||
$duplicate = DB::table('student_class')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $batch->to_school_year)
|
||||
->where('is_event_only', 0)
|
||||
->exists();
|
||||
if ($duplicate) {
|
||||
throw new RuntimeException('Student ' . $studentId . ' already has a target-year class placement.');
|
||||
}
|
||||
|
||||
$sectionId = $sectionMap[(int) $student->planned_section_index] ?? null;
|
||||
if ($sectionId === null) {
|
||||
throw new RuntimeException('Missing target section for planned section ' . $student->planned_section_index . '.');
|
||||
}
|
||||
|
||||
DB::table('student_class')->insert([
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $sectionId,
|
||||
'is_event_only' => 0,
|
||||
'semester' => $semester,
|
||||
'school_year' => $batch->to_school_year,
|
||||
'description' => 'Finalized from section placement batch #' . $batch->getKey(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
$student->assigned_section_id = $sectionId;
|
||||
$student->save();
|
||||
}
|
||||
|
||||
$batch->status = SectionPlacementBatch::STATUS_FINALIZED;
|
||||
$batch->finalized_by = $userId;
|
||||
$batch->finalized_at = now();
|
||||
$batch->save();
|
||||
|
||||
return $batch->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
private function ensureSections(SectionPlacementBatch $batch, string $semester): array
|
||||
{
|
||||
if (!$batch->to_grade_level_id) {
|
||||
throw new RuntimeException('to_grade_level_id is required to finalize placement sections.');
|
||||
}
|
||||
|
||||
$map = [];
|
||||
$classId = (int) $batch->to_grade_level_id;
|
||||
$sectionCount = (int) $batch->section_count;
|
||||
$maxSectionId = (int) DB::table('classSection')->max('class_section_id');
|
||||
$letters = range('A', 'Z');
|
||||
|
||||
for ($i = 1; $i <= $sectionCount; $i++) {
|
||||
$name = $this->sectionName($classId, $letters[$i - 1] ?? (string) $i, $batch->to_school_year);
|
||||
$existing = DB::table('classSection')
|
||||
->where('class_id', $classId)
|
||||
->where('class_section_name', $name)
|
||||
->where(function ($q) use ($batch) {
|
||||
$q->where('school_year', $batch->to_school_year)->orWhereNull('school_year');
|
||||
})
|
||||
->value('class_section_id');
|
||||
|
||||
if ($existing) {
|
||||
$map[$i] = (int) $existing;
|
||||
continue;
|
||||
}
|
||||
|
||||
$sectionId = ++$maxSectionId;
|
||||
DB::table('classSection')->insert([
|
||||
'class_id' => $classId,
|
||||
'class_section_id' => $sectionId,
|
||||
'class_section_name' => $name,
|
||||
'semester' => $semester,
|
||||
'school_year' => $batch->to_school_year,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$map[$i] = $sectionId;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function sectionName(int $classId, string $suffix, string $schoolYear): string
|
||||
{
|
||||
$className = DB::table('classes')->where('id', $classId)->value('class_name');
|
||||
$base = $className ? (string) $className : ('Class ' . $classId);
|
||||
return $base . '-' . $suffix . ' ' . $schoolYear;
|
||||
}
|
||||
}
|
||||
@@ -281,50 +281,59 @@ class PromotionEligibilityService
|
||||
*/
|
||||
private function loadAcademicResult(int $studentId, string $schoolYear): array
|
||||
{
|
||||
$rows = DB::table('semester_scores')
|
||||
->select('semester', 'semester_score')
|
||||
$decision = DB::table('student_decisions')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
->orderByDesc('updated_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
$fall = null;
|
||||
$spring = null;
|
||||
foreach ($rows as $row) {
|
||||
$semester = strtolower((string) ($row->semester ?? ''));
|
||||
$score = isset($row->semester_score) ? (float) $row->semester_score : null;
|
||||
if ($semester === 'fall') {
|
||||
$fall = $score;
|
||||
} elseif ($semester === 'spring') {
|
||||
$spring = $score;
|
||||
}
|
||||
}
|
||||
|
||||
$threshold = $this->passThreshold();
|
||||
|
||||
if ($fall === null && $spring === null) {
|
||||
if (!$decision) {
|
||||
return [
|
||||
'passed' => null,
|
||||
'final_average' => null,
|
||||
'notes' => 'Missing fall and spring scores',
|
||||
'has_data' => false,
|
||||
];
|
||||
}
|
||||
if ($fall === null || $spring === null) {
|
||||
$existing = $fall ?? $spring;
|
||||
return [
|
||||
'passed' => null,
|
||||
'final_average' => $existing,
|
||||
'notes' => 'Awaiting both fall and spring scores',
|
||||
'notes' => 'Missing student_decisions record for promotion eligibility',
|
||||
'has_data' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$rawDecision = strtolower(trim((string) ($decision->decision ?? '')));
|
||||
$score = $decision->year_score !== null ? round((float) $decision->year_score, 2) : null;
|
||||
|
||||
if (in_array($rawDecision, ['passed', 'pass', 'promoted', 'promote', 'eligible_to_continue'], true)) {
|
||||
return [
|
||||
'passed' => true,
|
||||
'final_average' => $score,
|
||||
'notes' => $score !== null
|
||||
? sprintf('student_decisions marked promoted with score %.2f', $score)
|
||||
: 'student_decisions marked promoted without a year_score',
|
||||
'has_data' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array($rawDecision, ['failed', 'fail', 'retained', 'repeat', 'repeated_level'], true)) {
|
||||
return [
|
||||
'passed' => false,
|
||||
'final_average' => $score,
|
||||
'notes' => 'student_decisions marked failed/retained',
|
||||
'has_data' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if ($rawDecision === '' || $rawDecision === 'pending' || $rawDecision === 'manual review' || $rawDecision === 'manual_review') {
|
||||
return [
|
||||
'passed' => null,
|
||||
'final_average' => $score,
|
||||
'notes' => 'student_decisions is pending or requires manual review',
|
||||
'has_data' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$avg = round(($fall + $spring) / 2.0, 2);
|
||||
return [
|
||||
'passed' => $avg >= $threshold,
|
||||
'final_average' => $avg,
|
||||
'notes' => sprintf('Final average %.2f (threshold %.2f)', $avg, $threshold),
|
||||
'has_data' => true,
|
||||
'passed' => null,
|
||||
'final_average' => $score,
|
||||
'notes' => 'student_decisions has unrecognized decision: ' . $rawDecision,
|
||||
'has_data' => false,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,10 @@ class AttendanceCalculator implements ScoreCalculatorInterface
|
||||
return ['attendance_score' => 100.0];
|
||||
}
|
||||
|
||||
$attendanceRatio = ($totalDays + 1 - $absences) / $totalDays;
|
||||
// School policy: one absence is excused by grace before attendance is reduced.
|
||||
$graceAbsences = 1;
|
||||
$effectiveAbsences = max(0, (int) $absences - $graceAbsences);
|
||||
$attendanceRatio = ($totalDays - $effectiveAbsences) / $totalDays;
|
||||
$score = min(100, max(0, $attendanceRatio * 100));
|
||||
|
||||
return ['attendance_score' => round($score, 2)];
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\MissingScoreOverride;
|
||||
use App\Models\Student;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\ClassSection;
|
||||
use App\Services\Grading\Validation\ScoreValueValidator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExamScoreService
|
||||
@@ -131,8 +132,10 @@ class ExamScoreService
|
||||
if (!$student) {
|
||||
continue;
|
||||
}
|
||||
$rawScore = $data['score'] ?? null;
|
||||
$normalizedScore = is_numeric($rawScore) ? (float) $rawScore : null;
|
||||
$rawScore = is_array($data) ? ($data['score'] ?? null) : $data;
|
||||
$validator = new ScoreValueValidator();
|
||||
$normalizedScore = $validator->normalizeNullable($rawScore, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Exam score');
|
||||
$missingAllowed = !empty($missingOk[$studentId]);
|
||||
|
||||
$existing = $builder
|
||||
->where('student_id', $studentId)
|
||||
@@ -147,6 +150,8 @@ class ExamScoreService
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'score' => $normalizedScore,
|
||||
'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS,
|
||||
'status' => $validator->inferStatus($normalizedScore, $missingAllowed),
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => now(),
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\ClassSection;
|
||||
use App\Services\Grading\Validation\ScoreValueValidator;
|
||||
|
||||
class HomeworkScoreService
|
||||
{
|
||||
@@ -248,8 +249,9 @@ class HomeworkScoreService
|
||||
continue;
|
||||
}
|
||||
$rawScore = $score ?? null;
|
||||
$isBlank = $rawScore === null || (is_string($rawScore) && trim($rawScore) === '');
|
||||
$normalizedScore = (!$isBlank && is_numeric($rawScore)) ? (float) $rawScore : null;
|
||||
$validator = new ScoreValueValidator();
|
||||
$normalizedScore = $validator->normalizeNullable($rawScore, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Homework score');
|
||||
$missingAllowed = !empty($missingOk[$studentId][$index]);
|
||||
|
||||
$existing = Homework::query()
|
||||
->where('student_id', $studentId)
|
||||
@@ -266,6 +268,8 @@ class HomeworkScoreService
|
||||
'updated_by' => $updatedBy,
|
||||
'homework_index' => (int) $index,
|
||||
'score' => $normalizedScore,
|
||||
'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS,
|
||||
'status' => $validator->inferStatus($normalizedScore, $missingAllowed),
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => now(),
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Services\Scores;
|
||||
|
||||
use App\Models\GradingLock;
|
||||
use App\Models\ClassSection;
|
||||
use App\Services\Grading\Validation\ScoreValueValidator;
|
||||
use App\Models\MissingScoreOverride;
|
||||
use App\Models\Participation;
|
||||
use App\Models\Student;
|
||||
@@ -185,8 +186,9 @@ class ParticipationScoreService
|
||||
}
|
||||
|
||||
$rawScore = $data['score'] ?? null;
|
||||
$isBlank = $rawScore === null || (is_string($rawScore) && trim($rawScore) === '');
|
||||
$normalizedScore = (!$isBlank && is_numeric($rawScore)) ? (float) $rawScore : null;
|
||||
$validator = new ScoreValueValidator();
|
||||
$normalizedScore = $validator->normalizeNullable($rawScore, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Participation score');
|
||||
$missingAllowed = !empty($missingOk[$studentId]);
|
||||
|
||||
$existing = Participation::query()
|
||||
->where('student_id', $studentId)
|
||||
@@ -201,6 +203,8 @@ class ParticipationScoreService
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'score' => $normalizedScore,
|
||||
'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS,
|
||||
'status' => $validator->inferStatus($normalizedScore, $missingAllowed),
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => now(),
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\ClassSection;
|
||||
use App\Services\Grading\Validation\ScoreValueValidator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProjectScoreService
|
||||
@@ -218,7 +219,9 @@ class ProjectScoreService
|
||||
if (!is_numeric($index)) {
|
||||
continue;
|
||||
}
|
||||
$normalizedScore = is_numeric($score) ? (float) $score : null;
|
||||
$validator = new ScoreValueValidator();
|
||||
$normalizedScore = $validator->normalizeNullable($score, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Project score');
|
||||
$missingAllowed = !empty($missingOk[$studentId][$index]);
|
||||
|
||||
$existing = Project::query()
|
||||
->where('student_id', $studentId)
|
||||
@@ -235,6 +238,8 @@ class ProjectScoreService
|
||||
'updated_by' => $updatedBy,
|
||||
'project_index' => (int) $index,
|
||||
'score' => $normalizedScore,
|
||||
'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS,
|
||||
'status' => $validator->inferStatus($normalizedScore, $missingAllowed),
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => now(),
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\ClassSection;
|
||||
use App\Services\Grading\Validation\ScoreValueValidator;
|
||||
|
||||
class QuizScoreService
|
||||
{
|
||||
@@ -219,8 +220,9 @@ class QuizScoreService
|
||||
if (!is_numeric($quizNumber)) {
|
||||
continue;
|
||||
}
|
||||
$isBlank = $score === null || (is_string($score) && trim($score) === '');
|
||||
$normalizedScore = (!$isBlank && is_numeric($score)) ? (float) $score : null;
|
||||
$validator = new ScoreValueValidator();
|
||||
$normalizedScore = $validator->normalizeNullable($score, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Quiz score');
|
||||
$missingAllowed = !empty($missingOk[$studentId][$quizNumber]);
|
||||
|
||||
$existing = Quiz::query()
|
||||
->where('student_id', $studentId)
|
||||
@@ -237,6 +239,8 @@ class QuizScoreService
|
||||
'updated_by' => $updatedBy,
|
||||
'quiz_index' => (int) $quizNumber,
|
||||
'score' => $normalizedScore,
|
||||
'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS,
|
||||
'status' => $validator->inferStatus($normalizedScore, $missingAllowed),
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => now(),
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Models\CalendarEvent;
|
||||
use App\Models\Homework;
|
||||
use App\Models\Project;
|
||||
use RuntimeException;
|
||||
use App\Services\Grading\Policy\GradingPolicyResolver;
|
||||
|
||||
class SemesterScoreService
|
||||
{
|
||||
@@ -26,6 +27,7 @@ class SemesterScoreService
|
||||
private string $semester;
|
||||
private string $schoolYear;
|
||||
private ?int $actorId;
|
||||
private GradingPolicyResolver $policyResolver;
|
||||
|
||||
public function __construct(
|
||||
?AttendanceCalculator $attendanceCalculator = null,
|
||||
@@ -36,6 +38,7 @@ class SemesterScoreService
|
||||
$this->semester = (string) (Configuration::getConfig('semester') ?? '');
|
||||
$this->schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
|
||||
$this->actorId = (int) (auth()->id() ?? 0) ?: null;
|
||||
$this->policyResolver = new GradingPolicyResolver();
|
||||
|
||||
$attendanceCalculator = $attendanceCalculator ?? new AttendanceCalculator(
|
||||
new AttendanceRecord(),
|
||||
@@ -209,12 +212,16 @@ class SemesterScoreService
|
||||
string $schoolYear,
|
||||
array $scoreData
|
||||
): void {
|
||||
$calculationMode = $this->policyResolver->calculationMode($classSectionId, $semester, $schoolYear);
|
||||
|
||||
$payload = array_merge([
|
||||
'student_id' => $studentId,
|
||||
'school_id' => $schoolId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'calculation_mode' => $calculationMode,
|
||||
'calculation_policy_version' => $this->policyResolver->policyVersion($calculationMode),
|
||||
'updated_at' => now(),
|
||||
], $scoreData);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user