feat: enhance purchase management by adding photo and S3 key support in requests and services
This commit is contained in:
parent
57040f1875
commit
78f422b1cf
@ -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');
|
||||
}
|
||||
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user