1013 lines
36 KiB
PHP
1013 lines
36 KiB
PHP
<?php
|
|
|
|
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;
|
|
use App\Models\RawMaterial;
|
|
use App\Models\RawMaterialPrice;
|
|
use App\Models\Supplier;
|
|
use App\Models\User;
|
|
use App\Services\Concerns\CachesQuery;
|
|
use App\Services\Concerns\RunsInTransaction;
|
|
use App\Services\Media\MediaService;
|
|
use App\Services\System\PushNotificationService;
|
|
use App\Support\Media\MediaPresenter;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class PurchaseService
|
|
{
|
|
use CachesQuery, RunsInTransaction;
|
|
|
|
private const MAX_PHOTOS = 1;
|
|
|
|
public function __construct(
|
|
private readonly MediaService $mediaService,
|
|
private readonly PushNotificationService $pushNotificationService,
|
|
) {}
|
|
|
|
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
|
|
{
|
|
$query = Purchase::query()
|
|
->with([
|
|
'supplier:id,name',
|
|
'createdBy.profile',
|
|
'pendingOwnerVerificationRequest.submittedBy.profile',
|
|
'items.rawMaterialPrice.rawMaterial:id,name,unit',
|
|
'media',
|
|
])
|
|
->when(($tableQuery['search_id'] ?? '') !== '', function (Builder $query) use ($tableQuery): void {
|
|
$query->where('purchases.id', $tableQuery['search_id']);
|
|
})
|
|
->when(($tableQuery['search_id'] ?? '') === '' && $tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
|
|
$search = $tableQuery['search'];
|
|
$query->where(function (Builder $query) use ($search): void {
|
|
$query->where('notes', 'like', "%{$search}%")
|
|
->orWhereHas('supplier', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"))
|
|
->orWhereHas('items.rawMaterialPrice', function (Builder $query) use ($search): void {
|
|
$query->where('variant', 'like', "%{$search}%")
|
|
->orWhereHas('rawMaterial', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
|
|
});
|
|
});
|
|
});
|
|
|
|
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
|
|
|
|
return $query
|
|
->paginate(25)
|
|
->withQueryString()
|
|
->through(function (Purchase $purchase) {
|
|
$purchase->setAttribute(
|
|
'photos',
|
|
MediaPresenter::first($purchase, 'photos'),
|
|
);
|
|
|
|
$pendingRequest = $purchase->pendingOwnerVerificationRequest;
|
|
|
|
$purchase->setAttribute('has_pending_request', $pendingRequest !== null);
|
|
$purchase->setAttribute('pending_request_id', $pendingRequest?->id);
|
|
$purchase->setAttribute('pending_request_action', $pendingRequest?->action->value);
|
|
$purchase->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
|
|
$purchase->setAttribute('pending_request_submitted_by_name', $pendingRequest?->submittedBy?->profile?->full_name ?? $pendingRequest?->submittedBy?->username);
|
|
|
|
$purchase->items->each(fn (PurchaseItem $item) => $this->breakItemCircularReference($item));
|
|
|
|
return $purchase;
|
|
});
|
|
}
|
|
|
|
public function supplierOptions(): array
|
|
{
|
|
return Supplier::query()
|
|
->orderBy('name')
|
|
->get(['id', 'name'])
|
|
->map(fn (Supplier $supplier) => [
|
|
'value' => $supplier->id,
|
|
'label' => $supplier->name,
|
|
])
|
|
->all();
|
|
}
|
|
|
|
public function catalogItems(?Purchase $purchase = null, ?User $user = null): Collection
|
|
{
|
|
$purchasePriceIds = $purchase
|
|
? $purchase->items()->pluck('raw_material_price_id')->all()
|
|
: ($user ? $this->draftItemsQuery($user)->pluck('raw_material_price_id')->all() : []);
|
|
|
|
return RawMaterial::query()
|
|
->with([
|
|
'prices' => fn ($query) => $query
|
|
->orderBy('created_at')
|
|
->with('media'),
|
|
])
|
|
->where(function (Builder $query) use ($purchasePriceIds): void {
|
|
$query->active();
|
|
|
|
if ($purchasePriceIds !== []) {
|
|
$query->orWhereHas(
|
|
'prices',
|
|
fn (Builder $query) => $query->whereIn('id', $purchasePriceIds),
|
|
);
|
|
}
|
|
})
|
|
->orderBy('name')
|
|
->get()
|
|
->each(function (RawMaterial $rawMaterial): void {
|
|
$rawMaterial->prices->each(function (RawMaterialPrice $price) use ($rawMaterial): void {
|
|
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
|
|
$price->setAttribute('unit_abbreviation', $rawMaterial->unit->abbreviation());
|
|
$price->unsetRelation('rawMaterial');
|
|
});
|
|
});
|
|
}
|
|
|
|
public function findForEdit(Purchase $purchase): Purchase
|
|
{
|
|
$purchase->load([
|
|
'items.rawMaterialPrice.rawMaterial:id,name,unit',
|
|
'media',
|
|
]);
|
|
|
|
$purchase->setAttribute(
|
|
'photos',
|
|
MediaPresenter::first($purchase, 'photos'),
|
|
);
|
|
|
|
$purchase->items->each(function (PurchaseItem $item): void {
|
|
$price = $item->rawMaterialPrice;
|
|
|
|
if ($price) {
|
|
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
|
|
}
|
|
|
|
$this->breakItemCircularReference($item);
|
|
});
|
|
|
|
return $purchase;
|
|
}
|
|
|
|
public function draftItemsForUser(User $user): array
|
|
{
|
|
return $this->draftItemsQuery($user)
|
|
->with([
|
|
'rawMaterialPrice.rawMaterial:id,name,unit',
|
|
'rawMaterialPrice.media',
|
|
])
|
|
->get()
|
|
->map(function (PurchaseItem $item) {
|
|
$result = $this->presentDraftItem($item);
|
|
$this->breakItemCircularReference($item);
|
|
|
|
return $result;
|
|
})
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
public function syncDraftItem(array $validated, User $user): array
|
|
{
|
|
$price = RawMaterialPrice::query()
|
|
->with('rawMaterial:id,name,unit')
|
|
->findOrFail($validated['raw_material_price_id']);
|
|
|
|
$quantity = (float) $validated['quantity'];
|
|
$unitPrice = (int) $price->price;
|
|
$subtotal = (int) round($quantity * $unitPrice);
|
|
|
|
$item = PurchaseItem::query()->updateOrCreate(
|
|
[
|
|
'user_id' => $user->id,
|
|
'raw_material_price_id' => $price->id,
|
|
'purchase_id' => null,
|
|
],
|
|
[
|
|
'quantity' => $quantity,
|
|
'unit_price' => $unitPrice,
|
|
'subtotal' => $subtotal,
|
|
],
|
|
);
|
|
|
|
$item->load([
|
|
'rawMaterialPrice.rawMaterial:id,name,unit',
|
|
'rawMaterialPrice.media',
|
|
]);
|
|
|
|
$result = $this->presentDraftItem($item);
|
|
$this->breakItemCircularReference($item);
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function createVariantAndDraft(array $validated, User $user): array
|
|
{
|
|
$rawMaterial = RawMaterial::query()->findOrFail($validated['raw_material_id']);
|
|
|
|
$price = RawMaterialPrice::create([
|
|
'raw_material_id' => $rawMaterial->id,
|
|
'variant' => $validated['variant'],
|
|
'price' => (int) $validated['price'],
|
|
'stock' => (float) ($validated['stock'] ?? 0),
|
|
]);
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
|
|
|
|
$this->pushNotificationService->sendToRoles(
|
|
'🆕 Varian Baru Ditambahkan',
|
|
"{$user->name} menambahkan varian \"{$price->variant}\" ke bahan baku \"{$rawMaterial->name}\".",
|
|
['owner', 'developer'],
|
|
route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]),
|
|
);
|
|
|
|
return [
|
|
'id' => $price->id,
|
|
'variant' => $price->variant,
|
|
'price' => $price->price,
|
|
'price_formatted' => $price->price_formatted,
|
|
'price_input' => $price->price_input,
|
|
'stock' => $price->stock,
|
|
'stock_formatted' => $price->stock_formatted,
|
|
'stock_input' => $price->stock_input,
|
|
'images' => $price->images,
|
|
];
|
|
}
|
|
|
|
public function createRawMaterialAndDraft(array $validated, User $user): array
|
|
{
|
|
$data = $this->runInTransaction(
|
|
function () use ($validated, $user): array {
|
|
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
|
|
|
|
$rawMaterial = RawMaterial::withTrashed()
|
|
->where('name', $validated['name'])
|
|
->where('unit', $validated['unit'])
|
|
->first();
|
|
|
|
if ($rawMaterial) {
|
|
if ($rawMaterial->trashed()) {
|
|
$rawMaterial->restore();
|
|
}
|
|
if ($isOwner && ! $rawMaterial->is_active) {
|
|
$rawMaterial->update(['is_active' => true]);
|
|
}
|
|
} else {
|
|
$rawMaterial = RawMaterial::create([
|
|
'name' => $validated['name'],
|
|
'unit' => $validated['unit'],
|
|
'is_active' => $isOwner,
|
|
]);
|
|
}
|
|
|
|
$price = RawMaterialPrice::withTrashed()
|
|
->where('raw_material_id', $rawMaterial->id)
|
|
->where('variant', $validated['variant'])
|
|
->first();
|
|
|
|
if ($price) {
|
|
if ($price->trashed()) {
|
|
$price->restore();
|
|
}
|
|
$price->update([
|
|
'price' => (int) $validated['price'],
|
|
]);
|
|
} else {
|
|
$price = RawMaterialPrice::create([
|
|
'raw_material_id' => $rawMaterial->id,
|
|
'variant' => $validated['variant'],
|
|
'price' => (int) $validated['price'],
|
|
'stock' => 0.0,
|
|
]);
|
|
}
|
|
|
|
$quantity = (float) $validated['quantity'];
|
|
$unitPrice = (int) $price->price;
|
|
$subtotal = (int) round($quantity * $unitPrice);
|
|
|
|
$existingItem = PurchaseItem::query()
|
|
->where('user_id', $user->id)
|
|
->where('raw_material_price_id', $price->id)
|
|
->whereNull('purchase_id')
|
|
->first();
|
|
|
|
if ($existingItem) {
|
|
$newQty = $existingItem->quantity + $quantity;
|
|
$existingItem->update([
|
|
'quantity' => $newQty,
|
|
'unit_price' => $unitPrice,
|
|
'subtotal' => (int) round($newQty * $unitPrice),
|
|
]);
|
|
$item = $existingItem;
|
|
} else {
|
|
$item = PurchaseItem::create([
|
|
'user_id' => $user->id,
|
|
'raw_material_price_id' => $price->id,
|
|
'purchase_id' => null,
|
|
'quantity' => $quantity,
|
|
'unit_price' => $unitPrice,
|
|
'subtotal' => $subtotal,
|
|
]);
|
|
}
|
|
|
|
return [$price, $item];
|
|
},
|
|
'Gagal membuat bahan baku baru dan menambahkan ke keranjang'
|
|
);
|
|
|
|
[$price, $item] = $data;
|
|
|
|
if ($price->media()->count() === 0 && (! empty($validated['photos']) || ! empty($validated['s3_keys']))) {
|
|
$this->mediaService->syncCollection(
|
|
$price,
|
|
'images',
|
|
$validated['photos'] ?? null,
|
|
null,
|
|
5,
|
|
required: false,
|
|
errorKey: 'photos',
|
|
s3Keys: $validated['s3_keys'] ?? null,
|
|
);
|
|
}
|
|
|
|
$item->load([
|
|
'rawMaterialPrice.rawMaterial:id,name,unit',
|
|
'rawMaterialPrice.media',
|
|
]);
|
|
|
|
$result = $this->presentDraftItem($item);
|
|
$this->breakItemCircularReference($item);
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function removeDraftItem(User $user, RawMaterialPrice $rawMaterialPrice): void
|
|
{
|
|
PurchaseItem::query()
|
|
->whereNull('purchase_id')
|
|
->where('user_id', $user->id)
|
|
->where('raw_material_price_id', $rawMaterialPrice->id)
|
|
->delete();
|
|
}
|
|
|
|
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);
|
|
|
|
$subtotal = array_sum(array_column($resolvedItems, 'subtotal'));
|
|
$discount = (int) ($validated['discount'] ?? 0);
|
|
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
|
|
$total = max($subtotal - $discount + $shippingCost, 0);
|
|
|
|
$purchase = Purchase::create([
|
|
'supplier_id' => $validated['supplier_id'],
|
|
'created_by_id' => $user->id,
|
|
'subtotal' => $subtotal,
|
|
'discount' => $discount,
|
|
'shipping_cost' => $shippingCost,
|
|
'total' => $total,
|
|
'notes' => $validated['notes'] ?? null,
|
|
]);
|
|
|
|
foreach ($resolvedItems as $itemData) {
|
|
$purchase->items()->create([
|
|
'raw_material_price_id' => $itemData['raw_material_price_id'],
|
|
'quantity' => $itemData['quantity'],
|
|
'unit_price' => $itemData['unit_price'],
|
|
'subtotal' => $itemData['subtotal'],
|
|
]);
|
|
}
|
|
|
|
$this->syncPhotos($purchase, $validated);
|
|
|
|
$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),
|
|
],
|
|
]);
|
|
}
|
|
|
|
return $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->cacheForgetByPattern('manage:purchases:*');
|
|
|
|
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);
|
|
|
|
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);
|
|
}
|
|
}
|
|
},
|
|
'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:*');
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
public function applyVerificationRequest(OwnerVerificationRequest $verificationRequest): void
|
|
{
|
|
match ($verificationRequest->action) {
|
|
OwnerVerificationAction::CREATE => $this->applyCreate($verificationRequest),
|
|
OwnerVerificationAction::UPDATE => $this->applyUpdate($verificationRequest),
|
|
OwnerVerificationAction::DELETE => $this->applyDelete($verificationRequest),
|
|
default => throw ValidationException::withMessages([
|
|
'action' => 'Aksi verifikasi belanja tidak didukung.',
|
|
]),
|
|
};
|
|
|
|
$this->cacheForgetByPattern('manage:purchases:*');
|
|
}
|
|
|
|
public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void
|
|
{
|
|
match ($verificationRequest->action) {
|
|
OwnerVerificationAction::CREATE => $this->rejectCreate($verificationRequest),
|
|
OwnerVerificationAction::UPDATE, OwnerVerificationAction::DELETE => null,
|
|
default => throw ValidationException::withMessages([
|
|
'action' => 'Aksi verifikasi belanja tidak didukung.',
|
|
]),
|
|
};
|
|
|
|
$this->cacheForgetByPattern('manage:purchases:*');
|
|
}
|
|
|
|
public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void
|
|
{
|
|
$verificationRequest->clearMediaCollection('photos');
|
|
}
|
|
|
|
public function applyCreate(OwnerVerificationRequest $verificationRequest): void
|
|
{
|
|
$purchase = $verificationRequest->subject;
|
|
|
|
if (! $purchase instanceof Purchase) {
|
|
throw ValidationException::withMessages([
|
|
'purchase' => 'Belanja tidak ditemukan.',
|
|
]);
|
|
}
|
|
|
|
$purchase->load('items');
|
|
|
|
foreach ($purchase->items as $item) {
|
|
$this->incrementStock($item);
|
|
}
|
|
}
|
|
|
|
public function applyUpdate(OwnerVerificationRequest $verificationRequest): void
|
|
{
|
|
$purchase = $verificationRequest->subject;
|
|
|
|
if (! $purchase instanceof Purchase) {
|
|
throw ValidationException::withMessages([
|
|
'purchase' => 'Belanja tidak ditemukan.',
|
|
]);
|
|
}
|
|
|
|
$this->applyPayloadToPurchase($purchase, $this->payloadNew($verificationRequest), $verificationRequest);
|
|
}
|
|
|
|
public function applyDelete(OwnerVerificationRequest $verificationRequest): void
|
|
{
|
|
$purchase = $verificationRequest->subject;
|
|
|
|
if (! $purchase instanceof Purchase) {
|
|
throw ValidationException::withMessages([
|
|
'purchase' => 'Belanja tidak ditemukan.',
|
|
]);
|
|
}
|
|
|
|
$this->executeDelete($purchase);
|
|
}
|
|
|
|
private function buildLineItems(array $items): array
|
|
{
|
|
return collect($items)
|
|
->map(function (array $itemData, int $index) {
|
|
$price = RawMaterialPrice::query()->find($itemData['raw_material_price_id']);
|
|
|
|
if ($price === null) {
|
|
throw ValidationException::withMessages([
|
|
"items.{$index}.raw_material_price_id" => 'Bahan baku tidak ditemukan.',
|
|
]);
|
|
}
|
|
|
|
$quantity = (float) $itemData['quantity'];
|
|
$unitPrice = (int) $price->price;
|
|
$subtotal = (int) round($quantity * $unitPrice);
|
|
|
|
return [
|
|
'raw_material_price_id' => $price->id,
|
|
'quantity' => $quantity,
|
|
'unit_price' => $unitPrice,
|
|
'subtotal' => $subtotal,
|
|
];
|
|
})
|
|
->all();
|
|
}
|
|
|
|
private function syncPhotos(Purchase $purchase, array $validated): void
|
|
{
|
|
$this->mediaService->syncCollection(
|
|
$purchase,
|
|
'photos',
|
|
$validated['photos'] ?? null,
|
|
$validated['remove_media_ids'] ?? null,
|
|
self::MAX_PHOTOS,
|
|
required: false,
|
|
errorKey: 'photos',
|
|
s3Keys: $validated['s3_keys'] ?? null,
|
|
);
|
|
}
|
|
|
|
private function draftItemsQuery(User $user): Builder
|
|
{
|
|
return PurchaseItem::query()
|
|
->whereNull('purchase_id')
|
|
->where('user_id', $user->id);
|
|
}
|
|
|
|
private function presentDraftItem(PurchaseItem $item): array
|
|
{
|
|
$price = $item->rawMaterialPrice;
|
|
$rawMaterial = $price?->rawMaterial;
|
|
|
|
return [
|
|
'raw_material_price_id' => $item->raw_material_price_id,
|
|
'raw_material_name' => $rawMaterial?->name ?? '',
|
|
'variant' => $price?->variant ?? '',
|
|
'unit_abbreviation' => $rawMaterial?->unit?->abbreviation() ?? '',
|
|
'quantity' => $item->quantity_input,
|
|
'unit_price' => $item->unit_price,
|
|
'images' => $price ? MediaPresenter::collection($price, 'images') : [],
|
|
];
|
|
}
|
|
|
|
private function incrementStock(PurchaseItem $item): void
|
|
{
|
|
RawMaterialPrice::query()
|
|
->whereKey($item->raw_material_price_id)
|
|
->increment('stock', $item->quantity);
|
|
}
|
|
|
|
private function decrementStock(PurchaseItem $item): void
|
|
{
|
|
RawMaterialPrice::query()
|
|
->whereKey($item->raw_material_price_id)
|
|
->decrement('stock', $item->quantity);
|
|
}
|
|
|
|
private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl, ?string $search = 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",
|
|
$body,
|
|
['owner', 'developer'],
|
|
$ownerUrl,
|
|
);
|
|
|
|
$this->pushNotificationService->sendToUser(
|
|
'📤 Pengajuan Terkirim',
|
|
$body,
|
|
$user->id,
|
|
$submitterUrl,
|
|
);
|
|
}
|
|
|
|
private function rejectCreate(OwnerVerificationRequest $verificationRequest): void
|
|
{
|
|
$purchase = $verificationRequest->subject;
|
|
|
|
if (! $purchase instanceof Purchase) {
|
|
return;
|
|
}
|
|
|
|
$this->runInTransaction(
|
|
function () use ($purchase): void {
|
|
$purchase->items()->delete();
|
|
$purchase->delete();
|
|
},
|
|
'Gagal menolak belanja',
|
|
);
|
|
}
|
|
|
|
private function executeDelete(Purchase $purchase): void
|
|
{
|
|
$this->runInTransaction(
|
|
function () use ($purchase): void {
|
|
$purchase->load('items');
|
|
|
|
foreach ($purchase->items as $item) {
|
|
$this->decrementStock($item);
|
|
}
|
|
|
|
$purchase->items()->delete();
|
|
$purchase->delete();
|
|
},
|
|
'Gagal menghapus belanja',
|
|
);
|
|
}
|
|
|
|
private function applyPayloadToPurchase(
|
|
Purchase $purchase,
|
|
array $payload,
|
|
?OwnerVerificationRequest $verificationRequest = null,
|
|
): void {
|
|
$this->runInTransaction(
|
|
function () use ($purchase, $payload, $verificationRequest): void {
|
|
$purchase->load('items');
|
|
|
|
foreach ($purchase->items as $item) {
|
|
$this->decrementStock($item);
|
|
}
|
|
|
|
$purchase->items()->delete();
|
|
|
|
foreach ($payload['items'] ?? [] as $itemData) {
|
|
$purchaseItem = $purchase->items()->create([
|
|
'raw_material_price_id' => $itemData['raw_material_price_id'],
|
|
'quantity' => $itemData['quantity'],
|
|
'unit_price' => $itemData['unit_price'],
|
|
'subtotal' => $itemData['subtotal'],
|
|
]);
|
|
$this->incrementStock($purchaseItem);
|
|
}
|
|
|
|
$purchase->update([
|
|
'supplier_id' => $payload['supplier_id'],
|
|
'subtotal' => $payload['subtotal'],
|
|
'discount' => $payload['discount'],
|
|
'shipping_cost' => $payload['shipping_cost'],
|
|
'total' => $payload['total'],
|
|
'notes' => $payload['notes'] ?? null,
|
|
]);
|
|
|
|
if ($verificationRequest !== null) {
|
|
$this->applyRequestPhotos($verificationRequest, $purchase, $payload);
|
|
}
|
|
},
|
|
'Gagal memperbarui belanja',
|
|
);
|
|
}
|
|
|
|
private function snapshotPurchase(Purchase $purchase): array
|
|
{
|
|
$purchase->load([
|
|
'supplier:id,name',
|
|
'items.rawMaterialPrice.rawMaterial:id,name',
|
|
]);
|
|
|
|
return [
|
|
'supplier_id' => $purchase->supplier_id,
|
|
'supplier_name' => $purchase->supplier?->name,
|
|
'subtotal' => $purchase->subtotal,
|
|
'discount' => $purchase->discount,
|
|
'shipping_cost' => $purchase->shipping_cost,
|
|
'total' => $purchase->total,
|
|
'notes' => $purchase->notes,
|
|
'items' => $purchase->items
|
|
->map(fn (PurchaseItem $item) => [
|
|
'raw_material_price_id' => $item->raw_material_price_id,
|
|
'raw_material_name' => $item->rawMaterialPrice?->rawMaterial?->name,
|
|
'variant' => $item->rawMaterialPrice?->variant,
|
|
'quantity' => (float) $item->quantity,
|
|
'unit_price' => $item->unit_price,
|
|
'subtotal' => $item->subtotal,
|
|
])
|
|
->all(),
|
|
'has_photos' => $purchase->hasMedia('photos'),
|
|
];
|
|
}
|
|
|
|
private function buildPayloadFromValidated(array $validated, bool $isOwner): array
|
|
{
|
|
$lineItems = $this->enrichLineItems($this->processRequestItems($validated['items'], $isOwner));
|
|
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
|
|
$discount = (int) ($validated['discount'] ?? 0);
|
|
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
|
|
$total = max($subtotal - $discount + $shippingCost, 0);
|
|
$supplier = Supplier::query()->find($validated['supplier_id']);
|
|
|
|
return [
|
|
'supplier_id' => (int) $validated['supplier_id'],
|
|
'supplier_name' => $supplier?->name,
|
|
'subtotal' => $subtotal,
|
|
'discount' => $discount,
|
|
'shipping_cost' => $shippingCost,
|
|
'total' => $total,
|
|
'notes' => $validated['notes'] ?? null,
|
|
'items' => $lineItems,
|
|
'remove_media_ids' => $validated['remove_media_ids'] ?? [],
|
|
];
|
|
}
|
|
|
|
private function processRequestItems(array $items, bool $isOwner): array
|
|
{
|
|
$resolvedItems = [];
|
|
|
|
foreach ($items as $index => $itemData) {
|
|
// Resolve RawMaterial
|
|
$rawMaterial = RawMaterial::withTrashed()
|
|
->where('name', $itemData['name'])
|
|
->where('unit', $itemData['unit'])
|
|
->first();
|
|
|
|
if ($rawMaterial) {
|
|
if ($rawMaterial->trashed()) {
|
|
$rawMaterial->restore();
|
|
}
|
|
if ($isOwner && ! $rawMaterial->is_active) {
|
|
$rawMaterial->update(['is_active' => true]);
|
|
}
|
|
} else {
|
|
$rawMaterial = RawMaterial::create([
|
|
'name' => $itemData['name'],
|
|
'unit' => $itemData['unit'],
|
|
'is_active' => $isOwner,
|
|
]);
|
|
}
|
|
|
|
// Resolve RawMaterialPrice (variant)
|
|
$price = RawMaterialPrice::withTrashed()
|
|
->where('raw_material_id', $rawMaterial->id)
|
|
->where('variant', $itemData['variant'])
|
|
->first();
|
|
|
|
if ($price) {
|
|
if ($price->trashed()) {
|
|
$price->restore();
|
|
}
|
|
$price->update([
|
|
'price' => (int) $itemData['price'],
|
|
]);
|
|
} else {
|
|
$price = RawMaterialPrice::create([
|
|
'raw_material_id' => $rawMaterial->id,
|
|
'variant' => $itemData['variant'],
|
|
'price' => (int) $itemData['price'],
|
|
'stock' => 0.0,
|
|
]);
|
|
}
|
|
|
|
// Sync variant photo if provided
|
|
if (! empty($itemData['photos']) || ! empty($itemData['s3_keys']) || ! empty($itemData['remove_media_ids'])) {
|
|
$this->mediaService->syncCollection(
|
|
$price,
|
|
'images',
|
|
$itemData['photos'] ?? null,
|
|
$itemData['remove_media_ids'] ?? null,
|
|
5,
|
|
required: false,
|
|
errorKey: "items.{$index}.photos",
|
|
s3Keys: $itemData['s3_keys'] ?? null,
|
|
);
|
|
}
|
|
|
|
$quantity = (float) $itemData['quantity'];
|
|
$unitPrice = (int) $price->price;
|
|
$lineSubtotal = (int) round($quantity * $unitPrice);
|
|
|
|
$resolvedItems[] = [
|
|
'raw_material_price_id' => $price->id,
|
|
'quantity' => $quantity,
|
|
'unit_price' => $unitPrice,
|
|
'subtotal' => $lineSubtotal,
|
|
];
|
|
}
|
|
|
|
return $resolvedItems;
|
|
}
|
|
|
|
private function enrichLineItems(array $lineItems): array
|
|
{
|
|
$prices = RawMaterialPrice::query()
|
|
->with('rawMaterial:id,name')
|
|
->whereIn('id', array_column($lineItems, 'raw_material_price_id'))
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
return collect($lineItems)
|
|
->map(function (array $item) use ($prices) {
|
|
$price = $prices->get($item['raw_material_price_id']);
|
|
|
|
return array_merge($item, [
|
|
'raw_material_name' => $price?->rawMaterial?->name,
|
|
'variant' => $price?->variant,
|
|
]);
|
|
})
|
|
->all();
|
|
}
|
|
|
|
private function syncRequestPhotos(OwnerVerificationRequest $verificationRequest, array $validated): void
|
|
{
|
|
$this->mediaService->syncCollection(
|
|
$verificationRequest,
|
|
'photos',
|
|
$validated['photos'] ?? null,
|
|
$validated['remove_media_ids'] ?? null,
|
|
self::MAX_PHOTOS,
|
|
required: false,
|
|
errorKey: 'photos',
|
|
s3Keys: $validated['s3_keys'] ?? null,
|
|
);
|
|
}
|
|
|
|
private function applyRequestPhotos(
|
|
OwnerVerificationRequest $verificationRequest,
|
|
Purchase $purchase,
|
|
array $payload,
|
|
): void {
|
|
if ($verificationRequest->hasMedia('photos')) {
|
|
$purchase->clearMediaCollection('photos');
|
|
|
|
foreach ($verificationRequest->getMedia('photos') as $media) {
|
|
$media->copy($purchase, 'photos');
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
foreach ($payload['remove_media_ids'] ?? [] as $mediaId) {
|
|
$purchase->deleteMedia((int) $mediaId);
|
|
}
|
|
}
|
|
|
|
private function payloadNew(OwnerVerificationRequest $verificationRequest): array
|
|
{
|
|
$payload = $verificationRequest->payload ?? [];
|
|
|
|
if (array_key_exists('new', $payload)) {
|
|
return is_array($payload['new']) ? $payload['new'] : [];
|
|
}
|
|
|
|
return is_array($payload) ? $payload : [];
|
|
}
|
|
|
|
private function breakItemCircularReference(PurchaseItem $item): void
|
|
{
|
|
$price = $item->rawMaterialPrice;
|
|
|
|
if ($price) {
|
|
$rawMaterial = $price->rawMaterial;
|
|
|
|
$item->setAttribute('variant', $price->variant);
|
|
|
|
if ($rawMaterial) {
|
|
$unitAbbreviation = $rawMaterial->unit->abbreviation();
|
|
$price->setAttribute('unit_abbreviation', $unitAbbreviation);
|
|
$item->setAttribute('unit_abbreviation', $unitAbbreviation);
|
|
$item->setAttribute('raw_material_id', $rawMaterial->id);
|
|
$item->setAttribute('raw_material_name', $rawMaterial->name);
|
|
$item->setAttribute('raw_material_unit_label', $rawMaterial->unit->label());
|
|
}
|
|
|
|
$price->unsetRelation('rawMaterial');
|
|
}
|
|
|
|
$item->unsetRelation('rawMaterialPrice');
|
|
}
|
|
|
|
private function applySorting(Builder $query, string $sort, string $direction): void
|
|
{
|
|
if (in_array($sort, ['created_at', 'total', 'discount', 'subtotal'], true)) {
|
|
$query->orderBy($sort, $direction);
|
|
|
|
return;
|
|
}
|
|
|
|
$query->latest();
|
|
}
|
|
}
|