Andrew Wallo 1 年之前
父節點
當前提交
6a5805ada4
共有 2 個檔案被更改,包括 218 行新增55 行删除
  1. 101
    0
      app/Services/AccountService.php
  2. 117
    55
      app/Services/ReportService.php

+ 101
- 0
app/Services/AccountService.php 查看文件

10
 use App\Repositories\Accounting\JournalEntryRepository;
10
 use App\Repositories\Accounting\JournalEntryRepository;
11
 use App\Utilities\Currency\CurrencyAccessor;
11
 use App\Utilities\Currency\CurrencyAccessor;
12
 use App\ValueObjects\Money;
12
 use App\ValueObjects\Money;
13
+use Closure;
14
+use Illuminate\Database\Query\Builder;
13
 use Illuminate\Support\Facades\DB;
15
 use Illuminate\Support\Facades\DB;
14
 
16
 
15
 class AccountService implements AccountHandler
17
 class AccountService implements AccountHandler
50
         return new Money($balances['starting_balance'], $account->currency_code);
52
         return new Money($balances['starting_balance'], $account->currency_code);
51
     }
53
     }
52
 
54
 
55
+    public function getStartingBalanceSubquery($startDate, $accountIds = null): Builder
56
+    {
57
+        return DB::table('journal_entries')
58
+            ->select('journal_entries.account_id')
59
+            ->selectRaw('
60
+                SUM(
61
+                    CASE
62
+                        WHEN accounts.category IN ("asset", "expense") THEN
63
+                            CASE WHEN journal_entries.type = "debit" THEN journal_entries.amount ELSE -journal_entries.amount END
64
+                        ELSE
65
+                            CASE WHEN journal_entries.type = "credit" THEN journal_entries.amount ELSE -journal_entries.amount END
66
+                    END
67
+                ) AS starting_balance
68
+            ')
69
+            ->join('transactions', 'transactions.id', '=', 'journal_entries.transaction_id')
70
+            ->join('accounts', 'accounts.id', '=', 'journal_entries.account_id')
71
+            ->where('transactions.posted_at', '<', $startDate)
72
+            ->groupBy('journal_entries.account_id');
73
+    }
74
+
75
+    public function getTotalDebitSubquery($startDate, $endDate, $accountIds = null): Builder
76
+    {
77
+        return DB::table('journal_entries')
78
+            ->select('journal_entries.account_id')
79
+            ->selectRaw('
80
+                SUM(CASE WHEN journal_entries.type = "debit" THEN journal_entries.amount ELSE 0 END) as total_debit
81
+            ')
82
+            ->join('transactions', 'transactions.id', '=', 'journal_entries.transaction_id')
83
+            ->whereBetween('transactions.posted_at', [$startDate, $endDate])
84
+            ->groupBy('journal_entries.account_id');
85
+    }
86
+
87
+    public function getTotalCreditSubquery($startDate, $endDate, $accountIds = null): Builder
88
+    {
89
+        return DB::table('journal_entries')
90
+            ->select('journal_entries.account_id')
91
+            ->selectRaw('
92
+                SUM(CASE WHEN journal_entries.type = "credit" THEN journal_entries.amount ELSE 0 END) as total_credit
93
+            ')
94
+            ->join('transactions', 'transactions.id', '=', 'journal_entries.transaction_id')
95
+            ->whereBetween('transactions.posted_at', [$startDate, $endDate])
96
+            ->groupBy('journal_entries.account_id');
97
+    }
98
+
99
+    public function getTransactionDetailsSubquery($startDate, $endDate): Closure
100
+    {
101
+        return function ($query) use ($startDate, $endDate) {
102
+            $query->select(
103
+                'journal_entries.id',
104
+                'journal_entries.account_id',
105
+                'journal_entries.transaction_id',
106
+                'journal_entries.type',
107
+                'journal_entries.amount',
108
+                DB::raw('journal_entries.amount * IF(journal_entries.type = "debit", 1, -1) AS signed_amount')
109
+            )
110
+                ->whereBetween('transactions.posted_at', [$startDate, $endDate])
111
+                ->join('transactions', 'transactions.id', '=', 'journal_entries.transaction_id')
112
+                ->orderBy('transactions.posted_at')
113
+                ->with('transaction:id,type,description,posted_at');
114
+        };
115
+    }
116
+
117
+    public function getAccountBalances($startDate, $endDate, $accountIds = null): \Illuminate\Database\Eloquent\Builder
118
+    {
119
+        $query = Account::query()
120
+            ->select([
121
+                'accounts.id',
122
+                'accounts.name',
123
+                'accounts.category',
124
+                'accounts.subtype_id',
125
+                'accounts.currency_code',
126
+                'accounts.code',
127
+            ])
128
+            ->leftJoinSub($this->getStartingBalanceSubquery($startDate), 'starting_balance', function ($join) {
129
+                $join->on('accounts.id', '=', 'starting_balance.account_id');
130
+            })
131
+            ->leftJoinSub($this->getTotalDebitSubquery($startDate, $endDate), 'total_debit', function ($join) {
132
+                $join->on('accounts.id', '=', 'total_debit.account_id');
133
+            })
134
+            ->leftJoinSub($this->getTotalCreditSubquery($startDate, $endDate), 'total_credit', function ($join) {
135
+                $join->on('accounts.id', '=', 'total_credit.account_id');
136
+            })
137
+            ->addSelect([
138
+                'starting_balance.starting_balance',
139
+                'total_debit.total_debit',
140
+                'total_credit.total_credit',
141
+            ])
142
+            ->with(['subtype:id,name'])
143
+            ->whereHas('journalEntries.transaction', function ($query) use ($startDate, $endDate) {
144
+                $query->whereBetween('posted_at', [$startDate, $endDate]);
145
+            });
146
+
147
+        if ($accountIds !== null) {
148
+            $query->whereIn('accounts.id', (array) $accountIds);
149
+        }
150
+
151
+        return $query;
152
+    }
153
+
53
     public function getEndingBalance(Account $account, string $startDate, string $endDate): ?Money
154
     public function getEndingBalance(Account $account, string $startDate, string $endDate): ?Money
54
     {
155
     {
55
         $calculatedBalances = $this->calculateBalances($account, $startDate, $endDate);
156
         $calculatedBalances = $this->calculateBalances($account, $startDate, $endDate);

+ 117
- 55
app/Services/ReportService.php 查看文件

8
 use App\DTO\AccountTransactionDTO;
8
 use App\DTO\AccountTransactionDTO;
9
 use App\DTO\ReportDTO;
9
 use App\DTO\ReportDTO;
10
 use App\Enums\Accounting\AccountCategory;
10
 use App\Enums\Accounting\AccountCategory;
11
+use App\Enums\Accounting\JournalEntryType;
11
 use App\Models\Accounting\Account;
12
 use App\Models\Accounting\Account;
12
 use App\Support\Column;
13
 use App\Support\Column;
13
 use App\Utilities\Currency\CurrencyAccessor;
14
 use App\Utilities\Currency\CurrencyAccessor;
14
-use Illuminate\Database\Eloquent\Builder;
15
-use Illuminate\Database\Eloquent\Relations\Relation;
16
 use Illuminate\Support\Collection;
15
 use Illuminate\Support\Collection;
17
 
16
 
18
 class ReportService
17
 class ReportService
56
     {
55
     {
57
         $allCategories = $this->accountService->getAccountCategoryOrder();
56
         $allCategories = $this->accountService->getAccountCategoryOrder();
58
 
57
 
59
-        $categoryGroupedAccounts = $this->getCategoryGroupedAccounts($allCategories);
58
+        $accountIds = Account::whereHas('journalEntries')->pluck('id')->toArray();
59
+
60
+        $accounts = $this->accountService->getAccountBalances($startDate, $endDate, $accountIds)->get();
60
 
61
 
61
         $balanceFields = ['starting_balance', 'debit_balance', 'credit_balance', 'net_movement', 'ending_balance'];
62
         $balanceFields = ['starting_balance', 'debit_balance', 'credit_balance', 'net_movement', 'ending_balance'];
62
 
63
 
64
 
65
 
65
         $updatedBalanceFields = array_filter($balanceFields, fn (string $balanceField) => in_array($balanceField, $columnNameKeys, true));
66
         $updatedBalanceFields = array_filter($balanceFields, fn (string $balanceField) => in_array($balanceField, $columnNameKeys, true));
66
 
67
 
67
-        return $this->buildReport(
68
-            $allCategories,
69
-            $categoryGroupedAccounts,
70
-            fn (Account $account) => $this->accountService->getBalances($account, $startDate, $endDate, $updatedBalanceFields),
71
-            $updatedBalanceFields,
72
-            $columns,
73
-            fn (string $categoryName, array &$categorySummaryBalances) => $this->adjustAccountBalanceCategoryFields($categoryName, $categorySummaryBalances),
74
-        );
68
+        $accountCategories = [];
69
+        $reportTotalBalances = array_fill_keys($updatedBalanceFields, 0);
70
+
71
+        foreach ($allCategories as $categoryPluralName) {
72
+            $categoryName = AccountCategory::fromPluralLabel($categoryPluralName);
73
+            $accountsInCategory = $accounts->where('category', $categoryName)->keyBy('id');
74
+            $categorySummaryBalances = array_fill_keys($updatedBalanceFields, 0);
75
+
76
+            $categoryAccounts = [];
77
+
78
+            foreach ($accountsInCategory as $account) {
79
+                $accountBalances = $this->calculateAccountBalances($account, $categoryName);
80
+
81
+                if ($this->isZeroBalance($accountBalances)) {
82
+                    continue;
83
+                }
84
+
85
+                foreach ($accountBalances as $accountBalanceType => $accountBalance) {
86
+                    if (array_key_exists($accountBalanceType, $categorySummaryBalances)) {
87
+                        $categorySummaryBalances[$accountBalanceType] += $accountBalance;
88
+                    }
89
+                }
90
+
91
+                $filteredAccountBalances = $this->filterBalances($accountBalances, $updatedBalanceFields);
92
+                $formattedAccountBalances = $this->formatBalances($filteredAccountBalances);
93
+
94
+                $categoryAccounts[] = new AccountDTO(
95
+                    $account->name,
96
+                    $account->code,
97
+                    $account->id,
98
+                    $formattedAccountBalances,
99
+                );
100
+            }
101
+
102
+            $this->adjustAccountBalanceCategoryFields($categoryName, $categorySummaryBalances);
103
+
104
+            foreach ($updatedBalanceFields as $field) {
105
+                if (array_key_exists($field, $categorySummaryBalances)) {
106
+                    $reportTotalBalances[$field] += $categorySummaryBalances[$field];
107
+                }
108
+            }
109
+
110
+            $filteredCategorySummaryBalances = $this->filterBalances($categorySummaryBalances, $updatedBalanceFields);
111
+            $formattedCategorySummaryBalances = $this->formatBalances($filteredCategorySummaryBalances);
112
+
113
+            $accountCategories[$categoryPluralName] = new AccountCategoryDTO(
114
+                $categoryAccounts,
115
+                $formattedCategorySummaryBalances,
116
+            );
117
+        }
118
+
119
+        $formattedReportTotalBalances = $this->formatBalances($reportTotalBalances);
120
+
121
+        return new ReportDTO($accountCategories, $formattedReportTotalBalances, $columns);
122
+    }
123
+
124
+    private function calculateAccountBalances(Account $account, AccountCategory $category): array
125
+    {
126
+        $balances = [
127
+            'debit_balance' => $account->total_debit,
128
+            'credit_balance' => $account->total_credit,
129
+        ];
130
+
131
+        if (in_array($category, [AccountCategory::Liability, AccountCategory::Equity, AccountCategory::Revenue])) {
132
+            $balances['net_movement'] = $account->total_credit - $account->total_debit;
133
+        } else {
134
+            $balances['net_movement'] = $account->total_debit - $account->total_credit;
135
+        }
136
+
137
+        if (! in_array($category, [AccountCategory::Expense, AccountCategory::Revenue], true)) {
138
+            $balances['starting_balance'] = $account->starting_balance;
139
+            $balances['ending_balance'] = $account->starting_balance + $account->total_credit - $account->total_debit;
140
+        }
141
+
142
+        return $balances;
143
+    }
144
+
145
+    private function adjustAccountBalanceCategoryFields(AccountCategory $category, array &$categorySummaryBalances): void
146
+    {
147
+        if (in_array($category, [AccountCategory::Expense, AccountCategory::Revenue], true)) {
148
+            unset($categorySummaryBalances['starting_balance'], $categorySummaryBalances['ending_balance']);
149
+        }
150
+    }
151
+
152
+    private function isZeroBalance(array $balances): bool
153
+    {
154
+        return array_sum(array_map('abs', $balances)) === 0;
75
     }
155
     }
76
 
156
 
77
     public function buildAccountTransactionsReport(string $startDate, string $endDate, ?array $columns = null, ?string $accountId = 'all'): ReportDTO
157
     public function buildAccountTransactionsReport(string $startDate, string $endDate, ?array $columns = null, ?string $accountId = 'all'): ReportDTO
78
     {
158
     {
79
         $columns ??= [];
159
         $columns ??= [];
80
-        $query = Account::whereHas('journalEntries.transaction', function (Builder $query) use ($startDate, $endDate) {
81
-            $query->whereBetween('posted_at', [$startDate, $endDate]);
82
-        })
83
-            ->with(['journalEntries' => function (Relation $query) use ($startDate, $endDate) {
84
-                $query->whereHas('transaction', function (Builder $query) use ($startDate, $endDate) {
85
-                    $query->whereBetween('posted_at', [$startDate, $endDate]);
86
-                })
87
-                    ->with('transaction:id,type,description,posted_at')
88
-                    ->select(['account_id', 'transaction_id'])
89
-                    ->selectRaw('SUM(CASE WHEN type = "debit" THEN amount ELSE 0 END) AS total_debit')
90
-                    ->selectRaw('SUM(CASE WHEN type = "credit" THEN amount ELSE 0 END) AS total_credit')
91
-                    ->selectRaw('(SELECT MIN(posted_at) FROM transactions WHERE transactions.id = journal_entries.transaction_id) AS earliest_posted_at')
92
-                    ->groupBy('account_id', 'transaction_id')
93
-                    ->orderBy('earliest_posted_at');
94
-            }])
95
-            ->select(['id', 'name', 'category', 'subtype_id', 'currency_code']);
160
+        $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
161
+
162
+        $accountIds = $accountId !== 'all' ? [$accountId] : null;
163
+
164
+        $query = $this->accountService->getAccountBalances($startDate, $endDate, $accountIds);
165
+
166
+        $query->with(['journalEntries' => $this->accountService->getTransactionDetailsSubquery($startDate, $endDate)]);
96
 
167
 
97
         if ($accountId !== 'all') {
168
         if ($accountId !== 'all') {
98
             $query->where('id', $accountId);
169
             $query->where('id', $accountId);
102
 
173
 
103
         $reportCategories = [];
174
         $reportCategories = [];
104
 
175
 
105
-        $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
106
-
107
         foreach ($accounts as $account) {
176
         foreach ($accounts as $account) {
108
             $accountTransactions = [];
177
             $accountTransactions = [];
109
-            $startingBalance = $this->accountService->getStartingBalance($account, $startDate, true);
110
-
111
-            $currentBalance = $startingBalance?->getAmount() ?? 0;
112
-            $totalDebit = 0;
113
-            $totalCredit = 0;
178
+            $currentBalance = $account->starting_balance;
114
 
179
 
115
             $accountTransactions[] = new AccountTransactionDTO(
180
             $accountTransactions[] = new AccountTransactionDTO(
116
                 id: null,
181
                 id: null,
118
                 description: '',
183
                 description: '',
119
                 debit: '',
184
                 debit: '',
120
                 credit: '',
185
                 credit: '',
121
-                balance: $startingBalance?->formatInDefaultCurrency() ?? 0,
186
+                balance: money($currentBalance, $defaultCurrency)->format(),
122
                 type: null,
187
                 type: null,
123
-                tableAction: null,
188
+                tableAction: null
124
             );
189
             );
125
 
190
 
191
+            /** @var Account $account */
126
             foreach ($account->journalEntries as $journalEntry) {
192
             foreach ($account->journalEntries as $journalEntry) {
127
                 $transaction = $journalEntry->transaction;
193
                 $transaction = $journalEntry->transaction;
128
-                $totalDebit += $journalEntry->total_debit;
129
-                $totalCredit += $journalEntry->total_credit;
194
+                $signedAmount = $journalEntry->signed_amount;
130
 
195
 
131
-                $currentBalance += $journalEntry->total_debit;
132
-                $currentBalance -= $journalEntry->total_credit;
196
+                // Adjust balance based on account category
197
+                if (in_array($account->category, [AccountCategory::Asset, AccountCategory::Expense])) {
198
+                    $currentBalance += $signedAmount;
199
+                } else {
200
+                    $currentBalance -= $signedAmount;
201
+                }
133
 
202
 
134
                 $accountTransactions[] = new AccountTransactionDTO(
203
                 $accountTransactions[] = new AccountTransactionDTO(
135
                     id: $transaction->id,
204
                     id: $transaction->id,
136
                     date: $transaction->posted_at->format('Y-m-d'),
205
                     date: $transaction->posted_at->format('Y-m-d'),
137
                     description: $transaction->description ?? '',
206
                     description: $transaction->description ?? '',
138
-                    debit: $journalEntry->total_debit ? money($journalEntry->total_debit, $defaultCurrency)->format() : '',
139
-                    credit: $journalEntry->total_credit ? money($journalEntry->total_credit, $defaultCurrency)->format() : '',
207
+                    debit: $journalEntry->type === JournalEntryType::Debit ? money(abs($signedAmount), $defaultCurrency)->format() : '',
208
+                    credit: $journalEntry->type === JournalEntryType::Credit ? money(abs($signedAmount), $defaultCurrency)->format() : '',
140
                     balance: money($currentBalance, $defaultCurrency)->format(),
209
                     balance: money($currentBalance, $defaultCurrency)->format(),
141
                     type: $transaction->type,
210
                     type: $transaction->type,
142
-                    tableAction: $transaction->type->isJournal() ? 'updateJournalTransaction' : 'updateTransaction',
211
+                    tableAction: $transaction->type->isJournal() ? 'updateJournalTransaction' : 'updateTransaction'
143
                 );
212
                 );
144
             }
213
             }
145
 
214
 
146
-            $balanceChange = $currentBalance - ($startingBalance?->getAmount() ?? 0);
215
+            $balanceChange = $currentBalance - $account->starting_balance;
147
 
216
 
148
             $accountTransactions[] = new AccountTransactionDTO(
217
             $accountTransactions[] = new AccountTransactionDTO(
149
                 id: null,
218
                 id: null,
150
                 date: 'Totals and Ending Balance',
219
                 date: 'Totals and Ending Balance',
151
                 description: '',
220
                 description: '',
152
-                debit: money($totalDebit, $defaultCurrency)->format(),
153
-                credit: money($totalCredit, $defaultCurrency)->format(),
221
+                debit: money($account->total_debit, $defaultCurrency)->format(),
222
+                credit: money($account->total_credit, $defaultCurrency)->format(),
154
                 balance: money($currentBalance, $defaultCurrency)->format(),
223
                 balance: money($currentBalance, $defaultCurrency)->format(),
155
                 type: null,
224
                 type: null,
156
-                tableAction: null,
225
+                tableAction: null
157
             );
226
             );
158
 
227
 
159
             $accountTransactions[] = new AccountTransactionDTO(
228
             $accountTransactions[] = new AccountTransactionDTO(
164
                 credit: '',
233
                 credit: '',
165
                 balance: money($balanceChange, $defaultCurrency)->format(),
234
                 balance: money($balanceChange, $defaultCurrency)->format(),
166
                 type: null,
235
                 type: null,
167
-                tableAction: null,
236
+                tableAction: null
168
             );
237
             );
169
 
238
 
170
             $reportCategories[] = [
239
             $reportCategories[] = [
260
         return new ReportDTO($accountCategories, $formattedReportTotalBalances, $allFields);
329
         return new ReportDTO($accountCategories, $formattedReportTotalBalances, $allFields);
261
     }
330
     }
262
 
331
 
263
-    private function adjustAccountBalanceCategoryFields(string $categoryName, array &$categorySummaryBalances): void
264
-    {
265
-        if (in_array($categoryName, [AccountCategory::Expense->getPluralLabel(), AccountCategory::Revenue->getPluralLabel()], true)) {
266
-            unset($categorySummaryBalances['starting_balance'], $categorySummaryBalances['ending_balance']);
267
-        }
268
-    }
269
-
270
     public function buildTrialBalanceReport(string $startDate, string $endDate, array $columns = []): ReportDTO
332
     public function buildTrialBalanceReport(string $startDate, string $endDate, array $columns = []): ReportDTO
271
     {
333
     {
272
         $allCategories = $this->accountService->getAccountCategoryOrder();
334
         $allCategories = $this->accountService->getAccountCategoryOrder();

Loading…
取消
儲存