diff --git a/app/Http/Requests/Admin/Manage/StokOpnameAutoSaveRequest.php b/app/Http/Requests/Admin/Manage/StokOpnameAutoSaveRequest.php index dec0f09..56f64ad 100644 --- a/app/Http/Requests/Admin/Manage/StokOpnameAutoSaveRequest.php +++ b/app/Http/Requests/Admin/Manage/StokOpnameAutoSaveRequest.php @@ -3,7 +3,9 @@ namespace App\Http\Requests\Admin\Manage; use App\Enums\Permission; +use App\Enums\ProductStockQuality; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class StokOpnameAutoSaveRequest extends FormRequest { @@ -25,7 +27,7 @@ public function rules(): array 'notes' => ['nullable', 'string', 'max:1000'], 'items' => ['nullable', 'array'], 'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'], - 'items.*.stock_quality' => ['required', \Illuminate\Validation\Rule::enum(\App\Enums\ProductStockQuality::class)], + 'items.*.stock_quality' => ['required', Rule::enum(ProductStockQuality::class)], 'items.*.physical_stock' => ['nullable', 'integer', 'min:0'], 'items.*.notes' => ['nullable', 'string', 'max:500'], ]; diff --git a/app/Http/Requests/Admin/Manage/StokOpnameRequest.php b/app/Http/Requests/Admin/Manage/StokOpnameRequest.php index 69b4395..461bf10 100644 --- a/app/Http/Requests/Admin/Manage/StokOpnameRequest.php +++ b/app/Http/Requests/Admin/Manage/StokOpnameRequest.php @@ -3,7 +3,9 @@ namespace App\Http\Requests\Admin\Manage; use App\Enums\Permission; +use App\Enums\ProductStockQuality; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class StokOpnameRequest extends FormRequest { @@ -23,7 +25,7 @@ public function rules(): array 'notes' => ['nullable', 'string', 'max:1000'], 'items' => ['required', 'array', 'min:1'], 'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'], - 'items.*.stock_quality' => ['required', \Illuminate\Validation\Rule::enum(\App\Enums\ProductStockQuality::class)], + 'items.*.stock_quality' => ['required', Rule::enum(ProductStockQuality::class)], 'items.*.physical_stock' => ['required', 'integer', 'min:0'], 'items.*.notes' => ['nullable', 'string', 'max:500'], ]; diff --git a/app/Models/StokOpnameItem.php b/app/Models/StokOpnameItem.php index 6cedea3..bbec05c 100644 --- a/app/Models/StokOpnameItem.php +++ b/app/Models/StokOpnameItem.php @@ -35,8 +35,9 @@ public function getProductNameAttribute(): string public function getVariantNameAttribute(): string { - $qualityLabel = $this->stock_quality ? ' (' . $this->stock_quality->label() . ')' : ''; - return $this->productVariant->name . $qualityLabel; + $qualityLabel = $this->stock_quality ? ' ('.$this->stock_quality->label().')' : ''; + + return $this->productVariant->name.$qualityLabel; } // 3. Relation diff --git a/app/Services/Concerns/CachesQuery.php b/app/Services/Concerns/CachesQuery.php new file mode 100644 index 0000000..77c91bf --- /dev/null +++ b/app/Services/Concerns/CachesQuery.php @@ -0,0 +1,49 @@ +scan($cursor ?? 0, ['match' => $fullPattern, 'count' => 100]); + + if (! empty($keys)) { + $redis->del($keys); + } + } while ($cursor); + } +} diff --git a/app/Services/Finance/CashService.php b/app/Services/Finance/CashService.php index 4f83395..02400ac 100644 --- a/app/Services/Finance/CashService.php +++ b/app/Services/Finance/CashService.php @@ -6,6 +6,7 @@ use App\Models\CashAccount; use App\Models\CashTransaction; use App\Models\User; +use App\Services\Concerns\CachesQuery; use App\Services\Concerns\RunsInTransaction; use App\Services\Concerns\SyncsPhotos; use App\Services\Media\MediaService; @@ -18,7 +19,7 @@ class CashService { - use RunsInTransaction, SyncsPhotos; + use CachesQuery, RunsInTransaction, SyncsPhotos; private const MAX_PHOTOS = 1; @@ -29,7 +30,9 @@ public function __construct( public function getDefaultAccount(): CashAccount { - return CashAccount::query()->firstOrFail(); + return $this->cacheRemember('finance:default_account', 86400, function (): CashAccount { + return CashAccount::query()->firstOrFail(); + }); } public function paginateForIndex(CashAccount $cashAccount, array $tableQuery, string $referenceType = ''): LengthAwarePaginator diff --git a/app/Services/Finance/PayrollService.php b/app/Services/Finance/PayrollService.php index 79e43a4..f5e3230 100644 --- a/app/Services/Finance/PayrollService.php +++ b/app/Services/Finance/PayrollService.php @@ -13,6 +13,7 @@ use App\Models\PayrollAdjustment; use App\Models\PayrollPeriod; use App\Models\User; +use App\Services\Concerns\CachesQuery; use App\Services\Concerns\RunsInTransaction; use App\Services\System\PushNotificationService; use Carbon\Carbon; @@ -22,7 +23,7 @@ class PayrollService { - use RunsInTransaction; + use CachesQuery, RunsInTransaction; public function __construct( private readonly CashService $cashService, @@ -31,10 +32,12 @@ public function __construct( public function listPeriods(): Collection { - return PayrollPeriod::query() - ->orderByDesc('year') - ->orderByDesc('month') - ->get(); + return $this->cacheRemember('payroll:periods', 3600, function (): Collection { + return PayrollPeriod::query() + ->orderByDesc('year') + ->orderByDesc('month') + ->get(); + }); } public function resolvePeriod(?int $periodId): ?PayrollPeriod @@ -43,15 +46,17 @@ public function resolvePeriod(?int $periodId): ?PayrollPeriod return PayrollPeriod::query()->find($periodId); } - return PayrollPeriod::query() - ->where('status', PayrollPeriodStatus::OPEN) - ->orderByDesc('year') - ->orderByDesc('month') - ->first() - ?? PayrollPeriod::query() + return $this->cacheRemember('payroll:current_period', 3600, function (): ?PayrollPeriod { + return PayrollPeriod::query() + ->where('status', PayrollPeriodStatus::OPEN) ->orderByDesc('year') ->orderByDesc('month') - ->first(); + ->first() + ?? PayrollPeriod::query() + ->orderByDesc('year') + ->orderByDesc('month') + ->first(); + }); } public function periodSummary(PayrollPeriod $period, User $user): array @@ -161,6 +166,8 @@ function () use ($user): PayrollPeriod { 'Gagal membuka periode payroll', ); + $this->cacheForgetByPattern('payroll:*'); + return $period; } @@ -212,6 +219,8 @@ function () use ($payroll, $validated, $user): void { 'Gagal menambahkan penyesuaian gaji', ); + $this->cacheForgetByPattern('payroll:*'); + if ($payroll->employee?->user_id) { $typeLabel = PayrollAdjustmentType::from($validated['type'])->label(); $formattedAmount = 'Rp '.number_format($validated['amount'], 0, ',', '.'); @@ -243,6 +252,8 @@ function () use ($payroll, $adjustment, $validated): void { 'Gagal memperbarui penyesuaian gaji', ); + $this->cacheForgetByPattern('payroll:*'); + if ($payroll->employee?->user_id) { $typeLabel = PayrollAdjustmentType::from($validated['type'])->label(); $formattedAmount = 'Rp '.number_format($validated['amount'], 0, ',', '.'); @@ -271,6 +282,8 @@ function () use ($payroll, $adjustment): void { 'Gagal menghapus penyesuaian gaji', ); + $this->cacheForgetByPattern('payroll:*'); + if ($payroll->employee?->user_id) { $this->pushNotificationService->sendToUser( 'πŸ“Š Penyesuaian Gaji Dihapus', @@ -298,6 +311,8 @@ function () use ($payroll, $user): void { 'Gagal membayar gaji (total 0)', ); + $this->cacheForgetByPattern('payroll:*'); + return; } @@ -327,6 +342,8 @@ function () use ($payroll, $user): void { 'Gagal membayar gaji', ); + $this->cacheForgetByPattern('payroll:*'); + if ($payroll->employee?->user_id) { $this->pushNotificationService->sendToUser( 'πŸ’Έ Gaji Dibayarkan', diff --git a/app/Services/Hr/EmployeeService.php b/app/Services/Hr/EmployeeService.php index 25f6db7..f5d7248 100644 --- a/app/Services/Hr/EmployeeService.php +++ b/app/Services/Hr/EmployeeService.php @@ -6,6 +6,7 @@ use App\Models\Employee; use App\Models\User; use App\Models\UserProfile; +use App\Services\Concerns\CachesQuery; use App\Services\Concerns\RunsInTransaction; use App\Services\Concerns\SyncsPhotos; use App\Services\Media\MediaService; @@ -17,7 +18,7 @@ class EmployeeService { - use RunsInTransaction, SyncsPhotos; + use CachesQuery, RunsInTransaction, SyncsPhotos; public function __construct( private readonly MediaService $mediaService, @@ -60,7 +61,9 @@ public function paginateForIndex( public function assignableRoleOptions(): array { - return Role::assignableSelectOptions(); + return $this->cacheRemember('system:assignable_roles', 86400, function (): array { + return Role::assignableSelectOptions(); + }); } public function findForEdit(User $user): array @@ -109,6 +112,8 @@ function () use ($validated): void { }, 'Gagal membuat karyawan', ); + + $this->cacheForgetByPattern('hr:employees:*'); } public function update(User $user, array $validated): void @@ -162,6 +167,8 @@ function () use ($validated, $user, $employee): void { }, 'Gagal memperbarui karyawan', ); + + $this->cacheForgetByPattern('hr:employees:*'); } public function toggleStatus(User $user, array $validated): void @@ -178,6 +185,8 @@ function () use ($user, $validated): void { }, 'Gagal memperbarui status karyawan', ); + + $this->cacheForgetByPattern('hr:employees:*'); } public function resetPassword(User $user): void @@ -192,6 +201,8 @@ function () use ($user): void { }, 'Gagal mereset kata sandi karyawan', ); + + $this->cacheForgetByPattern('hr:employees:*'); } public function delete(User $user): void @@ -204,6 +215,8 @@ function () use ($user): void { }, 'Gagal menghapus karyawan', ); + + $this->cacheForgetByPattern('hr:employees:*'); } private function syncProfilePhoto(UserProfile $profile, array $validated): void diff --git a/app/Services/Manage/CuttingResultPriceResolver.php b/app/Services/Manage/CuttingResultPriceResolver.php index 984ce39..0fb84cf 100644 --- a/app/Services/Manage/CuttingResultPriceResolver.php +++ b/app/Services/Manage/CuttingResultPriceResolver.php @@ -5,10 +5,13 @@ use App\Enums\PriceType; use App\Models\CuttingResultPrice; use App\Models\ProductPrice; +use App\Services\Concerns\CachesQuery; use Illuminate\Support\Collection; class CuttingResultPriceResolver { + use CachesQuery; + public function resolve(int $productVariantId, PriceType $priceType): ?CuttingResultPrice { $price = CuttingResultPrice::query() @@ -43,57 +46,61 @@ public function resolve(int $productVariantId, PriceType $priceType): ?CuttingRe public function latestPricesForVariant(int $productVariantId): array { - $prices = []; + return $this->cacheRemember("prices:variant:{$productVariantId}", 900, function () use ($productVariantId) { + $prices = []; - foreach (PriceType::cases() as $priceType) { - $price = $this->resolve($productVariantId, $priceType); + foreach (PriceType::cases() as $priceType) { + $price = $this->resolve($productVariantId, $priceType); - if ($price !== null) { - $prices[] = $price; + if ($price !== null) { + $prices[] = $price; + } } - } - return $prices; + return $prices; + }); } public function latestPricesForVariants(array $variantIds): Collection { - if (empty($variantIds)) { - return collect(); - } + return $this->cacheRemember('prices:variants:'.md5(implode(',', $variantIds)), 900, function () use ($variantIds) { + if (empty($variantIds)) { + return collect(); + } - $cuttingPrices = CuttingResultPrice::query() - ->whereIn('product_variant_id', $variantIds) - ->whereHas('cutting', fn ($query) => $query->verified()) - ->join('cuttings', 'cutting_result_prices.cutting_id', '=', 'cuttings.id') - ->orderByDesc('cuttings.created_at') - ->select('cutting_result_prices.*') - ->get() - ->groupBy(fn (CuttingResultPrice $price) => $price->product_variant_id.'-'.$price->price_type->value) - ->map(fn (Collection $group) => $group->first()); + $cuttingPrices = CuttingResultPrice::query() + ->whereIn('product_variant_id', $variantIds) + ->whereHas('cutting', fn ($query) => $query->verified()) + ->join('cuttings', 'cutting_result_prices.cutting_id', '=', 'cuttings.id') + ->orderByDesc('cuttings.created_at') + ->select('cutting_result_prices.*') + ->get() + ->groupBy(fn (CuttingResultPrice $price) => $price->product_variant_id.'-'.$price->price_type->value) + ->map(fn (Collection $group) => $group->first()); - $productPrices = ProductPrice::query() - ->whereIn('variant_id', $variantIds) - ->get() - ->groupBy(fn (ProductPrice $price) => $price->variant_id.'-'.$price->type->value); + $productPrices = ProductPrice::query() + ->whereIn('variant_id', $variantIds) + ->get() + ->groupBy(fn (ProductPrice $price) => $price->variant_id.'-'.$price->type->value); - $results = collect(); - foreach ($variantIds as $variantId) { - foreach (PriceType::cases() as $priceType) { - $key = $variantId.'-'.$priceType->value; - if ($cuttingPrices->has($key)) { - $results->push($cuttingPrices->get($key)); - } elseif ($productPrices->has($key)) { - $pp = $productPrices->get($key)->first(); - $cp = new CuttingResultPrice; - $cp->product_variant_id = $variantId; - $cp->price_type = $priceType; - $cp->price = $pp->price; - $results->push($cp); + $results = collect(); + foreach ($variantIds as $variantId) { + foreach (PriceType::cases() as $priceType) { + $key = $variantId.'-'.$priceType->value; + if ($cuttingPrices->has($key)) { + $results->push($cuttingPrices->get($key)); + } elseif ($productPrices->has($key)) { + $pp = $productPrices->get($key)->first(); + $cp = new CuttingResultPrice; + $cp->product_variant_id = $variantId; + $cp->price_type = $priceType; + $cp->price = $pp->price; + $results->push($cp); + } } } - } - return $results->groupBy('product_variant_id'); + return $results->groupBy('product_variant_id'); + }); } } diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php index bf55a54..ce759e3 100644 --- a/app/Services/Manage/CuttingService.php +++ b/app/Services/Manage/CuttingService.php @@ -15,6 +15,7 @@ use App\Models\RawMaterial; use App\Models\RawMaterialPrice; use App\Models\User; +use App\Services\Concerns\CachesQuery; use App\Services\Concerns\RunsInTransaction; use App\Services\Media\MediaService; use App\Services\System\PushNotificationService; @@ -26,7 +27,7 @@ class CuttingService { - use RunsInTransaction; + use CachesQuery, RunsInTransaction; public function __construct( private readonly PushNotificationService $pushNotificationService, @@ -555,6 +556,8 @@ function () use ($validated, $user): Cutting { route('admin.manage.cuttings.index'), ); + $this->cacheForgetByPattern('manage:cuttings:*'); + return $cutting; } @@ -650,6 +653,8 @@ function () use ($cutting, $validated): void { ['owner', 'developer', 'direktur'], route('admin.manage.cuttings.index'), ); + + $this->cacheForgetByPattern('manage:cuttings:*'); } public function delete(Cutting $cutting): void @@ -686,6 +691,8 @@ function () use ($cutting): void { ['owner', 'developer', 'direktur'], route('admin.manage.cuttings.index'), ); + + $this->cacheForgetByPattern('manage:cuttings:*'); } public function transitionStatus( @@ -785,6 +792,9 @@ function () use ($cutting, $status, $verificationNote, $results, $resultPrices, route('admin.manage.stocks.index'), ); } + + $this->cacheForgetByPattern('manage:cuttings:*'); + $this->cacheForgetByPattern('prices:*'); } private function buildMaterials(array $materials): array @@ -1217,6 +1227,8 @@ public function quickCreateRawMaterial(array $validated): array ]; } + $this->cacheForgetByPattern('manage:cuttings:*'); + return [ 'id' => $rawMaterial->id, 'name' => $rawMaterial->name, @@ -1283,6 +1295,8 @@ public function quickCreateProduct(array $validated): array ]; } + $this->cacheForgetByPattern('manage:cuttings:*'); + return [ 'id' => $product->id, 'name' => $product->name, diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php index 9c2d045..601280e 100644 --- a/app/Services/Manage/OrderService.php +++ b/app/Services/Manage/OrderService.php @@ -13,6 +13,7 @@ use App\Models\Product; use App\Models\ProductVariant; use App\Models\User; +use App\Services\Concerns\CachesQuery; use App\Services\Concerns\RunsInTransaction; use App\Services\Finance\CashService; use App\Services\Media\MediaService; @@ -27,7 +28,7 @@ class OrderService { - use RunsInTransaction; + use CachesQuery, RunsInTransaction; private const MAX_PHOTOS = 1; @@ -496,6 +497,8 @@ function () use ($validated, $user): Order { route('admin.manage.orders.index'), ); + $this->cacheForgetByPattern('manage:orders:*'); + return $order; } @@ -584,6 +587,8 @@ function () use ($order, $validated): void { ['owner', 'developer', 'direktur'], route('admin.manage.orders.index'), ); + + $this->cacheForgetByPattern('manage:orders:*'); } public function delete(Order $order): void @@ -617,6 +622,8 @@ function () use ($order): void { ['owner', 'developer', 'direktur'], route('admin.manage.orders.index'), ); + + $this->cacheForgetByPattern('manage:orders:*'); } public function transitionStatus(Order $order, OrderStatus $status): void @@ -654,6 +661,8 @@ function () use ($order, $status): void { ['owner', 'developer', 'direktur'], route('admin.manage.orders.index'), ); + + $this->cacheForgetByPattern('manage:orders:*'); } private function buildLineItems(array $items, PriceType $priceType): array diff --git a/app/Services/Manage/OwnerVerificationService.php b/app/Services/Manage/OwnerVerificationService.php index b9dbc24..5699245 100644 --- a/app/Services/Manage/OwnerVerificationService.php +++ b/app/Services/Manage/OwnerVerificationService.php @@ -12,6 +12,7 @@ use App\Models\Purchase; use App\Models\RawMaterial; use App\Models\User; +use App\Services\Concerns\CachesQuery; use App\Services\Master\ProductService; use App\Services\Master\RawMaterialService; use App\Services\System\PushNotificationService; @@ -29,6 +30,8 @@ class OwnerVerificationService { + use CachesQuery; + public function __construct( private readonly StockService $stockService, private readonly ProductService $productService, @@ -129,16 +132,18 @@ private function pendingCuttingRows(User $user, array $tableQuery): Collection public function pendingCountForUser(User $user): int { - $requestCount = OwnerVerificationRequest::query() - ->pending() - ->visibleTo($user) - ->count(); + return $this->cacheRemember("verification:pending_count:{$user->id}", 30, function () use ($user) { + $requestCount = OwnerVerificationRequest::query() + ->pending() + ->visibleTo($user) + ->count(); - if (! $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { - return $requestCount; - } + if (! $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { + return $requestCount; + } - return $requestCount + Cutting::query()->pendingVerification()->count(); + return $requestCount + Cutting::query()->pendingVerification()->count(); + }); } public function subjectTypeOptions(User $user): array @@ -217,6 +222,8 @@ public function approveRequest( 'βœ… Pengajuan Disetujui', "Pengajuan {$request->action->label()} {$subjectLabel} '{$this->requestTitle($request)}' telah disetujui owner.", ); + + $this->cacheForgetByPattern('verification:*'); } public function rejectRequest( @@ -274,6 +281,8 @@ public function rejectRequest( '❌ Pengajuan Ditolak', "Pengajuan {$request->action->label()} {$subjectLabel} '{$title}' ditolak dengan alasan: '{$reason}'.", ); + + $this->cacheForgetByPattern('verification:*'); } private function rejectVerificationRequest(OwnerVerificationRequest $request): void diff --git a/app/Services/Manage/PurchaseService.php b/app/Services/Manage/PurchaseService.php index 93bf0f6..7b3e3d6 100644 --- a/app/Services/Manage/PurchaseService.php +++ b/app/Services/Manage/PurchaseService.php @@ -12,6 +12,7 @@ use App\Models\RawMaterialPrice; use App\Models\Supplier; use App\Models\User; +use App\Services\Concerns\CachesQuery; use App\Services\Concerns\RunsInTransaction; use App\Services\Media\MediaService; use App\Services\System\PushNotificationService; @@ -23,7 +24,7 @@ class PurchaseService { - use RunsInTransaction; + use CachesQuery, RunsInTransaction; private const MAX_PHOTOS = 1; @@ -324,6 +325,8 @@ function () use ($validated, $user, $isOwner): Purchase { ); } + $this->cacheForgetByPattern('manage:purchases:*'); + return $purchase; } @@ -373,6 +376,8 @@ function () use ($purchase, $validated, $user, $isOwner): void { $purchase->supplier->name, ); } + + $this->cacheForgetByPattern('manage:purchases:*'); } public function delete(Purchase $purchase, User $user): void @@ -381,6 +386,7 @@ public function delete(Purchase $purchase, User $user): void if ($isOwner) { $this->executeDelete($purchase); + $this->cacheForgetByPattern('manage:purchases:*'); return; } @@ -425,6 +431,8 @@ public function applyVerificationRequest(OwnerVerificationRequest $verificationR 'action' => 'Aksi verifikasi belanja tidak didukung.', ]), }; + + $this->cacheForgetByPattern('manage:purchases:*'); } public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void @@ -436,6 +444,8 @@ public function rejectVerificationRequest(OwnerVerificationRequest $verification 'action' => 'Aksi verifikasi belanja tidak didukung.', ]), }; + + $this->cacheForgetByPattern('manage:purchases:*'); } public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void diff --git a/app/Services/Manage/StockService.php b/app/Services/Manage/StockService.php index 35577f6..2c6579f 100644 --- a/app/Services/Manage/StockService.php +++ b/app/Services/Manage/StockService.php @@ -10,6 +10,7 @@ use App\Models\ProductPrice; use App\Models\ProductVariant; use App\Models\User; +use App\Services\Concerns\CachesQuery; use App\Services\System\PushNotificationService; use App\Support\Media\MediaPresenter; use Illuminate\Support\Collection; @@ -19,6 +20,8 @@ class StockService { + use CachesQuery; + public function __construct( private readonly PushNotificationService $pushNotificationService, ) {} @@ -131,6 +134,8 @@ public function submitVerification( ['owner', 'developer', 'direktur'], route('admin.manage.stocks.index'), ); + + $this->cacheForgetByPattern('manage:stocks:*'); } public function approveVerification( @@ -181,6 +186,9 @@ public function approveVerification( ['owner', 'developer', 'direktur'], route('admin.manage.stocks.index'), ); + + $this->cacheForgetByPattern('manage:stocks:*'); + $this->cacheForgetByPattern('prices:*'); } public function rejectVerification( @@ -232,6 +240,8 @@ public function rejectVerification( route('admin.manage.stocks.index'), ); } + + $this->cacheForgetByPattern('manage:stocks:*'); } private function breakMaterialCircularReference(CuttingMaterial $material): void diff --git a/app/Services/Manage/StokOpnameService.php b/app/Services/Manage/StokOpnameService.php index fa4213b..ab1a23d 100644 --- a/app/Services/Manage/StokOpnameService.php +++ b/app/Services/Manage/StokOpnameService.php @@ -3,11 +3,13 @@ namespace App\Services\Manage; use App\Enums\Permission; +use App\Enums\ProductStockQuality; use App\Enums\StokOpnameStatus; use App\Models\Product; use App\Models\ProductVariant; use App\Models\StokOpname; use App\Models\User; +use App\Services\Concerns\CachesQuery; use App\Services\System\PushNotificationService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -17,6 +19,8 @@ class StokOpnameService { + use CachesQuery; + public function __construct( private readonly PushNotificationService $pushNotificationService, ) {} @@ -106,7 +110,7 @@ public function findForEdit(StokOpname $stokOpname): array public function create(array $validated, User $user): StokOpname { try { - return DB::transaction(function () use ($validated, $user): StokOpname { + $stokOpname = DB::transaction(function () use ($validated, $user): StokOpname { $stokOpname = StokOpname::create([ 'opname_date' => $validated['opname_date'], 'notes' => $validated['notes'], @@ -118,6 +122,10 @@ public function create(array $validated, User $user): StokOpname return $stokOpname; }); + + $this->cacheForgetByPattern('manage:stok_opname:*'); + + return $stokOpname; } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { @@ -160,6 +168,8 @@ public function update(StokOpname $stokOpname, array $validated): void 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } + + $this->cacheForgetByPattern('manage:stok_opname:*'); } public function delete(StokOpname $stokOpname): void @@ -186,6 +196,8 @@ public function delete(StokOpname $stokOpname): void 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } + + $this->cacheForgetByPattern('manage:stok_opname:*'); } public function submit(StokOpname $stokOpname, User $user): void @@ -226,6 +238,8 @@ public function submit(StokOpname $stokOpname, User $user): void ['owner', 'developer', 'admin-toko'], route('admin.manage.stok-opnames.index'), ); + + $this->cacheForgetByPattern('manage:stok_opname:*'); } public function verify(StokOpname $stokOpname, User $user, ?string $verificationNotes = null): void @@ -241,9 +255,9 @@ public function verify(StokOpname $stokOpname, User $user, ?string $verification 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', + ProductStockQuality::GOOD => 'stock', + ProductStockQuality::RETAIL => 'retail_stock', + ProductStockQuality::REJECT => 'reject_stock', }; $item->productVariant()->update([ $column => $item->physical_stock, @@ -275,6 +289,8 @@ public function verify(StokOpname $stokOpname, User $user, ?string $verification $stokOpname->created_by_id, route('admin.manage.stok-opnames.index'), ); + + $this->cacheForgetByPattern('manage:stok_opname:*'); } public function reject(StokOpname $stokOpname, User $user, string $reason): void @@ -311,12 +327,14 @@ public function reject(StokOpname $stokOpname, User $user, string $reason): void $stokOpname->created_by_id, route('admin.manage.stok-opnames.index'), ); + + $this->cacheForgetByPattern('manage:stok_opname:*'); } public function autoSave(array $validated, User $user): StokOpname { try { - return DB::transaction(function () use ($validated, $user): StokOpname { + $stokOpname = DB::transaction(function () use ($validated, $user): StokOpname { $stokOpname = null; if (! empty($validated['stok_opname_id'])) { @@ -342,6 +360,10 @@ public function autoSave(array $validated, User $user): StokOpname return $stokOpname; }); + + $this->cacheForgetByPattern('manage:stok_opname:*'); + + return $stokOpname; } catch (\Throwable $e) { Log::error('Gagal auto-save stok opname: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), @@ -357,15 +379,15 @@ private function syncItems(StokOpname $stokOpname, array $items): void foreach ($items as $item) { $variant = ProductVariant::find($item['product_variant_id']); - if (!$variant) { + if (! $variant) { continue; } - $quality = \App\Enums\ProductStockQuality::from($item['stock_quality']); + $quality = 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', + ProductStockQuality::GOOD => 'stock', + ProductStockQuality::RETAIL => 'retail_stock', + ProductStockQuality::REJECT => 'reject_stock', }; $systemStock = $variant->$column ?? 0; diff --git a/app/Services/Master/CategoryService.php b/app/Services/Master/CategoryService.php index e558c2d..5753b80 100644 --- a/app/Services/Master/CategoryService.php +++ b/app/Services/Master/CategoryService.php @@ -3,11 +3,14 @@ namespace App\Services\Master; use App\Models\Category; +use App\Services\Concerns\CachesQuery; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; class CategoryService { + use CachesQuery; + public function paginateForIndex(array $tableQuery): LengthAwarePaginator { $query = Category::query() @@ -28,28 +31,36 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator public function create(array $validated): void { Category::create($validated); + $this->cacheForget('master:categories:options'); + $this->cacheForget('homepage:page_data'); } public function update(Category $category, array $validated): void { $category->update($validated); + $this->cacheForget('master:categories:options'); + $this->cacheForget('homepage:page_data'); } public function delete(Category $category): void { $category->delete(); + $this->cacheForget('master:categories:options'); + $this->cacheForget('homepage:page_data'); } public function getSelectOptions(): array { - return Category::query() - ->orderBy('name') - ->get(['id', 'name']) - ->map(fn (Category $category) => [ - 'value' => $category->id, - 'label' => $category->name, - ]) - ->all(); + return $this->cacheRemember('master:categories:options', 3600, function () { + return Category::query() + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn (Category $category) => [ + 'value' => $category->id, + 'label' => $category->name, + ]) + ->all(); + }); } private function applySorting(Builder $query, string $sort, string $direction): void diff --git a/app/Services/Master/CustomerService.php b/app/Services/Master/CustomerService.php index 39441a3..125d2fb 100644 --- a/app/Services/Master/CustomerService.php +++ b/app/Services/Master/CustomerService.php @@ -3,11 +3,14 @@ namespace App\Services\Master; use App\Models\Customer; +use App\Services\Concerns\CachesQuery; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; class CustomerService { + use CachesQuery; + public function paginateForIndex(array $tableQuery): LengthAwarePaginator { $query = Customer::query() @@ -30,33 +33,41 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator public function create(array $validated): void { Customer::create($validated); + $this->cacheForget('master:customers:options'); } public function createAndReturn(array $validated): Customer { - return Customer::create($validated); + $customer = Customer::create($validated); + $this->cacheForget('master:customers:options'); + + return $customer; } public function update(Customer $customer, array $validated): void { $customer->update($validated); + $this->cacheForget('master:customers:options'); } public function delete(Customer $customer): void { $customer->delete(); + $this->cacheForget('master:customers:options'); } public function getSelectOptions(): array { - return Customer::query() - ->orderBy('name') - ->get(['id', 'name']) - ->map(fn (Customer $customer) => [ - 'value' => $customer->id, - 'label' => $customer->name, - ]) - ->all(); + return $this->cacheRemember('master:customers:options', 3600, function () { + return Customer::query() + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn (Customer $customer) => [ + 'value' => $customer->id, + 'label' => $customer->name, + ]) + ->all(); + }); } private function applySorting(Builder $query, string $sort, string $direction): void diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php index 37ab2f5..47ed00e 100644 --- a/app/Services/Master/ProductService.php +++ b/app/Services/Master/ProductService.php @@ -10,6 +10,7 @@ use App\Models\Product; use App\Models\ProductVariant; use App\Models\User; +use App\Services\Concerns\CachesQuery; use App\Services\Concerns\RunsInTransaction; use App\Services\Media\MediaService; use App\Services\System\PushNotificationService; @@ -20,7 +21,7 @@ class ProductService { - use RunsInTransaction; + use CachesQuery, RunsInTransaction; private const MAX_VARIANT_IMAGES = 5; @@ -166,6 +167,10 @@ function () use ($validated, $user, $isOwner): void { 'Gagal membuat produk', ); + if ($isOwner) { + $this->cacheForgetByPattern('master:products:*'); + } + if (! $isOwner) { $this->notifyForPendingRequest( $user, @@ -240,6 +245,9 @@ function () use ($validated, $product, $user, $isOwner): void { 'Gagal memperbarui produk', ); + $this->cacheForgetByPattern('master:products:*'); + $this->cacheForget('homepage:page_data'); + if (! $isOwner) { $this->notifyForPendingRequest( $user, @@ -257,6 +265,8 @@ public function delete(Product $product, User $user): void if ($isOwner) { $this->applyDeleteSubject($product); + $this->cacheForgetByPattern('master:products:*'); + $this->cacheForget('homepage:page_data'); return; } @@ -296,6 +306,9 @@ public function toggleStatus(Product $product, array $validated, User $user): vo 'is_active' => (bool) $validated['is_active'], ]); + $this->cacheForgetByPattern('master:products:*'); + $this->cacheForget('homepage:page_data'); + return; } @@ -344,6 +357,8 @@ public function applyVerificationRequest(OwnerVerificationRequest $verificationR 'action' => 'Aksi verifikasi produk tidak didukung.', ]), }; + + $this->cacheForgetByPattern('master:products:*'); } public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void @@ -356,6 +371,9 @@ public function rejectVerificationRequest(OwnerVerificationRequest $verification 'action' => 'Aksi verifikasi produk tidak didukung.', ]), }; + + $this->cacheForgetByPattern('master:products:*'); + $this->cacheForget('homepage:page_data'); } public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void diff --git a/app/Services/Master/RawMaterialService.php b/app/Services/Master/RawMaterialService.php index bc48eae..b6cf3bf 100644 --- a/app/Services/Master/RawMaterialService.php +++ b/app/Services/Master/RawMaterialService.php @@ -10,6 +10,7 @@ use App\Models\RawMaterial; use App\Models\RawMaterialPrice; use App\Models\User; +use App\Services\Concerns\CachesQuery; use App\Services\Concerns\RunsInTransaction; use App\Services\Media\MediaService; use App\Services\System\PushNotificationService; @@ -20,7 +21,7 @@ class RawMaterialService { - use RunsInTransaction; + use CachesQuery, RunsInTransaction; private const MAX_VARIANT_IMAGES = 5; @@ -144,6 +145,10 @@ function () use ($validated, $user, $isOwner): void { 'Gagal membuat bahan baku', ); + if ($isOwner) { + $this->cacheForgetByPattern('master:raw_materials:*'); + } + if (! $isOwner) { $this->notifyForPendingRequest( $user, @@ -197,6 +202,10 @@ function () use ($validated, $rawMaterial, $user, $isOwner): void { 'Gagal memperbarui bahan baku', ); + if ($isOwner) { + $this->cacheForgetByPattern('master:raw_materials:*'); + } + if (! $isOwner) { $this->notifyForPendingRequest( $user, @@ -214,6 +223,7 @@ public function delete(RawMaterial $rawMaterial, User $user): void if ($isOwner) { $this->applyDeleteSubject($rawMaterial); + $this->cacheForgetByPattern('master:raw_materials:*'); return; } @@ -253,6 +263,8 @@ public function toggleStatus(RawMaterial $rawMaterial, array $validated, User $u 'is_active' => (bool) $validated['is_active'], ]); + $this->cacheForgetByPattern('master:raw_materials:*'); + return; } @@ -301,6 +313,8 @@ public function applyVerificationRequest(OwnerVerificationRequest $verificationR 'action' => 'Aksi verifikasi bahan baku tidak didukung.', ]), }; + + $this->cacheForgetByPattern('master:raw_materials:*'); } public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void @@ -313,6 +327,8 @@ public function rejectVerificationRequest(OwnerVerificationRequest $verification 'action' => 'Aksi verifikasi bahan baku tidak didukung.', ]), }; + + $this->cacheForgetByPattern('master:raw_materials:*'); } public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void diff --git a/app/Services/Master/SupplierService.php b/app/Services/Master/SupplierService.php index 3a83aea..d95b2d7 100644 --- a/app/Services/Master/SupplierService.php +++ b/app/Services/Master/SupplierService.php @@ -3,11 +3,14 @@ namespace App\Services\Master; use App\Models\Supplier; +use App\Services\Concerns\CachesQuery; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; class SupplierService { + use CachesQuery; + public function paginateForIndex(array $tableQuery): LengthAwarePaginator { $query = Supplier::query() @@ -30,28 +33,33 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator public function create(array $validated): void { Supplier::create($validated); + $this->cacheForget('master:suppliers:options'); } public function update(Supplier $supplier, array $validated): void { $supplier->update($validated); + $this->cacheForget('master:suppliers:options'); } public function delete(Supplier $supplier): void { $supplier->delete(); + $this->cacheForget('master:suppliers:options'); } public function getSelectOptions(): array { - return Supplier::query() - ->orderBy('name') - ->get(['id', 'name']) - ->map(fn (Supplier $supplier) => [ - 'value' => $supplier->id, - 'label' => $supplier->name, - ]) - ->all(); + return $this->cacheRemember('master:suppliers:options', 3600, function () { + return Supplier::query() + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn (Supplier $supplier) => [ + 'value' => $supplier->id, + 'label' => $supplier->name, + ]) + ->all(); + }); } private function applySorting(Builder $query, string $sort, string $direction): void diff --git a/app/Services/System/AnalysisService.php b/app/Services/System/AnalysisService.php index b3add16..2130619 100644 --- a/app/Services/System/AnalysisService.php +++ b/app/Services/System/AnalysisService.php @@ -20,729 +20,762 @@ use App\Models\Purchase; use App\Models\RawMaterialPrice; use App\Models\User; +use App\Services\Concerns\CachesQuery; use Carbon\Carbon; use Illuminate\Support\Facades\DB; class AnalysisService { + use CachesQuery; + public function getAttendance(): array { - /** @var User|null $user */ - $user = auth()->user(); - $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; + return $this->cacheRemember('analysis:get_attendance', 900, function () { + /** @var User|null $user */ + $user = auth()->user(); + $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; - $employeeQuery = Employee::query(); - $attendanceQuery = Attendance::query()->where('attendance_date', Carbon::today()); - $leaveRequestQuery = LeaveRequest::query() - ->approved() - ->where('start_date', '<=', Carbon::today()) - ->where('end_date', '>=', Carbon::today()); + $employeeQuery = Employee::query(); + $attendanceQuery = Attendance::query()->where('attendance_date', Carbon::today()); + $leaveRequestQuery = LeaveRequest::query() + ->approved() + ->where('start_date', '<=', Carbon::today()) + ->where('end_date', '>=', Carbon::today()); - if (! $isSuper) { - $employeeId = $user?->employee?->id; - $employeeQuery->where('id', $employeeId); - $attendanceQuery->where('employee_id', $employeeId); - $leaveRequestQuery->where('employee_id', $employeeId); - } + if (! $isSuper) { + $employeeId = $user?->employee?->id; + $employeeQuery->where('id', $employeeId); + $attendanceQuery->where('employee_id', $employeeId); + $leaveRequestQuery->where('employee_id', $employeeId); + } - $totalEmployees = $employeeQuery->count(); - $present = $attendanceQuery->distinct('employee_id')->count('employee_id'); - $onLeave = $leaveRequestQuery->distinct('employee_id')->count('employee_id'); - $absent = max(0, $totalEmployees - $present - $onLeave); + $totalEmployees = $employeeQuery->count(); + $present = $attendanceQuery->distinct('employee_id')->count('employee_id'); + $onLeave = $leaveRequestQuery->distinct('employee_id')->count('employee_id'); + $absent = max(0, $totalEmployees - $present - $onLeave); - return [ - 'total_employees' => $totalEmployees, - 'percentage' => $totalEmployees > 0 ? round($present / $totalEmployees * 100) : 0, - 'present' => $present, - 'absent' => $absent, - 'on_leave' => $onLeave, - ]; + return [ + 'total_employees' => $totalEmployees, + 'percentage' => $totalEmployees > 0 ? round($present / $totalEmployees * 100) : 0, + 'present' => $present, + 'absent' => $absent, + 'on_leave' => $onLeave, + ]; + }); } public function getMyAttendance(User $user, ?Carbon $startDate = null, ?Carbon $endDate = null): array { - $employee = $user->employee; + return $this->cacheRemember('analysis:get_my_attendance', 900, function () use ($user, $startDate, $endDate) { + $employee = $user->employee; - if ($employee === null) { - return [ - 'total_days' => 0, - 'present_days' => 0, - 'absent_days' => 0, - 'leave_days' => 0, - 'percentage' => 0, - ]; - } - - $start = $startDate ?? ($employee->join_date ? Carbon::parse($employee->join_date) : Carbon::today()->startOfMonth()); - $end = $endDate ?? Carbon::today(); - - $totalDays = 0; - $presentDays = 0; - $current = $start->copy()->startOfDay(); - - while ($current->lte($end)) { - if ($current->isWeekday()) { - $totalDays++; - - $hasAttendance = Attendance::query() - ->where('employee_id', $employee->id) - ->whereDate('attendance_date', $current) - ->exists(); - - if ($hasAttendance) { - $presentDays++; - } + if ($employee === null) { + return [ + 'total_days' => 0, + 'present_days' => 0, + 'absent_days' => 0, + 'leave_days' => 0, + 'percentage' => 0, + ]; } - $current->addDay(); - } + $start = $startDate ?? ($employee->join_date ? Carbon::parse($employee->join_date) : Carbon::today()->startOfMonth()); + $end = $endDate ?? Carbon::today(); - $leaveDays = LeaveRequest::query() - ->approved() - ->where('employee_id', $employee->id) - ->where('start_date', '<=', $end->toDateString()) - ->where('end_date', '>=', $start->toDateString()) - ->get() - ->sum(fn ($leave) => max( - 0, - min($leave->end_date, $end->toDateString()) - - max($leave->start_date, $start->toDateString()) - ) / 86400 + 1); + $totalDays = 0; + $presentDays = 0; + $current = $start->copy()->startOfDay(); - $leaveDays = (int) $leaveDays; - $absentDays = max(0, $totalDays - $presentDays - $leaveDays); - $percentage = $totalDays > 0 ? round(($presentDays / $totalDays) * 100) : 0; + while ($current->lte($end)) { + if ($current->isWeekday()) { + $totalDays++; - return [ - 'total_days' => $totalDays, - 'present_days' => $presentDays, - 'absent_days' => $absentDays, - 'leave_days' => $leaveDays, - 'percentage' => $percentage, - ]; + $hasAttendance = Attendance::query() + ->where('employee_id', $employee->id) + ->whereDate('attendance_date', $current) + ->exists(); + + if ($hasAttendance) { + $presentDays++; + } + } + + $current->addDay(); + } + + $leaveDays = LeaveRequest::query() + ->approved() + ->where('employee_id', $employee->id) + ->where('start_date', '<=', $end->toDateString()) + ->where('end_date', '>=', $start->toDateString()) + ->get() + ->sum(fn ($leave) => max( + 0, + min($leave->end_date, $end->toDateString()) + - max($leave->start_date, $start->toDateString()) + ) / 86400 + 1); + + $leaveDays = (int) $leaveDays; + $absentDays = max(0, $totalDays - $presentDays - $leaveDays); + $percentage = $totalDays > 0 ? round(($presentDays / $totalDays) * 100) : 0; + + return [ + 'total_days' => $totalDays, + 'present_days' => $presentDays, + 'absent_days' => $absentDays, + 'leave_days' => $leaveDays, + 'percentage' => $percentage, + ]; + }); } public function getCashOverview(): array { - /** @var User|null $user */ - $user = auth()->user(); - $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; + return $this->cacheRemember('analysis:get_cash_overview', 900, function () { + /** @var User|null $user */ + $user = auth()->user(); + $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; - $totalBalanceQuery = CashAccount::query(); - $transactionQuery = CashTransaction::query()->whereDate('created_at', Carbon::today()); + $totalBalanceQuery = CashAccount::query(); + $transactionQuery = CashTransaction::query()->whereDate('created_at', Carbon::today()); - if (! $isSuper) { - $transactionQuery->where('created_by_id', $user->id); - $totalBalanceQuery->whereHas('transactions', fn ($q) => $q->where('created_by_id', $user->id)); - } + if (! $isSuper) { + $transactionQuery->where('created_by_id', $user->id); + $totalBalanceQuery->whereHas('transactions', fn ($q) => $q->where('created_by_id', $user->id)); + } - $totalBalance = $totalBalanceQuery->sum('balance'); - $summary = $transactionQuery - ->selectRaw(" - COUNT(*) as total_transactions, - COALESCE(SUM(CASE WHEN type = 'deposit' THEN amount ELSE 0 END), 0) as total_deposit, - COALESCE(SUM(CASE WHEN type = 'withdrawal' THEN amount ELSE 0 END), 0) as total_withdrawal - ") - ->first(); + $totalBalance = $totalBalanceQuery->sum('balance'); + $summary = $transactionQuery + ->selectRaw(" + COUNT(*) as total_transactions, + COALESCE(SUM(CASE WHEN type = 'deposit' THEN amount ELSE 0 END), 0) as total_deposit, + COALESCE(SUM(CASE WHEN type = 'withdrawal' THEN amount ELSE 0 END), 0) as total_withdrawal + ") + ->first(); - return [ - 'total_balance' => (int) $totalBalance, - 'total_transactions' => (int) ($summary->total_transactions ?? 0), - 'total_deposit' => (int) ($summary->total_deposit ?? 0), - 'total_withdrawal' => (int) ($summary->total_withdrawal ?? 0), - ]; + return [ + 'total_balance' => (int) $totalBalance, + 'total_transactions' => (int) ($summary->total_transactions ?? 0), + 'total_deposit' => (int) ($summary->total_deposit ?? 0), + 'total_withdrawal' => (int) ($summary->total_withdrawal ?? 0), + ]; + }); } public function getTopSuppliers(?Carbon $startDate = null, ?Carbon $endDate = null): array { - /** @var User|null $user */ - $user = auth()->user(); - $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; + return $this->cacheRemember('analysis:get_top_suppliers', 900, function () use ($startDate, $endDate) { + /** @var User|null $user */ + $user = auth()->user(); + $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; - return Purchase::query() - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('purchases.created_at', [$startDate, $endDate])) - ->when(! $isSuper, fn ($q) => $q->where('purchases.created_by_id', $user->id)) - ->join('suppliers', 'purchases.supplier_id', '=', 'suppliers.id') - ->selectRaw('suppliers.name, SUM(purchases.total) as total_amount, COUNT(purchases.id) as purchase_count') - ->groupBy('suppliers.id', 'suppliers.name') - ->orderByDesc('total_amount') - ->limit(5) - ->get() - ->map(fn ($item) => [ - 'name' => $item->name, - 'total_amount' => (int) $item->total_amount, - 'purchase_count' => (int) $item->purchase_count, - ]) - ->toArray(); + return Purchase::query() + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('purchases.created_at', [$startDate, $endDate])) + ->when(! $isSuper, fn ($q) => $q->where('purchases.created_by_id', $user->id)) + ->join('suppliers', 'purchases.supplier_id', '=', 'suppliers.id') + ->selectRaw('suppliers.name, SUM(purchases.total) as total_amount, COUNT(purchases.id) as purchase_count') + ->groupBy('suppliers.id', 'suppliers.name') + ->orderByDesc('total_amount') + ->limit(5) + ->get() + ->map(fn ($item) => [ + 'name' => $item->name, + 'total_amount' => (int) $item->total_amount, + 'purchase_count' => (int) $item->purchase_count, + ]) + ->toArray(); + }); } public function getTopCustomers(?Carbon $startDate = null, ?Carbon $endDate = null): array { - /** @var User|null $user */ - $user = auth()->user(); - $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; - $isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false; + return $this->cacheRemember('analysis:get_top_customers', 900, function () use ($startDate, $endDate) { + /** @var User|null $user */ + $user = auth()->user(); + $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; + $isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false; - return Order::query() - ->completed() - ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) - ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)) - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) - ->join('customers', 'orders.customer_id', '=', 'customers.id') - ->selectRaw('customers.name, SUM(orders.total_amount) as total_amount, COUNT(orders.id) as order_count') - ->groupBy('customers.id', 'customers.name') - ->orderByDesc('total_amount') - ->limit(5) - ->get() - ->map(fn ($item) => [ - 'name' => $item->name, - 'total_amount' => (int) $item->total_amount, - 'order_count' => (int) $item->order_count, - ]) - ->toArray(); + return Order::query() + ->completed() + ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) + ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)) + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) + ->join('customers', 'orders.customer_id', '=', 'customers.id') + ->selectRaw('customers.name, SUM(orders.total_amount) as total_amount, COUNT(orders.id) as order_count') + ->groupBy('customers.id', 'customers.name') + ->orderByDesc('total_amount') + ->limit(5) + ->get() + ->map(fn ($item) => [ + 'name' => $item->name, + 'total_amount' => (int) $item->total_amount, + 'order_count' => (int) $item->order_count, + ]) + ->toArray(); + }); } public function getRevenueSummary(?Carbon $startDate = null, ?Carbon $endDate = null): array { - /** @var User|null $user */ - $user = auth()->user(); - $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; - $isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false; + return $this->cacheRemember('analysis:get_revenue_summary', 900, function () use ($startDate, $endDate) { + /** @var User|null $user */ + $user = auth()->user(); + $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; + $isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false; - $revenueSummary = Order::query() - ->completed() - ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) - ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)) - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) - ->selectRaw('SUM(total_amount) as total_revenue, SUM(discount) as total_discount, COUNT(*) as total_orders, AVG(total_amount) as avg_order') - ->first(); + $revenueSummary = Order::query() + ->completed() + ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) + ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)) + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) + ->selectRaw('SUM(total_amount) as total_revenue, SUM(discount) as total_discount, COUNT(*) as total_orders, AVG(total_amount) as avg_order') + ->first(); - $totalMarketplaceFees = Order::query() - ->completed() - ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) - ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)) - ->whereNotNull('marketplace_settings_snapshot') - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) - ->get() - ->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0)); + $totalMarketplaceFees = Order::query() + ->completed() + ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) + ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)) + ->whereNotNull('marketplace_settings_snapshot') + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) + ->get() + ->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0)); - $totalCostPrice = OrderItem::query() - ->join('orders', 'order_items.order_id', '=', 'orders.id') - ->leftJoin('product_prices', function ($join) { - $join->on('order_items.product_variant_id', '=', 'product_prices.variant_id') - ->where('product_prices.type', PriceType::HARGA_MODAL->value); - }) - ->where('orders.status', OrderStatus::COMPLETED) - ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('orders.marketing_id', $user->id)) - ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('orders.created_by_id', $user->id)) - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) - ->selectRaw('COALESCE(SUM(order_items.quantity * product_prices.price), 0) as total_hpp') - ->value('total_hpp'); + $totalCostPrice = OrderItem::query() + ->join('orders', 'order_items.order_id', '=', 'orders.id') + ->leftJoin('product_prices', function ($join) { + $join->on('order_items.product_variant_id', '=', 'product_prices.variant_id') + ->where('product_prices.type', PriceType::HARGA_MODAL->value); + }) + ->where('orders.status', OrderStatus::COMPLETED) + ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('orders.marketing_id', $user->id)) + ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('orders.created_by_id', $user->id)) + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) + ->selectRaw('COALESCE(SUM(order_items.quantity * product_prices.price), 0) as total_hpp') + ->value('total_hpp'); - return [ - 'total_revenue' => (int) ($revenueSummary->total_revenue ?? 0), - 'total_discount' => (int) ($revenueSummary->total_discount ?? 0), - 'total_marketplace_fees' => $totalMarketplaceFees, - 'total_cost_price' => (int) ($totalCostPrice ?? 0), - 'total_deduction' => (int) ($revenueSummary->total_discount ?? 0) + $totalMarketplaceFees, - 'total_orders' => (int) ($revenueSummary->total_orders ?? 0), - 'avg_order' => (int) ($revenueSummary->avg_order ?? 0), - ]; + return [ + 'total_revenue' => (int) ($revenueSummary->total_revenue ?? 0), + 'total_discount' => (int) ($revenueSummary->total_discount ?? 0), + 'total_marketplace_fees' => $totalMarketplaceFees, + 'total_cost_price' => (int) ($totalCostPrice ?? 0), + 'total_deduction' => (int) ($revenueSummary->total_discount ?? 0) + $totalMarketplaceFees, + 'total_orders' => (int) ($revenueSummary->total_orders ?? 0), + 'avg_order' => (int) ($revenueSummary->avg_order ?? 0), + ]; + }); } public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate = null): array { - /** @var User|null $user */ - $user = auth()->user(); - $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; - $isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false; + return $this->cacheRemember('analysis:get_monthly_revenue', 900, function () use ($startDate, $endDate) { + /** @var User|null $user */ + $user = auth()->user(); + $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; + $isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false; - $query = Order::query()->completed() - ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) - ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)); - $feeQuery = Order::query()->completed() - ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) - ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)) - ->whereNotNull('marketplace_settings_snapshot'); + $query = Order::query()->completed() + ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) + ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)); + $feeQuery = Order::query()->completed() + ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) + ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)) + ->whereNotNull('marketplace_settings_snapshot'); - if ($startDate && $endDate) { - $query->whereBetween('orders.created_at', [$startDate, $endDate]); - $feeQuery->whereBetween('orders.created_at', [$startDate, $endDate]); - } - - $monthlyData = $query - ->selectRaw(" - DATE_FORMAT(orders.created_at, '%Y-%m') as month_key, - SUM(total_amount) as total_revenue, - SUM(discount) as total_discount - ") - ->groupBy('month_key') - ->orderBy('month_key') - ->get(); - - $monthlyFees = $feeQuery - ->selectRaw("DATE_FORMAT(orders.created_at, '%Y-%m') as month_key") - ->get() - ->groupBy('month_key') - ->map(fn ($orders) => $orders->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0))); - - $monthlyItemsData = OrderItem::query() - ->join('orders', 'order_items.order_id', '=', 'orders.id') - ->leftJoin('product_prices', function ($join) { - $join->on('order_items.product_variant_id', '=', 'product_prices.variant_id') - ->where('product_prices.type', PriceType::HARGA_MODAL->value); - }) - ->where('orders.status', OrderStatus::COMPLETED) - ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('orders.marketing_id', $user->id)) - ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('orders.created_by_id', $user->id)) - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) - ->selectRaw(" - DATE_FORMAT(orders.created_at, '%Y-%m') as month_key, - order_items.stock_quality, - SUM(order_items.subtotal) as subtotal, - COALESCE(SUM(order_items.quantity * product_prices.price), 0) as hpp - ") - ->groupBy('month_key', 'order_items.stock_quality') - ->get() - ->groupBy('month_key'); - - if ($monthlyData->isEmpty()) { - return []; - } - - $start = $startDate ?? Carbon::parse($monthlyData->first()->month_key.'-01'); - $end = $endDate ?? Carbon::parse($monthlyData->last()->month_key.'-01')->endOfMonth(); - - $result = []; - $current = $start->copy()->startOfMonth(); - while ($current->lte($end)) { - $key = $current->format('Y-m'); - $monthLabel = $current->locale('id')->translatedFormat('M Y'); - - $revenue = $monthlyData->firstWhere('month_key', $key); - $fees = $monthlyFees->get($key, 0); - $discount = (int) ($revenue->total_discount ?? 0); - $deduction = $discount + $fees; - - $monthItems = $monthlyItemsData->get($key, collect()); - $warehouseItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'good' - ); - $retailItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'retail' - ); - $rejectItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'reject' - ); - - $warehouseSubtotal = (int) ($warehouseItem?->subtotal ?? 0); - $warehouseHpp = (int) ($warehouseItem?->hpp ?? 0); - $retailSubtotal = (int) ($retailItem?->subtotal ?? 0); - $retailHpp = (int) ($retailItem?->hpp ?? 0); - $rejectSubtotal = (int) ($rejectItem?->subtotal ?? 0); - $rejectHpp = (int) ($rejectItem?->hpp ?? 0); - - $totalItemsSubtotal = $warehouseSubtotal + $retailSubtotal + $rejectSubtotal; - $hpp = $warehouseHpp + $retailHpp + $rejectHpp; - - $warehouseDeduction = 0; - $retailDeduction = 0; - if ($totalItemsSubtotal > 0) { - $warehouseDeduction = ($warehouseSubtotal / $totalItemsSubtotal) * $deduction; - $retailDeduction = ($retailSubtotal / $totalItemsSubtotal) * $deduction; + if ($startDate && $endDate) { + $query->whereBetween('orders.created_at', [$startDate, $endDate]); + $feeQuery->whereBetween('orders.created_at', [$startDate, $endDate]); } - $netWarehouse = $warehouseSubtotal - $warehouseDeduction - $warehouseHpp; - $netRetail = $retailSubtotal - $retailDeduction - $retailHpp; + $monthlyData = $query + ->selectRaw(" + DATE_FORMAT(orders.created_at, '%Y-%m') as month_key, + SUM(total_amount) as total_revenue, + SUM(discount) as total_discount + ") + ->groupBy('month_key') + ->orderBy('month_key') + ->get(); - $result[] = [ - 'month' => $monthLabel, - 'total' => (int) ($revenue->total_revenue ?? 0), - 'net' => (int) ($revenue->total_revenue ?? 0) - $deduction - $hpp, - 'net_warehouse' => (int) round($netWarehouse), - 'net_retail' => (int) round($netRetail), - 'deduction' => $deduction, - ]; + $monthlyFees = $feeQuery + ->selectRaw("DATE_FORMAT(orders.created_at, '%Y-%m') as month_key") + ->get() + ->groupBy('month_key') + ->map(fn ($orders) => $orders->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0))); - $current->addMonth(); - } + $monthlyItemsData = OrderItem::query() + ->join('orders', 'order_items.order_id', '=', 'orders.id') + ->leftJoin('product_prices', function ($join) { + $join->on('order_items.product_variant_id', '=', 'product_prices.variant_id') + ->where('product_prices.type', PriceType::HARGA_MODAL->value); + }) + ->where('orders.status', OrderStatus::COMPLETED) + ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('orders.marketing_id', $user->id)) + ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('orders.created_by_id', $user->id)) + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) + ->selectRaw(" + DATE_FORMAT(orders.created_at, '%Y-%m') as month_key, + order_items.stock_quality, + SUM(order_items.subtotal) as subtotal, + COALESCE(SUM(order_items.quantity * product_prices.price), 0) as hpp + ") + ->groupBy('month_key', 'order_items.stock_quality') + ->get() + ->groupBy('month_key'); - return $result; + if ($monthlyData->isEmpty()) { + return []; + } + + $start = $startDate ?? Carbon::parse($monthlyData->first()->month_key.'-01'); + $end = $endDate ?? Carbon::parse($monthlyData->last()->month_key.'-01')->endOfMonth(); + + $result = []; + $current = $start->copy()->startOfMonth(); + while ($current->lte($end)) { + $key = $current->format('Y-m'); + $monthLabel = $current->locale('id')->translatedFormat('M Y'); + + $revenue = $monthlyData->firstWhere('month_key', $key); + $fees = $monthlyFees->get($key, 0); + $discount = (int) ($revenue->total_discount ?? 0); + $deduction = $discount + $fees; + + $monthItems = $monthlyItemsData->get($key, collect()); + $warehouseItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'good' + ); + $retailItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'retail' + ); + $rejectItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'reject' + ); + + $warehouseSubtotal = (int) ($warehouseItem?->subtotal ?? 0); + $warehouseHpp = (int) ($warehouseItem?->hpp ?? 0); + $retailSubtotal = (int) ($retailItem?->subtotal ?? 0); + $retailHpp = (int) ($retailItem?->hpp ?? 0); + $rejectSubtotal = (int) ($rejectItem?->subtotal ?? 0); + $rejectHpp = (int) ($rejectItem?->hpp ?? 0); + + $totalItemsSubtotal = $warehouseSubtotal + $retailSubtotal + $rejectSubtotal; + $hpp = $warehouseHpp + $retailHpp + $rejectHpp; + + $warehouseDeduction = 0; + $retailDeduction = 0; + if ($totalItemsSubtotal > 0) { + $warehouseDeduction = ($warehouseSubtotal / $totalItemsSubtotal) * $deduction; + $retailDeduction = ($retailSubtotal / $totalItemsSubtotal) * $deduction; + } + + $netWarehouse = $warehouseSubtotal - $warehouseDeduction - $warehouseHpp; + $netRetail = $retailSubtotal - $retailDeduction - $retailHpp; + + $result[] = [ + 'month' => $monthLabel, + 'total' => (int) ($revenue->total_revenue ?? 0), + 'net' => (int) ($revenue->total_revenue ?? 0) - $deduction - $hpp, + 'net_warehouse' => (int) round($netWarehouse), + 'net_retail' => (int) round($netRetail), + 'deduction' => $deduction, + ]; + + $current->addMonth(); + } + + return $result; + }); } public function getExpenseSummary(?Carbon $startDate = null, ?Carbon $endDate = null): array { - /** @var User|null $user */ - $user = auth()->user(); - $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; + return $this->cacheRemember('analysis:get_expense_summary', 900, function () use ($startDate, $endDate) { + /** @var User|null $user */ + $user = auth()->user(); + $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; - $purchase = Purchase::query() - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('purchases.created_at', [$startDate, $endDate])) - ->when(! $isSuper, fn ($q) => $q->where('purchases.created_by_id', $user->id)) - ->selectRaw('COALESCE(SUM(total), 0) as total') - ->first(); + $purchase = Purchase::query() + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('purchases.created_at', [$startDate, $endDate])) + ->when(! $isSuper, fn ($q) => $q->where('purchases.created_by_id', $user->id)) + ->selectRaw('COALESCE(SUM(total), 0) as total') + ->first(); - $expenses = Expense::query() - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('expenses.created_at', [$startDate, $endDate])) - ->when(! $isSuper, fn ($q) => $q->where('expenses.created_by_id', $user->id)) - ->selectRaw('COALESCE(SUM(amount), 0) as total') - ->first(); + $expenses = Expense::query() + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('expenses.created_at', [$startDate, $endDate])) + ->when(! $isSuper, fn ($q) => $q->where('expenses.created_by_id', $user->id)) + ->selectRaw('COALESCE(SUM(amount), 0) as total') + ->first(); - $employeeAdvance = EmployeeAdvance::query() - ->whereIn('status', [EmployeeAdvanceStatus::APPROVED, EmployeeAdvanceStatus::PARTIALLY_PAID]) - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('employee_advances.created_at', [$startDate, $endDate])) - ->when(! $isSuper, fn ($q) => $q->where('employee_id', $user->employee?->id)) - ->selectRaw('COALESCE(SUM(amount - paid_amount), 0) as total') - ->first(); + $employeeAdvance = EmployeeAdvance::query() + ->whereIn('status', [EmployeeAdvanceStatus::APPROVED, EmployeeAdvanceStatus::PARTIALLY_PAID]) + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('employee_advances.created_at', [$startDate, $endDate])) + ->when(! $isSuper, fn ($q) => $q->where('employee_id', $user->employee?->id)) + ->selectRaw('COALESCE(SUM(amount - paid_amount), 0) as total') + ->first(); - $purchaseTotal = (int) ($purchase->total ?? 0); - if (! $user?->hasAnyRole([Role::DEVELOPER->value, Role::OWNER->value])) { - $purchaseTotal = 0; - } - $expenseTotal = (int) ($expenses->total ?? 0); - $advanceTotal = (int) ($employeeAdvance->total ?? 0); + $purchaseTotal = (int) ($purchase->total ?? 0); + if (! $user?->hasAnyRole([Role::DEVELOPER->value, Role::OWNER->value])) { + $purchaseTotal = 0; + } + $expenseTotal = (int) ($expenses->total ?? 0); + $advanceTotal = (int) ($employeeAdvance->total ?? 0); - return [ - 'total' => $purchaseTotal + $expenseTotal + $advanceTotal, - 'purchase_total' => $purchaseTotal, - 'expense_total' => $expenseTotal, - 'advance_total' => $advanceTotal, - ]; + return [ + 'total' => $purchaseTotal + $expenseTotal + $advanceTotal, + 'purchase_total' => $purchaseTotal, + 'expense_total' => $expenseTotal, + 'advance_total' => $advanceTotal, + ]; + }); } public function getMonthlyExpense(?Carbon $startDate = null, ?Carbon $endDate = null): array { - /** @var User|null $user */ - $user = auth()->user(); - $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; + return $this->cacheRemember('analysis:get_monthly_expense', 900, function () use ($startDate, $endDate) { + /** @var User|null $user */ + $user = auth()->user(); + $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; - $purchases = Purchase::query() - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('purchases.created_at', [$startDate, $endDate])) - ->when(! $isSuper, fn ($q) => $q->where('purchases.created_by_id', $user->id)) - ->selectRaw("DATE_FORMAT(purchases.created_at, '%Y-%m') as month_key, COALESCE(SUM(total), 0) as total") - ->groupBy('month_key') - ->orderBy('month_key') - ->get(); + $purchases = Purchase::query() + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('purchases.created_at', [$startDate, $endDate])) + ->when(! $isSuper, fn ($q) => $q->where('purchases.created_by_id', $user->id)) + ->selectRaw("DATE_FORMAT(purchases.created_at, '%Y-%m') as month_key, COALESCE(SUM(total), 0) as total") + ->groupBy('month_key') + ->orderBy('month_key') + ->get(); - $expenses = Expense::query() - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('expenses.created_at', [$startDate, $endDate])) - ->when(! $isSuper, fn ($q) => $q->where('expenses.created_by_id', $user->id)) - ->selectRaw("DATE_FORMAT(expenses.created_at, '%Y-%m') as month_key, COALESCE(SUM(amount), 0) as total") - ->groupBy('month_key') - ->orderBy('month_key') - ->get(); + $expenses = Expense::query() + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('expenses.created_at', [$startDate, $endDate])) + ->when(! $isSuper, fn ($q) => $q->where('expenses.created_by_id', $user->id)) + ->selectRaw("DATE_FORMAT(expenses.created_at, '%Y-%m') as month_key, COALESCE(SUM(amount), 0) as total") + ->groupBy('month_key') + ->orderBy('month_key') + ->get(); - $advances = EmployeeAdvance::query() - ->whereIn('status', [EmployeeAdvanceStatus::APPROVED, EmployeeAdvanceStatus::PARTIALLY_PAID]) - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('employee_advances.created_at', [$startDate, $endDate])) - ->when(! $isSuper, fn ($q) => $q->where('employee_id', $user->employee?->id)) - ->selectRaw("DATE_FORMAT(employee_advances.created_at, '%Y-%m') as month_key, COALESCE(SUM(amount - paid_amount), 0) as total") - ->groupBy('month_key') - ->orderBy('month_key') - ->get(); + $advances = EmployeeAdvance::query() + ->whereIn('status', [EmployeeAdvanceStatus::APPROVED, EmployeeAdvanceStatus::PARTIALLY_PAID]) + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('employee_advances.created_at', [$startDate, $endDate])) + ->when(! $isSuper, fn ($q) => $q->where('employee_id', $user->employee?->id)) + ->selectRaw("DATE_FORMAT(employee_advances.created_at, '%Y-%m') as month_key, COALESCE(SUM(amount - paid_amount), 0) as total") + ->groupBy('month_key') + ->orderBy('month_key') + ->get(); - $allMonths = collect() - ->merge($purchases->pluck('month_key')) - ->merge($expenses->pluck('month_key')) - ->merge($advances->pluck('month_key')) - ->unique() - ->sort() - ->values(); + $allMonths = collect() + ->merge($purchases->pluck('month_key')) + ->merge($expenses->pluck('month_key')) + ->merge($advances->pluck('month_key')) + ->unique() + ->sort() + ->values(); - if ($allMonths->isEmpty()) { - return []; - } - - $start = $startDate ?? Carbon::parse($allMonths->first().'-01'); - $end = $endDate ?? Carbon::parse($allMonths->last().'-01')->endOfMonth(); - - $result = []; - $current = $start->copy()->startOfMonth(); - while ($current->lte($end)) { - $key = $current->format('Y-m'); - $monthLabel = $current->locale('id')->translatedFormat('M Y'); - - $purchaseAmount = (int) ($purchases->firstWhere('month_key', $key)->total ?? 0); - if (! $user?->hasAnyRole([Role::DEVELOPER->value, Role::OWNER->value])) { - $purchaseAmount = 0; + if ($allMonths->isEmpty()) { + return []; } - $expenseAmount = (int) ($expenses->firstWhere('month_key', $key)->total ?? 0); - $advanceAmount = (int) ($advances->firstWhere('month_key', $key)->total ?? 0); - $result[] = [ - 'month' => $monthLabel, - 'total' => $purchaseAmount + $expenseAmount + $advanceAmount, - 'purchase' => $purchaseAmount, - 'expense' => $expenseAmount, - 'advance' => $advanceAmount, - ]; + $start = $startDate ?? Carbon::parse($allMonths->first().'-01'); + $end = $endDate ?? Carbon::parse($allMonths->last().'-01')->endOfMonth(); - $current->addMonth(); - } + $result = []; + $current = $start->copy()->startOfMonth(); + while ($current->lte($end)) { + $key = $current->format('Y-m'); + $monthLabel = $current->locale('id')->translatedFormat('M Y'); - return $result; + $purchaseAmount = (int) ($purchases->firstWhere('month_key', $key)->total ?? 0); + if (! $user?->hasAnyRole([Role::DEVELOPER->value, Role::OWNER->value])) { + $purchaseAmount = 0; + } + $expenseAmount = (int) ($expenses->firstWhere('month_key', $key)->total ?? 0); + $advanceAmount = (int) ($advances->firstWhere('month_key', $key)->total ?? 0); + + $result[] = [ + 'month' => $monthLabel, + 'total' => $purchaseAmount + $expenseAmount + $advanceAmount, + 'purchase' => $purchaseAmount, + 'expense' => $expenseAmount, + 'advance' => $advanceAmount, + ]; + + $current->addMonth(); + } + + return $result; + }); } public function getProfitMetrics(?Carbon $startDate = null, ?Carbon $endDate = null): array { - /** @var User|null $user */ - $user = auth()->user(); - $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; - $isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false; + return $this->cacheRemember('analysis:get_profit_metrics', 900, function () use ($startDate, $endDate) { + /** @var User|null $user */ + $user = auth()->user(); + $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; + $isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false; - $orderQuery = Order::query()->completed() - ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) - ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)); - if ($startDate && $endDate) { - $orderQuery->whereBetween('orders.created_at', [$startDate, $endDate]); - } + $orderQuery = Order::query()->completed() + ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) + ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)); + if ($startDate && $endDate) { + $orderQuery->whereBetween('orders.created_at', [$startDate, $endDate]); + } - $revenueData = (clone $orderQuery) - ->selectRaw(' - COUNT(*) as total_orders, - SUM(total_amount) as total_revenue, - SUM(discount) as total_discount, - SUM(subtotal) as total_subtotal - ') - ->first(); + $revenueData = (clone $orderQuery) + ->selectRaw(' + COUNT(*) as total_orders, + SUM(total_amount) as total_revenue, + SUM(discount) as total_discount, + SUM(subtotal) as total_subtotal + ') + ->first(); - $totalRevenue = (int) ($revenueData->total_revenue ?? 0); - $totalDiscount = (int) ($revenueData->total_discount ?? 0); - $totalSubtotal = (int) ($revenueData->total_subtotal ?? 0); - $totalOrders = (int) ($revenueData->total_orders ?? 0); + $totalRevenue = (int) ($revenueData->total_revenue ?? 0); + $totalDiscount = (int) ($revenueData->total_discount ?? 0); + $totalSubtotal = (int) ($revenueData->total_subtotal ?? 0); + $totalOrders = (int) ($revenueData->total_orders ?? 0); - $marketplaceFees = (clone $orderQuery) - ->whereNotNull('marketplace_settings_snapshot') - ->get() - ->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0)); + $marketplaceFees = (clone $orderQuery) + ->whereNotNull('marketplace_settings_snapshot') + ->get() + ->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0)); - $itemsData = OrderItem::query() - ->join('orders', 'order_items.order_id', '=', 'orders.id') - ->where('orders.status', OrderStatus::COMPLETED) - ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('orders.marketing_id', $user->id)) - ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('orders.created_by_id', $user->id)) - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) - ->selectRaw(' - SUM(order_items.quantity) as total_qty, - COUNT(order_items.id) as total_items - ') - ->first(); + $itemsData = OrderItem::query() + ->join('orders', 'order_items.order_id', '=', 'orders.id') + ->where('orders.status', OrderStatus::COMPLETED) + ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('orders.marketing_id', $user->id)) + ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('orders.created_by_id', $user->id)) + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) + ->selectRaw(' + SUM(order_items.quantity) as total_qty, + COUNT(order_items.id) as total_items + ') + ->first(); - $totalQty = (int) ($itemsData->total_qty ?? 0); - $totalItems = (int) ($itemsData->total_items ?? 0); + $totalQty = (int) ($itemsData->total_qty ?? 0); + $totalItems = (int) ($itemsData->total_items ?? 0); - // HPP from product_prices with type = 'harga_modal' - $hpp = OrderItem::query() - ->join('orders', 'order_items.order_id', '=', 'orders.id') - ->leftJoin('product_prices', function ($join) { - $join->on('order_items.product_variant_id', '=', 'product_prices.variant_id') - ->where('product_prices.type', PriceType::HARGA_MODAL->value); - }) - ->where('orders.status', OrderStatus::COMPLETED) - ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('orders.marketing_id', $user->id)) - ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('orders.created_by_id', $user->id)) - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) - ->selectRaw('COALESCE(SUM(order_items.quantity * product_prices.price), 0) as total_hpp') - ->value('total_hpp'); + // HPP from product_prices with type = 'harga_modal' + $hpp = OrderItem::query() + ->join('orders', 'order_items.order_id', '=', 'orders.id') + ->leftJoin('product_prices', function ($join) { + $join->on('order_items.product_variant_id', '=', 'product_prices.variant_id') + ->where('product_prices.type', PriceType::HARGA_MODAL->value); + }) + ->where('orders.status', OrderStatus::COMPLETED) + ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('orders.marketing_id', $user->id)) + ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('orders.created_by_id', $user->id)) + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) + ->selectRaw('COALESCE(SUM(order_items.quantity * product_prices.price), 0) as total_hpp') + ->value('total_hpp'); - $totalHpp = (int) ($hpp ?? 0); + $totalHpp = (int) ($hpp ?? 0); - // Expenses - $purchaseTotal = Purchase::query() - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('purchases.created_at', [$startDate, $endDate])) - ->when(! $isSuper, fn ($q) => $q->where('purchases.created_by_id', $user->id)) - ->sum('total'); + // Expenses + $purchaseTotal = Purchase::query() + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('purchases.created_at', [$startDate, $endDate])) + ->when(! $isSuper, fn ($q) => $q->where('purchases.created_by_id', $user->id)) + ->sum('total'); - if (! $user?->hasAnyRole([Role::DEVELOPER->value, Role::OWNER->value])) { - $purchaseTotal = 0; - } + if (! $user?->hasAnyRole([Role::DEVELOPER->value, Role::OWNER->value])) { + $purchaseTotal = 0; + } - $expenseTotal = Expense::query() - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('expenses.created_at', [$startDate, $endDate])) - ->when(! $isSuper, fn ($q) => $q->where('expenses.created_by_id', $user->id)) - ->sum('amount'); + $expenseTotal = Expense::query() + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('expenses.created_at', [$startDate, $endDate])) + ->when(! $isSuper, fn ($q) => $q->where('expenses.created_by_id', $user->id)) + ->sum('amount'); - $advanceTotal = EmployeeAdvance::query() - ->whereIn('status', [EmployeeAdvanceStatus::APPROVED, EmployeeAdvanceStatus::PARTIALLY_PAID]) - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('employee_advances.created_at', [$startDate, $endDate])) - ->when(! $isSuper, fn ($q) => $q->where('employee_id', $user->employee?->id)) - ->sum(DB::raw('amount - paid_amount')); + $advanceTotal = EmployeeAdvance::query() + ->whereIn('status', [EmployeeAdvanceStatus::APPROVED, EmployeeAdvanceStatus::PARTIALLY_PAID]) + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('employee_advances.created_at', [$startDate, $endDate])) + ->when(! $isSuper, fn ($q) => $q->where('employee_id', $user->employee?->id)) + ->sum(DB::raw('amount - paid_amount')); - $totalExpenses = (int) $purchaseTotal + (int) $expenseTotal + (int) $advanceTotal; + $totalExpenses = (int) $purchaseTotal + (int) $expenseTotal + (int) $advanceTotal; - $grossProfit = $totalRevenue - $totalHpp - $totalDiscount - $marketplaceFees; - $netProfit = $grossProfit - (int) $expenseTotal - (int) $advanceTotal; - $profitMargin = $totalRevenue > 0 ? round(($netProfit / $totalRevenue) * 100, 1) : 0; - $aov = $totalOrders > 0 ? (int) round($totalRevenue / $totalOrders) : 0; - $itemsPerTransaction = $totalOrders > 0 ? round($totalItems / $totalOrders, 1) : 0; + $grossProfit = $totalRevenue - $totalHpp - $totalDiscount - $marketplaceFees; + $netProfit = $grossProfit - (int) $expenseTotal - (int) $advanceTotal; + $profitMargin = $totalRevenue > 0 ? round(($netProfit / $totalRevenue) * 100, 1) : 0; + $aov = $totalOrders > 0 ? (int) round($totalRevenue / $totalOrders) : 0; + $itemsPerTransaction = $totalOrders > 0 ? round($totalItems / $totalOrders, 1) : 0; - return [ - 'total_orders' => $totalOrders, - 'total_products_sold' => $totalQty, - 'hpp' => $totalHpp, - 'gross_profit' => $grossProfit, - 'net_profit' => $netProfit, - 'profit_margin' => $profitMargin, - 'aov' => $aov, - 'items_per_transaction' => $itemsPerTransaction, - ]; + return [ + 'total_orders' => $totalOrders, + 'total_products_sold' => $totalQty, + 'hpp' => $totalHpp, + 'gross_profit' => $grossProfit, + 'net_profit' => $netProfit, + 'profit_margin' => $profitMargin, + 'aov' => $aov, + 'items_per_transaction' => $itemsPerTransaction, + ]; + }); } public function getRawMaterialStock(): array { - $prices = RawMaterialPrice::query() - ->join('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id') - ->selectRaw(' - raw_materials.unit, - SUM(raw_material_prices.stock) as total_stock, - SUM(raw_material_prices.stock * raw_material_prices.price) as total_value - ') - ->groupBy('raw_materials.unit') - ->get() - ->keyBy('unit'); + return $this->cacheRemember('analysis:get_raw_material_stock', 900, function () { + $prices = RawMaterialPrice::query() + ->join('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id') + ->selectRaw(' + raw_materials.unit, + SUM(raw_material_prices.stock) as total_stock, + SUM(raw_material_prices.stock * raw_material_prices.price) as total_value + ') + ->groupBy('raw_materials.unit') + ->get() + ->keyBy('unit'); - $totalStock = (float) $prices->sum('total_stock'); - $totalValue = (int) $prices->sum('total_value'); + $totalStock = (float) $prices->sum('total_stock'); + $totalValue = (int) $prices->sum('total_value'); - return [ - 'total_stock' => round($totalStock, 2), - 'total_value' => $totalValue, - 'by_unit' => [ - 'yard' => round((float) ($prices->get('yard')->total_stock ?? 0), 2), - 'meter' => round((float) ($prices->get('meter')->total_stock ?? 0), 2), - 'kilogram' => round((float) ($prices->get('kilogram')->total_stock ?? 0), 2), - ], - ]; + return [ + 'total_stock' => round($totalStock, 2), + 'total_value' => $totalValue, + 'by_unit' => [ + 'yard' => round((float) ($prices->get('yard')->total_stock ?? 0), 2), + 'meter' => round((float) ($prices->get('meter')->total_stock ?? 0), 2), + 'kilogram' => round((float) ($prices->get('kilogram')->total_stock ?? 0), 2), + ], + ]; + }); } public function getProductStock(): array { - $variants = ProductVariant::query() - ->join('products', 'product_variants.product_id', '=', 'products.id') - ->selectRaw(' - SUM(product_variants.stock) as total_stock, - SUM(product_variants.reject_stock) as total_reject, - SUM(product_variants.retail_stock) as total_retail, - COUNT(product_variants.id) as total_variants, - COUNT(DISTINCT products.id) as total_products - ') - ->first(); + return $this->cacheRemember('analysis:get_product_stock', 900, function () { + $variants = ProductVariant::query() + ->join('products', 'product_variants.product_id', '=', 'products.id') + ->selectRaw(' + SUM(product_variants.stock) as total_stock, + SUM(product_variants.reject_stock) as total_reject, + SUM(product_variants.retail_stock) as total_retail, + COUNT(product_variants.id) as total_variants, + COUNT(DISTINCT products.id) as total_products + ') + ->first(); - $totalValue = \DB::table('product_prices') - ->join('product_variants', 'product_prices.variant_id', '=', 'product_variants.id') - ->where('product_prices.type', PriceType::HARGA_MODAL->value) - ->whereNull('product_variants.deleted_at') - ->whereNull('product_prices.deleted_at') - ->selectRaw('SUM(product_variants.stock * product_prices.price) as total') - ->value('total'); + $totalValue = \DB::table('product_prices') + ->join('product_variants', 'product_prices.variant_id', '=', 'product_variants.id') + ->where('product_prices.type', PriceType::HARGA_MODAL->value) + ->whereNull('product_variants.deleted_at') + ->whereNull('product_prices.deleted_at') + ->selectRaw('SUM(product_variants.stock * product_prices.price) as total') + ->value('total'); - $totalCategories = \DB::table('product_categories') - ->distinct('category_id') - ->count('category_id'); + $totalCategories = \DB::table('product_categories') + ->distinct('category_id') + ->count('category_id'); - return [ - 'total_stock' => (int) ($variants->total_stock ?? 0), - 'total_reject' => (int) ($variants->total_reject ?? 0), - 'total_retail' => (int) ($variants->total_retail ?? 0), - 'total_value' => (int) ($totalValue ?? 0), - 'total_products' => (int) ($variants->total_products ?? 0), - 'total_variants' => (int) ($variants->total_variants ?? 0), - 'total_categories' => (int) $totalCategories, - ]; + return [ + 'total_stock' => (int) ($variants->total_stock ?? 0), + 'total_reject' => (int) ($variants->total_reject ?? 0), + 'total_retail' => (int) ($variants->total_retail ?? 0), + 'total_value' => (int) ($totalValue ?? 0), + 'total_products' => (int) ($variants->total_products ?? 0), + 'total_variants' => (int) ($variants->total_variants ?? 0), + 'total_categories' => (int) $totalCategories, + ]; + }); } public function getBusyHours(?Carbon $startDate = null, ?Carbon $endDate = null): array { - /** @var User|null $user */ - $user = auth()->user(); - $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; - $isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false; + return $this->cacheRemember('analysis:get_busy_hours', 900, function () use ($startDate, $endDate) { + /** @var User|null $user */ + $user = auth()->user(); + $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; + $isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false; - $hourlyData = Order::query() - ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) - ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)) - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) - ->selectRaw('HOUR(orders.created_at) as hour, COUNT(*) as order_count') - ->groupBy('hour') - ->orderBy('hour') - ->get() - ->keyBy('hour'); + $hourlyData = Order::query() + ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id)) + ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id)) + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) + ->selectRaw('HOUR(orders.created_at) as hour, COUNT(*) as order_count') + ->groupBy('hour') + ->orderBy('hour') + ->get() + ->keyBy('hour'); - $result = []; - for ($h = 0; $h < 24; $h++) { - $result[] = [ - 'hour' => str_pad($h, 2, '0', STR_PAD_LEFT).':00', - 'orders' => (int) ($hourlyData->get($h)->order_count ?? 0), - ]; - } + $result = []; + for ($h = 0; $h < 24; $h++) { + $result[] = [ + 'hour' => str_pad($h, 2, '0', STR_PAD_LEFT).':00', + 'orders' => (int) ($hourlyData->get($h)->order_count ?? 0), + ]; + } - return $result; + return $result; + }); } public function getTopProducts(?Carbon $startDate = null, ?Carbon $endDate = null): array { - /** @var User|null $user */ - $user = auth()->user(); - $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; - $isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false; + return $this->cacheRemember('analysis:get_top_products', 900, function () use ($startDate, $endDate) { + /** @var User|null $user */ + $user = auth()->user(); + $isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false; + $isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false; - return OrderItem::query() - ->join('orders', 'order_items.order_id', '=', 'orders.id') - ->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id') - ->join('products', 'product_variants.product_id', '=', 'products.id') - ->where('orders.status', OrderStatus::COMPLETED) - ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('orders.marketing_id', $user->id)) - ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('orders.created_by_id', $user->id)) - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) - ->selectRaw("CONCAT(products.name, ' - ', product_variants.name) as full_name, SUM(order_items.quantity) as total_qty, SUM(order_items.subtotal) as total_revenue") - ->groupBy('order_items.product_variant_id', 'products.name', 'product_variants.name') - ->orderByDesc('total_qty') - ->limit(5) - ->get() - ->map(fn ($item) => [ - 'name' => $item->full_name, - 'total_qty' => (int) $item->total_qty, - 'total_revenue' => (int) $item->total_revenue, - ]) - ->toArray(); + return OrderItem::query() + ->join('orders', 'order_items.order_id', '=', 'orders.id') + ->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id') + ->join('products', 'product_variants.product_id', '=', 'products.id') + ->where('orders.status', OrderStatus::COMPLETED) + ->when(! $isSuper && $isMarketing, fn ($q) => $q->where('orders.marketing_id', $user->id)) + ->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('orders.created_by_id', $user->id)) + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) + ->selectRaw("CONCAT(products.name, ' - ', product_variants.name) as full_name, SUM(order_items.quantity) as total_qty, SUM(order_items.subtotal) as total_revenue") + ->groupBy('order_items.product_variant_id', 'products.name', 'product_variants.name') + ->orderByDesc('total_qty') + ->limit(5) + ->get() + ->map(fn ($item) => [ + 'name' => $item->full_name, + 'total_qty' => (int) $item->total_qty, + 'total_revenue' => (int) $item->total_revenue, + ]) + ->toArray(); + }); } public function getMarketingSales(?Carbon $startDate = null, ?Carbon $endDate = null): array { - $qtySubquery = DB::table('order_items') - ->select('order_id', DB::raw('SUM(quantity) as total_qty')) - ->whereNull('deleted_at') - ->groupBy('order_id'); + return $this->cacheRemember('analysis:get_marketing_sales', 900, function () use ($startDate, $endDate) { + $qtySubquery = DB::table('order_items') + ->select('order_id', DB::raw('SUM(quantity) as total_qty')) + ->whereNull('deleted_at') + ->groupBy('order_id'); - return Order::query() - ->completed() - ->whereNotNull('marketing_id') - ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) - ->join('users', 'orders.marketing_id', '=', 'users.id') - ->leftJoin('user_profiles', 'users.id', '=', 'user_profiles.user_id') - ->leftJoinSub($qtySubquery, 'order_qtys', function ($join) { - $join->on('orders.id', '=', 'order_qtys.order_id'); - }) - ->selectRaw(' - users.id as marketing_id, - COALESCE(user_profiles.full_name, users.username) as marketing_name, - COUNT(orders.id) as total_orders, - SUM(orders.total_amount) as total_revenue, - SUM(orders.subtotal) as total_subtotal, - SUM(orders.discount) as total_discount, - AVG(orders.total_amount) as avg_order, - SUM(COALESCE(order_qtys.total_qty, 0)) as total_products_sold - ') - ->groupBy('users.id', 'user_profiles.full_name', 'users.username') - ->orderByDesc('total_revenue') - ->get() - ->map(fn ($item) => [ - 'marketing_name' => $item->marketing_name, - 'total_orders' => (int) $item->total_orders, - 'total_products_sold' => (int) $item->total_products_sold, - 'total_revenue' => (int) $item->total_revenue, - 'total_subtotal' => (int) $item->total_subtotal, - 'total_discount' => (int) $item->total_discount, - 'avg_order' => (int) $item->avg_order, - ]) - ->toArray(); + return Order::query() + ->completed() + ->whereNotNull('marketing_id') + ->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate])) + ->join('users', 'orders.marketing_id', '=', 'users.id') + ->leftJoin('user_profiles', 'users.id', '=', 'user_profiles.user_id') + ->leftJoinSub($qtySubquery, 'order_qtys', function ($join) { + $join->on('orders.id', '=', 'order_qtys.order_id'); + }) + ->selectRaw(' + users.id as marketing_id, + COALESCE(user_profiles.full_name, users.username) as marketing_name, + COUNT(orders.id) as total_orders, + SUM(orders.total_amount) as total_revenue, + SUM(orders.subtotal) as total_subtotal, + SUM(orders.discount) as total_discount, + AVG(orders.total_amount) as avg_order, + SUM(COALESCE(order_qtys.total_qty, 0)) as total_products_sold + ') + ->groupBy('users.id', 'user_profiles.full_name', 'users.username') + ->orderByDesc('total_revenue') + ->get() + ->map(fn ($item) => [ + 'marketing_name' => $item->marketing_name, + 'total_orders' => (int) $item->total_orders, + 'total_products_sold' => (int) $item->total_products_sold, + 'total_revenue' => (int) $item->total_revenue, + 'total_subtotal' => (int) $item->total_subtotal, + 'total_discount' => (int) $item->total_discount, + 'avg_order' => (int) $item->avg_order, + ]) + ->toArray(); + }); } public function isManager(?User $user): bool diff --git a/app/Services/System/HomepageService.php b/app/Services/System/HomepageService.php index 345e55e..fc6f82b 100644 --- a/app/Services/System/HomepageService.php +++ b/app/Services/System/HomepageService.php @@ -5,6 +5,7 @@ use App\Models\Category; use App\Models\Product; use App\Models\SystemConfiguration; +use App\Services\Concerns\CachesQuery; use App\Services\Manage\CuttingResultPriceResolver; use App\Services\System\Setting\HomepageSettingService; use App\Settings\SocialMediaSettings; @@ -13,6 +14,8 @@ class HomepageService { + use CachesQuery; + public function __construct( private readonly CuttingResultPriceResolver $cuttingResultPriceResolver, private readonly HomepageSettingService $homepageSettingService, @@ -20,30 +23,32 @@ public function __construct( public function pageData(): array { - $categories = Category::getActiveWithProducts(); - $products = $this->getProducts(); + return $this->cacheRemember('homepage:page_data', 900, function () { + $categories = Category::getActiveWithProducts(); + $products = $this->getProducts(); - $configuration = SystemConfiguration::instance(); - $logo = MediaPresenter::first($configuration, 'logo'); - $logoUrl = $logo['url'] ?? null; + $configuration = SystemConfiguration::instance(); + $logo = MediaPresenter::first($configuration, 'logo'); + $logoUrl = $logo['url'] ?? null; - $settings = app(SystemSettings::class); - $socialSettings = app(SocialMediaSettings::class); + $settings = app(SystemSettings::class); + $socialSettings = app(SocialMediaSettings::class); - return [ - 'categories' => $categories, - 'products' => $products, - 'appName' => $settings->app_name ?? 'DST Collection', - 'aboutApp' => $settings->about_app ?? '', - 'contactEmail' => $settings->email ?? '', - 'contactPhone' => $settings->phone ?? '', - 'contactAddress' => $settings->address ?? '', - 'logoUrl' => $logoUrl, - 'instagramUrl' => $socialSettings->instagram_url ?? null, - 'facebookUrl' => $socialSettings->facebook_url ?? null, - 'tiktokUrl' => $socialSettings->tiktok_url ?? null, - 'homepage' => $this->homepageSettingService->homepageData(), - ]; + return [ + 'categories' => $categories, + 'products' => $products, + 'appName' => $settings->app_name ?? 'DST Collection', + 'aboutApp' => $settings->about_app ?? '', + 'contactEmail' => $settings->email ?? '', + 'contactPhone' => $settings->phone ?? '', + 'contactAddress' => $settings->address ?? '', + 'logoUrl' => $logoUrl, + 'instagramUrl' => $socialSettings->instagram_url ?? null, + 'facebookUrl' => $socialSettings->facebook_url ?? null, + 'tiktokUrl' => $socialSettings->tiktok_url ?? null, + 'homepage' => $this->homepageSettingService->homepageData(), + ]; + }); } private function getProducts() diff --git a/app/Services/System/RoleService.php b/app/Services/System/RoleService.php index 33f41e5..776bef8 100644 --- a/app/Services/System/RoleService.php +++ b/app/Services/System/RoleService.php @@ -4,6 +4,7 @@ use App\Enums\Permission as PermissionEnum; use App\Enums\Role as EnumsRole; +use App\Services\Concerns\CachesQuery; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; @@ -14,6 +15,8 @@ class RoleService { + use CachesQuery; + public function paginateForIndex(array $tableQuery): LengthAwarePaginator { $query = Role::query() @@ -34,14 +37,16 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator public function permissionOptions(): array { - return collect(PermissionEnum::cases()) - ->map(fn (PermissionEnum $permission) => [ - 'value' => $permission->value, - 'label' => $permission->label(), - 'group' => $permission->group(), - ]) - ->values() - ->all(); + return $this->cacheRemember('system:permissions', 86400, function (): array { + return collect(PermissionEnum::cases()) + ->map(fn (PermissionEnum $permission) => [ + 'value' => $permission->value, + 'label' => $permission->label(), + 'group' => $permission->group(), + ]) + ->values() + ->all(); + }); } public function findForEdit(Role $role): array @@ -81,6 +86,8 @@ public function create(array $validated): void 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } + + $this->cacheForgetByPattern('system:roles:*'); } public function update(Role $role, array $validated): void @@ -106,11 +113,15 @@ public function update(Role $role, array $validated): void 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } + + $this->cacheForgetByPattern('system:roles:*'); } public function delete(Role $role): void { $role->delete(); + + $this->cacheForgetByPattern('system:roles:*'); } private function applySorting(Builder $query, string $sort, string $direction): void diff --git a/app/Services/System/Setting/HomepageSettingService.php b/app/Services/System/Setting/HomepageSettingService.php index ab8ae58..38a8cc6 100644 --- a/app/Services/System/Setting/HomepageSettingService.php +++ b/app/Services/System/Setting/HomepageSettingService.php @@ -3,6 +3,7 @@ namespace App\Services\System\Setting; use App\Models\HomepageConfiguration; +use App\Services\Concerns\CachesQuery; use App\Services\Concerns\RunsInTransaction; use App\Services\Concerns\SyncsPhotos; use App\Services\Media\MediaService; @@ -10,7 +11,7 @@ class HomepageSettingService { - use RunsInTransaction, SyncsPhotos; + use CachesQuery, RunsInTransaction, SyncsPhotos; public function __construct( private readonly MediaService $mediaService, @@ -18,18 +19,20 @@ public function __construct( public function homepageData(): array { - $configuration = HomepageConfiguration::instance(); - $configuration->load('media'); + return $this->cacheRemember('homepage:settings', 86400, function () { + $configuration = HomepageConfiguration::instance(); + $configuration->load('media'); - $heroImage = MediaPresenter::first($configuration, 'hero_image'); - $aboutImage = MediaPresenter::first($configuration, 'about_image'); - $galleryImages = MediaPresenter::collection($configuration, 'gallery'); + $heroImage = MediaPresenter::first($configuration, 'hero_image'); + $aboutImage = MediaPresenter::first($configuration, 'about_image'); + $galleryImages = MediaPresenter::collection($configuration, 'gallery'); - return [ - 'hero_image_url' => $heroImage['url'] ?? null, - 'about_image_url' => $aboutImage['url'] ?? null, - 'gallery_images' => $galleryImages, - ]; + return [ + 'hero_image_url' => $heroImage['url'] ?? null, + 'about_image_url' => $aboutImage['url'] ?? null, + 'gallery_images' => $galleryImages, + ]; + }); } public function updateHomepage(array $validated): void @@ -74,5 +77,8 @@ function () use ($validated): void { }, 'Gagal memperbarui pengaturan homepage', ); + + $this->cacheForget('homepage:settings'); + $this->cacheForget('homepage:page_data'); } } diff --git a/app/Services/System/Setting/HrSettingService.php b/app/Services/System/Setting/HrSettingService.php index 590aa51..8a1b7a8 100644 --- a/app/Services/System/Setting/HrSettingService.php +++ b/app/Services/System/Setting/HrSettingService.php @@ -2,20 +2,25 @@ namespace App\Services\System\Setting; +use App\Services\Concerns\CachesQuery; use App\Settings\HrSettings; class HrSettingService { + use CachesQuery; + public function hrData(): array { - $settings = app(HrSettings::class); + return $this->cacheRemember('system:hr_settings', 86400, function () { + $settings = app(HrSettings::class); - return [ - 'scheduled_check_in_time' => $settings->scheduled_check_in_time, - 'scheduled_check_out_time' => $settings->scheduled_check_out_time, - 'late_penalty_amount' => $settings->late_penalty_amount, - 'absent_penalty_amount' => $settings->absent_penalty_amount, - ]; + return [ + 'scheduled_check_in_time' => $settings->scheduled_check_in_time, + 'scheduled_check_out_time' => $settings->scheduled_check_out_time, + 'late_penalty_amount' => $settings->late_penalty_amount, + 'absent_penalty_amount' => $settings->absent_penalty_amount, + ]; + }); } public function updateHr(array $validated): void @@ -27,5 +32,7 @@ public function updateHr(array $validated): void $settings->late_penalty_amount = (int) $validated['late_penalty_amount']; $settings->absent_penalty_amount = (int) $validated['absent_penalty_amount']; $settings->save(); + + $this->cacheForget('system:hr_settings'); } } diff --git a/app/Services/System/Setting/MarketplaceService.php b/app/Services/System/Setting/MarketplaceService.php index 779d6bc..c499018 100644 --- a/app/Services/System/Setting/MarketplaceService.php +++ b/app/Services/System/Setting/MarketplaceService.php @@ -8,6 +8,7 @@ use App\Enums\Permission; use App\Models\OwnerVerificationRequest; use App\Models\User; +use App\Services\Concerns\CachesQuery; use App\Services\Concerns\RunsInTransaction; use App\Services\System\PushNotificationService; use App\Settings\MarketplaceSettings; @@ -17,7 +18,7 @@ class MarketplaceService { - use RunsInTransaction; + use CachesQuery, RunsInTransaction; public function __construct( private readonly PushNotificationService $pushNotificationService, @@ -52,25 +53,27 @@ private function shopeeFeeKeys(): array public function marketplaceData(): array { - $settings = app(MarketplaceSettings::class); + return $this->cacheRemember('system:marketplace', 86400, function () { + $settings = app(MarketplaceSettings::class); - return [ - 'tiktok_shop_platform_commission' => $this->presentFeeRule($settings->tiktok_shop_platform_commission), - 'tiktok_shop_logistics_service_fee' => $this->presentFeeRule($settings->tiktok_shop_logistics_service_fee), - 'tiktok_shop_dynamic_commission' => $this->presentFeeRule($settings->tiktok_shop_dynamic_commission), - 'tiktok_shop_order_processing_fee' => $this->presentFeeRule($settings->tiktok_shop_order_processing_fee), - 'tiktok_shop_affiliate' => $this->presentFeeRule($settings->tiktok_shop_affiliate), - 'tiktok_shop_pre_order_service_fee' => $this->presentFeeRule($settings->tiktok_shop_pre_order_service_fee), - 'shopee_admin_fee' => $this->presentFeeRule($settings->shopee_admin_fee), - 'shopee_program_fee' => $this->presentFeeRule($settings->shopee_program_fee), - 'shopee_shipping_savings' => $this->presentFeeRule($settings->shopee_shipping_savings), - 'shopee_premium' => $this->presentFeeRule($settings->shopee_premium), - 'shopee_service_fee' => $this->presentFeeRule($settings->shopee_service_fee), - 'shopee_order_processing_fee' => $this->presentFeeRule($settings->shopee_order_processing_fee), - 'shopee_ams_commission_fee' => $this->presentFeeRule($settings->shopee_ams_commission_fee), - 'shopee_pre_order' => $this->presentFeeRule($settings->shopee_pre_order), - 'shopee_live_extra' => $this->presentFeeRule($settings->shopee_live_extra), - ]; + return [ + 'tiktok_shop_platform_commission' => $this->presentFeeRule($settings->tiktok_shop_platform_commission), + 'tiktok_shop_logistics_service_fee' => $this->presentFeeRule($settings->tiktok_shop_logistics_service_fee), + 'tiktok_shop_dynamic_commission' => $this->presentFeeRule($settings->tiktok_shop_dynamic_commission), + 'tiktok_shop_order_processing_fee' => $this->presentFeeRule($settings->tiktok_shop_order_processing_fee), + 'tiktok_shop_affiliate' => $this->presentFeeRule($settings->tiktok_shop_affiliate), + 'tiktok_shop_pre_order_service_fee' => $this->presentFeeRule($settings->tiktok_shop_pre_order_service_fee), + 'shopee_admin_fee' => $this->presentFeeRule($settings->shopee_admin_fee), + 'shopee_program_fee' => $this->presentFeeRule($settings->shopee_program_fee), + 'shopee_shipping_savings' => $this->presentFeeRule($settings->shopee_shipping_savings), + 'shopee_premium' => $this->presentFeeRule($settings->shopee_premium), + 'shopee_service_fee' => $this->presentFeeRule($settings->shopee_service_fee), + 'shopee_order_processing_fee' => $this->presentFeeRule($settings->shopee_order_processing_fee), + 'shopee_ams_commission_fee' => $this->presentFeeRule($settings->shopee_ams_commission_fee), + 'shopee_pre_order' => $this->presentFeeRule($settings->shopee_pre_order), + 'shopee_live_extra' => $this->presentFeeRule($settings->shopee_live_extra), + ]; + }); } public function updateMarketplace(array $validated, User $user): void @@ -151,6 +154,8 @@ public function saveSettings(array $validated): void } $settings->save(); + + $this->cacheForget('system:marketplace'); } public function applyVerificationRequest(OwnerVerificationRequest $request): void @@ -159,6 +164,7 @@ public function applyVerificationRequest(OwnerVerificationRequest $request): voi function () use ($request): void { $newPayload = $request->payload['new'] ?? []; $this->saveSettings($newPayload); + $this->cacheForget('system:marketplace'); }, 'Gagal menerapkan pengajuan verifikasi owner', ); diff --git a/app/Services/System/Setting/SocialMediaService.php b/app/Services/System/Setting/SocialMediaService.php index b46a313..6fbb4ca 100644 --- a/app/Services/System/Setting/SocialMediaService.php +++ b/app/Services/System/Setting/SocialMediaService.php @@ -2,19 +2,24 @@ namespace App\Services\System\Setting; +use App\Services\Concerns\CachesQuery; use App\Settings\SocialMediaSettings; class SocialMediaService { + use CachesQuery; + public function socialMediaData(): array { - $settings = app(SocialMediaSettings::class); + return $this->cacheRemember('system:social_media', 86400, function () { + $settings = app(SocialMediaSettings::class); - return [ - 'instagram_url' => $settings->instagram_url, - 'facebook_url' => $settings->facebook_url, - 'tiktok_url' => $settings->tiktok_url, - ]; + return [ + 'instagram_url' => $settings->instagram_url, + 'facebook_url' => $settings->facebook_url, + 'tiktok_url' => $settings->tiktok_url, + ]; + }); } public function updateSocialMedia(array $validated): void @@ -26,5 +31,8 @@ public function updateSocialMedia(array $validated): void $settings->tiktok_url = $validated['tiktok_url'] ?? null; $settings->save(); + + $this->cacheForget('system:social_media'); + $this->cacheForget('homepage:page_data'); } } diff --git a/app/Services/System/Setting/SystemService.php b/app/Services/System/Setting/SystemService.php index bade6de..91c33f2 100644 --- a/app/Services/System/Setting/SystemService.php +++ b/app/Services/System/Setting/SystemService.php @@ -3,6 +3,7 @@ namespace App\Services\System\Setting; use App\Models\SystemConfiguration; +use App\Services\Concerns\CachesQuery; use App\Services\Concerns\RunsInTransaction; use App\Services\Concerns\SyncsPhotos; use App\Services\Media\MediaService; @@ -12,7 +13,7 @@ class SystemService { - use RunsInTransaction, SyncsPhotos; + use CachesQuery, RunsInTransaction, SyncsPhotos; public function __construct( private readonly MediaService $mediaService, @@ -20,24 +21,26 @@ public function __construct( public function systemData(): array { - $settings = app(SystemSettings::class); - $configuration = SystemConfiguration::instance(); - $configuration->load('media'); + return $this->cacheRemember('system:settings', 86400, function () { + $settings = app(SystemSettings::class); + $configuration = SystemConfiguration::instance(); + $configuration->load('media'); - $logo = MediaPresenter::first($configuration, 'logo'); - $favicon = MediaPresenter::first($configuration, 'favicon'); - $loginCover = MediaPresenter::first($configuration, 'login_cover'); + $logo = MediaPresenter::first($configuration, 'logo'); + $favicon = MediaPresenter::first($configuration, 'favicon'); + $loginCover = MediaPresenter::first($configuration, 'login_cover'); - return [ - 'app_name' => $settings->app_name, - 'about_app' => $settings->about_app, - 'email' => $settings->email, - 'phone' => $settings->phone, - 'address' => $settings->address, - 'logo_url' => $logo['url'] ?? null, - 'favicon_url' => $favicon['url'] ?? null, - 'login_cover_url' => $loginCover['url'] ?? null, - ]; + return [ + 'app_name' => $settings->app_name, + 'about_app' => $settings->about_app, + 'email' => $settings->email, + 'phone' => $settings->phone, + 'address' => $settings->address, + 'logo_url' => $logo['url'] ?? null, + 'favicon_url' => $favicon['url'] ?? null, + 'login_cover_url' => $loginCover['url'] ?? null, + ]; + }); } public function updateSystem(array $validated): void @@ -107,5 +110,8 @@ function () use ($validated): void { }, 'Gagal memperbarui pengaturan sistem', ); + + $this->cacheForget('system:settings'); + $this->cacheForget('homepage:page_data'); } } diff --git a/composer.json b/composer.json index 02c41a6..da5d988 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-ftp": "^3.0", "minishlink/web-push": "^9.0", + "predis/predis": "^3.5", "spatie/laravel-activitylog": "^5.0", "spatie/laravel-medialibrary": "^11.23", "spatie/laravel-permission": "^8.0", diff --git a/composer.lock b/composer.lock index 2eb72b0..49255eb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ea578c35bcf9ab3ac130534272e48937", + "content-hash": "af3de95989c6c9eabd05600486ed22da", "packages": [ { "name": "aws/aws-crt-php", @@ -3502,6 +3502,69 @@ }, "time": "2026-01-25T14:56:51+00:00" }, + { + "name": "predis/predis", + "version": "v3.5.1", + "source": { + "type": "git", + "url": "https://github.com/predis/predis.git", + "reference": "5c996db191ee2d9bafe651f454b1fca16754271b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/predis/predis/zipball/5c996db191ee2d9bafe651f454b1fca16754271b", + "reference": "5c996db191ee2d9bafe651f454b1fca16754271b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.0|^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3", + "phpstan/phpstan": "^1.9", + "phpunit/phpcov": "^6.0 || ^8.0", + "phpunit/phpunit": "^8.0 || ~9.4.4" + }, + "suggest": { + "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" + }, + "type": "library", + "autoload": { + "psr-4": { + "Predis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Till KrΓΌss", + "homepage": "https://till.im", + "role": "Maintainer" + } + ], + "description": "A flexible and feature-complete Redis/Valkey client for PHP.", + "homepage": "http://github.com/predis/predis", + "keywords": [ + "nosql", + "predis", + "redis" + ], + "support": { + "issues": "https://github.com/predis/predis/issues", + "source": "https://github.com/predis/predis/tree/v3.5.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/tillkruss", + "type": "github" + } + ], + "time": "2026-06-11T16:56:53+00:00" + }, { "name": "psr/clock", "version": "1.0.0", diff --git a/database/migrations/2026_07_05_124618_add_stock_quality_to_stok_opname_items_table.php b/database/migrations/2026_07_05_124618_add_stock_quality_to_stok_opname_items_table.php index c2b5f6c..9a809d6 100644 --- a/database/migrations/2026_07_05_124618_add_stock_quality_to_stok_opname_items_table.php +++ b/database/migrations/2026_07_05_124618_add_stock_quality_to_stok_opname_items_table.php @@ -16,10 +16,10 @@ public function up(): void $table->dropForeign(['stok_opname_id']); $table->dropForeign(['product_variant_id']); $table->dropUnique('stok_opname_items_stok_opname_id_product_variant_id_unique'); - + $table->enum('stock_quality', ProductStockQuality::values())->default(ProductStockQuality::GOOD->value)->after('product_variant_id'); $table->unique(['stok_opname_id', 'product_variant_id', 'stock_quality'], 'stok_opname_items_opname_variant_quality_unique'); - + $table->foreign('stok_opname_id')->references('id')->on('stok_opnames')->cascadeOnDelete(); $table->foreign('product_variant_id')->references('id')->on('product_variants')->cascadeOnDelete(); }); @@ -34,10 +34,10 @@ public function down(): void $table->dropForeign(['stok_opname_id']); $table->dropForeign(['product_variant_id']); $table->dropUnique('stok_opname_items_opname_variant_quality_unique'); - + $table->dropColumn('stock_quality'); $table->unique(['stok_opname_id', 'product_variant_id']); - + $table->foreign('stok_opname_id')->references('id')->on('stok_opnames')->cascadeOnDelete(); $table->foreign('product_variant_id')->references('id')->on('product_variants')->cascadeOnDelete(); }); diff --git a/routes/web.php b/routes/web.php index d16f14e..926da91 100644 --- a/routes/web.php +++ b/routes/web.php @@ -338,7 +338,6 @@ ->middleware('permission:'.Permission::CUTTINGS_COMPLETE->value.'|'.Permission::CUTTINGS_VERIFY->value.'|'.Permission::CUTTINGS_REJECT->value) ->name('transition_status'); - Route::post('draft-materials', [CuttingDraftItemController::class, 'storeMaterial']) ->middleware('permission:'.Permission::CUTTINGS_CREATE->value) ->name('draft_materials.store'); diff --git a/tests/Feature/Admin/Manage/CuttingTest.php b/tests/Feature/Admin/Manage/CuttingTest.php index 68ee493..382ee21 100644 --- a/tests/Feature/Admin/Manage/CuttingTest.php +++ b/tests/Feature/Admin/Manage/CuttingTest.php @@ -2,6 +2,7 @@ use App\Enums\CuttingStatus; use App\Enums\Permission as PermissionEnum; +use App\Models\Category; use App\Models\Cutting; use App\Models\CuttingMaterial; use App\Models\CuttingMaterialCombination; @@ -761,7 +762,7 @@ function setupDraftItems(User $user): array test('quick create product with categories', function () { $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE); - $category = \App\Models\Category::factory()->create(); + $category = Category::factory()->create(); $this->actingAs($user) ->postJson('/admin/manage/cuttings/quick-create-product', [ @@ -777,7 +778,7 @@ function setupDraftItems(User $user): array ]) ->assertOk(); - $product = \App\Models\Product::where('name', 'Product With Category')->first(); + $product = Product::where('name', 'Product With Category')->first(); expect($product)->not->toBeNull(); expect($product->categories)->toHaveCount(1); }); diff --git a/tests/Feature/Admin/Manage/StockTest.php b/tests/Feature/Admin/Manage/StockTest.php index 07a42dc..d6f42b7 100644 --- a/tests/Feature/Admin/Manage/StockTest.php +++ b/tests/Feature/Admin/Manage/StockTest.php @@ -102,7 +102,7 @@ function createCompletedCuttingSetup(): array 'product_variant_id' => $setup['variant']->id, 'good' => 6, // changed from 5 to 6 'reject' => 1, // changed from 2 to 1 - ] + ], ], 'result_prices' => [ [ @@ -115,9 +115,9 @@ function createCompletedCuttingSetup(): array [ 'type' => 'retail', 'price' => 75000, - ] - ] - ] + ], + ], + ], ], ]; diff --git a/tests/Feature/Admin/Manage/StokOpnameTest.php b/tests/Feature/Admin/Manage/StokOpnameTest.php index d843f12..f940762 100644 --- a/tests/Feature/Admin/Manage/StokOpnameTest.php +++ b/tests/Feature/Admin/Manage/StokOpnameTest.php @@ -105,7 +105,7 @@ function createCatalogSetup(): array 'stock_quality' => ProductStockQuality::REJECT->value, 'physical_stock' => 3, 'notes' => 'Kelebihan 1 reject', - ] + ], ], ]; @@ -150,7 +150,7 @@ function createCatalogSetup(): array 'stock_quality' => ProductStockQuality::RETAIL->value, 'physical_stock' => 6, 'notes' => 'Tambah eceran', - ] + ], ], ]; @@ -198,7 +198,7 @@ function createCatalogSetup(): array 'stock_quality' => ProductStockQuality::GOOD->value, 'physical_stock' => 9, 'notes' => 'Diubah jadi 9', - ] + ], ], ]; diff --git a/tests/Feature/CacheTest.php b/tests/Feature/CacheTest.php new file mode 100644 index 0000000..93b611c --- /dev/null +++ b/tests/Feature/CacheTest.php @@ -0,0 +1,294 @@ +seed(RolePermissionSeeder::class); +}); + +// ─── CachesQuery Trait ───────────────────────────────────── + +describe('CachesQuery Trait', function () { + test('cacheRemember stores and retrieves value', function () { + $service = new class + { + use CachesQuery; + + public function callRemember(): array + { + return $this->cacheRemember('test:key', 60, function () { + return ['foo' => 'bar']; + }); + } + }; + + $result = $service->callRemember(); + expect($result)->toBe(['foo' => 'bar']); + expect(Cache::has('test:key'))->toBeTrue(); + }); + + test('cacheRemember returns cached value on second call', function () { + $service = new class + { + use CachesQuery; + + public int $callCount = 0; + + public function callRemember(): array + { + return $this->cacheRemember('test:counter', 60, function () { + $this->callCount++; + + return ['count' => $this->callCount]; + }); + } + }; + + $first = $service->callRemember(); + $second = $service->callRemember(); + + expect($first)->toBe(['count' => 1]); + expect($second)->toBe(['count' => 1]); + expect($service->callCount)->toBe(1); + }); + + test('cacheForget removes cached value', function () { + $service = new class + { + use CachesQuery; + + public function callRemember(): array + { + return $this->cacheRemember('test:forget', 60, function () { + return ['data' => true]; + }); + } + + public function callForget(): void + { + $this->cacheForget('test:forget'); + } + }; + + $service->callRemember(); + expect(Cache::has('test:forget'))->toBeTrue(); + + $service->callForget(); + expect(Cache::has('test:forget'))->toBeFalse(); + }); + + test('cacheForever stores value without expiration', function () { + $service = new class + { + use CachesQuery; + + public function callForever(): string + { + return $this->cacheForever('test:forever', function () { + return 'permanent'; + }); + } + }; + + $result = $service->callForever(); + expect($result)->toBe('permanent'); + expect(Cache::has('test:forever'))->toBeTrue(); + }); +}); + +// ─── Category Cache ──────────────────────────────────────── + +describe('Category Caching', function () { + test('getSelectOptions caches results', function () { + Category::factory()->count(3)->create(); + + $service = app(CategoryService::class); + + $first = $service->getSelectOptions(); + expect($first)->toHaveCount(3); + expect(Cache::has('master:categories:options'))->toBeTrue(); + + Category::create(['name' => 'New Category']); + + $second = $service->getSelectOptions(); + expect($second)->toHaveCount(3); + + Cache::forget('master:categories:options'); + $third = $service->getSelectOptions(); + expect($third)->toHaveCount(4); + }); + + test('create invalidates category cache', function () { + $service = app(CategoryService::class); + + $service->getSelectOptions(); + expect(Cache::has('master:categories:options'))->toBeTrue(); + + $service->create(['name' => 'Test']); + expect(Cache::has('master:categories:options'))->toBeFalse(); + }); + + test('update invalidates category cache', function () { + $category = Category::factory()->create(); + $service = app(CategoryService::class); + + $service->getSelectOptions(); + expect(Cache::has('master:categories:options'))->toBeTrue(); + + $service->update($category, ['name' => 'Updated']); + expect(Cache::has('master:categories:options'))->toBeFalse(); + }); + + test('delete invalidates category cache', function () { + $category = Category::factory()->create(); + $service = app(CategoryService::class); + + $service->getSelectOptions(); + expect(Cache::has('master:categories:options'))->toBeTrue(); + + $service->delete($category); + expect(Cache::has('master:categories:options'))->toBeFalse(); + }); +}); + +// ─── Supplier Cache ──────────────────────────────────────── + +describe('Supplier Caching', function () { + test('getSelectOptions caches results', function () { + Supplier::factory()->count(3)->create(); + + $service = app(SupplierService::class); + + $first = $service->getSelectOptions(); + expect($first)->toHaveCount(3); + expect(Cache::has('master:suppliers:options'))->toBeTrue(); + + Cache::forget('master:suppliers:options'); + $second = $service->getSelectOptions(); + expect($second)->toHaveCount(3); + }); + + test('create invalidates supplier cache', function () { + $service = app(SupplierService::class); + + $service->getSelectOptions(); + expect(Cache::has('master:suppliers:options'))->toBeTrue(); + + $service->create(['name' => 'Test Supplier', 'phone_number' => '08123', 'address' => 'Jl. Test']); + expect(Cache::has('master:suppliers:options'))->toBeFalse(); + }); + + test('update invalidates supplier cache', function () { + $supplier = Supplier::factory()->create(); + $service = app(SupplierService::class); + + $service->getSelectOptions(); + expect(Cache::has('master:suppliers:options'))->toBeTrue(); + + $service->update($supplier, ['name' => 'Updated']); + expect(Cache::has('master:suppliers:options'))->toBeFalse(); + }); + + test('delete invalidates supplier cache', function () { + $supplier = Supplier::factory()->create(); + $service = app(SupplierService::class); + + $service->getSelectOptions(); + expect(Cache::has('master:suppliers:options'))->toBeTrue(); + + $service->delete($supplier); + expect(Cache::has('master:suppliers:options'))->toBeFalse(); + }); +}); + +// ─── Customer Cache ──────────────────────────────────────── + +describe('Customer Caching', function () { + test('getSelectOptions caches results', function () { + Customer::factory()->count(3)->create(); + + $service = app(CustomerService::class); + + $first = $service->getSelectOptions(); + expect($first)->toHaveCount(3); + expect(Cache::has('master:customers:options'))->toBeTrue(); + + Cache::forget('master:customers:options'); + $second = $service->getSelectOptions(); + expect($second)->toHaveCount(3); + }); + + test('create invalidates customer cache', function () { + $service = app(CustomerService::class); + + $service->getSelectOptions(); + expect(Cache::has('master:customers:options'))->toBeTrue(); + + $service->create(['name' => 'Test Customer', 'phone_number' => '08123', 'address' => 'Jl. Test']); + expect(Cache::has('master:customers:options'))->toBeFalse(); + }); + + test('createAndReturn invalidates customer cache', function () { + $service = app(CustomerService::class); + + $service->getSelectOptions(); + expect(Cache::has('master:customers:options'))->toBeTrue(); + + $customer = $service->createAndReturn(['name' => 'Test', 'phone_number' => '08123', 'address' => 'Jl. Test']); + expect($customer)->toBeInstanceOf(Customer::class); + expect(Cache::has('master:customers:options'))->toBeFalse(); + }); + + test('update invalidates customer cache', function () { + $customer = Customer::factory()->create(); + $service = app(CustomerService::class); + + $service->getSelectOptions(); + expect(Cache::has('master:customers:options'))->toBeTrue(); + + $service->update($customer, ['name' => 'Updated']); + expect(Cache::has('master:customers:options'))->toBeFalse(); + }); + + test('delete invalidates customer cache', function () { + $customer = Customer::factory()->create(); + $service = app(CustomerService::class); + + $service->getSelectOptions(); + expect(Cache::has('master:customers:options'))->toBeTrue(); + + $service->delete($customer); + expect(Cache::has('master:customers:options'))->toBeFalse(); + }); +}); + +// ─── Cache Key Isolation ─────────────────────────────────── + +describe('Cache Key Isolation', function () { + test('different services use different cache keys', function () { + Category::factory()->count(2)->create(); + Supplier::factory()->count(3)->create(); + + app(CategoryService::class)->getSelectOptions(); + app(SupplierService::class)->getSelectOptions(); + + expect(Cache::has('master:categories:options'))->toBeTrue(); + expect(Cache::has('master:suppliers:options'))->toBeTrue(); + + Cache::forget('master:categories:options'); + + expect(Cache::has('master:categories:options'))->toBeFalse(); + expect(Cache::has('master:suppliers:options'))->toBeTrue(); + }); +});