diff --git a/app/Console/Commands/ConvertLegacyBudgetPlans.php b/app/Console/Commands/ConvertLegacyBudgetPlans.php deleted file mode 100644 index f7c135fc..00000000 --- a/app/Console/Commands/ConvertLegacyBudgetPlans.php +++ /dev/null @@ -1,341 +0,0 @@ -option('dry-run'); - $planId = $this->option('plan-id'); - $organization = $this->option('organization'); - - if ($dryRun) { - $this->warn('🔍 Running in DRY-RUN mode - no changes will be made'); - } - - // Get legacy plans to convert - $legacyPlans = $planId - ? LegacyBudgetPlan::where('id', $planId)->get() - : LegacyBudgetPlan::all(); - - if ($legacyPlans->isEmpty()) { - $this->error('No legacy budget plans found to convert.'); - - return self::FAILURE; - } - - $this->info("Found {$legacyPlans->count()} legacy budget plan(s) to convert."); - - // CRITICAL: Find global maximum item ID across ALL plans before starting - $this->nextGroupId = $this->findGlobalMaxItemId($legacyPlans) + 1; - $this->line('🔢 Global max legacy item ID: '.($this->nextGroupId - 1)); - $this->line("🔢 Group IDs will start from: {$this->nextGroupId}"); - - DB::beginTransaction(); - - try { - foreach ($legacyPlans as $legacyPlan) { - $this->info("\n📋 Converting Legacy Plan ID: {$legacyPlan->id}"); - - $this->convertPlan($legacyPlan, $organization, $dryRun); - } - - if ($dryRun) { - DB::rollBack(); - $this->warn("\n✅ Dry run completed - no changes were made"); - } else { - DB::commit(); - $this->info("\n✅ Conversion completed successfully!"); - } - - return self::SUCCESS; - - } catch (\Exception $e) { - DB::rollBack(); - $this->error("\n❌ Error during conversion: ".$e->getMessage()); - $this->error($e->getTraceAsString()); - - return self::FAILURE; - } - } - - /** - * Find the global maximum legacy item ID across ALL plans to be converted - */ - protected function findGlobalMaxItemId($legacyPlans): int - { - $maxId = 0; - - foreach ($legacyPlans as $plan) { - $planMaxId = LegacyBudgetItem::whereHas('budgetGroup', function ($query) use ($plan): void { - $query->where('hhp_id', $plan->id); - })->max('id'); - - if ($planMaxId > $maxId) { - $maxId = $planMaxId; - } - } - - // Also check existing budget items in case we're adding to existing data - $existingMaxId = BudgetItem::max('id') ?? 0; - - return max($maxId, $existingMaxId); - } - - /** - * Convert a single legacy budget plan - */ - protected function convertPlan(LegacyBudgetPlan $legacyPlan, string $organization, bool $dryRun): void - { - // Check if plan already exists in new structure - if (BudgetPlan::find($legacyPlan->id)) { - $this->warn(" ⚠️ Budget Plan ID {$legacyPlan->id} already exists in new structure. Skipping."); - - return; - } - - // Create or find fiscal year - $fiscalYear = $this->getOrCreateFiscalYear($legacyPlan, $dryRun); - - // Convert state - $state = $this->convertState($legacyPlan->state); - - // Create new budget plan with the same ID - $newPlan = new BudgetPlan([ - 'organization' => $organization, - 'fiscal_year_id' => $fiscalYear->id, - 'state' => $state, - 'resolution_date' => null, // Legacy doesn't have this - 'approval_date' => null, // Legacy doesn't have this - ]); - - // Force the ID to match the legacy plan - $newPlan->id = $legacyPlan->id; - - if (! $dryRun) { - $newPlan->save(); - } - - $this->line(" ✓ Created Budget Plan (ID: {$newPlan->id}, State: {$state->value})"); - - // Get all legacy groups and items - $legacyGroups = $legacyPlan->budgetGroups()->with('budgetItems')->get(); - $this->line(" 📁 Processing {$legacyGroups->count()} budget groups..."); - - // PASS 1: Create all budget items with preserved IDs - $groupPosition = 0; - foreach ($legacyGroups as $legacyGroup) { - $this->convertItemsFirstPass($legacyGroup, $newPlan, $groupPosition, $dryRun); - $groupPosition++; - } - - // PASS 2: Create group items and update parent_id references - $this->createGroupItems($legacyGroups, $newPlan, $dryRun); - } - - /** - * First pass: Create budget items with their original IDs, temporarily with no parent - */ - protected function convertItemsFirstPass( - LegacyBudgetGroup $legacyGroup, - BudgetPlan $newPlan, - int $groupPosition, - bool $dryRun - ): void { - // Determine budget type from legacy type field (0 = income, 1 = expense) - $budgetType = $legacyGroup->type == 0 ? BudgetType::INCOME : BudgetType::EXPENSE; - - // Assign the next available group ID and increment for next group - $futureGroupId = $this->nextGroupId; - $this->nextGroupId++; - - // Store the mapping for later - $this->groupIdMapping[$legacyGroup->id] = [ - 'new_id' => $futureGroupId, - 'name' => $legacyGroup->gruppen_name, - 'type' => $budgetType, - 'position' => $groupPosition, - 'items' => [], - ]; - - $itemPosition = 0; - foreach ($legacyGroup->budgetItems as $legacyItem) { - if (BudgetItem::find($legacyItem->id)) { - $this->warn(" ⚠️ Budget Item ID {$legacyItem->id} already exists. Skipping."); - - continue; - } - - $newItem = new BudgetItem([ - 'budget_plan_id' => $newPlan->id, - 'short_name' => $legacyItem->titel_nr ?? $this->generateShortName($legacyItem->titel_name), - 'name' => $legacyItem->titel_name, - 'value' => $legacyItem->value, - 'budget_type' => $budgetType, - 'description' => null, - 'parent_id' => null, // Will be updated in pass 2 - 'is_group' => false, - 'position' => $itemPosition, - ]); - - // Force the ID to match the legacy item - $newItem->id = $legacyItem->id; - - if (! $dryRun) { - $newItem->save(); - } - - $this->line(" ✓ Item {$legacyItem->id}: {$legacyItem->titel_name}"); - - // Store the legacy group ID this item belongs to - $this->groupIdMapping[$legacyGroup->id]['items'][] = $legacyItem->id; - - $itemPosition++; - } - - $this->line(" ✓ Group items created: {$legacyGroup->gruppen_name} (will be ID: {$futureGroupId})"); - } - - /** - * Second pass: Create group items and update parent references - */ - protected function createGroupItems($legacyGroups, BudgetPlan $newPlan, bool $dryRun): void - { - $this->line("\n 📦 Creating group items and updating parent references..."); - - foreach ($legacyGroups as $legacyGroup) { - $groupInfo = $this->groupIdMapping[$legacyGroup->id]; - $groupId = $groupInfo['new_id']; - - // Calculate total value for the group - $totalValue = $legacyGroup->budgetItems()->sum('value'); - - // Create the group item with the predetermined ID - $groupItem = new BudgetItem([ - 'budget_plan_id' => $newPlan->id, - 'short_name' => $this->generateShortName($groupInfo['name']), - 'name' => $groupInfo['name'], - 'value' => $totalValue, - 'budget_type' => $groupInfo['type'], - 'description' => null, - 'parent_id' => null, - 'is_group' => true, - 'position' => $groupInfo['position'], - ]); - - $groupItem->id = $groupId; - - if (! $dryRun) { - $groupItem->save(); - } - - $this->line(" ✓ Created Group Item ID {$groupId}: {$groupInfo['name']}"); - - // Update all child items to point to this group - if (! empty($groupInfo['items']) && ! $dryRun) { - $itemCount = BudgetItem::whereIn('id', $groupInfo['items']) - ->update(['parent_id' => $groupId]); - - $this->line(" ↳ Updated {$itemCount} child items to parent ID {$groupId}"); - } elseif (! empty($groupInfo['items'])) { - $this->line(' ↳ Would update '.count($groupInfo['items'])." child items to parent ID {$groupId}"); - } - } - } - - /** - * Get or create fiscal year based on legacy plan dates - */ - protected function getOrCreateFiscalYear(LegacyBudgetPlan $legacyPlan, bool $dryRun): FiscalYear - { - // Try to find existing fiscal year that matches the dates - $fiscalYear = FiscalYear::where('start_date', $legacyPlan->von) - ->where('end_date', $legacyPlan->bis) - ->first(); - - if (! $fiscalYear) { - // Create a new fiscal year - $fiscalYear = new FiscalYear([ - 'start_date' => $legacyPlan->von, - 'end_date' => $legacyPlan->bis ?? $legacyPlan->von->addYear()->subDay(), - ]); - - if (! $dryRun) { - $fiscalYear->save(); - } else { - // In dry-run, we need a mock ID - $fiscalYear->id = 9999; - } - - $this->line(" ✓ Created Fiscal Year: {$fiscalYear->start_date} to {$fiscalYear->end_date}"); - } - - return $fiscalYear; - } - - /** - * Convert legacy state to new BudgetPlanState enum - */ - protected function convertState(?string $state): BudgetPlanState - { - // Adjust this mapping based on your legacy state values - return match ($state) { - 'final', 'approved', '1' => BudgetPlanState::FINAL, - default => BudgetPlanState::DRAFT, - }; - } - - /** - * Generate a short name from a full name - */ - protected function generateShortName(string $fullName): string - { - // Take first 3 words or first 20 characters - $words = explode(' ', $fullName); - $shortName = implode(' ', array_slice($words, 0, 3)); - - return substr($shortName, 0, 20); - } -} diff --git a/app/Console/Commands/ProjectMailDomainChangeCommand.php b/app/Console/Commands/ProjectMailDomainChangeCommand.php deleted file mode 100644 index 40db1c5b..00000000 --- a/app/Console/Commands/ProjectMailDomainChangeCommand.php +++ /dev/null @@ -1,59 +0,0 @@ -error('Mail domain is not configured in settings.'); - - return; - } - - $this->info("Checking projects with responsible emails not ending with @{$domain}..."); - - // Find projects where responsible doesn't end with the given domain - $projects = Project::whereNotNull('responsible') - ->where('responsible', '!=', '') - ->where('responsible', 'not like', "%@{$domain}") - ->get(); - - if ($projects->isEmpty()) { - $this->info('No projects found that need updating.'); - - return; - } - - $this->info("Found {$projects->count()} project(s) to update."); - - $bar = $this->output->createProgressBar($projects->count()); - $bar->start(); - - foreach ($projects as $project) { - // Extract the local part before @ or use the whole value if no @ - $localPart = strstr((string) $project->responsible, '@', true) ?: $project->responsible; - - // Update to the new domain - $project->responsible = $localPart.'@'.$domain; - $project->saveQuietly(); - - $bar->advance(); - } - - $bar->finish(); - $this->newLine(); - $this->info('Successfully updated all projects!'); - } -} diff --git a/app/Console/Commands/legacy/ConvertLegacyBudgetPlans.php b/app/Console/Commands/legacy/ConvertLegacyBudgetPlans.php new file mode 100644 index 00000000..ff9113a4 --- /dev/null +++ b/app/Console/Commands/legacy/ConvertLegacyBudgetPlans.php @@ -0,0 +1,65 @@ +option('dry-run'); + $planId = $this->option('plan-id') !== null ? (int) $this->option('plan-id') : null; + $organization = (string) $this->option('organization'); + + if ($dryRun) { + $this->warn('🔍 Running in DRY-RUN mode - no changes will be made'); + } + + $converter = new BudgetPlanConverter(fn (string $line) => $this->line(' '.$line)); + + DB::beginTransaction(); + + try { + $converter->convert($planId, $organization); + + if ($dryRun) { + DB::rollBack(); + $this->warn("\n✅ Dry run completed - no changes were made"); + } else { + DB::commit(); + $this->info("\n✅ Conversion completed successfully!"); + } + + return self::SUCCESS; + } catch (\Exception $e) { + DB::rollBack(); + $this->error("\n❌ Error during conversion: ".$e->getMessage()); + $this->error($e->getTraceAsString()); + + return self::FAILURE; + } + } +} diff --git a/app/Console/Commands/LegacyAddTaxBudgetsCommand.php b/app/Console/Commands/legacy/LegacyAddTaxBudgetsCommand.php similarity index 95% rename from app/Console/Commands/LegacyAddTaxBudgetsCommand.php rename to app/Console/Commands/legacy/LegacyAddTaxBudgetsCommand.php index 51c6dd68..c323e21a 100644 --- a/app/Console/Commands/LegacyAddTaxBudgetsCommand.php +++ b/app/Console/Commands/legacy/LegacyAddTaxBudgetsCommand.php @@ -1,6 +1,6 @@ warn('⚠️ DEPRECATED: the legacy budget tables are now views; this command is slated for deletion and will fail against them.'); + return \DB::transaction(function (): int { $latestPlan = LegacyBudgetPlan::orderBy('id', 'desc')->limit(1)->sole(); $budgetGroups = LegacyBudgetGroup::where('hhp_id', $latestPlan->id) diff --git a/app/Console/Commands/LegacyBudgetItemBatchShift.php b/app/Console/Commands/legacy/LegacyBudgetItemBatchShift.php similarity index 75% rename from app/Console/Commands/LegacyBudgetItemBatchShift.php rename to app/Console/Commands/legacy/LegacyBudgetItemBatchShift.php index ca58e02a..b71900a0 100644 --- a/app/Console/Commands/LegacyBudgetItemBatchShift.php +++ b/app/Console/Commands/legacy/LegacyBudgetItemBatchShift.php @@ -1,10 +1,15 @@ warn('⚠️ DEPRECATED: the legacy budget tables are now views; this command is slated for deletion and will fail against them.'); + $rawInputs = $this->argument('inputs'); $switch = []; // input validation diff --git a/app/Console/Commands/LegacyBudgetItemShift.php b/app/Console/Commands/legacy/LegacyBudgetItemShift.php similarity index 75% rename from app/Console/Commands/LegacyBudgetItemShift.php rename to app/Console/Commands/legacy/LegacyBudgetItemShift.php index 98d3a53a..f081dab4 100644 --- a/app/Console/Commands/LegacyBudgetItemShift.php +++ b/app/Console/Commands/legacy/LegacyBudgetItemShift.php @@ -1,12 +1,17 @@ warn('⚠️ DEPRECATED: the legacy budget tables are now views; this command is slated for deletion and will fail against them.'); + return \DB::transaction(function (): int { $old_id = $this->argument('old_id'); $new_id = $this->argument('new_id'); diff --git a/app/Console/Commands/LegacyChangeBankAccountId.php b/app/Console/Commands/legacy/LegacyChangeBankAccountId.php similarity index 98% rename from app/Console/Commands/LegacyChangeBankAccountId.php rename to app/Console/Commands/legacy/LegacyChangeBankAccountId.php index 7a0fcaed..a316f4ac 100644 --- a/app/Console/Commands/LegacyChangeBankAccountId.php +++ b/app/Console/Commands/legacy/LegacyChangeBankAccountId.php @@ -1,6 +1,6 @@ warn('⚠️ DEPRECATED: the legacy budget tables are now views; this command is slated for deletion and will fail against them.'); + $hhp = LegacyBudgetPlan::findOrFail($this->argument('id')); $groups = $hhp->budgetGroups(); $title = LegacyBudgetItem::whereIn('hhpgruppen_id', $groups->pluck('id')); diff --git a/app/Console/Commands/LegacyMigrateEncryption.php b/app/Console/Commands/legacy/LegacyMigrateEncryption.php similarity index 98% rename from app/Console/Commands/LegacyMigrateEncryption.php rename to app/Console/Commands/legacy/LegacyMigrateEncryption.php index 2051eb7b..d75336fe 100644 --- a/app/Console/Commands/LegacyMigrateEncryption.php +++ b/app/Console/Commands/legacy/LegacyMigrateEncryption.php @@ -1,6 +1,6 @@ getLegacyConfig(); if ($config === []) { - $this->error('No legacy config found.'); - - return self::FAILURE; + $this->components->warn('No legacy config found for realm "'.config('stufis.realm').'", seeding default settings only.'); } $this->migrateSettings($config); @@ -112,6 +110,12 @@ private function getLegacyConfig(): array $file = base_path('legacy/config/config.orgs.php'); } - return (include $file)[$realm]; + // A fresh instance may run against a realm that has no legacy config + // (or the legacy file may not exist at all). Treat both as "no config". + if (! is_file($file)) { + return []; + } + + return (include $file)[$realm] ?? []; } } diff --git a/app/Console/Commands/legacy/VerifyBookingMigration.php b/app/Console/Commands/legacy/VerifyBookingMigration.php new file mode 100644 index 00000000..1dfa9fa8 --- /dev/null +++ b/app/Console/Commands/legacy/VerifyBookingMigration.php @@ -0,0 +1,232 @@ +error('budget_item table is missing — run the conversion first.'); + + return self::FAILURE; + } + + $legacyPresent = Schema::hasTable('haushaltstitel'); + if (! $legacyPresent) { + $this->warn('⚠️ haushaltstitel is gone — old-side ground truth is no longer available, ' + .'only new-side bookability can be checked.'); + } + + // booking sums per titel_id (canceled is unused, so every row counts) + $bookingAgg = DB::table('booking') + ->select('titel_id', DB::raw('COUNT(*) as cnt'), DB::raw('COALESCE(SUM(value), 0) as sum')) + ->groupBy('titel_id') + ->get() + ->keyBy('titel_id'); + + $old = $legacyPresent + ? DB::table('haushaltstitel as t') + ->leftJoin('haushaltsgruppen as g', 't.hhpgruppen_id', '=', 'g.id') + ->select('t.id', 't.titel_nr', 't.titel_name', 'g.type', 'g.gruppen_name') + ->get()->keyBy('id') + : collect(); + + $new = DB::table('budget_item') + ->select('id', 'short_name', 'name', 'is_group', 'referenced_plan_id', 'budget_type') + ->get()->keyBy('id'); + + // which titel_ids to report: every booked title, plus all titles if asked + $titelIds = $bookingAgg->keys(); + if ($this->option('all-titles')) { + $titelIds = $titelIds->merge($old->keys())->merge($new->keys()); + } + $titelIds = $titelIds->filter(fn ($id) => $id !== null)->unique()->sort()->values(); + + $rows = []; + $report = []; + $statusCounts = []; + $fingerprintLines = []; + + // orphan bookings: titel_id null + if ($bookingAgg->has(null) || $bookingAgg->has('')) { + $orphan = $bookingAgg->get(null) ?? $bookingAgg->get(''); + $rows[] = ['(null)', '—', '—', $orphan->cnt, $this->money($orphan->sum), 'ORPHAN_BOOKING']; + $statusCounts['ORPHAN_BOOKING'] = ($statusCounts['ORPHAN_BOOKING'] ?? 0) + 1; + } + + foreach ($titelIds as $id) { + $agg = $bookingAgg->get($id); + $cnt = (int) ($agg->cnt ?? 0); + $sum = (float) ($agg->sum ?? 0); + $oldRow = $old->get($id); + $newRow = $new->get($id); + + $status = $this->statusFor($oldRow, $newRow); + + $oldLabel = $oldRow + ? trim(($oldRow->titel_nr ? $oldRow->titel_nr.' ' : '').$oldRow->titel_name) + : '—'; + $newLabel = $newRow + ? trim(($newRow->short_name ? $newRow->short_name.' ' : '').$newRow->name) + : '—'; + + $rows[] = [$id, $oldLabel, $newLabel, $cnt, $this->money($sum), $status]; + $statusCounts[$status] = ($statusCounts[$status] ?? 0) + 1; + + if ($cnt > 0) { + $fingerprintLines[] = $id.'|'.number_format($sum, 2, '.', ''); + } + + $report[] = [ + 'titel_id' => $id, + 'bookings' => $cnt, + 'sum' => round($sum, 2), + 'old' => $oldRow ? ['nr' => $oldRow->titel_nr, 'name' => $oldRow->titel_name, 'type' => $oldRow->type] : null, + 'new' => $newRow ? ['short_name' => $newRow->short_name, 'name' => $newRow->name, 'kind' => $this->kind($newRow)] : null, + 'status' => $status, + ]; + } + + $this->table(['titel_id', 'old (haushaltstitel)', 'new (budget_item)', '#', 'sum', 'status'], $rows); + + $this->renderSummary($statusCounts, $old, $new, $fingerprintLines); + + if ($path = $this->option('json')) { + file_put_contents($path, json_encode([ + 'generated_at' => now()->toIso8601String(), + 'fingerprint' => $this->fingerprint($fingerprintLines), + 'grand_total_bookings' => (int) DB::table('booking')->count(), + 'grand_total_sum' => round((float) DB::table('booking')->sum('value'), 2), + 'status_counts' => $statusCounts, + 'titles' => $report, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + $this->info("📄 Report written to {$path}"); + } + + $blockers = collect($statusCounts)->only(self::BLOCKERS)->sum(); + if ($blockers > 0) { + $this->error("\n❌ {$blockers} title(s) in a blocking state — do NOT consider the migration successful."); + + return self::FAILURE; + } + + $this->info("\n✅ Every booked title resolves to a bookable budget_item."); + + return self::SUCCESS; + } + + /** + * Worst-severity status for one title. Blockers first (a booking would hang + * off a non-bookable or missing item), then human-eyeball warnings. + */ + private function statusFor(?object $oldRow, ?object $newRow): string + { + if ($newRow === null) { + return 'MISSING_NEW'; + } + if ($newRow->referenced_plan_id !== null) { + return 'MOUNT_NOT_BOOKABLE'; + } + if ((bool) $newRow->is_group) { + return 'GROUP_NOT_BOOKABLE'; + } + if ($oldRow === null) { + return 'MISSING_OLD'; // new-only title (expected only if the new UI added titles) + } + if ($oldRow->type !== null && $this->expectedType($oldRow->type) !== (int) $newRow->budget_type) { + return 'TYPE_DIFF'; + } + if ($this->norm($oldRow->titel_name) !== $this->norm($newRow->name)) { + return 'NAME_DIFF'; + } + + return 'OK'; + } + + /** Legacy group type (0 = income, else expense) → new BudgetType value. */ + private function expectedType(int $legacyType): int + { + return ($legacyType === 0 ? BudgetType::INCOME : BudgetType::EXPENSE)->value; + } + + private function kind(object $newRow): string + { + if ($newRow->referenced_plan_id !== null) { + return 'mount'; + } + + return $newRow->is_group ? 'group' : 'budget'; + } + + private function renderSummary(array $statusCounts, $old, $new, array $fingerprintLines): void + { + $this->newLine(); + $this->line('Status breakdown:'); + foreach ($statusCounts as $status => $count) { + $marker = in_array($status, self::BLOCKERS, true) ? '❌' : ($status === 'OK' ? '✓' : '⚠️ '); + $this->line(" {$marker} {$status}: {$count}"); + } + + $bookableNew = $new->filter(fn ($r) => ! $r->is_group && $r->referenced_plan_id === null)->count(); + + $this->newLine(); + $this->line('Coverage:'); + if ($old->isNotEmpty()) { + $this->line(" legacy titles (haushaltstitel): {$old->count()}"); + } + $this->line(" bookable budget_items: {$bookableNew}"); + + $this->newLine(); + $this->line('Booking-table integrity (compare before vs after the upgrade):'); + $this->line(' grand total bookings: '.DB::table('booking')->count()); + $this->line(' grand total sum: '.$this->money((float) DB::table('booking')->sum('value'))); + $this->line(' fingerprint: '.$this->fingerprint($fingerprintLines)); + } + + /** Stable hash over per-title booking sums; identical pre/post unless bookings changed. */ + private function fingerprint(array $lines): string + { + sort($lines); + + return sha1(implode("\n", $lines)); + } + + private function norm(?string $s): string + { + return trim((string) $s); + } + + private function money(float $v): string + { + return number_format($v, 2, ',', '.').' €'; + } +} diff --git a/app/Console/Commands/CheckEncryptedFieldsCommand.php b/app/Console/Commands/stufis/CheckEncryptedFieldsCommand.php similarity index 99% rename from app/Console/Commands/CheckEncryptedFieldsCommand.php rename to app/Console/Commands/stufis/CheckEncryptedFieldsCommand.php index 75b0e59e..ef01948d 100644 --- a/app/Console/Commands/CheckEncryptedFieldsCommand.php +++ b/app/Console/Commands/stufis/CheckEncryptedFieldsCommand.php @@ -1,6 +1,6 @@ $this->plan, + 'income' => new BudgetPlanMeasures($this->plan, BudgetType::INCOME)->annotate(), + 'expense' => new BudgetPlanMeasures($this->plan, BudgetType::EXPENSE)->annotate(), + ]); + } + + #[\Override] + public function columnFormats(): array + { + return [ + 'C' => NumberFormat::FORMAT_CURRENCY_EUR, + 'D' => NumberFormat::FORMAT_CURRENCY_EUR, + 'E' => NumberFormat::FORMAT_CURRENCY_EUR, + ]; + } + + #[\Override] + public function columnWidths(): array + { + return [ + 'A' => 16, + 'B' => 50, + 'C' => 15, + 'D' => 15, + 'E' => 15, + ]; + } +} diff --git a/app/Exports/Datev/DatevExport.php b/app/Exports/Datev/DatevExport.php index 843dacea..2657f01a 100644 --- a/app/Exports/Datev/DatevExport.php +++ b/app/Exports/Datev/DatevExport.php @@ -4,6 +4,7 @@ use Ameax\Datev\DataObjects\DatevAccountLedgerData; use Ameax\Datev\DataObjects\DatevDocumentData; +use App\Models\Enums\BudgetType; use App\Models\Legacy\BankTransaction; use App\Models\Legacy\Booking; use App\Models\Legacy\Expense; @@ -210,8 +211,9 @@ private function ledgerIban(Expense $expense): ?string private function amount(Booking $booking, bool $isReceivable): float { - $invers = ($isReceivable === false && $booking->budgetItem->budgetGroup->type === 0) || - ($isReceivable && $booking->budgetItem->budgetGroup->type === 1); + $budgetType = $booking->budgetItem->budget_type; + $invers = ($isReceivable === false && $budgetType === BudgetType::INCOME) || + ($isReceivable && $budgetType === BudgetType::EXPENSE); return $invers ? -$booking->value : $booking->value; } diff --git a/app/Http/Controllers/BudgetPlanController.php b/app/Http/Controllers/BudgetPlanController.php index 63603782..837ee275 100644 --- a/app/Http/Controllers/BudgetPlanController.php +++ b/app/Http/Controllers/BudgetPlanController.php @@ -3,9 +3,7 @@ namespace App\Http\Controllers; use App\Models\BudgetPlan; -use App\Models\Enums\BudgetType; use App\Models\FiscalYear; -use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Gate; class BudgetPlanController extends Controller @@ -20,38 +18,4 @@ public function index() return view('budget-plan.index', ['years' => $years, 'orphaned_plans' => $orphaned_plans]); } - - public function show(int $plan_id) - { - $plan = BudgetPlan::findOrFail($plan_id); - Gate::authorize('view', $plan); - $items = [ - BudgetType::INCOME->slug() => $plan->budgetItemsTree(BudgetType::INCOME), - BudgetType::EXPENSE->slug() => $plan->budgetItemsTree(BudgetType::EXPENSE), - ]; - - return view('budget-plan.view', ['plan' => $plan, 'items' => $items]); - } - - public function create(): RedirectResponse - { - Gate::authorize('create', BudgetPlan::class); - $plan = BudgetPlan::create(['state' => 'draft']); - $groups = $plan->budgetItems()->createMany([ - ['is_group' => 1, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1'], - ['is_group' => 1, 'budget_type' => BudgetType::EXPENSE, 'position' => 0, 'short_name' => 'A1'], - ]); - $groups->each(function ($group) use ($plan): void { - $group->children()->createMany([ - [ - 'budget_plan_id' => $plan->id, - 'is_group' => 0, 'position' => 0, - 'budget_type' => $group->budget_type, - 'short_name' => $group->short_name.'.1', - ], - ]); - }); - - return to_route('budget-plan.edit', ['plan_id' => $plan->id]); - } } diff --git a/app/Http/Controllers/BudgetPlanExportController.php b/app/Http/Controllers/BudgetPlanExportController.php new file mode 100644 index 00000000..d288632a --- /dev/null +++ b/app/Http/Controllers/BudgetPlanExportController.php @@ -0,0 +1,32 @@ + Excel::XLSX, + 'ods' => Excel::ODS, + default => abort(404), + }; + + $name = Str::slug(today()->format('Y-m-d').' HHP '.$plan->label()) ?: 'hhp'; + + return new BudgetPlanExport($plan)->download("$name.$filetype", $writerType); + } +} diff --git a/app/Models/BudgetItem.php b/app/Models/BudgetItem.php index e92400ea..15148472 100644 --- a/app/Models/BudgetItem.php +++ b/app/Models/BudgetItem.php @@ -2,7 +2,9 @@ namespace App\Models; +use App\Models\Enums\BudgetItemKind; use App\Models\Enums\BudgetType; +use App\Models\Legacy\Booking; use Cknow\Money\Casts\MoneyDecimalCast; use Cknow\Money\Money; use Database\Factories\BudgetItemFactory; @@ -111,11 +113,21 @@ class BudgetItem extends Model /** * @var array */ - protected $fillable = ['budget_plan_id', 'short_name', 'name', 'value', 'budget_type', 'description', 'parent_id', 'is_group', 'position']; + protected $fillable = ['budget_plan_id', 'short_name', 'name', 'value', 'budget_type', 'description', 'parent_id', 'is_group', 'position', 'referenced_plan_id']; + /** + * Bookings recorded against this item. Wired by titel_id == budget_item.id, which holds + * for converted leaf items because the conversion preserves their legacy ids. Only + * bookable (leaf) items ever carry bookings — see isBookable(). + */ public function bookings(): HasMany { - return $this->hasMany('tbd', 'titel_id'); + return $this->hasMany(Booking::class, 'titel_id'); + } + + public function hasBookings(): bool + { + return $this->bookings()->exists(); } public function budgetPlan(): BelongsTo @@ -123,6 +135,59 @@ public function budgetPlan(): BelongsTo return $this->belongsTo(BudgetPlan::class, 'budget_plan_id'); } + /** The plan this item "mounts" (only set for mount items). */ + public function referencedPlan(): BelongsTo + { + return $this->belongsTo(BudgetPlan::class, 'referenced_plan_id'); + } + + /** Derived discriminator (mount > group > budget) until/unless we add a physical column. */ + public function kind(): BudgetItemKind + { + if ($this->referenced_plan_id !== null) { + return BudgetItemKind::Mount; + } + + return $this->is_group ? BudgetItemKind::Group : BudgetItemKind::Budget; + } + + public function isMount(): bool + { + return $this->referenced_plan_id !== null; + } + + /** Only plain budget leaves can be booked against — groups and mounts cannot. */ + public function isBookable(): bool + { + return $this->kind() === BudgetItemKind::Budget; + } + + /** + * The item's effective value: a mount resolves to the referenced plan's total for its side + * (income/expense), everything else uses the stored value. $visited guards reference cycles. + * + * @param array $visited plan ids already entered, to stop mount cycles + */ + public function effectiveValue(array $visited = []): Money + { + if ($this->isMount() && $this->referencedPlan !== null) { + return $this->referencedPlan->sumForType($this->budget_type, $visited); + } + + if ($this->is_group) { + // a group's value is the LIVE sum of its children's effective values, so a mount + // nested anywhere inside still rolls up (a mount's derived total can't be stored) + $sum = Money::EUR(0); + foreach ($this->children as $child) { + $sum = $sum->add($child->effectiveValue($visited)); + } + + return $sum; + } + + return $this->value ?? Money::EUR(0); + } + public function orderedChildren(): HasMany { return $this->hasMany(self::class, 'parent_id')->orderBy('position', 'asc'); diff --git a/app/Models/BudgetPlan.php b/app/Models/BudgetPlan.php index d2626532..334f9ea6 100644 --- a/app/Models/BudgetPlan.php +++ b/app/Models/BudgetPlan.php @@ -2,9 +2,10 @@ namespace App\Models; -use App\Models\Enums\BudgetPlanState; use App\Models\Enums\BudgetType; +use App\States\BudgetPlan\BudgetPlanState; use Carbon\Carbon; +use Cknow\Money\Money; use Database\Factories\BudgetPlanFactory; use Eloquent; use Illuminate\Database\Eloquent\Builder; @@ -12,6 +13,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Spatie\ModelStates\HasStates; use Staudenmeir\LaravelAdjacencyList\Eloquent\Collection; /** @@ -56,6 +58,7 @@ class BudgetPlan extends Model { use HasFactory; + use HasStates; /** * The table associated with the model. @@ -79,12 +82,18 @@ protected function casts(): array ]; } + /** + * @return HasMany returns all budget items of this plan flattend + */ public function budgetItems(): HasMany { return $this->hasMany(BudgetItem::class); } - public function budgetItemsTree(BudgetType $budgetType) + /** + * @return Collection returns all budget items of this plan in tree format + */ + public function budgetItemsTree(BudgetType $budgetType): Collection { // $this is not accessible from the closure scope $plan_id = $this->id; @@ -102,6 +111,143 @@ public function rootBudgetItems(): Builder|HasMany|BudgetPlan return $this->hasMany(BudgetItem::class)->whereNull('parent_id'); } + /** + * Sum of all root-level item values for the given budget type. + * + * Sums each root's effective value: normal roots use their stored value (group values are + * auto-maintained as the sum of their children), while a mount root resolves to the + * referenced plan's total — so totals roll up across mounted sub-plans. $visited guards + * against reference cycles. + * + * @param array $visited plan ids already entered while recursing through mounts + */ + public function sumForType(BudgetType $budgetType, array $visited = []): Money + { + if (in_array($this->id, $visited, true)) { + return Money::EUR(0); // reference cycle — stop recursing + } + $visited[] = $this->id; + + $sum = Money::EUR(0); + foreach ($this->rootBudgetItems()->where('budget_type', $budgetType)->get() as $root) { + $sum = $sum->add($root->effectiveValue($visited)); + } + + return $sum; + } + + /** + * Whether this plan reaches $planId through its mounts (transitively), or is it. + * Used to reject mounts that would create a reference cycle. + * + * @param array $visited + */ + public function reachesPlan(int $planId, array $visited = []): bool + { + if ($this->id === $planId) { + return true; + } + if (in_array($this->id, $visited, true)) { + return false; + } + $visited[] = $this->id; + + foreach ($this->budgetItems()->whereNotNull('referenced_plan_id')->pluck('referenced_plan_id') as $refId) { + $referenced = static::find($refId); + if ($referenced !== null && $referenced->reachesPlan($planId, $visited)) { + return true; + } + } + + return false; + } + + /** + * Distinct plans reachable through this plan's mounts (transitive). Used to ask the user, + * per sub-plan, whether to copy or drop it when cloning. $visited guards reference cycles. + * + * @param array $visited + * @return \Illuminate\Support\Collection + */ + public function reachableMountedPlans(array $visited = []): \Illuminate\Support\Collection + { + if (in_array($this->id, $visited, true)) { + return collect(); + } + $visited[] = $this->id; + + $plans = collect(); + foreach ($this->budgetItems()->whereNotNull('referenced_plan_id')->pluck('referenced_plan_id')->unique() as $refId) { + $referenced = static::find($refId); + if ($referenced === null) { + continue; + } + $plans->put($referenced->id, $referenced); + foreach ($referenced->reachableMountedPlans($visited) as $deep) { + $plans->put($deep->id, $deep); + } + } + + return $plans->values(); + } + + /** + * Whether $organization is already used by a plan in $fiscalYearId. A blank name never + * counts as taken. $ignoreId excludes a specific plan (e.g. the row being edited). + */ + public static function organizationTaken(?string $organization, ?int $fiscalYearId, ?int $ignoreId = null): bool + { + if (blank($organization)) { + return false; + } + + return static::query() + ->where('organization', $organization) + ->when($ignoreId !== null, fn ($query) => $query->whereKeyNot($ignoreId)) + ->when( + $fiscalYearId === null, + fn ($query) => $query->whereNull('fiscal_year_id'), + fn ($query) => $query->where('fiscal_year_id', $fiscalYearId), + ) + ->exists(); + } + + /** + * Suggest a non-colliding organization name for a new plan in $fiscalYearId: the name as + * given, unless a plan in that fiscal year already uses it — then append " (Kopie)" (numbered + * on repeat collisions) so duplicates within a year stay distinguishable. + */ + public static function resolveOrganization(?string $organization, ?int $fiscalYearId): ?string + { + if (blank($organization) || ! static::organizationTaken($organization, $fiscalYearId)) { + return $organization; + } + + $suffix = __('budget-plan.edit.copy-suffix'); + $candidate = $organization.' ('.$suffix.')'; + for ($n = 2; static::organizationTaken($candidate, $fiscalYearId); $n++) { + $candidate = $organization.' ('.$suffix.' '.$n.')'; + } + + return $candidate; + } + + /** Human label for the plan (organization, with a fallback). */ + public function label(): string + { + return $this->organization ?: __('budget-plan.view.no-organization'); + } + + public function incomeTotal(): Money + { + return $this->sumForType(BudgetType::INCOME); + } + + public function expenseTotal(): Money + { + return $this->sumForType(BudgetType::EXPENSE); + } + public function fiscalYear(): BelongsTo { return $this->belongsTo(FiscalYear::class); diff --git a/app/Models/Enums/BudgetItemKind.php b/app/Models/Enums/BudgetItemKind.php new file mode 100644 index 00000000..b41481c1 --- /dev/null +++ b/app/Models/Enums/BudgetItemKind.php @@ -0,0 +1,24 @@ + 'wallet', + self::Budget => 'banknotes', + self::Mount => 'arrow-top-right-on-square', + }; + } +} diff --git a/app/Models/Enums/BudgetPlanState.php b/app/Models/Enums/BudgetPlanState.php deleted file mode 100644 index 5796f168..00000000 --- a/app/Models/Enums/BudgetPlanState.php +++ /dev/null @@ -1,25 +0,0 @@ - 'final', - self::DRAFT => 'draft', - }; - } - - public function label(): string - { - return match ($this) { - self::FINAL => __('Final'), - self::DRAFT => __('Draft'), - }; - } -} diff --git a/app/Models/Enums/BudgetType.php b/app/Models/Enums/BudgetType.php index b497b85e..be4e5877 100644 --- a/app/Models/Enums/BudgetType.php +++ b/app/Models/Enums/BudgetType.php @@ -22,4 +22,25 @@ public function name(): string self::EXPENSE => __('Expense'), }; } + + /** + * Seed prefix for the auto-generated "Titelnummer" of a first-of-type root + * item (e.g. E1 / A1). Change here to alter the root prefix. + */ + public function numberPrefix(): string + { + return match ($this) { + self::INCOME => 'E', + self::EXPENSE => 'A', + }; + } + + /** The other side of the budget (income ↔ expense). */ + public function opposite(): self + { + return match ($this) { + self::INCOME => self::EXPENSE, + self::EXPENSE => self::INCOME, + }; + } } diff --git a/app/Models/FiscalYear.php b/app/Models/FiscalYear.php index e8685336..d93bfa56 100644 --- a/app/Models/FiscalYear.php +++ b/app/Models/FiscalYear.php @@ -39,4 +39,32 @@ public function budgetPlans(): HasMany { return $this->hasMany(BudgetPlan::class); } + + /** + * Human-readable label for the fiscal year's range. + * + * When both boundaries align to whole months (start on the 1st, end on the + * last day of a month) the range is rendered compactly as localized + * "MMM yy" per boundary (e.g. "Apr 22 – Mär 23"), collapsing to a single + * token when start and end fall in the same month. Otherwise it falls back + * to full "d.m.Y" dates. + */ + public function label(): string + { + $start = $this->start_date; + $end = $this->end_date; + + $wholeMonths = $start->isSameDay($start->copy()->startOfMonth()) + && $end->isSameDay($end->copy()->endOfMonth()); + + if (! $wholeMonths) { + return $start->format('d.m.Y').' – '.$end->format('d.m.Y'); + } + + if ($start->isSameMonth($end)) { + return $start->translatedFormat('M y'); + } + + return $start->translatedFormat('M y').' – '.$end->translatedFormat('M y'); + } } diff --git a/app/Models/Legacy/Booking.php b/app/Models/Legacy/Booking.php index c5b5b641..37d911ae 100644 --- a/app/Models/Legacy/Booking.php +++ b/app/Models/Legacy/Booking.php @@ -2,6 +2,7 @@ namespace App\Models\Legacy; +use App\Models\BudgetItem; use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -23,9 +24,8 @@ * @property string $comment * @property float $value * @property int $canceled - * @property LegacyBudgetItem $haushaltstitel * @property User $user - * @property-read LegacyBudgetItem $budgetItem + * @property-read BudgetItem $budgetItem * * @method static Builder|Booking newModelQuery() * @method static Builder|Booking newQuery() @@ -63,7 +63,7 @@ class Booking extends Model public function budgetItem(): BelongsTo { - return $this->belongsTo(LegacyBudgetItem::class, 'titel_id'); + return $this->belongsTo(BudgetItem::class, 'titel_id'); } public function user(): BelongsTo diff --git a/app/Models/Legacy/LegacyBudgetPlan.php b/app/Models/Legacy/LegacyBudgetPlan.php index 51c867e6..55717873 100644 --- a/app/Models/Legacy/LegacyBudgetPlan.php +++ b/app/Models/Legacy/LegacyBudgetPlan.php @@ -60,7 +60,7 @@ public function budgetItems(): HasManyThrough return $this->throughBudgetGroups()->hasBudgetItems(); } - public static function latest(): \Eloquent|static + public static function latest(): \Eloquent|static|null { return self::orderBy('id', 'desc')->first(); } diff --git a/app/Models/Legacy/ProjectPost.php b/app/Models/Legacy/ProjectPost.php index 27743406..020b9d4e 100644 --- a/app/Models/Legacy/ProjectPost.php +++ b/app/Models/Legacy/ProjectPost.php @@ -3,6 +3,7 @@ namespace App\Models\Legacy; use App\Events\UpdatingModel; +use App\Models\BudgetItem; use Cknow\Money\Casts\MoneyDecimalCast; use Cknow\Money\Money; use Illuminate\Database\Eloquent\Builder; @@ -78,7 +79,7 @@ public function expensePosts(): HasMany public function budgetItem(): BelongsTo { - return $this->belongsTo(LegacyBudgetItem::class, 'titel_id'); + return $this->belongsTo(BudgetItem::class, 'titel_id'); } public function expendedSum(): Money diff --git a/app/Models/Setting.php b/app/Models/Setting.php index cd9375f4..f53eba88 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -39,7 +39,10 @@ public static function defaults(): array ], ], 'tax.active' => false, + 'tax.rates' => [7, 19], 'datev' => false, + 'user.committees.mode' => 'raw', + 'user.committees.data' => [], ]; } diff --git a/app/Models/TaxBudget.php b/app/Models/TaxBudget.php index d01aa260..27d80421 100644 --- a/app/Models/TaxBudget.php +++ b/app/Models/TaxBudget.php @@ -2,9 +2,7 @@ namespace App\Models; -use App\Models\Legacy\LegacyBudgetGroup; -use App\Models\Legacy\LegacyBudgetItem; -use App\Models\Legacy\LegacyBudgetPlan; +use App\Models\Enums\BudgetType; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\DB; @@ -14,8 +12,8 @@ class TaxBudget extends Model protected $table = 'tax_budget'; protected $fillable = [ - 'hhp_id', - 'titel_id', + 'plan_id', + 'budget_id', 'tax_percent', ]; @@ -28,61 +26,73 @@ protected function casts(): array } /** - * Idempotently add the Umsatzsteuer group and its tax titles to a legacy - * budget plan. Existing entries are left untouched, so it is safe to call - * this repeatedly for the same plan. + * Idempotently add the Umsatzsteuer group and one tax title per VAT rate to a budget plan, in + * the new budget_item structure. Existing entries are left untouched, so it is safe to call + * repeatedly. Returns the number of tax titles newly added (0 if all already existed). * - * @param array $taxTitles Tax titles to ensure, keyed by tax percentage. - * @param int $groupType 1 = Ausgabe (expenses), 0 = Einnahme (income). + * @param list|null $rates VAT rates in percent; defaults to the `tax.rates` setting. */ public static function addToPlan( int $planId, + ?array $rates = null, string $groupName = 'Umsatzsteuer', - int $groupType = 1, - array $taxTitles = [ - 7 => ['titel_nr' => 'A.99.1', 'titel_name' => '7% Umsatzsteuer'], - 19 => ['titel_nr' => 'A.99.2', 'titel_name' => '19% Umsatzsteuer'], - ], - ): void { - DB::transaction(static function () use ($planId, $groupName, $groupType, $taxTitles): void { - $group = LegacyBudgetGroup::firstOrCreate([ - 'hhp_id' => $planId, - 'gruppen_name' => $groupName, - 'type' => $groupType, - ]); + string $groupShortName = 'A.99', + BudgetType $groupType = BudgetType::EXPENSE, + ): int { + $rates = collect($rates ?? Setting::get('tax.rates', [7, 19])) + ->map(fn ($rate): int => (int) $rate) + ->filter(fn (int $rate): bool => $rate > 0) + ->unique() + ->sort() + ->values(); - foreach ($taxTitles as $percent => $title) { - $item = LegacyBudgetItem::firstOrCreate( - [ - 'hhpgruppen_id' => $group->id, - 'titel_nr' => $title['titel_nr'], - ], + return DB::transaction(function () use ($planId, $rates, $groupName, $groupShortName, $groupType): int { + $group = BudgetItem::firstOrCreate( + ['budget_plan_id' => $planId, 'short_name' => $groupShortName, 'is_group' => true], + [ + 'name' => $groupName, + 'budget_type' => $groupType, + 'value' => 0, + 'position' => BudgetItem::where('budget_plan_id', $planId)->whereNull('parent_id') + ->where('budget_type', $groupType)->max('position') + 1, + ], + ); + + $added = 0; + foreach ($rates as $index => $percent) { + $item = BudgetItem::firstOrCreate( + ['budget_plan_id' => $planId, 'short_name' => $groupShortName.'.'.($index + 1)], [ - 'titel_name' => $title['titel_name'], + 'name' => $percent.'% '.$groupName, 'value' => 0, + 'budget_type' => $groupType, + 'is_group' => false, + 'parent_id' => $group->id, + 'position' => $index, ], ); - self::firstOrCreate( - [ - 'hhp_id' => $planId, - 'titel_id' => $item->id, - ], - [ - 'tax_percent' => $percent, - ], + $taxBudget = self::firstOrCreate( + ['plan_id' => $planId, 'budget_id' => $item->id], + ['tax_percent' => $percent], ); + + if ($taxBudget->wasRecentlyCreated) { + $added++; + } } + + return $added; }); } - public function legacyBudgetPlan(): BelongsTo + public function budgetPlan(): BelongsTo { - return $this->belongsTo(LegacyBudgetPlan::class, 'hhp_id'); + return $this->belongsTo(BudgetPlan::class, 'plan_id'); } - public function legacyBudgetTitle(): BelongsTo + public function budgetTitle(): BelongsTo { - return $this->belongsTo(LegacyBudgetItem::class, 'titel_id'); + return $this->belongsTo(BudgetItem::class, 'budget_id'); } } diff --git a/app/Policies/BudgetPlanPolicy.php b/app/Policies/BudgetPlanPolicy.php index ae9ce6b8..1d5c801e 100644 --- a/app/Policies/BudgetPlanPolicy.php +++ b/app/Policies/BudgetPlanPolicy.php @@ -4,6 +4,7 @@ use App\Models\BudgetPlan; use App\Models\User; +use App\States\BudgetPlan\BudgetPlanState; use Illuminate\Auth\Access\HandlesAuthorization; class BudgetPlanPolicy @@ -34,4 +35,15 @@ public function delete(User $user, BudgetPlan $budgetPlan): bool { return $user->can('budget-officer', User::class); } + + public function transitionTo(User $user, BudgetPlan $budgetPlan, BudgetPlanState $newState): bool + { + // the transition must be allowed by the state machine ... + if (! $budgetPlan->state->canTransitionTo($newState)) { + return false; + } + + // ... and only budget officers may move a plan along its workflow + return $user->can('budget-officer', User::class); + } } diff --git a/app/States/BudgetPlan/Approved.php b/app/States/BudgetPlan/Approved.php new file mode 100644 index 00000000..fb22f59b --- /dev/null +++ b/app/States/BudgetPlan/Approved.php @@ -0,0 +1,20 @@ +default(Draft::class) + ->allowTransition(Resolved::class, Draft::class) + ->allowTransition([Draft::class, Approved::class], Resolved::class) + ->allowTransition([Resolved::class, Published::class], Approved::class) + ->allowTransition([Approved::class, Completed::class], Published::class) + ->allowTransition(Published::class, Completed::class); + } + + public function toLivewire(): array + { + return [$this->getValue(), $this->getModel()->getKey()]; + } + + public static function fromLivewire($value): BudgetPlanState + { + [$name, $id] = $value; + $model = BudgetPlan::find($id); + + return BudgetPlanState::make($name, $model); + } +} diff --git a/app/States/BudgetPlan/Completed.php b/app/States/BudgetPlan/Completed.php new file mode 100644 index 00000000..e25027f2 --- /dev/null +++ b/app/States/BudgetPlan/Completed.php @@ -0,0 +1,20 @@ + $mountChoices keyed by source referenced_plan_id + */ + public function cloneInto(BudgetPlan $source, BudgetPlan $target, array $mountChoices = []): void + { + DB::transaction(function () use ($source, $target, $mountChoices): void { + $cloneMap = [$source->id => $target]; + $this->cloneForest($source, $target, $mountChoices, $cloneMap); + }); + } + + /** + * Clone every root item of $source (both budget types) into $target. + * + * @param array $mountChoices + * @param array $cloneMap sourcePlanId => cloned plan + */ + private function cloneForest(BudgetPlan $source, BudgetPlan $target, array $mountChoices, array &$cloneMap): void + { + foreach ($source->rootBudgetItems()->orderBy('position')->get() as $root) { + $this->cloneItem($root, $target, null, $mountChoices, $cloneMap); + } + } + + /** + * Recursively clone $item (and its subtree) under $parentId in $target. + * + * @param array $mountChoices + * @param array $cloneMap + */ + private function cloneItem(BudgetItem $item, BudgetPlan $target, ?int $parentId, array $mountChoices, array &$cloneMap): void + { + $attributes = [ + 'budget_plan_id' => $target->id, + 'parent_id' => $parentId, + 'budget_type' => $item->budget_type, + 'short_name' => $item->short_name, + 'name' => $item->name, + 'is_group' => $item->is_group, + 'position' => $item->position, + 'value' => $item->value, + 'referenced_plan_id' => null, + ]; + + if ($item->isMount()) { + if (($mountChoices[$item->referenced_plan_id] ?? 'drop') === 'copy') { + $clone = $this->cloneSubPlan($item->referencedPlan, $target, $mountChoices, $cloneMap); + $attributes['referenced_plan_id'] = $clone->id; + $attributes['is_group'] = false; + } else { + // drop: keep the label, but turn it into a plain empty group with no value + $attributes['is_group'] = true; + $attributes['value'] = Money::EUR(0); + } + } + + $new = BudgetItem::create($attributes); + + // a mount is a leaf; everything else recurses into its children + if (! $new->isMount()) { + foreach ($item->orderedChildren as $child) { + $this->cloneItem($child, $target, $new->id, $mountChoices, $cloneMap); + } + } + } + + /** + * Clone a mounted sub-plan into its own new draft (memoized), then clone its forest. The + * clone inherits the target's fiscal year so the same-fiscal-year mount invariant holds. + * + * @param array $mountChoices + * @param array $cloneMap + */ + private function cloneSubPlan(BudgetPlan $source, BudgetPlan $target, array $mountChoices, array &$cloneMap): BudgetPlan + { + if (isset($cloneMap[$source->id])) { + return $cloneMap[$source->id]; + } + + $clone = BudgetPlan::create([ + 'state' => Draft::class, + 'organization' => BudgetPlan::resolveOrganization($source->organization, $target->fiscal_year_id), + 'fiscal_year_id' => $target->fiscal_year_id, + ]); + $cloneMap[$source->id] = $clone; + + $this->cloneForest($source, $clone, $mountChoices, $cloneMap); + + return $clone; + } +} diff --git a/app/Support/Budget/BudgetPlanConverter.php b/app/Support/Budget/BudgetPlanConverter.php new file mode 100644 index 00000000..8f32bf3b --- /dev/null +++ b/app/Support/Budget/BudgetPlanConverter.php @@ -0,0 +1,315 @@ +get() + : LegacyBudgetPlan::all(); + + if ($legacyPlans->isEmpty()) { + $this->log('No legacy budget plans found to convert.'); + + return; + } + + $this->log("Found {$legacyPlans->count()} legacy budget plan(s) to convert."); + + // CRITICAL: find the global maximum item id across ALL plans before starting, so group + // ids (assigned sequentially from here) never collide with a preserved leaf id. + $this->nextGroupId = $this->findGlobalMaxItemId($legacyPlans) + 1; + + foreach ($legacyPlans as $legacyPlan) { + $this->convertPlan($legacyPlan, $organization); + } + } + + private function log(string $message): void + { + if ($this->log !== null) { + ($this->log)($message); + } + } + + /** Find the global maximum legacy item id across the plans (and existing budget items). */ + private function findGlobalMaxItemId(Collection $legacyPlans): int + { + $maxId = 0; + + foreach ($legacyPlans as $plan) { + $planMaxId = LegacyBudgetItem::whereHas('budgetGroup', function ($query) use ($plan): void { + $query->where('hhp_id', $plan->id); + })->max('id'); + + if ($planMaxId > $maxId) { + $maxId = $planMaxId; + } + } + + $existingMaxId = BudgetItem::max('id') ?? 0; + + return max($maxId, $existingMaxId); + } + + private function convertPlan(LegacyBudgetPlan $legacyPlan, string $organization): void + { + // already converted — skip (idempotent) + if (BudgetPlan::find($legacyPlan->id)) { + $this->log("Budget Plan ID {$legacyPlan->id} already exists in new structure. Skipping."); + + return; + } + + $fiscalYear = $this->getOrCreateFiscalYear($legacyPlan); + $state = $this->convertState($legacyPlan->state); + + $newPlan = new BudgetPlan([ + 'organization' => $organization, + 'fiscal_year_id' => $fiscalYear->id, + 'state' => $state, + 'resolution_date' => null, + 'approval_date' => null, + ]); + $newPlan->id = $legacyPlan->id; // preserve the plan id + $newPlan->save(); + + $this->log("Created Budget Plan (ID: {$newPlan->id}, State: {$state::$name})"); + + $legacyGroups = $legacyPlan->budgetGroups()->with('budgetItems')->get(); + + // PASS 1: create leaf items with preserved ids, temporarily parentless + $groupPosition = 0; + foreach ($legacyGroups as $legacyGroup) { + $this->convertItemsFirstPass($legacyGroup, $newPlan, $groupPosition); + $groupPosition++; + } + + // PASS 2: create the group items and re-parent the leaves under them + $this->createGroupItems($legacyGroups, $newPlan); + } + + private function convertItemsFirstPass(LegacyBudgetGroup $legacyGroup, BudgetPlan $newPlan, int $groupPosition): void + { + $budgetType = $legacyGroup->type == 0 ? BudgetType::INCOME : BudgetType::EXPENSE; + + $futureGroupId = $this->nextGroupId; + $this->nextGroupId++; + + $this->groupIdMapping[$legacyGroup->id] = [ + 'new_id' => $futureGroupId, + 'name' => $legacyGroup->gruppen_name, + 'type' => $budgetType, + 'position' => $groupPosition, + 'items' => [], + ]; + + $itemPosition = 0; + foreach ($legacyGroup->budgetItems as $legacyItem) { + if (BudgetItem::find($legacyItem->id)) { + $this->log("Budget Item ID {$legacyItem->id} already exists. Skipping."); + + continue; + } + + $newItem = new BudgetItem([ + 'budget_plan_id' => $newPlan->id, + 'short_name' => $legacyItem->titel_nr ?? $this->generateShortName($legacyItem->titel_name), + 'name' => $legacyItem->titel_name, + 'value' => $legacyItem->value, + 'budget_type' => $budgetType, + 'description' => null, + 'parent_id' => null, // set in pass 2 + 'is_group' => false, + 'position' => $itemPosition, + ]); + $newItem->id = $legacyItem->id; // preserve the leaf id + $newItem->save(); + + $this->groupIdMapping[$legacyGroup->id]['items'][] = $legacyItem->id; + $itemPosition++; + } + } + + private function createGroupItems(Collection $legacyGroups, BudgetPlan $newPlan): void + { + // Legacy groups have no number of their own; resolve each one up front. + $shortNames = $this->resolveGroupShortNames($legacyGroups); + + foreach ($legacyGroups as $legacyGroup) { + $groupInfo = $this->groupIdMapping[$legacyGroup->id]; + $groupId = $groupInfo['new_id']; + $totalValue = $legacyGroup->budgetItems()->sum('value'); + + $groupItem = new BudgetItem([ + 'budget_plan_id' => $newPlan->id, + 'short_name' => $shortNames[$legacyGroup->id], + 'name' => $groupInfo['name'], + 'value' => $totalValue, + 'budget_type' => $groupInfo['type'], + 'description' => null, + 'parent_id' => null, + 'is_group' => true, + 'position' => $groupInfo['position'], + ]); + $groupItem->id = $groupId; + $groupItem->save(); + + if (! empty($groupInfo['items'])) { + BudgetItem::whereIn('id', $groupInfo['items'])->update(['parent_id' => $groupId]); + } + } + } + + private function getOrCreateFiscalYear(LegacyBudgetPlan $legacyPlan): FiscalYear + { + $fiscalYear = FiscalYear::where('start_date', $legacyPlan->von) + ->where('end_date', $legacyPlan->bis) + ->first(); + + if (! $fiscalYear) { + $fiscalYear = new FiscalYear([ + 'start_date' => $legacyPlan->von, + 'end_date' => $legacyPlan->bis ?? $legacyPlan->von->copy()->addYear()->subDay(), + ]); + $fiscalYear->save(); + + $this->log("Created Fiscal Year: {$fiscalYear->start_date} to {$fiscalYear->end_date}"); + } + + return $fiscalYear; + } + + /** + * Convert a legacy state value to a new BudgetPlanState class. + * + * @return class-string + */ + public function convertState(?string $state): string + { + return match ($state) { + 'final', 'approved', '1' => Published::class, + default => Draft::class, + }; + } + + /** + * Resolve each legacy group's Titelnummer. + * + * Preferred: derive from the group's children (E.1.1 -> E.1). When a group has no numbered + * children, fall back to auto-counting the next free number per budget type (as the legacy + * system did), skipping any number already taken by a derived group so they never collide. + * + * @return array legacy group id => Titelnummer + */ + private function resolveGroupShortNames(Collection $legacyGroups): array + { + $resolved = []; + $usedByPrefix = []; + $needsFallback = []; + + foreach ($legacyGroups as $group) { + $derived = $this->deriveGroupShortName($group->budgetItems->pluck('titel_nr')->all()); + + if ($derived === null) { + $needsFallback[] = $group->id; + + continue; + } + + $resolved[$group->id] = $derived; + + if (preg_match('/^(\D+)\.(\d+)$/', $derived, $m)) { + $usedByPrefix[$m[1]][(int) $m[2]] = true; + } + } + + foreach ($needsFallback as $groupId) { + $prefix = $this->groupIdMapping[$groupId]['type']->numberPrefix(); + + $resolved[$groupId] = $this->nextFreeGroupNumber($prefix, $usedByPrefix[$prefix] ?? []); + $usedByPrefix[$prefix][(int) substr(strrchr($resolved[$groupId], '.'), 1)] = true; + } + + return $resolved; + } + + /** + * Derive a group's Titelnummer from the parent prefix of the shallowest numbered child + * (["E.1.1", "E.1.2"] -> "E.1"), or null when no child is numbered. + * + * @param array $childTitelNrs + */ + public function deriveGroupShortName(array $childTitelNrs): ?string + { + $numbered = array_values(array_filter( + $childTitelNrs, + static fn ($nr): bool => filled($nr) && str_contains((string) $nr, '.'), + )); + + if ($numbered === []) { + return null; + } + + usort($numbered, static fn ($a, $b): int => substr_count($a, '.') <=> substr_count($b, '.')); + + return substr($numbered[0], 0, (int) strrpos($numbered[0], '.')); + } + + /** + * The first "{prefix}.{n}" whose number is not already used (auto-counting fallback). + * + * @param array $used numbers already taken, keyed by the number + */ + public function nextFreeGroupNumber(string $prefix, array $used): string + { + $n = 1; + while (isset($used[$n])) { + $n++; + } + + return "{$prefix}.{$n}"; + } + + private function generateShortName(string $fullName): string + { + $words = explode(' ', $fullName); + $shortName = implode(' ', array_slice($words, 0, 3)); + + return substr($shortName, 0, 20); + } +} diff --git a/app/Support/Budget/BudgetPlanMeasures.php b/app/Support/Budget/BudgetPlanMeasures.php new file mode 100644 index 00000000..c27fba6f --- /dev/null +++ b/app/Support/Budget/BudgetPlanMeasures.php @@ -0,0 +1,284 @@ + booked per leaf item id (titel_id) */ + private array $booked; + + /** @var array committed per leaf item id (titel_id) */ + private array $committed; + + /** + * @param array $visited plan ids already entered while recursing through mounts + */ + public function __construct(private readonly BudgetPlan $plan, private readonly BudgetType $type, private array $visited = []) + { + $this->visited[] = $plan->id; + + $leafIds = $plan->budgetItems() + ->where('budget_type', $type) + ->where('is_group', false) + ->whereNull('referenced_plan_id') + ->pluck('id') + ->all(); + + $this->booked = $this->bookedMap($leafIds); + $this->committed = $this->committedMap($leafIds); + } + + /** + * Load this side's item tree and annotate every node with `planned`, `booked` and `committed` + * Money attributes (rolled up through groups and mounts). Returns the flattened, ordered tree. + */ + public function annotate(): Collection + { + $items = $this->plan->budgetItemsTree($this->type); + $childrenByParent = $items->groupBy('parent_id'); + + foreach ($items->whereNull('parent_id') as $root) { + $this->measure($root, $childrenByParent); + } + + return $items; + } + + /** + * Planned/booked/committed figures for a single item (rolled up, so it also works for + * groups/mounts). + * + * @return array{planned: Money, booked: Money, committed: Money} + */ + public function forItem(BudgetItem $item): array + { + $items = $this->plan->budgetItemsTree($this->type); + $childrenByParent = $items->groupBy('parent_id'); + + return $this->measure($item, $childrenByParent); + } + + /** + * Plan-level planned/booked/committed totals for this side (sum of the roots' effective + * figures). Used when a mount resolves to the referenced plan's total. + * + * @return array{planned: Money, booked: Money, committed: Money} + */ + public function totals(): array + { + $items = $this->plan->budgetItemsTree($this->type); + $childrenByParent = $items->groupBy('parent_id'); + + $planned = Money::EUR(0); + $booked = Money::EUR(0); + $committed = Money::EUR(0); + foreach ($items->whereNull('parent_id') as $root) { + $measure = $this->measure($root, $childrenByParent); + $planned = $planned->add($measure['planned']); + $booked = $booked->add($measure['booked']); + $committed = $committed->add($measure['committed']); + } + + return ['planned' => $planned, 'booked' => $booked, 'committed' => $committed]; + } + + /** + * Set planned/booked/committed on $item and return them: a mount resolves to the referenced + * plan's totals, a group sums its children, a leaf reads its own value and the precomputed + * per-titel maps. Planned mirrors BudgetItem::effectiveValue() so the numbers match the tree. + * + * @param Collection> $childrenByParent + * @return array{planned: Money, booked: Money, committed: Money} + */ + private function measure(BudgetItem $item, Collection $childrenByParent): array + { + if ($item->isMount()) { + $referenced = $item->referencedPlan; + if ($referenced === null) { + // dangling reference: effectiveValue() falls back to the stored value + return $this->assign($item, $item->value ?? Money::EUR(0), Money::EUR(0), Money::EUR(0)); + } + if (in_array($referenced->id, $this->visited, true)) { + return $this->assign($item, Money::EUR(0), Money::EUR(0), Money::EUR(0)); // cycle + } + $totals = new self($referenced, $this->type, $this->visited)->totals(); + + return $this->assign($item, $totals['planned'], $totals['booked'], $totals['committed']); + } + + if ($item->is_group) { + // a group's planned figure is the LIVE sum of its children (mirrors effectiveValue), + // so a mount nested anywhere inside still rolls up even though its total can't be stored + $planned = Money::EUR(0); + $booked = Money::EUR(0); + $committed = Money::EUR(0); + foreach ($childrenByParent->get($item->id, collect()) as $child) { + $measure = $this->measure($child, $childrenByParent); + $planned = $planned->add($measure['planned']); + $booked = $booked->add($measure['booked']); + $committed = $committed->add($measure['committed']); + } + + return $this->assign($item, $planned, $booked, $committed); + } + + return $this->assign( + $item, + $item->value ?? Money::EUR(0), + $this->booked[$item->id] ?? Money::EUR(0), + $this->committed[$item->id] ?? Money::EUR(0), + ); + } + + /** + * Attach the three computed figures to the item as (view-only, non-persisted) Money attributes + * and return them. These are dynamic attributes — not table columns — read as `$item->planned` + * / `->booked` / `->committed` in the plan view, item view and export. Returning them as well + * lets measure() roll a child's figures into its parent without re-reading the attributes. + * + * @return array{planned: Money, booked: Money, committed: Money} + */ + private function assign(BudgetItem $item, Money $planned, Money $booked, Money $committed): array + { + $item->planned = $planned; + $item->booked = $booked; + $item->committed = $committed; + + return ['planned' => $planned, 'booked' => $booked, 'committed' => $committed]; + } + + /** + * Σ booking.value per leaf, ignoring canceled bookings. + * + * @param array $leafIds + * @return array + */ + private function bookedMap(array $leafIds): array + { + if ($leafIds === []) { + return []; + } + + return DB::table('booking') + ->whereIn('titel_id', $leafIds) + ->where('canceled', 0) + ->groupBy('titel_id') + ->selectRaw('titel_id, SUM(value) as total') + ->get() + ->mapWithKeys(static fn ($row): array => [$row->titel_id => Money::parseByDecimal((string) $row->total, 'EUR')]) + ->all(); + } + + /** + * Committed money per leaf: open projects' postings plus terminated projects' receipt + * postings. Sign follows the side (income = einnahmen − ausgaben, expense = ausgaben − + * einnahmen) so both columns read as positive amounts. + * + * @param array $leafIds + * @return array + */ + private function committedMap(array $leafIds): array + { + if ($leafIds === []) { + return []; + } + + $committed = []; + foreach ([$this->openPostings($leafIds), $this->closedPostings($leafIds)] as $rows) { + foreach ($rows as $row) { + $einnahmen = Money::parseByDecimal((string) ($row->einnahmen ?? 0), 'EUR'); + $ausgaben = Money::parseByDecimal((string) ($row->ausgaben ?? 0), 'EUR'); + + $value = $this->type === BudgetType::EXPENSE + ? $ausgaben->subtract($einnahmen) + : $einnahmen->subtract($ausgaben); + + $committed[$row->titel_id] = isset($committed[$row->titel_id]) + ? $committed[$row->titel_id]->add($value) + : $value; + } + } + + return $committed; + } + + /** + * Postings of not-yet-terminated (approved/ongoing) projects, summed per titel. + * + * @param array $leafIds + */ + private function openPostings(array $leafIds): Collection + { + $openStates = [ + NeedFinanceApproval::$name, // ok-by-hv + ApprovedByOrg::$name, // ok-by-stura + ApprovedByFinance::$name, // done-hv + ApprovedByOther::$name, // done-other + ]; + + // DB::table (not the Eloquent model) keeps einnahmen/ausgaben as raw decimals rather than + // Money. titel_id/einnahmen/ausgaben only exist on projektposten here, so the raw SUM(...) + // can stay unqualified and sidestep the environment table prefix. + return DB::table('projektposten') + ->join('projekte', 'projekte.id', '=', 'projektposten.projekt_id') + ->whereIn('projekte.state', $openStates) + ->whereIn('projektposten.titel_id', $leafIds) + ->groupBy('projektposten.titel_id') + ->selectRaw('titel_id, SUM(einnahmen) as einnahmen, SUM(ausgaben) as ausgaben') + ->get(); + } + + /** + * Receipt postings of terminated projects (excluding revoked expenses), summed per titel. + * + * @param array $leafIds + */ + private function closedPostings(array $leafIds): Collection + { + // einnahmen/ausgaben exist on both beleg_posten and projektposten, so they must be qualified + // to beleg_posten — raw table refs need the runtime prefix. titel_id is unique to + // projektposten and stays unqualified. + $bp = DB::getTablePrefix().'beleg_posten'; + + return DB::table('beleg_posten') + ->join('belege', 'belege.id', '=', 'beleg_posten.beleg_id') + ->join('auslagen', 'auslagen.id', '=', 'belege.auslagen_id') + ->join('projekte', 'projekte.id', '=', 'auslagen.projekt_id') + ->join('projektposten', function ($join): void { + $join->on('projektposten.projekt_id', '=', 'projekte.id') + ->on('projektposten.id', '=', 'beleg_posten.projekt_posten_id'); + }) + ->where('projekte.state', Terminated::$name) + ->where('auslagen.state', 'NOT LIKE', 'revocation%') + ->whereIn('projektposten.titel_id', $leafIds) + ->groupBy('projektposten.titel_id') + ->selectRaw("titel_id, SUM({$bp}.einnahmen) as einnahmen, SUM({$bp}.ausgaben) as ausgaben") + ->get(); + } +} diff --git a/app/Support/Budget/TitleNumberer.php b/app/Support/Budget/TitleNumberer.php new file mode 100644 index 00000000..8c0fd9a9 --- /dev/null +++ b/app/Support/Budget/TitleNumberer.php @@ -0,0 +1,81 @@ +previousSibling($item); + + // continue whatever scheme the previous sibling uses + if ($previous instanceof BudgetItem && $this->hasNumber($previous->short_name)) { + return $this->incrementLastNumber($previous->short_name); + } + + // first child: hang off the parent's number + if ($item->parent !== null && filled($item->parent->short_name)) { + return $item->parent->short_name.self::SEPARATOR.'1'; + } + + // first root of its type: nothing to copy, fall back to the seed prefix + return $item->budget_type->numberPrefix().'1'; + } + + /** The sibling immediately before $item by position (null for the first one). */ + private function previousSibling(BudgetItem $item): ?BudgetItem + { + return $this->siblings($item) + ->where('position', '<', $item->position) + ->orderByDesc('position') + ->first(); + } + + private function siblings(BudgetItem $item): Builder + { + $query = BudgetItem::query()->whereKeyNot($item->getKey()); + + return $item->parent_id !== null + ? $query->where('parent_id', $item->parent_id) + : $query->where('budget_plan_id', $item->budget_plan_id) + ->whereNull('parent_id') + ->where('budget_type', $item->budget_type); + } + + private function hasNumber(?string $shortName): bool + { + return $shortName !== null && preg_match('/\d/', $shortName) === 1; + } + + /** Increment the last run of digits in the string, preserving everything around it. */ + private function incrementLastNumber(string $shortName): string + { + return preg_replace_callback( + '/(\d+)(?!.*\d)/', + static function (array $m): string { + $next = (string) ((int) $m[1] + 1); + + // keep zero-padding width (07 -> 08), but let it grow on overflow (99 -> 100) + return str_pad($next, strlen($m[1]), '0', STR_PAD_LEFT); + }, + $shortName, + 1, + ); + } +} diff --git a/composer.json b/composer.json index b44f71b9..00a81f8a 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "openadministration/stufis", "type": "project", "description": "Webinterface für das Management und Digitalisierung von Finanzanträgen und deren Buchung für Studierendenschaften nach Deutschem Recht", - "version": "4.4.1-beta", + "version": "4.5.0-beta", "license": "AGPL", "require": { "php": "^8.4", @@ -111,7 +111,7 @@ "pre-package-uninstall": [ "Illuminate\\Foundation\\ComposerScripts::prePackageUninstall" ], - "prepare-test": "@php artisan migrate:fresh --seed --env=testing", + "prepare-test": "@php artisan migrate:fresh --seed --drop-views --env=testing", "rector": "@php vendor/bin/rector process", "rector-dry": "@php vendor/bin/rector process --dry-run", "lint": "@php vendor/bin/phpstan", diff --git a/composer.lock b/composer.lock index 89f04e05..e9873aef 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "be8af9526599f10b052265e7886fb936", + "content-hash": "2afb4e0438d160358280f6a963015d34", "packages": [ { "name": "ameax/datev-xml", @@ -1479,16 +1479,16 @@ }, { "name": "giggsey/libphonenumber-for-php-lite", - "version": "9.0.32", + "version": "9.0.33", "source": { "type": "git", "url": "https://github.com/giggsey/libphonenumber-for-php-lite.git", - "reference": "8e3b2cfd8fb77b3922dad43f9a70d516c28570d2" + "reference": "38c09da75cebfd6f197b61e57b3a655db04163e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/giggsey/libphonenumber-for-php-lite/zipball/8e3b2cfd8fb77b3922dad43f9a70d516c28570d2", - "reference": "8e3b2cfd8fb77b3922dad43f9a70d516c28570d2", + "url": "https://api.github.com/repos/giggsey/libphonenumber-for-php-lite/zipball/38c09da75cebfd6f197b61e57b3a655db04163e6", + "reference": "38c09da75cebfd6f197b61e57b3a655db04163e6", "shasum": "" }, "require": { @@ -1553,7 +1553,7 @@ "issues": "https://github.com/giggsey/libphonenumber-for-php-lite/issues", "source": "https://github.com/giggsey/libphonenumber-for-php-lite" }, - "time": "2026-06-05T07:33:50+00:00" + "time": "2026-06-22T10:35:37+00:00" }, { "name": "globalcitizen/php-iban", @@ -1734,26 +1734,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.12.1", + "version": "7.12.3", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425" + "reference": "9aa17bcdd777ee31df9fc83c337ca4ca2340def3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d34627490fbc03bf5c5d7cfed81f2faa19519425", - "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/9aa17bcdd777ee31df9fc83c337ca4ca2340def3", + "reference": "9aa17bcdd777ee31df9fc83c337ca4ca2340def3", "shasum": "" }, "require": { "ext-json": "*", "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.12.1", + "guzzlehttp/psr7": "^2.12.3", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" @@ -1842,7 +1842,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.12.1" + "source": "https://github.com/guzzle/guzzle/tree/7.12.3" }, "funding": [ { @@ -1858,7 +1858,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T14:12:49+00:00" + "time": "2026-06-23T15:29:02+00:00" }, { "name": "guzzlehttp/promises", @@ -1946,16 +1946,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.12.1", + "version": "2.12.3", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7" + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", - "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", "shasum": "" }, "require": { @@ -1964,7 +1964,7 @@ "psr/http-message": "^1.1 || ^2.0", "ralouphie/getallheaders": "^3.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -2045,7 +2045,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.1" + "source": "https://github.com/guzzle/psr7/tree/2.12.3" }, "funding": [ { @@ -2061,25 +2061,25 @@ "type": "tidelift" } ], - "time": "2026-06-18T09:49:37+00:00" + "time": "2026-06-23T15:21:08+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.7", + "version": "v1.0.8", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "7fe811c23a9e3cd712b4389eaeb50b5456d8c529" + "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/7fe811c23a9e3cd712b4389eaeb50b5456d8c529", - "reference": "7fe811c23a9e3cd712b4389eaeb50b5456d8c529", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/9c19128923b05a5d7355e5d2318d7808b7e33bbd", + "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -2131,7 +2131,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.7" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.8" }, "funding": [ { @@ -2147,20 +2147,20 @@ "type": "tidelift" } ], - "time": "2026-06-12T21:33:43+00:00" + "time": "2026-06-23T13:02:23+00:00" }, { "name": "intervention/validation", - "version": "4.6.3", + "version": "4.6.4", "source": { "type": "git", "url": "https://github.com/Intervention/validation.git", - "reference": "ef4dac8b8ba2880bf91b184e98090dda0be51c67" + "reference": "ebb9e6e0fbdfb6e4f6dcd4ce8a135ce08e47fd9b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/validation/zipball/ef4dac8b8ba2880bf91b184e98090dda0be51c67", - "reference": "ef4dac8b8ba2880bf91b184e98090dda0be51c67", + "url": "https://api.github.com/repos/Intervention/validation/zipball/ebb9e6e0fbdfb6e4f6dcd4ce8a135ce08e47fd9b", + "reference": "ebb9e6e0fbdfb6e4f6dcd4ce8a135ce08e47fd9b", "shasum": "" }, "require": { @@ -2219,7 +2219,7 @@ ], "support": { "issues": "https://github.com/Intervention/validation/issues", - "source": "https://github.com/Intervention/validation/tree/4.6.3" + "source": "https://github.com/Intervention/validation/tree/4.6.4" }, "funding": [ { @@ -2235,7 +2235,7 @@ "type": "ko_fi" } ], - "time": "2026-05-31T09:01:40+00:00" + "time": "2026-06-25T12:17:28+00:00" }, { "name": "jschaedl/iban-validation", @@ -2562,16 +2562,16 @@ }, { "name": "laravel/prompts", - "version": "v0.3.18", + "version": "v0.3.21", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" + "reference": "7753c65c281c2550c7c183f14e18062073b7d821" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", - "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", + "url": "https://api.github.com/repos/laravel/prompts/zipball/7753c65c281c2550c7c183f14e18062073b7d821", + "reference": "7753c65c281c2550c7c183f14e18062073b7d821", "shasum": "" }, "require": { @@ -2615,9 +2615,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.18" + "source": "https://github.com/laravel/prompts/tree/v0.3.21" }, - "time": "2026-05-19T00:47:18+00:00" + "time": "2026-06-26T00:11:25+00:00" }, { "name": "laravel/serializable-closure", @@ -2943,16 +2943,16 @@ }, { "name": "league/flysystem", - "version": "3.34.0", + "version": "3.35.1", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" + "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", - "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f23af6c5aafd958a7593029a271d77baf5ed793c", + "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c", "shasum": "" }, "require": { @@ -3020,9 +3020,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.1" }, - "time": "2026-05-14T10:28:08+00:00" + "time": "2026-06-25T06:52:23+00:00" }, { "name": "league/flysystem-local", @@ -3528,16 +3528,16 @@ }, { "name": "livewire/livewire", - "version": "v4.3.1", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "6a9dd03f45a4b200abfd0ff644745b23fa7baaaa" + "reference": "8021f2561865c4c297a3bfca37212a99034377e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/6a9dd03f45a4b200abfd0ff644745b23fa7baaaa", - "reference": "6a9dd03f45a4b200abfd0ff644745b23fa7baaaa", + "url": "https://api.github.com/repos/livewire/livewire/zipball/8021f2561865c4c297a3bfca37212a99034377e7", + "reference": "8021f2561865c4c297a3bfca37212a99034377e7", "shasum": "" }, "require": { @@ -3592,7 +3592,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v4.3.1" + "source": "https://github.com/livewire/livewire/tree/v4.3.3" }, "funding": [ { @@ -3600,7 +3600,7 @@ "type": "github" } ], - "time": "2026-06-02T08:58:52+00:00" + "time": "2026-06-27T03:16:11+00:00" }, { "name": "maatwebsite/excel", @@ -4564,16 +4564,16 @@ }, { "name": "owenvoke/blade-fontawesome", - "version": "v3.2.2", + "version": "v3.3.0", "source": { "type": "git", "url": "https://github.com/owenvoke/blade-fontawesome.git", - "reference": "5a199630dd70c5133d58c158a974e12bcd8b3a71" + "reference": "b155b249742a49931c60a05cf43d33bc3f7c098e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/owenvoke/blade-fontawesome/zipball/5a199630dd70c5133d58c158a974e12bcd8b3a71", - "reference": "5a199630dd70c5133d58c158a974e12bcd8b3a71", + "url": "https://api.github.com/repos/owenvoke/blade-fontawesome/zipball/b155b249742a49931c60a05cf43d33bc3f7c098e", + "reference": "b155b249742a49931c60a05cf43d33bc3f7c098e", "shasum": "" }, "require": { @@ -4608,7 +4608,7 @@ "description": "A package to easily make use of Font Awesome in your Laravel Blade views", "support": { "issues": "https://github.com/owenvoke/blade-fontawesome/issues", - "source": "https://github.com/owenvoke/blade-fontawesome/tree/v3.2.2" + "source": "https://github.com/owenvoke/blade-fontawesome/tree/v3.3.0" }, "funding": [ { @@ -4620,7 +4620,7 @@ "type": "github" } ], - "time": "2026-03-20T08:37:22+00:00" + "time": "2026-06-26T07:46:10+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -6557,16 +6557,16 @@ }, { "name": "spatie/temporary-directory", - "version": "2.3.1", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/spatie/temporary-directory.git", - "reference": "662e481d6ec07ef29fd05010433428851a42cd07" + "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/662e481d6ec07ef29fd05010433428851a42cd07", - "reference": "662e481d6ec07ef29fd05010433428851a42cd07", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/32cbb9645b28839cf4f476708e99a2c70e6802c9", + "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9", "shasum": "" }, "require": { @@ -6602,7 +6602,7 @@ ], "support": { "issues": "https://github.com/spatie/temporary-directory/issues", - "source": "https://github.com/spatie/temporary-directory/tree/2.3.1" + "source": "https://github.com/spatie/temporary-directory/tree/2.4.0" }, "funding": [ { @@ -6614,7 +6614,7 @@ "type": "github" } ], - "time": "2026-01-12T07:42:22+00:00" + "time": "2026-06-22T07:55:44+00:00" }, { "name": "staudenmeir/eloquent-has-many-deep-contracts", @@ -6867,16 +6867,16 @@ }, { "name": "symfony/console", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", "shasum": "" }, "require": { @@ -6941,7 +6941,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.13" + "source": "https://github.com/symfony/console/tree/v7.4.14" }, "funding": [ { @@ -6961,7 +6961,7 @@ "type": "tidelift" } ], - "time": "2026-05-24T08:56:14+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/css-selector", @@ -7034,16 +7034,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -7081,7 +7081,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -7101,20 +7101,20 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + "reference": "4e1a093b481f323e6e326451f9760c3868430673" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/4e1a093b481f323e6e326451f9760c3868430673", + "reference": "4e1a093b481f323e6e326451f9760c3868430673", "shasum": "" }, "require": { @@ -7163,7 +7163,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.8" + "source": "https://github.com/symfony/error-handler/tree/v7.4.14" }, "funding": [ { @@ -7183,20 +7183,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-05T06:22:21+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.1.0", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", - "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", "shasum": "" }, "require": { @@ -7249,7 +7249,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" }, "funding": [ { @@ -7269,20 +7269,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-09T12:28:30+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -7329,7 +7329,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -7349,20 +7349,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/finder", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "e0be088d22278583a82da281886e8c3592fbf149" + "reference": "13b38720174286f55d1761152b575a8d1436fc25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", - "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", "shasum": "" }, "require": { @@ -7397,7 +7397,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.8" + "source": "https://github.com/symfony/finder/tree/v7.4.14" }, "funding": [ { @@ -7417,20 +7417,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "bc354f47c62301e990b7874fa662326368508e2c" + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", - "reference": "bc354f47c62301e990b7874fa662326368508e2c", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/06db5ae1552177bf8572f8908839f12e3c06aed3", + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3", "shasum": "" }, "require": { @@ -7479,7 +7479,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.13" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.14" }, "funding": [ { @@ -7499,20 +7499,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T11:20:33+00:00" + "time": "2026-06-11T07:31:44+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "9df847980c436451f4f51d1284491bb4356dd989" + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", - "reference": "9df847980c436451f4f51d1284491bb4356dd989", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e99af79b1e776646eda0e1c23b7b45c184ff99be", + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be", "shasum": "" }, "require": { @@ -7598,7 +7598,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.13" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.14" }, "funding": [ { @@ -7618,20 +7618,20 @@ "type": "tidelift" } ], - "time": "2026-05-27T08:31:43+00:00" + "time": "2026-06-27T09:14:35+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.12", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "5cefb712a25f320579615ba9e1942abaeade7dff" + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff", - "reference": "5cefb712a25f320579615ba9e1942abaeade7dff", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f88ce03ae73e3edb5c176ce1f337709996e88495", + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495", "shasum": "" }, "require": { @@ -7682,7 +7682,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.12" + "source": "https://github.com/symfony/mailer/tree/v7.4.14" }, "funding": [ { @@ -7702,7 +7702,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-06-13T08:51:35+00:00" }, { "name": "symfony/mime", @@ -8845,16 +8845,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -8908,7 +8908,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -8928,7 +8928,7 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/string", @@ -9022,16 +9022,16 @@ }, { "name": "symfony/translation", - "version": "v8.1.0", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" + "reference": "342b4218630dc2cf284cedcb2080c80b13404014" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", - "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "url": "https://api.github.com/repos/symfony/translation/zipball/342b4218630dc2cf284cedcb2080c80b13404014", + "reference": "342b4218630dc2cf284cedcb2080c80b13404014", "shasum": "" }, "require": { @@ -9091,7 +9091,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.1.0" + "source": "https://github.com/symfony/translation/tree/v8.1.1" }, "funding": [ { @@ -9111,20 +9111,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-06T11:11:44+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -9173,7 +9173,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -9193,7 +9193,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/uid", @@ -9275,16 +9275,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", "shasum": "" }, "require": { @@ -9338,7 +9338,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" }, "funding": [ { @@ -9358,7 +9358,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:44:50+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -10800,16 +10800,16 @@ }, { "name": "laravel/sail", - "version": "v1.62.0", + "version": "v1.63.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e" + "reference": "51bbce3f803c1d386cabbb44e618c955a12ff5fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", - "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", + "url": "https://api.github.com/repos/laravel/sail/zipball/51bbce3f803c1d386cabbb44e618c955a12ff5fc", + "reference": "51bbce3f803c1d386cabbb44e618c955a12ff5fc", "shasum": "" }, "require": { @@ -10859,7 +10859,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-05-27T04:02:01+00:00" + "time": "2026-06-18T08:54:14+00:00" }, { "name": "laravel/tinker", @@ -11363,16 +11363,16 @@ }, { "name": "pestphp/pest", - "version": "v4.7.3", + "version": "v4.7.4", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "87882a8561bf3ddf230b9a6b764f367f687d5b2f" + "reference": "ee2e97e932d158faceeaa63a4dc17324b15152cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/87882a8561bf3ddf230b9a6b764f367f687d5b2f", - "reference": "87882a8561bf3ddf230b9a6b764f367f687d5b2f", + "url": "https://api.github.com/repos/pestphp/pest/zipball/ee2e97e932d158faceeaa63a4dc17324b15152cb", + "reference": "ee2e97e932d158faceeaa63a4dc17324b15152cb", "shasum": "" }, "require": { @@ -11385,12 +11385,12 @@ "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.29", + "phpunit/phpunit": "^12.5.30", "symfony/process": "^7.4.13|^8.1.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.29", + "phpunit/phpunit": ">12.5.30", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, @@ -11466,7 +11466,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.7.3" + "source": "https://github.com/pestphp/pest/tree/v4.7.4" }, "funding": [ { @@ -11478,7 +11478,7 @@ "type": "github" } ], - "time": "2026-06-12T05:57:27+00:00" + "time": "2026-06-25T19:09:05+00:00" }, { "name": "pestphp/pest-plugin", @@ -12812,16 +12812,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.29", + "version": "12.5.30", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a" + "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9aa66a47db3ea70f1a468e66dd969f67e594945a", - "reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/900400a5b616d6fb306f9549f6da33ba615d3fbb", + "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb", "shasum": "" }, "require": { @@ -12890,7 +12890,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.29" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.30" }, "funding": [ { @@ -12898,7 +12898,7 @@ "type": "other" } ], - "time": "2026-06-04T06:14:42+00:00" + "time": "2026-06-15T13:12:30+00:00" }, { "name": "psy/psysh", @@ -12981,16 +12981,16 @@ }, { "name": "rector/rector", - "version": "2.4.6", + "version": "2.5.2", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "9b9e5c76618e4d359f65b54ca2eabcad3d1761ee" + "reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/9b9e5c76618e4d359f65b54ca2eabcad3d1761ee", - "reference": "9b9e5c76618e4d359f65b54ca2eabcad3d1761ee", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/49ff6339174bdbdf50b0b35ecbcff14a05ac9e24", + "reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24", "shasum": "" }, "require": { @@ -13029,7 +13029,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.4.6" + "source": "https://github.com/rectorphp/rector/tree/2.5.2" }, "funding": [ { @@ -13037,7 +13037,7 @@ "type": "github" } ], - "time": "2026-06-17T11:56:28+00:00" + "time": "2026-06-22T11:39:33+00:00" }, { "name": "roave/security-advisories", @@ -13045,12 +13045,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "d59bd7f09761435c5818e64cab019ca56e0137cd" + "reference": "36ba91e82e1b493faef2c13277d6bd2669ea9f31" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/d59bd7f09761435c5818e64cab019ca56e0137cd", - "reference": "d59bd7f09761435c5818e64cab019ca56e0137cd", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/36ba91e82e1b493faef2c13277d6bd2669ea9f31", + "reference": "36ba91e82e1b493faef2c13277d6bd2669ea9f31", "shasum": "" }, "conflict": { @@ -13067,6 +13067,7 @@ "aimeos/aimeos-core": ">=2022.04.1,<2022.10.17|>=2023.04.1,<2023.10.17|>=2024.04.1,<2024.04.7", "aimeos/aimeos-laravel": "==2021.10", "aimeos/aimeos-typo3": "<19.10.12|>=20,<20.10.5", + "aimeos/pagible": "<0.10.4", "airesvsg/acf-to-rest-api": "<=3.1", "akaunting/akaunting": "<2.1.13", "akeneo/pim-community-dev": "<5.0.119|>=6,<6.0.53", @@ -13152,13 +13153,13 @@ "cachethq/cachet": "<2.5.1", "cadmium-org/cadmium-cms": "<=0.4.9", "cakephp/authentication": "<3.3.6|>=4,<4.1.1", - "cakephp/cakephp": "<3.10.3|>=4,<4.0.10|>=4.1,<4.1.4|>=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10|>=5.2.10,<5.2.12|==5.3", + "cakephp/cakephp": "<4.5.11|>=4.6,<4.6.4|>=5,<5.1.7|>=5.2,<5.2.13|>=5.3,<5.3.6", "cakephp/database": ">=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10", "cardgate/magento2": "<2.0.33", "cardgate/woocommerce": "<=3.1.15", - "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4", + "cart2quote/module-quotation": ">=4.1.6,<4.4.6|>=5,<5.4.4", "cart2quote/module-quotation-encoded": ">=4.1.6,<=4.4.5|>=5,<5.4.4", - "cartalyst/sentry": "<=2.1.6", + "cartalyst/sentry": "<2.1.7", "catfan/medoo": "<1.7.5", "causal/oidc": "<4", "cecil/cecil": "<7.47.1", @@ -13184,7 +13185,7 @@ "commerceteam/commerce": ">=0.9.6,<0.9.9", "components/jquery": ">=1.0.3,<3.5", "composer/composer": "<2.2.28|>=2.3,<2.9.8", - "concrete5/concrete5": "<9.4.8", + "concrete5/concrete5": "<9.5.1", "concrete5/core": "<8.5.8|>=9,<9.1", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4", @@ -13196,12 +13197,13 @@ "coreshop/core-shop": "<4.1.9|==5", "corveda/phpsandbox": "<1.3.5", "cosenary/instagram": "<=2.3", + "cotonti/cotonti": "<=1", "couleurcitron/tarteaucitron-wp": "<0.3", "cpsit/typo3-mailqueue": "<0.4.5|>=0.5,<0.5.2", "craftcms/aws-s3": ">=2.0.2,<=2.2.4", "craftcms/azure-blob": ">=2.0.0.0-beta1,<=2.1", - "craftcms/cms": "<4.17.12|>=5,<5.9.18", - "craftcms/commerce": ">=4,<4.11|>=5,<5.6", + "craftcms/cms": "<4.18|>=5,<5.10", + "craftcms/commerce": ">=4,<=4.11.1|>=5,<=5.6.4", "craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1", "craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21", "craftcms/google-cloud": ">=2.0.0.0-beta1,<=2.2", @@ -13328,10 +13330,10 @@ "feehi/feehicms": "<=2.1.1", "fenom/fenom": "<=2.12.1", "filament/actions": ">=3.2,<3.2.123|>=4,<=4.11.3|>=5,<=5.6.3", - "filament/filament": ">=4,<4.3.1", + "filament/filament": ">=3,<=3.3.51|>=4,<4.11.5|>=5,<5.6.5", "filament/forms": ">=3,<=3.3.52", - "filament/infolists": ">=3,<3.2.115", - "filament/tables": ">=3,<=3.3.50|>=4,<4.8.5|>=5,<5.3.5", + "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", + "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", "filegator/filegator": "<7.8", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", @@ -13397,10 +13399,10 @@ "gregwar/rst": "<1.0.3", "grumpydictator/firefly-iii": "<=6.6.2", "gugoan/economizzer": "<=0.9.0.0-beta1", - "guzzlehttp/guzzle": "<6.5.8|>=7,<7.4.5", + "guzzlehttp/guzzle": "<7.12.1", "guzzlehttp/guzzle-services": "<1.5.4", "guzzlehttp/oauth-subscriber": "<0.8.1", - "guzzlehttp/psr7": "<2.10.2", + "guzzlehttp/psr7": "<2.12.1", "haffner/jh_captcha": "<=2.1.3|>=3,<=3.0.2", "handcraftedinthealps/goodby-csv": "<1.4.3", "harvesthq/chosen": "<1.8.7", @@ -13455,6 +13457,7 @@ "jasig/phpcas": "<1.3.3", "jbartels/wec-map": "<3.0.3", "jcbrand/converse.js": "<3.3.3", + "jleehr/canto-saas-api": "<=2", "joedolson/my-calendar": "<3.7.7", "joelbutcher/socialstream": "<5.6|>=6,<6.2", "johnbillion/query-monitor": "<3.20.4", @@ -13659,6 +13662,7 @@ "paragonie/random_compat": "<2", "paragonie/sodium_compat": "<1.24|>=2,<2.5", "passbolt/passbolt_api": "<4.6.2", + "paymenter/paymenter": "<1.5", "paypal/adaptivepayments-sdk-php": "<=3.9.2", "paypal/invoice-sdk-php": "<=3.9", "paypal/merchant-sdk-php": "<3.12", @@ -13676,13 +13680,15 @@ "phenx/php-svg-lib": "<0.5.2", "php-censor/php-censor": "<2.0.13|>=2.1,<2.1.5", "php-mod/curl": "<2.3.2", + "php-standard-library/h2": ">=6.1,<6.1.2|>=6.2,<6.2.1", + "php-standard-library/php-standard-library": ">=6.1,<6.1.2|>=6.2,<6.2.1", "phpbb/phpbb": "<3.3.16|==4.0.0.0-alpha1", "phpems/phpems": ">=6,<=6.1.3", "phpfastcache/phpfastcache": "<6.1.5|>=7,<7.1.2|>=8,<8.0.7", "phpmailer/phpmailer": "<6.5", "phpmussel/phpmussel": ">=1,<1.6", "phpmyadmin/phpmyadmin": "<5.2.2", - "phpmyfaq/phpmyfaq": "<4.1.3", + "phpmyfaq/phpmyfaq": "<4.1.4", "phpoffice/common": "<0.2.9", "phpoffice/math": "<=0.2", "phpoffice/phpexcel": "<=1.8.2", @@ -13704,7 +13710,7 @@ "pimcore/demo": "<10.3", "pimcore/ecommerce-framework-bundle": "<1.0.10", "pimcore/perspective-editor": "<1.5.1", - "pimcore/pimcore": "<=12.3.6", + "pimcore/pimcore": "<=12.3.8", "pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1", "piwik/piwik": "<1.11", "pixelfed/pixelfed": "<0.12.5", @@ -13712,6 +13718,7 @@ "pocketmine/bedrock-protocol": "<8.0.2", "pocketmine/pocketmine-mp": "<5.42.1", "pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1", + "pontedilana/php-weasyprint": "<=2.5.1", "poweradmin/poweradmin": "<4.2.4|>=4.3,<4.3.3", "pressbooks/pressbooks": "<5.18", "prestashop/autoupgrade": ">=4,<4.10.1", @@ -13728,8 +13735,8 @@ "prestashop/ps_linklist": "<3.1", "privatebin/privatebin": "<1.4|>=1.5,<1.7.4|>=1.7.7,<2.0.3", "processwire/processwire": "<=3.0.255", - "propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7", - "propel/propel1": ">=1,<=1.7.1", + "propel/propel": ">=2.0.0.0-alpha1,<2.0.0.0-alpha8", + "propel/propel1": ">=1,<1.7.2", "psy/psysh": "<=0.11.22|>=0.12,<=0.12.18", "pterodactyl/panel": "<1.12.3", "ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2", @@ -13790,10 +13797,10 @@ "silverstripe-australia/advancedreports": ">=1,<=2", "silverstripe/admin": "<1.13.19|>=2,<2.1.8", "silverstripe/assets": "<2.4.5|>=3,<3.1.3", - "silverstripe/cms": "<4.11.3", + "silverstripe/cms": "<6.2.1", "silverstripe/comments": ">=1.3,<3.1.1", - "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3", - "silverstripe/framework": "<5.3.23", + "silverstripe/forum": "<0.6.2|>=0.7,<0.7.4", + "silverstripe/framework": "<6.2.2", "silverstripe/graphql": ">=2,<2.0.5|>=3,<3.8.2|>=4,<4.3.7|>=5,<5.1.3", "silverstripe/hybridsessions": ">=1,<2.4.1|>=2.5,<2.5.1", "silverstripe/recipe-cms": ">=4.5,<4.5.3", @@ -13803,7 +13810,8 @@ "silverstripe/silverstripe-omnipay": "<2.5.2|>=3,<3.0.2|>=3.1,<3.1.4|>=3.2,<3.2.1", "silverstripe/subsites": ">=2,<2.6.1", "silverstripe/taxonomy": ">=1.3,<1.3.1|>=2,<2.0.1", - "silverstripe/userforms": "<3|>=5,<5.4.2", + "silverstripe/userforms": "<6.4.9|>=7,<7.0.7|>=7.1,<7.1.1", + "silverstripe/versioned": "<3.2.1", "silverstripe/versioned-admin": ">=1,<1.11.1", "simogeo/filemanager": "<=2.5", "simple-updates/phpwhois": "<=1", @@ -13822,12 +13830,13 @@ "sjbr/sr-freecap": "<2.4.6|>=2.5,<2.5.3", "sjbr/static-info-tables": "<2.3.1", "slim/psr7": "<1.4.1|>=1.5,<1.5.1|>=1.6,<1.6.1", - "slim/slim": "<2.6", + "slim/slim": "<2.6|>=4.4,<=4.15.1", "slub/slub-events": "<3.0.3", "smarty/smarty": "<4.5.3|>=5,<5.1.1", - "snipe/snipe-it": "<8.4.1", + "snipe/snipe-it": "<=8.6.1", "socalnick/scn-social-auth": "<1.15.2", "socialiteproviders/steam": "<1.1", + "solidinvoice/solidinvoice": "<=2.3.15", "solspace/craft-freeform": "<4.1.29|>=5,<=5.14.6", "soosyze/soosyze": "<=2", "spatie/browsershot": "<5.0.5", @@ -13845,7 +13854,7 @@ "starcitizentools/short-description": ">=4,<4.0.1", "starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1", "starcitizenwiki/embedvideo": "<=4", - "statamic/cms": "<5.73.22|>=6,<6.18.1", + "statamic/cms": "<5.74|>=6,<6.20.3", "stormpath/sdk": "<9.9.99", "studio-42/elfinder": "<=2.1.67", "studiomitte/friendlycaptcha": "<0.1.4", @@ -13865,6 +13874,7 @@ "sylius/paypal-plugin": "<1.6.2|>=1.7,<1.7.2|>=2,<2.0.2", "sylius/resource-bundle": ">=1,<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", "sylius/sylius": "<1.9.12|>=1.10,<1.10.16|>=1.11,<1.11.17|>=1.12,<=1.12.22|>=1.13,<=1.13.14|>=1.14,<=1.14.17|>=2,<=2.0.15|>=2.1,<=2.1.11|>=2.2,<=2.2.2", + "symbiote/silverstripe-advancedworkflow": "<6.4.5|>=7,<7.1.3|>=7.2,<7.2.1", "symbiote/silverstripe-multivaluefield": ">=3,<3.1", "symbiote/silverstripe-queuedjobs": ">=3,<3.0.2|>=3.1,<3.1.4|>=4,<4.0.7|>=4.1,<4.1.2|>=4.2,<4.2.4|>=4.3,<4.3.3|>=4.4,<4.4.3|>=4.5,<4.5.1|>=4.6,<4.6.4", "symbiote/silverstripe-seed": "<6.0.3", @@ -13910,7 +13920,9 @@ "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8|>=6.4.24,<6.4.40", "symfony/twilio-notifier": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/ux-autocomplete": "<2.36|>=3,<3.1", + "symfony/ux-icons": ">=2.17,<2.36.1|>=3,<3.2", "symfony/ux-live-component": "<2.36|>=3,<3.1", + "symfony/ux-toolkit": ">=2.32,<2.36.1|>=3,<3.2", "symfony/ux-twig-component": "<2.25.1", "symfony/validator": "<5.4.43|>=6,<6.4.11|>=7,<7.1.4", "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8", @@ -13930,7 +13942,7 @@ "thelia/thelia": ">=2.1,<2.1.3", "theonedemon/phpwhois": "<=4.2.5", "thinkcmf/thinkcmf": "<6.0.8", - "thorsten/phpmyfaq": "<4.1.3", + "thorsten/phpmyfaq": "<4.1.4", "tikiwiki/tiki-manager": "<=17.1", "timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1", "tinymce/tinymce": "<7.9.3|>=8,<8.5.1", @@ -14010,7 +14022,7 @@ "wapplersystems/a21glossary": "<=0.4.10", "web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9|>=5.2,<5.2.4|>=5.3,<5.3.1", "web-auth/webauthn-lib": ">=4.5,<4.9|>=5.2,<5.2.4", - "web-auth/webauthn-symfony-bundle": ">=5.2,<5.2.4", + "web-auth/webauthn-symfony-bundle": "<5.3.4", "web-feet/coastercms": "==5.5", "web-token/jwt-experimental": "<=4.1.6", "web-token/jwt-framework": "<=4.2.99", @@ -14140,7 +14152,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T21:44:25+00:00" + "time": "2026-06-26T23:29:05+00:00" }, { "name": "sebastian/cli-parser", @@ -15105,16 +15117,16 @@ }, { "name": "symfony/yaml", - "version": "v8.1.0", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0" + "reference": "8e4cdd4311683516be06944f4b85244063cdb886" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0", - "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "url": "https://api.github.com/repos/symfony/yaml/zipball/8e4cdd4311683516be06944f4b85244063cdb886", + "reference": "8e4cdd4311683516be06944f4b85244063cdb886", "shasum": "" }, "require": { @@ -15157,7 +15169,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.1.0" + "source": "https://github.com/symfony/yaml/tree/v8.1.1" }, "funding": [ { @@ -15177,7 +15189,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-09T11:06:24+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", diff --git a/database/factories/BudgetPlanFactory.php b/database/factories/BudgetPlanFactory.php index 671b81e0..5e52da05 100644 --- a/database/factories/BudgetPlanFactory.php +++ b/database/factories/BudgetPlanFactory.php @@ -3,7 +3,7 @@ namespace Database\Factories; use App\Models\BudgetItem; -use App\Models\Enums\BudgetPlanState; +use App\States\BudgetPlan\Published; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\Factory; @@ -14,7 +14,7 @@ public function definition(): array $year = Carbon::create(fake()->unique()->year()); return [ - 'state' => BudgetPlanState::FINAL, + 'state' => Published::class, 'start_date' => $year->dayOfYear(1)->format('Y-m-d'), 'end_date' => $year->dayOfYear(now()->daysInYear)->format('Y-m-d'), 'organisation' => 'Students Council', diff --git a/database/factories/Legacy/BankTransactionFactory.php b/database/factories/Legacy/BankTransactionFactory.php index ce63c01c..cd49a96e 100644 --- a/database/factories/Legacy/BankTransactionFactory.php +++ b/database/factories/Legacy/BankTransactionFactory.php @@ -28,8 +28,10 @@ public function definition(): array 'customer_ref' => fake()->word(), 'konto_id' => BankAccount::factory(), - 'value' => (fake()->boolean() ? '' : '-').fake()->randomFloat(2), - 'saldo' => fake()->randomFloat(2), + // value/saldo are decimal(10,2): keep magnitudes bounded so a random draw can never + // overflow the column (unbounded randomFloat() occasionally rolls into the 100s of millions) + 'value' => (fake()->boolean() ? '' : '-').fake()->randomFloat(2, 0, 100000), + 'saldo' => fake()->randomFloat(2, 0, 1000000), ]; } diff --git a/database/migrations/2026_04_25_100822_migrate_settings_to_db.php b/database/migrations/2026_04_25_100822_migrate_settings_to_db.php index 6b1db154..415ba52b 100644 --- a/database/migrations/2026_04_25_100822_migrate_settings_to_db.php +++ b/database/migrations/2026_04_25_100822_migrate_settings_to_db.php @@ -26,7 +26,7 @@ public function up(): void $table->timestamps(); }); - Artisan::call('settings:import-from-legacy-config'); + Artisan::call('legacy:import-config'); } public function down(): void diff --git a/database/migrations/dev/2024_04_02_125041_hhp_upgrade.php b/database/migrations/2026_07_01_000000_hhp_upgrade.php similarity index 83% rename from database/migrations/dev/2024_04_02_125041_hhp_upgrade.php rename to database/migrations/2026_07_01_000000_hhp_upgrade.php index 442033a4..d2604a91 100644 --- a/database/migrations/dev/2024_04_02_125041_hhp_upgrade.php +++ b/database/migrations/2026_07_01_000000_hhp_upgrade.php @@ -37,12 +37,17 @@ public function up(): void $table->decimal('value', 10, 2)->default(0); $table->integer('budget_type'); $table->boolean('is_group'); - $table->text('description'); + // optional metadata; the editor never sets it on create, so it must be nullable + $table->text('description')->nullable(); $table->integer('position')->nullable(); $table->unsignedBigInteger('parent_id')->nullable(); + // set => this item is a "mount": it stands in for the whole income/expense + // (per its budget_type) of the referenced plan; its value is derived, not stored + $table->unsignedBigInteger('referenced_plan_id')->nullable(); $table->foreign('budget_plan_id')->references('id')->on('budget_plan'); $table->foreign('parent_id')->references('id')->on('budget_item'); + $table->foreign('referenced_plan_id')->references('id')->on('budget_plan'); // $table->text('diff_description'); $table->timestamps(); }); diff --git a/database/migrations/2026_07_02_000000_swap_legacy_budget_tables_for_views.php b/database/migrations/2026_07_02_000000_swap_legacy_budget_tables_for_views.php new file mode 100644 index 00000000..483b964f --- /dev/null +++ b/database/migrations/2026_07_02_000000_swap_legacy_budget_tables_for_views.php @@ -0,0 +1,201 @@ +convert(); + + // 2. Drop every FK that references the legacy tables (constraint names vary by environment, + // so discover them instead of guessing). This also removes the legacy tables' internal + // FKs, freeing them to be dropped. + $this->dropForeignKeysReferencing(['haushaltstitel', 'haushaltsgruppen', 'haushaltsplan']); + + // 3. Drop the legacy tables (children first). + Schema::dropIfExists('haushaltstitel'); + Schema::dropIfExists('haushaltsgruppen'); + Schema::dropIfExists('haushaltsplan'); + + // 4. Re-point the accounting foreign keys at the new tables. titel_id == budget_item.id for + // the preserved leaves; tax_budget.hhp_id == budget_plan.id. The legacy columns are + // signed int(11); widen them to unsigned bigint to match the new keys before the FK. + Schema::table('booking', function (Blueprint $t): void { + $t->unsignedBigInteger('titel_id')->change(); + $t->foreign('titel_id')->references('id')->on('budget_item'); + }); + Schema::table('projektposten', function (Blueprint $t): void { + $t->unsignedBigInteger('titel_id')->nullable()->change(); + $t->foreign('titel_id')->references('id')->on('budget_item'); + }); + Schema::table('tax_budget', function (Blueprint $t): void { + $t->unsignedBigInteger('titel_id')->change(); + $t->renameColumn('titel_id', 'budget_id'); + + $t->unsignedBigInteger('hhp_id')->change(); + $t->renameColumn('hhp_id', 'plan_id'); + + $t->foreign('budget_id')->references('id')->on('budget_item'); + $t->foreign('plan_id')->references('id')->on('budget_plan'); + }); + + // 5. Recreate the legacy names as views projecting the new structure. Mounts are excluded + // (they have no legacy equivalent). A deeply-nested new plan flattens — fine for booking, + // which only ever targets leaves. + $this->createViews(); + } + + public function down(): void + { + $p = DB::getTablePrefix(); + DB::statement("DROP VIEW IF EXISTS `{$p}haushaltstitel`"); + DB::statement("DROP VIEW IF EXISTS `{$p}haushaltsgruppen`"); + DB::statement("DROP VIEW IF EXISTS `{$p}haushaltsplan`"); + + // Drop the re-pointed FKs before recreating the legacy tables. + $this->dropForeignKeysReferencing(['budget_item', 'budget_plan'], onlyOn: ['booking', 'projektposten', 'tax_budget']); + + Schema::create('haushaltsplan', function (Blueprint $table): void { + $table->integer('id', true); + $table->date('von')->nullable(); + $table->date('bis')->nullable(); + $table->string('state', 64)->nullable(); + }); + Schema::create('haushaltsgruppen', function (Blueprint $table): void { + $table->integer('id', true); + $table->integer('hhp_id')->index('hhp_id'); + $table->string('gruppen_name', 128)->nullable(); + $table->tinyInteger('type')->nullable(); + }); + Schema::create('haushaltstitel', function (Blueprint $table): void { + $table->integer('id', true); + $table->integer('hhpgruppen_id')->index('hhpgruppen_id'); + $table->string('titel_name', 128)->nullable(); + $table->string('titel_nr', 10)->nullable(); + $table->decimal('value', 12, 2)->nullable(); + }); + + // Narrow the columns back to the legacy signed int(11) so they match the recreated tables, + // then re-point the FKs without validating existing rows (the data is gone — this only + // restores the schema shape). + Schema::withoutForeignKeyConstraints(function (): void { + Schema::table('booking', function (Blueprint $t): void { + $t->integer('titel_id')->change(); + $t->foreign('titel_id')->references('id')->on('haushaltstitel'); + }); + Schema::table('projektposten', function (Blueprint $t): void { + $t->integer('titel_id')->nullable()->change(); + $t->foreign('titel_id')->references('id')->on('haushaltstitel'); + }); + Schema::table('tax_budget', function (Blueprint $t): void { + $t->integer('budget_id')->change(); + $t->renameColumn('budget_id', 'titel_id'); + $t->integer('plan_id')->change(); + $t->renameColumn('plan_id', 'hhp_id'); + $t->foreign('titel_id')->references('id')->on('haushaltstitel'); + $t->foreign('hhp_id')->references('id')->on('haushaltsplan'); + }); + Schema::table('haushaltsgruppen', fn (Blueprint $t) => $t->foreign('hhp_id')->references('id')->on('haushaltsplan')); + Schema::table('haushaltstitel', fn (Blueprint $t) => $t->foreign('hhpgruppen_id')->references('id')->on('haushaltsgruppen')); + }); + } + + /** + * Drop all foreign keys that reference any of $referencedTables. When $onlyOn is given, only + * drop FKs that live on those tables. Table names are matched with the connection prefix, and + * the query is raw so the prefix isn't (wrongly) applied to the information_schema table. + * + * @param list $referencedTables + * @param list|null $onlyOn + */ + private function dropForeignKeysReferencing(array $referencedTables, ?array $onlyOn = null): void + { + $prefix = DB::getTablePrefix(); + $referenced = array_map(fn (string $t): string => $prefix.$t, $referencedTables); + + $sql = 'SELECT DISTINCT TABLE_NAME, CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE ' + .'WHERE TABLE_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME IN ('.$this->placeholders($referenced).')'; + $bindings = $referenced; + + if ($onlyOn !== null) { + $on = array_map(fn (string $t): string => $prefix.$t, $onlyOn); + $sql .= ' AND TABLE_NAME IN ('.$this->placeholders($on).')'; + $bindings = array_merge($bindings, $on); + } + + foreach (DB::select($sql, $bindings) as $fk) { + DB::statement("ALTER TABLE `{$fk->TABLE_NAME}` DROP FOREIGN KEY `{$fk->CONSTRAINT_NAME}`"); + } + } + + /** + * @param list $values + */ + private function placeholders(array $values): string + { + return implode(',', array_fill(0, count($values), '?')); + } + + private function createViews(): void + { + $p = DB::getTablePrefix(); + + DB::statement( + // INNER JOIN: a plan without a fiscal year has no legacy von/bis representation, and + // the legacy code assumes those are set — so such (draft) plans are not exposed. + "CREATE VIEW `{$p}haushaltsplan` AS + SELECT bp.id AS id, + fy.start_date AS von, + fy.end_date AS bis, + CASE WHEN bp.state = 'draft' THEN 'draft' ELSE 'final' END AS state + FROM `{$p}budget_plan` bp + INNER JOIN `{$p}fiscal_year` fy ON fy.id = bp.fiscal_year_id" + ); + + // Legacy titles must always sit inside a group. A root-level budget line (a leaf with no + // parent) has no group, so we synthesize one for it, reusing the leaf's own id as the group + // id — safe because legacy group and title ids come from separate auto-increments and already + // overlap. Real groups and these phantom groups never collide (budget_item ids are unique). + DB::statement( + "CREATE VIEW `{$p}haushaltsgruppen` AS + SELECT bi.id AS id, + bi.budget_plan_id AS hhp_id, + bi.name AS gruppen_name, + CASE WHEN bi.budget_type = 1 THEN 0 ELSE 1 END AS type + FROM `{$p}budget_item` bi + WHERE bi.referenced_plan_id IS NULL + AND (bi.is_group = 1 OR bi.parent_id IS NULL)" + ); + + // A root leaf points at its phantom group (its own id); nested leaves point at their parent. + DB::statement( + "CREATE VIEW `{$p}haushaltstitel` AS + SELECT bi.id AS id, + COALESCE(bi.parent_id, bi.id) AS hhpgruppen_id, + bi.name AS titel_name, + bi.short_name AS titel_nr, + bi.value AS value + FROM `{$p}budget_item` bi + WHERE bi.is_group = 0 AND bi.referenced_plan_id IS NULL" + ); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 765d3f79..3ef49401 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,18 +18,21 @@ public function run(): void // BudgetPlan::factory(5)->populate()->create(); if (\App::runningUnitTests()) { + $this->call(DemoBudgetSeeder::class); $this->call(DemoDataSeeder::class); $this->call(LocalSeeder::class); $this->call(TestSeeder::class); } if (\App::isLocal()) { + $this->call(DemoBudgetSeeder::class); $this->call(DemoDataSeeder::class); $this->call(LocalSeeder::class); } if (\App::isProduction()) { if (config('stufis.realm') === 'demo') { + $this->call(DemoBudgetSeeder::class); $this->call(DemoDataSeeder::class); } $this->call(ProductionSeeder::class); diff --git a/database/seeders/DemoBudgetSeeder.php b/database/seeders/DemoBudgetSeeder.php new file mode 100644 index 00000000..3e962669 --- /dev/null +++ b/database/seeders/DemoBudgetSeeder.php @@ -0,0 +1,111 @@ +}>}> $plans */ + $plans = require database_path('seeders/data/demo_budget.php'); + + $delta = DemoDataSeeder::yearShiftDelta(); + $converter = new BudgetPlanConverter; + + // group items get fresh ids above every preserved leaf id (mirrors the converter) + $this->nextGroupId = collect($plans) + ->flatMap(fn (array $plan) => collect($plan['groups'])->flatMap(fn (array $g) => array_column($g['titels'], 'id'))) + ->max() + 1; + + foreach ($plans as $planId => $plan) { + $von = Carbon::parse($plan['von'])->addYears($delta); + // a NULL legacy "bis" means open-ended; mirror the converter's one-year fallback + $bis = $plan['bis'] !== null + ? Carbon::parse($plan['bis'])->addYears($delta) + : $von->copy()->addYear()->subDay(); + + $fiscalYear = FiscalYear::create(['start_date' => $von, 'end_date' => $bis]); + + $budgetPlan = new BudgetPlan([ + 'organization' => 'StuRa', + 'fiscal_year_id' => $fiscalYear->id, + 'state' => $converter->convertState($plan['state']), + ]); + $budgetPlan->id = $planId; // preserve the plan id + $budgetPlan->save(); + + $groupPosition = 0; + foreach ($plan['groups'] as $group) { + $this->seedGroup($budgetPlan, $group, $groupPosition++, $converter); + } + } + } + + /** + * Create one group's leaves (preserved ids) and the group item itself, mirroring the + * converter: leaves first, then the group with the leaves re-parented under it. + * + * @param array{name: string, type: int, titels: array} $group + */ + private function seedGroup(BudgetPlan $plan, array $group, int $position, BudgetPlanConverter $converter): void + { + $type = $group['type'] == 0 ? BudgetType::INCOME : BudgetType::EXPENSE; + + $leafIds = []; + $total = '0'; + $itemPosition = 0; + foreach ($group['titels'] as $titel) { + $item = new BudgetItem([ + 'budget_plan_id' => $plan->id, + 'short_name' => $titel['nr'], + 'name' => $titel['name'], + 'value' => $titel['value'], + 'budget_type' => $type, + 'is_group' => false, + 'parent_id' => null, + 'position' => $itemPosition++, + ]); + $item->id = $titel['id']; // preserve the leaf id + $item->save(); + + $leafIds[] = $titel['id']; + $total = bcadd($total, $titel['value'], 2); + } + + $shortName = $converter->deriveGroupShortName(array_column($group['titels'], 'nr')) + ?? $type->numberPrefix().'.'.($position + 1); + + $groupItem = new BudgetItem([ + 'budget_plan_id' => $plan->id, + 'short_name' => $shortName, + 'name' => $group['name'], + 'value' => $total, + 'budget_type' => $type, + 'is_group' => true, + 'parent_id' => null, + 'position' => $position, + ]); + $groupItem->id = $this->nextGroupId++; // above the leaf ids, so no collision + $groupItem->save(); + + BudgetItem::whereIn('id', $leafIds)->update(['parent_id' => $groupItem->id]); + } +} diff --git a/database/seeders/DemoDataSeeder.php b/database/seeders/DemoDataSeeder.php index 9a00ea0f..8b3b03cd 100644 --- a/database/seeders/DemoDataSeeder.php +++ b/database/seeders/DemoDataSeeder.php @@ -14,21 +14,26 @@ class DemoDataSeeder extends Seeder /** * Run the database seeds. */ + /** + * Whole-year shift applied to the demo data so the open budget plan (von 2024-04-01) contains + * today. Fiscal years are April-anchored, so before April we still belong to the previous one. + * Shared with DemoBudgetSeeder so the new budget plans line up with the dump's booking dates. + */ + public static function yearShiftDelta(): int + { + $today = Carbon::today(); + $targetOpenYear = $today->month < 4 ? $today->year - 1 : $today->year; + + return $targetOpenYear - 2024; + } + public function run(): void { if (App::isProduction() && config('stufis.realm') !== 'demo') { throw new \InvalidArgumentException('Realm is not demo but we are in production, aborting for your safety'); } - $today = Carbon::today(); - - // The demo data holds two fiscal years (April–March) that span three calendar - // years (2023–2025): the closed budget plan 2023-04-01..2024-03-31 and the open - // plan 2024-04-01..NULL. Fiscal years are anchored on April, so before April we - // still belong to the previous one. We pick a whole-year shift so the open plan - // (von 2024-04-01) contains today, then apply it uniformly to every year. - $targetOpenYear = $today->month < 4 ? $today->year - 1 : $today->year; - $delta = $targetOpenYear - 2024; + $delta = self::yearShiftDelta(); // Single-pass shift of the known data years only. The digit-boundary guards keep // us from touching years embedded in longer numbers (amounts, IBANs, refs), and a diff --git a/database/seeders/data/demo_budget.php b/database/seeders/data/demo_budget.php new file mode 100644 index 00000000..e5792693 --- /dev/null +++ b/database/seeders/data/demo_budget.php @@ -0,0 +1,560 @@ + groups -> titels; +// leaf ids are preserved so demo bookings/posts referencing titel_id still resolve. +return [ + 1 => [ + 'von' => '2023-04-01', + 'bis' => '2024-03-31', + 'state' => 'final', + 'groups' => [ + 1 => [ + 'name' => 'laufende Einnahmen', + 'type' => 0, + 'titels' => [ + 0 => [ + 'id' => 1, + 'name' => 'Semesterbeiträge', + 'nr' => 'E.1.1', + 'value' => '100000.00', + ], + 1 => [ + 'id' => 2, + 'name' => 'Zinseinnahmen', + 'nr' => 'E.1.2', + 'value' => '0.00', + ], + ], + ], + 2 => [ + 'name' => 'Stura-Dienstleistungen', + 'type' => 0, + 'titels' => [ + 0 => [ + 'id' => 3, + 'name' => 'Theaterfahrten', + 'nr' => 'E.2.1', + 'value' => '500.00', + ], + 1 => [ + 'id' => 4, + 'name' => 'Exkursionen', + 'nr' => 'E.2.2', + 'value' => '500.00', + ], + ], + ], + 3 => [ + 'name' => 'Gremienarbeit- und Projekte', + 'type' => 0, + 'titels' => [ + 0 => [ + 'id' => 5, + 'name' => 'Fachschaftsräte', + 'nr' => 'E.3.1', + 'value' => '0.00', + ], + 1 => [ + 'id' => 6, + 'name' => 'FSR EI', + 'nr' => 'E.3.1.1', + 'value' => '0.00', + ], + 2 => [ + 'id' => 7, + 'name' => 'FSR IA', + 'nr' => 'E.3.1.2', + 'value' => '0.00', + ], + 3 => [ + 'id' => 8, + 'name' => 'FSR MB', + 'nr' => 'E.3.1.3', + 'value' => '0.00', + ], + 4 => [ + 'id' => 9, + 'name' => 'FSR MN', + 'nr' => 'E.3.1.4', + 'value' => '0.00', + ], + 5 => [ + 'id' => 10, + 'name' => 'FSR WM', + 'nr' => 'E.3.1.5', + 'value' => '0.00', + ], + 6 => [ + 'id' => 11, + 'name' => 'StuRa-Projekte', + 'nr' => 'E.3.2', + 'value' => '0.00', + ], + 7 => [ + 'id' => 12, + 'name' => 'Erstiwoche', + 'nr' => 'E.3.3', + 'value' => '5000.00', + ], + ], + ], + 4 => [ + 'name' => 'Rückzahlungen und Gebühren', + 'type' => 1, + 'titels' => [ + 0 => [ + 'id' => 13, + 'name' => 'Kontogebühren', + 'nr' => 'A.1.1', + 'value' => '200.00', + ], + 1 => [ + 'id' => 14, + 'name' => 'Angestellte', + 'nr' => 'A.1.2', + 'value' => '40000.00', + ], + 2 => [ + 'id' => 15, + 'name' => 'Versicherungen', + 'nr' => 'A.1.3', + 'value' => '1000.00', + ], + 3 => [ + 'id' => 16, + 'name' => 'Transferkonto', + 'nr' => 'A.1.4', + 'value' => '0.00', + ], + ], + ], + 5 => [ + 'name' => 'Ausgaben für Ausstattung', + 'type' => 1, + 'titels' => [ + 0 => [ + 'id' => 17, + 'name' => 'Telefon und Faxdienste', + 'nr' => 'A.2.1', + 'value' => '50.00', + ], + 1 => [ + 'id' => 18, + 'name' => 'Bürobedarf', + 'nr' => 'A.2.2', + 'value' => '1000.00', + ], + 2 => [ + 'id' => 19, + 'name' => 'Domains und IT-Dienstleistungen', + 'nr' => 'A.2.3', + 'value' => '3000.00', + ], + ], + ], + 6 => [ + 'name' => 'Stura-Dienstleistungen', + 'type' => 1, + 'titels' => [ + 0 => [ + 'id' => 20, + 'name' => 'Theaterfahrten', + 'nr' => 'A.3.1', + 'value' => '1000.00', + ], + 1 => [ + 'id' => 21, + 'name' => 'Exkursionen', + 'nr' => 'A.3.2', + 'value' => '1000.00', + ], + 2 => [ + 'id' => 22, + 'name' => 'Zeitungen und Zeitschriften', + 'nr' => 'A.3.3', + 'value' => '200.00', + ], + 3 => [ + 'id' => 23, + 'name' => 'Veröffentlichungen', + 'nr' => 'A.3.4', + 'value' => '500.00', + ], + ], + ], + 7 => [ + 'name' => 'Gremienarbeit- und Projekte', + 'type' => 1, + 'titels' => [ + 0 => [ + 'id' => 24, + 'name' => 'Fachschaftsräte', + 'nr' => 'A.4.1', + 'value' => '10000.00', + ], + 1 => [ + 'id' => 25, + 'name' => 'FSR EI', + 'nr' => 'A.4.1.1', + 'value' => '2000.00', + ], + 2 => [ + 'id' => 26, + 'name' => 'FSR IA', + 'nr' => 'A.4.1.2', + 'value' => '2000.00', + ], + 3 => [ + 'id' => 27, + 'name' => 'FSR MB', + 'nr' => 'A.4.1.3', + 'value' => '2000.00', + ], + 4 => [ + 'id' => 28, + 'name' => 'FSR MN', + 'nr' => 'A.4.1.4', + 'value' => '2000.00', + ], + 5 => [ + 'id' => 29, + 'name' => 'FSR WM', + 'nr' => 'A.4.1.5', + 'value' => '2000.00', + ], + 6 => [ + 'id' => 30, + 'name' => 'StuRa-Projekte', + 'nr' => 'A.4.2', + 'value' => '30000.00', + ], + 7 => [ + 'id' => 31, + 'name' => 'Erstiwoche', + 'nr' => 'A.4.3', + 'value' => '20000.00', + ], + 8 => [ + 'id' => 32, + 'name' => 'Reisekosten', + 'nr' => 'A.4.4', + 'value' => '2000.00', + ], + 9 => [ + 'id' => 33, + 'name' => 'Mitgliedsbeiträge', + 'nr' => 'A.4.5', + 'value' => '2000.00', + ], + 10 => [ + 'id' => 34, + 'name' => 'Gremienwahlen', + 'nr' => 'A.4.6', + 'value' => '1000.00', + ], + 11 => [ + 'id' => 35, + 'name' => 'Klausurtagung', + 'nr' => 'A.4.7', + 'value' => '6000.00', + ], + ], + ], + ], + ], + 2 => [ + 'von' => '2024-04-01', + 'bis' => null, + 'state' => 'final', + 'groups' => [ + 8 => [ + 'name' => 'laufende Einnahmen', + 'type' => 0, + 'titels' => [ + 0 => [ + 'id' => 74, + 'name' => 'Semesterbeiträge', + 'nr' => 'E1.1', + 'value' => '100000.00', + ], + 1 => [ + 'id' => 75, + 'name' => 'Zinseinnahmen', + 'nr' => 'E1.2', + 'value' => '10.00', + ], + ], + ], + 9 => [ + 'name' => 'Stura-Dienstleistungen', + 'type' => 0, + 'titels' => [ + 0 => [ + 'id' => 36, + 'name' => 'Theaterfahrten', + 'nr' => 'E.2.1', + 'value' => '50.00', + ], + 1 => [ + 'id' => 37, + 'name' => 'Exkursionen', + 'nr' => 'E.2.2', + 'value' => '50.00', + ], + ], + ], + 10 => [ + 'name' => 'Gremienarbeit- und Projekte', + 'type' => 0, + 'titels' => [ + 0 => [ + 'id' => 38, + 'name' => 'Fachschaftsräte', + 'nr' => 'E.3.1', + 'value' => '0.00', + ], + 1 => [ + 'id' => 39, + 'name' => 'FSR EI', + 'nr' => 'E.3.1.1', + 'value' => '0.00', + ], + 2 => [ + 'id' => 40, + 'name' => 'FSR IA', + 'nr' => 'E.3.1.2', + 'value' => '0.00', + ], + 3 => [ + 'id' => 41, + 'name' => 'FSR MB', + 'nr' => 'E.3.1.3', + 'value' => '0.00', + ], + 4 => [ + 'id' => 42, + 'name' => 'FSR MN', + 'nr' => 'E.3.1.4', + 'value' => '0.00', + ], + 5 => [ + 'id' => 43, + 'name' => 'FSR WM', + 'nr' => 'E.3.1.5', + 'value' => '0.00', + ], + 6 => [ + 'id' => 44, + 'name' => 'StuRa-Projekte', + 'nr' => 'E.3.2', + 'value' => '2000.00', + ], + 7 => [ + 'id' => 45, + 'name' => 'Erstiwoche', + 'nr' => 'E.3.3', + 'value' => '2000.00', + ], + ], + ], + 11 => [ + 'name' => 'Semesterbeitragszuweisung', + 'type' => 0, + 'titels' => [ + 0 => [ + 'id' => 46, + 'name' => 'FSR EI', + 'nr' => 'E.4.1', + 'value' => '2000.00', + ], + 1 => [ + 'id' => 47, + 'name' => 'FSR IA', + 'nr' => 'E.4.2', + 'value' => '2000.00', + ], + 2 => [ + 'id' => 48, + 'name' => 'FSR MB', + 'nr' => 'E.4.3', + 'value' => '2000.00', + ], + 3 => [ + 'id' => 49, + 'name' => 'FSR MN', + 'nr' => 'E.4.4', + 'value' => '2000.00', + ], + 4 => [ + 'id' => 50, + 'name' => 'FSR WM', + 'nr' => 'E.4.5', + 'value' => '2000.00', + ], + ], + ], + 12 => [ + 'name' => 'Rückzahlungen und Gebühren', + 'type' => 1, + 'titels' => [ + 0 => [ + 'id' => 51, + 'name' => 'Kontogebühren', + 'nr' => 'A.1.1', + 'value' => '250.00', + ], + 1 => [ + 'id' => 52, + 'name' => 'Angestellte', + 'nr' => 'A.1.2', + 'value' => '51600.00', + ], + 2 => [ + 'id' => 53, + 'name' => 'Versicherungen', + 'nr' => 'A.1.3', + 'value' => '0.00', + ], + 3 => [ + 'id' => 54, + 'name' => 'Transferkonto', + 'nr' => 'A.1.4', + 'value' => '0.00', + ], + ], + ], + 13 => [ + 'name' => 'Ausgaben für Ausstattung', + 'type' => 1, + 'titels' => [ + 0 => [ + 'id' => 55, + 'name' => 'Telefon und Faxdienste', + 'nr' => 'A.2.1', + 'value' => '50.00', + ], + 1 => [ + 'id' => 56, + 'name' => 'Bürobedarf', + 'nr' => 'A.2.2', + 'value' => '500.00', + ], + 2 => [ + 'id' => 57, + 'name' => 'Domains und IT-Dienstleistungen', + 'nr' => 'A.2.3', + 'value' => '1200.00', + ], + ], + ], + 14 => [ + 'name' => 'Stura-Dienstleistungen', + 'type' => 1, + 'titels' => [ + 0 => [ + 'id' => 58, + 'name' => 'Theaterfahrten', + 'nr' => 'A.3.1', + 'value' => '1000.00', + ], + 1 => [ + 'id' => 59, + 'name' => 'Exkursionen', + 'nr' => 'A.3.2', + 'value' => '1000.00', + ], + 2 => [ + 'id' => 60, + 'name' => 'Zeitungen und Zeitschriften', + 'nr' => 'A.3.3', + 'value' => '100.00', + ], + 3 => [ + 'id' => 61, + 'name' => 'Veröffentlichungen', + 'nr' => 'A.3.4', + 'value' => '350.00', + ], + ], + ], + 15 => [ + 'name' => 'Gremienarbeit- und Projekte', + 'type' => 1, + 'titels' => [ + 0 => [ + 'id' => 62, + 'name' => 'Fachschaftsräte', + 'nr' => 'A.4.1', + 'value' => '10000.00', + ], + 1 => [ + 'id' => 63, + 'name' => 'FSR EI', + 'nr' => 'A.4.1.1', + 'value' => '2000.00', + ], + 2 => [ + 'id' => 64, + 'name' => 'FSR IA', + 'nr' => 'A.4.1.2', + 'value' => '2000.00', + ], + 3 => [ + 'id' => 65, + 'name' => 'FSR MB', + 'nr' => 'A.4.1.3', + 'value' => '2000.00', + ], + 4 => [ + 'id' => 66, + 'name' => 'FSR MN', + 'nr' => 'A.4.1.4', + 'value' => '2000.00', + ], + 5 => [ + 'id' => 67, + 'name' => 'FSR WM', + 'nr' => 'A.4.1.5', + 'value' => '2000.00', + ], + 6 => [ + 'id' => 68, + 'name' => 'StuRa-Projekte', + 'nr' => 'A.4.2', + 'value' => '39400.00', + ], + 7 => [ + 'id' => 69, + 'name' => 'Erstiwoche', + 'nr' => 'A.4.3', + 'value' => '30000.00', + ], + 8 => [ + 'id' => 70, + 'name' => 'Reisekosten', + 'nr' => 'A.4.4', + 'value' => '2000.00', + ], + 9 => [ + 'id' => 71, + 'name' => 'Mitgliedsbeiträge', + 'nr' => 'A.4.5', + 'value' => '0.00', + ], + 10 => [ + 'id' => 72, + 'name' => 'Gremienwahlen', + 'nr' => 'A.4.6', + 'value' => '2000.00', + ], + 11 => [ + 'id' => 73, + 'name' => 'Klausurtagung', + 'nr' => 'A.4.7', + 'value' => '4500.00', + ], + ], + ], + ], + ], +]; diff --git a/lang/de/budget-plan.php b/lang/de/budget-plan.php index cfe92e40..f52c6a65 100644 --- a/lang/de/budget-plan.php +++ b/lang/de/budget-plan.php @@ -1,6 +1,20 @@ [ + 'draft' => 'Entwurf', + 'resolved' => 'Beschlossen', + 'approved' => 'Genehmigt', + 'published' => 'Veröffentlicht', + 'completed' => 'Abgeschlossen', + ], + 'stateActions' => [ + 'draft' => 'als Entwurf zurücksetzen', + 'resolved' => 'beschließen', + 'approved' => 'genehmigen', + 'published' => 'veröffentlichen', + 'completed' => 'abschließen', + ], 'budget-plan' => 'Haushaltsplan', 'budget-plans' => 'Haushaltspläne', 'fiscal-year' => 'Haushaltsjahr', @@ -9,6 +23,8 @@ 'budget-longname' => 'Titelname', 'budget-value' => 'Wert', 'budget-group' => 'Haushaltstitelgruppe', + 'menu.legacy' => 'HHP (alt)', + 'menu.current' => 'Haushalt', 'index.button.new' => 'Neu anlegen', 'index.headline' => 'Übersicht aller Haushaltspläne', 'index.no-plans' => 'keine Haushaltspläne vorhanden', @@ -16,11 +32,14 @@ 'index.no-entries' => 'Keine Einträge', 'index.table.state' => 'Status', 'index.table.actions' => 'Aktionen', + 'index.table.edit' => 'Bearbeiten', + 'index.table.view' => 'Ansehen', 'edit.headline' => 'Haushaltsplan bearbeiten', 'edit.sub' => 'Bearbeite oder erstelle einen Haushaltsplan.', 'edit.organization' => 'Organisation', 'edit.organization-sub' => 'Wähle die Organisation aus, zu welcher der Haushaltsplan gehören soll.', 'edit.fiscal-year-sub' => 'Wähle das Haushaltsjahr aus, zu welchem der Haushaltsplan gehören soll.', + 'edit.add-fiscal-year' => 'Neues Haushaltsjahr anlegen', 'edit.resolution-date' => 'Beschlussdatum', 'edit.approval-date' => 'Genehmigungsdatum', 'edit.tab-headline.in' => 'Einnahmen', @@ -29,5 +48,96 @@ 'edit.table.headline.name-hint' => 'Der Titelname soll kurz aber beschreibend sein. Er sollte die Verwendung der Mittel widerspiegeln und beim Bearbeiten nicht zu weit abgewandelt werden, um eine Nachvollziehbarkeit über mehrere Haushaltsjahre hinweg gewährleisten zu können.', 'edit.table.headline.value-hint' => 'Der Wert eines Haushaltstitels sollte angemessen festgelegt werden. Beachte, ob du dich im "Einnahmen"- oder "Ausgaben"-Tab befindest. In der Regel sollen die Werte nach den Landeshaushaltsordnungen auf volle 10 EUR gerundet sein. Titelgruppen summieren sich immer automatisch aus den darunterliegenden Titeln und Titelgruppen. In Titelgruppen kann nicht direkt gebucht werden.', 'edit.save' => 'Speichern und zum nächsten Schritt', + 'edit.saved' => 'Gespeichert.', + 'edit.no-fiscal-year' => 'Kein Haushaltsjahr', + 'edit.new-group' => 'Neue Gruppe', + 'edit.new-budget' => 'Neuer Titel', + 'edit.more-actions' => 'Weitere Aktionen', + 'edit.to-budget' => 'In Titel umwandeln', + 'edit.to-group' => 'In Gruppe umwandeln', + 'edit.move-up' => 'Nach oben', + 'edit.move-down' => 'Nach unten', + 'edit.copy' => 'Kopieren', + 'edit.copy-inverse' => 'Auf andere Seite kopieren', + 'edit.copy-suffix' => 'Kopie', + 'edit.delete' => 'Löschen', + 'edit.delete-has-children' => 'Eine Gruppe mit Untertiteln kann nicht gelöscht werden.', + 'edit.has-bookings' => 'Auf diesen Titel wurden bereits Buchungen vorgenommen – er kann nicht gelöscht oder umgewandelt werden.', + 'edit.transform' => 'Umwandeln', + 'edit.to-mount' => 'Plan einbinden …', + 'edit.mount-heading' => 'Plan einbinden', + 'edit.mount-sub' => 'Wähle einen Plan; seine Einnahmen- bzw. Ausgabenseite wird hier eingebunden.', + 'edit.mount-pick' => 'Plan auswählen', + 'edit.mount-confirm' => 'Einbinden', + 'edit.mount-cycle' => 'Dieser Plan kann nicht eingebunden werden – das würde einen Verweis-Zyklus erzeugen.', + 'edit.add-tax-titles' => 'Umsatzsteuer-Titel hinzufügen', + 'edit.tax-added' => ':count Umsatzsteuer-Titel hinzugefügt.', + 'edit.tax-exists' => 'Die Umsatzsteuer-Titel sind bereits vorhanden.', + 'edit.tax-inactive' => 'Die Umsatzsteuer-Funktion ist nicht aktiviert.', + 'create.headline' => 'Neuen Haushaltsplan anlegen', + 'create.sub' => 'Lege Organisation und Haushaltsjahr fest und wähle, wie der Plan starten soll.', + 'create.organization' => 'Organisation', + 'create.organization-taken' => 'In diesem Haushaltsjahr gibt es bereits einen Plan dieser Organisation.', + 'create.fiscal-year' => 'Haushaltsjahr', + 'create.starting-point' => 'Startpunkt', + 'create.start-template' => 'Vorlage', + 'create.start-template-sub' => 'Je Seite eine Gruppe mit einem Titel.', + 'create.start-clone' => 'Aus bestehendem Plan kopieren', + 'create.start-clone-sub' => 'Übernimmt alle Titel des gewählten Plans.', + 'create.source-plan' => 'Vorlage-Plan', + 'create.source-plan-pick' => 'Plan auswählen …', + 'create.mounts-heading' => 'Eingebundene Pläne', + 'create.mounts-sub' => 'Lege für jeden eingebundenen Plan fest, ob er mitkopiert oder zu einer leeren Gruppe wird.', + 'create.mount.copy' => 'Kopieren', + 'create.mount.drop' => 'Leere Gruppe', + 'create.submit' => 'Anlegen', + 'create.cancel' => 'Abbrechen', + 'view.headline' => 'Haushaltsplan', + 'view.no-organization' => 'Keine Organisation', + 'view.actions' => 'Aktionen', + 'view.edit' => 'Bearbeiten', + 'view.duplicate' => 'Duplizieren', + 'view.print' => 'Drucken', + 'view.export' => 'Exportieren', + 'view.export.excel' => 'Excel (.xlsx)', + 'view.export.ods' => 'LibreOffice (.ods)', + 'export.total' => 'Summe', + 'view.summary.income' => 'Einnahmen', + 'view.summary.expense' => 'Ausgaben', + 'view.summary.balance' => 'Saldo', + 'view.col.planned' => 'Plan', + 'view.col.booked' => 'Gebucht', + 'view.col.committed' => 'Beschlossen', + 'view.col.planned-hint' => 'Der geplante Wert des Haushaltstitels, wie er im Haushaltsplan festgelegt wurde. Titelgruppen summieren sich automatisch aus den darunterliegenden Titeln.', + 'view.col.booked-hint' => 'Die Summe der tatsächlich auf diesem Titel verbuchten Beträge (Ist-Buchungen).', + 'view.col.committed-hint' => 'Die Summe der bereits beschlossenen, aber noch nicht verbuchten Beträge, die diesen Titel belasten.', + 'view.delete' => 'Haushaltsplan löschen', + 'view.delete-confirm' => 'Diesen Haushaltsplan und alle seine Titel unwiderruflich löschen?', + 'view.plan-deleted' => 'Haushaltsplan gelöscht.', + 'view.state' => 'Status', + 'view.change-state' => 'Status ändern', + 'view.state-changed' => 'Status geändert.', + 'view.state-modal.heading' => 'Status ändern', + 'view.state-modal.placeholder' => 'Neuen Status wählen …', + 'view.state-modal.cancel' => 'Abbrechen', + 'view.state-modal.save' => 'Speichern', + 'view.state-modal.no-transitions' => 'Keine Statusänderung möglich.', + 'item.bookings' => 'Buchungen', + 'item.no-bookings' => 'Auf diesen Titel wurden noch keine Buchungen vorgenommen.', + 'item.col.date' => 'Datum', + 'item.col.payment' => 'Zahlung', + 'item.col.reference' => 'Verwendung', + 'item.col.amount' => 'Betrag', + 'fiscal-year.create.headline' => 'Neues Haushaltsjahr anlegen', + 'fiscal-year.edit.headline' => 'Haushaltsjahr bearbeiten', + 'fiscal-year.edit.sub' => 'Lege den Zeitraum des Haushaltsjahres fest. Haushaltsjahre dürfen sich nicht überschneiden.', + 'fiscal-year.start' => 'Beginn', + 'fiscal-year.end' => 'Ende', + 'fiscal-year.save' => 'Speichern', + 'fiscal-year.cancel' => 'Abbrechen', + 'fiscal-year.saved' => 'Haushaltsjahr gespeichert.', + 'fiscal-year.overlap-error' => 'Der Zeitraum überschneidet sich mit einem bestehenden Haushaltsjahr.', + 'fiscal-year.gap-warning.heading' => 'Lücke zwischen Haushaltsjahren', + 'fiscal-year.gap-warning.text' => 'Der gewählte Zeitraum lässt eine Lücke zu benachbarten Haushaltsjahren. Haushaltsjahre sollten lückenlos aneinander anschließen:', '' => '', ]; diff --git a/legacy/lib/booking/HHPHandler.php b/legacy/lib/booking/HHPHandler.php index 592ad8a6..fa1ff284 100644 --- a/legacy/lib/booking/HHPHandler.php +++ b/legacy/lib/booking/HHPHandler.php @@ -234,6 +234,9 @@ class="btn btn-primary hasGroup( public function saveNewHHP(): bool { + // The legacy budget tables are now read-only views over the new budget_plan structure; + // creating/importing a Haushaltsplan happens in the new budget plan module instead. + throw new LegacyDieException(410, 'Die Haushaltsplan-Erstellung erfolgt jetzt im neuen Haushaltsplan-Modul.'); [$groups, $titels, $newHHPid] = $this->reverseCSV($_POST['importCSV']); $dateStart = date_create($_POST['date_start'])->format('Y-m-d'); $db = DBConnector::getInstance(); diff --git a/package-lock.json b/package-lock.json index 5d4efb62..682ced68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,6 @@ "packages": { "": { "dependencies": { - "@alpinejs/sort": "^3.14.9", "@fontsource-variable/inter": "^5.2.6" }, "devDependencies": { @@ -17,7 +16,7 @@ "vite": "^8.0.16" }, "engines": { - "node": ">=16" + "node": ">=22" } }, "node_modules/@alloc/quick-lru": { @@ -33,15 +32,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@alpinejs/sort": { - "version": "3.15.12", - "resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.15.12.tgz", - "integrity": "sha512-DNIS7SQFg4H4o5faluRgqYEPi1Q7Hf+HMDgoCcIHXbXWH0g66WPEzz9OMk1zsUYib0KxMms4xXOqk+xh2tHZzg==", - "license": "MIT", - "dependencies": { - "sortablejs": "^1.15.2" - } - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1565,12 +1555,6 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, - "node_modules/sortablejs": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", - "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", - "license": "MIT" - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index c1d0ae90..1dbbc0fb 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "vite": "^8.0.16" }, "dependencies": { - "@alpinejs/sort": "^3.14.9", "@fontsource-variable/inter": "^5.2.6" }, "engines": { diff --git a/resources/js/app.js b/resources/js/app.js index ce713d7a..ab6cf807 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,7 +1,4 @@ -import {Alpine, Livewire} from '../../vendor/livewire/livewire/dist/livewire.esm'; -import Sort from '@alpinejs/sort' +import {Livewire} from '../../vendor/livewire/livewire/dist/livewire.esm'; import '@fontsource-variable/inter'; -Alpine.plugin(Sort) - Livewire.start() diff --git a/resources/views/budget-plan/index.blade.php b/resources/views/budget-plan/index.blade.php index ef022cb2..691baec5 100644 --- a/resources/views/budget-plan/index.blade.php +++ b/resources/views/budget-plan/index.blade.php @@ -16,61 +16,59 @@ {{ __('budget-plan.budget-plans') }} {{ __('budget-plan.index.table.state') }} - {{ __('budget-plan.index.table.actions') }} @foreach($years as $year) - {{ __('budget-plan.fiscal-year') }} {{ $year->start_date->format('M y') }} to {{ $year->end_date->format('M y') }} + {{ __('budget-plan.fiscal-year') }}: {{ $year->label() }} - @foreach($year->budgetPlans as $plan) + @forelse($year->budgetPlans as $plan) - - {{ __('budget-plan.fiscal-year') }} {{ $plan->id }} + + {{ $plan->label() }} - - {{ $plan->state }} + + {{ $plan->state?->label() }} - - - - - - - + + @empty + + + {{ __('budget-plan.index.no-plans') }} - @endforeach + @endforelse @endforeach + @if($orphaned_plans->isNotEmpty()) - Pläne ohhneee HHHHJ + {{ __('budget-plan.index.orphaned-plans') }} + @foreach($orphaned_plans as $plan) + + + {{ $plan->organization ?: __('budget-plan.view.no-organization') }} + + + + {{ $plan->state?->label() }} + + + + @endforeach @endif - @foreach($orphaned_plans as $plan) + + @if($years->isEmpty() && $orphaned_plans->isEmpty()) - - {{ __('budget-plan.plan?') }} {{ $plan->id }} - - - - {{ $plan->state }} - - - - - - - - - + + {{ __('budget-plan.index.no-plans') }} - @endforeach + @endif diff --git a/resources/views/budget-plan/view.blade.php b/resources/views/budget-plan/view.blade.php deleted file mode 100644 index 8f80d8b7..00000000 --- a/resources/views/budget-plan/view.blade.php +++ /dev/null @@ -1,160 +0,0 @@ -@php - use App\Models\Enums\BudgetType; - use Cknow\Money\Money; -@endphp - - -
- - {{ __('budget-plan.view.headline') }} - - {{ $plan->organization ?? __('budget-plan.view.no-organization') }} - @if($plan->fiscalYear) - · {{ __('budget-plan.fiscal-year') }}: {{ $plan->fiscalYear->start_date->format('d.m.Y') }} - - {{ $plan->fiscalYear->end_date->format('d.m.Y') }} - @endif - - - - {{ __('budget-plan.view.actions') }} - - {{ __('budget-plan.view.edit') }} - {{ __('budget-plan.view.duplicate') }} - {{ __('budget-plan.view.print') }} - {{ __('budget-plan.view.export') }} - - - - - -
-
-
-
Status
-
-
- 71,897 - from 70,946 -
- -
- - Increased by - 12% -
-
-
-
-
Avg. Open Rate
-
-
- 58.16% - from 56.14% -
- -
- - Increased by - 2.02% -
-
-
-
-
Avg. Click Rate
-
-
- 24.57% - from 28.62% -
- -
- - Decreased by - 4.05% -
-
-
-
-
- - {{-- Budgetplan table --}} - - - - {{ __('budget-plan.edit.tab-headline.in') }} - - - {{ __('budget-plan.edit.tab-headline.out') }} - - - - @foreach(BudgetType::cases() as $budgetType) - -
-
-
-
-
- - - - - - - - - - - - - @foreach($items[$budgetType->slug()] as $item) - - @endforeach - -
- Title - - Name - - {{-- Sigma column --}} - - Soll - - Ist - - B -
-
-
-
-
-
- {{-- -
-
{{ __('budget-plan.budget-shortname') }}
-
{{ __('budget-plan.budget-longname') }}
-
{{ __('budget-plan.budget-value') }}
-
{{ __('budget-plan.view.booked') }}
-
{{ __('budget-plan.view.available') }}
- - @foreach($items[$budgetType->slug()] as $budgetItem) - - @endforeach - -
- --}} -
- @endforeach -
- -
-
diff --git a/resources/views/components/budgetplan/item-group-edit.blade.php b/resources/views/components/budgetplan/item-group-edit.blade.php index d9781067..423fd313 100644 --- a/resources/views/components/budgetplan/item-group-edit.blade.php +++ b/resources/views/components/budgetplan/item-group-edit.blade.php @@ -1,24 +1,28 @@ @props([ 'level' => 0, 'item', + /* @var array map of item id => precomputed effective value */ + 'values' => [], /* @var bool array of booleans, one for each level, indicating if the item is the last one on that level */ 'lastItem' => [], ])
+ ]) wire:sort:item="{{ $item->id }}">
$item->is_group, //"rounded" => $item->is_group, //"bg-zinc-300 my-2" => $item->is_group, ])> -
- @if($item->is_group) + @if($item->isMount()) + + @elseif($item->is_group) @else @@ -28,61 +32,93 @@
- + @if($item->isMount()) + {{-- a mount stands in for another plan; its label links to that plan --}} +
+ @if($item->referencedPlan) + {{ $item->referencedPlan->label() }} + @endif +
+ @else + + @endif
@if($level > 0)
@for($i = 1; $i <= $level; $i++) - +
$i < $level, - "h-full border-l-2 border-gray-300" => !($lastItem[$i-1]), - "h-1/2 border-l-2 border-gray-300" => ($lastItem[$i-1]) && $i === $level, + // ancestor pass-through line (continues full height through this row) + "h-full border-l-2 border-gray-300" => $i < $level && !($lastItem[$i-1]), + // immediate connector, this item is NOT last: full height + reach up to the parent box's bottom edge + "border-l-2 border-gray-300 -mt-2 h-[calc(100%+0.5rem)]" => $i === $level && !($lastItem[$i-1]), + // immediate connector, this item IS last: stop at the middle (the elbow) + reach up to the parent box's bottom edge + "border-l-2 border-gray-300 -mt-2 h-[calc(50%+0.5rem)]" => $i === $level && ($lastItem[$i-1]), ])>
@endfor
@endif - $level === 3, - //'pl-10 border-l-6 border-zinc-300' => $level === 2, - //'pl-5 border-l-4 border-zinc-300' => $level === 1, - ])> - @if($item->is_group) + @if($item->isMount()) + {{-- mount: read-only rolled-up total of the referenced plan's side (derived). + Styled like a group sum, but with a link prefix instead of Σ. --}} + + + + + + + @elseif($item->is_group) + {{-- group rows use an input-group so the Σ prefix welds onto the (readonly) sum. + Shown live via effectiveValue so a nested mount's derived total rolls up. --}} + Σ - @endif - - + + + @else + {{-- child rows have no prefix; a lone input must NOT sit in an input-group, otherwise + the trailing flux:error counts as the last child and strips the input's right rounding --}} + + @endif
{{-- Action Buttons --}} - @if($item->is_group) - {{-- subtle or ghost --}} - - @endif - + - Debug: L{{ $level }} id{{$item->id}} P{{$item->position}} - @if($item->is_group) - to budget - @else - to group - @endif - item up - item down - copy + + @if($item->isMount()) + {{ __('budget-plan.edit.to-budget') }} + @elseif($item->is_group) + {{ __('budget-plan.edit.to-budget') }} + @else + {{ __('budget-plan.edit.to-group') }} + {{-- open the modal instantly (client-side) so the skeleton shows while candidates load --}} + {{ __('budget-plan.edit.to-mount') }} + @endif + + + {{ __('budget-plan.edit.move-up') }} + {{ __('budget-plan.edit.move-down') }} + {{ __('budget-plan.edit.copy') }} - copy zur anderen seite + {{ __('budget-plan.edit.copy-inverse') }} - Delete + {{ __('budget-plan.edit.delete') }} + @if($item->is_group) + + @if($level < 2) + {{-- subtle or ghost --}} + @endif + @endif
@if($item->is_group) @@ -93,10 +129,11 @@ //"border-l-16" => $level === 1, //"border-l-24" => $level === 2, //"border-l-28" => $level === 3, - ]) x-sort="$wire.sort($item,$position)"> + ]) wire:sort="sort"> @foreach($item->orderedChildren as $child) $item->is_group, "text-sm whitespace-nowrap text-gray-700" => !$item->is_group, ])}}> - @if($item->is_group) + @if($item->isMount()) + {{-- Mount: read-only reference standing in for another plan's whole in/out side --}} + $level === 0, + "px-3 sm:pl-8" => $level === 1, + "px-3 sm:pl-13" => $level === 2, + "px-3 sm:pl-18" => $level === 3, + ])> + + {{ $item->short_name }} + + + @if($item->referencedPlan) + {{ $item->referencedPlan->label() }} + @endif + + + {{ $item->planned->format() }} + {{ $item->booked->format() }} + {{ $item->committed->format() }} + + @elseif($item->is_group) {{-- Is Group ; th needed to make sticky work --}} @if($item->is_group) Σ @endif - {{ $item->value }} - {{ $item->value }} - {{ $item->value }} + {{ $item->planned->format() }} + {{ $item->booked->format() }} + {{ $item->committed->format() }} @else {{-- No Group --}} @@ -46,80 +68,12 @@ "px-3 sm:pl-18" => $level === 3, ])> - {{ $item->short_name }} + {{ $item->short_name }} {{ $item->name }} - {{ $item->value }} - {{ $item->value }} - {{ $item->value }} + {{ $item->planned->format() }} + {{ $item->booked->format() }} + {{ $item->committed->format() }} @endif - - - - diff --git a/resources/views/exports/budget-plan.blade.php b/resources/views/exports/budget-plan.blade.php new file mode 100644 index 00000000..09231061 --- /dev/null +++ b/resources/views/exports/budget-plan.blade.php @@ -0,0 +1,71 @@ +@php + use App\Models\BudgetItem; + use Cknow\Money\Money; + + // planned/booked/committed are pre-rolled-up on every node by BudgetPlanMeasures::annotate() + + // decimal() → plain number so the EUR column format applies in the sheet + $decimal = static fn (Money $money) => (float) $money->formatByDecimal(); + + // Σ a column over the root rows as Money (not float) so currency totals don't drift + $total = static fn ($roots, callable $value) => $decimal( + $roots->reduce(static fn (Money $carry, BudgetItem $item) => $carry->add($value($item)), Money::EUR(0)) + ); + + $sections = [ + __('budget-plan.view.summary.income') => $income, + __('budget-plan.view.summary.expense') => $expense, + ]; +@endphp + + + + + + + @if($plan->fiscalYear) + + + + + @endif + + + + + + + @foreach($sections as $sectionTitle => $items) + + + + + + + + + + + @foreach($items as $item) + + + + + + + + @endforeach + @php($roots = $items->whereNull('parent_id')) + + + + + + + + + + @endforeach + +
{{ __('budget-plan.view.headline') }} · {{ $plan->label() }}
{{ __('budget-plan.fiscal-year') }}{{ $plan->fiscalYear->label() }}
{{ __('budget-plan.view.state') }}{{ $plan->state->label() }}
{{ $sectionTitle }}
{{ __('budget-plan.budget-shortname') }}{{ __('budget-plan.budget-longname') }}{{ __('budget-plan.view.col.planned') }}{{ __('budget-plan.view.col.booked') }}{{ __('budget-plan.view.col.committed') }}
{{ $item->short_name }}{!! str_repeat('   ', $item->depth) !!}@if($item->is_group){{ $item->name }}@else{{ $item->name }}@endif@if($item->is_group){{ $decimal($item->planned) }}@else{{ $decimal($item->planned) }}@endif@if($item->is_group){{ $decimal($item->booked) }}@else{{ $decimal($item->booked) }}@endif@if($item->is_group){{ $decimal($item->committed) }}@else{{ $decimal($item->committed) }}@endif
{{ $sectionTitle }}{{ __('budget-plan.export.total') }}{{ $total($roots, fn ($item) => $item->planned) }}{{ $total($roots, fn ($item) => $item->booked) }}{{ $total($roots, fn ($item) => $item->committed) }}
+ diff --git a/resources/views/flux/table/row-headline.blade.php b/resources/views/flux/table/row-headline.blade.php index a3efc1fe..85e73b6e 100644 --- a/resources/views/flux/table/row-headline.blade.php +++ b/resources/views/flux/table/row-headline.blade.php @@ -3,7 +3,7 @@ ]) merge(['class' => 'bg-zinc-200']) }} data-flux-row> - +
{{ $slot }}
diff --git a/resources/views/layout/app.blade.php b/resources/views/layout/app.blade.php index 72441adf..fe968752 100644 --- a/resources/views/layout/app.blade.php +++ b/resources/views/layout/app.blade.php @@ -69,11 +69,19 @@ > Sitzung - + {{ __('budget-plan.menu.legacy') }} + + @endcan + - Haushalt + {{ __('budget-plan.menu.current') }}
@@ -164,11 +172,19 @@ class="absolute top-1 right-0 -mr-14 p-1"> > Sitzung - + {{ __('budget-plan.menu.legacy') }} + + @endcan + - Haushalt + {{ __('budget-plan.menu.current') }}
TOS diff --git a/resources/views/legacy/transaction/view.blade.php b/resources/views/legacy/transaction/view.blade.php index 14e218ad..26bdbd71 100644 --- a/resources/views/legacy/transaction/view.blade.php +++ b/resources/views/legacy/transaction/view.blade.php @@ -66,7 +66,7 @@
{{ $booking->id }} {{ $booking->comment }} - {{ $booking->budgetItem->titel_nr }} {{ $booking->budgetItem->titel_name }} + {{ $booking->budgetItem->short_name }} {{ $booking->budgetItem->name }}
diff --git "a/resources/views/pages/budget-plan/\342\232\241item-view/item-view.blade.php" "b/resources/views/pages/budget-plan/\342\232\241item-view/item-view.blade.php" new file mode 100644 index 00000000..10172300 --- /dev/null +++ "b/resources/views/pages/budget-plan/\342\232\241item-view/item-view.blade.php" @@ -0,0 +1,79 @@ +
+ + {{ $item->short_name }} · {{ $item->name }} + + + {{ __('budget-plan.view.headline') }} · {{ $plan->label() }} + + + + +
+
+
+
{{ __('budget-plan.view.col.planned') }}
+
{{ $planned->format() }}
+
+
+
{{ __('budget-plan.view.col.booked') }}
+
{{ $booked->format() }}
+
+
+
{{ __('budget-plan.view.col.committed') }}
+
{{ $committed->format() }}
+
+
+
+ + {{-- Bookings against this item --}} +
+ {{ __('budget-plan.item.bookings') }} + + @if($rows->isEmpty()) + {{ __('budget-plan.item.no-bookings') }} + @else +
+
+
+ + + + + + + + + + + @foreach($rows as $row) + + + + + + + @endforeach + +
{{ __('budget-plan.item.col.date') }}{{ __('budget-plan.item.col.payment') }}{{ __('budget-plan.item.col.reference') }}{{ __('budget-plan.item.col.amount') }}
{{ $row['timestamp'] }} + @if($row['transaction']) + + {{ $row['transaction']->name }} + + @else + + @endif + + @if($row['project']) + {{ $row['project']->name }} + @elseif(filled($row['comment'])) + {{ $row['comment'] }} + @else + + @endif + {{ $row['amount']->format() }}
+
+
+
+ @endif +
+
diff --git "a/resources/views/pages/budget-plan/\342\232\241item-view/item-view.php" "b/resources/views/pages/budget-plan/\342\232\241item-view/item-view.php" new file mode 100644 index 00000000..02713c51 --- /dev/null +++ "b/resources/views/pages/budget-plan/\342\232\241item-view/item-view.php" @@ -0,0 +1,79 @@ + 'lg'])] class extends Component +{ + #[Locked] + public int $plan_id; + + #[Locked] + public int $item_id; + + public function mount(int $plan_id, int $item_id): void + { + $this->plan_id = $plan_id; + $this->item_id = $item_id; + $this->authorize('view', $this->plan()); + } + + public function with(): array + { + $plan = $this->plan(); + $item = $this->item(); + $measure = new BudgetPlanMeasures($plan, $item->budget_type)->forItem($item); + + return [ + 'plan' => $plan, + 'item' => $item, + 'planned' => $measure['planned'], + 'booked' => $measure['booked'], + 'committed' => $measure['committed'], + 'rows' => $this->rows($item), + ]; + } + + /** + * The item's non-canceled bookings, flattened to a small view model so the Blade stays + * simple: bank transaction (lazy access — never eager-loaded, see Booking::bankTransaction) + * and, where resolvable, the originating project. + */ + private function rows(BudgetItem $item): Collection + { + return $item->bookings() + ->where('canceled', 0) + ->orderByDesc('timestamp') + ->get() + ->map(static function (Booking $booking): array { + $project = $booking->beleg_type === 'belegposten' + ? $booking->expensesReceiptPost?->expensesReceipt?->expense?->project + : null; + + return [ + 'amount' => Money::parseByDecimal((string) $booking->value, 'EUR'), + 'timestamp' => $booking->timestamp, + 'comment' => $booking->comment, + 'transaction' => $booking->bankTransaction, + 'project' => $project, + ]; + }); + } + + private function plan(): BudgetPlan + { + return BudgetPlan::findOrFail($this->plan_id); + } + + private function item(): BudgetItem + { + return BudgetItem::where('budget_plan_id', $this->plan_id)->findOrFail($this->item_id); + } +}; diff --git "a/resources/views/pages/budget-plan/\342\232\241plan-create/plan-create.blade.php" "b/resources/views/pages/budget-plan/\342\232\241plan-create/plan-create.blade.php" new file mode 100644 index 00000000..76e3a82d --- /dev/null +++ "b/resources/views/pages/budget-plan/\342\232\241plan-create/plan-create.blade.php" @@ -0,0 +1,74 @@ +
+ + {{ __('budget-plan.create.headline') }} + {{ __('budget-plan.create.sub') }} + + +
+ + + + + + @if($starting_point === 'clone') + + @foreach($source_plans as $candidate) + + {{ $candidate->label() }}@if($candidate->fiscalYear) · {{ $candidate->fiscalYear->label() }}@endif + + @endforeach + + + @if($mounted_plans->isNotEmpty()) + + {{ __('budget-plan.create.mounts-heading') }} + {{ __('budget-plan.create.mounts-sub') }} +
+ @foreach($mounted_plans as $sub) +
+ {{ $sub->label() }} + + + + +
+ @endforeach +
+
+ @endif + @endif + + + @foreach($fiscal_years as $fiscal_year) + {{ $fiscal_year->label() }} + @endforeach + + {{ __('budget-plan.edit.add-fiscal-year') }} + + + + +
+ {{ __('budget-plan.create.submit') }} + {{ __('budget-plan.create.cancel') }} +
+ +
diff --git "a/resources/views/pages/budget-plan/\342\232\241plan-create/plan-create.php" "b/resources/views/pages/budget-plan/\342\232\241plan-create/plan-create.php" new file mode 100644 index 00000000..acc9a100 --- /dev/null +++ "b/resources/views/pages/budget-plan/\342\232\241plan-create/plan-create.php" @@ -0,0 +1,190 @@ + 'md'])] class extends Component +{ + public ?string $organization = null; + + public ?int $fiscal_year_id = null; + + /** How the new plan is seeded: a blank template, or cloned from an existing plan. */ + public string $starting_point = 'template'; + + public ?int $source_plan_id = null; + + /** Per mounted sub-plan: 'copy' (clone it too) or 'drop' (flatten to an empty group). Keyed by plan id. */ + public array $mountChoices = []; + + public function mount(): void + { + Gate::authorize('create', BudgetPlan::class); + + // deep-linked from a plan's "Duplizieren" action: preselect it as the clone source + $sourceId = request()->integer('source') ?: null; + if ($sourceId !== null && BudgetPlan::whereKey($sourceId)->exists()) { + $this->starting_point = 'clone'; + $this->source_plan_id = $sourceId; + $this->prefillFromSource(); + } + } + + public function with(): array + { + $source = $this->starting_point === 'clone' && $this->source_plan_id + ? BudgetPlan::find($this->source_plan_id) + : null; + + return [ + 'fiscal_years' => FiscalYear::orderByDesc('start_date')->get(), + 'source_plans' => BudgetPlan::with('fiscalYear')->orderByDesc('id')->get(), + 'mounted_plans' => $source ? $source->reachableMountedPlans() : collect(), + ]; + } + + public function rules(): array + { + return [ + 'organization' => [ + 'nullable', 'string', 'max:255', + // an organization may appear once per fiscal year (explicit, not silently renamed) + function (string $attribute, $value, callable $fail): void { + if (BudgetPlan::organizationTaken($value, $this->fiscal_year_id)) { + $fail(__('budget-plan.create.organization-taken')); + } + }, + ], + 'fiscal_year_id' => ['nullable', 'exists:fiscal_year,id'], + 'starting_point' => ['required', 'in:template,clone'], + 'source_plan_id' => ['nullable', 'required_if:starting_point,clone', 'exists:budget_plan,id'], + ]; + } + + public function updatedSourcePlanId(): void + { + $this->prefillFromSource(); + } + + public function updatedStartingPoint(): void + { + $this->prefillFromSource(); + } + + /** Re-suggest a non-colliding organization name when the target year changes while cloning. */ + public function updatedFiscalYearId(): void + { + $this->suggestOrganization(); + } + + /** Jump to the fiscal-year creator, mirroring the editor's inline "new year" action. */ + public function createFiscalYear(): void + { + $this->redirect(route('fiscal-year.create'), navigate: true); + } + + public function save(TitleNumberer $numberer, BudgetPlanCloner $cloner): void + { + Gate::authorize('create', BudgetPlan::class); + $this->validate(); + + $plan = BudgetPlan::create([ + 'state' => Draft::class, + 'organization' => $this->organization ?: null, + 'fiscal_year_id' => $this->fiscal_year_id, + ]); + + if ($this->starting_point === 'clone') { + $source = BudgetPlan::findOrFail($this->source_plan_id); + $cloner->cloneInto($source, $plan, $this->mountChoices); + } else { + $this->seedBlankTemplate($plan, $numberer); + } + + Flux::toast(__('budget-plan.edit.saved'), variant: 'success'); + $this->redirect(route('budget-plan.edit', ['plan_id' => $plan->id]), navigate: true); + } + + /** + * Carry the chosen clone source's metadata into the form: prefill the (year-aware) suggested + * organization name and fiscal year, and default every reachable mounted sub-plan to 'copy'. + * Runs whenever the starting point or source changes. + */ + private function prefillFromSource(): void + { + $this->mountChoices = []; + + if ($this->starting_point !== 'clone' || ! $this->source_plan_id) { + return; + } + + $source = BudgetPlan::find($this->source_plan_id); + if ($source === null) { + return; + } + + $this->fiscal_year_id = $source->fiscal_year_id; + $this->suggestOrganization(); + + foreach ($source->reachableMountedPlans() as $sub) { + $this->mountChoices[$sub->id] = 'copy'; + } + } + + /** + * Suggest the organization field from the clone source, appending " (Kopie)" only when the + * source's name already exists in the selected year. No-op outside the clone flow, so a + * blank/hand-typed name is left untouched. + */ + private function suggestOrganization(): void + { + if ($this->starting_point !== 'clone' || ! $this->source_plan_id) { + return; + } + + $source = BudgetPlan::find($this->source_plan_id); + if ($source !== null) { + $this->organization = BudgetPlan::resolveOrganization($source->organization, $this->fiscal_year_id); + } + } + + /** + * Seed each side with an empty group holding one budget line; Titelnummern (E1 / A1, then + * E1.1 / A1.1) are derived the same way as in the editor. + */ + private function seedBlankTemplate(BudgetPlan $plan, TitleNumberer $numberer): void + { + foreach (BudgetType::cases() as $type) { + $group = $this->numbered($numberer, $plan->budgetItems()->make([ + 'is_group' => true, + 'budget_type' => $type, + 'position' => 0, + ])); + + $this->numbered($numberer, $group->children()->make([ + 'budget_plan_id' => $plan->id, + 'is_group' => false, + 'budget_type' => $type, + 'position' => 0, + ])); + } + } + + private function numbered(TitleNumberer $numberer, BudgetItem $item): BudgetItem + { + $item->save(); + $item->short_name = $numberer->next($item); + $item->save(); + + return $item; + } +}; diff --git "a/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.blade.php" "b/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.blade.php" index b967dd45..f5870a7d 100644 --- "a/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.blade.php" +++ "b/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.blade.php" @@ -7,11 +7,20 @@
- - None + @foreach($fiscal_years as $fiscal_year) {{ $fiscal_year->start_date->format('d.m.y') }} - {{ $fiscal_year->end_date->format('d.m.y') }} @endforeach + + {{-- pinned create action at the bottom of the dropdown --}} + {{ __('budget-plan.edit.add-fiscal-year') }} @@ -21,17 +30,18 @@ {{ __('budget-plan.edit.tab-headline.in') }} - 100.000,01€ + {{ $in_total->format() }} {{ __('budget-plan.edit.tab-headline.out') }} - 100.400,01€ + {{ $out_total->format() }} @foreach(\App\Models\Enums\BudgetType::cases() as $budgetType) -
+ {{-- first track shrinks to the drag-handle width; tracks 2-8 stay equal so all col-start/col-span/subgrid references below are unchanged --}} +
{{ __('budget-plan.budget-shortname') }} @@ -62,26 +72,60 @@
- @foreach($root_items[$budgetType->slug()] as $id) - + wire:sort="sort"> + @foreach($root_items[$budgetType->slug()] as $item) + @endforeach
- + New Group + {{-- span all 4 sub-columns so the button text doesn't force the auto col-1 (handle) track wider --}} +
+ {{ __('budget-plan.edit.new-group') }} + {{ __('budget-plan.edit.new-budget') }} +
@endforeach -
+
{{ __('budget-plan.edit.save') }} - - DEV: Reset Positions - - - Last saved yesterday - + @if (\App\Models\Setting::get('tax.active', false)) + {{ __('budget-plan.edit.add-tax-titles') }} + @endif
+ + {{-- mount picker: turn the chosen item into a reference to another plan's in/out --}} + + {{-- entangle so the select + confirm button react client-side (instant), no round-trip --}} +
+ {{ __('budget-plan.edit.mount-heading') }} + {{ __('budget-plan.edit.mount-sub') }} + + {{-- skeleton while the cycle-filtered candidate list loads --}} +
+
+
+
+ +
+ + {{ __('None') }} + @foreach($mount_candidates as $candidate) + {{ $candidate['label'] }} + @endforeach + + +
+ + {{ __('budget-plan.fiscal-year.cancel') }} + + + {{ __('budget-plan.edit.mount-confirm') }} + +
+
+
+
diff --git "a/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.php" "b/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.php" index abd9a00e..78761508 100644 --- "a/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.php" +++ "b/resources/views/pages/budget-plan/\342\232\241plan-edit/plan-edit.php" @@ -5,15 +5,19 @@ use App\Models\BudgetPlan; use App\Models\Enums\BudgetType; use App\Models\FiscalYear; +use App\Models\Setting; +use App\Models\TaxBudget; +use App\Support\Budget\TitleNumberer; use Cknow\Money\Money; use Flux\Flux; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Livewire\Attributes\Layout; use Livewire\Attributes\Url; use Livewire\Component; -new #[Layout('layout.app', ['size' => 'md'])] class extends Component +new #[Layout('layout.app', ['size' => 'lg'])] class extends Component { public $organization; @@ -28,6 +32,14 @@ public $refresh = false; + /** Mount picker state: the item being transformed into a mount, and the plan it references. */ + public $mount_item_id = null; + + public $mount_plan_id = null; + + /** @var list candidate plans, loaded only when the picker opens */ + public $mount_candidates = []; + /** * @var array an array which holds Livewire ItemForm objects. * $items[] @@ -36,28 +48,31 @@ public function mount(int $plan_id): void { - BudgetItem::eagerLoadRelations(['orderedChildren']); $plan = BudgetPlan::findOrFail($plan_id); + $this->authorize('update', $plan); + $this->organization = $plan->organization; $this->fiscal_year_id = $plan->fiscal_year_id; $this->resolution_date = $plan->resolution_date; $this->approval_date = $plan->approval_date; - // we don't want to have the models as public properties, therfore we build a array of Livewire Forms - $this->items = []; + $this->loadItems(); + } - // query for all items as a flat array. - $all_items = $this->query()->without('children') - ->get()->keyBy('id'); + /** + * (Re)build the array of Livewire ItemForms for every item of the plan. + * We don't keep the models as public properties, so each item is wrapped in + * a Form keyed by its id (matching the `items.{id}` bindings in the view). + */ + private function loadItems(): void + { + $this->items = []; - foreach ($all_items as $item) { - // registers new Livewire ItemForms; there is not yet a native way - // to generate a dynamic amount of ItemForms, or even multiple + foreach ($this->query()->without('children')->get() as $item) { $form = new ItemForm($this, 'items.'.$item->id); $form->setItem($item); $this->items[$item->id] = $form; } - } public function query(BudgetType|int|null $budget_type = null): Builder @@ -74,23 +89,101 @@ public function query(BudgetType|int|null $budget_type = null): Builder public function with(): array { $fiscal_years = FiscalYear::all(); - $item_models = $this->query() - ->whereIn('id', array_keys($this->items)) - ->get()->keyBy('id'); - $in_ids = $this->query(1)->whereNull('parent_id')->pluck('id'); - $out_ids = $this->query(-1)->whereNull('parent_id')->pluck('id'); + // Load the whole plan in ONE query and assemble the tree in memory, so the recursive + // blade walks pre-loaded relations instead of lazy-loading `orderedChildren` per node. + $all = BudgetItem::where('budget_plan_id', $this->plan_id) + ->orderBy('position') + ->get(); + + // attach each item's children as the `orderedChildren` relation the view recurses on + $byParent = $all->groupBy('parent_id'); + foreach ($all as $item) { + $item->setRelation('orderedChildren', $byParent->get($item->id, collect())); + } + + // preload referenced plans for any mount items (one query for all of them) + $refIds = $all->pluck('referenced_plan_id')->filter()->unique(); + $refPlans = $refIds->isNotEmpty() + ? BudgetPlan::whereIn('id', $refIds)->get()->keyBy('id') + : collect(); + foreach ($all as $item) { + if ($item->referenced_plan_id !== null) { + $item->setRelation('referencedPlan', $refPlans->get($item->referenced_plan_id)); + } + } + + // compute every item's effective value once, bottom-up (memoized), instead of + // re-summing each subtree at every level during render + $values = $this->computeValues($all); + + $roots = $all->whereNull('parent_id'); + $rootsFor = fn (BudgetType $type) => $roots + ->filter(fn (BudgetItem $i): bool => $i->budget_type === $type) + ->values(); return [ 'fiscal_years' => $fiscal_years, - 'all_items' => $item_models, + 'values' => $values, 'root_items' => [ - 'in' => $in_ids, - 'out' => $out_ids, + 'in' => $rootsFor(BudgetType::INCOME), + 'out' => $rootsFor(BudgetType::EXPENSE), ], + 'in_total' => $this->sumRoots($rootsFor(BudgetType::INCOME), $values), + 'out_total' => $this->sumRoots($rootsFor(BudgetType::EXPENSE), $values), ]; } + /** + * Compute the effective value of every item once, memoized into an [id => Money] map. + * Groups sum their (already-loaded) children; mounts resolve through the referenced plan + * (the only branch that still touches the DB, and only for the rare mount item). + * + * @param Collection $all all items, with orderedChildren set + * @return array + */ + private function computeValues($all): array + { + $map = []; + $resolve = function (BudgetItem $item) use (&$resolve, &$map): Money { + if (isset($map[$item->id])) { + return $map[$item->id]; + } + if ($item->isMount()) { + return $map[$item->id] = $item->effectiveValue(); + } + if ($item->is_group) { + $sum = Money::EUR(0); + foreach ($item->orderedChildren as $child) { + $sum = $sum->add($resolve($child)); + } + + return $map[$item->id] = $sum; + } + + return $map[$item->id] = $item->value ?? Money::EUR(0); + }; + foreach ($all as $item) { + $resolve($item); + } + + return $map; + } + + /** + * @param Collection $roots + * @param array $values + */ + private function sumRoots($roots, array $values): Money + { + $sum = Money::EUR(0); + foreach ($roots as $root) { + $sum = $sum->add($values[$root->id]); + } + + return $sum; + } + /** * Handle the updated event for an item's property. * This method processes changes to item properties and updates the corresponding record in the database. @@ -109,7 +202,7 @@ public function updatedItems(mixed $value, string $property): void if ($item_prop === 'value') { $this->reSumItemValues($item); } - Flux::toast('FIXME: Your changes have been saved.', variant: 'success'); + Flux::toast(__('budget-plan.edit.saved'), variant: 'success'); $this->refresh(); } } @@ -147,12 +240,14 @@ public function reSumItemValues(BudgetItem $leafItem): void public function updated(string $property): void { if (in_array($property, ['organization', 'fiscal_year_id', 'resolution_date', 'approval_date'])) { - $value = $this->$property; + // empty optional fields come back as '' (e.g. cleared fiscal-year listbox); + // store them as null so nullable columns / FKs don't reject the empty string + $value = $this->$property === '' ? null : $this->$property; $plan = BudgetPlan::findOrFail($this->plan_id); $plan->update([ $property => $value, ]); - Flux::toast(text: "$property -> $value", heading: 'FIXME: Your changes have been saved.', variant: 'success'); + Flux::toast(__('budget-plan.edit.saved'), variant: 'success'); } } @@ -184,7 +279,12 @@ public function sort($item_id, $new_position): void }); - Flux::toast('FIXME: Dragging and dropping', variant: 'success'); + Flux::toast(__('budget-plan.edit.saved'), variant: 'success'); + } + + public function createFiscalYear(): void + { + $this->redirect(route('fiscal-year.create'), navigate: true); } public function save() @@ -193,15 +293,45 @@ public function save() // $this->validate(); $plan = BudgetPlan::findOrFail($this->plan_id); + // empty optional fields come back as ''; store them as null so nullable columns don't reject the empty string $plan->update([ - 'resolution_date' => $this->resolution_date, - 'approval_date' => $this->approval_date, - 'organization' => $this->organization, + 'resolution_date' => $this->resolution_date ?: null, + 'approval_date' => $this->approval_date ?: null, + 'organization' => $this->organization ?: null, ]); $this->redirect(route('budget-plan.view', $this->plan_id)); } + /** + * Add the Umsatzsteuer (VAT) group and one title per configured tax rate to this plan, then + * refresh the tree. Idempotent — re-running only adds what is missing. Gated by the global + * tax.active setting and the plan-update policy. + */ + public function addTaxTitles(): void + { + $plan = BudgetPlan::findOrFail($this->plan_id); + $this->authorize('update', $plan); + + if (! Setting::get('tax.active', false)) { + Flux::toast(__('budget-plan.edit.tax-inactive'), variant: 'warning'); + + return; + } + + $added = TaxBudget::addToPlan($this->plan_id); + + $this->loadItems(); + $this->refresh(); + + Flux::toast( + $added > 0 + ? __('budget-plan.edit.tax-added', ['count' => $added]) + : __('budget-plan.edit.tax-exists'), + variant: $added > 0 ? 'success' : 'warning', + ); + } + public function addGroup(BudgetType $budget_type): void { $newPos = $this->query($budget_type)->whereNull('parent_id')->max('position') + 1; @@ -211,15 +341,24 @@ public function addGroup(BudgetType $budget_type): void 'budget_type' => $budget_type, 'is_group' => true, 'position' => $newPos, - 'value' => Money::EUR(100), + // group value is derived from its children (sum); starts at 0 until the + // child budget below is added — keeps the group == sum(children) invariant + 'value' => Money::EUR(0), ]); - $form = new ItemForm($this, 'items.'.$new_item->budget_type->slug().'.'.$new_item->id); + $this->autoNumber($new_item); + $form = new ItemForm($this, 'items.'.$new_item->id); $form->setItem($new_item); $this->items[$new_item->id] = $form; $this->addBudget($new_item->id); } + /** Add a plain budget line (leaf) at root level — no surrounding group. */ + public function addRootBudget(BudgetType $budget_type): void + { + $this->addItem(null, false, budget_type: $budget_type); + } + public function addBudget(int $parent_id, float $value = 0.0): void { $this->addItem($parent_id, false, $value); @@ -230,34 +369,52 @@ public function addSubGroup(int $parent_id): void $this->addItem($parent_id, true); } - private function addItem(int $parent_id, bool $is_group, $value = 0.0): void + private function addItem(?int $parent_id, bool $is_group, $value = 0.0, ?BudgetType $budget_type = null): void { - $parent = BudgetItem::findOrFail($parent_id); - if ($parent->is_group === 0) { + $parent = $parent_id !== null ? BudgetItem::findOrFail($parent_id) : null; + if ($parent !== null && $parent->is_group === 0) { return; } - $pos = $parent->children()->max('position'); + // root items take their type from the caller; nested items inherit it from the parent + $budget_type = $parent?->budget_type ?? $budget_type; + $pos = $parent !== null + ? $parent->children()->max('position') + : $this->query($budget_type)->whereNull('parent_id')->max('position'); + $new_item = BudgetItem::create([ 'parent_id' => $parent_id, - 'budget_plan_id' => $parent->budget_plan_id, - 'budget_type' => $parent->budget_type, + 'budget_plan_id' => $parent?->budget_plan_id ?? $this->plan_id, + 'budget_type' => $budget_type, 'is_group' => $is_group, 'position' => $pos + 1, 'value' => Money::EUR($value, true), ]); - $form = new ItemForm($this, 'items.'.$new_item->budget_type->slug().'.'.$new_item->id); + $this->autoNumber($new_item); + $form = new ItemForm($this, 'items.'.$new_item->id); $form->setItem($new_item); $this->items[$new_item->id] = $form; $this->refresh(); } + /** Fill the new item's Titelnummer (short_name) from the surrounding numbering. */ + private function autoNumber(BudgetItem $item): void + { + $item->short_name = resolve(TitleNumberer::class)->next($item); + $item->save(); + } + public function convertToGroup(int $item_id): void { $item = BudgetItem::findOrFail($item_id); if ($item->is_group) { return; } + if ($item->hasBookings()) { + Flux::toast(__('budget-plan.edit.has-bookings'), variant: 'danger'); + + return; + } $item->update(['is_group' => true]); $this->addBudget($item->id, $item->value->getAmount() / 100); } @@ -265,6 +422,14 @@ public function convertToGroup(int $item_id): void public function convertToBudget(int $item_id): void { $item = BudgetItem::findOrFail($item_id); + + // un-mount: a mount becomes a plain budget line again + if ($item->isMount()) { + $item->update(['referenced_plan_id' => null]); + $this->refresh(); + + return; + } if (! $item->is_group) { return; } @@ -273,29 +438,117 @@ public function convertToBudget(int $item_id): void } } - public function copyItem(int $item_id, bool $inverse = false): void + /** Open the picker to turn a childless item into a mount of another plan. */ + public function openMountPicker(int $item_id): void + { + $this->mount_item_id = $item_id; + $this->mount_plan_id = null; + + $fiscalYearId = $this->fiscal_year_id ?: null; + + // candidates: other plans in the SAME fiscal year (null matches null) that wouldn't + // create a reference cycle. Computed here (only on open) rather than in with(), which + // runs on every edit-page render. + $this->mount_candidates = BudgetPlan::where('id', '!=', $this->plan_id) + ->when( + $fiscalYearId === null, + fn ($query) => $query->whereNull('fiscal_year_id'), + fn ($query) => $query->where('fiscal_year_id', $fiscalYearId), + ) + ->get() + ->reject(fn (BudgetPlan $candidate): bool => $candidate->reachesPlan((int) $this->plan_id)) + ->map(fn (BudgetPlan $candidate): array => ['id' => $candidate->id, 'label' => $candidate->label()]) + ->values() + ->all(); + + Flux::modal('mount-plan')->show(); + } + + public function convertToMount(): void + { + $item = BudgetItem::findOrFail($this->mount_item_id); + + // a mount has no children of its own; otherwise it can sit anywhere in the tree + if ($item->children()->count() > 0) { + return; + } + if ($item->hasBookings()) { + Flux::toast(__('budget-plan.edit.has-bookings'), variant: 'danger'); + + return; + } + + $referenced = BudgetPlan::find($this->mount_plan_id); + if ($referenced === null || $referenced->reachesPlan((int) $this->plan_id)) { + Flux::toast(__('budget-plan.edit.mount-cycle'), variant: 'danger'); + + return; + } + + $item->update(['referenced_plan_id' => $referenced->id, 'is_group' => false]); + + $this->mount_item_id = null; + $this->mount_plan_id = null; + Flux::modal('mount-plan')->close(); + Flux::toast(__('budget-plan.edit.saved'), variant: 'success'); + $this->refresh(); + } + + public function copyItem(int $item_id): void { $item = BudgetItem::findOrFail($item_id); - $this->copyItemModel($item, $item->parent_id); + $this->copyTree($item, $item->parent_id, $item->budget_type, copyValues: true, nameSuffix: ' - '.__('budget-plan.edit.copy-suffix')); + $this->loadItems(); + Flux::toast(__('budget-plan.edit.saved'), variant: 'success'); } - private function copyItemModel(BudgetItem $item, ?int $parent_id): void + /** + * Mirror a root item (and its subtree) to the opposite budget side, with all + * values reset to 0. Only roots can be mirrored. + */ + public function copyInverse(int $item_id): void + { + $item = BudgetItem::findOrFail($item_id); + if ($item->parent_id !== null) { + return; + } + $this->copyTree($item, null, $item->budget_type->opposite(), copyValues: false); + $this->loadItems(); + Flux::toast(__('budget-plan.edit.saved'), variant: 'success'); + } + + /** + * Deep-copy an item and its descendants. $budgetType overrides the type (for the + * inverse copy); when $copyValues is false every value starts at 0. The new item's + * Titelnummer is auto-generated, and it is appended after its target siblings. + */ + private function copyTree(BudgetItem $item, ?int $parent_id, BudgetType $budgetType, bool $copyValues, string $nameSuffix = ''): void { $newItem = BudgetItem::create([ 'budget_plan_id' => $item->budget_plan_id, - 'budget_type' => $item->budget_type, + 'budget_type' => $budgetType, 'is_group' => $item->is_group, 'parent_id' => $parent_id, - 'value' => $item->value, - 'position' => $item->position + 1, - 'name' => $item->name.' - Copy', - 'short_name' => $item->short_name.' - Copy', + 'value' => $copyValues ? $item->value : Money::EUR(0), + 'position' => $this->nextPosition($item->budget_plan_id, $parent_id, $budgetType), + 'name' => $item->name.$nameSuffix, ]); + $this->autoNumber($newItem); - foreach ($item->children as $child) { - $this->copyItemModel($child, $newItem->id); + foreach ($item->orderedChildren as $child) { + $this->copyTree($child, $newItem->id, $budgetType, $copyValues); } + } + /** Position after the last of the target siblings (a parent's children, or the roots of a type). */ + private function nextPosition(int $plan_id, ?int $parent_id, BudgetType $budgetType): int + { + $query = BudgetItem::query()->where('budget_plan_id', $plan_id); + $parent_id !== null + ? $query->where('parent_id', $parent_id) + : $query->whereNull('parent_id')->where('budget_type', $budgetType); + + return (int) $query->max('position') + 1; } public function delete(int $item_id): void @@ -303,19 +556,22 @@ public function delete(int $item_id): void $item = BudgetItem::findOrFail($item_id); if ($item->children()->count() > 0) { - $this->addError('name', 'You cannot delete more than one item.'); + Flux::toast(__('budget-plan.edit.delete-has-children'), variant: 'danger'); return; } - $item->delete(); - $this->resumItemValues($item); - } + if ($item->hasBookings()) { + Flux::toast(__('budget-plan.edit.has-bookings'), variant: 'danger'); - public function resetPositions(): void - { - $plan = BudgetPlan::findOrFail($this->plan_id); - $plan->normalizePositions(); - $this->refresh(); + return; + } + DB::transaction(function () use ($item): void { + // a tax title is referenced by a tax_budget row (budget_id FK, RESTRICT); + // drop it first so the item delete doesn't hit the constraint + TaxBudget::where('budget_id', $item->id)->delete(); + $item->delete(); + }); + $this->reSumItemValues($item); } public function refresh(): void diff --git "a/resources/views/pages/budget-plan/\342\232\241plan-view/plan-view.blade.php" "b/resources/views/pages/budget-plan/\342\232\241plan-view/plan-view.blade.php" new file mode 100644 index 00000000..9b3142a3 --- /dev/null +++ "b/resources/views/pages/budget-plan/\342\232\241plan-view/plan-view.blade.php" @@ -0,0 +1,196 @@ +@php use App\Models\Enums\BudgetType; @endphp + +
+ + {{ __('budget-plan.view.headline') }} · {{ $plan->label() }} + + + {{ $plan->state->label() }} + @if($plan->fiscalYear) + {{ __('budget-plan.fiscal-year') }}: {{ $plan->fiscalYear->label() }} + @endif + + + + + {{ __('budget-plan.view.actions') }} + + {{ __('budget-plan.view.edit') }} + @can('update', $plan) + + {{ __('budget-plan.view.change-state') }} + + @endcan + @can('create', \App\Models\BudgetPlan::class) + {{-- duplication is "create from an existing plan": deep-link into the create flow with this plan preselected as the clone source --}} + {{ __('budget-plan.view.duplicate') }} + @endcan + {{-- TODO: print not yet implemented — disabled until the print flow exists --}} + {{ __('budget-plan.view.print') }} + {{-- downloads must be real navigations (file responses), so no wire:navigate here --}} + + + {{ __('budget-plan.view.export.excel') }} + + + {{ __('budget-plan.view.export.ods') }} + + + @can('admin', \App\Models\User::class) + + + {{ __('budget-plan.view.delete') }} + + @endcan + + + + + + @php + $income = $plan->incomeTotal(); + $expense = $plan->expenseTotal(); + $balance = $income->subtract($expense); + @endphp +
+
+
+
{{ __('budget-plan.view.summary.income') }}
+
{{ $income->format() }}
+
+
+
{{ __('budget-plan.view.summary.expense') }}
+
{{ $expense->format() }}
+
+
+
{{ __('budget-plan.view.summary.balance') }}
+
$balance->isNegative(), + 'text-green-600' => ! $balance->isNegative(), + ])>{{ $balance->format() }}
+
+
+
+ + {{-- Budgetplan table --}} + + + + {{ __('budget-plan.edit.tab-headline.in') }} + + + {{ __('budget-plan.edit.tab-headline.out') }} + + + + @foreach(BudgetType::cases() as $budgetType) + +
+
+
+
+
+ + + + + + + + + + + + + @foreach($items[$budgetType->slug()] as $item) + + @endforeach + +
+ {{ __('budget-plan.budget-shortname') }} + + {{ __('budget-plan.budget-longname') }} + + {{-- Sigma column --}} + + + {{ __('budget-plan.view.col.planned') }} + + + + {{ __('budget-plan.view.col.planned-hint') }} + + + + + + {{ __('budget-plan.view.col.booked') }} + + + + {{ __('budget-plan.view.col.booked-hint') }} + + + + + + {{ __('budget-plan.view.col.committed') }} + + + + {{ __('budget-plan.view.col.committed-hint') }} + + + +
+
+
+
+
+
+
+ @endforeach +
+ + {{-- state-change modal: lists only the transitions allowed from the current state --}} + +
+ {{ __('budget-plan.view.state-modal.heading') }} + @php $transitions = $plan->state->transitionableStateInstances(); @endphp + @if(count($transitions) === 0) + {{ __('budget-plan.view.state-modal.no-transitions') }} + @else + + @foreach($transitions as $state) + +
+ + {{ $state->label() }} +
+
+ @endforeach +
+ @endif +
+ @error('newState') +
+

{{ $message }}

+
+ @enderror +
+ + + {{ __('budget-plan.view.state-modal.cancel') }} + + + {{ __('budget-plan.view.state-modal.save') }} + +
+
+
diff --git "a/resources/views/pages/budget-plan/\342\232\241plan-view/plan-view.php" "b/resources/views/pages/budget-plan/\342\232\241plan-view/plan-view.php" new file mode 100644 index 00000000..b68942e9 --- /dev/null +++ "b/resources/views/pages/budget-plan/\342\232\241plan-view/plan-view.php" @@ -0,0 +1,92 @@ + 'lg'])] class extends Component +{ + #[Locked] + public int $plan_id; + + public $newState; + + public function mount(int $plan_id): void + { + $this->plan_id = $plan_id; + $this->authorize('view', $this->plan()); + } + + public function with(): array + { + $plan = $this->plan(); + + return [ + 'plan' => $plan, + 'items' => [ + // annotate() returns the flattened tree with booked/committed Money set per node + BudgetType::INCOME->slug() => new BudgetPlanMeasures($plan, BudgetType::INCOME)->annotate(), + BudgetType::EXPENSE->slug() => new BudgetPlanMeasures($plan, BudgetType::EXPENSE)->annotate(), + ], + ]; + } + + /** + * Move the plan along its workflow. Mirrors the project state-change flow: + * validate the target, authorize the transition, then run it via the state machine. + */ + public function changeState(): void + { + $plan = $this->plan(); + $filtered = $this->validate(['newState' => ['required', new ValidStateRule(BudgetPlanState::class)]]); + $newState = BudgetPlanState::make($filtered['newState'], $plan); + + $this->authorize('transition-to', [$plan, $newState]); + + try { + $plan->state->transitionTo($newState); + Flux::toast(__('budget-plan.view.state-changed'), variant: 'success'); + Flux::modal('state-modal')->close(); + $this->reset('newState'); + } catch (CouldNotPerformTransition $e) { + $this->addError('newState', $e->getMessage()); + } + } + + /** + * Delete the whole plan and its items. Admin-only for now. + */ + public function deletePlan(): void + { + $this->authorize('admin', User::class); + + $plan = $this->plan(); + + DB::transaction(static function () use ($plan): void { + // budget_item has a self-referencing parent_id FK and a plan FK without cascade; + // drop the items with checks off, then the plan itself + Schema::disableForeignKeyConstraints(); + $plan->budgetItems()->delete(); + $plan->delete(); + Schema::enableForeignKeyConstraints(); + }); + + Flux::toast(__('budget-plan.view.plan-deleted'), variant: 'success'); + $this->redirect(route('budget-plan.index'), navigate: true); + } + + private function plan(): BudgetPlan + { + return BudgetPlan::findOrFail($this->plan_id); + } +}; diff --git "a/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.blade.php" "b/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.blade.php" index 69be83ac..7bcc77fc 100644 --- "a/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.blade.php" +++ "b/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.blade.php" @@ -1,18 +1,36 @@
- Test + {{ $id ? __('budget-plan.fiscal-year.edit.headline') : __('budget-plan.fiscal-year.create.headline') }} - Lorem ipsum + {{ __('budget-plan.fiscal-year.edit.sub') }} -
- - -
-
- - Speichern - -
+
+ + + + @if(count($this->gaps) > 0) + + {{ __('budget-plan.fiscal-year.gap-warning.heading') }} + + {{ __('budget-plan.fiscal-year.gap-warning.text') }} +
    + @foreach($this->gaps as $gap) +
  • {{ $gap['start']->format('d.m.Y') }} – {{ $gap['end']->format('d.m.Y') }}
  • + @endforeach +
+
+
+ @endif + +
+ + {{ __('budget-plan.fiscal-year.save') }} + + + {{ __('budget-plan.fiscal-year.cancel') }} + +
+
diff --git "a/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.php" "b/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.php" index 0ac96dd7..aecb5c45 100644 --- "a/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.php" +++ "b/resources/views/pages/fiscal-year/\342\232\241edit-fiscal-year/edit-fiscal-year.php" @@ -1,6 +1,12 @@ 'md'])] class extends Component { #[Locked] - public $id; + public ?int $id = null; - public $start_date; + public ?string $start_date = null; - public $end_date; + public ?string $end_date = null; - public function mount($year_id = null) + public function mount($year_id = null): void { + Gate::authorize('budget-officer', User::class); + $this->id = $year_id; + if ($this->id) { - // edit - $fiscal_year = FiscalYear::find($this->id); - $this->start_date = $fiscal_year->start_date->format('Y-m-d'); - $this->end_date = $fiscal_year->end_date->format('Y-m-d'); - } else { - // create with suggestions - $lastYear = FiscalYear::orderBy('end_date', 'desc')->limit(1)->first(); - if ($lastYear) { - $this->start_date = $lastYear->end_date->addDay()->format('Y-m-d'); - $this->end_date = $lastYear->end_date->addYear()->format('Y-m-d'); - } + // edit an existing fiscal year + $fiscalYear = FiscalYear::findOrFail($this->id); + $this->start_date = $fiscalYear->start_date->format('Y-m-d'); + $this->end_date = $fiscalYear->end_date->format('Y-m-d'); + + return; + } + + // create: suggest the year directly following the latest one + $lastYear = FiscalYear::orderBy('end_date', 'desc')->first(); + if ($lastYear) { + $nextStart = $lastYear->end_date->copy()->addDay(); + $this->start_date = $nextStart->format('Y-m-d'); + // a fiscal year spans one full year, e.g. 01.04.24 – 31.03.25 + $this->end_date = $nextStart->copy()->addYear()->subDay()->format('Y-m-d'); } } + /** + * Fiscal years are meant to tile time without gaps. Detect whether the + * currently entered range leaves a hole to the nearest neighbouring fiscal + * year on either side, so the form can warn (not block) about it. + * + * @return list + */ + #[Computed] + public function gaps(): array + { + if (! $this->start_date || ! $this->end_date) { + return []; + } + + try { + $start = Date::parse($this->start_date)->startOfDay(); + $end = Date::parse($this->end_date)->startOfDay(); + } catch (Throwable) { + return []; + } + + if ($end->lessThan($start)) { + return []; + } + + $gaps = []; + + $previous = FiscalYear::query() + ->when($this->id, fn ($query) => $query->whereKeyNot($this->id)) + ->whereDate('end_date', '<', $start) + ->latest('end_date') + ->first(); + + if ($previous && $previous->end_date->copy()->addDay()->lessThan($start)) { + $gaps[] = ['start' => $previous->end_date->copy()->addDay(), 'end' => $start->copy()->subDay()]; + } + + $next = FiscalYear::query() + ->when($this->id, fn ($query) => $query->whereKeyNot($this->id)) + ->whereDate('start_date', '>', $end) + ->oldest('start_date') + ->first(); + + if ($next && $next->start_date->copy()->subDay()->greaterThan($end)) { + $gaps[] = ['start' => $end->copy()->addDay(), 'end' => $next->start_date->copy()->subDay()]; + } + + return $gaps; + } + public function rules(): array { return [ - 'start_date' => 'required|date', - 'end_date' => 'required|date|after_or_equal:start_date', + 'start_date' => ['required', 'date'], + 'end_date' => [ + 'required', + 'date', + 'after_or_equal:start_date', + // fiscal years must not overlap: they partition time without gaps or overlaps + function (string $attribute, $value, callable $fail): void { + $overlaps = FiscalYear::query() + ->when($this->id, fn ($query) => $query->whereKeyNot($this->id)) + ->whereDate('start_date', '<=', $this->end_date) + ->whereDate('end_date', '>=', $this->start_date) + ->exists(); + + if ($overlaps) { + $fail(__('budget-plan.fiscal-year.overlap-error')); + } + }, + ], ]; } - public function save() + public function save(): void { + Gate::authorize('budget-officer', User::class); $this->validate(); - FiscalYear::updateOrCreate([ - 'id' => $this->id, - ], [ + + $fiscalYear = $this->id ? FiscalYear::findOrFail($this->id) : new FiscalYear; + $fiscalYear->fill([ 'start_date' => $this->start_date, 'end_date' => $this->end_date, - ]); - $this->redirect(route('budget-plan.index')); + ])->save(); + + Flux::toast(__('budget-plan.fiscal-year.saved'), variant: 'success'); + + $this->redirect(route('budget-plan.index'), navigate: true); } }; diff --git "a/resources/views/pages/project/\342\232\241edit-project/edit-project.php" "b/resources/views/pages/project/\342\232\241edit-project/edit-project.php" index ac27a0b9..ecf203c9 100644 --- "a/resources/views/pages/project/\342\232\241edit-project/edit-project.php" +++ "b/resources/views/pages/project/\342\232\241edit-project/edit-project.php" @@ -325,10 +325,10 @@ public function addEmptyPost(): void public function addTaxPosts(): void { - TaxBudget::where('hhp_id', $this->hhp_id)->get()->each(function (TaxBudget $taxBudget): void { - $budgetTitle = $taxBudget->legacyBudgetTitle; + TaxBudget::where('plan_id', $this->hhp_id)->get()->each(function (TaxBudget $taxBudget): void { + $budgetTitle = $taxBudget->budgetTitle; $this->posts[] = ([ - 'name' => $budgetTitle->titel_name.' - Einnahmen', + 'name' => $budgetTitle->name.' - Einnahmen', 'bemerkung' => 'Steuer', 'einnahmen' => Money::EUR($taxBudget->tax_percent), 'ausgaben' => Money::EUR(0), @@ -336,7 +336,7 @@ public function addTaxPosts(): void 'readonly' => false, ]); $this->posts[] = ([ - 'name' => $budgetTitle->titel_name.' - Ausgaben', + 'name' => $budgetTitle->name.' - Ausgaben', 'bemerkung' => 'Steuer', 'einnahmen' => Money::EUR(0), 'ausgaben' => Money::EUR($taxBudget->tax_percent), @@ -589,7 +589,7 @@ public function with(): array && collect($this->posts)->filter(fn ($post) => $post['readonly'])->isEmpty(); $canUpdateApproval = Auth::user()->can('update-approval', $this->getProject()); - $hasTaxTitels = TaxBudget::where('hhp_id', $this->hhp_id)->exists(); + $hasTaxTitels = TaxBudget::where('plan_id', $this->hhp_id)->exists(); $canAddTaxTitles = collect($this->posts)->filter(fn ($post) => $post['bemerkung'] === 'Steuer')->isEmpty(); // Backlink to the origin project: for a new copy/leftovers draft it comes diff --git "a/resources/views/pages/project/\342\232\241show-project/show-project.blade.php" "b/resources/views/pages/project/\342\232\241show-project/show-project.blade.php" index 13763a7d..c377ce3e 100644 --- "a/resources/views/pages/project/\342\232\241show-project/show-project.blade.php" +++ "b/resources/views/pages/project/\342\232\241show-project/show-project.blade.php" @@ -515,7 +515,7 @@ class="inline-flex items-center text-indigo-600 hover:text-indigo-800 transition @if($post->budgetItem) @php $budgetItem = $post->budgetItem @endphp - {{ $budgetItem->titel_name }} ({{ $budgetItem->titel_nr }}) + {{ $budgetItem->name }} ({{ $budgetItem->short_name }}) @endif diff --git a/routes/breadcrumbs.php b/routes/breadcrumbs.php index 12e3397d..bb8cc129 100644 --- a/routes/breadcrumbs.php +++ b/routes/breadcrumbs.php @@ -4,9 +4,11 @@ // Note: Laravel will automatically resolve `Breadcrumbs::` without // this import. This is nice for IDE syntax and refactoring. -use Diglactic\Breadcrumbs\Breadcrumbs; +use App\Models\BudgetItem; // This import is also not required, and you could replace `BreadcrumbTrail $trail` // with `$trail`. This is nice for IDE type checking and completion. +use App\Models\BudgetPlan; +use Diglactic\Breadcrumbs\Breadcrumbs; use Diglactic\Breadcrumbs\Generator as BreadcrumbTrail; /** @@ -166,8 +168,7 @@ // Home > project > PID Breadcrumbs::for('project.show', static function (BreadcrumbTrail $trail, $project_id): void { $trail->parent('legacy.dashboard'); - $trail->push(__('general.breadcrumb.project')); - $trail->push($project_id, route('project.show', $project_id)); + $trail->push(__('general.breadcrumb.project').' '.$project_id, route('project.show', $project_id)); }); // Home > project > PID > Edit @@ -185,8 +186,7 @@ // Home > project > PID > Abrechnung > AID Breadcrumbs::for('legacy.expense-long', static function (BreadcrumbTrail $trail, $project_id, $auslagen_id): void { $trail->parent('project.show', $project_id); - $trail->push(__('general.breadcrumb.abrechnung')); - $trail->push($auslagen_id, route('legacy.expense', $auslagen_id)); + $trail->push(__('general.breadcrumb.abrechnung').' '.$auslagen_id, route('legacy.expense', $auslagen_id)); }); // Home > project > PID > Abrechnung > AID > BelegePDF @@ -220,7 +220,13 @@ // Home > Budget-Plans > ID Breadcrumbs::for('budget-plan.view', static function (BreadcrumbTrail $trail, $plan_id): void { $trail->parent('budget-plan.index'); - $trail->push($plan_id, route('budget-plan.view', $plan_id)); + + $plan = BudgetPlan::find($plan_id); + $label = $plan + ? collect([$plan->label(), $plan->fiscalYear?->label()])->filter()->implode(' · ') + : $plan_id; + + $trail->push($label, route('budget-plan.view', $plan_id)); }); // Home > Budget-Plans > ID @@ -229,6 +235,18 @@ $trail->push(__('general.breadcrumb.budget-plan-edit'), route('budget-plan.edit', $plan_id)); }); +// Home > Budget-Plans > ID > Titel +Breadcrumbs::for('budget-plan.item.view', static function (BreadcrumbTrail $trail, $plan_id, $item_id): void { + $trail->parent('budget-plan.view', $plan_id); + + $item = BudgetItem::find($item_id); + $label = $item + ? collect([$item->short_name, $item->name])->filter()->implode(' · ') + : $item_id; + + $trail->push($label, route('budget-plan.item.view', [$plan_id, $item_id])); +}); + // Home > Admin Interface Breadcrumbs::for('config', static function (BreadcrumbTrail $trail): void { $trail->parent('legacy.dashboard'); @@ -239,3 +257,21 @@ $trail->parent('legacy.dashboard'); $trail->push(__('general.breadcrumb.changelog'), route('changelog')); }); + +// Home > Budget-Plans > New budget plan +Breadcrumbs::for('budget-plan.create', static function (BreadcrumbTrail $trail): void { + $trail->parent('budget-plan.index'); + $trail->push(__('budget-plan.create.headline'), route('budget-plan.create')); +}); + +// Home > Budget-Plans > New fiscal year +Breadcrumbs::for('fiscal-year.create', static function (BreadcrumbTrail $trail): void { + $trail->parent('budget-plan.index'); + $trail->push(__('budget-plan.edit.add-fiscal-year'), route('fiscal-year.create')); +}); + +// Home > Budget-Plans > Fiscal year ID +Breadcrumbs::for('fiscal-year.edit', static function (BreadcrumbTrail $trail, $year_id): void { + $trail->parent('budget-plan.index'); + $trail->push(__('budget-plan.fiscal-year').' '.$year_id, route('fiscal-year.edit', $year_id)); +}); diff --git a/routes/web-dev.php b/routes/web-dev.php index 00109ec1..1a52e6fd 100644 --- a/routes/web-dev.php +++ b/routes/web-dev.php @@ -1,7 +1,5 @@ group(function (): void { @@ -9,13 +7,4 @@ // Route::livewire('antrag/create', \App\Livewire\CreateAntrag::class)->name('antrag.create'); // Route::livewire('antrag/new-org', \App\Livewire\PTF15\CreateAntrag\NewOrganisation::class)->name('antrag.new-org'); - // Feature Budget Plans - Route::get('plan', [BudgetPlanController::class, 'index'])->name('budget-plan.index'); - Route::get('plan/create', [BudgetPlanController::class, 'create'])->name('budget-plan.create'); - Route::get('plan/{plan_id}', [BudgetPlanController::class, 'show'])->name('budget-plan.view'); - Route::livewire('plan/{plan_id}/edit', 'pages::budget-plan.plan-edit')->name('budget-plan.edit'); - - Route::livewire('year/create', 'pages::fiscal-year.edit-fiscal-year')->name('fiscal-year.create'); - Route::livewire('year/{year_id}', 'pages::fiscal-year.edit-fiscal-year')->name('fiscal-year.edit'); - }); diff --git a/routes/web.php b/routes/web.php index 6550176f..8bb449c7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,6 +12,8 @@ */ use App\Http\Controllers\AuthController; +use App\Http\Controllers\BudgetPlanController; +use App\Http\Controllers\BudgetPlanExportController; use App\Http\Controllers\DatevExportController; use App\Http\Controllers\Legacy\TransactionView; use App\Http\Controllers\ProjectController; @@ -22,9 +24,15 @@ Route::middleware(['auth'])->group(function (): void { Route::get('/', function () { - $sub = Auth::user()->getCommittees()->isEmpty() ? 'allgremium' : 'mygremium'; $latestPlan = LegacyBudgetPlan::latest(); + // Fresh install with no budget plan yet: send the user to the plan overview. + if ($latestPlan === null) { + return to_route('budget-plan.index'); + } + + $sub = Auth::user()->getCommittees()->isEmpty() ? 'allgremium' : 'mygremium'; + return to_route('legacy.dashboard', ['sub' => $sub, 'hhp_id' => $latestPlan->id]); })->name('home'); @@ -50,6 +58,16 @@ Route::permanentRedirect('projekt/create', '/project/create'); Route::permanentRedirect('projekt/{project_id}', '/project/{project_id}'); Route::permanentRedirect('projekt/{project_id}/edit', '/project/{project_id}/edit'); + + // Feature Budget Plans + Route::get('plan', [BudgetPlanController::class, 'index'])->name('budget-plan.index'); + Route::livewire('plan/create', 'pages::budget-plan.plan-create')->name('budget-plan.create'); + Route::livewire('plan/{plan_id}', 'pages::budget-plan.plan-view')->name('budget-plan.view'); + Route::livewire('plan/{plan_id}/edit', 'pages::budget-plan.plan-edit')->name('budget-plan.edit'); + Route::livewire('plan/{plan_id}/item/{item_id}', 'pages::budget-plan.item-view')->name('budget-plan.item.view'); + Route::get('plan/{plan_id}/export/{filetype}', [BudgetPlanExportController::class, 'download'])->name('budget-plan.export'); + Route::livewire('year/create', 'pages::fiscal-year.edit-fiscal-year')->name('fiscal-year.create'); + Route::livewire('year/{year_id}', 'pages::fiscal-year.edit-fiscal-year')->name('fiscal-year.edit'); }); // login routes diff --git a/storage/demo/stufis-demo-data.sql b/storage/demo/stufis-demo-data.sql index 9ceb01e4..92a3f9a5 100644 --- a/storage/demo/stufis-demo-data.sql +++ b/storage/demo/stufis-demo-data.sql @@ -812,115 +812,6 @@ INSERT INTO `demo__fileinfo` (`id`,`link`,`added_on`,`hashname`,`filename`,`size (91,'93','2025-02-04 15:29:41','TxL6PxIdkEc5TsLONzw63KTFBLu4p6R9LyUNfAQA','Beleg Platzhalter',34567,'pdf','application/pdf',NULL,91), (92,'94','2025-02-04 15:43:13','EpCKiZZ5UybTMvOfTLbRieiLYychWH4Dcnm6g4kh','noch ein Beleg Platzhalter',35822,'pdf','application/pdf',NULL,92); --- --- Daten für Tabelle `demo__haushaltsgruppen` --- - -INSERT INTO `demo__haushaltsgruppen` (`id`,`hhp_id`,`gruppen_name`,`type`) VALUES - (1,1,'laufende Einnahmen',0), - (2,1,'Stura-Dienstleistungen',0), - (3,1,'Gremienarbeit- und Projekte',0), - (4,1,'Rückzahlungen und Gebühren',1), - (5,1,'Ausgaben für Ausstattung',1), - (6,1,'Stura-Dienstleistungen',1), - (7,1,'Gremienarbeit- und Projekte',1), - (8,2,'laufende Einnahmen',0), - (9,2,'Stura-Dienstleistungen',0), - (10,2,'Gremienarbeit- und Projekte',0), - (11,2,'Semesterbeitragszuweisung',0), - (12,2,'Rückzahlungen und Gebühren',1), - (13,2,'Ausgaben für Ausstattung',1), - (14,2,'Stura-Dienstleistungen',1), - (15,2,'Gremienarbeit- und Projekte',1); - --- --- Daten für Tabelle `demo__haushaltsplan` --- - -INSERT INTO `demo__haushaltsplan` (`id`,`von`,`bis`,`state`) VALUES - (1,'2023-04-01','2024-03-31','final'), - (2,'2024-04-01',NULL,'final'); - --- --- Daten für Tabelle `demo__haushaltstitel` --- - -INSERT INTO `demo__haushaltstitel` (`id`,`hhpgruppen_id`,`titel_name`,`titel_nr`,`value`) VALUES - (1,1,'Semesterbeiträge','E.1.1',100000.00), - (2,1,'Zinseinnahmen','E.1.2',0.00), - (3,2,'Theaterfahrten','E.2.1',500.00), - (4,2,'Exkursionen','E.2.2',500.00), - (5,3,'Fachschaftsräte','E.3.1',0.00), - (6,3,'FSR EI','E.3.1.1',0.00), - (7,3,'FSR IA','E.3.1.2',0.00), - (8,3,'FSR MB','E.3.1.3',0.00), - (9,3,'FSR MN','E.3.1.4',0.00), - (10,3,'FSR WM','E.3.1.5',0.00), - (11,3,'StuRa-Projekte','E.3.2',0.00), - (12,3,'Erstiwoche','E.3.3',5000.00), - (13,4,'Kontogebühren','A.1.1',200.00), - (14,4,'Angestellte','A.1.2',40000.00), - (15,4,'Versicherungen','A.1.3',1000.00), - (16,4,'Transferkonto','A.1.4',0.00), - (17,5,'Telefon und Faxdienste','A.2.1',50.00), - (18,5,'Bürobedarf','A.2.2',1000.00), - (19,5,'Domains und IT-Dienstleistungen','A.2.3',3000.00), - (20,6,'Theaterfahrten','A.3.1',1000.00), - (21,6,'Exkursionen','A.3.2',1000.00), - (22,6,'Zeitungen und Zeitschriften','A.3.3',200.00), - (23,6,'Veröffentlichungen','A.3.4',500.00), - (24,7,'Fachschaftsräte','A.4.1',10000.00), - (25,7,'FSR EI','A.4.1.1',2000.00), - (26,7,'FSR IA','A.4.1.2',2000.00), - (27,7,'FSR MB','A.4.1.3',2000.00), - (28,7,'FSR MN','A.4.1.4',2000.00), - (29,7,'FSR WM','A.4.1.5',2000.00), - (30,7,'StuRa-Projekte','A.4.2',30000.00), - (31,7,'Erstiwoche','A.4.3',20000.00), - (32,7,'Reisekosten','A.4.4',2000.00), - (33,7,'Mitgliedsbeiträge','A.4.5',2000.00), - (34,7,'Gremienwahlen','A.4.6',1000.00), - (35,7,'Klausurtagung','A.4.7',6000.00), - (36,9,'Theaterfahrten','E.2.1',50.00), - (37,9,'Exkursionen','E.2.2',50.00), - (38,10,'Fachschaftsräte','E.3.1',0.00), - (39,10,'FSR EI','E.3.1.1',0.00), - (40,10,'FSR IA','E.3.1.2',0.00), - (41,10,'FSR MB','E.3.1.3',0.00), - (42,10,'FSR MN','E.3.1.4',0.00), - (43,10,'FSR WM','E.3.1.5',0.00), - (44,10,'StuRa-Projekte','E.3.2',2000.00), - (45,10,'Erstiwoche','E.3.3',2000.00), - (46,11,'FSR EI','E.4.1',2000.00), - (47,11,'FSR IA','E.4.2',2000.00), - (48,11,'FSR MB','E.4.3',2000.00), - (49,11,'FSR MN','E.4.4',2000.00), - (50,11,'FSR WM','E.4.5',2000.00), - (51,12,'Kontogebühren','A.1.1',250.00), - (52,12,'Angestellte','A.1.2',51600.00), - (53,12,'Versicherungen','A.1.3',0.00), - (54,12,'Transferkonto','A.1.4',0.00), - (55,13,'Telefon und Faxdienste','A.2.1',50.00), - (56,13,'Bürobedarf','A.2.2',500.00), - (57,13,'Domains und IT-Dienstleistungen','A.2.3',1200.00), - (58,14,'Theaterfahrten','A.3.1',1000.00), - (59,14,'Exkursionen','A.3.2',1000.00), - (60,14,'Zeitungen und Zeitschriften','A.3.3',100.00), - (61,14,'Veröffentlichungen','A.3.4',350.00), - (62,15,'Fachschaftsräte','A.4.1',10000.00), - (63,15,'FSR EI','A.4.1.1',2000.00), - (64,15,'FSR IA','A.4.1.2',2000.00), - (65,15,'FSR MB','A.4.1.3',2000.00), - (66,15,'FSR MN','A.4.1.4',2000.00), - (67,15,'FSR WM','A.4.1.5',2000.00), - (68,15,'StuRa-Projekte','A.4.2',39400.00), - (69,15,'Erstiwoche','A.4.3',30000.00), - (70,15,'Reisekosten','A.4.4',2000.00), - (71,15,'Mitgliedsbeiträge','A.4.5',0.00), - (72,15,'Gremienwahlen','A.4.6',2000.00), - (73,15,'Klausurtagung','A.4.7',4500.00), - (74,8,'Semesterbeiträge','E1.1',100000.00), - (75,8,'Zinseinnahmen','E1.2',10.00); -- -- Daten für Tabelle `demo__konto` diff --git a/tests/Pest/BudgetPlan/BudgetPlanClonerTest.php b/tests/Pest/BudgetPlan/BudgetPlanClonerTest.php new file mode 100644 index 00000000..89794c05 --- /dev/null +++ b/tests/Pest/BudgetPlan/BudgetPlanClonerTest.php @@ -0,0 +1,132 @@ + Draft::class, + 'fiscal_year_id' => $fiscalYearId, + 'organization' => $organization, + ]); +} + +function addMount(BudgetPlan $parent, BudgetPlan $sub, BudgetType $side, int $position): void +{ + $parent->budgetItems()->create([ + 'is_group' => false, 'budget_type' => $side, 'position' => $position, + 'referenced_plan_id' => $sub->id, + ]); +} + +function cloner(): BudgetPlanCloner +{ + return resolve(BudgetPlanCloner::class); +} + +it('clones a multi-level forest preserving structure, names, values and positions', function (): void { + $source = clonePlan(); + $group = $source->budgetItems()->create([ + 'is_group' => true, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1', + ]); + $group->children()->create([ + 'budget_plan_id' => $source->id, 'is_group' => false, 'budget_type' => BudgetType::INCOME, + 'position' => 0, 'short_name' => 'E1.1', 'value' => Money::EUR(200, true), + ]); + $group->children()->create([ + 'budget_plan_id' => $source->id, 'is_group' => false, 'budget_type' => BudgetType::INCOME, + 'position' => 1, 'short_name' => 'E1.2', 'value' => Money::EUR(300, true), + ]); + + $target = clonePlan(); + cloner()->cloneInto($source, $target, []); + + expect($target->budgetItems()->count())->toBe(3); + + $root = $target->rootBudgetItems()->first(); + expect($root->is_group)->toBeTrue()->and($root->short_name)->toBe('E1'); + + $children = $root->orderedChildren; + expect($children->pluck('short_name')->all())->toBe(['E1.1', 'E1.2']) + ->and($children->pluck('position')->all())->toBe([0, 1]) + ->and($target->incomeTotal()->getAmount())->toBe('50000'); +}); + +it('copies a mounted sub-plan and re-points the mount when the choice is copy', function (): void { + $sub = clonePlan(); + $sub->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0, + 'short_name' => 'E1', 'value' => Money::EUR(500, true), + ]); + $source = clonePlan(); + addMount($source, $sub, BudgetType::INCOME, 0); + + $target = clonePlan(); + cloner()->cloneInto($source, $target, [$sub->id => 'copy']); + + $mount = $target->rootBudgetItems()->first(); + expect($mount->isMount())->toBeTrue() + ->and($mount->referenced_plan_id)->not->toBe($sub->id) + ->and($target->incomeTotal()->getAmount())->toBe('50000'); + + $clonedSub = BudgetPlan::find($mount->referenced_plan_id); + expect($clonedSub->id)->not->toBe($sub->id) + ->and($clonedSub->incomeTotal()->getAmount())->toBe('50000'); +}); + +it('drops a mount to an empty group when the choice is drop', function (): void { + $sub = clonePlan(); + $sub->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0, + 'short_name' => 'E1', 'value' => Money::EUR(500, true), + ]); + $source = clonePlan(); + addMount($source, $sub, BudgetType::INCOME, 0); + + $target = clonePlan(); + cloner()->cloneInto($source, $target, [$sub->id => 'drop']); + + $item = $target->rootBudgetItems()->first(); + expect($item->isMount())->toBeFalse() + ->and($item->is_group)->toBeTrue() + ->and($item->effectiveValue()->getAmount())->toBe('0') + ->and($target->incomeTotal()->getAmount())->toBe('0'); +}); + +it('clones a sub-plan only once when it is mounted multiple times', function (): void { + $sub = clonePlan(); + $sub->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0, + 'short_name' => 'E1', 'value' => Money::EUR(500, true), + ]); + $source = clonePlan(); + addMount($source, $sub, BudgetType::INCOME, 0); + addMount($source, $sub, BudgetType::EXPENSE, 0); + + $target = clonePlan(); + $before = BudgetPlan::count(); + cloner()->cloneInto($source, $target, [$sub->id => 'copy']); + + expect(BudgetPlan::count() - $before)->toBe(1); // one shared sub-plan clone + + $refs = $target->budgetItems()->pluck('referenced_plan_id')->unique()->values(); + expect($refs)->toHaveCount(1)->and($refs->first())->not->toBe($sub->id); +}); + +it('suffixes the organization only on a same-year collision', function (): void { + $year = FiscalYear::factory()->create(); + BudgetPlan::create(['state' => Draft::class, 'organization' => 'Acme', 'fiscal_year_id' => $year->id]); + + $suffix = __('budget-plan.edit.copy-suffix'); + expect(BudgetPlan::resolveOrganization('Acme', $year->id))->toBe("Acme ($suffix)") + ->and(BudgetPlan::resolveOrganization('Acme', null))->toBe('Acme') // different year + ->and(BudgetPlan::resolveOrganization('Other', $year->id))->toBe('Other'); +}); diff --git a/tests/Pest/BudgetPlan/BudgetPlanCreateTest.php b/tests/Pest/BudgetPlan/BudgetPlanCreateTest.php index ea97feb0..e6a15708 100644 --- a/tests/Pest/BudgetPlan/BudgetPlanCreateTest.php +++ b/tests/Pest/BudgetPlan/BudgetPlanCreateTest.php @@ -1,54 +1,184 @@ set('stufis.features', 'dev'); +uses(DatabaseTransactions::class); + +it('renders the create page (layout + breadcrumb) for a budget officer', function (): void { + $this->actingAs(budgetManager()); + + $this->get(route('budget-plan.create')) + ->assertOk() + ->assertSee(__('budget-plan.create.headline')); +}); + +it('creates a blank-template draft plan and redirects to edit', function (): void { + $this->actingAs(budgetManager()); + + Livewire::test('pages::budget-plan.plan-create') + ->set('organization', 'Fachschaft') + ->set('starting_point', 'template') + ->call('save') + ->assertHasNoErrors(); + + $plan = BudgetPlan::orderByDesc('id')->first(); + expect($plan->state)->toBeInstanceOf(Draft::class) + ->and($plan->organization)->toBe('Fachschaft'); + + $inGroup = $plan->rootBudgetItems()->where('budget_type', BudgetType::INCOME)->first(); + $outGroup = $plan->rootBudgetItems()->where('budget_type', BudgetType::EXPENSE)->first(); + + expect($inGroup->is_group)->toBeTrue() + ->and($inGroup->short_name)->toBe('E1') + ->and($inGroup->children()->first()->short_name)->toBe('E1.1') + ->and($outGroup->short_name)->toBe('A1') + ->and($outGroup->children()->first()->short_name)->toBe('A1.1'); }); -it('creates a new draft budget plan with default groups and items and redirects to edit', function (): void { - $this->actingAs(user()); +it('clones the items of an existing plan', function (): void { + $this->actingAs(budgetManager()); - expect(config('stufis.features'))->toBe('dev'); + $source = BudgetPlan::create(['state' => Draft::class, 'organization' => 'Quelle']); + $group = $source->budgetItems()->create([ + 'is_group' => true, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1', + ]); + $group->children()->create([ + 'budget_plan_id' => $source->id, 'is_group' => false, 'budget_type' => BudgetType::INCOME, + 'position' => 0, 'short_name' => 'E1.1', 'value' => Money::EUR(200, true), + ]); - $response = $this->get(action([BudgetPlanController::class, 'create'])); + Livewire::test('pages::budget-plan.plan-create') + ->set('starting_point', 'clone') + ->set('source_plan_id', $source->id) + ->set('organization', 'Kopie-Ziel') + ->call('save') + ->assertHasNoErrors(); - // should redirect to edit route with created id $plan = BudgetPlan::orderByDesc('id')->first(); - $response->assertRedirect(route('budget-plan.edit', ['plan_id' => $plan->id])); + expect($plan->id)->not->toBe($source->id) + ->and($plan->budgetItems()->count())->toBe($source->budgetItems()->count()) + ->and($plan->incomeTotal()->getAmount())->toBe('20000'); +}); - // plan exists and is draft - expect($plan)->not->toBeNull(); - expect($plan->state)->toBe(BudgetPlanState::DRAFT); +it('rejects a duplicate organization within the same fiscal year', function (): void { + $this->actingAs(budgetManager()); + $year = FiscalYear::factory()->create(); + BudgetPlan::create(['state' => Draft::class, 'organization' => 'Fachschaft', 'fiscal_year_id' => $year->id]); - // two root groups created: income and expense - $rootIncome = $plan->rootBudgetItems()->where('budget_type', BudgetType::INCOME)->get(); - $rootExpense = $plan->rootBudgetItems()->where('budget_type', BudgetType::EXPENSE)->get(); + // same org + same year → explicit validation error, no second plan created + Livewire::test('pages::budget-plan.plan-create') + ->set('starting_point', 'template') + ->set('organization', 'Fachschaft') + ->set('fiscal_year_id', $year->id) + ->call('save') + ->assertHasErrors('organization'); - expect($rootIncome->count())->toBe(1); - expect($rootExpense->count())->toBe(1); + expect(BudgetPlan::where('fiscal_year_id', $year->id)->count())->toBe(1); +}); - // each root group gets one child budget item - /** @var BudgetItem $inGroup */ - $inGroup = $rootIncome->first(); - /** @var BudgetItem $outGroup */ - $outGroup = $rootExpense->first(); +it('allows the same organization in a different fiscal year', function (): void { + $this->actingAs(budgetManager()); + $year1 = FiscalYear::factory()->create(); + $year2 = FiscalYear::factory()->create(); + BudgetPlan::create(['state' => Draft::class, 'organization' => 'Fachschaft', 'fiscal_year_id' => $year1->id]); - expect($inGroup->is_group)->toBeTrue(); - expect($outGroup->is_group)->toBeTrue(); + Livewire::test('pages::budget-plan.plan-create') + ->set('starting_point', 'template') + ->set('organization', 'Fachschaft') + ->set('fiscal_year_id', $year2->id) + ->call('save') + ->assertHasNoErrors(); - expect($inGroup->children()->count())->toBe(1); - expect($outGroup->children()->count())->toBe(1); + expect(BudgetPlan::where('organization', 'Fachschaft')->count())->toBe(2); +}); + +it('preselects the clone source from the ?source query param', function (): void { + $this->actingAs(budgetManager()); + $year = FiscalYear::factory()->create(); + $source = BudgetPlan::create([ + 'state' => Draft::class, 'organization' => 'Vorlage', 'fiscal_year_id' => $year->id, + ]); - // check default short_name format as implemented in controller (E1/A1 and .1 child) - expect($inGroup->short_name)->toBe('E1'); - expect($outGroup->short_name)->toBe('A1'); + // prefill suggests a non-colliding name: the source already occupies "Vorlage" in this year + $suffix = __('budget-plan.edit.copy-suffix'); + Livewire::withQueryParams(['source' => $source->id]) + ->test('pages::budget-plan.plan-create') + ->assertSet('starting_point', 'clone') + ->assertSet('source_plan_id', $source->id) + ->assertSet('organization', "Vorlage ($suffix)") + ->assertSet('fiscal_year_id', $year->id); +}); - expect($inGroup->children()->first()->short_name)->toBe('E1.1'); - expect($outGroup->children()->first()->short_name)->toBe('A1.1'); -})->todo('budget-plan is a dev-only feature; enable once it graduates out of dev (preview/stable)'); +it('duplicates a preselected source: suggested name saved verbatim', function (): void { + $this->actingAs(budgetManager()); + $year = FiscalYear::factory()->create(); + $source = BudgetPlan::create([ + 'state' => Draft::class, 'organization' => 'Vorlage', 'fiscal_year_id' => $year->id, + ]); + $suffix = __('budget-plan.edit.copy-suffix'); + + Livewire::withQueryParams(['source' => $source->id]) + ->test('pages::budget-plan.plan-create') + ->assertSet('organization', "Vorlage ($suffix)") // suggested, collision-free + ->call('save') + ->assertHasNoErrors(); + + $copy = BudgetPlan::orderByDesc('id')->first(); + expect($copy->id)->not->toBe($source->id) + ->and($copy->organization)->toBe("Vorlage ($suffix)") + ->and($copy->fiscal_year_id)->toBe($year->id); +}); + +it('re-suggests the organization name when the target year changes', function (): void { + $this->actingAs(budgetManager()); + $year = FiscalYear::factory()->create(); + $freeYear = FiscalYear::factory()->create(); + $source = BudgetPlan::create([ + 'state' => Draft::class, 'organization' => 'Vorlage', 'fiscal_year_id' => $year->id, + ]); + $suffix = __('budget-plan.edit.copy-suffix'); + + Livewire::withQueryParams(['source' => $source->id]) + ->test('pages::budget-plan.plan-create') + ->assertSet('organization', "Vorlage ($suffix)") // collides in the source's year + ->set('fiscal_year_id', $freeYear->id) + ->assertSet('organization', 'Vorlage'); // no collision in the new year +}); + +it('shows a per-mount copy/drop chooser when the clone source has mounts', function (): void { + $this->actingAs(budgetManager()); + + $sub = BudgetPlan::create(['state' => Draft::class, 'organization' => 'Sub']); + $sub->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0, + 'short_name' => 'E1', 'value' => Money::EUR(500, true), + ]); + $source = BudgetPlan::create(['state' => Draft::class, 'organization' => 'Haupt']); + $source->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0, + 'short_name' => 'E1', 'referenced_plan_id' => $sub->id, + ]); + + Livewire::test('pages::budget-plan.plan-create') + ->set('starting_point', 'clone') + ->set('source_plan_id', $source->id) + ->assertSet('mountChoices', [$sub->id => 'copy']) // defaults to copy + ->assertSee('Sub') + ->assertSee(__('budget-plan.create.mount.copy')); +}); + +it('requires a source plan when cloning', function (): void { + $this->actingAs(budgetManager()); + + Livewire::test('pages::budget-plan.plan-create') + ->set('starting_point', 'clone') + ->set('source_plan_id', null) + ->call('save') + ->assertHasErrors('source_plan_id'); +}); diff --git a/tests/Pest/BudgetPlan/BudgetPlanEditActionsTest.php b/tests/Pest/BudgetPlan/BudgetPlanEditActionsTest.php new file mode 100644 index 00000000..fc750ec8 --- /dev/null +++ b/tests/Pest/BudgetPlan/BudgetPlanEditActionsTest.php @@ -0,0 +1,193 @@ + Draft::class]); +} + +function editComponent(BudgetPlan $plan) +{ + return Livewire::test('pages::budget-plan.plan-edit', ['plan_id' => $plan->id]); +} + +it('only lets budget officers open the edit page', function (): void { + $plan = draftPlan(); + + $this->actingAs(user()); + editComponent($plan)->assertForbidden(); + + $this->actingAs(budgetManager()); + editComponent($plan)->assertSuccessful(); +}); + +it('adds a group of the requested budget type with an auto-numbered Titelnummer', function (): void { + $this->actingAs(budgetManager()); + $plan = draftPlan(); + + editComponent($plan)->call('addGroup', BudgetType::EXPENSE)->assertHasNoErrors(); + + $root = BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id') + ->where('budget_type', BudgetType::EXPENSE)->first(); + + expect($root)->not->toBeNull() + ->and($root->budget_type)->toBe(BudgetType::EXPENSE) + ->and($root->short_name)->toBe('A1') + ->and($root->orderedChildren()->first()->short_name)->toBe('A1.1'); +}); + +it('mirrors a root to the opposite side with zeroed values via copy inverse', function (): void { + $this->actingAs(budgetManager()); + $plan = draftPlan(); + $lw = editComponent($plan); + + $lw->call('addGroup', BudgetType::EXPENSE); + $expenseRoot = BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id') + ->where('budget_type', BudgetType::EXPENSE)->first(); + // a second child carrying a real value, to prove it gets zeroed on the mirror + $lw->call('addBudget', $expenseRoot->id, 10.0)->assertHasNoErrors(); + + $lw->call('copyInverse', $expenseRoot->id)->assertHasNoErrors(); + + $incomeRoot = BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id') + ->where('budget_type', BudgetType::INCOME)->first(); + + expect($incomeRoot)->not->toBeNull() + ->and($incomeRoot->short_name)->toBe('E1') + ->and((int) $incomeRoot->value->getAmount())->toBe(0) + ->and($incomeRoot->orderedChildren()->count())->toBe($expenseRoot->orderedChildren()->count()); + + // source kept its value; every mirrored child is zeroed + expect($expenseRoot->orderedChildren()->where('value', '>', 0)->count())->toBeGreaterThan(0); + $incomeRoot->orderedChildren->each(fn (BudgetItem $child) => expect((int) $child->value->getAmount())->toBe(0)); +}); + +it('only mirrors root items (copy inverse is a no-op for children)', function (): void { + $this->actingAs(budgetManager()); + $plan = draftPlan(); + $lw = editComponent($plan); + + $lw->call('addGroup', BudgetType::EXPENSE); + $child = BudgetItem::where('budget_plan_id', $plan->id)->whereNotNull('parent_id')->first(); + + $lw->call('copyInverse', $child->id)->assertHasNoErrors(); + + expect(BudgetItem::where('budget_plan_id', $plan->id)->where('budget_type', BudgetType::INCOME)->count())->toBe(0); +}); + +it('duplicates an item (and its subtree) via copy', function (): void { + $this->actingAs(budgetManager()); + $plan = draftPlan(); + $lw = editComponent($plan); + + $lw->call('addGroup', BudgetType::EXPENSE); + $root = BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id') + ->where('budget_type', BudgetType::EXPENSE)->first(); + + $rootsBefore = BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id') + ->where('budget_type', BudgetType::EXPENSE)->count(); + + $lw->call('copyItem', $root->id)->assertHasNoErrors(); + + expect(BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id') + ->where('budget_type', BudgetType::EXPENSE)->count())->toBe($rootsBefore + 1); +}); + +it('blocks deleting a group with children but allows deleting a leaf', function (): void { + $this->actingAs(budgetManager()); + $plan = draftPlan(); + $lw = editComponent($plan); + + $lw->call('addGroup', BudgetType::EXPENSE); + $root = BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id') + ->where('budget_type', BudgetType::EXPENSE)->first(); + $leaf = $root->orderedChildren()->first(); + + // group still has a child -> delete refused + $lw->call('delete', $root->id)->assertHasNoErrors(); + expect(BudgetItem::find($root->id))->not->toBeNull(); + + // leaf -> deleted + $lw->call('delete', $leaf->id)->assertHasNoErrors(); + expect(BudgetItem::find($leaf->id))->toBeNull(); +}); + +it('derives bookability and wires the bookings relation per item kind', function (): void { + $plan = draftPlan(); + $group = BudgetItem::factory()->create(['budget_plan_id' => $plan->id, 'is_group' => true]); + $leaf = BudgetItem::factory()->create(['budget_plan_id' => $plan->id, 'is_group' => false]); + $mount = BudgetItem::factory()->create(['budget_plan_id' => $plan->id, 'is_group' => false, 'referenced_plan_id' => $plan->id]); + + expect($leaf->isBookable())->toBeTrue() + ->and($group->isBookable())->toBeFalse() + ->and($mount->isBookable())->toBeFalse() + // no bookings yet, and the relation is wired to the booking table by titel_id + ->and($leaf->hasBookings())->toBeFalse() + ->and($leaf->bookings()->getRelated())->toBeInstanceOf(Booking::class) + ->and($leaf->bookings()->getForeignKeyName())->toBe('titel_id'); +}); + +it('adds a plain budget line at root level (no group)', function (): void { + $this->actingAs(budgetManager()); + $plan = draftPlan(); + $lw = editComponent($plan); + + $lw->call('addRootBudget', BudgetType::EXPENSE)->assertHasNoErrors(); + + $root = BudgetItem::where('budget_plan_id', $plan->id)->whereNull('parent_id') + ->where('budget_type', BudgetType::EXPENSE)->sole(); + + expect($root->is_group)->toBeFalse() + ->and($root->parent_id)->toBeNull() + ->and($root->short_name)->not->toBeNull(); +}); + +it('deletes a tax title along with its tax_budget row (no FK violation)', function (): void { + Setting::set('tax.active', true); + $this->actingAs(budgetManager()); + $plan = draftPlan(); + $lw = editComponent($plan); + + $lw->call('addTaxTitles')->assertHasNoErrors(); + + $taxItem = BudgetItem::where('budget_plan_id', $plan->id) + ->where('short_name', 'A.99.1')->firstOrFail(); + expect(TaxBudget::where('budget_id', $taxItem->id)->exists())->toBeTrue(); + + // deleting the tax title used to fail on the tax_budget.budget_id FK + $lw->call('delete', $taxItem->id)->assertHasNoErrors(); + + expect(BudgetItem::find($taxItem->id))->toBeNull() + ->and(TaxBudget::where('budget_id', $taxItem->id)->exists())->toBeFalse(); +}); + +it('adds VAT tax titles via the action when the tax feature is active', function (): void { + Setting::set('tax.active', true); + $this->actingAs(budgetManager()); + $plan = draftPlan(); + + editComponent($plan)->call('addTaxTitles')->assertHasNoErrors(); + + expect(BudgetItem::where('budget_plan_id', $plan->id)->where('short_name', 'A.99')->where('is_group', true)->exists())->toBeTrue() + ->and(TaxBudget::where('plan_id', $plan->id)->count())->toBe(2); +}); + +it('the tax action is a no-op when the tax feature is inactive', function (): void { + Setting::set('tax.active', false); + $this->actingAs(budgetManager()); + $plan = draftPlan(); + + editComponent($plan)->call('addTaxTitles')->assertHasNoErrors(); + + expect(TaxBudget::where('plan_id', $plan->id)->count())->toBe(0); +}); diff --git a/tests/Pest/BudgetPlan/BudgetPlanEditTest.php b/tests/Pest/BudgetPlan/BudgetPlanEditTest.php index 02a272fd..f01f685c 100644 --- a/tests/Pest/BudgetPlan/BudgetPlanEditTest.php +++ b/tests/Pest/BudgetPlan/BudgetPlanEditTest.php @@ -2,22 +2,20 @@ use App\Models\BudgetItem; use App\Models\BudgetPlan; -use App\Models\Enums\BudgetPlanState; use App\Models\Enums\BudgetType; use App\Models\FiscalYear; +use App\States\BudgetPlan\Draft; +use Illuminate\Foundation\Testing\DatabaseTransactions; -beforeEach(function (): void { - // enable dev routes where budget plan routes live - config()->set('stufis.features', 'dev'); -}); +uses(DatabaseTransactions::class); function createEmptyPlan(): BudgetPlan { - return BudgetPlan::create(['state' => BudgetPlanState::DRAFT]); + return BudgetPlan::create(['state' => Draft::class]); } it('renders and can add groups and items, save metadata, and prevent deleting non-empty groups', function (): void { - $this->actingAs(user()); + $this->actingAs(budgetManager()); $plan = createEmptyPlan(); @@ -42,8 +40,8 @@ function createEmptyPlan(): BudgetPlan ->assertHasNoErrors(); $child = $incomeRoot->children()->orderByDesc('id')->first(); expect($child->is_group)->toBeFalse(); - // MoneyDecimalCast stores cents, so ensure amount matches - expect($child->value->getAmount())->toBe(1234); + // MoneyDecimalCast stores cents, so ensure amount matches (getAmount() returns a string) + expect((int) $child->value->getAmount())->toBe(1234); // set meta data and save -> redirects to view route $fy = FiscalYear::factory()->create(); @@ -59,9 +57,9 @@ function createEmptyPlan(): BudgetPlan expect($plan->organization)->toBe('Test Org'); expect($plan->fiscal_year_id)->toBe($fy->id); - // try to delete a non-empty group (has children) -> should add error and not delete + // try to delete a non-empty group (has children) -> refused (toast, no delete) $lw = Livewire::test('pages::budget-plan.plan-edit', ['plan_id' => $plan->id]); $lw->call('delete', $incomeRoot->id) - ->assertHasErrors(); + ->assertHasNoErrors(); expect(BudgetItem::find($incomeRoot->id))->not->toBeNull(); -})->todo('budget-plan is a dev-only feature; enable once it graduates out of dev (preview/stable)'); +}); diff --git a/tests/Pest/BudgetPlan/BudgetPlanExportTest.php b/tests/Pest/BudgetPlan/BudgetPlanExportTest.php new file mode 100644 index 00000000..63c87e3c --- /dev/null +++ b/tests/Pest/BudgetPlan/BudgetPlanExportTest.php @@ -0,0 +1,95 @@ + 'StuRa', 'state' => Draft::class]); + $plan->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::EXPENSE, 'position' => 0, + 'short_name' => 'A1', 'name' => 'Material', 'value' => Money::EUR(100, true), + ]); + + return $plan; +} + +it('downloads the plan as an xlsx spreadsheet', function (): void { + ExcelFacade::fake(); + ExcelFacade::matchByRegex(); + $this->actingAs(user()); + $plan = exportablePlan(); + + $this->get(route('budget-plan.export', [$plan->id, 'xlsx']))->assertOk(); + + ExcelFacade::assertDownloaded('/\.xlsx$/', fn (BudgetPlanExport $export): bool => $export->plan->is($plan)); +}); + +it('downloads the plan as an ods spreadsheet', function (): void { + ExcelFacade::fake(); + ExcelFacade::matchByRegex(); + $this->actingAs(user()); + $plan = exportablePlan(); + + $this->get(route('budget-plan.export', [$plan->id, 'ods']))->assertOk(); + + ExcelFacade::assertDownloaded('/\.ods$/'); +}); + +it('404s for an unsupported file type', function (): void { + $this->actingAs(user()); + $plan = exportablePlan(); + + $this->get(route('budget-plan.export', [$plan->id, 'csv']))->assertNotFound(); +}); + +it('requires authentication', function (): void { + $plan = exportablePlan(); + + $this->get(route('budget-plan.export', [$plan->id, 'xlsx']))->assertRedirect(route('login')); +}); + +// The facade-faked downloads above never render the Blade, so exercise the view directly. + +it('sums a section total from fractional leaf amounts', function (): void { + $plan = BudgetPlan::create(['state' => Draft::class]); + foreach ([10, 20] as $i => $cents) { + $plan->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::EXPENSE, 'position' => $i, + 'short_name' => "A$i", 'name' => "Leaf $i", 'value' => Money::EUR($cents), + ]); + } + + $html = new BudgetPlanExport($plan)->view()->render(); + + // 0.10 + 0.20 = 0.30 on the (bold) section total row; the leaves render 0.1 and 0.2 unbold + expect(substr_count($html, '0.3'))->toBe(1); +}); + +it('rolls leaf values up into the group and section total', function (): void { + // group value is stored as 0; the export must roll up the children live + $plan = BudgetPlan::create(['state' => Draft::class]); + $group = $plan->budgetItems()->create([ + 'is_group' => true, 'budget_type' => BudgetType::EXPENSE, 'position' => 0, + 'short_name' => 'A', 'name' => 'Ausgaben', 'value' => Money::EUR(0), + ]); + foreach ([100, 50] as $i => $euros) { + $group->children()->create([ + 'budget_plan_id' => $plan->id, 'is_group' => false, 'budget_type' => BudgetType::EXPENSE, + 'position' => $i, 'short_name' => "A.$i", 'name' => "Leaf $i", 'value' => Money::EUR($euros, true), + ]); + } + + $html = new BudgetPlanExport($plan)->view()->render(); + + // 100 + 50 rolled up: appears once on the group row and once on the section total row + expect(substr_count($html, '150'))->toBe(2); +}); diff --git a/tests/Pest/BudgetPlan/BudgetPlanIndexTest.php b/tests/Pest/BudgetPlan/BudgetPlanIndexTest.php new file mode 100644 index 00000000..844a969c --- /dev/null +++ b/tests/Pest/BudgetPlan/BudgetPlanIndexTest.php @@ -0,0 +1,42 @@ +actingAs(user()); + + $year = FiscalYear::factory()->create(); + $assigned = BudgetPlan::create([ + 'state' => Draft::class, 'fiscal_year_id' => $year->id, 'organization' => 'AStA', + ]); + BudgetPlan::create([ + 'state' => Draft::class, 'organization' => 'Waisenplan', + ]); + + $response = $this->get(route('budget-plan.index')); + + $response->assertOk() + ->assertSee('AStA') + ->assertSee('Waisenplan') + ->assertSee(__('budget-plan.index.orphaned-plans')) + ->assertSee($assigned->state->label()) + // the old debug placeholders are gone + ->assertDontSee('ohhneee') + ->assertDontSee('budget-plan.plan?'); +}); + +it('lists fiscal years without plans using a placeholder row', function (): void { + $this->actingAs(user()); + + $empty = FiscalYear::factory()->create(['start_date' => '2024-04-01', 'end_date' => '2025-03-31']); + + $this->get(route('budget-plan.index')) + ->assertOk() + ->assertSee($empty->label()) + ->assertSee(__('budget-plan.index.no-plans')); +}); diff --git a/tests/Pest/BudgetPlan/BudgetPlanItemViewTest.php b/tests/Pest/BudgetPlan/BudgetPlanItemViewTest.php new file mode 100644 index 00000000..cfa1f9e8 --- /dev/null +++ b/tests/Pest/BudgetPlan/BudgetPlanItemViewTest.php @@ -0,0 +1,90 @@ + Draft::class]); + $leaf = $plan->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::EXPENSE, 'position' => 0, + 'short_name' => 'A1', 'name' => 'Material', 'value' => Money::EUR(100, true), + ]); + + return [$plan, $leaf]; +} + +/** + * A konto transaction, returned as ['id' => …, 'konto_id' => …]. The konto PK isn't + * auto-increment, so we set the id explicitly and never read it back off the model. + */ +function itemPayment(): array +{ + $account = BankAccount::factory()->create(); + BankTransaction::factory()->create(['konto_id' => $account->id, 'id' => 1]); + + return ['id' => 1, 'konto_id' => $account->id]; +} + +/** Book $euros against a leaf, creating a konto transaction so the FK holds. */ +function bookItem(BudgetItem $leaf, string $euros, bool $canceled = false, ?array $payment = null): void +{ + $payment ??= itemPayment(); + Booking::create([ + 'titel_id' => $leaf->id, + 'user_id' => user()->id, + 'kostenstelle' => 0, + 'zahlung_id' => $payment['id'], + 'zahlung_type' => $payment['konto_id'], + 'beleg_id' => 0, + 'beleg_type' => '', + 'comment' => 'Buchung', + 'value' => $euros, + 'canceled' => $canceled ? 1 : 0, + ]); +} + +it('lists the item\'s non-canceled bookings and links the transaction', function (): void { + $this->actingAs(user()); + [$plan, $leaf] = planWithLeaf(); + + $payment = itemPayment(); + bookItem($leaf, '42', payment: $payment); + bookItem($leaf, '7', canceled: true); // must not show up + + $this->get(route('budget-plan.item.view', [$plan->id, $leaf->id])) + ->assertOk() + ->assertSee($leaf->short_name) + ->assertSee(__('budget-plan.item.bookings')) + ->assertSee('42,00') + ->assertDontSee('7,00') + ->assertSee(route('bank-account.transaction', [$payment['konto_id'], $payment['id']]), false); +}); + +it('shows an empty state when the item has no bookings', function (): void { + $this->actingAs(user()); + [$plan, $leaf] = planWithLeaf(); + + $this->get(route('budget-plan.item.view', [$plan->id, $leaf->id])) + ->assertOk() + ->assertSee(__('budget-plan.item.no-bookings')); +}); + +it('404s when the item does not belong to the plan', function (): void { + $this->actingAs(user()); + [$plan, $leaf] = planWithLeaf(); + $otherPlan = BudgetPlan::create(['state' => Draft::class]); + + $this->get(route('budget-plan.item.view', [$otherPlan->id, $leaf->id])) + ->assertNotFound(); +}); diff --git a/tests/Pest/BudgetPlan/BudgetPlanMeasuresTest.php b/tests/Pest/BudgetPlan/BudgetPlanMeasuresTest.php new file mode 100644 index 00000000..57dd73f9 --- /dev/null +++ b/tests/Pest/BudgetPlan/BudgetPlanMeasuresTest.php @@ -0,0 +1,190 @@ + Draft::class]); + $group = $plan->budgetItems()->create([ + 'is_group' => true, 'budget_type' => BudgetType::EXPENSE, 'position' => 0, + 'short_name' => 'A1', 'name' => 'Ausgaben', 'value' => Money::EUR(0), + ]); + $leaf = $group->children()->create([ + 'budget_plan_id' => $plan->id, 'is_group' => false, 'budget_type' => BudgetType::EXPENSE, + 'position' => 0, 'short_name' => 'A1.1', 'name' => 'Material', 'value' => Money::EUR($plannedEuros, true), + ]); + + return [$plan, $group, $leaf]; +} + +/** + * A konto transaction, returned as ['id' => …, 'konto_id' => …]. The konto PK isn't + * auto-increment, so we set the id explicitly and never read it back off the model. + */ +function makePayment(): array +{ + $account = BankAccount::factory()->create(); + BankTransaction::factory()->create(['konto_id' => $account->id, 'id' => 1]); + + return ['id' => 1, 'konto_id' => $account->id]; +} + +/** Book $euros against a leaf. Creates a konto transaction so the (zahlung_id, zahlung_type) FK holds. */ +function bookLeaf(BudgetItem $leaf, string $euros, bool $canceled = false, ?array $payment = null): Booking +{ + $payment ??= makePayment(); + + return Booking::create([ + 'titel_id' => $leaf->id, + 'user_id' => user()->id, + 'kostenstelle' => 0, + 'zahlung_id' => $payment['id'], + 'zahlung_type' => $payment['konto_id'], + 'beleg_id' => 0, + 'beleg_type' => '', + 'comment' => 'Buchung', + 'value' => $euros, + 'canceled' => $canceled ? 1 : 0, + ]); +} + +/** Reserve $ausgabenEuros against a leaf via an open (not-yet-terminated) project posting. */ +function commitOpen(BudgetItem $leaf, int $ausgabenEuros, string $state = 'ok-by-hv'): void +{ + $project = Project::factory()->withState($state)->create(); + $project->posts()->create([ + 'titel_id' => $leaf->id, + 'einnahmen' => Money::EUR(0), + 'ausgaben' => Money::EUR($ausgabenEuros, true), + 'name' => 'Posten', + 'bemerkung' => '', + ]); +} + +/** + * Reserve $ausgabenEuros against a leaf via a *terminated* project's receipt posting: a + * projektposten holds the titel, and a beleg_posten (belege → auslagen → projekte) books the + * actual receipt amount that closedPostings() sums. The auslage stays non-revocation. + */ +function commitClosed(BudgetItem $leaf, int $ausgabenEuros): void +{ + $project = Project::factory()->withState('terminated')->create(); + $post = $project->posts()->create([ + 'titel_id' => $leaf->id, + 'einnahmen' => Money::EUR(0), + 'ausgaben' => Money::EUR(0), + 'name' => 'Posten', + 'bemerkung' => '', + ]); + + $expense = Expense::factory()->create(['projekt_id' => $project->id, 'state' => 'draft']); + $receipt = ExpenseReceipt::factory()->create(['auslagen_id' => $expense->id]); + ExpenseReceiptPost::factory()->create([ + 'beleg_id' => $receipt->id, + 'projekt_posten_id' => $post->id, + 'ausgaben' => $ausgabenEuros, + 'einnahmen' => 0, + ]); +} + +it('sums booked against a leaf and ignores canceled bookings', function (): void { + [$plan, $group, $leaf] = expenseGroupWithLeaf(); + bookLeaf($leaf, '25'); + bookLeaf($leaf, '5', canceled: true); + + $items = new BudgetPlanMeasures($plan, BudgetType::EXPENSE)->annotate(); + + expect($items->firstWhere('id', $leaf->id)->booked->getAmount())->toBe('2500') + ->and($items->firstWhere('id', $group->id)->booked->getAmount())->toBe('2500'); +}); + +it('rolls the committed money from open projects up to the group', function (): void { + [$plan, $group, $leaf] = expenseGroupWithLeaf(); + commitOpen($leaf, 30); + + $items = new BudgetPlanMeasures($plan, BudgetType::EXPENSE)->annotate(); + + expect($items->firstWhere('id', $leaf->id)->committed->getAmount())->toBe('3000') + ->and($items->firstWhere('id', $group->id)->committed->getAmount())->toBe('3000'); +}); + +it('ignores committed money from draft (not-yet-approved) projects', function (): void { + [$plan, , $leaf] = expenseGroupWithLeaf(); + commitOpen($leaf, 30, state: 'draft'); + + $items = new BudgetPlanMeasures($plan, BudgetType::EXPENSE)->annotate(); + + expect($items->firstWhere('id', $leaf->id)->committed->getAmount())->toBe('0'); +}); + +it('counts committed money from terminated projects\' receipt postings', function (): void { + [$plan, $group, $leaf] = expenseGroupWithLeaf(); + commitClosed($leaf, 40); + + $items = new BudgetPlanMeasures($plan, BudgetType::EXPENSE)->annotate(); + + expect($items->firstWhere('id', $leaf->id)->committed->getAmount())->toBe('4000') + ->and($items->firstWhere('id', $group->id)->committed->getAmount())->toBe('4000'); +}); + +it('annotates planned and rolls it up through the group', function (): void { + [$plan, $group, $leaf] = expenseGroupWithLeaf(plannedEuros: 100); + + $items = new BudgetPlanMeasures($plan, BudgetType::EXPENSE)->annotate(); + + // leaf carries its own value; the group is the live sum of its children + expect($items->firstWhere('id', $leaf->id)->planned->getAmount())->toBe('10000') + ->and($items->firstWhere('id', $group->id)->planned->getAmount())->toBe('10000'); +}); + +it('rolls planned, booked and committed up through a mount', function (): void { + // referenced plan: one leaf (100 planned) carrying a booking and an open commitment + [$refPlan, , $refLeaf] = expenseGroupWithLeaf(plannedEuros: 100); + bookLeaf($refLeaf, '25'); + commitOpen($refLeaf, 30); + + // parent plan mounts the referenced plan + $parent = BudgetPlan::create(['state' => Draft::class]); + $mount = $parent->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::EXPENSE, 'position' => 0, + 'short_name' => 'M', 'name' => 'Mount', 'value' => Money::EUR(0), + 'referenced_plan_id' => $refPlan->id, + ]); + + $mounted = new BudgetPlanMeasures($parent, BudgetType::EXPENSE)->annotate()->firstWhere('id', $mount->id); + + expect($mounted->planned->getAmount())->toBe('10000') + ->and($mounted->booked->getAmount())->toBe('2500') + ->and($mounted->committed->getAmount())->toBe('3000'); +}); + +it('renders the booked and committed columns with real amounts', function (): void { + $this->actingAs(user()); + [$plan, , $leaf] = expenseGroupWithLeaf(); + bookLeaf($leaf, '25'); + commitOpen($leaf, 30); + + $this->get(route('budget-plan.view', $plan->id)) + ->assertOk() + ->assertSee(__('budget-plan.view.col.booked')) + ->assertSee(__('budget-plan.view.col.committed')) + ->assertDontSee('Verfügbar') // the old "available" header is gone + ->assertSee('25,00') + ->assertSee('30,00'); +}); diff --git a/tests/Pest/BudgetPlan/BudgetPlanMountTest.php b/tests/Pest/BudgetPlan/BudgetPlanMountTest.php new file mode 100644 index 00000000..309d194a --- /dev/null +++ b/tests/Pest/BudgetPlan/BudgetPlanMountTest.php @@ -0,0 +1,195 @@ + Draft::class]); + $plan->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0, + 'short_name' => 'E1', 'value' => Money::EUR($euros, true), + ]); + + return $plan; +} + +function mountInto(BudgetPlan $parent, BudgetPlan $sub, BudgetType $side): void +{ + $parent->budgetItems()->create([ + 'is_group' => false, 'budget_type' => $side, 'position' => 0, + 'referenced_plan_id' => $sub->id, + ]); +} + +it('resolves a mount item to the referenced plan side total', function (): void { + $sub = planWithIncome(500); + $parent = BudgetPlan::create(['state' => Draft::class]); + mountInto($parent, $sub, BudgetType::INCOME); + + $mount = $parent->rootBudgetItems()->first(); + + expect($mount->kind())->toBe(BudgetItemKind::Mount) + ->and($mount->isMount())->toBeTrue() + ->and($mount->effectiveValue()->getAmount())->toBe('50000'); // 500 € = 50000 cents + + // parent income rolls up the sub-plan; only the income side is pulled + expect($parent->incomeTotal()->getAmount())->toBe('50000') + ->and($parent->expenseTotal()->getAmount())->toBe('0'); +}); + +it('rolls up live when the sub-plan changes', function (): void { + $sub = planWithIncome(500); + $parent = BudgetPlan::create(['state' => Draft::class]); + mountInto($parent, $sub, BudgetType::INCOME); + + expect($parent->incomeTotal()->getAmount())->toBe('50000'); + + $sub->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 1, + 'short_name' => 'E2', 'value' => Money::EUR(100, true), + ]); + + expect($parent->incomeTotal()->getAmount())->toBe('60000'); // now 600 € +}); + +it('rolls a nested mount up through its group and the plan total', function (): void { + $sub = planWithIncome(500); + $parent = BudgetPlan::create(['state' => Draft::class]); + + $group = $parent->budgetItems()->create([ + 'is_group' => true, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1', + ]); + $group->children()->create([ + 'budget_plan_id' => $parent->id, 'is_group' => false, 'budget_type' => BudgetType::INCOME, + 'position' => 0, 'short_name' => 'E1.1', 'value' => Money::EUR(200, true), + ]); + $group->children()->create([ // a mount nested inside the group + 'budget_plan_id' => $parent->id, 'is_group' => false, 'budget_type' => BudgetType::INCOME, + 'position' => 1, 'short_name' => 'E1.2', 'referenced_plan_id' => $sub->id, + ]); + + // group = 200 € leaf + 500 € mounted sub-plan = 700 €, and so is the plan income total + expect($group->fresh()->effectiveValue()->getAmount())->toBe('70000') + ->and($parent->incomeTotal()->getAmount())->toBe('70000'); +}); + +it('transforms a nested budget item into a mount via the editor', function (): void { + $this->actingAs(budgetManager()); + + $sub = planWithIncome(500); + $parent = BudgetPlan::create(['state' => Draft::class]); + $group = $parent->budgetItems()->create([ + 'is_group' => true, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1', + ]); + $leaf = $group->children()->create([ + 'budget_plan_id' => $parent->id, 'is_group' => false, 'budget_type' => BudgetType::INCOME, + 'position' => 0, 'short_name' => 'E1.1', + ]); + + Livewire::test('pages::budget-plan.plan-edit', ['plan_id' => $parent->id]) + ->set('mount_item_id', $leaf->id) + ->set('mount_plan_id', $sub->id) + ->call('convertToMount') + ->assertHasNoErrors(); + + expect($leaf->fresh()->isMount())->toBeTrue() + ->and($parent->incomeTotal()->getAmount())->toBe('50000'); // nested mount rolls up +}); + +it('mounts a plan via the editor and rolls the total up', function (): void { + $this->actingAs(budgetManager()); + + $sub = planWithIncome(500); + $parent = BudgetPlan::create(['state' => Draft::class]); + $root = $parent->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1', + ]); + + Livewire::test('pages::budget-plan.plan-edit', ['plan_id' => $parent->id]) + ->set('mount_item_id', $root->id) + ->set('mount_plan_id', $sub->id) + ->call('convertToMount') + ->assertHasNoErrors(); + + expect($root->fresh()->isMount())->toBeTrue() + ->and($root->fresh()->referenced_plan_id)->toBe($sub->id) + ->and($parent->incomeTotal()->getAmount())->toBe('50000'); +}); + +it('loads mount candidates excluding self and cycle-creating plans', function (): void { + $this->actingAs(budgetManager()); + + $parent = BudgetPlan::create(['state' => Draft::class, 'organization' => 'Parent']); + $ok = BudgetPlan::create(['state' => Draft::class, 'organization' => 'Mountable']); + $cycle = BudgetPlan::create(['state' => Draft::class, 'organization' => 'WouldCycle']); + mountInto($cycle, $parent, BudgetType::INCOME); // $cycle already reaches $parent + + $root = $parent->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1', + ]); + + $candidates = Livewire::test('pages::budget-plan.plan-edit', ['plan_id' => $parent->id]) + ->call('openMountPicker', $root->id) + ->get('mount_candidates'); + $ids = collect($candidates)->pluck('id'); + + expect($ids)->toContain($ok->id) + ->and($ids)->not->toContain($parent->id) // self excluded + ->and($ids)->not->toContain($cycle->id); // would-cycle excluded +}); + +it('only offers mount candidates from the same fiscal year', function (): void { + $this->actingAs(budgetManager()); + + $fy1 = FiscalYear::factory()->create(); + $fy2 = FiscalYear::factory()->create(); + $parent = BudgetPlan::create(['state' => Draft::class, 'fiscal_year_id' => $fy1->id]); + $same = BudgetPlan::create(['state' => Draft::class, 'fiscal_year_id' => $fy1->id, 'organization' => 'Same year']); + $other = BudgetPlan::create(['state' => Draft::class, 'fiscal_year_id' => $fy2->id, 'organization' => 'Other year']); + + $root = $parent->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1', + ]); + + $ids = collect(Livewire::test('pages::budget-plan.plan-edit', ['plan_id' => $parent->id]) + ->call('openMountPicker', $root->id) + ->get('mount_candidates'))->pluck('id'); + + expect($ids)->toContain($same->id)->and($ids)->not->toContain($other->id); +}); + +it('rejects mounting a plan that would create a cycle', function (): void { + $this->actingAs(budgetManager()); + + $a = BudgetPlan::create(['state' => Draft::class]); + $b = BudgetPlan::create(['state' => Draft::class]); + mountInto($b, $a, BudgetType::INCOME); // B already mounts A + $root = $a->budgetItems()->create([ + 'is_group' => false, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1', + ]); + + Livewire::test('pages::budget-plan.plan-edit', ['plan_id' => $a->id]) + ->set('mount_item_id', $root->id) + ->set('mount_plan_id', $b->id) + ->call('convertToMount'); + + expect($root->fresh()->isMount())->toBeFalse(); // mounting B into A would cycle -> rejected +}); + +it('detects reference cycles (self and transitive)', function (): void { + $a = BudgetPlan::create(['state' => Draft::class]); + $b = BudgetPlan::create(['state' => Draft::class]); + mountInto($a, $b, BudgetType::INCOME); // A mounts B + + expect($a->reachesPlan($a->id))->toBeTrue() // self + ->and($a->reachesPlan($b->id))->toBeTrue() // A -> B + ->and($b->reachesPlan($a->id))->toBeFalse(); // B does not reach A (yet) +}); diff --git a/tests/Pest/BudgetPlan/BudgetPlanViewTest.php b/tests/Pest/BudgetPlan/BudgetPlanViewTest.php new file mode 100644 index 00000000..5007c0ae --- /dev/null +++ b/tests/Pest/BudgetPlan/BudgetPlanViewTest.php @@ -0,0 +1,101 @@ + Draft::class]); + $group = $plan->budgetItems()->create([ + 'is_group' => true, 'budget_type' => BudgetType::INCOME, 'position' => 0, + 'short_name' => 'E1', 'name' => 'Einnahmen', 'value' => Money::EUR(100, true), + ]); + $group->children()->create([ + 'budget_plan_id' => $plan->id, 'is_group' => false, 'budget_type' => BudgetType::INCOME, + 'position' => 0, 'short_name' => 'E1.1', 'name' => 'Beiträge', 'value' => Money::EUR(100, true), + ]); + + return $plan; +} + +it('renders the read-only view with real totals and item rows', function (): void { + $this->actingAs(user()); + $plan = planWithItems(); + + $this->get(route('budget-plan.view', $plan->id)) + ->assertOk() + ->assertSee(__('budget-plan.view.summary.income')) + ->assertSee(__('budget-plan.view.col.planned')) + ->assertDontSee('budget-plan.view.') + ->assertSee('E1.1') + ->assertSee('100,00 €') + ->assertDontSee('Avg. Open Rate') + ->assertDontSee('Semesterbeiträge'); +}); + +it('lets an admin delete the whole plan (with its items)', function (): void { + $this->actingAs(adminUser()); + $plan = planWithItems(); + + Livewire::test('pages::budget-plan.plan-view', ['plan_id' => $plan->id]) + ->call('deletePlan') + ->assertHasNoErrors() + ->assertRedirect(route('budget-plan.index')); + + expect(BudgetPlan::find($plan->id))->toBeNull() + ->and(BudgetItem::where('budget_plan_id', $plan->id)->count())->toBe(0); +}); + +it('forbids a non-admin from deleting the plan', function (): void { + $this->actingAs(budgetManager()); // budget-officer, not admin + $plan = planWithItems(); + + Livewire::test('pages::budget-plan.plan-view', ['plan_id' => $plan->id]) + ->call('deletePlan') + ->assertForbidden(); + + expect(BudgetPlan::find($plan->id))->not->toBeNull(); +}); + +it('lets a budget officer advance the plan state along an allowed transition', function (): void { + $this->actingAs(budgetManager()); // budget-officer + $plan = planWithItems(); // starts as draft + + Livewire::test('pages::budget-plan.plan-view', ['plan_id' => $plan->id]) + ->set('newState', 'resolved') + ->call('changeState') + ->assertHasNoErrors(); + + expect(BudgetPlan::find($plan->id)->state)->toBeInstanceOf(Resolved::class); +}); + +it('forbids a non-officer from changing the state', function (): void { + $this->actingAs(user()); // not a budget officer + $plan = planWithItems(); + + Livewire::test('pages::budget-plan.plan-view', ['plan_id' => $plan->id]) + ->set('newState', 'resolved') + ->call('changeState') + ->assertForbidden(); + + expect(BudgetPlan::find($plan->id)->state)->toBeInstanceOf(Draft::class); +}); + +it('forbids an illegal transition (draft straight to completed)', function (): void { + $this->actingAs(budgetManager()); + $plan = planWithItems(); // draft + + Livewire::test('pages::budget-plan.plan-view', ['plan_id' => $plan->id]) + ->set('newState', 'completed') + ->call('changeState') + ->assertForbidden(); + + expect(BudgetPlan::find($plan->id)->state)->toBeInstanceOf(Draft::class); +}); diff --git a/tests/Pest/BudgetPlan/TitleNumbererTest.php b/tests/Pest/BudgetPlan/TitleNumbererTest.php new file mode 100644 index 00000000..245de774 --- /dev/null +++ b/tests/Pest/BudgetPlan/TitleNumbererTest.php @@ -0,0 +1,125 @@ + Draft::class]); +} + +/** Persist a budget item, controlling only the numbering-relevant fields. */ +function numberedItem(BudgetPlan $plan, array $attrs = []): BudgetItem +{ + return BudgetItem::factory()->create(array_merge([ + 'budget_plan_id' => $plan->id, + 'budget_type' => BudgetType::EXPENSE, + 'parent_id' => null, + 'is_group' => true, + 'position' => 0, + 'short_name' => null, + ], $attrs)); +} + +it('seeds the first root of a type from the budget-type prefix', function (): void { + $plan = emptyPlan(); + + $expenseRoot = numberedItem($plan, ['budget_type' => BudgetType::EXPENSE]); + $incomeRoot = numberedItem($plan, ['budget_type' => BudgetType::INCOME]); + + expect(numberer()->next($expenseRoot))->toBe('A1'); + expect(numberer()->next($incomeRoot))->toBe('E1'); +}); + +it('continues numbering from the preceding root sibling', function (): void { + $plan = emptyPlan(); + numberedItem($plan, ['short_name' => 'A1', 'position' => 0]); + $newRoot = numberedItem($plan, ['short_name' => null, 'position' => 1]); + + expect(numberer()->next($newRoot))->toBe('A2'); +}); + +it('only counts siblings of the same budget type for roots', function (): void { + $plan = emptyPlan(); + // an income root must not bump the expense numbering and vice versa + numberedItem($plan, ['budget_type' => BudgetType::INCOME, 'short_name' => 'E1', 'position' => 0]); + $newExpenseRoot = numberedItem($plan, ['budget_type' => BudgetType::EXPENSE, 'short_name' => null, 'position' => 0]); + + expect(numberer()->next($newExpenseRoot))->toBe('A1'); +}); + +it('hangs the first child off the parent number', function (): void { + $plan = emptyPlan(); + $parent = numberedItem($plan, ['short_name' => 'A1']); + $child = numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 0]); + + expect(numberer()->next($child))->toBe('A1.1'); +}); + +it('increments from the preceding child sibling', function (): void { + $plan = emptyPlan(); + $parent = numberedItem($plan, ['short_name' => 'A1']); + numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 0, 'short_name' => 'A1.1']); + $child = numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 1]); + + expect(numberer()->next($child))->toBe('A1.2'); +}); + +it('continues whatever numeric scheme the preceding sibling uses', function (): void { + $plan = emptyPlan(); + $parent = numberedItem($plan, ['short_name' => 'A1']); + numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 0, 'short_name' => 'Pers-1']); + $child = numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 1]); + + expect(numberer()->next($child))->toBe('Pers-2'); +}); + +it('increments only the last number in a deep multi-level number', function (): void { + $plan = emptyPlan(); + $parent = numberedItem($plan, ['short_name' => 'A1.2']); + numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 0, 'short_name' => 'A1.2.9']); + $child = numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 1]); + + expect(numberer()->next($child))->toBe('A1.2.10'); +}); + +it('preserves zero-padding when incrementing (07 -> 08)', function (): void { + $plan = emptyPlan(); + numberedItem($plan, ['short_name' => '07', 'position' => 0]); + $newRoot = numberedItem($plan, ['short_name' => null, 'position' => 1]); + + expect(numberer()->next($newRoot))->toBe('08'); +}); + +it('carries within the padded width and only grows on overflow (09 -> 10, 99 -> 100)', function (): void { + $plan = emptyPlan(); + $parent = numberedItem($plan, ['short_name' => 'A1']); + + numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 0, 'short_name' => 'A1.09']); + $tenth = numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 1]); + expect(numberer()->next($tenth))->toBe('A1.10'); + + numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 2, 'short_name' => 'A1.99']); + $hundredth = numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 3]); + expect(numberer()->next($hundredth))->toBe('A1.100'); +}); + +it('falls back to parent numbering when the preceding sibling has no number', function (): void { + $plan = emptyPlan(); + $parent = numberedItem($plan, ['short_name' => 'A1']); + numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 0, 'short_name' => 'Personalkosten']); + $child = numberedItem($plan, ['parent_id' => $parent->id, 'is_group' => false, 'position' => 1]); + + expect(numberer()->next($child))->toBe('A1.1'); +}); diff --git a/tests/Pest/Datev/DatevExportTest.php b/tests/Pest/Datev/DatevExportTest.php index 701c5090..cbacb549 100644 --- a/tests/Pest/Datev/DatevExportTest.php +++ b/tests/Pest/Datev/DatevExportTest.php @@ -1,11 +1,11 @@ $groupType]); - $item = new LegacyBudgetItem; - $item->setRelation('budgetGroup', $group); + // legacy group type 0 = income, 1 = expense → new BudgetType on the item itself + $item = new BudgetItem; + $item->budget_type = $groupType === 0 ? BudgetType::INCOME : BudgetType::EXPENSE; $booking = new Booking; $booking->value = $value; diff --git a/tests/Pest/FiscalYear/FiscalYearGapWarningTest.php b/tests/Pest/FiscalYear/FiscalYearGapWarningTest.php new file mode 100644 index 00000000..765c1dcd --- /dev/null +++ b/tests/Pest/FiscalYear/FiscalYearGapWarningTest.php @@ -0,0 +1,42 @@ +actingAs(budgetManager()); + + // an existing year Apr 24 – Mär 25; a new year starting Mai 25 leaves a gap in April 25 + FiscalYear::factory()->create(['start_date' => '2024-04-01', 'end_date' => '2025-03-31']); + + Livewire::test('pages::fiscal-year.edit-fiscal-year') + ->set('start_date', '2025-05-01') + ->set('end_date', '2026-04-30') + ->assertSee(__('budget-plan.fiscal-year.gap-warning.heading')) + ->assertSee('01.04.2025') + ->assertSee('30.04.2025'); +}); + +it('does not warn when the entered range abuts the neighbour exactly', function (): void { + $this->actingAs(budgetManager()); + + FiscalYear::factory()->create(['start_date' => '2024-04-01', 'end_date' => '2025-03-31']); + + Livewire::test('pages::fiscal-year.edit-fiscal-year') + ->set('start_date', '2025-04-01') + ->set('end_date', '2026-03-31') + ->assertDontSee(__('budget-plan.fiscal-year.gap-warning.heading')); +}); + +it('ignores the year being edited when checking for gaps', function (): void { + $this->actingAs(budgetManager()); + + $before = FiscalYear::factory()->create(['start_date' => '2023-04-01', 'end_date' => '2024-03-31']); + $editing = FiscalYear::factory()->create(['start_date' => '2024-04-01', 'end_date' => '2025-03-31']); + + Livewire::test('pages::fiscal-year.edit-fiscal-year', ['year_id' => $editing->id]) + ->assertDontSee(__('budget-plan.fiscal-year.gap-warning.heading')); +}); diff --git a/tests/Pest/Legacy/ConvertLegacyBudgetPlansTest.php b/tests/Pest/Legacy/ConvertLegacyBudgetPlansTest.php new file mode 100644 index 00000000..426f89dd --- /dev/null +++ b/tests/Pest/Legacy/ConvertLegacyBudgetPlansTest.php @@ -0,0 +1,34 @@ +deriveGroupShortName($childTitelNrs); +} + +function nextFreeGroupNr(string $prefix, array $used): string +{ + return (new BudgetPlanConverter)->nextFreeGroupNumber($prefix, $used); +} + +it('derives the group number from the parent prefix of its children', function (): void { + expect(derivedGroupNr(['E.1.1', 'E.1.2']))->toBe('E.1'); + expect(derivedGroupNr(['E.2.1', 'E.2.2']))->toBe('E.2'); +}); + +it('uses the shallowest child when children have mixed depth', function (): void { + expect(derivedGroupNr(['E.3.1.1', 'E.3.1', 'E.3.1.2']))->toBe('E.3'); +}); + +it('returns null when no child is numbered', function (): void { + expect(derivedGroupNr([null, '', 'Personalkosten']))->toBeNull(); + expect(derivedGroupNr([]))->toBeNull(); +}); + +it('auto-counts the next free number per type, skipping taken ones', function (): void { + expect(nextFreeGroupNr('E', []))->toBe('E.1'); + expect(nextFreeGroupNr('E', [1 => true, 2 => true]))->toBe('E.3'); + // fills the first gap so it can't collide with a derived "A.1"/"A.3" + expect(nextFreeGroupNr('A', [1 => true, 3 => true]))->toBe('A.2'); +}); diff --git a/tests/Pest/Legacy/LegacyBudgetViewsTest.php b/tests/Pest/Legacy/LegacyBudgetViewsTest.php new file mode 100644 index 00000000..b95e1207 --- /dev/null +++ b/tests/Pest/Legacy/LegacyBudgetViewsTest.php @@ -0,0 +1,109 @@ + now()->startOfYear(), 'end_date' => now()->endOfYear()]); + + return BudgetPlan::create(['fiscal_year_id' => $fy->id, 'state' => $state]); +} + +function viewItem(BudgetPlan $plan, array $attrs): BudgetItem +{ + return BudgetItem::create(array_merge([ + 'budget_plan_id' => $plan->id, + 'parent_id' => null, + 'is_group' => false, + 'budget_type' => BudgetType::EXPENSE, + 'position' => 0, + 'value' => Money::EUR(0), + ], $attrs)); +} + +it('maps a nested leaf to its parent group', function (): void { + $plan = viewPlan(); + $group = viewItem($plan, ['is_group' => true, 'name' => 'Gruppe', 'budget_type' => BudgetType::EXPENSE]); + $leaf = viewItem($plan, ['parent_id' => $group->id, 'name' => 'Titel', 'short_name' => 'A1.1', 'value' => Money::EUR(300)]); + + // the group shows up as a haushaltsgruppen row; EXPENSE (-1) maps to legacy type 1 + $g = DB::table('haushaltsgruppen')->where('id', $group->id)->sole(); + expect($g->hhp_id)->toBe($plan->id) + ->and($g->gruppen_name)->toBe('Gruppe') + ->and((int) $g->type)->toBe(1); + + // the leaf points at its parent group + $t = DB::table('haushaltstitel')->where('id', $leaf->id)->sole(); + expect((int) $t->hhpgruppen_id)->toBe($group->id) + ->and($t->titel_name)->toBe('Titel') + ->and($t->titel_nr)->toBe('A1.1') + ->and((float) $t->value)->toBe(3.0); +}); + +it('gives a root-level leaf a phantom group and points it at itself', function (): void { + $plan = viewPlan(); + // INCOME (1) maps to legacy type 0 + $leaf = viewItem($plan, ['name' => 'Wurzeltitel', 'short_name' => 'E1', 'budget_type' => BudgetType::INCOME, 'value' => Money::EUR(500)]); + + // a phantom group is synthesized, reusing the leaf's own id + $g = DB::table('haushaltsgruppen')->where('id', $leaf->id)->sole(); + expect($g->hhp_id)->toBe($plan->id) + ->and($g->gruppen_name)->toBe('Wurzeltitel') + ->and((int) $g->type)->toBe(0); + + // the title's hhpgruppen_id points back at that phantom group (its own id), so the legacy join holds + $t = DB::table('haushaltstitel')->where('id', $leaf->id)->sole(); + expect((int) $t->hhpgruppen_id)->toBe($leaf->id); + + $joined = DB::table('haushaltstitel as ht') + ->join('haushaltsgruppen as hg', 'ht.hhpgruppen_id', '=', 'hg.id') + ->where('ht.id', $leaf->id) + ->exists(); + expect($joined)->toBeTrue(); +}); + +it('excludes mount items from both views', function (): void { + $plan = viewPlan(); + $mount = viewItem($plan, ['name' => 'Mount', 'referenced_plan_id' => $plan->id]); + // a group that is also a mount must not leak into haushaltsgruppen either + $groupMount = viewItem($plan, ['is_group' => true, 'name' => 'GroupMount', 'referenced_plan_id' => $plan->id]); + + expect(DB::table('haushaltstitel')->where('id', $mount->id)->exists())->toBeFalse() + ->and(DB::table('haushaltsgruppen')->where('id', $mount->id)->exists())->toBeFalse() + ->and(DB::table('haushaltsgruppen')->where('id', $groupMount->id)->exists())->toBeFalse(); +}); + +it('flags a published plan as final and a draft plan as draft', function (): void { + $published = viewPlan(Published::class); + $draft = viewPlan(Draft::class); + + $row = DB::table('haushaltsplan')->where('id', $published->id)->sole(); + expect($row->state)->toBe('final') + ->and($row->von)->not->toBeNull() + ->and($row->bis)->not->toBeNull(); + + // a draft plan is still in the view (INNER JOIN on fiscal year is satisfied) but flagged draft + expect(DB::table('haushaltsplan')->where('id', $draft->id)->value('state'))->toBe('draft'); +}); + +it('hides a plan without a fiscal year from the haushaltsplan view', function (): void { + $plan = BudgetPlan::create(['state' => Published::class]); // no fiscal_year_id + + expect(DB::table('haushaltsplan')->where('id', $plan->id)->exists())->toBeFalse(); +}); diff --git a/tests/Pest/Project/EditProjectTest.php b/tests/Pest/Project/EditProjectTest.php index eba3e40e..2b6855d5 100644 --- a/tests/Pest/Project/EditProjectTest.php +++ b/tests/Pest/Project/EditProjectTest.php @@ -2,23 +2,22 @@ namespace Tests\Pest\Project; +use App\Models\BudgetItem; +use App\Models\BudgetPlan; +use App\Models\Enums\BudgetType; +use App\Models\FiscalYear; use App\Models\Legacy\ExpenseReceiptPost; -use App\Models\Legacy\LegacyBudgetGroup; -use App\Models\Legacy\LegacyBudgetItem; -use App\Models\Legacy\LegacyBudgetPlan; use App\Models\Legacy\Project; use App\Models\LegalBasis; +use App\States\BudgetPlan\Published; +use Carbon\Carbon; use Cknow\Money\Money; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Livewire\Livewire; beforeEach(function (): void { - $this->budgetPlan = LegacyBudgetPlan::create([ - 'von' => now()->startOfYear(), - 'bis' => now()->endOfYear(), - 'state' => 'final', - ]); + $this->budgetPlan = coveringPlan(now()->startOfYear(), now()->endOfYear()); $this->actingAs(user()); }); @@ -225,21 +224,38 @@ }); /** - * Create a budget Titel (with its enclosing group) under a plan, so that the - * same titel_nr can be reproduced across plans to exercise cross-plan mapping. + * A budget plan (new structure) whose fiscal year covers the given range, projected by the + * legacy haushaltsplan view as a "final" plan so relatedBudgetPlan()/findByDate() see it. */ -function budgetItem(LegacyBudgetPlan $plan, string $titelNr, string $name): LegacyBudgetItem +function coveringPlan(Carbon $start, Carbon $end): BudgetPlan { - $group = LegacyBudgetGroup::create([ - 'hhp_id' => $plan->id, - 'gruppen_name' => 'Gruppe '.$titelNr, - 'type' => 1, + $fiscalYear = FiscalYear::create(['start_date' => $start, 'end_date' => $end]); + + return BudgetPlan::create(['fiscal_year_id' => $fiscalYear->id, 'state' => Published::class]); +} + +/** + * Create a budget Titel (with its enclosing group) under a plan, so that the same titel_nr can + * be reproduced across plans to exercise cross-plan mapping. The group makes the leaf reachable + * through the legacy haushaltsplan -> gruppen -> titel views. + */ +function budgetItem(BudgetPlan $plan, string $titelNr, string $name): BudgetItem +{ + $group = BudgetItem::factory()->create([ + 'budget_plan_id' => $plan->id, + 'is_group' => true, + 'budget_type' => BudgetType::EXPENSE, + 'name' => 'Gruppe '.$titelNr, + 'short_name' => 'G'.$titelNr, ]); - return LegacyBudgetItem::create([ - 'hhpgruppen_id' => $group->id, - 'titel_name' => $name, - 'titel_nr' => $titelNr, + return BudgetItem::factory()->create([ + 'budget_plan_id' => $plan->id, + 'is_group' => false, + 'parent_id' => $group->id, + 'budget_type' => BudgetType::EXPENSE, + 'name' => $name, + 'short_name' => $titelNr, 'value' => 1000, ]); } @@ -322,11 +338,7 @@ function spendOnPost(int $postId, float $euros): void it('carries remaining amounts and remaps titel when creating from leftovers', function (): void { $oldItem = budgetItem($this->budgetPlan, '6000', 'Reise'); - $newPlan = LegacyBudgetPlan::create([ - 'von' => now()->addYear()->startOfYear(), - 'bis' => now()->addYear()->endOfYear(), - 'state' => 'final', - ]); + $newPlan = coveringPlan(now()->addYear()->startOfYear(), now()->addYear()->endOfYear()); $newItem = budgetItem($newPlan, '6000', 'Reise'); $source = Project::factory()->by(user())->withState('terminated')->create(['name' => 'Old Project']); @@ -353,11 +365,7 @@ function spendOnPost(int $postId, float $euros): void it('empties titel when no match exists in the target plan on leftovers', function (): void { $oldItem = budgetItem($this->budgetPlan, '7000', 'Sonstiges'); - $newPlan = LegacyBudgetPlan::create([ - 'von' => now()->addYear()->startOfYear(), - 'bis' => now()->addYear()->endOfYear(), - 'state' => 'final', - ]); + $newPlan = coveringPlan(now()->addYear()->startOfYear(), now()->addYear()->endOfYear()); budgetItem($newPlan, '9999', 'Anderes'); // no matching titel_nr $source = Project::factory()->by(user())->withState('terminated')->create(['name' => 'Old Project']); @@ -375,11 +383,7 @@ function spendOnPost(int $postId, float $euros): void it('skips fully spent posts when creating from leftovers', function (): void { $oldItem = budgetItem($this->budgetPlan, '8000', 'Mixed'); - $newPlan = LegacyBudgetPlan::create([ - 'von' => now()->addYear()->startOfYear(), - 'bis' => now()->addYear()->endOfYear(), - 'state' => 'final', - ]); + $newPlan = coveringPlan(now()->addYear()->startOfYear(), now()->addYear()->endOfYear()); budgetItem($newPlan, '8000', 'Mixed'); $source = Project::factory()->by(user())->withState('terminated')->create(['name' => 'Old Project']); @@ -401,11 +405,7 @@ function spendOnPost(int $postId, float $euros): void it('remaps post titel when the budget plan is changed', function (): void { $matchedOld = budgetItem($this->budgetPlan, '5500', 'Matched'); $unmatchedOld = budgetItem($this->budgetPlan, '5600', 'Unmatched'); - $newPlan = LegacyBudgetPlan::create([ - 'von' => now()->addYear()->startOfYear(), - 'bis' => now()->addYear()->endOfYear(), - 'state' => 'final', - ]); + $newPlan = coveringPlan(now()->addYear()->startOfYear(), now()->addYear()->endOfYear()); $matchedNew = budgetItem($newPlan, '5500', 'Matched'); // only this titel_nr exists in the new plan $project = Project::factory()->by(user())->create(['name' => 'Switch Plan']); diff --git a/tests/Pest/Project/ShowProjectTest.php b/tests/Pest/Project/ShowProjectTest.php index 2bf280bf..f4799b15 100644 --- a/tests/Pest/Project/ShowProjectTest.php +++ b/tests/Pest/Project/ShowProjectTest.php @@ -2,17 +2,18 @@ namespace Tests\Pest\Project; -use App\Models\Legacy\LegacyBudgetPlan; +use App\Models\BudgetPlan; +use App\Models\FiscalYear; use App\Models\Legacy\Project; +use App\States\BudgetPlan\Published; use Livewire\Livewire; beforeEach(function (): void { - // relatedBudgetPlan()->label() / the budget-plan link need a covering plan. - LegacyBudgetPlan::create([ - 'von' => now()->startOfYear(), - 'bis' => now()->endOfYear(), - 'state' => 'final', - ]); + // relatedBudgetPlan()->label() / the budget-plan link need a covering plan. The legacy + // haushaltsplan is now a view over budget_plan, so seed the new structure (published => + // "final" in the view) with a fiscal year covering today. + $fiscalYear = FiscalYear::create(['start_date' => now()->startOfYear(), 'end_date' => now()->endOfYear()]); + BudgetPlan::create(['fiscal_year_id' => $fiscalYear->id, 'state' => Published::class]); $this->actingAs(user()); }); diff --git a/tests/Pest/TaxBudget/AddToPlanTest.php b/tests/Pest/TaxBudget/AddToPlanTest.php index 56bcf022..d2b18353 100644 --- a/tests/Pest/TaxBudget/AddToPlanTest.php +++ b/tests/Pest/TaxBudget/AddToPlanTest.php @@ -2,40 +2,43 @@ namespace Tests\Pest\TaxBudget; -use App\Models\Legacy\LegacyBudgetGroup; -use App\Models\Legacy\LegacyBudgetItem; -use App\Models\Legacy\LegacyBudgetPlan; +use App\Models\BudgetItem; +use App\Models\BudgetPlan; use App\Models\TaxBudget; +use App\States\BudgetPlan\Draft; beforeEach(function (): void { - $this->plan = LegacyBudgetPlan::create([ - 'von' => now()->startOfYear(), - 'bis' => now()->endOfYear(), - 'state' => 'final', - ]); + $this->plan = BudgetPlan::create(['state' => Draft::class]); }); function taxTitleCount(int $planId): int { - $groupIds = LegacyBudgetGroup::where('hhp_id', $planId)->pluck('id'); - - return LegacyBudgetItem::whereIn('hhpgruppen_id', $groupIds)->count(); + return BudgetItem::where('budget_plan_id', $planId)->where('is_group', false)->count(); } -it('adds the Umsatzsteuer group and two tax titles', function (): void { - TaxBudget::addToPlan($this->plan->id); +it('adds the Umsatzsteuer group and a title per rate, returning the count added', function (): void { + $added = TaxBudget::addToPlan($this->plan->id); - expect(LegacyBudgetGroup::where('hhp_id', $this->plan->id)->count())->toBe(1) + expect($added)->toBe(2) + ->and(BudgetItem::where('budget_plan_id', $this->plan->id)->where('is_group', true)->count())->toBe(1) ->and(taxTitleCount($this->plan->id))->toBe(2) - ->and(TaxBudget::where('hhp_id', $this->plan->id)->count())->toBe(2); + ->and(TaxBudget::where('plan_id', $this->plan->id)->count())->toBe(2); }); -it('does not add duplicates when called repeatedly', function (): void { - TaxBudget::addToPlan($this->plan->id); - TaxBudget::addToPlan($this->plan->id); - TaxBudget::addToPlan($this->plan->id); +it('is idempotent: a repeated call adds nothing and returns 0', function (): void { + expect(TaxBudget::addToPlan($this->plan->id))->toBe(2) + ->and(TaxBudget::addToPlan($this->plan->id))->toBe(0) + ->and(TaxBudget::addToPlan($this->plan->id))->toBe(0); - expect(LegacyBudgetGroup::where('hhp_id', $this->plan->id)->count())->toBe(1) + expect(BudgetItem::where('budget_plan_id', $this->plan->id)->where('is_group', true)->count())->toBe(1) ->and(taxTitleCount($this->plan->id))->toBe(2) - ->and(TaxBudget::where('hhp_id', $this->plan->id)->count())->toBe(2); + ->and(TaxBudget::where('plan_id', $this->plan->id)->count())->toBe(2); +}); + +it('honours explicit rates and normalises them (dedupe, drop non-positive, sort)', function (): void { + $added = TaxBudget::addToPlan($this->plan->id, [19, 7, 7, 0]); + + expect($added)->toBe(2) + ->and(TaxBudget::where('plan_id', $this->plan->id)->orderBy('tax_percent')->pluck('tax_percent')->map(fn ($p) => (int) $p)->all()) + ->toBe([7, 19]); });