822 lines
32 KiB
PHP
822 lines
32 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Modules\Equipments\Models\Equipment;
|
|
use Modules\Labors\Models\Labor;
|
|
use Modules\MasterData\Models\Material;
|
|
use Modules\ProjectManagement\Models\Project;
|
|
use Modules\ProjectManagement\Models\ProjectMilestone;
|
|
use Modules\ProjectManagement\Models\Task;
|
|
use Spatie\Permission\Models\Role;
|
|
use Tests\TestCase;
|
|
|
|
class ProjectWizardFlowTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected User $admin;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
Role::firstOrCreate(['name' => 'Super Admin', 'guard_name' => 'web']);
|
|
|
|
$this->admin = User::factory()->create([
|
|
'user_type' => 'admin',
|
|
'status' => 'active',
|
|
]);
|
|
$this->admin->assignRole('Super Admin');
|
|
|
|
$permission = \Spatie\Permission\Models\Permission::firstOrCreate([
|
|
'name' => 'projects.access',
|
|
'guard_name' => 'web',
|
|
]);
|
|
$this->admin->givePermissionTo($permission);
|
|
}
|
|
|
|
private function validProjectPayload(array $overrides = []): array
|
|
{
|
|
return array_merge([
|
|
'name' => 'Default Test Project',
|
|
'client_name' => 'Acme Corporation',
|
|
'location' => 'Makati City, Philippines',
|
|
'description' => 'Default project description and scope.',
|
|
'contract_value' => 5000000,
|
|
'start_date' => now()->addDays(5)->format('Y-m-d'),
|
|
'target_end_date' => now()->addMonths(6)->format('Y-m-d'),
|
|
'contract_duration' => 180,
|
|
'project_type' => 'standard',
|
|
'classifications' => ['Building and Industrial Plant'],
|
|
'pm_id' => $this->admin->id,
|
|
'is_unprofitable' => false,
|
|
], $overrides);
|
|
}
|
|
|
|
// ─── STEP 1: Project Create/Store ────────────────────────────────────────
|
|
|
|
/** @test */
|
|
public function guest_cannot_access_project_create(): void
|
|
{
|
|
$this->get(route('projects.create'))->assertRedirect(route('login'));
|
|
}
|
|
|
|
/** @test */
|
|
public function admin_can_view_project_create_page(): void
|
|
{
|
|
$this->actingAs($this->admin)
|
|
->get(route('projects.create'))
|
|
->assertOk()
|
|
->assertInertia(fn ($page) => $page->component('ProjectManagement::Projects/Create', false));
|
|
|
|
}
|
|
|
|
/** @test */
|
|
public function project_store_fails_without_name(): void
|
|
{
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.store'), ['name' => '', 'project_type' => 'standard'])
|
|
->assertSessionHasErrors('name');
|
|
}
|
|
|
|
/** @test */
|
|
public function project_store_fails_without_project_type(): void
|
|
{
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.store'), ['name' => 'Test', 'project_type' => ''])
|
|
->assertSessionHasErrors('project_type');
|
|
}
|
|
|
|
/** @test */
|
|
public function project_store_fails_with_invalid_project_type(): void
|
|
{
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.store'), ['name' => 'Test', 'project_type' => 'invalid'])
|
|
->assertSessionHasErrors('project_type');
|
|
}
|
|
|
|
/** @test */
|
|
public function project_store_fails_when_end_date_before_start_date(): void
|
|
{
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.store'), $this->validProjectPayload([
|
|
'start_date' => '2025-12-31',
|
|
'target_end_date' => '2025-01-01',
|
|
]))
|
|
->assertSessionHasErrors('target_end_date');
|
|
}
|
|
|
|
/** @test */
|
|
public function project_store_succeeds_with_minimum_fields(): void
|
|
{
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.store'), $this->validProjectPayload([
|
|
'name' => 'Minimal Project',
|
|
]));
|
|
|
|
$project = Project::where('name', 'Minimal Project')->first();
|
|
$this->assertNotNull($project);
|
|
$this->assertEquals(2, $project->current_wizard_step);
|
|
}
|
|
|
|
/** @test */
|
|
public function project_store_auto_generates_code(): void
|
|
{
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.store'), $this->validProjectPayload([
|
|
'name' => 'Code Project',
|
|
]));
|
|
|
|
$project = Project::where('name', 'Code Project')->first();
|
|
$this->assertStringStartsWith('PRJ-' . now()->year . '-', $project->code);
|
|
}
|
|
|
|
/** @test */
|
|
public function project_codes_are_unique_and_sequential(): void
|
|
{
|
|
$this->actingAs($this->admin)->post(route('projects.store'), $this->validProjectPayload(['name' => 'P1']));
|
|
$this->actingAs($this->admin)->post(route('projects.store'), $this->validProjectPayload(['name' => 'P2']));
|
|
|
|
$c1 = Project::where('name', 'P1')->value('code');
|
|
$c2 = Project::where('name', 'P2')->value('code');
|
|
|
|
$this->assertNotEquals($c1, $c2);
|
|
$this->assertStringStartsWith('PRJ-', $c1);
|
|
}
|
|
|
|
/** @test */
|
|
public function project_store_saves_all_optional_fields(): void
|
|
{
|
|
$this->actingAs($this->admin)->post(route('projects.store'), $this->validProjectPayload([
|
|
'name' => 'Full Project',
|
|
'client_name' => 'Acme Corp',
|
|
'description' => 'Detailed Project Scope',
|
|
'location' => 'Manila',
|
|
'contract_value' => 5000000,
|
|
'contract_duration' => 365,
|
|
'start_date' => '2025-01-01',
|
|
'target_end_date' => '2025-12-31',
|
|
'project_type' => 'standard',
|
|
'is_unprofitable' => false,
|
|
]));
|
|
|
|
$project = Project::where('name', 'Full Project')->first();
|
|
$this->assertEquals('Acme Corp', $project->client_name);
|
|
$this->assertEquals(5000000, $project->contract_value);
|
|
}
|
|
|
|
// ─── STEP 2: Milestones & Tasks ──────────────────────────────────────────
|
|
|
|
/** @test */
|
|
public function save_tasks_fails_with_empty_milestone_name(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 2]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.tasks', $project), [
|
|
'milestones' => [['name' => '', 'weight_percentage' => 50]],
|
|
])
|
|
->assertSessionHasErrors('milestones.0.name');
|
|
}
|
|
|
|
/** @test */
|
|
public function save_tasks_fails_with_milestone_weight_over_100(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 2]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.tasks', $project), [
|
|
'milestones' => [['name' => 'Phase 1', 'weight_percentage' => 150]],
|
|
])
|
|
->assertSessionHasErrors('milestones.0.weight_percentage');
|
|
}
|
|
|
|
/** @test */
|
|
public function save_tasks_fails_with_empty_task_name(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 2]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.tasks', $project), [
|
|
'tasks' => [['name' => '']],
|
|
])
|
|
->assertSessionHasErrors('tasks.0.name');
|
|
}
|
|
|
|
/** @test */
|
|
public function save_tasks_with_empty_arrays_advances_step(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 2]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.tasks', $project), ['milestones' => [], 'tasks' => []])
|
|
->assertRedirect(route('projects.wizard', [$project, 'step' => 3]));
|
|
|
|
$this->assertEquals(3, $project->fresh()->current_wizard_step);
|
|
}
|
|
|
|
/** @test */
|
|
public function save_tasks_does_not_downgrade_already_advanced_step(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 5]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.tasks', $project), ['milestones' => [], 'tasks' => []]);
|
|
|
|
$this->assertEquals(5, $project->fresh()->current_wizard_step);
|
|
}
|
|
|
|
/** @test */
|
|
public function save_tasks_prepopulates_8_default_milestones(): void
|
|
{
|
|
$project = Project::factory()->create([
|
|
'current_wizard_step' => 2,
|
|
'start_date' => '2025-01-01',
|
|
'contract_duration' => 365,
|
|
]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.tasks', $project), ['prepopulate_milestones' => true]);
|
|
|
|
$this->assertEquals(8, $project->milestones()->count());
|
|
}
|
|
|
|
/** @test */
|
|
public function save_tasks_creates_milestones_and_tasks(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 2]);
|
|
|
|
$this->actingAs($this->admin)->post(route('projects.wizard.tasks', $project), [
|
|
'milestones' => [['name' => 'Phase 1', 'weight_percentage' => 100, 'target_date' => now()->addMonths(2)->format('Y-m-d')]],
|
|
'tasks' => [['name' => 'Task A', 'description' => 'First task description', 'milestone_ulid' => 'temp-1']],
|
|
]);
|
|
|
|
$this->assertEquals(1, $project->milestones()->count());
|
|
$this->assertEquals(1, $project->tasks()->count());
|
|
}
|
|
|
|
// ─── STEP 3: Material Estimates ──────────────────────────────────────────
|
|
|
|
/** @test */
|
|
public function save_estimates_fails_without_material_ulid(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 3]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.estimates', $project), [
|
|
'estimates' => [['material_ulid' => '', 'estimated_qty' => 10, 'unit_cost' => 100]],
|
|
])
|
|
->assertSessionHasErrors('estimates.0.material_ulid');
|
|
}
|
|
|
|
/** @test */
|
|
public function save_estimates_fails_with_negative_quantity(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 3]);
|
|
$material = Material::factory()->create(['type' => 'single', 'status' => 'active']);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.estimates', $project), [
|
|
'estimates' => [['material_ulid' => $material->ulid, 'estimated_qty' => -5, 'unit_cost' => 100]],
|
|
])
|
|
->assertSessionHasErrors('estimates.0.estimated_qty');
|
|
}
|
|
|
|
/** @test */
|
|
public function save_estimates_with_empty_array_advances_step(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 3]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.estimates', $project), ['estimates' => []])
|
|
->assertRedirect(route('projects.wizard', [$project, 'step' => 4]));
|
|
|
|
$this->assertEquals(4, $project->fresh()->current_wizard_step);
|
|
}
|
|
|
|
/** @test */
|
|
public function save_estimates_creates_material_estimate_record(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 3]);
|
|
$material = Material::factory()->create(['type' => 'single', 'status' => 'active']);
|
|
|
|
$this->actingAs($this->admin)->post(route('projects.wizard.estimates', $project), [
|
|
'estimates' => [['material_ulid' => $material->ulid, 'estimated_qty' => 10, 'unit_cost' => 250.00]],
|
|
]);
|
|
|
|
$this->assertEquals(1, $project->materialsEstimates()->count());
|
|
$est = $project->materialsEstimates()->first();
|
|
$this->assertEquals(10, $est->estimated_qty);
|
|
$this->assertEquals(250.00, $est->unit_cost);
|
|
}
|
|
|
|
// ─── STEP 4: Labor Allocation ────────────────────────────────────────────
|
|
|
|
/** @test */
|
|
public function save_labor_fails_without_task_ulid(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 4]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.labor', $project), [
|
|
'labor' => [['task_ulid' => '', 'labor_ulid' => 'x', 'estimated_hours' => 8]],
|
|
])
|
|
->assertSessionHasErrors('labor.0.task_ulid');
|
|
}
|
|
|
|
/** @test */
|
|
public function save_labor_fails_with_negative_hours(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 4]);
|
|
$task = Task::factory()->create(['project_id' => $project->id]);
|
|
$labor = Labor::factory()->create(['status' => 'active']);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.labor', $project), [
|
|
'labor' => [['task_ulid' => $task->ulid, 'labor_ulid' => $labor->ulid, 'estimated_hours' => -2]],
|
|
])
|
|
->assertSessionHasErrors('labor.0.estimated_hours');
|
|
}
|
|
|
|
/** @test */
|
|
public function save_labor_with_empty_array_advances_step(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 4]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.labor', $project), ['labor' => []])
|
|
->assertRedirect(route('projects.wizard', [$project, 'step' => 5]));
|
|
|
|
$this->assertEquals(5, $project->fresh()->current_wizard_step);
|
|
}
|
|
|
|
// ─── STEP 5: Equipment Allocation ────────────────────────────────────────
|
|
|
|
/** @test */
|
|
public function save_equipment_fails_without_equipment_ulid(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 5]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.equipment', $project), [
|
|
'equipment' => [['task_ulid' => 'x', 'equipment_ulid' => '', 'estimated_hours' => 4]],
|
|
])
|
|
->assertSessionHasErrors('equipment.0.equipment_ulid');
|
|
}
|
|
|
|
/** @test */
|
|
public function save_equipment_with_empty_array_advances_step(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 5]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.equipment', $project), ['equipment' => []])
|
|
->assertRedirect(route('projects.wizard', [$project, 'step' => 6]));
|
|
|
|
$this->assertEquals(6, $project->fresh()->current_wizard_step);
|
|
}
|
|
|
|
// ─── STEP 6: Submit for Approval ─────────────────────────────────────────
|
|
|
|
/** @test */
|
|
public function submit_without_explicit_approvers_autoselects_approver(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 6]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.submit', $project), ['approver_ids' => []])
|
|
->assertRedirect(route('projects.show', $project));
|
|
}
|
|
|
|
/** @test */
|
|
public function submit_advances_step_to_7(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 6]);
|
|
$approver = User::factory()->create(['user_type' => 'admin', 'status' => 'active']);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.submit', $project), ['approver_ids' => [$approver->ulid]]);
|
|
|
|
$this->assertEquals(7, $project->fresh()->current_wizard_step);
|
|
}
|
|
|
|
// ─── Locked Project Guards (step >= 8) ───────────────────────────────────
|
|
|
|
/** @test */
|
|
public function locked_project_blocks_task_save(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 8]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.tasks', $project), ['milestones' => [], 'tasks' => []])
|
|
->assertSessionHas('error');
|
|
}
|
|
|
|
/** @test */
|
|
public function locked_project_blocks_estimate_save(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 8]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.estimates', $project), ['estimates' => []])
|
|
->assertSessionHas('error');
|
|
}
|
|
|
|
/** @test */
|
|
public function locked_project_blocks_labor_save(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 8]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.labor', $project), ['labor' => []])
|
|
->assertSessionHas('error');
|
|
}
|
|
|
|
/** @test */
|
|
public function locked_project_blocks_equipment_save(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 8]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.wizard.equipment', $project), ['equipment' => []])
|
|
->assertSessionHas('error');
|
|
}
|
|
|
|
/** @test */
|
|
public function locked_project_blocks_update(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 8]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->put(route('projects.update', $project), ['name' => 'Changed', 'project_type' => 'standard'])
|
|
->assertSessionHas('error');
|
|
|
|
$this->assertNotEquals('Changed', $project->fresh()->name);
|
|
}
|
|
|
|
// ─── Project Index Visibility ─────────────────────────────────────────────
|
|
|
|
/** @test */
|
|
public function index_only_shows_completed_wizard_projects(): void
|
|
{
|
|
$active = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']);
|
|
Project::factory()->create(['current_wizard_step' => 3]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->get(route('projects.index'))
|
|
->assertOk()
|
|
->assertInertia(fn ($page) => $page
|
|
->has('projects.data', 1)
|
|
->where('projects.data.0.id', $active->id)
|
|
);
|
|
}
|
|
|
|
/** @test */
|
|
public function drafts_shows_incomplete_wizard_projects(): void
|
|
{
|
|
$draft = Project::factory()->create(['current_wizard_step' => 3]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->get(route('projects.index'))
|
|
->assertOk()
|
|
->assertInertia(fn ($page) => $page
|
|
->has('drafts', 1)
|
|
->where('drafts.0.id', $draft->id)
|
|
);
|
|
}
|
|
|
|
/** @test */
|
|
public function admin_can_discard_a_draft_project(): void
|
|
{
|
|
$draft = Project::factory()->create(['current_wizard_step' => 3]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->delete(route('projects.discard', $draft))
|
|
->assertRedirect(route('projects.index'))
|
|
->assertSessionHas('success');
|
|
|
|
$this->assertSoftDeleted('projects', ['id' => $draft->id]);
|
|
}
|
|
|
|
/** @test */
|
|
public function completed_wizard_projects_cannot_be_discarded(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 7]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->delete(route('projects.discard', $project))
|
|
->assertSessionHas('error');
|
|
|
|
$this->assertDatabaseHas('projects', ['id' => $project->id, 'deleted_at' => null]);
|
|
}
|
|
|
|
// ─── Status Transitions ──────────────────────────────────────────────────
|
|
|
|
/** @test */
|
|
public function cannot_start_project_without_pm(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 8, 'status' => 'planning']);
|
|
|
|
$this->actingAs($this->admin)
|
|
->patch(route('projects.transition', $project), ['status' => 'in_progress'])
|
|
->assertSessionHas('error');
|
|
}
|
|
|
|
/** @test */
|
|
public function can_start_project_with_pm_assigned(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 8, 'status' => 'planning']);
|
|
$pm = User::factory()->create(['user_type' => 'admin']);
|
|
$project->personnel()->attach($pm->id, ['role' => 'pm']);
|
|
|
|
$this->actingAs($this->admin)
|
|
->patch(route('projects.transition', $project), ['status' => 'in_progress']);
|
|
|
|
$this->assertEquals('in_progress', $project->fresh()->status->value);
|
|
}
|
|
|
|
/** @test */
|
|
public function cannot_complete_parent_with_active_children(): void
|
|
{
|
|
$parent = Project::factory()->create(['current_wizard_step' => 8, 'status' => 'in_progress']);
|
|
$pm = User::factory()->create();
|
|
$parent->personnel()->attach($pm->id, ['role' => 'pm']);
|
|
|
|
Project::factory()->create([
|
|
'parent_project_id' => $parent->id,
|
|
'current_wizard_step' => 8,
|
|
'status' => 'in_progress',
|
|
]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->patch(route('projects.transition', $parent), ['status' => 'completed'])
|
|
->assertSessionHas('error');
|
|
|
|
$this->assertEquals('in_progress', $parent->fresh()->status->value);
|
|
}
|
|
|
|
// ─── Update Project ───────────────────────────────────────────────────────
|
|
|
|
/** @test */
|
|
public function update_succeeds_on_unlocked_project(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 5, 'status' => 'planning']);
|
|
|
|
$this->actingAs($this->admin)
|
|
->put(route('projects.update', $project), $this->validProjectPayload(['name' => 'New Name']));
|
|
|
|
$this->assertEquals('New Name', $project->fresh()->name);
|
|
}
|
|
|
|
// ─── Personnel Management ─────────────────────────────────────────────────
|
|
|
|
/** @test */
|
|
public function add_personnel_fails_with_nonexistent_user(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.personnel.add', $project), ['user_id' => 'bad-ulid', 'role' => 'pm'])
|
|
->assertSessionHas('error');
|
|
}
|
|
|
|
/** @test */
|
|
public function add_personnel_fails_with_invalid_role(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']);
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.personnel.add', $project), ['user_id' => $user->ulid, 'role' => 'bad_role'])
|
|
->assertSessionHasErrors('role');
|
|
}
|
|
|
|
/** @test */
|
|
public function add_personnel_fails_on_duplicate(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']);
|
|
$user = User::factory()->create();
|
|
$project->personnel()->attach($user->id, ['role' => 'member']);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post(route('projects.personnel.add', $project), ['user_id' => $user->ulid, 'role' => 'member'])
|
|
->assertSessionHas('error');
|
|
}
|
|
|
|
/** @test */
|
|
public function can_remove_personnel(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']);
|
|
$user = User::factory()->create();
|
|
$project->personnel()->attach($user->id, ['role' => 'member']);
|
|
|
|
$this->actingAs($this->admin)
|
|
->delete(route('projects.personnel.remove', [$project, $user]));
|
|
|
|
$this->assertFalse($project->personnel()->where('user_id', $user->id)->exists());
|
|
}
|
|
|
|
/** @test */
|
|
public function cannot_skip_ahead_wizard_steps(): void
|
|
{
|
|
$project = Project::factory()->create(['current_wizard_step' => 2]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->get(route('projects.wizard', [$project, 'step' => 5]))
|
|
->assertOk()
|
|
->assertInertia(fn ($page) => $page->where('step', 2));
|
|
}
|
|
|
|
/** @test */
|
|
public function contractor_project_manager_appears_in_project_wizard_roster_and_can_be_assigned(): void
|
|
{
|
|
$cpmRole = Role::firstOrCreate(['name' => 'Contractor Project Manager', 'guard_name' => 'web']);
|
|
$contractor = \Modules\ContractorManagement\Models\Contractor::create([
|
|
'company_name' => 'Titan Prime Subcontractor Corp',
|
|
'contact_person' => 'Juan Dela Cruz',
|
|
'email' => 'juan@titanprime.test',
|
|
'phone' => '09171234567',
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$contractorPM = User::factory()->create([
|
|
'name' => 'Juan Contractor PM',
|
|
'email' => 'juan.cpm@titanprime.test',
|
|
'user_type' => 'contractor',
|
|
'contractor_id' => $contractor->id,
|
|
'status' => 'active',
|
|
]);
|
|
$contractorPM->assignRole($cpmRole);
|
|
|
|
// 1. Verify Contractor PM appears in projects.create employees list
|
|
$this->actingAs($this->admin)
|
|
->get(route('projects.create'))
|
|
->assertOk()
|
|
->assertInertia(fn ($page) =>
|
|
$page->where('employees', fn ($emps) =>
|
|
collect($emps)->contains('email', 'juan.cpm@titanprime.test')
|
|
)
|
|
);
|
|
|
|
// 2. Verify Contractor PM can be assigned as project PM on project store
|
|
$storeResponse = $this->actingAs($this->admin)
|
|
->post(route('projects.store'), [
|
|
'name' => 'Titan Construction Tower A',
|
|
'client_name' => 'Titan Prime Holdings',
|
|
'location' => 'Bonifacio Global City, Taguig',
|
|
'description' => 'Titan tower high-rise development',
|
|
'project_type' => 'standard',
|
|
'contract_value' => 50000000,
|
|
'start_date' => now()->addDays(5)->format('Y-m-d'),
|
|
'target_end_date' => now()->addMonths(6)->format('Y-m-d'),
|
|
'contract_duration' => 180,
|
|
'classifications' => ['Building and Industrial Plant'],
|
|
'pm_id' => $contractorPM->ulid,
|
|
]);
|
|
|
|
$storeResponse->assertRedirect();
|
|
$newProject = Project::where('name', 'Titan Construction Tower A')->firstOrFail();
|
|
$this->assertTrue($newProject->personnel()->where('user_id', $contractorPM->id)->wherePivot('role', 'pm')->exists());
|
|
|
|
// 3. Verify Contractor PM appears in Step 4 wizard roster and can be assigned as team member
|
|
$wizardStep4Resp = $this->actingAs($this->admin)
|
|
->get(route('projects.wizard', [$newProject, 'step' => 4]));
|
|
|
|
$wizardStep4Resp->assertOk()
|
|
->assertInertia(fn ($page) =>
|
|
$page->where('employees', fn ($emps) =>
|
|
collect($emps)->contains('email', 'juan.cpm@titanprime.test')
|
|
)
|
|
);
|
|
|
|
// 4. Save Step 4 manpower with Contractor PM in team roster
|
|
$saveLaborResp = $this->actingAs($this->admin)
|
|
->post(route('projects.wizard.labor', $newProject), [
|
|
'user_ulids' => [$contractorPM->ulid],
|
|
'labor' => [],
|
|
]);
|
|
|
|
$saveLaborResp->assertRedirect(route('projects.wizard', [$newProject, 'step' => 5]));
|
|
$this->assertTrue($newProject->personnel()->where('user_id', $contractorPM->id)->exists());
|
|
// Verify Contractor was automatically attached to project_contractor
|
|
$this->assertTrue($newProject->contractors()->where('contractors.id', $contractor->id)->exists());
|
|
}
|
|
|
|
/** @test */
|
|
public function test_wizard_roster_auto_links_contractors_and_enforces_single_project_manager_with_multiple_site_operations(): void
|
|
{
|
|
$pmRole = Role::firstOrCreate(['name' => 'Project Manager', 'guard_name' => 'web']);
|
|
$siteTechRole = Role::firstOrCreate(['name' => 'Site Technical', 'guard_name' => 'web']);
|
|
$supervisorRole = Role::firstOrCreate(['name' => 'Construction Supervisor', 'guard_name' => 'web']);
|
|
|
|
$mainContractor = \Modules\ContractorManagement\Models\Contractor::create([
|
|
'company_name' => 'Main Structural Corp',
|
|
'contact_person' => 'Main Person',
|
|
'email' => 'main@structural.test',
|
|
'phone' => '09181111111',
|
|
'address' => 'Pasig City, Metro Manila',
|
|
'tax_id' => '001-234-567-000',
|
|
'payment_terms' => 'net_30',
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subcontractor = \Modules\ContractorManagement\Models\Contractor::create([
|
|
'company_name' => 'Subcon Electrical Services',
|
|
'contact_person' => 'Sub Person',
|
|
'email' => 'sub@electrical.test',
|
|
'phone' => '09182222222',
|
|
'address' => 'Quezon City, Metro Manila',
|
|
'tax_id' => '002-345-678-000',
|
|
'payment_terms' => 'net_30',
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$firstPM = User::factory()->create([
|
|
'name' => 'Alice First PM',
|
|
'user_type' => 'contractor',
|
|
'contractor_id' => $mainContractor->id,
|
|
'status' => 'active',
|
|
]);
|
|
$firstPM->assignRole($pmRole);
|
|
|
|
$secondPM = User::factory()->create([
|
|
'name' => 'Bob Second PM',
|
|
'user_type' => 'employee',
|
|
'status' => 'active',
|
|
]);
|
|
$secondPM->assignRole($pmRole);
|
|
|
|
$siteTechUser = User::factory()->create([
|
|
'name' => 'Charlie Site Tech',
|
|
'user_type' => 'contractor',
|
|
'contractor_id' => $subcontractor->id,
|
|
'status' => 'active',
|
|
]);
|
|
$siteTechUser->assignRole($siteTechRole);
|
|
|
|
$supervisorUser = User::factory()->create([
|
|
'name' => 'David Supervisor',
|
|
'user_type' => 'contractor',
|
|
'contractor_id' => $subcontractor->id,
|
|
'status' => 'active',
|
|
]);
|
|
$supervisorUser->assignRole($supervisorRole);
|
|
|
|
// 1. Create project with First PM
|
|
$this->actingAs($this->admin)->post(route('projects.store'), [
|
|
'name' => 'Skyline Heights Commercial Center',
|
|
'client_name' => 'Skyline Development Group',
|
|
'location' => 'Makati City, Metro Manila',
|
|
'description' => 'Commercial center high-rise structure',
|
|
'project_type' => 'standard',
|
|
'contract_value' => 35000000,
|
|
'start_date' => now()->addDays(5)->format('Y-m-d'),
|
|
'target_end_date' => now()->addMonths(6)->format('Y-m-d'),
|
|
'contract_duration' => 180,
|
|
'classifications' => ['Building and Industrial Plant'],
|
|
'pm_id' => $firstPM->ulid,
|
|
]);
|
|
|
|
$project = Project::where('name', 'Skyline Heights Commercial Center')->firstOrFail();
|
|
$this->assertEquals(1, $project->personnel()->wherePivot('role', 'pm')->count());
|
|
$this->assertTrue($project->contractors()->where('contractors.id', $mainContractor->id)->exists());
|
|
|
|
// 2. Change PM in Wizard Step 1 details -> Strictly 1 PM remains
|
|
$this->actingAs($this->admin)->post(route('projects.wizard.save-details', $project), [
|
|
'name' => 'Skyline Heights Commercial Center',
|
|
'client_name' => 'Skyline Development Group',
|
|
'location' => 'Makati City, Metro Manila',
|
|
'description' => 'Commercial center high-rise structure',
|
|
'project_type' => 'standard',
|
|
'contract_value' => 35000000,
|
|
'start_date' => now()->addDays(5)->format('Y-m-d'),
|
|
'target_end_date' => now()->addMonths(6)->format('Y-m-d'),
|
|
'contract_duration' => 180,
|
|
'classifications' => ['Building and Industrial Plant'],
|
|
'pm_id' => $secondPM->ulid,
|
|
]);
|
|
|
|
$project->refresh();
|
|
$this->assertEquals(1, $project->personnel()->wherePivot('role', 'pm')->count());
|
|
$this->assertEquals($secondPM->id, $project->personnel()->wherePivot('role', 'pm')->first()->id);
|
|
|
|
// 3. In Wizard Step 4, assign multiple site operations personnel from Subcontractor
|
|
$this->actingAs($this->admin)->post(route('projects.wizard.labor', $project), [
|
|
'user_ulids' => [$siteTechUser->ulid, $supervisorUser->ulid],
|
|
'labor' => [],
|
|
]);
|
|
|
|
$project->refresh();
|
|
|
|
// 4. Verify exactly 1 PM and multiple site operations
|
|
$this->assertEquals(1, $project->personnel()->wherePivot('role', 'pm')->count());
|
|
$this->assertEquals(2, $project->personnel()->wherePivot('role', 'member')->count());
|
|
|
|
// 5. Verify Subcontractor was automatically linked to the project in project_contractor
|
|
$this->assertTrue($project->contractors()->where('contractors.id', $subcontractor->id)->exists());
|
|
}
|
|
}
|