feat: enhance purchase management by adding photo and S3 key support in requests and services
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run

This commit is contained in:
Yoga Pangestu 2026-07-24 22:48:26 +07:00
parent 57040f1875
commit 78f422b1cf
6 changed files with 188 additions and 534 deletions

View File

@ -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');
}

View File

@ -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'],
];
}
}

View File

@ -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'],
];
}

View File

@ -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,
]);
}

View File

@ -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);

View File

@ -1,9 +1,6 @@
<?php
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission as PermissionEnum;
use App\Models\OwnerVerificationRequest;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\RawMaterial;
@ -38,33 +35,6 @@ function createPurchaseUserWithPermission(PermissionEnum ...$permissions): User
return $user;
}
function createPurchaseVerifierUser(): User
{
$user = User::factory()->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');
});
});