with(['createdBy.profile', 'verifiedBy.profile', 'items.productVariant.product']) ->withCount(['items', 'items as items_with_difference_count' => function (Builder $query): void { $query->where('difference', '!=', 0); }]) ->when(! $user->can(Permission::STOK_OPNAMES_SUBMIT), function (Builder $query): void { $query->where('status', '!=', StokOpnameStatus::DRAFT); }) ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { $search = $tableQuery['search']; $query->where(function (Builder $query) use ($search): void { $query->where('notes', 'like', "%{$search}%") ->orWhereHas('createdBy.profile', function (Builder $query) use ($search): void { $query->where('full_name', 'like', "%{$search}%"); }); }); }); $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']); return $query ->paginate(25) ->withQueryString(); } public function catalogItems(): array { return Product::query() ->active() ->with([ 'variants' => fn ($query) => $query ->select('id', 'product_id', 'name', 'stock', 'retail_stock', 'reject_stock') ->orderBy('created_at'), ]) ->orderBy('name') ->get() ->map(fn (Product $product) => [ 'id' => $product->id, 'name' => $product->name, 'variants' => $product->variants->map(fn ($variant) => [ 'id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock, 'stock_formatted' => $variant->stock_formatted, 'retail_stock' => $variant->retail_stock, 'retail_stock_formatted' => $variant->retail_stock_formatted, 'reject_stock' => $variant->reject_stock, 'reject_stock_formatted' => $variant->reject_stock_formatted, ]), ]) ->toArray(); } public function findForEdit(StokOpname $stokOpname): array { $stokOpname->load(['items.productVariant.product', 'createdBy.profile']); return [ 'id' => $stokOpname->id, 'opname_date' => $stokOpname->opname_date->format('Y-m-d'), 'status' => $stokOpname->status->value, 'status_label' => $stokOpname->status_label, 'notes' => $stokOpname->notes, 'verification_notes' => $stokOpname->verification_notes, 'created_by_name' => $stokOpname->created_by_name, 'created_at_formatted' => $stokOpname->created_at_formatted, 'items' => $stokOpname->items->map(fn ($item) => [ 'id' => $item->id, 'product_variant_id' => $item->product_variant_id, 'variant_name' => $item->productVariant->name, 'product_name' => $item->productVariant->product->name, 'stock_quality' => $item->stock_quality->value, 'system_stock' => $item->system_stock, 'physical_stock' => $item->physical_stock, 'difference' => $item->difference, 'notes' => $item->notes, ]), ]; } public function create(array $validated, User $user): StokOpname { try { return DB::transaction(function () use ($validated, $user): StokOpname { $stokOpname = StokOpname::create([ 'opname_date' => $validated['opname_date'], 'notes' => $validated['notes'], 'status' => StokOpnameStatus::DRAFT, 'created_by_id' => $user->id, ]); $this->syncItems($stokOpname, $validated['items'] ?? []); return $stokOpname; }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal membuat stok opname: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } } public function update(StokOpname $stokOpname, array $validated): void { if ($stokOpname->status !== StokOpnameStatus::DRAFT && $stokOpname->status !== StokOpnameStatus::REJECTED) { throw ValidationException::withMessages([ 'status' => 'Hanya stok opname dengan status draft atau ditolak yang dapat diubah.', ]); } try { DB::transaction(function () use ($stokOpname, $validated): void { $stokOpname->update([ 'opname_date' => $validated['opname_date'], 'notes' => $validated['notes'], 'status' => StokOpnameStatus::DRAFT, ]); $this->syncItems($stokOpname, $validated['items'] ?? []); }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal memperbarui stok opname: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } } public function delete(StokOpname $stokOpname): void { if ($stokOpname->status !== StokOpnameStatus::DRAFT && $stokOpname->status !== StokOpnameStatus::REJECTED) { throw ValidationException::withMessages([ 'status' => 'Hanya stok opname dengan status draft atau ditolak yang dapat dihapus.', ]); } try { DB::transaction(function () use ($stokOpname): void { $stokOpname->items()->delete(); $stokOpname->delete(); }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal menghapus stok opname: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } } public function submit(StokOpname $stokOpname, User $user): void { if ($stokOpname->status !== StokOpnameStatus::DRAFT && $stokOpname->status !== StokOpnameStatus::REJECTED) { throw ValidationException::withMessages([ 'status' => 'Hanya stok opname dengan status draft atau ditolak yang dapat diajukan.', ]); } if ($stokOpname->items()->count() === 0) { throw ValidationException::withMessages([ 'items' => 'Stok opname harus memiliki minimal 1 item.', ]); } try { DB::transaction(function () use ($stokOpname): void { $stokOpname->update([ 'status' => StokOpnameStatus::PENDING, ]); }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal mengajukan stok opname: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $this->pushNotificationService->sendToRoles( '📋 Stok Opname Menunggu Verifikasi', "Stok opname tanggal {$stokOpname->opname_date->format('d/m/Y')} oleh {$stokOpname->created_by_name} menunggu verifikasi.", ['owner', 'developer', 'admin-toko'], route('admin.manage.stok-opnames.index'), ); } public function verify(StokOpname $stokOpname, User $user, ?string $verificationNotes = null): void { if ($stokOpname->status !== StokOpnameStatus::PENDING) { throw ValidationException::withMessages([ 'status' => 'Hanya stok opname yang menunggu verifikasi yang dapat diverifikasi.', ]); } try { DB::transaction(function () use ($stokOpname, $user, $verificationNotes): void { foreach ($stokOpname->items as $item) { if ($item->difference !== 0) { $column = match ($item->stock_quality) { \App\Enums\ProductStockQuality::GOOD => 'stock', \App\Enums\ProductStockQuality::RETAIL => 'retail_stock', \App\Enums\ProductStockQuality::REJECT => 'reject_stock', }; $item->productVariant()->update([ $column => $item->physical_stock, ]); } } $stokOpname->update([ 'status' => StokOpnameStatus::VERIFIED, 'verified_by_id' => $user->id, 'verification_notes' => $verificationNotes, ]); }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal memverifikasi stok opname: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $this->pushNotificationService->sendToUser( '✅ Stok Opname Terverifikasi', "Stok opname tanggal {$stokOpname->opname_date->format('d/m/Y')} telah diverifikasi dan stok telah disesuaikan.", $stokOpname->created_by_id, route('admin.manage.stok-opnames.index'), ); } public function reject(StokOpname $stokOpname, User $user, string $reason): void { if ($stokOpname->status !== StokOpnameStatus::PENDING) { throw ValidationException::withMessages([ 'status' => 'Hanya stok opname yang menunggu verifikasi yang dapat ditolak.', ]); } try { DB::transaction(function () use ($stokOpname, $user, $reason): void { $stokOpname->update([ 'status' => StokOpnameStatus::REJECTED, 'verified_by_id' => $user->id, 'verification_notes' => $reason, ]); }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal menolak stok opname: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $this->pushNotificationService->sendToUser( '❌ Stok Opname Ditolak', "Stok opname tanggal {$stokOpname->opname_date->format('d/m/Y')} ditolak dengan alasan: {$reason}", $stokOpname->created_by_id, route('admin.manage.stok-opnames.index'), ); } public function autoSave(array $validated, User $user): StokOpname { try { return DB::transaction(function () use ($validated, $user): StokOpname { $stokOpname = null; if (! empty($validated['stok_opname_id'])) { $stokOpname = StokOpname::find($validated['stok_opname_id']); } if ($stokOpname && in_array($stokOpname->status, [StokOpnameStatus::DRAFT, StokOpnameStatus::REJECTED], true)) { $stokOpname->update([ 'opname_date' => $validated['opname_date'], 'notes' => $validated['notes'], 'status' => StokOpnameStatus::DRAFT, ]); } else { $stokOpname = StokOpname::create([ 'opname_date' => $validated['opname_date'], 'notes' => $validated['notes'], 'status' => StokOpnameStatus::DRAFT, 'created_by_id' => $user->id, ]); } $this->syncItems($stokOpname, $validated['items'] ?? []); return $stokOpname; }); } catch (\Throwable $e) { Log::error('Gagal auto-save stok opname: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw $e; } } private function syncItems(StokOpname $stokOpname, array $items): void { $stokOpname->items()->delete(); foreach ($items as $item) { $variant = ProductVariant::find($item['product_variant_id']); if (!$variant) { continue; } $quality = \App\Enums\ProductStockQuality::from($item['stock_quality']); $column = match ($quality) { \App\Enums\ProductStockQuality::GOOD => 'stock', \App\Enums\ProductStockQuality::RETAIL => 'retail_stock', \App\Enums\ProductStockQuality::REJECT => 'reject_stock', }; $systemStock = $variant->$column ?? 0; $stokOpname->items()->create([ 'product_variant_id' => $item['product_variant_id'], 'stock_quality' => $quality->value, 'system_stock' => $systemStock, 'physical_stock' => $item['physical_stock'], 'difference' => $item['physical_stock'] - $systemStock, 'notes' => $item['notes'] ?? null, ]); } } private function applySorting(Builder $query, string $sort, string $direction): void { if (in_array($sort, ['opname_date', 'status', 'created_at'], true)) { $query->orderBy($sort, $direction); return; } $query->latest(); } }