add controllers, servoices
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\ReimbursementBatch;
|
||||
use App\Models\ReimbursementBatchItem;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
class ReimbursementBatchAssignmentService
|
||||
{
|
||||
public function __construct(private ReimbursementLookupService $lookupService)
|
||||
{
|
||||
}
|
||||
|
||||
public function updateAssignment(
|
||||
int $expenseId,
|
||||
int $batchId,
|
||||
?int $adminId,
|
||||
?int $reimbursementId,
|
||||
string $schoolYear,
|
||||
string $semester
|
||||
): array {
|
||||
if ($expenseId <= 0) {
|
||||
throw new RuntimeException('Invalid expense id.');
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
$activeItem = ReimbursementBatchItem::query()
|
||||
->where('expense_id', $expenseId)
|
||||
->whereNull('unassigned_at')
|
||||
->first();
|
||||
|
||||
if ($batchId <= 0) {
|
||||
if ($activeItem) {
|
||||
$activeItem->update(['unassigned_at' => $now]);
|
||||
if (!$reimbursementId && !empty($activeItem->reimbursement_id)) {
|
||||
$reimbursementId = (int) $activeItem->reimbursement_id;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'batch_id' => 0,
|
||||
'admin_id' => null,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
];
|
||||
}
|
||||
|
||||
$batch = ReimbursementBatch::query()->find($batchId);
|
||||
if (!$batch || strtolower((string) $batch->status) !== ReimbursementBatch::STATUS_OPEN) {
|
||||
throw new RuntimeException('Batch not found or already closed.');
|
||||
}
|
||||
|
||||
$currentAdmin = $activeItem ? (int) ($activeItem->admin_id ?? 0) : null;
|
||||
$activeSameBatch = $activeItem && (int) ($activeItem->batch_id ?? 0) === $batchId;
|
||||
if ($activeSameBatch && ($currentAdmin === ($adminId ?? 0))) {
|
||||
return [
|
||||
'batch_id' => $batchId,
|
||||
'admin_id' => $adminId,
|
||||
'reimbursement_id' => $activeItem->reimbursement_id ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($activeSameBatch) {
|
||||
if (!$reimbursementId) {
|
||||
$reimbursementId = $activeItem->reimbursement_id
|
||||
? (int) $activeItem->reimbursement_id
|
||||
: $this->lookupService->findReimbursementIdForExpense($expenseId);
|
||||
}
|
||||
|
||||
$activeItem->update([
|
||||
'admin_id' => $adminId,
|
||||
'assigned_at' => $now,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
'unassigned_at' => $adminId === null ? $now : null,
|
||||
]);
|
||||
|
||||
return [
|
||||
'batch_id' => $batchId,
|
||||
'admin_id' => $adminId,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
];
|
||||
}
|
||||
|
||||
if ($activeItem) {
|
||||
$activeItem->update(['unassigned_at' => $now]);
|
||||
if (!$reimbursementId && !empty($activeItem->reimbursement_id)) {
|
||||
$reimbursementId = (int) $activeItem->reimbursement_id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$reimbursementId) {
|
||||
$reimbursementId = $this->lookupService->findReimbursementIdForExpense($expenseId);
|
||||
}
|
||||
|
||||
try {
|
||||
ReimbursementBatchItem::query()->create([
|
||||
'batch_id' => $batchId,
|
||||
'expense_id' => $expenseId,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
'admin_id' => $adminId,
|
||||
'assigned_at' => $now,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to assign expense to batch: ' . $e->getMessage(), [
|
||||
'expense_id' => $expenseId,
|
||||
'batch_id' => $batchId,
|
||||
]);
|
||||
throw new RuntimeException('Unable to update batch assignment right now.');
|
||||
}
|
||||
|
||||
return [
|
||||
'batch_id' => $batchId,
|
||||
'admin_id' => $adminId,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\Expense;
|
||||
use App\Models\Reimbursement;
|
||||
use App\Models\ReimbursementBatch;
|
||||
use App\Models\ReimbursementBatchAdminFile;
|
||||
use App\Models\ReimbursementBatchItem;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class ReimbursementBatchService
|
||||
{
|
||||
public function __construct(private ReimbursementLookupService $lookupService)
|
||||
{
|
||||
}
|
||||
|
||||
public function createBatch(?string $title, int $userId, string $schoolYear, string $semester): array
|
||||
{
|
||||
$sequence = $this->nextYearlyBatchNumber($schoolYear);
|
||||
$now = now();
|
||||
|
||||
$batch = ReimbursementBatch::query()->create([
|
||||
'title' => $title !== null && trim($title) !== '' ? trim($title) : null,
|
||||
'status' => ReimbursementBatch::STATUS_OPEN,
|
||||
'created_by' => $userId ?: null,
|
||||
'opened_at' => $now,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'yearly_batch_number' => $sequence,
|
||||
]);
|
||||
|
||||
$label = trim((string) $batch->title) !== '' ? (string) $batch->title : ('Batch #' . $sequence);
|
||||
if (trim((string) $batch->title) === '') {
|
||||
$batch->title = $label;
|
||||
$batch->save();
|
||||
}
|
||||
|
||||
return [
|
||||
'batch_id' => (int) $batch->id,
|
||||
'label' => $label,
|
||||
'sequence' => $sequence,
|
||||
];
|
||||
}
|
||||
|
||||
public function lockBatch(int $batchId, int $userId, string $schoolYear, string $semester): void
|
||||
{
|
||||
$batch = ReimbursementBatch::query()->find($batchId);
|
||||
if (!$batch || strtolower((string) $batch->status) !== ReimbursementBatch::STATUS_OPEN) {
|
||||
throw new RuntimeException('Batch not found or already closed.');
|
||||
}
|
||||
|
||||
$adminRows = ReimbursementBatchItem::query()
|
||||
->selectRaw('DISTINCT COALESCE(admin_id, 0) AS admin_id')
|
||||
->where('batch_id', $batchId)
|
||||
->whereNull('unassigned_at')
|
||||
->get();
|
||||
|
||||
$adminIds = array_values(array_unique($adminRows->pluck('admin_id')->map(fn ($id) => (int) $id)->all()));
|
||||
$requiredAdmins = array_values(array_filter($adminIds, static fn ($adminId) => $adminId > 0));
|
||||
|
||||
if (!empty($requiredAdmins)) {
|
||||
$uploadedAdminIds = ReimbursementBatchAdminFile::query()
|
||||
->where('batch_id', $batchId)
|
||||
->whereIn('admin_id', $requiredAdmins)
|
||||
->pluck('admin_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
foreach ($requiredAdmins as $adminId) {
|
||||
if (!in_array($adminId, $uploadedAdminIds, true)) {
|
||||
throw new RuntimeException('All admin sections must have a check file before submitting the batch.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($batchId, $userId, $schoolYear, $semester) {
|
||||
$items = DB::table('reimbursement_batch_items as bi')
|
||||
->selectRaw('bi.id as batch_item_id, bi.reimbursement_id as batch_reimb_id, bi.expense_id, e.amount, e.purchased_by, e.description, e.reimbursement_id as expense_reimb_id, e.school_year as expense_school_year, e.semester as expense_semester')
|
||||
->join('expenses as e', 'e.id', '=', 'bi.expense_id')
|
||||
->where('bi.batch_id', $batchId)
|
||||
->whereNull('bi.unassigned_at')
|
||||
->get();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$expenseId = (int) ($item->expense_id ?? 0);
|
||||
$recipientId = (int) ($item->purchased_by ?? 0);
|
||||
if ($expenseId <= 0 || $recipientId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reimbId = $item->batch_reimb_id ?: ($item->expense_reimb_id ?: $this->lookupService->findReimbursementIdForExpense($expenseId));
|
||||
|
||||
if (!$reimbId) {
|
||||
$payload = [
|
||||
'expense_id' => $expenseId,
|
||||
'amount' => (float) ($item->amount ?? 0),
|
||||
'reimbursed_to' => $recipientId,
|
||||
'approved_by' => $userId ?: null,
|
||||
'description' => trim((string) ($item->description ?? '')),
|
||||
'status' => 'Paid',
|
||||
'added_by' => $userId ?: null,
|
||||
'school_year' => $item->expense_school_year ?: $schoolYear,
|
||||
'semester' => $item->expense_semester ?: $semester,
|
||||
'reimbursement_method' => 'Check',
|
||||
'batch_number' => $batchId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
$reimbId = (int) Reimbursement::query()->create($payload)->id;
|
||||
}
|
||||
|
||||
if ($reimbId) {
|
||||
Reimbursement::query()->where('id', $reimbId)->update([
|
||||
'batch_number' => $batchId,
|
||||
'approved_by' => $userId ?: null,
|
||||
'status' => 'Paid',
|
||||
]);
|
||||
|
||||
Expense::query()->where('id', $expenseId)->update(['reimbursement_id' => $reimbId]);
|
||||
if (!empty($item->batch_item_id)) {
|
||||
ReimbursementBatchItem::query()->where('id', (int) $item->batch_item_id)->update([
|
||||
'reimbursement_id' => $reimbId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ReimbursementBatch::query()->where('id', $batchId)->update([
|
||||
'status' => ReimbursementBatch::STATUS_CLOSED,
|
||||
'closed_at' => now(),
|
||||
'closed_by' => $userId ?: null,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private function nextYearlyBatchNumber(string $schoolYear): int
|
||||
{
|
||||
$year = trim($schoolYear);
|
||||
if ($year === '') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$max = ReimbursementBatch::query()
|
||||
->where('school_year', $year)
|
||||
->max('yearly_batch_number');
|
||||
|
||||
return ((int) $max) + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\Configuration;
|
||||
|
||||
class ReimbursementContextService
|
||||
{
|
||||
public function getSchoolYear(): string
|
||||
{
|
||||
return (string) (Configuration::getConfig('school_year') ?? date('Y'));
|
||||
}
|
||||
|
||||
public function getSemester(): string
|
||||
{
|
||||
return (string) (Configuration::getConfig('semester') ?? 'Fall');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\Expense;
|
||||
use App\Models\Reimbursement;
|
||||
use RuntimeException;
|
||||
|
||||
class ReimbursementCrudService
|
||||
{
|
||||
public function store(array $payload, int $userId, string $schoolYear, string $semester, ?string $receiptName): Reimbursement
|
||||
{
|
||||
$expenseId = (int) ($payload['expense_id'] ?? 0);
|
||||
if ($expenseId > 0) {
|
||||
$expense = Expense::query()->find($expenseId);
|
||||
if ($expense && strcasecmp((string) $expense->category, 'Donation') === 0) {
|
||||
throw new RuntimeException('Donation expenses are tracked but should not be reimbursed.');
|
||||
}
|
||||
}
|
||||
|
||||
$methodRaw = (string) ($payload['reimbursement_method'] ?? '');
|
||||
$method = ucfirst(strtolower($methodRaw));
|
||||
|
||||
$reimb = Reimbursement::query()->create([
|
||||
'expense_id' => $expenseId ?: null,
|
||||
'amount' => $payload['amount'],
|
||||
'reimbursed_to' => (int) $payload['reimbursed_to'],
|
||||
'description' => $payload['description'] ?? null,
|
||||
'reimbursement_method' => $method,
|
||||
'check_number' => $method === 'Check' ? ($payload['check_number'] ?? null) : null,
|
||||
'receipt_path' => $receiptName,
|
||||
'school_year' => $payload['school_year'] ?? $schoolYear,
|
||||
'semester' => $payload['semester'] ?? $semester,
|
||||
'added_by' => $userId ?: null,
|
||||
'approved_by' => $userId ?: null,
|
||||
'status' => 'Paid',
|
||||
]);
|
||||
|
||||
if ($expenseId > 0) {
|
||||
Expense::query()->where('id', $expenseId)->update(['reimbursement_id' => $reimb->id]);
|
||||
}
|
||||
|
||||
return $reimb;
|
||||
}
|
||||
|
||||
public function process(array $payload, int $userId, string $schoolYear, string $semester, ?string $receiptName): Reimbursement
|
||||
{
|
||||
$expenseId = (int) ($payload['expense_id'] ?? 0);
|
||||
if ($expenseId > 0) {
|
||||
$expense = Expense::query()->find($expenseId);
|
||||
if ($expense && strcasecmp((string) $expense->category, 'Donation') === 0) {
|
||||
throw new RuntimeException('Donation expenses are tracked but should not be reimbursed.');
|
||||
}
|
||||
}
|
||||
|
||||
$reimb = Reimbursement::query()->create([
|
||||
'expense_id' => $expenseId ?: null,
|
||||
'amount' => $payload['amount'],
|
||||
'reimbursed_to' => (int) $payload['reimbursed_to'],
|
||||
'approved_by' => $userId ?: null,
|
||||
'receipt_path' => $receiptName,
|
||||
'description' => $payload['description'] ?? 'Expense reimbursement',
|
||||
'status' => 'Paid',
|
||||
'added_by' => $userId ?: null,
|
||||
'school_year' => $payload['school_year'] ?? $schoolYear,
|
||||
'semester' => $payload['semester'] ?? $semester,
|
||||
'check_number' => $payload['check_number'] ?? null,
|
||||
'reimbursement_method' => $payload['reimbursement_method'] ?? null,
|
||||
]);
|
||||
|
||||
if ($expenseId > 0) {
|
||||
Expense::query()->where('id', $expenseId)->update(['reimbursement_id' => $reimb->id]);
|
||||
}
|
||||
|
||||
return $reimb;
|
||||
}
|
||||
|
||||
public function update(Reimbursement $reimb, array $payload, int $userId, ?string $receiptName): Reimbursement
|
||||
{
|
||||
$methodRaw = (string) ($payload['reimbursement_method'] ?? $reimb->reimbursement_method);
|
||||
$method = ucfirst(strtolower($methodRaw));
|
||||
|
||||
$updateData = [
|
||||
'amount' => $payload['amount'],
|
||||
'reimbursed_to' => (int) $payload['reimbursed_to'],
|
||||
'description' => $payload['description'] ?? null,
|
||||
'reimbursement_method' => $method,
|
||||
'check_number' => $method === 'Check' ? ($payload['check_number'] ?? null) : null,
|
||||
'receipt_path' => $receiptName,
|
||||
'updated_by' => $userId ?: null,
|
||||
];
|
||||
|
||||
$reimb->update($updateData);
|
||||
|
||||
return $reimb->refresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\Expense;
|
||||
use App\Models\ReimbursementBatchItem;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
class ReimbursementDonationService
|
||||
{
|
||||
public function markDonation(int $expenseId, int $userId): void
|
||||
{
|
||||
if ($expenseId <= 0) {
|
||||
throw new RuntimeException('Invalid expense id.');
|
||||
}
|
||||
|
||||
$expense = Expense::query()->find($expenseId);
|
||||
if (!$expense) {
|
||||
throw new RuntimeException('Expense not found.');
|
||||
}
|
||||
|
||||
if (!empty($expense->reimbursement_id)) {
|
||||
throw new RuntimeException('Expense is already tied to a reimbursement.');
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
ReimbursementBatchItem::query()
|
||||
->where('expense_id', $expenseId)
|
||||
->whereNull('unassigned_at')
|
||||
->update(['unassigned_at' => $now]);
|
||||
|
||||
$expense->update([
|
||||
'category' => 'Donation',
|
||||
'status' => 'approved',
|
||||
'status_reason' => 'Marked as Donation (non-reimbursable).',
|
||||
'approved_by' => $userId ?: null,
|
||||
'updated_by' => $userId ?: null,
|
||||
'reimbursement_id' => null,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('Failed to mark donation: ' . $e->getMessage(), ['expense_id' => $expenseId]);
|
||||
throw new RuntimeException('Unable to mark donation right now.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\ReimbursementBatch;
|
||||
use App\Models\ReimbursementBatchAdminFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use ZipArchive;
|
||||
|
||||
class ReimbursementEmailService
|
||||
{
|
||||
public function __construct(private ReimbursementRecipientService $recipients)
|
||||
{
|
||||
}
|
||||
|
||||
public function sendBatchEmail(
|
||||
int $batchId,
|
||||
string $recipientEmail,
|
||||
string $message,
|
||||
array $receiptIds,
|
||||
array $checkIds
|
||||
): bool {
|
||||
$batch = ReimbursementBatch::query()->find($batchId);
|
||||
if (!$batch) {
|
||||
throw new \RuntimeException('Requested batch was not found.');
|
||||
}
|
||||
|
||||
$receiptRows = !empty($receiptIds) ? $this->fetchBatchReceiptRows($batchId, $receiptIds) : [];
|
||||
|
||||
$checkRows = [];
|
||||
if (!empty($checkIds)) {
|
||||
$checkRows = ReimbursementBatchAdminFile::query()
|
||||
->select('id as file_id', 'admin_id', 'filename', 'original_filename', 'uploaded_at')
|
||||
->where('batch_id', $batchId)
|
||||
->whereIn('id', $checkIds)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$adminLookup = [];
|
||||
foreach ($this->recipients->adminRoster() as $admin) {
|
||||
$adminLookup[(int) ($admin['id'] ?? 0)] = trim((string) (($admin['firstname'] ?? '') . ' ' . ($admin['lastname'] ?? '')));
|
||||
}
|
||||
foreach ($checkRows as &$row) {
|
||||
$aid = (int) ($row['admin_id'] ?? 0);
|
||||
$row['admin'] = $adminLookup[$aid] ?? ($aid > 0 ? 'Admin #' . $aid : 'Admin');
|
||||
}
|
||||
unset($row);
|
||||
}
|
||||
|
||||
$tempDir = storage_path('tmp');
|
||||
if (!is_dir($tempDir)) {
|
||||
mkdir($tempDir, 0755, true);
|
||||
}
|
||||
|
||||
$cleanupPaths = [];
|
||||
|
||||
$batchLabel = trim((string) ($batch->title ?? '')) ?: ('Batch #' . ((int) ($batch->yearly_batch_number ?? $batchId)));
|
||||
$batchFileLabel = $this->sanitizeAttachmentComponent($batchLabel);
|
||||
|
||||
$csvPath = tempnam($tempDir, 'batch_csv_');
|
||||
$cleanupPaths[] = $csvPath;
|
||||
$csvHandle = fopen($csvPath, 'w');
|
||||
if (!$csvHandle) {
|
||||
if (is_file($csvPath)) {
|
||||
@unlink($csvPath);
|
||||
}
|
||||
throw new \RuntimeException('Unable to create the summary file.');
|
||||
}
|
||||
|
||||
$batchClosedDate = $this->formatCsvDate($batch->closed_at ?? null);
|
||||
$this->writeBatchCsvMetadata($csvHandle, $batchLabel, $batch->toArray());
|
||||
$this->writeBatchCsvSummary($csvHandle, $receiptRows, $batchClosedDate);
|
||||
fclose($csvHandle);
|
||||
|
||||
$csvAttachmentName = $batchFileLabel . '_summary.csv';
|
||||
|
||||
$receiptsArchivePath = null;
|
||||
$receiptZipCreated = false;
|
||||
if (!empty($receiptRows)) {
|
||||
$archivePath = $tempDir . '/batch_receipts_' . $batchId . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
$added = 0;
|
||||
$usedNames = [];
|
||||
if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
|
||||
foreach ($receiptRows as $row) {
|
||||
$sourcePath = storage_path('uploads/receipts/' . basename((string) ($row['receipt_filename'] ?? '')));
|
||||
if (!is_file($sourcePath)) {
|
||||
continue;
|
||||
}
|
||||
$firstName = $row['purchaser_firstname'] ?? '';
|
||||
if (trim($firstName) === '' && !empty($row['purchaser_lastname'] ?? '')) {
|
||||
$firstName = $row['purchaser_lastname'];
|
||||
}
|
||||
$firstName = $this->sanitizeAttachmentComponent($firstName);
|
||||
$vendorPart = $this->sanitizeAttachmentComponent($row['retailor'] ?? '');
|
||||
$datePart = $this->formatAttachmentDate($row['purchase_date'] ?? '');
|
||||
$extension = pathinfo($sourcePath, PATHINFO_EXTENSION);
|
||||
$extPart = $extension !== '' ? ('.' . strtolower($extension)) : '';
|
||||
$baseName = $firstName . '_' . $vendorPart . '_' . $datePart;
|
||||
$entryName = $this->uniqueZipEntryName($baseName, $extPart, $usedNames);
|
||||
if ($zip->addFile($sourcePath, $entryName)) {
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
}
|
||||
if ($added > 0 && is_file($archivePath)) {
|
||||
$receiptsArchivePath = $archivePath;
|
||||
$cleanupPaths[] = $archivePath;
|
||||
$receiptZipCreated = true;
|
||||
} elseif (is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
}
|
||||
}
|
||||
|
||||
$checksArchivePath = null;
|
||||
$checksAdded = 0;
|
||||
if (!empty($checkRows)) {
|
||||
$archivePath = $tempDir . '/batch_checks_' . $batchId . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
$usedNames = [];
|
||||
if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
|
||||
foreach ($checkRows as $row) {
|
||||
$sourcePath = storage_path('uploads/reimbursements/' . basename((string) ($row['filename'] ?? '')));
|
||||
if (!is_file($sourcePath)) {
|
||||
continue;
|
||||
}
|
||||
$adminLabel = $row['admin'] ?? 'Admin';
|
||||
$firstToken = strtok($adminLabel, ' ');
|
||||
$namePart = $this->sanitizeAttachmentComponent($firstToken ?: $adminLabel);
|
||||
$extension = pathinfo($sourcePath, PATHINFO_EXTENSION);
|
||||
$extPart = $extension !== '' ? ('.' . strtolower($extension)) : '';
|
||||
$entryName = $this->uniqueZipEntryName($namePart, $extPart, $usedNames);
|
||||
if ($zip->addFile($sourcePath, $entryName)) {
|
||||
$checksAdded++;
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
}
|
||||
if ($checksAdded > 0 && is_file($archivePath)) {
|
||||
$checksArchivePath = $archivePath;
|
||||
$cleanupPaths[] = $archivePath;
|
||||
} elseif (is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
}
|
||||
}
|
||||
|
||||
$attachmentSummary = ['CSV Summary'];
|
||||
if ($receiptZipCreated) {
|
||||
$attachmentSummary[] = 'Receipt Files';
|
||||
}
|
||||
if ($checksArchivePath) {
|
||||
$attachmentSummary[] = 'Check Scans';
|
||||
}
|
||||
|
||||
$subject = 'Reimbursement Batch Export: ' . $batchLabel;
|
||||
$bodyParts = [
|
||||
'<p>Please find attached the export for <strong>' . htmlspecialchars($batchLabel, ENT_QUOTES, 'UTF-8') . '</strong>.</p>',
|
||||
];
|
||||
if (trim($message) !== '') {
|
||||
$bodyParts[] = '<p><strong>Message:</strong><br>' . nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8')) . '</p>';
|
||||
}
|
||||
$bodyParts[] = '<p>Attachments: ' . htmlspecialchars(implode(', ', $attachmentSummary), ENT_QUOTES, 'UTF-8') . '.</p>';
|
||||
$bodyParts[] = '<p>Regards,<br>Al Rahma Sunday School</p>';
|
||||
$htmlBody = implode("\n", $bodyParts);
|
||||
|
||||
$attachments = [
|
||||
['path' => $csvPath, 'name' => $csvAttachmentName],
|
||||
];
|
||||
if ($receiptsArchivePath) {
|
||||
$attachments[] = ['path' => $receiptsArchivePath, 'name' => $batchFileLabel . '_receipts.zip'];
|
||||
}
|
||||
if ($checksArchivePath) {
|
||||
$attachments[] = ['path' => $checksArchivePath, 'name' => $batchFileLabel . '_checks.zip'];
|
||||
}
|
||||
|
||||
$sender = $this->resolveSender('general');
|
||||
|
||||
try {
|
||||
Mail::html($htmlBody, function ($message) use ($recipientEmail, $subject, $sender, $attachments) {
|
||||
$message->to($recipientEmail)->subject($subject);
|
||||
if (!empty($sender['email'])) {
|
||||
$message->from($sender['email'], $sender['name']);
|
||||
}
|
||||
foreach ($attachments as $file) {
|
||||
if (!empty($file['path']) && is_file($file['path'])) {
|
||||
$message->attach($file['path'], ['as' => $file['name'] ?? basename($file['path'])]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send batch export email: ' . $e->getMessage());
|
||||
return false;
|
||||
} finally {
|
||||
foreach ($cleanupPaths as $path) {
|
||||
if ($path && is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function fetchBatchReceiptRows(int $batchId, array $receiptIds = []): array
|
||||
{
|
||||
$builder = DB::table('reimbursement_batch_items as bi')
|
||||
->select('e.id as expense_id', 'e.retailor', 'e.amount', 'e.description', 'e.receipt_path as receipt_filename', 'e.date_of_purchase as purchase_date', 'u.firstname as purchaser_firstname', 'u.lastname as purchaser_lastname')
|
||||
->join('expenses as e', 'e.id', '=', 'bi.expense_id')
|
||||
->leftJoin('users as u', 'u.id', '=', 'e.purchased_by')
|
||||
->where('bi.batch_id', $batchId);
|
||||
|
||||
if (!empty($receiptIds)) {
|
||||
$builder->whereIn('e.id', $receiptIds);
|
||||
}
|
||||
|
||||
$rows = $builder->get()->toArray();
|
||||
|
||||
return array_values(array_filter(array_map(static function ($row) {
|
||||
return [
|
||||
'expense_id' => $row->expense_id ?? null,
|
||||
'retailor' => $row->retailor ?? null,
|
||||
'amount' => $row->amount ?? null,
|
||||
'description' => $row->description ?? null,
|
||||
'receipt_filename' => $row->receipt_filename ?? null,
|
||||
'purchase_date' => $row->purchase_date ?? null,
|
||||
'purchaser_firstname' => $row->purchaser_firstname ?? null,
|
||||
'purchaser_lastname' => $row->purchaser_lastname ?? null,
|
||||
];
|
||||
}, $rows), static function ($row) {
|
||||
return !empty($row['receipt_filename']);
|
||||
}));
|
||||
}
|
||||
|
||||
private function writeBatchCsvMetadata($handle, string $batchLabel, array $batch): void
|
||||
{
|
||||
$closedAtRaw = $batch['closed_at'] ?? null;
|
||||
fputcsv($handle, ['Batch Title', $batchLabel]);
|
||||
fputcsv($handle, ['School Year', $batch['school_year'] ?? '', 'Semester', $batch['semester'] ?? '', 'Closed At', $this->formatCsvDate($closedAtRaw)]);
|
||||
fputcsv($handle, []);
|
||||
fputcsv($handle, ['Amount', 'Credit', 'Paid', 'Balance', 'Date', 'By Who', 'Vendor', 'Description']);
|
||||
}
|
||||
|
||||
private function writeBatchCsvSummary($handle, array $receiptRows, string $totalsDate = ''): void
|
||||
{
|
||||
$receiptTotalsByRecipient = [];
|
||||
foreach ($receiptRows as $row) {
|
||||
$recipientName = trim(($row['purchaser_firstname'] ?? '') . ' ' . ($row['purchaser_lastname'] ?? ''));
|
||||
if ($recipientName === '') {
|
||||
$recipientName = 'Unknown';
|
||||
}
|
||||
$vendor = trim((string) ($row['retailor'] ?? ''));
|
||||
if ($vendor === '') {
|
||||
$vendor = 'Vendor N/A';
|
||||
}
|
||||
$dateText = $this->formatCsvDate($row['purchase_date'] ?? null);
|
||||
$descriptionValue = trim((string) ($row['description'] ?? ''));
|
||||
$descriptionParts = $descriptionValue !== '' ? [$descriptionValue] : ['Receipt'];
|
||||
$amountValue = (float) ($row['amount'] ?? 0);
|
||||
$receiptTotalsByRecipient[$recipientName] = ($receiptTotalsByRecipient[$recipientName] ?? 0.0) + $amountValue;
|
||||
fputcsv($handle, [
|
||||
number_format($amountValue, 2, '.', ''),
|
||||
'',
|
||||
number_format($amountValue, 2, '.', ''),
|
||||
'0.00',
|
||||
$dateText,
|
||||
$recipientName,
|
||||
$vendor,
|
||||
implode(' | ', $descriptionParts),
|
||||
]);
|
||||
}
|
||||
if (!empty($receiptTotalsByRecipient)) {
|
||||
fputcsv($handle, []);
|
||||
fputcsv($handle, ['Totals by Recipient', '', '', '', '', '', '', '']);
|
||||
foreach ($receiptTotalsByRecipient as $recipient => $total) {
|
||||
$formatted = number_format($total, 2, '.', '');
|
||||
fputcsv($handle, [
|
||||
$formatted,
|
||||
'',
|
||||
$formatted,
|
||||
'0.00',
|
||||
$totalsDate,
|
||||
$recipient,
|
||||
'',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sanitizeAttachmentComponent(string $value): string
|
||||
{
|
||||
$clean = preg_replace('/[^A-Za-z0-9]+/', '_', trim((string) $value));
|
||||
$clean = trim($clean, '_');
|
||||
return $clean === '' ? 'unknown' : $clean;
|
||||
}
|
||||
|
||||
private function formatAttachmentDate(?string $value): string
|
||||
{
|
||||
if (!$value) {
|
||||
return 'unknown';
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp === false) {
|
||||
return 'unknown';
|
||||
}
|
||||
return date('Ymd', $timestamp);
|
||||
}
|
||||
|
||||
private function uniqueZipEntryName(string $base, string $extension, array &$used): string
|
||||
{
|
||||
$candidate = $base . $extension;
|
||||
$counter = 1;
|
||||
while (isset($used[$candidate])) {
|
||||
$candidate = $base . '_' . $counter . $extension;
|
||||
$counter++;
|
||||
}
|
||||
$used[$candidate] = true;
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function formatCsvDate(?string $value): string
|
||||
{
|
||||
if (!$value) {
|
||||
return '';
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp === false) {
|
||||
return '';
|
||||
}
|
||||
return date('m-d-Y', $timestamp);
|
||||
}
|
||||
|
||||
private function resolveSender(string $fromKey): array
|
||||
{
|
||||
$fromKey = $fromKey !== '' ? $fromKey : 'general';
|
||||
$json = env('MAIL_SENDERS', '{}');
|
||||
$senders = json_decode($json, true);
|
||||
|
||||
if (is_array($senders) && isset($senders[$fromKey])) {
|
||||
$info = $senders[$fromKey];
|
||||
return [
|
||||
'name' => (string) ($info['name'] ?? 'Sender'),
|
||||
'email' => (string) ($info['email'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$fallbackAddress = (string) (config('mail.from.address') ?: (getenv('SMTP_USER') ?: ''));
|
||||
$fallbackName = (string) (config('mail.from.name') ?: 'Al Rahma Sunday School');
|
||||
|
||||
return [
|
||||
'name' => $fallbackName,
|
||||
'email' => $fallbackAddress,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\ReimbursementBatch;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class ReimbursementExportService
|
||||
{
|
||||
public function __construct(private ReimbursementRecipientService $recipients)
|
||||
{
|
||||
}
|
||||
|
||||
public function buildUnderProcessingCsv(): array
|
||||
{
|
||||
$builder = DB::table('expenses')
|
||||
->select('expenses.*', 'u.firstname as purchaser_firstname', 'u.lastname as purchaser_lastname')
|
||||
->leftJoin('users as u', 'u.id', '=', 'expenses.purchased_by')
|
||||
->where('expenses.status', 'approved')
|
||||
->whereNull('expenses.reimbursement_id')
|
||||
->where('expenses.category', '!=', 'Donation')
|
||||
->orderByDesc('expenses.created_at');
|
||||
|
||||
$records = $builder->get();
|
||||
$grouped = [];
|
||||
|
||||
foreach ($records as $row) {
|
||||
$personId = (int) ($row->purchased_by ?? 0);
|
||||
$name = trim(($row->purchaser_firstname ?? '') . ' ' . ($row->purchaser_lastname ?? ''));
|
||||
if ($name === '') {
|
||||
$name = $row->purchaser_firstname ?? $row->purchaser_lastname ?? 'Unknown';
|
||||
}
|
||||
$key = $personId > 0 ? 'id_' . $personId : 'name_' . strtolower($name);
|
||||
if (!isset($grouped[$key])) {
|
||||
$grouped[$key] = [
|
||||
'name' => $name ?: 'Unknown',
|
||||
'total' => 0.0,
|
||||
'vendors' => [],
|
||||
'descriptions' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$amount = (float) ($row->amount ?? 0);
|
||||
$grouped[$key]['total'] += $amount;
|
||||
|
||||
$vendor = trim((string) ($row->retailor ?? ''));
|
||||
if ($vendor !== '' && !in_array($vendor, $grouped[$key]['vendors'], true)) {
|
||||
$grouped[$key]['vendors'][] = $vendor;
|
||||
}
|
||||
|
||||
$desc = trim((string) ($row->description ?? ''));
|
||||
if ($desc !== '' && !in_array($desc, $grouped[$key]['descriptions'], true)) {
|
||||
$grouped[$key]['descriptions'][] = $desc;
|
||||
}
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$rows[] = ['Person', 'Total Amount', 'Vendors', 'Descriptions'];
|
||||
foreach ($grouped as $entry) {
|
||||
$rows[] = [
|
||||
$entry['name'],
|
||||
number_format($entry['total'], 2, '.', ''),
|
||||
implode('; ', $entry['vendors']),
|
||||
implode('; ', $entry['descriptions']),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'filename' => 'under_processing_expenses.csv',
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
public function buildProcessedCsv(array $filters): array
|
||||
{
|
||||
$builder = DB::table('reimbursements')
|
||||
->select('reimbursements.*', 'u.firstname as reimb_firstname', 'u.lastname as reimb_lastname')
|
||||
->leftJoin('users as u', 'u.id', '=', 'reimbursements.reimbursed_to');
|
||||
|
||||
if (!empty($filters['semester'])) {
|
||||
$builder->where('reimbursements.semester', $filters['semester']);
|
||||
}
|
||||
if (!empty($filters['status'])) {
|
||||
$builder->where('reimbursements.status', $filters['status']);
|
||||
}
|
||||
if (!empty($filters['user_id'])) {
|
||||
$builder->where('reimbursements.reimbursed_to', $filters['user_id']);
|
||||
}
|
||||
if (!empty($filters['school_year'])) {
|
||||
$builder->where('reimbursements.school_year', $filters['school_year']);
|
||||
}
|
||||
|
||||
$records = $builder->orderByDesc('reimbursements.created_at')->get();
|
||||
$rows = [];
|
||||
$rows[] = ['Amount', 'Reimbursed To', 'Status', 'Method', 'Check #', 'School Year', 'Semester', 'Date'];
|
||||
|
||||
foreach ($records as $row) {
|
||||
$hasName = trim(($row->reimb_firstname ?? '') . ' ' . ($row->reimb_lastname ?? '')) !== '';
|
||||
if (!$hasName) {
|
||||
$label = $this->recipients->specialRecipientLabel($row->reimbursed_to ?? null);
|
||||
if ($label !== null) {
|
||||
$row->reimb_firstname = $label;
|
||||
$row->reimb_lastname = '';
|
||||
}
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
$row->amount,
|
||||
trim(($row->reimb_firstname ?? '') . ' ' . ($row->reimb_lastname ?? '')),
|
||||
$row->status,
|
||||
$row->reimbursement_method ?? '-',
|
||||
$row->check_number ?? '-',
|
||||
$row->school_year ?? '-',
|
||||
$row->semester ?? '-',
|
||||
$row->created_at,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'filename' => 'reimbursements_export.csv',
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
public function buildBatchCsv(int $batchId): array
|
||||
{
|
||||
$batch = ReimbursementBatch::query()->find($batchId);
|
||||
if (!$batch) {
|
||||
throw new RuntimeException('Batch not found.');
|
||||
}
|
||||
|
||||
$batchLabel = trim((string) ($batch->title ?? '')) ?: ('Batch #' . $batchId);
|
||||
$receiptRows = $this->fetchBatchReceiptRows($batchId);
|
||||
|
||||
$rows = [];
|
||||
$this->writeBatchCsvMetadata($rows, $batchLabel, $batch->toArray());
|
||||
$this->writeBatchCsvSummary($rows, $receiptRows, $this->formatCsvDate($batch->closed_at ?? null));
|
||||
|
||||
return [
|
||||
'filename' => sprintf('batch_%d_items.csv', $batchId),
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
private function fetchBatchReceiptRows(int $batchId, array $receiptIds = []): array
|
||||
{
|
||||
$builder = DB::table('reimbursement_batch_items as bi')
|
||||
->select('e.id as expense_id', 'e.retailor', 'e.amount', 'e.description', 'e.receipt_path as receipt_filename', 'e.date_of_purchase as purchase_date', 'u.firstname as purchaser_firstname', 'u.lastname as purchaser_lastname')
|
||||
->join('expenses as e', 'e.id', '=', 'bi.expense_id')
|
||||
->leftJoin('users as u', 'u.id', '=', 'e.purchased_by')
|
||||
->where('bi.batch_id', $batchId);
|
||||
|
||||
if (!empty($receiptIds)) {
|
||||
$builder->whereIn('e.id', $receiptIds);
|
||||
}
|
||||
|
||||
$rows = $builder->get()->toArray();
|
||||
|
||||
return array_values(array_filter($rows, static function ($row) {
|
||||
return !empty($row->receipt_filename);
|
||||
}));
|
||||
}
|
||||
|
||||
private function writeBatchCsvMetadata(array &$rows, string $batchLabel, array $batch): void
|
||||
{
|
||||
$closedAtRaw = $batch['closed_at'] ?? null;
|
||||
$rows[] = ['Batch Title', $batchLabel];
|
||||
$rows[] = ['School Year', $batch['school_year'] ?? '', 'Semester', $batch['semester'] ?? '', 'Closed At', $this->formatCsvDate($closedAtRaw)];
|
||||
$rows[] = [];
|
||||
$rows[] = ['Amount', 'Credit', 'Paid', 'Balance', 'Date', 'By Who', 'Vendor', 'Description'];
|
||||
}
|
||||
|
||||
private function writeBatchCsvSummary(array &$rows, array $receiptRows, string $totalsDate = ''): void
|
||||
{
|
||||
$receiptTotalsByRecipient = [];
|
||||
foreach ($receiptRows as $row) {
|
||||
$recipientName = trim(($row->purchaser_firstname ?? '') . ' ' . ($row->purchaser_lastname ?? ''));
|
||||
if ($recipientName === '') {
|
||||
$recipientName = 'Unknown';
|
||||
}
|
||||
$vendor = trim((string) ($row->retailor ?? ''));
|
||||
if ($vendor === '') {
|
||||
$vendor = 'Vendor N/A';
|
||||
}
|
||||
$dateText = $this->formatCsvDate($row->purchase_date ?? null);
|
||||
$descriptionValue = trim((string) ($row->description ?? ''));
|
||||
$descriptionParts = $descriptionValue !== '' ? [$descriptionValue] : ['Receipt'];
|
||||
$amountValue = (float) ($row->amount ?? 0);
|
||||
$receiptTotalsByRecipient[$recipientName] = ($receiptTotalsByRecipient[$recipientName] ?? 0.0) + $amountValue;
|
||||
$rows[] = [
|
||||
number_format($amountValue, 2, '.', ''),
|
||||
'',
|
||||
number_format($amountValue, 2, '.', ''),
|
||||
'0.00',
|
||||
$dateText,
|
||||
$recipientName,
|
||||
$vendor,
|
||||
implode(' | ', $descriptionParts),
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($receiptTotalsByRecipient)) {
|
||||
$rows[] = [];
|
||||
$rows[] = ['Totals by Recipient', '', '', '', '', '', '', ''];
|
||||
foreach ($receiptTotalsByRecipient as $recipient => $total) {
|
||||
$formatted = number_format($total, 2, '.', '');
|
||||
$rows[] = [
|
||||
$formatted,
|
||||
'',
|
||||
$formatted,
|
||||
'0.00',
|
||||
$totalsDate,
|
||||
$recipient,
|
||||
'',
|
||||
'',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function formatCsvDate(?string $value): string
|
||||
{
|
||||
if (!$value) {
|
||||
return '';
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp === false) {
|
||||
return '';
|
||||
}
|
||||
return date('m-d-Y', $timestamp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\ReimbursementBatchAdminFile;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class ReimbursementFileService
|
||||
{
|
||||
public function receiptUrl(?string $filename): ?string
|
||||
{
|
||||
if (!$filename) {
|
||||
return null;
|
||||
}
|
||||
$safe = basename(trim($filename));
|
||||
return $safe !== '' ? url('/api/v1/files/reimbursements/' . $safe) : null;
|
||||
}
|
||||
|
||||
public function expenseReceiptUrl(?string $filename): ?string
|
||||
{
|
||||
if (!$filename) {
|
||||
return null;
|
||||
}
|
||||
$safe = basename(trim($filename));
|
||||
return $safe !== '' ? url('/api/v1/files/receipts/' . $safe) : null;
|
||||
}
|
||||
|
||||
public function adminFileUrl(string $filename, string $mode = 'inline'): string
|
||||
{
|
||||
return url('/api/v1/finance/reimbursements/batches/admin-files/' . rawurlencode($filename) . '/' . $mode);
|
||||
}
|
||||
|
||||
public function storeReceipt(?UploadedFile $file): ?string
|
||||
{
|
||||
if (!$file || !$file->isValid()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($file->getSize() ?? 0) <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dir = $this->ensureUploadDir();
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$extension = $extension !== '' ? '.' . strtolower($extension) : '';
|
||||
$filename = 'reimb_' . date('Ymd_His') . '_' . Str::random(8) . $extension;
|
||||
|
||||
if (!$file->move($dir, $filename)) {
|
||||
throw new RuntimeException('Unable to save uploaded file.');
|
||||
}
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
public function storeAdminFile(int $batchId, int $adminId, UploadedFile $file, int $userId): array
|
||||
{
|
||||
$dir = $this->ensureUploadDir();
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$extension = $extension !== '' ? '.' . strtolower($extension) : '';
|
||||
$filename = 'batch_check_' . date('Ymd_His') . '_' . Str::random(8) . $extension;
|
||||
|
||||
if (!$file->move($dir, $filename)) {
|
||||
throw new RuntimeException('Unable to save uploaded file.');
|
||||
}
|
||||
|
||||
$existing = ReimbursementBatchAdminFile::query()
|
||||
->where('batch_id', $batchId)
|
||||
->where('admin_id', $adminId)
|
||||
->first();
|
||||
|
||||
if ($existing && !empty($existing->filename)) {
|
||||
$oldPath = $dir . DIRECTORY_SEPARATOR . $existing->filename;
|
||||
if (is_file($oldPath)) {
|
||||
@unlink($oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'batch_id' => $batchId,
|
||||
'admin_id' => $adminId,
|
||||
'filename' => $filename,
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'uploaded_at' => now(),
|
||||
'uploaded_by' => $userId ?: null,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$existing->fill($payload);
|
||||
$existing->save();
|
||||
} else {
|
||||
ReimbursementBatchAdminFile::query()->create($payload);
|
||||
}
|
||||
|
||||
return [
|
||||
'filename' => $filename,
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'url' => $this->adminFileUrl($filename, 'inline'),
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureUploadDir(): string
|
||||
{
|
||||
$dir = storage_path('uploads/reimbursements');
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
if (!@mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException('Upload directory is not available.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_writable($dir)) {
|
||||
throw new RuntimeException('Upload directory is not writable.');
|
||||
}
|
||||
|
||||
return $dir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\Reimbursement;
|
||||
|
||||
class ReimbursementLookupService
|
||||
{
|
||||
public function findReimbursementIdForExpense(int $expenseId): ?int
|
||||
{
|
||||
if ($expenseId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = Reimbursement::query()
|
||||
->select('id')
|
||||
->where('expense_id', $expenseId)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
return $row ? (int) $row->id : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\Expense;
|
||||
use App\Models\ReimbursementBatchAdminFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ReimbursementQueryService
|
||||
{
|
||||
public function __construct(
|
||||
private ReimbursementRecipientService $recipients,
|
||||
private ReimbursementFileService $files
|
||||
) {
|
||||
}
|
||||
|
||||
public function underProcessing(): array
|
||||
{
|
||||
$pendingQuery = DB::table('expenses as e')
|
||||
->selectRaw('e.id as expense_id, e.amount as expense_amount, e.description, e.receipt_path as expense_receipt, e.retailor, e.purchased_by, u.firstname as purchaser_firstname, u.lastname as purchaser_lastname')
|
||||
->leftJoin('users as u', 'u.id', '=', 'e.purchased_by')
|
||||
->leftJoin('reimbursement_batch_items as bi', function ($join) {
|
||||
$join->on('bi.expense_id', '=', 'e.id');
|
||||
$join->whereNull('bi.unassigned_at');
|
||||
})
|
||||
->whereNull('e.reimbursement_id')
|
||||
->where('e.category', '!=', 'Donation')
|
||||
->where(function ($q) {
|
||||
$q->where('e.status', 'approved')
|
||||
->orWhere('e.status', 'Approved')
|
||||
->orWhere('e.status', 'APPROVED');
|
||||
})
|
||||
->whereNull('bi.id')
|
||||
->orderByDesc('e.created_at');
|
||||
|
||||
$pendingRows = $pendingQuery->get();
|
||||
|
||||
$pendingItems = [];
|
||||
$itemsMap = [];
|
||||
|
||||
foreach ($pendingRows as $row) {
|
||||
$expenseId = (int) ($row->expense_id ?? 0);
|
||||
if ($expenseId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$receiptUrl = $this->files->expenseReceiptUrl($row->expense_receipt ?? null);
|
||||
$fullName = trim(($row->purchaser_firstname ?? '') . ' ' . ($row->purchaser_lastname ?? '')) ?: 'Unknown';
|
||||
|
||||
$item = [
|
||||
'id' => $expenseId,
|
||||
'expense_id' => $expenseId,
|
||||
'reimbursement_id' => null,
|
||||
'expense_amount' => (float) ($row->expense_amount ?? 0),
|
||||
'vendor' => (string) ($row->retailor ?? ''),
|
||||
'description' => trim((string) ($row->description ?? '')),
|
||||
'purchased_by' => $fullName,
|
||||
'receipt_url' => $receiptUrl,
|
||||
'batch_id' => null,
|
||||
'batch_label' => null,
|
||||
'admin_id' => null,
|
||||
'admin_name' => null,
|
||||
'batch_number' => 0,
|
||||
];
|
||||
|
||||
$pendingItems[] = $item;
|
||||
$itemsMap[$expenseId] = $item;
|
||||
}
|
||||
|
||||
$assignedQuery = DB::table('reimbursement_batch_items as bi')
|
||||
->selectRaw('bi.id as batch_item_id, bi.batch_id, bi.admin_id, bi.reimbursement_id, bi.expense_id, b.title as batch_title, b.status as batch_status, b.yearly_batch_number as batch_year_number, e.amount as expense_amount, e.description, e.receipt_path as expense_receipt, e.retailor, purchaser.firstname as purchaser_firstname, purchaser.lastname as purchaser_lastname, admin.firstname as admin_firstname, admin.lastname as admin_lastname')
|
||||
->join('reimbursement_batches as b', 'b.id', '=', 'bi.batch_id')
|
||||
->join('expenses as e', 'e.id', '=', 'bi.expense_id')
|
||||
->leftJoin('users as purchaser', 'purchaser.id', '=', 'e.purchased_by')
|
||||
->leftJoin('users as admin', 'admin.id', '=', 'bi.admin_id')
|
||||
->where('b.status', 'open')
|
||||
->whereNull('bi.unassigned_at')
|
||||
->whereNull('e.reimbursement_id')
|
||||
->where('e.category', '!=', 'Donation')
|
||||
->where(function ($q) {
|
||||
$q->where('e.status', 'approved')
|
||||
->orWhere('e.status', 'Approved')
|
||||
->orWhere('e.status', 'APPROVED');
|
||||
})
|
||||
->orderBy('b.opened_at', 'ASC')
|
||||
->orderBy('bi.assigned_at', 'ASC');
|
||||
|
||||
$assignedRows = $assignedQuery->get();
|
||||
$batchStructures = [];
|
||||
|
||||
foreach ($assignedRows as $row) {
|
||||
$expenseId = (int) ($row->expense_id ?? 0);
|
||||
$batchId = (int) ($row->batch_id ?? 0);
|
||||
if ($expenseId <= 0 || $batchId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$receiptUrl = $this->files->expenseReceiptUrl($row->expense_receipt ?? null);
|
||||
$purchaser = trim(($row->purchaser_firstname ?? '') . ' ' . ($row->purchaser_lastname ?? '')) ?: 'Unknown';
|
||||
$yearlyNumber = (int) ($row->batch_year_number ?? 0);
|
||||
$batchLabel = trim((string) ($row->batch_title ?? ''));
|
||||
if ($batchLabel === '') {
|
||||
$fallbackNumber = $yearlyNumber > 0 ? $yearlyNumber : $batchId;
|
||||
$batchLabel = 'Batch #' . $fallbackNumber;
|
||||
}
|
||||
$adminId = $row->admin_id ? (int) $row->admin_id : 0;
|
||||
$adminName = $adminId > 0
|
||||
? (trim(($row->admin_firstname ?? '') . ' ' . ($row->admin_lastname ?? '')) ?: 'Admin')
|
||||
: 'Unassigned';
|
||||
$batchStatus = strtolower(trim((string) ($row->batch_status ?? ''))) ?: 'open';
|
||||
|
||||
$item = [
|
||||
'id' => $expenseId,
|
||||
'expense_id' => $expenseId,
|
||||
'reimbursement_id' => $row->reimbursement_id ? (int) $row->reimbursement_id : null,
|
||||
'expense_amount' => (float) ($row->expense_amount ?? 0),
|
||||
'vendor' => (string) ($row->retailor ?? ''),
|
||||
'description' => trim((string) ($row->description ?? '')),
|
||||
'purchased_by' => $purchaser,
|
||||
'receipt_url' => $receiptUrl,
|
||||
'batch_id' => $batchId,
|
||||
'batch_label' => $batchLabel,
|
||||
'admin_id' => $adminId ?: null,
|
||||
'admin_name' => $adminId > 0 ? $adminName : null,
|
||||
'batch_number' => $batchId,
|
||||
];
|
||||
|
||||
$itemsMap[$expenseId] = $item;
|
||||
|
||||
if (!isset($batchStructures[$batchId])) {
|
||||
$batchStructures[$batchId] = [
|
||||
'batchId' => $batchId,
|
||||
'label' => $batchLabel,
|
||||
'slots' => [],
|
||||
'status' => $batchStatus,
|
||||
'yearly_batch_number' => $yearlyNumber,
|
||||
];
|
||||
} else {
|
||||
$batchStructures[$batchId]['status'] = $batchStatus;
|
||||
if ($yearlyNumber > 0) {
|
||||
$batchStructures[$batchId]['yearly_batch_number'] = $yearlyNumber;
|
||||
}
|
||||
}
|
||||
|
||||
$slotKey = $adminId ?: 0;
|
||||
if (!isset($batchStructures[$batchId]['slots'][$slotKey])) {
|
||||
$batchStructures[$batchId]['slots'][$slotKey] = [
|
||||
'admin_id' => $adminId ?: 0,
|
||||
'admin_name' => $adminId > 0 ? $adminName : 'Unassigned',
|
||||
'items' => [],
|
||||
'check_file' => null,
|
||||
];
|
||||
}
|
||||
$batchStructures[$batchId]['slots'][$slotKey]['items'][] = $expenseId;
|
||||
}
|
||||
|
||||
$adminFileMap = [];
|
||||
if (!empty($batchStructures)) {
|
||||
$batchIds = array_keys($batchStructures);
|
||||
$files = ReimbursementBatchAdminFile::query()
|
||||
->select('batch_id', 'admin_id', 'filename', 'original_filename')
|
||||
->whereIn('batch_id', $batchIds)
|
||||
->get();
|
||||
|
||||
foreach ($files as $file) {
|
||||
$batchId = (int) ($file->batch_id ?? 0);
|
||||
$adminId = (int) ($file->admin_id ?? 0);
|
||||
if ($batchId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$adminFileMap[$batchId][$adminId] = [
|
||||
'filename' => $file->filename,
|
||||
'original_filename' => $file->original_filename ?? null,
|
||||
'url' => $this->files->adminFileUrl($file->filename ?? '', 'inline'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($batchStructures as $batchId => &$batch) {
|
||||
foreach ($batch['slots'] as $slotKey => &$slot) {
|
||||
$adminId = $slot['admin_id'] ?? 0;
|
||||
$slot['check_file'] = $adminFileMap[$batchId][$adminId] ?? null;
|
||||
}
|
||||
unset($slot);
|
||||
}
|
||||
unset($batch);
|
||||
|
||||
$existingBatches = array_map(static function (array $batch) {
|
||||
$normalizedSlots = array_map(static function (array $slot) {
|
||||
$slot['items'] = array_values(array_map('intval', $slot['items']));
|
||||
return $slot;
|
||||
}, array_values($batch['slots']));
|
||||
|
||||
$itemIds = [];
|
||||
foreach ($normalizedSlots as $slot) {
|
||||
$itemIds = array_merge($itemIds, $slot['items']);
|
||||
}
|
||||
|
||||
return [
|
||||
'batchId' => $batch['batchId'],
|
||||
'batchNumber' => $batch['batchId'],
|
||||
'label' => $batch['label'],
|
||||
'sequence' => $batch['yearly_batch_number'] ?? 0,
|
||||
'yearly_batch_number' => $batch['yearly_batch_number'] ?? 0,
|
||||
'slots' => $normalizedSlots,
|
||||
'itemIds' => array_values(array_unique($itemIds)),
|
||||
'status' => $batch['status'] ?? 'open',
|
||||
];
|
||||
}, array_values($batchStructures));
|
||||
|
||||
$admins = $this->recipients->adminRoster();
|
||||
|
||||
return [
|
||||
'pendingItems' => $pendingItems,
|
||||
'existingBatches' => $existingBatches,
|
||||
'adminUsers' => $admins,
|
||||
'itemsPayload' => array_values($itemsMap),
|
||||
];
|
||||
}
|
||||
|
||||
public function index(array $filters): array
|
||||
{
|
||||
$expenses = Expense::getReimbursedExpensesWithDetails($filters)
|
||||
->get()
|
||||
->map(static fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$batchSummaries = [];
|
||||
$batchDetails = [];
|
||||
$batchAttachments = [];
|
||||
|
||||
$batchQuery = DB::table('reimbursement_batches as b')
|
||||
->selectRaw('b.id as batch_id, b.title as batch_title, b.yearly_batch_number, b.closed_at, b.school_year as batch_school_year, e.id as expense_id, e.amount as expense_amount, e.description, e.retailor, e.receipt_path as expense_receipt, e.date_of_purchase as expense_date_of_purchase, e.purchased_by, u.firstname as purchaser_firstname, u.lastname as purchaser_lastname, r.amount as reimb_amount')
|
||||
->join('reimbursement_batch_items as bi', 'bi.batch_id', '=', 'b.id')
|
||||
->join('expenses as e', 'e.id', '=', 'bi.expense_id')
|
||||
->leftJoin('users as u', 'u.id', '=', 'e.purchased_by')
|
||||
->leftJoin('reimbursements as r', 'r.expense_id', '=', 'e.id')
|
||||
->where('b.status', 'closed')
|
||||
->whereNull('bi.unassigned_at');
|
||||
|
||||
if (!empty($filters['school_year'])) {
|
||||
$batchQuery->where(function ($q) use ($filters) {
|
||||
$q->where('b.school_year', $filters['school_year'])
|
||||
->orWhere('e.school_year', $filters['school_year']);
|
||||
});
|
||||
}
|
||||
if (!empty($filters['user_id'])) {
|
||||
$batchQuery->where('e.purchased_by', $filters['user_id']);
|
||||
}
|
||||
|
||||
$batchRows = $batchQuery->get();
|
||||
foreach ($batchRows as $row) {
|
||||
$bid = (int) ($row->batch_id ?? 0);
|
||||
if ($bid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$labelNumber = (int) ($row->yearly_batch_number ?? $bid);
|
||||
if (!isset($batchSummaries[$bid])) {
|
||||
$batchSummaries[$bid] = [
|
||||
'batch_id' => $bid,
|
||||
'title' => trim((string) ($row->batch_title ?? '')) ?: ('Batch #' . $labelNumber),
|
||||
'sequence' => $labelNumber,
|
||||
'closed_at' => $row->closed_at ?? null,
|
||||
'amount' => 0.0,
|
||||
'items' => 0,
|
||||
];
|
||||
}
|
||||
$amount = $row->reimb_amount ?? $row->expense_amount ?? 0;
|
||||
$batchSummaries[$bid]['amount'] += (float) $amount;
|
||||
$batchSummaries[$bid]['items'] += 1;
|
||||
|
||||
if (!isset($batchDetails[$bid])) {
|
||||
$batchDetails[$bid] = [
|
||||
'title' => $batchSummaries[$bid]['title'],
|
||||
'sequence' => $labelNumber,
|
||||
'closed_at' => $batchSummaries[$bid]['closed_at'],
|
||||
'items' => [],
|
||||
'checks' => [],
|
||||
];
|
||||
}
|
||||
if (!isset($batchAttachments[$bid])) {
|
||||
$batchAttachments[$bid] = [
|
||||
'receipts' => [],
|
||||
'checks' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$batchDetails[$bid]['items'][] = [
|
||||
'amount' => (float) $amount,
|
||||
'description' => $row->description ?? '',
|
||||
'vendor' => $row->retailor ?? '',
|
||||
'purchased_by' => trim(($row->purchaser_firstname ?? '') . ' ' . ($row->purchaser_lastname ?? '')),
|
||||
'receipt_url' => $this->files->expenseReceiptUrl($row->expense_receipt ?? null),
|
||||
];
|
||||
|
||||
$receiptFilename = trim((string) ($row->expense_receipt ?? ''));
|
||||
if ($receiptFilename !== '') {
|
||||
$batchAttachments[$bid]['receipts'][] = [
|
||||
'expense_id' => (int) ($row->expense_id ?? 0),
|
||||
'receipt_filename' => $receiptFilename,
|
||||
'receipt_url' => $this->files->expenseReceiptUrl($receiptFilename),
|
||||
'purchaser_firstname' => $row->purchaser_firstname ?? '',
|
||||
'purchaser_lastname' => $row->purchaser_lastname ?? '',
|
||||
'vendor' => $row->retailor ?? '',
|
||||
'description' => $row->description ?? '',
|
||||
'amount' => (float) ($row->expense_amount ?? 0),
|
||||
'date_of_purchase' => $row->expense_date_of_purchase ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($batchSummaries)) {
|
||||
$batchIds = array_keys($batchSummaries);
|
||||
$files = ReimbursementBatchAdminFile::query()
|
||||
->select('id as file_id', 'batch_id', 'admin_id', 'filename', 'original_filename', 'uploaded_at')
|
||||
->whereIn('batch_id', $batchIds)
|
||||
->get();
|
||||
|
||||
$adminNames = $this->recipients->adminRoster();
|
||||
$adminNameMap = [];
|
||||
foreach ($adminNames as $admin) {
|
||||
$adminNameMap[(int) $admin['id']] = trim(($admin['firstname'] ?? '') . ' ' . ($admin['lastname'] ?? ''));
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$bid = (int) ($file->batch_id ?? 0);
|
||||
if ($bid <= 0 || !isset($batchDetails[$bid])) {
|
||||
continue;
|
||||
}
|
||||
$aid = (int) ($file->admin_id ?? 0);
|
||||
$batchDetails[$bid]['checks'][] = [
|
||||
'admin' => $adminNameMap[$aid] ?? ($aid > 0 ? 'Admin #' . $aid : 'Unassigned'),
|
||||
'filename' => $file->filename,
|
||||
'original' => $file->original_filename ?? $file->filename,
|
||||
'url' => $this->files->adminFileUrl($file->filename ?? '', 'inline'),
|
||||
];
|
||||
if (!isset($batchAttachments[$bid])) {
|
||||
$batchAttachments[$bid] = [
|
||||
'receipts' => [],
|
||||
'checks' => [],
|
||||
];
|
||||
}
|
||||
$batchAttachments[$bid]['checks'][] = [
|
||||
'file_id' => (int) ($file->file_id ?? 0),
|
||||
'filename' => $file->filename,
|
||||
'original_filename' => $file->original_filename ?? $file->filename,
|
||||
'admin' => $adminNameMap[$aid] ?? ($aid > 0 ? 'Admin #' . $aid : 'Unassigned'),
|
||||
'url' => $this->files->adminFileUrl($file->filename ?? '', 'inline'),
|
||||
'uploaded_at' => $file->uploaded_at ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($batchSummaries)) {
|
||||
$batchSummaries = array_values($batchSummaries);
|
||||
usort($batchSummaries, static function ($a, $b) {
|
||||
$aSeq = (int) ($a['sequence'] ?? 0);
|
||||
$bSeq = (int) ($b['sequence'] ?? 0);
|
||||
return $bSeq <=> $aSeq;
|
||||
});
|
||||
}
|
||||
|
||||
$donationBatch = null;
|
||||
$donationDetails = [
|
||||
'items' => [],
|
||||
'checks' => [],
|
||||
];
|
||||
|
||||
$donationQuery = DB::table('expenses as e')
|
||||
->selectRaw('e.id as expense_id, e.amount as expense_amount, e.description, e.retailor, e.receipt_path as expense_receipt, e.purchased_by, e.school_year, e.semester, e.status, u.firstname as purchaser_firstname, u.lastname as purchaser_lastname')
|
||||
->leftJoin('users as u', 'u.id', '=', 'e.purchased_by')
|
||||
->where('e.category', 'Donation');
|
||||
|
||||
if (!empty($filters['school_year'])) {
|
||||
$donationQuery->where('e.school_year', $filters['school_year']);
|
||||
}
|
||||
if (!empty($filters['semester'])) {
|
||||
$donationQuery->where('e.semester', $filters['semester']);
|
||||
}
|
||||
if (!empty($filters['user_id'])) {
|
||||
$donationQuery->where('e.purchased_by', $filters['user_id']);
|
||||
}
|
||||
if (!empty($filters['status'])) {
|
||||
$donationQuery->where(function ($q) use ($filters) {
|
||||
$q->where('e.status', $filters['status'])
|
||||
->orWhere('e.status', strtolower((string) $filters['status']))
|
||||
->orWhere('e.status', strtoupper((string) $filters['status']));
|
||||
});
|
||||
}
|
||||
|
||||
$donationRows = $donationQuery->orderByDesc('e.created_at')->get();
|
||||
if ($donationRows->isNotEmpty()) {
|
||||
$donationTotal = 0.0;
|
||||
foreach ($donationRows as $row) {
|
||||
$amount = (float) ($row->expense_amount ?? 0);
|
||||
$donationTotal += $amount;
|
||||
$purchaser = trim(($row->purchaser_firstname ?? '') . ' ' . ($row->purchaser_lastname ?? ''));
|
||||
if ($purchaser === '') {
|
||||
$purchaser = 'Unknown';
|
||||
}
|
||||
$donationDetails['items'][] = [
|
||||
'amount' => $amount,
|
||||
'description' => $row->description ?? '',
|
||||
'vendor' => $row->retailor ?? '',
|
||||
'purchased_by' => $purchaser,
|
||||
'receipt_url' => $this->files->expenseReceiptUrl($row->expense_receipt ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
$donationBatch = [
|
||||
'title' => 'Donations',
|
||||
'items' => count($donationDetails['items']),
|
||||
'amount' => $donationTotal,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($expenses as &$e) {
|
||||
if (array_key_exists('expense_receipt', $e)) {
|
||||
$e['expense_receipt_url'] = $this->files->expenseReceiptUrl($e['expense_receipt']);
|
||||
} elseif (array_key_exists('receipt_path', $e)) {
|
||||
$e['expense_receipt_url'] = $this->files->expenseReceiptUrl($e['receipt_path']);
|
||||
}
|
||||
|
||||
if (array_key_exists('reimb_receipt', $e)) {
|
||||
$e['reimb_receipt_url'] = $this->files->receiptUrl($e['reimb_receipt']);
|
||||
} elseif (array_key_exists('receipt_path_reimb', $e)) {
|
||||
$e['reimb_receipt_url'] = $this->files->receiptUrl($e['receipt_path_reimb']);
|
||||
}
|
||||
|
||||
$recipientId = $e['reimb_recipient_id'] ?? ($e['reimbursed_to'] ?? null);
|
||||
$hasName = trim(($e['reimb_firstname'] ?? '') . ' ' . ($e['reimb_lastname'] ?? '')) !== '';
|
||||
if (!$hasName) {
|
||||
$label = $this->recipients->specialRecipientLabel($recipientId);
|
||||
if ($label !== null) {
|
||||
$e['reimb_firstname'] = $label;
|
||||
$e['reimb_lastname'] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($e);
|
||||
|
||||
$users = $this->recipients->recipientOptions();
|
||||
|
||||
$years = DB::table('reimbursements')
|
||||
->select('school_year')
|
||||
->distinct()
|
||||
->orderByDesc('school_year')
|
||||
->get();
|
||||
$schoolYears = array_values(array_filter(array_map(static fn ($row) => $row->school_year ?? null, $years->all())));
|
||||
|
||||
return [
|
||||
'expenses' => $expenses,
|
||||
'users' => $users,
|
||||
'schoolYears' => $schoolYears,
|
||||
'batchSummaries' => $batchSummaries,
|
||||
'batchDetails' => $batchDetails,
|
||||
'batchAttachments' => $batchAttachments,
|
||||
'donationBatch' => $donationBatch,
|
||||
'donationDetails' => $donationDetails,
|
||||
];
|
||||
}
|
||||
|
||||
public function reimbursedExpenses(): array
|
||||
{
|
||||
$rows = DB::table('reimbursements as r')
|
||||
->selectRaw('e.amount as expense_amount, e.category, e.description, e.receipt_path as expense_receipt, e.purchased_by, r.amount as reimb_amount, r.reimbursement_method, r.check_number, r.receipt_path as reimb_receipt, r.status as reimb_status, r.created_at, u.firstname as purchaser_firstname, u.lastname as purchaser_lastname, a.firstname as approver_firstname, a.lastname as approver_lastname')
|
||||
->join('expenses as e', 'e.id', '=', 'r.expense_id')
|
||||
->leftJoin('users as u', 'u.id', '=', 'e.purchased_by')
|
||||
->leftJoin('users as a', 'a.id', '=', 'r.approved_by')
|
||||
->orderByDesc('r.created_at')
|
||||
->get();
|
||||
|
||||
$expenses = [];
|
||||
foreach ($rows as $row) {
|
||||
$expenses[] = [
|
||||
'expense_amount' => (float) ($row->expense_amount ?? 0),
|
||||
'category' => $row->category ?? null,
|
||||
'description' => $row->description ?? null,
|
||||
'expense_receipt' => $row->expense_receipt ?? null,
|
||||
'purchased_by' => $row->purchased_by ?? null,
|
||||
'reimb_amount' => (float) ($row->reimb_amount ?? 0),
|
||||
'reimbursement_method' => $row->reimbursement_method ?? null,
|
||||
'check_number' => $row->check_number ?? null,
|
||||
'reimb_receipt' => $row->reimb_receipt ?? null,
|
||||
'reimb_status' => $row->reimb_status ?? null,
|
||||
'created_at' => $row->created_at ?? null,
|
||||
'purchaser_firstname' => $row->purchaser_firstname ?? null,
|
||||
'purchaser_lastname' => $row->purchaser_lastname ?? null,
|
||||
'approver_firstname' => $row->approver_firstname ?? null,
|
||||
'approver_lastname' => $row->approver_lastname ?? null,
|
||||
'expense_receipt_url' => $this->files->expenseReceiptUrl($row->expense_receipt ?? null),
|
||||
'reimb_receipt_url' => $this->files->receiptUrl($row->reimb_receipt ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
return $expenses;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ReimbursementRecipientService
|
||||
{
|
||||
public const SPECIAL_RECIPIENTS = [
|
||||
990001 => 'Masjid',
|
||||
990002 => 'Donation',
|
||||
];
|
||||
|
||||
public function staffUsers(): array
|
||||
{
|
||||
$rows = DB::table('users')
|
||||
->select('users.id', 'users.firstname', 'users.lastname', 'roles.name as role_name')
|
||||
->leftJoin('user_roles', 'user_roles.user_id', '=', 'users.id')
|
||||
->leftJoin('roles', 'roles.id', '=', 'user_roles.role_id')
|
||||
->whereNotNull('roles.name')
|
||||
->get();
|
||||
|
||||
$excludedRoles = array_map('strtolower', ['parent', 'student', 'guest']);
|
||||
$staff = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$roleName = strtolower((string) ($row->role_name ?? ''));
|
||||
$id = (int) ($row->id ?? 0);
|
||||
if ($id <= 0 || in_array($roleName, $excludedRoles, true)) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($staff[$id])) {
|
||||
$staff[$id] = [
|
||||
'id' => $id,
|
||||
'firstname' => $row->firstname ?? '',
|
||||
'lastname' => $row->lastname ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
uasort($staff, static function (array $a, array $b) {
|
||||
$nameA = trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? ''));
|
||||
$nameB = trim(($b['firstname'] ?? '') . ' ' . ($b['lastname'] ?? ''));
|
||||
return strcasecmp($nameA, $nameB);
|
||||
});
|
||||
|
||||
return array_values($staff);
|
||||
}
|
||||
|
||||
public function recipientOptions(): array
|
||||
{
|
||||
return $this->appendSpecialRecipients($this->staffUsers());
|
||||
}
|
||||
|
||||
public function adminRoster(): array
|
||||
{
|
||||
$admins = $this->staffUsers();
|
||||
if (!empty($admins)) {
|
||||
return $admins;
|
||||
}
|
||||
|
||||
return $this->recipientOptions();
|
||||
}
|
||||
|
||||
public function specialRecipientLabel($value): ?string
|
||||
{
|
||||
if ($value === null || !is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = (int) $value;
|
||||
return self::SPECIAL_RECIPIENTS[$id] ?? null;
|
||||
}
|
||||
|
||||
private function appendSpecialRecipients(array $users): array
|
||||
{
|
||||
$existingIds = array_map(static function ($row) {
|
||||
return isset($row['id']) && is_numeric($row['id']) ? (int) $row['id'] : null;
|
||||
}, $users);
|
||||
|
||||
foreach (self::SPECIAL_RECIPIENTS as $id => $label) {
|
||||
if (!in_array($id, $existingIds, true)) {
|
||||
$users[] = [
|
||||
'id' => $id,
|
||||
'firstname' => $label,
|
||||
'lastname' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $users;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user