fix apply the plan docs/alrahma_api_fix_plan_school_year_only_v7
API CI/CD / Validate (composer + pint) (push) Successful in 3m10s
API CI/CD / Test (PHPUnit) (push) Failing after 6m49s
API CI/CD / Build frontend assets (push) Successful in 1m3s
API CI/CD / Security audit (push) Failing after 1m0s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped
API CI/CD / Validate (composer + pint) (push) Successful in 3m10s
API CI/CD / Test (PHPUnit) (push) Failing after 6m49s
API CI/CD / Build frontend assets (push) Successful in 1m3s
API CI/CD / Security audit (push) Failing after 1m0s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped
This commit is contained in:
@@ -9,9 +9,9 @@ use App\Http\Requests\Families\FamilyAdminSearchRequest;
|
||||
use App\Http\Requests\Families\FamilyComposeEmailRequest;
|
||||
use App\Http\Resources\Families\FamilyResource;
|
||||
use App\Http\Resources\Families\FamilySearchItemResource;
|
||||
use App\Models\Configuration;
|
||||
use App\Services\Families\FamilyNotificationService;
|
||||
use App\Services\Families\FamilyQueryService;
|
||||
use App\Services\SchoolYears\SelectedSchoolYearContextService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -20,7 +20,8 @@ class FamilyAdminController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private FamilyQueryService $queryService,
|
||||
private FamilyNotificationService $notificationService
|
||||
private FamilyNotificationService $notificationService,
|
||||
private SelectedSchoolYearContextService $schoolYears
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -28,7 +29,7 @@ class FamilyAdminController extends BaseApiController
|
||||
public function index(FamilyAdminIndexRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$schoolYear = $this->requestedSchoolYear($request, $payload);
|
||||
$schoolYear = $this->requestedSchoolYear($request);
|
||||
|
||||
$data = $this->queryService->adminIndexData(
|
||||
$payload['student_id'] ?? null,
|
||||
@@ -63,7 +64,7 @@ class FamilyAdminController extends BaseApiController
|
||||
public function card(FamilyAdminCardRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$schoolYear = $this->requestedSchoolYear($request, $payload);
|
||||
$schoolYear = $this->requestedSchoolYear($request);
|
||||
|
||||
$family = $this->queryService->familyCard(
|
||||
$payload['student_id'] ?? null,
|
||||
@@ -79,24 +80,9 @@ class FamilyAdminController extends BaseApiController
|
||||
return $this->success(new FamilyResource($family));
|
||||
}
|
||||
|
||||
private function requestedSchoolYear($request, array $payload): string
|
||||
private function requestedSchoolYear($request): string
|
||||
{
|
||||
$candidates = [
|
||||
$payload['school_year'] ?? null,
|
||||
$request->query('school_year'),
|
||||
$request->header('X-School-Year'),
|
||||
$request->header('X-Selected-School-Year'),
|
||||
Configuration::getConfig('school_year'),
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$value = trim((string) ($candidate ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
return $this->schoolYears->fromRequest($request)->name;
|
||||
}
|
||||
|
||||
public function composeEmail(FamilyComposeEmailRequest $request): JsonResponse
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Family;
|
||||
|
||||
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||
use App\Services\Families\ParentProfileAdminQueryService;
|
||||
use App\Services\SchoolYears\SelectedSchoolYearContextService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ParentProfileAdminController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private ParentProfileAdminQueryService $profiles,
|
||||
private SelectedSchoolYearContextService $schoolYears,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->has('school_year_id')) {
|
||||
return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$context = $this->schoolYears->fromRequest($request);
|
||||
$page = $this->profiles->index(
|
||||
$context->name,
|
||||
trim((string) $request->query('q', '')),
|
||||
(int) $request->query('per_page', 25),
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'school_year' => $context->name,
|
||||
'read_only' => $context->isReadOnly,
|
||||
'parents' => $page->getCollection()
|
||||
->map(fn ($row) => $this->profiles->parentSummary((array) $row, $context->name))
|
||||
->values()
|
||||
->all(),
|
||||
'pagination' => [
|
||||
'page' => $page->currentPage(),
|
||||
'per_page' => $page->perPage(),
|
||||
'total' => $page->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->has('school_year_id')) {
|
||||
return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$context = $this->schoolYears->fromRequest($request);
|
||||
|
||||
return $this->success([
|
||||
'school_year' => $context->name,
|
||||
'read_only' => $context->isReadOnly,
|
||||
'items' => $this->profiles->search(
|
||||
$context->name,
|
||||
trim((string) $request->query('q', '')),
|
||||
(int) $request->query('limit', 10),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $parent): JsonResponse
|
||||
{
|
||||
if ($request->has('school_year_id')) {
|
||||
return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$context = $this->schoolYears->fromRequest($request);
|
||||
$payload = $this->profiles->show($parent, $context->name);
|
||||
|
||||
if (! $payload) {
|
||||
return $this->error('Parent profile not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'school_year' => $context->name,
|
||||
'read_only' => $context->isReadOnly,
|
||||
...$payload,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $parent): JsonResponse
|
||||
{
|
||||
if ($request->has('school_year_id')) {
|
||||
return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$payload = $request->validate([
|
||||
'firstname' => ['sometimes', 'required', 'string', 'max:100'],
|
||||
'lastname' => ['sometimes', 'required', 'string', 'max:100'],
|
||||
'email' => ['sometimes', 'nullable', 'email', 'max:255'],
|
||||
'cellphone' => ['sometimes', 'nullable', 'string', 'max:30'],
|
||||
'address_street' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'city' => ['sometimes', 'nullable', 'string', 'max:100'],
|
||||
'state' => ['sometimes', 'nullable', 'string', 'max:100'],
|
||||
'zip' => ['sometimes', 'nullable', 'string', 'max:30'],
|
||||
'status' => ['sometimes', 'nullable', 'string', 'max:30'],
|
||||
]);
|
||||
|
||||
$updated = $this->profiles->updateParent($parent, $payload);
|
||||
if (! $updated) {
|
||||
return $this->error('Parent profile not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success(['parent' => $updated], 'Parent profile updated.');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Http\Requests\SchoolYears\PreviewCloseSchoolYearRequest;
|
||||
use App\Http\Requests\SchoolYears\SaveSchoolYearRequest;
|
||||
use App\Services\SchoolYears\SchoolYearClosureService;
|
||||
use App\Services\SchoolYears\SchoolYearContextService;
|
||||
use App\Services\SchoolYears\SelectedSchoolYearContextService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -17,7 +18,8 @@ class SchoolYearController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolYearClosureService $service,
|
||||
private SchoolYearContextService $context
|
||||
private SchoolYearContextService $context,
|
||||
private SelectedSchoolYearContextService $selectedSchoolYear
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -71,11 +73,28 @@ class SchoolYearController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateSelected(SaveSchoolYearRequest $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->updateYear($selected->id, $request->validated(), $this->getCurrentUserId()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function summary(int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json(['ok' => true, 'data' => $this->service->summary($schoolYear)]);
|
||||
}
|
||||
|
||||
public function summarySelected(Request $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json(['ok' => true, 'data' => $this->service->summaryByYearName($selected->name)]);
|
||||
}
|
||||
|
||||
public function validateClose(PreviewCloseSchoolYearRequest $request, int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
@@ -84,6 +103,16 @@ class SchoolYearController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function validateCloseSelected(PreviewCloseSchoolYearRequest $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'validation' => $this->service->validateCloseByYearName($selected->name, $request->validated()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function previewClose(PreviewCloseSchoolYearRequest $request, int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
@@ -92,6 +121,16 @@ class SchoolYearController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function previewCloseSelected(PreviewCloseSchoolYearRequest $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->previewCloseByYearName($selected->name, $request->validated()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function close(CloseSchoolYearRequest $request, int $schoolYear): JsonResponse
|
||||
{
|
||||
try {
|
||||
@@ -108,6 +147,24 @@ class SchoolYearController extends BaseApiController
|
||||
return response()->json(['ok' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
public function closeSelected(CloseSchoolYearRequest $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
try {
|
||||
$result = $this->service->closeByYearName($selected->name, $request->validated(), $this->getCurrentUserId());
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
public function reopen(Request $request, int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
@@ -116,6 +173,16 @@ class SchoolYearController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function reopenSelected(Request $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->reopenByYearName($selected->name, $this->getCurrentUserId()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function parentBalances(Request $request, int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
@@ -124,6 +191,16 @@ class SchoolYearController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function parentBalancesSelected(Request $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->parentBalancesByYearName($selected->name, $request->query('to_school_year')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function promotionPreview(Request $request, int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
@@ -132,6 +209,16 @@ class SchoolYearController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function promotionPreviewSelected(Request $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->promotionPreviewByYearName($selected->name, $request->query('to_school_year')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function closingReport(int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
@@ -140,6 +227,16 @@ class SchoolYearController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function closingReportSelected(Request $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->closingReportByYearName($selected->name),
|
||||
]);
|
||||
}
|
||||
|
||||
public function archive(Request $request, int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
@@ -147,4 +244,14 @@ class SchoolYearController extends BaseApiController
|
||||
'data' => $this->service->archive($schoolYear, $this->getCurrentUserId()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function archiveSelected(Request $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->archiveByYearName($selected->name, $this->getCurrentUserId()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Services\SchoolYears\SelectedSchoolYearContextService;
|
||||
use App\Services\SchoolYears\SchoolYearWriteGuard;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureSchoolYearEditable
|
||||
{
|
||||
public function __construct(private SchoolYearWriteGuard $guard) {}
|
||||
public function __construct(
|
||||
private SchoolYearWriteGuard $guard,
|
||||
private SelectedSchoolYearContextService $selectedSchoolYear
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
@@ -22,11 +25,16 @@ class EnsureSchoolYearEditable
|
||||
|
||||
try {
|
||||
$this->guard->assertEditable($this->resolveSchoolYears($request));
|
||||
} catch (RuntimeException $e) {
|
||||
} catch (HttpExceptionInterface $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 409);
|
||||
], $e->getStatusCode());
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
@@ -36,11 +44,11 @@ class EnsureSchoolYearEditable
|
||||
{
|
||||
$years = [];
|
||||
|
||||
foreach (['school_year', 'current_school_year'] as $field) {
|
||||
$value = $request->input($field, $request->query($field));
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$years[] = trim($value);
|
||||
}
|
||||
$years[] = $this->selectedSchoolYear->fromRequest($request)->name;
|
||||
|
||||
$currentSchoolYear = $request->input('current_school_year', $request->query('current_school_year'));
|
||||
if (is_string($currentSchoolYear) && trim($currentSchoolYear) !== '') {
|
||||
$years[] = trim($currentSchoolYear);
|
||||
}
|
||||
|
||||
$routeYears = [
|
||||
@@ -69,14 +77,6 @@ class EnsureSchoolYearEditable
|
||||
$years[] = $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
if ($years === []) {
|
||||
$current = trim((string) (Configuration::getConfig('school_year') ?? ''));
|
||||
if ($current !== '') {
|
||||
$years[] = $current;
|
||||
}
|
||||
}
|
||||
|
||||
return $years;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class ClassProgressIndexRequest extends ClassProgressFormRequest
|
||||
return [
|
||||
'class_section_id' => ['nullable', 'integer', 'min:1'],
|
||||
'teacher_id' => ['nullable', 'integer', 'min:1'],
|
||||
'school_year_id' => ['nullable', 'integer', 'min:1'],
|
||||
'school_year_id' => ['prohibited'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'semester' => ['nullable', 'string', 'max:20'],
|
||||
'week_start' => ['nullable', 'date_format:Y-m-d'],
|
||||
|
||||
@@ -17,6 +17,7 @@ class FamilyAdminCardRequest extends ApiFormRequest
|
||||
'student_id' => ['nullable', 'integer', 'min:1'],
|
||||
'guardian_id' => ['nullable', 'integer', 'min:1'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'school_year_id' => ['prohibited'],
|
||||
'family_id' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ class FamilyAdminIndexRequest extends ApiFormRequest
|
||||
'student_id' => ['nullable', 'integer', 'min:1'],
|
||||
'guardian_id' => ['nullable', 'integer', 'min:1'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'school_year_id' => ['prohibited'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ class FamilyComposeEmailRequest extends ApiFormRequest
|
||||
if (! $this->has('html_message') && $this->has('html')) {
|
||||
$this->merge(['html_message' => $this->input('html')]);
|
||||
}
|
||||
|
||||
if (! $this->has('recipient') && $this->has('to')) {
|
||||
$this->merge(['recipient' => $this->input('to')]);
|
||||
}
|
||||
}
|
||||
|
||||
public function authorize(): bool
|
||||
@@ -29,6 +33,7 @@ class FamilyComposeEmailRequest extends ApiFormRequest
|
||||
'profile' => ['nullable', 'string', 'max:50'],
|
||||
'reply_to_email' => ['nullable', 'email', 'max:255'],
|
||||
'reply_to_name' => ['nullable', 'string', 'max:255'],
|
||||
'return_url' => ['nullable', 'string', 'max:2048'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ class ParentAttendanceRequest extends ApiFormRequest
|
||||
{
|
||||
return [
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'school_year_id' => ['nullable', 'integer'],
|
||||
'school_year_id' => ['prohibited'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ class StaffIndexRequest extends ApiFormRequest
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'role' => ['nullable', 'string', 'max:100'],
|
||||
'status' => ['nullable', 'string', 'max:40'],
|
||||
'search' => ['nullable', 'string', 'max:150'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'school_year_id' => ['prohibited'],
|
||||
'semester' => ['nullable', 'string', 'max:20'],
|
||||
'sort_by' => ['nullable', 'in:created_at,lastname,firstname'],
|
||||
'sort_dir' => ['nullable', 'in:asc,desc'],
|
||||
|
||||
@@ -14,8 +14,11 @@ class StaffStoreRequest extends ApiFormRequest
|
||||
'lastname' => ['required', 'string', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:150'],
|
||||
'phone' => ['nullable', 'string', 'max:20'],
|
||||
'role_name' => ['required', 'string', 'max:100'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'password' => ['nullable', 'string', 'min:8', 'max:120'],
|
||||
'role_id' => ['nullable', 'integer', 'min:1', 'exists:roles,id'],
|
||||
'role_name' => ['nullable', 'required_without:role_id', 'string', 'max:100'],
|
||||
'school_year' => ['required', 'string', 'max:20'],
|
||||
'school_year_id' => ['prohibited'],
|
||||
'status' => ['nullable', 'string', 'max:40'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ class StaffUpdateRequest extends ApiFormRequest
|
||||
'lastname' => ['nullable', 'string', 'max:100'],
|
||||
'email' => ['nullable', 'email', 'max:150'],
|
||||
'phone' => ['nullable', 'string', 'max:20'],
|
||||
'role_id' => ['nullable', 'integer', 'min:1', 'exists:roles,id'],
|
||||
'role_name' => ['nullable', 'string', 'max:100'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'school_year_id' => ['prohibited'],
|
||||
'status' => ['nullable', 'string', 'max:40'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ class StaffResource extends JsonResource
|
||||
'lastname' => $this->lastname ?? null,
|
||||
'email' => $this->email ?? null,
|
||||
'phone' => $this->phone ?? null,
|
||||
'full_name' => trim((string) ($this->firstname ?? '').' '.(string) ($this->lastname ?? '')),
|
||||
'role' => [
|
||||
'name' => $this->role_name ?? $this->active_role ?? null,
|
||||
'label' => $this->role_name ?? $this->active_role ?? null,
|
||||
],
|
||||
'status' => strtolower((string) ($this->active_role ?? '')) === 'inactive' ? 'inactive' : 'active',
|
||||
'role_name' => $this->role_name ?? null,
|
||||
'active_role' => $this->active_role ?? null,
|
||||
'school_year' => $this->school_year ?? null,
|
||||
|
||||
@@ -9,12 +9,14 @@ class ParentAccount extends BaseModel
|
||||
protected $fillable = [
|
||||
'parent_id',
|
||||
'school_year',
|
||||
'school_year_id',
|
||||
'opening_balance',
|
||||
'current_balance',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'parent_id' => 'integer',
|
||||
'school_year_id' => 'integer',
|
||||
'opening_balance' => 'decimal:2',
|
||||
'current_balance' => 'decimal:2',
|
||||
];
|
||||
|
||||
@@ -10,18 +10,31 @@ class ParentBalanceTransfer extends BaseModel
|
||||
'parent_id',
|
||||
'from_school_year',
|
||||
'to_school_year',
|
||||
'from_school_year_id',
|
||||
'to_school_year_id',
|
||||
'amount',
|
||||
'status',
|
||||
'source_summary_json',
|
||||
'source_summary',
|
||||
'new_invoice_id',
|
||||
'old_balance_invoice_id',
|
||||
'reversed_at',
|
||||
'reversed_by',
|
||||
'reversal_reason',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'parent_id' => 'integer',
|
||||
'from_school_year_id' => 'integer',
|
||||
'to_school_year_id' => 'integer',
|
||||
'amount' => 'decimal:2',
|
||||
'source_summary_json' => 'array',
|
||||
'source_summary' => 'array',
|
||||
'new_invoice_id' => 'integer',
|
||||
'old_balance_invoice_id' => 'integer',
|
||||
'reversed_at' => 'datetime',
|
||||
'reversed_by' => 'integer',
|
||||
'created_by' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -213,21 +213,7 @@ class ClassProgressQueryService
|
||||
|
||||
private function resolveSchoolYearFilter(array $filters): string
|
||||
{
|
||||
$schoolYear = trim((string) ($filters['school_year'] ?? ''));
|
||||
if ($schoolYear !== '') {
|
||||
return $schoolYear;
|
||||
}
|
||||
|
||||
$schoolYearId = (int) ($filters['school_year_id'] ?? 0);
|
||||
if ($schoolYearId <= 0 || ! Schema::hasTable('school_years')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return trim((string) DB::table('school_years')->where('id', $schoolYearId)->value('name'));
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
return trim((string) ($filters['school_year'] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,9 +6,10 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FamilyFinanceService
|
||||
{
|
||||
public function loadFinancialsForParents(array $parentIds, int $paymentLimit = 10): array
|
||||
public function loadFinancialsForParents(array $parentIds, string $schoolYear, int $paymentLimit = 10): array
|
||||
{
|
||||
$parentIds = array_values(array_filter(array_map('intval', $parentIds)));
|
||||
$schoolYear = trim($schoolYear);
|
||||
|
||||
if (empty($parentIds)) {
|
||||
return [
|
||||
@@ -20,6 +21,8 @@ class FamilyFinanceService
|
||||
'total_amount' => 0.0,
|
||||
'paid_amount' => 0.0,
|
||||
'balance' => 0.0,
|
||||
'positive_unpaid_balance' => 0.0,
|
||||
'credit_balance' => 0.0,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -27,6 +30,7 @@ class FamilyFinanceService
|
||||
$invRows = DB::table('invoices')
|
||||
->select('id', 'parent_id', 'invoice_number', 'status', 'total_amount', 'paid_amount', 'balance', 'issue_date', 'due_date')
|
||||
->whereIn('parent_id', $parentIds)
|
||||
->when($schoolYear !== '', fn ($query) => $query->where('school_year', $schoolYear))
|
||||
->orderBy('issue_date', 'DESC')
|
||||
->get()
|
||||
->map(fn ($r) => (array) $r)
|
||||
@@ -38,19 +42,29 @@ class FamilyFinanceService
|
||||
'total_amount' => 0.0,
|
||||
'paid_amount' => 0.0,
|
||||
'balance' => 0.0,
|
||||
'positive_unpaid_balance' => 0.0,
|
||||
'credit_balance' => 0.0,
|
||||
];
|
||||
foreach ($invRows as $ir) {
|
||||
$invoiceMap[(int) ($ir['id'] ?? 0)] = (string) ($ir['invoice_number'] ?? '');
|
||||
$balance = (float) ($ir['balance'] ?? 0);
|
||||
$summary['invoices_count']++;
|
||||
$summary['total_amount'] += (float) ($ir['total_amount'] ?? 0);
|
||||
$summary['paid_amount'] += (float) ($ir['paid_amount'] ?? 0);
|
||||
$summary['balance'] += (float) ($ir['balance'] ?? 0);
|
||||
$summary['balance'] += $balance;
|
||||
if ($balance > 0) {
|
||||
$summary['positive_unpaid_balance'] += $balance;
|
||||
} elseif ($balance < 0) {
|
||||
$summary['credit_balance'] += abs($balance);
|
||||
}
|
||||
}
|
||||
|
||||
$payRows = DB::table('payments')
|
||||
->select('id', 'parent_id', 'invoice_id', 'paid_amount', 'balance', 'payment_method', 'payment_date', 'status')
|
||||
->whereIn('parent_id', $parentIds)
|
||||
->orderBy('payment_date', 'DESC')
|
||||
$payRows = DB::table('payments as p')
|
||||
->join('invoices as i', 'i.id', '=', 'p.invoice_id')
|
||||
->select('p.id', 'p.parent_id', 'p.invoice_id', 'p.paid_amount', 'p.balance', 'p.payment_method', 'p.payment_date', 'p.status')
|
||||
->whereIn('p.parent_id', $parentIds)
|
||||
->when($schoolYear !== '', fn ($query) => $query->where('i.school_year', $schoolYear))
|
||||
->orderBy('p.payment_date', 'DESC')
|
||||
->limit($paymentLimit)
|
||||
->get()
|
||||
->map(fn ($r) => (array) $r)
|
||||
|
||||
@@ -224,7 +224,7 @@ class FamilyQueryService
|
||||
static fn ($g) => (int) ($g['user_id'] ?? 0),
|
||||
$fam['guardians']
|
||||
)));
|
||||
$finance = $this->finance->loadFinancialsForParents($parentIds);
|
||||
$finance = $this->finance->loadFinancialsForParents($parentIds, $schoolYear);
|
||||
$fam['invoices'] = $finance['invoices'];
|
||||
$fam['payments'] = $finance['payments'];
|
||||
$fam['finance_summary'] = $finance['summary'];
|
||||
@@ -310,13 +310,13 @@ class FamilyQueryService
|
||||
$payload = $family->toArray();
|
||||
$payload['guardians'] = $this->listGuardiansByFamily($familyId);
|
||||
$payload['students'] = $this->studentsForFamily($familyId, $schoolYear);
|
||||
$payload['emergency_contacts'] = $this->emergencyContactsForParents($payload['guardians']);
|
||||
$payload['emergency_contacts'] = $this->emergencyContactsForParents($payload['guardians'], $schoolYear);
|
||||
|
||||
$parentIds = array_values(array_filter(array_map(
|
||||
static fn ($g) => (int) ($g['user_id'] ?? 0),
|
||||
$payload['guardians']
|
||||
)));
|
||||
$finance = $this->finance->loadFinancialsForParents($parentIds);
|
||||
$finance = $this->finance->loadFinancialsForParents($parentIds, $schoolYear);
|
||||
$payload['invoices'] = $finance['invoices'];
|
||||
$payload['payments'] = $finance['payments'];
|
||||
$payload['finance_summary'] = $finance['summary'];
|
||||
@@ -346,7 +346,7 @@ class FamilyQueryService
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function emergencyContactsForParents(array $guardians): array
|
||||
private function emergencyContactsForParents(array $guardians, string $schoolYear): array
|
||||
{
|
||||
$parentIds = array_values(array_filter(array_map(
|
||||
static fn ($g) => (int) ($g['user_id'] ?? 0),
|
||||
@@ -368,6 +368,7 @@ class FamilyQueryService
|
||||
$rows = DB::table('emergency_contacts')
|
||||
->select('id', 'parent_id', 'emergency_contact_name', 'relation', 'cellphone', 'email', 'school_year', 'semester', 'created_at', 'updated_at')
|
||||
->whereIn('parent_id', $parentIds)
|
||||
->when(trim($schoolYear) !== '', fn ($query) => $query->where('school_year', trim($schoolYear)))
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->get()
|
||||
->map(fn ($r) => (array) $r)
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Families;
|
||||
|
||||
use App\Models\StudentClass;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ParentProfileAdminQueryService
|
||||
{
|
||||
public function __construct(private FamilyFinanceService $finance) {}
|
||||
|
||||
public function index(string $schoolYear, string $search = '', int $perPage = 25): LengthAwarePaginator
|
||||
{
|
||||
$query = DB::table('users as u')
|
||||
->select(
|
||||
'u.id',
|
||||
'u.firstname',
|
||||
'u.lastname',
|
||||
'u.email',
|
||||
'u.cellphone',
|
||||
'u.status',
|
||||
DB::raw('COUNT(DISTINCT fg.family_id) as families_count'),
|
||||
DB::raw('COUNT(DISTINCT s.id) as selected_year_students_count'),
|
||||
DB::raw('COALESCE(SUM(DISTINCT i.balance), 0) as balance')
|
||||
)
|
||||
->leftJoin('family_guardians as fg', 'fg.user_id', '=', 'u.id')
|
||||
->leftJoin('family_students as fs', 'fs.family_id', '=', 'fg.family_id')
|
||||
->leftJoin('students as s', function ($join) use ($schoolYear) {
|
||||
$join->on('s.id', '=', 'fs.student_id')
|
||||
->where('s.school_year', '=', $schoolYear);
|
||||
})
|
||||
->leftJoin('invoices as i', function ($join) use ($schoolYear) {
|
||||
$join->on('i.parent_id', '=', 'u.id')
|
||||
->where('i.school_year', '=', $schoolYear);
|
||||
})
|
||||
->where($this->relevantParentConstraint($schoolYear))
|
||||
->groupBy('u.id', 'u.firstname', 'u.lastname', 'u.email', 'u.cellphone', 'u.status')
|
||||
->orderBy('u.lastname')
|
||||
->orderBy('u.firstname');
|
||||
|
||||
if ($search !== '') {
|
||||
$needle = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
|
||||
$query->where(function ($inner) use ($needle) {
|
||||
$inner->whereRaw("CONCAT_WS(' ', u.firstname, u.lastname) LIKE ?", [$needle])
|
||||
->orWhere('u.email', 'like', $needle)
|
||||
->orWhere('u.cellphone', 'like', $needle)
|
||||
->orWhereExists(function ($exists) use ($needle) {
|
||||
$exists->selectRaw('1')
|
||||
->from('family_guardians as sfg')
|
||||
->join('family_students as sfs', 'sfs.family_id', '=', 'sfg.family_id')
|
||||
->join('students as ss', 'ss.id', '=', 'sfs.student_id')
|
||||
->whereColumn('sfg.user_id', 'u.id')
|
||||
->where(function ($student) use ($needle) {
|
||||
$student->whereRaw("CONCAT_WS(' ', ss.firstname, ss.lastname) LIKE ?", [$needle]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return $query->paginate(max(1, min($perPage, 100)));
|
||||
}
|
||||
|
||||
public function search(string $schoolYear, string $search, int $limit = 10): array
|
||||
{
|
||||
if (trim($search) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->index($schoolYear, $search, $limit)
|
||||
->getCollection()
|
||||
->map(fn ($row) => $this->parentSummary((array) $row, $schoolYear))
|
||||
->all();
|
||||
}
|
||||
|
||||
public function show(int $parentId, string $schoolYear): ?array
|
||||
{
|
||||
$parent = DB::table('users')
|
||||
->select('id', 'firstname', 'lastname', 'email', 'cellphone', 'address_street', 'city', 'state', 'zip', 'status')
|
||||
->where('id', $parentId)
|
||||
->first();
|
||||
|
||||
if (! $parent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$guardians = [
|
||||
[
|
||||
'user_id' => $parentId,
|
||||
'firstname' => $parent->firstname,
|
||||
'lastname' => $parent->lastname,
|
||||
],
|
||||
];
|
||||
$finance = $this->finance->loadFinancialsForParents([$parentId], $schoolYear);
|
||||
|
||||
return [
|
||||
'parent' => (array) $parent,
|
||||
'families' => $this->familiesForParent($parentId, $schoolYear),
|
||||
'students' => $this->studentsForParent($parentId, $schoolYear),
|
||||
'emergency_contacts' => $this->emergencyContactsForParents($guardians, $schoolYear),
|
||||
'invoices' => $finance['invoices'],
|
||||
'payments' => $finance['payments'],
|
||||
'finance_summary' => $finance['summary'],
|
||||
];
|
||||
}
|
||||
|
||||
public function updateParent(int $parentId, array $payload): ?array
|
||||
{
|
||||
$allowed = array_intersect_key($payload, array_flip([
|
||||
'firstname',
|
||||
'lastname',
|
||||
'email',
|
||||
'cellphone',
|
||||
'address_street',
|
||||
'city',
|
||||
'state',
|
||||
'zip',
|
||||
'status',
|
||||
]));
|
||||
|
||||
if ($allowed !== []) {
|
||||
$allowed['updated_at'] = now();
|
||||
DB::table('users')->where('id', $parentId)->update($allowed);
|
||||
}
|
||||
|
||||
$parent = DB::table('users')->where('id', $parentId)->first();
|
||||
|
||||
return $parent ? (array) $parent : null;
|
||||
}
|
||||
|
||||
public function parentSummary(array $row, string $schoolYear): array
|
||||
{
|
||||
$balance = (float) ($row['balance'] ?? 0);
|
||||
$primaryFamily = DB::table('family_guardians as fg')
|
||||
->join('families as f', 'f.id', '=', 'fg.family_id')
|
||||
->where('fg.user_id', (int) ($row['id'] ?? 0))
|
||||
->orderByDesc('fg.is_primary')
|
||||
->orderBy('f.household_name')
|
||||
->select('f.id', 'f.household_name')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'firstname' => $row['firstname'] ?? null,
|
||||
'lastname' => $row['lastname'] ?? null,
|
||||
'email' => $row['email'] ?? null,
|
||||
'cellphone' => $row['cellphone'] ?? null,
|
||||
'is_active' => strtolower((string) ($row['status'] ?? '')) === 'active',
|
||||
'families_count' => (int) ($row['families_count'] ?? 0),
|
||||
'students_count' => (int) ($row['selected_year_students_count'] ?? 0),
|
||||
'selected_year_students_count' => (int) ($row['selected_year_students_count'] ?? 0),
|
||||
'balance' => $balance,
|
||||
'positive_unpaid_balance' => max(0, $balance),
|
||||
'credit_balance' => $balance < 0 ? abs($balance) : 0.0,
|
||||
'primary_family' => $primaryFamily ? (array) $primaryFamily : null,
|
||||
'school_year' => $schoolYear,
|
||||
];
|
||||
}
|
||||
|
||||
private function relevantParentConstraint(string $schoolYear): callable
|
||||
{
|
||||
return function ($query) use ($schoolYear) {
|
||||
$query->whereExists(function ($exists) use ($schoolYear) {
|
||||
$exists->selectRaw('1')
|
||||
->from('family_guardians as rfg')
|
||||
->join('family_students as rfs', 'rfs.family_id', '=', 'rfg.family_id')
|
||||
->join('students as rs', 'rs.id', '=', 'rfs.student_id')
|
||||
->whereColumn('rfg.user_id', 'u.id')
|
||||
->where('rs.school_year', $schoolYear);
|
||||
})->orWhereExists(function ($exists) use ($schoolYear) {
|
||||
$exists->selectRaw('1')
|
||||
->from('students as direct_students')
|
||||
->whereColumn('direct_students.parent_id', 'u.id')
|
||||
->where('direct_students.school_year', $schoolYear);
|
||||
})->orWhereExists(function ($exists) use ($schoolYear) {
|
||||
$exists->selectRaw('1')
|
||||
->from('invoices as ri')
|
||||
->whereColumn('ri.parent_id', 'u.id')
|
||||
->where('ri.school_year', $schoolYear);
|
||||
})->orWhereExists(function ($exists) use ($schoolYear) {
|
||||
$exists->selectRaw('1')
|
||||
->from('parent_accounts as pa')
|
||||
->whereColumn('pa.parent_id', 'u.id')
|
||||
->where('pa.school_year', $schoolYear);
|
||||
})->orWhereExists(function ($exists) use ($schoolYear) {
|
||||
$exists->selectRaw('1')
|
||||
->from('emergency_contacts as ec')
|
||||
->whereColumn('ec.parent_id', 'u.id')
|
||||
->where('ec.school_year', $schoolYear);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private function familiesForParent(int $parentId, string $schoolYear): array
|
||||
{
|
||||
return DB::table('family_guardians as fg')
|
||||
->join('families as f', 'f.id', '=', 'fg.family_id')
|
||||
->where('fg.user_id', $parentId)
|
||||
->whereExists(function ($exists) use ($schoolYear) {
|
||||
$exists->selectRaw('1')
|
||||
->from('family_students as fs')
|
||||
->join('students as s', 's.id', '=', 'fs.student_id')
|
||||
->whereColumn('fs.family_id', 'f.id')
|
||||
->where('s.school_year', $schoolYear);
|
||||
})
|
||||
->orderBy('f.household_name')
|
||||
->select('f.*', 'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
|
||||
private function studentsForParent(int $parentId, string $schoolYear): array
|
||||
{
|
||||
$rows = DB::table('students as s')
|
||||
->select('s.id', 's.firstname', 's.lastname', 's.parent_id')
|
||||
->where('s.school_year', $schoolYear)
|
||||
->where(function ($query) use ($parentId) {
|
||||
$query->where('s.parent_id', $parentId)
|
||||
->orWhereExists(function ($exists) use ($parentId) {
|
||||
$exists->selectRaw('1')
|
||||
->from('family_guardians as fg')
|
||||
->join('family_students as fs', 'fs.family_id', '=', 'fg.family_id')
|
||||
->whereColumn('fs.student_id', 's.id')
|
||||
->where('fg.user_id', $parentId);
|
||||
});
|
||||
})
|
||||
->orderBy('s.lastname')
|
||||
->orderBy('s.firstname')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$studentId = (int) ($row['id'] ?? 0);
|
||||
$classSectionName = $studentId > 0
|
||||
? (string) (StudentClass::getClassSectionsByStudentId($studentId, $schoolYear) ?? '')
|
||||
: '';
|
||||
$row['class_section_name'] = $classSectionName;
|
||||
$row['grade'] = $classSectionName;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function emergencyContactsForParents(array $guardians, string $schoolYear): array
|
||||
{
|
||||
$parentIds = array_values(array_filter(array_map(
|
||||
static fn ($g) => (int) ($g['user_id'] ?? 0),
|
||||
$guardians
|
||||
)));
|
||||
|
||||
if ($parentIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('emergency_contacts')
|
||||
->select('id', 'parent_id', 'emergency_contact_name', 'relation', 'cellphone', 'email', 'school_year', 'semester', 'created_at', 'updated_at')
|
||||
->whereIn('parent_id', $parentIds)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderByDesc('updated_at')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -145,12 +145,22 @@ class SchoolYearClosureService
|
||||
'totals' => [
|
||||
'students_considered' => $promotions['summary']['total_students'],
|
||||
'students_blocked' => $promotions['summary']['hold'],
|
||||
'parents_with_balances' => $balances['summary']['parents_with_non_zero_balance'],
|
||||
'net_balance_to_transfer' => $balances['summary']['net_balance_to_transfer'],
|
||||
'parents_with_balances' => $balances['summary']['parents_with_positive_balance'],
|
||||
'parents_with_positive_balance' => $balances['summary']['parents_with_positive_balance'],
|
||||
'parents_with_credit_balance' => $balances['summary']['parents_with_credit_balance'],
|
||||
'total_positive_unpaid_balance' => $balances['summary']['total_positive_unpaid_balance'],
|
||||
'total_credit_balance' => $balances['summary']['total_credit_balance'],
|
||||
'net_balance_to_transfer' => $balances['summary']['net_balance_impact'],
|
||||
'net_balance_impact' => $balances['summary']['net_balance_impact'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function summaryByYearName(string $schoolYear): array
|
||||
{
|
||||
return $this->summary((int) $this->findByNameOrFail($schoolYear)->id);
|
||||
}
|
||||
|
||||
public function closingReport(int $id): array
|
||||
{
|
||||
$year = $this->findOrFail($id);
|
||||
@@ -200,12 +210,23 @@ class SchoolYearClosureService
|
||||
'parents_with_unpaid_balances' => (int) ($balanceCounts['transferred_parents'] ?? $transferTotals['parents_with_unpaid_balances']),
|
||||
'total_unpaid_balance_transferred' => round((float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred']), 2),
|
||||
'parents_with_credit_balances' => (int) ($balanceCounts['credit_parents'] ?? $transferTotals['parents_with_credit_balances']),
|
||||
'total_credit_transferred' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_transferred']), 2),
|
||||
'total_credit_carried_or_reported' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']), 2),
|
||||
'total_credit_transferred' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']), 2),
|
||||
'net_balance_impact' => round(
|
||||
(float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred'])
|
||||
- (float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']),
|
||||
2
|
||||
),
|
||||
],
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
public function closingReportByYearName(string $schoolYear): array
|
||||
{
|
||||
return $this->closingReport((int) $this->findByNameOrFail($schoolYear)->id);
|
||||
}
|
||||
|
||||
public function validateClose(int $id, array $payload): array
|
||||
{
|
||||
$year = $this->findOrFail($id);
|
||||
@@ -270,6 +291,11 @@ class SchoolYearClosureService
|
||||
];
|
||||
}
|
||||
|
||||
public function validateCloseByYearName(string $schoolYear, array $payload): array
|
||||
{
|
||||
return $this->validateClose((int) $this->findByNameOrFail($schoolYear)->id, $payload);
|
||||
}
|
||||
|
||||
public function previewClose(int $id, array $payload): array
|
||||
{
|
||||
$year = $this->findOrFail($id);
|
||||
@@ -284,6 +310,11 @@ class SchoolYearClosureService
|
||||
];
|
||||
}
|
||||
|
||||
public function previewCloseByYearName(string $schoolYear, array $payload): array
|
||||
{
|
||||
return $this->previewClose((int) $this->findByNameOrFail($schoolYear)->id, $payload);
|
||||
}
|
||||
|
||||
public function close(int $id, array $payload, ?int $actorId): array
|
||||
{
|
||||
$year = $this->findOrFail($id);
|
||||
@@ -376,6 +407,11 @@ class SchoolYearClosureService
|
||||
});
|
||||
}
|
||||
|
||||
public function closeByYearName(string $schoolYear, array $payload, ?int $actorId): array
|
||||
{
|
||||
return $this->close((int) $this->findByNameOrFail($schoolYear)->id, $payload, $actorId);
|
||||
}
|
||||
|
||||
public function reopen(int $id, ?int $actorId): array
|
||||
{
|
||||
$year = $this->findOrFail($id);
|
||||
@@ -411,6 +447,11 @@ class SchoolYearClosureService
|
||||
});
|
||||
}
|
||||
|
||||
public function reopenByYearName(string $schoolYear, ?int $actorId): array
|
||||
{
|
||||
return $this->reopen((int) $this->findByNameOrFail($schoolYear)->id, $actorId);
|
||||
}
|
||||
|
||||
public function parentBalances(int $id, ?string $toSchoolYear = null): array
|
||||
{
|
||||
$year = $this->findOrFail($id);
|
||||
@@ -421,6 +462,11 @@ class SchoolYearClosureService
|
||||
);
|
||||
}
|
||||
|
||||
public function parentBalancesByYearName(string $schoolYear, ?string $toSchoolYear = null): array
|
||||
{
|
||||
return $this->parentBalances((int) $this->findByNameOrFail($schoolYear)->id, $toSchoolYear);
|
||||
}
|
||||
|
||||
public function promotionPreview(int $id, ?string $toSchoolYear = null): array
|
||||
{
|
||||
$year = $this->findOrFail($id);
|
||||
@@ -431,6 +477,11 @@ class SchoolYearClosureService
|
||||
);
|
||||
}
|
||||
|
||||
public function promotionPreviewByYearName(string $schoolYear, ?string $toSchoolYear = null): array
|
||||
{
|
||||
return $this->promotionPreview((int) $this->findByNameOrFail($schoolYear)->id, $toSchoolYear);
|
||||
}
|
||||
|
||||
public function findOrFail(int $id): SchoolYear
|
||||
{
|
||||
$this->resolver->ensureCurrentTracked();
|
||||
@@ -438,6 +489,13 @@ class SchoolYearClosureService
|
||||
return SchoolYear::query()->findOrFail($id);
|
||||
}
|
||||
|
||||
public function findByNameOrFail(string $schoolYear): SchoolYear
|
||||
{
|
||||
$this->resolver->ensureCurrentTracked();
|
||||
|
||||
return SchoolYear::query()->where('name', trim($schoolYear))->firstOrFail();
|
||||
}
|
||||
|
||||
private function validateNewYearPayload(SchoolYear $currentYear, array $newYear): array
|
||||
{
|
||||
$errors = [];
|
||||
@@ -683,85 +741,110 @@ class SchoolYearClosureService
|
||||
|
||||
private function buildParentBalanceRows(string $fromSchoolYear, string $toSchoolYear): array
|
||||
{
|
||||
$invoiceBalances = DB::table('invoices')
|
||||
->where('school_year', $fromSchoolYear)
|
||||
->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), ['void', 'voided', 'cancelled', 'canceled'])
|
||||
->select('parent_id')
|
||||
->selectRaw('ROUND(SUM(COALESCE(balance, 0)), 2) as net_balance')
|
||||
->selectRaw('COUNT(CASE WHEN COALESCE(balance, 0) > 0.01 THEN 1 END) as unpaid_invoice_count')
|
||||
->groupBy('parent_id')
|
||||
->havingRaw('ABS(SUM(COALESCE(balance, 0))) > 0.01')
|
||||
->get();
|
||||
$invoices = $this->eligibleCarryoverInvoiceQuery($fromSchoolYear)
|
||||
->whereRaw('ABS(COALESCE(balance, 0)) > 0.01')
|
||||
->get(['id', 'parent_id', 'balance']);
|
||||
|
||||
$rows = [];
|
||||
$summary = [
|
||||
'parents_with_non_zero_balance' => 0,
|
||||
'parents_with_credit_balance' => 0,
|
||||
'existing_transfer_conflicts' => 0,
|
||||
'total_old_unpaid_balance' => 0.0,
|
||||
'total_credit_balance' => 0.0,
|
||||
'net_balance_to_transfer' => 0.0,
|
||||
];
|
||||
|
||||
foreach ($invoiceBalances as $balanceRow) {
|
||||
$parentId = (int) ($balanceRow->parent_id ?? 0);
|
||||
$balancesByParent = [];
|
||||
foreach ($invoices as $invoice) {
|
||||
$parentId = (int) ($invoice->parent_id ?? 0);
|
||||
if ($parentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$net = round((float) ($balanceRow->net_balance ?? 0), 2);
|
||||
if (abs($net) < 0.01) {
|
||||
$balance = round((float) ($invoice->balance ?? 0), 2);
|
||||
$balancesByParent[$parentId] ??= [
|
||||
'positive_unpaid_balance' => 0.0,
|
||||
'credit_balance' => 0.0,
|
||||
'positive_invoice_ids' => [],
|
||||
'credit_invoice_ids' => [],
|
||||
];
|
||||
|
||||
if ($balance > 0.01) {
|
||||
$balancesByParent[$parentId]['positive_unpaid_balance'] += $balance;
|
||||
$balancesByParent[$parentId]['positive_invoice_ids'][] = (int) $invoice->id;
|
||||
} elseif ($balance < -0.01) {
|
||||
$balancesByParent[$parentId]['credit_balance'] += abs($balance);
|
||||
$balancesByParent[$parentId]['credit_invoice_ids'][] = (int) $invoice->id;
|
||||
}
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$summary = [
|
||||
'parents_with_positive_balance' => 0,
|
||||
'parents_with_credit_balance' => 0,
|
||||
'parents_with_net_non_zero_balance' => 0,
|
||||
'existing_transfer_conflicts' => 0,
|
||||
'total_positive_unpaid_balance' => 0.0,
|
||||
'total_credit_balance' => 0.0,
|
||||
'net_balance_impact' => 0.0,
|
||||
// Deprecated aliases retained for older UI/tests during the migration.
|
||||
'parents_with_non_zero_balance' => 0,
|
||||
'total_old_unpaid_balance' => 0.0,
|
||||
'net_balance_to_transfer' => 0.0,
|
||||
];
|
||||
|
||||
foreach ($balancesByParent as $parentId => $balanceRow) {
|
||||
$positive = round((float) $balanceRow['positive_unpaid_balance'], 2);
|
||||
$credit = round((float) $balanceRow['credit_balance'], 2);
|
||||
$net = round($positive - $credit, 2);
|
||||
|
||||
if ($positive < 0.01 && $credit < 0.01) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = ParentBalanceTransfer::query()
|
||||
$existing = $positive > 0.01
|
||||
? ParentBalanceTransfer::query()
|
||||
->where('parent_id', $parentId)
|
||||
->where('from_school_year', $fromSchoolYear)
|
||||
->where('to_school_year', $toSchoolYear)
|
||||
->first();
|
||||
|
||||
$sourceInvoiceIds = DB::table('invoices')
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $fromSchoolYear)
|
||||
->whereRaw('ABS(COALESCE(balance, 0)) > 0.01')
|
||||
->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), ['void', 'voided', 'cancelled', 'canceled'])
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
->first()
|
||||
: null;
|
||||
|
||||
$row = [
|
||||
'parent_id' => $parentId,
|
||||
'parent_name' => $this->resolveParentName($parentId),
|
||||
'student_names' => $this->resolveStudentNames($parentId, $fromSchoolYear),
|
||||
'unpaid_invoice_count' => (int) ($balanceRow->unpaid_invoice_count ?? 0),
|
||||
'old_unpaid_balance' => $net > 0 ? $net : 0.0,
|
||||
'credit_balance' => $net < 0 ? abs($net) : 0.0,
|
||||
'unpaid_invoice_count' => count($balanceRow['positive_invoice_ids']),
|
||||
'positive_unpaid_balance' => $positive,
|
||||
'old_unpaid_balance' => $positive,
|
||||
'credit_balance' => $credit,
|
||||
'net_balance' => $net,
|
||||
'amount_to_transfer' => $net,
|
||||
'transfer_status' => $existing?->status ?? ($net > 0 ? 'pending' : 'credit_pending'),
|
||||
'amount_to_transfer' => $positive,
|
||||
'transfer_status' => $existing?->status ?? ($positive > 0.01 ? 'pending' : 'credit_reported'),
|
||||
'existing_transfer_id' => $existing?->id,
|
||||
'source_invoice_ids' => $sourceInvoiceIds,
|
||||
'existing_transfer_conflict' => $existing !== null,
|
||||
'positive_invoice_ids' => $balanceRow['positive_invoice_ids'],
|
||||
'credit_invoice_ids' => $balanceRow['credit_invoice_ids'],
|
||||
'source_invoice_ids' => $balanceRow['positive_invoice_ids'],
|
||||
];
|
||||
|
||||
$rows[] = $row;
|
||||
|
||||
$summary['parents_with_non_zero_balance']++;
|
||||
$summary['net_balance_to_transfer'] += $net;
|
||||
$summary['total_old_unpaid_balance'] += $row['old_unpaid_balance'];
|
||||
$summary['total_credit_balance'] += $row['credit_balance'];
|
||||
if ($row['credit_balance'] > 0) {
|
||||
$summary['parents_with_credit_balance']++;
|
||||
if ($positive > 0.01) {
|
||||
$summary['parents_with_positive_balance']++;
|
||||
$summary['total_positive_unpaid_balance'] += $positive;
|
||||
}
|
||||
if ($row['existing_transfer_id']) {
|
||||
if ($credit > 0.01) {
|
||||
$summary['parents_with_credit_balance']++;
|
||||
$summary['total_credit_balance'] += $credit;
|
||||
}
|
||||
if (abs($net) > 0.01) {
|
||||
$summary['parents_with_net_non_zero_balance']++;
|
||||
}
|
||||
if ($row['existing_transfer_conflict']) {
|
||||
$summary['existing_transfer_conflicts']++;
|
||||
}
|
||||
}
|
||||
|
||||
usort($rows, static fn (array $a, array $b) => $b['amount_to_transfer'] <=> $a['amount_to_transfer']);
|
||||
|
||||
$summary['net_balance_to_transfer'] = round($summary['net_balance_to_transfer'], 2);
|
||||
$summary['total_old_unpaid_balance'] = round($summary['total_old_unpaid_balance'], 2);
|
||||
$summary['total_positive_unpaid_balance'] = round($summary['total_positive_unpaid_balance'], 2);
|
||||
$summary['total_credit_balance'] = round($summary['total_credit_balance'], 2);
|
||||
$summary['net_balance_impact'] = round($summary['total_positive_unpaid_balance'] - $summary['total_credit_balance'], 2);
|
||||
$summary['parents_with_non_zero_balance'] = $summary['parents_with_net_non_zero_balance'];
|
||||
$summary['total_old_unpaid_balance'] = $summary['total_positive_unpaid_balance'];
|
||||
$summary['net_balance_to_transfer'] = $summary['net_balance_impact'];
|
||||
|
||||
return [
|
||||
'from_school_year' => $fromSchoolYear,
|
||||
@@ -771,6 +854,13 @@ class SchoolYearClosureService
|
||||
];
|
||||
}
|
||||
|
||||
private function eligibleCarryoverInvoiceQuery(string $schoolYear)
|
||||
{
|
||||
return DB::table('invoices')
|
||||
->where('school_year', $schoolYear)
|
||||
->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), ['void', 'voided', 'cancelled', 'canceled', 'draft', 'pending']);
|
||||
}
|
||||
|
||||
private function resolveTargetSection(?int $targetLevelId, string $schoolYear): array
|
||||
{
|
||||
if ($targetLevelId === null || $targetLevelId <= 0) {
|
||||
@@ -905,7 +995,12 @@ class SchoolYearClosureService
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$amount = round((float) $row['amount_to_transfer'], 2);
|
||||
if (abs($amount) < 0.01) {
|
||||
$credit = round((float) ($row['credit_balance'] ?? 0), 2);
|
||||
if ($amount < 0.01) {
|
||||
if ($credit > 0.01) {
|
||||
$counts['credit_parents']++;
|
||||
$counts['credit_amount'] += $credit;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -924,36 +1019,51 @@ class SchoolYearClosureService
|
||||
));
|
||||
}
|
||||
|
||||
ParentAccount::query()->updateOrCreate(
|
||||
['parent_id' => $row['parent_id'], 'school_year' => $fromSchoolYear],
|
||||
['opening_balance' => 0, 'current_balance' => $amount]
|
||||
);
|
||||
|
||||
$newAccount = ParentAccount::query()->firstOrCreate(
|
||||
['parent_id' => $row['parent_id'], 'school_year' => $toSchoolYear],
|
||||
['opening_balance' => 0, 'current_balance' => 0]
|
||||
);
|
||||
if (Schema::hasColumn('parent_accounts', 'school_year_id') && $newAccount->school_year_id === null) {
|
||||
$newAccount->school_year_id = $this->schoolYearIdForName($toSchoolYear);
|
||||
}
|
||||
$newAccount->opening_balance = round((float) $newAccount->opening_balance + $amount, 2);
|
||||
$newAccount->current_balance = round((float) $newAccount->current_balance + $amount, 2);
|
||||
$newAccount->save();
|
||||
|
||||
$transfer = ParentBalanceTransfer::query()->create([
|
||||
$transferPayload = [
|
||||
'parent_id' => $row['parent_id'],
|
||||
'from_school_year' => $fromSchoolYear,
|
||||
'to_school_year' => $toSchoolYear,
|
||||
'amount' => $amount,
|
||||
'status' => 'transferred',
|
||||
'source_summary_json' => [
|
||||
'source_invoice_ids' => $row['source_invoice_ids'],
|
||||
'old_unpaid_balance' => $row['old_unpaid_balance'],
|
||||
'credit_balance' => $row['credit_balance'],
|
||||
'source_invoice_ids' => $row['positive_invoice_ids'] ?? $row['source_invoice_ids'] ?? [],
|
||||
'positive_invoice_ids' => $row['positive_invoice_ids'] ?? $row['source_invoice_ids'] ?? [],
|
||||
'credit_invoice_ids' => $row['credit_invoice_ids'] ?? [],
|
||||
'old_unpaid_balance' => $row['old_unpaid_balance'] ?? $amount,
|
||||
'positive_unpaid_balance' => $row['positive_unpaid_balance'] ?? $amount,
|
||||
'credit_balance' => $credit,
|
||||
'net_balance' => $row['net_balance'] ?? ($amount - $credit),
|
||||
],
|
||||
'created_by' => $actorId,
|
||||
]);
|
||||
];
|
||||
if (Schema::hasColumn('parent_balance_transfers', 'source_summary')) {
|
||||
$transferPayload['source_summary'] = $transferPayload['source_summary_json'];
|
||||
}
|
||||
$fromYearId = $this->schoolYearIdForName($fromSchoolYear);
|
||||
$toYearId = $this->schoolYearIdForName($toSchoolYear);
|
||||
if (Schema::hasColumn('parent_balance_transfers', 'from_school_year_id')) {
|
||||
$transferPayload['from_school_year_id'] = $fromYearId;
|
||||
}
|
||||
if (Schema::hasColumn('parent_balance_transfers', 'to_school_year_id')) {
|
||||
$transferPayload['to_school_year_id'] = $toYearId;
|
||||
}
|
||||
|
||||
$transfer = ParentBalanceTransfer::query()->create($transferPayload);
|
||||
|
||||
$newInvoiceId = null;
|
||||
if ($amount > 0) {
|
||||
$newInvoiceId = DB::table('invoices')->insertGetId([
|
||||
$invoicePayload = [
|
||||
'parent_id' => $row['parent_id'],
|
||||
'invoice_number' => sprintf('OB-%s-%d', str_replace('-', '', $toSchoolYear), $transfer->id),
|
||||
'total_amount' => $amount,
|
||||
@@ -970,15 +1080,23 @@ class SchoolYearClosureService
|
||||
'updated_by' => $actorId,
|
||||
'semester' => Configuration::getConfig('semester'),
|
||||
'balance_transfer_id' => $transfer->id,
|
||||
]);
|
||||
];
|
||||
if (Schema::hasColumn('invoices', 'invoice_type')) {
|
||||
$invoicePayload['invoice_type'] = 'opening_balance';
|
||||
}
|
||||
if (Schema::hasColumn('invoices', 'school_year_id')) {
|
||||
$invoicePayload['school_year_id'] = $toYearId;
|
||||
}
|
||||
$newInvoiceId = DB::table('invoices')->insertGetId($invoicePayload);
|
||||
$transfer->new_invoice_id = $newInvoiceId;
|
||||
$transfer->save();
|
||||
|
||||
$counts['transferred_parents']++;
|
||||
$counts['transferred_amount'] += $amount;
|
||||
} else {
|
||||
if ($credit > 0.01) {
|
||||
$counts['credit_parents']++;
|
||||
$counts['credit_amount'] += abs($amount);
|
||||
$counts['credit_amount'] += $credit;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logAudit(
|
||||
@@ -1018,6 +1136,17 @@ class SchoolYearClosureService
|
||||
->count();
|
||||
}
|
||||
|
||||
private function schoolYearIdForName(string $schoolYear): ?int
|
||||
{
|
||||
if (! Schema::hasTable('school_years')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = SchoolYear::query()->where('name', $schoolYear)->value('id');
|
||||
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
private function countFinancialInconsistencies(string $schoolYear): int
|
||||
{
|
||||
return DB::table('invoices')
|
||||
@@ -1114,7 +1243,9 @@ class SchoolYearClosureService
|
||||
'parents_with_unpaid_balances' => 0,
|
||||
'total_unpaid_balance_transferred' => 0.0,
|
||||
'parents_with_credit_balances' => 0,
|
||||
'total_credit_carried_or_reported' => 0.0,
|
||||
'total_credit_transferred' => 0.0,
|
||||
'net_balance_impact' => 0.0,
|
||||
];
|
||||
|
||||
if (! Schema::hasTable('parent_balance_transfers')) {
|
||||
@@ -1124,25 +1255,49 @@ class SchoolYearClosureService
|
||||
$rows = DB::table('parent_balance_transfers')
|
||||
->where('from_school_year', $fromSchoolYear)
|
||||
->when($toSchoolYear !== null && trim($toSchoolYear) !== '', fn ($query) => $query->where('to_school_year', $toSchoolYear))
|
||||
->get(['amount']);
|
||||
->get(['amount', 'source_summary_json', 'source_summary']);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$amount = round((float) ($row->amount ?? 0), 2);
|
||||
if ($amount > 0.01) {
|
||||
$totals['parents_with_unpaid_balances']++;
|
||||
$totals['total_unpaid_balance_transferred'] += $amount;
|
||||
} elseif ($amount < -0.01) {
|
||||
}
|
||||
|
||||
$sourceSummary = $this->decodeTransferSourceSummary($row);
|
||||
$credit = round((float) ($sourceSummary['credit_balance'] ?? 0), 2);
|
||||
if ($credit > 0.01) {
|
||||
$totals['parents_with_credit_balances']++;
|
||||
$totals['total_credit_transferred'] += abs($amount);
|
||||
$totals['total_credit_carried_or_reported'] += $credit;
|
||||
}
|
||||
}
|
||||
|
||||
$totals['total_unpaid_balance_transferred'] = round($totals['total_unpaid_balance_transferred'], 2);
|
||||
$totals['total_credit_transferred'] = round($totals['total_credit_transferred'], 2);
|
||||
$totals['total_credit_carried_or_reported'] = round($totals['total_credit_carried_or_reported'], 2);
|
||||
$totals['total_credit_transferred'] = $totals['total_credit_carried_or_reported'];
|
||||
$totals['net_balance_impact'] = round(
|
||||
$totals['total_unpaid_balance_transferred'] - $totals['total_credit_carried_or_reported'],
|
||||
2
|
||||
);
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
private function decodeTransferSourceSummary(object $row): array
|
||||
{
|
||||
$raw = $row->source_summary_json ?? $row->source_summary ?? null;
|
||||
if (is_array($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
if (! is_string($raw) || trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function resolveUserName(int $userId): string|int|null
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
@@ -1196,6 +1351,11 @@ class SchoolYearClosureService
|
||||
return $this->presentSchoolYear($year->fresh());
|
||||
}
|
||||
|
||||
public function archiveByYearName(string $schoolYear, ?int $actorId): array
|
||||
{
|
||||
return $this->archive((int) $this->findByNameOrFail($schoolYear)->id, $actorId);
|
||||
}
|
||||
|
||||
private function presentSchoolYear(?SchoolYear $year): ?array
|
||||
{
|
||||
if (! $year) {
|
||||
|
||||
@@ -30,7 +30,7 @@ class SchoolYearWriteGuard
|
||||
|
||||
foreach ($years as $year) {
|
||||
$row = $statuses->get($year);
|
||||
if ($row && $row->status !== SchoolYear::STATUS_ACTIVE) {
|
||||
if ($row && in_array($row->status, [SchoolYear::STATUS_CLOSED, SchoolYear::STATUS_ARCHIVED], true)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'School year %s is %s and read-only.',
|
||||
$year,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\SchoolYears;
|
||||
|
||||
final class SelectedSchoolYearContext
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $id,
|
||||
public readonly string $name,
|
||||
public readonly string $status,
|
||||
public readonly bool $isReadOnly,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\SchoolYears;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\SchoolYear;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
class SelectedSchoolYearContextService
|
||||
{
|
||||
public function __construct(private SchoolYearResolver $resolver) {}
|
||||
|
||||
public function fromRequest(Request $request): SelectedSchoolYearContext
|
||||
{
|
||||
$explicit = false;
|
||||
$name = $this->selectedName($request, $explicit);
|
||||
|
||||
if ($name === '') {
|
||||
$name = $this->activeSchoolYearName();
|
||||
}
|
||||
|
||||
if ($name === '') {
|
||||
throw new UnprocessableEntityHttpException('A school_year value is required.');
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('school_years')) {
|
||||
throw new UnprocessableEntityHttpException('School-year tracking is unavailable.');
|
||||
}
|
||||
|
||||
$this->resolver->ensureCurrentTracked();
|
||||
|
||||
$year = SchoolYear::query()->where('name', $name)->first();
|
||||
if (! $year) {
|
||||
throw new NotFoundHttpException(sprintf('School year %s was not found.', $name));
|
||||
}
|
||||
|
||||
return new SelectedSchoolYearContext(
|
||||
(int) $year->id,
|
||||
(string) $year->name,
|
||||
(string) $year->status,
|
||||
in_array($year->status, [SchoolYear::STATUS_CLOSED, SchoolYear::STATUS_ARCHIVED], true),
|
||||
);
|
||||
}
|
||||
|
||||
public function fromName(string $schoolYear): SelectedSchoolYearContext
|
||||
{
|
||||
$request = Request::create('/', 'GET', ['school_year' => $schoolYear]);
|
||||
|
||||
return $this->fromRequest($request);
|
||||
}
|
||||
|
||||
private function selectedName(Request $request, bool &$explicit): string
|
||||
{
|
||||
$sources = [
|
||||
$request->query('school_year'),
|
||||
$request->headers->get('X-School-Year'),
|
||||
$request->input('school_year'),
|
||||
];
|
||||
|
||||
foreach ($sources as $value) {
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$explicit = true;
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function activeSchoolYearName(): string
|
||||
{
|
||||
$configured = trim((string) (Configuration::getConfig('school_year') ?? ''));
|
||||
if ($configured !== '') {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('school_years')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$year = SchoolYear::query()->where('is_current', true)->orderByDesc('id')->first();
|
||||
|
||||
return $year ? trim((string) $year->name) : '';
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\Services\Staff;
|
||||
|
||||
use App\Models\Staff;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\System\GlobalConfigService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use RuntimeException;
|
||||
|
||||
class StaffCommandService
|
||||
@@ -15,9 +17,11 @@ class StaffCommandService
|
||||
public function create(array $payload): Staff
|
||||
{
|
||||
return DB::transaction(function () use ($payload) {
|
||||
$userId = $this->resolveUserId($payload);
|
||||
$role = $this->resolveRole($payload);
|
||||
$user = $this->resolveOrCreateUser($payload, $role);
|
||||
$userId = (int) $user->id;
|
||||
|
||||
$roleName = (string) ($payload['role_name'] ?? '');
|
||||
$roleName = (string) $role->name;
|
||||
$activeRole = strtolower($roleName);
|
||||
if (! empty($payload['status']) && strtolower((string) $payload['status']) === 'inactive') {
|
||||
$activeRole = 'inactive';
|
||||
@@ -25,8 +29,14 @@ class StaffCommandService
|
||||
|
||||
$schoolYear = (string) ($payload['school_year'] ?? $this->configService->getSchoolYear() ?? '');
|
||||
|
||||
$staff = Staff::query()->create([
|
||||
DB::table('user_roles')->updateOrInsert(
|
||||
['user_id' => $userId, 'role_id' => (int) $role->id],
|
||||
['created_at' => now(), 'updated_at' => now()]
|
||||
);
|
||||
|
||||
$staff = Staff::query()->updateOrCreate([
|
||||
'user_id' => $userId,
|
||||
], [
|
||||
'firstname' => (string) $payload['firstname'],
|
||||
'lastname' => (string) $payload['lastname'],
|
||||
'email' => (string) $payload['email'],
|
||||
@@ -34,7 +44,6 @@ class StaffCommandService
|
||||
'role_name' => $roleName,
|
||||
'active_role' => $activeRole,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
@@ -49,15 +58,23 @@ class StaffCommandService
|
||||
public function update(Staff $staff, array $payload): Staff
|
||||
{
|
||||
return DB::transaction(function () use ($staff, $payload) {
|
||||
$role = array_key_exists('role_id', $payload) || array_key_exists('role_name', $payload)
|
||||
? $this->resolveRole($payload)
|
||||
: null;
|
||||
$updates = [];
|
||||
foreach (['firstname', 'lastname', 'email', 'phone', 'role_name', 'school_year'] as $field) {
|
||||
foreach (['firstname', 'lastname', 'email', 'phone', 'school_year'] as $field) {
|
||||
if (array_key_exists($field, $payload) && $payload[$field] !== '') {
|
||||
$updates[$field] = $payload[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('role_name', $updates)) {
|
||||
if ($role) {
|
||||
$updates['role_name'] = (string) $role->name;
|
||||
$updates['active_role'] = strtolower((string) $updates['role_name']);
|
||||
DB::table('user_roles')->updateOrInsert(
|
||||
['user_id' => (int) $staff->user_id, 'role_id' => (int) $role->id],
|
||||
['created_at' => now(), 'updated_at' => now()]
|
||||
);
|
||||
}
|
||||
if (! empty($payload['status']) && strtolower((string) $payload['status']) === 'inactive') {
|
||||
$updates['active_role'] = 'inactive';
|
||||
@@ -68,6 +85,15 @@ class StaffCommandService
|
||||
if (! empty($updates)) {
|
||||
$staff->fill($updates);
|
||||
$staff->save();
|
||||
|
||||
$userUpdates = array_intersect_key($updates, array_flip(['firstname', 'lastname', 'email', 'phone', 'school_year']));
|
||||
if (isset($userUpdates['phone'])) {
|
||||
$userUpdates['cellphone'] = $userUpdates['phone'];
|
||||
unset($userUpdates['phone']);
|
||||
}
|
||||
if ($userUpdates !== []) {
|
||||
User::query()->whereKey((int) $staff->user_id)->update($userUpdates + ['updated_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
return $staff->fresh();
|
||||
@@ -79,20 +105,69 @@ class StaffCommandService
|
||||
return (bool) $staff->delete();
|
||||
}
|
||||
|
||||
private function resolveUserId(array $payload): int
|
||||
private function resolveRole(array $payload): Role
|
||||
{
|
||||
if (! empty($payload['role_id'])) {
|
||||
$role = Role::query()->whereKey((int) $payload['role_id'])->where('is_active', 1)->first();
|
||||
if ($role) {
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
|
||||
$roleName = trim((string) ($payload['role_name'] ?? ''));
|
||||
if ($roleName !== '') {
|
||||
$role = Role::query()
|
||||
->where('is_active', 1)
|
||||
->where(function ($query) use ($roleName) {
|
||||
$query->whereRaw('LOWER(name) = ?', [strtolower($roleName)])
|
||||
->orWhereRaw('LOWER(slug) = ?', [strtolower($roleName)]);
|
||||
})
|
||||
->first();
|
||||
if ($role) {
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('A valid active role is required.');
|
||||
}
|
||||
|
||||
private function resolveOrCreateUser(array $payload, Role $role): User
|
||||
{
|
||||
if (! empty($payload['user_id'])) {
|
||||
return (int) $payload['user_id'];
|
||||
$user = User::query()->find((int) $payload['user_id']);
|
||||
if (! $user) {
|
||||
throw new RuntimeException('The selected user was not found.');
|
||||
}
|
||||
} else {
|
||||
$email = strtolower(trim((string) ($payload['email'] ?? '')));
|
||||
if ($email === '') {
|
||||
throw new RuntimeException('A valid email is required.');
|
||||
}
|
||||
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first() ?? new User();
|
||||
}
|
||||
|
||||
$email = $payload['email'] ?? null;
|
||||
if ($email) {
|
||||
$userId = User::query()->where('email', $email)->value('id');
|
||||
if ($userId) {
|
||||
return (int) $userId;
|
||||
$isNew = ! $user->exists;
|
||||
$user->firstname = (string) $payload['firstname'];
|
||||
$user->lastname = (string) $payload['lastname'];
|
||||
$user->email = strtolower(trim((string) $payload['email']));
|
||||
$user->cellphone = $payload['phone'] ?? $user->cellphone ?? null;
|
||||
$user->school_year = (string) ($payload['school_year'] ?? $this->configService->getSchoolYear() ?? '');
|
||||
$user->semester = $user->semester ?: (string) ($this->configService->getSemester() ?? '');
|
||||
$user->status = ! empty($payload['status']) && strtolower((string) $payload['status']) === 'inactive' ? 'Inactive' : 'Active';
|
||||
$user->is_verified = 1;
|
||||
$user->is_suspended = 0;
|
||||
$user->accept_school_policy = $user->accept_school_policy ?? 1;
|
||||
$user->school_id = $user->school_id ?: random_int(100000, 999999);
|
||||
if ($isNew) {
|
||||
$password = (string) ($payload['password'] ?? '');
|
||||
if ($password === '') {
|
||||
throw new RuntimeException('A temporary password is required for a new staff user.');
|
||||
}
|
||||
$user->password = Hash::make($password);
|
||||
}
|
||||
$user->user_type = (string) $role->name;
|
||||
$user->save();
|
||||
|
||||
throw new RuntimeException('A valid user_id or existing email is required.');
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,29 @@ class StaffQueryService
|
||||
$query->whereRaw('LOWER(active_role) = ?', [$role]);
|
||||
}
|
||||
|
||||
$schoolYear = (string) ($filters['school_year'] ?? $this->configService->getSchoolYear() ?? '');
|
||||
if ($schoolYear !== '') {
|
||||
$query->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
if (! empty($filters['status'])) {
|
||||
$status = strtolower(trim((string) $filters['status']));
|
||||
if ($status === 'inactive') {
|
||||
$query->whereRaw('LOWER(active_role) = ?', ['inactive']);
|
||||
} elseif ($status === 'active') {
|
||||
$query->whereRaw('LOWER(active_role) != ?', ['inactive']);
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($filters['search'])) {
|
||||
$search = '%'.strtolower(trim((string) $filters['search'])).'%';
|
||||
$query->where(function ($inner) use ($search) {
|
||||
$inner->whereRaw('LOWER(firstname) LIKE ?', [$search])
|
||||
->orWhereRaw('LOWER(lastname) LIKE ?', [$search])
|
||||
->orWhereRaw('LOWER(email) LIKE ?', [$search]);
|
||||
});
|
||||
}
|
||||
|
||||
$sortBy = $filters['sort_by'] ?? 'created_at';
|
||||
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
@@ -31,7 +54,6 @@ class StaffQueryService
|
||||
->orderBy($sortBy, $sortDir)
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$schoolYear = (string) ($filters['school_year'] ?? $this->configService->getSchoolYear() ?? '');
|
||||
$semester = (string) ($filters['semester'] ?? $this->configService->getSemester() ?? '');
|
||||
|
||||
$assignByTeacher = $this->buildAssignments($schoolYear);
|
||||
|
||||
@@ -16,7 +16,6 @@ class WhatsappContextService
|
||||
$override,
|
||||
request()->query('school_year'),
|
||||
request()->header('X-School-Year'),
|
||||
request()->header('X-Selected-School-Year'),
|
||||
Configuration::getConfig('school_year'),
|
||||
] as $candidate) {
|
||||
$value = trim((string) ($candidate ?? ''));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+29
-9
@@ -43,6 +43,7 @@ use App\Http\Controllers\Api\Expenses\ExpenseController;
|
||||
use App\Http\Controllers\Api\ExtraCharges\ExtraChargesController;
|
||||
use App\Http\Controllers\Api\Family\FamilyAdminController;
|
||||
use App\Http\Controllers\Api\Family\FamilyController;
|
||||
use App\Http\Controllers\Api\Family\ParentProfileAdminController;
|
||||
use App\Http\Controllers\Api\Finance\BalanceCarryforwardController;
|
||||
use App\Http\Controllers\Api\Finance\ChargeController;
|
||||
use App\Http\Controllers\Api\Finance\EventChargeController;
|
||||
@@ -384,10 +385,10 @@ Route::prefix('v1')->group(function () {
|
||||
Route::prefix('staff')->group(function () {
|
||||
Route::get('/', [StaffController::class, 'index']);
|
||||
Route::get('form-options', [StaffController::class, 'formOptions']);
|
||||
Route::get('{id}', [StaffController::class, 'show']);
|
||||
Route::post('/', [StaffController::class, 'store']);
|
||||
Route::patch('{id}', [StaffController::class, 'update']);
|
||||
Route::delete('{id}', [StaffController::class, 'destroy']);
|
||||
Route::get('{id}', [StaffController::class, 'show'])->whereNumber('id');
|
||||
Route::post('/', [StaffController::class, 'store'])->middleware('school_year.editable');
|
||||
Route::patch('{id}', [StaffController::class, 'update'])->whereNumber('id')->middleware('school_year.editable');
|
||||
Route::delete('{id}', [StaffController::class, 'destroy'])->whereNumber('id')->middleware('school_year.editable');
|
||||
});
|
||||
|
||||
Route::prefix('flags')->group(function () {
|
||||
@@ -509,10 +510,20 @@ Route::prefix('v1')->group(function () {
|
||||
});
|
||||
|
||||
Route::middleware(['multi.auth', 'account.active'])->prefix('school-years')->group(function () {
|
||||
Route::get('/', [SchoolYearController::class, 'index'])->middleware('perm:school_year.view');
|
||||
Route::get('/', [SchoolYearController::class, 'index']);
|
||||
Route::get('current', [SchoolYearController::class, 'current']);
|
||||
Route::get('options', [SchoolYearController::class, 'options']);
|
||||
Route::get('summary', [SchoolYearController::class, 'summarySelected'])->middleware('perm:school_year.view');
|
||||
Route::get('reports/closing', [SchoolYearController::class, 'closingReportSelected'])->middleware('perm:school_year.view');
|
||||
Route::post('validate-close', [SchoolYearController::class, 'validateCloseSelected'])->middleware('perm:school_year.close');
|
||||
Route::post('preview-close', [SchoolYearController::class, 'previewCloseSelected'])->middleware('perm:school_year.close');
|
||||
Route::post('close', [SchoolYearController::class, 'closeSelected'])->middleware('perm:school_year.close');
|
||||
Route::post('reopen', [SchoolYearController::class, 'reopenSelected'])->middleware('perm:school_year.reopen');
|
||||
Route::post('archive', [SchoolYearController::class, 'archiveSelected'])->middleware('perm:school_year.archive');
|
||||
Route::get('parent-balances', [SchoolYearController::class, 'parentBalancesSelected'])->middleware('perm:finance.balance_transfer.view');
|
||||
Route::get('promotion-preview', [SchoolYearController::class, 'promotionPreviewSelected'])->middleware('perm:student_enrollment.promote');
|
||||
Route::post('/', [SchoolYearController::class, 'store'])->middleware('perm:school_year.create');
|
||||
Route::patch('selected', [SchoolYearController::class, 'updateSelected'])->middleware('perm:school_year.manage');
|
||||
Route::patch('{schoolYear}', [SchoolYearController::class, 'update'])->middleware('perm:school_year.manage')->whereNumber('schoolYear');
|
||||
Route::get('{schoolYear}/summary', [SchoolYearController::class, 'summary'])->middleware('perm:school_year.view')->whereNumber('schoolYear');
|
||||
Route::get('{schoolYear}/reports/closing', [SchoolYearController::class, 'closingReport'])->middleware('perm:school_year.view')->whereNumber('schoolYear');
|
||||
@@ -685,10 +696,10 @@ Route::prefix('v1')->group(function () {
|
||||
Route::prefix('staff')->group(function () {
|
||||
Route::get('/', [StaffController::class, 'index']);
|
||||
Route::get('form-options', [StaffController::class, 'formOptions']);
|
||||
Route::get('{id}', [StaffController::class, 'show']);
|
||||
Route::post('/', [StaffController::class, 'store']);
|
||||
Route::patch('{id}', [StaffController::class, 'update']);
|
||||
Route::delete('{id}', [StaffController::class, 'destroy']);
|
||||
Route::get('{id}', [StaffController::class, 'show'])->whereNumber('id');
|
||||
Route::post('/', [StaffController::class, 'store'])->middleware('school_year.editable');
|
||||
Route::patch('{id}', [StaffController::class, 'update'])->whereNumber('id')->middleware('school_year.editable');
|
||||
Route::delete('{id}', [StaffController::class, 'destroy'])->whereNumber('id')->middleware('school_year.editable');
|
||||
});
|
||||
|
||||
Route::prefix('messages')->group(function () {
|
||||
@@ -976,6 +987,15 @@ Route::prefix('v1')->group(function () {
|
||||
Route::post('compose-email', [FamilyAdminController::class, 'composeEmail']);
|
||||
});
|
||||
|
||||
Route::middleware('admin.access')->prefix('parent-profiles')->group(function () {
|
||||
Route::get('/', [ParentProfileAdminController::class, 'index']);
|
||||
Route::get('search', [ParentProfileAdminController::class, 'search']);
|
||||
Route::get('{parent}', [ParentProfileAdminController::class, 'show'])->whereNumber('parent');
|
||||
Route::patch('{parent}', [ParentProfileAdminController::class, 'update'])
|
||||
->whereNumber('parent')
|
||||
->middleware('school_year.editable');
|
||||
});
|
||||
|
||||
Route::prefix('families')->group(function () {
|
||||
Route::get('by-student/{studentId}', [FamilyController::class, 'familiesByStudent']);
|
||||
Route::get('{familyId}/guardians', [FamilyController::class, 'guardiansByFamily']);
|
||||
|
||||
@@ -45,7 +45,7 @@ class ClassProgressControllerTest extends TestCase
|
||||
$this->assertCount(2, $response->json('data.items'));
|
||||
}
|
||||
|
||||
public function test_admin_grouped_index_accepts_school_year_id_and_includes_legacy_rows(): void
|
||||
public function test_admin_grouped_index_uses_school_year_name_and_includes_legacy_rows(): void
|
||||
{
|
||||
$admin = $this->createUser('admin@example.com');
|
||||
$this->assignRole($admin->id, 'administrator');
|
||||
@@ -54,8 +54,9 @@ class ClassProgressControllerTest extends TestCase
|
||||
'semester' => '',
|
||||
'school_year' => null,
|
||||
]);
|
||||
$schoolYearId = DB::table('school_years')->insertGetId([
|
||||
DB::table('school_years')->updateOrInsert([
|
||||
'name' => '2025-2026',
|
||||
], [
|
||||
'start_date' => '2025-08-01',
|
||||
'end_date' => '2026-07-31',
|
||||
'status' => 'active',
|
||||
@@ -74,21 +75,30 @@ class ClassProgressControllerTest extends TestCase
|
||||
|
||||
Sanctum::actingAs($admin);
|
||||
|
||||
$response = $this->getJson('/api/v1/class-progress?school_year_id='.$schoolYearId.'&group_by_week=1');
|
||||
$response = $this->getJson('/api/v1/class-progress?school_year=2025-2026&group_by_week=1');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('status', true);
|
||||
$this->assertCount(1, $response->json('data.items'));
|
||||
|
||||
$administratorAlias = $this->getJson('/api/v1/administrator/progress?school_year_id='.$schoolYearId.'&group_by_week=1');
|
||||
$administratorAlias = $this->getJson('/api/v1/administrator/progress?school_year=2025-2026&group_by_week=1');
|
||||
$administratorAlias->assertOk();
|
||||
$this->assertCount(1, $administratorAlias->json('data.items'));
|
||||
|
||||
$adminAlias = $this->getJson('/api/v1/admin/progress?school_year_id='.$schoolYearId.'&group_by_week=1');
|
||||
$adminAlias = $this->getJson('/api/v1/admin/progress?school_year=2025-2026&group_by_week=1');
|
||||
$adminAlias->assertOk();
|
||||
$this->assertCount(1, $adminAlias->json('data.items'));
|
||||
}
|
||||
|
||||
public function test_index_rejects_school_year_id_context(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->getJson('/api/v1/class-progress?school_year_id=1')
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_parent_progress_returns_reports_for_enrolled_sections(): void
|
||||
{
|
||||
$parent = $this->createUser('parent@example.com');
|
||||
@@ -196,6 +206,7 @@ class ClassProgressControllerTest extends TestCase
|
||||
'unit_islamic' => ['Unit 1'],
|
||||
'chapter_islamic' => ['Chapter A'],
|
||||
'flags' => ['needs_support'],
|
||||
'school_year' => '2025-2026',
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/class-progress', $payload);
|
||||
@@ -246,6 +257,7 @@ class ClassProgressControllerTest extends TestCase
|
||||
|
||||
$response = $this->patchJson('/api/v1/class-progress/'.$report->id, [
|
||||
'status' => 'behind',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
@@ -266,7 +278,7 @@ class ClassProgressControllerTest extends TestCase
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->deleteJson('/api/v1/class-progress/'.$report->id);
|
||||
$response = $this->deleteJson('/api/v1/class-progress/'.$report->id.'?school_year=2025-2026');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseMissing('class_progress_reports', [
|
||||
@@ -328,6 +340,17 @@ class ClassProgressControllerTest extends TestCase
|
||||
|
||||
private function seedClassSection(): void
|
||||
{
|
||||
DB::table('school_years')->updateOrInsert([
|
||||
'name' => '2025-2026',
|
||||
], [
|
||||
'start_date' => '2025-08-01',
|
||||
'end_date' => '2026-07-31',
|
||||
'status' => 'active',
|
||||
'is_current' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('classSection')->insert([
|
||||
'id' => 1,
|
||||
'class_id' => 1,
|
||||
|
||||
@@ -118,6 +118,15 @@ class FamilyAdminControllerTest extends TestCase
|
||||
|
||||
private function seedUser(int $id): User
|
||||
{
|
||||
$roleId = DB::table('roles')->insertGetId([
|
||||
'name' => 'admin',
|
||||
'slug' => 'admin',
|
||||
'priority' => 1,
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('users')->insert([
|
||||
'id' => $id,
|
||||
'firstname' => 'Parent',
|
||||
@@ -134,6 +143,13 @@ class FamilyAdminControllerTest extends TestCase
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'status' => 'Active',
|
||||
'is_verified' => 1,
|
||||
'is_suspended' => 0,
|
||||
]);
|
||||
|
||||
DB::table('user_roles')->insert([
|
||||
'user_id' => $id,
|
||||
'role_id' => $roleId,
|
||||
]);
|
||||
|
||||
return User::query()->findOrFail($id);
|
||||
@@ -219,6 +235,7 @@ class FamilyAdminControllerTest extends TestCase
|
||||
'total_amount' => 100,
|
||||
'paid_amount' => 0,
|
||||
'balance' => 100,
|
||||
'school_year' => '2025-2026',
|
||||
'issue_date' => now()->toDateString(),
|
||||
'due_date' => now()->addDays(10)->toDateString(),
|
||||
'created_at' => now(),
|
||||
@@ -238,6 +255,7 @@ class FamilyAdminControllerTest extends TestCase
|
||||
'payment_method' => 'cash',
|
||||
'payment_date' => now()->toDateString(),
|
||||
'status' => 'completed',
|
||||
'school_year' => '2025-2026',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\V1\ParentProfiles;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ParentProfileAdminControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_parent_profiles_index_requires_admin(): void
|
||||
{
|
||||
Sanctum::actingAs($this->createUser('teacher'));
|
||||
|
||||
$this->getJson('/api/v1/parent-profiles?school_year=2025-2026')
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_parent_profiles_rejects_school_year_id_context(): void
|
||||
{
|
||||
Sanctum::actingAs($this->createUser('admin'));
|
||||
|
||||
$this->getJson('/api/v1/parent-profiles?school_year_id=1')
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_parent_profiles_index_lists_only_selected_year_relevant_parents(): void
|
||||
{
|
||||
Sanctum::actingAs($this->createUser('admin'));
|
||||
$selectedParent = $this->createUser('parent', 'selected@example.com');
|
||||
$otherParent = $this->createUser('parent', 'other@example.com');
|
||||
$this->createStudentFamily($selectedParent->id, 'Amina', 'Selected', '2025-2026');
|
||||
$this->createStudentFamily($otherParent->id, 'Omar', 'Other', '2024-2025');
|
||||
|
||||
$response = $this->getJson('/api/v1/parent-profiles?school_year=2025-2026');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertSame(['selected@example.com'], array_column($response->json('data.parents'), 'email'));
|
||||
}
|
||||
|
||||
public function test_parent_profile_show_returns_selected_year_siblings_and_finance_only(): void
|
||||
{
|
||||
Sanctum::actingAs($this->createUser('admin'));
|
||||
$parent = $this->createUser('parent', 'parent@example.com');
|
||||
$this->createStudentFamily($parent->id, 'Amina', 'Sibling', '2025-2026');
|
||||
$this->createStudentFamily($parent->id, 'Yusuf', 'Sibling', '2025-2026');
|
||||
$this->createStudentFamily($parent->id, 'Old', 'Student', '2024-2025');
|
||||
|
||||
$selectedInvoice = DB::table('invoices')->insertGetId([
|
||||
'parent_id' => $parent->id,
|
||||
'invoice_number' => 'INV-2025',
|
||||
'status' => 'open',
|
||||
'total_amount' => 100,
|
||||
'paid_amount' => 25,
|
||||
'balance' => 75,
|
||||
'school_year' => '2025-2026',
|
||||
'issue_date' => now()->toDateString(),
|
||||
'due_date' => now()->addDays(5)->toDateString(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('invoices')->insert([
|
||||
'parent_id' => $parent->id,
|
||||
'invoice_number' => 'INV-2024',
|
||||
'status' => 'open',
|
||||
'total_amount' => 80,
|
||||
'paid_amount' => 0,
|
||||
'balance' => 80,
|
||||
'school_year' => '2024-2025',
|
||||
'issue_date' => now()->subYear()->toDateString(),
|
||||
'due_date' => now()->subYear()->addDays(5)->toDateString(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('payments')->insert([
|
||||
'parent_id' => $parent->id,
|
||||
'invoice_id' => $selectedInvoice,
|
||||
'total_amount' => 100,
|
||||
'paid_amount' => 25,
|
||||
'balance' => 75,
|
||||
'number_of_installments' => 1,
|
||||
'payment_method' => 'cash',
|
||||
'payment_date' => now()->toDateString(),
|
||||
'status' => 'completed',
|
||||
'school_year' => '2024-2025',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('emergency_contacts')->insert([
|
||||
'parent_id' => $parent->id,
|
||||
'emergency_contact_name' => 'Emergency One',
|
||||
'relation' => 'uncle',
|
||||
'cellphone' => '5555555555',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/v1/parent-profiles/'.$parent->id.'?school_year=2025-2026');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertSame(['Amina', 'Yusuf'], array_column($response->json('data.students'), 'firstname'));
|
||||
$this->assertSame(['INV-2025'], array_column($response->json('data.invoices'), 'invoice_number'));
|
||||
$this->assertEquals(75.0, $response->json('data.finance_summary.positive_unpaid_balance'));
|
||||
$this->assertCount(1, $response->json('data.payments'));
|
||||
$this->assertCount(1, $response->json('data.emergency_contacts'));
|
||||
}
|
||||
|
||||
private function createUser(string $roleName, ?string $email = null): User
|
||||
{
|
||||
DB::table('school_years')->updateOrInsert(
|
||||
['name' => '2025-2026'],
|
||||
[
|
||||
'start_date' => '2025-09-01',
|
||||
'end_date' => '2026-06-30',
|
||||
'status' => 'active',
|
||||
'is_current' => 1,
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
$roleId = DB::table('roles')->insertGetId([
|
||||
'name' => $roleName,
|
||||
'priority' => 1,
|
||||
'is_active' => 1,
|
||||
]);
|
||||
|
||||
$user = User::query()->create([
|
||||
'firstname' => ucfirst($roleName),
|
||||
'lastname' => 'User',
|
||||
'email' => $email ?? ($roleName.uniqid().'@example.com'),
|
||||
'cellphone' => '5555555555',
|
||||
'address_street' => '123 Main',
|
||||
'city' => 'City',
|
||||
'state' => 'ST',
|
||||
'zip' => '12345',
|
||||
'accept_school_policy' => 1,
|
||||
'status' => 'Active',
|
||||
'is_verified' => 1,
|
||||
'is_suspended' => 0,
|
||||
'password' => bcrypt('secret'),
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
|
||||
DB::table('user_roles')->insert([
|
||||
'user_id' => $user->id,
|
||||
'role_id' => $roleId,
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function createStudentFamily(int $parentId, string $firstname, string $lastname, string $schoolYear): void
|
||||
{
|
||||
$familyId = DB::table('families')->insertGetId([
|
||||
'family_code' => uniqid('fam_', true),
|
||||
'household_name' => $lastname.' Household',
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$studentId = DB::table('students')->insertGetId([
|
||||
'firstname' => $firstname,
|
||||
'lastname' => $lastname,
|
||||
'is_active' => 1,
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('family_guardians')->insert([
|
||||
'family_id' => $familyId,
|
||||
'user_id' => $parentId,
|
||||
'relation' => 'parent',
|
||||
'is_primary' => 1,
|
||||
'receive_emails' => 1,
|
||||
'receive_sms' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('family_students')->insert([
|
||||
'family_id' => $familyId,
|
||||
'student_id' => $studentId,
|
||||
'is_primary_home' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,33 @@ class SchoolYearControllerTest extends TestCase
|
||||
$this->assertSame('promote', $response->json('data.promotion_preview.rows.0.promotion_action'));
|
||||
}
|
||||
|
||||
public function test_name_based_school_year_management_routes_use_school_year_query(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
|
||||
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||
|
||||
$this->getJson('/api/v1/school-years/summary?school_year=2025-2026')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.school_year.name', '2025-2026');
|
||||
|
||||
$this->getJson('/api/v1/school-years/promotion-preview?school_year=2025-2026&to_school_year=2026-2027')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.current_school_year', '2025-2026')
|
||||
->assertJsonPath('data.target_school_year', '2026-2027');
|
||||
|
||||
$this->postJson('/api/v1/school-years/preview-close?school_year=2025-2026', [
|
||||
'new_school_year' => [
|
||||
'name' => '2026-2027',
|
||||
'start_date' => '2026-09-01',
|
||||
'end_date' => '2027-06-30',
|
||||
],
|
||||
'transfer_unpaid_balances' => true,
|
||||
])->assertOk()
|
||||
->assertJsonPath('data.school_year.name', '2025-2026')
|
||||
->assertJsonPath('data.parent_balances.summary.total_old_unpaid_balance', 200);
|
||||
}
|
||||
|
||||
public function test_preview_close_excludes_zero_invoice_balance_parent_even_when_parent_account_is_stale(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
@@ -73,6 +100,146 @@ class SchoolYearControllerTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function test_positive_balance_is_not_hidden_by_credit_balance(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
|
||||
DB::table('invoices')->insert([
|
||||
'id' => 502,
|
||||
'parent_id' => 10,
|
||||
'invoice_number' => 'INV-502-CREDIT',
|
||||
'total_amount' => 0,
|
||||
'balance' => -200,
|
||||
'paid_amount' => 200,
|
||||
'has_discount' => 0,
|
||||
'issue_date' => '2026-05-11',
|
||||
'due_date' => '2026-06-10',
|
||||
'status' => 'credit',
|
||||
'description' => 'Credit balance',
|
||||
'school_year' => '2025-2026',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'updated_by' => 1,
|
||||
'semester' => 'Fall',
|
||||
]);
|
||||
|
||||
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||
|
||||
$response = $this->postJson('/api/v1/school-years/preview-close?school_year=2025-2026', [
|
||||
'new_school_year' => [
|
||||
'name' => '2026-2027',
|
||||
'start_date' => '2026-09-01',
|
||||
'end_date' => '2027-06-30',
|
||||
],
|
||||
'transfer_unpaid_balances' => true,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.parent_balances.summary.parents_with_positive_balance', 1)
|
||||
->assertJsonPath('data.parent_balances.summary.total_positive_unpaid_balance', 200)
|
||||
->assertJsonPath('data.parent_balances.summary.parents_with_credit_balance', 1)
|
||||
->assertJsonPath('data.parent_balances.summary.total_credit_balance', 200)
|
||||
->assertJsonPath('data.parent_balances.summary.net_balance_impact', 0);
|
||||
|
||||
$row = collect($response->json('data.parent_balances.rows'))
|
||||
->first(fn (array $row): bool => (int) $row['parent_id'] === 10);
|
||||
|
||||
$this->assertSame(200.0, (float) $row['positive_unpaid_balance']);
|
||||
$this->assertSame(200.0, (float) $row['credit_balance']);
|
||||
$this->assertSame(0.0, (float) $row['net_balance']);
|
||||
$this->assertSame(200.0, (float) $row['amount_to_transfer']);
|
||||
$this->assertSame([500], $row['positive_invoice_ids']);
|
||||
$this->assertSame([502], $row['credit_invoice_ids']);
|
||||
}
|
||||
|
||||
public function test_credit_only_parent_is_reported_as_credit_not_unpaid_carryover(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
$this->seedParent(12, 'Credit', 'Only', 'credit@example.com');
|
||||
|
||||
DB::table('invoices')->insert([
|
||||
'id' => 503,
|
||||
'parent_id' => 12,
|
||||
'invoice_number' => 'INV-503-CREDIT',
|
||||
'total_amount' => 0,
|
||||
'balance' => -50,
|
||||
'paid_amount' => 50,
|
||||
'has_discount' => 0,
|
||||
'issue_date' => '2026-05-11',
|
||||
'due_date' => '2026-06-10',
|
||||
'status' => 'credit',
|
||||
'description' => 'Credit only',
|
||||
'school_year' => '2025-2026',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'updated_by' => 1,
|
||||
'semester' => 'Fall',
|
||||
]);
|
||||
|
||||
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||
|
||||
$response = $this->getJson('/api/v1/school-years/parent-balances?school_year=2025-2026&to_school_year=2026-2027');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.summary.parents_with_positive_balance', 1)
|
||||
->assertJsonPath('data.summary.parents_with_credit_balance', 1)
|
||||
->assertJsonPath('data.summary.total_positive_unpaid_balance', 200)
|
||||
->assertJsonPath('data.summary.total_credit_balance', 50);
|
||||
|
||||
$creditRow = collect($response->json('data.rows'))
|
||||
->first(fn (array $row): bool => (int) $row['parent_id'] === 12);
|
||||
|
||||
$this->assertNotNull($creditRow);
|
||||
$this->assertSame(0.0, (float) $creditRow['positive_unpaid_balance']);
|
||||
$this->assertSame(50.0, (float) $creditRow['credit_balance']);
|
||||
$this->assertSame(0.0, (float) $creditRow['amount_to_transfer']);
|
||||
$this->assertSame([503], $creditRow['credit_invoice_ids']);
|
||||
}
|
||||
|
||||
public function test_pending_or_draft_invoice_blocks_close_but_is_not_transferred(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
|
||||
DB::table('invoices')->insert([
|
||||
'id' => 504,
|
||||
'parent_id' => 10,
|
||||
'invoice_number' => 'INV-504-DRAFT',
|
||||
'total_amount' => 100,
|
||||
'balance' => 100,
|
||||
'paid_amount' => 0,
|
||||
'has_discount' => 0,
|
||||
'issue_date' => '2026-05-11',
|
||||
'due_date' => '2026-06-10',
|
||||
'status' => 'draft',
|
||||
'description' => 'Draft invoice',
|
||||
'school_year' => '2025-2026',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'updated_by' => 1,
|
||||
'semester' => 'Fall',
|
||||
]);
|
||||
|
||||
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||
|
||||
$response = $this->postJson('/api/v1/school-years/preview-close?school_year=2025-2026', [
|
||||
'new_school_year' => [
|
||||
'name' => '2026-2027',
|
||||
'start_date' => '2026-09-01',
|
||||
'end_date' => '2027-06-30',
|
||||
],
|
||||
'transfer_unpaid_balances' => true,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.validation.can_close', false)
|
||||
->assertJsonPath('data.parent_balances.summary.total_positive_unpaid_balance', 200);
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'invoices are still in draft status',
|
||||
implode(' ', $response->json('data.validation.errors'))
|
||||
);
|
||||
}
|
||||
|
||||
public function test_close_school_year_does_not_transfer_zero_invoice_balance_parent_even_when_parent_account_is_stale(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
@@ -111,6 +278,79 @@ class SchoolYearControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_close_does_not_mutate_old_year_parent_account(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
|
||||
DB::table('parent_accounts')->insert([
|
||||
'parent_id' => 10,
|
||||
'school_year' => '2025-2026',
|
||||
'opening_balance' => 55,
|
||||
'current_balance' => 77,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||
|
||||
$this->postJson('/api/v1/school-years/close?school_year=2025-2026', [
|
||||
'new_school_year' => [
|
||||
'name' => '2026-2027',
|
||||
'start_date' => '2026-09-01',
|
||||
'end_date' => '2027-06-30',
|
||||
],
|
||||
'transfer_unpaid_balances' => true,
|
||||
'confirmation' => 'CLOSE 2025-2026',
|
||||
])->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('parent_accounts', [
|
||||
'parent_id' => 10,
|
||||
'school_year' => '2025-2026',
|
||||
'opening_balance' => 55,
|
||||
'current_balance' => 77,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('parent_accounts', [
|
||||
'parent_id' => 10,
|
||||
'school_year' => '2026-2027',
|
||||
'opening_balance' => 200,
|
||||
'current_balance' => 200,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_existing_balance_transfer_conflict_is_visible_in_preview_and_blocks_close(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
|
||||
DB::table('parent_balance_transfers')->insert([
|
||||
'parent_id' => 10,
|
||||
'from_school_year' => '2025-2026',
|
||||
'to_school_year' => '2026-2027',
|
||||
'amount' => 200,
|
||||
'status' => 'transferred',
|
||||
'source_summary_json' => json_encode(['source_invoice_ids' => [500]]),
|
||||
'created_by' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||
|
||||
$response = $this->postJson('/api/v1/school-years/preview-close?school_year=2025-2026', [
|
||||
'new_school_year' => [
|
||||
'name' => '2026-2027',
|
||||
'start_date' => '2026-09-01',
|
||||
'end_date' => '2027-06-30',
|
||||
],
|
||||
'transfer_unpaid_balances' => true,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.validation.can_close', false)
|
||||
->assertJsonPath('data.parent_balances.summary.existing_transfer_conflicts', 1)
|
||||
->assertJsonPath('data.parent_balances.rows.0.existing_transfer_conflict', true);
|
||||
}
|
||||
|
||||
public function test_parent_cannot_close_school_year(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
@@ -231,6 +471,41 @@ class SchoolYearControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_admin_can_update_school_year_by_name_via_api(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
DB::table('school_years')->insert([
|
||||
'id' => 2,
|
||||
'name' => '2027-2028',
|
||||
'start_date' => '2027-09-01',
|
||||
'end_date' => '2028-06-30',
|
||||
'status' => 'draft',
|
||||
'is_current' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||
|
||||
$response = $this->patchJson('/api/v1/school-years/selected?school_year=2027-2028', [
|
||||
'name' => '2028-2029',
|
||||
'start_date' => '2028-09-01',
|
||||
'end_date' => '2029-06-30',
|
||||
'school_year' => '2027-2028',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.name', '2028-2029')
|
||||
->assertJsonPath('data.start_date', '2028-09-01');
|
||||
|
||||
$this->assertDatabaseHas('school_years', [
|
||||
'id' => 2,
|
||||
'name' => '2028-2029',
|
||||
'start_date' => '2028-09-01 00:00:00',
|
||||
'end_date' => '2029-06-30 00:00:00',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_close_school_year_creates_new_active_year_enrollment_and_balance_transfer(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
@@ -379,6 +654,35 @@ class SchoolYearControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
private function seedParent(int $id, string $firstname, string $lastname, string $email): void
|
||||
{
|
||||
DB::table('users')->insert([
|
||||
'id' => $id,
|
||||
'school_id' => $id,
|
||||
'firstname' => $firstname,
|
||||
'lastname' => $lastname,
|
||||
'cellphone' => '5555555555',
|
||||
'email' => $email,
|
||||
'address_street' => '123 Main',
|
||||
'city' => 'City',
|
||||
'state' => 'ST',
|
||||
'zip' => '12345',
|
||||
'accept_school_policy' => 1,
|
||||
'is_verified' => 1,
|
||||
'status' => 'Active',
|
||||
'is_suspended' => 0,
|
||||
'failed_attempts' => 0,
|
||||
'password' => bcrypt('secret'),
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
|
||||
DB::table('user_roles')->insert([
|
||||
'user_id' => $id,
|
||||
'role_id' => 2,
|
||||
]);
|
||||
}
|
||||
|
||||
private function seedClosureData(): void
|
||||
{
|
||||
DB::table('configuration')->insert([
|
||||
|
||||
@@ -99,7 +99,7 @@ class StaffControllerTest extends TestCase
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
|
||||
$response = $this->patchJson('/api/v1/staff/'.$staff->id, [
|
||||
$response = $this->patchJson('/api/v1/staff/'.$staff->id.'?school_year=2025-2026', [
|
||||
'firstname' => 'Updated',
|
||||
]);
|
||||
|
||||
@@ -127,7 +127,7 @@ class StaffControllerTest extends TestCase
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
|
||||
$response = $this->deleteJson('/api/v1/staff/'.$staff->id);
|
||||
$response = $this->deleteJson('/api/v1/staff/'.$staff->id.'?school_year=2025-2026');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseMissing('staff', [
|
||||
@@ -137,6 +137,18 @@ class StaffControllerTest extends TestCase
|
||||
|
||||
private function createUser(string $roleName, ?string $email = null): User
|
||||
{
|
||||
DB::table('school_years')->updateOrInsert(
|
||||
['name' => '2025-2026'],
|
||||
[
|
||||
'start_date' => '2025-09-01',
|
||||
'end_date' => '2026-06-30',
|
||||
'status' => 'active',
|
||||
'is_current' => 1,
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
$roleId = DB::table('roles')->insertGetId([
|
||||
'name' => $roleName,
|
||||
'priority' => 1,
|
||||
@@ -154,6 +166,8 @@ class StaffControllerTest extends TestCase
|
||||
'zip' => '12345',
|
||||
'accept_school_policy' => 1,
|
||||
'status' => 'Active',
|
||||
'is_verified' => 1,
|
||||
'is_suspended' => 0,
|
||||
'password' => bcrypt('secret'),
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
|
||||
@@ -20,6 +20,7 @@ class FamilyFinanceServiceTest extends TestCase
|
||||
'total_amount' => 100,
|
||||
'paid_amount' => 20,
|
||||
'balance' => 80,
|
||||
'school_year' => '2025-2026',
|
||||
'issue_date' => now()->toDateString(),
|
||||
'due_date' => now()->addDays(5)->toDateString(),
|
||||
'created_at' => now(),
|
||||
@@ -36,16 +37,90 @@ class FamilyFinanceServiceTest extends TestCase
|
||||
'payment_method' => 'cash',
|
||||
'payment_date' => now()->toDateString(),
|
||||
'status' => 'completed',
|
||||
'school_year' => '2024-2025',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$service = new FamilyFinanceService;
|
||||
$result = $service->loadFinancialsForParents([1]);
|
||||
$result = $service->loadFinancialsForParents([1], '2025-2026');
|
||||
|
||||
$this->assertSame(1, $result['summary']['invoices_count']);
|
||||
$this->assertSame(100.0, $result['summary']['total_amount']);
|
||||
$this->assertSame(80.0, $result['summary']['positive_unpaid_balance']);
|
||||
$this->assertSame(0.0, $result['summary']['credit_balance']);
|
||||
$this->assertNotEmpty($result['invoices']);
|
||||
$this->assertNotEmpty($result['payments']);
|
||||
}
|
||||
|
||||
public function test_load_financials_filters_invoices_and_payments_by_selected_year(): void
|
||||
{
|
||||
DB::table('invoices')->insert([
|
||||
[
|
||||
'id' => 10,
|
||||
'parent_id' => 1,
|
||||
'invoice_number' => 'INV-2025',
|
||||
'status' => 'open',
|
||||
'total_amount' => 100,
|
||||
'paid_amount' => 0,
|
||||
'balance' => 100,
|
||||
'school_year' => '2025-2026',
|
||||
'issue_date' => now()->toDateString(),
|
||||
'due_date' => now()->addDays(5)->toDateString(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'id' => 11,
|
||||
'parent_id' => 1,
|
||||
'invoice_number' => 'INV-2024',
|
||||
'status' => 'open',
|
||||
'total_amount' => 75,
|
||||
'paid_amount' => 0,
|
||||
'balance' => -25,
|
||||
'school_year' => '2024-2025',
|
||||
'issue_date' => now()->subYear()->toDateString(),
|
||||
'due_date' => now()->subYear()->addDays(5)->toDateString(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
DB::table('payments')->insert([
|
||||
[
|
||||
'parent_id' => 1,
|
||||
'invoice_id' => 10,
|
||||
'total_amount' => 100,
|
||||
'paid_amount' => 50,
|
||||
'balance' => 50,
|
||||
'number_of_installments' => 1,
|
||||
'payment_method' => 'cash',
|
||||
'payment_date' => now()->toDateString(),
|
||||
'status' => 'completed',
|
||||
'school_year' => '2025-2026',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'parent_id' => 1,
|
||||
'invoice_id' => 11,
|
||||
'total_amount' => 75,
|
||||
'paid_amount' => 75,
|
||||
'balance' => -25,
|
||||
'number_of_installments' => 1,
|
||||
'payment_method' => 'cash',
|
||||
'payment_date' => now()->subYear()->toDateString(),
|
||||
'status' => 'completed',
|
||||
'school_year' => '2025-2026',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$service = new FamilyFinanceService;
|
||||
$result = $service->loadFinancialsForParents([1], '2025-2026');
|
||||
|
||||
$this->assertSame(['INV-2025'], array_column($result['invoices'], 'invoice_number'));
|
||||
$this->assertSame([10], array_column($result['payments'], 'invoice_id'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\User;
|
||||
use App\Services\Staff\StaffCommandService;
|
||||
use App\Services\System\GlobalConfigService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
class StaffCommandServiceTest extends TestCase
|
||||
@@ -15,6 +16,7 @@ class StaffCommandServiceTest extends TestCase
|
||||
|
||||
public function test_create_requires_user(): void
|
||||
{
|
||||
$this->seedRole('teacher');
|
||||
$service = new StaffCommandService(new GlobalConfigService);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
@@ -28,6 +30,7 @@ class StaffCommandServiceTest extends TestCase
|
||||
|
||||
public function test_create_with_user_id(): void
|
||||
{
|
||||
$this->seedRole('teacher');
|
||||
$user = User::query()->create([
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'User',
|
||||
@@ -57,4 +60,18 @@ class StaffCommandServiceTest extends TestCase
|
||||
$this->assertInstanceOf(Staff::class, $staff);
|
||||
$this->assertSame($user->id, $staff->user_id);
|
||||
}
|
||||
|
||||
private function seedRole(string $name): void
|
||||
{
|
||||
DB::table('roles')->updateOrInsert(
|
||||
['name' => $name],
|
||||
[
|
||||
'slug' => $name,
|
||||
'priority' => 1,
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user