diff --git a/app/DataTransferObjects/ExportFormat.php b/app/DataTransferObjects/ExportFormat.php new file mode 100644 index 0000000..97ac674 --- /dev/null +++ b/app/DataTransferObjects/ExportFormat.php @@ -0,0 +1,15 @@ +requireYear(), $this->month ?? 1, 1); + + assert($start !== null); + + return $start; + } + + public function end(): CarbonImmutable + { + return $this->month === null + ? $this->start()->endOfYear() + : $this->start()->endOfMonth(); + } + + public function requireYear(): int + { + return $this->year ?? throw new InvalidArgumentException('This export requires a year.'); + } + + public function requireMonth(): int + { + return $this->month ?? throw new InvalidArgumentException('This export requires a month.'); + } +} diff --git a/app/Enums/ExportPeriodOptions.php b/app/Enums/ExportPeriodOptions.php new file mode 100644 index 0000000..4fb4e11 --- /dev/null +++ b/app/Enums/ExportPeriodOptions.php @@ -0,0 +1,20 @@ + new MonthlyBudgetsExport($period->requireYear(), $period->requireMonth(), app(BudgetUsageService::class)), + 'budgets-excel' => new BudgetsExport, + 'entries-excel' => new EntriesExport($period->start(), $period->end()), + 'users-monthly-summary-excel' => new UsersMonthlySummaryExport($period->start(), $period->end()), + default => throw new InvalidArgumentException("Unknown export format: {$key}"), + }; + } + + public static function fromConfig(array $config): static + { + return new self; + } +} diff --git a/app/Exports/EntriesExport.php b/app/Exports/EntriesExport.php index 0914214..4627c19 100644 --- a/app/Exports/EntriesExport.php +++ b/app/Exports/EntriesExport.php @@ -3,6 +3,7 @@ namespace App\Exports; use App\DataTransferObjects\EntryExportRow; +use App\Integrations\Contracts\ExportInterface; use App\Models\Entry; use App\Models\User; use Brick\Math\BigDecimal; @@ -12,7 +13,7 @@ use OpenSpout\Common\Entity\Row; use OpenSpout\Writer\XLSX\Writer; -class EntriesExport +class EntriesExport implements ExportInterface { private CarbonInterface $start; diff --git a/app/Exports/MonthlyBudgetsExport.php b/app/Exports/MonthlyBudgetsExport.php index 2b561db..96bb042 100644 --- a/app/Exports/MonthlyBudgetsExport.php +++ b/app/Exports/MonthlyBudgetsExport.php @@ -3,6 +3,7 @@ namespace App\Exports; use App\DataTransferObjects\BudgetMutation; +use App\Integrations\Contracts\ExportInterface; use App\Models\Customer; use App\Models\User; use App\Services\BudgetUsageService; @@ -11,7 +12,7 @@ use OpenSpout\Common\Entity\Row; use OpenSpout\Writer\XLSX\Writer; -class MonthlyBudgetsExport +class MonthlyBudgetsExport implements ExportInterface { private Carbon $month; diff --git a/app/Exports/UsersMonthlySummaryExport.php b/app/Exports/UsersMonthlySummaryExport.php index 6e36061..561a8a9 100644 --- a/app/Exports/UsersMonthlySummaryExport.php +++ b/app/Exports/UsersMonthlySummaryExport.php @@ -2,6 +2,7 @@ namespace App\Exports; +use App\Integrations\Contracts\ExportInterface; use App\Models\Entry; use App\Models\User; use App\Queries\HoursPerUserPerMonth; @@ -11,7 +12,7 @@ use OpenSpout\Common\Entity\Row; use OpenSpout\Writer\XLSX\Writer; -class UsersMonthlySummaryExport +class UsersMonthlySummaryExport implements ExportInterface { public function __construct( private CarbonInterface $start, diff --git a/app/Filament/Resources/Integrations/Tables/IntegrationsTable.php b/app/Filament/Resources/Integrations/Tables/IntegrationsTable.php index a0a4610..6fcb632 100644 --- a/app/Filament/Resources/Integrations/Tables/IntegrationsTable.php +++ b/app/Filament/Resources/Integrations/Tables/IntegrationsTable.php @@ -2,7 +2,6 @@ namespace App\Filament\Resources\Integrations\Tables; -use App\Filament\Resources\Integrations\IntegrationResource; use App\Integrations\IntegrationTypeRegistry; use App\Models\Integration; use Filament\Tables\Columns\TextColumn; @@ -23,7 +22,7 @@ public static function configure(Table $table): Table return $pageClass ? $pageClass::getUrl(['record' => $record->id]) - : IntegrationResource::getUrl('edit', ['record' => $record]); + : null; }); } } diff --git a/app/Http/Controllers/ExportEmailController.php b/app/Http/Controllers/ExportEmailController.php index ce4ee54..cb54297 100644 --- a/app/Http/Controllers/ExportEmailController.php +++ b/app/Http/Controllers/ExportEmailController.php @@ -5,7 +5,6 @@ use App\Http\Requests\ExportRequest; use App\Jobs\ExportBudgetsJob; use App\Models\User; -use App\Services\BudgetUsageService; use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\JsonResponse; @@ -19,15 +18,15 @@ class ExportEmailController extends Controller * @return JsonResponse */ #[ExcludeRouteFromDocs] - public function __invoke(ExportRequest $request, BudgetUsageService $usageService, #[CurrentUser] User $user) + public function __invoke(ExportRequest $request, #[CurrentUser] User $user) { $validated = $request->validated(); $exportType = $validated['exportType']; - $year = $validated['year']; + $year = $validated['year'] ?? null; $month = $validated['month'] ?? null; - ExportBudgetsJob::dispatch($user, $exportType, $year, $month, $usageService); + ExportBudgetsJob::dispatch($user, $exportType, $year, $month); return response()->json(['message' => __('Export gestart. Je ontvangt een e-mail zodra het bestand klaar is.')], Response::HTTP_ACCEPTED); } @@ -43,6 +42,5 @@ public function download(string $fileName) } return Storage::download($fileName); - } } diff --git a/app/Http/Controllers/ExportFormatController.php b/app/Http/Controllers/ExportFormatController.php new file mode 100644 index 0000000..bf8a652 --- /dev/null +++ b/app/Http/Controllers/ExportFormatController.php @@ -0,0 +1,15 @@ +formats()); + } +} diff --git a/app/Http/Requests/ExportRequest.php b/app/Http/Requests/ExportRequest.php index f140dd6..d1c3dd5 100644 --- a/app/Http/Requests/ExportRequest.php +++ b/app/Http/Requests/ExportRequest.php @@ -2,8 +2,10 @@ namespace App\Http\Requests; +use App\Integrations\ExportService; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class ExportRequest extends FormRequest { @@ -20,12 +22,20 @@ public function authorize(): bool * * @return array|string> */ - public function rules(): array + public function rules(ExportService $exportService): array { + $format = $exportService->findFormat($this->string('exportType')->toString()); + return [ - 'exportType' => 'required|string', - 'year' => 'required|integer|min:2000|max:'.date('Y'), - 'month' => 'required_unless:exportType,entries-excel|nullable|integer|min:1|max:12', + 'exportType' => ['required', 'string', Rule::in($exportService->formatKeys())], + 'year' => [ + Rule::requiredIf($format?->periodOptions->yearIsRequired() ?? false), + 'nullable', 'integer', 'min:2000', 'max:'.date('Y'), + ], + 'month' => [ + Rule::requiredIf($format?->periodOptions->monthIsRequired() ?? false), + 'nullable', 'integer', 'min:1', 'max:12', + ], ]; } } diff --git a/app/Http/Resources/ExportFormat.php b/app/Http/Resources/ExportFormat.php new file mode 100644 index 0000000..10309f8 --- /dev/null +++ b/app/Http/Resources/ExportFormat.php @@ -0,0 +1,31 @@ + $this->label, + 'periodOptions' => $this->periodOptions->value, + 'extension' => $this->extension, + ]; + } + + public function toType(Request $request): string + { + return 'exportFormats'; + } + + public function toId(Request $request): string + { + return $this->key; + } +} diff --git a/app/Integrations/Contracts/ExportInterface.php b/app/Integrations/Contracts/ExportInterface.php new file mode 100644 index 0000000..60207fd --- /dev/null +++ b/app/Integrations/Contracts/ExportInterface.php @@ -0,0 +1,8 @@ + + */ + public function exportFormats(): Collection; + + public function createExport(string $key, ExportPeriod $period): ExportInterface; + + /** @param array $config */ + public static function fromConfig(array $config): static; +} diff --git a/app/Integrations/ExportProviderRegistry.php b/app/Integrations/ExportProviderRegistry.php new file mode 100644 index 0000000..eba1e8e --- /dev/null +++ b/app/Integrations/ExportProviderRegistry.php @@ -0,0 +1,35 @@ +>> */ + private array $classes = []; + + /** @param class-string $providerClass */ + public function register(string $type, string $providerClass): void + { + $this->classes[$type][] = $providerClass; + } + + /** + * @param array $config + * @return list + */ + public function makeProviders(string $type, array $config): array + { + return array_map( + fn (string $class): ExportProviderInterface => $class::fromConfig($config), + $this->classes[$type] ?? [], + ); + } + + /** @return list */ + public function registeredTypes(): array + { + return array_keys($this->classes); + } +} diff --git a/app/Integrations/ExportService.php b/app/Integrations/ExportService.php new file mode 100644 index 0000000..c8a6c5b --- /dev/null +++ b/app/Integrations/ExportService.php @@ -0,0 +1,76 @@ + */ + public function formats(): Collection + { + $formats = $this->resolveProviders() + ->flatMap(fn (ExportProviderInterface $provider) => $provider->exportFormats()) + ->values(); + + $duplicateKeys = $formats->countBy(fn (ExportFormat $format) => $format->key)->filter(fn (int $count) => $count > 1); + + if ($duplicateKeys->isNotEmpty()) { + throw new RuntimeException('Duplicate export format keys registered: '.$duplicateKeys->keys()->implode(', ')); + } + + return $formats; + } + + public function findFormat(string $key): ?ExportFormat + { + return $this->formats()->first(fn (ExportFormat $format) => $format->key === $key); + } + + /** @return list */ + public function formatKeys(): array + { + return array_values($this->formats()->map(fn (ExportFormat $format) => $format->key)->all()); + } + + public function createExport(string $key, ExportPeriod $period): ExportInterface + { + $provider = $this->resolveProviders()->first( + fn (ExportProviderInterface $provider) => $provider->exportFormats()->contains( + fn (ExportFormat $format) => $format->key === $key, + ), + ); + + if ($provider === null) { + throw new InvalidArgumentException("Unknown export format: {$key}"); + } + + return $provider->createExport($key, $period); + } + + /** @return Collection */ + private function resolveProviders(): Collection + { + return collect([$this->coreProvider]) + ->merge( + Integration::whereIn('type', $this->registry->registeredTypes())->get() + ->flatMap(fn (Integration $integration) => $this->registry->makeProviders( + $integration->type, + [...($integration->config ?? []), 'integration_id' => $integration->id], + )), + ); + } +} diff --git a/app/Jobs/ExportBudgetsJob.php b/app/Jobs/ExportBudgetsJob.php index 2fcb8c4..4c9f4ff 100644 --- a/app/Jobs/ExportBudgetsJob.php +++ b/app/Jobs/ExportBudgetsJob.php @@ -2,101 +2,61 @@ namespace App\Jobs; -use App\Exports\BudgetsExport; -use App\Exports\EntriesExport; -use App\Exports\MonthlyBudgetsExport; -use App\Exports\UsersMonthlySummaryExport; +use App\DataTransferObjects\ExportPeriod; +use App\Integrations\ExportService; use App\Mail\ExportEmail; use App\Models\User; -use App\Services\BudgetUsageService; -use Carbon\CarbonImmutable; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use InvalidArgumentException; -use Webmozart\Assert\Assert; class ExportBudgetsJob implements ShouldQueue { use Dispatchable, Queueable; - protected User $user; + public function __construct( + protected User $user, + protected string $exportType, + protected ?int $year, + protected ?int $month, + ) {} - protected string $exportType; - - protected int $year; - - protected ?int $month; + public function handle(ExportService $exportService): void + { + $format = $exportService->findFormat($this->exportType) + ?? throw new InvalidArgumentException("Invalid export type: {$this->exportType}"); - protected BudgetUsageService $usageService; + $fileName = $this->generateFileName($format->extension); - public function __construct(User $user, string $exportType, int $year, ?int $month, BudgetUsageService $usageService) - { - $this->user = $user; - $this->exportType = $exportType; - $this->year = $year; - $this->month = $month; - $this->usageService = $usageService; - } + $exportService + ->createExport($this->exportType, new ExportPeriod($this->year, $this->month)) + ->export(Storage::disk('temp')->path($fileName)); - public function handle(): void - { - $fileName = $this->generateFileName(); - $this->exportData($fileName); + $this->storeExport($fileName); $this->sendExportEmail($fileName); } - private function generateFileName(): string - { - return "export_{$this->exportType}_{$this->year}_".($this->month ?? 'all').'.xlsx'; - } - - private function exportData(string $fileName): void + private function generateFileName(string $extension): string { - $start = CarbonImmutable::create($this->year, $this->month ?? 1, 1); - if ($start === null) { - throw new InvalidArgumentException("Invalid date for year {$this->year} and month {$this->month}"); + if ($this->year === null) { + return "export_{$this->exportType}.{$extension}"; } - if ($this->month === null) { - $end = $start->endOfYear(); - } else { - $end = $start->endOfMonth(); - } - - $localFilePath = Storage::disk('temp')->path($fileName); - - switch ($this->exportType) { - case 'budgets-monthly-excel': - Assert::notNull($this->month); - (new MonthlyBudgetsExport($this->year, $this->month, $this->usageService))->export($localFilePath); - break; - - case 'budgets-excel': - (new BudgetsExport)->export($localFilePath); - break; - - case 'entries-excel': - (new EntriesExport($start, $end))->export($localFilePath); - break; - - case 'users-monthly-summary-excel': - (new UsersMonthlySummaryExport($start, $end))->export($localFilePath); - break; - default: - throw new InvalidArgumentException("Invalid export type: {$this->exportType}"); - } + return "export_{$this->exportType}_{$this->year}_".($this->month ?? 'all').".{$extension}"; + } + private function storeExport(string $fileName): void + { $content = Storage::disk('temp')->get($fileName); - if ($content !== null) { - Storage::disk('s3')->put($fileName, $content); - } else { + if ($content === null) { throw new InvalidArgumentException("File {$fileName} does not exist"); } + Storage::put($fileName, $content); } private function sendExportEmail(string $fileName): void diff --git a/app/Mail/ExportEmail.php b/app/Mail/ExportEmail.php index 65df6c0..8c3a7c9 100644 --- a/app/Mail/ExportEmail.php +++ b/app/Mail/ExportEmail.php @@ -19,13 +19,15 @@ class ExportEmail extends Mailable public string $formattedDate; - public function __construct(string $fileName, int $year, ?int $month, string $exportType) + public function __construct(string $fileName, ?int $year, ?int $month, string $exportType) { $this->fileName = $fileName; $this->exportType = $exportType; $this->downloadUrl = route('download.export', ['fileName' => $this->fileName]); - if (is_null($month)) { + if (is_null($year)) { + $this->formattedDate = ''; + } elseif (is_null($month)) { $this->formattedDate = (string) $year; } else { $this->formattedDate = Carbon::createFromDate($year, $month, 1) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index dd4bdf9..c192226 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Integrations\ExportProviderRegistry; +use App\Integrations\ExportService; use App\Integrations\IntegrationTypeRegistry; use App\Integrations\TicketProviderRegistry; use App\Integrations\TicketService; @@ -35,6 +37,8 @@ public function register(): void $this->app->singleton(IntegrationTypeRegistry::class); $this->app->singleton(TicketProviderRegistry::class); $this->app->singleton(TicketService::class); + $this->app->singleton(ExportProviderRegistry::class); + $this->app->singleton(ExportService::class); } public function boot(): void diff --git a/composer.json b/composer.json index 19fe31e..5d1e324 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ "spatie/laravel-query-builder": "^6.3", "timacdonald/json-api": "dev-main as 1.0.0-beta.99", "timatic/bitbucket-integration": "1.0.0", + "timatic/exact-globe-integration": "1.0.0", "timatic/google-calendar-integration": "1.0.0", "timatic/jira-integration": "1.0.0", "timatic/nmbrs-integration": "*", @@ -58,6 +59,10 @@ "type": "path", "url": "integrations/jira" }, + { + "type": "path", + "url": "integrations/exact-globe" + }, { "type": "path", "url": "integrations/bitbucket" diff --git a/composer.lock b/composer.lock index 4bfb2a0..7040458 100644 --- a/composer.lock +++ b/composer.lock @@ -10760,6 +10760,36 @@ "relative": true } }, + { + "name": "timatic/exact-globe-integration", + "version": "1.0.0", + "dist": { + "type": "path", + "url": "integrations/exact-globe", + "reference": "d0baa4d6750c782280b73d40ee1b05300a1b4dba" + }, + "require": { + "openspout/openspout": "^4.0", + "php": "^8.4" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Timatic\\ExactGlobe\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Timatic\\ExactGlobe\\": "src/" + } + }, + "description": "Exact Globe export integration for Timatic", + "transport-options": { + "relative": true + } + }, { "name": "timatic/google-calendar-integration", "version": "1.0.0", diff --git a/integrations/exact-globe/composer.json b/integrations/exact-globe/composer.json new file mode 100644 index 0000000..789c98f --- /dev/null +++ b/integrations/exact-globe/composer.json @@ -0,0 +1,24 @@ +{ + "name": "timatic/exact-globe-integration", + "description": "Exact Globe export integration for Timatic", + "type": "library", + "version": "1.0.0", + "require": { + "php": "^8.4", + "openspout/openspout": "^4.0" + }, + "autoload": { + "psr-4": { + "Timatic\\ExactGlobe\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Timatic\\ExactGlobe\\ServiceProvider" + ] + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/integrations/exact-globe/resources/views/filament/pages/ledger-mapping-page.blade.php b/integrations/exact-globe/resources/views/filament/pages/ledger-mapping-page.blade.php new file mode 100644 index 0000000..6d969da --- /dev/null +++ b/integrations/exact-globe/resources/views/filament/pages/ledger-mapping-page.blade.php @@ -0,0 +1,3 @@ + + {{ $this->form }} + diff --git a/integrations/exact-globe/src/BudgetsExactExport.php b/integrations/exact-globe/src/BudgetsExactExport.php new file mode 100644 index 0000000..710114b --- /dev/null +++ b/integrations/exact-globe/src/BudgetsExactExport.php @@ -0,0 +1,223 @@ + $ledgerMappings keyed by budget type id + */ + public function __construct( + int $year, + int $month, + private BudgetUsageService $usageService, + private Collection $ledgerMappings, + ) { + /** @var Carbon $firstOfMonth */ + $firstOfMonth = Carbon::create($year, $month, 1); + $this->month = $firstOfMonth; + } + + public function export(string $filePath): void + { + $writer = new Writer; + $writer->openToFile($filePath); + + $writer->addRow(Row::fromValues($this->headings())); + + foreach ($this->rows() as $row) { + $writer->addRow(Row::fromValues($this->map($row))); + } + + $writer->close(); + } + + /** + * @return list + */ + public function map(MutationRow $row): array + { + $amount = ($row->amount->isPositive() ? '+' : '').str_replace('.', ',', (string) $row->amount); + $lastDayOfMonth = $this->month->clone()->lastOfMonth()->format('tmY'); + $fullDescription = $row->description.' '.$this->month->format('F Y').' - '.$row->budgetId; + + return [ + $row->index, + 'M', // dagboekType + '90', // dagboekNr + $this->month->format('m'), + $this->month->format('Y'), + '', + $fullDescription, + $lastDayOfMonth, + $this->ledgerId($row), + $row->customerId ?? '', + '', + '', + $amount, + '', + 'EUR', + '1', + '', + '', + '', + '', + '0', + '0,00', + '', + '', + '', + '', + ($row->credit ? 10 : 20), // kostplaatsCode + '', + '0,00', + '', + '', + 'N', + '', + '', + '', + '', + '', + '', + '', + '', + ]; + } + + /** + * @return list + */ + public function headings(): array + { + return [ + '0', + 'M', + '90', + $this->month->format('m'), + $this->month->format('Y'), + '', + '', + '', + '', + '', + '', + '', + '0,00', + '', + '', + '', + 'K', + '0,00', + '', + '', + '', + '', + '', + '', + 'B', + '', + '', + '', + '', + '', + '', + 'N', + '', + '', + '', + '', + '', + '', + '', + '', + ]; + } + + /** + * @return Collection + */ + public function rows(): Collection + { + /** @var Collection $rows */ + $rows = new Collection; + + $budgetUsage = (new Collection($this->usageService->get($this->month))) + ->filter(fn (BudgetMutation $usage) => $this->ledgerMappings->has($usage->budget->budget_type_id)); + + foreach ($budgetUsage as $budgetMutation) { + $this->addRowPairToCollection(self::USAGE_DESCRIPTION, $budgetMutation->usedCredit, $budgetMutation, $rows); + } + + foreach ($budgetUsage as $budgetMutation) { + $this->addRowPairToCollection(self::RELEASE_DESCRIPTION, $budgetMutation->expiredCredit, $budgetMutation, $rows); + } + + return $rows; + } + + private function ledgerId(MutationRow $row): string + { + /** @var LedgerMapping $mapping */ + $mapping = $this->ledgerMappings->get($row->budgetTypeId); + + return match ($row->description) { + self::USAGE_DESCRIPTION => $row->credit ? $mapping->usageCreditLedgerId : $mapping->usageDebitLedgerId, + default => $row->credit ? $mapping->releaseCreditLedgerId : $mapping->releaseDebitLedgerId, + }; + } + + /** + * @param Collection $rows + */ + private function addRowPairToCollection( + string $description, + BigDecimal $amount, + BudgetMutation $budgetMutation, + Collection $rows, + ): void { + if ($amount->isEqualTo(0)) { + return; + } + + $creditRow = new MutationRow( + index: $rows->count() + 1, + description: $description, + amount: $amount, + customerId: $budgetMutation->budget->customer?->external_id, + budgetTypeId: $budgetMutation->budget->budget_type_id, + budgetId: $budgetMutation->budget->id, + credit: true, + ); + + $debitRow = new MutationRow( + index: $rows->count() + 2, + description: $description, + amount: $amount->multipliedBy(-1), + customerId: $creditRow->customerId, + budgetTypeId: $creditRow->budgetTypeId, + budgetId: $creditRow->budgetId, + credit: false, + ); + + $rows->push($creditRow); + $rows->push($debitRow); + } +} diff --git a/integrations/exact-globe/src/DataTransferObjects/LedgerMapping.php b/integrations/exact-globe/src/DataTransferObjects/LedgerMapping.php new file mode 100644 index 0000000..245f64d --- /dev/null +++ b/integrations/exact-globe/src/DataTransferObjects/LedgerMapping.php @@ -0,0 +1,26 @@ + $row */ + public static function fromConfigRow(string $budgetTypeId, array $row): self + { + return new self( + budgetTypeId: $budgetTypeId, + usageCreditLedgerId: (string) ($row['usage_credit'] ?? ''), + usageDebitLedgerId: (string) ($row['usage_debit'] ?? ''), + releaseCreditLedgerId: (string) ($row['release_credit'] ?? ''), + releaseDebitLedgerId: (string) ($row['release_debit'] ?? ''), + ); + } +} diff --git a/integrations/exact-globe/src/DataTransferObjects/MutationRow.php b/integrations/exact-globe/src/DataTransferObjects/MutationRow.php new file mode 100644 index 0000000..b3f2bb7 --- /dev/null +++ b/integrations/exact-globe/src/DataTransferObjects/MutationRow.php @@ -0,0 +1,18 @@ + $ledgerMappings keyed by budget type id + */ + public function __construct(private readonly Collection $ledgerMappings) {} + + public function exportFormats(): Collection + { + if ($this->ledgerMappings->isEmpty()) { + return new Collection; + } + + return new Collection([ + new ExportFormat(self::EXPORT_KEY, 'Budget mutations - Exact', ExportPeriodOptions::Monthly, 'csv'), + ]); + } + + public function createExport(string $key, ExportPeriod $period): ExportInterface + { + if ($key !== self::EXPORT_KEY) { + throw new InvalidArgumentException("Unknown export format: {$key}"); + } + + return new BudgetsExactExport( + $period->requireYear(), + $period->requireMonth(), + app(BudgetUsageService::class), + $this->ledgerMappings, + ); + } + + public static function fromConfig(array $config): static + { + /** @var array> $mappingRows */ + $mappingRows = $config['ledger_mapping'] ?? []; + + $ledgerMappings = (new Collection($mappingRows)) + ->map(fn (array $row, string $budgetTypeId) => LedgerMapping::fromConfigRow($budgetTypeId, $row)); + + return new self($ledgerMappings); + } +} diff --git a/integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php b/integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php new file mode 100644 index 0000000..5c54d86 --- /dev/null +++ b/integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php @@ -0,0 +1,175 @@ + */ + public array $data = []; + + public function getTitle(): string + { + return $this->getIntegration()->name; + } + + public static function getSubNavigationPosition(): SubNavigationPosition + { + return SubNavigationPosition::Start; + } + + public function getSubNavigation(): array + { + $record = $this->getRecord(); + + return [ + NavigationItem::make('Ledger mapping') + ->url(LedgerMappingPage::getUrl(['record' => $record])) + ->isActiveWhen(fn () => request()->url() === LedgerMappingPage::getUrl(['record' => $record])), + ]; + } + + public function mount(int|string $record): void + { + $this->record = $this->resolveRecord($record); + + $config = $this->getIntegration()->config ?? []; + $ledgerMapping = $config['ledger_mapping'] ?? []; + + $this->form->fill([ + 'name' => $this->getIntegration()->name, + 'ledger_mapping' => $ledgerMapping, + 'enabled' => array_fill_keys(array_keys($ledgerMapping), true), + ]); + } + + public function form(Schema $form): Schema + { + return $form->schema([ + TextInput::make('name') + ->label('Integration name') + ->required(), + + ...$this->budgetTypeSections(), + ])->statePath('data'); + } + + protected function getHeaderActions(): array + { + return [ + Action::make('delete') + ->label('Delete') + ->color('danger') + ->requiresConfirmation() + ->action(function (): void { + $this->getIntegration()->delete(); + $this->redirect(IntegrationResource::getUrl('index')); + }), + + Action::make('save') + ->label('Save') + ->action(function (): void { + $data = $this->form->getState(); + + $config = array_merge($this->getIntegration()->config ?? [], [ + 'ledger_mapping' => $this->completedMappingRows( + $data['ledger_mapping'] ?? [], + $data['enabled'] ?? [], + ), + ]); + + $this->getIntegration()->update([ + 'name' => $data['name'], + 'config' => $config, + ]); + + Notification::make()->title('Ledger mapping saved.')->success()->send(); + }), + ]; + } + + /** + * @return list
+ */ + private function budgetTypeSections(): array + { + return array_values(BudgetType::query() + ->where('is_archived', false) + ->orderBy('title') + ->get() + ->map(fn (BudgetType $budgetType): Section => Section::make($budgetType->title) + ->description('Enable to map ledger accounts for '.$budgetType->title.' budgets. Disabled budget types are excluded from the export.') + ->columns(2) + ->schema([ + Toggle::make("enabled.{$budgetType->id}") + ->label('Include in export') + ->live() + ->columnSpanFull(), + + TextInput::make("ledger_mapping.{$budgetType->id}.usage_credit") + ->label('Verbruik credit ledger') + ->numeric() + ->visible(fn (Get $get): bool => (bool) $get("enabled.{$budgetType->id}")), + TextInput::make("ledger_mapping.{$budgetType->id}.usage_debit") + ->label('Verbruik debit ledger') + ->numeric() + ->visible(fn (Get $get): bool => (bool) $get("enabled.{$budgetType->id}")), + TextInput::make("ledger_mapping.{$budgetType->id}.release_credit") + ->label('Vrijval credit ledger') + ->numeric() + ->visible(fn (Get $get): bool => (bool) $get("enabled.{$budgetType->id}")), + TextInput::make("ledger_mapping.{$budgetType->id}.release_debit") + ->label('Vrijval debit ledger') + ->numeric() + ->visible(fn (Get $get): bool => (bool) $get("enabled.{$budgetType->id}")), + ])) + ->all()); + } + + /** + * @param array> $rows + * @param array $enabled + * @return array> + */ + private function completedMappingRows(array $rows, array $enabled): array + { + return array_filter( + $rows, + fn (array $row, string $budgetTypeId): bool => ($enabled[$budgetTypeId] ?? false) + && filled($row['usage_credit'] ?? null) + && filled($row['usage_debit'] ?? null) + && filled($row['release_credit'] ?? null) + && filled($row['release_debit'] ?? null), + ARRAY_FILTER_USE_BOTH, + ); + } + + private function getIntegration(): Integration + { + /** @var Integration */ + return Integration::findOrFail($this->getRecord()->getKey()); + } +} diff --git a/integrations/exact-globe/src/ServiceProvider.php b/integrations/exact-globe/src/ServiceProvider.php new file mode 100644 index 0000000..9a82487 --- /dev/null +++ b/integrations/exact-globe/src/ServiceProvider.php @@ -0,0 +1,29 @@ +callAfterResolving(IntegrationTypeRegistry::class, function (IntegrationTypeRegistry $types): void { + $types->register(self::INTEGRATION_TYPE, [ + 'exact-globe.ledger-mapping' => LedgerMappingPage::class, + ]); + }); + } + + public function boot(ExportProviderRegistry $exportProviders): void + { + $exportProviders->register(self::INTEGRATION_TYPE, ExactGlobeExportProvider::class); + + $this->loadViewsFrom(__DIR__.'/../resources/views', self::INTEGRATION_TYPE); + } +} diff --git a/integrations/exact-globe/tests/BudgetsExactExportTest.php b/integrations/exact-globe/tests/BudgetsExactExportTest.php new file mode 100644 index 0000000..790ed71 --- /dev/null +++ b/integrations/exact-globe/tests/BudgetsExactExportTest.php @@ -0,0 +1,92 @@ +for(Customer::factory()->state(['external_id' => 'CUST-42'])) + ->create(['budget_type_id' => 'project']); + + $mutation = new BudgetMutation($budget); + $mutation->usedCredit = BigDecimal::of('10.5'); + $mutation->expiredCredit = BigDecimal::of('2'); + + $usageService = mock(BudgetUsageService::class); + $usageService->shouldReceive('get')->andReturn(new EloquentCollection([$mutation])); + + $ledgerMappings = new Collection([ + 'project' => new LedgerMapping('project', '1001', '1002', '2001', '2002'), + ]); + + $filePath = tempnam(sys_get_temp_dir(), 'exact').'.csv'; + (new BudgetsExactExport(2026, 6, $usageService, $ledgerMappings))->export($filePath); + + $rows = array_map('str_getcsv', file($filePath)); + + expect($rows)->toHaveCount(5); + [$usageCredit, $usageDebit, $releaseCredit, $releaseDebit] = array_slice($rows, 1); + expect($usageCredit[8])->toBe('1001') + ->and($usageCredit[12])->toBe('+10,5') + ->and($usageCredit[9])->toBe('CUST-42') + ->and($usageDebit[8])->toBe('1002') + ->and($usageDebit[12])->toBe('-10,5') + ->and($releaseCredit[8])->toBe('2001') + ->and($releaseCredit[12])->toBe('+2') + ->and($releaseDebit[8])->toBe('2002') + ->and($releaseDebit[12])->toBe('-2'); +}); + +it('skips budgets whose type has no ledger mapping', function () { + $budget = Budget::factory()->create(['budget_type_id' => 'support']); + + $mutation = new BudgetMutation($budget); + $mutation->usedCredit = BigDecimal::of('8'); + + $usageService = mock(BudgetUsageService::class); + $usageService->shouldReceive('get')->andReturn(new EloquentCollection([$mutation])); + + $ledgerMappings = new Collection([ + 'project' => new LedgerMapping('project', '1001', '1002', '2001', '2002'), + ]); + + $filePath = tempnam(sys_get_temp_dir(), 'exact').'.csv'; + (new BudgetsExactExport(2026, 6, $usageService, $ledgerMappings))->export($filePath); + + expect(file($filePath))->toHaveCount(1); +}); + +it('builds ledger mappings from the integration config', function () { + $provider = ExactGlobeExportProvider::fromConfig([ + 'ledger_mapping' => [ + 'project' => [ + 'usage_credit' => '1001', + 'usage_debit' => '1002', + 'release_credit' => '2001', + 'release_debit' => '2002', + ], + ], + ]); + + expect($provider->exportFormats())->toHaveCount(1) + ->and($provider->exportFormats()->first()->key)->toBe('exact-globe-csv'); +}); + +it('exposes no export formats without a ledger mapping', function () { + $provider = ExactGlobeExportProvider::fromConfig([]); + + expect($provider->exportFormats())->toBeEmpty(); +}); diff --git a/integrations/rework/src/Connector.php b/integrations/rework/src/Connector.php index 481a7ff..d2fc1b2 100644 --- a/integrations/rework/src/Connector.php +++ b/integrations/rework/src/Connector.php @@ -24,7 +24,7 @@ public function resolveBaseUrl(): string protected function defaultAuth(): HeaderAuthenticator { - return new HeaderAuthenticator((string) $this->apiKey, 'Authorization', 'Token '); + return new HeaderAuthenticator((string) $this->apiKey, 'Authorization'); } protected function defaultHeaders(): array diff --git a/phpunit.xml b/phpunit.xml index a89c019..5d64d72 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -14,6 +14,9 @@ ./integrations/nmbrs/tests + + ./integrations/exact-globe/tests + diff --git a/resources/views/mail/budgets/export.blade.php b/resources/views/mail/budgets/export.blade.php index 1275b23..1597cd5 100644 --- a/resources/views/mail/budgets/export.blade.php +++ b/resources/views/mail/budgets/export.blade.php @@ -6,7 +6,9 @@ ## {{__('File Details:')}} - {{__('Export Type:')}} {{$exportType}} +@if($formattedDate !== '') - {{__('Period:')}} {{ $formattedDate }} +@endif - {{__('File Name:')}} {{$fileName}} {{__('You can download your file by clicking the button below:')}} diff --git a/routes/web.php b/routes/web.php index ae011a0..40c4b8f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -21,6 +21,7 @@ use App\Http\Controllers\EntrySuggestionController; use App\Http\Controllers\EventController; use App\Http\Controllers\ExportEmailController; +use App\Http\Controllers\ExportFormatController; use App\Http\Controllers\Exports\GetBudgetEntriesExportController; use App\Http\Controllers\GetBudgetPeriodsController; use App\Http\Controllers\GetBudgetTimeSpentTotalsController; @@ -64,6 +65,8 @@ Route::get('daily-progress', GetDailyProgressController::class)->name('daily-progress'); + Route::get('export-formats', ExportFormatController::class)->name('export-formats.index'); + Route::get('budgets/export-mail', ExportEmailController::class)->name('budgets.export-mail'); Route::apiResource('budgets', BudgetController::class); diff --git a/tests/Feature/Exports/BudgetExportTest.php b/tests/Feature/Exports/BudgetExportTest.php index 654f9dc..1e20007 100644 --- a/tests/Feature/Exports/BudgetExportTest.php +++ b/tests/Feature/Exports/BudgetExportTest.php @@ -98,7 +98,7 @@ it('exports and allows downloading of budget data', function () { Mail::fake(); Event::fake(); - Storage::fake('local'); + Storage::fake(); $this->loginUser(permissions: ['user']); @@ -130,7 +130,7 @@ expect($rowCount)->toEqual(2); $fileName = 'export_budgets-excel_2023_7.xlsx'; - Storage::put($fileName, 'Sample export content'); // Optional mock content + Storage::assertExists($fileName); $downloadResponse = $this->get(route('download.export', ['fileName' => $fileName])); diff --git a/tests/Feature/Exports/ExportFormatsTest.php b/tests/Feature/Exports/ExportFormatsTest.php new file mode 100644 index 0000000..aa01716 --- /dev/null +++ b/tests/Feature/Exports/ExportFormatsTest.php @@ -0,0 +1,70 @@ +loginUser(); + + $response = $this->getJson(route('export-formats.index')); + + $response->assertSuccessful(); + $response->assertJsonFragment([ + 'id' => 'budgets-monthly-excel', + 'type' => 'exportFormats', + 'attributes' => [ + 'label' => 'Budget mutations', + 'periodOptions' => 'monthly', + 'extension' => 'xlsx', + ], + ]); + expect(collect($response->json('data'))->pluck('id')->all())->toBe([ + 'budgets-monthly-excel', + 'budgets-excel', + 'entries-excel', + 'users-monthly-summary-excel', + ]); +}); + +it('lists the exact globe export when the integration is configured', function () { + $this->loginUser(); + Integration::create([ + 'name' => 'Exact Globe', + 'type' => 'exact-globe', + 'config' => [ + 'ledger_mapping' => [ + 'project' => [ + 'usage_credit' => '28075', + 'usage_debit' => '81135', + 'release_credit' => '28075', + 'release_debit' => '81131', + ], + ], + ], + ]); + + $response = $this->getJson(route('export-formats.index')); + + $response->assertSuccessful(); + $response->assertJsonFragment([ + 'id' => 'exact-globe-csv', + 'type' => 'exportFormats', + 'attributes' => [ + 'label' => 'Budget mutations - Exact', + 'periodOptions' => 'monthly', + 'extension' => 'csv', + ], + ]); +}); + +it('hides the exact globe export when the integration has no ledger mapping', function () { + $this->loginUser(); + Integration::create(['name' => 'Exact Globe', 'type' => 'exact-globe', 'config' => []]); + + $response = $this->getJson(route('export-formats.index')); + + $response->assertSuccessful(); + expect(collect($response->json('data'))->pluck('id')->all())->not->toContain('exact-globe-csv'); +}); diff --git a/tests/Feature/Exports/ExportMailTest.php b/tests/Feature/Exports/ExportMailTest.php index 849cc97..f2a5619 100644 --- a/tests/Feature/Exports/ExportMailTest.php +++ b/tests/Feature/Exports/ExportMailTest.php @@ -14,7 +14,7 @@ $user = User::factory()->create(); $this->actingAs($user); - Storage::fake('s3'); + Storage::fake(); Mail::fake(); diff --git a/tests/Feature/Exports/ExportRequestValidationTest.php b/tests/Feature/Exports/ExportRequestValidationTest.php new file mode 100644 index 0000000..a3922ab --- /dev/null +++ b/tests/Feature/Exports/ExportRequestValidationTest.php @@ -0,0 +1,49 @@ +loginUser(); + + $this->getJson(route('budgets.export-mail', [ + 'exportType' => 'unknown-export', + 'year' => 2024, + 'month' => 10, + ]))->assertUnprocessable()->assertJsonValidationErrors('exportType'); +}); + +it('rejects a monthly export without a month', function () { + $this->loginUser(); + + $this->getJson(route('budgets.export-mail', [ + 'exportType' => 'budgets-monthly-excel', + 'year' => 2024, + ]))->assertUnprocessable()->assertJsonValidationErrors('month'); +}); + +it('accepts an export without period options without a year and month', function () { + $this->loginUser(); + Mail::fake(); + Storage::fake(); + Storage::fake('temp'); + + $this->getJson(route('budgets.export-mail', [ + 'exportType' => 'budgets-excel', + ]))->assertStatus(202); +}); + +it('accepts a monthly and yearly export without a month', function () { + $this->loginUser(); + Mail::fake(); + Storage::fake('s3'); + Storage::fake('temp'); + + $this->getJson(route('budgets.export-mail', [ + 'exportType' => 'entries-excel', + 'year' => 2024, + ]))->assertStatus(202); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 9fc3988..dffee1b 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -20,7 +20,13 @@ ->beforeEach(function () { Config::preventStrayRequests(); }) - ->in('Feature', 'Integration', 'Unit', '../integrations/nmbrs/tests'); + ->in( + 'Feature', + 'Integration', + 'Unit', + '../integrations/nmbrs/tests', + '../integrations/exact-globe/tests' + ); /* |-------------------------------------------------------------------------- diff --git a/tests/Unit/Integrations/ExportServiceTest.php b/tests/Unit/Integrations/ExportServiceTest.php new file mode 100644 index 0000000..2461031 --- /dev/null +++ b/tests/Unit/Integrations/ExportServiceTest.php @@ -0,0 +1,22 @@ + 'Duplicate core', 'type' => 'duplicate-core', 'config' => []]); + + $registry = new ExportProviderRegistry; + $registry->register('duplicate-core', CoreExportProvider::class); + + (new ExportService($registry, new CoreExportProvider))->formats(); +})->throws(RuntimeException::class, 'Duplicate export format keys'); + +it('throws when creating an export for an unknown format', function () { + $registry = new ExportProviderRegistry; + + (new ExportService($registry, new CoreExportProvider))->createExport('unknown-format', new ExportPeriod(2026, 6)); +})->throws(InvalidArgumentException::class, 'Unknown export format: unknown-format');