feat(backend): add reporting and variance calculation services
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.
This commit is contained in:
103
backend/app/Services/ReconciliationCalculator.php
Normal file
103
backend/app/Services/ReconciliationCalculator.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
164
backend/app/Services/VarianceCalculator.php
Normal file
164
backend/app/Services/VarianceCalculator.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Allocation;
|
||||
use App\Models\ProjectMonthPlan;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class VarianceCalculator
|
||||
{
|
||||
/**
|
||||
* Calculate row variance for a project in a given month.
|
||||
* Row variance = allocated_total - planned_month
|
||||
*
|
||||
* @return array{
|
||||
* allocated_total: float,
|
||||
* planned_month: float,
|
||||
* variance: float,
|
||||
* status: string
|
||||
* }
|
||||
*/
|
||||
public function calculateRowVariance(string $projectId, string $month): array
|
||||
{
|
||||
// Convert YYYY-MM to YYYY-MM-01 and then to a Carbon date for proper comparison
|
||||
$monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month);
|
||||
|
||||
// Get total allocated hours for this project/month (including untracked)
|
||||
$allocatedTotal = Allocation::where('project_id', $projectId)
|
||||
->whereMonth('month', $monthDate->month)
|
||||
->whereYear('month', $monthDate->year)
|
||||
->sum('allocated_hours');
|
||||
|
||||
// Get planned hours for this project/month (treat null as 0)
|
||||
$plannedMonth = $this->getPlannedHoursForMonth($projectId, $month);
|
||||
|
||||
$variance = $allocatedTotal - $plannedMonth;
|
||||
|
||||
return [
|
||||
'allocated_total' => $allocatedTotal,
|
||||
'planned_month' => $plannedMonth,
|
||||
'variance' => $variance,
|
||||
'status' => $this->determineStatus($variance),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate column variance for a team member in a given month.
|
||||
* Column variance = member_allocated - member_capacity
|
||||
*
|
||||
* @return array{
|
||||
* allocated: float,
|
||||
* capacity: float,
|
||||
* variance: float,
|
||||
* status: string
|
||||
* }
|
||||
*/
|
||||
public function calculateColumnVariance(string $teamMemberId, string $month, CapacityService $capacityService): array
|
||||
{
|
||||
// Convert YYYY-MM to Carbon date for proper comparison
|
||||
$monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month);
|
||||
|
||||
// Get total allocated hours for this member/month (excluding untracked)
|
||||
$allocated = Allocation::where('team_member_id', $teamMemberId)
|
||||
->whereMonth('month', $monthDate->month)
|
||||
->whereYear('month', $monthDate->year)
|
||||
->sum('allocated_hours');
|
||||
|
||||
// Get member capacity
|
||||
$capacityData = $capacityService->calculateIndividualCapacity($teamMemberId, $month);
|
||||
$capacity = $capacityData['hours'] ?? 0;
|
||||
|
||||
$variance = $allocated - $capacity;
|
||||
|
||||
return [
|
||||
'allocated' => $allocated,
|
||||
'capacity' => $capacity,
|
||||
'variance' => $variance,
|
||||
'status' => $this->determineStatus($variance),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get planned hours for a project/month.
|
||||
* Returns 0 if no plan exists (blank month treated as 0).
|
||||
*/
|
||||
public function getPlannedHoursForMonth(string $projectId, string $month): float
|
||||
{
|
||||
$monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month);
|
||||
|
||||
$plan = ProjectMonthPlan::where('project_id', $projectId)
|
||||
->whereMonth('month', $monthDate->month)
|
||||
->whereYear('month', $monthDate->year)
|
||||
->first();
|
||||
|
||||
// Blank plan is treated as 0 for allocation variance
|
||||
return (float) ($plan?->planned_hours ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine status based on variance value.
|
||||
*
|
||||
* - Positive variance (> 0): OVER (red)
|
||||
* - Negative variance (< 0): UNDER (amber)
|
||||
* - Zero variance: MATCH (neutral)
|
||||
*/
|
||||
public function determineStatus(float $variance): string
|
||||
{
|
||||
$epsilon = 0.0001;
|
||||
|
||||
if ($variance > $epsilon) {
|
||||
return 'OVER';
|
||||
}
|
||||
|
||||
if ($variance < -$epsilon) {
|
||||
return 'UNDER';
|
||||
}
|
||||
|
||||
return 'MATCH';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate both row and column variances for a complete matrix view.
|
||||
*
|
||||
* @return array{
|
||||
* project_variances: array<string, array>,
|
||||
* team_member_variances: array<string, array>
|
||||
* }
|
||||
*/
|
||||
public function calculateMatrixVariances(string $month, CapacityService $capacityService): array
|
||||
{
|
||||
// Convert YYYY-MM to Carbon date for proper comparison
|
||||
$monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month);
|
||||
|
||||
// Get all allocations for the month using whereMonth/whereYear
|
||||
$allocations = Allocation::whereMonth('month', $monthDate->month)
|
||||
->whereYear('month', $monthDate->year)
|
||||
->get();
|
||||
|
||||
// Calculate row variances per project
|
||||
$projectIds = $allocations->pluck('project_id')->unique()->toArray();
|
||||
$projectVariances = [];
|
||||
|
||||
foreach ($projectIds as $projectId) {
|
||||
$projectVariances[$projectId] = $this->calculateRowVariance($projectId, $month);
|
||||
}
|
||||
|
||||
// Calculate column variances per team member (excluding null/untracked)
|
||||
$teamMemberIds = $allocations->pluck('team_member_id')
|
||||
->filter(fn ($id) => $id !== null)
|
||||
->unique()
|
||||
->toArray();
|
||||
|
||||
$teamMemberVariances = [];
|
||||
|
||||
foreach ($teamMemberIds as $teamMemberId) {
|
||||
$teamMemberVariances[$teamMemberId] = $this->calculateColumnVariance($teamMemberId, $month, $capacityService);
|
||||
}
|
||||
|
||||
return [
|
||||
'project_variances' => $projectVariances,
|
||||
'team_member_variances' => $teamMemberVariances,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user