add test batches

This commit is contained in:
root
2026-06-08 23:45:55 -04:00
parent c792b8be05
commit 8d4d610b82
1480 changed files with 22587 additions and 10762 deletions
@@ -4,7 +4,6 @@ namespace App\Services\Finance;
use App\Models\Configuration;
use App\Models\FinanceFollowUpNote;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -36,11 +35,11 @@ class ParentPaymentFollowUpService
$discounted = (float) ($discountByInvoice[$invoiceId] ?? 0);
$refunded = (float) ($refundByInvoice[$invoiceId] ?? 0);
$open = max(0, $total - $paid - $discounted - $refunded);
if (! $includePaid && $open <= 0) {
if (!$includePaid && $open <= 0) {
continue;
}
if (! isset($parents[$parentId])) {
if (!isset($parents[$parentId])) {
$parents[$parentId] = [
'parent_id' => $parentId,
'parent_name' => $invoice->parent_name ?? null,
@@ -72,7 +71,7 @@ class ParentPaymentFollowUpService
];
}
$row = &$parents[$parentId];
$row =& $parents[$parentId];
$row['invoice_count']++;
$row['total_invoiced'] += $total;
$row['total_paid'] += $paid;
@@ -100,7 +99,7 @@ class ParentPaymentFollowUpService
$row['promise_to_pay_amount'] = $note->promise_to_pay_amount !== null ? (float) $note->promise_to_pay_amount : null;
$row['escalation_level'] = (int) ($note->escalation_level ?? 0);
}
$row['notes_count'] = FinanceFollowUpNote::query()->where('parent_id', $parentId)->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear))->count();
$row['notes_count'] = FinanceFollowUpNote::query()->where('parent_id', $parentId)->when($schoolYear, fn($q) => $q->where('school_year', $schoolYear))->count();
$row['days_overdue'] = $this->daysOverdue($row['oldest_unpaid_invoice_date']);
$row['aging_bucket'] = $this->agingBucket($row['days_overdue']);
$row['risk_flags'] = $this->riskFlags($row);
@@ -108,26 +107,15 @@ class ParentPaymentFollowUpService
unset($row);
$rows = array_values(array_filter($parents, function (array $row) use ($filters, $minimumBalance) {
if ($row['open_balance'] < $minimumBalance) {
return false;
}
if (! empty($filters['aging_bucket']) && $row['aging_bucket'] !== $filters['aging_bucket']) {
return false;
}
if (! empty($filters['follow_up_status']) && $row['follow_up_status'] !== $filters['follow_up_status']) {
return false;
}
if (! empty($filters['next_follow_up_due_before']) && (empty($row['next_follow_up_date']) || strtotime($row['next_follow_up_date']) > strtotime($filters['next_follow_up_due_before']))) {
return false;
}
if (! empty($filters['promise_to_pay_due_before']) && (empty($row['promise_to_pay_date']) || strtotime($row['promise_to_pay_date']) > strtotime($filters['promise_to_pay_due_before']))) {
return false;
}
if ($row['open_balance'] < $minimumBalance) return false;
if (!empty($filters['aging_bucket']) && $row['aging_bucket'] !== $filters['aging_bucket']) return false;
if (!empty($filters['follow_up_status']) && $row['follow_up_status'] !== $filters['follow_up_status']) return false;
if (!empty($filters['next_follow_up_due_before']) && (empty($row['next_follow_up_date']) || strtotime($row['next_follow_up_date']) > strtotime($filters['next_follow_up_due_before']))) return false;
if (!empty($filters['promise_to_pay_due_before']) && (empty($row['promise_to_pay_date']) || strtotime($row['promise_to_pay_date']) > strtotime($filters['promise_to_pay_due_before']))) return false;
return true;
}));
usort($rows, fn ($a, $b) => [$b['open_balance'], $b['days_overdue']] <=> [$a['open_balance'], $a['days_overdue']]);
usort($rows, fn($a, $b) => [$b['open_balance'], $b['days_overdue']] <=> [$a['open_balance'], $a['days_overdue']]);
return ['schoolYear' => $schoolYear, 'generatedAt' => now()->toISOString(), 'results' => $rows];
}
@@ -153,78 +141,56 @@ class ParentPaymentFollowUpService
public function csvRows(array $report): array
{
$rows = [['Parent ID', 'Parent', 'Email', 'Phone', 'Students', 'School Year', 'Invoices', 'Open Invoices', 'Total Invoiced', 'Total Paid', 'Open Balance', 'Oldest Unpaid', 'Days Overdue', 'Aging', 'Status', 'Next Follow-Up', 'Promise Date', 'Promise Amount', 'Risk Flags']];
$rows = [['Parent ID','Parent','Email','Phone','Students','School Year','Invoices','Open Invoices','Total Invoiced','Total Paid','Open Balance','Oldest Unpaid','Days Overdue','Aging','Status','Next Follow-Up','Promise Date','Promise Amount','Risk Flags']];
foreach ($report['results'] as $r) {
$rows[] = [
$r['parent_id'], $r['parent_name'], $r['parent_email'], $r['parent_phone'], implode('; ', $r['student_names'] ?? []), $r['school_year'], $r['invoice_count'], $r['open_invoice_count'], $r['total_invoiced'], $r['total_paid'], $r['open_balance'], $r['oldest_unpaid_invoice_date'], $r['days_overdue'], $r['aging_bucket'], $r['follow_up_status'], $r['next_follow_up_date'], $r['promise_to_pay_date'], $r['promise_to_pay_amount'], implode('; ', $r['risk_flags'] ?? []),
];
}
return $rows;
}
private function invoiceRows(string $schoolYear, array $filters)
{
$q = DB::table('invoices')->where('invoices.school_year', $schoolYear);
if (! empty($filters['parent_id'])) {
$q->where('invoices.parent_id', $filters['parent_id']);
}
if (! empty($filters['date_from'])) {
$q->whereDate('invoices.created_at', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$q->whereDate('invoices.created_at', '<=', $filters['date_to']);
}
if (!empty($filters['parent_id'])) $q->where('invoices.parent_id', $filters['parent_id']);
if (!empty($filters['date_from'])) $q->whereDate('invoices.created_at', '>=', $filters['date_from']);
if (!empty($filters['date_to'])) $q->whereDate('invoices.created_at', '<=', $filters['date_to']);
if (Schema::hasTable('users')) {
$name = $this->nameExpression('users');
$q->leftJoin('users', 'users.id', '=', 'invoices.parent_id')
->addSelect('invoices.*')
->addSelect(DB::raw($name.' as parent_name'))
->addSelect('users.email as parent_email');
if (Schema::hasColumn('users', 'phone')) {
$q->addSelect('users.phone as parent_phone');
} else {
$q->addSelect(DB::raw('NULL as parent_phone'));
}
->addSelect('invoices.*')
->addSelect(DB::raw($name . ' as parent_name'))
->addSelect('users.email as parent_email');
if (Schema::hasColumn('users', 'phone')) $q->addSelect('users.phone as parent_phone');
else $q->addSelect(DB::raw('NULL as parent_phone'));
} else {
$q->select('invoices.*', DB::raw('NULL as parent_name'), DB::raw('NULL as parent_email'), DB::raw('NULL as parent_phone'));
}
return $q->get();
}
private function paymentTotalsByInvoice(string $schoolYear): array
{
if (! Schema::hasTable('payments')) {
return [];
}
if (!Schema::hasTable('payments')) return [];
return DB::table('payments')->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) total'), DB::raw('MAX(payment_date) last_payment_date'))
->where('school_year', $schoolYear)->whereNotIn('status', ['void', 'voided', 'failed', 'cancelled', 'canceled', 'reversed'])
->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn ($v) => (float) $v)->all();
->where('school_year', $schoolYear)->whereNotIn('status', ['void','voided','failed','cancelled','canceled','reversed'])
->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn($v) => (float) $v)->all();
}
private function discountTotalsByInvoice(): array
{
if (! Schema::hasTable('discount_usages')) {
return [];
}
if (!Schema::hasTable('discount_usages')) return [];
$col = Schema::hasColumn('discount_usages', 'discount_amount') ? 'discount_amount' : 'amount';
return DB::table('discount_usages')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn ($v) => (float) $v)->all();
return DB::table('discount_usages')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
}
private function refundTotalsByInvoice(): array
{
if (! Schema::hasTable('refunds')) {
return [];
}
if (!Schema::hasTable('refunds')) return [];
$col = Schema::hasColumn('refunds', 'refund_amount') ? 'refund_amount' : (Schema::hasColumn('refunds', 'amount') ? 'amount' : null);
if (! $col) {
return [];
}
return DB::table('refunds')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->whereNotIn('status', ['void', 'voided', 'cancelled', 'canceled'])->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn ($v) => (float) $v)->all();
if (!$col) return [];
return DB::table('refunds')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->whereNotIn('status', ['void','voided','cancelled','canceled'])->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
}
private function latestNotesByParent(string $schoolYear): array
@@ -234,66 +200,40 @@ class ParentPaymentFollowUpService
private function studentsByParent(): array
{
if (! Schema::hasTable('students') || ! Schema::hasColumn('students', 'parent_id')) {
return [];
}
if (!Schema::hasTable('students') || !Schema::hasColumn('students', 'parent_id')) return [];
$name = $this->nameExpression('students');
return DB::table('students')->select('parent_id', DB::raw($name.' as student_name'))->whereNotNull('parent_id')->get()->groupBy('parent_id')->map(fn ($rows) => $rows->pluck('student_name')->filter()->values()->all())->all();
return DB::table('students')->select('parent_id', DB::raw($name.' as student_name'))->whereNotNull('parent_id')->get()->groupBy('parent_id')->map(fn($rows) => $rows->pluck('student_name')->filter()->values()->all())->all();
}
private function nameExpression(string $table): string
{
if (Schema::hasColumn($table, 'name')) {
return $table.'.name';
}
if (Schema::hasColumn($table, 'full_name')) {
return $table.'.full_name';
}
if (Schema::hasColumn($table, 'name')) return $table.'.name';
if (Schema::hasColumn($table, 'full_name')) return $table.'.full_name';
$driver = DB::connection()->getDriverName();
$concat = $driver === 'sqlite' ? '%s || \' \' || %s' : "CONCAT(%s, ' ', %s)";
if (Schema::hasColumn($table, 'firstname') && Schema::hasColumn($table, 'lastname')) {
return sprintf($concat, $table.'.firstname', $table.'.lastname');
}
if (Schema::hasColumn($table, 'first_name') && Schema::hasColumn($table, 'last_name')) {
return sprintf($concat, $table.'.first_name', $table.'.last_name');
}
if (Schema::hasColumn($table, 'firstname') && Schema::hasColumn($table, 'lastname')) return sprintf($concat, $table.'.firstname', $table.'.lastname');
if (Schema::hasColumn($table, 'first_name') && Schema::hasColumn($table, 'last_name')) return sprintf($concat, $table.'.first_name', $table.'.last_name');
return "CAST($table.id AS CHAR)";
}
private function daysOverdue(?string $date): int
{
if (! $date) {
return 0;
}
return max(0, now()->startOfDay()->diffInDays(Carbon::parse($date)->startOfDay(), false) * -1);
if (!$date) return 0;
return max(0, now()->startOfDay()->diffInDays(\Carbon\Carbon::parse($date)->startOfDay(), false) * -1);
}
private function agingBucket(int $days): string
{
return match (true) {
$days <= 0 => 'current', $days <= 30 => '1-30 days overdue', $days <= 60 => '31-60 days overdue', $days <= 90 => '61-90 days overdue', default => '90+ days overdue'
};
return match (true) { $days <= 0 => 'current', $days <= 30 => '1-30 days overdue', $days <= 60 => '31-60 days overdue', $days <= 90 => '61-90 days overdue', default => '90+ days overdue' };
}
private function riskFlags(array $row): array
{
$flags = [];
if ($row['days_overdue'] > 90) {
$flags[] = '90+ days overdue';
}
if ($row['open_balance'] > 1000) {
$flags[] = 'high balance';
}
if ($row['promise_to_pay_date'] && strtotime($row['promise_to_pay_date']) < strtotime(date('Y-m-d'))) {
$flags[] = 'missed promise-to-pay';
}
if (! $row['parent_email'] && ! $row['parent_phone']) {
$flags[] = 'missing contact info';
}
if ($row['days_overdue'] > 90) $flags[] = '90+ days overdue';
if ($row['open_balance'] > 1000) $flags[] = 'high balance';
if ($row['promise_to_pay_date'] && strtotime($row['promise_to_pay_date']) < strtotime(date('Y-m-d'))) $flags[] = 'missed promise-to-pay';
if (!$row['parent_email'] && !$row['parent_phone']) $flags[] = 'missing contact info';
return $flags;
}
}