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
{
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.',

View File

@ -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);
}
});
}
}

View File

@ -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

View File

@ -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;

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

View File

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

View File

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

View File

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