Skip to content

Commit d6550e7

Browse files
committed
feat: Phase 44 — Comparative Profit and Loss report
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 71d4a2f commit d6550e7

5 files changed

Lines changed: 630 additions & 1 deletion

File tree

erp/app/Modules/Finance/Http/Controllers/ReportController.php

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -934,7 +934,193 @@ public function exportSupplierStatement(Request $request): \Symfony\Component\Ht
934934
);
935935
}
936936

937-
// ─── CSV Export Methods ───────────────────────────────────────────────────
937+
public function comparativeProfitLoss(Request $request): Response
938+
{
939+
$this->authorize('viewAny', Account::class);
940+
941+
$currentFrom = $request->get('current_from', now()->startOfMonth()->toDateString());
942+
$currentTo = $request->get('current_to', now()->toDateString());
943+
$priorFrom = $request->get('prior_from', now()->subMonth()->startOfMonth()->toDateString());
944+
$priorTo = $request->get('prior_to', now()->subMonth()->endOfMonth()->toDateString());
945+
946+
$buildSection = function (string $type, string $from, string $to): array {
947+
$totals = $this->aggregateJournalLines($from, $to);
948+
949+
return Account::where('type', $type)
950+
->orderBy('name')
951+
->get()
952+
->map(function (Account $account) use ($totals, $type) {
953+
$row = $totals->get($account->id);
954+
$debit = (float) ($row?->total_debit ?? 0);
955+
$credit = (float) ($row?->total_credit ?? 0);
956+
$balance = $type === 'income'
957+
? $credit - $debit
958+
: $debit - $credit;
959+
return [
960+
'id' => $account->id,
961+
'name' => $account->name,
962+
'code' => $account->code ?? '',
963+
'balance' => round($balance, 2),
964+
];
965+
})
966+
->filter(fn ($row) => $row['balance'] != 0)
967+
->values()
968+
->toArray();
969+
};
970+
971+
$currentIncome = $buildSection('income', $currentFrom, $currentTo);
972+
$currentExpenses = $buildSection('expense', $currentFrom, $currentTo);
973+
$priorIncome = $buildSection('income', $priorFrom, $priorTo);
974+
$priorExpenses = $buildSection('expense', $priorFrom, $priorTo);
975+
976+
// Merge account lists (union of both periods)
977+
$allIncomeIds = collect(array_merge($currentIncome, $priorIncome))->pluck('id')->unique();
978+
$allExpenseIds = collect(array_merge($currentExpenses, $priorExpenses))->pluck('id')->unique();
979+
980+
$indexBy = fn (array $rows) => collect($rows)->keyBy('id');
981+
982+
$currentIncomeIdx = $indexBy($currentIncome);
983+
$priorIncomeIdx = $indexBy($priorIncome);
984+
$currentExpenseIdx = $indexBy($currentExpenses);
985+
$priorExpenseIdx = $indexBy($priorExpenses);
986+
987+
$mergeRows = function ($ids, $currentIdx, $priorIdx) {
988+
return $ids->map(function ($id) use ($currentIdx, $priorIdx) {
989+
$cur = $currentIdx->get($id);
990+
$pri = $priorIdx->get($id);
991+
return [
992+
'id' => $id,
993+
'name' => ($cur ?? $pri)['name'],
994+
'code' => ($cur ?? $pri)['code'],
995+
'current' => $cur['balance'] ?? 0,
996+
'prior' => $pri['balance'] ?? 0,
997+
'change' => round(($cur['balance'] ?? 0) - ($pri['balance'] ?? 0), 2),
998+
];
999+
})->sortBy('name')->values()->toArray();
1000+
};
1001+
1002+
$incomeRows = $mergeRows($allIncomeIds, $currentIncomeIdx, $priorIncomeIdx);
1003+
$expenseRows = $mergeRows($allExpenseIds, $currentExpenseIdx, $priorExpenseIdx);
1004+
1005+
$totalCurrentIncome = round(collect($incomeRows)->sum('current'), 2);
1006+
$totalPriorIncome = round(collect($incomeRows)->sum('prior'), 2);
1007+
$totalCurrentExpenses = round(collect($expenseRows)->sum('current'), 2);
1008+
$totalPriorExpenses = round(collect($expenseRows)->sum('prior'), 2);
1009+
1010+
return Inertia::render('Finance/Reports/ComparativeProfitLoss', [
1011+
'incomeRows' => $incomeRows,
1012+
'expenseRows' => $expenseRows,
1013+
'totalCurrentIncome' => $totalCurrentIncome,
1014+
'totalPriorIncome' => $totalPriorIncome,
1015+
'totalCurrentExpenses' => $totalCurrentExpenses,
1016+
'totalPriorExpenses' => $totalPriorExpenses,
1017+
'netCurrentProfit' => round($totalCurrentIncome - $totalCurrentExpenses, 2),
1018+
'netPriorProfit' => round($totalPriorIncome - $totalPriorExpenses, 2),
1019+
'currentFrom' => $currentFrom,
1020+
'currentTo' => $currentTo,
1021+
'priorFrom' => $priorFrom,
1022+
'priorTo' => $priorTo,
1023+
]);
1024+
}
1025+
1026+
public function exportComparativeProfitLoss(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse
1027+
{
1028+
$this->authorize('viewAny', Account::class);
1029+
1030+
$currentFrom = $request->get('current_from', now()->startOfMonth()->toDateString());
1031+
$currentTo = $request->get('current_to', now()->toDateString());
1032+
$priorFrom = $request->get('prior_from', now()->subMonth()->startOfMonth()->toDateString());
1033+
$priorTo = $request->get('prior_to', now()->subMonth()->endOfMonth()->toDateString());
1034+
1035+
$buildSection = function (string $type, string $from, string $to): array {
1036+
$totals = $this->aggregateJournalLines($from, $to);
1037+
1038+
return Account::where('type', $type)
1039+
->orderBy('name')
1040+
->get()
1041+
->map(function (Account $account) use ($totals, $type) {
1042+
$row = $totals->get($account->id);
1043+
$debit = (float) ($row?->total_debit ?? 0);
1044+
$credit = (float) ($row?->total_credit ?? 0);
1045+
$balance = $type === 'income'
1046+
? $credit - $debit
1047+
: $debit - $credit;
1048+
return [
1049+
'id' => $account->id,
1050+
'name' => $account->name,
1051+
'code' => $account->code ?? '',
1052+
'balance' => round($balance, 2),
1053+
];
1054+
})
1055+
->filter(fn ($row) => $row['balance'] != 0)
1056+
->values()
1057+
->toArray();
1058+
};
1059+
1060+
$currentIncome = $buildSection('income', $currentFrom, $currentTo);
1061+
$currentExpenses = $buildSection('expense', $currentFrom, $currentTo);
1062+
$priorIncome = $buildSection('income', $priorFrom, $priorTo);
1063+
$priorExpenses = $buildSection('expense', $priorFrom, $priorTo);
1064+
1065+
$allIncomeIds = collect(array_merge($currentIncome, $priorIncome))->pluck('id')->unique();
1066+
$allExpenseIds = collect(array_merge($currentExpenses, $priorExpenses))->pluck('id')->unique();
1067+
1068+
$indexBy = fn (array $rows) => collect($rows)->keyBy('id');
1069+
1070+
$currentIncomeIdx = $indexBy($currentIncome);
1071+
$priorIncomeIdx = $indexBy($priorIncome);
1072+
$currentExpenseIdx = $indexBy($currentExpenses);
1073+
$priorExpenseIdx = $indexBy($priorExpenses);
1074+
1075+
$rows = [];
1076+
1077+
foreach ($allIncomeIds as $id) {
1078+
$cur = $currentIncomeIdx->get($id);
1079+
$pri = $priorIncomeIdx->get($id);
1080+
$name = ($cur ?? $pri)['name'];
1081+
$code = ($cur ?? $pri)['code'];
1082+
$rows[] = [
1083+
'Income',
1084+
$code,
1085+
$name,
1086+
number_format($cur['balance'] ?? 0, 2, '.', ''),
1087+
number_format($pri['balance'] ?? 0, 2, '.', ''),
1088+
number_format(($cur['balance'] ?? 0) - ($pri['balance'] ?? 0), 2, '.', ''),
1089+
];
1090+
}
1091+
1092+
foreach ($allExpenseIds as $id) {
1093+
$cur = $currentExpenseIdx->get($id);
1094+
$pri = $priorExpenseIdx->get($id);
1095+
$name = ($cur ?? $pri)['name'];
1096+
$code = ($cur ?? $pri)['code'];
1097+
$rows[] = [
1098+
'Expense',
1099+
$code,
1100+
$name,
1101+
number_format($cur['balance'] ?? 0, 2, '.', ''),
1102+
number_format($pri['balance'] ?? 0, 2, '.', ''),
1103+
number_format(($cur['balance'] ?? 0) - ($pri['balance'] ?? 0), 2, '.', ''),
1104+
];
1105+
}
1106+
1107+
$totalCurrentIncome = collect($currentIncome)->sum('balance');
1108+
$totalPriorIncome = collect($priorIncome)->sum('balance');
1109+
$totalCurrentExpenses = collect($currentExpenses)->sum('balance');
1110+
$totalPriorExpenses = collect($priorExpenses)->sum('balance');
1111+
1112+
$netCurrent = $totalCurrentIncome - $totalCurrentExpenses;
1113+
$netPrior = $totalPriorIncome - $totalPriorExpenses;
1114+
$rows[] = ['Net Profit', '', '', number_format($netCurrent, 2, '.', ''), number_format($netPrior, 2, '.', ''), number_format($netCurrent - $netPrior, 2, '.', '')];
1115+
1116+
return $this->streamCsv(
1117+
"comparative-profit-loss-{$currentFrom}-{$currentTo}.csv",
1118+
['Section', 'Code', 'Account', 'Current Period', 'Prior Period', 'Change'],
1119+
$rows
1120+
);
1121+
}
1122+
1123+
// ─── CSV Export Methods ───────────────────────────────────────────────────
9381124

9391125
public function exportProfitLoss(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse
9401126
{

erp/app/Modules/Finance/routes/finance.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@
126126
Route::get('reports/aged-payables/export', [ReportController::class, 'exportAgedPayables'])->name('reports.aged-payables.export');
127127
Route::get('reports/account-ledger/{account}/export', [ReportController::class, 'exportAccountLedger'])->name('reports.account-ledger.export');
128128
Route::get('reports/vat-report/export', [ReportController::class, 'exportVatReport'])->name('reports.vat-report.export');
129+
Route::get('reports/comparative-profit-loss', [ReportController::class, 'comparativeProfitLoss'])->name('reports.comparative-profit-loss');
130+
Route::get('reports/comparative-profit-loss/export', [ReportController::class, 'exportComparativeProfitLoss'])->name('reports.comparative-profit-loss.export');
129131
Route::get('reports/cash-flow-forecast/export', [ReportController::class, 'exportCashFlowForecast'])->name('reports.cash-flow-forecast.export');
130132

131133
// Exchange Rates

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ const navItems: NavItem[] = [
8686
{ label: 'Supplier Statement', href: '/finance/reports/supplier-statement', icon: <span /> },
8787
{ label: 'VAT Report', href: '/finance/reports/vat-report', icon: <span /> },
8888
{ label: 'Cash Flow', href: '/finance/reports/cash-flow-forecast', icon: <span /> },
89+
{ label: 'Comparative P&L', href: '/finance/reports/comparative-profit-loss', icon: <span /> },
8990
{ label: 'Exchange Rates', href: '/finance/exchange-rates', icon: <span /> },
9091
{ label: 'Bank Accounts', href: '/finance/bank-accounts', icon: <span /> },
9192
{ label: 'Budgets', href: '/finance/budgets', icon: <span /> },

0 commit comments

Comments
 (0)