430 lines
14 KiB
PHP
430 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->purchase_id = $purchase->id;
|
|
$item->save();
|
|
$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'],
|
|
'/admin/manage/purchases',
|
|
);
|
|
|
|
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->supplier_id = $validated['supplier_id'];
|
|
$purchase->subtotal = $subtotal;
|
|
$purchase->discount = $discount;
|
|
$purchase->shipping_cost = $shippingCost;
|
|
$purchase->total = $total;
|
|
$purchase->notes = $validated['notes'] ?? null;
|
|
$purchase->save();
|
|
|
|
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'],
|
|
'/admin/manage/purchases',
|
|
);
|
|
}
|
|
|
|
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'],
|
|
'/admin/manage/purchases',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @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();
|
|
}
|
|
}
|