dstpabuaran.com/app/Services/Admin/Manage/StokOpnameService.php
Yoga Pangestu 05707aad7d feat: add stok opname management functionality
- 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.
2026-08-16 19:38:36 +07:00

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.',
]);
}
}
}