update retribution calculation spatial plannings

This commit is contained in:
arifal
2025-06-17 17:58:37 +07:00
parent 236b6f9bfc
commit 6946fa7074
14 changed files with 1069 additions and 1 deletions

View File

@@ -0,0 +1,189 @@
<?php
namespace App\Console\Commands;
use App\Models\SpatialPlanning;
use Illuminate\Console\Command;
use Maatwebsite\Excel\Facades\Excel;
use Illuminate\Support\Facades\DB;
use Exception;
class InitSpatialPlanningDatas extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'spatial:init {file? : Path to the CSV/Excel file} {--truncate : Clear existing data before import}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Import spatial planning data from CSV/Excel file for retribution';
/**
* Execute the console command.
*/
public function handle()
{
$filePath = $this->argument('file') ?? 'public/templates/Data_2025___Estimasi_Jumlah_Lantai.csv';
$fullPath = storage_path('app/' . $filePath);
// Check if file exists
if (!file_exists($fullPath)) {
$this->error("File not found: {$fullPath}");
$this->info("Available files in templates:");
$this->listAvailableFiles();
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.');
} else {
$this->info('Operation cancelled.');
return 0;
}
}
$this->info("Starting import from: {$filePath}");
$this->info("Full path: {$fullPath}");
try {
DB::beginTransaction();
$data = Excel::toArray([], $fullPath);
if (empty($data) || empty($data[0])) {
$this->error('No data found in the file.');
return 1;
}
$rows = $data[0]; // Get first sheet
$headers = array_shift($rows); // Remove header row
$this->info("Found " . count($rows) . " data rows to import.");
$this->info("Headers: " . implode(', ', $headers));
$progressBar = $this->output->createProgressBar(count($rows));
$progressBar->start();
$imported = 0;
$skipped = 0;
foreach ($rows as $index => $row) {
try {
// Skip empty rows
if (empty(array_filter($row))) {
$skipped++;
$progressBar->advance();
continue;
}
// Map CSV columns to model attributes
$spatialData = [
'name' => $this->cleanString($row[1] ?? ''), // pemohon
'location' => $this->cleanString($row[2] ?? ''), // alamat
'activities' => $this->cleanString($row[3] ?? ''), // activities
'land_area' => $this->cleanNumber($row[4] ?? 0), // luas_lahan
'site_bcr' => $this->cleanNumber($row[5] ?? 0), // bcr_kawasan
'area' => $this->cleanNumber($row[6] ?? 0), // area
'no_tapak' => $this->cleanString($row[7] ?? ''), // no_tapak
'no_skkl' => $this->cleanString($row[8] ?? ''), // no_skkl
'no_ukl' => $this->cleanString($row[9] ?? ''), // no_ukl
'building_function' => $this->cleanString($row[10] ?? ''), // fungsi_bangunan
'sub_building_function' => $this->cleanString($row[11] ?? ''), // sub_fungsi_bangunan
'number_of_floors' => $this->cleanNumber($row[12] ?? 1), // jumlah_lantai
'number' => $this->cleanString($row[0] ?? ''), // no
'date' => now(), // Set current date
'kbli' => null, // Not in CSV, set as null
];
// Validate required fields
if (empty($spatialData['name']) && empty($spatialData['activities'])) {
$skipped++;
$progressBar->advance();
continue;
}
SpatialPlanning::create($spatialData);
$imported++;
} catch (Exception $e) {
$this->newLine();
$this->error("Error importing row " . ($index + 2) . ": " . $e->getMessage());
$skipped++;
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
DB::commit();
$this->info("Import completed successfully!");
$this->info("Imported: {$imported} records");
$this->info("Skipped: {$skipped} records");
return 0;
} catch (Exception $e) {
DB::rollBack();
$this->error("Import failed: " . $e->getMessage());
return 1;
}
}
/**
* Clean string data
*/
private function cleanString($value)
{
if (is_null($value)) return null;
return trim(str_replace(["\n", "\r", "\t"], ' ', $value));
}
/**
* Clean numeric data
*/
private function cleanNumber($value)
{
if (is_null($value) || $value === '') return 0;
// Remove non-numeric characters except decimal point
$cleaned = preg_replace('/[^0-9.]/', '', $value);
return is_numeric($cleaned) ? (float) $cleaned : 0;
}
/**
* List available template files
*/
private function listAvailableFiles()
{
$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));
}
}
$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));
}
}
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
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 $casts = [
'is_active' => 'boolean',
'level' => 'integer',
'sort_order' => 'integer'
];
/**
* Parent relationship (self-referencing)
*/
public function parent(): BelongsTo
{
return $this->belongsTo(BuildingFunction::class, 'parent_id');
}
/**
* Children relationship (self-referencing)
*/
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
{
return $this->hasOne(BuildingFunctionParameter::class);
}
/**
* Formula relationship (1:1)
*/
public function formula(): HasOne
{
return $this->hasOne(RetributionFormula::class);
}
/**
* Spatial plannings relationship (1:n) - via detected building function
*/
public function spatialPlannings(): HasMany
{
return $this->hasMany(SpatialPlanning::class, 'building_function_id');
}
/**
* Retribution calculations relationship (1:n) - via detected building function
*/
public function retributionCalculations(): 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);
}
/**
* Scope: Parent functions only
*/
public function scopeParents($query)
{
return $query->whereNull('parent_id');
}
/**
* Scope: Children functions only
*/
public function scopeChildren($query)
{
return $query->whereNotNull('parent_id');
}
/**
* Check if building function has complete setup (parameters + formula)
*/
public function hasCompleteSetup(): bool
{
return $this->parameters()->exists() && $this->formula()->exists();
}
/**
* Get building function with all related data
*/
public function getCompleteData(): array
{
return [
'id' => $this->id,
'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
],
'has_complete_setup' => $this->hasCompleteSetup()
];
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BuildingFunctionParameter extends Model
{
protected $fillable = [
'building_function_id',
'fungsi_bangunan',
'ip_permanen',
'ip_kompleksitas',
'ip_ketinggian',
'indeks_lokalitas',
'is_active',
'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'
];
/**
* Building function relationship (1:1)
*/
public function buildingFunction(): BelongsTo
{
return $this->belongsTo(BuildingFunction::class);
}
/**
* Scope: Active parameters only
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Get all parameter values as array
*/
public function getParametersArray(): array
{
return [
'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
];
}
/**
* Get formatted parameters for display
*/
public function getFormattedParameters(): array
{
return [
'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 . '%'
];
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RetributionCalculation extends Model
{
protected $fillable = [
'spatial_planning_id',
'retribution_formula_id',
'detected_building_function_id',
'luas_bangunan',
'used_parameters',
'used_formula',
'calculation_result',
'calculation_date',
'calculated_by',
'notes'
];
protected $casts = [
'luas_bangunan' => '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;
}
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class RetributionFormula extends Model
{
protected $fillable = [
'building_function_id',
'name',
'formula_expression',
'description',
'is_active'
];
protected $casts = [
'is_active' => 'boolean'
];
/**
* Building function relationship (1:1)
*/
public function buildingFunction(): BelongsTo
{
return $this->belongsTo(BuildingFunction::class);
}
/**
* Retribution calculations relationship (1:n)
*/
public function retributionCalculations(): HasMany
{
return $this->hasMany(RetributionCalculation::class);
}
/**
* Scope: Active formulas only
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Execute formula calculation
*/
public function calculate(float $luasBangunan, array $parameters): float
{
// Replace placeholders in formula with actual values
$formula = $this->formula_expression;
// Replace luas_bangunan
$formula = str_replace('luas_bangunan', $luasBangunan, $formula);
// Replace parameter values
foreach ($parameters as $key => $value) {
$formula = str_replace($key, $value, $formula);
}
// 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());
}
}
/**
* Get formula with parameter placeholders replaced for display
*/
public function getDisplayFormula(array $parameters = []): string
{
$formula = $this->formula_expression;
if (!empty($parameters)) {
foreach ($parameters as $key => $value) {
$formula = str_replace($key, "({$key}={$value})", $formula);
}
}
return $formula;
}
/**
* Validate formula expression
*/
public function validateFormula(): bool
{
// Basic validation - check if formula contains required elements
$requiredElements = ['luas_bangunan'];
foreach ($requiredElements as $element) {
if (strpos($this->formula_expression, $element) === false) {
return false;
}
}
return true;
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Class SpatialPlanning
@@ -31,7 +33,69 @@ class SpatialPlanning extends Model
*
* @var array<int, string>
*/
protected $fillable = ['name', 'kbli', 'activities', 'area', 'location', 'number', 'date'];
protected $fillable = ['name', 'kbli', 'activities', 'area', 'location', 'number', 'date', 'no_tapak', 'no_skkl', 'no_ukl', 'building_function', 'sub_building_function', 'number_of_floors', 'land_area', 'site_bcr'];
protected $casts = [
'area' => 'decimal:6',
'land_area' => 'decimal:6',
'site_bcr' => 'decimal:6',
'number_of_floors' => 'integer',
'date' => 'date'
];
/**
* Retribution calculation relationship (1:1)
*/
public function retributionCalculation(): HasOne
{
return $this->hasOne(RetributionCalculation::class);
}
/**
* Building function relationship (if building_function becomes FK in future)
*/
public function buildingFunctionRelation(): BelongsTo
{
return $this->belongsTo(BuildingFunction::class, 'building_function_id');
}
/**
* Check if spatial planning has retribution calculation
*/
public function hasRetributionCalculation(): bool
{
return $this->retributionCalculation()->exists();
}
/**
* Get building function text for detection
*/
public function getBuildingFunctionText(): string
{
return $this->building_function ?? $this->activities ?? '';
}
/**
* Get area for calculation (prioritize area, fallback to land_area)
*/
public function getCalculationArea(): float
{
return (float) ($this->area ?? $this->land_area ?? 0);
}
/**
* Scope: Without retribution calculation
*/
public function scopeWithoutRetributionCalculation($query)
{
return $query->whereDoesntHave('retributionCalculation');
}
/**
* Scope: With retribution calculation
*/
public function scopeWithRetributionCalculation($query)
{
return $query->whereHas('retributionCalculation');
}
}