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
|
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.',
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
<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;
|
||||||
|
|||||||
@ -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
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user