From a3954faab649980ed56ed1811ca53fc7a2bb538a Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 9 Jul 2026 14:54:23 +0700 Subject: [PATCH] feat: add functionality to create new raw materials and store them in the purchase draft, enhancing the purchasing process --- .../Purchase/PurchaseDraftItemController.php | 8 + .../Manage/PurchaseNewRawMaterialRequest.php | 65 ++ .../Requests/Admin/Manage/PurchaseRequest.php | 34 +- app/Services/Manage/PurchaseService.php | 216 +++++- resources/js/components/ConfirmDialog.vue | 2 +- .../form/QuickCreateRawMaterialModal.vue | 6 +- .../js/pages/admin/manage/purchases/Edit.vue | 4 +- .../form/PurchasePosCartDetailDialog.vue | 12 +- .../form/PurchasePosCatalogPanel.vue | 287 ++++---- .../manage/purchases/form/PurchasePosForm.vue | 637 +++++++++++++++--- .../purchases/form/usePurchasePosCart.ts | 26 + .../form/RawMaterialVariantSection.vue | 56 +- routes/web.php | 4 + tests/Feature/Admin/Manage/PurchaseTest.php | 184 ++++- 14 files changed, 1248 insertions(+), 293 deletions(-) create mode 100644 app/Http/Requests/Admin/Manage/PurchaseNewRawMaterialRequest.php diff --git a/app/Http/Controllers/Admin/Manage/Purchase/PurchaseDraftItemController.php b/app/Http/Controllers/Admin/Manage/Purchase/PurchaseDraftItemController.php index 71196d2..199573f 100644 --- a/app/Http/Controllers/Admin/Manage/Purchase/PurchaseDraftItemController.php +++ b/app/Http/Controllers/Admin/Manage/Purchase/PurchaseDraftItemController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Manage\PurchaseDraftItemRequest; +use App\Http\Requests\Admin\Manage\PurchaseNewRawMaterialRequest; use App\Http\Requests\Admin\Manage\PurchaseNewVariantRequest; use App\Models\RawMaterialPrice; use App\Services\Manage\PurchaseService; @@ -30,6 +31,13 @@ public function storeNewVariant(PurchaseNewVariantRequest $request): JsonRespons return response()->json(['price' => $price]); } + public function storeNewRawMaterial(PurchaseNewRawMaterialRequest $request): JsonResponse + { + $item = $this->purchaseService->createRawMaterialAndDraft($request->validated(), $request->user()); + + return response()->json(['item' => $item]); + } + public function destroy(Request $request, RawMaterialPrice $rawMaterialPrice): JsonResponse { $this->purchaseService->removeDraftItem($request->user(), $rawMaterialPrice); diff --git a/app/Http/Requests/Admin/Manage/PurchaseNewRawMaterialRequest.php b/app/Http/Requests/Admin/Manage/PurchaseNewRawMaterialRequest.php new file mode 100644 index 0000000..3c02163 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/PurchaseNewRawMaterialRequest.php @@ -0,0 +1,65 @@ +user()?->can(Permission::PURCHASES_CREATE->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:200'], + 'unit' => ['required', Rule::enum(RawMaterialUnit::class)], + 'variant' => ['required', 'string', 'max:200'], + 'price' => ['required', 'integer', 'gt:0'], + 'quantity' => ['required', 'numeric', 'decimal:0,4', 'gt:0'], + 'photos' => ['required_without:s3_keys', 'array', 'min:1', 'max:5'], + 'photos.*' => ['image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], + 's3_keys' => ['required_without:photos', 'array', 'min:1', 'max:5'], + 's3_keys.*' => ['required', 'string'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'photos.required_without' => 'Foto varian wajib diisi.', + 's3_keys.required_without' => 'Foto varian wajib diisi.', + 'photos.min' => 'Foto varian wajib diisi.', + 's3_keys.min' => 'Foto varian wajib diisi.', + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'name' => 'nama bahan baku', + 'unit' => 'satuan', + 'variant' => 'nama varian', + 'price' => 'harga', + 'quantity' => 'jumlah', + ...$this->photoUploadAttributes('foto varian'), + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/PurchaseRequest.php b/app/Http/Requests/Admin/Manage/PurchaseRequest.php index 03f22a1..9a7584f 100644 --- a/app/Http/Requests/Admin/Manage/PurchaseRequest.php +++ b/app/Http/Requests/Admin/Manage/PurchaseRequest.php @@ -25,25 +25,24 @@ public function authorize(): bool */ public function rules(): array { - $rules = [ + return [ 'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')->whereNull('deleted_at')], 'discount' => ['nullable', 'integer', 'min:0'], 'shipping_cost' => ['nullable', 'integer', 'min:0'], 'notes' => ['nullable', 'string', 'max:100'], - ...$this->photoRules(), + ...$this->photoRules('photos', 1), + + 'items' => ['required', 'array', 'min:1'], + 'items.*.raw_material_price_id' => ['nullable', 'integer'], + 'items.*.name' => ['required', 'string', 'max:200'], + 'items.*.unit' => ['required', 'string', Rule::in(['yard', 'meter', 'kilogram'])], + 'items.*.variant' => ['required', 'string', 'max:200'], + 'items.*.quantity' => ['required', 'numeric', 'decimal:0,4', 'gt:0'], + 'items.*.price' => ['required', 'integer', 'gt:0'], + 'items.*.photos' => ['nullable', 'array', 'max:5'], + 'items.*.photos.*' => ['image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], + ...$this->variantImageRules('items', 5), ]; - - if ($this->isMethod('PUT') || $this->isMethod('PATCH')) { - $rules['items'] = ['required', 'array', 'min:1']; - $rules['items.*.raw_material_price_id'] = [ - 'required', - 'integer', - Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), - ]; - $rules['items.*.quantity'] = ['required', 'numeric', 'decimal:0,4', 'gt:0']; - } - - return $rules; } /** @@ -58,8 +57,13 @@ public function attributes(): array 'notes' => 'keterangan', 'items' => 'bahan baku', 'items.*.raw_material_price_id' => 'bahan baku', + 'items.*.name' => 'nama bahan baku', + 'items.*.unit' => 'satuan', + 'items.*.variant' => 'nama varian', 'items.*.quantity' => 'jumlah', - ...$this->photoUploadAttributes('bukti transaksi'), + 'items.*.price' => 'harga', + ...$this->photoUploadAttributes('bukti transaksi', 'photos'), + ...$this->variantImageAttributes('items', 'foto varian'), ]; } } diff --git a/app/Services/Manage/PurchaseService.php b/app/Services/Manage/PurchaseService.php index 6c9165f..3ea0134 100644 --- a/app/Services/Manage/PurchaseService.php +++ b/app/Services/Manage/PurchaseService.php @@ -248,6 +248,113 @@ public function createVariantAndDraft(array $validated, User $user): array ]; } + public function createRawMaterialAndDraft(array $validated, User $user): array + { + $data = $this->runInTransaction( + function () use ($validated, $user): array { + $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); + + $rawMaterial = RawMaterial::withTrashed() + ->where('name', $validated['name']) + ->where('unit', $validated['unit']) + ->first(); + + if ($rawMaterial) { + if ($rawMaterial->trashed()) { + $rawMaterial->restore(); + } + if ($isOwner && !$rawMaterial->is_active) { + $rawMaterial->update(['is_active' => true]); + } + } else { + $rawMaterial = RawMaterial::create([ + 'name' => $validated['name'], + 'unit' => $validated['unit'], + 'is_active' => $isOwner, + ]); + } + + $price = RawMaterialPrice::withTrashed() + ->where('raw_material_id', $rawMaterial->id) + ->where('variant', $validated['variant']) + ->first(); + + if ($price) { + if ($price->trashed()) { + $price->restore(); + } + $price->update([ + 'price' => (int) $validated['price'], + ]); + } else { + $price = RawMaterialPrice::create([ + 'raw_material_id' => $rawMaterial->id, + 'variant' => $validated['variant'], + 'price' => (int) $validated['price'], + 'stock' => 0.0, + ]); + } + + $quantity = (float) $validated['quantity']; + $unitPrice = (int) $price->price; + $subtotal = (int) round($quantity * $unitPrice); + + $existingItem = PurchaseItem::query() + ->where('user_id', $user->id) + ->where('raw_material_price_id', $price->id) + ->whereNull('purchase_id') + ->first(); + + if ($existingItem) { + $newQty = $existingItem->quantity + $quantity; + $existingItem->update([ + 'quantity' => $newQty, + 'unit_price' => $unitPrice, + 'subtotal' => (int) round($newQty * $unitPrice), + ]); + $item = $existingItem; + } else { + $item = PurchaseItem::create([ + 'user_id' => $user->id, + 'raw_material_price_id' => $price->id, + 'purchase_id' => null, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'subtotal' => $subtotal, + ]); + } + + return [$price, $item]; + }, + 'Gagal membuat bahan baku baru dan menambahkan ke keranjang' + ); + + [$price, $item] = $data; + + if ($price->media()->count() === 0 && (! empty($validated['photos']) || ! empty($validated['s3_keys']))) { + $this->mediaService->syncCollection( + $price, + 'images', + $validated['photos'] ?? null, + null, + 5, + required: false, + errorKey: 'photos', + s3Keys: $validated['s3_keys'] ?? null, + ); + } + + $item->load([ + 'rawMaterialPrice.rawMaterial:id,name,unit', + 'rawMaterialPrice.media', + ]); + + $result = $this->presentDraftItem($item); + $this->breakItemCircularReference($item); + + return $result; + } + public function removeDraftItem(User $user, RawMaterialPrice $rawMaterialPrice): void { PurchaseItem::query() @@ -263,18 +370,9 @@ public function create(array $validated, User $user): Purchase $purchase = $this->runInTransaction( function () use ($validated, $user, $isOwner): Purchase { + $resolvedItems = $this->processRequestItems($validated['items'] ?? [], $isOwner); - $draftItems = $this->draftItemsQuery($user) - ->lockForUpdate() - ->get(); - - if ($draftItems->isEmpty()) { - throw ValidationException::withMessages([ - 'items' => 'Tambahkan minimal satu bahan baku ke keranjang.', - ]); - } - - $subtotal = $draftItems->sum('subtotal'); + $subtotal = array_sum(array_column($resolvedItems, 'subtotal')); $discount = (int) ($validated['discount'] ?? 0); $shippingCost = (int) ($validated['shipping_cost'] ?? 0); $total = max($subtotal - $discount + $shippingCost, 0); @@ -289,9 +387,12 @@ function () use ($validated, $user, $isOwner): Purchase { 'notes' => $validated['notes'] ?? null, ]); - foreach ($draftItems as $item) { - $item->update([ - 'purchase_id' => $purchase->id, + foreach ($resolvedItems as $itemData) { + $purchase->items()->create([ + 'raw_material_price_id' => $itemData['raw_material_price_id'], + 'quantity' => $itemData['quantity'], + 'unit_price' => $itemData['unit_price'], + 'subtotal' => $itemData['subtotal'], ]); } @@ -345,7 +446,7 @@ public function update(Purchase $purchase, array $validated, User $user): void $this->runInTransaction( function () use ($purchase, $validated, $user, $isOwner): void { if ($isOwner) { - $payload = $this->buildPayloadFromValidated($validated); + $payload = $this->buildPayloadFromValidated($validated, $isOwner); $this->applyPayloadToPurchase($purchase, $payload); if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) { @@ -360,7 +461,7 @@ function () use ($purchase, $validated, $user, $isOwner): void { 'submitted_by_id' => $user->id, 'payload' => [ 'old' => $this->snapshotPurchase($purchase), - 'new' => $this->buildPayloadFromValidated($validated), + 'new' => $this->buildPayloadFromValidated($validated, $isOwner), ], ]); @@ -369,7 +470,7 @@ function () use ($purchase, $validated, $user, $isOwner): void { } } }, - 'Gagal memperbarui pembelian', + 'Gagal memperbarui belanja', ); if (! $isOwner) { @@ -709,9 +810,9 @@ private function snapshotPurchase(Purchase $purchase): array ]; } - private function buildPayloadFromValidated(array $validated): array + private function buildPayloadFromValidated(array $validated, bool $isOwner): array { - $lineItems = $this->enrichLineItems($this->buildLineItems($validated['items'])); + $lineItems = $this->enrichLineItems($this->processRequestItems($validated['items'], $isOwner)); $subtotal = array_sum(array_column($lineItems, 'subtotal')); $discount = (int) ($validated['discount'] ?? 0); $shippingCost = (int) ($validated['shipping_cost'] ?? 0); @@ -731,6 +832,83 @@ private function buildPayloadFromValidated(array $validated): array ]; } + private function processRequestItems(array $items, bool $isOwner): array + { + $resolvedItems = []; + + foreach ($items as $index => $itemData) { + // Resolve RawMaterial + $rawMaterial = RawMaterial::withTrashed() + ->where('name', $itemData['name']) + ->where('unit', $itemData['unit']) + ->first(); + + if ($rawMaterial) { + if ($rawMaterial->trashed()) { + $rawMaterial->restore(); + } + if ($isOwner && !$rawMaterial->is_active) { + $rawMaterial->update(['is_active' => true]); + } + } else { + $rawMaterial = RawMaterial::create([ + 'name' => $itemData['name'], + 'unit' => $itemData['unit'], + 'is_active' => $isOwner, + ]); + } + + // Resolve RawMaterialPrice (variant) + $price = RawMaterialPrice::withTrashed() + ->where('raw_material_id', $rawMaterial->id) + ->where('variant', $itemData['variant']) + ->first(); + + if ($price) { + if ($price->trashed()) { + $price->restore(); + } + $price->update([ + 'price' => (int) $itemData['price'], + ]); + } else { + $price = RawMaterialPrice::create([ + 'raw_material_id' => $rawMaterial->id, + 'variant' => $itemData['variant'], + 'price' => (int) $itemData['price'], + 'stock' => 0.0, + ]); + } + + // Sync variant photo if provided + if (!empty($itemData['photos']) || !empty($itemData['s3_keys']) || !empty($itemData['remove_media_ids'])) { + $this->mediaService->syncCollection( + $price, + 'images', + $itemData['photos'] ?? null, + $itemData['remove_media_ids'] ?? null, + 5, + required: false, + errorKey: "items.{$index}.photos", + s3Keys: $itemData['s3_keys'] ?? null, + ); + } + + $quantity = (float) $itemData['quantity']; + $unitPrice = (int) $price->price; + $lineSubtotal = (int) round($quantity * $unitPrice); + + $resolvedItems[] = [ + 'raw_material_price_id' => $price->id, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'subtotal' => $lineSubtotal, + ]; + } + + return $resolvedItems; + } + private function enrichLineItems(array $lineItems): array { $prices = RawMaterialPrice::query() diff --git a/resources/js/components/ConfirmDialog.vue b/resources/js/components/ConfirmDialog.vue index 9e08226..4a77b3a 100644 --- a/resources/js/components/ConfirmDialog.vue +++ b/resources/js/components/ConfirmDialog.vue @@ -52,7 +52,7 @@ const emit = defineEmits<{