From a9a2429b67ca95b419b97daeade54517ef64f6ef Mon Sep 17 00:00:00 2001 From: nyavokevin Date: Mon, 2 Mar 2026 15:45:35 +0300 Subject: [PATCH] =?UTF-8?q?Stock=20et=20achats:=20am=C3=A9lioration=20des?= =?UTF-8?q?=20r=C3=A9ceptions,=20entrep=C3=B4ts=20et=20commandes=20fournis?= =?UTF-8?q?seurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Api/GoodsReceiptController.php | 28 +- .../Api/PurchaseOrderController.php | 66 +- .../Http/Resources/GoodsReceiptResource.php | 1 + .../Commande/CommandeDetailPresentation.vue | 607 ++++++--- .../Commande/CommandeListPresentation.vue | 9 +- .../Stock/GoodsReceiptListPresentation.vue | 30 +- .../Stock/NewReceptionPresentation.vue | 6 +- .../Stock/ProductDetailsPresentation.vue | 1112 +++++++++++------ .../Stock/ReceptionDetailPresentation.vue | 487 +++++--- .../Stock/WarehouseDetailPresentation.vue | 677 +++++++++- .../Stock/WarehouseFormPresentation.vue | 7 +- .../molecules/Commande/CommandeLinesTable.vue | 4 +- .../molecules/Commande/NewCommandeForm.vue | 192 +-- .../molecules/Stock/NewReceptionForm.vue | 243 ++-- .../molecules/Stock/WarehouseDetailInfo.vue | 18 +- .../molecules/Stock/WarehouseForm.vue | 8 +- .../Tables/Fournisseurs/CommandeTable.vue | 8 +- .../Tables/Stock/GoodsReceiptTable.vue | 39 +- .../molecules/Tables/Stock/WarehouseTable.vue | 12 +- thanasoft-front/src/services/goodsReceipt.ts | 17 +- .../src/services/productPackaging.ts | 5 +- thanasoft-front/src/services/purchaseOrder.ts | 21 +- thanasoft-front/src/services/receipt.ts | 38 + thanasoft-front/src/services/warehouse.ts | 5 +- .../src/stores/purchaseOrderStore.ts | 4 +- thanasoft-front/src/stores/receiptStore.ts | 79 ++ thanasoft-front/src/stores/stockStore.ts | 7 +- thanasoft-front/src/stores/warehouseStore.ts | 14 +- .../src/views/pages/Stock/EditReception.vue | 112 +- .../src/views/pages/Stock/Reception.vue | 8 + 30 files changed, 2817 insertions(+), 1047 deletions(-) create mode 100644 thanasoft-front/src/services/receipt.ts create mode 100644 thanasoft-front/src/stores/receiptStore.ts diff --git a/thanasoft-back/app/Http/Controllers/Api/GoodsReceiptController.php b/thanasoft-back/app/Http/Controllers/Api/GoodsReceiptController.php index 419af92..db6ffa2 100644 --- a/thanasoft-back/app/Http/Controllers/Api/GoodsReceiptController.php +++ b/thanasoft-back/app/Http/Controllers/Api/GoodsReceiptController.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\StoreGoodsReceiptRequest; use App\Http\Requests\UpdateGoodsReceiptRequest; use App\Http\Resources\GoodsReceiptResource; +use App\Models\PurchaseOrder; use App\Repositories\GoodsReceiptRepositoryInterface; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; @@ -45,7 +46,32 @@ class GoodsReceiptController extends Controller public function store(StoreGoodsReceiptRequest $request): JsonResponse { try { - $goodsReceipt = $this->goodsReceiptRepository->create($request->validated()); + $payload = $request->validated(); + + if (empty($payload['lines']) && !empty($payload['purchase_order_id'])) { + $purchaseOrder = PurchaseOrder::query() + ->with('lines') + ->find($payload['purchase_order_id']); + + if ($purchaseOrder) { + $payload['lines'] = $purchaseOrder->lines + ->filter(fn($line) => !empty($line->product_id)) + ->map(fn($line) => [ + 'product_id' => (int) $line->product_id, + 'packaging_id' => null, + 'packages_qty_received' => null, + 'units_qty_received' => (float) $line->quantity, + 'qty_received_base' => (float) $line->quantity, + 'unit_price' => (float) $line->unit_price, + 'unit_price_per_package' => null, + 'tva_rate_id' => null, + ]) + ->values() + ->all(); + } + } + + $goodsReceipt = $this->goodsReceiptRepository->create($payload); return response()->json([ 'data' => new GoodsReceiptResource($goodsReceipt), 'message' => 'Réception de marchandise créée avec succès.', diff --git a/thanasoft-back/app/Http/Controllers/Api/PurchaseOrderController.php b/thanasoft-back/app/Http/Controllers/Api/PurchaseOrderController.php index 0d39d71..dd7eb69 100644 --- a/thanasoft-back/app/Http/Controllers/Api/PurchaseOrderController.php +++ b/thanasoft-back/app/Http/Controllers/Api/PurchaseOrderController.php @@ -8,6 +8,9 @@ use App\Http\Controllers\Controller; use App\Http\Requests\StorePurchaseOrderRequest; use App\Http\Requests\UpdatePurchaseOrderRequest; use App\Http\Resources\Fournisseur\PurchaseOrderResource; +use App\Models\GoodsReceipt; +use App\Models\Warehouse; +use App\Repositories\GoodsReceiptRepositoryInterface; use App\Repositories\PurchaseOrderRepositoryInterface; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -16,7 +19,8 @@ use Illuminate\Support\Facades\Log; class PurchaseOrderController extends Controller { public function __construct( - protected PurchaseOrderRepositoryInterface $purchaseOrderRepository + protected PurchaseOrderRepositoryInterface $purchaseOrderRepository, + protected GoodsReceiptRepositoryInterface $goodsReceiptRepository ) { } @@ -98,6 +102,9 @@ class PurchaseOrderController extends Controller public function update(UpdatePurchaseOrderRequest $request, string $id): PurchaseOrderResource|JsonResponse { try { + $existingPurchaseOrder = $this->purchaseOrderRepository->find($id); + $previousStatus = $existingPurchaseOrder?->status; + $updated = $this->purchaseOrderRepository->update($id, $request->validated()); if (!$updated) { @@ -107,6 +114,16 @@ class PurchaseOrderController extends Controller } $purchaseOrder = $this->purchaseOrderRepository->find($id); + + // On validation/delivery (status => confirmee|livree), create a draft goods receipt automatically. + if ( + $purchaseOrder + && in_array($purchaseOrder->status, ['confirmee', 'livree'], true) + && !in_array($previousStatus, ['confirmee', 'livree'], true) + ) { + $this->createGoodsReceiptFromValidatedPurchaseOrder($purchaseOrder); + } + return new PurchaseOrderResource($purchaseOrder); } catch (\Exception $e) { Log::error('Error updating purchase order: ' . $e->getMessage(), [ @@ -123,6 +140,53 @@ class PurchaseOrderController extends Controller } } + /** + * Create a draft goods receipt when a purchase order is validated. + */ + protected function createGoodsReceiptFromValidatedPurchaseOrder($purchaseOrder): void + { + $alreadyExists = GoodsReceipt::query() + ->where('purchase_order_id', $purchaseOrder->id) + ->exists(); + + if ($alreadyExists) { + return; + } + + $warehouseId = Warehouse::query()->value('id'); + if (!$warehouseId) { + throw new \RuntimeException('Aucun entrepôt disponible pour créer la réception de marchandise.'); + } + + $receiptNumber = 'GR-' . now()->format('Ym') . '-' . str_pad((string) $purchaseOrder->id, 4, '0', STR_PAD_LEFT); + + $lines = collect($purchaseOrder->lines ?? []) + ->filter(fn($line) => !empty($line->product_id)) + ->map(function ($line) { + return [ + 'product_id' => (int) $line->product_id, + 'packaging_id' => null, + 'packages_qty_received' => null, + 'units_qty_received' => (float) $line->quantity, + 'qty_received_base' => (float) $line->quantity, + 'unit_price' => (float) $line->unit_price, + 'unit_price_per_package' => null, + 'tva_rate_id' => null, + ]; + }) + ->values() + ->all(); + + $this->goodsReceiptRepository->create([ + 'purchase_order_id' => $purchaseOrder->id, + 'warehouse_id' => (int) $warehouseId, + 'receipt_number' => $receiptNumber, + 'receipt_date' => now()->toDateString(), + 'status' => 'draft', + 'lines' => $lines, + ]); + } + /** * Remove the specified purchase order. */ diff --git a/thanasoft-back/app/Http/Resources/GoodsReceiptResource.php b/thanasoft-back/app/Http/Resources/GoodsReceiptResource.php index 0a01d15..8e775bf 100644 --- a/thanasoft-back/app/Http/Resources/GoodsReceiptResource.php +++ b/thanasoft-back/app/Http/Resources/GoodsReceiptResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Http\Resources\Fournisseur\PurchaseOrderResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; diff --git a/thanasoft-front/src/components/Organism/Commande/CommandeDetailPresentation.vue b/thanasoft-front/src/components/Organism/Commande/CommandeDetailPresentation.vue index 5efd717..ef305d7 100644 --- a/thanasoft-front/src/components/Organism/Commande/CommandeDetailPresentation.vue +++ b/thanasoft-front/src/components/Organism/Commande/CommandeDetailPresentation.vue @@ -7,178 +7,208 @@
{{ error }}
-
- -
-
- - Informations générales -
- - -
-
- -
{{ commande.number }}
-
-
- -
{{ formatDate(commande.date) }}
-
-
- -
- - {{ getStatusLabel(commande.status) }} -
-
-
- - -
-
- -
{{ commande.supplierName }}
-
-
- -
{{ commande.supplierContact }}
-
-
- - -
- -
{{ commande.deliveryAddress || 'Non spécifiée' }}
-
-
- - -
-
-
- - Articles commandés -
-
- -
-
+
+
+
-
-
- - -
-
-
- Total HT - {{ formatCurrency(commande.total_ht) }} -
-
- TVA (20%) - {{ formatCurrency(commande.total_tva) }} -
-
- Total TTC - {{ formatCurrency(commande.total_ttc) }} -
-
-
- - -
-
- - Informations supplémentaires -
- -
-
- -
{{ commande.supplierAddress }}
-
-
- -
{{ formatDate(commande.date) }}
-
+ + {{ getStatusLabel(status) }} +
-
- -
{{ commande.notes }}
-
-
- - -
-
+
- - Changer le statut - + + {{ primaryActionLabel }} - + + {{ secondaryActionLabel }} + + + + + Envoyer par mail + + + + + Télécharger PDF + +
+
+ +
+ +
+
+ + Informations générales +
+ + +
+
+ +
{{ commande.number }}
+
+
+ +
{{ formatDate(commande.date) }}
+
+
+ +
+ + {{ getStatusLabel(commande.status) }} +
+
+
+ + +
+
+ +
{{ commande.supplierName }}
+
+
+ +
{{ commande.supplierContact }}
+
+
+ + +
+ +
+ {{ commande.deliveryAddress || "Non spécifiée" }} +
+
- - Télécharger PDF - + +
+
+
+ + Articles commandés +
+
+ +
+
+
+
+ +
{{ line.designation }}
+
+ +
+ +
{{ line.quantity }}
+
+ +
+ +
+ {{ formatCurrency(line.price_ht) }} +
+
+ +
+ + + {{ formatCurrency(line.total_ht) }} + +
+
+
+
+
+ + +
+
+
+ Total HT + {{ + formatCurrency(commande.total_ht) + }} +
+
+ TVA (20%) + {{ + formatCurrency(commande.total_tva) + }} +
+
+ Total TTC + {{ + formatCurrency(commande.total_ttc) + }} +
+
+
+ + +
+
+ + Informations supplémentaires +
+ +
+
+ +
{{ commande.supplierAddress }}
+
+
+ +
{{ formatDate(commande.date) }}
+
+
+ +
+ +
{{ commande.notes }}
+
+
@@ -1021,6 +1261,70 @@ onMounted(() => { color: #1f2937; } +.product-side-nav-card { + position: sticky; + top: 1rem; + border: 0; + box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15); +} + +.product-sidebar-profile { + display: flex; + flex-direction: column; + align-items: center; +} + +.product-sidebar-image { + width: 96px; + height: 96px; + border-radius: 12px; + overflow: hidden; + border: 1px solid #e5e7eb; + margin-bottom: 0.75rem; +} + +.product-sidebar-title { + font-size: 1rem; + font-weight: 700; + color: #1f2937; +} + +.product-sidebar-reference { + color: #6b7280; + font-size: 0.85rem; +} + +.product-sidebar-badges { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.product-nav .nav-link { + border-radius: 0.5rem; + margin-bottom: 0.25rem; + color: #67748e; + background-color: transparent; + transition: all 0.2s ease-in-out; +} + +.product-nav .nav-link:hover { + background-color: #f8f9fa; +} + +.product-nav .nav-link.active { + background: linear-gradient(310deg, #7928ca, #ff0080); + color: #fff; + box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.09), + 0 2px 3px -1px rgba(0, 0, 0, 0.07); +} + +.product-nav .nav-link i { + width: 20px; +} + .document-link { display: flex; align-items: center; diff --git a/thanasoft-front/src/components/Organism/Stock/ReceptionDetailPresentation.vue b/thanasoft-front/src/components/Organism/Stock/ReceptionDetailPresentation.vue index 1f6b507..a5ad098 100644 --- a/thanasoft-front/src/components/Organism/Stock/ReceptionDetailPresentation.vue +++ b/thanasoft-front/src/components/Organism/Stock/ReceptionDetailPresentation.vue @@ -1,169 +1,267 @@ + + diff --git a/thanasoft-front/src/components/Organism/Stock/WarehouseDetailPresentation.vue b/thanasoft-front/src/components/Organism/Stock/WarehouseDetailPresentation.vue index bdd5160..d9535a3 100644 --- a/thanasoft-front/src/components/Organism/Stock/WarehouseDetailPresentation.vue +++ b/thanasoft-front/src/components/Organism/Stock/WarehouseDetailPresentation.vue @@ -1,38 +1,423 @@ + + diff --git a/thanasoft-front/src/components/Organism/Stock/WarehouseFormPresentation.vue b/thanasoft-front/src/components/Organism/Stock/WarehouseFormPresentation.vue index 1623f68..bbe9959 100644 --- a/thanasoft-front/src/components/Organism/Stock/WarehouseFormPresentation.vue +++ b/thanasoft-front/src/components/Organism/Stock/WarehouseFormPresentation.vue @@ -4,7 +4,7 @@
-
{{ isEdit ? 'Modifier' : 'Nouvel' }} Entrepôt
+
{{ isEdit ? "Modifier" : "Nouvel" }} Entrepôt
{ } router.push("/stock/warehouses"); } catch (e) { - error.value = e.response?.data?.message || e.message || "Une erreur est survenue lors de l'enregistrement."; + error.value = + e.response?.data?.message || + e.message || + "Une erreur est survenue lors de l'enregistrement."; console.error(e); } finally { submitting.value = false; diff --git a/thanasoft-front/src/components/molecules/Commande/CommandeLinesTable.vue b/thanasoft-front/src/components/molecules/Commande/CommandeLinesTable.vue index 2fdeecd..d0c3352 100644 --- a/thanasoft-front/src/components/molecules/Commande/CommandeLinesTable.vue +++ b/thanasoft-front/src/components/molecules/Commande/CommandeLinesTable.vue @@ -15,7 +15,9 @@ {{ line.designation }} {{ line.quantity }} - {{ formatCurrency(line.price_ht) }} + + {{ formatCurrency(line.price_ht) }} + {{ formatCurrency(line.total_ht) }} diff --git a/thanasoft-front/src/components/molecules/Commande/NewCommandeForm.vue b/thanasoft-front/src/components/molecules/Commande/NewCommandeForm.vue index 3d8655e..4df36e2 100644 --- a/thanasoft-front/src/components/molecules/Commande/NewCommandeForm.vue +++ b/thanasoft-front/src/components/molecules/Commande/NewCommandeForm.vue @@ -1,36 +1,44 @@
- +
- - + +
@@ -113,8 +120,8 @@ type="button" color="primary" size="sm" - @click="addLine" class="add-btn" + @click="addLine" > Ajouter ligne @@ -128,31 +135,42 @@ >
- +
-
- +
- +
- +
- +
- +
- +
- +
@@ -228,15 +254,21 @@
Total HT - {{ formatCurrency(calculateTotalHt()) }} + {{ + formatCurrency(calculateTotalHt()) + }}
TVA (20%) - {{ formatCurrency(calculateTotalTva()) }} + {{ + formatCurrency(calculateTotalTva()) + }}
Total TTC - {{ formatCurrency(calculateTotalTtc()) }} + {{ + formatCurrency(calculateTotalTtc()) + }}
@@ -247,16 +279,12 @@ type="button" color="secondary" variant="outline" - @click="cancelForm" class="btn-cancel" + @click="cancelForm" > Annuler - + Créer la commande
@@ -301,7 +329,9 @@ const handleSupplierSearch = () => { isSearchingSuppliers.value = true; showSupplierResults.value = true; try { - const results = await FournisseurService.searchFournisseurs(supplierSearchQuery.value); + const results = await FournisseurService.searchFournisseurs( + supplierSearchQuery.value + ); // Handle both direct array or paginated object with data property let actualResults = []; if (Array.isArray(results)) { @@ -309,7 +339,7 @@ const handleSupplierSearch = () => { } else if (results && Array.isArray(results.data)) { actualResults = results.data; } - supplierSearchResults.value = actualResults.filter(s => s && s.id); + supplierSearchResults.value = actualResults.filter((s) => s && s.id); } catch (error) { console.error("Error searching suppliers:", error); } finally { @@ -321,13 +351,15 @@ const handleSupplierSearch = () => { const selectSupplier = (supplier) => { if (!supplier || !supplier.id) return; if (searchTimeout) clearTimeout(searchTimeout); - + formData.value.supplierId = supplier.id; formData.value.supplierName = supplier.name; - formData.value.supplierAddress = supplier.billing_address ? - `${supplier.billing_address.line1 || ''} ${supplier.billing_address.postal_code || ''} ${supplier.billing_address.city || ''}` : - "À déterminer"; - + formData.value.supplierAddress = supplier.billing_address + ? `${supplier.billing_address.line1 || ""} ${ + supplier.billing_address.postal_code || "" + } ${supplier.billing_address.city || ""}` + : "À déterminer"; + supplierSearchQuery.value = supplier.name; showSupplierResults.value = false; }; @@ -344,7 +376,6 @@ const handleProductSearch = (index) => { const query = formData.value.lines[index].searchQuery; console.log(query.length); if (query.length < 2) { - productSearchResults.value = []; if (productSearchTimeout) clearTimeout(productSearchTimeout); isSearchingProducts.value = false; @@ -362,10 +393,11 @@ const handleProductSearch = (index) => { try { const response = await productService.searchProducts(query); // Double check if this is still the active line and query, and line still exists - if (activeLineIndex.value === index && - formData.value.lines[index] && - formData.value.lines[index].searchQuery === query) { - + if ( + activeLineIndex.value === index && + formData.value.lines[index] && + formData.value.lines[index].searchQuery === query + ) { // Handle paginated response: the array is in response.data.data // Handle non-paginated: the array is in response.data let results = []; @@ -376,8 +408,8 @@ const handleProductSearch = (index) => { results = response.data.data; } } - - productSearchResults.value = results.filter(p => p && p.id); + + productSearchResults.value = results.filter((p) => p && p.id); } } catch (error) { console.error("Error searching products:", error); @@ -396,42 +428,46 @@ const selectProduct = (index, product) => { const line = formData.value.lines[index]; if (!line) return; - + line.productId = product.id; line.searchQuery = product.nom; line.designation = product.nom; line.priceHt = product.prix_unitaire; - + showProductResults.value = false; activeLineIndex.value = null; }; // Close dropdowns on click outside const handleClickOutside = (event) => { - const supplierContainer = document.querySelector('.supplier-search-container'); + const supplierContainer = document.querySelector( + ".supplier-search-container" + ); if (supplierContainer && !supplierContainer.contains(event.target)) { showSupplierResults.value = false; } - const productContainers = document.querySelectorAll('.product-search-container'); + const productContainers = document.querySelectorAll( + ".product-search-container" + ); let clickedInsideAnyProduct = false; - productContainers.forEach(container => { + productContainers.forEach((container) => { if (container.contains(event.target)) { clickedInsideAnyProduct = true; } }); - + if (!clickedInsideAnyProduct) { showProductResults.value = false; } }; onMounted(() => { - document.addEventListener('click', handleClickOutside); + document.addEventListener("click", handleClickOutside); }); onUnmounted(() => { - document.removeEventListener('click', handleClickOutside); + document.removeEventListener("click", handleClickOutside); }); const formData = ref({ @@ -493,7 +529,10 @@ const removeLine = (index) => { const submitForm = async () => { if (!formData.value.supplierId) { - notificationStore.error("Champ requis", "Veuillez sélectionner un fournisseur."); + notificationStore.error( + "Champ requis", + "Veuillez sélectionner un fournisseur." + ); return; } @@ -508,26 +547,27 @@ const submitForm = async () => { total_ht: calculateTotalHt(), total_tva: calculateTotalTva(), total_ttc: calculateTotalTtc(), - lines: formData.value.lines.map(line => ({ + lines: formData.value.lines.map((line) => ({ product_id: line.productId || null, description: line.designation, quantity: line.quantity, unit_price: line.priceHt, tva_rate: 20, // Default 20% - total_ht: line.quantity * line.priceHt - })) + total_ht: line.quantity * line.priceHt, + })), }; console.log("Submitting purchase order:", payload); const response = await PurchaseOrderService.createPurchaseOrder(payload); console.log("Purchase order created:", response); - + emit("submit", response.data); notificationStore.success("Succès", "Commande créée avec succès !"); - } catch (error) { console.error("Error creating purchase order:", error); - const message = error.response?.data?.message || "Une erreur est survenue lors de la création de la commande."; + const message = + error.response?.data?.message || + "Une erreur est survenue lors de la création de la commande."; notificationStore.error("Erreur", message); } }; @@ -862,21 +902,21 @@ const cancelForm = () => { .form-section { padding: 1rem; } - + .section-header { flex-direction: column; align-items: flex-start; gap: 0.75rem; } - + .totals-content { max-width: 100%; } - + .action-buttons { flex-direction: column-reverse; } - + .btn-cancel, .btn-submit { width: 100%; diff --git a/thanasoft-front/src/components/molecules/Stock/NewReceptionForm.vue b/thanasoft-front/src/components/molecules/Stock/NewReceptionForm.vue index 85eacd7..cdec001 100644 --- a/thanasoft-front/src/components/molecules/Stock/NewReceptionForm.vue +++ b/thanasoft-front/src/components/molecules/Stock/NewReceptionForm.vue @@ -1,53 +1,64 @@ @@ -83,12 +95,10 @@ />
- - + +
@@ -125,8 +135,8 @@ type="button" color="primary" size="sm" - @click="addLine" class="add-btn" + @click="addLine" > Ajouter ligne @@ -140,31 +150,42 @@ >
- +
-
- +
-
- +
- +
- +
- +
@@ -252,8 +278,8 @@ type="button" color="secondary" variant="outline" - @click="cancelForm" class="btn-cancel" + @click="cancelForm" > Annuler @@ -270,11 +296,19 @@