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.
This commit is contained in:
nyavokevin 2026-04-02 16:08:44 +03:00
parent 9cbc1bcbdb
commit 8f7019e815
9 changed files with 145 additions and 51 deletions

View File

@ -22,7 +22,7 @@ class StoreInvoiceRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'client_id' => 'required|exists:clients,id', 'client_id' => 'nullable|exists:clients,id',
'group_id' => 'nullable|exists:client_groups,id', 'group_id' => 'nullable|exists:client_groups,id',
'source_quote_id' => 'nullable|exists:quotes,id', 'source_quote_id' => 'nullable|exists:quotes,id',
'status' => 'required|in:brouillon,emise,envoyee,partiellement_payee,payee,echue,annulee,avoir', '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 public function messages(): array
{ {
return [ return [
'client_id.required' => 'Le client est obligatoire.',
'client_id.exists' => 'Le client sélectionné est invalide.', 'client_id.exists' => 'Le client sélectionné est invalide.',
'group_id.exists' => 'Le groupe sélectionné est invalide.', 'group_id.exists' => 'Le groupe sélectionné est invalide.',
'status.required' => 'Le statut est obligatoire.', 'status.required' => 'Le statut est obligatoire.',

View File

@ -24,7 +24,7 @@ class UpdateInvoiceRequest extends FormRequest
$invoiceId = $this->route('invoice'); $invoiceId = $this->route('invoice');
return [ return [
'client_id' => 'sometimes|exists:clients,id', 'client_id' => 'nullable|exists:clients,id',
'group_id' => 'nullable|exists:client_groups,id', 'group_id' => 'nullable|exists:client_groups,id',
'source_quote_id' => 'nullable|exists:quotes,id', 'source_quote_id' => 'nullable|exists:quotes,id',
'invoice_number' => 'sometimes|string|max:191|unique:invoices,invoice_number,' . $invoiceId, '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.', '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);
}
});
}
} }

View File

