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:
parent
9cbc1bcbdb
commit
8f7019e815
@ -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.',
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
);
|
||||
})
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user