diff --git a/app/Http/Controllers/Admin/Manage/Purchase/PurchaseController.php b/app/Http/Controllers/Admin/Manage/Purchase/PurchaseController.php index 529c565..ae12670 100644 --- a/app/Http/Controllers/Admin/Manage/Purchase/PurchaseController.php +++ b/app/Http/Controllers/Admin/Manage/Purchase/PurchaseController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers\Admin\Manage\Purchase; -use App\Enums\Permission; use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Controller; @@ -50,11 +49,7 @@ public function store(PurchaseRequest $request): RedirectResponse { $this->purchaseService->create($request->validated(), $request->user()); - if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { - $this->flashCreated('Belanja'); - } else { - $this->flashSuccess('Belanja berhasil diajukan dan menunggu verifikasi owner.'); - } + $this->flashCreated('Belanja'); return redirect()->route('admin.manage.purchases.index'); } @@ -72,11 +67,7 @@ public function update(PurchaseRequest $request, Purchase $purchase): RedirectRe { $this->purchaseService->update($purchase, $request->validated(), $request->user()); - if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { - $this->flashUpdated('Belanja'); - } else { - $this->flashSuccess('Perubahan belanja berhasil diajukan dan menunggu verifikasi owner.'); - } + $this->flashUpdated('Belanja'); return redirect()->route('admin.manage.purchases.index'); } @@ -85,11 +76,7 @@ public function destroy(Request $request, Purchase $purchase): RedirectResponse { $this->purchaseService->delete($purchase, $request->user()); - if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { - $this->flashDeleted('Belanja'); - } else { - $this->flashSuccess('Penghapusan belanja berhasil diajukan dan menunggu verifikasi owner.'); - } + $this->flashDeleted('Belanja'); return redirect()->route('admin.manage.purchases.index'); } diff --git a/app/Http/Requests/Admin/Manage/PurchaseDraftItemRequest.php b/app/Http/Requests/Admin/Manage/PurchaseDraftItemRequest.php index 1837c29..984eff8 100644 --- a/app/Http/Requests/Admin/Manage/PurchaseDraftItemRequest.php +++ b/app/Http/Requests/Admin/Manage/PurchaseDraftItemRequest.php @@ -25,6 +25,10 @@ public function rules(): array Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), ], 'quantity' => ['required', 'numeric', 'decimal:0,4', 'gt:0'], + 'photos' => ['nullable', 'array', 'max:5'], + 'photos.*' => ['image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], + 's3_keys' => ['nullable', 'array', 'max:5'], + 's3_keys.*' => ['nullable', 'string'], ]; } } diff --git a/app/Http/Requests/Admin/Manage/PurchaseNewRawMaterialRequest.php b/app/Http/Requests/Admin/Manage/PurchaseNewRawMaterialRequest.php index 3c02163..0fa9d54 100644 --- a/app/Http/Requests/Admin/Manage/PurchaseNewRawMaterialRequest.php +++ b/app/Http/Requests/Admin/Manage/PurchaseNewRawMaterialRequest.php @@ -28,10 +28,10 @@ public function rules(): array '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' => ['nullable', 'array', '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'], + 's3_keys' => ['nullable', 'array', 'max:5'], + 's3_keys.*' => ['nullable', 'string'], ]; } diff --git a/app/Services/Manage/PurchaseService.php b/app/Services/Manage/PurchaseService.php index 385fcba..b145585 100644 --- a/app/Services/Manage/PurchaseService.php +++ b/app/Services/Manage/PurchaseService.php @@ -3,8 +3,6 @@ namespace App\Services\Manage; use App\Enums\OwnerVerificationAction; -use App\Enums\OwnerVerificationStatus; -use App\Enums\Permission; use App\Models\OwnerVerificationRequest; use App\Models\Purchase; use App\Models\PurchaseItem; @@ -214,6 +212,19 @@ public function syncDraftItem(array $validated, User $user): array ], ); + if (! 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', @@ -275,48 +286,18 @@ 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::create([ + 'name' => $validated['name'], + 'unit' => $validated['unit'], + 'is_active' => true, + ]); - $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, - ]); - } + $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; @@ -389,11 +370,9 @@ public function removeDraftItem(User $user, RawMaterialPrice $rawMaterialPrice): public function create(array $validated, User $user): Purchase { - $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); - $purchase = $this->runInTransaction( - function () use ($validated, $user, $isOwner): Purchase { - $resolvedItems = $this->processRequestItems($validated['items'] ?? [], $isOwner); + function () use ($validated, $user): Purchase { + $resolvedItems = $this->processRequestItems($validated['items'] ?? []); $subtotal = array_sum(array_column($resolvedItems, 'subtotal')); $discount = (int) ($validated['discount'] ?? 0); @@ -423,22 +402,8 @@ function () use ($validated, $user, $isOwner): Purchase { $purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']); - if ($isOwner) { - foreach ($purchase->items as $item) { - $this->incrementStock($item); - } - } else { - OwnerVerificationRequest::create([ - 'action' => OwnerVerificationAction::CREATE, - 'status' => OwnerVerificationStatus::PENDING, - 'subject_type' => Purchase::class, - 'subject_id' => $purchase->id, - 'submitted_by_id' => $user->id, - 'payload' => [ - 'old' => null, - 'new' => $this->snapshotPurchase($purchase), - ], - ]); + foreach ($purchase->items as $item) { + $this->incrementStock($item); } return $purchase; @@ -446,109 +411,55 @@ function () use ($validated, $user, $isOwner): Purchase { 'Gagal membuat pembelian', ); - if (! $isOwner) { - $this->notifyForPendingRequest( - $user, - 'Tambah Belanja', - "Pengajuan belanja dari supplier {$purchase->supplier->name} senilai {$purchase->total_formatted} oleh {$user->profile?->full_name} menunggu verifikasi owner.", - route('admin.manage.purchases.index', ['search_id' => $purchase->id]), - (string) $purchase->id, - ); - } + $this->draftItemsQuery($user)->delete(); $this->cacheForgetByPattern('manage:purchases:*'); + $this->notifyPurchase( + 'Belanja Baru', + "{$user->profile?->full_name} membuat belanja dari supplier {$purchase->supplier->name} senilai {$purchase->total_formatted}.", + route('admin.manage.purchases.index', ['search_id' => $purchase->id]), + ); + return $purchase; } public function update(Purchase $purchase, array $validated, User $user): void { - $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); $purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']); $this->runInTransaction( - function () use ($purchase, $validated, $user, $isOwner): void { - if ($isOwner) { - $payload = $this->buildPayloadFromValidated($validated, $isOwner); - $this->applyPayloadToPurchase($purchase, $payload); + function () use ($purchase, $validated): void { + $payload = $this->buildPayloadFromValidated($validated); + $this->applyPayloadToPurchase($purchase, $payload); - if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) { - $this->syncPhotos($purchase, $validated); - } - } else { - $verificationRequest = OwnerVerificationRequest::create([ - 'action' => OwnerVerificationAction::UPDATE, - 'status' => OwnerVerificationStatus::PENDING, - 'subject_type' => Purchase::class, - 'subject_id' => $purchase->id, - 'submitted_by_id' => $user->id, - 'payload' => [ - 'old' => $this->snapshotPurchase($purchase), - 'new' => $this->buildPayloadFromValidated($validated, $isOwner), - ], - ]); - - if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) { - $this->syncRequestPhotos($verificationRequest, $validated); - } + if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) { + $this->syncPhotos($purchase, $validated); } }, 'Gagal memperbarui belanja', ); - if (! $isOwner) { - $purchase->load('supplier'); - - $this->notifyForPendingRequest( - $user, - 'Ubah Belanja', - "Pengajuan ubah belanja dari supplier {$purchase->supplier->name} oleh {$user->profile?->full_name} menunggu verifikasi owner.", - route('admin.manage.purchases.index', ['search_id' => $purchase->id]), - (string) $purchase->id, - ); - } - $this->cacheForgetByPattern('manage:purchases:*'); + + $this->notifyPurchase( + 'Belanja Diubah', + "{$user->profile?->full_name} mengubah belanja dari supplier {$purchase->supplier->name}.", + route('admin.manage.purchases.index', ['search_id' => $purchase->id]), + ); } public function delete(Purchase $purchase, User $user): void { - $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); - - if ($isOwner) { - $this->executeDelete($purchase); - $this->cacheForgetByPattern('manage:purchases:*'); - - return; - } - - $purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']); - - $this->runInTransaction( - function () use ($purchase, $user): void { - OwnerVerificationRequest::create([ - 'action' => OwnerVerificationAction::DELETE, - 'status' => OwnerVerificationStatus::PENDING, - 'subject_type' => Purchase::class, - 'subject_id' => $purchase->id, - 'submitted_by_id' => $user->id, - 'payload' => [ - 'old' => $this->snapshotPurchase($purchase), - 'new' => null, - ], - ]); - }, - 'Gagal mengajukan penghapusan belanja', - ); - $purchase->load('supplier'); - $this->notifyForPendingRequest( - $user, - 'Hapus Belanja', - "Pengajuan hapus belanja dari supplier {$purchase->supplier->name} oleh {$user->profile?->full_name} menunggu verifikasi owner.", - route('admin.manage.purchases.index', ['search_id' => $purchase->id]), - (string) $purchase->id, + $this->executeDelete($purchase); + $this->cacheForgetByPattern('manage:purchases:*'); + + $this->notifyPurchase( + 'Belanja Dihapus', + "{$user->profile?->full_name} menghapus belanja dari supplier {$purchase->supplier->name}.", + route('admin.manage.purchases.index'), ); } @@ -593,12 +504,6 @@ public function applyCreate(OwnerVerificationRequest $verificationRequest): void 'purchase' => 'Belanja tidak ditemukan.', ]); } - - $purchase->load('items'); - - foreach ($purchase->items as $item) { - $this->incrementStock($item); - } } public function applyUpdate(OwnerVerificationRequest $verificationRequest): void @@ -704,26 +609,23 @@ private function decrementStock(PurchaseItem $item): void ->decrement('stock', $item->quantity); } - private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl, ?string $search = null): void + private function notifyPurchase(string $typeLabel, string $body, string $url, ?string $userId = null): void { - $ownerUrl = route('admin.manage.purchases.index'); - if ($search !== null) { - $ownerUrl = route('admin.manage.purchases.index', ['search_id' => $search]); - } - $this->pushNotificationService->sendToRoles( - "📦 {$typeLabel} Menunggu Persetujuan Owner", + "📦 {$typeLabel}", $body, ['owner', 'developer'], - $ownerUrl, + $url, ); - $this->pushNotificationService->sendToUser( - '📤 Pengajuan Terkirim', - $body, - $user->id, - $submitterUrl, - ); + if ($userId !== null) { + $this->pushNotificationService->sendToUser( + '📦 Belanja', + $body, + $userId, + $url, + ); + } } private function rejectCreate(OwnerVerificationRequest $verificationRequest): void @@ -831,9 +733,9 @@ private function snapshotPurchase(Purchase $purchase): array ]; } - private function buildPayloadFromValidated(array $validated, bool $isOwner): array + private function buildPayloadFromValidated(array $validated): array { - $lineItems = $this->enrichLineItems($this->processRequestItems($validated['items'], $isOwner)); + $lineItems = $this->enrichLineItems($this->processRequestItems($validated['items'])); $subtotal = array_sum(array_column($lineItems, 'subtotal')); $discount = (int) ($validated['discount'] ?? 0); $shippingCost = (int) ($validated['shipping_cost'] ?? 0); @@ -853,7 +755,7 @@ private function buildPayloadFromValidated(array $validated, bool $isOwner): arr ]; } - private function processRequestItems(array $items, bool $isOwner): array + private function processRequestItems(array $items): array { $resolvedItems = []; @@ -868,14 +770,14 @@ private function processRequestItems(array $items, bool $isOwner): array if ($rawMaterial->trashed()) { $rawMaterial->restore(); } - if ($isOwner && ! $rawMaterial->is_active) { + if (! $rawMaterial->is_active) { $rawMaterial->update(['is_active' => true]); } } else { $rawMaterial = RawMaterial::create([ 'name' => $itemData['name'], 'unit' => $itemData['unit'], - 'is_active' => $isOwner, + 'is_active' => true, ]); } diff --git a/resources/js/pages/admin/manage/purchases/form/PurchasePosForm.vue b/resources/js/pages/admin/manage/purchases/form/PurchasePosForm.vue index 96dde56..38c6acd 100644 --- a/resources/js/pages/admin/manage/purchases/form/PurchasePosForm.vue +++ b/resources/js/pages/admin/manage/purchases/form/PurchasePosForm.vue @@ -160,6 +160,8 @@ function applyPriceToAllVariants(sourceClientId: string) { } function addPrice() { + prices.value.forEach((price) => savePriceToDb(price)); + const newPrice: RawMaterialPriceFormItem = { client_id: createClientId(), variant: '', @@ -315,6 +317,12 @@ async function savePriceToDb(price: typeof prices.value[0]) { }); } + if (price.media.newFileS3Keys.length > 0) { + price.media.newFileS3Keys.forEach((key) => { + formData.append('s3_keys[]', key); + }); + } + const response = await apiFetch<{ item: any }>( '/admin/manage/purchases/draft-items/new-raw-material', { @@ -324,19 +332,54 @@ async function savePriceToDb(price: typeof prices.value[0]) { ); price.id = response.item.raw_material_price_id; - toast.success(`Varian "${price.variant}" berhasil disimpan.`); + + if (response.item.images?.length > 0) { + price.media.existing = response.item.images; + price.media.newFiles = []; + price.media.newFileS3Keys = []; + } } else { - await apiFetch( - '/admin/manage/purchases/draft-items', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - raw_material_price_id: price.id, - quantity: Number(price.stock) || 0, - }), + const hasNewPhotos = price.media.newFiles.length > 0 || price.media.newFileS3Keys.length > 0; + + if (hasNewPhotos) { + const formData = new FormData(); + formData.append('raw_material_price_id', String(price.id)); + formData.append('quantity', String(Number(price.stock) || 0)); + + price.media.newFiles.forEach((file) => { + formData.append('photos[]', file); + }); + + price.media.newFileS3Keys.forEach((key) => { + formData.append('s3_keys[]', key); + }); + + const response = await apiFetch<{ item: any }>( + '/admin/manage/purchases/draft-items', + { + method: 'POST', + body: formData, + } + ); + + if (response.item.images?.length > 0) { + price.media.existing = response.item.images; + price.media.newFiles = []; + price.media.newFileS3Keys = []; } - ); + } else { + await apiFetch( + '/admin/manage/purchases/draft-items', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + raw_material_price_id: price.id, + quantity: Number(price.stock) || 0, + }), + } + ); + } } } catch (error) { console.error(error); @@ -349,6 +392,23 @@ const debouncedSave = debounce(async (price: typeof prices.value[0]) => { await savePriceToDb(price); }, 600); +const prevUploadCount = ref(0); + +watch( + () => prices.value.reduce((sum, p) => sum + (p.media.pendingUploads || 0), 0), + (current, old) => { + prevUploadCount.value = old ?? 0; + + if (old > 0 && current === 0) { + prices.value.forEach((price) => { + if (price.media.newFiles.length > 0 || price.media.newFileS3Keys.length > 0) { + debouncedSave(price); + } + }); + } + }, +); + // Variant master form event interceptors function handleUpdateVariant(clientId: string, value: string) { setPriceField(clientId, 'variant', value); diff --git a/tests/Feature/Admin/Manage/PurchaseTest.php b/tests/Feature/Admin/Manage/PurchaseTest.php index 16b0ad3..2aff8af 100644 --- a/tests/Feature/Admin/Manage/PurchaseTest.php +++ b/tests/Feature/Admin/Manage/PurchaseTest.php @@ -1,9 +1,6 @@ create(); - - $user->givePermissionTo([ - PermissionEnum::DASHBOARD_VIEW->value, - PermissionEnum::OWNER_VERIFICATIONS_VIEW->value, - PermissionEnum::OWNER_VERIFICATIONS_VERIFY->value, - PermissionEnum::OWNER_VERIFICATIONS_REJECT->value, - ]); - - $user->forgetCachedPermissions(); - - return $user; -} - -function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerificationRequest -{ - $verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail(); - - test()->actingAs($verifier) - ->post(route('admin.manage.owner_verifications.approve_request', $verificationRequest)) - ->assertRedirect(); - - return $verificationRequest->fresh(); -} - function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2): array { $unit = $price->rawMaterial->unit; @@ -82,290 +52,6 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2): ]; } -describe('Purchase Owner Verification', function () { - test('create purchase submits verification without changing stock', function () { - $user = createPurchaseUserWithPermission( - PermissionEnum::PURCHASES_VIEW, - PermissionEnum::PURCHASES_CREATE, - ); - - $supplier = Supplier::factory()->create(); - $price = RawMaterialPrice::factory()->create(['stock' => 10]); - $initialStock = (float) $price->stock; - - $this->actingAs($user) - ->postJson(route('admin.manage.purchases.draft_items.store'), [ - 'raw_material_price_id' => $price->id, - 'quantity' => 2, - ]) - ->assertOk(); - - $this->actingAs($user) - ->post(route('admin.manage.purchases.store'), [ - 'supplier_id' => $supplier->id, - 'discount' => 0, - 'shipping_cost' => 0, - 'notes' => 'Belanja test', - 'items' => getPurchaseItemsPayload($price, 2), - ]) - ->assertRedirect(route('admin.manage.purchases.index')); - - $purchase = Purchase::query()->latest()->first(); - - expect($purchase)->not->toBeNull(); - - $this->assertDatabaseHas('owner_verification_requests', [ - 'subject_type' => Purchase::class, - 'subject_id' => $purchase->id, - 'action' => OwnerVerificationAction::CREATE->value, - 'status' => OwnerVerificationStatus::PENDING->value, - ]); - - expect((float) $price->fresh()->stock)->toBe($initialStock); - }); - - test('approving create purchase increments stock', function () { - $user = createPurchaseUserWithPermission( - PermissionEnum::PURCHASES_VIEW, - PermissionEnum::PURCHASES_CREATE, - ); - $verifier = createPurchaseVerifierUser(); - - $supplier = Supplier::factory()->create(); - $price = RawMaterialPrice::factory()->create(['stock' => 10]); - - $this->actingAs($user) - ->postJson(route('admin.manage.purchases.draft_items.store'), [ - 'raw_material_price_id' => $price->id, - 'quantity' => 2, - ]) - ->assertOk(); - - $this->actingAs($user) - ->post(route('admin.manage.purchases.store'), [ - 'supplier_id' => $supplier->id, - 'discount' => 0, - 'shipping_cost' => 0, - 'items' => getPurchaseItemsPayload($price, 2), - ]) - ->assertRedirect(route('admin.manage.purchases.index')); - - approveLatestPurchaseVerificationRequest($verifier); - - expect((float) $price->fresh()->stock)->toBe(12.0); - }); - - test('update purchase submits verification request only', function () { - $user = createPurchaseUserWithPermission( - PermissionEnum::PURCHASES_VIEW, - PermissionEnum::PURCHASES_CREATE, - PermissionEnum::PURCHASES_UPDATE, - ); - $verifier = createPurchaseVerifierUser(); - - $supplier = Supplier::factory()->create(); - $price = RawMaterialPrice::factory()->create(['stock' => 10, 'price' => 50_000]); - $otherPrice = RawMaterialPrice::factory()->create(['stock' => 5, 'price' => 30_000]); - - $this->actingAs($user) - ->postJson(route('admin.manage.purchases.draft_items.store'), [ - 'raw_material_price_id' => $price->id, - 'quantity' => 2, - ]) - ->assertOk(); - - $this->actingAs($user) - ->post(route('admin.manage.purchases.store'), [ - 'supplier_id' => $supplier->id, - 'discount' => 0, - 'shipping_cost' => 0, - 'items' => getPurchaseItemsPayload($price, 2), - ]) - ->assertRedirect(route('admin.manage.purchases.index')); - - approveLatestPurchaseVerificationRequest($verifier); - - $purchase = Purchase::query()->latest()->firstOrFail(); - $originalTotal = $purchase->total; - - $this->actingAs($user) - ->put(route('admin.manage.purchases.update', $purchase), [ - 'supplier_id' => $supplier->id, - 'discount' => 1000, - 'shipping_cost' => 0, - 'notes' => 'Ubah belanja', - 'items' => getPurchaseItemsPayload($otherPrice, 1), - ]) - ->assertRedirect(route('admin.manage.purchases.index')); - - $this->assertDatabaseHas('owner_verification_requests', [ - 'subject_type' => Purchase::class, - 'subject_id' => $purchase->id, - 'action' => OwnerVerificationAction::UPDATE->value, - 'status' => OwnerVerificationStatus::PENDING->value, - ]); - - expect($purchase->fresh()->total)->toBe($originalTotal); - }); - - test('delete purchase submits verification request only', function () { - $user = createPurchaseUserWithPermission( - PermissionEnum::PURCHASES_VIEW, - PermissionEnum::PURCHASES_CREATE, - PermissionEnum::PURCHASES_DELETE, - ); - $verifier = createPurchaseVerifierUser(); - - $supplier = Supplier::factory()->create(); - $price = RawMaterialPrice::factory()->create(['stock' => 10]); - - $this->actingAs($user) - ->postJson(route('admin.manage.purchases.draft_items.store'), [ - 'raw_material_price_id' => $price->id, - 'quantity' => 2, - ]) - ->assertOk(); - - $this->actingAs($user) - ->post(route('admin.manage.purchases.store'), [ - 'supplier_id' => $supplier->id, - 'discount' => 0, - 'shipping_cost' => 0, - 'items' => getPurchaseItemsPayload($price, 2), - ]) - ->assertRedirect(route('admin.manage.purchases.index')); - - approveLatestPurchaseVerificationRequest($verifier); - - $purchase = Purchase::query()->latest()->firstOrFail(); - - $this->actingAs($user) - ->delete(route('admin.manage.purchases.destroy', $purchase)) - ->assertRedirect(route('admin.manage.purchases.index')); - - $this->assertDatabaseHas('owner_verification_requests', [ - 'subject_type' => Purchase::class, - 'subject_id' => $purchase->id, - 'action' => OwnerVerificationAction::DELETE->value, - 'status' => OwnerVerificationStatus::PENDING->value, - ]); - - expect(Purchase::query()->whereKey($purchase->id)->exists())->toBeTrue(); - expect((float) $price->fresh()->stock)->toBe(12.0); - }); - - test('rejecting create purchase deletes purchase without changing stock', function () { - $user = createPurchaseUserWithPermission( - PermissionEnum::PURCHASES_VIEW, - PermissionEnum::PURCHASES_CREATE, - ); - $verifier = createPurchaseVerifierUser(); - - $supplier = Supplier::factory()->create(); - $price = RawMaterialPrice::factory()->create(['stock' => 10]); - - $this->actingAs($user) - ->postJson(route('admin.manage.purchases.draft_items.store'), [ - 'raw_material_price_id' => $price->id, - 'quantity' => 2, - ]) - ->assertOk(); - - $this->actingAs($user) - ->post(route('admin.manage.purchases.store'), [ - 'supplier_id' => $supplier->id, - 'discount' => 0, - 'shipping_cost' => 0, - 'items' => getPurchaseItemsPayload($price, 2), - ]) - ->assertRedirect(route('admin.manage.purchases.index')); - - $purchase = Purchase::query()->latest()->firstOrFail(); - $verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail(); - - $this->actingAs($verifier) - ->post(route('admin.manage.owner_verifications.reject_request', $verificationRequest), [ - 'reason' => 'Tidak sesuai', - ]) - ->assertRedirect(); - - expect(Purchase::query()->whereKey($purchase->id)->exists())->toBeFalse(); - expect(PurchaseItem::query()->where('purchase_id', $purchase->id)->exists())->toBeFalse(); - expect((float) $price->fresh()->stock)->toBe(10.0); - }); - - test('pending purchase blocks edit route', function () { - $user = createPurchaseUserWithPermission( - PermissionEnum::PURCHASES_VIEW, - PermissionEnum::PURCHASES_CREATE, - PermissionEnum::PURCHASES_UPDATE, - ); - - $supplier = Supplier::factory()->create(); - $price = RawMaterialPrice::factory()->create(['stock' => 10]); - - $this->actingAs($user) - ->postJson(route('admin.manage.purchases.draft_items.store'), [ - 'raw_material_price_id' => $price->id, - 'quantity' => 2, - ]) - ->assertOk(); - - $this->actingAs($user) - ->post(route('admin.manage.purchases.store'), [ - 'supplier_id' => $supplier->id, - 'discount' => 0, - 'shipping_cost' => 0, - 'items' => getPurchaseItemsPayload($price, 2), - ]) - ->assertRedirect(route('admin.manage.purchases.index')); - - $purchase = Purchase::query()->latest()->firstOrFail(); - - $this->actingAs($user) - ->get(route('admin.manage.purchases.edit', $purchase)) - ->assertRedirect(route('admin.manage.purchases.index')); - }); - - test('purchase index exposes pending verification state', function () { - $user = createPurchaseUserWithPermission( - PermissionEnum::PURCHASES_VIEW, - PermissionEnum::PURCHASES_CREATE, - ); - - $supplier = Supplier::factory()->create(); - $price = RawMaterialPrice::factory()->create(['stock' => 10]); - - $this->actingAs($user) - ->postJson(route('admin.manage.purchases.draft_items.store'), [ - 'raw_material_price_id' => $price->id, - 'quantity' => 2, - ]) - ->assertOk(); - - $this->actingAs($user) - ->post(route('admin.manage.purchases.store'), [ - 'supplier_id' => $supplier->id, - 'discount' => 0, - 'shipping_cost' => 0, - 'items' => getPurchaseItemsPayload($price, 2), - ]) - ->assertRedirect(route('admin.manage.purchases.index')); - - $purchase = Purchase::query()->latest()->firstOrFail(); - - $response = $this->actingAs($user) - ->get(route('admin.manage.purchases.index')); - - $response->assertOk(); - $response->assertInertia(fn ($page) => $page - ->where('purchases.data.0.id', $purchase->id) - ->where('purchases.data.0.has_pending_request', true) - ->where('purchases.data.0.pending_request_action', OwnerVerificationAction::CREATE->value) - ); - }); -}); - // ─── Index ──────────────────────────────────────────────── describe('Purchase Index', function () { @@ -510,7 +196,6 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2): describe('Purchase Edit', function () { test('authenticated user with permission can view edit form', function () { $user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE, PermissionEnum::PURCHASES_UPDATE); - $verifier = createPurchaseVerifierUser(); $supplier = Supplier::factory()->create(); $price = RawMaterialPrice::factory()->create(['stock' => 10]); @@ -530,8 +215,6 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2): 'items' => getPurchaseItemsPayload($price, 2), ]); - approveLatestPurchaseVerificationRequest($verifier); - $purchase = Purchase::query()->latest()->firstOrFail(); $this->actingAs($user) @@ -561,7 +244,6 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2): describe('Purchase Update Validation', function () { test('items is required on update', function () { $user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE, PermissionEnum::PURCHASES_UPDATE); - $verifier = createPurchaseVerifierUser(); $supplier = Supplier::factory()->create(); $price = RawMaterialPrice::factory()->create(['stock' => 10]); @@ -581,8 +263,6 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2): 'items' => getPurchaseItemsPayload($price, 2), ]); - approveLatestPurchaseVerificationRequest($verifier); - $purchase = Purchase::query()->latest()->firstOrFail(); $this->actingAs($user) @@ -842,7 +522,7 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2): ]); }); - test('store new raw material fails if photo/s3_keys is missing', function () { + test('store new raw material succeeds without photo/s3_keys', function () { $user = createPurchaseUserWithPermission( PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE, @@ -856,11 +536,34 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2): 'price' => 25000, 'quantity' => 10, ]) - ->assertStatus(422) - ->assertJsonValidationErrors(['photos', 's3_keys']); + ->assertOk() + ->assertJsonPath('item.raw_material_name', 'Kain Toyobo Baru') + ->assertJsonPath('item.variant', 'Standard') + ->assertJsonPath('item.unit_abbreviation', 'yard'); + + $this->assertDatabaseHas('raw_materials', [ + 'name' => 'Kain Toyobo Baru', + 'unit' => 'yard', + ]); + + $this->assertDatabaseHas('raw_material_prices', [ + 'variant' => 'Standard', + 'price' => 25000, + 'stock' => 0.0, + ]); + + $rawMaterialPrice = RawMaterialPrice::where('variant', 'Standard') + ->whereHas('rawMaterial', fn ($q) => $q->where('name', 'Kain Toyobo Baru')) + ->first(); + + $this->assertDatabaseHas('purchase_items', [ + 'raw_material_price_id' => $rawMaterialPrice->id, + 'quantity' => 10, + 'purchase_id' => null, + ]); }); - test('store new raw material reuses existing raw material if name and unit match', function () { + test('store new raw material creates new records even if same name and unit exist', function () { $user = createPurchaseUserWithPermission( PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE, @@ -897,34 +600,32 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2): ]) ->assertOk(); - // Check that raw_materials table only has 1 record for this name + // Duplicates are fine — each creates its own raw_material $rawMaterialsCount = RawMaterial::where('name', 'Kain Toyobo Unik')->count(); - expect($rawMaterialsCount)->toBe(1); + expect($rawMaterialsCount)->toBe(2); - // Check that raw_material_prices has both variants for the same raw_material_id - $rawMaterial = RawMaterial::where('name', 'Kain Toyobo Unik')->first(); $this->assertDatabaseHas('raw_material_prices', [ - 'raw_material_id' => $rawMaterial->id, 'variant' => 'Standard', 'price' => 25000, ]); $this->assertDatabaseHas('raw_material_prices', [ - 'raw_material_id' => $rawMaterial->id, 'variant' => 'Premium', 'price' => 30000, ]); }); - test('store new raw material increments draft item quantity if same variant is added again', function () { + test('store new raw material creates separate draft items for same variant name', function () { $user = createPurchaseUserWithPermission( PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE, ); $disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public'); - $key = 'raw-materials/'.Str::uuid().'.jpg'; + $key1 = 'raw-materials/'.Str::uuid().'.jpg'; + $key2 = 'raw-materials/'.Str::uuid().'.jpg'; $imageContent = UploadedFile::fake()->image('Standard.jpg', 100, 100)->get(); - Storage::disk($disk)->put($key, $imageContent); + Storage::disk($disk)->put($key1, $imageContent); + Storage::disk($disk)->put($key2, $imageContent); // Add standard variant first time (qty = 10) $this->actingAs($user) @@ -934,12 +635,12 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2): 'variant' => 'Standard', 'price' => 25000, 'quantity' => 10, - 's3_keys' => [$key], + 's3_keys' => [$key1], ]) ->assertOk() ->assertJsonPath('item.quantity', '10'); - // Add same variant second time (qty = 5) + // Add same variant second time — creates separate draft item (qty = 5) $this->actingAs($user) ->postJson(route('admin.manage.purchases.draft_items.store_new_raw_material'), [ 'name' => 'Kain Toyobo Sama', @@ -947,9 +648,9 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2): 'variant' => 'Standard', 'price' => 25000, 'quantity' => 5, - 's3_keys' => [$key], + 's3_keys' => [$key2], ]) ->assertOk() - ->assertJsonPath('item.quantity', '15'); // 10 + 5 + ->assertJsonPath('item.quantity', '5'); }); });