mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-03 05:41:07 +00:00
feat(reporting): fresh reporting implementation
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Reporting\Http\Controllers\Company;
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Purchases\Models\Expense;
|
||||
use App\Domains\Receivables\Models\Payment;
|
||||
use App\Domains\Sales\Models\Estimate;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Platform\Http\Controller;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Silber\Bouncer\BouncerFacade;
|
||||
|
||||
/**
|
||||
* The company overview: a twelve-month money series, the headline counters and
|
||||
* the two "latest activity" lists.
|
||||
*
|
||||
* The series is anchored on the company's `fiscal_year` preference, whose first
|
||||
* dash-separated component names the opening month. Anything the parser cannot
|
||||
* read — the shipped default is the word "calendar_year" — intval()s to zero,
|
||||
* and month zero rolls Carbon back into December of the year before. That is
|
||||
* the window those companies really get, so it is reproduced rather than
|
||||
* corrected.
|
||||
*/
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$companyId = $request->header('company');
|
||||
|
||||
$this->authorize('view dashboard', Company::find($companyId));
|
||||
|
||||
$openingMonth = intval(explode('-', CompanySetting::getSetting('fiscal_year', $companyId))[0]);
|
||||
|
||||
// Three cursors over the same starting instant: the fixed left edge of
|
||||
// the whole window, and the pair that walks it a month at a time.
|
||||
$windowStart = Carbon::now();
|
||||
$monthStart = Carbon::now();
|
||||
$monthEnd = Carbon::now();
|
||||
|
||||
// A fiscal year whose opening month is still ahead in the calendar year
|
||||
// is the one that opened twelve months ago.
|
||||
$openedLastYear = $openingMonth > $monthStart->month;
|
||||
|
||||
foreach ([$windowStart, $monthStart, $monthEnd] as $cursor) {
|
||||
if ($openedLastYear) {
|
||||
$cursor->subYear();
|
||||
}
|
||||
|
||||
$cursor->month($openingMonth);
|
||||
}
|
||||
|
||||
$windowStart->startOfMonth();
|
||||
$monthStart->startOfMonth();
|
||||
$monthEnd->endOfMonth();
|
||||
|
||||
// The key's presence is the whole signal — its value is never read.
|
||||
$previousYear = $request->has('previous_year');
|
||||
|
||||
if ($previousYear) {
|
||||
$windowStart->subYear()->startOfMonth();
|
||||
$monthStart->subYear()->startOfMonth();
|
||||
$monthEnd->subYear()->endOfMonth();
|
||||
}
|
||||
|
||||
$months = [];
|
||||
$invoiceTotals = [];
|
||||
$expenseTotals = [];
|
||||
$receiptTotals = [];
|
||||
$netIncomeTotals = [];
|
||||
|
||||
for ($bucket = 0; $bucket < 12; $bucket++) {
|
||||
$bucketSpan = [$monthStart->format('Y-m-d'), $monthEnd->format('Y-m-d')];
|
||||
|
||||
$invoiceTotals[] = Invoice::query()
|
||||
->whereBetween('invoice_date', $bucketSpan)
|
||||
->whereCompany()
|
||||
->sum('base_total');
|
||||
|
||||
$expenseTotals[] = Expense::query()
|
||||
->whereBetween('expense_date', $bucketSpan)
|
||||
->whereCompany()
|
||||
->sum('base_amount');
|
||||
|
||||
$receiptTotals[] = Payment::query()
|
||||
->whereBetween('payment_date', $bucketSpan)
|
||||
->whereCompany()
|
||||
->sum('base_amount');
|
||||
|
||||
// Net income is what came in less what went out. Invoiced money is
|
||||
// not part of it — only money actually received counts.
|
||||
$netIncomeTotals[] = $receiptTotals[$bucket] - $expenseTotals[$bucket];
|
||||
|
||||
$months[] = $monthStart->translatedFormat('M');
|
||||
|
||||
// Both cursors step forward off the first of their month, so a
|
||||
// short month can never drag the walk backwards.
|
||||
$monthEnd->startOfMonth()->addMonth()->endOfMonth();
|
||||
$monthStart->addMonth()->startOfMonth();
|
||||
}
|
||||
|
||||
// Twelve steps left the walking cursor on the month after the window.
|
||||
// Back it up on to the last month and take that month's final day as
|
||||
// the right edge of the whole-window figures.
|
||||
$monthStart->subMonth()->endOfMonth();
|
||||
|
||||
$windowSpan = [$windowStart->format('Y-m-d'), $monthStart->format('Y-m-d')];
|
||||
|
||||
$totalSales = Invoice::query()
|
||||
->whereBetween('invoice_date', $windowSpan)
|
||||
->whereCompany()
|
||||
->sum('base_total');
|
||||
|
||||
$totalReceipts = Payment::query()
|
||||
->whereBetween('payment_date', $windowSpan)
|
||||
->whereCompany()
|
||||
->sum('base_amount');
|
||||
|
||||
$totalExpenses = Expense::query()
|
||||
->whereBetween('expense_date', $windowSpan)
|
||||
->whereCompany()
|
||||
->sum('base_amount');
|
||||
|
||||
$totalNetIncome = (int) $totalReceipts - (int) $totalExpenses;
|
||||
|
||||
$chartData = [
|
||||
'months' => $months,
|
||||
'invoice_totals' => $invoiceTotals,
|
||||
'expense_totals' => $expenseTotals,
|
||||
'receipt_totals' => $receiptTotals,
|
||||
'net_income_totals' => $netIncomeTotals,
|
||||
];
|
||||
|
||||
$customerCount = Customer::query()->whereCompany()->count();
|
||||
|
||||
// "How many invoices did we issue" counts issued documents, so the
|
||||
// reversals are left out. The money figures above deliberately keep
|
||||
// them: a credit note's negated total is exactly what nets a sale back
|
||||
// out. The outstanding sum below keeps them too, which is a quirk
|
||||
// rather than a decision — a credit note's due amount is always zero,
|
||||
// so it adds nothing, and the sum has always been taken over the lot.
|
||||
$invoiceCount = Invoice::query()
|
||||
->whereCompany()
|
||||
->where('type', Invoice::TYPE_INVOICE)
|
||||
->count();
|
||||
|
||||
$estimateCount = Estimate::query()->whereCompany()->count();
|
||||
|
||||
$amountDue = Invoice::query()
|
||||
->whereCompany()
|
||||
->sum('base_due_amount');
|
||||
|
||||
// Raw models rather than InvoiceResource: each loaded relation is
|
||||
// serialized with the full $appends set, so a column-limited
|
||||
// creditNotes load blew up inside the date accessors (the children
|
||||
// arrive without company_id) and loading them whole would run those
|
||||
// appends per credit note for nothing. Neither list needs the relation
|
||||
// anyway — credited_status is a resource-level field, and a fully
|
||||
// credited invoice has no due amount left, so it never reaches here.
|
||||
$recentDueInvoices = Invoice::with('customer')
|
||||
->whereCompany()->where('base_due_amount', '>', 0)
|
||||
->take(5)
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
$recentEstimates = Estimate::with('customer')
|
||||
->whereCompany()
|
||||
->take(5)
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Both lists are gated on the viewer's own document rights and come
|
||||
// back empty — never absent — when those are missing. The counters and
|
||||
// the money figures are not gated at all: holding the dashboard
|
||||
// ability is enough to see company revenue.
|
||||
return response()->json([
|
||||
'total_amount_due' => $amountDue,
|
||||
'total_customer_count' => $customerCount,
|
||||
'total_invoice_count' => $invoiceCount,
|
||||
'total_estimate_count' => $estimateCount,
|
||||
|
||||
'recent_due_invoices' => BouncerFacade::can('view-invoice', Invoice::class) ? $recentDueInvoices : [],
|
||||
'recent_estimates' => BouncerFacade::can('view-estimate', Estimate::class) ? $recentEstimates : [],
|
||||
|
||||
'chart_data' => $chartData,
|
||||
|
||||
'total_sales' => $totalSales,
|
||||
'total_receipts' => $totalReceipts,
|
||||
'total_expenses' => $totalExpenses,
|
||||
'total_net_income' => $totalNetIncome,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user