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:
@@ -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'),
|
||||
|
||||
151
backend/app/Http/Controllers/Api/ProjectMonthPlanController.php
Normal file
151
backend/app/Http/Controllers/Api/ProjectMonthPlanController.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
21
backend/app/Http/Resources/ProjectMonthPlanResource.php
Normal file
21
backend/app/Http/Resources/ProjectMonthPlanResource.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
50
backend/app/Models/ProjectMonthPlan.php
Normal file
50
backend/app/Models/ProjectMonthPlan.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProjectMonthPlan extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'project_month_plans';
|
||||
|
||||
protected $fillable = [
|
||||
'project_id',
|
||||
'month',
|
||||
'planned_hours',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'month' => 'date:Y-m-01',
|
||||
'planned_hours' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the project this plan belongs to.
|
||||
*/
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this plan cell is blank (unset).
|
||||
*/
|
||||
public function isBlank(): bool
|
||||
{
|
||||
return $this->planned_hours === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get planned hours or 0 for variance calculations.
|
||||
* Blank plan is treated as 0 for allocation variance.
|
||||
*/
|
||||
public function getPlannedHoursForVariance(): float
|
||||
{
|
||||
return (float) ($this->planned_hours ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('allocations', function (Blueprint $table) {
|
||||
$table->uuid('team_member_id')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('allocations', function (Blueprint $table) {
|
||||
$table->uuid('team_member_id')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_month_plans', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->uuid('project_id');
|
||||
$table->date('month'); // First day of month
|
||||
$table->decimal('planned_hours', 10, 2)->nullable(); // null = blank/unset
|
||||
$table->timestamps();
|
||||
|
||||
// Unique constraint: one plan per project per month
|
||||
$table->unique(['project_id', 'month']);
|
||||
|
||||
// Foreign key
|
||||
$table->foreign('project_id')
|
||||
->references('id')
|
||||
->on('projects')
|
||||
->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_month_plans');
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ use App\Http\Controllers\Api\AuthController;
|
||||
use App\Http\Controllers\Api\CapacityController;
|
||||
use App\Http\Controllers\Api\HolidayController;
|
||||
use App\Http\Controllers\Api\ProjectController;
|
||||
use App\Http\Controllers\Api\ProjectMonthPlanController;
|
||||
use App\Http\Controllers\Api\PtoController;
|
||||
use App\Http\Controllers\Api\TeamMemberController;
|
||||
use App\Http\Middleware\JwtAuth;
|
||||
@@ -42,6 +43,10 @@ Route::middleware(JwtAuth::class)->group(function () {
|
||||
Route::put('projects/{project}/estimate', [ProjectController::class, 'setEstimate']);
|
||||
Route::put('projects/{project}/forecast', [ProjectController::class, 'setForecast']);
|
||||
|
||||
// Project Month Plans
|
||||
Route::get('/project-month-plans', [ProjectMonthPlanController::class, 'index']);
|
||||
Route::put('/project-month-plans/bulk', [ProjectMonthPlanController::class, 'bulkUpdate']);
|
||||
|
||||
// Capacity
|
||||
Route::get('/capacity', [CapacityController::class, 'individual']);
|
||||
Route::get('/capacity/team', [CapacityController::class, 'team']);
|
||||
|
||||
@@ -257,4 +257,161 @@ class AllocationTest extends TestCase
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
// ===== Allocation Indicator Tests =====
|
||||
// 1.1 API test: GET /api/allocations returns allocation_indicator per item
|
||||
public function test_get_allocations_returns_allocation_indicator()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
$project = Project::factory()->create(['approved_estimate' => 100]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 100,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/allocations?month=2026-02');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonStructure([
|
||||
'data' => [
|
||||
'*' => ['allocation_indicator'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// 1.2 API test: allocation_indicator is green when >= 100%
|
||||
public function test_allocation_indicator_is_green_when_full()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
$project = Project::factory()->create(['approved_estimate' => 100]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 100,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/allocations?month=2026-02');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('data.0.allocation_indicator', 'green');
|
||||
}
|
||||
|
||||
// 1.3 API test: allocation_indicator is yellow when < 100%
|
||||
public function test_allocation_indicator_is_yellow_when_under()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
$project = Project::factory()->create(['approved_estimate' => 100]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 80,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/allocations?month=2026-02');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('data.0.allocation_indicator', 'yellow');
|
||||
}
|
||||
|
||||
// 1.4 API test: allocation_indicator is red when > 100%
|
||||
public function test_allocation_indicator_is_red_when_over()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
$project = Project::factory()->create(['approved_estimate' => 100]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 120,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/allocations?month=2026-02');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('data.0.allocation_indicator', 'red');
|
||||
}
|
||||
|
||||
// 1.5 API test: allocation_indicator is gray when no approved_estimate
|
||||
public function test_allocation_indicator_is_gray_when_no_estimate()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
$project = Project::factory()->create(['approved_estimate' => null]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/allocations?month=2026-02');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('data.0.allocation_indicator', 'gray');
|
||||
}
|
||||
|
||||
// ===== Untracked Allocation Tests =====
|
||||
// 2.1 API test: POST /api/allocations accepts null team_member_id
|
||||
public function test_can_create_allocation_with_null_team_member()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/allocations', [
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => null,
|
||||
'month' => '2026-02',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('data.team_member_id', null);
|
||||
$response->assertJsonPath('data.team_member', null);
|
||||
}
|
||||
|
||||
// 2.2 API test: GET /api/allocations returns null team_member
|
||||
public function test_get_allocations_returns_null_team_member()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
// Create untracked allocation
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => null,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/allocations?month=2026-02');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('data.0.team_member_id', null);
|
||||
$response->assertJsonPath('data.0.team_member', null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,4 +47,13 @@ class AllocationPolicyTest extends TestCase
|
||||
|
||||
$this->assertFalse($this->policy->create($developer));
|
||||
}
|
||||
|
||||
// 2.3 Unit test: AllocationPolicy allows untracked allocation
|
||||
public function test_manager_can_create_untracked_allocations()
|
||||
{
|
||||
$manager = User::factory()->create(['role' => 'manager']);
|
||||
|
||||
// Policy should allow creating allocations (untracked is just null team_member_id)
|
||||
$this->assertTrue($this->policy->create($manager));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user