483 lines
19 KiB
PHP
483 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Admin\Manage;
|
|
|
|
use App\Models\Purchase;
|
|
use App\Models\PurchaseItem;
|
|
use App\Models\RawMaterial;
|
|
use App\Models\RawMaterialPrice;
|
|
use App\Models\Supplier;
|
|
use App\Services\Concerns\RegistersMedia;
|
|
use App\Services\NotificationService;
|
|
use App\Services\S3PresignedService;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class PurchaseService
|
|
{
|
|
use RegistersMedia;
|
|
|
|
public function __construct(
|
|
private S3PresignedService $s3Service,
|
|
) {}
|
|
|
|
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
|
{
|
|
$paginator = Purchase::query()
|
|
->select(['id', 'supplier_id', 'created_by_id', 'subtotal', 'discount', 'shipping_cost', 'total', 'notes', 'created_at'])
|
|
->with([
|
|
'supplier:id,name',
|
|
'createdBy:id',
|
|
'createdBy.userProfile:id,user_id,full_name',
|
|
'purchaseItems' => fn ($q) => $q
|
|
->select(['id', 'purchase_id', 'raw_material_price_id', 'quantity', 'unit_price', 'subtotal'])
|
|
->orderByRaw('(SELECT variant FROM raw_material_prices WHERE raw_material_prices.id = purchase_items.raw_material_price_id)'),
|
|
'purchaseItems.rawMaterialPrice:id,raw_material_id,variant,price,stock',
|
|
'purchaseItems.rawMaterialPrice.rawMaterial:id,name,unit',
|
|
])
|
|
->when($search, function ($q) use ($search) {
|
|
$q->whereHas('supplier', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
|
|
->orWhere('notes', 'like', "%{$search}%");
|
|
})
|
|
->when($filters['supplier_id'] ?? null, fn ($q, $supplierId) => $q->where('supplier_id', $supplierId))
|
|
->orderBy($sort, $direction)
|
|
->paginate($perPage);
|
|
|
|
$paginator->getCollection()->each(function (Purchase $purchase) {
|
|
$purchaseMedia = $purchase->getFirstMedia('photos');
|
|
$purchase->photo_url = $purchaseMedia
|
|
? $this->s3Service->getTemporaryUrl($purchaseMedia->file_name)
|
|
: null;
|
|
|
|
$purchase->purchaseItems->each(function (PurchaseItem $item) {
|
|
if (! $item->rawMaterialPrice) {
|
|
return;
|
|
}
|
|
|
|
$media = $item->rawMaterialPrice->getMedia('photos');
|
|
$item->rawMaterialPrice->photo_url = $media->first()
|
|
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
|
|
: null;
|
|
});
|
|
});
|
|
|
|
return $paginator;
|
|
}
|
|
|
|
public function getForCreate(): array
|
|
{
|
|
return [
|
|
'suppliers' => Supplier::select(['id', 'name'])->latest()->get(),
|
|
'rawMaterials' => RawMaterial::query()
|
|
->select(['id', 'name', 'unit', 'is_active'])
|
|
->with([
|
|
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
|
|
])
|
|
->orderBy('name')
|
|
->get()
|
|
->each(function (RawMaterial $rawMaterial) {
|
|
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
|
|
$media = $price->getFirstMedia('photos');
|
|
$price->photo_url = $media
|
|
? $this->s3Service->getTemporaryUrl($media->file_name)
|
|
: null;
|
|
});
|
|
}),
|
|
];
|
|
}
|
|
|
|
public function getForEdit(Purchase $purchase): array
|
|
{
|
|
$purchase->load([
|
|
'purchaseItems' => fn ($q) => $q
|
|
->orderByRaw('(SELECT variant FROM raw_material_prices WHERE raw_material_prices.id = purchase_items.raw_material_price_id)'),
|
|
'purchaseItems.rawMaterialPrice.rawMaterial',
|
|
'supplier',
|
|
]);
|
|
|
|
$rawMaterial = $purchase->purchaseItems->first()?->rawMaterialPrice?->rawMaterial;
|
|
|
|
$variants = $purchase->purchaseItems->map(function (PurchaseItem $item) {
|
|
if (! $item->rawMaterialPrice) {
|
|
return null;
|
|
}
|
|
|
|
$media = $item->rawMaterialPrice->getFirstMedia('photos');
|
|
|
|
return [
|
|
'id' => $item->rawMaterialPrice->id,
|
|
'variant' => $item->rawMaterialPrice->variant,
|
|
'price' => $item->unit_price,
|
|
'stock' => $item->quantity,
|
|
'photo_key' => $media?->file_name,
|
|
'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null,
|
|
];
|
|
})->filter()->values();
|
|
|
|
$items = $purchase->purchaseItems;
|
|
$items = $items->filter(fn (PurchaseItem $item) => $item->rawMaterialPrice !== null);
|
|
|
|
$materials = $items
|
|
->map(fn (PurchaseItem $item) => $item->rawMaterialPrice->rawMaterial)
|
|
->filter()
|
|
->unique(fn (RawMaterial $material) => $material->id);
|
|
|
|
$singleMaterial = $materials->count() === 1;
|
|
$sharedWithOther = PurchaseItem::where('purchase_id', '!=', $purchase->id)
|
|
->whereIn('raw_material_price_id', $items->pluck('raw_material_price_id'))
|
|
->exists();
|
|
|
|
$purchaseMedia = $purchase->getFirstMedia('photos');
|
|
$purchasePhotoKey = $purchaseMedia?->file_name;
|
|
$purchasePhotoUrl = $purchaseMedia
|
|
? $this->s3Service->getTemporaryUrl($purchaseMedia->file_name)
|
|
: null;
|
|
|
|
return [
|
|
'id' => $purchase->id,
|
|
'name' => $rawMaterial?->name ?? '',
|
|
'unit' => $rawMaterial?->unit->value ?? 'kg',
|
|
'supplier_id' => $purchase->supplier_id,
|
|
'discount' => $purchase->discount,
|
|
'shipping_cost' => $purchase->shipping_cost,
|
|
'notes' => $purchase->notes,
|
|
'photo_key' => $purchasePhotoKey,
|
|
'photo_url' => $purchasePhotoUrl,
|
|
'variants' => $variants,
|
|
'default_mode' => $singleMaterial && ! $sharedWithOther ? 'new' : 'existing',
|
|
'existing_material_name' => $singleMaterial ? $materials->first()->name : null,
|
|
'existing_quantities' => $singleMaterial
|
|
? $items->mapWithKeys(fn (PurchaseItem $item) => [
|
|
(int) $item->raw_material_price_id => (int) $item->quantity,
|
|
])->all()
|
|
: [],
|
|
];
|
|
}
|
|
|
|
public function create(array $data): Purchase
|
|
{
|
|
if (($data['mode'] ?? 'new') === 'existing') {
|
|
return $this->createFromExisting($data);
|
|
}
|
|
|
|
return $this->createNew($data);
|
|
}
|
|
|
|
private function createFromExisting(array $data): Purchase
|
|
{
|
|
return DB::transaction(function () use ($data) {
|
|
$now = now();
|
|
$subtotal = 0;
|
|
|
|
$itemRows = collect($data['existing_items'])->map(function ($item) use ($now, &$subtotal) {
|
|
$itemSubtotal = (int) ($item['unit_price'] * $item['quantity']);
|
|
$subtotal += $itemSubtotal;
|
|
|
|
return [
|
|
'purchase_id' => null,
|
|
'raw_material_price_id' => $item['raw_material_price_id'],
|
|
'user_id' => auth()->id(),
|
|
'quantity' => $item['quantity'],
|
|
'unit_price' => $item['unit_price'],
|
|
'subtotal' => $itemSubtotal,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
];
|
|
})->toArray();
|
|
|
|
$discount = $data['discount'] ?? 0;
|
|
$shippingCost = $data['shipping_cost'] ?? 0;
|
|
$total = $subtotal - $discount + $shippingCost;
|
|
|
|
$purchase = Purchase::create([
|
|
'supplier_id' => $data['supplier_id'],
|
|
'created_by_id' => auth()->id(),
|
|
'subtotal' => $subtotal,
|
|
'discount' => $discount,
|
|
'shipping_cost' => $shippingCost,
|
|
'total' => $total,
|
|
'notes' => $data['notes'] ?? null,
|
|
]);
|
|
|
|
foreach ($itemRows as &$row) {
|
|
$row['purchase_id'] = $purchase->id;
|
|
}
|
|
DB::table('purchase_items')->insert($itemRows);
|
|
|
|
foreach ($data['existing_items'] as $item) {
|
|
RawMaterialPrice::whereKey($item['raw_material_price_id'])
|
|
->increment('stock', (int) $item['quantity']);
|
|
}
|
|
|
|
if (! empty($data['photo_key'])) {
|
|
$this->registerMedia(
|
|
model: $purchase,
|
|
s3Key: $data['photo_key'],
|
|
collectionName: 'photos',
|
|
orderColumn: 1,
|
|
);
|
|
}
|
|
|
|
NotificationService::notify(
|
|
roles: ['Owner', 'Developer', 'Admin Bahan Baku'],
|
|
title: 'Belanja Baru',
|
|
body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
|
|
url: route('admin.manage.purchases.index'),
|
|
);
|
|
|
|
return $purchase;
|
|
});
|
|
}
|
|
|
|
private function createNew(array $data): Purchase
|
|
{
|
|
return DB::transaction(function () use ($data) {
|
|
$rawMaterial = RawMaterial::create([
|
|
'name' => $data['name'],
|
|
'unit' => $data['unit'],
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$subtotal = 0;
|
|
$now = now();
|
|
$priceRows = collect($data['variants'])->map(function ($v) use ($rawMaterial, $now, &$subtotal) {
|
|
$itemSubtotal = (int) ($v['price'] * $v['stock']);
|
|
$subtotal += $itemSubtotal;
|
|
|
|
return [
|
|
'raw_material_id' => $rawMaterial->id,
|
|
'variant' => $v['variant'],
|
|
'price' => $v['price'],
|
|
'stock' => $v['stock'],
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
];
|
|
})->toArray();
|
|
|
|
DB::table('raw_material_prices')->insert($priceRows);
|
|
|
|
$insertedPrices = RawMaterialPrice::where('raw_material_id', $rawMaterial->id)->get();
|
|
$variantMap = $insertedPrices->mapWithKeys(fn ($p) => [$p->variant => $p->id]);
|
|
|
|
foreach ($data['variants'] as $variantData) {
|
|
if (! empty($variantData['photo_key'])) {
|
|
$priceId = $variantMap[$variantData['variant']];
|
|
$priceModel = RawMaterialPrice::find($priceId);
|
|
$this->registerMedia(
|
|
model: $priceModel,
|
|
s3Key: $variantData['photo_key'],
|
|
collectionName: 'photos',
|
|
orderColumn: 1,
|
|
);
|
|
}
|
|
}
|
|
|
|
$discount = $data['discount'] ?? 0;
|
|
$shippingCost = $data['shipping_cost'] ?? 0;
|
|
$total = $subtotal - $discount + $shippingCost;
|
|
|
|
$purchase = Purchase::create([
|
|
'supplier_id' => $data['supplier_id'],
|
|
'created_by_id' => auth()->id(),
|
|
'subtotal' => $subtotal,
|
|
'discount' => $discount,
|
|
'shipping_cost' => $shippingCost,
|
|
'total' => $total,
|
|
'notes' => $data['notes'] ?? null,
|
|
]);
|
|
|
|
$purchaseItems = $insertedPrices->map(fn ($price) => [
|
|
'purchase_id' => $purchase->id,
|
|
'raw_material_price_id' => $price->id,
|
|
'user_id' => auth()->id(),
|
|
'quantity' => $price->stock,
|
|
'unit_price' => $price->price,
|
|
'subtotal' => (int) ($price->price * $price->stock),
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
])->toArray();
|
|
|
|
DB::table('purchase_items')->insert($purchaseItems);
|
|
|
|
if (! empty($data['photo_key'])) {
|
|
$this->registerMedia(
|
|
model: $purchase,
|
|
s3Key: $data['photo_key'],
|
|
collectionName: 'photos',
|
|
orderColumn: 1,
|
|
);
|
|
}
|
|
|
|
NotificationService::notify(
|
|
roles: ['Owner', 'Developer', 'Admin Bahan Baku'],
|
|
title: 'Belanja Baru',
|
|
body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
|
|
url: route('admin.manage.purchases.index'),
|
|
);
|
|
|
|
return $purchase;
|
|
});
|
|
}
|
|
|
|
public function update(Purchase $purchase, array $data): Purchase
|
|
{
|
|
return DB::transaction(function () use ($purchase, $data) {
|
|
$purchase->load('purchaseItems.rawMaterialPrice.rawMaterial');
|
|
|
|
$oldItems = $purchase->purchaseItems;
|
|
|
|
// 1. Reverse the stock increments of the old items, so prices
|
|
// get adjusted by the difference instead of being reset.
|
|
$oldItems->each(function (PurchaseItem $item) {
|
|
if ($item->rawMaterialPrice) {
|
|
$item->rawMaterialPrice->decrement('stock', (int) $item->quantity);
|
|
}
|
|
});
|
|
|
|
$oldMaterial = $oldItems
|
|
->map(fn (PurchaseItem $item) => $item->rawMaterialPrice?->rawMaterial)
|
|
->filter()
|
|
->unique(fn (RawMaterial $material) => $material->id)
|
|
->first();
|
|
|
|
$oldItems->each->delete();
|
|
|
|
$now = now();
|
|
$subtotal = 0;
|
|
|
|
if (($data['mode'] ?? 'new') === 'existing') {
|
|
// 2a. Reference existing prices and add their new stock.
|
|
$itemRows = collect($data['existing_items'])->map(function ($item) use ($now, &$subtotal) {
|
|
$itemSubtotal = (int) ($item['unit_price'] * $item['quantity']);
|
|
$subtotal += $itemSubtotal;
|
|
|
|
return [
|
|
'purchase_id' => null,
|
|
'raw_material_price_id' => $item['raw_material_price_id'],
|
|
'user_id' => auth()->id(),
|
|
'quantity' => $item['quantity'],
|
|
'unit_price' => $item['unit_price'],
|
|
'subtotal' => $itemSubtotal,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
];
|
|
})->toArray();
|
|
|
|
foreach ($data['existing_items'] as $item) {
|
|
RawMaterialPrice::whereKey($item['raw_material_price_id'])
|
|
->increment('stock', (int) $item['quantity']);
|
|
}
|
|
} else {
|
|
// 2a. Always reuse the purchase's existing material in place;
|
|
// a fresh material is only created when the purchase has
|
|
// no items yet.
|
|
if ($oldMaterial) {
|
|
$rawMaterial = $oldMaterial;
|
|
$rawMaterial->update([
|
|
'name' => $data['name'],
|
|
'is_active' => true,
|
|
]);
|
|
} else {
|
|
$rawMaterial = RawMaterial::create([
|
|
'name' => $data['name'],
|
|
'unit' => $data['unit'] ?? 'kg',
|
|
'is_active' => true,
|
|
]);
|
|
}
|
|
|
|
// 2b. Adjust the stock of existing variant prices, create
|
|
// prices for new variants, but never delete variants.
|
|
$itemRows = collect($data['variants'])->map(function ($v) use ($purchase, $rawMaterial, $now, &$subtotal) {
|
|
$price = null;
|
|
|
|
if (! empty($v['id'])) {
|
|
$price = RawMaterialPrice::withTrashed()->find($v['id']);
|
|
}
|
|
|
|
if (! $price) {
|
|
$price = $rawMaterial->rawMaterialPrices()
|
|
->where('variant', $v['variant'])
|
|
->first();
|
|
}
|
|
|
|
if ($price) {
|
|
$price->increment('stock', (int) $v['stock']);
|
|
$price->update(['price' => $v['price']]);
|
|
} else {
|
|
$price = $rawMaterial->rawMaterialPrices()->create([
|
|
'variant' => $v['variant'],
|
|
'price' => $v['price'],
|
|
'stock' => $v['stock'],
|
|
]);
|
|
}
|
|
|
|
if (! empty($v['photo_key']) && $price->getFirstMedia('photos')?->file_name !== $v['photo_key']) {
|
|
$this->registerMedia(
|
|
model: $price,
|
|
s3Key: $v['photo_key'],
|
|
collectionName: 'photos',
|
|
orderColumn: 1,
|
|
);
|
|
}
|
|
|
|
$itemSubtotal = (int) ($v['price'] * $v['stock']);
|
|
$subtotal += $itemSubtotal;
|
|
|
|
return [
|
|
'purchase_id' => $purchase->id,
|
|
'raw_material_price_id' => $price->id,
|
|
'user_id' => auth()->id(),
|
|
'quantity' => $v['stock'],
|
|
'unit_price' => $v['price'],
|
|
'subtotal' => $itemSubtotal,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
];
|
|
})->toArray();
|
|
}
|
|
|
|
$discount = $data['discount'] ?? 0;
|
|
$shippingCost = $data['shipping_cost'] ?? 0;
|
|
$total = $subtotal - $discount + $shippingCost;
|
|
|
|
$purchase->update([
|
|
'supplier_id' => $data['supplier_id'],
|
|
'subtotal' => $subtotal,
|
|
'discount' => $discount,
|
|
'shipping_cost' => $shippingCost,
|
|
'total' => $total,
|
|
'notes' => $data['notes'] ?? null,
|
|
]);
|
|
|
|
foreach ($itemRows as &$row) {
|
|
$row['purchase_id'] = $purchase->id;
|
|
}
|
|
DB::table('purchase_items')->insert($itemRows);
|
|
|
|
$this->syncPhoto($purchase, $data);
|
|
|
|
return $purchase;
|
|
});
|
|
}
|
|
|
|
public function delete(Purchase $purchase): bool
|
|
{
|
|
return DB::transaction(function () use ($purchase) {
|
|
$purchase->load('purchaseItems.rawMaterialPrice');
|
|
|
|
// Remove the stock the purchase added, keep the variants.
|
|
$purchase->purchaseItems->each(function (PurchaseItem $item) {
|
|
if ($item->rawMaterialPrice) {
|
|
$item->rawMaterialPrice->decrement('stock', (int) $item->quantity);
|
|
}
|
|
});
|
|
|
|
$purchase->clearMediaCollection('photos');
|
|
$purchase->purchaseItems()->delete();
|
|
$purchase->delete();
|
|
|
|
return true;
|
|
});
|
|
}
|
|
}
|