@ -25,12 +25,17 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter
{ {
return DB::transaction(function () use ($quoteId) { return DB::transaction(function () use ($quoteId) {
// Resolve Quote directly to avoid circular dependency // Resolve Quote directly to avoid circular dependency
$quote = Quote::with(['client', 'lines'])->find($quoteId); $quote = Quote::with(['client', 'group', 'lines'])->find($quoteId);
if (!$quote) { if (!$quote) {
throw new \Exception("Quote not found"); throw new \Exception("Quote not found");
} }
$existingInvoice = Invoice::where('source_quote_id', $quote->id)->first();
if ($existingInvoice) {
return $existingInvoice;
}
// Create Invoice // Create Invoice
$invoiceData = [ $invoiceData = [
'client_id' => $quote->client_id, '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); $this->recordHistory($invoice->id, null, 'brouillon', 'Created from Quote ' . $quote->reference);
try { try {
$this->timelineRepository->logActivity([ if ($invoice->client_id !== null) {
'client_id' => $invoice->client_id, $this->timelineRepository->logActivity([
'actor_type' => 'user', 'client_id' => $invoice->client_id,
'actor_user_id' => auth()->id(), 'actor_type' => 'user',
'event_type' => 'invoice_created', 'actor_user_id' => auth()->id(),
'entity_type' => 'invoice', 'event_type' => 'invoice_created',
'entity_id' => $invoice->id, 'entity_type' => 'invoice',
'title' => 'Nouvelle facture créée', 'entity_id' => $invoice->id,
'description' => "Une facture a été créée à partir du devis #{$quote->id}.", 'title' => 'Nouvelle facture créée',
'created_at' => now(), 'description' => "Une facture a été créée à partir du devis #{$quote->id}.",
]); 'created_at' => now(),
]);
}
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Failed to log invoice creation activity: " . $e->getMessage()); 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 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 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'); $this->recordHistory($invoice->id, null, $invoice->status, 'Invoice created');
try { try {
$this->timelineRepository->logActivity([ if ($invoice->client_id !== null) {
'client_id' => $invoice->client_id, $this->timelineRepository->logActivity([
'actor_type' => 'user', 'client_id' => $invoice->client_id,
'actor_user_id' => auth()->id(), 'actor_type' => 'user',
'event_type' => 'invoice_created', 'actor_user_id' => auth()->id(),
'entity_type' => 'invoice', 'event_type' => 'invoice_created',
'entity_id' => $invoice->id, 'entity_type' => 'invoice',
'title' => 'Nouvelle facture créée', 'entity_id' => $invoice->id,
'description' => "La facture #{$invoice->id} a été créée.", 'title' => 'Nouvelle facture créée',
'created_at' => now(), 'description' => "La facture #{$invoice->id} a été créée.",
]); 'created_at' => now(),
]);
}
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Failed to log manual invoice creation activity: " . $e->getMessage()); 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 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 private function recordHistory(int $invoiceId, ?string $oldStatus, string $newStatus, ?string $comment = null): void

View File

@ -95,18 +95,10 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
// Auto-create invoice when status changes to 'accepte' // Auto-create invoice when status changes to 'accepte'
if ($newStatus === 'accepte' && $oldStatus !== 'accepte') { if ($newStatus === 'accepte' && $oldStatus !== 'accepte') {
try { $this->invoiceRepository->createFromQuote($id);
$this->invoiceRepository->createFromQuote($id); Log::info('Invoice auto-created from quote', ['quote_id' => $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
}
} }
} }
} }
return $updated; return $updated;

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('invoices', function (Blueprint $table) {
$table->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();
});
}
};

View File

@ -56,7 +56,7 @@
</div> </div>
<div> <div>
<h6 class="text-lg mb-0 mt-1"> <h6 class="text-lg mb-0 mt-1">
{{ invoice.client?.name || "Client inconnu" }} {{ recipientName }}
</h6> </h6>
<p class="text-sm mb-3"> <p class="text-sm mb-3">
{{ invoice.lines?.length || 0 }} ligne(s) dans cette facture. {{ invoice.lines?.length || 0 }} ligne(s) dans cette facture.
@ -109,18 +109,18 @@
> >
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<h6 class="mb-3 text-sm"> <h6 class="mb-3 text-sm">
{{ invoice.client?.name || "Client inconnu" }} {{ recipientName }}
</h6> </h6>
<span class="mb-2 text-xs"> <span class="mb-2 text-xs">
Adresse email : Adresse email :
<span class="text-dark ms-2 font-weight-bold">{{ <span class="text-dark ms-2 font-weight-bold">{{
invoice.client?.email || "—" invoice.client?.email || groupDetailsFallback
}}</span> }}</span>
</span> </span>
<span class="mb-2 text-xs"> <span class="mb-2 text-xs">
Téléphone : Téléphone :
<span class="text-dark ms-2 font-weight-bold">{{ <span class="text-dark ms-2 font-weight-bold">{{
invoice.client?.phone || "—" invoice.client?.phone || groupDetailsFallback
}}</span> }}</span>
</span> </span>
<span class="text-xs"> <span class="text-xs">
@ -159,7 +159,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, defineProps } from "vue"; import { computed, ref, onMounted, defineProps } from "vue";
import { useInvoiceStore } from "@/stores/invoiceStore"; import { useInvoiceStore } from "@/stores/invoiceStore";
import { useNotificationStore } from "@/stores/notification"; import { useNotificationStore } from "@/stores/notification";
import InvoiceDetailTemplate from "@/components/templates/Invoice/InvoiceDetailTemplate.vue"; import InvoiceDetailTemplate from "@/components/templates/Invoice/InvoiceDetailTemplate.vue";
@ -185,6 +185,16 @@ const error = ref(null);
const selectedStatus = ref("brouillon"); const selectedStatus = ref("brouillon");
let invoiceStatusRequestId = 0; let invoiceStatusRequestId = 0;
const recipientName = computed(() => {
if (invoice.value?.client?.name) return invoice.value.client.name;
if (invoice.value?.group?.name) return invoice.value.group.name;
return "Client inconnu";
});
const groupDetailsFallback = computed(() => {
return invoice.value?.group?.name ? `Groupe: ${invoice.value.group.name}` : "—";
});
const load = async () => { const load = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;

View File

@ -335,9 +335,13 @@ const changeStatus = (id, newStatus) => {
}) })
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
const message =
e?.response?.data?.message ||
e?.response?.data?.error ||
"Impossible de mettre à jour le statut";
notificationStore.error( notificationStore.error(
"Erreur", "Erreur",
"Impossible de mettre à jour le statut", message,
3000 3000
); );
}) })

View File

@ -1,9 +1,10 @@
import { request } from "./http"; import { request } from "./http";
import { Client } from "./client"; import { Client } from "./client";
import type { ClientGroup } from "./clientGroup";
export interface Invoice { export interface Invoice {
id: number; id: number;
client_id: number; client_id: number | null;
group_id: number | null; group_id: number | null;
source_quote_id: number | null; source_quote_id: number | null;
invoice_number: string; invoice_number: string;
@ -27,6 +28,7 @@ export interface Invoice {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
client?: Client; client?: Client;
group?: ClientGroup;
} }
export interface InvoiceListResponse { export interface InvoiceListResponse {
@ -61,7 +63,7 @@ export interface InvoiceLine {
} }
export interface CreateInvoicePayload { export interface CreateInvoicePayload {
client_id: number; client_id: number | null;
group_id?: number | null; group_id?: number | null;
source_quote_id?: number | null; source_quote_id?: number | null;
status: status:

View File

@ -477,8 +477,8 @@
"affectsGlobalScope": false "affectsGlobalScope": false
}, },
"./src/services/invoice.ts": { "./src/services/invoice.ts": {
"version": "80e3771c11bc5a9285a945e823ae6cf3e91d10d6e84026bcebc4c0461d66a4f1", "version": "d74eaedf75a9bd7919401583b1c257b97aab1cf4aca230dd7cb00331dcaeb499",
"signature": "803ff09fa250ab40dd95a25dba1f86f3f09a1f4314e0b0c675bd6c046fa859b7", "signature": "4bca8af28c0a39beeeff6fc014cb983a1904406e1020b3a1839da6210cfac5e7",
"affectsGlobalScope": false "affectsGlobalScope": false
}, },
"./src/services/priceList.ts": { "./src/services/priceList.ts": {
@ -623,7 +623,7 @@
}, },
"./src/stores/invoiceStore.ts": { "./src/stores/invoiceStore.ts": {
"version": "e96532fbe118d5b784d083890259379da5d2c4e956be7eb1d0fcf450dc4e54bd", "version": "e96532fbe118d5b784d083890259379da5d2c4e956be7eb1d0fcf450dc4e54bd",
"signature": "1f51d66178b55624d5f7e4d27e3f0f64f0a9d7a0c1f2984fc558e0839230bfaf", "signature": "cc7dee187f2712a03719c3d47ab10dd5468f769af851931185f129964e578fbf",
"affectsGlobalScope": false "affectsGlobalScope": false
}, },
"./src/stores/productCategoryStore.ts": { "./src/stores/productCategoryStore.ts": {
@ -883,6 +883,7 @@
"./src/services/invoice.ts": [ "./src/services/invoice.ts": [
"./node_modules/tslib/tslib.d.ts", "./node_modules/tslib/tslib.d.ts",
"./src/services/client.ts", "./src/services/client.ts",
"./src/services/clientGroup.ts",
"./src/services/http.ts" "./src/services/http.ts"
], ],
"./src/services/priceList.ts": [ "./src/services/priceList.ts": [
@ -1195,7 +1196,8 @@
"./src/services/http.ts" "./src/services/http.ts"
], ],
"./src/services/invoice.ts": [ "./src/services/invoice.ts": [
"./src/services/client.ts" "./src/services/client.ts",
"./src/services/clientGroup.ts"
], ],
"./src/services/quote.ts": [ "./src/services/quote.ts": [
"./src/services/client.ts" "./src/services/client.ts"