fix apply the plan docs/alrahma_api_fix_plan_school_year_only_v7
API CI/CD / Validate (composer + pint) (push) Successful in 3m10s
API CI/CD / Test (PHPUnit) (push) Failing after 6m49s
API CI/CD / Build frontend assets (push) Successful in 1m3s
API CI/CD / Security audit (push) Failing after 1m0s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped

This commit is contained in:
root
2026-07-07 01:52:29 -04:00
parent 58726ee0e9
commit 031e499819
35 changed files with 5633 additions and 182 deletions
@@ -213,21 +213,7 @@ class ClassProgressQueryService
private function resolveSchoolYearFilter(array $filters): string
{
$schoolYear = trim((string) ($filters['school_year'] ?? ''));
if ($schoolYear !== '') {
return $schoolYear;
}
$schoolYearId = (int) ($filters['school_year_id'] ?? 0);
if ($schoolYearId <= 0 || ! Schema::hasTable('school_years')) {
return '';
}
try {
return trim((string) DB::table('school_years')->where('id', $schoolYearId)->value('name'));
} catch (\Throwable) {
return '';
}
return trim((string) ($filters['school_year'] ?? ''));
}
/**
+20 -6
View File
@@ -6,9 +6,10 @@ use Illuminate\Support\Facades\DB;
class FamilyFinanceService
{
public function loadFinancialsForParents(array $parentIds, int $paymentLimit = 10): array
public function loadFinancialsForParents(array $parentIds, string $schoolYear, int $paymentLimit = 10): array
{
$parentIds = array_values(array_filter(array_map('intval', $parentIds)));
$schoolYear = trim($schoolYear);
if (empty($parentIds)) {
return [
@@ -20,6 +21,8 @@ class FamilyFinanceService
'total_amount' => 0.0,
'paid_amount' => 0.0,
'balance' => 0.0,
'positive_unpaid_balance' => 0.0,
'credit_balance' => 0.0,
],
];
}
@@ -27,6 +30,7 @@ class FamilyFinanceService
$invRows = DB::table('invoices')
->select('id', 'parent_id', 'invoice_number', 'status', 'total_amount', 'paid_amount', 'balance', 'issue_date', 'due_date')
->whereIn('parent_id', $parentIds)
->when($schoolYear !== '', fn ($query) => $query->where('school_year', $schoolYear))
->orderBy('issue_date', 'DESC')
->get()
->map(fn ($r) => (array) $r)
@@ -38,19 +42,29 @@ class FamilyFinanceService
'total_amount' => 0.0,
'paid_amount' => 0.0,
'balance' => 0.0,
'positive_unpaid_balance' => 0.0,
'credit_balance' => 0.0,
];
foreach ($invRows as $ir) {
$invoiceMap[(int) ($ir['id'] ?? 0)] = (string) ($ir['invoice_number'] ?? '');
$balance = (float) ($ir['balance'] ?? 0);
$summary['invoices_count']++;
$summary['total_amount'] += (float) ($ir['total_amount'] ?? 0);
$summary['paid_amount'] += (float) ($ir['paid_amount'] ?? 0);
$summary['balance'] += (float) ($ir['balance'] ?? 0);
$summary['balance'] += $balance;
if ($balance > 0) {
$summary['positive_unpaid_balance'] += $balance;
} elseif ($balance < 0) {
$summary['credit_balance'] += abs($balance);
}
}
$payRows = DB::table('payments')
->select('id', 'parent_id', 'invoice_id', 'paid_amount', 'balance', 'payment_method', 'payment_date', 'status')
->whereIn('parent_id', $parentIds)
->orderBy('payment_date', 'DESC')
$payRows = DB::table('payments as p')
->join('invoices as i', 'i.id', '=', 'p.invoice_id')
->select('p.id', 'p.parent_id', 'p.invoice_id', 'p.paid_amount', 'p.balance', 'p.payment_method', 'p.payment_date', 'p.status')
->whereIn('p.parent_id', $parentIds)
->when($schoolYear !== '', fn ($query) => $query->where('i.school_year', $schoolYear))
->orderBy('p.payment_date', 'DESC')
->limit($paymentLimit)
->get()
->map(fn ($r) => (array) $r)
+5 -4
View File
@@ -224,7 +224,7 @@ class FamilyQueryService
static fn ($g) => (int) ($g['user_id'] ?? 0),
$fam['guardians']
)));
$finance = $this->finance->loadFinancialsForParents($parentIds);
$finance = $this->finance->loadFinancialsForParents($parentIds, $schoolYear);
$fam['invoices'] = $finance['invoices'];
$fam['payments'] = $finance['payments'];
$fam['finance_summary'] = $finance['summary'];
@@ -310,13 +310,13 @@ class FamilyQueryService
$payload = $family->toArray();
$payload['guardians'] = $this->listGuardiansByFamily($familyId);
$payload['students'] = $this->studentsForFamily($familyId, $schoolYear);
$payload['emergency_contacts'] = $this->emergencyContactsForParents($payload['guardians']);
$payload['emergency_contacts'] = $this->emergencyContactsForParents($payload['guardians'], $schoolYear);
$parentIds = array_values(array_filter(array_map(
static fn ($g) => (int) ($g['user_id'] ?? 0),
$payload['guardians']
)));
$finance = $this->finance->loadFinancialsForParents($parentIds);
$finance = $this->finance->loadFinancialsForParents($parentIds, $schoolYear);
$payload['invoices'] = $finance['invoices'];
$payload['payments'] = $finance['payments'];
$payload['finance_summary'] = $finance['summary'];
@@ -346,7 +346,7 @@ class FamilyQueryService
return $rows;
}
private function emergencyContactsForParents(array $guardians): array
private function emergencyContactsForParents(array $guardians, string $schoolYear): array
{
$parentIds = array_values(array_filter(array_map(
static fn ($g) => (int) ($g['user_id'] ?? 0),
@@ -368,6 +368,7 @@ class FamilyQueryService
$rows = DB::table('emergency_contacts')
->select('id', 'parent_id', 'emergency_contact_name', 'relation', 'cellphone', 'email', 'school_year', 'semester', 'created_at', 'updated_at')
->whereIn('parent_id', $parentIds)
->when(trim($schoolYear) !== '', fn ($query) => $query->where('school_year', trim($schoolYear)))
->orderBy('updated_at', 'DESC')
->get()
->map(fn ($r) => (array) $r)
@@ -0,0 +1,267 @@
<?php
namespace App\Services\Families;
use App\Models\StudentClass;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class ParentProfileAdminQueryService
{
public function __construct(private FamilyFinanceService $finance) {}
public function index(string $schoolYear, string $search = '', int $perPage = 25): LengthAwarePaginator
{
$query = DB::table('users as u')
->select(
'u.id',
'u.firstname',
'u.lastname',
'u.email',
'u.cellphone',
'u.status',
DB::raw('COUNT(DISTINCT fg.family_id) as families_count'),
DB::raw('COUNT(DISTINCT s.id) as selected_year_students_count'),
DB::raw('COALESCE(SUM(DISTINCT i.balance), 0) as balance')
)
->leftJoin('family_guardians as fg', 'fg.user_id', '=', 'u.id')
->leftJoin('family_students as fs', 'fs.family_id', '=', 'fg.family_id')
->leftJoin('students as s', function ($join) use ($schoolYear) {
$join->on('s.id', '=', 'fs.student_id')
->where('s.school_year', '=', $schoolYear);
})
->leftJoin('invoices as i', function ($join) use ($schoolYear) {
$join->on('i.parent_id', '=', 'u.id')
->where('i.school_year', '=', $schoolYear);
})
->where($this->relevantParentConstraint($schoolYear))
->groupBy('u.id', 'u.firstname', 'u.lastname', 'u.email', 'u.cellphone', 'u.status')
->orderBy('u.lastname')
->orderBy('u.firstname');
if ($search !== '') {
$needle = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$query->where(function ($inner) use ($needle) {
$inner->whereRaw("CONCAT_WS(' ', u.firstname, u.lastname) LIKE ?", [$needle])
->orWhere('u.email', 'like', $needle)
->orWhere('u.cellphone', 'like', $needle)
->orWhereExists(function ($exists) use ($needle) {
$exists->selectRaw('1')
->from('family_guardians as sfg')
->join('family_students as sfs', 'sfs.family_id', '=', 'sfg.family_id')
->join('students as ss', 'ss.id', '=', 'sfs.student_id')
->whereColumn('sfg.user_id', 'u.id')
->where(function ($student) use ($needle) {
$student->whereRaw("CONCAT_WS(' ', ss.firstname, ss.lastname) LIKE ?", [$needle]);
});
});
});
}
return $query->paginate(max(1, min($perPage, 100)));
}
public function search(string $schoolYear, string $search, int $limit = 10): array
{
if (trim($search) === '') {
return [];
}
return $this->index($schoolYear, $search, $limit)
->getCollection()
->map(fn ($row) => $this->parentSummary((array) $row, $schoolYear))
->all();
}
public function show(int $parentId, string $schoolYear): ?array
{
$parent = DB::table('users')
->select('id', 'firstname', 'lastname', 'email', 'cellphone', 'address_street', 'city', 'state', 'zip', 'status')
->where('id', $parentId)
->first();
if (! $parent) {
return null;
}
$guardians = [
[
'user_id' => $parentId,
'firstname' => $parent->firstname,
'lastname' => $parent->lastname,
],
];
$finance = $this->finance->loadFinancialsForParents([$parentId], $schoolYear);
return [
'parent' => (array) $parent,
'families' => $this->familiesForParent($parentId, $schoolYear),
'students' => $this->studentsForParent($parentId, $schoolYear),
'emergency_contacts' => $this->emergencyContactsForParents($guardians, $schoolYear),
'invoices' => $finance['invoices'],
'payments' => $finance['payments'],
'finance_summary' => $finance['summary'],
];
}
public function updateParent(int $parentId, array $payload): ?array
{
$allowed = array_intersect_key($payload, array_flip([
'firstname',
'lastname',
'email',
'cellphone',
'address_street',
'city',
'state',
'zip',
'status',
]));
if ($allowed !== []) {
$allowed['updated_at'] = now();
DB::table('users')->where('id', $parentId)->update($allowed);
}
$parent = DB::table('users')->where('id', $parentId)->first();
return $parent ? (array) $parent : null;
}
public function parentSummary(array $row, string $schoolYear): array
{
$balance = (float) ($row['balance'] ?? 0);
$primaryFamily = DB::table('family_guardians as fg')
->join('families as f', 'f.id', '=', 'fg.family_id')
->where('fg.user_id', (int) ($row['id'] ?? 0))
->orderByDesc('fg.is_primary')
->orderBy('f.household_name')
->select('f.id', 'f.household_name')
->first();
return [
'id' => (int) ($row['id'] ?? 0),
'firstname' => $row['firstname'] ?? null,
'lastname' => $row['lastname'] ?? null,
'email' => $row['email'] ?? null,
'cellphone' => $row['cellphone'] ?? null,
'is_active' => strtolower((string) ($row['status'] ?? '')) === 'active',
'families_count' => (int) ($row['families_count'] ?? 0),
'students_count' => (int) ($row['selected_year_students_count'] ?? 0),
'selected_year_students_count' => (int) ($row['selected_year_students_count'] ?? 0),
'balance' => $balance,
'positive_unpaid_balance' => max(0, $balance),
'credit_balance' => $balance < 0 ? abs($balance) : 0.0,
'primary_family' => $primaryFamily ? (array) $primaryFamily : null,
'school_year' => $schoolYear,
];
}
private function relevantParentConstraint(string $schoolYear): callable
{
return function ($query) use ($schoolYear) {
$query->whereExists(function ($exists) use ($schoolYear) {
$exists->selectRaw('1')
->from('family_guardians as rfg')
->join('family_students as rfs', 'rfs.family_id', '=', 'rfg.family_id')
->join('students as rs', 'rs.id', '=', 'rfs.student_id')
->whereColumn('rfg.user_id', 'u.id')
->where('rs.school_year', $schoolYear);
})->orWhereExists(function ($exists) use ($schoolYear) {
$exists->selectRaw('1')
->from('students as direct_students')
->whereColumn('direct_students.parent_id', 'u.id')
->where('direct_students.school_year', $schoolYear);
})->orWhereExists(function ($exists) use ($schoolYear) {
$exists->selectRaw('1')
->from('invoices as ri')
->whereColumn('ri.parent_id', 'u.id')
->where('ri.school_year', $schoolYear);
})->orWhereExists(function ($exists) use ($schoolYear) {
$exists->selectRaw('1')
->from('parent_accounts as pa')
->whereColumn('pa.parent_id', 'u.id')
->where('pa.school_year', $schoolYear);
})->orWhereExists(function ($exists) use ($schoolYear) {
$exists->selectRaw('1')
->from('emergency_contacts as ec')
->whereColumn('ec.parent_id', 'u.id')
->where('ec.school_year', $schoolYear);
});
};
}
private function familiesForParent(int $parentId, string $schoolYear): array
{
return DB::table('family_guardians as fg')
->join('families as f', 'f.id', '=', 'fg.family_id')
->where('fg.user_id', $parentId)
->whereExists(function ($exists) use ($schoolYear) {
$exists->selectRaw('1')
->from('family_students as fs')
->join('students as s', 's.id', '=', 'fs.student_id')
->whereColumn('fs.family_id', 'f.id')
->where('s.school_year', $schoolYear);
})
->orderBy('f.household_name')
->select('f.*', 'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms')
->get()
->map(fn ($row) => (array) $row)
->all();
}
private function studentsForParent(int $parentId, string $schoolYear): array
{
$rows = DB::table('students as s')
->select('s.id', 's.firstname', 's.lastname', 's.parent_id')
->where('s.school_year', $schoolYear)
->where(function ($query) use ($parentId) {
$query->where('s.parent_id', $parentId)
->orWhereExists(function ($exists) use ($parentId) {
$exists->selectRaw('1')
->from('family_guardians as fg')
->join('family_students as fs', 'fs.family_id', '=', 'fg.family_id')
->whereColumn('fs.student_id', 's.id')
->where('fg.user_id', $parentId);
});
})
->orderBy('s.lastname')
->orderBy('s.firstname')
->get()
->map(fn ($row) => (array) $row)
->all();
foreach ($rows as &$row) {
$studentId = (int) ($row['id'] ?? 0);
$classSectionName = $studentId > 0
? (string) (StudentClass::getClassSectionsByStudentId($studentId, $schoolYear) ?? '')
: '';
$row['class_section_name'] = $classSectionName;
$row['grade'] = $classSectionName;
}
unset($row);
return $rows;
}
private function emergencyContactsForParents(array $guardians, string $schoolYear): array
{
$parentIds = array_values(array_filter(array_map(
static fn ($g) => (int) ($g['user_id'] ?? 0),
$guardians
)));
if ($parentIds === []) {
return [];
}
return DB::table('emergency_contacts')
->select('id', 'parent_id', 'emergency_contact_name', 'relation', 'cellphone', 'email', 'school_year', 'semester', 'created_at', 'updated_at')
->whereIn('parent_id', $parentIds)
->where('school_year', $schoolYear)
->orderByDesc('updated_at')
->get()
->map(fn ($row) => (array) $row)
->all();
}
}
@@ -145,12 +145,22 @@ class SchoolYearClosureService
'totals' => [
'students_considered' => $promotions['summary']['total_students'],
'students_blocked' => $promotions['summary']['hold'],
'parents_with_balances' => $balances['summary']['parents_with_non_zero_balance'],
'net_balance_to_transfer' => $balances['summary']['net_balance_to_transfer'],
'parents_with_balances' => $balances['summary']['parents_with_positive_balance'],
'parents_with_positive_balance' => $balances['summary']['parents_with_positive_balance'],
'parents_with_credit_balance' => $balances['summary']['parents_with_credit_balance'],
'total_positive_unpaid_balance' => $balances['summary']['total_positive_unpaid_balance'],
'total_credit_balance' => $balances['summary']['total_credit_balance'],
'net_balance_to_transfer' => $balances['summary']['net_balance_impact'],
'net_balance_impact' => $balances['summary']['net_balance_impact'],
],
];
}
public function summaryByYearName(string $schoolYear): array
{
return $this->summary((int) $this->findByNameOrFail($schoolYear)->id);
}
public function closingReport(int $id): array
{
$year = $this->findOrFail($id);
@@ -200,12 +210,23 @@ class SchoolYearClosureService
'parents_with_unpaid_balances' => (int) ($balanceCounts['transferred_parents'] ?? $transferTotals['parents_with_unpaid_balances']),
'total_unpaid_balance_transferred' => round((float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred']), 2),
'parents_with_credit_balances' => (int) ($balanceCounts['credit_parents'] ?? $transferTotals['parents_with_credit_balances']),
'total_credit_transferred' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_transferred']), 2),
'total_credit_carried_or_reported' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']), 2),
'total_credit_transferred' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']), 2),
'net_balance_impact' => round(
(float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred'])
- (float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']),
2
),
],
'warnings' => $warnings,
];
}
public function closingReportByYearName(string $schoolYear): array
{
return $this->closingReport((int) $this->findByNameOrFail($schoolYear)->id);
}
public function validateClose(int $id, array $payload): array
{
$year = $this->findOrFail($id);
@@ -270,6 +291,11 @@ class SchoolYearClosureService
];
}
public function validateCloseByYearName(string $schoolYear, array $payload): array
{
return $this->validateClose((int) $this->findByNameOrFail($schoolYear)->id, $payload);
}
public function previewClose(int $id, array $payload): array
{
$year = $this->findOrFail($id);
@@ -284,6 +310,11 @@ class SchoolYearClosureService
];
}
public function previewCloseByYearName(string $schoolYear, array $payload): array
{
return $this->previewClose((int) $this->findByNameOrFail($schoolYear)->id, $payload);
}
public function close(int $id, array $payload, ?int $actorId): array
{
$year = $this->findOrFail($id);
@@ -376,6 +407,11 @@ class SchoolYearClosureService
});
}
public function closeByYearName(string $schoolYear, array $payload, ?int $actorId): array
{
return $this->close((int) $this->findByNameOrFail($schoolYear)->id, $payload, $actorId);
}
public function reopen(int $id, ?int $actorId): array
{
$year = $this->findOrFail($id);
@@ -411,6 +447,11 @@ class SchoolYearClosureService
});
}
public function reopenByYearName(string $schoolYear, ?int $actorId): array
{
return $this->reopen((int) $this->findByNameOrFail($schoolYear)->id, $actorId);
}
public function parentBalances(int $id, ?string $toSchoolYear = null): array
{
$year = $this->findOrFail($id);
@@ -421,6 +462,11 @@ class SchoolYearClosureService
);
}
public function parentBalancesByYearName(string $schoolYear, ?string $toSchoolYear = null): array
{
return $this->parentBalances((int) $this->findByNameOrFail($schoolYear)->id, $toSchoolYear);
}
public function promotionPreview(int $id, ?string $toSchoolYear = null): array
{
$year = $this->findOrFail($id);
@@ -431,6 +477,11 @@ class SchoolYearClosureService
);
}
public function promotionPreviewByYearName(string $schoolYear, ?string $toSchoolYear = null): array
{
return $this->promotionPreview((int) $this->findByNameOrFail($schoolYear)->id, $toSchoolYear);
}
public function findOrFail(int $id): SchoolYear
{
$this->resolver->ensureCurrentTracked();
@@ -438,6 +489,13 @@ class SchoolYearClosureService
return SchoolYear::query()->findOrFail($id);
}
public function findByNameOrFail(string $schoolYear): SchoolYear
{
$this->resolver->ensureCurrentTracked();
return SchoolYear::query()->where('name', trim($schoolYear))->firstOrFail();
}
private function validateNewYearPayload(SchoolYear $currentYear, array $newYear): array
{
$errors = [];
@@ -683,85 +741,110 @@ class SchoolYearClosureService
private function buildParentBalanceRows(string $fromSchoolYear, string $toSchoolYear): array
{
$invoiceBalances = DB::table('invoices')
->where('school_year', $fromSchoolYear)
->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), ['void', 'voided', 'cancelled', 'canceled'])
->select('parent_id')
->selectRaw('ROUND(SUM(COALESCE(balance, 0)), 2) as net_balance')
->selectRaw('COUNT(CASE WHEN COALESCE(balance, 0) > 0.01 THEN 1 END) as unpaid_invoice_count')
->groupBy('parent_id')
->havingRaw('ABS(SUM(COALESCE(balance, 0))) > 0.01')
->get();
$invoices = $this->eligibleCarryoverInvoiceQuery($fromSchoolYear)
->whereRaw('ABS(COALESCE(balance, 0)) > 0.01')
->get(['id', 'parent_id', 'balance']);
$rows = [];
$summary = [
'parents_with_non_zero_balance' => 0,
'parents_with_credit_balance' => 0,
'existing_transfer_conflicts' => 0,
'total_old_unpaid_balance' => 0.0,
'total_credit_balance' => 0.0,
'net_balance_to_transfer' => 0.0,
];
foreach ($invoiceBalances as $balanceRow) {
$parentId = (int) ($balanceRow->parent_id ?? 0);
$balancesByParent = [];
foreach ($invoices as $invoice) {
$parentId = (int) ($invoice->parent_id ?? 0);
if ($parentId <= 0) {
continue;
}
$net = round((float) ($balanceRow->net_balance ?? 0), 2);
if (abs($net) < 0.01) {
$balance = round((float) ($invoice->balance ?? 0), 2);
$balancesByParent[$parentId] ??= [
'positive_unpaid_balance' => 0.0,
'credit_balance' => 0.0,
'positive_invoice_ids' => [],
'credit_invoice_ids' => [],
];
if ($balance > 0.01) {
$balancesByParent[$parentId]['positive_unpaid_balance'] += $balance;
$balancesByParent[$parentId]['positive_invoice_ids'][] = (int) $invoice->id;
} elseif ($balance < -0.01) {
$balancesByParent[$parentId]['credit_balance'] += abs($balance);
$balancesByParent[$parentId]['credit_invoice_ids'][] = (int) $invoice->id;
}
}
$rows = [];
$summary = [
'parents_with_positive_balance' => 0,
'parents_with_credit_balance' => 0,
'parents_with_net_non_zero_balance' => 0,
'existing_transfer_conflicts' => 0,
'total_positive_unpaid_balance' => 0.0,
'total_credit_balance' => 0.0,
'net_balance_impact' => 0.0,
// Deprecated aliases retained for older UI/tests during the migration.
'parents_with_non_zero_balance' => 0,
'total_old_unpaid_balance' => 0.0,
'net_balance_to_transfer' => 0.0,
];
foreach ($balancesByParent as $parentId => $balanceRow) {
$positive = round((float) $balanceRow['positive_unpaid_balance'], 2);
$credit = round((float) $balanceRow['credit_balance'], 2);
$net = round($positive - $credit, 2);
if ($positive < 0.01 && $credit < 0.01) {
continue;
}
$existing = ParentBalanceTransfer::query()
->where('parent_id', $parentId)
->where('from_school_year', $fromSchoolYear)
->where('to_school_year', $toSchoolYear)
->first();
$sourceInvoiceIds = DB::table('invoices')
->where('parent_id', $parentId)
->where('school_year', $fromSchoolYear)
->whereRaw('ABS(COALESCE(balance, 0)) > 0.01')
->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), ['void', 'voided', 'cancelled', 'canceled'])
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
$existing = $positive > 0.01
? ParentBalanceTransfer::query()
->where('parent_id', $parentId)
->where('from_school_year', $fromSchoolYear)
->where('to_school_year', $toSchoolYear)
->first()
: null;
$row = [
'parent_id' => $parentId,
'parent_name' => $this->resolveParentName($parentId),
'student_names' => $this->resolveStudentNames($parentId, $fromSchoolYear),
'unpaid_invoice_count' => (int) ($balanceRow->unpaid_invoice_count ?? 0),
'old_unpaid_balance' => $net > 0 ? $net : 0.0,
'credit_balance' => $net < 0 ? abs($net) : 0.0,
'unpaid_invoice_count' => count($balanceRow['positive_invoice_ids']),
'positive_unpaid_balance' => $positive,
'old_unpaid_balance' => $positive,
'credit_balance' => $credit,
'net_balance' => $net,
'amount_to_transfer' => $net,
'transfer_status' => $existing?->status ?? ($net > 0 ? 'pending' : 'credit_pending'),
'amount_to_transfer' => $positive,
'transfer_status' => $existing?->status ?? ($positive > 0.01 ? 'pending' : 'credit_reported'),
'existing_transfer_id' => $existing?->id,
'source_invoice_ids' => $sourceInvoiceIds,
'existing_transfer_conflict' => $existing !== null,
'positive_invoice_ids' => $balanceRow['positive_invoice_ids'],
'credit_invoice_ids' => $balanceRow['credit_invoice_ids'],
'source_invoice_ids' => $balanceRow['positive_invoice_ids'],
];
$rows[] = $row;
$summary['parents_with_non_zero_balance']++;
$summary['net_balance_to_transfer'] += $net;
$summary['total_old_unpaid_balance'] += $row['old_unpaid_balance'];
$summary['total_credit_balance'] += $row['credit_balance'];
if ($row['credit_balance'] > 0) {
$summary['parents_with_credit_balance']++;
if ($positive > 0.01) {
$summary['parents_with_positive_balance']++;
$summary['total_positive_unpaid_balance'] += $positive;
}
if ($row['existing_transfer_id']) {
if ($credit > 0.01) {
$summary['parents_with_credit_balance']++;
$summary['total_credit_balance'] += $credit;
}
if (abs($net) > 0.01) {
$summary['parents_with_net_non_zero_balance']++;
}
if ($row['existing_transfer_conflict']) {
$summary['existing_transfer_conflicts']++;
}
}
usort($rows, static fn (array $a, array $b) => $b['amount_to_transfer'] <=> $a['amount_to_transfer']);
$summary['net_balance_to_transfer'] = round($summary['net_balance_to_transfer'], 2);
$summary['total_old_unpaid_balance'] = round($summary['total_old_unpaid_balance'], 2);
$summary['total_positive_unpaid_balance'] = round($summary['total_positive_unpaid_balance'], 2);
$summary['total_credit_balance'] = round($summary['total_credit_balance'], 2);
$summary['net_balance_impact'] = round($summary['total_positive_unpaid_balance'] - $summary['total_credit_balance'], 2);
$summary['parents_with_non_zero_balance'] = $summary['parents_with_net_non_zero_balance'];
$summary['total_old_unpaid_balance'] = $summary['total_positive_unpaid_balance'];
$summary['net_balance_to_transfer'] = $summary['net_balance_impact'];
return [
'from_school_year' => $fromSchoolYear,
@@ -771,6 +854,13 @@ class SchoolYearClosureService
];
}
private function eligibleCarryoverInvoiceQuery(string $schoolYear)
{
return DB::table('invoices')
->where('school_year', $schoolYear)
->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), ['void', 'voided', 'cancelled', 'canceled', 'draft', 'pending']);
}
private function resolveTargetSection(?int $targetLevelId, string $schoolYear): array
{
if ($targetLevelId === null || $targetLevelId <= 0) {
@@ -905,7 +995,12 @@ class SchoolYearClosureService
foreach ($rows as $row) {
$amount = round((float) $row['amount_to_transfer'], 2);
if (abs($amount) < 0.01) {
$credit = round((float) ($row['credit_balance'] ?? 0), 2);
if ($amount < 0.01) {
if ($credit > 0.01) {
$counts['credit_parents']++;
$counts['credit_amount'] += $credit;
}
continue;
}
@@ -924,36 +1019,51 @@ class SchoolYearClosureService
));
}
ParentAccount::query()->updateOrCreate(
['parent_id' => $row['parent_id'], 'school_year' => $fromSchoolYear],
['opening_balance' => 0, 'current_balance' => $amount]
);
$newAccount = ParentAccount::query()->firstOrCreate(
['parent_id' => $row['parent_id'], 'school_year' => $toSchoolYear],
['opening_balance' => 0, 'current_balance' => 0]
);
if (Schema::hasColumn('parent_accounts', 'school_year_id') && $newAccount->school_year_id === null) {
$newAccount->school_year_id = $this->schoolYearIdForName($toSchoolYear);
}
$newAccount->opening_balance = round((float) $newAccount->opening_balance + $amount, 2);
$newAccount->current_balance = round((float) $newAccount->current_balance + $amount, 2);
$newAccount->save();
$transfer = ParentBalanceTransfer::query()->create([
$transferPayload = [
'parent_id' => $row['parent_id'],
'from_school_year' => $fromSchoolYear,
'to_school_year' => $toSchoolYear,
'amount' => $amount,
'status' => 'transferred',
'source_summary_json' => [
'source_invoice_ids' => $row['source_invoice_ids'],
'old_unpaid_balance' => $row['old_unpaid_balance'],
'credit_balance' => $row['credit_balance'],
'source_invoice_ids' => $row['positive_invoice_ids'] ?? $row['source_invoice_ids'] ?? [],
'positive_invoice_ids' => $row['positive_invoice_ids'] ?? $row['source_invoice_ids'] ?? [],
'credit_invoice_ids' => $row['credit_invoice_ids'] ?? [],
'old_unpaid_balance' => $row['old_unpaid_balance'] ?? $amount,
'positive_unpaid_balance' => $row['positive_unpaid_balance'] ?? $amount,
'credit_balance' => $credit,
'net_balance' => $row['net_balance'] ?? ($amount - $credit),
],
'created_by' => $actorId,
]);
];
if (Schema::hasColumn('parent_balance_transfers', 'source_summary')) {
$transferPayload['source_summary'] = $transferPayload['source_summary_json'];
}
$fromYearId = $this->schoolYearIdForName($fromSchoolYear);
$toYearId = $this->schoolYearIdForName($toSchoolYear);
if (Schema::hasColumn('parent_balance_transfers', 'from_school_year_id')) {
$transferPayload['from_school_year_id'] = $fromYearId;
}
if (Schema::hasColumn('parent_balance_transfers', 'to_school_year_id')) {
$transferPayload['to_school_year_id'] = $toYearId;
}
$transfer = ParentBalanceTransfer::query()->create($transferPayload);
$newInvoiceId = null;
if ($amount > 0) {
$newInvoiceId = DB::table('invoices')->insertGetId([
$invoicePayload = [
'parent_id' => $row['parent_id'],
'invoice_number' => sprintf('OB-%s-%d', str_replace('-', '', $toSchoolYear), $transfer->id),
'total_amount' => $amount,
@@ -970,15 +1080,23 @@ class SchoolYearClosureService
'updated_by' => $actorId,
'semester' => Configuration::getConfig('semester'),
'balance_transfer_id' => $transfer->id,
]);
];
if (Schema::hasColumn('invoices', 'invoice_type')) {
$invoicePayload['invoice_type'] = 'opening_balance';
}
if (Schema::hasColumn('invoices', 'school_year_id')) {
$invoicePayload['school_year_id'] = $toYearId;
}
$newInvoiceId = DB::table('invoices')->insertGetId($invoicePayload);
$transfer->new_invoice_id = $newInvoiceId;
$transfer->save();
$counts['transferred_parents']++;
$counts['transferred_amount'] += $amount;
} else {
$counts['credit_parents']++;
$counts['credit_amount'] += abs($amount);
if ($credit > 0.01) {
$counts['credit_parents']++;
$counts['credit_amount'] += $credit;
}
}
$this->logAudit(
@@ -1018,6 +1136,17 @@ class SchoolYearClosureService
->count();
}
private function schoolYearIdForName(string $schoolYear): ?int
{
if (! Schema::hasTable('school_years')) {
return null;
}
$id = SchoolYear::query()->where('name', $schoolYear)->value('id');
return $id ? (int) $id : null;
}
private function countFinancialInconsistencies(string $schoolYear): int
{
return DB::table('invoices')
@@ -1114,7 +1243,9 @@ class SchoolYearClosureService
'parents_with_unpaid_balances' => 0,
'total_unpaid_balance_transferred' => 0.0,
'parents_with_credit_balances' => 0,
'total_credit_carried_or_reported' => 0.0,
'total_credit_transferred' => 0.0,
'net_balance_impact' => 0.0,
];
if (! Schema::hasTable('parent_balance_transfers')) {
@@ -1124,25 +1255,49 @@ class SchoolYearClosureService
$rows = DB::table('parent_balance_transfers')
->where('from_school_year', $fromSchoolYear)
->when($toSchoolYear !== null && trim($toSchoolYear) !== '', fn ($query) => $query->where('to_school_year', $toSchoolYear))
->get(['amount']);
->get(['amount', 'source_summary_json', 'source_summary']);
foreach ($rows as $row) {
$amount = round((float) ($row->amount ?? 0), 2);
if ($amount > 0.01) {
$totals['parents_with_unpaid_balances']++;
$totals['total_unpaid_balance_transferred'] += $amount;
} elseif ($amount < -0.01) {
}
$sourceSummary = $this->decodeTransferSourceSummary($row);
$credit = round((float) ($sourceSummary['credit_balance'] ?? 0), 2);
if ($credit > 0.01) {
$totals['parents_with_credit_balances']++;
$totals['total_credit_transferred'] += abs($amount);
$totals['total_credit_carried_or_reported'] += $credit;
}
}
$totals['total_unpaid_balance_transferred'] = round($totals['total_unpaid_balance_transferred'], 2);
$totals['total_credit_transferred'] = round($totals['total_credit_transferred'], 2);
$totals['total_credit_carried_or_reported'] = round($totals['total_credit_carried_or_reported'], 2);
$totals['total_credit_transferred'] = $totals['total_credit_carried_or_reported'];
$totals['net_balance_impact'] = round(
$totals['total_unpaid_balance_transferred'] - $totals['total_credit_carried_or_reported'],
2
);
return $totals;
}
private function decodeTransferSourceSummary(object $row): array
{
$raw = $row->source_summary_json ?? $row->source_summary ?? null;
if (is_array($raw)) {
return $raw;
}
if (! is_string($raw) || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
private function resolveUserName(int $userId): string|int|null
{
if ($userId <= 0) {
@@ -1196,6 +1351,11 @@ class SchoolYearClosureService
return $this->presentSchoolYear($year->fresh());
}
public function archiveByYearName(string $schoolYear, ?int $actorId): array
{
return $this->archive((int) $this->findByNameOrFail($schoolYear)->id, $actorId);
}
private function presentSchoolYear(?SchoolYear $year): ?array
{
if (! $year) {
@@ -30,7 +30,7 @@ class SchoolYearWriteGuard
foreach ($years as $year) {
$row = $statuses->get($year);
if ($row && $row->status !== SchoolYear::STATUS_ACTIVE) {
if ($row && in_array($row->status, [SchoolYear::STATUS_CLOSED, SchoolYear::STATUS_ARCHIVED], true)) {
throw new RuntimeException(sprintf(
'School year %s is %s and read-only.',
$year,
@@ -0,0 +1,13 @@
<?php
namespace App\Services\SchoolYears;
final class SelectedSchoolYearContext
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly string $status,
public readonly bool $isReadOnly,
) {}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Services\SchoolYears;
use App\Models\Configuration;
use App\Models\SchoolYear;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
class SelectedSchoolYearContextService
{
public function __construct(private SchoolYearResolver $resolver) {}
public function fromRequest(Request $request): SelectedSchoolYearContext
{
$explicit = false;
$name = $this->selectedName($request, $explicit);
if ($name === '') {
$name = $this->activeSchoolYearName();
}
if ($name === '') {
throw new UnprocessableEntityHttpException('A school_year value is required.');
}
if (! Schema::hasTable('school_years')) {
throw new UnprocessableEntityHttpException('School-year tracking is unavailable.');
}
$this->resolver->ensureCurrentTracked();
$year = SchoolYear::query()->where('name', $name)->first();
if (! $year) {
throw new NotFoundHttpException(sprintf('School year %s was not found.', $name));
}
return new SelectedSchoolYearContext(
(int) $year->id,
(string) $year->name,
(string) $year->status,
in_array($year->status, [SchoolYear::STATUS_CLOSED, SchoolYear::STATUS_ARCHIVED], true),
);
}
public function fromName(string $schoolYear): SelectedSchoolYearContext
{
$request = Request::create('/', 'GET', ['school_year' => $schoolYear]);
return $this->fromRequest($request);
}
private function selectedName(Request $request, bool &$explicit): string
{
$sources = [
$request->query('school_year'),
$request->headers->get('X-School-Year'),
$request->input('school_year'),
];
foreach ($sources as $value) {
if (is_string($value) && trim($value) !== '') {
$explicit = true;
return trim($value);
}
}
return '';
}
private function activeSchoolYearName(): string
{
$configured = trim((string) (Configuration::getConfig('school_year') ?? ''));
if ($configured !== '') {
return $configured;
}
if (! Schema::hasTable('school_years')) {
return '';
}
$year = SchoolYear::query()->where('is_current', true)->orderByDesc('id')->first();
return $year ? trim((string) $year->name) : '';
}
}
+92 -17
View File
@@ -3,9 +3,11 @@
namespace App\Services\Staff;
use App\Models\Staff;
use App\Models\Role;
use App\Models\User;
use App\Services\System\GlobalConfigService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use RuntimeException;
class StaffCommandService
@@ -15,9 +17,11 @@ class StaffCommandService
public function create(array $payload): Staff
{
return DB::transaction(function () use ($payload) {
$userId = $this->resolveUserId($payload);
$role = $this->resolveRole($payload);
$user = $this->resolveOrCreateUser($payload, $role);
$userId = (int) $user->id;
$roleName = (string) ($payload['role_name'] ?? '');
$roleName = (string) $role->name;
$activeRole = strtolower($roleName);
if (! empty($payload['status']) && strtolower((string) $payload['status']) === 'inactive') {
$activeRole = 'inactive';
@@ -25,8 +29,14 @@ class StaffCommandService
$schoolYear = (string) ($payload['school_year'] ?? $this->configService->getSchoolYear() ?? '');
$staff = Staff::query()->create([
DB::table('user_roles')->updateOrInsert(
['user_id' => $userId, 'role_id' => (int) $role->id],
['created_at' => now(), 'updated_at' => now()]
);
$staff = Staff::query()->updateOrCreate([
'user_id' => $userId,
], [
'firstname' => (string) $payload['firstname'],
'lastname' => (string) $payload['lastname'],
'email' => (string) $payload['email'],
@@ -34,7 +44,6 @@ class StaffCommandService
'role_name' => $roleName,
'active_role' => $activeRole,
'school_year' => $schoolYear,
'created_at' => now(),
'updated_at' => now(),
]);
@@ -49,15 +58,23 @@ class StaffCommandService
public function update(Staff $staff, array $payload): Staff
{
return DB::transaction(function () use ($staff, $payload) {
$role = array_key_exists('role_id', $payload) || array_key_exists('role_name', $payload)
? $this->resolveRole($payload)
: null;
$updates = [];
foreach (['firstname', 'lastname', 'email', 'phone', 'role_name', 'school_year'] as $field) {
foreach (['firstname', 'lastname', 'email', 'phone', 'school_year'] as $field) {
if (array_key_exists($field, $payload) && $payload[$field] !== '') {
$updates[$field] = $payload[$field];
}
}
if (array_key_exists('role_name', $updates)) {
if ($role) {
$updates['role_name'] = (string) $role->name;
$updates['active_role'] = strtolower((string) $updates['role_name']);
DB::table('user_roles')->updateOrInsert(
['user_id' => (int) $staff->user_id, 'role_id' => (int) $role->id],
['created_at' => now(), 'updated_at' => now()]
);
}
if (! empty($payload['status']) && strtolower((string) $payload['status']) === 'inactive') {
$updates['active_role'] = 'inactive';
@@ -68,6 +85,15 @@ class StaffCommandService
if (! empty($updates)) {
$staff->fill($updates);
$staff->save();
$userUpdates = array_intersect_key($updates, array_flip(['firstname', 'lastname', 'email', 'phone', 'school_year']));
if (isset($userUpdates['phone'])) {
$userUpdates['cellphone'] = $userUpdates['phone'];
unset($userUpdates['phone']);
}
if ($userUpdates !== []) {
User::query()->whereKey((int) $staff->user_id)->update($userUpdates + ['updated_at' => now()]);
}
}
return $staff->fresh();
@@ -79,20 +105,69 @@ class StaffCommandService
return (bool) $staff->delete();
}
private function resolveUserId(array $payload): int
private function resolveRole(array $payload): Role
{
if (! empty($payload['user_id'])) {
return (int) $payload['user_id'];
}
$email = $payload['email'] ?? null;
if ($email) {
$userId = User::query()->where('email', $email)->value('id');
if ($userId) {
return (int) $userId;
if (! empty($payload['role_id'])) {
$role = Role::query()->whereKey((int) $payload['role_id'])->where('is_active', 1)->first();
if ($role) {
return $role;
}
}
throw new RuntimeException('A valid user_id or existing email is required.');
$roleName = trim((string) ($payload['role_name'] ?? ''));
if ($roleName !== '') {
$role = Role::query()
->where('is_active', 1)
->where(function ($query) use ($roleName) {
$query->whereRaw('LOWER(name) = ?', [strtolower($roleName)])
->orWhereRaw('LOWER(slug) = ?', [strtolower($roleName)]);
})
->first();
if ($role) {
return $role;
}
}
throw new RuntimeException('A valid active role is required.');
}
private function resolveOrCreateUser(array $payload, Role $role): User
{
if (! empty($payload['user_id'])) {
$user = User::query()->find((int) $payload['user_id']);
if (! $user) {
throw new RuntimeException('The selected user was not found.');
}
} else {
$email = strtolower(trim((string) ($payload['email'] ?? '')));
if ($email === '') {
throw new RuntimeException('A valid email is required.');
}
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first() ?? new User();
}
$isNew = ! $user->exists;
$user->firstname = (string) $payload['firstname'];
$user->lastname = (string) $payload['lastname'];
$user->email = strtolower(trim((string) $payload['email']));
$user->cellphone = $payload['phone'] ?? $user->cellphone ?? null;
$user->school_year = (string) ($payload['school_year'] ?? $this->configService->getSchoolYear() ?? '');
$user->semester = $user->semester ?: (string) ($this->configService->getSemester() ?? '');
$user->status = ! empty($payload['status']) && strtolower((string) $payload['status']) === 'inactive' ? 'Inactive' : 'Active';
$user->is_verified = 1;
$user->is_suspended = 0;
$user->accept_school_policy = $user->accept_school_policy ?? 1;
$user->school_id = $user->school_id ?: random_int(100000, 999999);
if ($isNew) {
$password = (string) ($payload['password'] ?? '');
if ($password === '') {
throw new RuntimeException('A temporary password is required for a new staff user.');
}
$user->password = Hash::make($password);
}
$user->user_type = (string) $role->name;
$user->save();
return $user;
}
}
+23 -1
View File
@@ -24,6 +24,29 @@ class StaffQueryService
$query->whereRaw('LOWER(active_role) = ?', [$role]);
}
$schoolYear = (string) ($filters['school_year'] ?? $this->configService->getSchoolYear() ?? '');
if ($schoolYear !== '') {
$query->where('school_year', $schoolYear);
}
if (! empty($filters['status'])) {
$status = strtolower(trim((string) $filters['status']));
if ($status === 'inactive') {
$query->whereRaw('LOWER(active_role) = ?', ['inactive']);
} elseif ($status === 'active') {
$query->whereRaw('LOWER(active_role) != ?', ['inactive']);
}
}
if (! empty($filters['search'])) {
$search = '%'.strtolower(trim((string) $filters['search'])).'%';
$query->where(function ($inner) use ($search) {
$inner->whereRaw('LOWER(firstname) LIKE ?', [$search])
->orWhereRaw('LOWER(lastname) LIKE ?', [$search])
->orWhereRaw('LOWER(email) LIKE ?', [$search]);
});
}
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
@@ -31,7 +54,6 @@ class StaffQueryService
->orderBy($sortBy, $sortDir)
->paginate($perPage, ['*'], 'page', $page);
$schoolYear = (string) ($filters['school_year'] ?? $this->configService->getSchoolYear() ?? '');
$semester = (string) ($filters['semester'] ?? $this->configService->getSemester() ?? '');
$assignByTeacher = $this->buildAssignments($schoolYear);
@@ -16,7 +16,6 @@ class WhatsappContextService
$override,
request()->query('school_year'),
request()->header('X-School-Year'),
request()->header('X-Selected-School-Year'),
Configuration::getConfig('school_year'),
] as $candidate) {
$value = trim((string) ($candidate ?? ''));