Implement core reporting infrastructure: - ReconciliationCalculator: plan vs estimate reconciliation - VarianceCalculator: project and member variance calculations - ReportController: GET /api/reports/allocations endpoint - Support did/is/will view type inference based on date ranges - Distinguish blank plans from explicit zero values Part of enhanced-allocation change.
104 lines
2.8 KiB
PHP
104 lines
2.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Project;
|
|
use App\Models\ProjectMonthPlan;
|
|
use Illuminate\Support\Collection;
|
|
|
|
class ReconciliationCalculator
|
|
{
|
|
/**
|
|
* Calculate reconciliation status for a single project.
|
|
* Returns OVER, UNDER, or MATCH based on plan_sum vs approved_estimate.
|
|
*/
|
|
public function calculateStatus(Project $project, ?Collection $plans = null): string
|
|
{
|
|
$approved = (float) $project->approved_estimate;
|
|
|
|
// If no approved estimate, consider it UNDER
|
|
if ($approved <= 0) {
|
|
return 'UNDER';
|
|
}
|
|
|
|
$planSum = $this->calculatePlanSum($project, $plans);
|
|
|
|
// Use decimal-safe comparison
|
|
if ($this->isGreaterThan($planSum, $approved)) {
|
|
return 'OVER';
|
|
}
|
|
|
|
if ($this->isLessThan($planSum, $approved)) {
|
|
return 'UNDER';
|
|
}
|
|
|
|
return 'MATCH';
|
|
}
|
|
|
|
/**
|
|
* Calculate the sum of planned hours for a project.
|
|
* Only sums non-null planned_hours values.
|
|
*/
|
|
public function calculatePlanSum(Project $project, ?Collection $plans = null): float
|
|
{
|
|
if ($plans === null) {
|
|
$plans = ProjectMonthPlan::where('project_id', $project->id)->get();
|
|
}
|
|
|
|
return $plans
|
|
->filter(fn ($plan) => $plan->planned_hours !== null)
|
|
->sum('planned_hours');
|
|
}
|
|
|
|
/**
|
|
* Calculate plan sum and status for multiple projects.
|
|
* Returns array with project_id => ['plan_sum' => float, 'status' => string].
|
|
*/
|
|
public function calculateForProjects(Collection $projects, int $year): array
|
|
{
|
|
$startDate = "{$year}-01-01";
|
|
$endDate = "{$year}-12-01";
|
|
|
|
// Get all plans for the year, grouped by project_id
|
|
$allPlans = ProjectMonthPlan::whereBetween('month', [$startDate, $endDate])
|
|
->get()
|
|
->groupBy('project_id');
|
|
|
|
$results = [];
|
|
|
|
foreach ($projects as $project) {
|
|
$projectPlans = $allPlans->get($project->id, collect());
|
|
$planSum = $this->calculatePlanSum($project, $projectPlans);
|
|
$status = $this->calculateStatus($project, $projectPlans);
|
|
|
|
$results[$project->id] = [
|
|
'plan_sum' => $planSum,
|
|
'status' => $status,
|
|
'approved_estimate' => $project->approved_estimate,
|
|
];
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* Compare two floats using epsilon for decimal-safe comparison.
|
|
*/
|
|
private function isGreaterThan(float $a, float $b): bool
|
|
{
|
|
$epsilon = 0.0001;
|
|
|
|
return ($a - $b) > $epsilon;
|
|
}
|
|
|
|
/**
|
|
* Compare two floats using epsilon for decimal-safe comparison.
|
|
*/
|
|
private function isLessThan(float $a, float $b): bool
|
|
{
|
|
$epsilon = 0.0001;
|
|
|
|
return ($b - $a) > $epsilon;
|
|
}
|
|
}
|