fix parent pages and teachers and admin
API CI/CD / Validate (composer + pint) (push) Successful in 3m8s
API CI/CD / Build frontend assets (push) Successful in 1m2s
API CI/CD / Test (PHPUnit) (push) Failing after 6m5s
API CI/CD / Security audit (push) Failing after 52s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped
API CI/CD / Validate (composer + pint) (push) Successful in 3m8s
API CI/CD / Build frontend assets (push) Successful in 1m2s
API CI/CD / Test (PHPUnit) (push) Failing after 6m5s
API CI/CD / Security audit (push) Failing after 52s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped
This commit is contained in:
@@ -97,13 +97,22 @@ class ClassProgressController extends BaseApiController
|
||||
return $auth;
|
||||
}
|
||||
|
||||
if ($this->isLegacyTeacherProgressListRoute($request) && ! $this->hasAnyRole($auth, ['teacher', 'administrator', 'admin'])) {
|
||||
if ($this->isLegacyTeacherProgressListRoute($request) && ! $this->hasAnyRole($auth, ['teacher', 'teacher_assistant', 'ta', 'administrator', 'admin'])) {
|
||||
return $this->respondError('Forbidden.', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$filters = $request->validated();
|
||||
$meta = $this->queryService->meta($filters['school_year'] ?? null, $filters['semester'] ?? null);
|
||||
$filters['school_year'] = $filters['school_year'] ?? ($meta['school_year'] ?? null);
|
||||
if ($this->isLegacyTeacherProgressListRoute($request) && empty($filters['class_section_id'])) {
|
||||
$resolvedClassSectionId = $this->resolveTeacherHistoryClassSectionId($auth, $filters);
|
||||
if ($resolvedClassSectionId instanceof JsonResponse) {
|
||||
return $resolvedClassSectionId;
|
||||
}
|
||||
if ($resolvedClassSectionId > 0) {
|
||||
$filters['class_section_id'] = $resolvedClassSectionId;
|
||||
}
|
||||
}
|
||||
if ($this->isAdminProgressAliasRoute($request)) {
|
||||
$filters['group_by_week'] = true;
|
||||
}
|
||||
@@ -136,7 +145,12 @@ class ClassProgressController extends BaseApiController
|
||||
|
||||
try {
|
||||
$filesBySubject = $request->filesBySubject();
|
||||
$reports = $this->mutationService->createReports($auth, $request->validated(), $filesBySubject);
|
||||
$payload = $this->resolveTeacherStorePayload($auth, $request->validated());
|
||||
if ($payload instanceof JsonResponse) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$reports = $this->mutationService->createReports($auth, $payload, $filesBySubject);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
if ($e->getMessage() === 'CONFIRM_OVERWRITE') {
|
||||
return response()->json([
|
||||
@@ -278,6 +292,100 @@ class ClassProgressController extends BaseApiController
|
||||
|| $request->is('api/v1/admin/progress');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>|JsonResponse
|
||||
*/
|
||||
private function resolveTeacherStorePayload(User $user, array $payload): array|JsonResponse
|
||||
{
|
||||
if ($this->hasAnyRole($user, ['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'])) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
if (! $this->hasAnyRole($user, ['teacher', 'teacher_assistant', 'ta'])) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$assignments = $this->queryService->teacherAssignments(
|
||||
$user,
|
||||
$payload['school_year'] ?? null,
|
||||
$payload['semester'] ?? null
|
||||
);
|
||||
|
||||
if ($assignments === []) {
|
||||
return $this->respondError('No class section assigned to this teacher.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$postedSectionId = (int) ($payload['class_section_id'] ?? 0);
|
||||
$assignment = null;
|
||||
if ($postedSectionId > 0) {
|
||||
$assignment = collect($assignments)->first(function (array $assignment) use ($postedSectionId) {
|
||||
return (int) ($assignment['class_section_id'] ?? 0) === $postedSectionId
|
||||
|| (int) ($assignment['class_section_pk'] ?? 0) === $postedSectionId;
|
||||
});
|
||||
}
|
||||
|
||||
if (! $assignment) {
|
||||
$sessionClassSectionId = (int) session('class_section_id', 0);
|
||||
if ($sessionClassSectionId > 0) {
|
||||
$assignment = collect($assignments)->first(function (array $assignment) use ($sessionClassSectionId) {
|
||||
return (int) ($assignment['class_section_id'] ?? 0) === $sessionClassSectionId
|
||||
|| (int) ($assignment['class_section_pk'] ?? 0) === $sessionClassSectionId;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$assignment ??= $assignments[0];
|
||||
$payload['class_section_id'] = (int) ($assignment['class_section_id'] ?? 0);
|
||||
|
||||
if (empty($payload['school_year']) && ! empty($assignment['school_year'])) {
|
||||
$payload['school_year'] = $assignment['school_year'];
|
||||
}
|
||||
if (empty($payload['semester']) && ! empty($assignment['semester'])) {
|
||||
$payload['semester'] = $assignment['semester'];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
private function resolveTeacherHistoryClassSectionId(User $user, array $filters): int|JsonResponse
|
||||
{
|
||||
if ($this->hasAnyRole($user, ['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$assignments = $this->queryService->teacherAssignments(
|
||||
$user,
|
||||
$filters['school_year'] ?? null,
|
||||
$filters['semester'] ?? null
|
||||
);
|
||||
$assignedSectionIds = collect($assignments)
|
||||
->pluck('class_section_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->filter(fn ($id) => $id > 0)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($assignedSectionIds === []) {
|
||||
return $this->respondError('No class section assigned to this teacher.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
if (count($assignedSectionIds) === 1) {
|
||||
return (int) $assignedSectionIds[0];
|
||||
}
|
||||
|
||||
$sessionClassSectionId = (int) session('class_section_id', 0);
|
||||
if ($sessionClassSectionId > 0 && in_array($sessionClassSectionId, $assignedSectionIds, true)) {
|
||||
return $sessionClassSectionId;
|
||||
}
|
||||
|
||||
return (int) $assignedSectionIds[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
|
||||
@@ -80,6 +80,21 @@ class ParentController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function statement(ParentInvoiceRequest $request): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$schoolYear = $request->validated()['school_year'] ?? null;
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->invoiceService->statement($guard, $schoolYear),
|
||||
]);
|
||||
}
|
||||
|
||||
public function enrollments(ParentEnrollmentRequest $request): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
|
||||
@@ -9,6 +9,8 @@ use App\Services\Files\ExamDraftDownloadNameService;
|
||||
use App\Services\Files\FileServeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class FilesController extends BaseApiController
|
||||
{
|
||||
@@ -71,6 +73,7 @@ class FilesController extends BaseApiController
|
||||
|
||||
public function examDraftFinal(FileNameRequest $request, string $name): Response|JsonResponse
|
||||
{
|
||||
$name = $this->resolveFinalExamFileName($name);
|
||||
$downloadName = $this->draftNameService->build($name, 'finals');
|
||||
|
||||
return $this->serveFile(
|
||||
@@ -102,4 +105,37 @@ class FilesController extends BaseApiController
|
||||
|
||||
return $this->fileService->serveInline($baseDir, $name, $allowedExtensions, $request, $downloadName, $nosniff);
|
||||
}
|
||||
|
||||
private function resolveFinalExamFileName(string $name): string
|
||||
{
|
||||
if (! ctype_digit($name) || ! Schema::hasTable('exam_drafts')) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$query = DB::table('exam_drafts')->where('id', (int) $name);
|
||||
if (Schema::hasColumn('exam_drafts', 'final_pdf_file')) {
|
||||
$query->select('final_file', 'final_pdf_file');
|
||||
} else {
|
||||
$query->select('final_file');
|
||||
}
|
||||
|
||||
$row = $query->first();
|
||||
if (! $row) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$pdfFile = trim((string) ($row->final_pdf_file ?? ''));
|
||||
if ($this->looksLikeExamFileName($pdfFile)) {
|
||||
return $pdfFile;
|
||||
}
|
||||
|
||||
$finalFile = trim((string) ($row->final_file ?? ''));
|
||||
|
||||
return $this->looksLikeExamFileName($finalFile) ? $finalFile : $name;
|
||||
}
|
||||
|
||||
private function looksLikeExamFileName(string $name): bool
|
||||
{
|
||||
return preg_match('/\.(doc|docx|pdf)\z/i', $name) === 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ class TeacherController extends BaseApiController
|
||||
$studentsBySection[(string) $sectionId] = TeacherStudentResource::collection($students);
|
||||
}
|
||||
|
||||
$this->storeActiveClassInSession($data);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'teacher' => $teacher,
|
||||
@@ -91,6 +93,8 @@ class TeacherController extends BaseApiController
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->storeActiveClassInSession($data);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'active_class_section_id' => $requested,
|
||||
@@ -162,4 +166,18 @@ class TeacherController extends BaseApiController
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function storeActiveClassInSession(array $data): void
|
||||
{
|
||||
$classSectionId = (int) ($data['active_class_section_id'] ?? 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
session()->put('class_section_id', $classSectionId);
|
||||
session()->put('class_section_name', $data['class_section_name'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ class ClassProgressStoreRequest extends ClassProgressFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'class_section_id' => ['required', 'integer', 'exists:classSection,class_section_id'],
|
||||
'class_section_id' => ['nullable', 'integer'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'semester' => ['nullable', 'string', 'max:20'],
|
||||
'week_start' => ['required', 'date_format:Y-m-d'],
|
||||
|
||||
@@ -15,6 +15,7 @@ class InvoiceResource extends JsonResource
|
||||
'invoice_number' => $this->resource['invoice_number'] ?? null,
|
||||
'total_amount' => (float) ($this->resource['total_amount'] ?? 0),
|
||||
'paid_amount' => (float) ($this->resource['paid_amount'] ?? 0),
|
||||
'discount' => (float) ($this->resource['discount'] ?? 0),
|
||||
'balance' => (float) ($this->resource['balance'] ?? 0),
|
||||
'status' => $this->resource['status'] ?? null,
|
||||
'school_year' => $this->resource['school_year'] ?? null,
|
||||
@@ -26,6 +27,19 @@ class InvoiceResource extends JsonResource
|
||||
'refund_amount' => (float) ($this->resource['refund_amount'] ?? 0),
|
||||
'last_paid_amount' => (float) ($this->resource['last_paid_amount'] ?? 0),
|
||||
'last_payment_date' => $this->resource['last_payment_date'] ?? null,
|
||||
'payments' => collect($this->resource['payments'] ?? [])
|
||||
->map(static fn ($payment) => [
|
||||
'id' => (int) ($payment['id'] ?? 0),
|
||||
'invoice_id' => (int) ($payment['invoice_id'] ?? 0),
|
||||
'paid_amount' => (float) ($payment['paid_amount'] ?? 0),
|
||||
'balance' => (float) ($payment['balance'] ?? 0),
|
||||
'payment_method' => $payment['payment_method'] ?? null,
|
||||
'payment_date' => $payment['payment_date'] ?? null,
|
||||
'transaction_id' => $payment['transaction_id'] ?? null,
|
||||
'status' => $payment['status'] ?? null,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+47
-22
@@ -8,13 +8,23 @@ class Payment extends BaseModel
|
||||
{
|
||||
protected $table = 'payments';
|
||||
|
||||
/**
|
||||
* legacy: useTimestamps = false because DB handles created_at/updated_at (defaults/triggers).
|
||||
* Keep OFF in Laravel too.
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
protected $fillable = ['parent_id', 'invoice_id', 'total_amount', 'paid_amount', 'balance', 'number_of_installments', 'transaction_id', 'check_file', 'check_number', 'payment_method', 'payment_date', 'semester', 'school_year', 'status', 'updated_by', 'created_at', 'updated_at'];
|
||||
protected $fillable = [
|
||||
'parent_id',
|
||||
'invoice_id',
|
||||
'total_amount',
|
||||
'paid_amount',
|
||||
'balance',
|
||||
'number_of_installments',
|
||||
'transaction_id',
|
||||
'check_file',
|
||||
'check_number',
|
||||
'payment_method',
|
||||
'payment_date',
|
||||
'semester',
|
||||
'school_year',
|
||||
'status',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'parent_id' => 'integer',
|
||||
@@ -26,10 +36,20 @@ class Payment extends BaseModel
|
||||
'paid_amount' => 'decimal:2',
|
||||
'balance' => 'decimal:2',
|
||||
|
||||
'payment_date' => 'datetime',
|
||||
'payment_date' => 'date',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'paid_amount' => '0.00',
|
||||
'balance' => '0.00',
|
||||
'payment_method' => 'Unknown',
|
||||
'status' => 'Pending',
|
||||
];
|
||||
|
||||
/* Optional relationships */
|
||||
|
||||
public function invoice()
|
||||
{
|
||||
return $this->belongsTo(Invoice::class, 'invoice_id');
|
||||
@@ -67,19 +87,18 @@ class Payment extends BaseModel
|
||||
{
|
||||
/** @var self|null $payment */
|
||||
$payment = static::query()->find($paymentId);
|
||||
|
||||
if (! $payment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$newBalance = (float) $payment->balance - (float) $amountPaid;
|
||||
|
||||
// keep precision stable
|
||||
$paidAmount = (float) $payment->paid_amount + (float) $amountPaid;
|
||||
$newPaidAmount = round((float) $payment->paid_amount + $amountPaid, 2);
|
||||
$newBalance = round((float) $payment->balance - $amountPaid, 2);
|
||||
|
||||
$affected = static::query()
|
||||
->whereKey($paymentId)
|
||||
->update([
|
||||
'paid_amount' => $paidAmount,
|
||||
'paid_amount' => $newPaidAmount,
|
||||
'balance' => $newBalance,
|
||||
]);
|
||||
|
||||
@@ -101,10 +120,10 @@ class Payment extends BaseModel
|
||||
public static function generateNewTransactionId(): string
|
||||
{
|
||||
$currentYear = date('Y');
|
||||
$prefix = $currentYear.'-';
|
||||
$prefix = $currentYear . '-';
|
||||
|
||||
$latest = static::query()
|
||||
->where('transaction_id', 'like', $prefix.'%')
|
||||
->where('transaction_id', 'like', $prefix . '%')
|
||||
->orderByDesc('transaction_id')
|
||||
->first();
|
||||
|
||||
@@ -115,30 +134,34 @@ class Payment extends BaseModel
|
||||
$newNumber = 1;
|
||||
}
|
||||
|
||||
return $prefix.str_pad((string) $newNumber, 6, '0', STR_PAD_LEFT);
|
||||
return $prefix . str_pad((string) $newNumber, 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest payment (date/amount/status) per invoice for given invoice IDs.
|
||||
* Matches your legacy logic: statuses in ['Full','Paid','Partially Paid'].
|
||||
* Get latest payment date/amount/status per invoice for given invoice IDs.
|
||||
* Statuses included: Full, Paid, Partially Paid.
|
||||
*/
|
||||
public static function getPaymentsByInvoice($invoiceIds): array
|
||||
{
|
||||
if (! is_array($invoiceIds)) {
|
||||
$invoiceIds = [$invoiceIds];
|
||||
}
|
||||
$invoiceIds = array_values(array_unique(array_map('intval', array_filter($invoiceIds))));
|
||||
|
||||
$invoiceIds = array_values(array_unique(array_map(
|
||||
'intval',
|
||||
array_filter($invoiceIds)
|
||||
)));
|
||||
|
||||
if (empty($invoiceIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Subquery: latest payment_date per invoice (Full/Partial only)
|
||||
$latestSub = DB::table('payments')
|
||||
->selectRaw('invoice_id, MAX(payment_date) AS max_date')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Full', 'Paid', 'Partially Paid'])
|
||||
->groupBy('invoice_id');
|
||||
|
||||
// Main query: join payments to the latest per invoice
|
||||
$rows = DB::table('payments as p')
|
||||
->joinSub($latestSub, 'latest', function ($join) {
|
||||
$join->on('latest.invoice_id', '=', 'p.invoice_id')
|
||||
@@ -149,11 +172,13 @@ class Payment extends BaseModel
|
||||
->select([
|
||||
'p.invoice_id',
|
||||
DB::raw('p.paid_amount AS last_paid_amount'),
|
||||
DB::raw('p.balance AS balance'),
|
||||
DB::raw('p.balance AS balance_after_payment'),
|
||||
DB::raw('p.payment_date AS last_payment_date'),
|
||||
DB::raw('p.status AS last_payment_status'),
|
||||
])
|
||||
->get();
|
||||
|
||||
return $rows->map(fn ($r) => (array) $r)->all();
|
||||
return $rows->map(fn ($row) => (array) $row)->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class TeacherClass extends BaseModel
|
||||
{
|
||||
@@ -162,6 +163,70 @@ class TeacherClass extends BaseModel
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct section identifiers this teacher can access, expanded across legacy
|
||||
* `classSection.id` PKs and `classSection.class_section_id` business codes.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function distinctAccessibleSectionIdsForTeacher(int $userId): array
|
||||
{
|
||||
$sectionIds = static::distinctSectionIdsForTeacher($userId);
|
||||
if ($sectionIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$expanded = [];
|
||||
|
||||
if (Schema::hasTable('classSection')) {
|
||||
$codeRows = DB::table('classSection')
|
||||
->select('id', 'class_section_id')
|
||||
->whereIn('class_section_id', $sectionIds)
|
||||
->get();
|
||||
$matchedRawIds = [];
|
||||
|
||||
foreach ($codeRows as $row) {
|
||||
$matchedRawIds[(int) $row->class_section_id] = true;
|
||||
foreach ([(int) $row->id, (int) $row->class_section_id] as $sectionId) {
|
||||
if ($sectionId > 0) {
|
||||
$expanded[$sectionId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$unmatchedSectionIds = array_values(array_filter(
|
||||
$sectionIds,
|
||||
static fn (int $sectionId): bool => ! isset($matchedRawIds[$sectionId]),
|
||||
));
|
||||
|
||||
$pkRows = $unmatchedSectionIds === []
|
||||
? collect()
|
||||
: DB::table('classSection')
|
||||
->select('id', 'class_section_id')
|
||||
->whereIn('id', $unmatchedSectionIds)
|
||||
->get();
|
||||
|
||||
foreach ($pkRows as $row) {
|
||||
foreach ([(int) $row->id, (int) $row->class_section_id] as $sectionId) {
|
||||
if ($sectionId > 0) {
|
||||
$expanded[$sectionId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($sectionIds as $sectionId) {
|
||||
if (! isset($expanded[(int) $sectionId])) {
|
||||
$expanded[(int) $sectionId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$ids = array_map('intval', array_keys($expanded));
|
||||
sort($ids);
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/** legacy: getClassSectionsByTeacherId() */
|
||||
public static function getClassSectionsByTeacherId(int $teacherId): array
|
||||
{
|
||||
|
||||
@@ -51,7 +51,7 @@ class ClassProgressReportPolicy
|
||||
{
|
||||
return in_array(
|
||||
(int) $report->class_section_id,
|
||||
TeacherClass::distinctSectionIdsForTeacher((int) $user->id),
|
||||
TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -8,6 +8,7 @@ use App\Models\SemesterScore;
|
||||
use App\Models\TeacherSubmissionNotificationHistory;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class TeacherSubmissionReportService
|
||||
{
|
||||
@@ -25,6 +26,11 @@ class TeacherSubmissionReportService
|
||||
} catch (\Throwable) {
|
||||
$teacherClassSupportsSemester = false;
|
||||
}
|
||||
try {
|
||||
$studentClassSupportsSemester = Schema::hasColumn('student_class', 'semester');
|
||||
} catch (\Throwable) {
|
||||
$studentClassSupportsSemester = false;
|
||||
}
|
||||
|
||||
$assignmentQuery = DB::table('teacher_class as tc')
|
||||
->select([
|
||||
@@ -99,10 +105,15 @@ class TeacherSubmissionReportService
|
||||
continue;
|
||||
}
|
||||
|
||||
$studentIds = DB::table('student_class')
|
||||
$studentQuery = DB::table('student_class')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if ($studentClassSupportsSemester && $semester !== '') {
|
||||
$studentQuery->where('semester', $semester);
|
||||
}
|
||||
|
||||
$studentIds = $studentQuery
|
||||
->pluck('student_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->filter()
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Services\ClassProgress;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\SubjectCurriculum;
|
||||
use App\Services\SemesterRangeService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ClassProgressMetaService
|
||||
{
|
||||
@@ -146,7 +147,17 @@ class ClassProgressMetaService
|
||||
|
||||
$options = [];
|
||||
foreach ($this->subjectSections() as $slug => $section) {
|
||||
$options[$slug] = SubjectCurriculum::getOptionsForClass((int) $classId, (string) $slug, $schoolYear);
|
||||
$subjects = $this->subjectLookupValues((string) $slug, is_array($section) ? $section : []);
|
||||
|
||||
$options[$slug] = SubjectCurriculum::query()
|
||||
->forClass((int) $classId)
|
||||
->where(function (Builder $query) use ($subjects) {
|
||||
$query->whereIn('subject', $subjects);
|
||||
})
|
||||
->forSchoolYear($schoolYear)
|
||||
->ordered()
|
||||
->get()
|
||||
->all();
|
||||
}
|
||||
|
||||
return $options;
|
||||
@@ -198,4 +209,26 @@ class ClassProgressMetaService
|
||||
|
||||
return $unitTitle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Curriculum rows have existed with both short slugs (`quran`) and display
|
||||
* subjects (`Quran/Arabic`). Query all configured aliases but keep the API
|
||||
* response keyed by slug for the frontend.
|
||||
*
|
||||
* @param array<string, mixed> $section
|
||||
* @return list<string>
|
||||
*/
|
||||
private function subjectLookupValues(string $slug, array $section): array
|
||||
{
|
||||
$values = [$slug];
|
||||
|
||||
foreach (['db_subject', 'label'] as $key) {
|
||||
$value = trim((string) ($section[$key] ?? ''));
|
||||
if ($value !== '') {
|
||||
$values[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($values));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,10 +362,11 @@ class ClassProgressMutationService
|
||||
return;
|
||||
}
|
||||
|
||||
$assigned = TeacherClass::query()
|
||||
->where('teacher_id', $user->id)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->exists();
|
||||
$assigned = in_array(
|
||||
$classSectionId,
|
||||
TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id),
|
||||
true
|
||||
);
|
||||
|
||||
if (! $assigned) {
|
||||
throw new \InvalidArgumentException('No class assignment found for this report.');
|
||||
|
||||
@@ -25,6 +25,11 @@ class ClassProgressQueryService
|
||||
|
||||
public function listReports(User $user, array $filters): LengthAwarePaginator
|
||||
{
|
||||
$filterSectionIds = [];
|
||||
if (! empty($filters['class_section_id'])) {
|
||||
$filterSectionIds = $this->equivalentClassSectionIds((int) $filters['class_section_id']);
|
||||
}
|
||||
|
||||
$query = ClassProgressReport::query()
|
||||
->with(['classSection', 'teacher'])
|
||||
->orderBy($filters['sort_by'] ?? 'week_start', $filters['sort_dir'] ?? 'desc');
|
||||
@@ -44,22 +49,19 @@ class ClassProgressQueryService
|
||||
$query->whereRaw('1 = 0');
|
||||
}
|
||||
} else {
|
||||
$relaxedSectionIds = TeacherClass::distinctSectionIdsForTeacher((int) $user->id);
|
||||
$relaxedSectionIds = TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id);
|
||||
|
||||
if (! empty($filters['class_section_id'])) {
|
||||
$sid = (int) $filters['class_section_id'];
|
||||
$requestedSectionIds = $filterSectionIds !== [] ? $filterSectionIds : [(int) $filters['class_section_id']];
|
||||
// Section filter: if this user has ever taught the section, show all rows for that section
|
||||
// (legacy DB often has `class_progress_reports` without matching current-year `teacher_class`).
|
||||
if (! in_array($sid, $relaxedSectionIds, true)) {
|
||||
$query->where('teacher_id', (int) $user->id);
|
||||
if (array_intersect($requestedSectionIds, $relaxedSectionIds) === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
}
|
||||
} elseif ($relaxedSectionIds !== []) {
|
||||
$query->whereIn('class_section_id', $relaxedSectionIds);
|
||||
} else {
|
||||
$query->where(function ($q) use ($user, $relaxedSectionIds) {
|
||||
$q->where('teacher_id', (int) $user->id);
|
||||
if ($relaxedSectionIds !== []) {
|
||||
$q->orWhereIn('class_section_id', $relaxedSectionIds);
|
||||
}
|
||||
});
|
||||
$query->where('teacher_id', (int) $user->id);
|
||||
}
|
||||
}
|
||||
} elseif (! empty($filters['teacher_id'])) {
|
||||
@@ -67,7 +69,10 @@ class ClassProgressQueryService
|
||||
}
|
||||
|
||||
if (! empty($filters['class_section_id'])) {
|
||||
$query->where('class_section_id', (int) $filters['class_section_id']);
|
||||
$query->whereIn(
|
||||
'class_section_id',
|
||||
$filterSectionIds !== [] ? $filterSectionIds : [(int) $filters['class_section_id']]
|
||||
);
|
||||
}
|
||||
|
||||
if (! empty($filters['subject'])) {
|
||||
@@ -203,7 +208,12 @@ class ClassProgressQueryService
|
||||
$schoolYear = (string) (config('school.school_year') ?? '');
|
||||
}
|
||||
|
||||
return TeacherClass::getClassAssignmentsByUserId($user->id, $schoolYear, $semester);
|
||||
$assignments = $this->teacherAssignmentsForTerm($user, $schoolYear !== '' ? $schoolYear : null);
|
||||
if ($assignments !== []) {
|
||||
return $assignments;
|
||||
}
|
||||
|
||||
return $this->teacherAssignmentsForTerm($user, null);
|
||||
}
|
||||
|
||||
public function meta(?string $schoolYear = null, ?string $semester = null): array
|
||||
@@ -258,6 +268,119 @@ class ClassProgressQueryService
|
||||
return $options[$status] ?? 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the logged-in teacher's real assignment rows. The join accepts both
|
||||
* legacy section identifiers used in this codebase.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function teacherAssignmentsForTerm(User $user, ?string $schoolYear): array
|
||||
{
|
||||
if (! Schema::hasTable('classSection') || ! Schema::hasTable('teacher_class')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$teacherClassHasSchoolYear = Schema::hasColumn('teacher_class', 'school_year');
|
||||
$teacherClassHasSemester = Schema::hasColumn('teacher_class', 'semester');
|
||||
$schoolYearSelect = $teacherClassHasSchoolYear
|
||||
? 'COALESCE(NULLIF(tc.school_year, ""), cs_code.school_year, cs_pk.school_year) AS school_year'
|
||||
: 'COALESCE(cs_code.school_year, cs_pk.school_year) AS school_year';
|
||||
$semesterSelect = $teacherClassHasSemester
|
||||
? 'COALESCE(NULLIF(tc.semester, ""), cs_code.semester, cs_pk.semester) AS semester'
|
||||
: 'COALESCE(cs_code.semester, cs_pk.semester) AS semester';
|
||||
|
||||
$query = DB::table('teacher_class as tc')
|
||||
->selectRaw("
|
||||
COALESCE(cs_code.class_section_name, cs_pk.class_section_name) AS class_section_name,
|
||||
COALESCE(cs_code.id, cs_pk.id) AS class_section_pk,
|
||||
COALESCE(cs_code.class_section_id, cs_pk.class_section_id) AS class_section_id,
|
||||
c.id AS class_id,
|
||||
c.class_name,
|
||||
tc.teacher_id,
|
||||
tc.position,
|
||||
{$schoolYearSelect},
|
||||
{$semesterSelect}
|
||||
")
|
||||
->leftJoin('classSection as cs_code', 'tc.class_section_id', '=', 'cs_code.class_section_id')
|
||||
->leftJoin('classSection as cs_pk', function ($join) {
|
||||
$join->on('tc.class_section_id', '=', 'cs_pk.id')
|
||||
->whereNull('cs_code.class_section_id');
|
||||
})
|
||||
->leftJoin('classes as c', DB::raw('COALESCE(cs_code.class_id, cs_pk.class_id)'), '=', 'c.id')
|
||||
->where('tc.teacher_id', (int) $user->id)
|
||||
->whereNotNull('tc.class_section_id')
|
||||
->whereRaw('COALESCE(cs_code.class_section_id, cs_pk.class_section_id) IS NOT NULL');
|
||||
|
||||
if ($schoolYear !== null && $schoolYear !== '') {
|
||||
if ($teacherClassHasSchoolYear) {
|
||||
$query->where(function ($yearQuery) use ($schoolYear) {
|
||||
$yearQuery->where('tc.school_year', $schoolYear)
|
||||
->orWhere(function ($legacyYearQuery) use ($schoolYear) {
|
||||
$legacyYearQuery->where(function ($blankYearQuery) {
|
||||
$blankYearQuery->whereNull('tc.school_year')
|
||||
->orWhere('tc.school_year', '');
|
||||
})->whereRaw('COALESCE(cs_code.school_year, cs_pk.school_year) = ?', [$schoolYear]);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
$query->whereRaw('COALESCE(cs_code.school_year, cs_pk.school_year) = ?', [$schoolYear]);
|
||||
}
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByRaw("CASE tc.position WHEN 'main' THEN 0 WHEN 'ta' THEN 1 ELSE 2 END")
|
||||
->orderByRaw('COALESCE(cs_code.class_section_name, cs_pk.class_section_name) ASC')
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'class_section_pk' => isset($row->class_section_pk) ? (int) $row->class_section_pk : null,
|
||||
'class_section_id' => (int) $row->class_section_id,
|
||||
'class_section_name' => (string) $row->class_section_name,
|
||||
'class_id' => isset($row->class_id) ? (int) $row->class_id : null,
|
||||
'class_name' => $row->class_name ?? null,
|
||||
'teacher_id' => (int) $row->teacher_id,
|
||||
'teacher_role' => ucfirst((string) $row->position),
|
||||
'school_year' => $row->school_year ?? null,
|
||||
'semester' => $row->semester ?? null,
|
||||
])
|
||||
->unique('class_section_id')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function equivalentClassSectionIds(int $classSectionId): array
|
||||
{
|
||||
if ($classSectionId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = [$classSectionId => true];
|
||||
if (! Schema::hasTable('classSection')) {
|
||||
return [$classSectionId];
|
||||
}
|
||||
|
||||
$rows = DB::table('classSection')
|
||||
->select('id', 'class_section_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->orWhere('id', $classSectionId)
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
foreach ([(int) $row->id, (int) $row->class_section_id] as $id) {
|
||||
if ($id > 0) {
|
||||
$ids[$id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ids = array_map('intval', array_keys($ids));
|
||||
sort($ids);
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function isAdmin(User $user): bool
|
||||
{
|
||||
$roles = $user->roles()
|
||||
@@ -339,7 +462,7 @@ class ClassProgressQueryService
|
||||
|
||||
return in_array(
|
||||
(int) $report->class_section_id,
|
||||
TeacherClass::distinctSectionIdsForTeacher((int) $user->id),
|
||||
TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,49 @@ class InvoicePaymentService
|
||||
}
|
||||
|
||||
$invoiceIds = array_column($invoices, 'id');
|
||||
$payments = [];
|
||||
$discounts = [];
|
||||
$refunds = [];
|
||||
if (! empty($invoiceIds)) {
|
||||
$paymentTotals = DB::table('payments')
|
||||
->selectRaw('invoice_id, COALESCE(SUM(paid_amount),0) as paid_amount')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->where(function ($query) {
|
||||
$query->whereNotIn(DB::raw('LOWER(status)'), [
|
||||
'pending',
|
||||
'void',
|
||||
'voided',
|
||||
'cancelled',
|
||||
'canceled',
|
||||
'failed',
|
||||
'rejected',
|
||||
'refunded',
|
||||
'chargeback',
|
||||
'declined',
|
||||
'reversed',
|
||||
])->orWhereNull('status');
|
||||
})
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->map(fn ($r) => (array) $r)
|
||||
->all();
|
||||
|
||||
foreach ($paymentTotals as $row) {
|
||||
$payments[$row['invoice_id']] = (float) ($row['paid_amount'] ?? 0);
|
||||
}
|
||||
|
||||
$discountResults = DB::table('discount_usages')
|
||||
->selectRaw('invoice_id, COALESCE(SUM(discount_amount),0) as discount_amount')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->map(fn ($r) => (array) $r)
|
||||
->all();
|
||||
|
||||
foreach ($discountResults as $row) {
|
||||
$discounts[$row['invoice_id']] = (float) ($row['discount_amount'] ?? 0);
|
||||
}
|
||||
|
||||
$refundResults = DB::table('refunds')
|
||||
->selectRaw('invoice_id, COALESCE(SUM(refund_paid_amount),0) as refund_paid_amount')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
@@ -63,9 +104,20 @@ class InvoicePaymentService
|
||||
}
|
||||
|
||||
foreach ($invoices as &$invoice) {
|
||||
$invoice['refund_amount'] = $refunds[$invoice['id']] ?? 0.0;
|
||||
$invoice['last_paid_amount'] = $lastPayments[$invoice['id']]['last_paid_amount'] ?? 0.0;
|
||||
$invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null;
|
||||
$invoiceId = (int) ($invoice['id'] ?? 0);
|
||||
$totalAmount = round((float) ($invoice['total_amount'] ?? 0), 2);
|
||||
$paidAmount = round((float) ($payments[$invoiceId] ?? $invoice['paid_amount'] ?? 0), 2);
|
||||
$discountAmount = round((float) ($discounts[$invoiceId] ?? $invoice['discount'] ?? 0), 2);
|
||||
$refundAmount = round((float) ($refunds[$invoiceId] ?? 0), 2);
|
||||
$balance = max(0.0, round($totalAmount - $paidAmount - $discountAmount - $refundAmount, 2));
|
||||
|
||||
$invoice['paid_amount'] = $paidAmount;
|
||||
$invoice['discount'] = $discountAmount;
|
||||
$invoice['refund_amount'] = $refundAmount;
|
||||
$invoice['balance'] = $balance;
|
||||
$invoice['status'] = $this->deriveInvoiceStatus($invoice['status'] ?? null, $paidAmount, $balance);
|
||||
$invoice['last_paid_amount'] = $lastPayments[$invoiceId]['last_paid_amount'] ?? 0.0;
|
||||
$invoice['last_payment_date'] = $lastPayments[$invoiceId]['last_payment_date'] ?? null;
|
||||
}
|
||||
unset($invoice);
|
||||
|
||||
@@ -77,4 +129,17 @@ class InvoicePaymentService
|
||||
'dueDate' => $this->config->getDueDate(),
|
||||
];
|
||||
}
|
||||
|
||||
private function deriveInvoiceStatus(?string $storedStatus, float $paidAmount, float $balance): string
|
||||
{
|
||||
if ($balance <= 0.00001) {
|
||||
return 'Paid';
|
||||
}
|
||||
|
||||
if ($paidAmount > 0) {
|
||||
return 'Partially Paid';
|
||||
}
|
||||
|
||||
return $storedStatus ?: 'Unpaid';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ class ParentEnrollmentService
|
||||
'reason' => 'Withdrawal under review for student ID '.$studentId,
|
||||
'request' => 'new',
|
||||
'semester' => $semester,
|
||||
'refund_amount' => 0.0,
|
||||
'refund_paid_amount' => 0.0,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
namespace App\Services\Parents;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Models\ParentAccount;
|
||||
use App\Models\ParentBalanceTransfer;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ParentInvoiceService
|
||||
{
|
||||
@@ -14,6 +19,311 @@ class ParentInvoiceService
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
return $rows->toArray();
|
||||
$invoiceIds = $rows
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->filter(fn ($id) => $id > 0)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$paymentsByInvoice = $this->paymentsByInvoice($parentId, $invoiceIds);
|
||||
$adjustments = $this->invoiceAdjustments($invoiceIds);
|
||||
|
||||
return $rows
|
||||
->map(function (Invoice $invoice) use ($paymentsByInvoice, $adjustments): array {
|
||||
$invoiceRow = $invoice->toArray();
|
||||
$invoiceId = (int) $invoice->id;
|
||||
$payments = $paymentsByInvoice[(int) $invoice->id] ?? [];
|
||||
$paidAmount = round(array_sum(array_map(
|
||||
static fn (array $payment): float => (float) ($payment['paid_amount'] ?? 0),
|
||||
$payments
|
||||
)), 2);
|
||||
$discountAmount = round((float) ($adjustments['discounts'][$invoiceId] ?? 0), 2);
|
||||
$refundAmount = round((float) ($adjustments['refunds'][$invoiceId] ?? 0), 2);
|
||||
$totalAmount = round((float) ($invoice->total_amount ?? 0), 2);
|
||||
$balance = max(0.0, round($totalAmount - $paidAmount - $discountAmount - $refundAmount, 2));
|
||||
|
||||
$invoiceRow['payments'] = $payments;
|
||||
$invoiceRow['paid_amount'] = $paidAmount;
|
||||
$invoiceRow['discount'] = $discountAmount;
|
||||
$invoiceRow['refund_amount'] = $refundAmount;
|
||||
$invoiceRow['balance'] = $balance;
|
||||
$invoiceRow['last_paid_amount'] = (float) ($payments[0]['paid_amount'] ?? 0);
|
||||
$invoiceRow['last_payment_date'] = $payments[0]['payment_date'] ?? null;
|
||||
$invoiceRow['status'] = $this->deriveInvoiceStatus($invoice->status, $paidAmount, $balance);
|
||||
|
||||
return $invoiceRow;
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
public function statement(int $parentId, ?string $schoolYear = null): array
|
||||
{
|
||||
$parent = User::query()->find($parentId);
|
||||
$account = $schoolYear
|
||||
? ParentAccount::query()
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->first()
|
||||
: null;
|
||||
|
||||
$columns = [
|
||||
'id',
|
||||
'invoice_number',
|
||||
'total_amount',
|
||||
'paid_amount',
|
||||
'balance',
|
||||
'status',
|
||||
'description',
|
||||
'school_year',
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('invoices', 'invoice_type')) {
|
||||
$columns[] = 'invoice_type';
|
||||
}
|
||||
if (Schema::hasColumn('invoices', 'balance_transfer_id')) {
|
||||
$columns[] = 'balance_transfer_id';
|
||||
}
|
||||
|
||||
$invoices = Invoice::query()
|
||||
->where('parent_id', $parentId)
|
||||
->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear))
|
||||
->orderBy('created_at')
|
||||
->get($columns);
|
||||
|
||||
$transferSourceYears = $this->transferSourceYears($invoices->pluck('balance_transfer_id')->filter()->all());
|
||||
$invoiceIds = $invoices
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->filter(fn ($id) => $id > 0)
|
||||
->values()
|
||||
->all();
|
||||
$adjustments = $this->invoiceAdjustments($invoiceIds);
|
||||
|
||||
$lines = $invoices
|
||||
->map(function (Invoice $invoice) use ($transferSourceYears, $adjustments): array {
|
||||
$transferId = (int) ($invoice->balance_transfer_id ?? 0);
|
||||
$invoiceId = (int) $invoice->id;
|
||||
$totalAmount = round((float) ($invoice->total_amount ?? 0), 2);
|
||||
$paidAmount = round((float) ($adjustments['payments'][$invoiceId] ?? $invoice->paid_amount ?? 0), 2);
|
||||
$discountAmount = round((float) ($adjustments['discounts'][$invoiceId] ?? 0), 2);
|
||||
$refundAmount = round((float) ($adjustments['refunds'][$invoiceId] ?? 0), 2);
|
||||
$balance = max(0.0, round($totalAmount - $paidAmount - $discountAmount - $refundAmount, 2));
|
||||
|
||||
return [
|
||||
'label' => $invoice->description ?: ($invoice->invoice_number ?: sprintf('Invoice #%d', $invoice->id)),
|
||||
'amount' => $balance,
|
||||
'source_school_year' => $transferId > 0
|
||||
? ($transferSourceYears[$transferId] ?? $invoice->school_year)
|
||||
: $invoice->school_year,
|
||||
'invoice_id' => $invoice->id,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$openingBalance = round((float) ($account?->opening_balance ?? 0), 2);
|
||||
$currentBalance = round($openingBalance + array_sum(array_map(
|
||||
static fn (array $line): float => (float) ($line['amount'] ?? 0),
|
||||
$lines
|
||||
)), 2);
|
||||
|
||||
return [
|
||||
'parent_id' => $parentId,
|
||||
'parent_name' => $this->parentName($parent),
|
||||
'school_year' => $schoolYear,
|
||||
'lines' => $lines,
|
||||
'opening_balance' => $openingBalance,
|
||||
'current_balance' => round($currentBalance, 2),
|
||||
];
|
||||
}
|
||||
|
||||
private function parentName(?User $parent): ?string
|
||||
{
|
||||
if (! $parent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = array_filter([
|
||||
trim((string) ($parent->firstname ?? '')),
|
||||
trim((string) ($parent->lastname ?? '')),
|
||||
]);
|
||||
|
||||
if ($parts !== []) {
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
return $parent->name ?? $parent->email ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $invoiceIds
|
||||
* @return array<int, list<array<string, mixed>>>
|
||||
*/
|
||||
private function paymentsByInvoice(int $parentId, array $invoiceIds): array
|
||||
{
|
||||
if ($invoiceIds === [] || ! Schema::hasTable('payments')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$columns = [
|
||||
'id',
|
||||
'parent_id',
|
||||
'invoice_id',
|
||||
'paid_amount',
|
||||
Schema::hasColumn('payments', 'balance') ? 'balance' : DB::raw('NULL AS balance'),
|
||||
Schema::hasColumn('payments', 'payment_method') ? 'payment_method' : DB::raw('NULL AS payment_method'),
|
||||
Schema::hasColumn('payments', 'payment_date') ? 'payment_date' : DB::raw('NULL AS payment_date'),
|
||||
Schema::hasColumn('payments', 'transaction_id') ? 'transaction_id' : DB::raw('NULL AS transaction_id'),
|
||||
Schema::hasColumn('payments', 'status') ? 'status' : DB::raw('NULL AS status'),
|
||||
Schema::hasColumn('payments', 'school_year') ? 'school_year' : DB::raw('NULL AS school_year'),
|
||||
Schema::hasColumn('payments', 'semester') ? 'semester' : DB::raw('NULL AS semester'),
|
||||
Schema::hasColumn('payments', 'created_at') ? 'created_at' : DB::raw('NULL AS created_at'),
|
||||
];
|
||||
|
||||
$query = DB::table('payments')
|
||||
->select($columns)
|
||||
->where('parent_id', $parentId)
|
||||
->whereIn('invoice_id', $invoiceIds);
|
||||
|
||||
if (Schema::hasColumn('payments', 'status')) {
|
||||
$query->whereNotIn(DB::raw('LOWER(status)'), ['pending', 'void', 'voided', 'cancelled', 'canceled', 'failed', 'rejected']);
|
||||
}
|
||||
if (Schema::hasColumn('payments', 'payment_date')) {
|
||||
$query->orderByDesc('payment_date');
|
||||
}
|
||||
if (Schema::hasColumn('payments', 'created_at')) {
|
||||
$query->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
$rows = $query
|
||||
->get()
|
||||
->map(static fn ($row): array => [
|
||||
'id' => (int) $row->id,
|
||||
'parent_id' => (int) $row->parent_id,
|
||||
'invoice_id' => (int) $row->invoice_id,
|
||||
'paid_amount' => (float) $row->paid_amount,
|
||||
'balance' => (float) $row->balance,
|
||||
'payment_method' => $row->payment_method,
|
||||
'payment_date' => $row->payment_date,
|
||||
'transaction_id' => $row->transaction_id,
|
||||
'status' => $row->status,
|
||||
'school_year' => $row->school_year,
|
||||
'semester' => $row->semester,
|
||||
'created_at' => $row->created_at,
|
||||
])
|
||||
->all();
|
||||
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$grouped[(int) $row['invoice_id']][] = $row;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $invoiceIds
|
||||
* @return array{payments: array<int, float>, discounts: array<int, float>, refunds: array<int, float>}
|
||||
*/
|
||||
private function invoiceAdjustments(array $invoiceIds): array
|
||||
{
|
||||
$invoiceIds = array_values(array_unique(array_filter(array_map('intval', $invoiceIds))));
|
||||
$adjustments = [
|
||||
'payments' => [],
|
||||
'discounts' => [],
|
||||
'refunds' => [],
|
||||
];
|
||||
|
||||
if ($invoiceIds === []) {
|
||||
return $adjustments;
|
||||
}
|
||||
|
||||
if (Schema::hasTable('payments')) {
|
||||
$paymentQuery = DB::table('payments')
|
||||
->selectRaw('invoice_id, COALESCE(SUM(paid_amount),0) AS total')
|
||||
->whereIn('invoice_id', $invoiceIds);
|
||||
|
||||
if (Schema::hasColumn('payments', 'status')) {
|
||||
$paymentQuery->where(function ($query) {
|
||||
$query->whereNotIn(DB::raw('LOWER(status)'), [
|
||||
'pending',
|
||||
'void',
|
||||
'voided',
|
||||
'cancelled',
|
||||
'canceled',
|
||||
'failed',
|
||||
'rejected',
|
||||
'refunded',
|
||||
'chargeback',
|
||||
'declined',
|
||||
'reversed',
|
||||
])->orWhereNull('status');
|
||||
});
|
||||
}
|
||||
|
||||
$adjustments['payments'] = $paymentQuery
|
||||
->groupBy('invoice_id')
|
||||
->pluck('total', 'invoice_id')
|
||||
->map(fn ($value) => (float) $value)
|
||||
->all();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('discount_usages')) {
|
||||
$adjustments['discounts'] = DB::table('discount_usages')
|
||||
->selectRaw('invoice_id, COALESCE(SUM(discount_amount),0) AS total')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupBy('invoice_id')
|
||||
->pluck('total', 'invoice_id')
|
||||
->map(fn ($value) => (float) $value)
|
||||
->all();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('refunds')) {
|
||||
$adjustments['refunds'] = DB::table('refunds')
|
||||
->selectRaw('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->groupBy('invoice_id')
|
||||
->pluck('total', 'invoice_id')
|
||||
->map(fn ($value) => (float) $value)
|
||||
->all();
|
||||
}
|
||||
|
||||
return $adjustments;
|
||||
}
|
||||
|
||||
private function deriveInvoiceStatus(?string $storedStatus, float $paidAmount, float $balance): string
|
||||
{
|
||||
if ($paidAmount <= 0 && $balance > 0) {
|
||||
return 'Unpaid';
|
||||
}
|
||||
|
||||
if ($balance <= 0) {
|
||||
return 'Paid';
|
||||
}
|
||||
|
||||
if ($paidAmount > 0) {
|
||||
return 'Partially Paid';
|
||||
}
|
||||
|
||||
return $storedStatus ?: 'Unpaid';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $transferIds
|
||||
* @return array<int, string|null>
|
||||
*/
|
||||
private function transferSourceYears(array $transferIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $transferIds)));
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ParentBalanceTransfer::query()
|
||||
->whereIn('id', $ids)
|
||||
->pluck('from_school_year', 'id')
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ class PaymentManualService
|
||||
}
|
||||
|
||||
$parentData = null;
|
||||
$parentMatches = [];
|
||||
$students = [];
|
||||
$payments = [];
|
||||
$invoices = [];
|
||||
@@ -47,6 +48,14 @@ class PaymentManualService
|
||||
$builder->where(function ($q) use ($searchTerm, $searchClean, $searchFormatted) {
|
||||
$q->where('email', $searchTerm);
|
||||
|
||||
if (ctype_digit($searchTerm)) {
|
||||
$q->orWhere('id', (int) $searchTerm);
|
||||
}
|
||||
|
||||
$q->orWhereRaw("CONCAT_WS(' ', firstname, lastname) LIKE ?", ['%'.$searchTerm.'%'])
|
||||
->orWhere('firstname', 'like', '%'.$searchTerm.'%')
|
||||
->orWhere('lastname', 'like', '%'.$searchTerm.'%');
|
||||
|
||||
if (! empty($searchClean)) {
|
||||
if (strlen($searchClean) === 10) {
|
||||
$formattedPhone = substr($searchClean, 0, 3).'-'.substr($searchClean, 3, 3).'-'.substr($searchClean, 6, 4);
|
||||
@@ -62,11 +71,19 @@ class PaymentManualService
|
||||
if (! empty($searchFormatted)) {
|
||||
$q->orWhere('cellphone', $searchFormatted);
|
||||
}
|
||||
});
|
||||
})
|
||||
->orderByRaw('LOWER(firstname)')
|
||||
->orderByRaw('LOWER(lastname)')
|
||||
->orderByRaw('LOWER(email)')
|
||||
->limit(20);
|
||||
|
||||
$parent = (array) $builder->first();
|
||||
$parentRows = $builder->get()
|
||||
->map(fn ($row) => $this->sanitizeParentRow((array) $row))
|
||||
->all();
|
||||
$parentMatches = $parentRows;
|
||||
|
||||
$parent = $parentRows[0] ?? [];
|
||||
if (! empty($parent)) {
|
||||
unset($parent['password']);
|
||||
$parentData = $parent;
|
||||
$parentId = (int) ($parent['id'] ?? 0);
|
||||
|
||||
@@ -100,6 +117,7 @@ class PaymentManualService
|
||||
|
||||
return [
|
||||
'parent' => $parentData,
|
||||
'parent_matches' => $parentMatches,
|
||||
'students' => $students,
|
||||
'payments' => $payments,
|
||||
'invoices' => $invoices,
|
||||
@@ -133,7 +151,7 @@ class PaymentManualService
|
||||
$params[] = '%'.$digits.'%';
|
||||
}
|
||||
|
||||
$sql .= ') ORDER BY lastname, firstname LIMIT 20';
|
||||
$sql .= ') ORDER BY LOWER(firstname), LOWER(lastname), LOWER(email) LIMIT 20';
|
||||
|
||||
$rows = DB::select($sql, $params);
|
||||
|
||||
@@ -156,6 +174,13 @@ class PaymentManualService
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function sanitizeParentRow(array $parent): array
|
||||
{
|
||||
unset($parent['password'], $parent['remember_token']);
|
||||
|
||||
return $parent;
|
||||
}
|
||||
|
||||
public function recordPayment(
|
||||
int $invoiceId,
|
||||
float $amount,
|
||||
|
||||
@@ -179,10 +179,8 @@ class SchoolYearClosureService
|
||||
$promotionCounts = is_array($metadata['promotion_counts'] ?? null)
|
||||
? $metadata['promotion_counts']
|
||||
: [];
|
||||
$balanceCounts = is_array($metadata['balance_counts'] ?? null)
|
||||
? $metadata['balance_counts']
|
||||
: [];
|
||||
$transferTotals = $this->transferTotalsForClosedYear($year->name, $newYearName !== '' ? $newYearName : null);
|
||||
$invoiceBalanceTotals = $this->positiveInvoiceBalanceTotals($year->name);
|
||||
|
||||
$warnings = [];
|
||||
if (! $audit) {
|
||||
@@ -207,14 +205,13 @@ class SchoolYearClosureService
|
||||
'students_graduated' => (int) ($promotionCounts['graduate'] ?? 0),
|
||||
'students_transferred' => (int) ($promotionCounts['transfer'] ?? 0),
|
||||
'students_withdrawn' => (int) ($promotionCounts['withdraw'] ?? 0),
|
||||
'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_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),
|
||||
'parents_with_unpaid_balances' => $invoiceBalanceTotals['parents_with_unpaid_balances'],
|
||||
'total_unpaid_balance_transferred' => $invoiceBalanceTotals['total_unpaid_balance'],
|
||||
'parents_with_credit_balances' => $transferTotals['parents_with_credit_balances'],
|
||||
'total_credit_carried_or_reported' => $transferTotals['total_credit_carried_or_reported'],
|
||||
'total_credit_transferred' => $transferTotals['total_credit_carried_or_reported'],
|
||||
'net_balance_impact' => round(
|
||||
(float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred'])
|
||||
- (float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']),
|
||||
$invoiceBalanceTotals['total_unpaid_balance'] - $transferTotals['total_credit_carried_or_reported'],
|
||||
2
|
||||
),
|
||||
],
|
||||
@@ -458,7 +455,8 @@ class SchoolYearClosureService
|
||||
|
||||
return $this->buildParentBalanceRows(
|
||||
$year->name,
|
||||
$toSchoolYear ?: ($this->resolver->suggestNextName($year->name) ?? $year->name)
|
||||
$toSchoolYear ?: ($this->resolver->suggestNextName($year->name) ?? $year->name),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@@ -680,6 +678,8 @@ class SchoolYearClosureService
|
||||
$action = 'graduate';
|
||||
} elseif (in_array($decisionText, ['retained', 'repeat', 'repeated', 'not promoted'], true) || ($score !== null && $score < 60)) {
|
||||
$action = 'repeat';
|
||||
} elseif ($this->isPassingSeventeenYearOld($student, $decisionText, $score)) {
|
||||
$action = 'graduate';
|
||||
} elseif (($progression['is_terminal'] ?? false) === true) {
|
||||
$action = 'graduate';
|
||||
} elseif ($decisionText !== '' || $score !== null) {
|
||||
@@ -694,6 +694,9 @@ class SchoolYearClosureService
|
||||
} elseif ($action === 'promote') {
|
||||
$targetLevelId = $progression['next_level_id'] ?? null;
|
||||
$targetLevelName = $progression['next_level_name'] ?? null;
|
||||
if ($this->isKindergartenAgeReadyForGradeOne($student, $currentClass, $section)) {
|
||||
[$targetLevelId, $targetLevelName] = $this->resolveGradeOneTarget();
|
||||
}
|
||||
if ($targetLevelId === null) {
|
||||
$action = 'hold';
|
||||
$warnings[] = 'No next grade mapping exists for this student.';
|
||||
@@ -739,11 +742,61 @@ class SchoolYearClosureService
|
||||
];
|
||||
}
|
||||
|
||||
private function buildParentBalanceRows(string $fromSchoolYear, string $toSchoolYear): array
|
||||
private function isPassingSeventeenYearOld(object $student, string $decisionText, ?float $score): bool
|
||||
{
|
||||
$invoices = $this->eligibleCarryoverInvoiceQuery($fromSchoolYear)
|
||||
->whereRaw('ABS(COALESCE(balance, 0)) > 0.01')
|
||||
->get(['id', 'parent_id', 'balance']);
|
||||
$age = (int) ($student->age ?? 0);
|
||||
if ($age !== 17) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($score !== null) {
|
||||
return $score >= 60;
|
||||
}
|
||||
|
||||
return str_contains($decisionText, 'promot')
|
||||
|| str_contains($decisionText, 'pass');
|
||||
}
|
||||
|
||||
private function isKindergartenAgeReadyForGradeOne(object $student, ?object $currentClass, ?object $section): bool
|
||||
{
|
||||
$age = (int) ($student->age ?? 0);
|
||||
if ($age + 1 < 6) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$className = strtolower(trim((string) ($currentClass->class_name ?? '')));
|
||||
$sectionName = strtolower(trim((string) ($section->class_section_name ?? '')));
|
||||
|
||||
return in_array($className, ['kg', 'kindergarten'], true)
|
||||
|| in_array($sectionName, ['kg', 'kindergarten'], true);
|
||||
}
|
||||
|
||||
private function resolveGradeOneTarget(): array
|
||||
{
|
||||
$target = DB::table('classes')
|
||||
->whereIn(DB::raw('LOWER(class_name)'), ['1', 'grade 1', 'class 1'])
|
||||
->orderByRaw("CASE WHEN class_name = '1' THEN 0 ELSE 1 END")
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
return $target
|
||||
? [(int) $target->id, (string) $target->class_name]
|
||||
: [null, null];
|
||||
}
|
||||
|
||||
private function buildParentBalanceRows(string $fromSchoolYear, string $toSchoolYear, bool $includeCreditOnlyRows = true): array
|
||||
{
|
||||
$invoiceQuery = $includeCreditOnlyRows
|
||||
? $this->eligibleCarryoverInvoiceQuery($fromSchoolYear)
|
||||
: DB::table('invoices')->where('school_year', $fromSchoolYear);
|
||||
|
||||
if ($includeCreditOnlyRows) {
|
||||
$invoiceQuery->whereRaw('ABS(COALESCE(balance, 0)) > 0.01');
|
||||
} else {
|
||||
$invoiceQuery->whereRaw('COALESCE(balance, 0) > 0.01');
|
||||
}
|
||||
|
||||
$invoices = $invoiceQuery->get(['id', 'parent_id', 'balance']);
|
||||
|
||||
$balancesByParent = [];
|
||||
foreach ($invoices as $invoice) {
|
||||
@@ -801,6 +854,18 @@ class SchoolYearClosureService
|
||||
->first()
|
||||
: null;
|
||||
|
||||
if (! $includeCreditOnlyRows && $positive < 0.01) {
|
||||
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']++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = [
|
||||
'parent_id' => $parentId,
|
||||
'parent_name' => $this->resolveParentName($parentId),
|
||||
@@ -808,9 +873,15 @@ class SchoolYearClosureService
|
||||
'unpaid_invoice_count' => count($balanceRow['positive_invoice_ids']),
|
||||
'positive_unpaid_balance' => $positive,
|
||||
'old_unpaid_balance' => $positive,
|
||||
'old_unpaid_amount' => $positive,
|
||||
'credit_balance' => $credit,
|
||||
'net_balance' => $net,
|
||||
'amount_to_transfer' => $positive,
|
||||
'old_school_year' => $fromSchoolYear,
|
||||
'new_school_year' => $toSchoolYear,
|
||||
'new_old_balance_invoice_id' => $existing?->new_invoice_id ?? $existing?->old_balance_invoice_id ?? null,
|
||||
'transfer_date' => optional($existing?->created_at)->toDateTimeString(),
|
||||
'created_by' => $existing?->created_by,
|
||||
'transfer_status' => $existing?->status ?? ($positive > 0.01 ? 'pending' : 'credit_reported'),
|
||||
'existing_transfer_id' => $existing?->id,
|
||||
'existing_transfer_conflict' => $existing !== null,
|
||||
@@ -845,6 +916,9 @@ class SchoolYearClosureService
|
||||
$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'];
|
||||
$summary['parent_count'] = $summary['parents_with_positive_balance'];
|
||||
$summary['total_transferred'] = $summary['total_positive_unpaid_balance'];
|
||||
$summary['total_credit'] = $summary['total_credit_balance'];
|
||||
|
||||
return [
|
||||
'from_school_year' => $fromSchoolYear,
|
||||
@@ -858,7 +932,19 @@ class SchoolYearClosureService
|
||||
{
|
||||
return DB::table('invoices')
|
||||
->where('school_year', $schoolYear)
|
||||
->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), ['void', 'voided', 'cancelled', 'canceled', 'draft', 'pending']);
|
||||
->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), [
|
||||
'void',
|
||||
'voided',
|
||||
'cancelled',
|
||||
'canceled',
|
||||
'draft',
|
||||
'pending',
|
||||
'paid',
|
||||
'full',
|
||||
'full payment',
|
||||
'complete',
|
||||
'completed',
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveTargetSection(?int $targetLevelId, string $schoolYear): array
|
||||
@@ -1252,10 +1338,18 @@ class SchoolYearClosureService
|
||||
return $totals;
|
||||
}
|
||||
|
||||
$columns = ['amount'];
|
||||
if (Schema::hasColumn('parent_balance_transfers', 'source_summary_json')) {
|
||||
$columns[] = 'source_summary_json';
|
||||
}
|
||||
if (Schema::hasColumn('parent_balance_transfers', 'source_summary')) {
|
||||
$columns[] = 'source_summary';
|
||||
}
|
||||
|
||||
$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', 'source_summary_json', 'source_summary']);
|
||||
->get($columns);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$amount = round((float) ($row->amount ?? 0), 2);
|
||||
@@ -1283,6 +1377,21 @@ class SchoolYearClosureService
|
||||
return $totals;
|
||||
}
|
||||
|
||||
private function positiveInvoiceBalanceTotals(string $schoolYear): array
|
||||
{
|
||||
$rows = DB::table('invoices')
|
||||
->where('school_year', $schoolYear)
|
||||
->whereRaw('COALESCE(balance, 0) > 0.01')
|
||||
->select('parent_id', DB::raw('SUM(COALESCE(balance, 0)) as total_balance'))
|
||||
->groupBy('parent_id')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'parents_with_unpaid_balances' => $rows->count(),
|
||||
'total_unpaid_balance' => round((float) $rows->sum('total_balance'), 2),
|
||||
];
|
||||
}
|
||||
|
||||
private function decodeTransferSourceSummary(object $row): array
|
||||
{
|
||||
$raw = $row->source_summary_json ?? $row->source_summary ?? null;
|
||||
|
||||
+4
-1
@@ -676,8 +676,10 @@ Route::prefix('v1')->group(function () {
|
||||
Route::get('teacher/class-progress-history', [ClassProgressController::class, 'index']);
|
||||
// Legacy teacher progress view page
|
||||
Route::get('teacher/class-progress-view', [ClassProgressController::class, 'index']);
|
||||
// Legacy alias: same handler as GET /api/v1/competition-scores
|
||||
// Legacy aliases: same handlers as /api/v1/competition-scores.
|
||||
Route::get('teacher/competition-scores', [CompetitionScoresController::class, 'index']);
|
||||
Route::get('teacher/competition-scores/{id}', [CompetitionScoresController::class, 'edit'])->whereNumber('id');
|
||||
Route::post('teacher/competition-scores/{id}', [CompetitionScoresController::class, 'save'])->whereNumber('id');
|
||||
|
||||
Route::get('dashboard/route', [DashboardController::class, 'route']);
|
||||
Route::get('emergency-contacts', [AdministratorEmergencyContactController::class, 'index']);
|
||||
@@ -861,6 +863,7 @@ Route::prefix('v1')->group(function () {
|
||||
Route::middleware('parent.access')->prefix('parents')->group(function () {
|
||||
Route::get('attendance', [ParentApiController::class, 'attendance']);
|
||||
Route::get('invoices', [ParentApiController::class, 'invoices']);
|
||||
Route::get('statements', [ParentApiController::class, 'statement']);
|
||||
Route::get('enrollments', [ParentApiController::class, 'enrollments']);
|
||||
Route::post('enrollments', [ParentApiController::class, 'updateEnrollments']);
|
||||
Route::get('progress', [ParentApiController::class, 'progress']);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
PDFDATA
|
||||
@@ -9,7 +9,9 @@ use App\Models\TeacherSubmissionNotificationHistory;
|
||||
use App\Models\User;
|
||||
use App\Services\Administrator\AdministratorSharedService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
@@ -290,6 +292,57 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
|
||||
$this->assertSame('Main: Legacy Teacher', $response->json('rows.0.teachers.0.label'));
|
||||
}
|
||||
|
||||
public function test_teacher_submission_report_handles_student_class_without_semester_column(): void
|
||||
{
|
||||
Schema::table('student_class', function (Blueprint $table) {
|
||||
$table->dropColumn('semester');
|
||||
});
|
||||
|
||||
$admin = $this->seedAdminUser('admin.report.student-class-legacy@test.com');
|
||||
Sanctum::actingAs($admin, [], 'api');
|
||||
|
||||
User::factory()->create([
|
||||
'id' => 220,
|
||||
'firstname' => 'StudentClass',
|
||||
'lastname' => 'Legacy',
|
||||
'email' => 'student.class.legacy.teacher@test.com',
|
||||
]);
|
||||
|
||||
DB::table('classSection')->insert([
|
||||
[
|
||||
'class_section_id' => 22,
|
||||
'class_section_name' => 'Grade 9A',
|
||||
'class_id' => 9,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
],
|
||||
]);
|
||||
|
||||
DB::table('teacher_class')->insert([
|
||||
[
|
||||
'class_section_id' => 22,
|
||||
'teacher_id' => 220,
|
||||
'position' => 'main',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
],
|
||||
]);
|
||||
|
||||
DB::table('student_class')->insert([
|
||||
'student_id' => 1,
|
||||
'class_section_id' => 22,
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/v1/administrator/teacher-submissions?school_year=2025-2026');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'rows');
|
||||
|
||||
$this->assertSame('Grade 9A', $response->json('rows.0.class_section'));
|
||||
$this->assertSame(1, $response->json('rows.0.student_count'));
|
||||
}
|
||||
|
||||
public function test_teacher_submission_report_limits_history_to_three_entries(): void
|
||||
{
|
||||
$admin = $this->seedAdminUser('admin.report.two@test.com', 901, 'Admin', 'Two');
|
||||
|
||||
@@ -73,6 +73,23 @@ class ClassProgressControllerTest extends TestCase
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('classSection')->insert([
|
||||
'id' => 8,
|
||||
'class_id' => 8,
|
||||
'class_section_id' => 808,
|
||||
'class_section_name' => '8-A',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2024-2025',
|
||||
]);
|
||||
DB::table('teacher_class')->insert([
|
||||
'class_section_id' => 808,
|
||||
'teacher_id' => $user->id,
|
||||
'position' => 'main',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2024-2025',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('subject_curriculum_items')->insert([
|
||||
'class_id' => 9,
|
||||
'school_year' => null,
|
||||
@@ -83,6 +100,16 @@ class ClassProgressControllerTest extends TestCase
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('subject_curriculum_items')->insert([
|
||||
'class_id' => 9,
|
||||
'school_year' => null,
|
||||
'subject' => 'Quran/Arabic',
|
||||
'unit_number' => 2,
|
||||
'unit_title' => 'Surah Review',
|
||||
'chapter_name' => 'Al-Fatiha',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
@@ -93,8 +120,12 @@ class ClassProgressControllerTest extends TestCase
|
||||
$response->assertJsonPath('data.defaults.school_year', '2025-2026');
|
||||
$response->assertJsonPath('data.defaults.semester', 'Fall');
|
||||
$response->assertJsonPath('data.defaults.class_section_id', 909);
|
||||
$this->assertCount(1, $response->json('data.classes'));
|
||||
$response->assertJsonPath('data.classes.0.class_section_id', 909);
|
||||
$response->assertJsonPath('data.curriculum.islamic.0.chapter_name', 'Intro');
|
||||
$response->assertJsonPath('data.unit_options.islamic.0.label', 'Unit 1 - Faith');
|
||||
$response->assertJsonPath('data.curriculum.quran.0.chapter_name', 'Al-Fatiha');
|
||||
$response->assertJsonPath('data.unit_options.quran.0.label', 'Unit 2 - Surah Review');
|
||||
}
|
||||
|
||||
public function test_index_returns_paginated_reports(): void
|
||||
@@ -115,10 +146,184 @@ class ClassProgressControllerTest extends TestCase
|
||||
$this->assertCount(2, $response->json('data.items'));
|
||||
}
|
||||
|
||||
public function test_teacher_history_is_scoped_to_assigned_class_sections(): void
|
||||
{
|
||||
$teacher = $this->createUser();
|
||||
$otherTeacher = $this->createUser('other-history-teacher@example.com');
|
||||
$this->assignRole($teacher->id, 'teacher');
|
||||
$this->seedClassSection();
|
||||
DB::table('classSection')->insert([
|
||||
'id' => 2,
|
||||
'class_id' => 2,
|
||||
'class_section_id' => 202,
|
||||
'class_section_name' => '2-A',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
$this->assignTeacher($teacher->id, 1);
|
||||
$this->assignTeacher($teacher->id, 202);
|
||||
|
||||
$assignedOwnReport = ClassProgressReport::factory()->create([
|
||||
'teacher_id' => $teacher->id,
|
||||
'class_section_id' => 101,
|
||||
'school_year' => '2025-2026',
|
||||
'week_start' => '2025-09-07',
|
||||
]);
|
||||
$assignedSectionReport = ClassProgressReport::factory()->create([
|
||||
'teacher_id' => $otherTeacher->id,
|
||||
'class_section_id' => 101,
|
||||
'school_year' => '2025-2026',
|
||||
'week_start' => '2025-09-14',
|
||||
]);
|
||||
$otherSectionReport = ClassProgressReport::factory()->create([
|
||||
'teacher_id' => $teacher->id,
|
||||
'class_section_id' => 202,
|
||||
'school_year' => '2025-2026',
|
||||
'week_start' => '2025-09-21',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($teacher);
|
||||
|
||||
$response = $this->getJson('/api/v1/teacher/class-progress-history?school_year=2025-2026&class_section_id=1');
|
||||
|
||||
$response->assertOk();
|
||||
$ids = collect($response->json('data.items'))->pluck('id')->all();
|
||||
|
||||
$this->assertContains($assignedOwnReport->id, $ids);
|
||||
$this->assertContains($assignedSectionReport->id, $ids);
|
||||
$this->assertNotContains($otherSectionReport->id, $ids);
|
||||
|
||||
$detailResponse = $this->getJson('/api/v1/class-progress/'.$assignedSectionReport->id);
|
||||
|
||||
$detailResponse->assertOk();
|
||||
$detailResponse->assertJsonPath('data.report.id', $assignedSectionReport->id);
|
||||
|
||||
$unfilteredResponse = $this
|
||||
->withSession(['class_section_id' => 202])
|
||||
->getJson('/api/v1/teacher/class-progress-history?school_year=2025-2026');
|
||||
|
||||
$unfilteredResponse->assertOk();
|
||||
$unfilteredIds = collect($unfilteredResponse->json('data.items'))->pluck('id')->all();
|
||||
|
||||
$this->assertNotContains($assignedOwnReport->id, $unfilteredIds);
|
||||
$this->assertNotContains($assignedSectionReport->id, $unfilteredIds);
|
||||
$this->assertContains($otherSectionReport->id, $unfilteredIds);
|
||||
}
|
||||
|
||||
public function test_teacher_history_accepts_legacy_progress_rows_saved_with_section_pk(): void
|
||||
{
|
||||
$teacher = $this->createUser();
|
||||
$otherTeacher = $this->createUser('legacy-pk-history-teacher@example.com');
|
||||
$this->assignRole($teacher->id, 'teacher_assistant');
|
||||
$this->seedClassSection();
|
||||
$this->assignTeacher($teacher->id, 101);
|
||||
|
||||
$legacyPkReport = ClassProgressReport::factory()->create([
|
||||
'teacher_id' => $otherTeacher->id,
|
||||
'class_section_id' => 1,
|
||||
'school_year' => '2025-2026',
|
||||
'week_start' => '2025-09-28',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($teacher);
|
||||
|
||||
$response = $this->getJson('/api/v1/teacher/class-progress-history?school_year=2025-2026&class_section_id=101');
|
||||
|
||||
$response->assertOk();
|
||||
$ids = collect($response->json('data.items'))->pluck('id')->all();
|
||||
|
||||
$this->assertContains($legacyPkReport->id, $ids);
|
||||
}
|
||||
|
||||
public function test_teacher_history_without_section_uses_logged_in_teacher_assignment(): void
|
||||
{
|
||||
$teacher = $this->createUser();
|
||||
$otherTeacher = $this->createUser('assignment-history-teacher@example.com');
|
||||
$this->assignRole($teacher->id, 'teacher');
|
||||
$this->seedClassSection();
|
||||
DB::table('classSection')->insert([
|
||||
'id' => 2,
|
||||
'class_id' => 2,
|
||||
'class_section_id' => 202,
|
||||
'class_section_name' => '2-A',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
$this->assignTeacher($teacher->id, 1);
|
||||
|
||||
$sameSectionReport = ClassProgressReport::factory()->create([
|
||||
'teacher_id' => $otherTeacher->id,
|
||||
'class_section_id' => 101,
|
||||
'school_year' => '2025-2026',
|
||||
'week_start' => '2025-10-05',
|
||||
]);
|
||||
$wrongSectionReport = ClassProgressReport::factory()->create([
|
||||
'teacher_id' => $otherTeacher->id,
|
||||
'class_section_id' => 202,
|
||||
'school_year' => '2025-2026',
|
||||
'week_start' => '2025-10-12',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($teacher);
|
||||
|
||||
$response = $this->getJson('/api/v1/teacher/class-progress-history?school_year=2025-2026');
|
||||
|
||||
$response->assertOk();
|
||||
$ids = collect($response->json('data.items'))->pluck('id')->all();
|
||||
|
||||
$this->assertContains($sameSectionReport->id, $ids);
|
||||
$this->assertNotContains($wrongSectionReport->id, $ids);
|
||||
}
|
||||
|
||||
public function test_teacher_history_without_section_does_not_422_for_multiple_assignments(): void
|
||||
{
|
||||
$teacher = $this->createUser();
|
||||
$otherTeacher = $this->createUser('multi-assignment-history-teacher@example.com');
|
||||
$this->assignRole($teacher->id, 'teacher');
|
||||
$this->seedClassSection();
|
||||
DB::table('classSection')->insert([
|
||||
'id' => 2,
|
||||
'class_id' => 2,
|
||||
'class_section_id' => 202,
|
||||
'class_section_name' => '2-A',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
$this->assignTeacher($teacher->id, 1);
|
||||
$this->assignTeacher($teacher->id, 202);
|
||||
|
||||
$primarySectionReport = ClassProgressReport::factory()->create([
|
||||
'teacher_id' => $otherTeacher->id,
|
||||
'class_section_id' => 101,
|
||||
'school_year' => '2025-2026',
|
||||
'week_start' => '2025-10-19',
|
||||
]);
|
||||
$otherSectionReport = ClassProgressReport::factory()->create([
|
||||
'teacher_id' => $otherTeacher->id,
|
||||
'class_section_id' => 202,
|
||||
'school_year' => '2025-2026',
|
||||
'week_start' => '2025-10-26',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($teacher);
|
||||
|
||||
$response = $this->getJson('/api/v1/teacher/class-progress-history?school_year=2025-2026&per_page=100&sort_by=week_start&sort_dir=desc');
|
||||
|
||||
$response->assertOk();
|
||||
$ids = collect($response->json('data.items'))->pluck('id')->all();
|
||||
|
||||
$this->assertContains($primarySectionReport->id, $ids);
|
||||
$this->assertNotContains($otherSectionReport->id, $ids);
|
||||
}
|
||||
|
||||
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');
|
||||
DB::table('configuration')->updateOrInsert(
|
||||
['config_key' => 'semester'],
|
||||
['config_value' => 'Fall']
|
||||
);
|
||||
$this->seedClassSection();
|
||||
DB::table('classSection')->where('class_section_id', 101)->update([
|
||||
'semester' => '',
|
||||
@@ -286,6 +491,74 @@ class ClassProgressControllerTest extends TestCase
|
||||
$this->assertDatabaseCount('class_progress_reports', 2);
|
||||
}
|
||||
|
||||
public function test_teacher_store_uses_logged_in_teacher_assignment_for_section(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$user = $this->createUser();
|
||||
$this->assignRole($user->id, 'teacher');
|
||||
$this->seedClassSection();
|
||||
DB::table('classSection')->insert([
|
||||
'id' => 2,
|
||||
'class_id' => 2,
|
||||
'class_section_id' => 202,
|
||||
'class_section_name' => '2-A',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
$this->assignTeacher($user->id, 101);
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$payload = [
|
||||
'class_section_id' => 202,
|
||||
'week_start' => '2025-09-07',
|
||||
'week_end' => '2025-09-13',
|
||||
'covered_islamic' => 'Lesson A',
|
||||
'covered_quran' => 'Lesson B',
|
||||
'unit_islamic' => ['Unit 1'],
|
||||
'chapter_islamic' => ['Chapter A'],
|
||||
'school_year' => '2025-2026',
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/teacher/class-progress-submit', $payload);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$this->assertDatabaseHas('class_progress_reports', [
|
||||
'teacher_id' => $user->id,
|
||||
'class_section_id' => 101,
|
||||
'subject' => 'Islamic Studies',
|
||||
]);
|
||||
$this->assertDatabaseMissing('class_progress_reports', [
|
||||
'teacher_id' => $user->id,
|
||||
'class_section_id' => 202,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_teacher_store_without_section_uses_logged_in_teacher_assignment(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$user = $this->createUser();
|
||||
$this->assignRole($user->id, 'teacher');
|
||||
$this->seedClassSection();
|
||||
$this->assignTeacher($user->id, 101);
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->postJson('/api/v1/teacher/class-progress-submit', [
|
||||
'week_start' => '2025-09-07',
|
||||
'week_end' => '2025-09-13',
|
||||
'covered_islamic' => 'Lesson A',
|
||||
'unit_islamic' => ['Unit 1'],
|
||||
'chapter_islamic' => ['Chapter A'],
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$this->assertDatabaseHas('class_progress_reports', [
|
||||
'teacher_id' => $user->id,
|
||||
'class_section_id' => 101,
|
||||
'subject' => 'Islamic Studies',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_show_returns_weekly_reports(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
|
||||
@@ -45,6 +45,21 @@ class CompetitionScoresControllerTest extends TestCase
|
||||
$this->assertSame(20, $response->json('questionCount'));
|
||||
}
|
||||
|
||||
public function test_teacher_edit_alias_returns_students_and_scores(): void
|
||||
{
|
||||
$this->seedCompetitionData();
|
||||
|
||||
$user = $this->createUser();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->getJson('/api/v1/teacher/competition-scores/1?school_year=2025-2026');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertNotEmpty($response->json('students'));
|
||||
$this->assertSame(5, $response->json('scoreMap.1'));
|
||||
$this->assertSame(20, $response->json('questionCount'));
|
||||
}
|
||||
|
||||
public function test_save_updates_scores(): void
|
||||
{
|
||||
$this->seedCompetitionData();
|
||||
@@ -72,6 +87,33 @@ class CompetitionScoresControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_teacher_save_alias_updates_scores(): void
|
||||
{
|
||||
$this->seedCompetitionData();
|
||||
|
||||
$user = $this->createUser();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->postJson('/api/v1/teacher/competition-scores/1', [
|
||||
'class_section_id' => 101,
|
||||
'scores' => [
|
||||
1 => 12,
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson([
|
||||
'ok' => true,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('competition_scores', [
|
||||
'competition_id' => 1,
|
||||
'student_id' => 1,
|
||||
'class_section_id' => 101,
|
||||
'score' => 12,
|
||||
]);
|
||||
}
|
||||
|
||||
private function seedCompetitionData(): void
|
||||
{
|
||||
DB::table('configuration')->updateOrInsert(
|
||||
|
||||
@@ -27,6 +27,112 @@ class PaymentManualControllerTest extends TestCase
|
||||
$this->assertSame(10, $response->json('parent.id'));
|
||||
}
|
||||
|
||||
public function test_search_returns_parent_data_by_selected_name(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
$this->seedConfig();
|
||||
$this->seedStudent();
|
||||
$this->seedInvoice();
|
||||
Sanctum::actingAs(User::query()->findOrFail(1));
|
||||
|
||||
$response = $this->getJson('/api/v1/finance/manual-pay/search?search_term=Parent+User&school_year=2025-2026');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['ok' => true]);
|
||||
$this->assertSame(10, $response->json('parent.id'));
|
||||
$this->assertNotEmpty($response->json('students'));
|
||||
$this->assertNotEmpty($response->json('invoices'));
|
||||
}
|
||||
|
||||
public function test_search_lists_multiple_parent_matches_alphabetically(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
$this->seedConfig();
|
||||
Sanctum::actingAs(User::query()->findOrFail(1));
|
||||
|
||||
DB::table('users')->insert([
|
||||
[
|
||||
'id' => 11,
|
||||
'school_id' => 1,
|
||||
'firstname' => 'Zara',
|
||||
'lastname' => 'Akter',
|
||||
'cellphone' => '5555555556',
|
||||
'email' => 'zara.akter@example.com',
|
||||
'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',
|
||||
],
|
||||
[
|
||||
'id' => 12,
|
||||
'school_id' => 1,
|
||||
'firstname' => 'Mousumi',
|
||||
'lastname' => 'Akter',
|
||||
'cellphone' => '5555555557',
|
||||
'email' => 'mousumi.akter@example.com',
|
||||
'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',
|
||||
],
|
||||
[
|
||||
'id' => 13,
|
||||
'school_id' => 1,
|
||||
'firstname' => 'Aisha',
|
||||
'lastname' => 'Akter',
|
||||
'cellphone' => '5555555558',
|
||||
'email' => 'aisha.akter@example.com',
|
||||
'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' => 11, 'role_id' => 2],
|
||||
['user_id' => 12, 'role_id' => 2],
|
||||
['user_id' => 13, 'role_id' => 2],
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/v1/finance/manual-pay/search?search_term=akter&school_year=2025-2026');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertSame([
|
||||
'Aisha Akter',
|
||||
'Mousumi Akter',
|
||||
'Zara Akter',
|
||||
], array_map(
|
||||
fn (array $parent) => $parent['firstname'].' '.$parent['lastname'],
|
||||
$response->json('parent_matches')
|
||||
));
|
||||
$this->assertSame(13, $response->json('parent.id'));
|
||||
}
|
||||
|
||||
public function test_suggest_returns_items(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
@@ -103,6 +103,52 @@ class ParentControllerTest extends TestCase
|
||||
$this->assertDatabaseHas('emergency_contacts', ['parent_id' => $parent->id, 'emergency_contact_name' => 'John Parent']);
|
||||
}
|
||||
|
||||
public function test_statement_returns_parent_account_and_invoice_lines(): void
|
||||
{
|
||||
$this->seedConfig();
|
||||
$parent = $this->seedParent();
|
||||
|
||||
DB::table('parent_accounts')->insert([
|
||||
'parent_id' => $parent->id,
|
||||
'school_year' => '2025-2026',
|
||||
'opening_balance' => 25,
|
||||
'current_balance' => 125,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('invoices')->insert([
|
||||
'parent_id' => $parent->id,
|
||||
'invoice_number' => 'INV-2025-001',
|
||||
'total_amount' => 150,
|
||||
'paid_amount' => 50,
|
||||
'balance' => 100,
|
||||
'has_discount' => 0,
|
||||
'issue_date' => now(),
|
||||
'due_date' => now()->addWeek(),
|
||||
'status' => 'unpaid',
|
||||
'description' => 'Tuition invoice',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($parent);
|
||||
$response = $this->getJson('/api/v1/parents/statements?school_year=2025-2026');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('ok', true)
|
||||
->assertJsonPath('data.parent_id', $parent->id)
|
||||
->assertJsonPath('data.parent_name', 'Parent User')
|
||||
->assertJsonPath('data.school_year', '2025-2026')
|
||||
->assertJsonPath('data.opening_balance', 25)
|
||||
->assertJsonPath('data.current_balance', 125)
|
||||
->assertJsonPath('data.lines.0.label', 'Tuition invoice')
|
||||
->assertJsonPath('data.lines.0.amount', 100)
|
||||
->assertJsonPath('data.lines.0.source_school_year', '2025-2026');
|
||||
}
|
||||
|
||||
private function seedParent(): User
|
||||
{
|
||||
$roleId = DB::table('roles')->insertGetId([
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\Api\V1\Reports;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -72,6 +73,93 @@ class FilesControllerTest extends TestCase
|
||||
$response->assertHeader('Content-Disposition', 'inline; filename="1a_midterm_v2.pdf"');
|
||||
}
|
||||
|
||||
public function test_exam_draft_final_streams_when_requested_by_draft_id(): void
|
||||
{
|
||||
$user = $this->seedUser();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
DB::table('classSection')->insert([
|
||||
'id' => 1,
|
||||
'class_section_id' => 1,
|
||||
'class_section_name' => '1A',
|
||||
'class_id' => 1,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
|
||||
DB::table('exam_drafts')->insert([
|
||||
'id' => 1,
|
||||
'teacher_id' => 1,
|
||||
'class_section_id' => 1,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'exam_type' => 'Final Exam',
|
||||
'draft_title' => 'Final',
|
||||
'teacher_file' => 'draft1.pdf',
|
||||
'final_file' => 'final1.pdf',
|
||||
'version' => 3,
|
||||
'status' => 'finalized',
|
||||
]);
|
||||
|
||||
$dir = storage_path('uploads/exams/finals');
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
$path = $dir.DIRECTORY_SEPARATOR.'final1.pdf';
|
||||
file_put_contents($path, 'PDFDATA');
|
||||
|
||||
$response = $this->get('/api/v1/files/exams/final/1');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertHeader('Content-Disposition', 'inline; filename="1a_final_exam_v3.pdf"');
|
||||
}
|
||||
|
||||
public function test_exam_draft_final_ignores_numeric_final_pdf_placeholder(): void
|
||||
{
|
||||
$user = $this->seedUser();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
DB::table('classSection')->insert([
|
||||
'id' => 1,
|
||||
'class_section_id' => 1,
|
||||
'class_section_name' => '1A',
|
||||
'class_id' => 1,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'id' => 1,
|
||||
'teacher_id' => 1,
|
||||
'class_section_id' => 1,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'exam_type' => 'Final Exam',
|
||||
'draft_title' => 'Final',
|
||||
'teacher_file' => 'draft1.pdf',
|
||||
'final_file' => 'final1.pdf',
|
||||
'version' => 3,
|
||||
'status' => 'finalized',
|
||||
];
|
||||
if (Schema::hasColumn('exam_drafts', 'final_pdf_file')) {
|
||||
$payload['final_pdf_file'] = '1';
|
||||
}
|
||||
|
||||
DB::table('exam_drafts')->insert($payload);
|
||||
|
||||
$dir = storage_path('uploads/exams/finals');
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
$path = $dir.DIRECTORY_SEPARATOR.'final1.pdf';
|
||||
file_put_contents($path, 'PDFDATA');
|
||||
|
||||
$response = $this->get('/api/v1/files/exams/final/1');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertHeader('Content-Disposition', 'inline; filename="1a_final_exam_v3.pdf"');
|
||||
}
|
||||
|
||||
private function seedUser(): User
|
||||
{
|
||||
DB::table('users')->insert([
|
||||
|
||||
@@ -152,28 +152,49 @@ class SchoolYearControllerTest extends TestCase
|
||||
$this->assertSame([502], $row['credit_invoice_ids']);
|
||||
}
|
||||
|
||||
public function test_credit_only_parent_is_reported_as_credit_not_unpaid_carryover(): void
|
||||
public function test_parent_balance_report_uses_only_positive_invoice_balances(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
$this->seedParent(12, 'Credit', 'Only', 'credit@example.com');
|
||||
$this->seedParent(13, 'Discount', 'Paid', 'discount-paid@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',
|
||||
[
|
||||
'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',
|
||||
],
|
||||
[
|
||||
'id' => 505,
|
||||
'parent_id' => 13,
|
||||
'invoice_number' => 'INV-505-DISCOUNT-PAID',
|
||||
'total_amount' => 500,
|
||||
'balance' => 0,
|
||||
'paid_amount' => 500,
|
||||
'has_discount' => 1,
|
||||
'issue_date' => '2026-05-11',
|
||||
'due_date' => '2026-06-10',
|
||||
'status' => 'paid',
|
||||
'description' => 'Discount paid invoice',
|
||||
'school_year' => '2025-2026',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'updated_by' => 1,
|
||||
'semester' => 'Fall',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||
@@ -182,18 +203,53 @@ class SchoolYearControllerTest extends TestCase
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.summary.parents_with_positive_balance', 1)
|
||||
->assertJsonPath('data.summary.parents_with_credit_balance', 1)
|
||||
->assertJsonPath('data.summary.parents_with_credit_balance', 0)
|
||||
->assertJsonPath('data.summary.total_positive_unpaid_balance', 200)
|
||||
->assertJsonPath('data.summary.total_credit_balance', 50);
|
||||
->assertJsonPath('data.summary.total_credit_balance', 0)
|
||||
->assertJsonPath('data.summary.parent_count', 1)
|
||||
->assertJsonPath('data.summary.total_transferred', 200);
|
||||
|
||||
$creditRow = collect($response->json('data.rows'))
|
||||
->first(fn (array $row): bool => (int) $row['parent_id'] === 12);
|
||||
$rows = collect($response->json('data.rows'));
|
||||
|
||||
$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']);
|
||||
$this->assertTrue($rows->contains(fn (array $row): bool => (int) $row['parent_id'] === 10));
|
||||
$this->assertSame(200.0, (float) $rows->first(fn (array $row): bool => (int) $row['parent_id'] === 10)['old_unpaid_amount']);
|
||||
$this->assertFalse($rows->contains(fn (array $row): bool => (int) $row['parent_id'] === 12));
|
||||
$this->assertFalse($rows->contains(fn (array $row): bool => (int) $row['parent_id'] === 13));
|
||||
}
|
||||
|
||||
public function test_closing_report_unpaid_balance_totals_are_recomputed_from_positive_invoice_balances(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
|
||||
DB::table('school_years')->where('id', 1)->update([
|
||||
'status' => 'closed',
|
||||
'closed_at' => now(),
|
||||
'closed_by' => 1,
|
||||
]);
|
||||
|
||||
DB::table('audit_logs')->insert([
|
||||
'user_id' => 1,
|
||||
'action' => 'school_year_closed',
|
||||
'table_name' => 'school_years',
|
||||
'record_id' => 1,
|
||||
'old_value' => null,
|
||||
'new_value' => null,
|
||||
'metadata' => json_encode([
|
||||
'new_school_year' => '2026-2027',
|
||||
'balance_counts' => [
|
||||
'transferred_parents' => 48,
|
||||
'transferred_amount' => 9999,
|
||||
],
|
||||
]),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||
|
||||
$this->getJson('/api/v1/school-years/reports/closing?school_year=2025-2026')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.totals.parents_with_unpaid_balances', 1)
|
||||
->assertJsonPath('data.totals.total_unpaid_balance_transferred', 200);
|
||||
}
|
||||
|
||||
public function test_pending_or_draft_invoice_blocks_close_but_is_not_transferred(): void
|
||||
@@ -567,6 +623,139 @@ class SchoolYearControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_passing_age_17_student_graduates_and_is_not_enrolled_next_year(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
|
||||
DB::table('students')->where('id', 100)->update(['age' => 17]);
|
||||
|
||||
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||
|
||||
$preview = $this->postJson('/api/v1/school-years/1/preview-close', [
|
||||
'new_school_year' => [
|
||||
'name' => '2026-2027',
|
||||
'start_date' => '2026-09-01',
|
||||
'end_date' => '2027-06-30',
|
||||
],
|
||||
'transfer_unpaid_balances' => true,
|
||||
]);
|
||||
|
||||
$preview->assertOk()
|
||||
->assertJsonPath('data.promotion_preview.summary.promote', 0)
|
||||
->assertJsonPath('data.promotion_preview.summary.graduate', 1)
|
||||
->assertJsonPath('data.promotion_preview.rows.0.promotion_action', 'graduate');
|
||||
|
||||
$this->postJson('/api/v1/school-years/1/close', [
|
||||
'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->assertDatabaseMissing('enrollments', [
|
||||
'student_id' => 100,
|
||||
'school_year' => '2026-2027',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('student_class', [
|
||||
'student_id' => 100,
|
||||
'school_year' => '2026-2027',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_kindergarten_student_age_six_next_year_promotes_to_grade_one_not_arabic(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
|
||||
DB::table('classes')->insert([
|
||||
['id' => 1, 'class_name' => '1', 'schedule' => 'Sunday 9:00', 'school_year' => '2026-2027', 'semester' => 'Fall', 'capacity' => 30],
|
||||
['id' => 13, 'class_name' => 'KG', 'schedule' => 'Sunday 9:00', 'school_year' => '2025-2026', 'semester' => 'Fall', 'capacity' => 30],
|
||||
['id' => 14, 'class_name' => 'Arabic', 'schedule' => 'Sunday 11:00', 'school_year' => '2026-2027', 'semester' => 'Fall', 'capacity' => 30],
|
||||
]);
|
||||
|
||||
DB::table('classSection')->insert([
|
||||
[
|
||||
'class_section_id' => 1,
|
||||
'class_section_name' => 'KG',
|
||||
'class_id' => 13,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
],
|
||||
[
|
||||
'class_section_id' => 10,
|
||||
'class_section_name' => '1A',
|
||||
'class_id' => 1,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2026-2027',
|
||||
],
|
||||
[
|
||||
'class_section_id' => 140,
|
||||
'class_section_name' => 'Arabic A',
|
||||
'class_id' => 14,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2026-2027',
|
||||
],
|
||||
]);
|
||||
|
||||
DB::table('students')->where('id', 100)->update(['age' => 5]);
|
||||
DB::table('student_class')->where('student_id', 100)->where('school_year', '2025-2026')->update([
|
||||
'class_section_id' => 1,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('enrollments')->where('student_id', 100)->where('school_year', '2025-2026')->update([
|
||||
'class_section_id' => 1,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||
|
||||
$preview = $this->postJson('/api/v1/school-years/1/preview-close', [
|
||||
'new_school_year' => [
|
||||
'name' => '2026-2027',
|
||||
'start_date' => '2026-09-01',
|
||||
'end_date' => '2027-06-30',
|
||||
],
|
||||
'transfer_unpaid_balances' => true,
|
||||
]);
|
||||
|
||||
$preview->assertOk()
|
||||
->assertJsonPath('data.promotion_preview.rows.0.promotion_action', 'promote')
|
||||
->assertJsonPath('data.promotion_preview.rows.0.current_grade', 'KG')
|
||||
->assertJsonPath('data.promotion_preview.rows.0.target_grade', '1')
|
||||
->assertJsonPath('data.promotion_preview.rows.0.target_class_section_id', 10);
|
||||
|
||||
$this->postJson('/api/v1/school-years/1/close', [
|
||||
'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('enrollments', [
|
||||
'student_id' => 100,
|
||||
'school_year' => '2026-2027',
|
||||
'class_section_id' => 10,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('student_class', [
|
||||
'student_id' => 100,
|
||||
'school_year' => '2026-2027',
|
||||
'class_section_id' => 10,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('enrollments', [
|
||||
'student_id' => 100,
|
||||
'school_year' => '2026-2027',
|
||||
'class_section_id' => 140,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_closed_school_year_blocks_legacy_invoice_generation(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
|
||||
@@ -28,6 +28,7 @@ class TeacherControllerTest extends TestCase
|
||||
$response->assertJson(['ok' => true]);
|
||||
$this->assertNotEmpty($response->json('students'));
|
||||
$this->assertSame($classSectionId, $response->json('active_class_section_id'));
|
||||
$this->assertSame($classSectionId, session('class_section_id'));
|
||||
}
|
||||
|
||||
public function test_switch_class_returns_active_id(): void
|
||||
@@ -44,6 +45,7 @@ class TeacherControllerTest extends TestCase
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('active_class_section_id', $classSectionId);
|
||||
$this->assertSame($classSectionId, session('class_section_id'));
|
||||
}
|
||||
|
||||
public function test_absence_submit_creates_staff_attendance(): void
|
||||
|
||||
@@ -25,6 +25,23 @@ class ClassProgressQueryServiceTest extends TestCase
|
||||
'teacher_id' => $user->id,
|
||||
'class_section_id' => 202,
|
||||
]);
|
||||
DB::table('classSection')->insert([
|
||||
'id' => 1,
|
||||
'class_id' => 1,
|
||||
'class_section_id' => 101,
|
||||
'class_section_name' => '1-A',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
DB::table('teacher_class')->insert([
|
||||
'class_section_id' => 1,
|
||||
'teacher_id' => $user->id,
|
||||
'position' => 'main',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$service = new ClassProgressQueryService(new ClassProgressRuleService);
|
||||
$results = $service->listReports($user, ['class_section_id' => 101]);
|
||||
@@ -32,6 +49,78 @@ class ClassProgressQueryServiceTest extends TestCase
|
||||
$this->assertSame(1, $results->total());
|
||||
}
|
||||
|
||||
public function test_list_reports_filters_by_equivalent_legacy_section_ids(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
DB::table('classSection')->insert([
|
||||
'id' => 1,
|
||||
'class_id' => 1,
|
||||
'class_section_id' => 101,
|
||||
'class_section_name' => '1-A',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
DB::table('teacher_class')->insert([
|
||||
'class_section_id' => 101,
|
||||
'teacher_id' => $user->id,
|
||||
'position' => 'main',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$report = ClassProgressReport::factory()->create([
|
||||
'teacher_id' => 999,
|
||||
'class_section_id' => 1,
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
|
||||
$service = new ClassProgressQueryService(new ClassProgressRuleService);
|
||||
$results = $service->listReports($user, ['class_section_id' => 101]);
|
||||
|
||||
$this->assertSame(1, $results->total());
|
||||
$this->assertSame($report->id, $results->items()[0]->id);
|
||||
}
|
||||
|
||||
public function test_teacher_assignments_prefer_section_code_over_colliding_primary_key(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
DB::table('classSection')->insert([
|
||||
[
|
||||
'id' => 1,
|
||||
'class_id' => 1,
|
||||
'class_section_id' => 10,
|
||||
'class_section_name' => '1',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
],
|
||||
[
|
||||
'id' => 10,
|
||||
'class_id' => 3,
|
||||
'class_section_id' => 31,
|
||||
'class_section_name' => '3-A',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
],
|
||||
]);
|
||||
DB::table('teacher_class')->insert([
|
||||
'class_section_id' => 10,
|
||||
'teacher_id' => $user->id,
|
||||
'position' => 'main',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$service = new ClassProgressQueryService(new ClassProgressRuleService);
|
||||
$assignments = $service->teacherAssignments($user, '2025-2026', 'Fall');
|
||||
|
||||
$this->assertCount(1, $assignments);
|
||||
$this->assertSame(10, $assignments[0]['class_section_id']);
|
||||
$this->assertSame('1', $assignments[0]['class_section_name']);
|
||||
}
|
||||
|
||||
private function createUser(): User
|
||||
{
|
||||
DB::table('users')->insert([
|
||||
|
||||
@@ -60,4 +60,39 @@ class InvoicePaymentServiceTest extends TestCase
|
||||
$this->assertSame(5.0, $data['invoices'][0]['refund_amount']);
|
||||
$this->assertSame(60.0, $data['invoices'][0]['last_paid_amount']);
|
||||
}
|
||||
|
||||
public function test_parent_invoice_summary_applies_full_discount_to_balance(): void
|
||||
{
|
||||
DB::table('invoices')->insert([
|
||||
[
|
||||
'id' => 1,
|
||||
'parent_id' => 10,
|
||||
'invoice_number' => 'INV-DISCOUNT',
|
||||
'total_amount' => 370,
|
||||
'balance' => 370,
|
||||
'paid_amount' => 0,
|
||||
'has_discount' => 1,
|
||||
'issue_date' => '2025-01-10',
|
||||
'status' => 'Paid',
|
||||
'school_year' => '2025-2026',
|
||||
],
|
||||
]);
|
||||
|
||||
DB::table('discount_usages')->insert([
|
||||
'id' => 1,
|
||||
'voucher_id' => 1,
|
||||
'parent_id' => 10,
|
||||
'invoice_id' => 1,
|
||||
'discount_amount' => 370,
|
||||
'description' => 'Full scholarship',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
|
||||
$service = new InvoicePaymentService(new InvoiceConfigService);
|
||||
$data = $service->getParentInvoiceSummary(10, '2025-2026');
|
||||
|
||||
$this->assertSame(370.0, $data['invoices'][0]['discount']);
|
||||
$this->assertSame(0.0, $data['invoices'][0]['balance']);
|
||||
$this->assertSame('Paid', $data['invoices'][0]['status']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,67 @@ class ParentEnrollmentServiceTest extends TestCase
|
||||
$this->assertSame('enrolled', $result['students'][0]['enrollment_status']);
|
||||
}
|
||||
|
||||
public function test_withdrawal_request_creates_pending_zero_amount_refund(): void
|
||||
{
|
||||
$this->seedConfig();
|
||||
$parentId = $this->seedParent();
|
||||
|
||||
$studentId = DB::table('students')->insertGetId([
|
||||
'school_id' => 'S-201',
|
||||
'firstname' => 'Omar',
|
||||
'lastname' => 'Parent',
|
||||
'dob' => '2014-09-01',
|
||||
'age' => 11,
|
||||
'gender' => 'Male',
|
||||
'photo_consent' => 1,
|
||||
'parent_id' => $parentId,
|
||||
'year_of_registration' => '2025',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
]);
|
||||
|
||||
DB::table('enrollments')->insert([
|
||||
'student_id' => $studentId,
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'enrollment_date' => '2025-09-01',
|
||||
'enrollment_status' => 'enrolled',
|
||||
'admission_status' => 'accepted',
|
||||
'is_withdrawn' => 0,
|
||||
]);
|
||||
|
||||
$invoiceId = DB::table('invoices')->insertGetId([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_number' => 'INV-201',
|
||||
'total_amount' => 100,
|
||||
'paid_amount' => 100,
|
||||
'balance' => 0,
|
||||
'issue_date' => now(),
|
||||
'due_date' => now()->addWeek(),
|
||||
'status' => 'paid',
|
||||
'description' => 'Tuition',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$service = new ParentEnrollmentService(new ParentConfigService);
|
||||
$result = $service->updateEnrollment($parentId, [], [$studentId]);
|
||||
|
||||
$this->assertSame([$studentId], $result['withdrawn']);
|
||||
$this->assertDatabaseHas('refunds', [
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'status' => 'Pending',
|
||||
'refund_amount' => 0,
|
||||
'refund_paid_amount' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function seedParent(): int
|
||||
{
|
||||
return DB::table('users')->insertGetId([
|
||||
|
||||
@@ -41,6 +41,188 @@ class ParentInvoiceServiceTest extends TestCase
|
||||
$this->assertSame('INV-1', $rows[0]['invoice_number']);
|
||||
}
|
||||
|
||||
public function test_list_invoices_derives_payment_totals_from_payments_table(): void
|
||||
{
|
||||
$parentId = $this->seedParent();
|
||||
|
||||
$invoiceId = DB::table('invoices')->insertGetId([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_number' => 'INV-PAY',
|
||||
'total_amount' => 100,
|
||||
'paid_amount' => 0,
|
||||
'balance' => 100,
|
||||
'issue_date' => '2025-09-01',
|
||||
'due_date' => '2025-10-01',
|
||||
'status' => 'Unpaid',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('payments')->insert([
|
||||
[
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'total_amount' => 100,
|
||||
'paid_amount' => 40,
|
||||
'balance' => 60,
|
||||
'number_of_installments' => 1,
|
||||
'payment_method' => 'Check',
|
||||
'payment_date' => '2025-09-15',
|
||||
'transaction_id' => 'CHK-1',
|
||||
'status' => 'Paid',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'total_amount' => 100,
|
||||
'paid_amount' => 25,
|
||||
'balance' => 35,
|
||||
'number_of_installments' => 1,
|
||||
'payment_method' => 'Online',
|
||||
'payment_date' => '2025-09-16',
|
||||
'transaction_id' => 'PENDING-1',
|
||||
'status' => 'Pending',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$service = new ParentInvoiceService;
|
||||
$rows = $service->listInvoices($parentId, '2025-2026');
|
||||
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertSame(40.0, $rows[0]['paid_amount']);
|
||||
$this->assertSame(60.0, $rows[0]['balance']);
|
||||
$this->assertSame('Partially Paid', $rows[0]['status']);
|
||||
$this->assertCount(1, $rows[0]['payments']);
|
||||
$this->assertSame('CHK-1', $rows[0]['payments'][0]['transaction_id']);
|
||||
}
|
||||
|
||||
public function test_list_invoices_applies_discounts_to_balance(): void
|
||||
{
|
||||
$parentId = $this->seedParent();
|
||||
|
||||
$invoiceId = DB::table('invoices')->insertGetId([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_number' => 'INV-DISCOUNT',
|
||||
'total_amount' => 380,
|
||||
'paid_amount' => 10,
|
||||
'balance' => 370,
|
||||
'has_discount' => 1,
|
||||
'issue_date' => '2025-09-06',
|
||||
'due_date' => '2025-09-21',
|
||||
'status' => 'Paid',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('payments')->insert([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'total_amount' => 380,
|
||||
'paid_amount' => 10,
|
||||
'balance' => 370,
|
||||
'number_of_installments' => 1,
|
||||
'payment_method' => 'Cash',
|
||||
'payment_date' => '2025-09-10',
|
||||
'status' => 'Paid',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('discount_usages')->insert([
|
||||
'voucher_id' => 1,
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'discount_amount' => 370,
|
||||
'description' => 'Tuition discount',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
]);
|
||||
|
||||
$service = new ParentInvoiceService;
|
||||
$rows = $service->listInvoices($parentId, '2025-2026');
|
||||
|
||||
$this->assertSame(10.0, $rows[0]['paid_amount']);
|
||||
$this->assertSame(370.0, $rows[0]['discount']);
|
||||
$this->assertSame(0.0, $rows[0]['balance']);
|
||||
$this->assertSame('Paid', $rows[0]['status']);
|
||||
}
|
||||
|
||||
public function test_statement_derives_current_balance_from_computed_invoice_lines(): void
|
||||
{
|
||||
$parentId = $this->seedParent();
|
||||
|
||||
$invoiceId = DB::table('invoices')->insertGetId([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_number' => 'INV-STMT-DISCOUNT',
|
||||
'total_amount' => 380,
|
||||
'paid_amount' => 10,
|
||||
'balance' => 370,
|
||||
'has_discount' => 1,
|
||||
'issue_date' => '2025-09-06',
|
||||
'due_date' => '2025-09-21',
|
||||
'status' => 'Paid',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('parent_accounts')->insert([
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => '2025-2026',
|
||||
'opening_balance' => 0,
|
||||
'current_balance' => 370,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('payments')->insert([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'total_amount' => 380,
|
||||
'paid_amount' => 10,
|
||||
'balance' => 370,
|
||||
'number_of_installments' => 1,
|
||||
'payment_method' => 'Cash',
|
||||
'payment_date' => '2025-09-10',
|
||||
'status' => 'Paid',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('discount_usages')->insert([
|
||||
'voucher_id' => 1,
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'discount_amount' => 370,
|
||||
'description' => 'Tuition discount',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
]);
|
||||
|
||||
$service = new ParentInvoiceService;
|
||||
$statement = $service->statement($parentId, '2025-2026');
|
||||
|
||||
$this->assertSame(0.0, $statement['lines'][0]['amount']);
|
||||
$this->assertSame(0.0, $statement['current_balance']);
|
||||
}
|
||||
|
||||
private function seedParent(string $email = 'parent@example.com'): int
|
||||
{
|
||||
return DB::table('users')->insertGetId([
|
||||
|
||||
@@ -58,4 +58,82 @@ class PaymentManualServiceTest extends TestCase
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertSame('parent@example.com', $results[0]['value']);
|
||||
}
|
||||
|
||||
public function test_suggest_returns_matches_in_display_name_order(): void
|
||||
{
|
||||
DB::table('users')->insert([
|
||||
[
|
||||
'id' => 10,
|
||||
'school_id' => 1,
|
||||
'firstname' => 'Zara',
|
||||
'lastname' => 'Akter',
|
||||
'cellphone' => '555-555-5555',
|
||||
'email' => 'zara.akter@example.com',
|
||||
'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',
|
||||
],
|
||||
[
|
||||
'id' => 11,
|
||||
'school_id' => 1,
|
||||
'firstname' => 'Aisha',
|
||||
'lastname' => 'Akter',
|
||||
'cellphone' => '555-555-5556',
|
||||
'email' => 'aisha.akter@example.com',
|
||||
'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',
|
||||
],
|
||||
[
|
||||
'id' => 12,
|
||||
'school_id' => 1,
|
||||
'firstname' => 'Mousumi',
|
||||
'lastname' => 'Akter',
|
||||
'cellphone' => '555-555-5557',
|
||||
'email' => 'mousumi.akter@example.com',
|
||||
'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',
|
||||
],
|
||||
]);
|
||||
|
||||
$invoiceService = Mockery::mock(DiscountInvoiceService::class);
|
||||
$enrollmentService = Mockery::mock(PaymentEnrollmentEventService::class);
|
||||
|
||||
$service = new PaymentManualService($invoiceService, $enrollmentService);
|
||||
$results = $service->suggest('akter');
|
||||
|
||||
$this->assertSame([
|
||||
'Aisha Akter',
|
||||
'Mousumi Akter',
|
||||
'Zara Akter',
|
||||
], array_column($results, 'label'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user