dstpabuaran.com/app/Services/Admin/Manage/PurchaseService.php
Yoga Pangestu 22c9a4cf2a feat: add notification highlight feature to various services and frontend components
- Updated paginated methods in multiple services to accept a highlight parameter for filtering results.
- Modified notification URLs to include the highlight parameter for specific entity IDs.
- Enhanced frontend components to display a message when filtered by notification, with an option to show all entries.
- Implemented mark as read functionality in the notification bell component upon clicking a notification.
- Updated multiple index pages to handle the highlight prop and display relevant messages.
2026-08-15 00:11:40 +07:00

521 lines
21 KiB
PHP

<?php
namespace App\Services\Admin\Manage;
use App\Enums\Role;
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\Collection;
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 = [], ?int $highlight = null): LengthAwarePaginator
{
$itemCountQuery = '(SELECT COUNT(*) FROM purchase_items WHERE purchase_items.purchase_id = purchases.id AND purchase_items.deleted_at IS NULL)';
$totalQtyQuery = '(SELECT IFNULL(SUM(quantity), 0) FROM purchase_items WHERE purchase_items.purchase_id = purchases.id AND purchase_items.deleted_at IS NULL)';
$materialNameQuery = '(SELECT raw_materials.name FROM purchase_items JOIN raw_material_prices ON raw_material_prices.id = purchase_items.raw_material_price_id JOIN raw_materials ON raw_materials.id = raw_material_prices.raw_material_id WHERE purchase_items.purchase_id = purchases.id AND purchase_items.deleted_at IS NULL LIMIT 1)';
$unitQuery = '(SELECT raw_materials.unit FROM purchase_items JOIN raw_material_prices ON raw_material_prices.id = purchase_items.raw_material_price_id JOIN raw_materials ON raw_materials.id = raw_material_prices.raw_material_id WHERE purchase_items.purchase_id = purchases.id AND purchase_items.deleted_at IS NULL LIMIT 1)';
$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',
])
->selectRaw("{$itemCountQuery} as variants_count")
->selectRaw("{$totalQtyQuery} as total_qty")
->selectRaw("{$materialNameQuery} as material_name")
->selectRaw("{$unitQuery} as unit")
->when($highlight, fn ($q) => $q->where('id', $highlight))
->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->getMedia('photos');
$purchase->photo_urls = $purchaseMedia->map(
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath())
)->toArray();
$purchase->photo_conversion_urls = $purchaseMedia->map(
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
)->toArray();
});
return $paginator;
}
public function getItems(Purchase $purchase): Collection
{
return $purchase->purchaseItems()
->select(['id', 'purchase_id', 'raw_material_price_id', 'quantity', 'unit_price', 'subtotal'])
->with(['rawMaterialPrice:id,raw_material_id,variant,price,stock', 'rawMaterialPrice.rawMaterial:id,name,unit'])
->orderByRaw('(SELECT variant FROM raw_material_prices WHERE raw_material_prices.id = purchase_items.raw_material_price_id)')
->get()
->each(function (PurchaseItem $item) {
if (! $item->rawMaterialPrice) {
return;
}
$media = $item->rawMaterialPrice->getFirstMedia('images');
$item->rawMaterialPrice->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
$item->rawMaterialPrice->photo_conversion_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: null;
});
}
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(),
];
}
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('images');
return [
'id' => $item->rawMaterialPrice->id,
'variant' => $item->rawMaterialPrice->variant,
'price' => $item->unit_price,
'stock' => $item->quantity,
'photo_key' => $media?->getCustomProperty('s3_key') ?? $media?->file_name,
'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : 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->getMedia('photos');
$purchasePhotoKeys = $purchaseMedia->map(
fn ($media) => $media->getCustomProperty('s3_key') ?? $media->file_name
)->toArray();
$purchasePhotoUrls = $purchaseMedia->map(
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath())
)->toArray();
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_keys' => $purchasePhotoKeys,
'photo_urls' => $purchasePhotoUrls,
'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 store(array $data): Purchase
{
if (($data['mode'] ?? 'new') === 'existing') {
return $this->storeFromExisting($data);
}
return $this->storeNew($data);
}
private function storeFromExisting(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);
$stockIncrementMap = collect($data['existing_items'])
->groupBy('raw_material_price_id')
->map(fn ($group) => $group->sum('quantity'));
foreach ($stockIncrementMap as $priceId => $totalQty) {
RawMaterialPrice::where('id', $priceId)->increment('stock', $totalQty);
}
if (! empty($data['photo_keys']) && is_array($data['photo_keys'])) {
$this->registerPhotos(
model: $purchase,
photoKeys: $data['photo_keys'],
collectionName: 'photos',
);
}
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::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', ['highlight' => $purchase->id]),
);
return $purchase;
});
}
private function storeNew(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);
$priceIdMap = DB::table('raw_material_prices')
->where('raw_material_id', $rawMaterial->id)
->pluck('id', 'variant')
->toArray();
foreach ($data['variants'] as $variantData) {
if (! empty($variantData['photo_key'])) {
$priceId = $priceIdMap[$variantData['variant']] ?? null;
if ($priceId) {
$priceModel = RawMaterialPrice::find($priceId);
$this->registerMedia(
model: $priceModel,
s3Key: $variantData['photo_key'],
collectionName: 'images',
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 = collect($data['variants'])->map(function ($v) use ($purchase, $priceIdMap, $now) {
$priceId = $priceIdMap[$v['variant']] ?? null;
return [
'purchase_id' => $purchase->id,
'raw_material_price_id' => $priceId,
'user_id' => auth()->id(),
'quantity' => $v['stock'],
'unit_price' => $v['price'],
'subtotal' => (int) ($v['price'] * $v['stock']),
'created_at' => $now,
'updated_at' => $now,
];
})->toArray();
DB::table('purchase_items')->insert($purchaseItems);
if (! empty($data['photo_keys']) && is_array($data['photo_keys'])) {
$this->registerPhotos(
model: $purchase,
photoKeys: $data['photo_keys'],
collectionName: 'photos',
);
}
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::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', ['highlight' => $purchase->id]),
);
return $purchase;
});
}
public function update(Purchase $purchase, array $data): Purchase
{
$purchase = DB::transaction(function () use ($purchase, $data) {
$oldStockMap = $purchase->purchaseItems()
->select('raw_material_price_id', 'quantity')
->get()
->groupBy('raw_material_price_id')
->map(fn ($group) => $group->sum('quantity'));
foreach ($oldStockMap as $priceId => $totalQty) {
RawMaterialPrice::where('id', $priceId)->decrement('stock', $totalQty);
}
$oldMaterial = $purchase->purchaseItems()
->join('raw_material_prices', 'raw_material_prices.id', '=', 'purchase_items.raw_material_price_id')
->join('raw_materials', 'raw_materials.id', '=', 'raw_material_prices.raw_material_id')
->select('raw_materials.*')
->first();
$purchase->purchaseItems()->delete();
$now = now();
$subtotal = 0;
if (($data['mode'] ?? 'new') === 'existing') {
$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();
$stockIncrementMap = collect($data['existing_items'])
->groupBy('raw_material_price_id')
->map(fn ($group) => $group->sum('quantity'));
foreach ($stockIncrementMap as $priceId => $totalQty) {
RawMaterialPrice::where('id', $priceId)->increment('stock', $totalQty);
}
} else {
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,
]);
}
$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('images')?->getCustomProperty('s3_key') !== $v['photo_key']) {
$price->clearMediaCollection('images');
$this->registerMedia(
model: $price,
s3Key: $v['photo_key'],
collectionName: 'images',
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->syncPhotos($purchase, $data['photo_keys'] ?? [], 'photos');
return $purchase;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Belanja Diperbarui',
body: 'Belanja bahan baku sebesar '.$purchase->formatted_total.' berhasil diperbarui oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.purchases.index', ['highlight' => $purchase->id]),
);
return $purchase;
}
public function destroy(Purchase $purchase): bool
{
$result = DB::transaction(function () use ($purchase) {
$stockDecrementMap = $purchase->purchaseItems()
->select('raw_material_price_id', 'quantity')
->get()
->groupBy('raw_material_price_id')
->map(fn ($group) => $group->sum('quantity'));
foreach ($stockDecrementMap as $priceId => $totalQty) {
RawMaterialPrice::where('id', $priceId)->decrement('stock', $totalQty);
}
$purchase->purchaseItems()->delete();
$purchase->delete();
return true;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Belanja Dihapus',
body: 'Belanja bahan baku berhasil dihapus oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.purchases.index'),
);
return $result;
}
}