1
|
|
|
<?php |
2
|
|
|
declare(strict_types=1); |
3
|
|
|
/** |
4
|
|
|
* MigrateToGroups.php |
5
|
|
|
* Copyright (c) 2019 [email protected] |
6
|
|
|
* |
7
|
|
|
* This file is part of Firefly III. |
8
|
|
|
* |
9
|
|
|
* Firefly III is free software: you can redistribute it and/or modify |
10
|
|
|
* it under the terms of the GNU General Public License as published by |
11
|
|
|
* the Free Software Foundation, either version 3 of the License, or |
12
|
|
|
* (at your option) any later version. |
13
|
|
|
* |
14
|
|
|
* Firefly III is distributed in the hope that it will be useful, |
15
|
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
16
|
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
17
|
|
|
* GNU General Public License for more details. |
18
|
|
|
* |
19
|
|
|
* You should have received a copy of the GNU General Public License |
20
|
|
|
* along with Firefly III. If not, see <http://www.gnu.org/licenses/>. |
21
|
|
|
*/ |
22
|
|
|
|
23
|
|
|
namespace FireflyIII\Console\Commands\Upgrade; |
24
|
|
|
|
25
|
|
|
use DB; |
26
|
|
|
use Exception; |
27
|
|
|
use FireflyIII\Factory\TransactionGroupFactory; |
28
|
|
|
use FireflyIII\Models\Budget; |
29
|
|
|
use FireflyIII\Models\Category; |
30
|
|
|
use FireflyIII\Models\Transaction; |
31
|
|
|
use FireflyIII\Models\TransactionJournal; |
32
|
|
|
use FireflyIII\Repositories\Journal\JournalCLIRepositoryInterface; |
33
|
|
|
use FireflyIII\Repositories\Journal\JournalRepositoryInterface; |
34
|
|
|
use FireflyIII\Services\Internal\Destroy\JournalDestroyService; |
35
|
|
|
use Illuminate\Console\Command; |
36
|
|
|
use Illuminate\Support\Collection; |
37
|
|
|
use Log; |
38
|
|
|
|
39
|
|
|
/** |
40
|
|
|
* This command will take split transactions and migrate them to "transaction groups". |
41
|
|
|
* |
42
|
|
|
* It will only run once, but can be forced to run again. |
43
|
|
|
* |
44
|
|
|
* Class MigrateToGroups |
45
|
|
|
*/ |
46
|
|
|
class MigrateToGroups extends Command |
47
|
|
|
{ |
48
|
|
|
public const CONFIG_NAME = '480_migrated_to_groups'; |
49
|
|
|
/** |
50
|
|
|
* The console command description. |
51
|
|
|
* |
52
|
|
|
* @var string |
53
|
|
|
*/ |
54
|
|
|
protected $description = 'Migrates a pre-4.7.8 transaction structure to the 4.7.8+ transaction structure.'; |
55
|
|
|
/** |
56
|
|
|
* The name and signature of the console command. |
57
|
|
|
* |
58
|
|
|
* @var string |
59
|
|
|
*/ |
60
|
|
|
protected $signature = 'firefly-iii:migrate-to-groups {--F|force : Force the migration, even if it fired before.}'; |
61
|
|
|
/** @var TransactionGroupFactory */ |
62
|
|
|
private $groupFactory; |
63
|
|
|
/** @var JournalRepositoryInterface */ |
64
|
|
|
private $journalRepository; |
65
|
|
|
/** @var JournalCLIRepositoryInterface */ |
66
|
|
|
private $cliRepository; |
67
|
|
|
/** @var JournalDestroyService */ |
68
|
|
|
private $service; |
69
|
|
|
private $count; |
70
|
|
|
|
71
|
|
|
/** |
72
|
|
|
* Execute the console command. |
73
|
|
|
* |
74
|
|
|
* @return int |
75
|
|
|
* @throws Exception |
76
|
|
|
*/ |
77
|
|
|
public function handle(): int |
78
|
|
|
{ |
79
|
|
|
$this->stupidLaravel(); |
80
|
|
|
$start = microtime(true); |
81
|
|
|
// @codeCoverageIgnoreStart |
82
|
|
|
if ($this->isMigrated() && true !== $this->option('force')) { |
83
|
|
|
$this->info('Database already seems to be migrated.'); |
84
|
|
|
|
85
|
|
|
return 0; |
86
|
|
|
} |
87
|
|
|
|
88
|
|
|
if (true === $this->option('force')) { |
|
|
|
|
89
|
|
|
$this->warn('Forcing the migration.'); |
90
|
|
|
} |
91
|
|
|
// @codeCoverageIgnoreEnd |
92
|
|
|
|
93
|
|
|
Log::debug('---- start group migration ----'); |
94
|
|
|
$this->makeGroupsFromSplitJournals(); |
95
|
|
|
$end = round(microtime(true) - $start, 2); |
96
|
|
|
$this->info(sprintf('Migrate split journals to groups in %s seconds.', $end)); |
97
|
|
|
|
98
|
|
|
$start = microtime(true); |
99
|
|
|
$this->makeGroupsFromAll(); |
100
|
|
|
Log::debug('---- end group migration ----'); |
101
|
|
|
$end = round(microtime(true) - $start, 2); |
102
|
|
|
$this->info(sprintf('Migrate all journals to groups in %s seconds.', $end)); |
103
|
|
|
|
104
|
|
|
if (0 !== $this->count) { |
105
|
|
|
$this->line(sprintf('Migrated %d transaction journal(s).', $this->count)); |
106
|
|
|
} |
107
|
|
|
if (0 === $this->count) { |
108
|
|
|
$this->line('No journals to migrate to groups.'); |
109
|
|
|
} |
110
|
|
|
|
111
|
|
|
|
112
|
|
|
$this->markAsMigrated(); |
113
|
|
|
|
114
|
|
|
|
115
|
|
|
return 0; |
116
|
|
|
} |
117
|
|
|
|
118
|
|
|
/** |
119
|
|
|
* Laravel will execute ALL __construct() methods for ALL commands whenever a SINGLE command is |
120
|
|
|
* executed. This leads to noticeable slow-downs and class calls. To prevent this, this method should |
121
|
|
|
* be called from the handle method instead of using the constructor to initialize the command. |
122
|
|
|
* |
123
|
|
|
* @codeCoverageIgnore |
124
|
|
|
*/ |
125
|
|
|
private function stupidLaravel(): void |
126
|
|
|
{ |
127
|
|
|
$this->count = 0; |
128
|
|
|
$this->journalRepository = app(JournalRepositoryInterface::class); |
129
|
|
|
$this->service = app(JournalDestroyService::class); |
130
|
|
|
$this->groupFactory = app(TransactionGroupFactory::class); |
131
|
|
|
$this->cliRepository = app(JournalCLIRepositoryInterface::class); |
132
|
|
|
} |
133
|
|
|
|
134
|
|
|
/** |
135
|
|
|
* @param TransactionJournal $journal |
136
|
|
|
* @param Transaction $transaction |
137
|
|
|
* |
138
|
|
|
* @return Transaction|null |
139
|
|
|
*/ |
140
|
|
|
private function findOpposingTransaction(TransactionJournal $journal, Transaction $transaction): ?Transaction |
141
|
|
|
{ |
142
|
|
|
$set = $journal->transactions->filter( |
143
|
|
|
static function (Transaction $subject) use ($transaction) { |
144
|
|
|
$amount = (float)$transaction->amount * -1 === (float)$subject->amount; |
145
|
|
|
$identifier = $transaction->identifier === $subject->identifier; |
146
|
|
|
Log::debug(sprintf('Amount the same? %s', var_export($amount, true))); |
147
|
|
|
Log::debug(sprintf('ID the same? %s', var_export($identifier, true))); |
148
|
|
|
|
149
|
|
|
return $amount && $identifier; |
150
|
|
|
} |
151
|
|
|
); |
152
|
|
|
|
153
|
|
|
return $set->first(); |
154
|
|
|
} |
155
|
|
|
|
156
|
|
|
/** |
157
|
|
|
* @param TransactionJournal $journal |
158
|
|
|
* |
159
|
|
|
* @return Collection |
160
|
|
|
*/ |
161
|
|
|
private function getDestinationTransactions(TransactionJournal $journal): Collection |
162
|
|
|
{ |
163
|
|
|
return $journal->transactions->filter( |
164
|
|
|
static function (Transaction $transaction) { |
165
|
|
|
return $transaction->amount > 0; |
166
|
|
|
} |
167
|
|
|
); |
168
|
|
|
} |
169
|
|
|
|
170
|
|
|
/** |
171
|
|
|
* @param array $array |
172
|
|
|
*/ |
173
|
|
|
private function giveGroup(array $array): void |
174
|
|
|
{ |
175
|
|
|
$groupId = DB::table('transaction_groups')->insertGetId( |
176
|
|
|
[ |
177
|
|
|
'created_at' => date('Y-m-d H:i:s'), |
178
|
|
|
'updated_at' => date('Y-m-d H:i:s'), |
179
|
|
|
'title' => null, |
180
|
|
|
'user_id' => $array['user_id'], |
181
|
|
|
] |
182
|
|
|
); |
183
|
|
|
DB::table('transaction_journals')->where('id', $array['id'])->update(['transaction_group_id' => $groupId]); |
184
|
|
|
$this->count++; |
185
|
|
|
} |
186
|
|
|
|
187
|
|
|
/** |
188
|
|
|
* @return bool |
189
|
|
|
*/ |
190
|
|
|
private function isMigrated(): bool |
191
|
|
|
{ |
192
|
|
|
$configVar = app('fireflyconfig')->get(self::CONFIG_NAME, false); |
193
|
|
|
if (null !== $configVar) { |
194
|
|
|
return (bool)$configVar->data; |
195
|
|
|
} |
196
|
|
|
|
197
|
|
|
return false; // @codeCoverageIgnore |
198
|
|
|
} |
199
|
|
|
|
200
|
|
|
/** |
201
|
|
|
* Gives all journals without a group a group. |
202
|
|
|
*/ |
203
|
|
|
private function makeGroupsFromAll(): void |
204
|
|
|
{ |
205
|
|
|
$orphanedJournals = $this->cliRepository->getJournalsWithoutGroup(); |
206
|
|
|
$count = count($orphanedJournals); |
207
|
|
|
if ($count > 0) { |
208
|
|
|
Log::debug(sprintf('Going to convert %d transaction journals. Please hold..', $count)); |
209
|
|
|
$this->line(sprintf('Going to convert %d transaction journals. Please hold..', $count)); |
210
|
|
|
/** @var array $journal */ |
211
|
|
|
foreach ($orphanedJournals as $array) { |
212
|
|
|
$this->giveGroup($array); |
213
|
|
|
} |
214
|
|
|
} |
215
|
|
|
if (0 === $count) { |
216
|
|
|
$this->info('No need to convert transaction journals.'); |
217
|
|
|
} |
218
|
|
|
} |
219
|
|
|
|
220
|
|
|
/** |
221
|
|
|
* @throws Exception |
222
|
|
|
*/ |
223
|
|
|
private function makeGroupsFromSplitJournals(): void |
224
|
|
|
{ |
225
|
|
|
$splitJournals = $this->cliRepository->getSplitJournals(); |
226
|
|
|
if ($splitJournals->count() > 0) { |
227
|
|
|
$this->info(sprintf('Going to convert %d split transaction(s). Please hold..', $splitJournals->count())); |
228
|
|
|
/** @var TransactionJournal $journal */ |
229
|
|
|
foreach ($splitJournals as $journal) { |
230
|
|
|
$this->makeMultiGroup($journal); |
231
|
|
|
} |
232
|
|
|
} |
233
|
|
|
if (0 === $splitJournals->count()) { |
234
|
|
|
$this->info('Found no split transaction journals. Nothing to do.'); |
235
|
|
|
} |
236
|
|
|
} |
237
|
|
|
|
238
|
|
|
/** |
239
|
|
|
* @param TransactionJournal $journal |
240
|
|
|
* |
241
|
|
|
* @throws Exception |
242
|
|
|
* |
243
|
|
|
*/ |
244
|
|
|
private function makeMultiGroup(TransactionJournal $journal): void |
245
|
|
|
{ |
246
|
|
|
// double check transaction count. |
247
|
|
|
if ($journal->transactions->count() <= 2) { |
248
|
|
|
// @codeCoverageIgnoreStart |
249
|
|
|
Log::debug(sprintf('Will not try to convert journal #%d because it has 2 or less transactions.', $journal->id)); |
250
|
|
|
|
251
|
|
|
return; |
252
|
|
|
// @codeCoverageIgnoreEnd |
253
|
|
|
} |
254
|
|
|
Log::debug(sprintf('Will now try to convert journal #%d', $journal->id)); |
255
|
|
|
|
256
|
|
|
$this->journalRepository->setUser($journal->user); |
257
|
|
|
$this->groupFactory->setUser($journal->user); |
258
|
|
|
$this->cliRepository->setUser($journal->user); |
259
|
|
|
|
260
|
|
|
$data = [ |
261
|
|
|
// mandatory fields. |
262
|
|
|
'group_title' => $journal->description, |
263
|
|
|
'transactions' => [], |
264
|
|
|
]; |
265
|
|
|
$destTransactions = $this->getDestinationTransactions($journal); |
266
|
|
|
$budgetId = $this->cliRepository->getJournalBudgetId($journal); |
267
|
|
|
$categoryId = $this->cliRepository->getJournalCategoryId($journal); |
268
|
|
|
$notes = $this->cliRepository->getNoteText($journal); |
269
|
|
|
$tags = $this->cliRepository->getTags($journal); |
270
|
|
|
$internalRef = $this->cliRepository->getMetaField($journal, 'internal-reference'); |
271
|
|
|
$sepaCC = $this->cliRepository->getMetaField($journal, 'sepa_cc'); |
272
|
|
|
$sepaCtOp = $this->cliRepository->getMetaField($journal, 'sepa_ct_op'); |
273
|
|
|
$sepaCtId = $this->cliRepository->getMetaField($journal, 'sepa_ct_id'); |
274
|
|
|
$sepaDb = $this->cliRepository->getMetaField($journal, 'sepa_db'); |
275
|
|
|
$sepaCountry = $this->cliRepository->getMetaField($journal, 'sepa_country'); |
276
|
|
|
$sepaEp = $this->cliRepository->getMetaField($journal, 'sepa_ep'); |
277
|
|
|
$sepaCi = $this->cliRepository->getMetaField($journal, 'sepa_ci'); |
278
|
|
|
$sepaBatchId = $this->cliRepository->getMetaField($journal, 'sepa_batch_id'); |
279
|
|
|
$externalId = $this->cliRepository->getMetaField($journal, 'external-id'); |
280
|
|
|
$originalSource = $this->cliRepository->getMetaField($journal, 'original-source'); |
281
|
|
|
$recurrenceId = $this->cliRepository->getMetaField($journal, 'recurrence_id'); |
282
|
|
|
$bunq = $this->cliRepository->getMetaField($journal, 'bunq_payment_id'); |
283
|
|
|
$hash = $this->cliRepository->getMetaField($journal, 'import_hash'); |
284
|
|
|
$hashTwo = $this->cliRepository->getMetaField($journal, 'import_hash_v2'); |
285
|
|
|
$interestDate = $this->cliRepository->getMetaDate($journal, 'interest_date'); |
286
|
|
|
$bookDate = $this->cliRepository->getMetaDate($journal, 'book_date'); |
287
|
|
|
$processDate = $this->cliRepository->getMetaDate($journal, 'process_date'); |
288
|
|
|
$dueDate = $this->cliRepository->getMetaDate($journal, 'due_date'); |
289
|
|
|
$paymentDate = $this->cliRepository->getMetaDate($journal, 'payment_date'); |
290
|
|
|
$invoiceDate = $this->cliRepository->getMetaDate($journal, 'invoice_date'); |
291
|
|
|
|
292
|
|
|
Log::debug(sprintf('Will use %d positive transactions to create a new group.', $destTransactions->count())); |
293
|
|
|
|
294
|
|
|
/** @var Transaction $transaction */ |
295
|
|
|
foreach ($destTransactions as $transaction) { |
296
|
|
|
Log::debug(sprintf('Now going to add transaction #%d to the array.', $transaction->id)); |
297
|
|
|
$opposingTr = $this->findOpposingTransaction($journal, $transaction); |
298
|
|
|
|
299
|
|
|
if (null === $opposingTr) { |
300
|
|
|
// @codeCoverageIgnoreStart |
301
|
|
|
$this->error( |
302
|
|
|
sprintf( |
303
|
|
|
'Journal #%d has no opposing transaction for transaction #%d. Cannot upgrade this entry.', |
304
|
|
|
$journal->id, $transaction->id |
305
|
|
|
) |
306
|
|
|
); |
307
|
|
|
continue; |
308
|
|
|
// @codeCoverageIgnoreEnd |
309
|
|
|
} |
310
|
|
|
|
311
|
|
|
// overrule journal category with transaction category. |
312
|
|
|
$budgetId = $this->getTransactionBudget($transaction, $opposingTr) ?? $budgetId; |
313
|
|
|
$categoryId = $this->getTransactionCategory($transaction, $opposingTr) ?? $categoryId; |
314
|
|
|
|
315
|
|
|
$tArray = [ |
316
|
|
|
'type' => strtolower($journal->transactionType->type), |
317
|
|
|
'date' => $journal->date, |
318
|
|
|
'user' => $journal->user_id, |
319
|
|
|
'currency_id' => $transaction->transaction_currency_id, |
320
|
|
|
'foreign_currency_id' => $transaction->foreign_currency_id, |
321
|
|
|
'amount' => $transaction->amount, |
322
|
|
|
'foreign_amount' => $transaction->foreign_amount, |
323
|
|
|
'description' => $transaction->description ?? $journal->description, |
324
|
|
|
'source_id' => $opposingTr->account_id, |
325
|
|
|
'destination_id' => $transaction->account_id, |
326
|
|
|
'budget_id' => $budgetId, |
327
|
|
|
'category_id' => $categoryId, |
328
|
|
|
'bill_id' => $journal->bill_id, |
329
|
|
|
'notes' => $notes, |
330
|
|
|
'tags' => $tags, |
331
|
|
|
'internal_reference' => $internalRef, |
332
|
|
|
'sepa_cc' => $sepaCC, |
333
|
|
|
'sepa_ct_op' => $sepaCtOp, |
334
|
|
|
'sepa_ct_id' => $sepaCtId, |
335
|
|
|
'sepa_db' => $sepaDb, |
336
|
|
|
'sepa_country' => $sepaCountry, |
337
|
|
|
'sepa_ep' => $sepaEp, |
338
|
|
|
'sepa_ci' => $sepaCi, |
339
|
|
|
'sepa_batch_id' => $sepaBatchId, |
340
|
|
|
'external_id' => $externalId, |
341
|
|
|
'original-source' => $originalSource, |
342
|
|
|
'recurrence_id' => $recurrenceId, |
343
|
|
|
'bunq_payment_id' => $bunq, |
344
|
|
|
'import_hash' => $hash, |
345
|
|
|
'import_hash_v2' => $hashTwo, |
346
|
|
|
'interest_date' => $interestDate, |
347
|
|
|
'book_date' => $bookDate, |
348
|
|
|
'process_date' => $processDate, |
349
|
|
|
'due_date' => $dueDate, |
350
|
|
|
'payment_date' => $paymentDate, |
351
|
|
|
'invoice_date' => $invoiceDate, |
352
|
|
|
]; |
353
|
|
|
|
354
|
|
|
$data['transactions'][] = $tArray; |
355
|
|
|
} |
356
|
|
|
Log::debug(sprintf('Now calling transaction journal factory (%d transactions in array)', count($data['transactions']))); |
357
|
|
|
$group = $this->groupFactory->create($data); |
358
|
|
|
Log::debug('Done calling transaction journal factory'); |
359
|
|
|
|
360
|
|
|
// delete the old transaction journal. |
361
|
|
|
$this->service->destroy($journal); |
362
|
|
|
|
363
|
|
|
$this->count++; |
364
|
|
|
|
365
|
|
|
// report on result: |
366
|
|
|
Log::debug( |
367
|
|
|
sprintf('Migrated journal #%d into group #%d with these journals: #%s', |
368
|
|
|
$journal->id, $group->id, implode(', #', $group->transactionJournals->pluck('id')->toArray())) |
369
|
|
|
); |
370
|
|
|
$this->line( |
371
|
|
|
sprintf('Migrated journal #%d into group #%d with these journals: #%s', |
372
|
|
|
$journal->id, $group->id, implode(', #', $group->transactionJournals->pluck('id')->toArray())) |
373
|
|
|
); |
374
|
|
|
} |
375
|
|
|
|
376
|
|
|
/** |
377
|
|
|
* @param Transaction $left |
378
|
|
|
* @param Transaction $right |
379
|
|
|
* |
380
|
|
|
* @return int|null |
381
|
|
|
*/ |
382
|
|
|
private function getTransactionBudget(Transaction $left, Transaction $right): ?int |
383
|
|
|
{ |
384
|
|
|
Log::debug('Now in getTransactionBudget()'); |
385
|
|
|
|
386
|
|
|
// try to get a budget ID from the left transaction: |
387
|
|
|
/** @var Budget $budget */ |
388
|
|
|
$budget = $left->budgets()->first(); |
389
|
|
|
if (null !== $budget) { |
390
|
|
|
Log::debug(sprintf('Return budget #%d, from transaction #%d', $budget->id, $left->id)); |
391
|
|
|
|
392
|
|
|
return (int)$budget->id; |
393
|
|
|
} |
394
|
|
|
|
395
|
|
|
// try to get a budget ID from the right transaction: |
396
|
|
|
/** @var Budget $budget */ |
397
|
|
|
$budget = $right->budgets()->first(); |
398
|
|
|
if (null !== $budget) { |
399
|
|
|
Log::debug(sprintf('Return budget #%d, from transaction #%d', $budget->id, $right->id)); |
400
|
|
|
|
401
|
|
|
return (int)$budget->id; |
402
|
|
|
} |
403
|
|
|
Log::debug('Neither left or right have a budget, return NULL'); |
404
|
|
|
|
405
|
|
|
// if all fails, return NULL. |
406
|
|
|
return null; |
407
|
|
|
} |
408
|
|
|
|
409
|
|
|
/** |
410
|
|
|
* @param Transaction $left |
411
|
|
|
* @param Transaction $right |
412
|
|
|
* |
413
|
|
|
* @return int|null |
414
|
|
|
*/ |
415
|
|
|
private function getTransactionCategory(Transaction $left, Transaction $right): ?int |
416
|
|
|
{ |
417
|
|
|
Log::debug('Now in getTransactionCategory()'); |
418
|
|
|
|
419
|
|
|
// try to get a category ID from the left transaction: |
420
|
|
|
/** @var Category $category */ |
421
|
|
|
$category = $left->categories()->first(); |
422
|
|
|
if (null !== $category) { |
423
|
|
|
Log::debug(sprintf('Return category #%d, from transaction #%d', $category->id, $left->id)); |
424
|
|
|
|
425
|
|
|
return (int)$category->id; |
426
|
|
|
} |
427
|
|
|
|
428
|
|
|
// try to get a category ID from the left transaction: |
429
|
|
|
/** @var Category $category */ |
430
|
|
|
$category = $right->categories()->first(); |
431
|
|
|
if (null !== $category) { |
432
|
|
|
Log::debug(sprintf('Return category #%d, from transaction #%d', $category->id, $category->id)); |
433
|
|
|
|
434
|
|
|
return (int)$category->id; |
435
|
|
|
} |
436
|
|
|
Log::debug('Neither left or right have a category, return NULL'); |
437
|
|
|
|
438
|
|
|
// if all fails, return NULL. |
439
|
|
|
return null; |
440
|
|
|
} |
441
|
|
|
|
442
|
|
|
/** |
443
|
|
|
* |
444
|
|
|
*/ |
445
|
|
|
private function markAsMigrated(): void |
446
|
|
|
{ |
447
|
|
|
app('fireflyconfig')->set(self::CONFIG_NAME, true); |
448
|
|
|
} |
449
|
|
|
|
450
|
|
|
} |
451
|
|
|
|