Also add no budget and no category overview.

This commit is contained in:
James Cole
2025-09-27 16:07:03 +02:00
parent 2cc8568077
commit 1ff47441ce
9 changed files with 245 additions and 142 deletions

View File

@@ -92,7 +92,7 @@ class ShowController extends Controller
// get first journal ever to set off the budget period overview.
$first = $this->journalRepos->firstNull();
$firstDate = $first instanceof TransactionJournal ? $first->date : $start;
$periods = $this->getNoBudgetPeriodOverview($firstDate, $end);
$periods = $this->getNoModelPeriodOverview('budget', $firstDate, $end);
$page = (int) $request->get('page');
$pageSize = (int) app('preferences')->get('listPageSize', 50)->data;

View File

@@ -35,6 +35,7 @@ use FireflyIII\Support\Http\Controllers\PeriodOverview;
use Illuminate\Contracts\View\Factory;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
/**
@@ -74,7 +75,7 @@ class NoCategoryController extends Controller
*/
public function show(Request $request, ?Carbon $start = null, ?Carbon $end = null)
{
app('log')->debug('Start of noCategory()');
Log::debug('Start of noCategory()');
$start ??= session('start');
$end ??= session('end');
@@ -82,14 +83,12 @@ class NoCategoryController extends Controller
/** @var Carbon $end */
$page = (int) $request->get('page');
$pageSize = (int) app('preferences')->get('listPageSize', 50)->data;
$subTitle = trans(
'firefly.without_category_between',
['start' => $start->isoFormat($this->monthAndDayFormat), 'end' => $end->isoFormat($this->monthAndDayFormat)]
);
$periods = $this->getNoCategoryPeriodOverview($start);
$subTitle = trans('firefly.without_category_between', ['start' => $start->isoFormat($this->monthAndDayFormat), 'end' => $end->isoFormat($this->monthAndDayFormat)]);
$first = $this->journalRepos->firstNull()->date ?? clone $start;
$periods = $this->getNoModelPeriodOverview('category', $first, $end);
app('log')->debug(sprintf('Start for noCategory() is %s', $start->format('Y-m-d')));
app('log')->debug(sprintf('End for noCategory() is %s', $end->format('Y-m-d')));
Log::debug(sprintf('Start for noCategory() is %s', $start->format('Y-m-d')));
Log::debug(sprintf('End for noCategory() is %s', $end->format('Y-m-d')));
/** @var GroupCollectorInterface $collector */
$collector = app(GroupCollectorInterface::class);
@@ -117,13 +116,13 @@ class NoCategoryController extends Controller
$periods = new Collection();
$page = (int) $request->get('page');
$pageSize = (int) app('preferences')->get('listPageSize', 50)->data;
app('log')->debug('Start of noCategory()');
Log::debug('Start of noCategory()');
$subTitle = (string) trans('firefly.all_journals_without_category');
$first = $this->journalRepos->firstNull();
$start = $first instanceof TransactionJournal ? $first->date : new Carbon();
$end = today(config('app.timezone'));
app('log')->debug(sprintf('Start for noCategory() is %s', $start->format('Y-m-d')));
app('log')->debug(sprintf('End for noCategory() is %s', $end->format('Y-m-d')));
Log::debug(sprintf('Start for noCategory() is %s', $start->format('Y-m-d')));
Log::debug(sprintf('End for noCategory() is %s', $end->format('Y-m-d')));
/** @var GroupCollectorInterface $collector */
$collector = app(GroupCollectorInterface::class);

View File

@@ -8,6 +8,7 @@ use FireflyIII\Casts\SeparateTimezoneCaster;
use FireflyIII\Support\Models\ReturnsIntegerUserIdTrait;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class PeriodStatistic extends Model
@@ -24,6 +25,11 @@ class PeriodStatistic extends Model
];
}
public function userGroup(): BelongsTo
{
return $this->belongsTo(UserGroup::class);
}
protected function count(): Attribute
{
return Attribute::make(

View File

@@ -76,6 +76,14 @@ class UserGroup extends Model
return $this->hasMany(Account::class);
}
/**
* Link to accounts.
*/
public function periodStatistics(): HasMany
{
return $this->hasMany(PeriodStatistic::class);
}
/**
* Link to attachments.
*/

View File

@@ -163,7 +163,6 @@ class FireflyServiceProvider extends ServiceProvider
$this->app->bind(AttachmentHelperInterface::class, AttachmentHelper::class);
$this->app->bind(ALERepositoryInterface::class, ALERepository::class);
$this->app->bind(PeriodStatisticRepositoryInterface::class, PeriodStatisticRepository::class);
$this->app->bind(
static function (Application $app): ObjectGroupRepositoryInterface {
@@ -177,6 +176,18 @@ class FireflyServiceProvider extends ServiceProvider
}
);
$this->app->bind(
static function (Application $app): PeriodStatisticRepositoryInterface {
/** @var PeriodStatisticRepository $repository */
$repository = app(PeriodStatisticRepository::class);
if ($app->auth->check()) { // @phpstan-ignore-line (phpstan does not understand the reference to auth)
$repository->setUser(auth()->user());
}
return $repository;
}
);
$this->app->bind(
static function (Application $app): WebhookRepositoryInterface {
/** @var WebhookRepository $repository */

View File

@@ -25,37 +25,40 @@ namespace FireflyIII\Repositories\PeriodStatistic;
use Carbon\Carbon;
use FireflyIII\Models\PeriodStatistic;
use FireflyIII\Support\Repositories\UserGroup\UserGroupInterface;
use FireflyIII\Support\Repositories\UserGroup\UserGroupTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class PeriodStatisticRepository implements PeriodStatisticRepositoryInterface
class PeriodStatisticRepository implements PeriodStatisticRepositoryInterface, UserGroupInterface
{
use UserGroupTrait;
public function findPeriodStatistics(Model $model, Carbon $start, Carbon $end, array $types): Collection
{
return $model->primaryPeriodStatistics()
->where('start', $start)
->where('end', $end)
->whereIn('type', $types)
->get()
;
->where('start', $start)
->where('end', $end)
->whereIn('type', $types)
->get();
}
public function findPeriodStatistic(Model $model, Carbon $start, Carbon $end, string $type): Collection
{
return $model->primaryPeriodStatistics()
->where('start', $start)
->where('end', $end)
->where('type', $type)
->get()
;
->where('start', $start)
->where('end', $end)
->where('type', $type)
->get();
}
public function saveStatistic(Model $model, int $currencyId, Carbon $start, Carbon $end, string $type, int $count, string $amount): PeriodStatistic
{
$stat = new PeriodStatistic();
$stat = new PeriodStatistic();
$stat->primaryStatable()->associate($model);
$stat->transaction_currency_id = $currencyId;
$stat->user_group_id = $this->getUserGroup()->id;
$stat->start = $start;
$stat->start_tz = $start->format('e');
$stat->end = $end;
@@ -66,16 +69,16 @@ class PeriodStatisticRepository implements PeriodStatisticRepositoryInterface
$stat->save();
Log::debug(sprintf(
'Saved #%d [currency #%d, Model %s #%d, %s to %s, %d, %s] as new statistic.',
$stat->id,
$model::class,
$model->id,
$stat->transaction_currency_id,
$stat->start->toW3cString(),
$stat->end->toW3cString(),
$count,
$amount
));
'Saved #%d [currency #%d, Model %s #%d, %s to %s, %d, %s] as new statistic.',
$stat->id,
$model::class,
$model->id,
$stat->transaction_currency_id,
$stat->start->toW3cString(),
$stat->end->toW3cString(),
$count,
$amount
));
return $stat;
}
@@ -89,4 +92,41 @@ class PeriodStatisticRepository implements PeriodStatisticRepositoryInterface
{
$model->primaryPeriodStatistics()->where('start', '<=', $date)->where('end', '>=', $date)->delete();
}
#[\Override]
public function allInRangeForPrefix(string $prefix, Carbon $start, Carbon $end): Collection
{
return $this->userGroup->periodStatistics()
->where('type', 'LIKE', sprintf('%s%%', $prefix))
->where('start', '>=', $start)->where('end', '<=', $end)->get();
}
#[\Override]
public function savePrefixedStatistic(string $prefix, int $currencyId, Carbon $start, Carbon $end, string $type, int $count, string $amount): PeriodStatistic
{
$stat = new PeriodStatistic();
$stat->transaction_currency_id = $currencyId;
$stat->user_group_id = $this->getUserGroup()->id;
$stat->start = $start;
$stat->start_tz = $start->format('e');
$stat->end = $end;
$stat->end_tz = $end->format('e');
$stat->amount = $amount;
$stat->count = $count;
$stat->type = sprintf('%s_%s',$prefix, $type);
$stat->save();
Log::debug(sprintf(
'Saved #%d [currency #%d, type "%s", %s to %s, %d, %s] as new statistic.',
$stat->id,
$stat->transaction_currency_id,
$stat->type,
$stat->start->toW3cString(),
$stat->end->toW3cString(),
$count,
$amount
));
return $stat;
}
}

View File

@@ -35,8 +35,10 @@ interface PeriodStatisticRepositoryInterface
public function findPeriodStatistic(Model $model, Carbon $start, Carbon $end, string $type): Collection;
public function saveStatistic(Model $model, int $currencyId, Carbon $start, Carbon $end, string $type, int $count, string $amount): PeriodStatistic;
public function savePrefixedStatistic(string $prefix, int $currencyId, Carbon $start, Carbon $end, string $type, int $count, string $amount): PeriodStatistic;
public function allInRangeForModel(Model $model, Carbon $start, Carbon $end): Collection;
public function allInRangeForPrefix(string $prefix, Carbon $start, Carbon $end): Collection;
public function deleteStatisticsForModel(Model $model, Carbon $date): void;
}

