add controllers, servoices
This commit is contained in:
@@ -23,8 +23,6 @@ class AdministratorEnrollmentStatusService
|
||||
$schoolYear = $this->shared->getSchoolYear();
|
||||
$semester = $this->shared->getSemester();
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
if (empty($enrollmentStatuses)) {
|
||||
return [
|
||||
@@ -34,6 +32,8 @@ class AdministratorEnrollmentStatusService
|
||||
];
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
$errors = [];
|
||||
$groupsByParentStatus = [];
|
||||
$parentInfo = [];
|
||||
@@ -270,4 +270,4 @@ class AdministratorEnrollmentStatusService
|
||||
'lastname' => $parentData['lastname'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Assignment;
|
||||
|
||||
use App\Models\Configuration;
|
||||
|
||||
class AssignmentContextService
|
||||
{
|
||||
public function __construct(private Configuration $configuration) {}
|
||||
|
||||
public function getCurrentSemester(): string
|
||||
{
|
||||
return (string) ($this->getConfigValue('semester') ?? '');
|
||||
}
|
||||
|
||||
public function getCurrentSchoolYear(): string
|
||||
{
|
||||
return (string) ($this->getConfigValue('school_year') ?? '');
|
||||
}
|
||||
|
||||
private function getConfigValue(string $key): mixed
|
||||
{
|
||||
return $this->configuration
|
||||
->newQuery()
|
||||
->where('config_key', $key)
|
||||
->value('config_value');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Assignment;
|
||||
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\TeacherClass;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AssignmentDataLoaderService
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherClass $teacherClass,
|
||||
private StudentClass $studentClass
|
||||
) {}
|
||||
|
||||
public function loadTeacherClasses(?string $schoolYear = null): Collection
|
||||
{
|
||||
return $this->teacherClass
|
||||
->newQuery()
|
||||
->with([
|
||||
'teacher:id,firstname,lastname',
|
||||
])
|
||||
->when(filled($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
|
||||
->get()
|
||||
->map(function ($row) {
|
||||
$row->teacher_full_name = trim(
|
||||
((string) optional($row->teacher)->firstname) . ' ' .
|
||||
((string) optional($row->teacher)->lastname)
|
||||
);
|
||||
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
public function loadStudentClasses(?string $schoolYear = null): Collection
|
||||
{
|
||||
return $this->studentClass
|
||||
->newQuery()
|
||||
->with([
|
||||
'student:id,firstname,lastname,age,gender,registration_grade,photo_consent,tuition_paid,school_id,is_active',
|
||||
])
|
||||
->whereHas('student', fn ($q) => $q->where('is_active', 1))
|
||||
->when(filled($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Assignment;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AssignmentMetaService
|
||||
{
|
||||
public function getSchoolYears(string $fallbackYear = ''): array
|
||||
{
|
||||
$years = DB::table('teacher_class')
|
||||
->selectRaw('DISTINCT school_year')
|
||||
->whereNotNull('school_year')
|
||||
->orderByDesc('school_year')
|
||||
->pluck('school_year')
|
||||
->map(fn ($year) => (string) $year)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (empty($years) && $fallbackYear !== '') {
|
||||
$years[] = $fallbackYear;
|
||||
}
|
||||
|
||||
return $years;
|
||||
}
|
||||
|
||||
public function firstNonEmpty(array $values): mixed
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
if (filled($value)) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Assignment;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
|
||||
class AssignmentSectionService
|
||||
{
|
||||
public function __construct(private ClassSection $classSection) {}
|
||||
|
||||
public function getSectionNamesMap(array $sectionIds): array
|
||||
{
|
||||
if (empty($sectionIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->classSection
|
||||
->newQuery()
|
||||
->whereIn('id', $sectionIds)
|
||||
->get()
|
||||
->mapWithKeys(function ($section) {
|
||||
$name = $section->class_section_name ?? $section->section_name ?? $section->name ?? '';
|
||||
return [(int) $section->id => (string) $name];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -2,38 +2,26 @@
|
||||
|
||||
namespace App\Services\Assignment;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\TeacherClass;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AssignmentService
|
||||
{
|
||||
public function __construct(
|
||||
protected Configuration $configurationModel,
|
||||
protected TeacherClass $teacherClassModel,
|
||||
protected StudentClass $studentClassModel,
|
||||
protected ClassSection $classSectionModel,
|
||||
protected AssignmentContextService $contextService,
|
||||
protected AssignmentDataLoaderService $dataLoader,
|
||||
protected AssignmentSectionService $sectionService,
|
||||
protected AssignmentMetaService $metaService
|
||||
) {}
|
||||
|
||||
public function getCurrentSemester(): string
|
||||
{
|
||||
return (string) ($this->getConfigValue('semester') ?? '');
|
||||
return $this->contextService->getCurrentSemester();
|
||||
}
|
||||
|
||||
public function getCurrentSchoolYear(): string
|
||||
{
|
||||
return (string) ($this->getConfigValue('school_year') ?? '');
|
||||
}
|
||||
|
||||
protected function getConfigValue(string $key): mixed
|
||||
{
|
||||
return $this->configurationModel
|
||||
->newQuery()
|
||||
->where('config_key', $key)
|
||||
->value('config_value');
|
||||
return $this->contextService->getCurrentSchoolYear();
|
||||
}
|
||||
|
||||
public function getAssignmentsOverview(?string $schoolYear = null, ?string $semester = null): array
|
||||
@@ -44,8 +32,8 @@ class AssignmentService
|
||||
$selectedSemester = (string) ($semester ?? $currentSemester);
|
||||
$selectedYear = (string) ($schoolYear ?? $currentSchoolYear);
|
||||
|
||||
$teacherClasses = $this->loadTeacherClasses($selectedYear);
|
||||
$studentClasses = $this->loadStudentClasses($selectedYear);
|
||||
$teacherClasses = $this->dataLoader->loadTeacherClasses($selectedYear);
|
||||
$studentClasses = $this->dataLoader->loadStudentClasses($selectedYear);
|
||||
|
||||
$teacherBySection = $teacherClasses->groupBy('class_section_id');
|
||||
$studentBySection = $studentClasses->groupBy('class_section_id');
|
||||
@@ -56,7 +44,7 @@ class AssignmentService
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$sectionNames = $this->getSectionNamesMap($allSectionIds->all());
|
||||
$sectionNames = $this->sectionService->getSectionNamesMap($allSectionIds->all());
|
||||
|
||||
$classSections = $allSectionIds->map(function ($sectionId) use (
|
||||
$teacherBySection,
|
||||
@@ -86,19 +74,19 @@ class AssignmentService
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$semesterMeta = $this->firstNonEmpty([
|
||||
$semesterMeta = $this->metaService->firstNonEmpty([
|
||||
$teacherRows->pluck('semester')->first(fn ($v) => filled($v)),
|
||||
$studentRows->pluck('semester')->first(fn ($v) => filled($v)),
|
||||
$currentSemester,
|
||||
]);
|
||||
|
||||
$schoolYearMeta = $this->firstNonEmpty([
|
||||
$schoolYearMeta = $this->metaService->firstNonEmpty([
|
||||
$teacherRows->pluck('school_year')->first(fn ($v) => filled($v)),
|
||||
$studentRows->pluck('school_year')->first(fn ($v) => filled($v)),
|
||||
$currentSchoolYear,
|
||||
]);
|
||||
|
||||
$descriptionMeta = $this->firstNonEmpty([
|
||||
$descriptionMeta = $this->metaService->firstNonEmpty([
|
||||
$teacherRows->pluck('description')->first(fn ($v) => filled($v)),
|
||||
$studentRows->pluck('description')->first(fn ($v) => filled($v)),
|
||||
'',
|
||||
@@ -141,7 +129,7 @@ class AssignmentService
|
||||
|
||||
return [
|
||||
'classSections' => $classSections,
|
||||
'schoolYears' => $this->getSchoolYears($currentSchoolYear),
|
||||
'schoolYears' => $this->metaService->getSchoolYears($currentSchoolYear),
|
||||
'schoolYear' => $selectedYear,
|
||||
'selectedYear' => $selectedYear,
|
||||
'selectedSemester' => $selectedSemester,
|
||||
@@ -171,8 +159,8 @@ class AssignmentService
|
||||
$currentSemester = $this->getCurrentSemester();
|
||||
$currentSchoolYear = $this->getCurrentSchoolYear();
|
||||
|
||||
$teacherClasses = $this->loadTeacherClasses();
|
||||
$studentClasses = $this->loadStudentClasses();
|
||||
$teacherClasses = $this->dataLoader->loadTeacherClasses();
|
||||
$studentClasses = $this->dataLoader->loadStudentClasses();
|
||||
|
||||
$teacherBySection = $teacherClasses->groupBy('class_section_id');
|
||||
$studentBySection = $studentClasses->groupBy('class_section_id');
|
||||
@@ -183,7 +171,7 @@ class AssignmentService
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$sectionNames = $this->getSectionNamesMap($allSectionIds->all());
|
||||
$sectionNames = $this->sectionService->getSectionNamesMap($allSectionIds->all());
|
||||
|
||||
$classSections = $allSectionIds->map(function ($sectionId) use (
|
||||
$teacherBySection,
|
||||
@@ -213,19 +201,19 @@ class AssignmentService
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$semesterMeta = $this->firstNonEmpty([
|
||||
$semesterMeta = $this->metaService->firstNonEmpty([
|
||||
$teacherRows->pluck('semester')->first(fn ($v) => filled($v)),
|
||||
$studentRows->pluck('semester')->first(fn ($v) => filled($v)),
|
||||
$currentSemester,
|
||||
]);
|
||||
|
||||
$schoolYearMeta = $this->firstNonEmpty([
|
||||
$schoolYearMeta = $this->metaService->firstNonEmpty([
|
||||
$teacherRows->pluck('school_year')->first(fn ($v) => filled($v)),
|
||||
$studentRows->pluck('school_year')->first(fn ($v) => filled($v)),
|
||||
$currentSchoolYear,
|
||||
]);
|
||||
|
||||
$descriptionMeta = $this->firstNonEmpty([
|
||||
$descriptionMeta = $this->metaService->firstNonEmpty([
|
||||
$teacherRows->pluck('description')->first(fn ($v) => filled($v)),
|
||||
$studentRows->pluck('description')->first(fn ($v) => filled($v)),
|
||||
'',
|
||||
@@ -272,82 +260,4 @@ class AssignmentService
|
||||
'school_year' => $currentSchoolYear,
|
||||
];
|
||||
}
|
||||
|
||||
protected function loadTeacherClasses(?string $schoolYear = null): Collection
|
||||
{
|
||||
return $this->teacherClassModel
|
||||
->newQuery()
|
||||
->with([
|
||||
'teacher:id,firstname,lastname',
|
||||
])
|
||||
->when(filled($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
|
||||
->get()
|
||||
->map(function ($row) {
|
||||
$row->teacher_full_name = trim(
|
||||
((string) optional($row->teacher)->firstname) . ' ' .
|
||||
((string) optional($row->teacher)->lastname)
|
||||
);
|
||||
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
protected function loadStudentClasses(?string $schoolYear = null): Collection
|
||||
{
|
||||
return $this->studentClassModel
|
||||
->newQuery()
|
||||
->with([
|
||||
'student:id,firstname,lastname,age,gender,registration_grade,photo_consent,tuition_paid,school_id,is_active',
|
||||
])
|
||||
->whereHas('student', fn ($q) => $q->where('is_active', 1))
|
||||
->when(filled($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
|
||||
->get();
|
||||
}
|
||||
|
||||
protected function getSectionNamesMap(array $sectionIds): array
|
||||
{
|
||||
if (empty($sectionIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->classSectionModel
|
||||
->newQuery()
|
||||
->whereIn('id', $sectionIds)
|
||||
->get()
|
||||
->mapWithKeys(function ($section) {
|
||||
$name = $section->section_name ?? $section->name ?? '';
|
||||
return [(int) $section->id => (string) $name];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function getSchoolYears(string $fallbackYear = ''): array
|
||||
{
|
||||
$years = DB::table('teacher_class')
|
||||
->selectRaw('DISTINCT school_year')
|
||||
->whereNotNull('school_year')
|
||||
->orderByDesc('school_year')
|
||||
->pluck('school_year')
|
||||
->map(fn ($year) => (string) $year)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (empty($years) && $fallbackYear !== '') {
|
||||
$years[] = $fallbackYear;
|
||||
}
|
||||
|
||||
return $years;
|
||||
}
|
||||
|
||||
protected function firstNonEmpty(array $values): mixed
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
if (filled($value)) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,179 +2,6 @@
|
||||
|
||||
namespace App\Services\AttendanceTracking;
|
||||
|
||||
use App\Models\AttendanceData;
|
||||
use App\Models\AttendanceTracking;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AttendanceTrackingService
|
||||
{
|
||||
protected string $semester;
|
||||
protected string $schoolYear;
|
||||
|
||||
public function __construct(
|
||||
protected Student $studentModel,
|
||||
protected StudentClass $studentClassModel,
|
||||
protected AttendanceData $attendanceDataModel,
|
||||
protected AttendanceTracking $attendanceTrackingModel,
|
||||
protected Configuration $configModel,
|
||||
protected AttendanceMailerService $attendanceMailerService,
|
||||
protected ViolationRuleEngineService $violationRuleEngine,
|
||||
protected AttendanceParentLookupService $parentLookupService,
|
||||
protected AttendanceEmailComposerService $emailComposerService,
|
||||
protected AttendanceNotificationLogService $notificationLogService,
|
||||
protected AttendanceViolationStudentResolverService $studentResolverService,
|
||||
protected AttendanceCaseQueryService $attendanceCaseQueryService,
|
||||
protected AttendanceNotificationWorkflowService $attendanceNotificationWorkflowService,
|
||||
protected AttendanceCommunicationSupportService $attendanceCommunicationSupportService,
|
||||
protected AttendancePendingViolationService $attendancePendingViolationService
|
||||
) {
|
||||
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
||||
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
public function getPendingViolations(?string $schoolYearParam = null, ?string $semesterParam = null): array
|
||||
{
|
||||
return $this->attendancePendingViolationService->getPendingViolations(
|
||||
$this->schoolYear,
|
||||
$schoolYearParam,
|
||||
$semesterParam
|
||||
);
|
||||
}
|
||||
|
||||
public function getStudentCase(
|
||||
int $studentId,
|
||||
?string $codeParam = null,
|
||||
?string $incidentDate = null,
|
||||
bool $includeNotified = false,
|
||||
bool $pendingOnly = false,
|
||||
?string $schoolYearFilter = null,
|
||||
?string $semesterFilter = null
|
||||
): array {
|
||||
return $this->attendanceCaseQueryService->getStudentCase(
|
||||
$studentId,
|
||||
$codeParam,
|
||||
$incidentDate,
|
||||
$includeNotified,
|
||||
$pendingOnly,
|
||||
$schoolYearFilter,
|
||||
$semesterFilter,
|
||||
$this->schoolYear,
|
||||
$this->semester
|
||||
);
|
||||
}
|
||||
|
||||
public function getNotifiedViolations(?string $schoolYearParam = null, ?string $semesterParam = null): array
|
||||
{
|
||||
return $this->attendanceCaseQueryService->getNotifiedViolations(
|
||||
$schoolYearParam,
|
||||
$semesterParam,
|
||||
$this->schoolYear,
|
||||
$this->semester
|
||||
);
|
||||
}
|
||||
|
||||
public function compose(
|
||||
int $studentId,
|
||||
string $code = 'ABS_1',
|
||||
string $variant = 'default',
|
||||
?string $incidentDate = null
|
||||
): array {
|
||||
return $this->attendanceCommunicationSupportService->compose(
|
||||
$studentId,
|
||||
$code,
|
||||
$variant,
|
||||
$incidentDate
|
||||
);
|
||||
}
|
||||
|
||||
public function parentsInfo(int $studentId): array
|
||||
{
|
||||
return $this->attendanceCommunicationSupportService->parentsInfo($studentId);
|
||||
}
|
||||
|
||||
public function record(array $data): array
|
||||
{
|
||||
return $this->attendanceNotificationWorkflowService->record($data);
|
||||
}
|
||||
|
||||
public function sendAutoEmails(?string $variantOverride = null): array
|
||||
{
|
||||
$classStudentsQuery = $this->studentClassModel->query();
|
||||
if (method_exists($this->studentClassModel, 'scopeActive')) {
|
||||
$classStudentsQuery->active();
|
||||
}
|
||||
|
||||
$classStudents = $classStudentsQuery
|
||||
->where('school_year', $this->schoolYear)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
if (empty($classStudents)) {
|
||||
return [
|
||||
'success' => true,
|
||||
'sent' => 0,
|
||||
'skipped' => 0,
|
||||
'errors' => 0,
|
||||
'details' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$studentIds = array_column($classStudents, 'student_id');
|
||||
|
||||
$students = $this->studentModel->query()
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 1)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$attendanceQuery = $this->attendanceDataModel->query()
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('date', '>=', Carbon::today()->subWeeks(5)->format('Y-m-d'));
|
||||
|
||||
$this->violationRuleEngine->applyUnreportedAttendanceFilter(
|
||||
$attendanceQuery,
|
||||
$this->attendanceDataModel->getTable()
|
||||
);
|
||||
|
||||
$attendanceData = $attendanceQuery
|
||||
->whereRaw("LOWER(TRIM(status)) IN ('absent','late')")
|
||||
->orderByDesc('date')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$violations = $this->violationRuleEngine->computeViolations(
|
||||
$students,
|
||||
$attendanceData,
|
||||
$this->schoolYear,
|
||||
null,
|
||||
$attendanceData
|
||||
);
|
||||
|
||||
return $this->attendanceNotificationWorkflowService->sendAutoEmails($violations, $variantOverride);
|
||||
}
|
||||
|
||||
public function sendEmailManual(array $data): array
|
||||
{
|
||||
$sid = (int) $data['student_id'];
|
||||
$code = (string) $data['code'];
|
||||
$ymdRaw = (string) ($data['incident_date'] ?? '');
|
||||
$ymd = $ymdRaw !== '' ? substr($ymdRaw, 0, 10) : '';
|
||||
|
||||
$violationDates = $this->attendanceCommunicationSupportService->getViolationDatesForStudent(
|
||||
$sid,
|
||||
$code,
|
||||
$ymd
|
||||
);
|
||||
|
||||
return $this->attendanceNotificationWorkflowService->sendEmailManual($data, $violationDates);
|
||||
}
|
||||
|
||||
public function saveNotificationNote(array $data): array
|
||||
{
|
||||
return $this->attendanceNotificationWorkflowService->saveNotificationNote($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Services\AttendanceTracking\AttendanceTrackingService as CoreService;
|
||||
|
||||
class AttendanceTrackingService extends CoreService
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
class RegistrationCaptchaService
|
||||
{
|
||||
public function generate(?int $length = null): string
|
||||
{
|
||||
$length = $length ?? random_int(4, 8);
|
||||
$characters = 'ABCDEFGHJKLMNOPQRSTUVWXYZabcdefghjkmnopqrstuvwxyz0123456789';
|
||||
$captchaText = '';
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$captchaText .= $characters[random_int(0, strlen($characters) - 1)];
|
||||
}
|
||||
|
||||
session()->put('captcha_answer', $captchaText);
|
||||
|
||||
return $captchaText;
|
||||
}
|
||||
|
||||
public function verify(string $input): bool
|
||||
{
|
||||
$expected = (string) session()->get('captcha_answer', '');
|
||||
if ($expected === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($expected, $input);
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
session()->forget('captcha_answer');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Services\PhoneFormatterService;
|
||||
|
||||
class RegistrationFormatterService
|
||||
{
|
||||
public function __construct(private PhoneFormatterService $phoneFormatter)
|
||||
{
|
||||
}
|
||||
|
||||
public function format(array $payload): array
|
||||
{
|
||||
$g = static function (string $key) use ($payload): string {
|
||||
return trim((string) ($payload[$key] ?? ''));
|
||||
};
|
||||
|
||||
$data = [
|
||||
'firstname' => $this->formatName($g('firstname')),
|
||||
'lastname' => $this->formatName($g('lastname')),
|
||||
'email' => $this->formatEmail($g('email')),
|
||||
'gender' => $g('gender'),
|
||||
'city' => $this->formatName($g('city')),
|
||||
'cellphone' => $this->phoneFormatter->formatPhoneNumber($g('cellphone')),
|
||||
'address_street' => $this->formatAddress($g('address_street')),
|
||||
'apt' => strtoupper($g('apt')),
|
||||
'state' => strtoupper($g('state')),
|
||||
'zip' => $g('zip'),
|
||||
'accept_school_policy' => (int) ($payload['accept_school_policy'] ?? 0),
|
||||
];
|
||||
|
||||
$noSecondInfo = !empty($payload['no_second_parent_info']);
|
||||
|
||||
$secondFirstname = $this->formatName($g('second_firstname'));
|
||||
$secondLastname = $this->formatName($g('second_lastname'));
|
||||
$secondGender = $g('second_gender');
|
||||
$secondEmail = $this->formatEmail($g('second_email'));
|
||||
$secondCellphone = $this->phoneFormatter->formatPhoneNumber($g('second_cellphone'));
|
||||
|
||||
$secondProvided = !$noSecondInfo && (
|
||||
$secondFirstname !== '' ||
|
||||
$secondLastname !== '' ||
|
||||
$secondGender !== '' ||
|
||||
$secondEmail !== '' ||
|
||||
preg_replace('/\D/', '', (string) $secondCellphone) !== ''
|
||||
);
|
||||
|
||||
if ($secondProvided) {
|
||||
$data['second_firstname'] = $secondFirstname;
|
||||
$data['second_lastname'] = $secondLastname;
|
||||
$data['second_gender'] = $secondGender;
|
||||
$data['second_email'] = $secondEmail;
|
||||
$data['second_cellphone'] = $secondCellphone;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function formatName(string $name): string
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return ucwords(strtolower($name), " -");
|
||||
}
|
||||
|
||||
private function formatEmail(string $email): string
|
||||
{
|
||||
return strtolower(trim($email));
|
||||
}
|
||||
|
||||
private function formatAddress(string $address): string
|
||||
{
|
||||
$address = trim($address);
|
||||
if ($address === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return ucwords(strtolower($address));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\ParentModel;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRole;
|
||||
use App\Services\EmailService;
|
||||
use App\Services\SchoolIdService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegistrationService
|
||||
{
|
||||
public function __construct(
|
||||
private EmailService $emailService,
|
||||
private SchoolIdService $schoolIdService,
|
||||
private RegistrationFormatterService $formatter,
|
||||
private RegistrationCaptchaService $captchaService
|
||||
) {
|
||||
}
|
||||
|
||||
public function register(array $payload): array
|
||||
{
|
||||
$captcha = (string) ($payload['captcha'] ?? '');
|
||||
if (!$this->captchaService->verify($captcha)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'code' => 'captcha_invalid',
|
||||
'message' => 'Incorrect CAPTCHA answer. Please try again.',
|
||||
];
|
||||
}
|
||||
|
||||
$isParent = !empty($payload['is_parent']);
|
||||
$noSecondInfo = !empty($payload['no_second_parent_info']);
|
||||
|
||||
$formatted = $this->formatter->format($payload);
|
||||
$email = (string) ($formatted['email'] ?? '');
|
||||
|
||||
$existing = User::query()->where('email', $email)->first();
|
||||
if ($existing) {
|
||||
$pending = !empty($existing->token) && (int) ($existing->is_verified ?? 0) === 0;
|
||||
if ($pending) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'code' => 'pending_activation',
|
||||
'message' => 'This email address is already registered and pending activation.',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'code' => 'email_exists',
|
||||
'message' => 'The email address you entered is already in use.',
|
||||
];
|
||||
}
|
||||
|
||||
$roleName = $isParent ? 'parent' : 'guest';
|
||||
$role = Role::query()->where('name', $roleName)->first();
|
||||
if (!$role) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'code' => 'role_missing',
|
||||
'message' => 'Role not found.',
|
||||
];
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(48));
|
||||
$schoolYear = Configuration::getConfig('school_year');
|
||||
$semester = Configuration::getConfig('semester');
|
||||
|
||||
$userData = [
|
||||
'firstname' => (string) ($formatted['firstname'] ?? ''),
|
||||
'lastname' => (string) ($formatted['lastname'] ?? ''),
|
||||
'gender' => (string) ($formatted['gender'] ?? ''),
|
||||
'email' => $email,
|
||||
'cellphone' => (string) ($formatted['cellphone'] ?? ($payload['cellphone'] ?? '')),
|
||||
'address_street' => (string) ($formatted['address_street'] ?? ''),
|
||||
'apt' => $formatted['apt'] ?? null,
|
||||
'city' => (string) ($formatted['city'] ?? ''),
|
||||
'state' => (string) ($formatted['state'] ?? ''),
|
||||
'zip' => (string) ($formatted['zip'] ?? ''),
|
||||
'token' => $token,
|
||||
'is_verified' => 0,
|
||||
'accept_school_policy' => (int) ($formatted['accept_school_policy'] ?? 0),
|
||||
'status' => 'Inactive',
|
||||
'school_id' => $this->schoolIdService->generateUserSchoolId(),
|
||||
'semester' => (string) ($semester ?? ''),
|
||||
'school_year' => $schoolYear,
|
||||
'password' => pbkdf2_hash(bin2hex(random_bytes(8))),
|
||||
];
|
||||
|
||||
$user = null;
|
||||
|
||||
DB::transaction(function () use (
|
||||
$userData,
|
||||
$role,
|
||||
$isParent,
|
||||
$noSecondInfo,
|
||||
$formatted,
|
||||
$schoolYear,
|
||||
$semester,
|
||||
&$user
|
||||
) {
|
||||
$user = User::query()->create($userData);
|
||||
|
||||
UserRole::query()->create([
|
||||
'user_id' => (int) $user->id,
|
||||
'role_id' => (int) $role->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
if ($isParent && !$noSecondInfo && !empty($formatted['second_firstname'])) {
|
||||
ParentModel::query()->create([
|
||||
'secondparent_firstname' => $formatted['second_firstname'] ?? null,
|
||||
'secondparent_lastname' => $formatted['second_lastname'] ?? null,
|
||||
'secondparent_gender' => $formatted['second_gender'] ?? null,
|
||||
'secondparent_email' => $formatted['second_email'] ?? null,
|
||||
'secondparent_phone' => $formatted['second_cellphone'] ?? null,
|
||||
'firstparent_id' => (int) $user->id,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$activationLink = url('/user/confirm/' . $token);
|
||||
$recipientName = trim((string) ($formatted['firstname'] ?? '') . ' ' . (string) ($formatted['lastname'] ?? ''));
|
||||
|
||||
$emailData = [
|
||||
'recipientName' => $recipientName,
|
||||
'activationLink' => $activationLink,
|
||||
'orgName' => 'Al Rahma Sunday School',
|
||||
'contactInfo' => 'alrahma.isgl@gmail.com',
|
||||
'logoUrl' => 'https://alrahmaisgl.org/assets/images/alrahma_logo.png',
|
||||
'subject' => 'Email Confirmation',
|
||||
];
|
||||
|
||||
if (view()->exists($isParent ? 'emails.welcome_parent' : 'emails.welcome_staff')) {
|
||||
$html = view($isParent ? 'emails.welcome_parent' : 'emails.welcome_staff', $emailData);
|
||||
} else {
|
||||
$html = '<p>Hello ' . e($recipientName !== '' ? $recipientName : 'there') . ',</p>'
|
||||
. '<p>Please confirm your email by clicking the link below:</p>'
|
||||
. '<p><a href="' . e($activationLink) . '">Activate your account</a></p>'
|
||||
. '<p>Thank you.</p>';
|
||||
}
|
||||
|
||||
$sent = $this->emailService->send($email, 'Email Confirmation', $html, 'general');
|
||||
$this->captchaService->clear();
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'user' => $user,
|
||||
'role' => $roleName,
|
||||
'activation_sent' => $sent,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\BroadcastEmail;
|
||||
|
||||
use Illuminate\Support\Facades\View;
|
||||
|
||||
class BroadcastEmailComposerService
|
||||
{
|
||||
public function sanitizeHtml(string $html): string
|
||||
{
|
||||
$html = preg_replace('#<(script|iframe)[^>]*>.*?</\\1>#is', '', $html);
|
||||
$html = preg_replace('/\\son\\w+="[^"]*"/i', '', $html);
|
||||
$html = preg_replace("/\\son\\w+='[^']*'/i", '', $html);
|
||||
return $html ?? '';
|
||||
}
|
||||
|
||||
public function compose(
|
||||
bool $wrapLayout,
|
||||
string $subject,
|
||||
string $body,
|
||||
string $recipientName,
|
||||
string $preheader = '',
|
||||
string $ctaText = '',
|
||||
string $ctaUrl = '',
|
||||
bool $personalize = true
|
||||
): string {
|
||||
$content = $personalize ? str_replace('{{name}}', $recipientName, $body) : $body;
|
||||
|
||||
if (!$wrapLayout) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
if (!View::exists('emails.broadcast_wrapper')) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
return view('emails.broadcast_wrapper', [
|
||||
'subject' => $subject,
|
||||
'content' => $content,
|
||||
'preheader' => $preheader,
|
||||
'cta_text' => $ctaText,
|
||||
'cta_url' => $ctaUrl,
|
||||
])->render();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\BroadcastEmail;
|
||||
|
||||
use App\Services\EmailService;
|
||||
|
||||
class BroadcastEmailDispatchService
|
||||
{
|
||||
private EmailService $mailer;
|
||||
private BroadcastEmailComposerService $composer;
|
||||
|
||||
public function __construct(EmailService $mailer, BroadcastEmailComposerService $composer)
|
||||
{
|
||||
$this->mailer = $mailer;
|
||||
$this->composer = $composer;
|
||||
}
|
||||
|
||||
public function sendTest(array $payload): array
|
||||
{
|
||||
$html = $this->composer->compose(
|
||||
(bool) $payload['wrap_layout'],
|
||||
$payload['subject'],
|
||||
$payload['body_html'],
|
||||
$payload['recipient_name'],
|
||||
$payload['preheader'],
|
||||
$payload['cta_text'],
|
||||
$payload['cta_url'],
|
||||
(bool) $payload['personalize']
|
||||
);
|
||||
|
||||
$ok = $this->mailer->send(
|
||||
$payload['test_email'],
|
||||
'[TEST] ' . $payload['subject'],
|
||||
$html,
|
||||
$payload['from_key']
|
||||
);
|
||||
|
||||
return [
|
||||
'ok' => $ok,
|
||||
'message' => $ok
|
||||
? "Test email sent to {$payload['test_email']}."
|
||||
: 'Test email failed (mailer->send() returned false). Check logs.',
|
||||
];
|
||||
}
|
||||
|
||||
public function sendBroadcast(array $payload, array $recipients): array
|
||||
{
|
||||
$stats = [
|
||||
'attempted' => 0,
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
'mode' => $payload['mode'],
|
||||
];
|
||||
|
||||
foreach ($recipients as $recipient) {
|
||||
$stats['attempted']++;
|
||||
$recipientName = $recipient['name'] ?: 'Parent';
|
||||
$html = $this->composer->compose(
|
||||
(bool) $payload['wrap_layout'],
|
||||
$payload['subject'],
|
||||
$payload['body_html'],
|
||||
$recipientName,
|
||||
$payload['preheader'],
|
||||
$payload['cta_text'],
|
||||
$payload['cta_url'],
|
||||
(bool) $payload['personalize']
|
||||
);
|
||||
|
||||
$ok = $this->mailer->send(
|
||||
(string) $recipient['email'],
|
||||
$payload['subject'],
|
||||
$html,
|
||||
$payload['from_key']
|
||||
);
|
||||
|
||||
$ok ? $stats['sent']++ : $stats['failed']++;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\BroadcastEmail;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class BroadcastEmailImageService
|
||||
{
|
||||
private const MAX_SIZE_BYTES = 5242880;
|
||||
|
||||
private array $allowedTypes = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
];
|
||||
|
||||
public function store(UploadedFile $file): array
|
||||
{
|
||||
$mime = strtolower((string) $file->getMimeType());
|
||||
if (!isset($this->allowedTypes[$mime])) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 415,
|
||||
'error' => 'Unsupported image type',
|
||||
];
|
||||
}
|
||||
|
||||
if ($file->getSize() > self::MAX_SIZE_BYTES) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 413,
|
||||
'error' => 'Image too large (max 5MB)',
|
||||
];
|
||||
}
|
||||
|
||||
$targetDir = public_path('uploads/email');
|
||||
if (!is_dir($targetDir)) {
|
||||
@mkdir($targetDir, 0755, true);
|
||||
}
|
||||
|
||||
$ext = $this->allowedTypes[$mime];
|
||||
$newName = uniqid('em_', true) . '.' . $ext;
|
||||
$file->move($targetDir, $newName);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'url' => asset('uploads/email/' . $newName),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\BroadcastEmail;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BroadcastEmailRecipientService
|
||||
{
|
||||
public function parentsWithEmails(): array
|
||||
{
|
||||
$parents = User::getParents();
|
||||
|
||||
return array_values(array_filter($parents, static function ($parent) {
|
||||
$email = $parent['email'] ?? '';
|
||||
return is_string($email) && trim($email) !== '';
|
||||
}));
|
||||
}
|
||||
|
||||
public function recipientsByIds(array $ids): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('users')
|
||||
->select('users.id', 'users.email', DB::raw('CONCAT(users.firstname, " ", users.lastname) AS name'))
|
||||
->whereIn('users.id', $ids)
|
||||
->whereNotNull('users.email')
|
||||
->where('users.email', '!=', '')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\BroadcastEmail;
|
||||
|
||||
class BroadcastEmailSenderOptionsService
|
||||
{
|
||||
public function listOptions(): array
|
||||
{
|
||||
$json = env('MAIL_SENDERS', '{}');
|
||||
$arr = json_decode($json, true);
|
||||
|
||||
if (!is_array($arr) || empty($arr)) {
|
||||
$smtpUser = getenv('SMTP_USER') ?: '';
|
||||
$name = 'Al Rahma Sunday School';
|
||||
return [[
|
||||
'key' => 'general',
|
||||
'label' => $name . ($smtpUser ? " <{$smtpUser}>" : ''),
|
||||
]];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($arr as $key => $info) {
|
||||
$name = $info['name'] ?? 'Sender';
|
||||
$email = $info['email'] ?? '';
|
||||
$out[] = [
|
||||
'key' => (string) $key,
|
||||
'label' => trim($name . ($email ? " <{$email}>" : '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassPrep;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ClassRosterService
|
||||
{
|
||||
public function listStudentsByClass(int $classSectionId, ?string $schoolYear = null): array
|
||||
{
|
||||
$schoolYear = $schoolYear && trim($schoolYear) !== ''
|
||||
? $schoolYear
|
||||
: (string) Configuration::getConfig('school_year');
|
||||
|
||||
return DB::table('students')
|
||||
->select(
|
||||
'students.id',
|
||||
'students.firstname',
|
||||
'students.lastname',
|
||||
'students.gender',
|
||||
'cs.class_section_name AS registration_grade'
|
||||
)
|
||||
->join('student_class as sc', 'sc.student_id', '=', 'students.id')
|
||||
->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->where('sc.class_section_id', $classSectionId)
|
||||
->groupBy(
|
||||
'students.id',
|
||||
'students.firstname',
|
||||
'students.lastname',
|
||||
'students.gender',
|
||||
'cs.class_section_name'
|
||||
)
|
||||
->orderBy('students.firstname', 'ASC')
|
||||
->orderBy('students.lastname', 'ASC')
|
||||
->limit(1000)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassPrep;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StickerCountService
|
||||
{
|
||||
public function listAll(?string $schoolYear = null, ?string $semester = null): array
|
||||
{
|
||||
$schoolYear = $this->resolveSchoolYear($schoolYear);
|
||||
$semester = $this->resolveSemester($semester);
|
||||
|
||||
return $this->buildStickerCounts($schoolYear, $semester, null, $this->specialMapWithKg());
|
||||
}
|
||||
|
||||
public function listForClass(?string $schoolYear, ?string $semester, int $classSectionId): array
|
||||
{
|
||||
$schoolYear = $this->resolveSchoolYear($schoolYear);
|
||||
$semester = $this->resolveSemester($semester);
|
||||
|
||||
return $this->buildStickerCounts($schoolYear, $semester, $classSectionId, $this->specialMapWithoutKg());
|
||||
}
|
||||
|
||||
private function resolveSchoolYear(?string $schoolYear): string
|
||||
{
|
||||
return $schoolYear && trim($schoolYear) !== ''
|
||||
? $schoolYear
|
||||
: (string) Configuration::getConfig('school_year');
|
||||
}
|
||||
|
||||
private function resolveSemester(?string $semester): string
|
||||
{
|
||||
return $semester && trim($semester) !== ''
|
||||
? $semester
|
||||
: (string) Configuration::getConfig('semester');
|
||||
}
|
||||
|
||||
private function buildStickerCounts(string $schoolYear, string $semester, ?int $classSectionId, array $specialMap): array
|
||||
{
|
||||
$builder = DB::table('student_class as sc')
|
||||
->select(
|
||||
'sc.student_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.is_new',
|
||||
'cs.class_section_name AS grade_label'
|
||||
)
|
||||
->join('students as s', 's.id', '=', 'sc.student_id')
|
||||
->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->join('classes as c', 'c.id', '=', 'cs.class_id')
|
||||
->where('sc.school_year', $schoolYear);
|
||||
|
||||
if ($classSectionId !== null && $classSectionId > 0) {
|
||||
$builder->where('sc.class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$rows = $builder->get()->toArray();
|
||||
|
||||
$students = [];
|
||||
$totalPrimary = 0;
|
||||
$totalSecondary = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$grade = trim((string) ($row->grade_label ?? ''));
|
||||
if ($grade === '' || $this->isYouth($grade)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isNew = (int) ($row->is_new ?? 0) === 1;
|
||||
[$primary, $secondary] = $this->calculateStickerCounts($grade, $isNew, $specialMap);
|
||||
|
||||
$totalPrimary += $primary;
|
||||
$totalSecondary += $secondary;
|
||||
|
||||
$students[] = [
|
||||
'student_id' => (int) ($row->student_id ?? 0),
|
||||
'firstname' => trim((string) ($row->firstname ?? '')),
|
||||
'lastname' => trim((string) ($row->lastname ?? '')),
|
||||
'grade_label' => $grade,
|
||||
'primary_count' => $primary,
|
||||
'secondary_count' => $secondary,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'classSectionId' => $classSectionId,
|
||||
'students' => $students,
|
||||
'totals' => [
|
||||
'primary' => $totalPrimary,
|
||||
'secondary' => $totalSecondary,
|
||||
'students' => count($students),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function calculateStickerCounts(string $grade, bool $isNew, array $specialMap): array
|
||||
{
|
||||
$primary = 0;
|
||||
$secondary = 0;
|
||||
|
||||
if (array_key_exists($grade, $specialMap)) {
|
||||
$primary = (int) $specialMap[$grade];
|
||||
$secondary = 0;
|
||||
} elseif ($this->isUpperBlock($grade)) {
|
||||
if ($isNew) {
|
||||
$primary = 4;
|
||||
$secondary = 0;
|
||||
} else {
|
||||
$primary = $this->isGrade5($grade) ? 3 : 2;
|
||||
$secondary = max(0, 4 - $primary);
|
||||
}
|
||||
} else {
|
||||
$primary = 4;
|
||||
$secondary = 0;
|
||||
}
|
||||
|
||||
return [$primary, $secondary];
|
||||
}
|
||||
|
||||
private function isUpperBlock(string $grade): bool
|
||||
{
|
||||
return (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($grade));
|
||||
}
|
||||
|
||||
private function isGrade5(string $grade): bool
|
||||
{
|
||||
return (bool) preg_match('/^5(\-|$)/', trim($grade));
|
||||
}
|
||||
|
||||
private function isYouth(string $grade): bool
|
||||
{
|
||||
return (bool) preg_match('/^youth(?:\b|-)/i', trim($grade));
|
||||
}
|
||||
|
||||
private function specialMapWithKg(): array
|
||||
{
|
||||
return [
|
||||
'KG' => 1,
|
||||
'1-A' => 3,
|
||||
'1-B' => 4,
|
||||
'2-A' => 5,
|
||||
'2-B' => 5,
|
||||
'9' => 2,
|
||||
];
|
||||
}
|
||||
|
||||
private function specialMapWithoutKg(): array
|
||||
{
|
||||
return [
|
||||
'1-A' => 3,
|
||||
'1-B' => 4,
|
||||
'2-A' => 5,
|
||||
'2-B' => 5,
|
||||
'9' => 2,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassPreparation;
|
||||
|
||||
use App\Models\ClassPrepAdjustment;
|
||||
|
||||
class ClassPreparationAdjustmentService
|
||||
{
|
||||
public function applyAdjustments(array $baseItems, string $classSectionId, string $schoolYear): array
|
||||
{
|
||||
$rawAdjustments = ClassPrepAdjustment::query()
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('adjustable', 1)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
|
||||
foreach ($rawAdjustments as $adjustment) {
|
||||
$item = $adjustment['item_name'] ?? '';
|
||||
$delta = (int) ($adjustment['adjustment'] ?? 0);
|
||||
if (array_key_exists($item, $adjMap)) {
|
||||
$adjMap[$item] = $delta;
|
||||
}
|
||||
if (isset($baseItems[$item])) {
|
||||
$baseItems[$item] = max(0, (int) $baseItems[$item] + $delta);
|
||||
}
|
||||
}
|
||||
|
||||
return [$baseItems, $adjMap];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassPreparation;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ClassPreparationCalculatorService
|
||||
{
|
||||
private array $allowedPrepCategories = [
|
||||
'Grade Box',
|
||||
'Large Table',
|
||||
'Regular Chair',
|
||||
'Small Chair',
|
||||
'Small Table',
|
||||
'Teacher Chair',
|
||||
'Trash Bin',
|
||||
'White Board',
|
||||
];
|
||||
|
||||
public function getAllowedCategories(): array
|
||||
{
|
||||
return $this->allowedPrepCategories;
|
||||
}
|
||||
|
||||
public function getClassLevelBySection(string $classSectionId): int
|
||||
{
|
||||
$sectionName = ClassSection::getClassSectionNameBySectionId($classSectionId);
|
||||
$label = $sectionName !== null && $sectionName !== '' ? (string) $sectionName : $classSectionId;
|
||||
|
||||
if (preg_match('/^(kg|k)(\\b|[^a-z0-9])/i', $label)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$numValue = (int) preg_replace('/\\D/', '', $label);
|
||||
|
||||
if ($numValue > 0 && $numValue < 30) {
|
||||
$firstDigit = (int) substr((string) $numValue, 0, 1);
|
||||
return $firstDigit === 1 ? 1 : ($firstDigit === 2 ? 2 : 3);
|
||||
}
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
public function calculatePrepItems(int $students, int $classLevel, string $classSectionId): array
|
||||
{
|
||||
$allowed = $this->allowedPrepCategories;
|
||||
$items = array_fill_keys($allowed, 0);
|
||||
|
||||
$categories = DB::table('inventory_categories')
|
||||
->select('name', 'grade_min', 'grade_max')
|
||||
->where('type', 'classroom')
|
||||
->whereIn('name', $allowed)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$isLowerGrades = in_array($classLevel, [1, 2], true);
|
||||
$teacherCount = $this->getTeacherCountForSection(
|
||||
$classSectionId,
|
||||
(string) Configuration::getConfig('school_year')
|
||||
);
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
$name = (string) $cat->name;
|
||||
$gradeMin = $cat->grade_min ?? null;
|
||||
$gradeMax = $cat->grade_max ?? null;
|
||||
|
||||
if ($gradeMin !== null && $classLevel < (int) $gradeMin) {
|
||||
$items[$name] = 0;
|
||||
continue;
|
||||
}
|
||||
if ($gradeMax !== null && $classLevel > (int) $gradeMax) {
|
||||
$items[$name] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
$qty = 0;
|
||||
switch (strtolower($name)) {
|
||||
case 'small table':
|
||||
$qty = $isLowerGrades ? (int) ceil($students / 3) : 0;
|
||||
break;
|
||||
case 'large table':
|
||||
$qty = $isLowerGrades ? 0 : (int) ceil($students / 4);
|
||||
break;
|
||||
case 'small chair':
|
||||
$qty = $isLowerGrades ? $students : 0;
|
||||
break;
|
||||
case 'regular chair':
|
||||
$qty = $isLowerGrades ? 0 : $students;
|
||||
break;
|
||||
case 'teacher chair':
|
||||
$qty = (int) $teacherCount;
|
||||
break;
|
||||
case 'trash bin':
|
||||
case 'white board':
|
||||
case 'grade box':
|
||||
$qty = 1;
|
||||
break;
|
||||
default:
|
||||
$qty = 0;
|
||||
}
|
||||
|
||||
if (array_key_exists($name, $items)) {
|
||||
$items[$name] = $qty;
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function getTeacherCountForSection(string $classSectionId, ?string $schoolYear = null): int
|
||||
{
|
||||
$schoolYear = $schoolYear ?: (string) Configuration::getConfig('school_year');
|
||||
|
||||
$row = DB::table('teacher_class')
|
||||
->select('COUNT(*) AS cnt')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereIn('position', ['main', 'ta'])
|
||||
->where('school_year', $schoolYear)
|
||||
->first();
|
||||
|
||||
return (int) ($row->cnt ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassPreparation;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ClassPreparationContextService
|
||||
{
|
||||
/** cache for roster presence per term */
|
||||
private array $rosterPresenceCache = [];
|
||||
|
||||
public function getSchoolYear(): string
|
||||
{
|
||||
return (string) (Configuration::getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
public function getSemester(): string
|
||||
{
|
||||
return (string) (Configuration::getConfig('semester') ?? '');
|
||||
}
|
||||
|
||||
public function hasRosterForSemester(string $schoolYear, string $semester): bool
|
||||
{
|
||||
$year = trim((string) $schoolYear);
|
||||
$sem = trim((string) $semester);
|
||||
if ($year === '' || $sem === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$key = sprintf('sem:%s:%s', $year, $sem);
|
||||
if (array_key_exists($key, $this->rosterPresenceCache)) {
|
||||
return $this->rosterPresenceCache[$key];
|
||||
}
|
||||
|
||||
$cnt = DB::table('student_class')
|
||||
->where('school_year', $year)
|
||||
->where('semester', $sem)
|
||||
->count();
|
||||
|
||||
$this->rosterPresenceCache[$key] = $cnt > 0;
|
||||
return $this->rosterPresenceCache[$key];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassPreparation;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ClassPreparationInventoryService
|
||||
{
|
||||
public function buildAvailability(string $schoolYear, string $semester, bool $limitToSemester, array $allowed): array
|
||||
{
|
||||
$inventoryMap = array_fill_keys($allowed, 0);
|
||||
|
||||
$joinRows = DB::table('inventory_items as ii')
|
||||
->select('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.good_qty IS NOT NULL THEN ii.good_qty WHEN ii.`condition`=\"good\" THEN ii.quantity ELSE 0 END),0) AS available')
|
||||
->join('inventory_categories as ic', 'ic.id', '=', 'ii.category_id')
|
||||
->where('ii.type', 'classroom')
|
||||
->where('ii.school_year', $schoolYear)
|
||||
->whereIn('ic.name', $allowed);
|
||||
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$joinRows->where('ii.semester', $semester);
|
||||
}
|
||||
|
||||
$joinRows = $joinRows->groupBy('ic.name')->get()->toArray();
|
||||
|
||||
foreach ($joinRows as $r) {
|
||||
$name = (string) ($r->item_name ?? '');
|
||||
if ($name !== '' && isset($inventoryMap[$name])) {
|
||||
$inventoryMap[$name] = max($inventoryMap[$name], (int) ($r->available ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
$nameRows = DB::table('inventory_items')
|
||||
->select('name AS item_name, COALESCE(SUM(CASE WHEN good_qty IS NOT NULL THEN good_qty WHEN `condition`=\"good\" THEN quantity ELSE 0 END),0) AS available')
|
||||
->where('type', 'classroom')
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('name', $allowed);
|
||||
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$nameRows->where('semester', $semester);
|
||||
}
|
||||
|
||||
$nameRows = $nameRows->groupBy('name')->get()->toArray();
|
||||
|
||||
foreach ($nameRows as $r) {
|
||||
$name = (string) ($r->item_name ?? '');
|
||||
if ($name !== '' && isset($inventoryMap[$name])) {
|
||||
$inventoryMap[$name] = max($inventoryMap[$name], (int) ($r->available ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
return $inventoryMap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassPreparation;
|
||||
|
||||
use App\Models\ClassPreparationLog;
|
||||
|
||||
class ClassPreparationLogService
|
||||
{
|
||||
public function getLatestLog(string $classSectionId, string $schoolYear): ?ClassPreparationLog
|
||||
{
|
||||
return ClassPreparationLog::query()
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function hasPrepChanged(array $new, array $old): bool
|
||||
{
|
||||
ksort($new);
|
||||
ksort($old);
|
||||
|
||||
return json_encode($new) !== json_encode($old);
|
||||
}
|
||||
|
||||
public function createLog(string $classSectionId, string $className, string $schoolYear, array $items, string $createdAt): bool
|
||||
{
|
||||
try {
|
||||
ClassPreparationLog::query()->create([
|
||||
'class_section_id' => $classSectionId,
|
||||
'class_section' => $className,
|
||||
'school_year' => $schoolYear,
|
||||
'prep_data' => json_encode($items),
|
||||
'created_at' => $createdAt,
|
||||
]);
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassPreparation;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ClassPreparationRosterService
|
||||
{
|
||||
public function getClassSectionStudentCounts(string $schoolYear, string $semester, bool $limitToSemester): array
|
||||
{
|
||||
$query = DB::table('student_class as sc')
|
||||
->select('sc.class_section_id, COUNT(DISTINCT sc.student_id) AS student_count', false)
|
||||
->join('students as s', 's.id', '=', 'sc.student_id')
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.school_year', $schoolYear);
|
||||
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$query->where('sc.semester', $semester);
|
||||
}
|
||||
|
||||
return $query->groupBy('sc.class_section_id')->get()->toArray();
|
||||
}
|
||||
|
||||
public function getStudentCountForSection(string $schoolYear, string $semester, bool $limitToSemester, string $classSectionId): int
|
||||
{
|
||||
$query = DB::table('student_class as sc')
|
||||
->select('COUNT(DISTINCT sc.student_id) AS cnt', false)
|
||||
->join('students as s', 's.id', '=', 'sc.student_id')
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.class_section_id', $classSectionId)
|
||||
->where('sc.school_year', $schoolYear);
|
||||
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$query->where('sc.semester', $semester);
|
||||
}
|
||||
|
||||
$row = $query->first();
|
||||
return (int) ($row->cnt ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassPreparation;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
|
||||
class ClassPreparationService
|
||||
{
|
||||
private ClassPreparationContextService $context;
|
||||
private ClassPreparationRosterService $roster;
|
||||
private ClassPreparationInventoryService $inventory;
|
||||
private ClassPreparationCalculatorService $calculator;
|
||||
private ClassPreparationAdjustmentService $adjustments;
|
||||
private ClassPreparationLogService $logs;
|
||||
|
||||
public function __construct(
|
||||
ClassPreparationContextService $context,
|
||||
ClassPreparationRosterService $roster,
|
||||
ClassPreparationInventoryService $inventory,
|
||||
ClassPreparationCalculatorService $calculator,
|
||||
ClassPreparationAdjustmentService $adjustments,
|
||||
ClassPreparationLogService $logs
|
||||
) {
|
||||
$this->context = $context;
|
||||
$this->roster = $roster;
|
||||
$this->inventory = $inventory;
|
||||
$this->calculator = $calculator;
|
||||
$this->adjustments = $adjustments;
|
||||
$this->logs = $logs;
|
||||
}
|
||||
|
||||
public function listPrep(?string $schoolYear = null, ?string $semester = null): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?: $this->context->getSchoolYear();
|
||||
$semester = $semester ?: $this->context->getSemester();
|
||||
$allowed = $this->calculator->getAllowedCategories();
|
||||
$limitToSemester = $this->context->hasRosterForSemester($schoolYear, $semester);
|
||||
|
||||
$classSections = $this->roster->getClassSectionStudentCounts($schoolYear, $semester, $limitToSemester);
|
||||
|
||||
$inventoryMap = $this->inventory->buildAvailability($schoolYear, $semester, $limitToSemester, $allowed);
|
||||
$requiredTotals = array_fill_keys($allowed, 0);
|
||||
$results = [];
|
||||
|
||||
foreach ($classSections as $row) {
|
||||
$classSectionId = (string) $row->class_section_id;
|
||||
$studentCount = (int) $row->student_count;
|
||||
$classLevel = $this->calculator->getClassLevelBySection($classSectionId);
|
||||
$className = ClassSection::getClassSectionNameBySectionId($classSectionId);
|
||||
|
||||
$baseItems = $this->calculator->calculatePrepItems($studentCount, $classLevel, $classSectionId);
|
||||
[$baseItems, $adjMap] = $this->adjustments->applyAdjustments($baseItems, $classSectionId, $schoolYear);
|
||||
|
||||
foreach ($allowed as $cat) {
|
||||
$requiredTotals[$cat] += (int) ($baseItems[$cat] ?? 0);
|
||||
}
|
||||
|
||||
$oldSnap = $this->logs->getLatestLog($classSectionId, $schoolYear);
|
||||
$oldPrep = $oldSnap ? (array) ($oldSnap->prep_data ?? []) : [];
|
||||
$hasChanged = $this->logs->hasPrepChanged($baseItems, $oldPrep);
|
||||
|
||||
$results[] = [
|
||||
'class_section' => $className,
|
||||
'class_section_id' => $classSectionId,
|
||||
'student_count' => $studentCount,
|
||||
'class_level' => $classLevel,
|
||||
'prep_items' => $baseItems,
|
||||
'needs_print' => $hasChanged,
|
||||
'last_printed_at' => $oldSnap?->created_at,
|
||||
'adjustments' => $adjMap,
|
||||
];
|
||||
}
|
||||
|
||||
$shortages = [];
|
||||
foreach ($requiredTotals as $item => $reqQty) {
|
||||
$have = (int) ($inventoryMap[$item] ?? 0);
|
||||
if ($have < (int) $reqQty) {
|
||||
$shortages[$item] = (int) $reqQty - $have;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'results' => $results,
|
||||
'totals' => $requiredTotals,
|
||||
'shortages' => $shortages,
|
||||
];
|
||||
}
|
||||
|
||||
public function markPrinted(string $schoolYear, string $semester, array $classSectionIds): int
|
||||
{
|
||||
$limitToSemester = $this->context->hasRosterForSemester($schoolYear, $semester);
|
||||
$ids = array_values(array_unique(array_filter(array_map('strval', $classSectionIds))));
|
||||
$now = $this->utcNow();
|
||||
$count = 0;
|
||||
|
||||
foreach ($ids as $classSectionId) {
|
||||
$studentCount = $this->roster->getStudentCountForSection($schoolYear, $semester, $limitToSemester, $classSectionId);
|
||||
$classLevel = $this->calculator->getClassLevelBySection($classSectionId);
|
||||
$className = ClassSection::getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
||||
|
||||
$items = $this->calculator->calculatePrepItems($studentCount, $classLevel, $classSectionId);
|
||||
[$items] = $this->adjustments->applyAdjustments($items, $classSectionId, $schoolYear);
|
||||
|
||||
if ($this->logs->createLog($classSectionId, $className, $schoolYear, $items, $now)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function utcNow(): string
|
||||
{
|
||||
if (function_exists('utc_now')) {
|
||||
return (string) utc_now();
|
||||
}
|
||||
return now('UTC')->toDateTimeString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Communication;
|
||||
|
||||
use App\Models\FamilyStudent;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CommunicationFamilyService
|
||||
{
|
||||
public function familiesForStudent(int $studentId): array
|
||||
{
|
||||
return FamilyStudent::getFamiliesForStudent($studentId);
|
||||
}
|
||||
|
||||
public function guardiansForFamily(int $familyId): array
|
||||
{
|
||||
return DB::table('family_guardians as fg')
|
||||
->join('users as u', 'u.id', '=', 'fg.user_id')
|
||||
->where('fg.family_id', $familyId)
|
||||
->select(
|
||||
'u.id as user_id',
|
||||
'u.firstname',
|
||||
'u.lastname',
|
||||
'u.email',
|
||||
'fg.relation',
|
||||
'fg.is_primary',
|
||||
'fg.receive_emails',
|
||||
'fg.receive_sms'
|
||||
)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
|
||||
public function guardianSalutation(int $familyId): string
|
||||
{
|
||||
$rows = DB::table('family_guardians as fg')
|
||||
->join('users as u', 'u.id', '=', 'fg.user_id')
|
||||
->where('fg.family_id', $familyId)
|
||||
->where('fg.receive_emails', 1)
|
||||
->orderByDesc('fg.is_primary')
|
||||
->orderBy('u.lastname')
|
||||
->orderBy('u.firstname')
|
||||
->select('u.firstname', 'u.lastname')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
if (empty($rows)) {
|
||||
return 'Parent/Guardian';
|
||||
}
|
||||
|
||||
$names = array_map(static function ($row) {
|
||||
return trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? ''));
|
||||
}, $rows);
|
||||
|
||||
return implode(' & ', $names);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Communication;
|
||||
|
||||
use App\Models\CommunicationLog;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CommunicationLogService
|
||||
{
|
||||
public function log(array $payload): void
|
||||
{
|
||||
if (!Schema::hasTable('communication_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
CommunicationLog::query()->create($payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Communication;
|
||||
|
||||
class CommunicationPreviewService
|
||||
{
|
||||
private CommunicationTemplateService $templates;
|
||||
private CommunicationStudentService $students;
|
||||
private CommunicationFamilyService $families;
|
||||
|
||||
public function __construct(
|
||||
CommunicationTemplateService $templates,
|
||||
CommunicationStudentService $students,
|
||||
CommunicationFamilyService $families
|
||||
) {
|
||||
$this->templates = $templates;
|
||||
$this->students = $students;
|
||||
$this->families = $families;
|
||||
}
|
||||
|
||||
public function buildPreview(
|
||||
string $templateKey,
|
||||
int $studentId,
|
||||
int $familyId,
|
||||
array $vars,
|
||||
string $teacherName
|
||||
): array {
|
||||
$template = $this->templates->findTemplateByKey($templateKey);
|
||||
if (!$template) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 404,
|
||||
'message' => 'Template not found',
|
||||
];
|
||||
}
|
||||
|
||||
$student = $this->students->getStudentBasic($studentId);
|
||||
if (!$student) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 404,
|
||||
'message' => 'Student not found',
|
||||
];
|
||||
}
|
||||
|
||||
$salutation = $this->families->guardianSalutation($familyId);
|
||||
|
||||
$autoVars = [
|
||||
'student_fullname' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
|
||||
'student_grade' => $student['grade'] ?? '',
|
||||
'parent_salutation' => $salutation,
|
||||
'date' => $this->formatLocalDate('Y-m-d'),
|
||||
'school_name' => 'Al Rahma Sunday School',
|
||||
'teacher_name' => $teacherName !== '' ? $teacherName : 'Teacher',
|
||||
];
|
||||
|
||||
$allVars = array_merge($autoVars, $vars);
|
||||
|
||||
$subject = $this->renderTwig($template['subject'], $allVars);
|
||||
$body = $this->renderTwig($template['body'], $allVars);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'subject' => $subject,
|
||||
'html' => nl2br($body),
|
||||
];
|
||||
}
|
||||
|
||||
private function renderTwig(string $template, array $vars): string
|
||||
{
|
||||
return (string) preg_replace_callback('/\\{\\{\\s*([a-zA-Z0-9_\\.]+)\\s*\\}\\}/', function ($m) use ($vars) {
|
||||
$key = $m[1];
|
||||
return htmlspecialchars((string) ($vars[$key] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
}, $template);
|
||||
}
|
||||
|
||||
private function formatLocalDate(string $format): string
|
||||
{
|
||||
if (function_exists('local_date') && function_exists('utc_now')) {
|
||||
return (string) local_date(utc_now(), $format);
|
||||
}
|
||||
return now()->format($format);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Communication;
|
||||
|
||||
use App\Services\EmailService;
|
||||
|
||||
class CommunicationSendService
|
||||
{
|
||||
private EmailService $mailer;
|
||||
private CommunicationLogService $logService;
|
||||
|
||||
public function __construct(EmailService $mailer, CommunicationLogService $logService)
|
||||
{
|
||||
$this->mailer = $mailer;
|
||||
$this->logService = $logService;
|
||||
}
|
||||
|
||||
public function send(array $payload): array
|
||||
{
|
||||
$recipients = $this->normalizeEmails($payload['recipients'] ?? []);
|
||||
$cc = $this->normalizeEmails($payload['cc'] ?? []);
|
||||
$bcc = $this->normalizeEmails($payload['bcc'] ?? []);
|
||||
|
||||
if (empty($recipients)) {
|
||||
$this->logService->log([
|
||||
'student_id' => (int) $payload['student_id'],
|
||||
'family_id' => (int) $payload['family_id'],
|
||||
'student_name' => (string) $payload['student_name'],
|
||||
'template_key' => (string) $payload['template_key'],
|
||||
'subject' => (string) $payload['subject'],
|
||||
'body' => (string) $payload['body'],
|
||||
'recipients' => [],
|
||||
'cc' => array_values($cc),
|
||||
'bcc' => array_values($bcc),
|
||||
'status' => 'failed',
|
||||
'error_message' => 'No recipients provided',
|
||||
'sent_by' => (int) ($payload['sent_by'] ?? 0),
|
||||
'metadata' => null,
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => 'No recipients provided',
|
||||
];
|
||||
}
|
||||
|
||||
$sendOk = false;
|
||||
$error = null;
|
||||
|
||||
try {
|
||||
$sendOk = $this->mailer->send(
|
||||
$recipients,
|
||||
(string) $payload['subject'],
|
||||
(string) $payload['body'],
|
||||
'general',
|
||||
$cc,
|
||||
$bcc
|
||||
);
|
||||
} catch (\Throwable $t) {
|
||||
$error = $t->getMessage();
|
||||
$sendOk = false;
|
||||
}
|
||||
|
||||
if (!$sendOk && $error === null) {
|
||||
$error = 'Mailer returned false';
|
||||
}
|
||||
|
||||
$this->logService->log([
|
||||
'student_id' => (int) $payload['student_id'],
|
||||
'family_id' => (int) $payload['family_id'],
|
||||
'student_name' => (string) $payload['student_name'],
|
||||
'template_key' => (string) $payload['template_key'],
|
||||
'subject' => (string) $payload['subject'],
|
||||
'body' => (string) $payload['body'],
|
||||
'recipients' => array_values($recipients),
|
||||
'cc' => array_values($cc),
|
||||
'bcc' => array_values($bcc),
|
||||
'status' => $sendOk ? 'sent' : 'failed',
|
||||
'error_message' => $error,
|
||||
'sent_by' => (int) ($payload['sent_by'] ?? 0),
|
||||
'metadata' => null,
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => $sendOk,
|
||||
'error' => $error,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeEmails(array $values): array
|
||||
{
|
||||
$out = array_values(array_unique(array_filter(array_map('strval', $values))));
|
||||
return array_values(array_filter($out, static function ($email) {
|
||||
return $email !== '';
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Communication;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CommunicationStudentService
|
||||
{
|
||||
public function listStudents(): array
|
||||
{
|
||||
return DB::table('students')
|
||||
->select('id', 'firstname', 'lastname', 'registration_grade')
|
||||
->orderBy('lastname', 'asc')
|
||||
->orderBy('firstname', 'asc')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
|
||||
public function getStudentBasic(int $studentId): ?array
|
||||
{
|
||||
$row = DB::table('students as s')
|
||||
->leftJoin('student_class as sc', 'sc.student_id', '=', 's.id')
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->select(
|
||||
's.id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.registration_grade',
|
||||
'cs.class_section_name as class_section_name'
|
||||
)
|
||||
->where('s.id', $studentId)
|
||||
->orderBy('sc.id', 'desc')
|
||||
->first();
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = (array) $row;
|
||||
$grade = $row['registration_grade'] ?? '';
|
||||
if (!$grade && !empty($row['class_section_name'])) {
|
||||
$grade = $row['class_section_name'];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'firstname' => (string) ($row['firstname'] ?? ''),
|
||||
'lastname' => (string) ($row['lastname'] ?? ''),
|
||||
'grade' => (string) ($grade ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Communication;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CommunicationTemplateService
|
||||
{
|
||||
public function listActiveTemplates(): array
|
||||
{
|
||||
[$keyColumn, $bodyColumn] = $this->resolveTemplateColumns();
|
||||
|
||||
return DB::table('email_templates')
|
||||
->where('is_active', 1)
|
||||
->orderBy($keyColumn, 'asc')
|
||||
->get()
|
||||
->map(function ($row) use ($keyColumn, $bodyColumn) {
|
||||
$row = (array) $row;
|
||||
return [
|
||||
'template_key' => (string) ($row[$keyColumn] ?? ''),
|
||||
'subject' => (string) ($row['subject'] ?? ''),
|
||||
'body' => (string) ($row[$bodyColumn] ?? ''),
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
public function findTemplateByKey(string $key): ?array
|
||||
{
|
||||
[$keyColumn, $bodyColumn] = $this->resolveTemplateColumns();
|
||||
|
||||
$row = DB::table('email_templates')
|
||||
->where($keyColumn, $key)
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = (array) $row;
|
||||
|
||||
return [
|
||||
'template_key' => (string) ($row[$keyColumn] ?? ''),
|
||||
'subject' => (string) ($row['subject'] ?? ''),
|
||||
'body' => (string) ($row[$bodyColumn] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveTemplateColumns(): array
|
||||
{
|
||||
$keyColumn = Schema::hasColumn('email_templates', 'template_key') ? 'template_key' : 'code';
|
||||
$bodyColumn = Schema::hasColumn('email_templates', 'body') ? 'body' : 'body_html';
|
||||
return [$keyColumn, $bodyColumn];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\CompetitionScores;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\TeacherClass;
|
||||
|
||||
class CompetitionScoresContextService
|
||||
{
|
||||
public function getTeacherContext(int $userId): array
|
||||
{
|
||||
$schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
|
||||
$semester = (string) (Configuration::getConfig('semester') ?? '');
|
||||
$assignments = TeacherClass::getClassAssignmentsByUserId($userId, $schoolYear, $semester);
|
||||
|
||||
return [$assignments, $schoolYear, $semester];
|
||||
}
|
||||
|
||||
public function getAllowedClassIds(array $assignments): array
|
||||
{
|
||||
$ids = array_map(static function ($row) {
|
||||
return (int) ($row['class_section_id'] ?? 0);
|
||||
}, $assignments);
|
||||
|
||||
return array_values(array_filter(array_unique($ids)));
|
||||
}
|
||||
|
||||
public function resolveActiveClassId(array $assignments, int $requestedClassId = 0): int
|
||||
{
|
||||
$allowed = $this->getAllowedClassIds($assignments);
|
||||
if ($requestedClassId > 0 && in_array($requestedClassId, $allowed, true)) {
|
||||
return $requestedClassId;
|
||||
}
|
||||
|
||||
return (int) ($allowed[0] ?? 0);
|
||||
}
|
||||
|
||||
public function getActiveClassName(array $assignments, int $classSectionId): ?string
|
||||
{
|
||||
if ($classSectionId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($assignments as $row) {
|
||||
if ((int) ($row['class_section_id'] ?? 0) === $classSectionId) {
|
||||
return $row['class_section_name'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return ClassSection::getClassSectionNameBySectionId($classSectionId);
|
||||
}
|
||||
|
||||
public function getClassSectionMap(): array
|
||||
{
|
||||
$sections = ClassSection::query()
|
||||
->select('class_section_id', 'class_section_name')
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$map = [];
|
||||
foreach ($sections as $section) {
|
||||
$id = (int) ($section['class_section_id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$map[$id] = $section['class_section_name'] ?? (string) $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\CompetitionScores;
|
||||
|
||||
use App\Models\Competition;
|
||||
use App\Models\CompetitionClassWinner;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CompetitionScoresQueryService
|
||||
{
|
||||
public function getCompetitionsForClass(int $classSectionId, string $schoolYear, string $semester): array
|
||||
{
|
||||
$builder = Competition::query()->orderBy('id', 'DESC');
|
||||
|
||||
if ($classSectionId > 0) {
|
||||
$builder->where(function ($q) use ($classSectionId) {
|
||||
$q->where('class_section_id', $classSectionId)
|
||||
->orWhere('class_section_id', 0)
|
||||
->orWhereNull('class_section_id');
|
||||
});
|
||||
}
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$builder->where(function ($q) use ($schoolYear) {
|
||||
$q->where('school_year', $schoolYear)
|
||||
->orWhere('school_year', '')
|
||||
->orWhereNull('school_year');
|
||||
});
|
||||
}
|
||||
|
||||
if ($semester !== '') {
|
||||
$builder->where(function ($q) use ($semester) {
|
||||
$q->where('semester', $semester)
|
||||
->orWhere('semester', '')
|
||||
->orWhereNull('semester');
|
||||
});
|
||||
}
|
||||
|
||||
return $builder->get()->map(fn ($row) => (array) $row)->all();
|
||||
}
|
||||
|
||||
public function getQuestionCounts(array $competitionIds, int $classSectionId): array
|
||||
{
|
||||
if (!$this->hasQuestionCount() || empty($competitionIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = CompetitionClassWinner::query()
|
||||
->select('competition_id', 'question_count')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereIn('competition_id', $competitionIds)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$compId = (int) ($row['competition_id'] ?? 0);
|
||||
if ($compId > 0 && $row['question_count'] !== null) {
|
||||
$out[$compId] = (int) $row['question_count'];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function getScoreCounts(array $competitionIds, int $classSectionId): array
|
||||
{
|
||||
if (empty($competitionIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::table('competition_scores')
|
||||
->selectRaw('competition_id, COUNT(*) AS total')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereIn('competition_id', $competitionIds)
|
||||
->groupBy('competition_id')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$compId = (int) ($row['competition_id'] ?? 0);
|
||||
if ($compId > 0) {
|
||||
$out[$compId] = (int) ($row['total'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function getQuestionCountForCompetition(int $competitionId, int $classSectionId): ?int
|
||||
{
|
||||
if (!$this->hasQuestionCount()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = CompetitionClassWinner::query()
|
||||
->select('question_count')
|
||||
->where('competition_id', $competitionId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->first();
|
||||
|
||||
if ($row && $row->question_count !== null) {
|
||||
return (int) $row->question_count;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function hasQuestionCount(): bool
|
||||
{
|
||||
return Schema::hasTable('competition_class_winners')
|
||||
&& Schema::hasColumn('competition_class_winners', 'question_count');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\CompetitionScores;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CompetitionScoresRosterService
|
||||
{
|
||||
private string $classStudentTable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
if (Schema::hasTable('class_student')) {
|
||||
$this->classStudentTable = 'class_student';
|
||||
} elseif (Schema::hasTable('student_class')) {
|
||||
$this->classStudentTable = 'student_class';
|
||||
} else {
|
||||
$this->classStudentTable = '';
|
||||
}
|
||||
}
|
||||
|
||||
public function getClassStudentCount(int $classSectionId, ?string $schoolYear): int
|
||||
{
|
||||
if ($classSectionId <= 0 || $this->classStudentTable === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$builder = DB::table($this->classStudentTable)
|
||||
->selectRaw('COUNT(*) AS total')
|
||||
->where('class_section_id', $classSectionId);
|
||||
|
||||
if ($schoolYear && Schema::hasColumn($this->classStudentTable, 'school_year')) {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$row = (array) $builder->first();
|
||||
return (int) ($row['total'] ?? 0);
|
||||
}
|
||||
|
||||
public function getStudentsForCompetition(array $competition, int $classSectionId): array
|
||||
{
|
||||
if ($classSectionId <= 0 || $this->classStudentTable === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$builder = DB::table('students as s')
|
||||
->select('s.id', 's.school_id', 's.firstname', 's.lastname')
|
||||
->join($this->classStudentTable . ' as cs', 'cs.student_id', '=', 's.id')
|
||||
->where('cs.class_section_id', $classSectionId);
|
||||
|
||||
if (Schema::hasColumn($this->classStudentTable, 'school_year')) {
|
||||
$schoolYear = (string) ($competition['school_year'] ?? '');
|
||||
if ($schoolYear !== '') {
|
||||
$builder->where('cs.school_year', $schoolYear);
|
||||
}
|
||||
}
|
||||
|
||||
return $builder
|
||||
->distinct()
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\CompetitionScores;
|
||||
|
||||
use App\Models\CompetitionScore;
|
||||
|
||||
class CompetitionScoresSaveService
|
||||
{
|
||||
public function filterScores(array $scores): array
|
||||
{
|
||||
$cleanScores = [];
|
||||
$invalidScores = [];
|
||||
|
||||
foreach ($scores as $studentId => $scoreValue) {
|
||||
$scoreValue = trim((string) $scoreValue);
|
||||
if ($scoreValue === '') {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match('/^\\d+$/', $scoreValue)) {
|
||||
$invalidScores[] = (int) $studentId;
|
||||
continue;
|
||||
}
|
||||
$cleanScores[(int) $studentId] = (int) $scoreValue;
|
||||
}
|
||||
|
||||
return [$cleanScores, $invalidScores];
|
||||
}
|
||||
|
||||
public function saveScores(int $competitionId, int $classSectionId, array $scores): void
|
||||
{
|
||||
foreach ($scores as $studentId => $scoreValue) {
|
||||
CompetitionScore::query()->updateOrCreate(
|
||||
[
|
||||
'competition_id' => $competitionId,
|
||||
'student_id' => (int) $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
],
|
||||
[
|
||||
'score' => (int) $scoreValue,
|
||||
'class_section_id' => $classSectionId,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function getScoreMap(int $competitionId, int $classSectionId): array
|
||||
{
|
||||
$rows = CompetitionScore::query()
|
||||
->select('student_id', 'score')
|
||||
->where('competition_id', $competitionId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int) $row['student_id']] = $row['score'];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Discounts;
|
||||
|
||||
use App\Models\DiscountVoucher;
|
||||
use App\Models\Invoice;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DiscountApplyService
|
||||
{
|
||||
private DiscountContextService $context;
|
||||
private DiscountInvoiceService $invoiceService;
|
||||
|
||||
public function __construct(DiscountContextService $context, DiscountInvoiceService $invoiceService)
|
||||
{
|
||||
$this->context = $context;
|
||||
$this->invoiceService = $invoiceService;
|
||||
}
|
||||
|
||||
public function applyVoucher(int $voucherId, array $parentIds, bool $allowAdditional, int $actorId): array
|
||||
{
|
||||
$schoolYear = $this->context->getSchoolYear();
|
||||
$semester = $this->context->getSemester();
|
||||
|
||||
$voucher = DiscountVoucher::query()->find($voucherId);
|
||||
if (!$voucher) {
|
||||
return ['ok' => false, 'message' => 'Voucher not found.'];
|
||||
}
|
||||
|
||||
$voucherDescription = trim((string) ($voucher->description ?? ''));
|
||||
$voucherDescription = preg_replace(
|
||||
'/^Auto-generated on \\d{4}-\\d{2}-\\d{2}\\s*[—-]\\s*reason:\\s*/i',
|
||||
'',
|
||||
$voucherDescription
|
||||
);
|
||||
|
||||
$maxUsesRaw = $voucher->max_uses;
|
||||
$maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw;
|
||||
$timesUsed = (int) ($voucher->times_used ?? 0);
|
||||
$remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed);
|
||||
if ($remainingUses <= 0) {
|
||||
return ['ok' => false, 'message' => 'This voucher has reached its maximum allowed uses.'];
|
||||
}
|
||||
|
||||
if (!$allowAdditional) {
|
||||
$rows = DB::table('discount_usages as du')
|
||||
->select('i.parent_id')
|
||||
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
|
||||
->whereIn('i.parent_id', $parentIds)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->groupBy('i.parent_id')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$blockedParentIds = array_map(static fn ($r) => (int) ($r['parent_id'] ?? 0), $rows);
|
||||
if (!empty($blockedParentIds)) {
|
||||
$parentIds = array_values(array_diff(array_map('intval', $parentIds), $blockedParentIds));
|
||||
}
|
||||
if (empty($parentIds)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'All selected parents already have discounts. Enable "Allow additional discounts" to apply another.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$paymentDate = function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString();
|
||||
$fullyCoveredInvoiceIds = [];
|
||||
$touchedInvoiceIds = [];
|
||||
$appliedCount = 0;
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
foreach ($parentIds as $parentId) {
|
||||
$invoices = Invoice::getInvoicesByUserId((int) $parentId, $schoolYear);
|
||||
if (empty($invoices)) {
|
||||
Log::warning('Discount apply: parent has no invoices', [
|
||||
'parent_id' => (int) $parentId,
|
||||
'voucher_id' => $voucherId,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
if ($remainingUses <= 0) {
|
||||
break 2;
|
||||
}
|
||||
|
||||
$invoiceId = (int) ($invoice['id'] ?? 0);
|
||||
if ($invoiceId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$initialPreBalance = (float) $this->invoiceService->getCurrentInvoiceBalance($invoiceId, $schoolYear);
|
||||
if ($initialPreBalance <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$allowAdditional) {
|
||||
$exists = DB::table('discount_usages as du')
|
||||
->join('invoices as i', 'du.invoice_id', '=', 'i.id')
|
||||
->where('du.voucher_id', $voucherId)
|
||||
->where('du.invoice_id', $invoiceId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->count();
|
||||
if ($exists) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$rawDiscount = $voucher->discount_type === 'percent'
|
||||
? round(((float) ($invoice['total_amount'] ?? 0) * (float) $voucher->discount_value) / 100, 2)
|
||||
: (float) $voucher->discount_value;
|
||||
|
||||
$discount = min($rawDiscount, $initialPreBalance);
|
||||
if ($discount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$now = function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString();
|
||||
DB::table('discount_usages')->insert([
|
||||
'voucher_id' => $voucherId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'parent_id' => (int) $parentId,
|
||||
'discount_amount' => $discount,
|
||||
'description' => $voucherDescription,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_by' => $actorId,
|
||||
'used_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
|
||||
$newBalance = max(0.0, round($initialPreBalance - $discount, 2));
|
||||
DB::table('invoices')
|
||||
->where('id', $invoiceId)
|
||||
->update([
|
||||
'balance' => $newBalance,
|
||||
'has_discount' => 1,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$postBalance = round($initialPreBalance - $discount, 2);
|
||||
if ($postBalance < 0) {
|
||||
$postBalance = 0.0;
|
||||
}
|
||||
|
||||
$currentBalance = (float) $this->invoiceService->getCurrentInvoiceBalance($invoiceId, $schoolYear);
|
||||
|
||||
DB::table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->update([
|
||||
'times_used' => DB::raw('COALESCE(times_used,0) + 1'),
|
||||
]);
|
||||
|
||||
$this->triggerPaymentReceived(
|
||||
$invoiceId,
|
||||
(string) $voucherId,
|
||||
$discount,
|
||||
$paymentDate,
|
||||
$initialPreBalance,
|
||||
$postBalance,
|
||||
$schoolYear,
|
||||
$semester
|
||||
);
|
||||
|
||||
$touchedInvoiceIds[] = $invoiceId;
|
||||
$appliedCount++;
|
||||
|
||||
$epsilon = 0.01;
|
||||
$fullyCovered = (abs($discount - $initialPreBalance) <= $epsilon)
|
||||
|| ($postBalance <= $epsilon)
|
||||
|| ($currentBalance <= $epsilon);
|
||||
|
||||
if ($fullyCovered) {
|
||||
$fullyCoveredInvoiceIds[] = $invoiceId;
|
||||
}
|
||||
|
||||
$remainingUses--;
|
||||
|
||||
if ($remainingUses <= 0 && $maxUses !== null) {
|
||||
DB::table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->update([
|
||||
'is_active' => 0,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
return ['ok' => false, 'message' => 'Voucher application failed. Transaction rolled back.'];
|
||||
}
|
||||
|
||||
if ($appliedCount === 0) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'No discounts were applied. The voucher may already be used for these invoices or balances are zero.',
|
||||
];
|
||||
}
|
||||
|
||||
if ($maxUses !== null) {
|
||||
$row = (array) DB::table('discount_vouchers')
|
||||
->select('times_used')
|
||||
->where('id', $voucherId)
|
||||
->first();
|
||||
$usedNow = (int) ($row['times_used'] ?? 0);
|
||||
if ($usedNow >= $maxUses) {
|
||||
DB::table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->update([
|
||||
'is_active' => 0,
|
||||
'updated_at' => function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_unique($touchedInvoiceIds) as $iid) {
|
||||
try {
|
||||
$this->invoiceService->recalculateInvoice($iid, $schoolYear);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('recalculateInvoice failed for invoice {id}: {err}', [
|
||||
'id' => $iid,
|
||||
'err' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$updatedTotal = 0;
|
||||
foreach (array_unique($fullyCoveredInvoiceIds) as $iid) {
|
||||
try {
|
||||
$updatedTotal += $this->invoiceService->updateEnrollmentStatusIfPaid($iid, $schoolYear);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('updateEnrollmentStatusIfPaid failed for invoice {id}: {err}', [
|
||||
'id' => $iid,
|
||||
'err' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => $updatedTotal > 0
|
||||
? 'Voucher applied successfully and enrollments updated.'
|
||||
: 'Voucher applied successfully.',
|
||||
'appliedCount' => $appliedCount,
|
||||
'updatedEnrollments' => $updatedTotal,
|
||||
'touchedInvoices' => array_values(array_unique($touchedInvoiceIds)),
|
||||
];
|
||||
}
|
||||
|
||||
private function triggerPaymentReceived(
|
||||
int $invoiceId,
|
||||
string $voucherId,
|
||||
float $amount,
|
||||
string $paymentDate,
|
||||
float $preBalance,
|
||||
float $postBalance,
|
||||
string $schoolYear,
|
||||
string $semester
|
||||
): void {
|
||||
try {
|
||||
[$eventData, $studentData] = $this->invoiceService->buildPaymentEventData(
|
||||
$invoiceId,
|
||||
$voucherId,
|
||||
$amount,
|
||||
'discount',
|
||||
$paymentDate,
|
||||
'',
|
||||
0,
|
||||
$preBalance,
|
||||
$postBalance,
|
||||
$schoolYear,
|
||||
$semester
|
||||
);
|
||||
|
||||
if (class_exists(\CodeIgniter\Events\Events::class)) {
|
||||
\CodeIgniter\Events\Events::trigger('paymentReceived', $eventData, $studentData);
|
||||
} elseif (function_exists('event')) {
|
||||
event('paymentReceived', [$eventData, $studentData]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('paymentReceived hook failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Discounts;
|
||||
|
||||
use App\Models\Configuration;
|
||||
|
||||
class DiscountContextService
|
||||
{
|
||||
public function getSchoolYear(): string
|
||||
{
|
||||
return (string) (Configuration::getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
public function getSemester(): string
|
||||
{
|
||||
return (string) (Configuration::getConfig('semester') ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Discounts;
|
||||
|
||||
use App\Models\AdditionalCharge;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Enrollment;
|
||||
use App\Models\EventCharges;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class DiscountInvoiceService
|
||||
{
|
||||
public function getCurrentInvoiceBalance(int $invoiceId, string $schoolYear): float
|
||||
{
|
||||
$invoice = Invoice::query()->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$paymentQuery = Payment::query()
|
||||
->selectRaw('COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if (Schema::hasColumn('payments', 'status')) {
|
||||
$paymentQuery->where(function ($q) {
|
||||
$q->whereNotIn('status', [
|
||||
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
|
||||
])->orWhereNull('status');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('payments', 'is_void')) {
|
||||
$paymentQuery->where(function ($q) {
|
||||
$q->where('is_void', 0)->orWhereNull('is_void');
|
||||
});
|
||||
}
|
||||
|
||||
$rowPaid = (array) $paymentQuery->first();
|
||||
$totalPaid = (float) ($rowPaid['total_paid'] ?? 0);
|
||||
|
||||
$rowDisc = (array) DB::table('discount_usages as du')
|
||||
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
|
||||
->where('du.invoice_id', $invoiceId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->selectRaw('COALESCE(SUM(du.discount_amount),0) AS total_disc')
|
||||
->first();
|
||||
$totalDisc = (float) ($rowDisc['total_disc'] ?? 0);
|
||||
|
||||
$rowRefund = (array) DB::table('refunds')
|
||||
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->first();
|
||||
$totalRefundPaid = (float) ($rowRefund['total_refund_paid'] ?? 0);
|
||||
|
||||
$total = (float) ($invoice->total_amount ?? 0);
|
||||
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
|
||||
}
|
||||
|
||||
public function recalculateInvoice(int $invoiceId, string $schoolYear): void
|
||||
{
|
||||
$invoice = Invoice::query()->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parentId = (int) ($invoice->parent_id ?? 0);
|
||||
if ($parentId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$enrollments = Enrollment::query()
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$registered = [];
|
||||
$withdrawn = [];
|
||||
foreach ($enrollments as $e) {
|
||||
$row = [
|
||||
'student_id' => (int) ($e['student_id'] ?? 0),
|
||||
'class_section_id' => $e['class_section_id'] ?? null,
|
||||
'enrollment_status' => (string) ($e['enrollment_status'] ?? ''),
|
||||
];
|
||||
if (in_array($row['enrollment_status'], ['enrolled', 'payment pending'], true)) {
|
||||
$registered[] = $row;
|
||||
} elseif (in_array($row['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
|
||||
$withdrawn[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$refundDeadline = (string) (Configuration::getConfig('refund_deadline') ?? '');
|
||||
$refundAllowed = true;
|
||||
if ($refundDeadline !== '') {
|
||||
try {
|
||||
$tzName = function_exists('user_timezone') ? (string) user_timezone() : 'UTC';
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$today = new \DateTimeImmutable('today', $tz);
|
||||
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
|
||||
$refundAllowed = $today <= $deadline;
|
||||
} catch (\Throwable $e) {
|
||||
$refundAllowed = true;
|
||||
}
|
||||
}
|
||||
|
||||
$tuitionStudents = $registered;
|
||||
if (!$refundAllowed) {
|
||||
$tuitionStudents = array_merge($tuitionStudents, $withdrawn);
|
||||
}
|
||||
|
||||
$gradeFee = (int) (Configuration::getConfig('grade_fee') ?? 9);
|
||||
$firstStudentFee = (float) (Configuration::getConfig('first_student_fee') ?? 350);
|
||||
$secondStudentFee = (float) (Configuration::getConfig('second_student_fee') ?? 200);
|
||||
$youthFee = (float) (Configuration::getConfig('youth_fee') ?? 180);
|
||||
|
||||
foreach ($tuitionStudents as &$s) {
|
||||
$name = null;
|
||||
if (!empty($s['class_section_id'])) {
|
||||
$name = ClassSection::getClassSectionNameBySectionId($s['class_section_id']);
|
||||
}
|
||||
$s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A';
|
||||
}
|
||||
unset($s);
|
||||
|
||||
$regularCount = 0;
|
||||
$youthCount = 0;
|
||||
foreach ($tuitionStudents as $s) {
|
||||
$lvl = $this->parseGradeLevel((string) ($s['grade_name'] ?? ''), $gradeFee);
|
||||
if ($lvl > $gradeFee) {
|
||||
$youthCount++;
|
||||
} else {
|
||||
$regularCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$tuitionSubtotal = 0.0;
|
||||
$tuitionSubtotal += $youthCount * $youthFee;
|
||||
if ($regularCount >= 2) {
|
||||
$tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee;
|
||||
} elseif ($regularCount === 1) {
|
||||
$tuitionSubtotal += $firstStudentFee;
|
||||
}
|
||||
|
||||
$eventSubtotal = 0.0;
|
||||
$events = EventCharges::getChargesWithEventInfo($parentId, $schoolYear) ?? [];
|
||||
foreach ($events as $ev) {
|
||||
$eventSubtotal += (float) ($ev['charged'] ?? 0.0);
|
||||
}
|
||||
|
||||
$additionalSubtotal = 0.0;
|
||||
$rows = AdditionalCharge::query()
|
||||
->select('charge_type', 'amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('status', 'applied')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$amt = (float) ($r['amount'] ?? 0);
|
||||
$typ = strtolower((string) ($r['charge_type'] ?? 'add'));
|
||||
$amt = $typ === 'deduct' ? -abs($amt) : abs($amt);
|
||||
$additionalSubtotal += $amt;
|
||||
}
|
||||
|
||||
$newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
|
||||
|
||||
$paymentQuery = Payment::query()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if (Schema::hasColumn('payments', 'status')) {
|
||||
$paymentQuery->where(function ($q) {
|
||||
$q->whereNotIn('status', [
|
||||
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
|
||||
])->orWhereNull('status');
|
||||
});
|
||||
}
|
||||
if (Schema::hasColumn('payments', 'is_void')) {
|
||||
$paymentQuery->where(function ($q) {
|
||||
$q->where('is_void', 0)->orWhereNull('is_void');
|
||||
});
|
||||
}
|
||||
|
||||
$payments = $paymentQuery->get()->map(fn ($row) => (array) $row)->all();
|
||||
$totalPaid = 0.0;
|
||||
foreach ($payments as $p) {
|
||||
$totalPaid += (float) ($p['paid_amount'] ?? 0);
|
||||
}
|
||||
|
||||
$discRow = (array) DB::table('discount_usages')
|
||||
->selectRaw('COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->first();
|
||||
$totalDisc = (float) ($discRow['total_disc'] ?? 0);
|
||||
|
||||
$refundRow = (array) DB::table('refunds')
|
||||
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->first();
|
||||
$totalRefundPaid = (float) ($refundRow['total_refund_paid'] ?? 0);
|
||||
|
||||
$newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid);
|
||||
$newStatus = $newBalance <= 0.00001 ? 'Paid' : ($totalPaid > 0 ? 'Partially Paid' : 'Unpaid');
|
||||
|
||||
$updateData = [
|
||||
'total_amount' => $newTotal,
|
||||
'paid_amount' => $totalPaid,
|
||||
'balance' => $newBalance,
|
||||
'status' => $newStatus,
|
||||
'has_discount' => $totalDisc > 0.0 ? 1 : 0,
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('invoices', 'discount')) {
|
||||
$updateData['discount'] = $totalDisc;
|
||||
}
|
||||
|
||||
Invoice::query()->whereKey($invoiceId)->update($updateData);
|
||||
}
|
||||
|
||||
public function updateEnrollmentStatusIfPaid(int $invoiceId, string $schoolYear): int
|
||||
{
|
||||
$invoice = Invoice::query()->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$total = (float) ($invoice->total_amount ?? 0);
|
||||
$balance = (float) ($invoice->balance ?? 0);
|
||||
if (!($total > 0 && $balance < $total)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$parentId = (int) ($invoice->parent_id ?? 0);
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$paidEnrollmentIds = [];
|
||||
if (Schema::hasTable('invoice_items') && Schema::hasColumn('invoice_items', 'enrollment_id')) {
|
||||
$rows = DB::table('invoice_items')
|
||||
->select('enrollment_id')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereNotNull('enrollment_id')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$eid = (int) ($r['enrollment_id'] ?? 0);
|
||||
if ($eid > 0) {
|
||||
$paidEnrollmentIds[] = $eid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$query = Enrollment::query()
|
||||
->select('id')
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where(function ($q) {
|
||||
$q->where('enrollment_status', 'payment pending')
|
||||
->orWhere('enrollment_status', 'Payment pending')
|
||||
->orWhere('enrollment_status', 'Payment Pending')
|
||||
->orWhere('enrollment_status', 'PAYMENT PENDING')
|
||||
->orWhereRaw("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'");
|
||||
});
|
||||
|
||||
if (!empty($paidEnrollmentIds)) {
|
||||
$query->whereIn('id', array_values(array_unique($paidEnrollmentIds)));
|
||||
}
|
||||
|
||||
$toUpdateIds = $query->get()->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||
if (empty($toUpdateIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$affected = Enrollment::query()
|
||||
->whereIn('id', $toUpdateIds)
|
||||
->update([
|
||||
'enrollment_status' => 'enrolled',
|
||||
'enrollment_date' => $this->formatLocalDate('Y-m-d'),
|
||||
]);
|
||||
|
||||
return (int) $affected;
|
||||
}
|
||||
|
||||
public function buildPaymentEventData(
|
||||
int $invoiceId,
|
||||
string $transactionIdOrRef,
|
||||
float $amount,
|
||||
string $paymentMethod,
|
||||
string $paymentDate,
|
||||
?string $checkNumber,
|
||||
int $installmentSeq,
|
||||
float $preBalance,
|
||||
float $postBalance,
|
||||
string $schoolYear,
|
||||
string $semester
|
||||
): array {
|
||||
$invoice = Invoice::query()->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
throw new \RuntimeException("Invoice {$invoiceId} not found");
|
||||
}
|
||||
|
||||
$parentRow = (array) DB::table('users')
|
||||
->select('id', 'firstname', 'lastname', 'email')
|
||||
->where('id', $invoice->parent_id)
|
||||
->first();
|
||||
|
||||
if (empty($parentRow)) {
|
||||
throw new \RuntimeException("Parent not found for invoice {$invoiceId}");
|
||||
}
|
||||
|
||||
$studentRows = DB::table('students as s')
|
||||
->select('s.id', 's.firstname', 's.lastname', 'sc.class_section_id')
|
||||
->leftJoin('student_class as sc', 'sc.student_id', '=', 's.id')
|
||||
->where('s.parent_id', $invoice->parent_id)
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$data = [
|
||||
'user_id' => (int) $parentRow['id'],
|
||||
'email' => $parentRow['email'],
|
||||
'firstname' => $parentRow['firstname'],
|
||||
'lastname' => $parentRow['lastname'],
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'invoice_id' => $invoiceId,
|
||||
'transaction_id' => $transactionIdOrRef,
|
||||
'payment_date' => $paymentDate,
|
||||
'amount' => $amount,
|
||||
'method' => $paymentMethod,
|
||||
'check_number' => $checkNumber,
|
||||
'installment_seq' => $installmentSeq,
|
||||
'invoice_total' => (float) $invoice->total_amount,
|
||||
'pre_balance' => $preBalance,
|
||||
'post_balance' => $postBalance,
|
||||
'portalLink' => url('parent/invoices/' . $invoiceId),
|
||||
];
|
||||
|
||||
return [$data, $studentRows];
|
||||
}
|
||||
|
||||
private function parseGradeLevel(string $grade, int $gradeFee): int
|
||||
{
|
||||
if (is_numeric($grade)) {
|
||||
return (int) $grade;
|
||||
}
|
||||
$g = strtoupper(trim($grade));
|
||||
$g = preg_replace('/\\s+/', ' ', $g);
|
||||
$g = str_replace(['.', '_', '-'], ['', '', ' '], $g);
|
||||
|
||||
$kg = ['K', 'KG', 'K G', 'KINDER', 'KINDERGARTEN'];
|
||||
if (in_array($g, $kg, true)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$pk = ['PK', 'P K', 'PREK', 'PRE K', 'PRE KINDER', 'PREKINDER'];
|
||||
if (in_array($g, $pk, true)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (preg_match('/^Y(?:OUTH)?\\s*(\\d+)?$/', $g, $m)) {
|
||||
$n = isset($m[1]) && $m[1] !== '' ? max(1, (int) $m[1]) : 1;
|
||||
return $gradeFee + $n;
|
||||
}
|
||||
|
||||
if (preg_match('/^(?:GR?ADE\\s*)?(\\d{1,2})\\s*([A-Z]*)$/', $g, $m)) {
|
||||
return (int) $m[1];
|
||||
}
|
||||
|
||||
return 999;
|
||||
}
|
||||
|
||||
private function formatLocalDate(string $format): string
|
||||
{
|
||||
if (function_exists('local_date') && function_exists('utc_now')) {
|
||||
return (string) local_date(utc_now(), $format);
|
||||
}
|
||||
return now()->format($format);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Discounts;
|
||||
|
||||
use App\Models\DiscountUsage;
|
||||
use App\Models\User;
|
||||
|
||||
class DiscountParentService
|
||||
{
|
||||
public function listParentsWithDiscounts(string $schoolYear): array
|
||||
{
|
||||
$parents = User::getParents();
|
||||
|
||||
foreach ($parents as &$parent) {
|
||||
$total = DiscountUsage::getTotalDiscountByParentIdAndSchoolYear(
|
||||
(int) ($parent['id'] ?? 0),
|
||||
$schoolYear
|
||||
);
|
||||
|
||||
$parent['total_discount'] = $total;
|
||||
$parent['has_discount'] = $total > 0 ? 1 : 0;
|
||||
}
|
||||
unset($parent);
|
||||
|
||||
return $parents;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Discounts;
|
||||
|
||||
use App\Models\DiscountVoucher;
|
||||
|
||||
class DiscountVoucherService
|
||||
{
|
||||
public function listAll(): array
|
||||
{
|
||||
return DiscountVoucher::query()
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function listActive(): array
|
||||
{
|
||||
return DiscountVoucher::query()
|
||||
->where('is_active', 1)
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function create(array $payload): DiscountVoucher
|
||||
{
|
||||
return DiscountVoucher::query()->create($payload);
|
||||
}
|
||||
|
||||
public function update(int $id, array $payload): DiscountVoucher
|
||||
{
|
||||
$voucher = DiscountVoucher::query()->findOrFail($id);
|
||||
$voucher->fill($payload);
|
||||
$voucher->save();
|
||||
return $voucher;
|
||||
}
|
||||
|
||||
public function find(int $id): DiscountVoucher
|
||||
{
|
||||
return DiscountVoucher::query()->findOrFail($id);
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
DiscountVoucher::query()->whereKey($id)->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class EmailService
|
||||
{
|
||||
public function send(
|
||||
array|string $to,
|
||||
string $subject,
|
||||
string $html,
|
||||
string $fromKey = 'general',
|
||||
array $cc = [],
|
||||
array $bcc = []
|
||||
): bool
|
||||
{
|
||||
$sender = $this->resolveSender($fromKey);
|
||||
|
||||
try {
|
||||
Mail::html($html, function ($message) use ($to, $subject, $sender, $cc, $bcc) {
|
||||
$message->to($to)->subject($subject);
|
||||
if ($sender['email'] !== '') {
|
||||
$message->from($sender['email'], $sender['name']);
|
||||
}
|
||||
if (!empty($cc)) {
|
||||
$message->cc($cc);
|
||||
}
|
||||
if (!empty($bcc)) {
|
||||
$message->bcc($bcc);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('EmailService send failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveSender(string $fromKey): array
|
||||
{
|
||||
$fromKey = $fromKey !== '' ? $fromKey : 'general';
|
||||
$json = env('MAIL_SENDERS', '{}');
|
||||
$senders = json_decode($json, true);
|
||||
|
||||
if (is_array($senders) && isset($senders[$fromKey])) {
|
||||
$info = $senders[$fromKey];
|
||||
return [
|
||||
'name' => (string) ($info['name'] ?? 'Sender'),
|
||||
'email' => (string) ($info['email'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$fallbackAddress = (string) (config('mail.from.address') ?: (getenv('SMTP_USER') ?: ''));
|
||||
$fallbackName = (string) (config('mail.from.name') ?: 'Al Rahma Sunday School');
|
||||
|
||||
return [
|
||||
'name' => $fallbackName,
|
||||
'email' => $fallbackAddress,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Expenses;
|
||||
|
||||
use App\Models\Configuration;
|
||||
|
||||
class ExpenseContextService
|
||||
{
|
||||
public function getSchoolYear(): string
|
||||
{
|
||||
return (string) (Configuration::getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
public function getSemester(): string
|
||||
{
|
||||
return (string) (Configuration::getConfig('semester') ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Expenses;
|
||||
|
||||
use App\Models\Expense;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExpenseQueryService
|
||||
{
|
||||
public function listAll(): array
|
||||
{
|
||||
return $this->baseQuery()
|
||||
->orderByDesc('expenses.created_at')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
$row = $this->baseQuery()
|
||||
->where('expenses.id', $id)
|
||||
->first();
|
||||
|
||||
return $row ? (array) $row : null;
|
||||
}
|
||||
|
||||
private function baseQuery()
|
||||
{
|
||||
return Expense::query()
|
||||
->selectRaw('
|
||||
expenses.*,
|
||||
u.firstname AS purchaser_firstname,
|
||||
u.lastname AS purchaser_lastname,
|
||||
approver.firstname AS approver_firstname,
|
||||
approver.lastname AS approver_lastname
|
||||
')
|
||||
->leftJoin('users as u', 'u.id', '=', 'expenses.purchased_by')
|
||||
->leftJoin('users as approver', 'approver.id', '=', 'expenses.approved_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Expenses;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class ExpenseReceiptService
|
||||
{
|
||||
public function storeReceipt(UploadedFile $file): string
|
||||
{
|
||||
$stored = $file->store('receipts');
|
||||
return basename($stored);
|
||||
}
|
||||
|
||||
public function receiptUrl(?string $filename): ?string
|
||||
{
|
||||
if (!$filename) {
|
||||
return null;
|
||||
}
|
||||
return url('receipts/' . $filename);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Expenses;
|
||||
|
||||
class ExpenseRetailorService
|
||||
{
|
||||
public function listRetailors(): array
|
||||
{
|
||||
return [
|
||||
'Amazon',
|
||||
'Walmart',
|
||||
'Costco',
|
||||
"BJ's",
|
||||
'Market Basket',
|
||||
'Aldi',
|
||||
'Hannaford',
|
||||
"Sam's Club",
|
||||
'HomeGoods',
|
||||
'Hostinger',
|
||||
'Wicked Cheesy',
|
||||
'Shatila',
|
||||
'Brothers Pizzeria',
|
||||
'Paradise Biryani Pointe',
|
||||
'Emad Leiman',
|
||||
'Nova Trampoline Park',
|
||||
"Lubin's Awards",
|
||||
'Dollar Tree',
|
||||
'Stop & Shop',
|
||||
"Dunkin' Donuts",
|
||||
"Giovanni's Pizza",
|
||||
'Trader Joes',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Expenses;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExpenseStaffService
|
||||
{
|
||||
public function listStaffUsers(): array
|
||||
{
|
||||
$rows = DB::table('users')
|
||||
->select('users.id', 'users.firstname', 'users.lastname', 'roles.name AS role_name')
|
||||
->leftJoin('user_roles', 'user_roles.user_id', '=', 'users.id')
|
||||
->leftJoin('roles', 'roles.id', '=', 'user_roles.role_id')
|
||||
->whereNotNull('roles.name')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$excludedRoles = array_map('strtolower', ['parent', 'student', 'guest']);
|
||||
$staff = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$roleName = strtolower((string) ($row['role_name'] ?? ''));
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if ($id <= 0 || in_array($roleName, $excludedRoles, true)) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($staff[$id])) {
|
||||
$staff[$id] = [
|
||||
'id' => $id,
|
||||
'firstname' => $row['firstname'] ?? '',
|
||||
'lastname' => $row['lastname'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
uasort($staff, static function ($a, $b) {
|
||||
$nameA = trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? ''));
|
||||
$nameB = trim(($b['firstname'] ?? '') . ' ' . ($b['lastname'] ?? ''));
|
||||
return strcasecmp($nameA, $nameB);
|
||||
});
|
||||
|
||||
return array_values($staff);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Expenses;
|
||||
|
||||
use App\Models\Expense;
|
||||
|
||||
class ExpenseStatusService
|
||||
{
|
||||
public function updateStatus(Expense $expense, string $status, string $reason, int $userId): Expense
|
||||
{
|
||||
$expense->status = $status;
|
||||
$expense->status_reason = $reason;
|
||||
$expense->approved_by = $userId;
|
||||
$expense->updated_by = $userId;
|
||||
$expense->save();
|
||||
|
||||
return $expense;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Expenses;
|
||||
|
||||
use App\Models\Expense;
|
||||
|
||||
class ExpenseWriteService
|
||||
{
|
||||
public function __construct(private ExpenseContextService $context) {}
|
||||
|
||||
public function store(array $payload): Expense
|
||||
{
|
||||
$schoolYear = $payload['school_year'] ?? $this->context->getSchoolYear();
|
||||
$semester = $payload['semester'] ?? $this->context->getSemester();
|
||||
|
||||
$data = [
|
||||
'category' => $payload['category'],
|
||||
'amount' => $payload['amount'],
|
||||
'receipt_path' => $payload['receipt_path'] ?? null,
|
||||
'description' => $payload['description'] ?? null,
|
||||
'retailor' => $payload['retailor'] ?? null,
|
||||
'date_of_purchase' => $payload['date_of_purchase'],
|
||||
'purchased_by' => $payload['purchased_by'],
|
||||
'added_by' => $payload['added_by'],
|
||||
'status' => $payload['status'],
|
||||
'status_reason' => $payload['status_reason'] ?? null,
|
||||
'approved_by' => $payload['approved_by'] ?? null,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
];
|
||||
|
||||
return Expense::query()->create($data);
|
||||
}
|
||||
|
||||
public function update(Expense $expense, array $payload): Expense
|
||||
{
|
||||
$expense->fill($payload);
|
||||
$expense->save();
|
||||
return $expense;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ExtraCharges;
|
||||
|
||||
use App\Models\AdditionalCharge;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ExtraChargesChargeService
|
||||
{
|
||||
public function __construct(
|
||||
private ExtraChargesContextService $context,
|
||||
private ExtraChargesInvoiceService $invoiceService
|
||||
) {}
|
||||
|
||||
public function createCharge(array $payload): array
|
||||
{
|
||||
$invoiceId = $payload['invoice_id'] ?? null;
|
||||
$chargeType = $payload['charge_type'];
|
||||
$amountAbs = round(abs((float) $payload['amount']), 2);
|
||||
$signedAmount = $chargeType === 'add' ? $amountAbs : -$amountAbs;
|
||||
$schoolYear = $payload['school_year'] ?? $this->context->getSchoolYear();
|
||||
$semester = $payload['semester'] ?? $this->context->getSemester();
|
||||
|
||||
$data = [
|
||||
'parent_id' => (int) $payload['parent_id'],
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'charge_type' => $chargeType,
|
||||
'title' => trim($payload['title']),
|
||||
'description' => trim((string) ($payload['description'] ?? '')),
|
||||
'amount' => $signedAmount,
|
||||
'due_date' => $payload['due_date'] ?? null,
|
||||
'status' => $invoiceId ? 'applied' : 'pending',
|
||||
'created_by' => (int) ($payload['created_by'] ?? 0),
|
||||
'created_at' => $payload['created_at'] ?? $this->utcNow(),
|
||||
];
|
||||
|
||||
$invoiceBefore = $invoiceId ? Invoice::getInvoicesByParentId((int) $payload['parent_id'], $schoolYear) : [];
|
||||
$invoiceAfter = [];
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$chargeId = AdditionalCharge::query()->create($data)->id;
|
||||
|
||||
if ($invoiceId) {
|
||||
$this->invoiceService->applyToInvoice((int) $invoiceId, $chargeType, $amountAbs);
|
||||
}
|
||||
|
||||
$invoiceAfter = $invoiceId ? Invoice::getInvoicesByParentId((int) $payload['parent_id'], $schoolYear) : [];
|
||||
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('Extra charge create failed: ' . $e->getMessage());
|
||||
return ['ok' => false, 'message' => 'Failed to save charge.'];
|
||||
}
|
||||
|
||||
$parentUser = User::getUserInfoById((int) $payload['parent_id']);
|
||||
$this->triggerExtraChargeEvent($parentUser, $data, (int) $chargeId, $invoiceId, $invoiceBefore, $invoiceAfter);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'id' => (int) $chargeId,
|
||||
'invoice_id' => $invoiceId ? (int) $invoiceId : null,
|
||||
'parent_id' => (int) $payload['parent_id'],
|
||||
];
|
||||
}
|
||||
|
||||
public function updateCharge(AdditionalCharge $charge, array $payload): bool
|
||||
{
|
||||
$chargeType = $payload['charge_type'] ?? $charge->charge_type ?? 'add';
|
||||
$amountRaw = isset($payload['amount']) ? (float) $payload['amount'] : (float) $charge->amount;
|
||||
$newAmount = $chargeType === 'add' ? abs($amountRaw) : -abs($amountRaw);
|
||||
$delta = $newAmount - (float) $charge->amount;
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$charge->title = trim($payload['title'] ?? $charge->title);
|
||||
$charge->description = trim($payload['description'] ?? $charge->description);
|
||||
$charge->amount = $newAmount;
|
||||
$charge->due_date = $payload['due_date'] ?? $charge->due_date;
|
||||
$charge->charge_type = $chargeType;
|
||||
if (array_key_exists('updated_by', $payload)) {
|
||||
$charge->updated_by = $payload['updated_by'];
|
||||
}
|
||||
$charge->save();
|
||||
|
||||
if ($charge->status === 'applied' && !empty($charge->invoice_id) && abs($delta) > 0.00001) {
|
||||
$this->invoiceService->adjustDelta((int) $charge->invoice_id, $delta);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('Extra charge update failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function voidCharge(AdditionalCharge $charge): bool
|
||||
{
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$invoiceId = (int) ($charge->invoice_id ?? 0);
|
||||
$amountAbs = round(abs((float) ($charge->amount ?? 0)), 2);
|
||||
$chargeType = (string) ($charge->charge_type ?? 'add');
|
||||
$status = (string) ($charge->status ?? 'pending');
|
||||
|
||||
if ($status === 'applied' && $invoiceId > 0 && $amountAbs > 0) {
|
||||
$this->invoiceService->reverseOnInvoice($invoiceId, $chargeType, $amountAbs);
|
||||
}
|
||||
|
||||
$charge->status = 'void';
|
||||
$charge->save();
|
||||
|
||||
DB::commit();
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('Extra charge void failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function reverseCharge(AdditionalCharge $charge): bool
|
||||
{
|
||||
$invoiceId = (int) ($charge->invoice_id ?? 0);
|
||||
$amountAbs = round(abs((float) ($charge->amount ?? 0)), 2);
|
||||
$chargeType = (string) ($charge->charge_type ?? 'add');
|
||||
$status = (string) ($charge->status ?? 'pending');
|
||||
|
||||
if ($status !== 'applied' || $invoiceId <= 0 || $amountAbs <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$this->invoiceService->reverseOnInvoice($invoiceId, $chargeType, $amountAbs);
|
||||
|
||||
$charge->status = 'pending';
|
||||
$charge->invoice_id = null;
|
||||
$charge->save();
|
||||
|
||||
DB::commit();
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('Extra charge reverse failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function triggerExtraChargeEvent(?array $parentUser, array $payload, int $chargeId, ?int $invoiceId, array $invoiceBefore, array $invoiceAfter): void
|
||||
{
|
||||
if (!$parentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
$first = $parentUser['first_name'] ?? $parentUser['firstname'] ?? '';
|
||||
$last = $parentUser['last_name'] ?? $parentUser['lastname'] ?? '';
|
||||
$parentName = trim($first . ' ' . $last);
|
||||
|
||||
$before = $this->normalizeRow($invoiceBefore);
|
||||
$after = $this->normalizeRow($invoiceAfter);
|
||||
|
||||
$invoiceTotal = $after ? (float) ($after['total_amount'] ?? 0) : 0.0;
|
||||
$preBalance = $before ? (float) ($before['balance'] ?? 0) : 0.0;
|
||||
$postBalance = $after ? (float) ($after['balance'] ?? 0) : 0.0;
|
||||
|
||||
$eventData = [
|
||||
'user_id' => (int) ($parentUser['id'] ?? 0),
|
||||
'email' => $parentUser['email'] ?? null,
|
||||
'firstname' => $first,
|
||||
'lastname' => $last,
|
||||
'parentName' => $parentName,
|
||||
'school_year' => $payload['school_year'],
|
||||
'semester' => $payload['semester'],
|
||||
'invoice_id' => $invoiceId,
|
||||
'invoice_number' => $after['invoice_number'] ?? ($invoiceId ? 'INV-' . $invoiceId : null),
|
||||
'charge_id' => $chargeId,
|
||||
'charge_title' => $payload['title'],
|
||||
'charge_desc' => $payload['description'],
|
||||
'charge_type' => $payload['charge_type'],
|
||||
'amount_signed' => $payload['amount'],
|
||||
'amount_abs' => abs((float) $payload['amount']),
|
||||
'due_date' => $payload['due_date'],
|
||||
'created_at' => $payload['created_at'],
|
||||
'pre_balance' => $preBalance,
|
||||
'post_balance' => $postBalance,
|
||||
'invoice_total' => $invoiceTotal,
|
||||
'portal_link' => url('/login'),
|
||||
'invoice_link' => $invoiceId ? url('parent/invoices/view/' . $invoiceId) : url('parent/invoices'),
|
||||
];
|
||||
|
||||
$students = [];
|
||||
|
||||
try {
|
||||
if (class_exists(\CodeIgniter\Events\Events::class)) {
|
||||
\CodeIgniter\Events\Events::trigger('extraCharge', $eventData, $students);
|
||||
} elseif (function_exists('event')) {
|
||||
event('extraCharge', [$eventData, $students]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('extraCharge event failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeRow($row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if (is_array($row) && isset($row[0]) && is_array($row[0])) {
|
||||
return $row[0];
|
||||
}
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private function utcNow(): string
|
||||
{
|
||||
if (function_exists('utc_now')) {
|
||||
return (string) utc_now();
|
||||
}
|
||||
return now('UTC')->toDateTimeString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ExtraCharges;
|
||||
|
||||
use App\Models\Configuration;
|
||||
|
||||
class ExtraChargesContextService
|
||||
{
|
||||
public function getSchoolYear(): string
|
||||
{
|
||||
return (string) (Configuration::getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
public function getSemester(): string
|
||||
{
|
||||
return (string) (Configuration::getConfig('semester') ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ExtraCharges;
|
||||
|
||||
use App\Models\Invoice;
|
||||
|
||||
class ExtraChargesInvoiceService
|
||||
{
|
||||
public function listInvoicesForParent(int $parentId, string $schoolYear): array
|
||||
{
|
||||
if ($parentId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = Invoice::getInvoicesByParentId($parentId, $schoolYear) ?? [];
|
||||
|
||||
return array_map(function ($inv) {
|
||||
$id = (int) ($inv['id'] ?? 0);
|
||||
$num = (string) ($inv['invoice_number'] ?? ('INV-' . $id));
|
||||
$status = strtolower((string) ($inv['status'] ?? ''));
|
||||
$balance = (float) ($inv['balance'] ?? 0);
|
||||
$issue = $inv['issue_date'] ?? null;
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'invoice_number' => $num,
|
||||
'status' => $status,
|
||||
'balance' => $balance,
|
||||
'issue_date' => $issue,
|
||||
'text' => $num
|
||||
. ($status ? ' — ' . ucfirst($status) : '')
|
||||
. ' (Bal: $' . number_format($balance, 2) . ')',
|
||||
];
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
public function applyToInvoice(int $invoiceId, string $chargeType, float $amountAbs): void
|
||||
{
|
||||
if ($invoiceId <= 0 || $amountAbs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($chargeType === 'add') {
|
||||
Invoice::applyAdditionalCharge($invoiceId, $amountAbs);
|
||||
} else {
|
||||
Invoice::deductAdditionalCharge($invoiceId, $amountAbs);
|
||||
}
|
||||
}
|
||||
|
||||
public function reverseOnInvoice(int $invoiceId, string $chargeType, float $amountAbs): void
|
||||
{
|
||||
if ($invoiceId <= 0 || $amountAbs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($chargeType === 'add') {
|
||||
Invoice::reverseAdditionalCharge($invoiceId, $amountAbs);
|
||||
} else {
|
||||
Invoice::applyAdditionalCharge($invoiceId, $amountAbs);
|
||||
}
|
||||
}
|
||||
|
||||
public function adjustDelta(int $invoiceId, float $delta): void
|
||||
{
|
||||
if ($invoiceId <= 0 || abs($delta) <= 0.00001) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($delta > 0) {
|
||||
Invoice::applyAdditionalCharge($invoiceId, $delta);
|
||||
} else {
|
||||
Invoice::reverseAdditionalCharge($invoiceId, abs($delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ExtraCharges;
|
||||
|
||||
use App\Models\AdditionalCharge;
|
||||
|
||||
class ExtraChargesListService
|
||||
{
|
||||
public function listForTerm(string $schoolYear, string $semester, ?string $status, ?string $q, int $perPage): array
|
||||
{
|
||||
$rows = AdditionalCharge::listAllForTerm($schoolYear, $semester, $status, $q, $perPage);
|
||||
$meta = [
|
||||
'perPage' => method_exists($rows, 'perPage') ? $rows->perPage() : $perPage,
|
||||
'page' => method_exists($rows, 'currentPage') ? $rows->currentPage() : null,
|
||||
'total' => method_exists($rows, 'total') ? $rows->total() : null,
|
||||
'pageCount' => method_exists($rows, 'lastPage') ? $rows->lastPage() : null,
|
||||
];
|
||||
|
||||
$items = method_exists($rows, 'items') ? $rows->items() : (array) $rows;
|
||||
|
||||
return [$items, $meta];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ExtraCharges;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExtraChargesMetaService
|
||||
{
|
||||
public function getSchoolYears(string $fallbackYear = ''): array
|
||||
{
|
||||
$schoolYears = [];
|
||||
|
||||
try {
|
||||
$rows = DB::table('additional_charges')
|
||||
->selectRaw('DISTINCT school_year')
|
||||
->whereNotNull('school_year')
|
||||
->orderByDesc('school_year')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$val = (string) ($row['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYears, true)) {
|
||||
$schoolYears[] = $val;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = DB::table('invoices')
|
||||
->selectRaw('DISTINCT school_year')
|
||||
->whereNotNull('school_year')
|
||||
->orderByDesc('school_year')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$val = (string) ($row['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYears, true)) {
|
||||
$schoolYears[] = $val;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
if (empty($schoolYears) && $fallbackYear !== '') {
|
||||
$schoolYears[] = $fallbackYear;
|
||||
if (str_contains($fallbackYear, '-')) {
|
||||
[$start] = explode('-', $fallbackYear) + [0 => ''];
|
||||
$start = (int) $start;
|
||||
if ($start > 0) {
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$schoolYears[] = ($start - $i) . '-' . (($start - $i) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rsort($schoolYears);
|
||||
|
||||
return array_values(array_unique($schoolYears));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ExtraCharges;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExtraChargesParentService
|
||||
{
|
||||
public function searchParents(string $q = '', int $limit = 20): array
|
||||
{
|
||||
$builder = DB::table('users')
|
||||
->select('users.id', 'users.firstname', 'users.lastname', 'users.email')
|
||||
->join('user_roles', 'user_roles.user_id', '=', 'users.id')
|
||||
->join('roles', 'roles.id', '=', 'user_roles.role_id')
|
||||
->where('roles.name', 'parent')
|
||||
->whereNull('user_roles.deleted_at');
|
||||
|
||||
if ($q !== '') {
|
||||
$builder->where(function ($query) use ($q) {
|
||||
$query->where('users.firstname', 'like', "%{$q}%")
|
||||
->orWhere('users.lastname', 'like', "%{$q}%")
|
||||
->orWhere('users.email', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
$rows = $builder
|
||||
->orderBy('users.lastname', 'ASC')
|
||||
->orderBy('users.firstname', 'ASC')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
return array_map(function ($row) {
|
||||
$name = trim(($row['lastname'] ?? '') . ', ' . ($row['firstname'] ?? ''));
|
||||
$email = $row['email'] ?? '';
|
||||
$label = $name !== ',' ? $name : ('User #' . $row['id']);
|
||||
if ($email) {
|
||||
$label .= ' — ' . $email;
|
||||
}
|
||||
$label .= ' (ID: ' . $row['id'] . ')';
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'text' => $label,
|
||||
];
|
||||
}, $rows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Files;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExamDraftDownloadNameService
|
||||
{
|
||||
public function build(string $filename, string $subdir): string
|
||||
{
|
||||
$column = $subdir === 'finals' ? 'final_file' : 'teacher_file';
|
||||
|
||||
$row = DB::table('exam_drafts as ed')
|
||||
->select('ed.version', 'ed.exam_type', 'ed.class_section_id', 'cs.class_section_name')
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ed.class_section_id')
|
||||
->where('ed.' . $column, $filename)
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
$row = $row ? (array) $row : [];
|
||||
|
||||
$classLabel = trim((string) ($row['class_section_name'] ?? ('Class' . ($row['class_section_id'] ?? '0'))));
|
||||
$typeLabel = trim((string) ($row['exam_type'] ?? 'Exam'));
|
||||
$version = 'v' . max(1, (int) ($row['version'] ?? 1));
|
||||
|
||||
$parts = array_filter([
|
||||
$this->slugify($classLabel),
|
||||
$this->slugify($typeLabel),
|
||||
$version,
|
||||
]);
|
||||
|
||||
return implode('_', $parts);
|
||||
}
|
||||
|
||||
private function slugify(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
$value = preg_replace('/[^\p{L}\p{N}]+/u', '_', $value);
|
||||
$value = trim($value, '_');
|
||||
if ($value === '') {
|
||||
return 'Exam';
|
||||
}
|
||||
return mb_strtolower($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Files;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class FileServeService
|
||||
{
|
||||
public function meta(string $baseDir, string $name, array $allowedExtensions, ?string $downloadName = null): array
|
||||
{
|
||||
return $this->resolveFile($baseDir, $name, $allowedExtensions, $downloadName);
|
||||
}
|
||||
|
||||
public function serveInline(
|
||||
string $baseDir,
|
||||
string $name,
|
||||
array $allowedExtensions,
|
||||
Request $request,
|
||||
?string $downloadName = null,
|
||||
bool $nosniff = false
|
||||
): Response {
|
||||
$meta = $this->resolveFile($baseDir, $name, $allowedExtensions, $downloadName);
|
||||
|
||||
$ifNoneMatch = trim((string) $request->headers->get('If-None-Match', ''), '"');
|
||||
$ifModifiedSince = (string) $request->headers->get('If-Modified-Since', '');
|
||||
$imsTime = $ifModifiedSince !== '' ? strtotime($ifModifiedSince) : false;
|
||||
|
||||
if (($ifNoneMatch !== '' && $ifNoneMatch === $meta['etag']) ||
|
||||
($imsTime !== false && $imsTime >= $meta['mtime'])
|
||||
) {
|
||||
return response('', 304, $this->notModifiedHeaders($meta));
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => $meta['mime'],
|
||||
'Content-Disposition' => 'inline; filename="' . $meta['download_name'] . '"',
|
||||
'Content-Length' => (string) $meta['size'],
|
||||
'ETag' => $meta['etag'],
|
||||
'Last-Modified' => $meta['last_modified'],
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
];
|
||||
|
||||
if ($nosniff) {
|
||||
$headers['X-Content-Type-Options'] = 'nosniff';
|
||||
}
|
||||
|
||||
return response(file_get_contents($meta['path']), 200, $headers);
|
||||
}
|
||||
|
||||
private function resolveFile(string $baseDir, string $name, array $allowedExtensions, ?string $downloadName): array
|
||||
{
|
||||
if ($name !== basename($name)) {
|
||||
throw new HttpException(400, 'Invalid filename');
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowedExtensions, true)) {
|
||||
throw new HttpException(404, 'File not found');
|
||||
}
|
||||
|
||||
$path = rtrim($baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name;
|
||||
if (!is_file($path)) {
|
||||
throw new HttpException(404, 'File not found');
|
||||
}
|
||||
|
||||
$mime = $this->detectMime($path);
|
||||
$mtime = filemtime($path) ?: time();
|
||||
$size = filesize($path) ?: 0;
|
||||
$etag = md5($name . '|' . $mtime . '|' . $size);
|
||||
$downloadName = $downloadName ? $downloadName . '.' . $ext : $name;
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'path' => $path,
|
||||
'mime' => $mime,
|
||||
'mtime' => $mtime,
|
||||
'size' => $size,
|
||||
'etag' => $etag,
|
||||
'download_name' => $downloadName,
|
||||
'last_modified' => gmdate('D, d M Y H:i:s', $mtime) . ' GMT',
|
||||
];
|
||||
}
|
||||
|
||||
private function detectMime(string $path): string
|
||||
{
|
||||
$mime = 'application/octet-stream';
|
||||
if (function_exists('finfo_open')) {
|
||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($fi) {
|
||||
$detected = finfo_file($fi, $path);
|
||||
if ($detected) {
|
||||
$mime = $detected;
|
||||
}
|
||||
finfo_close($fi);
|
||||
}
|
||||
} elseif (function_exists('mime_content_type')) {
|
||||
$mime = mime_content_type($path) ?: $mime;
|
||||
}
|
||||
|
||||
return $mime;
|
||||
}
|
||||
|
||||
private function notModifiedHeaders(array $meta): array
|
||||
{
|
||||
return [
|
||||
'ETag' => $meta['etag'],
|
||||
'Last-Modified' => $meta['last_modified'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
class FinancialChartService
|
||||
{
|
||||
public function generateBarChart(array $summary): ?string
|
||||
{
|
||||
if ($this->skipRemoteCharts()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$chartData = [
|
||||
'type' => 'bar',
|
||||
'data' => [
|
||||
'labels' => ['Charges', 'Paid', 'Unpaid', 'Discounts', 'Refunds', 'Expenses', 'Reimbursements', 'Net'],
|
||||
'datasets' => [[
|
||||
'label' => 'Amount (USD)',
|
||||
'data' => [
|
||||
$summary['totalCharges'] ?? 0,
|
||||
$summary['amountCollected'] ?? 0,
|
||||
$summary['totalUnpaid'] ?? 0,
|
||||
$summary['totalDiscounts'] ?? 0,
|
||||
$summary['totalRefunds'] ?? 0,
|
||||
$summary['totalExpenses'] ?? 0,
|
||||
$summary['totalReimbursements'] ?? 0,
|
||||
$summary['netAmount'] ?? 0,
|
||||
],
|
||||
'backgroundColor' => 'rgba(54, 162, 235, 0.6)',
|
||||
]],
|
||||
],
|
||||
];
|
||||
|
||||
return $this->downloadChart($chartData, 'bar_chart.png');
|
||||
}
|
||||
|
||||
public function generatePieChart(array $summary): ?string
|
||||
{
|
||||
if ($this->skipRemoteCharts()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$chartData = [
|
||||
'type' => 'pie',
|
||||
'data' => [
|
||||
'labels' => ['Expenses', 'Reimbursements'],
|
||||
'datasets' => [[
|
||||
'data' => [
|
||||
$summary['totalExpenses'] ?? 0,
|
||||
$summary['totalReimbursements'] ?? 0,
|
||||
],
|
||||
'backgroundColor' => ['#e74c3c', '#8e44ad'],
|
||||
]],
|
||||
],
|
||||
];
|
||||
|
||||
return $this->downloadChart($chartData, 'pie_chart.png');
|
||||
}
|
||||
|
||||
private function downloadChart(array $chartData, string $filename): ?string
|
||||
{
|
||||
$baseUrl = 'https://quickchart.io/chart?c=';
|
||||
$url = $baseUrl . urlencode(json_encode($chartData));
|
||||
$targetDir = storage_path('app/reports');
|
||||
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$contents = @file_get_contents($url);
|
||||
if ($contents === false) {
|
||||
return null;
|
||||
}
|
||||
$path = $targetDir . DIRECTORY_SEPARATOR . $filename;
|
||||
file_put_contents($path, $contents);
|
||||
return $path;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function skipRemoteCharts(): bool
|
||||
{
|
||||
if (app()->environment('testing')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) config('services.quickchart.disabled', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
class FinancialDonationRecipientService
|
||||
{
|
||||
public const SPECIAL_RECIPIENTS = [
|
||||
990001 => 'Masjid',
|
||||
990002 => 'Donation',
|
||||
];
|
||||
|
||||
public function recipientIds(): array
|
||||
{
|
||||
return array_map('intval', array_keys(self::SPECIAL_RECIPIENTS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class FinancialPaymentService
|
||||
{
|
||||
public function paymentsByInvoice(?string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
$query = $this->applyPaymentFilters($this->buildPaymentQuery($schoolYear, $dateFrom, $dateTo));
|
||||
|
||||
return $query
|
||||
->selectRaw('invoice_id, SUM(paid_amount) AS paid_amount')
|
||||
->whereNotNull('invoice_id')
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
|
||||
public function paymentBreakdown(?string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
$query = $this->applyPaymentFilters($this->buildPaymentQuery($schoolYear, $dateFrom, $dateTo));
|
||||
|
||||
$rows = $query
|
||||
->selectRaw("
|
||||
invoice_id,
|
||||
CASE
|
||||
WHEN LOWER(TRIM(payment_method)) = 'cash' THEN 'cash'
|
||||
WHEN LOWER(TRIM(payment_method)) IN ('check','cheque') THEN 'check'
|
||||
WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN 'credit'
|
||||
ELSE 'other'
|
||||
END AS method,
|
||||
SUM(paid_amount) AS amount
|
||||
")
|
||||
->whereNotNull('invoice_id')
|
||||
->groupBy('invoice_id', 'method')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$breakdown = [];
|
||||
foreach ($rows as $row) {
|
||||
$invoiceId = (int) ($row['invoice_id'] ?? 0);
|
||||
if ($invoiceId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$method = (string) ($row['method'] ?? '');
|
||||
$amount = (float) ($row['amount'] ?? 0);
|
||||
if (!isset($breakdown[$invoiceId])) {
|
||||
$breakdown[$invoiceId] = [];
|
||||
}
|
||||
$breakdown[$invoiceId][$method] = $amount;
|
||||
}
|
||||
|
||||
return $breakdown;
|
||||
}
|
||||
|
||||
public function paymentTotals(?string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
$query = $this->applyPaymentFilters($this->buildPaymentQuery($schoolYear, $dateFrom, $dateTo));
|
||||
|
||||
$row = (array) $query
|
||||
->selectRaw("
|
||||
SUM(paid_amount) AS total_all,
|
||||
SUM(CASE WHEN LOWER(TRIM(payment_method)) = 'cash' THEN paid_amount ELSE 0 END) AS total_cash,
|
||||
SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('check','cheque') THEN paid_amount ELSE 0 END) AS total_check,
|
||||
SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN paid_amount ELSE 0 END) AS total_credit
|
||||
")
|
||||
->whereNotNull('invoice_id')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'total_all' => (float) ($row['total_all'] ?? 0),
|
||||
'total_cash' => (float) ($row['total_cash'] ?? 0),
|
||||
'total_check' => (float) ($row['total_check'] ?? 0),
|
||||
'total_credit' => (float) ($row['total_credit'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildPaymentQuery(?string $schoolYear, ?string $dateFrom, ?string $dateTo): Builder
|
||||
{
|
||||
$query = DB::table('payments');
|
||||
|
||||
if (!empty($schoolYear)) {
|
||||
$query->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(payment_date) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(payment_date) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function applyPaymentFilters(Builder $query): Builder
|
||||
{
|
||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
|
||||
if (Schema::hasColumn('payments', 'status')) {
|
||||
$query->where(function ($q) use ($exclude) {
|
||||
$q->whereNotIn('status', $exclude)->orWhereNull('status');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('payments', 'is_void')) {
|
||||
$query->where(function ($q) {
|
||||
$q->where('is_void', 0)->orWhereNull('is_void');
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
class FinancialPdfReportService
|
||||
{
|
||||
public function __construct(private FinancialChartService $charts)
|
||||
{
|
||||
}
|
||||
|
||||
public function buildPdf(array $summary): string
|
||||
{
|
||||
$pdf = new \FPDF();
|
||||
$pdf->AddPage();
|
||||
$pdf->SetFont('Arial', 'B', 16);
|
||||
$pdf->Cell(0, 10, 'Financial Report for School Year: ' . ($summary['schoolYear'] ?? ''), 0, 1, 'C');
|
||||
|
||||
$pdf->Ln(10);
|
||||
$pdf->SetFont('Arial', 'B', 12);
|
||||
$pdf->Cell(130, 10, 'Description', 1);
|
||||
$pdf->Cell(60, 10, 'Amount (USD)', 1);
|
||||
$pdf->Ln();
|
||||
|
||||
$rows = [
|
||||
'Total Charges' => $summary['totalCharges'] ?? 0,
|
||||
'Total Extra Charges' => $summary['totalExtraCharges'] ?? 0,
|
||||
'Total Discounts' => $summary['totalDiscounts'] ?? 0,
|
||||
'Total Refunds' => $summary['totalRefunds'] ?? 0,
|
||||
'Total Expenses' => $summary['totalExpenses'] ?? 0,
|
||||
'Total Reimbursements' => $summary['totalReimbursements'] ?? 0,
|
||||
'Donation to School (Masjid/Donation reimbursements)' => $summary['donationToSchool'] ?? 0,
|
||||
'Net Amount (Earned Income)' => $summary['netAmount'] ?? 0,
|
||||
'Amount Collected (Paid)' => $summary['amountCollected'] ?? 0,
|
||||
'Amount Unpaid (Outstanding)' => $summary['totalUnpaid'] ?? 0,
|
||||
];
|
||||
|
||||
$pdf->SetFont('Arial', '', 11);
|
||||
foreach ($rows as $label => $amount) {
|
||||
$pdf->Cell(130, 10, $label, 1);
|
||||
$pdf->Cell(60, 10, '$' . number_format((float) $amount, 2), 1);
|
||||
$pdf->Ln();
|
||||
}
|
||||
|
||||
$barChart = $this->charts->generateBarChart($summary);
|
||||
if (!empty($barChart) && file_exists($barChart)) {
|
||||
$pdf->Ln(10);
|
||||
$pdf->SetFont('Arial', 'B', 12);
|
||||
$pdf->Cell(0, 10, 'Summary Graph', 0, 1);
|
||||
$pdf->Image($barChart, null, null, 180);
|
||||
}
|
||||
|
||||
$pieChart = $this->charts->generatePieChart($summary);
|
||||
if (!empty($pieChart) && file_exists($pieChart)) {
|
||||
$pdf->Ln(10);
|
||||
$pdf->SetFont('Arial', 'B', 12);
|
||||
$pdf->Cell(0, 10, 'Expense Breakdown', 0, 1);
|
||||
$pdf->Image($pieChart, null, null, 120);
|
||||
}
|
||||
|
||||
return $pdf->Output('S');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FinancialReportService
|
||||
{
|
||||
public function __construct(
|
||||
private FinancialPaymentService $payments,
|
||||
private FinancialSchoolYearService $schoolYears
|
||||
) {
|
||||
}
|
||||
|
||||
public function getReport(?string $dateFrom, ?string $dateTo, ?string $schoolYear): array
|
||||
{
|
||||
$invoiceQuery = DB::table('invoices')
|
||||
->selectRaw("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
|
||||
->join('users', 'users.id', '=', 'invoices.parent_id');
|
||||
|
||||
$refundQuery = DB::table('refunds');
|
||||
$discountQuery = DB::table('discount_usages');
|
||||
$expenseQuery = DB::table('expenses');
|
||||
$reimbQuery = DB::table('reimbursements');
|
||||
|
||||
if (!empty($schoolYear)) {
|
||||
$invoiceQuery->where('invoices.school_year', $schoolYear);
|
||||
$refundQuery->where('school_year', $schoolYear);
|
||||
$discountQuery->where('school_year', $schoolYear);
|
||||
$expenseQuery->where('school_year', $schoolYear);
|
||||
$reimbQuery->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$invoiceQuery->whereRaw('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >= ?', [$dateFrom]);
|
||||
$refundQuery->whereRaw('DATE(COALESCE(refunded_at, created_at)) >= ?', [$dateFrom]);
|
||||
$discountQuery->whereRaw('DATE(COALESCE(used_at, created_at)) >= ?', [$dateFrom]);
|
||||
$expenseQuery->whereRaw('DATE(created_at) >= ?', [$dateFrom]);
|
||||
$reimbQuery->whereRaw('DATE(created_at) >= ?', [$dateFrom]);
|
||||
}
|
||||
|
||||
if (!empty($dateTo)) {
|
||||
$invoiceQuery->whereRaw('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <= ?', [$dateTo]);
|
||||
$refundQuery->whereRaw('DATE(COALESCE(refunded_at, created_at)) <= ?', [$dateTo]);
|
||||
$discountQuery->whereRaw('DATE(COALESCE(used_at, created_at)) <= ?', [$dateTo]);
|
||||
$expenseQuery->whereRaw('DATE(created_at) <= ?', [$dateTo]);
|
||||
$reimbQuery->whereRaw('DATE(created_at) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$invoices = $invoiceQuery->get()->map(fn ($row) => (array) $row)->all();
|
||||
|
||||
$payments = $this->payments->paymentsByInvoice($schoolYear, $dateFrom, $dateTo);
|
||||
$paymentBreakdown = $this->payments->paymentBreakdown($schoolYear, $dateFrom, $dateTo);
|
||||
$paymentTotals = $this->payments->paymentTotals($schoolYear, $dateFrom, $dateTo);
|
||||
|
||||
$refunds = $refundQuery
|
||||
->selectRaw('invoice_id, school_year, SUM(refund_paid_amount) AS total_refunded')
|
||||
->whereNotNull('invoice_id')
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->groupBy('invoice_id', 'school_year')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$discounts = $discountQuery
|
||||
->selectRaw('invoice_id, school_year, SUM(discount_amount) AS discount_amount')
|
||||
->whereNotNull('invoice_id')
|
||||
->groupBy('invoice_id', 'school_year')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$expenses = $expenseQuery
|
||||
->selectRaw('category, SUM(amount) AS total_amount')
|
||||
->groupBy('category')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$reimbursements = $reimbQuery
|
||||
->selectRaw('status, SUM(amount) AS total_amount')
|
||||
->groupBy('status')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$schoolYears = $this->schoolYears->listYears($schoolYear);
|
||||
|
||||
return [
|
||||
'selectedYear' => $schoolYear,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'schoolYears' => $schoolYears,
|
||||
'invoices' => $invoices,
|
||||
'payments' => $payments,
|
||||
'paymentBreakdown' => $paymentBreakdown,
|
||||
'paymentTotals' => $paymentTotals,
|
||||
'refunds' => $refunds,
|
||||
'expenses' => $expenses,
|
||||
'reimbursements' => $reimbursements,
|
||||
'discounts' => $discounts,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FinancialSchoolYearService
|
||||
{
|
||||
public function listYears(?string $fallbackYear = null): array
|
||||
{
|
||||
$years = DB::table('invoices')
|
||||
->select('school_year')
|
||||
->whereNotNull('school_year')
|
||||
->distinct()
|
||||
->orderBy('school_year', 'DESC')
|
||||
->pluck('school_year')
|
||||
->map(static fn ($val) => (string) $val)
|
||||
->filter(static fn ($val) => $val !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (empty($years) && !empty($fallbackYear)) {
|
||||
$years[] = (string) $fallbackYear;
|
||||
}
|
||||
|
||||
return $years;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class FinancialSummaryService
|
||||
{
|
||||
public function __construct(
|
||||
private FinancialDonationRecipientService $donationRecipients
|
||||
) {
|
||||
}
|
||||
|
||||
public function getSummary(?string $dateFrom, ?string $dateTo, ?string $schoolYear): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?: (Configuration::getConfig('school_year') ?? date('Y'));
|
||||
$derivedDateFrom = null;
|
||||
$derivedDateTo = null;
|
||||
|
||||
if (!empty($schoolYear) && empty($dateFrom) && empty($dateTo)) {
|
||||
if (preg_match('/^(\\d{4})[^\\d]?(\\d{4})$/', $schoolYear, $m)) {
|
||||
$derivedDateFrom = $m[1] . '-01-01';
|
||||
$derivedDateTo = $m[2] . '-12-31';
|
||||
}
|
||||
}
|
||||
|
||||
$invoiceDateFrom = $dateFrom ?: $derivedDateFrom;
|
||||
$invoiceDateTo = $dateTo ?: $derivedDateTo;
|
||||
|
||||
$invoiceQuery = DB::table('invoices')->where('school_year', $schoolYear);
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
$invoiceQuery->whereRaw('DATE(COALESCE(issue_date, created_at)) >= ?', [$invoiceDateFrom]);
|
||||
}
|
||||
if (!empty($invoiceDateTo)) {
|
||||
$invoiceQuery->whereRaw('DATE(COALESCE(issue_date, created_at)) <= ?', [$invoiceDateTo]);
|
||||
}
|
||||
$invoices = $invoiceQuery->get()->map(fn ($row) => (array) $row)->all();
|
||||
$totalCharges = array_sum(array_map(static fn ($row) => (float) ($row['total_amount'] ?? 0), $invoices));
|
||||
|
||||
$invoiceIds = array_values(array_unique(array_filter(array_map(static function ($row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
return $id > 0 ? $id : null;
|
||||
}, $invoices))));
|
||||
|
||||
$totalExtraCharges = $this->sumAdditionalCharges($schoolYear, $dateFrom, $dateTo);
|
||||
$extraByParent = $this->extraChargesByParent($schoolYear, $dateFrom, $dateTo);
|
||||
$extraChargesUnapplied = array_sum($extraByParent);
|
||||
$totalCharges += $extraChargesUnapplied;
|
||||
|
||||
$totalPaid = $this->sumPayments($schoolYear, $invoiceDateFrom, $invoiceDateTo);
|
||||
|
||||
$paidByInvoice = [];
|
||||
$discountByInvoice = [];
|
||||
$refundByInvoice = [];
|
||||
if (!empty($invoiceIds)) {
|
||||
$paidByInvoice = $this->paymentsByInvoiceIds($invoiceIds, $schoolYear, $invoiceDateFrom, $invoiceDateTo);
|
||||
$discountByInvoice = $this->discountsByInvoiceIds($invoiceIds, $dateFrom, $dateTo);
|
||||
$refundByInvoice = $this->refundsByInvoiceIds($invoiceIds, $dateFrom, $dateTo);
|
||||
}
|
||||
|
||||
$totalExpenses = $this->sumExpenses($schoolYear, $invoiceDateFrom, $invoiceDateTo);
|
||||
$totalReimbursements = $this->sumReimbursements($schoolYear, $invoiceDateFrom, $invoiceDateTo);
|
||||
|
||||
$donationToSchool = $this->sumDonationToSchool($schoolYear, $invoiceDateFrom, $invoiceDateTo);
|
||||
$totalReimbursements = max(0.0, $totalReimbursements - $donationToSchool['donationReimb']);
|
||||
|
||||
$totalRefunds = $this->sumRefunds($schoolYear, $dateFrom, $dateTo);
|
||||
$totalDiscounts = $this->sumDiscounts($schoolYear, $dateFrom, $dateTo);
|
||||
|
||||
$totalUnpaid = $this->sumUnpaidBalances($invoices, $paidByInvoice, $discountByInvoice, $refundByInvoice, $extraByParent);
|
||||
|
||||
$amountCollected = $totalPaid;
|
||||
$netAmount = $totalCharges - $totalDiscounts - $totalRefunds;
|
||||
|
||||
return [
|
||||
'schoolYear' => $schoolYear,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'totalCharges' => $totalCharges,
|
||||
'totalExtraCharges' => $totalExtraCharges,
|
||||
'totalDiscounts' => $totalDiscounts,
|
||||
'totalRefunds' => $totalRefunds,
|
||||
'totalExpenses' => $totalExpenses,
|
||||
'totalReimbursements' => $totalReimbursements,
|
||||
'donationToSchool' => $donationToSchool['donationToSchool'],
|
||||
'totalPaid' => $totalPaid,
|
||||
'amountCollected' => $amountCollected,
|
||||
'totalUnpaid' => $totalUnpaid,
|
||||
'netAmount' => $netAmount,
|
||||
];
|
||||
}
|
||||
|
||||
private function sumAdditionalCharges(string $schoolYear, ?string $dateFrom, ?string $dateTo): float
|
||||
{
|
||||
$query = DB::table('additional_charges as ac')
|
||||
->selectRaw('COALESCE(SUM(ac.amount), 0) AS amount')
|
||||
->where('ac.school_year', $schoolYear)
|
||||
->where('ac.status !=', 'void');
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(ac.due_date) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(ac.due_date) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$row = (array) $query->first();
|
||||
return (float) ($row['amount'] ?? 0);
|
||||
}
|
||||
|
||||
private function extraChargesByParent(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
$query = DB::table('additional_charges as ac')
|
||||
->selectRaw('ac.parent_id, COALESCE(SUM(ac.amount), 0) AS amount')
|
||||
->where('ac.school_year', $schoolYear)
|
||||
->where('ac.status !=', 'void')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('ac.invoice_id')
|
||||
->orWhere('ac.invoice_id', 0)
|
||||
->orWhere('ac.status !=', 'applied');
|
||||
})
|
||||
->groupBy('ac.parent_id');
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(ac.due_date) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(ac.due_date) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$rows = $query->get()->map(fn ($row) => (array) $row)->all();
|
||||
$extraByParent = [];
|
||||
foreach ($rows as $row) {
|
||||
$parentId = (int) ($row['parent_id'] ?? 0);
|
||||
$amount = (float) ($row['amount'] ?? 0);
|
||||
if ($parentId > 0 && abs($amount) > 0.00001) {
|
||||
$extraByParent[$parentId] = $amount;
|
||||
}
|
||||
}
|
||||
|
||||
return $extraByParent;
|
||||
}
|
||||
|
||||
private function sumPayments(string $schoolYear, ?string $dateFrom, ?string $dateTo): float
|
||||
{
|
||||
$query = DB::table('payments')->where('school_year', $schoolYear);
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(payment_date) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(payment_date) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
if (Schema::hasColumn('payments', 'status')) {
|
||||
$query->where(function ($q) use ($exclude) {
|
||||
$q->whereNotIn('status', $exclude)->orWhereNull('status');
|
||||
});
|
||||
}
|
||||
if (Schema::hasColumn('payments', 'is_void')) {
|
||||
$query->where(function ($q) {
|
||||
$q->where('is_void', 0)->orWhereNull('is_void');
|
||||
});
|
||||
}
|
||||
|
||||
$row = (array) $query->selectRaw('COALESCE(SUM(paid_amount), 0) AS paid_amount')->first();
|
||||
return (float) ($row['paid_amount'] ?? 0);
|
||||
}
|
||||
|
||||
private function paymentsByInvoiceIds(array $invoiceIds, string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
$query = DB::table('payments')
|
||||
->selectRaw('invoice_id, COALESCE(SUM(paid_amount), 0) AS total_paid')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(payment_date) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(payment_date) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
if (Schema::hasColumn('payments', 'status')) {
|
||||
$query->where(function ($q) use ($exclude) {
|
||||
$q->whereNotIn('status', $exclude)->orWhereNull('status');
|
||||
});
|
||||
}
|
||||
if (Schema::hasColumn('payments', 'is_void')) {
|
||||
$query->where(function ($q) {
|
||||
$q->where('is_void', 0)->orWhereNull('is_void');
|
||||
});
|
||||
}
|
||||
|
||||
$rows = $query->groupBy('invoice_id')->get()->map(fn ($row) => (array) $row)->all();
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$invoiceId = (int) ($row['invoice_id'] ?? 0);
|
||||
if ($invoiceId > 0) {
|
||||
$map[$invoiceId] = (float) ($row['total_paid'] ?? 0);
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function discountsByInvoiceIds(array $invoiceIds, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
$query = DB::table('discount_usages')
|
||||
->selectRaw('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->whereIn('invoice_id', $invoiceIds);
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(COALESCE(used_at, created_at)) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(COALESCE(used_at, created_at)) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$rows = $query->groupBy('invoice_id')->get()->map(fn ($row) => (array) $row)->all();
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$invoiceId = (int) ($row['invoice_id'] ?? 0);
|
||||
if ($invoiceId > 0) {
|
||||
$map[$invoiceId] = (float) ($row['total_disc'] ?? 0);
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function refundsByInvoiceIds(array $invoiceIds, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
$query = DB::table('refunds')
|
||||
->selectRaw('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial', 'Paid']);
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(COALESCE(refunded_at, created_at)) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(COALESCE(refunded_at, created_at)) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$rows = $query->groupBy('invoice_id')->get()->map(fn ($row) => (array) $row)->all();
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$invoiceId = (int) ($row['invoice_id'] ?? 0);
|
||||
if ($invoiceId > 0) {
|
||||
$map[$invoiceId] = (float) ($row['total_refund'] ?? 0);
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function sumExpenses(string $schoolYear, ?string $dateFrom, ?string $dateTo): float
|
||||
{
|
||||
$query = DB::table('expenses')->where('school_year', $schoolYear);
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(created_at) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(created_at) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$row = (array) $query->selectRaw('COALESCE(SUM(amount), 0) AS amount')->first();
|
||||
return (float) ($row['amount'] ?? 0);
|
||||
}
|
||||
|
||||
private function sumReimbursements(string $schoolYear, ?string $dateFrom, ?string $dateTo): float
|
||||
{
|
||||
$normalizedYear = preg_replace('/[^0-9]/', '', (string) $schoolYear);
|
||||
|
||||
$query = DB::table('reimbursements as r')
|
||||
->selectRaw('COALESCE(SUM(r.amount),0) AS amount')
|
||||
->leftJoin('expenses as e', 'e.id', '=', 'r.expense_id');
|
||||
|
||||
if (!empty($schoolYear)) {
|
||||
$query->where(function ($q) use ($schoolYear, $normalizedYear) {
|
||||
$q->where(function ($q2) use ($schoolYear, $normalizedYear) {
|
||||
$q2->where('r.school_year', $schoolYear)
|
||||
->orWhereRaw(
|
||||
"REPLACE(REPLACE(REPLACE(REPLACE(r.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '') = ?",
|
||||
[$normalizedYear]
|
||||
);
|
||||
})->orWhere(function ($q2) use ($schoolYear, $normalizedYear) {
|
||||
$q2->whereNull('r.school_year')
|
||||
->where(function ($q3) use ($schoolYear, $normalizedYear) {
|
||||
$q3->where('e.school_year', $schoolYear)
|
||||
->orWhereRaw(
|
||||
"REPLACE(REPLACE(REPLACE(REPLACE(e.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '') = ?",
|
||||
[$normalizedYear]
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(COALESCE(r.created_at, e.created_at)) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(COALESCE(r.created_at, e.created_at)) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$row = (array) $query->first();
|
||||
$total = (float) ($row['amount'] ?? 0);
|
||||
|
||||
$batchFallback = DB::table('reimbursement_batch_items as bi')
|
||||
->selectRaw('COALESCE(SUM(e.amount),0) AS amount')
|
||||
->join('reimbursement_batches as b', 'b.id', '=', 'bi.batch_id')
|
||||
->join('expenses as e', 'e.id', '=', 'bi.expense_id')
|
||||
->leftJoin('reimbursements as r', 'r.expense_id', '=', 'e.id')
|
||||
->where('b.status', 'closed')
|
||||
->whereNull('bi.unassigned_at')
|
||||
->whereNull('r.id');
|
||||
|
||||
if (!empty($schoolYear)) {
|
||||
$batchFallback->where(function ($q) use ($schoolYear, $normalizedYear) {
|
||||
$q->where('e.school_year', $schoolYear)
|
||||
->orWhereRaw(
|
||||
"REPLACE(REPLACE(REPLACE(REPLACE(e.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '') = ?",
|
||||
[$normalizedYear]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$batchFallback->whereRaw('DATE(COALESCE(e.created_at, b.closed_at)) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$batchFallback->whereRaw('DATE(COALESCE(e.created_at, b.closed_at)) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$fallbackRow = (array) $batchFallback->first();
|
||||
$total += (float) ($fallbackRow['amount'] ?? 0);
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function sumDonationToSchool(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
$expenseQuery = DB::table('expenses')
|
||||
->where('school_year', $schoolYear)
|
||||
->where('category', 'Donation');
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$expenseQuery->whereRaw('DATE(created_at) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$expenseQuery->whereRaw('DATE(created_at) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$expenseRow = (array) $expenseQuery->selectRaw('COALESCE(SUM(amount),0) AS amount')->first();
|
||||
$donationExpense = (float) ($expenseRow['amount'] ?? 0);
|
||||
|
||||
$donationReimb = 0.0;
|
||||
$recipientIds = $this->donationRecipients->recipientIds();
|
||||
if (!empty($recipientIds)) {
|
||||
$normalizedYear = preg_replace('/[^0-9]/', '', (string) $schoolYear);
|
||||
$donationQuery = DB::table('reimbursements as r')
|
||||
->selectRaw('COALESCE(SUM(r.amount),0) AS amount')
|
||||
->leftJoin('expenses as e', 'e.id', '=', 'r.expense_id')
|
||||
->whereIn('r.reimbursed_to', $recipientIds);
|
||||
|
||||
if (!empty($schoolYear)) {
|
||||
$donationQuery->where(function ($q) use ($schoolYear, $normalizedYear) {
|
||||
$q->where(function ($q2) use ($schoolYear, $normalizedYear) {
|
||||
$q2->where('r.school_year', $schoolYear)
|
||||
->orWhereRaw(
|
||||
"REPLACE(REPLACE(REPLACE(REPLACE(r.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '') = ?",
|
||||
[$normalizedYear]
|
||||
);
|
||||
})->orWhere(function ($q2) use ($schoolYear, $normalizedYear) {
|
||||
$q2->whereNull('r.school_year')
|
||||
->where(function ($q3) use ($schoolYear, $normalizedYear) {
|
||||
$q3->where('e.school_year', $schoolYear)
|
||||
->orWhereRaw(
|
||||
"REPLACE(REPLACE(REPLACE(REPLACE(e.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '') = ?",
|
||||
[$normalizedYear]
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$donationQuery->whereRaw('DATE(COALESCE(r.created_at, e.created_at)) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$donationQuery->whereRaw('DATE(COALESCE(r.created_at, e.created_at)) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$donationRow = (array) $donationQuery->first();
|
||||
$donationReimb = (float) ($donationRow['amount'] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'donationToSchool' => $donationExpense + $donationReimb,
|
||||
'donationReimb' => $donationReimb,
|
||||
];
|
||||
}
|
||||
|
||||
private function sumRefunds(string $schoolYear, ?string $dateFrom, ?string $dateTo): float
|
||||
{
|
||||
$query = DB::table('refunds')
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->whereNotNull('refund_paid_amount');
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(COALESCE(refunded_at, created_at)) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(COALESCE(refunded_at, created_at)) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$row = (array) $query->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')->first();
|
||||
return (float) ($row['refund_paid_amount'] ?? 0);
|
||||
}
|
||||
|
||||
private function sumDiscounts(string $schoolYear, ?string $dateFrom, ?string $dateTo): float
|
||||
{
|
||||
$query = DB::table('discount_usages as du')
|
||||
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
|
||||
->where('i.school_year', $schoolYear);
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(COALESCE(du.used_at, du.created_at)) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(COALESCE(du.used_at, du.created_at)) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$row = (array) $query->selectRaw('COALESCE(SUM(du.discount_amount),0) AS discount_amount')->first();
|
||||
return (float) ($row['discount_amount'] ?? 0);
|
||||
}
|
||||
|
||||
private function sumUnpaidBalances(array $invoices, array $paidByInvoice, array $discountByInvoice, array $refundByInvoice, array $extraByParent): float
|
||||
{
|
||||
$parentBalances = [];
|
||||
|
||||
foreach ($invoices as $inv) {
|
||||
$invoiceId = (int) ($inv['id'] ?? 0);
|
||||
$parentId = (int) ($inv['parent_id'] ?? 0);
|
||||
if ($invoiceId <= 0 || $parentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$total = (float) ($inv['total_amount'] ?? 0);
|
||||
$paid = (float) ($paidByInvoice[$invoiceId] ?? 0);
|
||||
$disc = (float) ($discountByInvoice[$invoiceId] ?? 0);
|
||||
$ref = (float) ($refundByInvoice[$invoiceId] ?? 0);
|
||||
$balance = max(0.0, round($total - $disc - $paid - $ref, 2));
|
||||
|
||||
if (!isset($parentBalances[$parentId])) {
|
||||
$parentBalances[$parentId] = 0.0;
|
||||
}
|
||||
$parentBalances[$parentId] += $balance;
|
||||
}
|
||||
|
||||
foreach ($extraByParent as $parentId => $extra) {
|
||||
if (!isset($parentBalances[$parentId])) {
|
||||
$parentBalances[$parentId] = 0.0;
|
||||
}
|
||||
$parentBalances[$parentId] += (float) $extra;
|
||||
}
|
||||
|
||||
$totalUnpaid = 0.0;
|
||||
foreach ($parentBalances as $balance) {
|
||||
if ($balance > 0.00001) {
|
||||
$totalUnpaid += $balance;
|
||||
}
|
||||
}
|
||||
|
||||
return $totalUnpaid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class FinancialUnpaidParentsService
|
||||
{
|
||||
public function __construct(private FinancialSchoolYearService $schoolYears)
|
||||
{
|
||||
}
|
||||
|
||||
public function getUnpaidParents(?string $schoolYear): array
|
||||
{
|
||||
$schoolYear = trim((string) ($schoolYear ?? ''));
|
||||
if ($schoolYear === '') {
|
||||
$schoolYear = (string) (Configuration::getConfig('school_year') ?? date('Y'));
|
||||
}
|
||||
|
||||
$schoolYears = $this->schoolYears->listYears($schoolYear);
|
||||
|
||||
$invRows = DB::table('invoices as i')
|
||||
->select('i.id', 'i.parent_id', 'i.total_amount', 'u.firstname', 'u.lastname', 'u.email')
|
||||
->join('users as u', 'u.id', '=', 'i.parent_id')
|
||||
->where('i.school_year', $schoolYear)
|
||||
->orderBy('i.parent_id', 'ASC')
|
||||
->orderBy('i.id', 'ASC')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$byParent = [];
|
||||
$hasStatus = Schema::hasColumn('payments', 'status');
|
||||
$hasVoid = Schema::hasColumn('payments', 'is_void');
|
||||
|
||||
foreach ($invRows as $row) {
|
||||
$invoiceId = (int) ($row['id'] ?? 0);
|
||||
$parentId = (int) ($row['parent_id'] ?? 0);
|
||||
if ($invoiceId <= 0 || $parentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$paidQuery = DB::table('payments')
|
||||
->selectRaw('COALESCE(SUM(paid_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId);
|
||||
if ($hasStatus) {
|
||||
$paidQuery->where(function ($q) {
|
||||
$q->whereNotIn('status', [
|
||||
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
|
||||
])->orWhereNull('status');
|
||||
});
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$paidQuery->where(function ($q) {
|
||||
$q->where('is_void', 0)->orWhereNull('is_void');
|
||||
});
|
||||
}
|
||||
$paidRow = (array) $paidQuery->first();
|
||||
$paidSum = (float) ($paidRow['tot'] ?? 0);
|
||||
|
||||
$discRow = (array) DB::table('discount_usages')
|
||||
->selectRaw('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->first();
|
||||
$discSum = (float) ($discRow['tot'] ?? 0);
|
||||
|
||||
$refRow = (array) DB::table('refunds')
|
||||
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->first();
|
||||
$refSum = (float) ($refRow['tot'] ?? 0);
|
||||
|
||||
$total = (float) ($row['total_amount'] ?? 0);
|
||||
$balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2));
|
||||
|
||||
if (!isset($byParent[$parentId])) {
|
||||
$byParent[$parentId] = [
|
||||
'parent_id' => $parentId,
|
||||
'firstname' => (string) ($row['firstname'] ?? ''),
|
||||
'lastname' => (string) ($row['lastname'] ?? ''),
|
||||
'email' => (string) ($row['email'] ?? ''),
|
||||
'total_invoice' => 0.0,
|
||||
'total_balance' => 0.0,
|
||||
'total_paid' => 0.0,
|
||||
'total_discount' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
$byParent[$parentId]['total_invoice'] += $total;
|
||||
$byParent[$parentId]['total_balance'] += $balance;
|
||||
$byParent[$parentId]['total_paid'] += $paidSum;
|
||||
$byParent[$parentId]['total_discount'] += $discSum;
|
||||
}
|
||||
|
||||
$extraRows = DB::table('additional_charges as ac')
|
||||
->selectRaw(
|
||||
"ac.parent_id, u.firstname, u.lastname, u.email, COALESCE(SUM(ac.amount), 0) AS total_extra",
|
||||
false
|
||||
)
|
||||
->leftJoin('users as u', 'u.id', '=', 'ac.parent_id')
|
||||
->where('ac.school_year', $schoolYear)
|
||||
->where('ac.status !=', 'void')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('ac.invoice_id')
|
||||
->orWhere('ac.invoice_id', 0)
|
||||
->orWhere('ac.status !=', 'applied');
|
||||
})
|
||||
->groupBy('ac.parent_id')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
foreach ($extraRows as $row) {
|
||||
$parentId = (int) ($row['parent_id'] ?? 0);
|
||||
if ($parentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$extra = (float) ($row['total_extra'] ?? 0);
|
||||
if (abs($extra) < 0.00001) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($byParent[$parentId])) {
|
||||
$byParent[$parentId] = [
|
||||
'parent_id' => $parentId,
|
||||
'firstname' => (string) ($row['firstname'] ?? ''),
|
||||
'lastname' => (string) ($row['lastname'] ?? ''),
|
||||
'email' => (string) ($row['email'] ?? ''),
|
||||
'total_invoice' => 0.0,
|
||||
'total_balance' => 0.0,
|
||||
'total_paid' => 0.0,
|
||||
'total_discount' => 0.0,
|
||||
];
|
||||
}
|
||||
$byParent[$parentId]['total_invoice'] += $extra;
|
||||
$byParent[$parentId]['total_balance'] += $extra;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$installmentEndRaw = (string) (Configuration::getConfig('installment_date') ?? '');
|
||||
$tzName = function_exists('user_timezone') ? (string) user_timezone() : config('app.timezone', 'UTC');
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$today = new \DateTimeImmutable('today', $tz);
|
||||
|
||||
$endDate = null;
|
||||
if ($installmentEndRaw !== '') {
|
||||
try {
|
||||
$endDate = new \DateTimeImmutable($installmentEndRaw, $tz);
|
||||
} catch (\Throwable $e) {
|
||||
$endDate = null;
|
||||
}
|
||||
}
|
||||
|
||||
$monthsUntil = static function (? \DateTimeImmutable $end) use ($today): int {
|
||||
if (!$end) {
|
||||
return 0;
|
||||
}
|
||||
$y = (int) $end->format('Y') - (int) $today->format('Y');
|
||||
$m = (int) $end->format('n') - (int) $today->format('n');
|
||||
$months = $y * 12 + $m;
|
||||
if ((int) $end->format('j') > (int) $today->format('j')) {
|
||||
$months += 1;
|
||||
}
|
||||
return max(0, $months);
|
||||
};
|
||||
|
||||
foreach ($byParent as $agg) {
|
||||
$balance = (float) ($agg['total_balance'] ?? 0);
|
||||
if ($balance <= 0.00001) {
|
||||
continue;
|
||||
}
|
||||
$remMonths = $monthsUntil($endDate);
|
||||
$remainingInstallments = max(1, $remMonths);
|
||||
$instAmount = round($balance / $remainingInstallments, 2);
|
||||
|
||||
$rows[] = [
|
||||
'parent_id' => (int) ($agg['parent_id'] ?? 0),
|
||||
'firstname' => (string) ($agg['firstname'] ?? ''),
|
||||
'lastname' => (string) ($agg['lastname'] ?? ''),
|
||||
'email' => (string) ($agg['email'] ?? ''),
|
||||
'total_invoice' => (float) ($agg['total_invoice'] ?? 0),
|
||||
'total_balance' => $balance,
|
||||
'total_discount' => (float) ($agg['total_discount'] ?? 0),
|
||||
'remaining_installments' => $remainingInstallments,
|
||||
'installment_amount' => $instAmount,
|
||||
];
|
||||
}
|
||||
|
||||
usort($rows, static fn ($a, $b) => $b['total_balance'] <=> $a['total_balance']);
|
||||
|
||||
$nextInstallment = (new \DateTime('now', $tz))
|
||||
->modify('first day of next month')
|
||||
->format('Y-m-d');
|
||||
|
||||
$parentIds = array_values(array_unique(array_map(static fn ($row) => (int) ($row['parent_id'] ?? 0), $rows)));
|
||||
$hasPayments = [];
|
||||
$paidTotals = [];
|
||||
$paymentCounts = [];
|
||||
if (!empty($parentIds)) {
|
||||
$pRows = DB::table('payments')
|
||||
->selectRaw('parent_id, SUM(paid_amount) AS total_paid, COUNT(*) AS payment_count')
|
||||
->whereIn('parent_id', $parentIds)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('parent_id')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
foreach ($pRows as $row) {
|
||||
$parentId = (int) ($row['parent_id'] ?? 0);
|
||||
if ($parentId > 0) {
|
||||
$hasPayments[$parentId] = true;
|
||||
$paidTotals[$parentId] = (float) ($row['total_paid'] ?? 0);
|
||||
$paymentCounts[$parentId] = (int) ($row['payment_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$results = array_map(function (array $row) use ($hasPayments, $paidTotals, $paymentCounts, $nextInstallment) {
|
||||
$parentId = (int) ($row['parent_id'] ?? 0);
|
||||
$name = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
|
||||
return [
|
||||
'parent_id' => $parentId,
|
||||
'parent_name' => $name,
|
||||
'email' => (string) ($row['email'] ?? ''),
|
||||
'total_invoice' => (float) ($row['total_invoice'] ?? 0),
|
||||
'total_balance' => (float) ($row['total_balance'] ?? 0),
|
||||
'total_discount' => (float) ($row['total_discount'] ?? 0),
|
||||
'remaining_installments' => (int) ($row['remaining_installments'] ?? 0),
|
||||
'installment_amount' => (float) ($row['installment_amount'] ?? 0),
|
||||
'type' => isset($hasPayments[$parentId]) ? 'installment' : 'no_payment',
|
||||
'total_paid' => isset($paidTotals[$parentId]) ? (float) $paidTotals[$parentId] : 0.0,
|
||||
'payment_count' => isset($paymentCounts[$parentId]) ? (int) $paymentCounts[$parentId] : 0,
|
||||
'has_installment' => isset($hasPayments[$parentId]) ? 1 : 0,
|
||||
'next_installment' => $nextInstallment,
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return [
|
||||
'schoolYear' => $schoolYear,
|
||||
'schoolYears' => $schoolYears,
|
||||
'results' => $results,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Grading;
|
||||
|
||||
use App\Models\CurrentFlag;
|
||||
use App\Models\ParentMeetingSchedule;
|
||||
use App\Models\ScoreComment;
|
||||
use App\Models\Student;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use RuntimeException;
|
||||
|
||||
class GradingBelowSixtyService
|
||||
{
|
||||
public function listRows(string $schoolYear, string $semester): array
|
||||
{
|
||||
$semesterKey = strtolower(trim($semester));
|
||||
$rows = DB::table('semester_scores as ss')
|
||||
->select([
|
||||
's.id as student_id',
|
||||
's.school_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
'cs.class_section_name',
|
||||
'ss.homework_avg',
|
||||
'ss.project_avg',
|
||||
'ss.participation_score',
|
||||
DB::raw('COALESCE(ss.test_avg, ss.quiz_avg) as test_avg'),
|
||||
'ss.ptap_score',
|
||||
'ss.attendance_score',
|
||||
'ss.midterm_exam_score',
|
||||
'ss.final_exam_score',
|
||||
'ss.semester_score',
|
||||
])
|
||||
->join('students as s', 's.id', '=', 'ss.student_id')
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
|
||||
->where('s.is_active', 1)
|
||||
->where('ss.school_year', $schoolYear)
|
||||
->whereRaw('LOWER(TRIM(ss.semester)) = ?', [$semesterKey])
|
||||
->whereNotNull('ss.semester_score')
|
||||
->where('ss.semester_score', '<', 60)
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
if (empty($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$studentIds = array_values(array_unique(array_map(
|
||||
static fn ($r) => (int) ($r['student_id'] ?? 0),
|
||||
$rows
|
||||
)));
|
||||
$studentIds = array_values(array_filter($studentIds, static fn ($id) => $id > 0));
|
||||
|
||||
$commentMap = [];
|
||||
if (!empty($studentIds)) {
|
||||
$commentRows = ScoreComment::query()
|
||||
->select('student_id', 'comment', 'created_at')
|
||||
->where('score_type', 'general')
|
||||
->where('school_year', $schoolYear)
|
||||
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
|
||||
->whereIn('student_id', $studentIds)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->get();
|
||||
foreach ($commentRows as $row) {
|
||||
$sid = (int) $row->student_id;
|
||||
if ($sid > 0 && !isset($commentMap[$sid])) {
|
||||
$commentMap[$sid] = (string) ($row->comment ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$statusMap = [];
|
||||
if (!empty($studentIds)) {
|
||||
$flagRows = CurrentFlag::query()
|
||||
->select('student_id', 'flag_state')
|
||||
->where('flag', 'grade')
|
||||
->where('school_year', $schoolYear)
|
||||
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
|
||||
->whereIn('student_id', $studentIds)
|
||||
->get();
|
||||
foreach ($flagRows as $row) {
|
||||
$sid = (int) $row->student_id;
|
||||
if ($sid > 0) {
|
||||
$statusMap[$sid] = (string) ($row->flag_state ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$sid = (int) ($row['student_id'] ?? 0);
|
||||
$row['comment'] = $commentMap[$sid] ?? '';
|
||||
$flagState = strtolower(trim((string) ($statusMap[$sid] ?? '')));
|
||||
$row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open';
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function emailContext(int $studentId, string $schoolYear, string $semester): array
|
||||
{
|
||||
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
|
||||
if (empty($row)) {
|
||||
throw new RuntimeException('Student record not found for the selected term.');
|
||||
}
|
||||
|
||||
$studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
|
||||
$subject = $this->buildBelowSixtySubject($studentName, $semester, $schoolYear);
|
||||
$parentName = $this->fetchBelowSixtyParentName($studentId);
|
||||
|
||||
$scores = [
|
||||
'homework_avg' => $row['homework_avg'] ?? null,
|
||||
'project_avg' => $row['project_avg'] ?? null,
|
||||
'participation_score' => $row['participation_score'] ?? null,
|
||||
'test_avg' => $row['test_avg'] ?? null,
|
||||
'ptap_score' => $row['ptap_score'] ?? null,
|
||||
'attendance_score' => $row['attendance_score'] ?? null,
|
||||
'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
|
||||
'semester_score' => $row['semester_score'] ?? null,
|
||||
];
|
||||
|
||||
return [
|
||||
'student_id' => $studentId,
|
||||
'student_name' => $studentName !== '' ? $studentName : 'Student',
|
||||
'parent_name' => $parentName,
|
||||
'class_section_name' => $row['class_section_name'] ?? '',
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'scores' => $scores,
|
||||
'comment' => $row['comment'] ?? '',
|
||||
'subject' => $subject,
|
||||
];
|
||||
}
|
||||
|
||||
public function sendEmail(array $payload): void
|
||||
{
|
||||
Event::dispatch('below60.email', $payload);
|
||||
}
|
||||
|
||||
public function updateStatus(int $studentId, string $semester, string $schoolYear, string $status, string $note, ?int $userId): void
|
||||
{
|
||||
$status = ucfirst(strtolower($status));
|
||||
if (!in_array($status, ['Open', 'Closed'], true)) {
|
||||
throw new RuntimeException('Invalid status.');
|
||||
}
|
||||
|
||||
$semesterKey = strtolower(trim($semester));
|
||||
$existing = CurrentFlag::query()
|
||||
->where('student_id', $studentId)
|
||||
->where('flag', 'grade')
|
||||
->where('school_year', $schoolYear)
|
||||
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
|
||||
->first();
|
||||
|
||||
$now = now();
|
||||
|
||||
if ($existing) {
|
||||
$data = [
|
||||
'flag_state' => $status,
|
||||
'flag_datetime' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if ($status === 'Open') {
|
||||
$data['updated_by_open'] = $userId;
|
||||
if ($note !== '') {
|
||||
$prev = (string) ($existing->open_description ?? '');
|
||||
$data['open_description'] = trim($prev . PHP_EOL . $note);
|
||||
}
|
||||
} else {
|
||||
$data['updated_by_closed'] = $userId;
|
||||
if ($note !== '') {
|
||||
$prev = (string) ($existing->close_description ?? '');
|
||||
$data['close_description'] = trim($prev . PHP_EOL . $note);
|
||||
}
|
||||
}
|
||||
$existing->update($data);
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
|
||||
$studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
|
||||
$grade = (string) ($row['class_section_name'] ?? '');
|
||||
$data = [
|
||||
'student_id' => $studentId,
|
||||
'student_name' => $studentName !== '' ? $studentName : 'Student',
|
||||
'grade' => $grade,
|
||||
'flag' => 'grade',
|
||||
'flag_datetime' => $now,
|
||||
'flag_state' => $status,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if ($status === 'Open') {
|
||||
$data['updated_by_open'] = $userId;
|
||||
if ($note !== '') {
|
||||
$data['open_description'] = $note;
|
||||
}
|
||||
} else {
|
||||
$data['updated_by_closed'] = $userId;
|
||||
if ($note !== '') {
|
||||
$data['close_description'] = $note;
|
||||
}
|
||||
}
|
||||
|
||||
CurrentFlag::query()->create($data);
|
||||
}
|
||||
|
||||
public function meetingContext(int $studentId, string $schoolYear, string $semester): array
|
||||
{
|
||||
$context = $this->fetchBelowSixtyMeetingContext($studentId, $schoolYear, $semester);
|
||||
if (empty($context)) {
|
||||
throw new RuntimeException('Student record not found for the selected term.');
|
||||
}
|
||||
|
||||
return $context + [
|
||||
'student_id' => $studentId,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
];
|
||||
}
|
||||
|
||||
public function saveMeeting(array $payload, ?int $userId): void
|
||||
{
|
||||
$studentId = (int) ($payload['student_id'] ?? 0);
|
||||
$semester = (string) ($payload['semester'] ?? '');
|
||||
$schoolYear = (string) ($payload['school_year'] ?? '');
|
||||
$date = trim((string) ($payload['date'] ?? ''));
|
||||
$time = trim((string) ($payload['time'] ?? ''));
|
||||
$notes = trim((string) ($payload['notes'] ?? ''));
|
||||
|
||||
if ($studentId <= 0 || $semester === '' || $schoolYear === '' || $date === '') {
|
||||
throw new RuntimeException('Missing required fields.');
|
||||
}
|
||||
|
||||
$context = $this->fetchBelowSixtyMeetingContext($studentId, $schoolYear, $semester);
|
||||
if (empty($context)) {
|
||||
throw new RuntimeException('Student record not found for the selected term.');
|
||||
}
|
||||
|
||||
ParentMeetingSchedule::query()->create([
|
||||
'student_id' => $studentId,
|
||||
'parent_user_id' => $context['parent_user_id'] ?? null,
|
||||
'parent_name' => $context['parent_name'],
|
||||
'student_name' => $context['student_name'],
|
||||
'class_section_name' => $context['class_section_name'],
|
||||
'date' => $date,
|
||||
'time' => $time !== '' ? $time : null,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'status' => 'scheduled',
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function fetchBelowSixtyEmailRow(int $studentId, string $schoolYear, string $semester): array
|
||||
{
|
||||
$semesterKey = strtolower(trim($semester));
|
||||
$row = DB::table('semester_scores as ss')
|
||||
->select([
|
||||
's.id as student_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
'cs.class_section_name',
|
||||
'ss.homework_avg',
|
||||
'ss.project_avg',
|
||||
'ss.participation_score',
|
||||
DB::raw('COALESCE(ss.test_avg, ss.quiz_avg) as test_avg'),
|
||||
'ss.ptap_score',
|
||||
'ss.attendance_score',
|
||||
'ss.midterm_exam_score',
|
||||
'ss.semester_score',
|
||||
])
|
||||
->join('students as s', 's.id', '=', 'ss.student_id')
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
|
||||
->where('ss.school_year', $schoolYear)
|
||||
->where('ss.student_id', $studentId)
|
||||
->where('s.is_active', 1)
|
||||
->whereRaw('LOWER(TRIM(ss.semester)) = ?', [$semesterKey])
|
||||
->first();
|
||||
|
||||
if (!$row) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$commentRow = ScoreComment::query()
|
||||
->select('comment')
|
||||
->where('score_type', 'general')
|
||||
->where('school_year', $schoolYear)
|
||||
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
|
||||
->where('student_id', $studentId)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->first();
|
||||
|
||||
$row = (array) $row;
|
||||
$row['comment'] = (string) ($commentRow?->comment ?? '');
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function fetchBelowSixtyParentName(int $studentId): string
|
||||
{
|
||||
$parentName = 'Parent/Guardian';
|
||||
try {
|
||||
$rows = DB::select(
|
||||
"SELECT u.firstname, u.lastname
|
||||
FROM family_students fs
|
||||
JOIN family_guardians fg ON fg.family_id = fs.family_id
|
||||
JOIN users u ON u.id = fg.user_id
|
||||
WHERE fs.student_id = ?
|
||||
ORDER BY fg.is_primary DESC, u.lastname, u.firstname
|
||||
LIMIT 1",
|
||||
[$studentId]
|
||||
);
|
||||
if (!empty($rows[0])) {
|
||||
$candidate = trim((string) ($rows[0]->firstname ?? '') . ' ' . (string) ($rows[0]->lastname ?? ''));
|
||||
if ($candidate !== '') {
|
||||
$parentName = $candidate;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
return $parentName;
|
||||
}
|
||||
|
||||
private function buildBelowSixtySubject(string $studentName, string $semester, string $schoolYear): string
|
||||
{
|
||||
$subject = 'Student Performance Alert';
|
||||
if ($studentName !== '') {
|
||||
$subject .= ' — ' . $studentName;
|
||||
}
|
||||
if ($semester !== '' || $schoolYear !== '') {
|
||||
$subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
|
||||
}
|
||||
return $subject;
|
||||
}
|
||||
|
||||
private function fetchBelowSixtyMeetingContext(int $studentId, string $schoolYear, string $semester): array
|
||||
{
|
||||
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
|
||||
if (empty($row)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parentName = 'Parent/Guardian';
|
||||
$parentUserId = null;
|
||||
try {
|
||||
$pRows = DB::select(
|
||||
"SELECT u.id AS user_id, u.firstname, u.lastname
|
||||
FROM family_students fs
|
||||
JOIN family_guardians fg ON fg.family_id = fs.family_id
|
||||
JOIN users u ON u.id = fg.user_id
|
||||
WHERE fs.student_id = ?
|
||||
ORDER BY fg.is_primary DESC, u.lastname, u.firstname
|
||||
LIMIT 1",
|
||||
[$studentId]
|
||||
);
|
||||
if (!empty($pRows[0])) {
|
||||
$parentUserId = (int) ($pRows[0]->user_id ?? 0) ?: null;
|
||||
$parentName = trim((string) ($pRows[0]->firstname ?? '') . ' ' . (string) ($pRows[0]->lastname ?? ''));
|
||||
if ($parentName === '') {
|
||||
$parentName = 'Parent/Guardian';
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
if ($parentUserId === null) {
|
||||
try {
|
||||
$srow = DB::selectOne(
|
||||
"SELECT s.parent_id, u.firstname, u.lastname
|
||||
FROM students s
|
||||
LEFT JOIN users u ON u.id = s.parent_id
|
||||
WHERE s.id = ?
|
||||
LIMIT 1",
|
||||
[$studentId]
|
||||
);
|
||||
if (!empty($srow)) {
|
||||
$parentUserId = (int) ($srow->parent_id ?? 0) ?: null;
|
||||
$fallbackName = trim((string) ($srow->firstname ?? '') . ' ' . (string) ($srow->lastname ?? ''));
|
||||
if ($fallbackName !== '') {
|
||||
$parentName = $fallbackName;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
$studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
|
||||
|
||||
return [
|
||||
'student_name' => $studentName !== '' ? $studentName : 'Student',
|
||||
'parent_name' => $parentName,
|
||||
'parent_user_id' => $parentUserId,
|
||||
'class_section_name' => (string) ($row['class_section_name'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Grading;
|
||||
|
||||
use App\Models\GradingLock;
|
||||
use App\Models\ClassSection;
|
||||
use RuntimeException;
|
||||
|
||||
class GradingLockService
|
||||
{
|
||||
public function toggle(int $classSectionId, string $semester, string $schoolYear, ?int $userId): bool
|
||||
{
|
||||
if ($classSectionId <= 0 || $semester === '' || $schoolYear === '') {
|
||||
throw new RuntimeException('Missing class section or term.');
|
||||
}
|
||||
|
||||
$existing = GradingLock::getLock($classSectionId, $semester, $schoolYear);
|
||||
|
||||
if ($existing && $existing->is_locked) {
|
||||
$existing->update([
|
||||
'is_locked' => 0,
|
||||
'locked_by' => null,
|
||||
'locked_at' => null,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$existing->update([
|
||||
'is_locked' => 1,
|
||||
'locked_by' => $userId ?: null,
|
||||
'locked_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
GradingLock::query()->create([
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'is_locked' => 1,
|
||||
'locked_by' => $userId ?: null,
|
||||
'locked_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function lockAll(string $semester, string $schoolYear, ?int $userId): int
|
||||
{
|
||||
if ($semester === '' || $schoolYear === '') {
|
||||
throw new RuntimeException('Missing semester or school year.');
|
||||
}
|
||||
|
||||
$sectionIds = ClassSection::query()
|
||||
->select('class_section_id')
|
||||
->groupBy('class_section_id')
|
||||
->pluck('class_section_id')
|
||||
->map(fn ($v) => (int) $v)
|
||||
->filter(fn ($v) => $v > 0)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (empty($sectionIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$existingLocks = GradingLock::query()
|
||||
->whereIn('class_section_id', $sectionIds)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
|
||||
$existingBySection = [];
|
||||
foreach ($existingLocks as $row) {
|
||||
$sid = (int) $row->class_section_id;
|
||||
if ($sid > 0) {
|
||||
$existingBySection[$sid] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$insertRows = [];
|
||||
$count = 0;
|
||||
|
||||
foreach ($sectionIds as $sid) {
|
||||
if (!empty($existingBySection[$sid])) {
|
||||
if ($existingBySection[$sid]->is_locked) {
|
||||
continue;
|
||||
}
|
||||
$existingBySection[$sid]->update([
|
||||
'is_locked' => 1,
|
||||
'locked_by' => $userId ?: null,
|
||||
'locked_at' => $now,
|
||||
]);
|
||||
$count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$insertRows[] = [
|
||||
'class_section_id' => $sid,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'is_locked' => 1,
|
||||
'locked_by' => $userId ?: null,
|
||||
'locked_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$count++;
|
||||
}
|
||||
|
||||
if (!empty($insertRows)) {
|
||||
GradingLock::query()->insert($insertRows);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Grading;
|
||||
|
||||
use App\Models\AttendanceRecord;
|
||||
use App\Models\CalendarEvent;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\GradingLock;
|
||||
use App\Models\Student;
|
||||
use App\Services\Scores\AttendanceCalculator;
|
||||
use App\Services\Scores\SemesterScoreService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GradingOverviewService
|
||||
{
|
||||
private AttendanceCalculator $attendanceCalculator;
|
||||
|
||||
public function __construct(private SemesterScoreService $semesterScoreService)
|
||||
{
|
||||
$this->attendanceCalculator = new AttendanceCalculator(
|
||||
new AttendanceRecord(),
|
||||
new Configuration(),
|
||||
new CalendarEvent()
|
||||
);
|
||||
}
|
||||
|
||||
public function overview(?int $classId, ?string $semester, ?string $schoolYear): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?: (string) (Configuration::getConfig('school_year') ?? '');
|
||||
$configuredSemester = (string) (Configuration::getConfig('semester') ?? '');
|
||||
$semesterOptions = $this->getSemestersForSchoolYear($schoolYear, $configuredSemester);
|
||||
$semester = $this->resolveSemesterSelection($semester, $semesterOptions, $configuredSemester);
|
||||
|
||||
$this->ensureParentReleaseKeyExists('Fall');
|
||||
$this->ensureParentReleaseKeyExists('Spring');
|
||||
$scoresReleased = $this->getParentScoresReleasedForSemester($semester);
|
||||
$scoresReleasedFall = $this->getParentScoresReleasedForSemester('Fall');
|
||||
$scoresReleasedSpring = $this->getParentScoresReleasedForSemester('Spring');
|
||||
|
||||
if ($classId && $this->semesterScoreService) {
|
||||
$sectionIds = ClassSection::query()
|
||||
->select('class_section_id')
|
||||
->where('class_id', $classId)
|
||||
->pluck('class_section_id')
|
||||
->map(fn ($v) => (int) $v)
|
||||
->all();
|
||||
foreach ($sectionIds as $sectionId) {
|
||||
$studentTeacherInfo = Student::getStudentInfoByClassSectionId(
|
||||
$sectionId,
|
||||
$semester,
|
||||
$schoolYear
|
||||
);
|
||||
if (empty($studentTeacherInfo)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $this->buildGradingRows($semester, $schoolYear);
|
||||
|
||||
$counts = $this->loadScoreCounts($rows, $semester, $schoolYear);
|
||||
|
||||
$sectionsNeedingRefresh = [];
|
||||
foreach ($rows as $row) {
|
||||
$sectionId = (int) ($row['section_id'] ?? 0);
|
||||
if ($sectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($row['ss_ptap_score'] === null || $row['ss_semester_score'] === null) {
|
||||
$sectionsNeedingRefresh[$sectionId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($sectionsNeedingRefresh)) {
|
||||
foreach (array_keys($sectionsNeedingRefresh) as $sectionId) {
|
||||
$studentTeacherInfo = Student::getStudentInfoByClassSectionId(
|
||||
$sectionId,
|
||||
$semester,
|
||||
$schoolYear
|
||||
);
|
||||
if (empty($studentTeacherInfo)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
$rows = $this->buildGradingRows($semester, $schoolYear);
|
||||
}
|
||||
|
||||
$grades = [];
|
||||
$studentsBySection = [];
|
||||
foreach ($rows as $row) {
|
||||
$sectionId = (int) ($row['section_id'] ?? 0);
|
||||
$classIdValue = (int) ($row['class_id'] ?? 0);
|
||||
$sectionName = (string) ($row['class_section_name'] ?? '');
|
||||
if ($sectionId <= 0 || $classIdValue <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($grades[$classIdValue])) {
|
||||
$grades[$classIdValue] = [];
|
||||
}
|
||||
$exists = false;
|
||||
foreach ($grades[$classIdValue] as $section) {
|
||||
if ((int) $section['class_section_id'] === $sectionId) {
|
||||
$exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$exists) {
|
||||
$grades[$classIdValue][] = [
|
||||
'class_section_id' => $sectionId,
|
||||
'class_section_name' => $sectionName,
|
||||
];
|
||||
}
|
||||
|
||||
$studentId = (int) ($row['student_id'] ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attendanceScore = $this->calculateAttendanceScoreForStudent(
|
||||
$studentId,
|
||||
$semester,
|
||||
$schoolYear,
|
||||
$sectionId
|
||||
);
|
||||
if ($attendanceScore === null) {
|
||||
$rawAttendance = $row['ss_attendance_score'] ?? null;
|
||||
if ($rawAttendance !== null && $rawAttendance !== '') {
|
||||
$attendanceScore = round((float) $rawAttendance, 2);
|
||||
}
|
||||
}
|
||||
|
||||
$studentRow = [
|
||||
'id' => $studentId,
|
||||
'school_id' => $row['school_id'] ?? null,
|
||||
'firstname' => $row['firstname'] ?? null,
|
||||
'lastname' => $row['lastname'] ?? null,
|
||||
'class_id' => $classIdValue,
|
||||
'ptap' => $this->normalizeScore($row['ss_ptap_score'] ?? null),
|
||||
'semester_score' => $this->normalizeScore($row['ss_semester_score'] ?? null),
|
||||
'attendance' => $attendanceScore,
|
||||
'homework_avg' => $this->normalizeCountedScore($row['ss_homework_avg'] ?? null, $counts['homework'] ?? [], $sectionId, $studentId),
|
||||
'project_avg' => $this->normalizeCountedScore($row['ss_project_avg'] ?? null, $counts['project'] ?? [], $sectionId, $studentId),
|
||||
'quiz_avg' => $this->normalizeCountedScore($row['ss_quiz_avg'] ?? null, $counts['quiz'] ?? [], $sectionId, $studentId),
|
||||
'participation' => $this->normalizeCountedScore($row['ss_participation_score'] ?? null, $counts['participation'] ?? [], $sectionId, $studentId),
|
||||
'midterm_exam' => $this->normalizeCountedScore($row['ss_midterm_exam_score'] ?? null, $counts['midterm'] ?? [], $sectionId, $studentId),
|
||||
'final_exam' => $this->normalizeScore($row['ss_final_exam_score'] ?? null),
|
||||
'matched_biz_csid' => $row['matched_biz_csid'] ?? null,
|
||||
'matched_pk_csid' => $row['matched_pk_csid'] ?? null,
|
||||
'placement_level' => $row['placement_level'] ?? null,
|
||||
];
|
||||
|
||||
$studentsBySection[$sectionId][] = $studentRow;
|
||||
}
|
||||
|
||||
$scoreLocks = $this->loadScoreLocks($grades, $semester, $schoolYear);
|
||||
|
||||
return [
|
||||
'grades' => $grades,
|
||||
'students_by_section' => $studentsBySection,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'requested_class_id' => $classId,
|
||||
'semester_options' => $semesterOptions,
|
||||
'scores_released' => $scoresReleased,
|
||||
'scores_released_fall' => $scoresReleasedFall,
|
||||
'scores_released_spring' => $scoresReleasedSpring,
|
||||
'score_locks' => $scoreLocks,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildGradingRows(string $semester, string $schoolYear): array
|
||||
{
|
||||
$semLower = strtolower(trim($semester));
|
||||
|
||||
return DB::table('student_class as sc')
|
||||
->select([
|
||||
'cs.id as section_pk',
|
||||
'cs.class_section_id as section_id',
|
||||
'cs.class_id as class_id',
|
||||
'cs.class_section_name',
|
||||
's.id as student_id',
|
||||
's.school_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
'pl.level as placement_level',
|
||||
DB::raw('COALESCE(ss_b.ptap_score, ss_p.ptap_score) as ss_ptap_score'),
|
||||
DB::raw('COALESCE(ss_b.semester_score, ss_p.semester_score) as ss_semester_score'),
|
||||
DB::raw('COALESCE(ss_b.attendance_score, ss_p.attendance_score) as ss_attendance_score'),
|
||||
DB::raw('COALESCE(ss_b.homework_avg, ss_p.homework_avg) as ss_homework_avg'),
|
||||
DB::raw('COALESCE(ss_b.project_avg, ss_p.project_avg) as ss_project_avg'),
|
||||
DB::raw('COALESCE(ss_b.quiz_avg, ss_p.quiz_avg) as ss_quiz_avg'),
|
||||
DB::raw('COALESCE(ss_b.participation_score, ss_p.participation_score) as ss_participation_score'),
|
||||
DB::raw('COALESCE(ss_b.midterm_exam_score, ss_p.midterm_exam_score) as ss_midterm_exam_score'),
|
||||
DB::raw('COALESCE(ss_b.final_exam_score, ss_p.final_exam_score) as ss_final_exam_score'),
|
||||
'ss_b.class_section_id as matched_biz_csid',
|
||||
'ss_p.class_section_id as matched_pk_csid',
|
||||
])
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->join('students as s', 's.id', '=', 'sc.student_id')
|
||||
->leftJoin('placement_levels as pl', function ($join) use ($schoolYear) {
|
||||
$join->on('pl.student_id', '=', 's.id')
|
||||
->where('pl.school_year', '=', $schoolYear);
|
||||
})
|
||||
->leftJoin('semester_scores as ss_b', function ($join) use ($semLower, $schoolYear) {
|
||||
$join->on('ss_b.student_id', '=', 's.id')
|
||||
->on('ss_b.class_section_id', '=', 'sc.class_section_id')
|
||||
->whereRaw('LOWER(TRIM(ss_b.semester)) = ?', [$semLower])
|
||||
->where('ss_b.school_year', '=', $schoolYear);
|
||||
})
|
||||
->leftJoin('semester_scores as ss_p', function ($join) use ($semLower, $schoolYear) {
|
||||
$join->on('ss_p.student_id', '=', 's.id')
|
||||
->on('ss_p.class_section_id', '=', 'cs.id')
|
||||
->whereRaw('LOWER(TRIM(ss_p.semester)) = ?', [$semLower])
|
||||
->where('ss_p.school_year', '=', $schoolYear);
|
||||
})
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->where('s.is_active', 1)
|
||||
->orderBy('cs.class_id', 'ASC')
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
|
||||
private function loadScoreCounts(array $rows, string $semester, string $schoolYear): array
|
||||
{
|
||||
$sectionIds = [];
|
||||
$studentIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$studentId = (int) ($row['student_id'] ?? 0);
|
||||
$sectionId = (int) ($row['section_id'] ?? 0);
|
||||
if ($studentId > 0) {
|
||||
$studentIds[$studentId] = true;
|
||||
}
|
||||
if ($sectionId > 0) {
|
||||
$sectionIds[$sectionId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$sectionIds = array_keys($sectionIds);
|
||||
$studentIds = array_keys($studentIds);
|
||||
if (empty($sectionIds) || empty($studentIds)) {
|
||||
return [
|
||||
'quiz' => [],
|
||||
'homework' => [],
|
||||
'project' => [],
|
||||
'participation' => [],
|
||||
'midterm' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'quiz' => $this->countScores('quiz', $sectionIds, $studentIds, $semester, $schoolYear),
|
||||
'homework' => $this->countScores('homework', $sectionIds, $studentIds, $semester, $schoolYear),
|
||||
'project' => $this->countScores('project', $sectionIds, $studentIds, $semester, $schoolYear),
|
||||
'participation' => $this->countScores('participation', $sectionIds, $studentIds, $semester, $schoolYear),
|
||||
'midterm' => $this->countScores('midterm_exam', $sectionIds, $studentIds, $semester, $schoolYear),
|
||||
];
|
||||
}
|
||||
|
||||
private function countScores(string $table, array $sectionIds, array $studentIds, string $semester, string $schoolYear): array
|
||||
{
|
||||
$rows = DB::table($table)
|
||||
->select('student_id', 'class_section_id', DB::raw('COUNT(*) as cnt'))
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('class_section_id', $sectionIds)
|
||||
->whereIn('student_id', $studentIds)
|
||||
->whereNotNull('score')
|
||||
->groupBy('student_id', 'class_section_id')
|
||||
->get();
|
||||
|
||||
$counts = [];
|
||||
foreach ($rows as $row) {
|
||||
$counts[(int) $row->class_section_id][(int) $row->student_id] = (int) $row->cnt;
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function loadScoreLocks(array $grades, string $semester, string $schoolYear): array
|
||||
{
|
||||
$sectionIds = [];
|
||||
foreach ($grades as $sections) {
|
||||
foreach ($sections as $section) {
|
||||
$sid = (int) ($section['class_section_id'] ?? 0);
|
||||
if ($sid > 0) {
|
||||
$sectionIds[$sid] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$sectionIds = array_keys($sectionIds);
|
||||
if (empty($sectionIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$locks = GradingLock::query()
|
||||
->whereIn('class_section_id', $sectionIds)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
|
||||
$map = [];
|
||||
foreach ($locks as $lock) {
|
||||
$map[(int) $lock->class_section_id] = (bool) $lock->is_locked;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function calculateAttendanceScoreForStudent(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): ?float
|
||||
{
|
||||
try {
|
||||
$result = $this->attendanceCalculator->calculate($studentId, $semester, $schoolYear, $classSectionId);
|
||||
$score = $result['attendance_score'] ?? null;
|
||||
if ($score === null || $score === '') {
|
||||
return null;
|
||||
}
|
||||
return round((float) $score, 2);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeScore($value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
return round((float) $value, 2);
|
||||
}
|
||||
|
||||
private function normalizeCountedScore($value, array $countMap, int $sectionId, int $studentId): ?float
|
||||
{
|
||||
$score = $this->normalizeScore($value);
|
||||
if ($score !== null && (float) $score === 0.0) {
|
||||
$count = (int) ($countMap[$sectionId][$studentId] ?? 0);
|
||||
if ($count === 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return $score;
|
||||
}
|
||||
|
||||
private function resolveSemesterSelection(?string $requestedSemester, array $semesterOptions, ?string $fallbackSemester): string
|
||||
{
|
||||
if ($requestedSemester !== null && $requestedSemester !== '') {
|
||||
foreach ($semesterOptions as $option) {
|
||||
if (strcasecmp($option, $requestedSemester) === 0) {
|
||||
return $option;
|
||||
}
|
||||
}
|
||||
return $requestedSemester;
|
||||
}
|
||||
if ($fallbackSemester !== null && $fallbackSemester !== '') {
|
||||
foreach ($semesterOptions as $option) {
|
||||
if (strcasecmp($option, $fallbackSemester) === 0) {
|
||||
return $option;
|
||||
}
|
||||
}
|
||||
return $fallbackSemester;
|
||||
}
|
||||
return $semesterOptions[0] ?? '';
|
||||
}
|
||||
|
||||
private function getSemestersForSchoolYear(string $schoolYear, ?string $fallbackSemester = null): array
|
||||
{
|
||||
$rows = DB::table('semester_scores')
|
||||
->select(DB::raw('DISTINCT semester'))
|
||||
->whereNotNull('semester')
|
||||
->where('semester', '!=', '')
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('semester', 'ASC')
|
||||
->get();
|
||||
|
||||
$semesters = [];
|
||||
foreach ($rows as $row) {
|
||||
$value = trim((string) $row->semester);
|
||||
if ($value !== '') {
|
||||
$semesters[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($semesters)) {
|
||||
$semesters = ['Fall', 'Spring'];
|
||||
}
|
||||
|
||||
if ($fallbackSemester !== null && $fallbackSemester !== '' && !in_array($fallbackSemester, $semesters, true)) {
|
||||
array_unshift($semesters, $fallbackSemester);
|
||||
}
|
||||
|
||||
return array_values(array_unique($semesters));
|
||||
}
|
||||
|
||||
private function getParentReleaseKey(string $semester): ?string
|
||||
{
|
||||
$norm = strtolower(trim($semester));
|
||||
if ($norm === 'fall') {
|
||||
return 'parent_scores_released_fall';
|
||||
}
|
||||
if ($norm === 'spring') {
|
||||
return 'parent_scores_released_spring';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getParentScoresReleasedForSemester(string $semester): bool
|
||||
{
|
||||
$key = $this->getParentReleaseKey($semester);
|
||||
$raw = $key ? Configuration::getConfig($key) : null;
|
||||
$raw = (string) ($raw ?? '');
|
||||
return in_array(strtolower(trim($raw)), ['1', 'true', 'yes', 'y', 'on'], true);
|
||||
}
|
||||
|
||||
private function ensureParentReleaseKeyExists(string $semester): void
|
||||
{
|
||||
$key = $this->getParentReleaseKey($semester);
|
||||
if (!$key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Configuration::getConfigValueByKey($key) === null) {
|
||||
Configuration::setConfigValueByKey($key, '0');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Grading;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\PlacementBatch;
|
||||
use App\Models\PlacementLevel;
|
||||
use App\Models\PlacementScore;
|
||||
use App\Models\Student;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class GradingPlacementService
|
||||
{
|
||||
public function placementContext(?int $classSectionId, string $schoolYear, ?string $placementTest, ?string $open): array
|
||||
{
|
||||
if (!$classSectionId) {
|
||||
$showStudents = ($placementTest !== '' && $open === '1');
|
||||
$students = $showStudents ? $this->fetchActiveStudentsWithSection($schoolYear) : [];
|
||||
$batches = $this->fetchPlacementBatches($schoolYear);
|
||||
$batchDetails = $this->fetchPlacementBatchDetails($batches, $schoolYear);
|
||||
|
||||
return [
|
||||
'school_year' => $schoolYear,
|
||||
'students' => $students,
|
||||
'batches' => $batches,
|
||||
'batch_details' => $batchDetails,
|
||||
'placement_test' => $placementTest,
|
||||
'show_students' => $showStudents,
|
||||
];
|
||||
}
|
||||
|
||||
$sectionName = ClassSection::query()->where('class_section_id', $classSectionId)->value('class_section_name');
|
||||
$classId = ClassSection::query()->where('class_section_id', $classSectionId)->value('class_id');
|
||||
|
||||
$students = Student::getStudentInfoByClassSectionId($classSectionId, null, $schoolYear);
|
||||
$studentIds = array_values(array_filter(array_map(
|
||||
static fn ($row) => (int) ($row['student_id'] ?? 0),
|
||||
$students
|
||||
), static fn ($id) => $id > 0));
|
||||
|
||||
$levels = [];
|
||||
if (!empty($studentIds)) {
|
||||
$rows = PlacementLevel::query()
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
foreach ($rows as $row) {
|
||||
$levels[(int) $row->student_id] = $row->level ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'class_section_id' => $classSectionId,
|
||||
'class_section_name' => $sectionName ?? '',
|
||||
'class_id' => $classId,
|
||||
'school_year' => $schoolYear,
|
||||
'students' => $students,
|
||||
'levels' => $levels,
|
||||
];
|
||||
}
|
||||
|
||||
public function updatePlacementLevel(int $studentId, string $schoolYear, $level, ?int $userId): void
|
||||
{
|
||||
if ($studentId <= 0 || $schoolYear === '') {
|
||||
throw new RuntimeException('Missing student or school year.');
|
||||
}
|
||||
|
||||
$level = $level === '' || $level === null ? null : (int) $level;
|
||||
if ($level !== null && !in_array($level, [1, 2, 3], true)) {
|
||||
throw new RuntimeException('Invalid placement level.');
|
||||
}
|
||||
|
||||
$existing = PlacementLevel::query()
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->first();
|
||||
|
||||
if ($level === null) {
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'student_id' => $studentId,
|
||||
'school_year' => $schoolYear,
|
||||
'level' => $level,
|
||||
'updated_by' => $userId,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$existing->update($payload);
|
||||
} else {
|
||||
$payload['created_by'] = $userId;
|
||||
PlacementLevel::query()->create($payload);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatePlacementLevels(int $classSectionId, string $schoolYear, array $levels, ?int $userId): void
|
||||
{
|
||||
if ($classSectionId <= 0 || $schoolYear === '') {
|
||||
throw new RuntimeException('Missing class section or school year.');
|
||||
}
|
||||
|
||||
$students = Student::getStudentInfoByClassSectionId($classSectionId, null, $schoolYear);
|
||||
$validIds = array_values(array_filter(array_map(
|
||||
static fn ($row) => (int) ($row['student_id'] ?? 0),
|
||||
$students
|
||||
), static fn ($id) => $id > 0));
|
||||
|
||||
$validSet = array_flip($validIds);
|
||||
$existingRows = [];
|
||||
if (!empty($validIds)) {
|
||||
$rows = PlacementLevel::query()
|
||||
->whereIn('student_id', $validIds)
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
foreach ($rows as $row) {
|
||||
$existingRows[(int) $row->student_id] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($levels as $studentIdRaw => $levelRaw) {
|
||||
$studentId = (int) $studentIdRaw;
|
||||
if (!isset($validSet[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
$levelRaw = trim((string) $levelRaw);
|
||||
$level = $levelRaw === '' ? null : (int) $levelRaw;
|
||||
if ($level !== null && !in_array($level, [1, 2, 3], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($level === null) {
|
||||
if (isset($existingRows[$studentId])) {
|
||||
$existingRows[$studentId]->delete();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'student_id' => $studentId,
|
||||
'school_year' => $schoolYear,
|
||||
'level' => $level,
|
||||
'updated_by' => $userId,
|
||||
];
|
||||
|
||||
if (isset($existingRows[$studentId])) {
|
||||
$existingRows[$studentId]->update($payload);
|
||||
} else {
|
||||
$payload['created_by'] = $userId;
|
||||
PlacementLevel::query()->create($payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function createPlacementBatch(string $schoolYear, string $placementTest, array $levels, ?int $userId): int
|
||||
{
|
||||
if ($schoolYear === '' || $placementTest === '') {
|
||||
throw new RuntimeException('Missing placement test or school year.');
|
||||
}
|
||||
|
||||
$students = $this->fetchActiveStudentsWithSection($schoolYear);
|
||||
$validIds = array_values(array_filter(array_map(
|
||||
static fn ($row) => (int) ($row['student_id'] ?? 0),
|
||||
$students
|
||||
), static fn ($id) => $id > 0));
|
||||
$validSet = array_flip($validIds);
|
||||
|
||||
$batch = PlacementBatch::query()->create([
|
||||
'placement_test' => $placementTest,
|
||||
'school_year' => $schoolYear,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
$savedCount = 0;
|
||||
foreach ($levels as $studentIdRaw => $levelRaw) {
|
||||
$studentId = (int) $studentIdRaw;
|
||||
if (!isset($validSet[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
$levelRaw = trim((string) $levelRaw);
|
||||
if ($levelRaw === '') {
|
||||
continue;
|
||||
}
|
||||
$score = (int) $levelRaw;
|
||||
if ($score < 0 || $score > 100) {
|
||||
continue;
|
||||
}
|
||||
|
||||
PlacementScore::query()->create([
|
||||
'batch_id' => (int) $batch->id,
|
||||
'student_id' => $studentId,
|
||||
'score' => $score,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$savedCount++;
|
||||
}
|
||||
|
||||
if ($savedCount === 0) {
|
||||
$batch->delete();
|
||||
throw new RuntimeException('No scores entered. Batch not saved.');
|
||||
}
|
||||
|
||||
return (int) $batch->id;
|
||||
}
|
||||
|
||||
public function getPlacementBatch(int $batchId): array
|
||||
{
|
||||
$batch = PlacementBatch::query()->find($batchId);
|
||||
if (!$batch) {
|
||||
throw new RuntimeException('Placement batch not found.');
|
||||
}
|
||||
|
||||
$schoolYear = (string) ($batch->school_year ?? '');
|
||||
$students = $this->fetchActiveStudentsWithSection($schoolYear);
|
||||
$scores = $this->fetchPlacementScoresForBatch($batchId);
|
||||
|
||||
return [
|
||||
'batch' => $batch,
|
||||
'students' => $students,
|
||||
'scores' => $scores,
|
||||
];
|
||||
}
|
||||
|
||||
public function updatePlacementBatch(int $batchId, string $schoolYear, array $levels, ?int $userId): void
|
||||
{
|
||||
$batch = PlacementBatch::query()->find($batchId);
|
||||
if (!$batch) {
|
||||
throw new RuntimeException('Placement batch not found.');
|
||||
}
|
||||
|
||||
$students = $this->fetchActiveStudentsWithSection($schoolYear);
|
||||
$validIds = array_values(array_filter(array_map(
|
||||
static fn ($row) => (int) ($row['student_id'] ?? 0),
|
||||
$students
|
||||
), static fn ($id) => $id > 0));
|
||||
|
||||
$validSet = array_flip($validIds);
|
||||
$existing = $this->fetchPlacementScoresForBatch($batchId);
|
||||
|
||||
foreach ($levels as $studentIdRaw => $scoreRaw) {
|
||||
$studentId = (int) $studentIdRaw;
|
||||
if (!isset($validSet[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
$scoreRaw = trim((string) $scoreRaw);
|
||||
if ($scoreRaw === '') {
|
||||
if (isset($existing[$studentId])) {
|
||||
PlacementScore::query()->whereKey($existing[$studentId]['id'])->delete();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$score = (int) $scoreRaw;
|
||||
if ($score < 0 || $score > 100) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($existing[$studentId])) {
|
||||
PlacementScore::query()->whereKey($existing[$studentId]['id'])->update([
|
||||
'score' => $score,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
} else {
|
||||
PlacementScore::query()->create([
|
||||
'batch_id' => $batchId,
|
||||
'student_id' => $studentId,
|
||||
'score' => $score,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$batch->update([
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function fetchActiveStudentsWithSection(string $schoolYear): array
|
||||
{
|
||||
return DB::table('students as s')
|
||||
->select('s.id as student_id', 's.school_id', 's.firstname', 's.lastname', 'sc.class_section_id', 'cs.class_section_name', 'c.class_name')
|
||||
->leftJoin('student_class as sc', function ($join) use ($schoolYear) {
|
||||
$join->on('sc.student_id', '=', 's.id')
|
||||
->where('sc.school_year', '=', $schoolYear);
|
||||
})
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->leftJoin('classes as c', 'c.id', '=', 'cs.class_id')
|
||||
->where('s.is_active', 1)
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
|
||||
private function fetchPlacementBatches(string $schoolYear): array
|
||||
{
|
||||
return PlacementBatch::query()
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
|
||||
private function fetchPlacementBatchDetails(array $batches, string $schoolYear): array
|
||||
{
|
||||
if (empty($batches)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$batchIds = array_values(array_filter(array_map(
|
||||
static fn ($row) => (int) ($row['id'] ?? 0),
|
||||
$batches
|
||||
), static fn ($id) => $id > 0));
|
||||
|
||||
if (empty($batchIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::table('placement_scores as ps')
|
||||
->select('ps.batch_id', 'ps.score', 's.school_id', 's.firstname', 's.lastname', 'cs.class_section_name', 'c.class_name')
|
||||
->join('students as s', 's.id', '=', 'ps.student_id')
|
||||
->leftJoin('student_class as sc', function ($join) use ($schoolYear) {
|
||||
$join->on('sc.student_id', '=', 's.id')
|
||||
->where('sc.school_year', '=', $schoolYear);
|
||||
})
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->leftJoin('classes as c', 'c.id', '=', 'cs.class_id')
|
||||
->whereIn('ps.batch_id', $batchIds)
|
||||
->where('s.is_active', 1)
|
||||
->orderBy('ps.batch_id', 'ASC')
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$details = [];
|
||||
foreach ($rows as $row) {
|
||||
$bid = (int) ($row['batch_id'] ?? 0);
|
||||
if ($bid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$details[$bid][] = $row;
|
||||
}
|
||||
|
||||
return $details;
|
||||
}
|
||||
|
||||
private function fetchPlacementScoresForBatch(int $batchId): array
|
||||
{
|
||||
$rows = PlacementScore::query()
|
||||
->where('batch_id', $batchId)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$scores = [];
|
||||
foreach ($rows as $row) {
|
||||
$scores[(int) $row['student_id']] = $row;
|
||||
}
|
||||
|
||||
return $scores;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Grading;
|
||||
|
||||
use App\Models\ScoreComment;
|
||||
use App\Models\SemesterScore;
|
||||
use App\Models\Student;
|
||||
use App\Services\Scores\SemesterScoreService;
|
||||
use RuntimeException;
|
||||
|
||||
class GradingRefreshService
|
||||
{
|
||||
public function __construct(private SemesterScoreService $semesterScoreService)
|
||||
{
|
||||
}
|
||||
|
||||
public function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void
|
||||
{
|
||||
if ($classSectionId <= 0) {
|
||||
throw new RuntimeException('Missing class section.');
|
||||
}
|
||||
|
||||
$studentTeacherInfo = Student::getStudentInfoByClassSectionId(
|
||||
$classSectionId,
|
||||
$semester,
|
||||
$schoolYear
|
||||
);
|
||||
if (empty($studentTeacherInfo)) {
|
||||
throw new RuntimeException('No students found for this class/term.');
|
||||
}
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
$this->refreshAttendanceComments($classSectionId, $semester, $schoolYear);
|
||||
}
|
||||
|
||||
private function refreshAttendanceComments(int $classSectionId, string $semester, string $schoolYear): void
|
||||
{
|
||||
if (!function_exists('attendance_comment_from_score')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scoreRows = SemesterScore::query()
|
||||
->select(['student_id', 'attendance_score'])
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
|
||||
if ($scoreRows->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$studentIds = $scoreRows->pluck('student_id')->map(fn ($v) => (int) $v)->unique()->values()->all();
|
||||
if (empty($studentIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$students = Student::query()
|
||||
->select(['id', 'firstname'])
|
||||
->whereIn('id', $studentIds)
|
||||
->get();
|
||||
$nameMap = [];
|
||||
foreach ($students as $student) {
|
||||
$nameMap[(int) $student->id] = (string) ($student->firstname ?? '');
|
||||
}
|
||||
|
||||
$existing = ScoreComment::query()
|
||||
->where('score_type', 'attendance')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('student_id', $studentIds)
|
||||
->get();
|
||||
$existingByStudent = [];
|
||||
foreach ($existing as $row) {
|
||||
$existingByStudent[(int) $row->student_id] = $row;
|
||||
}
|
||||
|
||||
foreach ($scoreRows as $row) {
|
||||
$studentId = (int) $row->student_id;
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$score = $row->attendance_score !== null ? (float) $row->attendance_score : null;
|
||||
if ($score === null) {
|
||||
continue;
|
||||
}
|
||||
$auto = attendance_comment_from_score($score, $nameMap[$studentId] ?? '');
|
||||
if ($auto === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($existingByStudent[$studentId])) {
|
||||
$existingByStudent[$studentId]->update([
|
||||
'comment' => $auto,
|
||||
]);
|
||||
} else {
|
||||
ScoreComment::query()->create([
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'score_type' => 'attendance',
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'comment' => $auto,
|
||||
'commented_by' => null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Grading;
|
||||
|
||||
use App\Models\FinalExam;
|
||||
use App\Models\GradingLock;
|
||||
use App\Models\Homework;
|
||||
use App\Models\MidtermExam;
|
||||
use App\Models\Project;
|
||||
use App\Models\Quiz;
|
||||
use App\Models\ScoreComment;
|
||||
use App\Models\SemesterScore;
|
||||
use App\Models\Student;
|
||||
use App\Services\Scores\SemesterScoreService;
|
||||
use RuntimeException;
|
||||
|
||||
class GradingScoreService
|
||||
{
|
||||
public function __construct(private SemesterScoreService $semesterScoreService)
|
||||
{
|
||||
}
|
||||
|
||||
public function show(string $type, int $classSectionId, int $studentId, string $semester, string $schoolYear): array
|
||||
{
|
||||
$model = $this->resolveModel($type);
|
||||
$student = Student::query()->find($studentId);
|
||||
|
||||
if (!$student) {
|
||||
throw new RuntimeException('Student not found.');
|
||||
}
|
||||
|
||||
$scores = $model->newQuery()
|
||||
->where('student_id', $studentId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
|
||||
$scoresLocked = $classSectionId > 0
|
||||
? GradingLock::isLocked($classSectionId, $semester, $schoolYear)
|
||||
: false;
|
||||
|
||||
return [
|
||||
'student' => $student,
|
||||
'scores' => $scores,
|
||||
'type' => $type,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $semester,
|
||||
'scores_locked' => $scoresLocked,
|
||||
];
|
||||
}
|
||||
|
||||
public function update(array $payload, int $updatedBy): void
|
||||
{
|
||||
$type = (string) ($payload['type'] ?? '');
|
||||
$studentId = (int) ($payload['student_id'] ?? 0);
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? 0);
|
||||
$semester = (string) ($payload['semester'] ?? '');
|
||||
$schoolYear = (string) ($payload['school_year'] ?? '');
|
||||
|
||||
if ($classSectionId > 0 && GradingLock::isLocked($classSectionId, $semester, $schoolYear)) {
|
||||
throw new RuntimeException('Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
$model = $this->resolveModel($type);
|
||||
|
||||
if (in_array($type, ['homework', 'quiz', 'project'], true)) {
|
||||
$scoreIds = $payload['score_ids'] ?? [];
|
||||
$scores = $payload['scores'] ?? [];
|
||||
$comments = $payload['comments'] ?? [];
|
||||
|
||||
foreach ($scoreIds as $i => $id) {
|
||||
$model->newQuery()->whereKey($id)->update([
|
||||
'score' => $scores[$i] ?? null,
|
||||
'comment' => $comments[$i] ?? null,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
} elseif (in_array($type, ['midterm', 'final', 'test'], true)) {
|
||||
$score = $payload['score'] ?? null;
|
||||
|
||||
$data = [
|
||||
'score' => $score,
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
$existing = $model->newQuery()
|
||||
->where('student_id', $studentId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->update($data);
|
||||
} else {
|
||||
$model->newQuery()->create($data + [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
} elseif ($type === 'comments') {
|
||||
$comment = (string) ($payload['comment'] ?? '');
|
||||
|
||||
$model->newQuery()
|
||||
->where('student_id', $studentId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->delete();
|
||||
|
||||
$model->newQuery()->create([
|
||||
'student_id' => $studentId,
|
||||
'score_type' => 'general',
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'comment' => $comment,
|
||||
'commented_by' => $updatedBy ?: null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$studentTeacherInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, $updatedBy);
|
||||
if (!empty($studentTeacherInfo)) {
|
||||
try {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getScoreComments(string $semester, string $schoolYear): array
|
||||
{
|
||||
$studentIds = Student::query()
|
||||
->select('students.id')
|
||||
->join('student_class', 'student_class.student_id', '=', 'students.id')
|
||||
->where('student_class.semester', $semester)
|
||||
->where('student_class.school_year', $schoolYear)
|
||||
->pluck('students.id')
|
||||
->map(fn ($v) => (int) $v)
|
||||
->all();
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$scores = [];
|
||||
$students = Student::query()
|
||||
->whereIn('id', $studentIds)
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
|
||||
foreach ($students as $student) {
|
||||
$scores[$student->id] = [
|
||||
'school_id' => $student->school_id,
|
||||
'firstname' => $student->firstname,
|
||||
'lastname' => $student->lastname,
|
||||
'comments' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$commentRows = ScoreComment::query()
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
|
||||
foreach ($commentRows as $comment) {
|
||||
$scores[$comment->student_id]['comments'][] = $comment;
|
||||
}
|
||||
|
||||
return $scores;
|
||||
}
|
||||
|
||||
private function resolveModel(string $type)
|
||||
{
|
||||
return match ($type) {
|
||||
'homework' => new Homework(),
|
||||
'quiz' => new Quiz(),
|
||||
'project' => new Project(),
|
||||
'midterm' => new MidtermExam(),
|
||||
'final' => new FinalExam(),
|
||||
'test' => new SemesterScore(),
|
||||
'comments' => new ScoreComment(),
|
||||
default => throw new RuntimeException("Invalid type: {$type}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Grading;
|
||||
|
||||
use App\Models\CalendarEvent;
|
||||
use DateTime;
|
||||
|
||||
class HomeworkTrackingCalendarService
|
||||
{
|
||||
public function buildDateRange(string $schoolYear, string $semester): array
|
||||
{
|
||||
$startYear = null;
|
||||
if (preg_match('/\b(20\d{2})\b/', $schoolYear, $m)) {
|
||||
$startYear = (int) $m[1];
|
||||
}
|
||||
if ($startYear === null) {
|
||||
$today = new DateTime('today');
|
||||
return [$today, $today];
|
||||
}
|
||||
|
||||
$nextYear = $startYear + 1;
|
||||
$sem = strtolower(trim($semester));
|
||||
|
||||
try {
|
||||
if ($sem === 'spring') {
|
||||
$start = new DateTime("{$nextYear}-01-25");
|
||||
$end = new DateTime("{$nextYear}-05-31");
|
||||
} else {
|
||||
$start = new DateTime("{$startYear}-09-21");
|
||||
$end = new DateTime("{$nextYear}-01-18");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$today = new DateTime('today');
|
||||
return [$today, $today];
|
||||
}
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
|
||||
public function buildSundays(DateTime $start, DateTime $end): array
|
||||
{
|
||||
if ((int) $start->format('w') !== 0) {
|
||||
$start = (clone $start)->modify('next sunday');
|
||||
}
|
||||
|
||||
$sundays = [];
|
||||
$d = clone $start;
|
||||
while ($d <= $end) {
|
||||
$sundays[] = $d->format('Y-m-d');
|
||||
$d->modify('+7 days');
|
||||
}
|
||||
|
||||
return $sundays;
|
||||
}
|
||||
|
||||
public function eventDays(string $schoolYear): array
|
||||
{
|
||||
$events = CalendarEvent::getEventsBySchoolYear($schoolYear) ?? [];
|
||||
$eventDays = [];
|
||||
foreach ($events as $event) {
|
||||
$ymd = substr((string) ($event['date'] ?? ''), 0, 10);
|
||||
$no = (int) ($event['no_school'] ?? 0);
|
||||
if ($ymd && $no === 1) {
|
||||
$eventDays[$ymd] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $eventDays;
|
||||
}
|
||||
|
||||
public function dateToIndex(array $sundays, array $eventDays): array
|
||||
{
|
||||
$dateToIndex = [];
|
||||
$idx = 0;
|
||||
foreach ($sundays as $ymd) {
|
||||
if (!isset($eventDays[$ymd])) {
|
||||
$idx++;
|
||||
$dateToIndex[$ymd] = $idx;
|
||||
} else {
|
||||
$dateToIndex[$ymd] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $dateToIndex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Grading;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class HomeworkTrackingService
|
||||
{
|
||||
private array $teacherAssignmentCache = [];
|
||||
|
||||
public function __construct(private HomeworkTrackingCalendarService $calendarService)
|
||||
{
|
||||
}
|
||||
|
||||
public function report(?string $semester = null, ?string $schoolYear = null, int $page = 1): array
|
||||
{
|
||||
$semester = $semester ?: (string) (Configuration::getConfig('semester') ?? '');
|
||||
$schoolYear = $schoolYear ?: (string) (Configuration::getConfig('school_year') ?? '');
|
||||
|
||||
[$start, $end] = $this->calendarService->buildDateRange($schoolYear, $semester);
|
||||
$sundays = $this->calendarService->buildSundays($start, $end);
|
||||
$eventDays = $this->calendarService->eventDays($schoolYear);
|
||||
$dateToIndex = $this->calendarService->dateToIndex($sundays, $eventDays);
|
||||
|
||||
$limitToSemester = $this->hasTeacherAssignments($schoolYear);
|
||||
|
||||
$homeworkByIndex = $this->loadHomeworkByIndex($schoolYear, $semester, $limitToSemester);
|
||||
$homeworkByDate = $this->loadHomeworkByDate($schoolYear, $semester, $limitToSemester, $sundays, $eventDays);
|
||||
$teachers = $this->loadTeachers($schoolYear);
|
||||
|
||||
$perPage = 8;
|
||||
$totalRows = count($teachers);
|
||||
$totalPages = max(1, (int) ceil($totalRows / $perPage));
|
||||
$page = max(1, min($page, $totalPages));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$teachersPage = array_slice($teachers, $offset, $perPage);
|
||||
|
||||
return [
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'sundays' => $sundays,
|
||||
'event_days' => $eventDays,
|
||||
'date_to_index' => $dateToIndex,
|
||||
'teachers' => $teachersPage,
|
||||
'has_homework' => $homeworkByIndex['has_homework'],
|
||||
'hw_entered_at' => $homeworkByIndex['entered_at'],
|
||||
'has_homework_by_date' => $homeworkByDate['has_homework'],
|
||||
'hw_entered_at_by_date' => $homeworkByDate['entered_at'],
|
||||
'page' => $page,
|
||||
'total_pages' => $totalPages,
|
||||
'per_page' => $perPage,
|
||||
'total_rows' => $totalRows,
|
||||
];
|
||||
}
|
||||
|
||||
private function hasTeacherAssignments(string $schoolYear): bool
|
||||
{
|
||||
$year = trim((string) $schoolYear);
|
||||
if ($year === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (array_key_exists($year, $this->teacherAssignmentCache)) {
|
||||
return $this->teacherAssignmentCache[$year];
|
||||
}
|
||||
|
||||
$cnt = DB::table('teacher_class')->where('school_year', $year)->count();
|
||||
$this->teacherAssignmentCache[$year] = $cnt > 0;
|
||||
|
||||
return $this->teacherAssignmentCache[$year];
|
||||
}
|
||||
|
||||
private function loadHomeworkByIndex(string $schoolYear, string $semester, bool $limitToSemester): array
|
||||
{
|
||||
$query = DB::table('homework')
|
||||
->select('class_section_id', 'homework_index', DB::raw('MIN(created_at) as first_created'), DB::raw('COUNT(*) as cnt'))
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$query->where('semester', $semester);
|
||||
}
|
||||
|
||||
$rows = $query->groupBy('class_section_id', 'homework_index')->get();
|
||||
|
||||
$hasHomework = [];
|
||||
$enteredAt = [];
|
||||
foreach ($rows as $row) {
|
||||
$csid = (int) $row->class_section_id;
|
||||
$hi = (int) $row->homework_index;
|
||||
$cnt = (int) $row->cnt;
|
||||
if ($csid > 0 && $hi > 0 && $cnt > 0) {
|
||||
$hasHomework[$csid][$hi] = true;
|
||||
$dateStr = substr((string) ($row->first_created ?? ''), 0, 10);
|
||||
$enteredAt[$csid][$hi] = $dateStr ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
return ['has_homework' => $hasHomework, 'entered_at' => $enteredAt];
|
||||
}
|
||||
|
||||
private function loadHomeworkByDate(string $schoolYear, string $semester, bool $limitToSemester, array $sundays, array $eventDays): array
|
||||
{
|
||||
$query = DB::table('homework')
|
||||
->select(DB::raw('class_section_id, DATE(created_at) as hw_date, MIN(created_at) as first_created, COUNT(*) as cnt'))
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$query->where('semester', $semester);
|
||||
}
|
||||
|
||||
$rows = $query
|
||||
->groupBy('class_section_id', DB::raw('DATE(created_at)'))
|
||||
->orderBy('hw_date', 'ASC')
|
||||
->get();
|
||||
|
||||
$hasHomework = [];
|
||||
$enteredAt = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$csid = (int) $row->class_section_id;
|
||||
$date = substr((string) ($row->hw_date ?? ''), 0, 10);
|
||||
$cnt = (int) $row->cnt;
|
||||
$firstCreated = substr((string) ($row->first_created ?? ''), 0, 10);
|
||||
if ($csid <= 0 || !$date || $cnt <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$baseIndex = -1;
|
||||
for ($i = count($sundays) - 1; $i >= 0; $i--) {
|
||||
if ($sundays[$i] <= $date) {
|
||||
$baseIndex = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($baseIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$j = $baseIndex;
|
||||
while ($j >= 0 && !empty($eventDays[$sundays[$j]])) {
|
||||
$j--;
|
||||
}
|
||||
if ($j < 0) {
|
||||
continue;
|
||||
}
|
||||
$sunday = $sundays[$j];
|
||||
|
||||
$hasHomework[$csid][$sunday] = true;
|
||||
$candidate = $firstCreated ?: $date;
|
||||
if (empty($enteredAt[$csid][$sunday]) || ($candidate && $candidate < $enteredAt[$csid][$sunday])) {
|
||||
$enteredAt[$csid][$sunday] = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return ['has_homework' => $hasHomework, 'entered_at' => $enteredAt];
|
||||
}
|
||||
|
||||
private function loadTeachers(string $schoolYear): array
|
||||
{
|
||||
$rows = DB::table('teacher_class as tc')
|
||||
->select('tc.class_section_id', 'tc.position', 'cs.class_section_name', 'cs.class_id', 'u.firstname', 'u.lastname')
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'tc.class_section_id')
|
||||
->leftJoin('users as u', 'u.id', '=', 'tc.teacher_id')
|
||||
->where('tc.school_year', $schoolYear)
|
||||
->whereNotNull('tc.class_section_id')
|
||||
->whereIn('tc.position', ['main', 'ta'])
|
||||
->orderBy('cs.class_id', 'ASC')
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderByRaw("FIELD(tc.position, 'main','ta')")
|
||||
->orderBy('u.firstname', 'ASC')
|
||||
->get();
|
||||
|
||||
$bySection = [];
|
||||
foreach ($rows as $row) {
|
||||
$sid = (int) $row->class_section_id;
|
||||
if ($sid <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($bySection[$sid])) {
|
||||
$bySection[$sid] = [
|
||||
'class_section_id' => $sid,
|
||||
'class_id' => (int) ($row->class_id ?? 0),
|
||||
'class_section_name' => (string) ($row->class_section_name ?? ''),
|
||||
'teachers' => [],
|
||||
'tas' => [],
|
||||
];
|
||||
}
|
||||
$name = trim((string) ($row->firstname ?? '') . ' ' . (string) ($row->lastname ?? ''));
|
||||
$pos = strtolower((string) ($row->position ?? ''));
|
||||
if ($name !== '') {
|
||||
if ($pos === 'main') {
|
||||
$bySection[$sid]['teachers'][] = $name;
|
||||
} elseif ($pos === 'ta') {
|
||||
$bySection[$sid]['tas'][] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$teachers = array_values($bySection);
|
||||
usort($teachers, function ($a, $b) {
|
||||
$ai = (int) ($a['class_id'] ?? 0);
|
||||
$bi = (int) ($b['class_id'] ?? 0);
|
||||
$order = function (int $cid): int {
|
||||
if ($cid === 13) return 0;
|
||||
if ($cid >= 1 && $cid <= 11) return $cid;
|
||||
if ($cid === 12) return 99;
|
||||
return 200 + $cid;
|
||||
};
|
||||
$cmp = $order($ai) <=> $order($bi);
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
return strnatcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? '');
|
||||
});
|
||||
|
||||
return $teachers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Incidents;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\CurrentIncident;
|
||||
use App\Models\Incident;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CurrentIncidentService
|
||||
{
|
||||
public function __construct(private IncidentLookupService $lookup)
|
||||
{
|
||||
}
|
||||
|
||||
public function listCurrent(): array
|
||||
{
|
||||
$incidents = CurrentIncident::query()->get()->map(fn ($row) => $row->toArray())->all();
|
||||
$schoolYear = Configuration::getConfig('school_year');
|
||||
|
||||
$updaterIds = [];
|
||||
foreach ($incidents as $incident) {
|
||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
||||
if (!empty($incident[$key])) {
|
||||
$updaterIds[] = (int) $incident[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$updaterMap = $this->lookup->updaterNameMap($updaterIds);
|
||||
foreach ($incidents as &$incident) {
|
||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
||||
$id = (int) ($incident[$key] ?? 0);
|
||||
$incident[$key . '_name'] = $id > 0 ? ($updaterMap[$id] ?? null) : null;
|
||||
}
|
||||
}
|
||||
unset($incident);
|
||||
|
||||
return [
|
||||
'incidents' => $incidents,
|
||||
'grades' => $this->lookup->gradeOptionsWithActiveStudents($schoolYear),
|
||||
];
|
||||
}
|
||||
|
||||
public function studentsByGrade(int $gradeId): array
|
||||
{
|
||||
$studentIds = StudentClass::query()
|
||||
->active()
|
||||
->where('student_class.class_section_id', $gradeId)
|
||||
->pluck('student_id')
|
||||
->all();
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Student::query()
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 1)
|
||||
->get()
|
||||
->map(fn ($student) => [
|
||||
'id' => (int) $student->id,
|
||||
'name' => trim((string) $student->firstname . ' ' . (string) $student->lastname),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
public function addIncident(array $payload, int $userId): array
|
||||
{
|
||||
$studentId = (int) $payload['student_id'];
|
||||
$incidentType = (string) $payload['incident'];
|
||||
$description = (string) ($payload['description'] ?? '');
|
||||
$incidentState = (string) ($payload['incident_state'] ?? 'Open');
|
||||
$stateDescription = (string) ($payload['state_description'] ?? '');
|
||||
$grade = (string) $payload['grade'];
|
||||
|
||||
$student = Student::query()->find($studentId);
|
||||
if (!$student) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Student not found.',
|
||||
];
|
||||
}
|
||||
|
||||
$semester = Configuration::getConfig('semester');
|
||||
$schoolYear = Configuration::getConfig('school_year');
|
||||
$currentDateTime = function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString();
|
||||
|
||||
$existingIncident = CurrentIncident::query()
|
||||
->where('student_id', $studentId)
|
||||
->where('incident', $incidentType)
|
||||
->first();
|
||||
|
||||
if ($existingIncident) {
|
||||
$data = [
|
||||
'incident_datetime' => $currentDateTime,
|
||||
'updated_by_open' => $userId,
|
||||
];
|
||||
|
||||
if ($incidentState === 'Closed') {
|
||||
$data['incident_state'] = 'Closed';
|
||||
$data['close_description'] = $this->appendLine($existingIncident->close_description ?? '', $stateDescription);
|
||||
} elseif ($incidentState === 'Canceled') {
|
||||
$data['incident_state'] = 'Canceled';
|
||||
$data['cancel_description'] = $this->appendLine($existingIncident->cancel_description ?? '', $stateDescription);
|
||||
} else {
|
||||
$data['open_description'] = $this->appendLine($existingIncident->open_description ?? '', $description);
|
||||
}
|
||||
|
||||
$existingIncident->fill($data);
|
||||
$existingIncident->save();
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'created' => false,
|
||||
'incident_id' => (int) $existingIncident->id,
|
||||
];
|
||||
}
|
||||
|
||||
$incident = CurrentIncident::query()->create([
|
||||
'student_id' => $student->id,
|
||||
'student_name' => trim((string) $student->firstname . ' ' . (string) $student->lastname),
|
||||
'grade' => $grade,
|
||||
'incident' => $incidentType,
|
||||
'incident_datetime' => $currentDateTime,
|
||||
'incident_state' => 'Open',
|
||||
'updated_by_open' => $userId,
|
||||
'open_description' => $description,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'created' => true,
|
||||
'incident_id' => (int) $incident->id,
|
||||
];
|
||||
}
|
||||
|
||||
public function updateState(int $incidentId, string $newState): bool
|
||||
{
|
||||
return CurrentIncident::query()->whereKey($incidentId)->update([
|
||||
'incident_state' => $newState,
|
||||
]) > 0;
|
||||
}
|
||||
|
||||
public function closeIncident(int $incidentId, string $closeDescription, ?string $actionTaken, int $userId): array
|
||||
{
|
||||
$incident = CurrentIncident::query()->find($incidentId);
|
||||
if (!$incident) {
|
||||
return ['ok' => false, 'message' => 'Incident not found.'];
|
||||
}
|
||||
|
||||
if ($incident->incident_state === 'Closed') {
|
||||
return ['ok' => false, 'message' => 'Incident is already closed.'];
|
||||
}
|
||||
|
||||
$incident->fill([
|
||||
'incident_state' => 'Closed',
|
||||
'updated_by_closed' => $userId,
|
||||
'close_description' => $closeDescription,
|
||||
'action_taken' => $actionTaken,
|
||||
]);
|
||||
$incident->save();
|
||||
|
||||
return $this->moveToHistory($incident);
|
||||
}
|
||||
|
||||
public function cancelIncident(int $incidentId, ?string $cancelDescription, ?string $actionTaken, int $userId): array
|
||||
{
|
||||
$incident = CurrentIncident::query()->find($incidentId);
|
||||
if (!$incident) {
|
||||
return ['ok' => false, 'message' => 'Incident not found.'];
|
||||
}
|
||||
|
||||
if ($incident->incident_state === 'Canceled') {
|
||||
return ['ok' => false, 'message' => 'Incident is already canceled.'];
|
||||
}
|
||||
|
||||
$incident->fill([
|
||||
'incident_state' => 'Canceled',
|
||||
'updated_by_canceled' => $userId,
|
||||
'cancel_description' => $cancelDescription,
|
||||
'action_taken' => $actionTaken,
|
||||
]);
|
||||
$incident->save();
|
||||
|
||||
return $this->moveToHistory($incident);
|
||||
}
|
||||
|
||||
private function moveToHistory(CurrentIncident $incident): array
|
||||
{
|
||||
return DB::transaction(function () use ($incident) {
|
||||
$history = Incident::query()->create([
|
||||
'student_id' => $incident->student_id,
|
||||
'student_name' => $incident->student_name,
|
||||
'grade' => $incident->grade,
|
||||
'incident' => $incident->incident,
|
||||
'incident_datetime' => $incident->incident_datetime,
|
||||
'incident_state' => $incident->incident_state,
|
||||
'updated_by_open' => $incident->updated_by_open,
|
||||
'open_description' => $incident->open_description,
|
||||
'updated_by_closed' => $incident->updated_by_closed,
|
||||
'close_description' => $incident->close_description,
|
||||
'action_taken' => $incident->action_taken,
|
||||
'updated_by_canceled' => $incident->updated_by_canceled,
|
||||
'cancel_description' => $incident->cancel_description,
|
||||
'semester' => $incident->semester,
|
||||
'school_year' => $incident->school_year,
|
||||
'created_at' => $incident->created_at,
|
||||
'updated_at' => $incident->updated_at,
|
||||
]);
|
||||
|
||||
$incident->delete();
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'incident_id' => (int) $history->id,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function appendLine(string $existing, string $append): string
|
||||
{
|
||||
if ($existing === '') {
|
||||
return $append;
|
||||
}
|
||||
|
||||
return $existing . PHP_EOL . $append;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Incidents;
|
||||
|
||||
use App\Models\Incident;
|
||||
|
||||
class IncidentAnalysisService
|
||||
{
|
||||
public function __construct(private IncidentLookupService $lookup)
|
||||
{
|
||||
}
|
||||
|
||||
public function analyze(?string $schoolYear, ?string $semester): array
|
||||
{
|
||||
$builder = Incident::query()->orderBy('incident_datetime', 'DESC');
|
||||
if ($schoolYear !== null && $schoolYear !== '') {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($semester !== null && $semester !== '') {
|
||||
$builder->where('semester', $semester);
|
||||
}
|
||||
|
||||
$incidents = $builder->get()->map(fn ($row) => $row->toArray())->all();
|
||||
if (empty($incidents)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$gradeMap = $this->lookup->gradeNameMap(array_column($incidents, 'grade'));
|
||||
$students = [];
|
||||
|
||||
foreach ($incidents as $incident) {
|
||||
$studentId = (string) ($incident['student_id'] ?? '');
|
||||
$studentName = (string) ($incident['student_name'] ?? '');
|
||||
$studentKey = $studentId !== '' ? 'id:' . $studentId : 'name:' . strtolower(trim($studentName));
|
||||
|
||||
$gradeValue = $incident['grade'] ?? '';
|
||||
$gradeLabel = $gradeValue;
|
||||
if (is_numeric($gradeValue)) {
|
||||
$gradeLabel = $gradeMap[(int) $gradeValue] ?? (string) $gradeValue;
|
||||
}
|
||||
|
||||
if (!isset($students[$studentKey])) {
|
||||
$students[$studentKey] = [
|
||||
'student_id' => $studentId,
|
||||
'student_name' => $studentName,
|
||||
'grade' => (string) $gradeLabel,
|
||||
'total' => 0,
|
||||
'open' => 0,
|
||||
'closed' => 0,
|
||||
'canceled' => 0,
|
||||
'last_incident' => null,
|
||||
'logs' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$students[$studentKey]['total'] += 1;
|
||||
|
||||
$state = (string) ($incident['incident_state'] ?? '');
|
||||
if ($state === 'Open') {
|
||||
$students[$studentKey]['open'] += 1;
|
||||
} elseif ($state === 'Closed') {
|
||||
$students[$studentKey]['closed'] += 1;
|
||||
} elseif ($state === 'Canceled') {
|
||||
$students[$studentKey]['canceled'] += 1;
|
||||
}
|
||||
|
||||
if ($students[$studentKey]['last_incident'] === null) {
|
||||
$students[$studentKey]['last_incident'] = $incident['incident_datetime'] ?? null;
|
||||
}
|
||||
|
||||
$students[$studentKey]['logs'][] = [
|
||||
'incident' => $incident['incident'] ?? '',
|
||||
'incident_state' => $state,
|
||||
'incident_datetime' => $incident['incident_datetime'] ?? null,
|
||||
'open_description' => $incident['open_description'] ?? null,
|
||||
'close_description' => $incident['close_description'] ?? null,
|
||||
'cancel_description' => $incident['cancel_description'] ?? null,
|
||||
'action_taken' => $incident['action_taken'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$studentRows = array_values($students);
|
||||
usort($studentRows, function ($a, $b) {
|
||||
if ($a['total'] === $b['total']) {
|
||||
return strcasecmp($a['student_name'], $b['student_name']);
|
||||
}
|
||||
return $b['total'] <=> $a['total'];
|
||||
});
|
||||
|
||||
return $studentRows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Incidents;
|
||||
|
||||
use App\Models\Incident;
|
||||
|
||||
class IncidentHistoryService
|
||||
{
|
||||
public function __construct(private IncidentLookupService $lookup)
|
||||
{
|
||||
}
|
||||
|
||||
public function history(?string $schoolYear, ?string $semester): array
|
||||
{
|
||||
$builder = Incident::query()->orderBy('incident_datetime', 'DESC');
|
||||
if ($schoolYear !== null && $schoolYear !== '') {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($semester !== null && $semester !== '') {
|
||||
$builder->where('semester', $semester);
|
||||
}
|
||||
|
||||
return $builder->get()->map(fn ($row) => $row->toArray())->all();
|
||||
}
|
||||
|
||||
public function processed(?string $schoolYear, ?string $semester): array
|
||||
{
|
||||
$incidents = $this->history($schoolYear, $semester);
|
||||
if (empty($incidents)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$gradeMap = $this->lookup->gradeNameMap(array_column($incidents, 'grade'));
|
||||
|
||||
$updaterIds = [];
|
||||
foreach ($incidents as $incident) {
|
||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
||||
if (!empty($incident[$key])) {
|
||||
$updaterIds[] = (int) $incident[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$updaterMap = $this->lookup->updaterNameMap($updaterIds);
|
||||
|
||||
foreach ($incidents as &$incident) {
|
||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
||||
$id = (int) ($incident[$key] ?? 0);
|
||||
$incident[$key . '_name'] = $id > 0 ? ($updaterMap[$id] ?? null) : null;
|
||||
}
|
||||
|
||||
$gradeValue = $incident['grade'] ?? '';
|
||||
if (is_numeric($gradeValue)) {
|
||||
$incident['grade'] = $gradeMap[(int) $gradeValue] ?? (string) $gradeValue;
|
||||
}
|
||||
}
|
||||
unset($incident);
|
||||
|
||||
return $incidents;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Incidents;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\StudentClass;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class IncidentLookupService
|
||||
{
|
||||
public function gradeOptionsWithActiveStudents(?string $schoolYear = null): array
|
||||
{
|
||||
$studentCounts = StudentClass::getStudentCountsBySection($schoolYear);
|
||||
if (empty($studentCounts)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$classSections = ClassSection::query()
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->whereIn('class_section_id', array_keys($studentCounts))
|
||||
->get()
|
||||
->map(fn ($row) => $row->toArray())
|
||||
->all();
|
||||
|
||||
$grades = [];
|
||||
foreach ($classSections as $section) {
|
||||
$grades[] = [
|
||||
'id' => $section['class_section_id'],
|
||||
'name' => $section['class_section_name'],
|
||||
];
|
||||
}
|
||||
|
||||
return $grades;
|
||||
}
|
||||
|
||||
public function gradeNameMap(array $gradeValues): array
|
||||
{
|
||||
$gradeIds = [];
|
||||
foreach ($gradeValues as $gradeValue) {
|
||||
if (is_numeric($gradeValue)) {
|
||||
$gradeIds[(int) $gradeValue] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($gradeIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = ClassSection::query()
|
||||
->select('class_section_id', 'class_section_name')
|
||||
->whereIn('class_section_id', array_keys($gradeIds))
|
||||
->get()
|
||||
->map(fn ($row) => $row->toArray())
|
||||
->all();
|
||||
|
||||
$gradeMap = [];
|
||||
foreach ($rows as $row) {
|
||||
$gradeMap[(int) $row['class_section_id']] = (string) ($row['class_section_name'] ?? '');
|
||||
}
|
||||
|
||||
return $gradeMap;
|
||||
}
|
||||
|
||||
public function updaterNameMap(array $userIds): array
|
||||
{
|
||||
$userIds = array_values(array_filter(array_unique(array_map('intval', $userIds))));
|
||||
if (empty($userIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::table('users')
|
||||
->select('id, firstname, lastname')
|
||||
->whereIn('id', $userIds)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$name = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
|
||||
$map[(int) $row['id']] = $name !== '' ? $name : ('User #' . $row['id']);
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Invoices;
|
||||
|
||||
use App\Models\Configuration;
|
||||
|
||||
class InvoiceConfigService
|
||||
{
|
||||
public function getSchoolYear(): string
|
||||
{
|
||||
return (string) (Configuration::getConfig('school_year') ?? date('Y'));
|
||||
}
|
||||
|
||||
public function getSemester(): string
|
||||
{
|
||||
return (string) (Configuration::getConfig('semester') ?? 'Fall');
|
||||
}
|
||||
|
||||
public function getDueDate(): ?string
|
||||
{
|
||||
$value = Configuration::getConfig('due_date');
|
||||
return $value ? (string) $value : null;
|
||||
}
|
||||
|
||||
public function getGradeFee(): int
|
||||
{
|
||||
return (int) (Configuration::getConfig('grade_fee') ?? 9);
|
||||
}
|
||||
|
||||
public function getFirstStudentFee(): float
|
||||
{
|
||||
return (float) (Configuration::getConfig('first_student_fee') ?? 350);
|
||||
}
|
||||
|
||||
public function getSecondStudentFee(): float
|
||||
{
|
||||
return (float) (Configuration::getConfig('second_student_fee') ?? 200);
|
||||
}
|
||||
|
||||
public function getYouthFee(): float
|
||||
{
|
||||
return (float) (Configuration::getConfig('youth_fee') ?? 180);
|
||||
}
|
||||
|
||||
public function getRefundDeadline(): ?string
|
||||
{
|
||||
$value = Configuration::getConfig('refund_deadline');
|
||||
if (!$value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return date('Y-m-d', strtotime((string) $value));
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getTimezone(): string
|
||||
{
|
||||
return (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Invoices;
|
||||
|
||||
use App\Models\Enrollment;
|
||||
use App\Models\EventCharges;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Refund;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\User;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InvoiceGenerationService
|
||||
{
|
||||
public function __construct(
|
||||
private InvoiceConfigService $config,
|
||||
private InvoiceTuitionService $tuitionService
|
||||
) {
|
||||
}
|
||||
|
||||
public function generateInvoice(int $parentId, ?string $schoolYear = null): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?: $this->config->getSchoolYear();
|
||||
$semester = $this->config->getSemester();
|
||||
|
||||
$enrollments = Enrollment::query()
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->map(fn ($r) => (array) $r)
|
||||
->all();
|
||||
|
||||
if (empty($enrollments)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'No enrollment records found.',
|
||||
];
|
||||
}
|
||||
|
||||
$registeredKids = [];
|
||||
$withdrawnKids = [];
|
||||
|
||||
foreach ($enrollments as $enrollment) {
|
||||
$student = [
|
||||
'student_id' => $enrollment['student_id'],
|
||||
'parent_id' => $enrollment['parent_id'],
|
||||
'class_section_id' => $enrollment['class_section_id'],
|
||||
'enrollment_status' => $enrollment['enrollment_status'],
|
||||
'school_year' => $enrollment['school_year'],
|
||||
'semester' => $enrollment['semester'],
|
||||
'admission_status' => $enrollment['admission_status'],
|
||||
'is_withdrawn' => $enrollment['is_withdrawn'],
|
||||
];
|
||||
|
||||
if (in_array($enrollment['enrollment_status'], ['enrolled', 'payment pending'], true)) {
|
||||
$registeredKids[] = $student;
|
||||
} elseif (in_array($enrollment['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
|
||||
$withdrawnKids[] = $student;
|
||||
}
|
||||
}
|
||||
|
||||
$registeredKids = array_values(array_filter($registeredKids, function ($student) use ($schoolYear) {
|
||||
$sid = (int) ($student['student_id'] ?? 0);
|
||||
return $sid > 0 && StudentClass::hasNonEventAssignment($sid, $schoolYear);
|
||||
}));
|
||||
|
||||
$withdrawnKids = array_values(array_filter($withdrawnKids, function ($student) use ($schoolYear) {
|
||||
$sid = (int) ($student['student_id'] ?? 0);
|
||||
return $sid > 0 && StudentClass::hasNonEventAssignment($sid, $schoolYear);
|
||||
}));
|
||||
|
||||
$tuitionFee = $this->tuitionService->calculateTuitionFee(
|
||||
$registeredKids,
|
||||
$withdrawnKids,
|
||||
$this->config->getRefundDeadline()
|
||||
);
|
||||
|
||||
$eventsList = EventCharges::getChargesWithEventInfo($parentId, $schoolYear);
|
||||
$eventChargeTotal = array_sum(array_column($eventsList, 'charged'));
|
||||
|
||||
$this->recalculateAndUpdateDiscount($parentId, $schoolYear, $tuitionFee, $enrollments);
|
||||
|
||||
$refundPaid = (new Refund())->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $schoolYear);
|
||||
$totalPaid = Payment::getTotalPaidByParentId($parentId, $schoolYear);
|
||||
|
||||
$totalAmount = max(0.0, (float) $tuitionFee) + (float) $eventChargeTotal;
|
||||
|
||||
$existingInvoices = Invoice::getInvoicesByParentId($parentId, $schoolYear);
|
||||
$updated = false;
|
||||
$updatedIds = [];
|
||||
$insertId = null;
|
||||
|
||||
if (!empty($existingInvoices)) {
|
||||
foreach ($existingInvoices as $invoice) {
|
||||
if (!isset($invoice['id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extrasSum = $this->sumInvoiceAdditionalCharges((int) $invoice['id'], $schoolYear);
|
||||
$newTotal = round($totalAmount + $extrasSum, 2);
|
||||
|
||||
$invDiscount = $this->sumInvoiceDiscounts((int) $invoice['id']);
|
||||
$invRefunds = $this->sumInvoiceRefunds((int) $invoice['id'], $schoolYear);
|
||||
$paidOnInv = (float) ($invoice['paid_amount'] ?? 0.0);
|
||||
|
||||
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
|
||||
$newStatus = $newBalance <= 0.00001
|
||||
? 'Paid'
|
||||
: ($paidOnInv > 0 ? 'Partially Paid' : 'Unpaid');
|
||||
|
||||
Invoice::query()
|
||||
->whereKey($invoice['id'])
|
||||
->update([
|
||||
'total_amount' => $newTotal,
|
||||
'paid_amount' => $paidOnInv,
|
||||
'balance' => $newBalance,
|
||||
'status' => $newStatus,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
|
||||
$updatedIds[] = (int) $invoice['id'];
|
||||
$updated = true;
|
||||
}
|
||||
} else {
|
||||
$schoolId = User::getSchoolIdByUserId($parentId);
|
||||
$invoiceNumber = $schoolId
|
||||
? ('INV-' . $schoolId . '-' . uniqid())
|
||||
: uniqid('INV-');
|
||||
|
||||
$issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
|
||||
$dueUtc = null;
|
||||
$dueDate = $this->config->getDueDate();
|
||||
if (!empty($dueDate)) {
|
||||
$dueLocal = new DateTime($dueDate . ' 19:59:59', new DateTimeZone($this->config->getTimezone()));
|
||||
$dueLocal->setTimezone(new DateTimeZone('UTC'));
|
||||
$dueUtc = $dueLocal->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$insertId = Invoice::query()->insertGetId([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'total_amount' => $totalAmount,
|
||||
'paid_amount' => 0,
|
||||
'balance' => $totalAmount,
|
||||
'status' => 'Unpaid',
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'issue_date' => $issueUtc,
|
||||
'due_date' => $dueUtc,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'updated' => $updated,
|
||||
'updated_ids' => $updatedIds,
|
||||
'insert_id' => $insertId ? (int) $insertId : null,
|
||||
'total_paid' => $totalPaid,
|
||||
'refund_paid' => $refundPaid,
|
||||
];
|
||||
}
|
||||
|
||||
private function recalculateAndUpdateDiscount(int $parentId, string $schoolYear, float $tuitionFee, array $enrollments): void
|
||||
{
|
||||
$eventChargeTotal = 0.0;
|
||||
try {
|
||||
$eventsList = EventCharges::getChargesWithEventInfo($parentId, $schoolYear);
|
||||
$eventChargeTotal = array_sum(array_column($eventsList, 'charged'));
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
$invoices = Invoice::getInvoicesByParentId($parentId, $schoolYear);
|
||||
if (empty($invoices)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
if (!isset($invoice['id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$invoiceId = (int) $invoice['id'];
|
||||
$discountUsage = DB::table('discount_usages as du')
|
||||
->select('du.id', 'dv.id as voucher_id', 'dv.discount_type', 'dv.discount_value')
|
||||
->join('discount_vouchers as dv', 'du.voucher_id', '=', 'dv.id')
|
||||
->join('invoices as i', 'du.invoice_id', '=', 'i.id')
|
||||
->where('du.invoice_id', $invoiceId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->first();
|
||||
|
||||
if (!$discountUsage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extrasSum = $this->sumInvoiceAdditionalCharges($invoiceId, $schoolYear);
|
||||
$baseTotal = round($tuitionFee + $eventChargeTotal + $extrasSum, 2);
|
||||
|
||||
if (($discountUsage->discount_type ?? '') === 'percent') {
|
||||
$discountAmount = round(($baseTotal * (float) $discountUsage->discount_value) / 100, 2);
|
||||
} else {
|
||||
$discountAmount = min((float) $discountUsage->discount_value, $baseTotal);
|
||||
}
|
||||
|
||||
DB::table('discount_usages')
|
||||
->where('id', $discountUsage->id)
|
||||
->update([
|
||||
'discount_amount' => $discountAmount,
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function sumInvoiceAdditionalCharges(int $invoiceId, string $schoolYear): float
|
||||
{
|
||||
$extrasSum = 0.0;
|
||||
$rows = DB::table('additional_charges')
|
||||
->select('charge_type', 'amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('status', 'applied')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$amount = (float) ($row->amount ?? 0);
|
||||
$type = strtolower((string) ($row->charge_type ?? 'add'));
|
||||
if ($type === 'deduct') {
|
||||
$amount = -abs($amount);
|
||||
} else {
|
||||
$amount = abs($amount);
|
||||
}
|
||||
$extrasSum += $amount;
|
||||
}
|
||||
|
||||
return $extrasSum;
|
||||
}
|
||||
|
||||
private function sumInvoiceDiscounts(int $invoiceId): float
|
||||
{
|
||||
$row = DB::table('discount_usages')
|
||||
->selectRaw('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->first();
|
||||
|
||||
return (float) ($row->tot ?? 0.0);
|
||||
}
|
||||
|
||||
private function sumInvoiceRefunds(int $invoiceId, string $schoolYear): float
|
||||
{
|
||||
$row = DB::table('refunds')
|
||||
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->first();
|
||||
|
||||
return (float) ($row->tot ?? 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Invoices;
|
||||
|
||||
class InvoiceGradeService
|
||||
{
|
||||
public function __construct(private int $gradeFee)
|
||||
{
|
||||
}
|
||||
|
||||
public function isKindergarten(string $grade): bool
|
||||
{
|
||||
$g = strtolower(trim($grade));
|
||||
return in_array($g, ['KG', 'kg', 'k', 'kindergarten', 'k-g', 'k.g', 'k g'], true);
|
||||
}
|
||||
|
||||
public function compareGrades(string $gradeA, string $gradeB): int
|
||||
{
|
||||
$valA = $this->getGradeLevel($gradeA);
|
||||
$valB = $this->getGradeLevel($gradeB);
|
||||
|
||||
if ($valA['level'] !== $valB['level']) {
|
||||
return $valA['level'] <=> $valB['level'];
|
||||
}
|
||||
|
||||
return strcmp($valA['suffix'] ?? '', $valB['suffix'] ?? '');
|
||||
}
|
||||
|
||||
public function gradeLevelInt(string $grade): int
|
||||
{
|
||||
$gl = $this->getGradeLevel($grade);
|
||||
return (int) ($gl['level'] ?? 0);
|
||||
}
|
||||
|
||||
public function getGradeLevel($grade): array
|
||||
{
|
||||
if (is_numeric($grade)) {
|
||||
$num = (int) $grade;
|
||||
if ($num === 13) {
|
||||
return ['level' => 1, 'suffix' => '', 'classId' => 13];
|
||||
}
|
||||
return ['level' => $num, 'suffix' => '', 'classId' => null];
|
||||
}
|
||||
|
||||
if (!is_string($grade)) {
|
||||
return ['level' => 999, 'suffix' => '', 'classId' => null];
|
||||
}
|
||||
|
||||
$g = strtoupper(trim($grade));
|
||||
$g = preg_replace('/\s+/', ' ', $g);
|
||||
$g = str_replace(['.', '_'], '', $g);
|
||||
$g = str_replace('-', ' ', $g);
|
||||
|
||||
$kg = ['K', 'KG', 'K G', 'KINDER', 'KINDERGARTEN'];
|
||||
if (in_array($g, $kg, true)) {
|
||||
return ['level' => 1, 'suffix' => '', 'classId' => 13];
|
||||
}
|
||||
|
||||
$pk = ['PK', 'P K', 'PREK', 'PRE K', 'PRE-K', 'PRE KINDER', 'PREKINDER'];
|
||||
if (in_array($g, $pk, true)) {
|
||||
return ['level' => -1, 'suffix' => '', 'classId' => null];
|
||||
}
|
||||
|
||||
if (preg_match('/^Y(?:OUTH)?(?:\s*(\d+))?$/', $g, $m)) {
|
||||
$n = isset($m[1]) && $m[1] !== '' ? max(1, (int) $m[1]) : 1;
|
||||
return [
|
||||
'level' => $this->gradeFee + $n,
|
||||
'suffix' => '',
|
||||
'classId' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/', $g, $m)) {
|
||||
return [
|
||||
'level' => (int) $m[1],
|
||||
'suffix' => $m[2] ?? '',
|
||||
'classId' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return ['level' => 999, 'suffix' => '', 'classId' => null];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Invoices;
|
||||
|
||||
use App\Models\Enrollment;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InvoiceManagementService
|
||||
{
|
||||
public function __construct(private InvoiceConfigService $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function getManagementData(?string $schoolYear): array
|
||||
{
|
||||
$schoolYear = trim((string) ($schoolYear ?? ''));
|
||||
if ($schoolYear === '') {
|
||||
$schoolYear = $this->config->getSchoolYear();
|
||||
}
|
||||
|
||||
$schoolYears = DB::table('invoices')
|
||||
->select('school_year')
|
||||
->distinct()
|
||||
->orderBy('school_year', 'DESC')
|
||||
->pluck('school_year')
|
||||
->map(fn ($val) => (string) $val)
|
||||
->filter(fn ($val) => $val !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (empty($schoolYears)) {
|
||||
$schoolYears = [$schoolYear];
|
||||
}
|
||||
|
||||
$invoiceData = [];
|
||||
$parents = User::getUsersByRoleAndSchoolYear('parent', $schoolYear);
|
||||
|
||||
foreach ($parents as $parent) {
|
||||
$students = Student::query()->where('parent_id', $parent['id'])->get()->map(fn ($r) => (array) $r)->all();
|
||||
|
||||
$parentData = [
|
||||
'parent_name' => trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')),
|
||||
'parent_id' => (int) $parent['id'],
|
||||
'enrolledKids' => [],
|
||||
'withdrawnKids' => [],
|
||||
'invoice_amount' => 0,
|
||||
'refund_amount' => 0,
|
||||
'last_updated' => null,
|
||||
'invoice_date' => null,
|
||||
'invoice_id' => null,
|
||||
];
|
||||
|
||||
$invoices = Invoice::getInvoicesByParentId((int) $parent['id'], $schoolYear);
|
||||
foreach ($invoices as $invoice) {
|
||||
if (!$invoice) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parentData['invoice_amount'] = (float) ($invoice['total_amount'] ?? 0);
|
||||
$parentData['last_updated'] = $invoice['updated_at'] ?? null;
|
||||
$parentData['invoice_id'] = $invoice['id'] ?? null;
|
||||
|
||||
if (!empty($invoice['issue_date'])) {
|
||||
$tzName = $this->config->getTimezone();
|
||||
$parentData['invoice_date'] = (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC')))
|
||||
->setTimezone(new \DateTimeZone($tzName))
|
||||
->format('Y-m-d H:i:s');
|
||||
} else {
|
||||
$parentData['invoice_date'] = !empty($invoice['updated_at'])
|
||||
? date('Y-m-d H:i:s', strtotime($invoice['updated_at']))
|
||||
: null;
|
||||
}
|
||||
|
||||
$refund = DB::table('refunds')
|
||||
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')
|
||||
->where('parent_id', $parent['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->first();
|
||||
|
||||
$parentData['refund_amount'] = (float) ($refund->refund_paid_amount ?? 0.0);
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($students as $student) {
|
||||
$grade = 'N/A';
|
||||
$studentClass = DB::table('student_class')
|
||||
->where('student_id', $student['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->first();
|
||||
|
||||
if ($studentClass && isset($studentClass->class_section_id)) {
|
||||
$classSection = DB::table('classSection')
|
||||
->where('class_section_id', $studentClass->class_section_id)
|
||||
->first();
|
||||
if ($classSection && isset($classSection->class_section_name)) {
|
||||
$grade = $classSection->class_section_name;
|
||||
}
|
||||
}
|
||||
|
||||
$enrollments = Enrollment::query()
|
||||
->where('student_id', $student['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->map(fn ($r) => (array) $r)
|
||||
->all();
|
||||
|
||||
foreach ($enrollments as $enrollment) {
|
||||
$kid = [
|
||||
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
|
||||
'grade' => $grade,
|
||||
'tuition_fee' => (float) ($enrollment['tuition_fee'] ?? 0),
|
||||
];
|
||||
|
||||
switch ($enrollment['enrollment_status']) {
|
||||
case 'payment pending':
|
||||
case 'enrolled':
|
||||
$parentData['enrolledKids'][] = $kid;
|
||||
break;
|
||||
case 'withdraw under review':
|
||||
case 'withdrawn':
|
||||
case 'refund pending':
|
||||
$parentData['withdrawnKids'][] = $kid;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($parentData['enrolledKids']) || !empty($parentData['withdrawnKids'])) {
|
||||
$invoiceData[] = $parentData;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'schoolYear' => $schoolYear,
|
||||
'schoolYears' => $schoolYears,
|
||||
'invoices' => $invoiceData,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Invoices;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InvoicePaymentService
|
||||
{
|
||||
public function __construct(private InvoiceConfigService $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function getParentInvoiceSummary(int $parentId, ?string $schoolYear): array
|
||||
{
|
||||
$currentSchoolYear = $this->config->getSchoolYear();
|
||||
|
||||
$schoolYears = DB::table('invoices')
|
||||
->select('school_year')
|
||||
->distinct()
|
||||
->where('parent_id', $parentId)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()
|
||||
->map(fn ($r) => (array) $r)
|
||||
->all();
|
||||
|
||||
$selectedYear = $schoolYear ?: null;
|
||||
if (!$selectedYear) {
|
||||
$hasCurrentYear = in_array($currentSchoolYear, array_column($schoolYears, 'school_year'), true);
|
||||
$selectedYear = $hasCurrentYear ? $currentSchoolYear : (!empty($schoolYears) ? $schoolYears[0]['school_year'] : null);
|
||||
}
|
||||
|
||||
$invoices = [];
|
||||
if ($selectedYear) {
|
||||
$invoices = Invoice::getInvoicesByParentId($parentId, $selectedYear);
|
||||
}
|
||||
|
||||
$invoiceIds = array_column($invoices, 'id');
|
||||
$refunds = [];
|
||||
if (!empty($invoiceIds)) {
|
||||
$refundResults = DB::table('refunds')
|
||||
->selectRaw('invoice_id, COALESCE(SUM(refund_paid_amount),0) as refund_paid_amount')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->map(fn ($r) => (array) $r)
|
||||
->all();
|
||||
|
||||
foreach ($refundResults as $row) {
|
||||
$refunds[$row['invoice_id']] = (float) ($row['refund_paid_amount'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$lastPayments = [];
|
||||
if (!empty($invoiceIds)) {
|
||||
$paymentResults = Payment::getPaymentsByInvoice($invoiceIds);
|
||||
foreach ($paymentResults as $payment) {
|
||||
$lastPayments[$payment['invoice_id']] = [
|
||||
'last_paid_amount' => (float) ($payment['last_paid_amount'] ?? 0),
|
||||
'last_payment_date' => $payment['last_payment_date'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($invoices as &$invoice) {
|
||||
$invoice['refund_amount'] = $refunds[$invoice['id']] ?? 0.0;
|
||||
$invoice['last_paid_amount'] = $lastPayments[$invoice['id']]['last_paid_amount'] ?? 0.0;
|
||||
$invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null;
|
||||
}
|
||||
unset($invoice);
|
||||
|
||||
return [
|
||||
'invoices' => $invoices,
|
||||
'schoolYears' => $schoolYears,
|
||||
'selectedYear' => $selectedYear,
|
||||
'currentSchoolYear' => $currentSchoolYear,
|
||||
'dueDate' => $this->config->getDueDate(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Invoices;
|
||||
|
||||
use App\Models\AdditionalCharge;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\DiscountUsage;
|
||||
use App\Models\Enrollment;
|
||||
use App\Models\EventCharges;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Refund;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class InvoicePdfService
|
||||
{
|
||||
public function __construct(
|
||||
private InvoiceConfigService $config,
|
||||
private InvoiceGradeService $grades
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildPdf(int $invoiceId): string
|
||||
{
|
||||
$data = $this->prepareInvoiceData($invoiceId);
|
||||
if (isset($data['error'])) {
|
||||
return $this->renderErrorPdf($data['error']);
|
||||
}
|
||||
|
||||
return $this->renderPdfInvoice($data);
|
||||
}
|
||||
|
||||
private function prepareInvoiceData(int $invoiceId): array
|
||||
{
|
||||
$invoice = Invoice::query()->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return ['error' => 'No invoice was generated. Please contact the school administration.'];
|
||||
}
|
||||
|
||||
$parentId = (int) ($invoice->parent_id ?? 0);
|
||||
$schoolYear = (string) ($invoice->school_year ?? $this->config->getSchoolYear());
|
||||
|
||||
$paymentsQuery = Payment::query()
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('invoice_id', $invoiceId);
|
||||
|
||||
if (Schema::hasColumn('payments', 'status')) {
|
||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
$paymentsQuery->where(function ($q) use ($exclude) {
|
||||
$q->whereNotIn('status', $exclude)->orWhereNull('status');
|
||||
});
|
||||
}
|
||||
if (Schema::hasColumn('payments', 'is_void')) {
|
||||
$paymentsQuery->where(function ($q) {
|
||||
$q->where('is_void', 0)->orWhereNull('is_void');
|
||||
});
|
||||
}
|
||||
|
||||
$payments = $paymentsQuery->get()->map(fn ($r) => $r->toArray())->all();
|
||||
|
||||
$parent = User::query()->find($parentId);
|
||||
if (!$parent) {
|
||||
return ['error' => 'Parent associated with the invoice was not found.'];
|
||||
}
|
||||
|
||||
$enrollments = Enrollment::query()
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->map(fn ($r) => $r->toArray())
|
||||
->all();
|
||||
|
||||
if (empty($enrollments)) {
|
||||
return ['error' => 'No enrollments found for this invoice.'];
|
||||
}
|
||||
|
||||
$registeredKids = [];
|
||||
$withdrawnKids = [];
|
||||
|
||||
foreach ($enrollments as $enrollment) {
|
||||
$student = Student::query()->find($enrollment['student_id']);
|
||||
if (!$student) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$grade = StudentClass::getStudentGrade((int) $student->id);
|
||||
$studentData = [
|
||||
'student_id' => (int) $student->id,
|
||||
'student_firstname' => (string) ($student->firstname ?? ''),
|
||||
'student_lastname' => (string) ($student->lastname ?? ''),
|
||||
'grade' => $grade,
|
||||
'tuition_fee' => $enrollment['tuition_fee'] ?? 0,
|
||||
'enrollment_status' => $enrollment['enrollment_status'],
|
||||
];
|
||||
|
||||
if (in_array($enrollment['enrollment_status'], ['enrolled', 'payment pending'], true)) {
|
||||
$registeredKids[] = $studentData;
|
||||
} elseif (in_array($enrollment['enrollment_status'], ['withdrawn', 'withdraw under review', 'refund pending'], true)) {
|
||||
$withdrawnKids[] = $studentData;
|
||||
}
|
||||
}
|
||||
|
||||
$registeredKids = array_values(array_filter($registeredKids, function ($student) use ($schoolYear) {
|
||||
$sid = (int) ($student['student_id'] ?? 0);
|
||||
return $sid > 0 && StudentClass::hasNonEventAssignment($sid, $schoolYear);
|
||||
}));
|
||||
|
||||
$withdrawnKids = array_values(array_filter($withdrawnKids, function ($student) use ($schoolYear) {
|
||||
$sid = (int) ($student['student_id'] ?? 0);
|
||||
return $sid > 0 && StudentClass::hasNonEventAssignment($sid, $schoolYear);
|
||||
}));
|
||||
|
||||
usort($registeredKids, fn ($a, $b) => $this->grades->compareGrades((string) $a['grade'], (string) $b['grade']));
|
||||
usort($withdrawnKids, fn ($a, $b) => $this->grades->compareGrades((string) $a['grade'], (string) $b['grade']));
|
||||
|
||||
$refundAllowed = true;
|
||||
$refundDeadline = $this->config->getRefundDeadline();
|
||||
if ($refundDeadline) {
|
||||
try {
|
||||
$tz = new \DateTimeZone($this->config->getTimezone());
|
||||
$today = new \DateTimeImmutable('today', $tz);
|
||||
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
|
||||
$refundAllowed = $today <= $deadline;
|
||||
} catch (\Throwable $e) {
|
||||
$refundAllowed = true;
|
||||
}
|
||||
}
|
||||
|
||||
$studentCharges = [];
|
||||
$regularCount = 0;
|
||||
$gradeFee = $this->config->getGradeFee();
|
||||
$firstFee = $this->config->getFirstStudentFee();
|
||||
$secondFee = $this->config->getSecondStudentFee();
|
||||
$youthFee = $this->config->getYouthFee();
|
||||
|
||||
$computeCharge = function (int $gradeLevel, string $gradeName) use (&$regularCount, $gradeFee, $firstFee, $secondFee, $youthFee) {
|
||||
$isRegular = $this->grades->isKindergarten($gradeName) || ($gradeLevel > 0 && $gradeLevel <= $gradeFee);
|
||||
if ($isRegular) {
|
||||
$fee = $regularCount === 0 ? $firstFee : $secondFee;
|
||||
$regularCount++;
|
||||
return $fee;
|
||||
}
|
||||
return $youthFee;
|
||||
};
|
||||
|
||||
foreach ($registeredKids as $student) {
|
||||
$gradeName = (string) ($student['grade'] ?? '');
|
||||
$gradeLevel = $this->grades->gradeLevelInt($gradeName);
|
||||
$unitFee = $computeCharge($gradeLevel, $gradeName);
|
||||
$studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0];
|
||||
}
|
||||
|
||||
if (!$refundAllowed) {
|
||||
foreach ($withdrawnKids as $student) {
|
||||
$gradeName = (string) ($student['grade'] ?? '');
|
||||
$gradeLevel = $this->grades->gradeLevelInt($gradeName);
|
||||
$unitFee = $computeCharge($gradeLevel, $gradeName);
|
||||
$studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
$eventsList = EventCharges::getChargesWithEventInfo($parentId, $schoolYear);
|
||||
|
||||
$studentIds = array_values(array_unique(array_map(static fn ($r) => (int) ($r['student_id'] ?? 0), array_merge($registeredKids, $withdrawnKids))));
|
||||
$schoolIdMap = [];
|
||||
if (!empty($studentIds)) {
|
||||
$rows = Student::query()->whereIn('id', $studentIds)->get(['id', 'school_id'])->all();
|
||||
foreach ($rows as $row) {
|
||||
$schoolIdMap[(int) $row->id] = $row->school_id ?? 'N/A';
|
||||
}
|
||||
}
|
||||
|
||||
$students = array_merge($registeredKids, $withdrawnKids);
|
||||
foreach ($students as $i => $s) {
|
||||
$sid = (int) ($s['student_id'] ?? 0);
|
||||
$students[$i]['student_school_id'] = $schoolIdMap[$sid] ?? 'N/A';
|
||||
}
|
||||
|
||||
$discounts = DiscountUsage::query()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->map(fn ($r) => $r->toArray())
|
||||
->all();
|
||||
|
||||
$refundsPaidTotal = (float) DB::table('refunds')
|
||||
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->value('tot');
|
||||
|
||||
$acRows = AdditionalCharge::query()
|
||||
->select('id', 'charge_type', 'title', 'description', 'amount', 'due_date', 'status', 'created_at')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('status', '!=', 'void')
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn ($r) => $r->toArray())
|
||||
->all();
|
||||
|
||||
$additionalChargeLines = [];
|
||||
$additionalChargesTotal = 0.0;
|
||||
foreach ($acRows as $ac) {
|
||||
$signed = (float) ($ac['amount'] ?? 0);
|
||||
$ctype = strtolower((string) ($ac['charge_type'] ?? ''));
|
||||
if ($ctype === 'deduct' && $signed > 0) {
|
||||
$signed = -$signed;
|
||||
} elseif ($ctype === 'add' && $signed < 0) {
|
||||
$signed = abs($signed);
|
||||
}
|
||||
|
||||
$lineDate = !empty($ac['created_at'])
|
||||
? date('Y-m-d', strtotime($ac['created_at']))
|
||||
: (!empty($invoice->created_at) ? local_date($invoice->created_at, 'Y-m-d') : local_date(utc_now(), 'Y-m-d'));
|
||||
|
||||
$desc = $ac['description'] ?? '';
|
||||
if ($desc === '') {
|
||||
$desc = $ctype === 'deduct' ? 'Deduct' : 'Add';
|
||||
}
|
||||
|
||||
$additionalChargesTotal += $signed;
|
||||
$additionalChargeLines[] = [
|
||||
'date' => $lineDate,
|
||||
'description' => $desc,
|
||||
'amount' => $signed,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'invoice' => $invoice->toArray(),
|
||||
'parent' => $parent->toArray(),
|
||||
'registeredKids' => $registeredKids,
|
||||
'withdrawnKids' => $withdrawnKids,
|
||||
'studentCharges' => $studentCharges,
|
||||
'events' => $eventsList,
|
||||
'students' => $students,
|
||||
'payments' => $payments,
|
||||
'discounts' => $discounts,
|
||||
'additionalChargeLines' => $additionalChargeLines,
|
||||
'additionalChargesTotal' => round($additionalChargesTotal, 2),
|
||||
'refundsPaidTotal' => $refundsPaidTotal,
|
||||
];
|
||||
}
|
||||
|
||||
private function renderPdfInvoice(array $data): string
|
||||
{
|
||||
$invoice = $data['invoice'];
|
||||
$parent = $data['parent'];
|
||||
$registeredKids = $data['registeredKids'] ?? [];
|
||||
$withdrawnKids = $data['withdrawnKids'] ?? [];
|
||||
$studentCharges = $data['studentCharges'] ?? [];
|
||||
$events = $data['events'] ?? [];
|
||||
$students = $data['students'] ?? [];
|
||||
$payments = $data['payments'] ?? [];
|
||||
$discounts = $data['discounts'] ?? [];
|
||||
$additionalChargeLines = $data['additionalChargeLines'] ?? [];
|
||||
$additionalChargesTotal = (float) ($data['additionalChargesTotal'] ?? 0);
|
||||
$refundsPaidTotal = (float) ($data['refundsPaidTotal'] ?? 0);
|
||||
|
||||
$pdf = new \FPDF();
|
||||
$pdf->AddPage();
|
||||
$pdf->SetFont('Arial', 'B', 16);
|
||||
$pdf->Cell(0, 10, 'Invoice', 0, 1, 'C');
|
||||
$pdf->Ln(4);
|
||||
|
||||
$pdf->SetFont('Arial', 'B', 12);
|
||||
$pdf->Cell(40, 6, 'Parent Name:', 0, 0, 'L');
|
||||
$pdf->SetFont('Arial', '', 12);
|
||||
$pdf->Cell(0, 6, ($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? ''), 0, 1, 'L');
|
||||
|
||||
$pdf->SetFont('Arial', 'B', 12);
|
||||
$pdf->Cell(40, 6, 'Invoice Number:', 0, 0, 'L');
|
||||
$pdf->SetFont('Arial', '', 12);
|
||||
$pdf->Cell(0, 6, (string) ($invoice['invoice_number'] ?? ''), 0, 1, 'L');
|
||||
|
||||
$pdf->Ln(8);
|
||||
$pdf->SetFont('Arial', 'B', 11);
|
||||
$pdf->Cell(30, 7, 'Date', 1);
|
||||
$pdf->Cell(130, 7, 'Description', 1);
|
||||
$pdf->Cell(30, 7, 'Amount', 1, 1, 'R');
|
||||
|
||||
$transactions = [];
|
||||
$seq = 0;
|
||||
$push = function (\DateTimeImmutable $dt, string $desc, float $amount, string $cat) use (&$transactions, &$seq) {
|
||||
$transactions[] = [
|
||||
'dt' => $dt,
|
||||
'description' => $desc,
|
||||
'amount' => $amount,
|
||||
'cat' => $cat,
|
||||
'seq' => $seq++,
|
||||
];
|
||||
};
|
||||
|
||||
$tz = new \DateTimeZone($this->config->getTimezone());
|
||||
$toLocal = function (?string $raw) use ($tz): \DateTimeImmutable {
|
||||
if (!$raw) {
|
||||
return new \DateTimeImmutable('now', $tz);
|
||||
}
|
||||
try {
|
||||
return new \DateTimeImmutable($raw, $tz);
|
||||
} catch (\Throwable $e) {
|
||||
return new \DateTimeImmutable('now', $tz);
|
||||
}
|
||||
};
|
||||
|
||||
foreach ($registeredKids as $student) {
|
||||
$id = $student['student_id'];
|
||||
$unit = (float) ($studentCharges[$id]['unit_fee'] ?? 0.0);
|
||||
$name = $student['student_firstname'] . ' ' . $student['student_lastname'];
|
||||
$gradeName = ClassSection::getClassSectionNameByClassId($student['grade']) ?? $student['grade'];
|
||||
$push($toLocal($invoice['created_at'] ?? null), 'Registration of "' . $name . '" in ' . $gradeName, $unit, 'registration');
|
||||
}
|
||||
|
||||
foreach ($events as $event) {
|
||||
$studentName = 'N/A';
|
||||
if (!empty($event['student_id'])) {
|
||||
foreach ($students as $st) {
|
||||
if (($st['student_id'] ?? null) == $event['student_id']) {
|
||||
$studentName = $st['student_firstname'] . ' ' . $st['student_lastname'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$amount = (float) ($event['charged'] ?? 0.0);
|
||||
$eventName = $event['event_name'] ?? 'Event';
|
||||
$push($toLocal($event['created_at'] ?? null), 'Event ' . $eventName . ' for "' . $studentName . '"', $amount, 'event');
|
||||
}
|
||||
|
||||
foreach ($additionalChargeLines as $line) {
|
||||
$push($toLocal($line['date'] ?? null), (string) ($line['description'] ?? 'Additional Charge'), (float) ($line['amount'] ?? 0), 'additional');
|
||||
}
|
||||
|
||||
$totalPaid = 0.0;
|
||||
foreach ($payments as $payment) {
|
||||
$amount = (float) ($payment['paid_amount'] ?? 0.0);
|
||||
$totalPaid += $amount;
|
||||
$push($toLocal($payment['payment_date'] ?? null), 'Payment (' . ($payment['payment_method'] ?? 'Payment') . ')', -$amount, 'payment');
|
||||
}
|
||||
|
||||
$totalDiscount = 0.0;
|
||||
foreach ($discounts as $discount) {
|
||||
$amount = (float) ($discount['discount_amount'] ?? 0.0);
|
||||
$totalDiscount += $amount;
|
||||
$push($toLocal($discount['used_at'] ?? null), 'Discount applied', -$amount, 'discount');
|
||||
}
|
||||
|
||||
usort($transactions, function ($a, $b) {
|
||||
$dayA = $a['dt']->format('Y-m-d');
|
||||
$dayB = $b['dt']->format('Y-m-d');
|
||||
if ($dayA !== $dayB) {
|
||||
return $a['dt'] <=> $b['dt'];
|
||||
}
|
||||
$pri = [
|
||||
'registration' => 10,
|
||||
'event' => 20,
|
||||
'additional' => 25,
|
||||
'discount' => 30,
|
||||
'payment' => 90,
|
||||
'other' => 50,
|
||||
];
|
||||
$pa = $pri[$a['cat']] ?? 50;
|
||||
$pb = $pri[$b['cat']] ?? 50;
|
||||
if ($pa !== $pb) {
|
||||
return $pa <=> $pb;
|
||||
}
|
||||
$cmp = $a['dt'] <=> $b['dt'];
|
||||
return $cmp !== 0 ? $cmp : ($a['seq'] <=> $b['seq']);
|
||||
});
|
||||
|
||||
$pdf->SetFont('Arial', '', 10);
|
||||
foreach ($transactions as $t) {
|
||||
$pdf->Cell(30, 7, $t['dt']->format('m-d-Y'), 1);
|
||||
$pdf->Cell(130, 7, $t['description'], 1);
|
||||
$pdf->Cell(30, 7, ($t['amount'] < 0 ? '-$' : '$') . number_format(abs($t['amount']), 2), 1, 1, 'R');
|
||||
}
|
||||
|
||||
$tuitionSubtotal = 0.0;
|
||||
foreach ($studentCharges as $sc) {
|
||||
$tuitionSubtotal += (float) ($sc['unit_fee'] ?? 0.0);
|
||||
}
|
||||
$eventSubtotal = 0.0;
|
||||
foreach ($events as $ev) {
|
||||
$eventSubtotal += (float) ($ev['charged'] ?? 0.0);
|
||||
}
|
||||
|
||||
$totalAmount = round($tuitionSubtotal + $eventSubtotal + $additionalChargesTotal, 2);
|
||||
$balance = $totalAmount - $totalPaid - $totalDiscount - $refundsPaidTotal;
|
||||
if ($balance < 0) {
|
||||
$balance = 0.0;
|
||||
}
|
||||
|
||||
$pdf->Ln(5);
|
||||
$pdf->SetFont('Arial', 'B', 12);
|
||||
$pdf->Cell(160, 7, 'Total Charges:', 0, 0, 'R');
|
||||
$pdf->SetFont('Arial', '', 12);
|
||||
$pdf->Cell(30, 7, '$' . number_format($totalAmount, 2), 0, 1, 'R');
|
||||
|
||||
$pdf->SetFont('Arial', 'B', 12);
|
||||
$pdf->Cell(160, 7, 'Total Paid:', 0, 0, 'R');
|
||||
$pdf->SetFont('Arial', '', 12);
|
||||
$pdf->Cell(30, 7, '$' . number_format($totalPaid, 2), 0, 1, 'R');
|
||||
|
||||
if ($totalDiscount > 0) {
|
||||
$pdf->SetFont('Arial', 'B', 12);
|
||||
$pdf->Cell(160, 7, 'Total Discount:', 0, 0, 'R');
|
||||
$pdf->SetFont('Arial', '', 12);
|
||||
$pdf->Cell(30, 7, '$' . number_format($totalDiscount, 2), 0, 1, 'R');
|
||||
}
|
||||
|
||||
if ($refundsPaidTotal > 0) {
|
||||
$pdf->SetFont('Arial', 'B', 12);
|
||||
$pdf->Cell(160, 7, 'Refunds Paid:', 0, 0, 'R');
|
||||
$pdf->SetFont('Arial', '', 12);
|
||||
$pdf->Cell(30, 7, '$' . number_format($refundsPaidTotal, 2), 0, 1, 'R');
|
||||
}
|
||||
|
||||
$pdf->SetFont('Arial', 'B', 12);
|
||||
$pdf->Cell(160, 7, 'Balance Due:', 0, 0, 'R');
|
||||
$pdf->SetFont('Arial', '', 12);
|
||||
$pdf->Cell(30, 7, '$' . number_format($balance, 2), 0, 1, 'R');
|
||||
|
||||
return $pdf->Output('S');
|
||||
}
|
||||
|
||||
private function renderErrorPdf(string $message): string
|
||||
{
|
||||
$pdf = new \FPDF();
|
||||
$pdf->AddPage();
|
||||
$pdf->SetFont('Arial', 'B', 16);
|
||||
$pdf->Cell(0, 10, 'Error', 0, 1, 'C');
|
||||
$pdf->SetFont('Arial', '', 12);
|
||||
$pdf->Ln(10);
|
||||
$pdf->MultiCell(0, 10, $message);
|
||||
|
||||
return $pdf->Output('S');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Invoices;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
|
||||
class InvoiceTuitionService
|
||||
{
|
||||
public function __construct(
|
||||
private InvoiceGradeService $grades,
|
||||
private int $gradeFee,
|
||||
private float $firstStudentFee,
|
||||
private float $secondStudentFee,
|
||||
private float $youthFee,
|
||||
private string $timezone
|
||||
) {
|
||||
}
|
||||
|
||||
public function calculateTuitionFee(array $registeredKids, array $withdrawnKids, ?string $refundDeadline): float
|
||||
{
|
||||
$refundOk = $this->isRefundAllowed($refundDeadline);
|
||||
$tuitionStudents = $refundOk ? $registeredKids : array_merge($registeredKids, $withdrawnKids);
|
||||
|
||||
return $this->calculateTotalTuitionFee($tuitionStudents);
|
||||
}
|
||||
|
||||
public function calculateTotalTuitionFee(array $students): float
|
||||
{
|
||||
foreach ($students as &$student) {
|
||||
$gradeName = ClassSection::getClassSectionNameBySectionId($student['class_section_id'] ?? null) ?? '';
|
||||
$student['grade'] = strtoupper(trim($gradeName));
|
||||
}
|
||||
unset($student);
|
||||
|
||||
$regularCount = 0;
|
||||
$youthCount = 0;
|
||||
|
||||
foreach ($students as $student) {
|
||||
$levelInfo = $this->grades->getGradeLevel($student['grade'] ?? '');
|
||||
$level = (int) ($levelInfo['level'] ?? 999);
|
||||
|
||||
if ($level > $this->gradeFee) {
|
||||
$youthCount++;
|
||||
} else {
|
||||
$regularCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$total = 0.0;
|
||||
$total += $youthCount * $this->youthFee;
|
||||
|
||||
if ($regularCount >= 2) {
|
||||
$total += $this->firstStudentFee;
|
||||
$total += ($regularCount - 1) * $this->secondStudentFee;
|
||||
} elseif ($regularCount === 1) {
|
||||
$total += $this->firstStudentFee;
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function isRefundAllowed(?string $refundDeadline): bool
|
||||
{
|
||||
if (!$refundDeadline) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$tz = new \DateTimeZone($this->timezone);
|
||||
$today = new \DateTimeImmutable('today', $tz);
|
||||
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
|
||||
return $today <= $deadline;
|
||||
} catch (\Throwable $e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Payment;
|
||||
|
||||
class PaymentBalanceService
|
||||
{
|
||||
public function updateBalance(int $paymentId, float $amountPaid): bool
|
||||
{
|
||||
return Payment::updateBalance($paymentId, $amountPaid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PaymentEnrollmentEventService
|
||||
{
|
||||
public function buildEventData(
|
||||
int $parentId,
|
||||
array $studentIds,
|
||||
?string $schoolYear = null,
|
||||
?string $semester = null,
|
||||
?string $portalLink = null
|
||||
): array {
|
||||
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
|
||||
$semester = $semester ?: Configuration::getConfig('semester');
|
||||
$portalLink = $portalLink ?: url('/login');
|
||||
|
||||
$parent = DB::table('users')
|
||||
->select('id', 'firstname', 'lastname', 'email')
|
||||
->where('id', $parentId)
|
||||
->first();
|
||||
|
||||
if (!$parent) {
|
||||
throw new \RuntimeException("Parent user #{$parentId} not found");
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique(array_map('intval', $studentIds)));
|
||||
if (empty($ids)) {
|
||||
$ids = DB::table('students')
|
||||
->where('parent_id', $parentId)
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
if (empty($ids)) {
|
||||
$data = [
|
||||
'user_id' => (int) $parent->id,
|
||||
'email' => $parent->email ?? null,
|
||||
'firstname' => $parent->firstname ?? '',
|
||||
'lastname' => $parent->lastname ?? '',
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'portal_link' => $portalLink,
|
||||
];
|
||||
|
||||
return [$data, []];
|
||||
}
|
||||
|
||||
$rows = DB::table('students as s')
|
||||
->select([
|
||||
's.id as student_id',
|
||||
's.firstname as s_firstname',
|
||||
's.lastname as s_lastname',
|
||||
's.registration_grade',
|
||||
'sc.class_section_id',
|
||||
'cs.class_section_name',
|
||||
'tu.firstname as t_firstname',
|
||||
'tu.lastname as t_lastname',
|
||||
])
|
||||
->leftJoin('student_class as sc', function ($join) use ($schoolYear) {
|
||||
$join->on('sc.student_id', '=', 's.id')
|
||||
->where('sc.school_year', '=', $schoolYear);
|
||||
})
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->leftJoin('teacher_class as tc', function ($join) use ($schoolYear) {
|
||||
$join->on('tc.class_section_id', '=', 'sc.class_section_id')
|
||||
->where('tc.position', '=', 'main')
|
||||
->where('tc.school_year', '=', $schoolYear);
|
||||
})
|
||||
->leftJoin('users as tu', 'tu.id', '=', 'tc.teacher_id')
|
||||
->where('s.parent_id', $parentId)
|
||||
->whereIn('s.id', $ids)
|
||||
->orderByDesc('sc.updated_at')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
if (empty($rows)) {
|
||||
$rows = DB::table('students as s')
|
||||
->select('s.id as student_id', 's.firstname as s_firstname', 's.lastname as s_lastname', 's.registration_grade')
|
||||
->where('s.parent_id', $parentId)
|
||||
->whereIn('s.id', $ids)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
|
||||
$students = [];
|
||||
$seen = [];
|
||||
foreach ($rows as $row) {
|
||||
$sid = (int) ($row['student_id'] ?? 0);
|
||||
if ($sid <= 0 || isset($seen[$sid])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$sid] = true;
|
||||
|
||||
$teacherFull = trim(trim((string) ($row['t_firstname'] ?? '')) . ' ' . trim((string) ($row['t_lastname'] ?? '')));
|
||||
$teacherFull = $teacherFull !== '' ? $teacherFull : null;
|
||||
|
||||
$students[] = [
|
||||
'id' => $sid,
|
||||
'student_id' => $sid,
|
||||
'firstname' => (string) ($row['s_firstname'] ?? ''),
|
||||
'lastname' => (string) ($row['s_lastname'] ?? ''),
|
||||
'name' => trim(((string) ($row['s_firstname'] ?? '')) . ' ' . ((string) ($row['s_lastname'] ?? ''))),
|
||||
'registration_grade' => (string) ($row['registration_grade'] ?? ''),
|
||||
'class_section_id' => isset($row['class_section_id']) ? (int) $row['class_section_id'] : null,
|
||||
'class_section_name' => $row['class_section_name'] ?? null,
|
||||
'teacher_name' => $teacherFull,
|
||||
'start_date' => '',
|
||||
'portal_link' => $portalLink,
|
||||
];
|
||||
}
|
||||
|
||||
$data = [
|
||||
'user_id' => (int) $parent->id,
|
||||
'email' => $parent->email ?? null,
|
||||
'firstname' => $parent->firstname ?? '',
|
||||
'lastname' => $parent->lastname ?? '',
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'portal_link' => $portalLink,
|
||||
];
|
||||
|
||||
return [$data, $students];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Enrollment;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PaymentEventChargesService
|
||||
{
|
||||
public function listCharges(?string $schoolYear, ?string $semester): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
|
||||
$semester = $semester ?: Configuration::getConfig('semester');
|
||||
|
||||
$charges = DB::table('event_charges')
|
||||
->select(
|
||||
'event_charges.*',
|
||||
'users.firstname AS parent_firstname',
|
||||
'users.lastname AS parent_lastname',
|
||||
'students.firstname AS student_firstname',
|
||||
'students.lastname AS student_lastname'
|
||||
)
|
||||
->leftJoin('users', 'users.id', '=', 'event_charges.parent_id')
|
||||
->leftJoin('students', 'students.id', '=', 'event_charges.student_id')
|
||||
->where('event_charges.school_year', $schoolYear)
|
||||
->where('event_charges.semester', $semester)
|
||||
->orderByDesc('event_charges.created_at')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
return [
|
||||
'charges' => $charges,
|
||||
'parents' => User::getParents(),
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
];
|
||||
}
|
||||
|
||||
public function addCharges(array $payload, int $userId): int
|
||||
{
|
||||
$studentIds = $payload['student_ids'] ?? [];
|
||||
$parentId = (int) $payload['parent_id'];
|
||||
|
||||
$commonData = [
|
||||
'parent_id' => $parentId,
|
||||
'event_name' => $payload['event_name'],
|
||||
'description' => $payload['description'] ?? null,
|
||||
'amount' => (float) $payload['amount'],
|
||||
'semester' => $payload['semester'],
|
||||
'school_year' => $payload['school_year'],
|
||||
'charged' => 0,
|
||||
'updated_by' => $userId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
$count = 0;
|
||||
|
||||
if (!empty($studentIds)) {
|
||||
foreach ($studentIds as $studentId) {
|
||||
DB::table('event_charges')->insert(array_merge($commonData, [
|
||||
'student_id' => (int) $studentId,
|
||||
]));
|
||||
$count++;
|
||||
}
|
||||
} elseif (!empty($payload['student_id'])) {
|
||||
DB::table('event_charges')->insert(array_merge($commonData, [
|
||||
'student_id' => (int) $payload['student_id'],
|
||||
]));
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function getEnrolledStudents(int $parentId, ?string $schoolYear): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
|
||||
|
||||
$enrollments = Enrollment::getEnrolledStudents($parentId, $schoolYear);
|
||||
|
||||
$students = [];
|
||||
foreach ($enrollments as $row) {
|
||||
$students[] = [
|
||||
'id' => (int) ($row->id ?? 0),
|
||||
'firstname' => (string) ($row->firstname ?? ''),
|
||||
'lastname' => (string) ($row->lastname ?? ''),
|
||||
'grade' => StudentClass::getStudentGrade((int) ($row->id ?? 0)) ?: 'N/A',
|
||||
];
|
||||
}
|
||||
|
||||
return $students;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Payment;
|
||||
|
||||
class PaymentLookupService
|
||||
{
|
||||
public function getByParent(int $parentId, ?string $schoolYear = null): array
|
||||
{
|
||||
$query = Payment::query()->where('parent_id', $parentId);
|
||||
|
||||
if ($schoolYear) {
|
||||
$query->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
return $query->orderByDesc('payment_date')->get()->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Student;
|
||||
use App\Services\Discounts\DiscountInvoiceService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class PaymentManualService
|
||||
{
|
||||
public function __construct(
|
||||
private DiscountInvoiceService $invoiceService,
|
||||
private PaymentEnrollmentEventService $enrollmentEventService
|
||||
) {
|
||||
}
|
||||
|
||||
public function search(string $searchTerm, ?string $schoolYear = null): array
|
||||
{
|
||||
$searchTerm = trim($searchTerm);
|
||||
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
|
||||
$installmentDateRaw = (string) (Configuration::getConfig('installment_date') ?? '');
|
||||
|
||||
$installmentEndYmd = '';
|
||||
if ($installmentDateRaw !== '') {
|
||||
$ts = strtotime($installmentDateRaw);
|
||||
if ($ts !== false) {
|
||||
$installmentEndYmd = date('Y-m-d', $ts);
|
||||
}
|
||||
}
|
||||
|
||||
$parentData = null;
|
||||
$students = [];
|
||||
$payments = [];
|
||||
$invoices = [];
|
||||
$pagination = null;
|
||||
|
||||
if ($searchTerm !== '') {
|
||||
$searchClean = preg_replace('/\D/', '', $searchTerm);
|
||||
$searchFormatted = $this->formatPhone($searchClean);
|
||||
|
||||
$builder = DB::table('users')->select('*');
|
||||
$builder->where(function ($q) use ($searchTerm, $searchClean, $searchFormatted) {
|
||||
$q->where('email', $searchTerm);
|
||||
|
||||
if (!empty($searchClean)) {
|
||||
if (strlen($searchClean) === 10) {
|
||||
$formattedPhone = substr($searchClean, 0, 3) . '-' . substr($searchClean, 3, 3) . '-' . substr($searchClean, 6, 4);
|
||||
$q->orWhere('cellphone', $formattedPhone);
|
||||
}
|
||||
|
||||
$q->orWhereRaw(
|
||||
"REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') = ?",
|
||||
[$searchClean]
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($searchFormatted)) {
|
||||
$q->orWhere('cellphone', $searchFormatted);
|
||||
}
|
||||
});
|
||||
|
||||
$parent = (array) $builder->first();
|
||||
if (!empty($parent)) {
|
||||
unset($parent['password']);
|
||||
$parentData = $parent;
|
||||
$parentId = (int) ($parent['id'] ?? 0);
|
||||
|
||||
if ($parentId > 0) {
|
||||
$students = Student::query()->where('parent_id', $parentId)->get()->toArray();
|
||||
|
||||
$paymentPaginator = Payment::query()
|
||||
->where('parent_id', $parentId)
|
||||
->orderByDesc('payment_date')
|
||||
->paginate(10);
|
||||
|
||||
$payments = $paymentPaginator->items();
|
||||
$pagination = [
|
||||
'current_page' => $paymentPaginator->currentPage(),
|
||||
'per_page' => $paymentPaginator->perPage(),
|
||||
'total' => $paymentPaginator->total(),
|
||||
'last_page' => $paymentPaginator->lastPage(),
|
||||
];
|
||||
|
||||
$rawInvoices = Invoice::query()
|
||||
->where('parent_id', $parentId)
|
||||
->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear))
|
||||
->orderByDesc('issue_date')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$invoices = $this->normalizeInvoices($rawInvoices, $installmentEndYmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'parent' => $parentData,
|
||||
'students' => $students,
|
||||
'payments' => $payments,
|
||||
'invoices' => $invoices,
|
||||
'pagination' => $pagination,
|
||||
'search_term' => $searchTerm,
|
||||
'today' => function_exists('utc_now') ? utc_now() : now()->toDateTimeString(),
|
||||
'installment_end_ymd' => $installmentEndYmd,
|
||||
];
|
||||
}
|
||||
|
||||
public function suggest(string $query): array
|
||||
{
|
||||
$q = trim($query);
|
||||
if ($q === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D/', '', $q);
|
||||
|
||||
$sql = "SELECT id, firstname, lastname, email, cellphone
|
||||
FROM users
|
||||
WHERE (
|
||||
CONCAT_WS(' ', firstname, lastname) LIKE ?
|
||||
OR email LIKE ?
|
||||
OR cellphone LIKE ?";
|
||||
|
||||
$params = ['%' . $q . '%', '%' . $q . '%', '%' . $q . '%'];
|
||||
|
||||
if ($digits !== '') {
|
||||
$sql .= " OR REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') LIKE ?";
|
||||
$params[] = '%' . $digits . '%';
|
||||
}
|
||||
|
||||
$sql .= ") ORDER BY lastname, firstname LIMIT 20";
|
||||
|
||||
$rows = DB::select($sql, $params);
|
||||
|
||||
$items = [];
|
||||
foreach ($rows as $row) {
|
||||
$label = trim(($row->firstname ?? '') . ' ' . ($row->lastname ?? ''));
|
||||
$email = trim((string) ($row->email ?? ''));
|
||||
$phone = trim((string) ($row->cellphone ?? ''));
|
||||
$sub = trim($email . ' ' . $phone);
|
||||
$value = $email !== '' ? $email : ($phone !== '' ? $phone : $label);
|
||||
|
||||
$items[] = [
|
||||
'id' => (int) ($row->id ?? 0),
|
||||
'label' => $label,
|
||||
'sub' => $sub,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function recordPayment(
|
||||
int $invoiceId,
|
||||
float $amount,
|
||||
string $paymentMethod,
|
||||
?string $paymentDate,
|
||||
?string $checkNumber,
|
||||
?string $paymentType,
|
||||
?UploadedFile $paymentFile,
|
||||
?string $schoolYear = null,
|
||||
?string $semester = null,
|
||||
?int $userId = null
|
||||
): array {
|
||||
$paymentMethod = strtolower(trim($paymentMethod));
|
||||
$allowedMethods = ['cash', 'check', 'card'];
|
||||
|
||||
if (!in_array($paymentMethod, $allowedMethods, true)) {
|
||||
throw new \InvalidArgumentException('Invalid payment method.');
|
||||
}
|
||||
|
||||
if ($paymentMethod === 'check' && ($checkNumber === null || $checkNumber === '')) {
|
||||
throw new \InvalidArgumentException('Check number is required for check payments.');
|
||||
}
|
||||
|
||||
if ($amount <= 0) {
|
||||
throw new \InvalidArgumentException('Invalid amount.');
|
||||
}
|
||||
|
||||
$invoice = Invoice::query()->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
throw new \RuntimeException('Invoice not found.');
|
||||
}
|
||||
|
||||
$schoolYear = $schoolYear ?: (string) ($invoice->school_year ?? Configuration::getConfig('school_year'));
|
||||
$semester = $semester ?: (string) ($invoice->semester ?? Configuration::getConfig('semester'));
|
||||
|
||||
$initialPreBalance = $this->getCurrentInvoiceBalance($invoiceId, $schoolYear);
|
||||
|
||||
if ($paymentMethod === 'card' && round($amount, 2) !== round($initialPreBalance, 2)) {
|
||||
throw new \InvalidArgumentException('Card payments must equal the full remaining balance.');
|
||||
}
|
||||
|
||||
$checkFile = null;
|
||||
if ($paymentFile) {
|
||||
$checkFile = $this->storePaymentFile($paymentFile, $paymentMethod);
|
||||
}
|
||||
|
||||
$paymentDate = $paymentDate ? date('Y-m-d H:i:s', strtotime($paymentDate)) : (function_exists('utc_now') ? utc_now() : now()->toDateTimeString());
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$locked = DB::table('invoices')->where('id', $invoiceId)->lockForUpdate()->first();
|
||||
if (!$locked) {
|
||||
DB::rollBack();
|
||||
throw new \RuntimeException('Invoice not found.');
|
||||
}
|
||||
|
||||
$this->invoiceService->recalculateInvoice($invoiceId, $schoolYear);
|
||||
$currentBalance = $this->getCurrentInvoiceBalance($invoiceId, $schoolYear);
|
||||
|
||||
if ($amount > $currentBalance + 0.00001) {
|
||||
DB::rollBack();
|
||||
throw new \InvalidArgumentException('Entered amount exceeds remaining balance.');
|
||||
}
|
||||
|
||||
if ($paymentMethod === 'card' && round($amount, 2) !== round($currentBalance, 2)) {
|
||||
DB::rollBack();
|
||||
throw new \InvalidArgumentException('Card payments must equal the full remaining balance.');
|
||||
}
|
||||
|
||||
$paidCount = $this->getSuccessfulPaymentCount($invoiceId);
|
||||
$installmentSeq = $paidCount + 1;
|
||||
|
||||
$transactionId = 'INV-' . $invoiceId . '-' . str_replace('.', '', (string) microtime(true));
|
||||
|
||||
$ok = $this->processPayment(
|
||||
$invoiceId,
|
||||
$amount,
|
||||
$paymentMethod,
|
||||
$checkFile,
|
||||
$transactionId,
|
||||
$paymentDate,
|
||||
$schoolYear,
|
||||
$semester,
|
||||
$checkNumber,
|
||||
$installmentSeq,
|
||||
$userId
|
||||
);
|
||||
|
||||
if (!$ok) {
|
||||
DB::rollBack();
|
||||
throw new \RuntimeException('Failed to record payment.');
|
||||
}
|
||||
|
||||
$this->invoiceService->recalculateInvoice($invoiceId, $schoolYear);
|
||||
|
||||
$postBalance = max(0.0, round($initialPreBalance - $amount, 2));
|
||||
|
||||
$enrollmentUpdated = $this->invoiceService->updateEnrollmentStatusIfPaid($invoiceId, $schoolYear);
|
||||
if ($enrollmentUpdated > 0) {
|
||||
$studentIds = Student::query()->where('parent_id', $invoice->parent_id)->pluck('id')->all();
|
||||
[$eventDataEnroll, $studentDataEnroll] = $this->enrollmentEventService->buildEventData(
|
||||
(int) $invoice->parent_id,
|
||||
$studentIds,
|
||||
$schoolYear,
|
||||
$semester
|
||||
);
|
||||
|
||||
$this->triggerEvent('studentEnrolled', [$eventDataEnroll, $studentDataEnroll]);
|
||||
}
|
||||
|
||||
$invoiceDiscount = (float) (DB::table('discount_usages')
|
||||
->selectRaw('COALESCE(SUM(discount_amount),0) AS sum_disc')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->value('sum_disc') ?? 0);
|
||||
|
||||
$parentYearDiscountTotal = (float) (DB::table('discount_usages')
|
||||
->selectRaw('COALESCE(SUM(discount_amount),0) AS sum_disc')
|
||||
->where('parent_id', $invoice->parent_id)
|
||||
->where('school_year', $schoolYear)
|
||||
->value('sum_disc') ?? 0);
|
||||
|
||||
DB::commit();
|
||||
|
||||
[$eventData, $studentData] = $this->invoiceService->buildPaymentEventData(
|
||||
$invoiceId,
|
||||
$transactionId,
|
||||
$amount,
|
||||
$paymentMethod,
|
||||
$paymentDate,
|
||||
$checkNumber,
|
||||
$installmentSeq,
|
||||
$initialPreBalance,
|
||||
$postBalance,
|
||||
$schoolYear,
|
||||
$semester
|
||||
);
|
||||
|
||||
$eventData['invoice_discount'] = $invoiceDiscount;
|
||||
$eventData['parent_year_discount_total'] = $parentYearDiscountTotal;
|
||||
|
||||
$this->triggerEvent('paymentReceived', [$eventData, $studentData]);
|
||||
|
||||
return [
|
||||
'transaction_id' => $transactionId,
|
||||
'installment_seq' => $installmentSeq,
|
||||
'pre_balance' => $initialPreBalance,
|
||||
'post_balance' => $postBalance,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('[manualPayUpdate] ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function editPayment(
|
||||
int $paymentId,
|
||||
float $paidAmount,
|
||||
string $paymentMethod,
|
||||
?string $checkNumber,
|
||||
?UploadedFile $paymentFile,
|
||||
?int $userId = null
|
||||
): void {
|
||||
$paymentMethod = strtolower(trim($paymentMethod));
|
||||
|
||||
if ($paidAmount <= 0) {
|
||||
throw new \InvalidArgumentException('Invalid amount entered.');
|
||||
}
|
||||
|
||||
$payment = Payment::query()->find($paymentId);
|
||||
if (!$payment) {
|
||||
throw new \RuntimeException('Original payment not found.');
|
||||
}
|
||||
|
||||
$invoice = Invoice::query()->find((int) $payment->invoice_id);
|
||||
if (!$invoice) {
|
||||
throw new \RuntimeException('Linked invoice not found.');
|
||||
}
|
||||
|
||||
$checkFile = $payment->check_file;
|
||||
if ($paymentFile) {
|
||||
$checkFile = $this->storePaymentFile($paymentFile, $paymentMethod);
|
||||
}
|
||||
|
||||
$this->invoiceService->recalculateInvoice((int) $payment->invoice_id, (string) ($invoice->school_year ?? Configuration::getConfig('school_year')));
|
||||
|
||||
$currentBalance = $this->getCurrentInvoiceBalanceExcludingPayment((int) $payment->invoice_id, $paymentId);
|
||||
|
||||
if ($paidAmount > $currentBalance) {
|
||||
throw new \InvalidArgumentException('Entered amount exceeds remaining balance.');
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'paid_amount' => $paidAmount,
|
||||
'payment_method' => $paymentMethod,
|
||||
'check_file' => $checkFile,
|
||||
'updated_by' => $userId,
|
||||
'check_number' => $paymentMethod === 'check' ? $checkNumber : null,
|
||||
];
|
||||
|
||||
Payment::query()->whereKey($paymentId)->update($updateData);
|
||||
|
||||
$this->invoiceService->recalculateInvoice((int) $payment->invoice_id, (string) ($invoice->school_year ?? Configuration::getConfig('school_year')));
|
||||
}
|
||||
|
||||
public function serveCheckFile(string $filename, string $mode = 'download')
|
||||
{
|
||||
$filename = basename($filename);
|
||||
$roots = [
|
||||
storage_path('app/uploads/checks/' . $filename),
|
||||
storage_path('app/uploads/cards/' . $filename),
|
||||
storage_path('app/uploads/misc/' . $filename),
|
||||
storage_path('app/uploads/' . $filename),
|
||||
];
|
||||
|
||||
$path = null;
|
||||
foreach ($roots as $candidate) {
|
||||
if (is_file($candidate)) {
|
||||
$path = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$path) {
|
||||
abort(404, 'Payment file not found.');
|
||||
}
|
||||
|
||||
if ($mode === 'inline') {
|
||||
return response()->file($path, [
|
||||
'Content-Type' => mime_content_type($path) ?: 'application/octet-stream',
|
||||
'Content-Disposition' => 'inline; filename="' . $filename . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->download($path, $filename);
|
||||
}
|
||||
|
||||
private function processPayment(
|
||||
int $invoiceId,
|
||||
float $amount,
|
||||
string $paymentMethod,
|
||||
?string $checkFile,
|
||||
string $transactionId,
|
||||
string $paymentDate,
|
||||
?string $schoolYear,
|
||||
?string $semester,
|
||||
?string $checkNumber,
|
||||
int $installmentSeq,
|
||||
?int $userId
|
||||
): bool {
|
||||
$invoice = Invoice::query()->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$newPaid = (float) $invoice->paid_amount + $amount;
|
||||
$newBalance = (float) $invoice->balance - $amount;
|
||||
|
||||
if ($newBalance == $invoice->total_amount) {
|
||||
$paymentStatus = 'Unpaid';
|
||||
} elseif ($newBalance > 0 && $newBalance < $invoice->total_amount) {
|
||||
$paymentStatus = 'Partially Paid';
|
||||
} elseif ($newBalance <= 0.00001) {
|
||||
$paymentStatus = 'Paid';
|
||||
} else {
|
||||
$paymentStatus = $invoice->status;
|
||||
}
|
||||
|
||||
$invoiceUpdateData = [
|
||||
'paid_amount' => $newPaid,
|
||||
'balance' => $newBalance,
|
||||
'status' => $paymentStatus,
|
||||
'updated_by' => $userId,
|
||||
];
|
||||
|
||||
if (!Invoice::query()->whereKey($invoiceId)->update($invoiceUpdateData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$paymentData = [
|
||||
'parent_id' => $invoice->parent_id,
|
||||
'invoice_id' => $invoiceId,
|
||||
'total_amount' => $invoice->total_amount,
|
||||
'paid_amount' => $amount,
|
||||
'balance' => $newBalance,
|
||||
'number_of_installments' => $installmentSeq,
|
||||
'transaction_id' => $transactionId,
|
||||
'payment_method' => strtolower($paymentMethod),
|
||||
'payment_date' => $paymentDate,
|
||||
'status' => $paymentStatus,
|
||||
'check_file' => $checkFile,
|
||||
'check_number' => strtolower($paymentMethod) === 'check' ? $checkNumber : null,
|
||||
'updated_by' => $userId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
];
|
||||
|
||||
return (bool) Payment::query()->create($paymentData);
|
||||
}
|
||||
|
||||
private function getCurrentInvoiceBalance(int $invoiceId, string $schoolYear): float
|
||||
{
|
||||
return $this->invoiceService->getCurrentInvoiceBalance($invoiceId, $schoolYear);
|
||||
}
|
||||
|
||||
private function getCurrentInvoiceBalanceExcludingPayment(int $invoiceId, int $excludePaymentId): float
|
||||
{
|
||||
$invoice = Invoice::query()->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$paymentQuery = Payment::query()
|
||||
->selectRaw('COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('id', '!=', $excludePaymentId);
|
||||
|
||||
if (Schema::hasColumn('payments', 'status')) {
|
||||
$paymentQuery->where(function ($q) {
|
||||
$q->whereNotIn('status', [
|
||||
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
|
||||
])->orWhereNull('status');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('payments', 'is_void')) {
|
||||
$paymentQuery->where(function ($q) {
|
||||
$q->where('is_void', 0)->orWhereNull('is_void');
|
||||
});
|
||||
}
|
||||
|
||||
$row = (array) $paymentQuery->first();
|
||||
$totalPaid = (float) ($row['total_paid'] ?? 0);
|
||||
|
||||
$totalDisc = (float) (DB::table('discount_usages')
|
||||
->selectRaw('COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->value('total_disc') ?? 0);
|
||||
|
||||
$totalRefundPaid = (float) (DB::table('refunds')
|
||||
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->value('total_refund_paid') ?? 0);
|
||||
|
||||
$total = (float) ($invoice->total_amount ?? 0);
|
||||
|
||||
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
|
||||
}
|
||||
|
||||
private function getSuccessfulPaymentCount(int $invoiceId): int
|
||||
{
|
||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
|
||||
return Payment::query()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('paid_amount', '>', 0)
|
||||
->whereNotIn('status', $exclude)
|
||||
->count();
|
||||
}
|
||||
|
||||
private function normalizeInvoices(array $rawInvoices, string $installmentEndYmd): array
|
||||
{
|
||||
if (empty($rawInvoices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$invoiceIds = array_values(array_unique(array_map(static fn ($r) => (int) ($r['id'] ?? 0), $rawInvoices)));
|
||||
$paidByInvoice = [];
|
||||
$discountByInvoice = [];
|
||||
$refundByInvoice = [];
|
||||
|
||||
if (!empty($invoiceIds)) {
|
||||
$rows = Payment::query()
|
||||
->selectRaw('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$paidByInvoice[(int) ($row['invoice_id'] ?? 0)] = (float) ($row['total_paid'] ?? 0);
|
||||
}
|
||||
|
||||
$discRows = DB::table('discount_usages')
|
||||
->selectRaw('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
foreach ($discRows as $row) {
|
||||
$discountByInvoice[(int) ($row['invoice_id'] ?? 0)] = (float) ($row['total_disc'] ?? 0);
|
||||
}
|
||||
|
||||
$refundRows = DB::table('refunds')
|
||||
->selectRaw('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
foreach ($refundRows as $row) {
|
||||
$refundByInvoice[(int) ($row['invoice_id'] ?? 0)] = (float) ($row['total_refund_paid'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return array_map(function (array $inv) use ($installmentEndYmd, $paidByInvoice, $discountByInvoice, $refundByInvoice) {
|
||||
$inv['total_amount'] = isset($inv['total_amount']) ? (float) $inv['total_amount'] : 0.0;
|
||||
$iid = (int) ($inv['id'] ?? 0);
|
||||
$actualPaid = $paidByInvoice[$iid] ?? null;
|
||||
$inv['paid_amount'] = is_numeric($actualPaid) ? (float) $actualPaid : (float) ($inv['paid_amount'] ?? 0);
|
||||
$inv['discount'] = isset($discountByInvoice[$iid]) ? (float) $discountByInvoice[$iid] : (float) ($inv['discount'] ?? 0.0);
|
||||
$inv['refund_paid'] = isset($refundByInvoice[$iid]) ? (float) $refundByInvoice[$iid] : 0.0;
|
||||
$inv['balance'] = max(0.0, (float) $inv['total_amount'] - (float) $inv['paid_amount'] - (float) $inv['discount'] - (float) $inv['refund_paid']);
|
||||
$inv['due_ymd'] = $installmentEndYmd;
|
||||
|
||||
$issueYmd = '';
|
||||
foreach (['issue_date', 'start_date', 'created_at'] as $k) {
|
||||
if (!empty($inv[$k])) {
|
||||
$its = strtotime((string) $inv[$k]);
|
||||
if ($its !== false) {
|
||||
$issueYmd = date('Y-m-d', $its);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$inv['issue_ymd'] = $issueYmd;
|
||||
|
||||
return $inv;
|
||||
}, $rawInvoices);
|
||||
}
|
||||
|
||||
private function formatPhone(string $input): string
|
||||
{
|
||||
$digits = preg_replace('/\D/', '', $input);
|
||||
if (strlen($digits) !== 10) {
|
||||
return $input;
|
||||
}
|
||||
|
||||
return sprintf('(%s)-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6, 4));
|
||||
}
|
||||
|
||||
private function storePaymentFile(UploadedFile $file, string $paymentMethod): string
|
||||
{
|
||||
$mime = $file->getMimeType();
|
||||
$ext = strtolower($file->getClientOriginalExtension() ?: '');
|
||||
|
||||
$okTypes = ['image/jpeg', 'image/png', 'application/pdf'];
|
||||
$okExts = ['jpg', 'jpeg', 'png', 'pdf'];
|
||||
|
||||
if (!in_array($mime, $okTypes, true) || !in_array($ext, $okExts, true)) {
|
||||
throw new \InvalidArgumentException('Unsupported file type. Use JPG, PNG, or PDF.');
|
||||
}
|
||||
|
||||
if ($file->getSize() > 5 * 1024 * 1024) {
|
||||
throw new \InvalidArgumentException('File too large. Max 5MB.');
|
||||
}
|
||||
|
||||
$fileName = $file->hashName();
|
||||
$subdir = $paymentMethod === 'check' ? 'checks' : ($paymentMethod === 'card' ? 'cards' : 'misc');
|
||||
$targetDir = storage_path('app/uploads/' . $subdir);
|
||||
|
||||
if (!is_dir($targetDir)) {
|
||||
mkdir($targetDir, 0775, true);
|
||||
}
|
||||
|
||||
$file->move($targetDir, $fileName);
|
||||
|
||||
return $fileName;
|
||||
}
|
||||
|
||||
private function triggerEvent(string $name, array $payload): void
|
||||
{
|
||||
try {
|
||||
if (class_exists(\CodeIgniter\Events\Events::class)) {
|
||||
\CodeIgniter\Events\Events::trigger($name, ...$payload);
|
||||
} elseif (function_exists('event')) {
|
||||
event($name, $payload);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning($name . ' hook failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Notification;
|
||||
use App\Models\UserNotification;
|
||||
use App\Models\Configuration;
|
||||
|
||||
class PaymentNotificationDispatchService
|
||||
{
|
||||
public function notifyUser(int $userId, string $title, string $message, array $channels = ['in_app']): void
|
||||
{
|
||||
$schoolYear = Configuration::getConfig('school_year');
|
||||
$semester = Configuration::getConfig('semester');
|
||||
|
||||
$notification = Notification::query()->create([
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'target_group' => 'user',
|
||||
'delivery_channels' => $channels,
|
||||
'priority' => 'normal',
|
||||
'status' => 'sent',
|
||||
'scheduled_at' => now(),
|
||||
'sent_at' => now(),
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
|
||||
UserNotification::query()->create([
|
||||
'notification_id' => (int) $notification->id,
|
||||
'user_id' => $userId,
|
||||
'is_read' => 0,
|
||||
'delivered' => 1,
|
||||
'delivered_at' => now(),
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\PaymentNotificationLog;
|
||||
use App\Models\User;
|
||||
use App\Services\EmailService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PaymentNotificationService
|
||||
{
|
||||
public function __construct(
|
||||
private EmailService $emailService,
|
||||
private PaymentNotificationDispatchService $dispatchService
|
||||
) {
|
||||
}
|
||||
|
||||
public function listLogs(?string $from, ?string $to, ?string $type): array
|
||||
{
|
||||
$query = PaymentNotificationLog::query()->with('parent')->orderByDesc('sent_at');
|
||||
|
||||
if (!empty($from)) {
|
||||
$query->where('sent_at', '>=', $from);
|
||||
}
|
||||
if (!empty($to)) {
|
||||
$query->where('sent_at', '<=', $to);
|
||||
}
|
||||
if (!empty($type)) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
return $query->get()->all();
|
||||
}
|
||||
|
||||
public function send(array $payload): array
|
||||
{
|
||||
$parentId = (int) ($payload['parent_id'] ?? 0);
|
||||
$typeInput = (string) ($payload['type'] ?? '');
|
||||
$force = (bool) ($payload['force'] ?? false);
|
||||
$schoolYear = (string) ($payload['school_year'] ?? (Configuration::getConfig('school_year') ?? date('Y')));
|
||||
|
||||
$tzName = $this->getTimezone();
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$now = new \DateTime('now', $tz);
|
||||
$year = (int) $now->format('Y');
|
||||
$month = (int) $now->format('n');
|
||||
|
||||
$results = [
|
||||
'sent' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
'details' => [],
|
||||
];
|
||||
|
||||
if ($parentId > 0) {
|
||||
$result = $this->sendToParent($parentId, $typeInput, $schoolYear, $year, $month, $now, $force, false);
|
||||
$results = $this->mergeResults($results, $result);
|
||||
} else {
|
||||
$parentIds = $this->getParentsWithInvoices($schoolYear);
|
||||
foreach ($parentIds as $pid) {
|
||||
$result = $this->sendToParent($pid, $typeInput, $schoolYear, $year, $month, $now, $force, true);
|
||||
$results = $this->mergeResults($results, $result);
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function sendToParent(
|
||||
int $parentId,
|
||||
string $typeInput,
|
||||
string $schoolYear,
|
||||
int $year,
|
||||
int $month,
|
||||
\DateTime $now,
|
||||
bool $force,
|
||||
bool $notifyHeads
|
||||
): array {
|
||||
$results = ['sent' => 0, 'skipped' => 0, 'failed' => 0, 'details' => []];
|
||||
|
||||
[$balance, $latestInv] = $this->computeBalance($parentId, $schoolYear);
|
||||
|
||||
$type = $this->resolveType($parentId, $schoolYear, $typeInput);
|
||||
|
||||
if (!$force && PaymentNotificationLog::existsForPeriod($parentId, $year, $month, $type)) {
|
||||
$results['skipped']++;
|
||||
$results['details'][] = ['parent_id' => $parentId, 'result' => 'already_sent'];
|
||||
return $results;
|
||||
}
|
||||
|
||||
if ($balance <= 0 && !$force) {
|
||||
$results['skipped']++;
|
||||
$results['details'][] = ['parent_id' => $parentId, 'result' => 'no_balance'];
|
||||
return $results;
|
||||
}
|
||||
|
||||
[$subject, $body] = $this->composeMessage($parentId, $balance, $schoolYear, $now);
|
||||
$installmentInfo = $this->computeInstallmentSuggestion($balance);
|
||||
|
||||
$toEmail = $this->getUserEmail($parentId);
|
||||
$ccEmail = $this->getSecondaryGuardianEmail($parentId);
|
||||
|
||||
$ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false;
|
||||
if ($ok && $ccEmail && strcasecmp($ccEmail, (string) $toEmail) !== 0) {
|
||||
$this->emailService->send($ccEmail, $subject, $body, 'finance');
|
||||
}
|
||||
|
||||
PaymentNotificationLog::query()->create([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $latestInv,
|
||||
'school_year' => $schoolYear,
|
||||
'period_year' => $year,
|
||||
'period_month' => $month,
|
||||
'type' => $type,
|
||||
'to_email' => $toEmail,
|
||||
'cc_email' => $ccEmail,
|
||||
'head_fa_notified' => $notifyHeads ? 1 : 0,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'status' => $ok ? 'sent' : 'failed',
|
||||
'error_message' => $ok ? null : 'Missing/invalid email or send failed',
|
||||
'balance_snapshot' => $balance,
|
||||
'sent_at' => $now->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
if ($ok) {
|
||||
$results['sent']++;
|
||||
$results['details'][] = [
|
||||
'parent_id' => $parentId,
|
||||
'result' => 'sent',
|
||||
'balance' => $balance,
|
||||
'installment_due' => $installmentInfo['installment_due'],
|
||||
'remaining' => $installmentInfo['remaining_installments'],
|
||||
];
|
||||
} else {
|
||||
$results['failed']++;
|
||||
$results['details'][] = ['parent_id' => $parentId, 'result' => 'failed'];
|
||||
}
|
||||
|
||||
if ($notifyHeads) {
|
||||
$this->notifyHeadsOfFinance($type, $parentId, $balance);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function computeBalance(int $parentId, string $schoolYear): array
|
||||
{
|
||||
$rows = DB::table('invoices')
|
||||
->select('id', 'total_amount')
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$hasStatus = DB::getSchemaBuilder()->hasColumn('payments', 'status');
|
||||
$hasVoid = DB::getSchemaBuilder()->hasColumn('payments', 'is_void');
|
||||
|
||||
$sumBalance = 0.0;
|
||||
$latestId = null;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$invoiceId = (int) ($row['id'] ?? 0);
|
||||
if ($invoiceId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($latestId === null) {
|
||||
$latestId = $invoiceId;
|
||||
}
|
||||
|
||||
$total = (float) ($row['total_amount'] ?? 0);
|
||||
|
||||
$qb = DB::table('payments')
|
||||
->selectRaw('COALESCE(SUM(paid_amount),0) AS total')
|
||||
->where('invoice_id', $invoiceId);
|
||||
|
||||
if ($hasStatus) {
|
||||
$qb->where(function ($q) {
|
||||
$q->whereNotIn('status', [
|
||||
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
|
||||
])->orWhereNull('status');
|
||||
});
|
||||
}
|
||||
|
||||
if ($hasVoid) {
|
||||
$qb->where(function ($q) {
|
||||
$q->where('is_void', 0)->orWhereNull('is_void');
|
||||
});
|
||||
}
|
||||
|
||||
$paid = (float) (optional($qb->first())->total ?? 0);
|
||||
|
||||
$disc = (float) (optional(DB::table('discount_usages')
|
||||
->selectRaw('COALESCE(SUM(discount_amount),0) AS total')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->first())->total ?? 0);
|
||||
|
||||
$rfnd = (float) (optional(DB::table('refunds')
|
||||
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->first())->total ?? 0);
|
||||
|
||||
$sumBalance += max(0.0, round($total - $disc - $paid - $rfnd, 2));
|
||||
}
|
||||
|
||||
return [$sumBalance, $latestId];
|
||||
}
|
||||
|
||||
private function resolveType(int $parentId, string $schoolYear, string $typeInput): string
|
||||
{
|
||||
if (in_array($typeInput, ['no_payment', 'installment'], true)) {
|
||||
return $typeInput;
|
||||
}
|
||||
|
||||
$hasPayments = DB::table('payments')
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->count() > 0;
|
||||
|
||||
return $hasPayments ? 'installment' : 'no_payment';
|
||||
}
|
||||
|
||||
private function composeMessage(int $parentId, float $balance, string $schoolYear, \DateTime $now): array
|
||||
{
|
||||
$user = User::query()->find($parentId);
|
||||
$parentName = $user ? trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? '')) : '';
|
||||
$greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,';
|
||||
|
||||
$monthYear = $now->format('F Y');
|
||||
$subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear);
|
||||
$balanceFmt = '$' . number_format($balance, 2);
|
||||
$intro = 'This is your monthly installment reminder for ' . $schoolYear . '.';
|
||||
|
||||
$installmentInfo = $this->computeInstallmentSuggestion($balance);
|
||||
|
||||
$bodyHtml = <<<HTML
|
||||
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
|
||||
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder</h2>
|
||||
<p>{$greeting}</p>
|
||||
<p>{$intro}</p>
|
||||
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
|
||||
<ul>
|
||||
<li><strong>Remaining installments:</strong> {$installmentInfo['remaining_installments']}</li>
|
||||
<li><strong>Suggested installment this month:</strong> {$installmentInfo['installment_due_fmt']}</li>
|
||||
<li><strong>Maximum installments available:</strong> {$installmentInfo['max_installments']}</li>
|
||||
</ul>
|
||||
<p>As a friendly reminder, our tuition installments are due at the beginning of each month.</p>
|
||||
<p>You can review your invoice and payment options by logging in to your parent portal.</p>
|
||||
<p>Thank you for your prompt attention.</p>
|
||||
<p><em>Reminder Period:</em> {$monthYear}</p>
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
if (view()->exists('emails._wrap_layout')) {
|
||||
$body = view('emails._wrap_layout', [
|
||||
'title' => 'Monthly Tuition Reminder',
|
||||
'body_html' => $bodyHtml,
|
||||
], ['saveData' => true]);
|
||||
} else {
|
||||
$body = $bodyHtml;
|
||||
}
|
||||
|
||||
return [$subject, $body];
|
||||
}
|
||||
|
||||
private function computeInstallmentSuggestion(float $balance): array
|
||||
{
|
||||
$installmentEndRaw = (string) (Configuration::getConfig('installment_date') ?? '');
|
||||
$tzName = $this->getTimezone();
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$today = new \DateTimeImmutable('today', $tz);
|
||||
|
||||
$end = null;
|
||||
if ($installmentEndRaw !== '') {
|
||||
try {
|
||||
$end = new \DateTimeImmutable($installmentEndRaw, $tz);
|
||||
} catch (\Throwable $e) {
|
||||
$end = null;
|
||||
}
|
||||
}
|
||||
|
||||
$remMonths = 0;
|
||||
if ($end) {
|
||||
$y = (int) $end->format('Y') - (int) $today->format('Y');
|
||||
$m = (int) $end->format('n') - (int) $today->format('n');
|
||||
$remMonths = $y * 12 + $m;
|
||||
if ((int) $end->format('j') > (int) $today->format('j')) {
|
||||
$remMonths += 1;
|
||||
}
|
||||
if ($remMonths < 0) {
|
||||
$remMonths = 0;
|
||||
}
|
||||
}
|
||||
|
||||
$remainingInstallments = max(1, $remMonths);
|
||||
$maxInstallments = max($remMonths ?: 0, $balance > 0 ? 2 : 0);
|
||||
$installmentDue = $remainingInstallments > 0 ? ($balance / $remainingInstallments) : 0;
|
||||
|
||||
return [
|
||||
'remaining_installments' => $remainingInstallments,
|
||||
'max_installments' => $maxInstallments,
|
||||
'installment_due' => round($installmentDue, 2),
|
||||
'installment_due_fmt' => '$' . number_format($installmentDue, 2),
|
||||
];
|
||||
}
|
||||
|
||||
private function getSecondaryGuardianEmail(int $primaryUserId): ?string
|
||||
{
|
||||
$row = DB::table('family_guardians')->where('user_id', $primaryUserId)->first();
|
||||
if (!$row || empty($row->family_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$others = DB::table('family_guardians')
|
||||
->where('family_id', (int) $row->family_id)
|
||||
->where('user_id', '!=', $primaryUserId)
|
||||
->where('receive_emails', 1)
|
||||
->get()
|
||||
->map(fn ($r) => (array) $r)
|
||||
->all();
|
||||
|
||||
foreach ($others as $g) {
|
||||
$email = (string) (DB::table('users')->where('id', (int) ($g['user_id'] ?? 0))->value('email') ?? '');
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return $email;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getUserEmail(int $userId): ?string
|
||||
{
|
||||
$email = (string) (DB::table('users')->where('id', $userId)->value('email') ?? '');
|
||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return null;
|
||||
}
|
||||
return $email;
|
||||
}
|
||||
|
||||
private function notifyHeadsOfFinance(string $type, int $parentId, float $balance): void
|
||||
{
|
||||
try {
|
||||
$heads = User::getUsersByRole('head of department (finance)');
|
||||
if (empty($heads)) {
|
||||
$heads = User::getUsersByRole('accountant');
|
||||
}
|
||||
|
||||
foreach ($heads as $user) {
|
||||
$this->dispatchService->notifyUser(
|
||||
(int) ($user['id'] ?? 0),
|
||||
'Payment Reminder Sent',
|
||||
sprintf('A %s reminder was sent to parent #%d (balance: $%0.2f).', $type, $parentId, $balance),
|
||||
['in_app']
|
||||
);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Unable to notify heads of finance: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function mergeResults(array $base, array $update): array
|
||||
{
|
||||
$base['sent'] += $update['sent'] ?? 0;
|
||||
$base['skipped'] += $update['skipped'] ?? 0;
|
||||
$base['failed'] += $update['failed'] ?? 0;
|
||||
if (!empty($update['details'])) {
|
||||
$base['details'] = array_merge($base['details'], $update['details']);
|
||||
}
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function getParentsWithInvoices(string $schoolYear): array
|
||||
{
|
||||
$rows = DB::table('invoices')
|
||||
->selectRaw('DISTINCT parent_id')
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
return array_values(array_unique(array_map(static fn ($r) => (int) ($r['parent_id'] ?? 0), $rows)));
|
||||
}
|
||||
|
||||
private function getTimezone(): string
|
||||
{
|
||||
if (function_exists('user_timezone')) {
|
||||
return (string) user_timezone();
|
||||
}
|
||||
return (string) (config('app.timezone') ?: 'UTC');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Payment;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class PaymentPlanService
|
||||
{
|
||||
public function createPlan(array $payload): Payment
|
||||
{
|
||||
$schoolYear = $payload['school_year'] ?? Configuration::getConfig('school_year');
|
||||
$semester = $payload['semester'] ?? Configuration::getConfig('semester');
|
||||
|
||||
$data = [
|
||||
'parent_id' => (int) $payload['parent_id'],
|
||||
'total_amount' => (float) $payload['total_amount'],
|
||||
'number_of_installments' => (int) $payload['number_of_installments'],
|
||||
'paid_amount' => 0,
|
||||
'balance' => (float) $payload['total_amount'],
|
||||
'payment_date' => $payload['payment_date'] ?? null,
|
||||
'payment_method' => $payload['payment_method'] ?? null,
|
||||
'status' => $payload['status'] ?? 'Pending',
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
];
|
||||
|
||||
if (isset($payload['invoice_id']) && Schema::hasColumn('payments', 'invoice_id')) {
|
||||
$data['invoice_id'] = (int) $payload['invoice_id'];
|
||||
}
|
||||
|
||||
if (isset($payload['payment_type']) && Schema::hasColumn('payments', 'payment_type')) {
|
||||
$data['payment_type'] = $payload['payment_type'];
|
||||
}
|
||||
|
||||
return Payment::query()->create($data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\PaymentTransaction;
|
||||
|
||||
class PaymentTransactionService
|
||||
{
|
||||
public function create(array $payload): PaymentTransaction
|
||||
{
|
||||
$data = [
|
||||
'transaction_id' => $payload['transaction_id'],
|
||||
'payment_id' => (int) $payload['payment_id'],
|
||||
'transaction_date' => $payload['transaction_date'],
|
||||
'amount' => (float) $payload['amount'],
|
||||
'payment_method' => $payload['payment_method'],
|
||||
'payment_status' => $payload['payment_status'] ?? 'Pending',
|
||||
'transaction_fee' => $payload['transaction_fee'] ?? null,
|
||||
'payment_reference' => $payload['payment_reference'] ?? null,
|
||||
'is_full_payment' => !empty($payload['is_full_payment']),
|
||||
'school_year' => $payload['school_year'] ?? null,
|
||||
'semester' => $payload['semester'] ?? null,
|
||||
];
|
||||
|
||||
return PaymentTransaction::query()->create($data);
|
||||
}
|
||||
|
||||
public function getByPayment(int $paymentId): array
|
||||
{
|
||||
return PaymentTransaction::getTransactionsByPaymentId($paymentId);
|
||||
}
|
||||
|
||||
public function updateStatus(string $transactionId, string $status): bool
|
||||
{
|
||||
return PaymentTransaction::updateTransactionStatus($transactionId, $status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Payment;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PaypalPaymentService
|
||||
{
|
||||
public function getRedirectUrl(): string
|
||||
{
|
||||
return (string) (config('services.paypal.payment_url')
|
||||
?: env('PAYPAL_PAYMENT_URL')
|
||||
?: 'https://www.paypal.com/ncp/payment/87FJL3EV8C7NE');
|
||||
}
|
||||
|
||||
public function createPayment(int $paymentId): array
|
||||
{
|
||||
$payment = Payment::query()->find($paymentId);
|
||||
if (!$payment) {
|
||||
throw new \RuntimeException('Payment not found.');
|
||||
}
|
||||
|
||||
if (!$this->sdkAvailable()) {
|
||||
return [
|
||||
'redirect_url' => $this->getRedirectUrl(),
|
||||
'mode' => 'hosted',
|
||||
'message' => 'PayPal SDK not installed; using hosted redirect URL.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$apiContext = $this->buildApiContext();
|
||||
|
||||
$payer = new \PayPal\Api\Payer();
|
||||
$payer->setPaymentMethod('paypal');
|
||||
|
||||
$amount = new \PayPal\Api\Amount();
|
||||
$amount->setCurrency('USD')->setTotal($payment->balance ?? $payment->total_amount ?? 0);
|
||||
|
||||
$transaction = new \PayPal\Api\Transaction();
|
||||
$transaction->setAmount($amount)
|
||||
->setDescription('Payment for school fees')
|
||||
->setInvoiceNumber(uniqid('inv_', true));
|
||||
|
||||
$paypalPayment = new \PayPal\Api\Payment();
|
||||
$paypalPayment->setIntent('sale')
|
||||
->setPayer($payer)
|
||||
->setTransactions([$transaction]);
|
||||
|
||||
$redirectUrls = new \PayPal\Api\RedirectUrls();
|
||||
$redirectUrls->setReturnUrl(url('/api/v1/finance/paypal/execute'))
|
||||
->setCancelUrl(url('/api/v1/finance/paypal/cancel'));
|
||||
|
||||
$paypalPayment->setRedirectUrls($redirectUrls);
|
||||
$paypalPayment->create($apiContext);
|
||||
|
||||
session()->put('paypalPaymentId', $paypalPayment->getId());
|
||||
session()->put('paymentId', $paymentId);
|
||||
|
||||
return [
|
||||
'redirect_url' => $paypalPayment->getApprovalLink(),
|
||||
'mode' => 'sdk',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('PayPal create payment failed: ' . $e->getMessage());
|
||||
throw new \RuntimeException('Unable to create PayPal payment.');
|
||||
}
|
||||
}
|
||||
|
||||
public function executePayment(string $payerId, ?string $paypalPaymentId = null): void
|
||||
{
|
||||
if (!$this->sdkAvailable()) {
|
||||
throw new \RuntimeException('PayPal SDK not installed.');
|
||||
}
|
||||
|
||||
$paymentId = $paypalPaymentId ?: (string) session()->get('paypalPaymentId');
|
||||
if (!$paymentId) {
|
||||
throw new \RuntimeException('Missing PayPal payment ID.');
|
||||
}
|
||||
|
||||
$apiContext = $this->buildApiContext();
|
||||
$payment = \PayPal\Api\Payment::get($paymentId, $apiContext);
|
||||
$execution = new \PayPal\Api\PaymentExecution();
|
||||
$execution->setPayerId($payerId);
|
||||
|
||||
$payment->execute($execution, $apiContext);
|
||||
|
||||
$internalPaymentId = (int) session()->get('paymentId');
|
||||
if ($internalPaymentId) {
|
||||
Payment::query()->whereKey($internalPaymentId)->update(['status' => 'Completed']);
|
||||
}
|
||||
}
|
||||
|
||||
public function cancelPayment(): void
|
||||
{
|
||||
// no-op (client handles redirect)
|
||||
}
|
||||
|
||||
private function sdkAvailable(): bool
|
||||
{
|
||||
return class_exists(\PayPal\Rest\ApiContext::class)
|
||||
&& class_exists(\PayPal\Auth\OAuthTokenCredential::class);
|
||||
}
|
||||
|
||||
private function buildApiContext(): \PayPal\Rest\ApiContext
|
||||
{
|
||||
$clientId = (string) (config('services.paypal.client_id') ?: env('PAYPAL_CLIENT_ID'));
|
||||
$secret = (string) (config('services.paypal.secret') ?: env('PAYPAL_SECRET'));
|
||||
$mode = (string) (config('services.paypal.mode') ?: env('PAYPAL_MODE') ?: 'sandbox');
|
||||
|
||||
$apiContext = new \PayPal\Rest\ApiContext(
|
||||
new \PayPal\Auth\OAuthTokenCredential($clientId, $secret)
|
||||
);
|
||||
|
||||
$apiContext->setConfig([
|
||||
'mode' => $mode,
|
||||
'http.headers' => ['Connection' => 'Close'],
|
||||
]);
|
||||
|
||||
return $apiContext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\PayPalPayment;
|
||||
|
||||
class PaypalTransactionsService
|
||||
{
|
||||
public function list(?string $keyword, int $perPage = 10)
|
||||
{
|
||||
$query = PayPalPayment::query()->orderByDesc('created_at');
|
||||
|
||||
if ($keyword) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('transaction_id', 'like', '%' . $keyword . '%')
|
||||
->orWhere('payer_email', 'like', '%' . $keyword . '%')
|
||||
->orWhere('event_type', 'like', '%' . $keyword . '%')
|
||||
->orWhere('order_id', 'like', '%' . $keyword . '%')
|
||||
->orWhere('parent_school_id', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
public function listAll(?string $keyword): array
|
||||
{
|
||||
$query = PayPalPayment::query()->orderByDesc('created_at');
|
||||
|
||||
if ($keyword) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('transaction_id', 'like', '%' . $keyword . '%')
|
||||
->orWhere('payer_email', 'like', '%' . $keyword . '%')
|
||||
->orWhere('event_type', 'like', '%' . $keyword . '%')
|
||||
->orWhere('order_id', 'like', '%' . $keyword . '%')
|
||||
->orWhere('parent_school_id', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
return $query->get()->map(fn ($row) => (array) $row)->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class PhoneFormatterService
|
||||
{
|
||||
public function formatPhoneNumber(string $number): ?string
|
||||
{
|
||||
$digits = preg_replace('/\D/', '', $number);
|
||||
|
||||
if (strlen($digits) === 10) {
|
||||
return sprintf(
|
||||
'%s-%s-%s',
|
||||
substr($digits, 0, 3),
|
||||
substr($digits, 3, 3),
|
||||
substr($digits, 6)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\ReimbursementBatch;
|
||||
use App\Models\ReimbursementBatchItem;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
class ReimbursementBatchAssignmentService
|
||||
{
|
||||
public function __construct(private ReimbursementLookupService $lookupService)
|
||||
{
|
||||
}
|
||||
|
||||
public function updateAssignment(
|
||||
int $expenseId,
|
||||
int $batchId,
|
||||
?int $adminId,
|
||||
?int $reimbursementId,
|
||||
string $schoolYear,
|
||||
string $semester
|
||||
): array {
|
||||
if ($expenseId <= 0) {
|
||||
throw new RuntimeException('Invalid expense id.');
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
$activeItem = ReimbursementBatchItem::query()
|
||||
->where('expense_id', $expenseId)
|
||||
->whereNull('unassigned_at')
|
||||
->first();
|
||||
|
||||
if ($batchId <= 0) {
|
||||
if ($activeItem) {
|
||||
$activeItem->update(['unassigned_at' => $now]);
|
||||
if (!$reimbursementId && !empty($activeItem->reimbursement_id)) {
|
||||
$reimbursementId = (int) $activeItem->reimbursement_id;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'batch_id' => 0,
|
||||
'admin_id' => null,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
];
|
||||
}
|
||||
|
||||
$batch = ReimbursementBatch::query()->find($batchId);
|
||||
if (!$batch || strtolower((string) $batch->status) !== ReimbursementBatch::STATUS_OPEN) {
|
||||
throw new RuntimeException('Batch not found or already closed.');
|
||||
}
|
||||
|
||||
$currentAdmin = $activeItem ? (int) ($activeItem->admin_id ?? 0) : null;
|
||||
$activeSameBatch = $activeItem && (int) ($activeItem->batch_id ?? 0) === $batchId;
|
||||
if ($activeSameBatch && ($currentAdmin === ($adminId ?? 0))) {
|
||||
return [
|
||||
'batch_id' => $batchId,
|
||||
'admin_id' => $adminId,
|
||||
'reimbursement_id' => $activeItem->reimbursement_id ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($activeSameBatch) {
|
||||
if (!$reimbursementId) {
|
||||
$reimbursementId = $activeItem->reimbursement_id
|
||||
? (int) $activeItem->reimbursement_id
|
||||
: $this->lookupService->findReimbursementIdForExpense($expenseId);
|
||||
}
|
||||
|
||||
$activeItem->update([
|
||||
'admin_id' => $adminId,
|
||||
'assigned_at' => $now,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
'unassigned_at' => $adminId === null ? $now : null,
|
||||
]);
|
||||
|
||||
return [
|
||||
'batch_id' => $batchId,
|
||||
'admin_id' => $adminId,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
];
|
||||
}
|
||||
|
||||
if ($activeItem) {
|
||||
$activeItem->update(['unassigned_at' => $now]);
|
||||
if (!$reimbursementId && !empty($activeItem->reimbursement_id)) {
|
||||
$reimbursementId = (int) $activeItem->reimbursement_id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$reimbursementId) {
|
||||
$reimbursementId = $this->lookupService->findReimbursementIdForExpense($expenseId);
|
||||
}
|
||||
|
||||
try {
|
||||
ReimbursementBatchItem::query()->create([
|
||||
'batch_id' => $batchId,
|
||||
'expense_id' => $expenseId,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
'admin_id' => $adminId,
|
||||
'assigned_at' => $now,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to assign expense to batch: ' . $e->getMessage(), [
|
||||
'expense_id' => $expenseId,
|
||||
'batch_id' => $batchId,
|
||||
]);
|
||||
throw new RuntimeException('Unable to update batch assignment right now.');
|
||||
}
|
||||
|
||||
return [
|
||||
'batch_id' => $batchId,
|
||||
'admin_id' => $adminId,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\Expense;
|
||||
use App\Models\Reimbursement;
|
||||
use App\Models\ReimbursementBatch;
|
||||
use App\Models\ReimbursementBatchAdminFile;
|
||||
use App\Models\ReimbursementBatchItem;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class ReimbursementBatchService
|
||||
{
|
||||
public function __construct(private ReimbursementLookupService $lookupService)
|
||||
{
|
||||
}
|
||||
|
||||
public function createBatch(?string $title, int $userId, string $schoolYear, string $semester): array
|
||||
{
|
||||
$sequence = $this->nextYearlyBatchNumber($schoolYear);
|
||||
$now = now();
|
||||
|
||||
$batch = ReimbursementBatch::query()->create([
|
||||
'title' => $title !== null && trim($title) !== '' ? trim($title) : null,
|
||||
'status' => ReimbursementBatch::STATUS_OPEN,
|
||||
'created_by' => $userId ?: null,
|
||||
'opened_at' => $now,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'yearly_batch_number' => $sequence,
|
||||
]);
|
||||
|
||||
$label = trim((string) $batch->title) !== '' ? (string) $batch->title : ('Batch #' . $sequence);
|
||||
if (trim((string) $batch->title) === '') {
|
||||
$batch->title = $label;
|
||||
$batch->save();
|
||||
}
|
||||
|
||||
return [
|
||||
'batch_id' => (int) $batch->id,
|
||||
'label' => $label,
|
||||
'sequence' => $sequence,
|
||||
];
|
||||
}
|
||||
|
||||
public function lockBatch(int $batchId, int $userId, string $schoolYear, string $semester): void
|
||||
{
|
||||
$batch = ReimbursementBatch::query()->find($batchId);
|
||||
if (!$batch || strtolower((string) $batch->status) !== ReimbursementBatch::STATUS_OPEN) {
|
||||
throw new RuntimeException('Batch not found or already closed.');
|
||||
}
|
||||
|
||||
$adminRows = ReimbursementBatchItem::query()
|
||||
->selectRaw('DISTINCT COALESCE(admin_id, 0) AS admin_id')
|
||||
->where('batch_id', $batchId)
|
||||
->whereNull('unassigned_at')
|
||||
->get();
|
||||
|
||||
$adminIds = array_values(array_unique($adminRows->pluck('admin_id')->map(fn ($id) => (int) $id)->all()));
|
||||
$requiredAdmins = array_values(array_filter($adminIds, static fn ($adminId) => $adminId > 0));
|
||||
|
||||
if (!empty($requiredAdmins)) {
|
||||
$uploadedAdminIds = ReimbursementBatchAdminFile::query()
|
||||
->where('batch_id', $batchId)
|
||||
->whereIn('admin_id', $requiredAdmins)
|
||||
->pluck('admin_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
foreach ($requiredAdmins as $adminId) {
|
||||
if (!in_array($adminId, $uploadedAdminIds, true)) {
|
||||
throw new RuntimeException('All admin sections must have a check file before submitting the batch.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($batchId, $userId, $schoolYear, $semester) {
|
||||
$items = DB::table('reimbursement_batch_items as bi')
|
||||
->selectRaw('bi.id as batch_item_id, bi.reimbursement_id as batch_reimb_id, bi.expense_id, e.amount, e.purchased_by, e.description, e.reimbursement_id as expense_reimb_id, e.school_year as expense_school_year, e.semester as expense_semester')
|
||||
->join('expenses as e', 'e.id', '=', 'bi.expense_id')
|
||||
->where('bi.batch_id', $batchId)
|
||||
->whereNull('bi.unassigned_at')
|
||||
->get();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$expenseId = (int) ($item->expense_id ?? 0);
|
||||
$recipientId = (int) ($item->purchased_by ?? 0);
|
||||
if ($expenseId <= 0 || $recipientId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reimbId = $item->batch_reimb_id ?: ($item->expense_reimb_id ?: $this->lookupService->findReimbursementIdForExpense($expenseId));
|
||||
|
||||
if (!$reimbId) {
|
||||
$payload = [
|
||||
'expense_id' => $expenseId,
|
||||
'amount' => (float) ($item->amount ?? 0),
|
||||
'reimbursed_to' => $recipientId,
|
||||
'approved_by' => $userId ?: null,
|
||||
'description' => trim((string) ($item->description ?? '')),
|
||||
'status' => 'Paid',
|
||||
'added_by' => $userId ?: null,
|
||||
'school_year' => $item->expense_school_year ?: $schoolYear,
|
||||
'semester' => $item->expense_semester ?: $semester,
|
||||
'reimbursement_method' => 'Check',
|
||||
'batch_number' => $batchId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
$reimbId = (int) Reimbursement::query()->create($payload)->id;
|
||||
}
|
||||
|
||||
if ($reimbId) {
|
||||
Reimbursement::query()->where('id', $reimbId)->update([
|
||||
'batch_number' => $batchId,
|
||||
'approved_by' => $userId ?: null,
|
||||
'status' => 'Paid',
|
||||
]);
|
||||
|
||||
Expense::query()->where('id', $expenseId)->update(['reimbursement_id' => $reimbId]);
|
||||
if (!empty($item->batch_item_id)) {
|
||||
ReimbursementBatchItem::query()->where('id', (int) $item->batch_item_id)->update([
|
||||
'reimbursement_id' => $reimbId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ReimbursementBatch::query()->where('id', $batchId)->update([
|
||||
'status' => ReimbursementBatch::STATUS_CLOSED,
|
||||
'closed_at' => now(),
|
||||
'closed_by' => $userId ?: null,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private function nextYearlyBatchNumber(string $schoolYear): int
|
||||
{
|
||||
$year = trim($schoolYear);
|
||||
if ($year === '') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$max = ReimbursementBatch::query()
|
||||
->where('school_year', $year)
|
||||
->max('yearly_batch_number');
|
||||
|
||||
return ((int) $max) + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\Configuration;
|
||||
|
||||
class ReimbursementContextService
|
||||
{
|
||||
public function getSchoolYear(): string
|
||||
{
|
||||
return (string) (Configuration::getConfig('school_year') ?? date('Y'));
|
||||
}
|
||||
|
||||
public function getSemester(): string
|
||||
{
|
||||
return (string) (Configuration::getConfig('semester') ?? 'Fall');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\Expense;
|
||||
use App\Models\Reimbursement;
|
||||
use RuntimeException;
|
||||
|
||||
class ReimbursementCrudService
|
||||
{
|
||||
public function store(array $payload, int $userId, string $schoolYear, string $semester, ?string $receiptName): Reimbursement
|
||||
{
|
||||
$expenseId = (int) ($payload['expense_id'] ?? 0);
|
||||
if ($expenseId > 0) {
|
||||
$expense = Expense::query()->find($expenseId);
|
||||
if ($expense && strcasecmp((string) $expense->category, 'Donation') === 0) {
|
||||
throw new RuntimeException('Donation expenses are tracked but should not be reimbursed.');
|
||||
}
|
||||
}
|
||||
|
||||
$methodRaw = (string) ($payload['reimbursement_method'] ?? '');
|
||||
$method = ucfirst(strtolower($methodRaw));
|
||||
|
||||
$reimb = Reimbursement::query()->create([
|
||||
'expense_id' => $expenseId ?: null,
|
||||
'amount' => $payload['amount'],
|
||||
'reimbursed_to' => (int) $payload['reimbursed_to'],
|
||||
'description' => $payload['description'] ?? null,
|
||||
'reimbursement_method' => $method,
|
||||
'check_number' => $method === 'Check' ? ($payload['check_number'] ?? null) : null,
|
||||
'receipt_path' => $receiptName,
|
||||
'school_year' => $payload['school_year'] ?? $schoolYear,
|
||||
'semester' => $payload['semester'] ?? $semester,
|
||||
'added_by' => $userId ?: null,
|
||||
'approved_by' => $userId ?: null,
|
||||
'status' => 'Paid',
|
||||
]);
|
||||
|
||||
if ($expenseId > 0) {
|
||||
Expense::query()->where('id', $expenseId)->update(['reimbursement_id' => $reimb->id]);
|
||||
}
|
||||
|
||||
return $reimb;
|
||||
}
|
||||
|
||||
public function process(array $payload, int $userId, string $schoolYear, string $semester, ?string $receiptName): Reimbursement
|
||||
{
|
||||
$expenseId = (int) ($payload['expense_id'] ?? 0);
|
||||
if ($expenseId > 0) {
|
||||
$expense = Expense::query()->find($expenseId);
|
||||
if ($expense && strcasecmp((string) $expense->category, 'Donation') === 0) {
|
||||
throw new RuntimeException('Donation expenses are tracked but should not be reimbursed.');
|
||||
}
|
||||
}
|
||||
|
||||
$reimb = Reimbursement::query()->create([
|
||||
'expense_id' => $expenseId ?: null,
|
||||
'amount' => $payload['amount'],
|
||||
'reimbursed_to' => (int) $payload['reimbursed_to'],
|
||||
'approved_by' => $userId ?: null,
|
||||
'receipt_path' => $receiptName,
|
||||
'description' => $payload['description'] ?? 'Expense reimbursement',
|
||||
'status' => 'Paid',
|
||||
'added_by' => $userId ?: null,
|
||||
'school_year' => $payload['school_year'] ?? $schoolYear,
|
||||
'semester' => $payload['semester'] ?? $semester,
|
||||
'check_number' => $payload['check_number'] ?? null,
|
||||
'reimbursement_method' => $payload['reimbursement_method'] ?? null,
|
||||
]);
|
||||
|
||||
if ($expenseId > 0) {
|
||||
Expense::query()->where('id', $expenseId)->update(['reimbursement_id' => $reimb->id]);
|
||||
}
|
||||
|
||||
return $reimb;
|
||||
}
|
||||
|
||||
public function update(Reimbursement $reimb, array $payload, int $userId, ?string $receiptName): Reimbursement
|
||||
{
|
||||
$methodRaw = (string) ($payload['reimbursement_method'] ?? $reimb->reimbursement_method);
|
||||
$method = ucfirst(strtolower($methodRaw));
|
||||
|
||||
$updateData = [
|
||||
'amount' => $payload['amount'],
|
||||
'reimbursed_to' => (int) $payload['reimbursed_to'],
|
||||
'description' => $payload['description'] ?? null,
|
||||
'reimbursement_method' => $method,
|
||||
'check_number' => $method === 'Check' ? ($payload['check_number'] ?? null) : null,
|
||||
'receipt_path' => $receiptName,
|
||||
'updated_by' => $userId ?: null,
|
||||
];
|
||||
|
||||
$reimb->update($updateData);
|
||||
|
||||
return $reimb->refresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\Expense;
|
||||
use App\Models\ReimbursementBatchItem;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
class ReimbursementDonationService
|
||||
{
|
||||
public function markDonation(int $expenseId, int $userId): void
|
||||
{
|
||||
if ($expenseId <= 0) {
|
||||
throw new RuntimeException('Invalid expense id.');
|
||||
}
|
||||
|
||||
$expense = Expense::query()->find($expenseId);
|
||||
if (!$expense) {
|
||||
throw new RuntimeException('Expense not found.');
|
||||
}
|
||||
|
||||
if (!empty($expense->reimbursement_id)) {
|
||||
throw new RuntimeException('Expense is already tied to a reimbursement.');
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
ReimbursementBatchItem::query()
|
||||
->where('expense_id', $expenseId)
|
||||
->whereNull('unassigned_at')
|
||||
->update(['unassigned_at' => $now]);
|
||||
|
||||
$expense->update([
|
||||
'category' => 'Donation',
|
||||
'status' => 'approved',
|
||||
'status_reason' => 'Marked as Donation (non-reimbursable).',
|
||||
'approved_by' => $userId ?: null,
|
||||
'updated_by' => $userId ?: null,
|
||||
'reimbursement_id' => null,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('Failed to mark donation: ' . $e->getMessage(), ['expense_id' => $expenseId]);
|
||||
throw new RuntimeException('Unable to mark donation right now.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user