store/app/Services/Manage/PurchaseService.php

432 lines
14 KiB
PHP

<?php
namespace App\Services\Manage;
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\Media\MediaService;
use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class PurchaseService
{
private const MAX_PHOTOS = 1;
public function __construct(
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
) {}
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
{
$query = Purchase::query()
->with([
'supplier:id,name',
'createdBy.profile',
'items.rawMaterialPrice.rawMaterial:id,name,unit',
'media',
])
->when($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(10)
->withQueryString()
->through(function (Purchase $purchase) {
$purchase->setAttribute(
'photos',
MediaPresenter::first($purchase, 'photos'),
);
return $purchase;
});
}
/**
* @return list<array{value: int, label: string}>
*/
public function supplierOptions(): array
{
return Supplier::query()
->orderBy('name')
->get(['id', 'name'])
->map(fn (Supplier $supplier) => [
'value' => $supplier->id,
'label' => $supplier->name,
])
->all();
}
/**
* @return Collection<int, RawMaterial>
*/
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(['rawMaterial:id,unit', '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): void {
$price->setAttribute(
'images',
MediaPresenter::collection($price, 'images'),
);
});
});
}
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'),
);
}
});
return $purchase;
}
/**
* @return list<array<string, mixed>>
*/
public function draftItemsForUser(User $user): array
{
return $this->draftItemsQuery($user)
->with([
'rawMaterialPrice.rawMaterial:id,name,unit',
'rawMaterialPrice.media',
])
->get()
->map(fn (PurchaseItem $item) => $this->presentDraftItem($item))
->values()
->all();
}
/**
* @param array<string, mixed> $validated
* @return array<string, mixed>
*/
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',
]);
return $this->presentDraftItem($item);
}
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();
}
/**
* @param array<string, mixed> $validated
*/
public function create(array $validated, User $user): Purchase
{
$purchase = DB::transaction(function () use ($validated, $user): Purchase {
/** @var EloquentCollection<int, PurchaseItem> $draftItems */
$draftItems = $this->draftItemsQuery($user)
->lockForUpdate()
->get();
if ($draftItems->isEmpty()) {
throw ValidationException::withMessages([
'items' => 'Tambahkan minimal satu bahan baku ke keranjang.',
]);
}
$subtotal = $draftItems->sum('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 ($draftItems as $item) {
$item->update([
'purchase_id' => $purchase->id,
]);
$this->incrementStock($item);
}
$this->syncPhotos($purchase, $validated);
return $purchase;
});
$purchase->load('supplier');
$this->pushNotificationService->sendToRoles(
'🛒 Belanja Baru',
"Pembelian dari supplier {$purchase->supplier->name} senilai {$purchase->total_formatted} telah ditambahkan oleh {$user->profile?->full_name}.",
['owner', 'developer'],
route('admin.manage.purchases.index'),
);
return $purchase;
}
/**
* @param array<string, mixed> $validated
*/
public function update(Purchase $purchase, array $validated): void
{
DB::transaction(function () use ($purchase, $validated): void {
$purchase->load('items');
foreach ($purchase->items as $item) {
$this->decrementStock($item);
}
$purchase->items()->delete();
$lineItems = $this->buildLineItems($validated['items']);
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
$discount = (int) ($validated['discount'] ?? 0);
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
$total = max($subtotal - $discount + $shippingCost, 0);
$purchase->update([
'supplier_id' => $validated['supplier_id'],
'subtotal' => $subtotal,
'discount' => $discount,
'shipping_cost' => $shippingCost,
'total' => $total,
'notes' => $validated['notes'] ?? null,
]);
foreach ($lineItems as $itemData) {
$purchaseItem = $purchase->items()->create($itemData);
$this->incrementStock($purchaseItem);
}
$this->syncPhotos($purchase, $validated);
});
$purchase->load('supplier');
$this->pushNotificationService->sendToRoles(
'✏️ Belanja Diperbarui',
"Pembelian dari supplier {$purchase->supplier->name} senilai {$purchase->total_formatted} telah diperbarui.",
['owner', 'developer'],
route('admin.manage.purchases.index'),
);
}
public function delete(Purchase $purchase): void
{
$supplierName = $purchase->supplier->name;
DB::transaction(function () use ($purchase): void {
$purchase->load('items');
foreach ($purchase->items as $item) {
$this->decrementStock($item);
}
$purchase->clearMediaCollection('photos');
$purchase->items()->delete();
$purchase->delete();
});
$this->pushNotificationService->sendToRoles(
'🗑️ Belanja Dihapus',
"Pembelian dari supplier {$supplierName} senilai {$purchase->total_formatted} telah dihapus.",
['owner', 'developer'],
route('admin.manage.purchases.index'),
);
}
/**
* @param list<array{raw_material_price_id: int, quantity: float|int|string}> $items
* @return list<array{raw_material_price_id: int, quantity: float, unit_price: int, subtotal: int}>
*/
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();
}
/**
* @param array<string, mixed> $validated
*/
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',
);
}
/**
* @return Builder<PurchaseItem>
*/
private function draftItemsQuery(User $user): Builder
{
return PurchaseItem::query()
->whereNull('purchase_id')
->where('user_id', $user->id);
}
/**
* @return array<string, mixed>
*/
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 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();
}
}