- Add RestockIndex component for displaying and managing restocks. - Create RestockCardRow component for rendering individual restock items. - Implement RestockItemSubRow component for displaying detailed item information. - Define routes for restock management in web.php. - Create RestockTest to cover various scenarios for restock creation, updating, and deletion. - Ensure proper handling of permissions for restock actions. - Add validation for restock data and ensure correct relationships are maintained.
277 lines
9.8 KiB
PHP
277 lines
9.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Admin\Manage;
|
|
|
|
use App\Enums\PriceType;
|
|
use App\Enums\ProductStockQuality;
|
|
use App\Models\Product;
|
|
use App\Models\ProductVariant;
|
|
use App\Models\Restock;
|
|
use App\Models\RestockItem;
|
|
use App\Services\Concerns\RegistersMedia;
|
|
use App\Services\NotificationService;
|
|
use App\Services\S3PresignedService;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class RestockService
|
|
{
|
|
use RegistersMedia;
|
|
|
|
private const QUALITY_STOCK_MAP = [
|
|
ProductStockQuality::GOOD->value => 'stock',
|
|
ProductStockQuality::REJECT->value => 'reject_stock',
|
|
];
|
|
|
|
public function __construct(
|
|
private S3PresignedService $s3Service = new S3PresignedService,
|
|
) {}
|
|
|
|
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
|
|
{
|
|
$paginator = Restock::query()
|
|
->select('id', 'created_by_id', 'subtotal', 'total', 'notes', 'stock_type', 'created_at')
|
|
->with([
|
|
'createdBy:id',
|
|
'createdBy.userProfile:id,user_id,full_name',
|
|
'restockItems:id,restock_id,product_variant_id,quantity,unit_price,subtotal',
|
|
'restockItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock',
|
|
'restockItems.productVariant.product:id,name',
|
|
])
|
|
->when($search, function ($q) use ($search) {
|
|
$q->whereHas('restockItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
|
|
->orWhere('notes', 'like', "%{$search}%");
|
|
})
|
|
->orderBy($sort, $direction)
|
|
->paginate($perPage);
|
|
|
|
$paginator->getCollection()->each(function (Restock $restock) {
|
|
$restock->restockItems->each(function (RestockItem $item) {
|
|
if (! $item->productVariant) {
|
|
return;
|
|
}
|
|
|
|
$media = $item->productVariant->getFirstMedia('photos');
|
|
$item->productVariant->photo_url = $media
|
|
? $this->s3Service->getTemporaryUrl($media->file_name)
|
|
: null;
|
|
});
|
|
});
|
|
|
|
return $paginator;
|
|
}
|
|
|
|
public function getForCreate(): array
|
|
{
|
|
return [
|
|
'products' => Product::query()
|
|
->select('id', 'name', 'status')
|
|
->with([
|
|
'productVariants:id,product_id,name,stock,reject_stock',
|
|
'productVariants.productPrices:id,variant_id,type,price',
|
|
])
|
|
->orderBy('name')
|
|
->get()
|
|
->each(function (Product $product) {
|
|
$product->productVariants->each(function (ProductVariant $variant) {
|
|
$media = $variant->getFirstMedia('photos');
|
|
$variant->photo_url = $media
|
|
? $this->s3Service->getTemporaryUrl($media->file_name)
|
|
: null;
|
|
|
|
$capitalPrice = $variant->productPrices
|
|
->first(fn ($price) => $price->type === PriceType::CAPITAL);
|
|
$variant->capital_price = $capitalPrice?->price ?? 0;
|
|
});
|
|
}),
|
|
];
|
|
}
|
|
|
|
public function getForEdit(Restock $restock): array
|
|
{
|
|
$restock->load('restockItems.productVariant.product');
|
|
|
|
$media = $restock->getFirstMedia('photos');
|
|
|
|
return [
|
|
'id' => $restock->id,
|
|
'stock_type' => $restock->stock_type->value,
|
|
'notes' => $restock->notes,
|
|
'photo_key' => $media?->file_name,
|
|
'photo_url' => $media
|
|
? $this->s3Service->getTemporaryUrl($media->file_name)
|
|
: null,
|
|
'items' => $restock->restockItems->map(fn (RestockItem $item) => [
|
|
'id' => $item->id,
|
|
'product_variant_id' => $item->product_variant_id,
|
|
'quantity' => $item->quantity,
|
|
'unit_price' => $item->unit_price,
|
|
])->values(),
|
|
];
|
|
}
|
|
|
|
public function create(array $data): Restock
|
|
{
|
|
return DB::transaction(function () use ($data) {
|
|
$now = now();
|
|
$subtotal = 0;
|
|
$stockType = $data['stock_type'] ?? ProductStockQuality::GOOD->value;
|
|
|
|
$itemRows = $this->buildItemRows($data['items'], $now, $subtotal);
|
|
|
|
$restock = Restock::create([
|
|
'created_by_id' => auth()->id(),
|
|
'subtotal' => $subtotal,
|
|
'total' => $subtotal,
|
|
'notes' => $data['notes'] ?? null,
|
|
'stock_type' => $stockType,
|
|
]);
|
|
|
|
foreach ($itemRows as &$row) {
|
|
$row['restock_id'] = $restock->id;
|
|
}
|
|
DB::table('restock_items')->insert($itemRows);
|
|
|
|
$this->applyStock($data['items'], $stockType, 1);
|
|
$this->syncPhoto($restock, $data);
|
|
|
|
NotificationService::notify(
|
|
roles: ['Owner', 'Developer', 'Admin Toko'],
|
|
title: 'Restock Baru',
|
|
body: 'Restock '.($stockType === ProductStockQuality::GOOD->value ? 'produk' : 'reject').' sebesar Rp '.number_format($subtotal, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
|
|
url: route('admin.manage.restocks.index'),
|
|
);
|
|
|
|
return $restock;
|
|
});
|
|
}
|
|
|
|
public function update(Restock $restock, array $data): Restock
|
|
{
|
|
return DB::transaction(function () use ($restock, $data) {
|
|
$restock->load('restockItems');
|
|
|
|
$restock->restockItems->each(function (RestockItem $item) use ($restock) {
|
|
$this->adjustVariantStock($item->product_variant_id, $item->quantity, -1, $restock->stock_type->value);
|
|
});
|
|
|
|
$restock->restockItems()->delete();
|
|
|
|
$now = now();
|
|
$subtotal = 0;
|
|
$stockType = $data['stock_type'] ?? $restock->stock_type->value;
|
|
|
|
$itemRows = $this->buildItemRows($data['items'], $now, $subtotal);
|
|
|
|
foreach ($itemRows as &$row) {
|
|
$row['restock_id'] = $restock->id;
|
|
}
|
|
DB::table('restock_items')->insert($itemRows);
|
|
|
|
$restock->update([
|
|
'subtotal' => $subtotal,
|
|
'total' => $subtotal,
|
|
'notes' => $data['notes'] ?? null,
|
|
'stock_type' => $stockType,
|
|
]);
|
|
|
|
$this->applyStock($data['items'], $stockType, 1);
|
|
$this->syncPhoto($restock, $data);
|
|
|
|
return $restock;
|
|
});
|
|
}
|
|
|
|
public function delete(Restock $restock): bool
|
|
{
|
|
return DB::transaction(function () use ($restock) {
|
|
$restock->load('restockItems');
|
|
|
|
$restock->restockItems->each(function (RestockItem $item) use ($restock) {
|
|
$this->adjustVariantStock($item->product_variant_id, $item->quantity, -1, $restock->stock_type->value);
|
|
});
|
|
|
|
$restock->restockItems()->delete();
|
|
$restock->clearMediaCollection('photos');
|
|
$restock->delete();
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
private function buildItemRows(array $items, $now, int &$subtotal): array
|
|
{
|
|
$variantIds = collect($items)->pluck('product_variant_id')->unique()->all();
|
|
$capitalPrices = ProductVariant::query()
|
|
->whereKey($variantIds)
|
|
->with('productPrices:id,variant_id,type,price')
|
|
->get()
|
|
->mapWithKeys(function (ProductVariant $variant) {
|
|
$capitalPrice = $variant->productPrices
|
|
->first(fn ($price) => $price->type === PriceType::CAPITAL);
|
|
|
|
return [$variant->id => $capitalPrice?->price ?? 0];
|
|
});
|
|
|
|
return collect($items)->map(function ($item) use ($now, $capitalPrices, &$subtotal) {
|
|
$quantity = (int) $item['quantity'];
|
|
$unitPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0);
|
|
$itemSubtotal = $unitPrice * $quantity;
|
|
$subtotal += $itemSubtotal;
|
|
|
|
return [
|
|
'restock_id' => null,
|
|
'user_id' => auth()->id(),
|
|
'product_variant_id' => $item['product_variant_id'],
|
|
'quantity' => $quantity,
|
|
'unit_price' => $unitPrice,
|
|
'subtotal' => $itemSubtotal,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
];
|
|
})->toArray();
|
|
}
|
|
|
|
private function applyStock(array $items, string $stockType, int $sign): void
|
|
{
|
|
foreach ($items as $item) {
|
|
$this->adjustVariantStock($item['product_variant_id'], $item['quantity'], $sign, $stockType);
|
|
}
|
|
}
|
|
|
|
private function adjustVariantStock(int $variantId, int $quantity, int $sign, string $stockType): void
|
|
{
|
|
$field = self::QUALITY_STOCK_MAP[$stockType] ?? 'stock';
|
|
|
|
if ($sign > 0) {
|
|
ProductVariant::whereKey($variantId)->increment($field, $quantity);
|
|
} else {
|
|
ProductVariant::whereKey($variantId)->decrement($field, $quantity);
|
|
}
|
|
}
|
|
|
|
private function syncPhoto(Restock $restock, array $data): void
|
|
{
|
|
if (! array_key_exists('photo_key', $data)) {
|
|
return;
|
|
}
|
|
|
|
$currentKey = $restock->getFirstMedia('photos')?->file_name;
|
|
|
|
if ($data['photo_key'] === $currentKey) {
|
|
return;
|
|
}
|
|
|
|
$restock->clearMediaCollection('photos');
|
|
|
|
if (! empty($data['photo_key'])) {
|
|
$this->registerMedia(
|
|
model: $restock,
|
|
s3Key: $data['photo_key'],
|
|
collectionName: 'photos',
|
|
orderColumn: 1,
|
|
);
|
|
}
|
|
}
|
|
}
|