- Implemented StokOpnameEdit component for editing stock opname entries. - Created StokOpnameIndex component for listing and managing stock opnames. - Added StokOpnameCardRow and StokOpnameItemSubRow components for displaying stock opname details. - Introduced routes for stok opname CRUD operations and actions (submit, verify, reject, cancel). - Integrated UI components for better user interaction and data presentation.
294 lines
12 KiB
PHP
294 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Admin\Manage;
|
|
|
|
use App\Enums\ProductStockQuality;
|
|
use App\Enums\Role;
|
|
use App\Enums\StokOpnameStatus;
|
|
use App\Models\ProductVariant;
|
|
use App\Models\StokOpname;
|
|
use App\Models\StokOpnameItem;
|
|
use App\Services\Concerns\HasStockAdjustment;
|
|
use App\Services\NotificationService;
|
|
use App\Services\S3PresignedService;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class StokOpnameService
|
|
{
|
|
use HasStockAdjustment;
|
|
|
|
public function __construct(
|
|
private S3PresignedService $s3Service,
|
|
) {}
|
|
|
|
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?int $highlight = null, ?string $status = null): LengthAwarePaginator
|
|
{
|
|
$itemsCountQuery = '(SELECT COUNT(DISTINCT product_variant_id) FROM stok_opname_items WHERE stok_opname_items.stok_opname_id = stok_opnames.id)';
|
|
$totalDifferenceQuery = '(SELECT IFNULL(SUM(difference), 0) FROM stok_opname_items WHERE stok_opname_items.stok_opname_id = stok_opnames.id)';
|
|
$productNamesQuery = '(SELECT GROUP_CONCAT(DISTINCT p.name ORDER BY p.name SEPARATOR \', \') FROM stok_opname_items soi JOIN product_variants pv ON pv.id = soi.product_variant_id JOIN products p ON p.id = pv.product_id WHERE soi.stok_opname_id = stok_opnames.id)';
|
|
|
|
return StokOpname::query()
|
|
->select(['id', 'created_by_id', 'verified_by_id', 'opname_date', 'status', 'notes', 'verification_notes', 'created_at'])
|
|
->with([
|
|
'createdBy:id',
|
|
'createdBy.userProfile:id,user_id,full_name',
|
|
'verifiedBy:id',
|
|
'verifiedBy.userProfile:id,user_id,full_name',
|
|
])
|
|
->selectRaw("{$itemsCountQuery} as items_count")
|
|
->selectRaw("{$totalDifferenceQuery} as total_difference")
|
|
->selectRaw("{$productNamesQuery} as product_names")
|
|
->when($highlight, fn ($q) => $q->where('id', $highlight))
|
|
->when($status, fn ($q) => $q->where('status', $status))
|
|
->when($search, function ($q) use ($search) {
|
|
$q->whereHas('stokOpnameItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
|
|
->orWhere('notes', 'like', "%{$search}%");
|
|
})
|
|
->orderBy($sort, $direction)
|
|
->paginate($perPage);
|
|
}
|
|
|
|
public function store(array $data): StokOpname
|
|
{
|
|
return DB::transaction(function () use ($data) {
|
|
$stokOpname = StokOpname::create([
|
|
'created_by_id' => auth()->id(),
|
|
'opname_date' => $data['opname_date'],
|
|
'status' => StokOpnameStatus::DRAFT,
|
|
'notes' => $data['notes'] ?? null,
|
|
]);
|
|
|
|
$this->createItems($stokOpname, $data['items']);
|
|
|
|
NotificationService::notify(
|
|
roles: [Role::OWNER, Role::DEVELOPER],
|
|
title: 'Stok Opname Baru',
|
|
body: 'Stok opname baru berhasil dibuat oleh '.auth()->user()->full_name.'.',
|
|
url: route('admin.manage.stok-opnames.index', ['highlight' => $stokOpname->id]),
|
|
);
|
|
|
|
return $stokOpname;
|
|
});
|
|
}
|
|
|
|
public function update(StokOpname $stokOpname, array $data): StokOpname
|
|
{
|
|
$this->assertDraft($stokOpname);
|
|
|
|
return DB::transaction(function () use ($stokOpname, $data) {
|
|
$stokOpname->stokOpnameItems()->delete();
|
|
|
|
$stokOpname->update([
|
|
'opname_date' => $data['opname_date'],
|
|
'notes' => $data['notes'] ?? null,
|
|
]);
|
|
|
|
$this->createItems($stokOpname, $data['items']);
|
|
|
|
return $stokOpname;
|
|
});
|
|
}
|
|
|
|
public function destroy(StokOpname $stokOpname): void
|
|
{
|
|
$this->assertDraft($stokOpname);
|
|
|
|
DB::transaction(function () use ($stokOpname) {
|
|
$stokOpname->stokOpnameItems()->delete();
|
|
$stokOpname->delete();
|
|
});
|
|
}
|
|
|
|
public function submit(StokOpname $stokOpname): StokOpname
|
|
{
|
|
$this->assertDraft($stokOpname);
|
|
|
|
$stokOpname->update(['status' => StokOpnameStatus::IN_PROGRESS]);
|
|
|
|
NotificationService::notify(
|
|
roles: [Role::OWNER, Role::DEVELOPER],
|
|
title: 'Stok Opname Disubmit',
|
|
body: 'Stok opname berhasil disubmit oleh '.auth()->user()->full_name.'.',
|
|
url: route('admin.manage.stok-opnames.index', ['highlight' => $stokOpname->id]),
|
|
);
|
|
|
|
return $stokOpname;
|
|
}
|
|
|
|
public function verify(StokOpname $stokOpname, array $data): StokOpname
|
|
{
|
|
$this->assertInProgress($stokOpname);
|
|
|
|
return DB::transaction(function () use ($stokOpname, $data) {
|
|
$stokOpname->load('stokOpnameItems');
|
|
|
|
foreach ($stokOpname->stokOpnameItems as $item) {
|
|
if ($item->difference != 0) {
|
|
$this->adjustVariantStock(
|
|
$item->product_variant_id,
|
|
abs($item->difference),
|
|
$item->difference > 0 ? 1 : -1,
|
|
$item->stock_quality->value,
|
|
);
|
|
}
|
|
}
|
|
|
|
$stokOpname->update([
|
|
'status' => StokOpnameStatus::VERIFIED,
|
|
'verified_by_id' => auth()->id(),
|
|
'verification_notes' => $data['verification_notes'] ?? null,
|
|
]);
|
|
|
|
NotificationService::notify(
|
|
roles: [Role::STOK_OPNAME, Role::ADMIN_TOKO],
|
|
title: 'Stok Opname Diverifikasi',
|
|
body: 'Stok opname berhasil diverifikasi oleh '.auth()->user()->full_name.'.',
|
|
url: route('admin.manage.stok-opnames.index', ['highlight' => $stokOpname->id]),
|
|
);
|
|
|
|
return $stokOpname;
|
|
});
|
|
}
|
|
|
|
public function reject(StokOpname $stokOpname): StokOpname
|
|
{
|
|
$this->assertInProgress($stokOpname);
|
|
|
|
$stokOpname->update([
|
|
'status' => StokOpnameStatus::DRAFT,
|
|
'verification_notes' => null,
|
|
]);
|
|
|
|
NotificationService::notify(
|
|
roles: [Role::STOK_OPNAME],
|
|
title: 'Stok Opname Ditolak',
|
|
body: 'Stok opname ditolak oleh '.auth()->user()->full_name.'. Silakan periksa dan submit ulang.',
|
|
url: route('admin.manage.stok-opnames.index', ['highlight' => $stokOpname->id]),
|
|
);
|
|
|
|
return $stokOpname;
|
|
}
|
|
|
|
public function cancel(StokOpname $stokOpname): StokOpname
|
|
{
|
|
if ($stokOpname->status === StokOpnameStatus::VERIFIED) {
|
|
throw ValidationException::withMessages([
|
|
'status' => 'Stok opname yang sudah diverifikasi tidak dapat dibatalkan.',
|
|
]);
|
|
}
|
|
|
|
$stokOpname->update(['status' => StokOpnameStatus::CANCELLED]);
|
|
|
|
return $stokOpname;
|
|
}
|
|
|
|
public function getForEdit(StokOpname $stokOpname): array
|
|
{
|
|
$stokOpname->load([
|
|
'stokOpnameItems' => fn ($q) => $q
|
|
->select(['id', 'stok_opname_id', 'product_variant_id', 'stock_quality', 'system_stock', 'physical_stock', 'difference', 'notes']),
|
|
'stokOpnameItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock',
|
|
'stokOpnameItems.productVariant.product:id,name',
|
|
]);
|
|
|
|
return [
|
|
'id' => $stokOpname->id,
|
|
'opname_date' => $stokOpname->opname_date->format('Y-m-d'),
|
|
'status' => $stokOpname->status->value,
|
|
'notes' => $stokOpname->notes,
|
|
'items' => $stokOpname->stokOpnameItems->map(fn ($item) => [
|
|
'id' => $item->id,
|
|
'product_variant_id' => $item->product_variant_id,
|
|
'stock_quality' => $item->stock_quality->value,
|
|
'system_stock' => $item->system_stock,
|
|
'physical_stock' => $item->physical_stock,
|
|
'difference' => $item->difference,
|
|
'notes' => $item->notes,
|
|
'variant_name' => $item->productVariant?->name,
|
|
'product_name' => $item->productVariant?->product?->name,
|
|
]),
|
|
];
|
|
}
|
|
|
|
public function getItems(StokOpname $stokOpname): \Illuminate\Support\Collection
|
|
{
|
|
return $stokOpname->stokOpnameItems()
|
|
->select(['id', 'stok_opname_id', 'product_variant_id', 'stock_quality', 'system_stock', 'physical_stock', 'difference', 'notes'])
|
|
->with(['productVariant:id,product_id,name', 'productVariant.product:id,name'])
|
|
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = stok_opname_items.product_variant_id)')
|
|
->get()
|
|
->map(function (StokOpnameItem $item) {
|
|
$media = $item->productVariant?->getFirstMedia('images');
|
|
$photoUrl = $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null;
|
|
$photoConversionUrl = $media
|
|
? ($media->getGeneratedConversions()->contains('thumb')
|
|
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
|
|
: $this->s3Service->getTemporaryUrl($media->getPath()))
|
|
: null;
|
|
|
|
return [
|
|
'id' => $item->id,
|
|
'product_variant_id' => $item->product_variant_id,
|
|
'stock_quality' => $item->stock_quality,
|
|
'system_stock' => $item->system_stock,
|
|
'physical_stock' => $item->physical_stock,
|
|
'difference' => $item->difference,
|
|
'notes' => $item->notes,
|
|
'variant_name' => $item->productVariant?->name,
|
|
'product_name' => $item->productVariant?->product?->name,
|
|
'photo_url' => $photoUrl,
|
|
'photo_conversion_url' => $photoConversionUrl,
|
|
];
|
|
});
|
|
}
|
|
|
|
private function createItems(StokOpname $stokOpname, array $items): void
|
|
{
|
|
$now = now();
|
|
|
|
$rows = collect($items)->map(function ($item) use ($stokOpname, $now) {
|
|
$variant = ProductVariant::findOrFail($item['product_variant_id']);
|
|
$stockQuality = ProductStockQuality::from($item['stock_quality']);
|
|
$systemStock = match ($stockQuality) {
|
|
ProductStockQuality::GOOD => $variant->stock,
|
|
ProductStockQuality::REJECT => $variant->reject_stock,
|
|
ProductStockQuality::RETAIL => $variant->retail_stock,
|
|
};
|
|
|
|
return [
|
|
'stok_opname_id' => $stokOpname->id,
|
|
'product_variant_id' => $item['product_variant_id'],
|
|
'stock_quality' => $stockQuality->value,
|
|
'system_stock' => $systemStock,
|
|
'physical_stock' => (int) $item['physical_stock'],
|
|
'difference' => (int) $item['physical_stock'] - $systemStock,
|
|
'notes' => $item['notes'] ?? null,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
];
|
|
})->toArray();
|
|
|
|
DB::table('stok_opname_items')->insert($rows);
|
|
}
|
|
|
|
private function assertDraft(StokOpname $stokOpname): void
|
|
{
|
|
if ($stokOpname->status !== StokOpnameStatus::DRAFT) {
|
|
throw ValidationException::withMessages([
|
|
'status' => 'Hanya stok opname dengan status draft yang dapat diubah.',
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function assertInProgress(StokOpname $stokOpname): void
|
|
{
|
|
if ($stokOpname->status !== StokOpnameStatus::IN_PROGRESS) {
|
|
throw ValidationException::withMessages([
|
|
'status' => 'Hanya stok opname dengan status dalam proses yang dapat diproses.',
|
|
]);
|
|
}
|
|
}
|
|
}
|