View File

@@ -169,117 +169,129 @@ trait PeriodOverview
*
* @throws FireflyException
*/
protected function getNoBudgetPeriodOverview(Carbon $start, Carbon $end): array
protected function getNoModelPeriodOverview(string $model, Carbon $start, Carbon $end): array
{
$range = Navigation::getViewRange(true);
Log::debug(sprintf('Now in getNoModelPeriodOverview(%s, %s %s)', $model, $start->format('Y-m-d'), $end->format('Y-m-d')));
$this->periodStatisticRepo = app(PeriodStatisticRepositoryInterface::class);
$range = Navigation::getViewRange(true);
[$start, $end] = $end < $start ? [$end, $start] : [$start, $end];
$cache = new CacheProperties();
$cache->addProperty($start);
$cache->addProperty($end);
$cache->addProperty($this->convertToPrimary);
$cache->addProperty('no-budget-period-entries');
if ($cache->has()) {
return $cache->get();
}
/** @var array $dates */
$dates = Navigation::blockPeriods($start, $end, $range);
$entries = [];
// get all expenses without a budget.
/** @var GroupCollectorInterface $collector */
$collector = app(GroupCollectorInterface::class);
$collector->setRange($start, $end)->withoutBudget()->withAccountInformation()->setTypes([TransactionTypeEnum::WITHDRAWAL->value]);
$journals = $collector->getExtractedJournals();
$dates = Navigation::blockPeriods($start, $end, $range);
[$start, $end] = $this->getPeriodFromBlocks($dates, $start, $end);
$entries = [];
$this->statistics = $this->periodStatisticRepo->allInRangeForPrefix(sprintf('no_%s', $model), $start, $end);
Log::debug(sprintf('Collected %d stats', $this->statistics->count()));
foreach ($dates as $currentDate) {
$set = $this->filterJournalsByDate($journals, $currentDate['start'], $currentDate['end']);
$title = Navigation::periodShow($currentDate['end'], $currentDate['period']);
$entries[]
= [
'title' => $title,
'route' => route('budgets.no-budget', [$currentDate['start']->format('Y-m-d'), $currentDate['end']->format('Y-m-d')]),
'total_transactions' => count($set),
'spent' => $this->groupByCurrency($set),
'earned' => [],
'transferred_away' => [],
'transferred_in' => [],
];
$entries[] = $this->getSingleNoModelPeriodOverview($model, $currentDate['start'], $currentDate['end'], $currentDate['period']);
}
$cache->store($entries);
return $entries;
}
/**
* TODO fix the date.
*
* Show period overview for no category view.
*
* @throws FireflyException
*/
protected function getNoCategoryPeriodOverview(Carbon $theDate): array
private function getSingleNoModelPeriodOverview(string $model, Carbon $start, Carbon $end, string $period): array
{
Log::debug(sprintf('Now in getNoCategoryPeriodOverview(%s)', $theDate->format('Y-m-d')));
$range = Navigation::getViewRange(true);
$first = $this->journalRepos->firstNull();
$start = null === $first ? new Carbon() : $first->date;
$end = clone $theDate;
$end = Navigation::endOfPeriod($end, $range);
Log::debug(sprintf('getSingleNoModelPeriodOverview(%s, %s, %s, %s)', $model, $start->format('Y-m-d'), $end->format('Y-m-d'), $period));
$statistics = $this->filterPrefixedStatistics($start, $end, sprintf('no_%s', $model));
$title = Navigation::periodShow($end, $period);
Log::debug(sprintf('Start for getNoCategoryPeriodOverview() is %s', $start->format('Y-m-d')));
Log::debug(sprintf('End for getNoCategoryPeriodOverview() is %s', $end->format('Y-m-d')));
if (0 === $statistics->count()) {
Log::debug(sprintf('Found no statistics in period %s - %s, regenerating them.', $start->format('Y-m-d'), $end->format('Y-m-d')));
switch ($model) {
default:
throw new FireflyException(sprintf('Cannot deal with model of type "%s"', $model));
case 'budget':
// get all expenses without a budget.
/** @var GroupCollectorInterface $collector */
$collector = app(GroupCollectorInterface::class);
$collector->setRange($start, $end)->withoutBudget()->withAccountInformation()->setTypes([TransactionTypeEnum::WITHDRAWAL->value]);
$spent = $collector->getExtractedJournals();
$earned = [];
$transferred = [];
break;
case 'category':
// collect all expenses in this period:
/** @var GroupCollectorInterface $collector */
$collector = app(GroupCollectorInterface::class);
$collector->withoutCategory();
$collector->setRange($start, $end);
$collector->setTypes([TransactionTypeEnum::DEPOSIT->value]);
$earned = $collector->getExtractedJournals();
// properties for cache
$dates = Navigation::blockPeriods($start, $end, $range);
$entries = [];
// collect all income in this period:
/** @var GroupCollectorInterface $collector */
$collector = app(GroupCollectorInterface::class);
$collector->withoutCategory();
$collector->setRange($start, $end);
$collector->setTypes([TransactionTypeEnum::WITHDRAWAL->value]);
$spent = $collector->getExtractedJournals();
// collect all expenses in this period:
/** @var GroupCollectorInterface $collector */
$collector = app(GroupCollectorInterface::class);
$collector->withoutCategory();
$collector->setRange($start, $end);
$collector->setTypes([TransactionTypeEnum::DEPOSIT->value]);
$earnedSet = $collector->getExtractedJournals();
// collect all income in this period:
/** @var GroupCollectorInterface $collector */
$collector = app(GroupCollectorInterface::class);
$collector->withoutCategory();
$collector->setRange($start, $end);
$collector->setTypes([TransactionTypeEnum::WITHDRAWAL->value]);
$spentSet = $collector->getExtractedJournals();
// collect all transfers in this period:
/** @var GroupCollectorInterface $collector */
$collector = app(GroupCollectorInterface::class);
$collector->withoutCategory();
$collector->setRange($start, $end);
$collector->setTypes([TransactionTypeEnum::TRANSFER->value]);
$transferSet = $collector->getExtractedJournals();
/** @var array $currentDate */
foreach ($dates as $currentDate) {
$spent = $this->filterJournalsByDate($spentSet, $currentDate['start'], $currentDate['end']);
$earned = $this->filterJournalsByDate($earnedSet, $currentDate['start'], $currentDate['end']);
$transferred = $this->filterJournalsByDate($transferSet, $currentDate['start'], $currentDate['end']);
$title = Navigation::periodShow($currentDate['end'], $currentDate['period']);
$entries[]
= [
// collect all transfers in this period:
/** @var GroupCollectorInterface $collector */
$collector = app(GroupCollectorInterface::class);
$collector->withoutCategory();
$collector->setRange($start, $end);
$collector->setTypes([TransactionTypeEnum::TRANSFER->value]);
$transferred = $collector->getExtractedJournals();
break;
}
$groupedSpent = $this->groupByCurrency($spent);
$groupedEarned = $this->groupByCurrency($earned);
$groupedTransferred = $this->groupByCurrency($transferred);
$entry
= [
'title' => $title,
'route' => route('categories.no-category', [$currentDate['start']->format('Y-m-d'), $currentDate['end']->format('Y-m-d')]),
'total_transactions' => count($spent) + count($earned) + count($transferred),
'spent' => $this->groupByCurrency($spent),
'earned' => $this->groupByCurrency($earned),
'transferred' => $this->groupByCurrency($transferred),
'route' => route(sprintf('%s.no-%s', Str::plural($model), $model), [$start->format('Y-m-d'), $end->format('Y-m-d')]),
'total_transactions' => count($spent),
'spent' => $groupedSpent,
'earned' => $groupedEarned,
'transferred' => $groupedTransferred,
];
$this->saveGroupedForPrefix(sprintf('no_%s', $model), $start, $end, 'spent', $groupedSpent);
$this->saveGroupedForPrefix(sprintf('no_%s', $model), $start, $end, 'earned', $groupedEarned);
$this->saveGroupedForPrefix(sprintf('no_%s', $model), $start, $end, 'transferred', $groupedTransferred);
return $entry;
}
Log::debug('End of loops');
Log::debug(sprintf('Found %d statistics in period %s - %s.', count($statistics), $start->format('Y-m-d'), $end->format('Y-m-d')));
return $entries;
$entry
= [
'title' => $title,
'route' => route(sprintf('%s.no-%s', Str::plural($model), $model), [$start->format('Y-m-d'), $end->format('Y-m-d')]),
'total_transactions' => 0,
'spent' => [],
'earned' => [],
'transferred' => [],
];
$grouped = [];
/** @var PeriodStatistic $statistic */
foreach ($statistics as $statistic) {
$type = str_replace(sprintf('no_%s_', $model), '', $statistic->type);
$id = (int)$statistic->transaction_currency_id;
$currency = Amount::getTransactionCurrencyById($id);
$grouped[$type]['count'] ??= 0;
$grouped[$type][$id] = [
'amount' => (string)$statistic->amount,
'count' => (int)$statistic->count,
'currency_id' => $currency->id,
'currency_name' => $currency->name,
'currency_code' => $currency->code,
'currency_symbol' => $currency->symbol,
'currency_decimal_places' => $currency->decimal_places,
];
$grouped[$type]['count'] += (int)$statistic->count;
}
$types = ['spent', 'earned', 'transferred'];
foreach ($types as $type) {
if (array_key_exists($type, $grouped)) {
$entry['total_transactions'] += $grouped[$type]['count'];
unset($grouped[$type]['count']);
$entry[$type] = $grouped[$type];
}
}
return $entry;
}
protected function getSingleModelPeriod(Model $model, string $period, Carbon $start, Carbon $end): array
@@ -303,30 +315,34 @@ trait PeriodOverview
}
protected function filterStatistics(Carbon $start, Carbon $end, string $type): Collection
private function filterStatistics(Carbon $start, Carbon $end, string $type): Collection
{
if (0 === $this->statistics->count()) {
Log::warning('Have no statistic to filter!');
return new Collection;
}
return $this->statistics->filter(
function (PeriodStatistic $statistic) use ($start, $end, $type) {
if (
!$statistic->end->equalTo($end)
&& $statistic->end->format('Y-m-d H:i:s') === $end->format('Y-m-d H:i:s')
) {
echo sprintf('End: "%s" vs "%s": %s', $statistic->end->toW3cString(), $end->toW3cString(), var_export($statistic->end->eq($end), true));
var_dump($statistic->end);
var_dump($end);
exit;
}
return $statistic->start->eq($start) && $statistic->end->eq($end) && $statistic->type === $type;
}
);
}
private function filterPrefixedStatistics(Carbon $start, Carbon $end, string $prefix): Collection
{
if (0 === $this->statistics->count()) {
Log::warning('Have no statistic to filter!');
return new Collection;
}
return $this->statistics->filter(
function (PeriodStatistic $statistic) use ($start, $end, $prefix) {
return $statistic->start->eq($start) && $statistic->end->eq($end) && str_starts_with($statistic->type, $prefix);
}
);
}
protected function getSingleModelPeriodByType(Model $model, Carbon $start, Carbon $end, string $type): array
private function getSingleModelPeriodByType(Model $model, Carbon $start, Carbon $end, string $type): array
{
Log::debug(sprintf('Now in getSingleModelPeriodByType(%s #%d, %s %s, %s)', $model::class, $model->id, $start->format('Y-m-d'), $end->format('Y-m-d'), $type));
$statistics = $this->filterStatistics($start, $end, $type);
@@ -497,7 +513,7 @@ trait PeriodOverview
return $entries;
}
protected function saveGroupedAsStatistics(Model $model, Carbon $start, Carbon $end, string $type, array $array): void
private function saveGroupedAsStatistics(Model $model, Carbon $start, Carbon $end, string $type, array $array): void
{
unset($array['count']);
Log::debug(sprintf('saveGroupedAsStatistics(%s #%d, %s, %s, "%s", array(%d))', $model::class, $model->id, $start->format('Y-m-d'), $end->format('Y-m-d'), $type, count($array)));
@@ -510,6 +526,19 @@ trait PeriodOverview
}
}
private function saveGroupedForPrefix(string $prefix, Carbon $start, Carbon $end, string $type, array $array): void
{
unset($array['count']);
Log::debug(sprintf('saveGroupedForPrefix("%s", %s, %s, "%s", array(%d))', $prefix, $start->format('Y-m-d'), $end->format('Y-m-d'), $type, count($array)));
foreach ($array as $entry) {
$this->periodStatisticRepo->savePrefixedStatistic($prefix, $entry['currency_id'], $start, $end, $type, $entry['count'], $entry['amount']);
}
if (0 === count($array)) {
Log::debug('Save empty statistic.');
$this->periodStatisticRepo->savePrefixedStatistic($prefix, $this->primaryCurrency->id, $start, $end, $type, 0, '0');
}
}
/**
* Filter a list of journals by a set of dates, and then group them by currency.
*/
@@ -584,6 +613,9 @@ trait PeriodOverview
$return = [
'count' => 0,
];
if (0 === count($journals)) {
return $return;
}
/** @var array $journal */
foreach ($journals as $journal) {

View File

@@ -14,6 +14,10 @@ return new class extends Migration
Schema::create('period_statistics', function (Blueprint $table) {
$table->id();
$table->timestamps();
// reference to user group id.
$table->bigInteger('user_group_id', false, true);
$table->integer('primary_statable_id', false, true)->nullable();
$table->string('primary_statable_type', 255)->nullable();
@@ -33,6 +37,7 @@ return new class extends Migration
$table->string('type',255);
$table->integer('count', false, true)->default(0);
$table->decimal('amount', 32, 12);
$table->foreign('user_group_id')->references('id')->on('user_groups')->onDelete('cascade');
});
}