docs(openspec): add reporting API contract documentation

Add comprehensive API documentation for the reporting endpoint:
- Request/response structure
- View type inference (did/is/will)
- Blank vs explicit zero semantics
- Status values and error responses

Related to enhanced-allocation change.
This commit is contained in:
2026-03-08 18:22:27 -04:00
parent 3324c4f156
commit b7bbfb45c0
27 changed files with 1632 additions and 35 deletions

View File

@@ -59,9 +59,51 @@ class AllocationController extends Controller
$allocations = $query->get();
// Compute allocation_indicator for each allocation based on project totals
$allocations->each(function ($allocation) {
$allocation->allocation_indicator = $this->computeAllocationIndicator(
$allocation->project_id,
$allocation->month
);
});
return $this->wrapResource(AllocationResource::collection($allocations));
}
/**
* Compute allocation indicator based on project totals.
*/
private function computeAllocationIndicator(string $projectId, string $month): string
{
// Convert month to date format if needed
$monthDate = strlen($month) === 7 ? $month . '-01' : $month;
// Get total allocated for this project in this month
$totalAllocated = Allocation::where('project_id', $projectId)
->where('month', $monthDate)
->sum('allocated_hours');
// Get project approved estimate
$project = \App\Models\Project::find($projectId);
$approvedEstimate = $project?->approved_estimate;
// Handle no estimate
if (! $approvedEstimate || $approvedEstimate <= 0) {
return 'gray';
}
$percentage = ($totalAllocated / $approvedEstimate) * 100;
// Check in correct order: over first, then at capacity, then under
if ($percentage > 100) {
return 'red';
} elseif ($percentage >= 100) {
return 'green';
} else {
return 'yellow';
}
}
/**
* Create a new allocation
*
@@ -89,7 +131,7 @@ class AllocationController extends Controller
{
$validator = Validator::make($request->all(), [
'project_id' => 'required|uuid|exists:projects,id',
'team_member_id' => 'required|uuid|exists:team_members,id',
'team_member_id' => 'nullable|uuid|exists:team_members,id',
'month' => 'required|date_format:Y-m',
'allocated_hours' => 'required|numeric|min:0',
]);
@@ -101,12 +143,17 @@ class AllocationController extends Controller
], 422);
}
// Validate against capacity and approved estimate
$capacityValidation = $this->validationService->validateCapacity(
$request->input('team_member_id'),
$request->input('month'),
(float) $request->input('allocated_hours')
);
// Validate against capacity and approved estimate (skip for untracked)
$teamMemberId = $request->input('team_member_id');
$capacityValidation = ['valid' => true, 'warning' => null, 'utilization' => 0];
if ($teamMemberId) {
$capacityValidation = $this->validationService->validateCapacity(
$teamMemberId,
$request->input('month'),
(float) $request->input('allocated_hours')
);
}
$estimateValidation = $this->validationService->validateApprovedEstimate(
$request->input('project_id'),

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ProjectMonthPlanResource;
use App\Models\ProjectMonthPlan;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class ProjectMonthPlanController extends Controller
{
/**
* GET /api/project-month-plans?year=2026
* Returns month-plan grid payload by project/month for the year.
*/
public function index(Request $request): JsonResponse
{
$year = $request->query('year', date('Y'));
$startDate = "{$year}-01-01";
$endDate = "{$year}-12-01";
$plans = ProjectMonthPlan::whereBetween('month', [$startDate, $endDate])
->with('project')
->get()
->groupBy('project_id');
// Get all active projects for the year
$projects = \App\Models\Project::where('active', true)->get();
// Build grid payload
$data = $projects->map(function ($project) use ($plans, $year) {
$projectPlans = $plans->get($project->id, collect());
$months = [];
for ($month = 1; $month <= 12; $month++) {
$monthDate = sprintf('%04d-%02d-01', $year, $month);
$plan = $projectPlans->firstWhere('month', $monthDate);
$months[$monthDate] = $plan
? [
'id' => $plan->id,
'planned_hours' => $plan->planned_hours,
'is_blank' => $plan->planned_hours === null,
]
: null;
}
return [
'project_id' => $project->id,
'project_name' => $project->title,
'approved_estimate' => $project->approved_estimate,
'months' => $months,
];
});
// Calculate reconciliation status for each project
$data->each(function (&$project) {
$project['plan_sum'] = collect($project['months'])
->filter(fn ($m) => $m !== null && $m['planned_hours'] !== null)
->sum('planned_hours');
$approved = $project['approved_estimate'] ?? 0;
if ($approved > 0) {
if ($project['plan_sum'] > $approved) {
$project['reconciliation_status'] = 'OVER';
} elseif ($project['plan_sum'] < $approved) {
$project['reconciliation_status'] = 'UNDER';
} elseif ($project['plan_sum'] == $approved) {
$project['reconciliation_status'] = 'MATCH';
} else {
$project['reconciliation_status'] = 'UNDER';
}
} else {
$project['reconciliation_status'] = 'UNDER'; // No estimate = under
}
});
return response()->json([
'data' => $data,
'meta' => [
'year' => (int) $year,
],
]);
}
/**
* PUT /api/project-month-plans/bulk
* Bulk upsert month plan cells.
*/
public function bulkUpdate(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'year' => 'required|integer|min:2020|max:2100',
'items' => 'required|array',
'items.*.project_id' => 'required|uuid|exists:projects,id',
'items.*.month' => 'required|date_format:Y-m',
'items.*.planned_hours' => 'nullable|numeric|min:0',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed',
'errors' => $validator->errors(),
], 422);
}
$year = $request->input('year');
$items = $request->input('items');
$created = 0;
$updated = 0;
$cleared = 0;
foreach ($items as $item) {
$projectId = $item['project_id'];
$month = $item['month'] . '-01'; // Convert YYYY-MM to YYYY-MM-01
$plannedHours = $item['planned_hours']; // Can be null to clear
$plan = ProjectMonthPlan::firstOrNew([
'project_id' => $projectId,
'month' => $month,
]);
if ($plannedHours === null && $plan->exists) {
// Clear semantics: delete the row to represent blank
$plan->delete();
$cleared++;
} elseif ($plannedHours !== null) {
$plan->planned_hours = $plannedHours;
$plan->save();
if (!$plan->wasRecentlyCreated) {
$updated++;
} else {
$created++;
}
}
}
return response()->json([
'message' => 'Bulk update complete',
'summary' => [
'created' => $created,
'updated' => $updated,
'cleared' => $cleared,
],
]);
}
}

View File

@@ -14,6 +14,7 @@ class AllocationResource extends BaseResource
'team_member_id' => $this->team_member_id,
'month' => $this->month?->format('Y-m'),
'allocated_hours' => $this->formatDecimal($this->allocated_hours),
'allocation_indicator' => $this->allocation_indicator ?? 'gray',
'created_at' => $this->formatDate($this->created_at),
'updated_at' => $this->formatDate($this->updated_at),
'project' => $this->whenLoaded('project', fn () => new ProjectResource($this->project)),

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class ProjectMonthPlanResource extends BaseResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'project_id' => $this->project_id,
'month' => $this->month?->format('Y-m'),
'planned_hours' => $this->formatDecimal($this->planned_hours),
'is_blank' => $this->planned_hours === null,
'created_at' => $this->formatDate($this->created_at),
'updated_at' => $this->formatDate($this->updated_at),
];
}
}