From 8f7019e81517e7d8e35ee64b86262afea87197a0 Mon Sep 17 00:00:00 2001 From: nyavokevin Date: Thu, 2 Apr 2026 16:08:44 +0300 Subject: [PATCH] feat(invoice): support group-based invoices without a client Allow invoices to target either a client or a client group by making `client_id` nullable and validating that exactly one recipient is set. Load group relations in invoice data so the frontend can display group details in invoice views and type definitions. Make quote acceptance reuse an existing invoice instead of creating a duplicate, and surface backend status update errors in the quote UI. --- .../app/Http/Requests/StoreInvoiceRequest.php | 25 +++++++- .../Http/Requests/UpdateInvoiceRequest.php | 28 ++++++++- .../app/Repositories/InvoiceRepository.php | 59 +++++++++++-------- .../app/Repositories/QuoteRepository.php | 14 +---- ...02_151000_make_invoice_client_nullable.php | 28 +++++++++ .../Invoice/InvoiceDetailPresentation.vue | 20 +++++-- .../Quote/QuoteDetailPresentation.vue | 6 +- thanasoft-front/src/services/invoice.ts | 6 +- thanasoft-front/tsconfig.tsbuildinfo | 10 ++-- 9 files changed, 145 insertions(+), 51 deletions(-) create mode 100644 thanasoft-back/database/migrations/2026_04_02_151000_make_invoice_client_nullable.php diff --git a/thanasoft-back/app/Http/Requests/StoreInvoiceRequest.php b/thanasoft-back/app/Http/Requests/StoreInvoiceRequest.php index 45f489c..2f5168d 100644 --- a/thanasoft-back/app/Http/Requests/StoreInvoiceRequest.php +++ b/thanasoft-back/app/Http/Requests/StoreInvoiceRequest.php @@ -22,7 +22,7 @@ class StoreInvoiceRequest extends FormRequest public function rules(): array { return [ - 'client_id' => 'required|exists:clients,id', + 'client_id' => 'nullable|exists:clients,id', 'group_id' => 'nullable|exists:client_groups,id', 'source_quote_id' => 'nullable|exists:quotes,id', 'status' => 'required|in:brouillon,emise,envoyee,partiellement_payee,payee,echue,annulee,avoir', @@ -49,10 +49,31 @@ class StoreInvoiceRequest extends FormRequest ]; } + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $hasClient = filled($this->input('client_id')); + $hasGroup = filled($this->input('group_id')); + + if (! $hasClient && ! $hasGroup) { + $message = 'Un client ou un groupe de clients est obligatoire.'; + + $validator->errors()->add('client_id', $message); + $validator->errors()->add('group_id', $message); + } + + if ($hasClient && $hasGroup) { + $message = 'Selectionnez soit un client, soit un groupe de clients.'; + + $validator->errors()->add('client_id', $message); + $validator->errors()->add('group_id', $message); + } + }); + } + public function messages(): array { return [ - 'client_id.required' => 'Le client est obligatoire.', 'client_id.exists' => 'Le client sélectionné est invalide.', 'group_id.exists' => 'Le groupe sélectionné est invalide.', 'status.required' => 'Le statut est obligatoire.', diff --git a/thanasoft-back/app/Http/Requests/UpdateInvoiceRequest.php b/thanasoft-back/app/Http/Requests/UpdateInvoiceRequest.php index 6bba608..a996b5a 100644 --- a/thanasoft-back/app/Http/Requests/UpdateInvoiceRequest.php +++ b/thanasoft-back/app/Http/Requests/UpdateInvoiceRequest.php @@ -24,7 +24,7 @@ class UpdateInvoiceRequest extends FormRequest $invoiceId = $this->route('invoice'); return [ - 'client_id' => 'sometimes|exists:clients,id', + 'client_id' => 'nullable|exists:clients,id', 'group_id' => 'nullable|exists:client_groups,id', 'source_quote_id' => 'nullable|exists:quotes,id', 'invoice_number' => 'sometimes|string|max:191|unique:invoices,invoice_number,' . $invoiceId, @@ -64,4 +64,30 @@ class UpdateInvoiceRequest extends FormRequest 'e_invoice_status.in' => 'Le statut de facturation électronique est invalide.', ]; } + + public function withValidator($validator): void + { + $validator->after(function ($validator) { + if (! $this->hasAny(['client_id', 'group_id'])) { + return; + } + + $hasClient = filled($this->input('client_id')); + $hasGroup = filled($this->input('group_id')); + + if (! $hasClient && ! $hasGroup) { + $message = 'Un client ou un groupe de clients est obligatoire.'; + + $validator->errors()->add('client_id', $message); + $validator->errors()->add('group_id', $message); + } + + if ($hasClient && $hasGroup) { + $message = 'Selectionnez soit un client, soit un groupe de clients.'; + + $validator->errors()->add('client_id', $message); + $validator->errors()->add('group_id', $message); + } + }); + } } diff --git a/thanasoft-back/app/Repositories/InvoiceRepository.php b/thanasoft-back/app/Repositories/InvoiceRepository.php index b6c1539..80ed82e 100644 --- a/thanasoft-back/app/Repositories/InvoiceRepository.php +++ b/thanasoft-back/app/Repositories/InvoiceRepository.php @@ -25,12 +25,17 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter { return DB::transaction(function () use ($quoteId) { // Resolve Quote directly to avoid circular dependency - $quote = Quote::with(['client', 'lines'])->find($quoteId); + $quote = Quote::with(['client', 'group', 'lines'])->find($quoteId); if (!$quote) { throw new \Exception("Quote not found"); } + $existingInvoice = Invoice::where('source_quote_id', $quote->id)->first(); + if ($existingInvoice) { + return $existingInvoice; + } + // Create Invoice $invoiceData = [ 'client_id' => $quote->client_id, @@ -68,17 +73,19 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter $this->recordHistory($invoice->id, null, 'brouillon', 'Created from Quote ' . $quote->reference); try { - $this->timelineRepository->logActivity([ - 'client_id' => $invoice->client_id, - 'actor_type' => 'user', - 'actor_user_id' => auth()->id(), - 'event_type' => 'invoice_created', - 'entity_type' => 'invoice', - 'entity_id' => $invoice->id, - 'title' => 'Nouvelle facture créée', - 'description' => "Une facture a été créée à partir du devis #{$quote->id}.", - 'created_at' => now(), - ]); + if ($invoice->client_id !== null) { + $this->timelineRepository->logActivity([ + 'client_id' => $invoice->client_id, + 'actor_type' => 'user', + 'actor_user_id' => auth()->id(), + 'event_type' => 'invoice_created', + 'entity_type' => 'invoice', + 'entity_id' => $invoice->id, + 'title' => 'Nouvelle facture créée', + 'description' => "Une facture a été créée à partir du devis #{$quote->id}.", + 'created_at' => now(), + ]); + } } catch (\Exception $e) { Log::error("Failed to log invoice creation activity: " . $e->getMessage()); } @@ -91,7 +98,7 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter public function all(array $columns = ['*']): \Illuminate\Support\Collection { - return $this->model->with(['client', 'lines.product'])->get($columns); + return $this->model->with(['client', 'group', 'lines.product'])->get($columns); } public function create(array $data): Invoice @@ -113,17 +120,19 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter $this->recordHistory($invoice->id, null, $invoice->status, 'Invoice created'); try { - $this->timelineRepository->logActivity([ - 'client_id' => $invoice->client_id, - 'actor_type' => 'user', - 'actor_user_id' => auth()->id(), - 'event_type' => 'invoice_created', - 'entity_type' => 'invoice', - 'entity_id' => $invoice->id, - 'title' => 'Nouvelle facture créée', - 'description' => "La facture #{$invoice->id} a été créée.", - 'created_at' => now(), - ]); + if ($invoice->client_id !== null) { + $this->timelineRepository->logActivity([ + 'client_id' => $invoice->client_id, + 'actor_type' => 'user', + 'actor_user_id' => auth()->id(), + 'event_type' => 'invoice_created', + 'entity_type' => 'invoice', + 'entity_id' => $invoice->id, + 'title' => 'Nouvelle facture créée', + 'description' => "La facture #{$invoice->id} a été créée.", + 'created_at' => now(), + ]); + } } catch (\Exception $e) { Log::error("Failed to log manual invoice creation activity: " . $e->getMessage()); } @@ -176,7 +185,7 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter public function find(int|string $id, array $columns = ['*']): ?Invoice { - return $this->model->with(['client', 'lines.product', 'history.user'])->find($id, $columns); + return $this->model->with(['client', 'group', 'lines.product', 'history.user'])->find($id, $columns); } private function recordHistory(int $invoiceId, ?string $oldStatus, string $newStatus, ?string $comment = null): void diff --git a/thanasoft-back/app/Repositories/QuoteRepository.php b/thanasoft-back/app/Repositories/QuoteRepository.php index c1544cc..9fec950 100644 --- a/thanasoft-back/app/Repositories/QuoteRepository.php +++ b/thanasoft-back/app/Repositories/QuoteRepository.php @@ -95,18 +95,10 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface // Auto-create invoice when status changes to 'accepte' if ($newStatus === 'accepte' && $oldStatus !== 'accepte') { - try { - $this->invoiceRepository->createFromQuote($id); - Log::info('Invoice auto-created from quote', ['quote_id' => $id]); - } catch (\Exception $e) { - Log::error('Failed to auto-create invoice from quote: ' . $e->getMessage(), [ - 'quote_id' => $id, - 'exception' => $e, - ]); - // Don't throw - quote update should still succeed - } + $this->invoiceRepository->createFromQuote($id); + Log::info('Invoice auto-created from quote', ['quote_id' => $id]); } - } + } } return $updated; diff --git a/thanasoft-back/database/migrations/2026_04_02_151000_make_invoice_client_nullable.php b/thanasoft-back/database/migrations/2026_04_02_151000_make_invoice_client_nullable.php new file mode 100644 index 0000000..8e25eb4 --- /dev/null +++ b/thanasoft-back/database/migrations/2026_04_02_151000_make_invoice_client_nullable.php @@ -0,0 +1,28 @@ +unsignedBigInteger('client_id')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->unsignedBigInteger('client_id')->nullable(false)->change(); + }); + } +}; diff --git a/thanasoft-front/src/components/Organism/Invoice/InvoiceDetailPresentation.vue b/thanasoft-front/src/components/Organism/Invoice/InvoiceDetailPresentation.vue index d692b54..dd269c6 100644 --- a/thanasoft-front/src/components/Organism/Invoice/InvoiceDetailPresentation.vue +++ b/thanasoft-front/src/components/Organism/Invoice/InvoiceDetailPresentation.vue @@ -56,7 +56,7 @@
- {{ invoice.client?.name || "Client inconnu" }} + {{ recipientName }}

{{ invoice.lines?.length || 0 }} ligne(s) dans cette facture. @@ -109,18 +109,18 @@ >

- {{ invoice.client?.name || "Client inconnu" }} + {{ recipientName }}
Adresse email : {{ - invoice.client?.email || "—" + invoice.client?.email || groupDetailsFallback }} Téléphone : {{ - invoice.client?.phone || "—" + invoice.client?.phone || groupDetailsFallback }} @@ -159,7 +159,7 @@