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