From fc54e20fa4e44aaa04a51a227d20eb4f235e34ad Mon Sep 17 00:00:00 2001 From: arifal hidayat Date: Wed, 18 Jun 2025 02:54:41 +0700 Subject: [PATCH] add spatial plannings retribution calculations --- .../CalculateRetributionProposalsCommand.php | 288 ++++++++++++++++ .../CheckSpatialPlanningConstraints.php | 101 ++++++ .../Commands/InitSpatialPlanningDatas.php | 150 +++++++- ...ocessSpatialPlanningRetributionCommand.php | 321 ++++++++++++++++++ .../Commands/TestExcelFormulaCommand.php | 209 ++++++++++++ .../Api/BigDataResumeController.php | 5 +- .../RetributionProposalController.php | 295 ++++++++++++++++ app/Models/BuildingFunction.php | 105 ++++-- app/Models/BuildingFunctionParameter.php | 79 ++++- app/Models/FloorHeightIndex.php | 56 +++ app/Models/RetributionCalculation.php | 129 ------- app/Models/RetributionFormula.php | 190 +++++++++-- app/Models/RetributionProposal.php | 187 ++++++++++ app/Models/SpatialPlanning.php | 43 ++- app/Services/RetributionProposalService.php | 294 ++++++++++++++++ app/Services/ServiceGoogleSheet.php | 8 +- ...163401_create_building_functions_table.php | 18 +- ...5000_create_floor_height_indices_table.php | 34 ++ ...ate_building_function_parameters_table.php | 13 +- ...4433_create_retribution_formulas_table.php | 9 +- ..._create_retribution_calculations_table.php | 44 --- ...558_create_retribution_proposals_table.php | 46 +++ .../BuildingFunctionParameterSeeder.php | 148 ++++++++ database/seeders/BuildingFunctionSeeder.php | 257 +++++++------- database/seeders/DatabaseSeeder.php | 4 + database/seeders/FloorHeightIndexSeeder.php | 62 ++++ database/seeders/RetributionFormulaSeeder.php | 74 ++++ .../seeders/RetributionProposalSeeder.php | 159 +++++++++ routes/api.php | 14 + 29 files changed, 2926 insertions(+), 416 deletions(-) create mode 100644 app/Console/Commands/CalculateRetributionProposalsCommand.php create mode 100644 app/Console/Commands/CheckSpatialPlanningConstraints.php create mode 100644 app/Console/Commands/ProcessSpatialPlanningRetributionCommand.php create mode 100644 app/Console/Commands/TestExcelFormulaCommand.php create mode 100644 app/Http/Controllers/RetributionProposalController.php create mode 100644 app/Models/FloorHeightIndex.php delete mode 100644 app/Models/RetributionCalculation.php create mode 100644 app/Models/RetributionProposal.php create mode 100644 app/Services/RetributionProposalService.php create mode 100644 database/migrations/2025_06_17_165000_create_floor_height_indices_table.php delete mode 100644 database/migrations/2025_06_17_174442_create_retribution_calculations_table.php create mode 100644 database/migrations/2025_06_17_211558_create_retribution_proposals_table.php create mode 100644 database/seeders/BuildingFunctionParameterSeeder.php create mode 100644 database/seeders/FloorHeightIndexSeeder.php create mode 100644 database/seeders/RetributionFormulaSeeder.php create mode 100644 database/seeders/RetributionProposalSeeder.php diff --git a/app/Console/Commands/CalculateRetributionProposalsCommand.php b/app/Console/Commands/CalculateRetributionProposalsCommand.php new file mode 100644 index 0000000..63fd5a2 --- /dev/null +++ b/app/Console/Commands/CalculateRetributionProposalsCommand.php @@ -0,0 +1,288 @@ +proposalService = $proposalService; + } + + /** + * Execute the console command. + */ + public function handle() + { + $this->info('🧮 Starting Retribution Proposal Calculation...'); + $this->newLine(); + + try { + // Get processing options + $force = $this->option('force'); + $limit = $this->option('limit') ? (int) $this->option('limit') : null; + $skipExisting = $this->option('skip-existing'); + $dryRun = $this->option('dry-run'); + + // Build query for spatial plannings + $query = SpatialPlanning::query(); + + if ($skipExisting && !$force) { + $query->whereDoesntHave('retributionProposals'); + } + + if ($limit) { + $query->limit($limit); + } + + $spatialPlannings = $query->get(); + $totalCount = $spatialPlannings->count(); + + if ($totalCount === 0) { + $this->warn('No spatial plannings found to process.'); + return 0; + } + + // Show processing summary + $this->info("📊 PROCESSING SUMMARY:"); + $this->info(" Total Spatial Plannings: {$totalCount}"); + $this->info(" Force Recalculate: " . ($force ? 'Yes' : 'No')); + $this->info(" Skip Existing: " . ($skipExisting ? 'Yes' : 'No')); + $this->info(" Dry Run: " . ($dryRun ? 'Yes' : 'No')); + $this->newLine(); + + if ($dryRun) { + $this->showDryRunPreview($spatialPlannings); + return 0; + } + + // Confirm processing + if (!$this->confirm("Process {$totalCount} spatial plannings?")) { + $this->info('Operation cancelled.'); + return 0; + } + + // Process spatial plannings + $this->processRetributionCalculations($spatialPlannings, $force); + + return 0; + + } catch (Exception $e) { + $this->error('Error during retribution calculation: ' . $e->getMessage()); + return 1; + } + } + + /** + * Show dry run preview + */ + private function showDryRunPreview($spatialPlannings) + { + $this->info('🔍 DRY RUN PREVIEW:'); + $this->newLine(); + + $processable = 0; + $withProposals = 0; + $withoutFunction = 0; + $withoutArea = 0; + + foreach ($spatialPlannings as $spatial) { + $hasProposals = $spatial->retributionProposals()->exists(); + $buildingFunction = $this->proposalService->detectBuildingFunction($spatial->getBuildingFunctionText()); + $area = $spatial->getCalculationArea(); + + if ($hasProposals) { + $withProposals++; + } + + if (!$buildingFunction) { + $withoutFunction++; + } + + if ($area <= 0) { + $withoutArea++; + } + + if ($buildingFunction && $area > 0) { + $processable++; + } + } + + $this->table( + ['Status', 'Count', 'Description'], + [ + ['✅ Processable', $processable, 'Can create retribution proposals'], + ['🔄 With Existing Proposals', $withProposals, 'Already have proposals (will skip unless --force)'], + ['❌ Missing Building Function', $withoutFunction, 'Cannot detect building function'], + ['❌ Missing Area', $withoutArea, 'Area is zero or missing'], + ] + ); + + $this->newLine(); + $this->info("💡 To process: php artisan retribution:calculate-proposals"); + $this->info("💡 To force recalculate: php artisan retribution:calculate-proposals --force"); + } + + /** + * Process retribution calculations + */ + private function processRetributionCalculations($spatialPlannings, $force) + { + $progressBar = $this->output->createProgressBar($spatialPlannings->count()); + $progressBar->start(); + + $stats = [ + 'processed' => 0, + 'skipped' => 0, + 'errors' => 0, + 'created_proposals' => 0 + ]; + + foreach ($spatialPlannings as $spatial) { + try { + $result = $this->processSingleSpatialPlanning($spatial, $force); + + if ($result['status'] === 'processed') { + $stats['processed']++; + $stats['created_proposals'] += $result['proposals_created']; + } elseif ($result['status'] === 'skipped') { + $stats['skipped']++; + } else { + $stats['errors']++; + } + + } catch (Exception $e) { + $stats['errors']++; + $this->newLine(); + $this->error("Error processing spatial planning ID {$spatial->id}: " . $e->getMessage()); + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + // Show final statistics + $this->showFinalStatistics($stats); + } + + /** + * Process single spatial planning + */ + private function processSingleSpatialPlanning(SpatialPlanning $spatial, $force) + { + // Check if already has proposals + if (!$force && $spatial->retributionProposals()->exists()) { + return ['status' => 'skipped', 'reason' => 'already_has_proposals']; + } + + // Detect building function + $buildingFunction = $this->proposalService->detectBuildingFunction($spatial->getBuildingFunctionText()); + if (!$buildingFunction) { + return ['status' => 'error', 'reason' => 'no_building_function']; + } + + // Get area + $totalArea = $spatial->getCalculationArea(); + if ($totalArea <= 0) { + return ['status' => 'error', 'reason' => 'no_area']; + } + + // Get number of floors + $numberOfFloors = max(1, $spatial->number_of_floors ?? 1); + + // Delete existing proposals if force mode + if ($force) { + $spatial->retributionProposals()->delete(); + } + + $proposalsCreated = 0; + + // Create proposals for each floor + for ($floor = 1; $floor <= $numberOfFloors; $floor++) { + // Calculate floor area (distribute total area across floors) + $floorArea = $totalArea / $numberOfFloors; + + // Create retribution proposal + $proposal = $this->proposalService->createProposalForSpatialPlanning( + $spatial, + $buildingFunction->id, + $floor, + $floorArea, + $totalArea, + "Auto-generated from spatial planning calculation" + ); + + if ($proposal) { + $proposalsCreated++; + } + } + + return [ + 'status' => 'processed', + 'proposals_created' => $proposalsCreated + ]; + } + + /** + * Show final statistics + */ + private function showFinalStatistics($stats) + { + $this->info('✅ CALCULATION COMPLETED!'); + $this->newLine(); + + $this->table( + ['Metric', 'Count'], + [ + ['Processed Successfully', $stats['processed']], + ['Skipped (Already Exists)', $stats['skipped']], + ['Errors', $stats['errors']], + ['Total Proposals Created', $stats['created_proposals']], + ] + ); + + if ($stats['errors'] > 0) { + $this->newLine(); + $this->warn("⚠️ {$stats['errors']} spatial plannings had errors:"); + $this->warn(" • Missing building function detection"); + $this->warn(" • Missing or zero area"); + $this->warn(" • Other calculation errors"); + } + + if ($stats['processed'] > 0) { + $this->newLine(); + $this->info("🎉 Successfully created {$stats['created_proposals']} retribution proposals!"); + $this->info("💡 You can view them using: php artisan retribution:list-proposals"); + } + } +} \ No newline at end of file diff --git a/app/Console/Commands/CheckSpatialPlanningConstraints.php b/app/Console/Commands/CheckSpatialPlanningConstraints.php new file mode 100644 index 0000000..bec54b8 --- /dev/null +++ b/app/Console/Commands/CheckSpatialPlanningConstraints.php @@ -0,0 +1,101 @@ +info('Checking Spatial Planning Constraints...'); + $this->newLine(); + + try { + // Get total spatial plannings + $totalSpatialPlannings = SpatialPlanning::count(); + + // Get spatial plannings with retribution proposals + $withProposals = SpatialPlanning::whereHas('retributionProposals')->count(); + + // Get spatial plannings without retribution proposals + $withoutProposals = SpatialPlanning::whereDoesntHave('retributionProposals')->count(); + + // Get total retribution proposals + $totalProposals = RetributionProposal::count(); + + // Get retribution proposals linked to spatial plannings + $linkedProposals = RetributionProposal::whereNotNull('spatial_planning_id')->count(); + + // Get standalone retribution proposals + $standaloneProposals = RetributionProposal::whereNull('spatial_planning_id')->count(); + + // Display statistics + $this->table( + ['Metric', 'Count'], + [ + ['Total Spatial Plannings', $totalSpatialPlannings], + ['├─ With Retribution Proposals', $withProposals], + ['└─ Without Retribution Proposals', $withoutProposals], + ['', ''], + ['Total Retribution Proposals', $totalProposals], + ['├─ Linked to Spatial Planning', $linkedProposals], + ['└─ Standalone Proposals', $standaloneProposals], + ] + ); + + $this->newLine(); + + // Show constraint implications + if ($withProposals > 0) { + $this->warn("⚠️ CONSTRAINT WARNING:"); + $this->warn(" {$withProposals} spatial plannings have retribution proposals linked to them."); + $this->warn(" These cannot be deleted directly due to foreign key constraints."); + $this->newLine(); + + $this->info("💡 TRUNCATE OPTIONS:"); + $this->info(" • Use --truncate to delete ALL data (spatial plannings + linked proposals)"); + $this->info(" • Use --safe-truncate to delete only spatial plannings without proposals"); + $this->info(" • Manual cleanup: Delete proposals first, then spatial plannings"); + } else { + $this->info("✅ No foreign key constraints found."); + $this->info(" All spatial plannings can be safely truncated."); + } + + $this->newLine(); + + // Show example commands + $this->info("📋 EXAMPLE COMMANDS:"); + $this->info(" php artisan spatial:init --truncate # Delete all data (smart method)"); + $this->info(" php artisan spatial:init --safe-truncate # Delete safe data only"); + $this->info(" php artisan spatial:init --force-truncate # Force truncate (disable FK checks)"); + $this->info(" php artisan spatial:init file.csv --truncate # Import with truncate"); + + return 0; + + } catch (\Exception $e) { + $this->error('Error checking constraints: ' . $e->getMessage()); + return 1; + } + } +} \ No newline at end of file diff --git a/app/Console/Commands/InitSpatialPlanningDatas.php b/app/Console/Commands/InitSpatialPlanningDatas.php index 4e4a1e0..b398819 100644 --- a/app/Console/Commands/InitSpatialPlanningDatas.php +++ b/app/Console/Commands/InitSpatialPlanningDatas.php @@ -15,7 +15,7 @@ class InitSpatialPlanningDatas extends Command * * @var string */ - protected $signature = 'spatial:init {file? : Path to the CSV/Excel file} {--truncate : Clear existing data before import}'; + protected $signature = 'spatial:init {file? : Path to the CSV/Excel file} {--truncate : Clear existing data before import} {--safe-truncate : Clear only spatial plannings without retribution proposals} {--force-truncate : Force truncate by disabling foreign key checks}'; /** * The console command description. @@ -40,12 +40,133 @@ class InitSpatialPlanningDatas extends Command return 1; } + // Handle truncate options + if (($this->option('truncate') && $this->option('safe-truncate')) || + ($this->option('truncate') && $this->option('force-truncate')) || + ($this->option('safe-truncate') && $this->option('force-truncate'))) { + $this->error('Cannot use multiple truncate options together. Choose only one.'); + return 1; + } + // Confirm truncate if requested if ($this->option('truncate')) { - if ($this->confirm('This will delete all existing spatial planning data. Continue?')) { - $this->info('Truncating spatial_plannings table...'); - DB::table('spatial_plannings')->truncate(); - $this->info('Table truncated successfully.'); + if ($this->confirm('This will delete all existing spatial planning data and related retribution proposals. Continue?')) { + $this->info('Truncating tables...'); + + try { + // First delete retribution proposals that reference spatial plannings + $deletedProposals = DB::table('retribution_proposals') + ->whereNotNull('spatial_planning_id') + ->count(); + + if ($deletedProposals > 0) { + DB::table('retribution_proposals') + ->whereNotNull('spatial_planning_id') + ->delete(); + $this->info("Deleted {$deletedProposals} retribution proposals linked to spatial plannings."); + } + + // Method 1: Try truncate with disabled foreign key checks + try { + DB::statement('SET FOREIGN_KEY_CHECKS = 0'); + DB::table('spatial_plannings')->truncate(); + DB::statement('SET FOREIGN_KEY_CHECKS = 1'); + $this->info('Spatial plannings table truncated successfully.'); + } catch (\Exception $truncateError) { + // Method 2: Fallback to delete if truncate fails + $this->warn('Truncate failed, using delete method...'); + $deletedSpatial = DB::table('spatial_plannings')->delete(); + $this->info("Deleted {$deletedSpatial} spatial planning records."); + + // Reset auto increment + DB::statement('ALTER TABLE spatial_plannings AUTO_INCREMENT = 1'); + } + + } catch (\Exception $e) { + $this->error('Failed to truncate tables: ' . $e->getMessage()); + return 1; + } + } else { + $this->info('Operation cancelled.'); + return 0; + } + } + + // Force truncate - disable foreign key checks and truncate everything + if ($this->option('force-truncate')) { + if ($this->confirm('This will FORCE truncate ALL spatial planning data by disabling foreign key checks. This is risky! Continue?')) { + $this->info('Force truncating with disabled foreign key checks...'); + + try { + DB::beginTransaction(); + + // Disable foreign key checks + DB::statement('SET FOREIGN_KEY_CHECKS = 0'); + + // Truncate both tables + DB::table('retribution_proposals')->truncate(); + DB::table('spatial_plannings')->truncate(); + + // Re-enable foreign key checks + DB::statement('SET FOREIGN_KEY_CHECKS = 1'); + + $this->info('Force truncate completed successfully.'); + + DB::commit(); + + } catch (\Exception $e) { + DB::rollBack(); + // Make sure to re-enable foreign key checks even on error + try { + DB::statement('SET FOREIGN_KEY_CHECKS = 1'); + } catch (\Exception $fkError) { + $this->error('Failed to re-enable foreign key checks: ' . $fkError->getMessage()); + } + $this->error('Failed to force truncate: ' . $e->getMessage()); + return 1; + } + } else { + $this->info('Operation cancelled.'); + return 0; + } + } + + // Safe truncate - only delete spatial plannings without retribution proposals + if ($this->option('safe-truncate')) { + if ($this->confirm('This will delete only spatial planning data that have no retribution proposals. Continue?')) { + $this->info('Safe truncating spatial plannings...'); + + try { + DB::beginTransaction(); + + // Count spatial plannings with retribution proposals + $withProposals = DB::table('spatial_plannings') + ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('retribution_proposals') + ->whereColumn('retribution_proposals.spatial_planning_id', 'spatial_plannings.id'); + }) + ->count(); + + // Delete spatial plannings without retribution proposals + $deletedCount = DB::table('spatial_plannings') + ->whereNotExists(function ($query) { + $query->select(DB::raw(1)) + ->from('retribution_proposals') + ->whereColumn('retribution_proposals.spatial_planning_id', 'spatial_plannings.id'); + }) + ->delete(); + + $this->info("Deleted {$deletedCount} spatial plannings without retribution proposals."); + $this->info("Kept {$withProposals} spatial plannings that have retribution proposals."); + + DB::commit(); + + } catch (\Exception $e) { + DB::rollBack(); + $this->error('Failed to safe truncate: ' . $e->getMessage()); + return 1; + } } else { $this->info('Operation cancelled.'); return 0; @@ -171,18 +292,25 @@ class InitSpatialPlanningDatas extends Command { $templatesPath = storage_path('app/public/templates'); if (is_dir($templatesPath)) { - $files = glob($templatesPath . '/*.{csv,xlsx,xls}', GLOB_BRACE); - foreach ($files as $file) { - $this->line(' - ' . basename($file)); + $this->info("Files in storage/app/public/templates:"); + $extensions = ['csv', 'xlsx', 'xls']; + foreach ($extensions as $ext) { + $files = glob($templatesPath . '/*.' . $ext); + foreach ($files as $file) { + $this->line(' - ' . basename($file)); + } } } $publicTemplatesPath = public_path('templates'); if (is_dir($publicTemplatesPath)) { $this->info("Files in public/templates:"); - $files = glob($publicTemplatesPath . '/*.{csv,xlsx,xls}', GLOB_BRACE); - foreach ($files as $file) { - $this->line(' - ' . basename($file)); + $extensions = ['csv', 'xlsx', 'xls']; + foreach ($extensions as $ext) { + $files = glob($publicTemplatesPath . '/*.' . $ext); + foreach ($files as $file) { + $this->line(' - ' . basename($file)); + } } } } diff --git a/app/Console/Commands/ProcessSpatialPlanningRetributionCommand.php b/app/Console/Commands/ProcessSpatialPlanningRetributionCommand.php new file mode 100644 index 0000000..96df416 --- /dev/null +++ b/app/Console/Commands/ProcessSpatialPlanningRetributionCommand.php @@ -0,0 +1,321 @@ +proposalService = $proposalService; + } + + /** + * Execute the console command. + */ + public function handle() + { + $this->info('🏗️ Processing Spatial Planning Retribution Calculations...'); + $this->newLine(); + + try { + // Get options + $processAll = $this->option('all'); + $newOnly = $this->option('new-only'); + $force = $this->option('force'); + + // Build query + $query = SpatialPlanning::query(); + + if ($newOnly || (!$processAll && !$force)) { + $query->whereDoesntHave('retributionProposals'); + $this->info('📋 Mode: Processing only spatial plannings WITHOUT retribution proposals'); + } elseif ($processAll) { + $this->info('📋 Mode: Processing ALL spatial plannings'); + } + + $spatialPlannings = $query->get(); + $totalCount = $spatialPlannings->count(); + + if ($totalCount === 0) { + $this->warn('❌ No spatial plannings found to process.'); + $this->info('💡 Try running: php artisan spatial:init first'); + return 0; + } + + $this->info("📊 Found {$totalCount} spatial plannings to process"); + $this->newLine(); + + // Show preview of what will be processed + $this->showProcessingPreview($spatialPlannings); + + // Confirm processing + if (!$this->confirm("Process {$totalCount} spatial plannings and create retribution proposals?")) { + $this->info('Operation cancelled.'); + return 0; + } + + // Process all spatial plannings + $this->processAllSpatialPlannings($spatialPlannings, $force); + + return 0; + + } catch (Exception $e) { + $this->error('❌ Error during processing: ' . $e->getMessage()); + return 1; + } + } + + /** + * Show processing preview + */ + private function showProcessingPreview($spatialPlannings) + { + $this->info('🔍 PREVIEW:'); + + $canProcess = 0; + $cannotProcess = 0; + $hasExisting = 0; + + $sampleData = []; + $errorReasons = []; + + foreach ($spatialPlannings->take(5) as $spatial) { + $buildingFunction = $this->proposalService->detectBuildingFunction($spatial->getBuildingFunctionText()); + $area = $spatial->getCalculationArea(); + $floors = max(1, $spatial->number_of_floors ?? 1); + $hasProposals = $spatial->retributionProposals()->exists(); + + if ($hasProposals) { + $hasExisting++; + } + + if ($buildingFunction && $area > 0) { + $canProcess++; + $sampleData[] = [ + 'ID' => $spatial->id, + 'Name' => substr($spatial->name ?? 'N/A', 0, 30), + 'Function' => $buildingFunction->name ?? 'N/A', + 'Area' => number_format($area, 2), + 'Floors' => $floors, + 'Status' => $hasProposals ? '🔄 Has Proposals' : '✅ Ready' + ]; + } else { + $cannotProcess++; + if (!$buildingFunction) { + $errorReasons[] = 'Missing building function'; + } + if ($area <= 0) { + $errorReasons[] = 'Missing/zero area'; + } + } + } + + // Show sample data table + if (!empty($sampleData)) { + $this->table( + ['ID', 'Name', 'Function', 'Area (m²)', 'Floors', 'Status'], + $sampleData + ); + } + + // Show summary + $this->newLine(); + $this->info("📈 SUMMARY:"); + $this->info(" ✅ Can Process: {$canProcess}"); + $this->info(" ❌ Cannot Process: {$cannotProcess}"); + $this->info(" 🔄 Has Existing Proposals: {$hasExisting}"); + + if ($cannotProcess > 0) { + $this->warn(" ⚠️ Common Issues: " . implode(', ', array_unique($errorReasons))); + } + + $this->newLine(); + } + + /** + * Process all spatial plannings + */ + private function processAllSpatialPlannings($spatialPlannings, $force) + { + $this->info('🚀 Starting processing...'); + $this->newLine(); + + $progressBar = $this->output->createProgressBar($spatialPlannings->count()); + $progressBar->start(); + + $stats = [ + 'processed' => 0, + 'skipped' => 0, + 'errors' => 0, + 'total_proposals' => 0, + 'total_amount' => 0 + ]; + + $errors = []; + + foreach ($spatialPlannings as $spatial) { + try { + $result = $this->processSingleSpatialPlanning($spatial, $force); + + if ($result['success']) { + $stats['processed']++; + $stats['total_proposals'] += $result['proposals_count']; + $stats['total_amount'] += $result['total_amount']; + } elseif ($result['skipped']) { + $stats['skipped']++; + } else { + $stats['errors']++; + $errors[] = "ID {$spatial->id}: " . $result['error']; + } + + } catch (Exception $e) { + $stats['errors']++; + $errors[] = "ID {$spatial->id}: " . $e->getMessage(); + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + // Show final results + $this->showFinalResults($stats, $errors); + } + + /** + * Process single spatial planning + */ + private function processSingleSpatialPlanning(SpatialPlanning $spatial, $force) + { + // Check if already has proposals + if (!$force && $spatial->retributionProposals()->exists()) { + return ['success' => false, 'skipped' => true]; + } + + // Detect building function + $buildingFunction = $this->proposalService->detectBuildingFunction($spatial->getBuildingFunctionText()); + if (!$buildingFunction) { + return ['success' => false, 'skipped' => false, 'error' => 'Cannot detect building function from: ' . $spatial->getBuildingFunctionText()]; + } + + // Get area + $totalArea = $spatial->getCalculationArea(); + if ($totalArea <= 0) { + return ['success' => false, 'skipped' => false, 'error' => 'Area is zero or missing']; + } + + // Get number of floors + $numberOfFloors = max(1, $spatial->number_of_floors ?? 1); + + // Delete existing proposals if force mode + if ($force) { + $spatial->retributionProposals()->delete(); + } + + $proposalsCount = 0; + $totalAmount = 0; + + // Create single proposal for the spatial planning (not per floor to prevent duplicates) + // Use the highest floor for IP ketinggian calculation (worst case scenario) + $highestFloor = $numberOfFloors; + + $proposal = $this->proposalService->createProposalForSpatialPlanning( + $spatial, + $buildingFunction->id, + $highestFloor, // Use highest floor for calculation + $totalArea, // Use total area, not divided by floors + $totalArea, + "Auto-calculated from spatial planning data (floors: {$numberOfFloors})" + ); + + if ($proposal) { + $proposalsCount = 1; + $totalAmount = $proposal->total_retribution_amount; + } + + return [ + 'success' => true, + 'skipped' => false, + 'proposals_count' => $proposalsCount, + 'total_amount' => $totalAmount + ]; + } + + /** + * Show final results + */ + private function showFinalResults($stats, $errors) + { + $this->info('🎉 PROCESSING COMPLETED!'); + $this->newLine(); + + // Main statistics table + $this->table( + ['Metric', 'Count'], + [ + ['✅ Successfully Processed', $stats['processed']], + ['⏭️ Skipped (Already Has Proposals)', $stats['skipped']], + ['❌ Errors', $stats['errors']], + ['📄 Total Proposals Created', $stats['total_proposals']], + ['💰 Total Retribution Amount', 'Rp ' . number_format($stats['total_amount'], 2)], + ] + ); + + // Show success message + if ($stats['processed'] > 0) { + $this->newLine(); + $this->info("🎊 SUCCESS!"); + $this->info(" 📊 Created {$stats['total_proposals']} retribution proposals"); + $this->info(" 💵 Total calculated amount: Rp " . number_format($stats['total_amount'], 2)); + $this->info(" 📋 Processed {$stats['processed']} spatial plannings"); + } + + // Show errors if any + if ($stats['errors'] > 0) { + $this->newLine(); + $this->warn("⚠️ ERRORS ENCOUNTERED:"); + foreach (array_slice($errors, 0, 10) as $error) { + $this->warn(" • {$error}"); + } + + if (count($errors) > 10) { + $this->warn(" ... and " . (count($errors) - 10) . " more errors"); + } + } + + // Show next steps + $this->newLine(); + $this->info("📋 NEXT STEPS:"); + $this->info(" • View proposals: php artisan spatial:check-constraints"); + $this->info(" • Check database: SELECT COUNT(*) FROM retribution_proposals;"); + $this->info(" • Access via API: GET /api/retribution-proposals"); + } +} \ No newline at end of file diff --git a/app/Console/Commands/TestExcelFormulaCommand.php b/app/Console/Commands/TestExcelFormulaCommand.php new file mode 100644 index 0000000..168232d --- /dev/null +++ b/app/Console/Commands/TestExcelFormulaCommand.php @@ -0,0 +1,209 @@ +info('🧮 Testing Excel Formula Calculation'); + $this->newLine(); + + // Get parameters + $buildingFunctionId = $this->option('building-function'); + $floorArea = (float) $this->option('floor-area'); + $floorNumber = (int) $this->option('floor-number'); + + // Get building function and parameters + $buildingFunction = BuildingFunction::with('parameter')->find($buildingFunctionId); + if (!$buildingFunction || !$buildingFunction->parameter) { + $this->error("Building function with ID {$buildingFunctionId} not found or has no parameters."); + return 1; + } + + // Get IP ketinggian for floor + $floorHeightIndex = FloorHeightIndex::where('floor_number', $floorNumber)->first(); + $ipKetinggian = $floorHeightIndex ? $floorHeightIndex->ip_ketinggian : 1.0; + + // Get parameters + $parameters = $buildingFunction->parameter->getParametersArray(); + + $this->info("Test Parameters:"); + $this->table( + ['Parameter', 'Excel Cell', 'Value'], + [ + ['Building Function', 'Building Function', $buildingFunction->name], + ['Floor Area (D13)', 'D13', $floorArea . ' m²'], + ['Fungsi Bangunan (E13)', 'E13', $parameters['fungsi_bangunan']], + ['IP Permanen (F13)', 'F13', $parameters['ip_permanen']], + ['IP Kompleksitas (G13)', 'G13', $parameters['ip_kompleksitas']], + ['IP Ketinggian (H$3)', 'H$3', $ipKetinggian], + ['Indeks Lokalitas (N13)', 'N13', $parameters['indeks_lokalitas']], + ['Base Value', '70350', '70,350'], + ['Additional Factor ($O$3)', '$O$3', '0.5 (50%)'] + ] + ); + + $this->newLine(); + + // Calculate manually using the Excel formula + $fungsi_bangunan = $parameters['fungsi_bangunan']; + $ip_permanen = $parameters['ip_permanen']; + $ip_kompleksitas = $parameters['ip_kompleksitas']; + $indeks_lokalitas = $parameters['indeks_lokalitas']; + $base_value = 70350; + $additional_factor = 0.5; + + // Step 1: Calculate H13 (floor coefficient) + $h13 = $fungsi_bangunan * ($ip_permanen + $ip_kompleksitas + (0.5 * $ipKetinggian)); + + // Step 2: Main calculation + $main_calculation = 1 * $floorArea * ($indeks_lokalitas * $base_value * $h13 * 1); + + // Step 3: Additional (50%) + $additional_calculation = $additional_factor * $main_calculation; + + // Step 4: Total + $total_retribution = $main_calculation + $additional_calculation; + + // Create breakdown array + $breakdown = [ + 'calculation_steps' => [ + 'step_1_h13' => [ + 'formula' => 'fungsi_bangunan * (ip_permanen + ip_kompleksitas + (0.5 * ip_ketinggian))', + 'calculation' => "{$fungsi_bangunan} * ({$ip_permanen} + {$ip_kompleksitas} + (0.5 * {$ipKetinggian}))", + 'result' => $h13 + ], + 'step_2_main' => [ + 'formula' => '(1*D13*(N13*70350*H13*1))', + 'calculation' => "1 * {$floorArea} * ({$indeks_lokalitas} * {$base_value} * {$h13} * 1)", + 'result' => $main_calculation + ], + 'step_3_additional' => [ + 'formula' => '($O$3*(1*D13*(N13*70350*H13*1)))', + 'calculation' => "{$additional_factor} * {$main_calculation}", + 'result' => $additional_calculation + ], + 'step_4_total' => [ + 'formula' => 'main + additional', + 'calculation' => "{$main_calculation} + {$additional_calculation}", + 'result' => $total_retribution + ] + ], + 'formatted_results' => [ + 'H13' => number_format($h13, 6), + 'main_calculation' => 'Rp ' . number_format($main_calculation, 2), + 'additional_calculation' => 'Rp ' . number_format($additional_calculation, 2), + 'total_result' => 'Rp ' . number_format($total_retribution, 2) + ] + ]; + + $this->info("📊 Calculation Breakdown:"); + $this->newLine(); + + // Show each step + foreach ($breakdown['calculation_steps'] as $stepName => $step) { + $this->info("🔸 " . strtoupper(str_replace('_', ' ', $stepName))); + $this->line(" Formula: " . $step['formula']); + $this->line(" Calculation: " . $step['calculation']); + $this->line(" Result: " . (is_numeric($step['result']) ? number_format($step['result'], 6) : $step['result'])); + $this->newLine(); + } + + $this->info("💰 Final Results:"); + $this->table( + ['Component', 'Value'], + [ + ['H13 (Floor Coefficient)', $breakdown['formatted_results']['H13']], + ['Main Calculation', $breakdown['formatted_results']['main_calculation']], + ['Additional (50%)', $breakdown['formatted_results']['additional_calculation']], + ['Total Retribution', $breakdown['formatted_results']['total_result']] + ] + ); + + $this->newLine(); + $this->info("🔍 Excel Formula Verification:"); + $this->line("Main Formula: =(1*D13*(N13*70350*H13*1))+(\$O\$3*(1*D13*(N13*70350*H13*1)))"); + $this->line("H13 Formula: =(\$E13*(\$F13+\$G13+(0.5*H\$3)))"); + + // Test with different floor numbers + if ($this->confirm('Test with different floor numbers?')) { + $this->testMultipleFloors($buildingFunction, $floorArea, $parameters); + } + + return 0; + } + + /** + * Test calculation with multiple floor numbers + */ + protected function testMultipleFloors(BuildingFunction $buildingFunction, float $floorArea, array $parameters) + { + $this->newLine(); + $this->info("🏢 Testing Multiple Floors:"); + + $tableData = []; + $totalRetribution = 0; + + for ($floor = 1; $floor <= 5; $floor++) { + $floorHeightIndex = FloorHeightIndex::where('floor_number', $floor)->first(); + $ipKetinggian = $floorHeightIndex ? $floorHeightIndex->ip_ketinggian : 1.0; + + // Calculate using Excel formula + $fungsi_bangunan = $parameters['fungsi_bangunan']; + $ip_permanen = $parameters['ip_permanen']; + $ip_kompleksitas = $parameters['ip_kompleksitas']; + $indeks_lokalitas = $parameters['indeks_lokalitas']; + $base_value = 70350; + $additional_factor = 0.5; + + // Calculate H13 and result + $H13 = $fungsi_bangunan * ($ip_permanen + $ip_kompleksitas + (0.5 * $ipKetinggian)); + $main_calc = 1 * $floorArea * ($indeks_lokalitas * $base_value * $H13 * 1); + $additional_calc = $additional_factor * $main_calc; + $result = $main_calc + $additional_calc; + + $totalRetribution += $result; + + $tableData[] = [ + "L{$floor}", + $ipKetinggian, + number_format($H13, 6), + 'Rp ' . number_format($result, 2) + ]; + } + + $this->table( + ['Floor', 'IP Ketinggian', 'H13 Value', 'Retribution Amount'], + $tableData + ); + + $this->newLine(); + $this->info("🏗️ Total Building Retribution (5 floors): Rp " . number_format($totalRetribution, 2)); + $this->info("📏 Total Building Area: " . number_format($floorArea * 5, 2) . " m²"); + $this->info("💡 Average per m²: Rp " . number_format($totalRetribution / ($floorArea * 5), 2)); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/BigDataResumeController.php b/app/Http/Controllers/Api/BigDataResumeController.php index 5b68fbd..00f6855 100644 --- a/app/Http/Controllers/Api/BigDataResumeController.php +++ b/app/Http/Controllers/Api/BigDataResumeController.php @@ -25,12 +25,13 @@ class BigDataResumeController extends Controller $filterDate = $request->get("filterByDate"); if (!$filterDate || $filterDate === "latest") { - $big_data_resume = BigdataResume::where('year', now()->year)->latest()->first(); + $big_data_resume = BigdataResume::where('year', 'leader')->latest()->first(); if (!$big_data_resume) { return $this->response_empty_resume(); } } else { - $big_data_resume = BigdataResume::whereDate('created_at', $filterDate) + $big_data_resume = BigdataResume::where('year', 'leader') + ->whereDate('created_at', $filterDate) ->orderBy('id', 'desc') ->first(); diff --git a/app/Http/Controllers/RetributionProposalController.php b/app/Http/Controllers/RetributionProposalController.php new file mode 100644 index 0000000..1988377 --- /dev/null +++ b/app/Http/Controllers/RetributionProposalController.php @@ -0,0 +1,295 @@ +proposalService = $proposalService; + } + + /** + * Display a listing of retribution proposals + */ + public function index(Request $request): JsonResponse + { + $query = RetributionProposal::with(['spatialPlanning', 'buildingFunction', 'retributionFormula']); + + // Filtering + if ($request->has('building_function_id')) { + $query->where('building_function_id', $request->building_function_id); + } + + if ($request->has('spatial_planning_id')) { + $query->where('spatial_planning_id', $request->spatial_planning_id); + } + + if ($request->has('floor_number')) { + $query->where('floor_number', $request->floor_number); + } + + if ($request->has('has_spatial_planning')) { + if ($request->boolean('has_spatial_planning')) { + $query->whereNotNull('spatial_planning_id'); + } else { + $query->whereNull('spatial_planning_id'); + } + } + + // Search + if ($request->has('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('proposal_number', 'like', "%{$search}%") + ->orWhere('notes', 'like', "%{$search}%") + ->orWhereHas('spatialPlanning', function ($sq) use ($search) { + $sq->where('name', 'like', "%{$search}%"); + }) + ->orWhereHas('buildingFunction', function ($bq) use ($search) { + $bq->where('name', 'like', "%{$search}%"); + }); + }); + } + + // Sorting + $sortBy = $request->get('sort_by', 'created_at'); + $sortOrder = $request->get('sort_order', 'desc'); + $query->orderBy($sortBy, $sortOrder); + + // Pagination + $perPage = $request->get('per_page', 15); + $proposals = $query->paginate($perPage); + + return response()->json([ + 'success' => true, + 'data' => $proposals, + 'meta' => [ + 'total_amount' => RetributionProposal::sum('total_retribution_amount'), + 'total_proposals' => RetributionProposal::count(), + 'formatted_total_amount' => 'Rp ' . number_format(RetributionProposal::sum('total_retribution_amount'), 0, ',', '.') + ] + ]); + } + + /** + * Store a new retribution proposal + */ + public function store(Request $request): JsonResponse + { + $request->validate([ + 'spatial_planning_id' => 'nullable|exists:spatial_plannings,id', + 'building_function_id' => 'required|exists:building_functions,id', + 'floor_number' => 'required|integer|min:1|max:10', + 'floor_area' => 'required|numeric|min:0.01', + 'total_building_area' => 'nullable|numeric|min:0.01', + 'notes' => 'nullable|string|max:1000' + ]); + + try { + if ($request->spatial_planning_id) { + $spatialPlanning = SpatialPlanning::findOrFail($request->spatial_planning_id); + $proposal = $this->proposalService->createProposalForSpatialPlanning( + $spatialPlanning, + $request->building_function_id, + $request->floor_number, + $request->floor_area, + $request->total_building_area, + $request->notes + ); + } else { + $proposal = $this->proposalService->createStandaloneProposal( + $request->building_function_id, + $request->floor_number, + $request->floor_area, + $request->total_building_area, + $request->notes + ); + } + + $proposal->load(['spatialPlanning', 'buildingFunction', 'retributionFormula']); + + return response()->json([ + 'success' => true, + 'message' => 'Retribution proposal created successfully', + 'data' => $proposal + ], 201); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Failed to create retribution proposal', + 'error' => $e->getMessage() + ], 400); + } + } + + /** + * Display the specified retribution proposal + */ + public function show(int $id): JsonResponse + { + $proposal = RetributionProposal::with(['spatialPlanning', 'buildingFunction', 'retributionFormula']) + ->findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => $proposal + ]); + } + + /** + * Update the specified retribution proposal + */ + public function update(Request $request, int $id): JsonResponse + { + $proposal = RetributionProposal::findOrFail($id); + + $request->validate([ + 'notes' => 'nullable|string|max:1000' + ]); + + $proposal->update($request->only(['notes'])); + + $proposal->load(['spatialPlanning', 'buildingFunction', 'retributionFormula']); + + return response()->json([ + 'success' => true, + 'message' => 'Retribution proposal updated successfully', + 'data' => $proposal + ]); + } + + /** + * Remove the specified retribution proposal + */ + public function destroy(int $id): JsonResponse + { + $proposal = RetributionProposal::findOrFail($id); + $proposal->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Retribution proposal deleted successfully' + ]); + } + + /** + * Get retribution proposal statistics + */ + public function statistics(): JsonResponse + { + $stats = $this->proposalService->getStatistics(); + + return response()->json([ + 'success' => true, + 'data' => $stats + ]); + } + + /** + * Get total sum of all retribution proposals + */ + public function totalSum(): JsonResponse + { + $totalAmount = RetributionProposal::sum('total_retribution_amount'); + + return response()->json([ + 'success' => true, + 'data' => [ + 'total_amount' => $totalAmount, + 'formatted_total_amount' => 'Rp ' . number_format($totalAmount, 0, ',', '.'), + 'total_proposals' => RetributionProposal::count() + ] + ]); + } + + /** + * Get building functions list + */ + public function buildingFunctions(): JsonResponse + { + $buildingFunctions = BuildingFunction::whereNotNull('parent_id') + ->with(['parameter']) + ->orderBy('name') + ->get(); + + return response()->json([ + 'success' => true, + 'data' => $buildingFunctions + ]); + } + + /** + * Auto-detect building function and create proposal + */ + public function autoCreateProposal(Request $request): JsonResponse + { + $request->validate([ + 'spatial_planning_id' => 'nullable|exists:spatial_plannings,id', + 'building_function_text' => 'required|string', + 'floor_number' => 'required|integer|min:1|max:10', + 'floor_area' => 'required|numeric|min:0.01', + 'total_building_area' => 'nullable|numeric|min:0.01', + 'notes' => 'nullable|string|max:1000' + ]); + + try { + // Auto-detect building function + $buildingFunction = $this->proposalService->detectBuildingFunction($request->building_function_text); + + if (!$buildingFunction) { + return response()->json([ + 'success' => false, + 'message' => 'Could not detect building function from text', + 'suggestion' => 'Please specify building function manually' + ], 400); + } + + if ($request->spatial_planning_id) { + $spatialPlanning = SpatialPlanning::findOrFail($request->spatial_planning_id); + $proposal = $this->proposalService->createProposalForSpatialPlanning( + $spatialPlanning, + $buildingFunction->id, + $request->floor_number, + $request->floor_area, + $request->total_building_area, + $request->notes + ); + } else { + $proposal = $this->proposalService->createStandaloneProposal( + $buildingFunction->id, + $request->floor_number, + $request->floor_area, + $request->total_building_area, + $request->notes + ); + } + + $proposal->load(['spatialPlanning', 'buildingFunction', 'retributionFormula']); + + return response()->json([ + 'success' => true, + 'message' => 'Retribution proposal created successfully with auto-detected building function', + 'data' => $proposal, + 'detected_building_function' => $buildingFunction->name + ], 201); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Failed to create retribution proposal', + 'error' => $e->getMessage() + ], 400); + } + } +} \ No newline at end of file diff --git a/app/Models/BuildingFunction.php b/app/Models/BuildingFunction.php index 7aceb19..d384aea 100644 --- a/app/Models/BuildingFunction.php +++ b/app/Models/BuildingFunction.php @@ -10,12 +10,21 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class BuildingFunction extends Model { protected $table = 'building_functions'; - protected $fillable = ['code', 'name', 'description', 'parent_id', 'is_active', 'level', 'sort_order']; + + protected $fillable = [ + 'code', + 'name', + 'description', + 'parent_id', + 'level', + 'sort_order', + 'base_tariff' + ]; protected $casts = [ - 'is_active' => 'boolean', 'level' => 'integer', - 'sort_order' => 'integer' + 'sort_order' => 'integer', + 'base_tariff' => 'decimal:2' ]; /** @@ -32,48 +41,41 @@ class BuildingFunction extends Model public function children(): HasMany { return $this->hasMany(BuildingFunction::class, 'parent_id') - ->where('is_active', true) ->orderBy('sort_order'); } /** * Parameters relationship (1:1) */ - public function parameters(): HasOne + public function parameter(): HasOne { return $this->hasOne(BuildingFunctionParameter::class); } /** - * Formula relationship (1:1) + * Formulas relationship (1:n) - multiple formulas for different floor numbers */ - public function formula(): HasOne + public function formulas(): HasMany { - return $this->hasOne(RetributionFormula::class); + return $this->hasMany(RetributionFormula::class)->orderBy('floor_number'); } /** - * Spatial plannings relationship (1:n) - via detected building function + * Get appropriate formula based on floor number */ - public function spatialPlannings(): HasMany + public function getFormulaForFloor(int $floorNumber): ?RetributionFormula { - return $this->hasMany(SpatialPlanning::class, 'building_function_id'); + return $this->formulas() + ->where('floor_number', $floorNumber) + ->first(); } /** - * Retribution calculations relationship (1:n) - via detected building function + * Retribution proposals relationship (1:n) */ - public function retributionCalculations(): HasMany + public function retributionProposals(): HasMany { - return $this->hasMany(RetributionCalculation::class, 'detected_building_function_id'); - } - - /** - * Scope: Active building functions only - */ - public function scopeActive($query) - { - return $query->where('is_active', true); + return $this->hasMany(RetributionProposal::class, 'building_function_id'); } /** @@ -93,11 +95,19 @@ class BuildingFunction extends Model } /** - * Check if building function has complete setup (parameters + formula) + * Scope: By level + */ + public function scopeByLevel($query, int $level) + { + return $query->where('level', $level); + } + + /** + * Check if building function has complete setup (parameters + at least one formula) */ public function hasCompleteSetup(): bool { - return $this->parameters()->exists() && $this->formula()->exists(); + return $this->parameter()->exists() && $this->formulas()->exists(); } /** @@ -110,13 +120,48 @@ class BuildingFunction extends Model 'code' => $this->code, 'name' => $this->name, 'description' => $this->description, - 'parameters' => $this->parameters?->getParametersArray(), - 'formula' => [ - 'name' => $this->formula?->name, - 'expression' => $this->formula?->formula_expression, - 'description' => $this->formula?->description - ], + 'level' => $this->level, + 'sort_order' => $this->sort_order, + 'base_tariff' => $this->base_tariff, + 'parameters' => $this->parameter?->getParametersArray(), + 'formulas' => $this->formulas->map(function ($formula) { + return [ + 'id' => $formula->id, + 'name' => $formula->name, + 'expression' => $formula->formula_expression, + 'floor_number' => $formula->floor_number, + 'floor_description' => $this->getFloorDescription($formula->floor_number) + ]; + })->toArray(), 'has_complete_setup' => $this->hasCompleteSetup() ]; } + + /** + * Get floor description + */ + public function getFloorDescription(?int $floorNumber): string + { + if ($floorNumber === null || $floorNumber === 0) { + return 'Semua lantai'; + } + + return "Lantai {$floorNumber}"; + } + + /** + * Check if this is a parent function + */ + public function isParent(): bool + { + return $this->parent_id === null; + } + + /** + * Check if this is a child function + */ + public function isChild(): bool + { + return $this->parent_id !== null; + } } diff --git a/app/Models/BuildingFunctionParameter.php b/app/Models/BuildingFunctionParameter.php index de0f357..3d561db 100644 --- a/app/Models/BuildingFunctionParameter.php +++ b/app/Models/BuildingFunctionParameter.php @@ -12,19 +12,23 @@ class BuildingFunctionParameter extends Model 'fungsi_bangunan', 'ip_permanen', 'ip_kompleksitas', - 'ip_ketinggian', 'indeks_lokalitas', - 'is_active', - 'notes' + 'asumsi_prasarana', + 'koefisien_dasar', + 'faktor_penyesuaian', + 'custom_parameters', + 'parameter_notes' ]; protected $casts = [ 'fungsi_bangunan' => 'decimal:6', 'ip_permanen' => 'decimal:6', 'ip_kompleksitas' => 'decimal:6', - 'ip_ketinggian' => 'decimal:6', 'indeks_lokalitas' => 'decimal:6', - 'is_active' => 'boolean' + 'asumsi_prasarana' => 'decimal:6', + 'koefisien_dasar' => 'decimal:6', + 'faktor_penyesuaian' => 'decimal:6', + 'custom_parameters' => 'array' ]; /** @@ -52,8 +56,11 @@ class BuildingFunctionParameter extends Model 'fungsi_bangunan' => $this->fungsi_bangunan, 'ip_permanen' => $this->ip_permanen, 'ip_kompleksitas' => $this->ip_kompleksitas, - 'ip_ketinggian' => $this->ip_ketinggian, - 'indeks_lokalitas' => $this->indeks_lokalitas + 'indeks_lokalitas' => $this->indeks_lokalitas, + 'asumsi_prasarana' => $this->asumsi_prasarana, + 'koefisien_dasar' => $this->koefisien_dasar, + 'faktor_penyesuaian' => $this->faktor_penyesuaian, + 'custom_parameters' => $this->custom_parameters ]; } @@ -66,8 +73,62 @@ class BuildingFunctionParameter extends Model 'Fungsi Bangunan' => $this->fungsi_bangunan, 'IP Permanen' => $this->ip_permanen, 'IP Kompleksitas' => $this->ip_kompleksitas, - 'IP Ketinggian' => $this->ip_ketinggian, - 'Indeks Lokalitas' => $this->indeks_lokalitas . '%' + 'Indeks Lokalitas' => $this->indeks_lokalitas, + 'Asumsi Prasarana' => $this->asumsi_prasarana, + 'Koefisien Dasar' => $this->koefisien_dasar, + 'Faktor Penyesuaian' => $this->faktor_penyesuaian ]; } + + /** + * Calculate floor result using the main formula + */ + public function calculateFloorResult(float $ipKetinggian): float + { + return $this->fungsi_bangunan * ( + $this->ip_permanen + + $this->ip_kompleksitas + + (0.5 * $ipKetinggian) + ); + } + + /** + * Calculate full retribution for given building area and floor result + */ + public function calculateRetribution(float $luasBangunan, float $floorResult): float + { + $baseValue = 70350; // Base retribution value + $mainCalculation = 1 * $luasBangunan * ($this->indeks_lokalitas * $baseValue * $floorResult * 1); + $additionalCalculation = 0.5 * $mainCalculation; + + return $mainCalculation + $additionalCalculation; + } + + /** + * Apply custom parameters if available + */ + public function getParameterValue(string $key, $default = null) + { + // First check if it's a standard parameter + if (property_exists($this, $key) && $this->$key !== null) { + return $this->$key; + } + + // Then check custom parameters + if ($this->custom_parameters && is_array($this->custom_parameters)) { + return $this->custom_parameters[$key] ?? $default; + } + + return $default; + } + + /** + * Set custom parameter + */ + public function setCustomParameter(string $key, $value): void + { + $customParams = $this->custom_parameters ?? []; + $customParams[$key] = $value; + $this->custom_parameters = $customParams; + } } diff --git a/app/Models/FloorHeightIndex.php b/app/Models/FloorHeightIndex.php new file mode 100644 index 0000000..2b25e33 --- /dev/null +++ b/app/Models/FloorHeightIndex.php @@ -0,0 +1,56 @@ + 'integer', + 'ip_ketinggian' => 'decimal:6' + ]; + + /** + * Get IP ketinggian by floor number + */ + public static function getIpKetinggianByFloor(int $floorNumber): float + { + $index = self::where('floor_number', $floorNumber)->first(); + return $index ? (float) $index->ip_ketinggian : 1.0; + } + + /** + * Get all IP ketinggian mapping as array + */ + public static function getAllMapping(): array + { + return self::orderBy('floor_number') + ->pluck('ip_ketinggian', 'floor_number') + ->toArray(); + } + + /** + * Get available floor numbers + */ + public static function getAvailableFloors(): array + { + return self::orderBy('floor_number') + ->pluck('floor_number') + ->toArray(); + } + + /** + * Scope: By floor number + */ + public function scopeByFloor($query, int $floorNumber) + { + return $query->where('floor_number', $floorNumber); + } +} diff --git a/app/Models/RetributionCalculation.php b/app/Models/RetributionCalculation.php deleted file mode 100644 index c90f8af..0000000 --- a/app/Models/RetributionCalculation.php +++ /dev/null @@ -1,129 +0,0 @@ - 'decimal:6', - 'calculation_result' => 'decimal:6', - 'used_parameters' => 'array', - 'calculation_date' => 'datetime' - ]; - - /** - * Spatial planning relationship (1:1) - */ - public function spatialPlanning(): BelongsTo - { - return $this->belongsTo(SpatialPlanning::class); - } - - /** - * Retribution formula relationship - */ - public function retributionFormula(): BelongsTo - { - return $this->belongsTo(RetributionFormula::class); - } - - /** - * Detected building function relationship - */ - public function detectedBuildingFunction(): BelongsTo - { - return $this->belongsTo(BuildingFunction::class, 'detected_building_function_id'); - } - - /** - * User who calculated relationship - */ - public function calculatedBy(): BelongsTo - { - return $this->belongsTo(User::class, 'calculated_by'); - } - - /** - * Scope: Recent calculations - */ - public function scopeRecent($query, int $days = 30) - { - return $query->where('calculation_date', '>=', now()->subDays($days)); - } - - /** - * Scope: By building function - */ - public function scopeByBuildingFunction($query, int $buildingFunctionId) - { - return $query->where('detected_building_function_id', $buildingFunctionId); - } - - /** - * Get formatted calculation result - */ - public function getFormattedResultAttribute(): string - { - return number_format($this->calculation_result, 2); - } - - /** - * Get calculation summary - */ - public function getCalculationSummary(): array - { - return [ - 'spatial_planning' => $this->spatialPlanning->name ?? 'N/A', - 'building_function' => $this->detectedBuildingFunction->name ?? 'N/A', - 'luas_bangunan' => $this->luas_bangunan, - 'formula_used' => $this->used_formula, - 'parameters_used' => $this->used_parameters, - 'result' => $this->calculation_result, - 'calculated_date' => $this->calculation_date?->format('Y-m-d H:i:s'), - 'calculated_by' => $this->calculatedBy->name ?? 'System' - ]; - } - - /** - * Check if calculation is recent (within last 24 hours) - */ - public function isRecent(): bool - { - return $this->calculation_date && $this->calculation_date->isAfter(now()->subDay()); - } - - /** - * Recalculate retribution - */ - public function recalculate(): bool - { - if (!$this->spatialPlanning || !$this->retributionFormula) { - return false; - } - - try { - $service = app(\App\Services\RetributionCalculationService::class); - $service->calculateRetribution($this->spatialPlanning); - return true; - } catch (\Exception $e) { - \Log::error('Recalculation failed: ' . $e->getMessage()); - return false; - } - } -} diff --git a/app/Models/RetributionFormula.php b/app/Models/RetributionFormula.php index 6cabc72..527608e 100644 --- a/app/Models/RetributionFormula.php +++ b/app/Models/RetributionFormula.php @@ -11,17 +11,16 @@ class RetributionFormula extends Model protected $fillable = [ 'building_function_id', 'name', - 'formula_expression', - 'description', - 'is_active' + 'floor_number', + 'formula_expression' ]; protected $casts = [ - 'is_active' => 'boolean' + 'floor_number' => 'integer' ]; /** - * Building function relationship (1:1) + * Building function relationship (n:1) */ public function buildingFunction(): BelongsTo { @@ -29,45 +28,47 @@ class RetributionFormula extends Model } /** - * Retribution calculations relationship (1:n) + * Retribution proposals relationship (1:n) */ - public function retributionCalculations(): HasMany + public function retributionProposals(): HasMany { - return $this->hasMany(RetributionCalculation::class); + return $this->hasMany(RetributionProposal::class); } /** - * Scope: Active formulas only + * Scope: By floor number */ - public function scopeActive($query) + public function scopeByFloor($query, int $floorNumber) { - return $query->where('is_active', true); + return $query->where('floor_number', $floorNumber); } /** - * Execute formula calculation + * Execute formula calculation with parameters and IP ketinggian */ - public function calculate(float $luasBangunan, array $parameters): float + public function calculate(float $luasBangunan, array $parameters, float $ipKetinggian): float { - // Replace placeholders in formula with actual values - $formula = $this->formula_expression; + // Extract parameters + $fungsi_bangunan = $parameters['fungsi_bangunan'] ?? 0; + $ip_permanen = $parameters['ip_permanen'] ?? 0; + $ip_kompleksitas = $parameters['ip_kompleksitas'] ?? 0; + $indeks_lokalitas = $parameters['indeks_lokalitas'] ?? 0; + + // Calculate H13 (floor coefficient) using Excel formula + $h13 = $fungsi_bangunan * ($ip_permanen + $ip_kompleksitas + (0.5 * $ipKetinggian)); + + // Calculate full retribution using Excel formula + $baseValue = 70350; + $additionalFactor = 0.5; - // Replace luas_bangunan - $formula = str_replace('luas_bangunan', $luasBangunan, $formula); + // Main calculation: (1*D13*(N13*70350*H13*1)) + $mainCalculation = 1 * $luasBangunan * ($indeks_lokalitas * $baseValue * $h13 * 1); - // Replace parameter values - foreach ($parameters as $key => $value) { - $formula = str_replace($key, $value, $formula); - } + // Additional calculation: ($O$3*(1*D13*(N13*70350*H13*1))) + $additionalCalculation = $additionalFactor * $mainCalculation; - // Evaluate the mathematical expression - // Note: In production, use a safer math expression evaluator - try { - $result = eval("return $formula;"); - return (float) $result; - } catch (Exception $e) { - throw new \Exception("Error calculating formula: " . $e->getMessage()); - } + // Total: main + additional + return $mainCalculation + $additionalCalculation; } /** @@ -87,19 +88,140 @@ class RetributionFormula extends Model } /** - * Validate formula expression + * Check if this formula applies to given floor number + */ + public function appliesTo(int $floorNumber): bool + { + // If floor_number is 0, formula applies to all floors + if ($this->floor_number === 0) { + return true; + } + + // Otherwise, exact match required + return $this->floor_number == $floorNumber; + } + + /** + * Get human readable floor description + */ + public function getFloorDescription(): string + { + if ($this->floor_number === 0) { + return 'Semua lantai'; + } + + return "Lantai {$this->floor_number}"; + } + + /** + * Get default formula expression + */ + public static function getDefaultFormula(): string + { + return '(fungsi_bangunan * (ip_permanen + ip_kompleksitas + (0.5 * ip_ketinggian)))'; + } + + /** + * Validate formula expression syntax */ public function validateFormula(): bool { - // Basic validation - check if formula contains required elements - $requiredElements = ['luas_bangunan']; + // Basic validation - check if formula contains required variables + $requiredVariables = ['fungsi_bangunan', 'ip_permanen', 'ip_kompleksitas', 'ip_ketinggian']; - foreach ($requiredElements as $element) { - if (strpos($this->formula_expression, $element) === false) { + foreach ($requiredVariables as $variable) { + if (strpos($this->formula_expression, $variable) === false) { return false; } } return true; } + + /** + * Get calculation breakdown for debugging + */ + public function getCalculationBreakdown(float $luasBangunan, array $parameters, float $ipKetinggian): array + { + // Extract parameters + $fungsi_bangunan = $parameters['fungsi_bangunan'] ?? 0; + $ip_permanen = $parameters['ip_permanen'] ?? 0; + $ip_kompleksitas = $parameters['ip_kompleksitas'] ?? 0; + $indeks_lokalitas = $parameters['indeks_lokalitas'] ?? 0; + + // Calculate H13 (floor coefficient) + $h13 = $fungsi_bangunan * ($ip_permanen + $ip_kompleksitas + (0.5 * $ipKetinggian)); + + // Calculate components + $baseValue = 70350; + $additionalFactor = 0.5; + $mainCalculation = 1 * $luasBangunan * ($indeks_lokalitas * $baseValue * $h13 * 1); + $additionalCalculation = $additionalFactor * $mainCalculation; + $totalCalculation = $mainCalculation + $additionalCalculation; + + return [ + 'input_parameters' => [ + 'luas_bangunan' => $luasBangunan, + 'fungsi_bangunan' => $fungsi_bangunan, + 'ip_permanen' => $ip_permanen, + 'ip_kompleksitas' => $ip_kompleksitas, + 'ip_ketinggian' => $ipKetinggian, + 'indeks_lokalitas' => $indeks_lokalitas, + 'base_value' => $baseValue, + 'additional_factor' => $additionalFactor + ], + 'calculation_steps' => [ + 'h13_calculation' => [ + 'formula' => 'fungsi_bangunan * (ip_permanen + ip_kompleksitas + (0.5 * ip_ketinggian))', + 'calculation' => "{$fungsi_bangunan} * ({$ip_permanen} + {$ip_kompleksitas} + (0.5 * {$ipKetinggian}))", + 'result' => $h13 + ], + 'main_calculation' => [ + 'formula' => '1 * luas_bangunan * (indeks_lokalitas * base_value * h13 * 1)', + 'calculation' => "1 * {$luasBangunan} * ({$indeks_lokalitas} * {$baseValue} * {$h13} * 1)", + 'result' => $mainCalculation + ], + 'additional_calculation' => [ + 'formula' => 'additional_factor * main_calculation', + 'calculation' => "{$additionalFactor} * {$mainCalculation}", + 'result' => $additionalCalculation + ], + 'total_calculation' => [ + 'formula' => 'main_calculation + additional_calculation', + 'calculation' => "{$mainCalculation} + {$additionalCalculation}", + 'result' => $totalCalculation + ] + ], + 'formatted_results' => [ + 'h13' => number_format($h13, 6), + 'main_calculation' => 'Rp ' . number_format($mainCalculation, 2), + 'additional_calculation' => 'Rp ' . number_format($additionalCalculation, 2), + 'total_calculation' => 'Rp ' . number_format($totalCalculation, 2) + ] + ]; + } + + /** + * Check if this formula has been used in any proposals + */ + public function hasProposals(): bool + { + return $this->retributionProposals()->exists(); + } + + /** + * Get total amount calculated using this formula + */ + public function getTotalCalculatedAmount(): float + { + return $this->retributionProposals()->sum('total_retribution_amount'); + } + + /** + * Get count of proposals using this formula + */ + public function getProposalCount(): int + { + return $this->retributionProposals()->count(); + } } diff --git a/app/Models/RetributionProposal.php b/app/Models/RetributionProposal.php new file mode 100644 index 0000000..4fa42db --- /dev/null +++ b/app/Models/RetributionProposal.php @@ -0,0 +1,187 @@ + 'decimal:6', + 'total_building_area' => 'decimal:6', + 'ip_ketinggian' => 'decimal:6', + 'floor_retribution_amount' => 'decimal:2', + 'total_retribution_amount' => 'decimal:2', + 'calculation_parameters' => 'array', + 'calculation_breakdown' => 'array', + 'calculated_at' => 'datetime' + ]; + + + + /** + * Relationship with SpatialPlanning + */ + public function spatialPlanning(): BelongsTo + { + return $this->belongsTo(SpatialPlanning::class); + } + + /** + * Relationship with BuildingFunction + */ + public function buildingFunction(): BelongsTo + { + return $this->belongsTo(BuildingFunction::class); + } + + /** + * Relationship with RetributionFormula + */ + public function retributionFormula(): BelongsTo + { + return $this->belongsTo(RetributionFormula::class); + } + + /** + * Generate proposal number + */ + public static function generateProposalNumber(): string + { + $year = now()->format('Y'); + $month = now()->format('m'); + + // Use max ID + 1 to avoid duplicates when records are deleted + $maxId = static::whereYear('created_at', now()->year) + ->whereMonth('created_at', now()->month) + ->max('id') ?? 0; + $nextNumber = $maxId + 1; + + // Fallback: if still duplicate, use timestamp + $proposalNumber = sprintf('RP-%s%s-%04d', $year, $month, $nextNumber); + + // Check if exists and increment until unique + $counter = $nextNumber; + while (static::where('proposal_number', $proposalNumber)->exists()) { + $counter++; + $proposalNumber = sprintf('RP-%s%s-%04d', $year, $month, $counter); + } + + return $proposalNumber; + } + + /** + * Get formatted floor retribution amount + */ + public function getFormattedFloorAmountAttribute(): string + { + return 'Rp ' . number_format($this->floor_retribution_amount, 0, ',', '.'); + } + + /** + * Get formatted total retribution amount + */ + public function getFormattedTotalAmountAttribute(): string + { + return 'Rp ' . number_format($this->total_retribution_amount, 0, ',', '.'); + } + + /** + * Get calculation breakdown for specific parameter + */ + public function getCalculationBreakdownFor(string $parameter) + { + return $this->calculation_breakdown[$parameter] ?? null; + } + + /** + * Get parameter value + */ + public function getParameterValue(string $parameter) + { + return $this->calculation_parameters[$parameter] ?? null; + } + + + + + + /** + * Scope for filtering by building function + */ + public function scopeByBuildingFunction($query, int $buildingFunctionId) + { + return $query->where('building_function_id', $buildingFunctionId); + } + + /** + * Scope for filtering by spatial planning + */ + public function scopeBySpatialPlanning($query, int $spatialPlanningId) + { + return $query->where('spatial_planning_id', $spatialPlanningId); + } + + /** + * Get total retribution amount for multiple proposals + */ + public static function getTotalAmount($proposals = null): float + { + if ($proposals) { + return $proposals->sum('total_retribution_amount'); + } + + return static::sum('total_retribution_amount'); + } + + /** + * Get basic statistics + */ + public static function getBasicStats(): array + { + return [ + 'total_count' => static::count(), + 'total_amount' => static::sum('total_retribution_amount'), + 'average_amount' => static::avg('total_retribution_amount'), + ]; + } + + /** + * Boot method to generate proposal number + */ + protected static function boot() + { + parent::boot(); + + static::creating(function ($model) { + if (empty($model->proposal_number)) { + $model->proposal_number = static::generateProposalNumber(); + } + + if (empty($model->calculated_at)) { + $model->calculated_at = now(); + } + }); + } +} \ No newline at end of file diff --git a/app/Models/SpatialPlanning.php b/app/Models/SpatialPlanning.php index bb6d91b..5807924 100644 --- a/app/Models/SpatialPlanning.php +++ b/app/Models/SpatialPlanning.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** @@ -44,11 +45,11 @@ class SpatialPlanning extends Model ]; /** - * Retribution calculation relationship (1:1) + * Retribution proposals relationship (1:many) */ - public function retributionCalculation(): HasOne + public function retributionProposals(): HasMany { - return $this->hasOne(RetributionCalculation::class); + return $this->hasMany(RetributionProposal::class); } /** @@ -60,11 +61,27 @@ class SpatialPlanning extends Model } /** - * Check if spatial planning has retribution calculation + * Check if spatial planning has retribution proposals */ - public function hasRetributionCalculation(): bool + public function hasRetributionProposals(): bool { - return $this->retributionCalculation()->exists(); + return $this->retributionProposals()->exists(); + } + + /** + * Get latest retribution proposal + */ + public function getLatestRetributionProposal() + { + return $this->retributionProposals()->latest()->first(); + } + + /** + * Get all retribution proposals + */ + public function getAllRetributionProposals() + { + return $this->retributionProposals()->get(); } /** @@ -84,18 +101,20 @@ class SpatialPlanning extends Model } /** - * Scope: Without retribution calculation + * Scope: Without retribution proposals */ - public function scopeWithoutRetributionCalculation($query) + public function scopeWithoutRetributionProposals($query) { - return $query->whereDoesntHave('retributionCalculation'); + return $query->whereDoesntHave('retributionProposals'); } /** - * Scope: With retribution calculation + * Scope: With retribution proposals */ - public function scopeWithRetributionCalculation($query) + public function scopeWithRetributionProposals($query) { - return $query->whereHas('retributionCalculation'); + return $query->whereHas('retributionProposals'); } + + } diff --git a/app/Services/RetributionProposalService.php b/app/Services/RetributionProposalService.php new file mode 100644 index 0000000..45ec828 --- /dev/null +++ b/app/Services/RetributionProposalService.php @@ -0,0 +1,294 @@ +findOrFail($buildingFunctionId); + $parameters = $buildingFunction->parameter; + + if (!$parameters) { + throw new \Exception("Building function parameters not found for ID: {$buildingFunctionId}"); + } + + // Get floor height index + $floorHeightIndex = FloorHeightIndex::where('floor_number', $floorNumber)->first(); + if (!$floorHeightIndex) { + throw new \Exception("Floor height index not found for floor: {$floorNumber}"); + } + + // Get retribution formula + $retributionFormula = RetributionFormula::where('building_function_id', $buildingFunctionId) + ->where('floor_number', $floorNumber) + ->first(); + + if (!$retributionFormula) { + throw new \Exception("Retribution formula not found for building function ID: {$buildingFunctionId}, floor: {$floorNumber}"); + } + + // Calculate retribution using Excel formula + $calculationResult = $this->calculateRetribution( + $floorArea, + $parameters, + $floorHeightIndex->ip_ketinggian + ); + + // Create retribution proposal + return RetributionProposal::create([ + 'spatial_planning_id' => $spatialPlanning->id, + 'building_function_id' => $buildingFunctionId, + 'retribution_formula_id' => $retributionFormula->id, + 'floor_number' => $floorNumber, + 'floor_area' => $floorArea, + 'total_building_area' => $totalBuildingArea ?? $floorArea, + 'ip_ketinggian' => $floorHeightIndex->ip_ketinggian, + 'floor_retribution_amount' => $calculationResult['total_retribution'], + 'total_retribution_amount' => $calculationResult['total_retribution'], + 'calculation_parameters' => $calculationResult['parameters'], + 'calculation_breakdown' => $calculationResult['breakdown'], + 'notes' => $notes, + 'calculated_at' => Carbon::now() + ]); + } + + /** + * Create proposal without spatial planning + */ + public function createStandaloneProposal( + int $buildingFunctionId, + int $floorNumber, + float $floorArea, + float $totalBuildingArea = null, + string $notes = null + ): RetributionProposal { + // Get building function and its parameters + $buildingFunction = BuildingFunction::with('parameter')->findOrFail($buildingFunctionId); + $parameters = $buildingFunction->parameter; + + if (!$parameters) { + throw new \Exception("Building function parameters not found for ID: {$buildingFunctionId}"); + } + + // Get floor height index + $floorHeightIndex = FloorHeightIndex::where('floor_number', $floorNumber)->first(); + if (!$floorHeightIndex) { + throw new \Exception("Floor height index not found for floor: {$floorNumber}"); + } + + // Get retribution formula + $retributionFormula = RetributionFormula::where('building_function_id', $buildingFunctionId) + ->where('floor_number', $floorNumber) + ->first(); + + if (!$retributionFormula) { + throw new \Exception("Retribution formula not found for building function ID: {$buildingFunctionId}, floor: {$floorNumber}"); + } + + // Calculate retribution using Excel formula + $calculationResult = $this->calculateRetribution( + $floorArea, + $parameters, + $floorHeightIndex->ip_ketinggian + ); + + // Create retribution proposal + return RetributionProposal::create([ + 'spatial_planning_id' => null, + 'building_function_id' => $buildingFunctionId, + 'retribution_formula_id' => $retributionFormula->id, + 'floor_number' => $floorNumber, + 'floor_area' => $floorArea, + 'total_building_area' => $totalBuildingArea ?? $floorArea, + 'ip_ketinggian' => $floorHeightIndex->ip_ketinggian, + 'floor_retribution_amount' => $calculationResult['total_retribution'], + 'total_retribution_amount' => $calculationResult['total_retribution'], + 'calculation_parameters' => $calculationResult['parameters'], + 'calculation_breakdown' => $calculationResult['breakdown'], + 'notes' => $notes, + 'calculated_at' => Carbon::now() + ]); + } + + /** + * Calculate retribution using Excel formula + */ + protected function calculateRetribution( + float $floorArea, + BuildingFunctionParameter $parameters, + float $ipKetinggian + ): array { + // Excel formula parameters + $fungsi_bangunan = $parameters->fungsi_bangunan; + $ip_permanen = $parameters->ip_permanen; + $ip_kompleksitas = $parameters->ip_kompleksitas; + $indeks_lokalitas = $parameters->indeks_lokalitas; + $base_value = 70350; + $additional_factor = 0.5; + + // Step 1: Calculate H13 (floor coefficient) + $h13 = $fungsi_bangunan * ($ip_permanen + $ip_kompleksitas + (0.5 * $ipKetinggian)); + + // Step 2: Main calculation + $main_calculation = 1 * $floorArea * ($indeks_lokalitas * $base_value * $h13 * 1); + + // Step 3: Additional (50%) + $additional_calculation = $additional_factor * $main_calculation; + + // Step 4: Total + $total_retribution = $main_calculation + $additional_calculation; + + return [ + 'total_retribution' => $total_retribution, + 'parameters' => [ + 'fungsi_bangunan' => $fungsi_bangunan, + 'ip_permanen' => $ip_permanen, + 'ip_kompleksitas' => $ip_kompleksitas, + 'ip_ketinggian' => $ipKetinggian, + 'indeks_lokalitas' => $indeks_lokalitas, + 'base_value' => $base_value, + 'additional_factor' => $additional_factor, + 'floor_area' => $floorArea + ], + 'breakdown' => [ + 'h13_calculation' => [ + 'formula' => 'fungsi_bangunan * (ip_permanen + ip_kompleksitas + (0.5 * ip_ketinggian))', + 'calculation' => "{$fungsi_bangunan} * ({$ip_permanen} + {$ip_kompleksitas} + (0.5 * {$ipKetinggian}))", + 'result' => $h13 + ], + 'main_calculation' => [ + 'formula' => '1 * floor_area * (indeks_lokalitas * base_value * h13 * 1)', + 'calculation' => "1 * {$floorArea} * ({$indeks_lokalitas} * {$base_value} * {$h13} * 1)", + 'result' => $main_calculation + ], + 'additional_calculation' => [ + 'formula' => 'additional_factor * main_calculation', + 'calculation' => "{$additional_factor} * {$main_calculation}", + 'result' => $additional_calculation + ], + 'total_calculation' => [ + 'formula' => 'main_calculation + additional_calculation', + 'calculation' => "{$main_calculation} + {$additional_calculation}", + 'result' => $total_retribution + ] + ] + ]; + } + + /** + * Get proposal statistics + */ + public function getStatistics(): array + { + return [ + 'total_proposals' => RetributionProposal::count(), + 'total_amount' => RetributionProposal::sum('total_retribution_amount'), + 'average_amount' => RetributionProposal::avg('total_retribution_amount'), + 'proposals_with_spatial_planning' => RetributionProposal::whereNotNull('spatial_planning_id')->count(), + 'proposals_without_spatial_planning' => RetributionProposal::whereNull('spatial_planning_id')->count(), + 'by_building_function' => RetributionProposal::with('buildingFunction') + ->selectRaw('building_function_id, COUNT(*) as count, SUM(total_retribution_amount) as total_amount') + ->groupBy('building_function_id') + ->orderBy('total_amount', 'desc') + ->get() + ->map(function ($item) { + return [ + 'building_function_id' => $item->building_function_id, + 'building_function_name' => $item->buildingFunction->name ?? 'Unknown', + 'count' => $item->count, + 'total_amount' => $item->total_amount, + 'formatted_amount' => 'Rp ' . number_format($item->total_amount, 0, ',', '.') + ]; + })->toArray() + ]; + } + + /** + * Detect building function from text + */ + public function detectBuildingFunction(string $text): ?BuildingFunction + { + $text = strtolower($text); + + // Detection patterns - order matters (more specific first) + $patterns = [ + 'HUNIAN_TIDAK_SEDERHANA' => [ + 'hunian mewah', 'villa', 'apartemen', 'kondominium', 'townhouse' + ], + 'HUNIAN_SEDERHANA' => [ + 'hunian sederhana', 'hunian', 'rumah', 'perumahan', 'residential', 'fungsi hunian' + ], + 'USAHA_BESAR' => [ + 'usaha besar', 'pabrik', 'industri', 'mall', 'hotel', 'restoran', 'supermarket', + 'plaza', 'gedung perkantoran', 'non-mikro', 'non mikro' + ], + 'USAHA_KECIL' => [ + 'usaha kecil', 'umkm', 'warung', 'toko', 'mikro', 'kios' + ], + 'CAMPURAN_BESAR' => [ + 'campuran besar', 'mixed use besar' + ], + 'CAMPURAN_KECIL' => [ + 'campuran kecil', 'ruko', 'mixed use', 'campuran' + ], + 'SOSIAL_BUDAYA' => [ + 'sekolah', 'rumah sakit', 'masjid', 'gereja', 'puskesmas', 'klinik', + 'universitas', 'perpustakaan', 'museum' + ], + 'AGAMA' => [ + 'tempat ibadah', 'masjid', 'mushola', 'gereja', 'pura', 'vihara', 'klenteng' + ] + ]; + + // Try to find exact matches first + foreach ($patterns as $functionCode => $keywords) { + foreach ($keywords as $keyword) { + if (strpos($text, $keyword) !== false) { + $buildingFunction = BuildingFunction::where('code', $functionCode)->first(); + if ($buildingFunction) { + return $buildingFunction; + } + } + } + } + + // Debug: Log what we're trying to match + \Illuminate\Support\Facades\Log::info("Building function detection failed for text: '{$text}'"); + + // If no exact match, try to find by name similarity + $allFunctions = BuildingFunction::whereNotNull('parent_id')->get(); // Only child functions + + foreach ($allFunctions as $function) { + $functionName = strtolower($function->name); + + // Check if any word from the function name appears in the text + $words = explode(' ', $functionName); + foreach ($words as $word) { + if (strlen($word) > 3 && strpos($text, $word) !== false) { + return $function; + } + } + } + + return null; + } +} \ No newline at end of file diff --git a/app/Services/ServiceGoogleSheet.php b/app/Services/ServiceGoogleSheet.php index 5e34df3..68f1752 100644 --- a/app/Services/ServiceGoogleSheet.php +++ b/app/Services/ServiceGoogleSheet.php @@ -6,6 +6,7 @@ use App\Models\BigdataResume; use App\Models\DataSetting; use App\Models\ImportDatasource; use App\Models\PbgTaskGoogleSheet; +use App\Models\RetributionProposal; use Carbon\Carbon; use Exception; use Google\Client as Google_Client; @@ -249,7 +250,7 @@ class ServiceGoogleSheet BigdataResume::create([ 'import_datasource_id' => $import_datasource->id, - 'year' => now()->year, + 'year' => 'leader', // USAHA 'business_count' => $this->convertToInteger($result['USAHA']['total'] ?? null) ?? 0, 'business_sum' => $this->convertToDecimal($result['USAHA']['nominal'] ?? null) ?? 0, @@ -273,7 +274,10 @@ class ServiceGoogleSheet 'waiting_click_dpmptsp_sum' => $this->convertToDecimal($result['WAITING_KLIK_DPMPTSP']['nominal'] ?? null) ?? 0, // PROSES DINAS TEKNIS 'process_in_technical_office_count' => $this->convertToInteger($result['PROSES_DINAS_TEKNIS']['total'] ?? null) ?? 0, - 'process_in_technical_office_sum' => $this->convertToDecimal($result['PROSES_DINAS_TEKNIS']['nominal'] ?? null) ?? 0 + 'process_in_technical_office_sum' => $this->convertToDecimal($result['PROSES_DINAS_TEKNIS']['nominal'] ?? null) ?? 0, + // TATA RUANG + 'spatial_count' => RetributionProposal::count(), + 'spatial_sum' => RetributionProposal::sum('total_retribution_amount'), ]); // Save data settings diff --git a/database/migrations/2025_06_17_163401_create_building_functions_table.php b/database/migrations/2025_06_17_163401_create_building_functions_table.php index 66d24e0..0c01aaf 100644 --- a/database/migrations/2025_06_17_163401_create_building_functions_table.php +++ b/database/migrations/2025_06_17_163401_create_building_functions_table.php @@ -13,15 +13,17 @@ return new class extends Migration { Schema::create('building_functions', function (Blueprint $table) { $table->id(); - $table->string('code')->unique(); - $table->string('name'); - $table->string('description')->nullable(); - $table->unsignedBigInteger('parent_id')->nullable(); + $table->string('code')->unique()->comment('Kode unik fungsi bangunan'); + $table->string('name', 255)->comment('Nama fungsi bangunan'); + $table->text('description')->nullable()->comment('Deskripsi detail fungsi bangunan'); + $table->unsignedBigInteger('parent_id')->nullable()->comment('ID parent untuk hierarki'); $table->foreign('parent_id')->references('id')->on('building_functions')->onDelete('cascade'); - $table->boolean('is_active')->default(true); - $table->integer('level')->default(0); - $table->integer('sort_order')->default(0); - $table->index(['parent_id', 'is_active']); + $table->integer('level')->default(0)->comment('Level hierarki (0=root, 1=child, dst)'); + $table->integer('sort_order')->default(0)->comment('Urutan tampilan'); + $table->decimal('base_tariff', 15, 2)->nullable()->comment('Tarif dasar per m2'); + + // Indexes untuk performa + $table->index(['parent_id', 'level']); $table->index(['level', 'sort_order']); $table->timestamps(); }); diff --git a/database/migrations/2025_06_17_165000_create_floor_height_indices_table.php b/database/migrations/2025_06_17_165000_create_floor_height_indices_table.php new file mode 100644 index 0000000..5c2cf53 --- /dev/null +++ b/database/migrations/2025_06_17_165000_create_floor_height_indices_table.php @@ -0,0 +1,34 @@ +id(); + $table->integer('floor_number')->comment('Nomor lantai'); + $table->decimal('ip_ketinggian', 10, 6)->comment('Indeks ketinggian per lantai'); + $table->text('description')->nullable()->comment('Deskripsi indeks ketinggian'); + $table->timestamps(); + + // Unique constraint untuk floor_number + $table->unique('floor_number'); + $table->index('floor_number'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('floor_height_indices'); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_06_17_174423_create_building_function_parameters_table.php b/database/migrations/2025_06_17_174423_create_building_function_parameters_table.php index 6462ae8..1b08659 100644 --- a/database/migrations/2025_06_17_174423_create_building_function_parameters_table.php +++ b/database/migrations/2025_06_17_174423_create_building_function_parameters_table.php @@ -14,13 +14,12 @@ return new class extends Migration Schema::create('building_function_parameters', function (Blueprint $table) { $table->id(); $table->foreignId('building_function_id')->constrained('building_functions')->onDelete('cascade'); - $table->decimal('fungsi_bangunan', 10, 6)->comment('Parameter fungsi bangunan'); - $table->decimal('ip_permanen', 10, 6)->comment('Parameter IP permanen'); - $table->decimal('ip_kompleksitas', 10, 6)->comment('Parameter IP kompleksitas'); - $table->decimal('ip_ketinggian', 10, 6)->comment('Parameter IP ketinggian'); - $table->decimal('indeks_lokalitas', 10, 6)->comment('Parameter indeks lokalitas'); - $table->boolean('is_active')->default(true); - $table->text('notes')->nullable(); + $table->decimal('fungsi_bangunan', 10, 6)->nullable()->comment('Parameter fungsi bangunan'); + $table->decimal('ip_permanen', 10, 6)->nullable()->comment('Parameter IP permanen'); + $table->decimal('ip_kompleksitas', 10, 6)->nullable()->comment('Parameter IP kompleksitas'); + $table->decimal('indeks_lokalitas', 10, 6)->nullable()->comment('Parameter indeks lokalitas'); + $table->decimal('asumsi_prasarana', 8, 6)->nullable()->comment('Parameter asumsi prasarana untuk perhitungan retribusi'); + $table->decimal('koefisien_dasar', 15, 6)->nullable()->comment('Koefisien dasar perhitungan'); $table->timestamps(); // Unique constraint untuk 1:1 relationship diff --git a/database/migrations/2025_06_17_174433_create_retribution_formulas_table.php b/database/migrations/2025_06_17_174433_create_retribution_formulas_table.php index b011372..1895ccd 100644 --- a/database/migrations/2025_06_17_174433_create_retribution_formulas_table.php +++ b/database/migrations/2025_06_17_174433_create_retribution_formulas_table.php @@ -14,14 +14,13 @@ return new class extends Migration Schema::create('retribution_formulas', function (Blueprint $table) { $table->id(); $table->foreignId('building_function_id')->constrained('building_functions')->onDelete('cascade'); - $table->string('name')->comment('Nama formula'); + $table->string('name', 255)->comment('Nama formula'); + $table->integer('floor_number')->comment('Nomor lantai (1, 2, 3, dst, 0=semua lantai)'); $table->text('formula_expression')->comment('Rumus matematika untuk perhitungan'); - $table->text('description')->nullable()->comment('Deskripsi formula'); - $table->boolean('is_active')->default(true); $table->timestamps(); - // Unique constraint untuk 1:1 relationship - $table->unique('building_function_id'); + // Indexes untuk performa + $table->index(['building_function_id', 'floor_number'], 'idx_building_floor'); }); } diff --git a/database/migrations/2025_06_17_174442_create_retribution_calculations_table.php b/database/migrations/2025_06_17_174442_create_retribution_calculations_table.php deleted file mode 100644 index f31ee04..0000000 --- a/database/migrations/2025_06_17_174442_create_retribution_calculations_table.php +++ /dev/null @@ -1,44 +0,0 @@ -id(); - $table->foreignId('spatial_planning_id')->constrained('spatial_plannings')->onDelete('cascade'); - $table->foreignId('retribution_formula_id')->nullable()->constrained('retribution_formulas')->onDelete('set null'); - $table->foreignId('detected_building_function_id')->nullable()->constrained('building_functions')->onDelete('set null'); - $table->decimal('luas_bangunan', 15, 6)->comment('Luas bangunan yang digunakan dalam perhitungan'); - $table->json('used_parameters')->nullable()->comment('Parameter yang digunakan dalam perhitungan'); - $table->string('used_formula')->nullable()->comment('Formula yang digunakan dalam perhitungan'); - $table->decimal('calculation_result', 15, 6)->nullable()->comment('Hasil perhitungan retribusi'); - $table->datetime('calculation_date')->nullable()->comment('Tanggal perhitungan'); - $table->foreignId('calculated_by')->nullable()->constrained('users')->onDelete('set null'); - $table->text('notes')->nullable(); - $table->timestamps(); - - // Unique constraint untuk 1:1 relationship dengan spatial_plannings - $table->unique('spatial_planning_id'); - - // Index untuk performa query dengan nama yang lebih pendek - $table->index(['retribution_formula_id', 'calculation_date'], 'idx_formula_calc_date'); - $table->index(['detected_building_function_id', 'calculation_date'], 'idx_building_func_calc_date'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('retribution_calculations'); - } -}; diff --git a/database/migrations/2025_06_17_211558_create_retribution_proposals_table.php b/database/migrations/2025_06_17_211558_create_retribution_proposals_table.php new file mode 100644 index 0000000..0071612 --- /dev/null +++ b/database/migrations/2025_06_17_211558_create_retribution_proposals_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('spatial_planning_id')->nullable()->constrained('spatial_plannings')->onDelete('cascade'); + $table->foreignId('building_function_id')->constrained('building_functions')->onDelete('cascade'); + $table->foreignId('retribution_formula_id')->constrained('retribution_formulas')->onDelete('cascade'); + $table->string('proposal_number')->unique()->comment('Nomor usulan retribusi'); + $table->integer('floor_number')->comment('Nomor lantai (1, 2, 3, dst)'); + $table->decimal('floor_area', 15, 6)->comment('Luas lantai ini (m2)'); + $table->decimal('total_building_area', 15, 6)->comment('Total luas bangunan (m2)'); + $table->decimal('ip_ketinggian', 10, 6)->comment('IP ketinggian untuk lantai ini'); + $table->decimal('floor_retribution_amount', 15, 2)->comment('Jumlah retribusi untuk lantai ini'); + $table->decimal('total_retribution_amount', 15, 2)->comment('Total retribusi keseluruhan'); + $table->json('calculation_parameters')->nullable()->comment('Parameter yang digunakan dalam perhitungan'); + $table->json('calculation_breakdown')->nullable()->comment('Breakdown detail perhitungan'); + $table->text('notes')->nullable()->comment('Catatan tambahan'); + $table->datetime('calculated_at')->comment('Waktu perhitungan dilakukan'); + $table->timestamps(); + + // Indexes untuk performa + $table->index(['spatial_planning_id', 'floor_number'], 'idx_spatial_floor'); + $table->index(['building_function_id'], 'idx_function'); + $table->index('calculated_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('retribution_proposals'); + } +}; \ No newline at end of file diff --git a/database/seeders/BuildingFunctionParameterSeeder.php b/database/seeders/BuildingFunctionParameterSeeder.php new file mode 100644 index 0000000..3655d19 --- /dev/null +++ b/database/seeders/BuildingFunctionParameterSeeder.php @@ -0,0 +1,148 @@ + 'AGAMA', + 'fungsi_bangunan' => 0, + 'ip_permanen' => 0, + 'ip_kompleksitas' => 0, + 'indeks_lokalitas' => 0, + 'asumsi_prasarana' => 0.5, + 'is_active' => true, + 'notes' => 'Parameter untuk fungsi keagamaan' + ], + + // Fungsi Sosial Budaya + [ + 'building_function_code' => 'SOSIAL_BUDAYA', + 'fungsi_bangunan' => 0.3, + 'ip_permanen' => 0.4, + 'ip_kompleksitas' => 0.6, + 'indeks_lokalitas' => 0.3, + 'asumsi_prasarana' => 0.5, + 'is_active' => true, + 'notes' => 'Parameter untuk fungsi sosial budaya' + ], + + // Fungsi Campuran Kecil + [ + 'building_function_code' => 'CAMPURAN_KECIL', + 'fungsi_bangunan' => 0.6, + 'ip_permanen' => 0.4, + 'ip_kompleksitas' => 0.6, + 'indeks_lokalitas' => 0.5, + 'asumsi_prasarana' => 0.5, + 'is_active' => true, + 'notes' => 'Parameter untuk fungsi campuran kecil' + ], + + // Fungsi Campuran Besar + [ + 'building_function_code' => 'CAMPURAN_BESAR', + 'fungsi_bangunan' => 0.8, + 'ip_permanen' => 0.4, + 'ip_kompleksitas' => 0.6, + 'indeks_lokalitas' => 0.5, + 'asumsi_prasarana' => 0.5, + 'is_active' => true, + 'notes' => 'Parameter untuk fungsi campuran besar' + ], + + // UMKM (Usaha Kecil) + [ + 'building_function_code' => 'USAHA_KECIL', + 'fungsi_bangunan' => 0.5, + 'ip_permanen' => 0.4, + 'ip_kompleksitas' => 0.6, + 'indeks_lokalitas' => 0.4, + 'asumsi_prasarana' => 0.5, + 'is_active' => true, + 'notes' => 'Parameter untuk UMKM' + ], + + // Usaha Besar (Non-Mikro) + [ + 'building_function_code' => 'USAHA_BESAR', + 'fungsi_bangunan' => 0.7, + 'ip_permanen' => 0.4, + 'ip_kompleksitas' => 0.6, + 'indeks_lokalitas' => 0.5, + 'asumsi_prasarana' => 0.5, + 'is_active' => true, + 'notes' => 'Parameter untuk usaha besar (non-mikro)' + ], + + // Hunian Sederhana < 100 m2 + [ + 'building_function_code' => 'HUNIAN_KECIL', + 'fungsi_bangunan' => 0.15, + 'ip_permanen' => 0.4, + 'ip_kompleksitas' => 0.3, + 'indeks_lokalitas' => 0.4, + 'asumsi_prasarana' => 0.5, + 'is_active' => true, + 'notes' => 'Parameter untuk hunian sederhana < 100 m2' + ], + + // Hunian Sederhana > 100 m2 + [ + 'building_function_code' => 'HUNIAN_BESAR', + 'fungsi_bangunan' => 0.17, + 'ip_permanen' => 0.4, + 'ip_kompleksitas' => 0.6, + 'indeks_lokalitas' => 0.4, + 'asumsi_prasarana' => 0.5, + 'is_active' => true, + 'notes' => 'Parameter untuk hunian sederhana > 100 m2' + ], + + // MBR (Masyarakat Berpenghasilan Rendah) + [ + 'building_function_code' => 'HUNIAN_MBR', + 'fungsi_bangunan' => 0, + 'ip_permanen' => 0, + 'ip_kompleksitas' => 0, + 'indeks_lokalitas' => 0, + 'asumsi_prasarana' => 0.5, + 'is_active' => true, + 'notes' => 'Parameter untuk MBR (tarif lebih rendah)' + ] + ]; + + foreach ($parameters as $parameterData) { + $buildingFunction = BuildingFunction::where('code', $parameterData['building_function_code'])->first(); + + if ($buildingFunction) { + // Remove building_function_code from parameter data + $parameterData['building_function_id'] = $buildingFunction->id; + unset($parameterData['building_function_code']); + + BuildingFunctionParameter::updateOrCreate( + ['building_function_id' => $buildingFunction->id], + $parameterData + ); + + $this->command->info("Created/Updated parameter for: {$buildingFunction->name}"); + } else { + $this->command->warn("Building function not found: {$parameterData['building_function_code']}"); + } + } + + $this->command->info('Building Function Parameters seeding completed!'); + } +} diff --git a/database/seeders/BuildingFunctionSeeder.php b/database/seeders/BuildingFunctionSeeder.php index 3a2cff1..705a6a8 100644 --- a/database/seeders/BuildingFunctionSeeder.php +++ b/database/seeders/BuildingFunctionSeeder.php @@ -5,141 +5,152 @@ namespace Database\Seeders; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; use App\Models\BuildingFunction; +use Illuminate\Support\Facades\DB; +use Carbon\Carbon; class BuildingFunctionSeeder extends Seeder { + /** + * Run the database seeds. + */ public function run(): void { + $now = Carbon::now(); + + // 1. Building Functions Data - Fixed structure with proper parent-child relationships $buildingFunctions = [ - [ - 'code' => 'AGAMA', - 'name' => 'Fungsi Keagamaan', - 'description' => 'Fungsi Keagamaan', - 'parent_id' => null, - 'is_active' => true, - 'level' => 0, - 'sort_order' => 1, - ], - [ - 'code' => 'SOSIAL_BUDAYA', - 'name' => 'Fungsi Sosial Budaya', - 'description' => 'Fungsi Sosial Budaya', - 'parent_id' => null, - 'is_active' => true, - 'level' => 0, - 'sort_order' => 2, - ], - [ - 'code' => 'CAMPURAN', - 'name' => 'Fungsi Campuran', - 'description' => 'Fungsi Campuran', - 'parent_id' => null, - 'is_active' => true, - 'level' => 0, - 'sort_order' => 3, - 'children' => [ - [ - 'code' => 'CAMPURAN_KECIL', - 'name' => 'Fungsi Campuran Kecil', - 'description' => 'Fungsi Campuran Kecil', - 'is_active' => true, - 'level' => 1, - 'sort_order' => 1, - ], - [ - 'code' => 'CAMPURAN_BESAR', - 'name' => 'Fungsi Campuran Besar', - 'description' => 'Fungsi Campuran Besar', - 'is_active' => true, - 'level' => 1, - 'sort_order' => 2, - ] - ] - ], - [ - 'code' => 'USAHA', - 'name' => 'Fungsi Usaha', - 'description' => 'Fungsi Usaha', - 'parent_id' => null, - 'is_active' => true, - 'level' => 0, - 'sort_order' => 4, - 'children' => [ - [ - 'code' => 'USAHA_KECIL', - 'name' => 'UMKM', - 'description' => 'Fungsi Usaha Kecil', - 'is_active' => true, - 'level' => 1, - 'sort_order' => 1, - ], - [ - 'code' => 'USAHA_BESAR', - 'name' => 'Usaha Besar (Non-Mikro)', - 'description' => 'Fungsi Usaha Besar', - 'is_active' => true, - 'level' => 1, - 'sort_order' => 2, - ] - ] - ], - [ - 'code' => 'HUNIAN', - 'name' => 'Fungsi Hunian', - 'description' => 'Fungsi Hunian', - 'parent_id' => null, - 'is_active' => true, - 'level' => 0, - 'sort_order' => 5, - 'children' => [ - [ - 'code' => 'HUNIAN_KECIL', - 'name' => 'Sederhana < 100 m2', - 'description' => 'Sederhana < 100 m2', - 'is_active' => true, - 'level' => 1, - 'sort_order' => 1, - ], - [ - 'code' => 'HUNIAN_BESAR', - 'name' => 'Sederhana > 100 m2', - 'description' => 'Sederhana > 100 m2', - 'is_active' => true, - 'level' => 1, - 'sort_order' => 2, - ], - [ - 'code' => 'HUNIAN_MBR', - 'name' => 'MBR', - 'description' => 'Rumah Tinggal Deret (MBR) dan Rumah Tinggal Tunggal (MBR)', - 'is_active' => true, - 'level' => 1, - 'sort_order' => 3, - ] - ] - ] + // Parent Categories + ['id' => 1, 'code' => 'AGAMA', 'name' => 'Bangunan Keagamaan', 'parent_id' => null, 'level' => 0, 'sort_order' => 1, 'base_tariff' => 0.00], + ['id' => 2, 'code' => 'SOSIAL_BUDAYA', 'name' => 'Bangunan Sosial Budaya', 'parent_id' => null, 'level' => 0, 'sort_order' => 2, 'base_tariff' => 30000.00], + ['id' => 3, 'code' => 'CAMPURAN', 'name' => 'Bangunan Campuran', 'parent_id' => null, 'level' => 0, 'sort_order' => 3, 'base_tariff' => 70000.00], + ['id' => 4, 'code' => 'USAHA', 'name' => 'Bangunan Usaha', 'parent_id' => null, 'level' => 0, 'sort_order' => 4, 'base_tariff' => 60000.00], + ['id' => 5, 'code' => 'HUNIAN', 'name' => 'Bangunan Hunian', 'parent_id' => null, 'level' => 0, 'sort_order' => 5, 'base_tariff' => 40000.00], + + // Sub Categories - Campuran + ['id' => 6, 'code' => 'CAMPURAN_KECIL', 'name' => 'Bangunan Campuran Kecil', 'parent_id' => 3, 'level' => 1, 'sort_order' => 1, 'base_tariff' => 60000.00], + ['id' => 7, 'code' => 'CAMPURAN_BESAR', 'name' => 'Bangunan Campuran Besar', 'parent_id' => 3, 'level' => 1, 'sort_order' => 2, 'base_tariff' => 80000.00], + + // Sub Categories - Usaha + ['id' => 8, 'code' => 'USAHA_KECIL', 'name' => 'Bangunan Usaha Kecil (UMKM)', 'parent_id' => 4, 'level' => 1, 'sort_order' => 1, 'base_tariff' => 50000.00], + ['id' => 9, 'code' => 'USAHA_BESAR', 'name' => 'Bangunan Usaha Besar', 'parent_id' => 4, 'level' => 1, 'sort_order' => 2, 'base_tariff' => 70000.00], + + // Sub Categories - Hunian + ['id' => 10, 'code' => 'HUNIAN_SEDERHANA', 'name' => 'Hunian Sederhana', 'parent_id' => 5, 'level' => 1, 'sort_order' => 1, 'base_tariff' => 35000.00], + ['id' => 11, 'code' => 'HUNIAN_TIDAK_SEDERHANA', 'name' => 'Hunian Tidak Sederhana', 'parent_id' => 5, 'level' => 1, 'sort_order' => 2, 'base_tariff' => 45000.00], + ['id' => 12, 'code' => 'MBR', 'name' => 'Hunian MBR (Masyarakat Berpenghasilan Rendah)', 'parent_id' => 5, 'level' => 1, 'sort_order' => 3, 'base_tariff' => 0.00], ]; foreach ($buildingFunctions as $function) { - $this->insertOrUpdateByCode($function); + DB::table('building_functions')->updateOrInsert( + ['id' => $function['id']], + array_merge($function, [ + 'description' => 'Deskripsi untuk ' . $function['name'], + 'created_at' => $now, + 'updated_at' => $now + ]) + ); } - } - private function insertOrUpdateByCode(array $data, ?int $parentId = null): void - { - $children = $data['children'] ?? []; - unset($data['children']); + // 2. Building Function Parameters + $parameters = [ + // AGAMA - No charge + ['building_function_id' => 1, 'fungsi_bangunan' => 0, 'ip_permanen' => 0, 'ip_kompleksitas' => 0, 'indeks_lokalitas' => 0, 'asumsi_prasarana' => 0.5], + + // SOSIAL_BUDAYA + ['building_function_id' => 2, 'fungsi_bangunan' => 0.3, 'ip_permanen' => 0.4, 'ip_kompleksitas' => 0.6, 'indeks_lokalitas' => 0.3, 'asumsi_prasarana' => 0.5], + + // CAMPURAN_KECIL + ['building_function_id' => 6, 'fungsi_bangunan' => 0.6, 'ip_permanen' => 0.4, 'ip_kompleksitas' => 0.6, 'indeks_lokalitas' => 0.5, 'asumsi_prasarana' => 0.5], + + // CAMPURAN_BESAR + ['building_function_id' => 7, 'fungsi_bangunan' => 0.8, 'ip_permanen' => 0.4, 'ip_kompleksitas' => 0.6, 'indeks_lokalitas' => 0.5, 'asumsi_prasarana' => 0.5], + + // USAHA_KECIL + ['building_function_id' => 8, 'fungsi_bangunan' => 0.5, 'ip_permanen' => 0.4, 'ip_kompleksitas' => 0.6, 'indeks_lokalitas' => 0.4, 'asumsi_prasarana' => 0.5], + + // USAHA_BESAR + ['building_function_id' => 9, 'fungsi_bangunan' => 0.7, 'ip_permanen' => 0.4, 'ip_kompleksitas' => 0.6, 'indeks_lokalitas' => 0.5, 'asumsi_prasarana' => 0.5], + + // HUNIAN_SEDERHANA + ['building_function_id' => 10, 'fungsi_bangunan' => 0.15, 'ip_permanen' => 0.4, 'ip_kompleksitas' => 0.3, 'indeks_lokalitas' => 0.4, 'asumsi_prasarana' => 0.5], + + // HUNIAN_TIDAK_SEDERHANA + ['building_function_id' => 11, 'fungsi_bangunan' => 0.17, 'ip_permanen' => 0.4, 'ip_kompleksitas' => 0.6, 'indeks_lokalitas' => 0.4, 'asumsi_prasarana' => 0.5], + + // MBR - No charge + ['building_function_id' => 12, 'fungsi_bangunan' => 0, 'ip_permanen' => 0, 'ip_kompleksitas' => 0, 'indeks_lokalitas' => 0, 'asumsi_prasarana' => 0.5], + ]; - $data['parent_id'] = $parentId; - - // Insert or update by code - $record = BuildingFunction::updateOrCreate( - ['code' => $data['code']], - $data - ); - - foreach ($children as $child) { - $this->insertOrUpdateByCode($child, $record->id); + foreach ($parameters as $param) { + DB::table('building_function_parameters')->updateOrInsert( + ['building_function_id' => $param['building_function_id']], + array_merge($param, [ + 'koefisien_dasar' => 1.0, + 'created_at' => $now, + 'updated_at' => $now + ]) + ); } + + // 3. Floor Height Indices (IP Ketinggian per lantai) + $floorHeightIndices = [ + ['floor_number' => 1, 'ip_ketinggian' => 1.000000, 'description' => 'IP ketinggian untuk lantai 1'], + ['floor_number' => 2, 'ip_ketinggian' => 1.090000, 'description' => 'IP ketinggian untuk lantai 2'], + ['floor_number' => 3, 'ip_ketinggian' => 1.120000, 'description' => 'IP ketinggian untuk lantai 3'], + ['floor_number' => 4, 'ip_ketinggian' => 1.350000, 'description' => 'IP ketinggian untuk lantai 4'], + ['floor_number' => 5, 'ip_ketinggian' => 1.620000, 'description' => 'IP ketinggian untuk lantai 5'], + ['floor_number' => 6, 'ip_ketinggian' => 1.197000, 'description' => 'IP ketinggian untuk lantai 6'], + ]; + + foreach ($floorHeightIndices as $heightIndex) { + DB::table('floor_height_indices')->updateOrInsert( + ['floor_number' => $heightIndex['floor_number']], + array_merge($heightIndex, [ + 'created_at' => $now, + 'updated_at' => $now + ]) + ); + } + + // Formula Templates removed - not used in current system + + // 5. Retribution Formulas per Floor (for child building functions only) + $childBuildingFunctionIds = [1, 2, 6, 7, 8, 9, 10, 11, 12]; // All leaf nodes + $formulaExpression = '(1 * luas_bangunan * (indeks_lokalitas * 70350 * ((fungsi_bangunan * (ip_permanen + ip_kompleksitas + (0.5 * ip_ketinggian)))) * 1)) + (0.5 * (1 * luas_bangunan * (indeks_lokalitas * 70350 * ((fungsi_bangunan * (ip_permanen + ip_kompleksitas + (0.5 * ip_ketinggian)))) * 1)))'; + + foreach ($childBuildingFunctionIds as $buildingFunctionId) { + $buildingFunction = DB::table('building_functions')->where('id', $buildingFunctionId)->first(); + + if ($buildingFunction) { + // Create formulas for floors 1-5 + for ($floorNumber = 1; $floorNumber <= 5; $floorNumber++) { + DB::table('retribution_formulas')->updateOrInsert( + [ + 'building_function_id' => $buildingFunctionId, + 'floor_number' => $floorNumber + ], + [ + 'name' => "{$buildingFunction->name} - Lantai {$floorNumber}", + 'formula_expression' => $formulaExpression, + 'created_at' => $now, + 'updated_at' => $now + ] + ); + } + } + } + + $this->command->info('Building Function data seeded successfully!'); + $this->command->info('Building Function Parameters seeded successfully!'); + $this->command->info('Floor Height Indices seeded successfully!'); + $this->command->info('Retribution Formulas with IP Ketinggian seeded successfully!'); + + // Display summary + $this->command->info('=== SUMMARY ==='); + $this->command->info('Parent Functions: ' . DB::table('building_functions')->whereNull('parent_id')->count()); + $this->command->info('Child Functions: ' . DB::table('building_functions')->whereNotNull('parent_id')->count()); + $this->command->info('Parameters: ' . DB::table('building_function_parameters')->count()); + $this->command->info('Floor Height Indices: ' . DB::table('floor_height_indices')->count()); + $this->command->info('Retribution Formulas: ' . DB::table('retribution_formulas')->count()); } } \ No newline at end of file diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 61f09d0..ceff5fc 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -51,6 +51,10 @@ class DatabaseSeeder extends Seeder UsersRoleMenuSeeder::class, GlobalSettingSeeder::class, BuildingFunctionSeeder::class, + BuildingFunctionParameterSeeder::class, + FloorHeightIndexSeeder::class, + RetributionFormulaSeeder::class, + RetributionProposalSeeder::class, ]); } } diff --git a/database/seeders/FloorHeightIndexSeeder.php b/database/seeders/FloorHeightIndexSeeder.php new file mode 100644 index 0000000..c668ddb --- /dev/null +++ b/database/seeders/FloorHeightIndexSeeder.php @@ -0,0 +1,62 @@ + 1, + 'ip_ketinggian' => 1.000000, + 'description' => 'IP ketinggian untuk lantai 1' + ], + [ + 'floor_number' => 2, + 'ip_ketinggian' => 1.090000, + 'description' => 'IP ketinggian untuk lantai 2' + ], + [ + 'floor_number' => 3, + 'ip_ketinggian' => 1.120000, + 'description' => 'IP ketinggian untuk lantai 3' + ], + [ + 'floor_number' => 4, + 'ip_ketinggian' => 1.350000, + 'description' => 'IP ketinggian untuk lantai 4' + ], + [ + 'floor_number' => 5, + 'ip_ketinggian' => 1.620000, + 'description' => 'IP ketinggian untuk lantai 5' + ], + [ + 'floor_number' => 6, + 'ip_ketinggian' => 1.197000, + 'description' => 'IP ketinggian untuk lantai 6' + ] + ]; + + foreach ($indices as $index) { + FloorHeightIndex::updateOrCreate( + ['floor_number' => $index['floor_number']], + $index + ); + } + + $this->command->info('Floor Height Index seeding completed!'); + $this->command->info('IP Ketinggian values:'); + foreach ($indices as $index) { + $this->command->info("Lantai {$index['floor_number']}: {$index['ip_ketinggian']}"); + } + } +} diff --git a/database/seeders/RetributionFormulaSeeder.php b/database/seeders/RetributionFormulaSeeder.php new file mode 100644 index 0000000..b442a00 --- /dev/null +++ b/database/seeders/RetributionFormulaSeeder.php @@ -0,0 +1,74 @@ +first(); + + if ($buildingFunction) { + $formulaName = "{$buildingFunction->name} - Lantai {$floorNumber}"; + + RetributionFormula::updateOrCreate( + [ + 'building_function_id' => $buildingFunction->id, + 'floor_number' => $floorNumber + ], + [ + 'name' => $formulaName, + 'formula_expression' => $fullFormulaExpression, + 'description' => "Formula retribusi untuk {$buildingFunction->name} dengan {$floorNumber} lantai. Formula: {$fullFormulaExpression}", + 'floor_number' => $floorNumber, + 'luas_bangunan_rate' => $buildingFunction->base_tariff ?? 50000, + 'is_active' => true + ] + ); + + $this->command->info("Created formula for: {$formulaName}"); + } else { + $this->command->warn("Building function not found: {$code}"); + } + } + } + + $this->command->info('Retribution Formulas seeding completed for 5 floors!'); + $this->command->info("Building functions processed: " . implode(', ', $buildingFunctionCodes)); + $this->command->info("Floor calculation formula: {$floorCalculationFormula}"); + $this->command->info("Full retribution formula: {$fullFormulaExpression}"); + } +} diff --git a/database/seeders/RetributionProposalSeeder.php b/database/seeders/RetributionProposalSeeder.php new file mode 100644 index 0000000..7a7d7d0 --- /dev/null +++ b/database/seeders/RetributionProposalSeeder.php @@ -0,0 +1,159 @@ +get(); + $buildingFunctions = BuildingFunction::whereNotNull('parent_id')->get(); // Only child functions + + if ($spatialPlannings->isEmpty() || $buildingFunctions->isEmpty()) { + $this->command->warn('No spatial plannings or building functions found. Please seed them first.'); + return; + } + + $sampleProposals = [ + [ + 'spatial_planning_id' => $spatialPlannings->first()?->id, + 'building_function_code' => 'HUNIAN_SEDERHANA', + 'floor_number' => 2, + 'floor_area' => 45666, + 'total_building_area' => 91332, // 2 floors + 'notes' => 'Sample calculation for Hunian Sederhana' + ], + [ + 'spatial_planning_id' => $spatialPlannings->skip(1)->first()?->id, + 'building_function_code' => 'USAHA_KECIL', + 'floor_number' => 1, + 'floor_area' => 150, + 'total_building_area' => 150, + 'notes' => 'Sample calculation for UMKM' + ], + [ + 'spatial_planning_id' => null, // Testing nullable spatial_planning_id + 'building_function_code' => 'CAMPURAN_KECIL', + 'floor_number' => 3, + 'floor_area' => 200, + 'total_building_area' => 600, + 'notes' => 'Sample calculation without spatial planning link' + ] + ]; + + foreach ($sampleProposals as $proposalData) { + $buildingFunction = BuildingFunction::where('code', $proposalData['building_function_code'])->first(); + + if (!$buildingFunction) { + $this->command->warn("Building function not found: {$proposalData['building_function_code']}"); + continue; + } + + // Get parameters + $parameters = BuildingFunctionParameter::where('building_function_id', $buildingFunction->id)->first(); + $floorHeightIndex = FloorHeightIndex::where('floor_number', $proposalData['floor_number'])->first(); + $retributionFormula = RetributionFormula::where('building_function_id', $buildingFunction->id) + ->where('floor_number', $proposalData['floor_number']) + ->first(); + + if (!$parameters || !$floorHeightIndex || !$retributionFormula) { + $this->command->warn("Missing data for building function: {$buildingFunction->name}"); + continue; + } + + // Calculate retribution using the Excel formula + $floorArea = $proposalData['floor_area']; + $fungsi_bangunan = $parameters->fungsi_bangunan; + $ip_permanen = $parameters->ip_permanen; + $ip_kompleksitas = $parameters->ip_kompleksitas; + $ip_ketinggian = $floorHeightIndex->ip_ketinggian; + $indeks_lokalitas = $parameters->indeks_lokalitas; + $base_value = 70350; + $additional_factor = 0.5; + + // Step 1: Calculate H13 (floor coefficient) + $h13 = $fungsi_bangunan * ($ip_permanen + $ip_kompleksitas + (0.5 * $ip_ketinggian)); + + // Step 2: Main calculation + $main_calculation = 1 * $floorArea * ($indeks_lokalitas * $base_value * $h13 * 1); + + // Step 3: Additional (50%) + $additional_calculation = $additional_factor * $main_calculation; + + // Step 4: Total + $total_retribution = $main_calculation + $additional_calculation; + + // Prepare calculation parameters and breakdown + $calculationParameters = [ + 'fungsi_bangunan' => $fungsi_bangunan, + 'ip_permanen' => $ip_permanen, + 'ip_kompleksitas' => $ip_kompleksitas, + 'ip_ketinggian' => $ip_ketinggian, + 'indeks_lokalitas' => $indeks_lokalitas, + 'base_value' => $base_value, + 'additional_factor' => $additional_factor, + 'floor_area' => $floorArea + ]; + + $calculationBreakdown = [ + 'h13_calculation' => [ + 'formula' => 'fungsi_bangunan * (ip_permanen + ip_kompleksitas + (0.5 * ip_ketinggian))', + 'calculation' => "{$fungsi_bangunan} * ({$ip_permanen} + {$ip_kompleksitas} + (0.5 * {$ip_ketinggian}))", + 'result' => $h13 + ], + 'main_calculation' => [ + 'formula' => '1 * floor_area * (indeks_lokalitas * base_value * h13 * 1)', + 'calculation' => "1 * {$floorArea} * ({$indeks_lokalitas} * {$base_value} * {$h13} * 1)", + 'result' => $main_calculation + ], + 'additional_calculation' => [ + 'formula' => 'additional_factor * main_calculation', + 'calculation' => "{$additional_factor} * {$main_calculation}", + 'result' => $additional_calculation + ], + 'total_calculation' => [ + 'formula' => 'main_calculation + additional_calculation', + 'calculation' => "{$main_calculation} + {$additional_calculation}", + 'result' => $total_retribution + ] + ]; + + // Create the proposal + RetributionProposal::create([ + 'spatial_planning_id' => $proposalData['spatial_planning_id'], + 'building_function_id' => $buildingFunction->id, + 'retribution_formula_id' => $retributionFormula->id, + 'floor_number' => $proposalData['floor_number'], + 'floor_area' => $proposalData['floor_area'], + 'total_building_area' => $proposalData['total_building_area'], + 'ip_ketinggian' => $ip_ketinggian, + 'floor_retribution_amount' => $total_retribution, + 'total_retribution_amount' => $total_retribution, // For single floor, same as floor amount + 'calculation_parameters' => $calculationParameters, + 'calculation_breakdown' => $calculationBreakdown, + 'notes' => $proposalData['notes'], + 'calculated_at' => Carbon::now() + ]); + + $this->command->info("Created retribution proposal for: {$buildingFunction->name} - Floor {$proposalData['floor_number']}"); + $this->command->info(" Amount: Rp " . number_format($total_retribution, 0, ',', '.')); + } + + $this->command->info('Retribution Proposal seeding completed!'); + $this->command->info('Total proposals created: ' . RetributionProposal::count()); + } +} \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 6123da1..73a0d2d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -28,6 +28,7 @@ use App\Http\Controllers\Api\UmkmController; use App\Http\Controllers\Api\TourismController; use App\Http\Controllers\Api\SpatialPlanningController; use App\Http\Controllers\Api\ChatbotController; +use App\Http\Controllers\RetributionProposalController; use Illuminate\Support\Facades\Route; Route::post('/login', [UsersController::class, 'login'])->name('api.user.login'); @@ -192,4 +193,17 @@ Route::group(['middleware' => 'auth:sanctum'], function (){ Route::controller(GrowthReportAPIController::class)->group(function(){ Route::get('/growth','index')->name('api.growth'); }); + + // Retribution Proposal API + Route::controller(RetributionProposalController::class)->group(function(){ + Route::get('/retribution-proposals', 'index')->name('api.retribution-proposals.index'); + Route::post('/retribution-proposals', 'store')->name('api.retribution-proposals.store'); + Route::get('/retribution-proposals/statistics', 'statistics')->name('api.retribution-proposals.statistics'); + Route::get('/retribution-proposals/total-sum', 'totalSum')->name('api.retribution-proposals.total-sum'); + Route::get('/retribution-proposals/building-functions', 'buildingFunctions')->name('api.retribution-proposals.building-functions'); + Route::post('/retribution-proposals/auto-create', 'autoCreateProposal')->name('api.retribution-proposals.auto-create'); + Route::get('/retribution-proposals/{id}', 'show')->name('api.retribution-proposals.show'); + Route::put('/retribution-proposals/{id}', 'update')->name('api.retribution-proposals.update'); + Route::delete('/retribution-proposals/{id}', 'destroy')->name('api.retribution-proposals.destroy'); + }); }); \ No newline at end of file