From 24f73084fade9ef43c1dfdf6863c6a8bdf27edc7 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 14:27:51 -0500 Subject: [PATCH] Phase 8 and 9 marketplace matching --- CHANGELOG.md | 2 + app/Http/Controllers/JobPostController.php | 45 +++++- .../Controllers/ProviderProfileController.php | 41 +++++- app/Models/JobPost.php | 27 +++- app/Models/ProviderProfile.php | 16 +- app/Models/TaxonomyTerm.php | 45 ++++++ app/Models/WorkOrder.php | 7 + config/technician-levels.php | 34 +++++ ...reate_taxonomy_and_marketplace_filters.php | 65 +++++++++ database/seeders/DatabaseSeeder.php | 137 ++++++++++++++++++ docs/FORWARD_PHASE_PLAN.md | 37 ++++- docs/ROADMAP.md | 25 +++- resources/views/jobs/create.blade.php | 45 ++++++ resources/views/jobs/index.blade.php | 128 ++++++++++++---- resources/views/jobs/show.blade.php | 7 + resources/views/providers/edit.blade.php | 22 +++ resources/views/providers/index.blade.php | 29 ++++ resources/views/providers/show.blade.php | 16 ++ resources/views/work-orders/print.blade.php | 1 + resources/views/work-orders/show.blade.php | 1 + tests/Feature/ExchangeWorkflowTest.php | 98 +++++++++++++ 21 files changed, 778 insertions(+), 50 deletions(-) create mode 100644 app/Models/TaxonomyTerm.php create mode 100644 config/technician-levels.php create mode 100644 database/migrations/2026_07_10_232000_create_taxonomy_and_marketplace_filters.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e19e2..9b0bb9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re - Added Phase 4 work-order and profile depth with structured work-order setup, checklist completion, evidence rules, change requests, print packets, structured provider services/tools/certifications, structured buyer policies/locations, and public/private profile section controls. - Added Phase 5 reputation governance with explicit review category definitions, buyer/provider-specific review dimensions, reviewee responses, review report flow, admin moderation controls, moderation status tracking, a review edit window, and imported-history labeling. - Added Phase 6/7 scope and contact safeguards with structured job scope fields, scope clarity and risk badges, support/contact certification, work-order scope/contact snapshots, first-class change requests, contact/support event logging, reason-coded disputes, and print-packet support. +- Added Phase 8/9 marketplace matching with a dense available-work board, category/level/safety filters, technician level definitions, normalized taxonomy terms, provider tag assignment, and self-declared evidence-source tracking. - Created the Laravel Provider Exchange prototype with Breeze authentication, seeded demo users, and role support for admins, providers, buyers, and hybrid accounts. - Added provider and buyer profile management with service areas, profile details, and external profile snapshot records. - Added social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance. @@ -35,6 +36,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re - `php artisan test` passed with 35 tests and 116 assertions. - `php artisan test` passed under WSL with 44 tests and 195 assertions after Phase 5. - `php artisan test` passed under Windows PHP with 46 tests and 216 assertions after enabling `pdo_sqlite`, `sqlite3`, `fileinfo`, and `gd` in `C:\tools\php85\php.ini`. +- `php artisan test` passed under Windows PHP with 48 tests and 228 assertions after Phase 8/9. - `npm run build` passed with a production Vite build. - `https://christit.com/tse/login` loads publicly. - Seeded admin login succeeds on `https://christit.com/tse` and reaches `/tse/dashboard`. diff --git a/app/Http/Controllers/JobPostController.php b/app/Http/Controllers/JobPostController.php index d455eff..1d5dd31 100644 --- a/app/Http/Controllers/JobPostController.php +++ b/app/Http/Controllers/JobPostController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\JobPost; +use App\Models\TaxonomyTerm; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -11,7 +12,8 @@ class JobPostController extends Controller { public function index(Request $request): View { - $jobs = JobPost::with('buyer.buyerProfile', 'attachments', 'ratings.user'); + $jobs = JobPost::with('buyer.buyerProfile', 'workCategory', 'workSpecialty', 'attachments', 'ratings.user') + ->withCount('quotes'); if ($search = $request->string('q')->toString()) { $jobs->where(fn ($query) => $query @@ -26,8 +28,35 @@ class JobPostController extends Controller $jobs->where('status', $status); } + if ($category = $request->integer('work_category_id')) { + $jobs->where('work_category_id', $category); + } + + if ($level = $request->integer('technician_level')) { + $jobs->where('required_technician_level', '<=', $level); + } + + if ($scope = $request->string('scope_clarity')->toString()) { + $jobs->where('scope_clarity_status', $scope); + } + + if ($request->boolean('support_certified')) { + $jobs->where('contact_certified', true); + } + + if ($request->boolean('remote_only')) { + $jobs->where(fn ($query) => $query->where('remote_eligible', true)->orWhere('work_mode', 'remote')); + } + + if ($request->boolean('hide_risky')) { + $jobs->where(fn ($query) => $query->whereNull('risk_flags')->orWhereJsonLength('risk_flags', 0)); + } + return view('jobs.index', [ 'jobs' => $jobs->latest()->paginate(20)->withQueryString(), + 'categories' => TaxonomyTerm::where('type', 'work_category')->where('is_active', true)->orderBy('sort_order')->orderBy('name')->get(), + 'technicianLevels' => config('technician-levels'), + 'filters' => $request->only(['q', 'status', 'work_category_id', 'technician_level', 'scope_clarity', 'support_certified', 'remote_only', 'hide_risky']), ]); } @@ -35,7 +64,11 @@ class JobPostController extends Controller { abort_unless($request->user()->hasRole('buyer'), 403); - return view('jobs.create'); + return view('jobs.create', [ + 'categories' => TaxonomyTerm::where('type', 'work_category')->where('is_active', true)->orderBy('sort_order')->orderBy('name')->get(), + 'specialties' => TaxonomyTerm::where('type', 'work_specialty')->where('is_active', true)->orderBy('sort_order')->orderBy('name')->get(), + 'technicianLevels' => config('technician-levels'), + ]); } public function store(Request $request): RedirectResponse @@ -45,6 +78,12 @@ class JobPostController extends Controller $data = $request->validate([ 'title' => ['required', 'string', 'max:255'], 'service_category' => ['nullable', 'string', 'max:255'], + 'work_category_id' => ['nullable', 'integer', 'exists:taxonomy_terms,id'], + 'work_specialty_id' => ['nullable', 'integer', 'exists:taxonomy_terms,id'], + 'required_technician_level' => ['required', 'integer', 'min:1', 'max:5'], + 'work_mode' => ['required', 'string', 'in:onsite,remote,hybrid'], + 'pay_type' => ['nullable', 'string', 'in:fixed,hourly,blended,not_listed'], + 'posted_terms_summary' => ['nullable', 'string', 'max:255'], 'location' => ['required', 'string', 'max:255'], 'starts_at' => ['nullable', 'date'], 'time_window' => ['nullable', 'string', 'max:255'], @@ -110,6 +149,8 @@ class JobPostController extends Controller return view('jobs.show', [ 'job' => $job->load( 'buyer.buyerProfile', + 'workCategory', + 'workSpecialty', 'quotes.provider.providerProfile', 'quotes.revisions.user', 'workOrder', diff --git a/app/Http/Controllers/ProviderProfileController.php b/app/Http/Controllers/ProviderProfileController.php index f122a05..830e486 100644 --- a/app/Http/Controllers/ProviderProfileController.php +++ b/app/Http/Controllers/ProviderProfileController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\ProviderProfile; +use App\Models\TaxonomyTerm; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -11,7 +12,7 @@ class ProviderProfileController extends Controller { public function index(Request $request): View { - $profiles = ProviderProfile::with('user', 'externalImports', 'attachments', 'ratings.user') + $profiles = ProviderProfile::with('user', 'externalImports', 'taxonomyTerms', 'attachments', 'ratings.user') ->withAvg(['ratings as average_stars' => fn ($query) => $query->whereNotNull('stars')], 'stars') ->withCount('ratings'); @@ -32,6 +33,14 @@ class ProviderProfileController extends Controller $profiles->where('skills', 'like', "%{$skill}%"); } + if ($level = $request->integer('technician_level')) { + $profiles->where('max_technician_level', '>=', $level); + } + + if ($tag = $request->integer('taxonomy_term_id')) { + $profiles->whereHas('taxonomyTerms', fn ($query) => $query->whereKey($tag)); + } + if ($insurance = $request->string('insurance')->toString()) { $profiles->where('insurance_status', 'like', "%{$insurance}%"); } @@ -48,7 +57,14 @@ class ProviderProfileController extends Controller return view('providers.index', [ 'profiles' => $profiles->paginate(20)->withQueryString(), - 'filters' => $request->only(['q', 'service_area', 'skill', 'insurance', 'public_contact', 'sort']), + 'filters' => $request->only(['q', 'service_area', 'skill', 'technician_level', 'taxonomy_term_id', 'insurance', 'public_contact', 'sort']), + 'technicianLevels' => config('technician-levels'), + 'taxonomyTerms' => TaxonomyTerm::whereIn('type', ['work_category', 'work_specialty', 'skill', 'tool', 'certification']) + ->where('is_active', true) + ->orderBy('type') + ->orderBy('sort_order') + ->orderBy('name') + ->get(), ]); } @@ -57,7 +73,14 @@ class ProviderProfileController extends Controller abort_unless($request->user()->hasRole('provider'), 403); return view('providers.edit', [ - 'profile' => $request->user()->providerProfile?->load('attachments', 'externalImports.attachments'), + 'profile' => $request->user()->providerProfile?->load('attachments', 'externalImports.attachments', 'taxonomyTerms'), + 'technicianLevels' => config('technician-levels'), + 'taxonomyTerms' => TaxonomyTerm::whereIn('type', ['work_category', 'work_specialty', 'skill', 'tool', 'certification']) + ->where('is_active', true) + ->orderBy('type') + ->orderBy('sort_order') + ->orderBy('name') + ->get(), ]); } @@ -70,7 +93,10 @@ class ProviderProfileController extends Controller 'headline' => ['nullable', 'string', 'max:255'], 'bio' => ['nullable', 'string'], 'service_area' => ['nullable', 'string', 'max:255'], + 'max_technician_level' => ['required', 'integer', 'min:1', 'max:5'], 'skills' => ['nullable', 'string'], + 'taxonomy_terms' => ['nullable', 'array'], + 'taxonomy_terms.*' => ['integer', 'exists:taxonomy_terms,id'], 'services_text' => ['nullable', 'string'], 'tools' => ['nullable', 'string'], 'tool_inventory_text' => ['nullable', 'string'], @@ -97,18 +123,23 @@ class ProviderProfileController extends Controller ]); unset($data['services_text'], $data['tool_inventory_text'], $data['certification_records_text']); - $request->user()->providerProfile()->updateOrCreate( + $profile = $request->user()->providerProfile()->updateOrCreate( ['user_id' => $request->user()->id], $data ); + $sync = collect($data['taxonomy_terms'] ?? []) + ->mapWithKeys(fn ($termId): array => [(int) $termId => ['evidence_source' => 'self_declared']]) + ->all(); + $profile->taxonomyTerms()->sync($sync); + return redirect()->route('providers.edit')->with('status', 'Provider profile saved.'); } public function show(ProviderProfile $provider): View { return view('providers.show', [ - 'profile' => $provider->load('user', 'externalImports.attachments', 'attachments', 'ratings.user'), + 'profile' => $provider->load('user', 'externalImports.attachments', 'taxonomyTerms', 'attachments', 'ratings.user'), ]); } diff --git a/app/Models/JobPost.php b/app/Models/JobPost.php index e837b24..19584e4 100644 --- a/app/Models/JobPost.php +++ b/app/Models/JobPost.php @@ -11,7 +11,8 @@ use Illuminate\Database\Eloquent\Model; #[Fillable([ 'buyer_id', 'title', 'status', 'service_category', 'location', 'starts_at', - 'time_window', 'schedule_type', 'remote_eligible', 'scope', 'primary_objective', + 'work_category_id', 'work_specialty_id', 'required_technician_level', 'work_mode', + 'pay_type', 'posted_terms_summary', 'time_window', 'schedule_type', 'remote_eligible', 'scope', 'primary_objective', 'included_work', 'excluded_work', 'maximum_onsite_expectations', 'expected_duration', 'required_skills', 'required_tools', 'required_certifications', 'required_safety_gear', 'deliverables', 'closeout_conditions', 'buyer_provided_equipment', @@ -38,6 +39,7 @@ class JobPost extends Model { return [ 'starts_at' => 'datetime', + 'required_technician_level' => 'integer', 'remote_eligible' => 'boolean', 'risk_flags' => 'array', 'contact_certified' => 'boolean', @@ -50,6 +52,16 @@ class JobPost extends Model return $this->belongsTo(User::class, 'buyer_id'); } + public function workCategory(): BelongsTo + { + return $this->belongsTo(TaxonomyTerm::class, 'work_category_id'); + } + + public function workSpecialty(): BelongsTo + { + return $this->belongsTo(TaxonomyTerm::class, 'work_specialty_id'); + } + public function quotes(): HasMany { return $this->hasMany(Quote::class); @@ -95,6 +107,10 @@ class JobPost extends Model 'parking_access_notes' => $this->parking_access_notes, 'onsite_restrictions' => $this->onsite_restrictions, 'supplemental_instructions' => $this->supplemental_instructions, + 'required_technician_level' => $this->required_technician_level, + 'technician_level' => $this->technicianLevel()['name'], + 'work_category' => $this->workCategory?->name, + 'work_specialty' => $this->workSpecialty?->name, ]; } @@ -152,6 +168,10 @@ class JobPost extends Model $flags[] = 'compressed_schedule'; } + if ((int) $this->required_technician_level <= 1 && ($this->required_certifications || str_contains(strtolower((string) $this->primary_objective.' '.$this->included_work), 'troubleshoot'))) { + $flags[] = 'level_scope_mismatch'; + } + return array_values(array_unique($flags)); } @@ -173,4 +193,9 @@ class JobPost extends Model return 'clear'; } + + public function technicianLevel(): array + { + return config('technician-levels')[(int) $this->required_technician_level] ?? config('technician-levels')[1]; + } } diff --git a/app/Models/ProviderProfile.php b/app/Models/ProviderProfile.php index 00b7ea6..fe9ad41 100644 --- a/app/Models/ProviderProfile.php +++ b/app/Models/ProviderProfile.php @@ -4,13 +4,14 @@ namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Model; #[Fillable([ 'user_id', 'business_name', 'headline', 'bio', 'service_area', 'skills', - 'services', 'tools', 'tool_inventory', 'certifications', 'certification_records', + 'max_technician_level', 'services', 'tools', 'tool_inventory', 'certifications', 'certification_records', 'insurance_status', 'rate_card', 'travel_policy', 'availability_notes', 'website_url', 'phone', 'public_contact', 'profile_visibility', 'private_notes', ])] @@ -24,6 +25,7 @@ class ProviderProfile extends Model 'certification_records' => 'array', 'profile_visibility' => 'array', 'public_contact' => 'boolean', + 'max_technician_level' => 'integer', ]; } @@ -47,6 +49,18 @@ class ProviderProfile extends Model return $this->morphMany(Rating::class, 'rateable'); } + public function taxonomyTerms(): BelongsToMany + { + return $this->belongsToMany(TaxonomyTerm::class) + ->withPivot('evidence_source') + ->withTimestamps(); + } + + public function technicianLevel(): array + { + return config('technician-levels')[(int) $this->max_technician_level] ?? config('technician-levels')[1]; + } + public function visible(string $field): bool { $visibility = $this->profile_visibility ?? []; diff --git a/app/Models/TaxonomyTerm.php b/app/Models/TaxonomyTerm.php new file mode 100644 index 0000000..1d77849 --- /dev/null +++ b/app/Models/TaxonomyTerm.php @@ -0,0 +1,45 @@ + 'boolean', + ]; + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + public function providerProfiles(): BelongsToMany + { + return $this->belongsToMany(ProviderProfile::class) + ->withPivot('evidence_source') + ->withTimestamps(); + } +} diff --git a/app/Models/WorkOrder.php b/app/Models/WorkOrder.php index c6dd8b6..26e871f 100644 --- a/app/Models/WorkOrder.php +++ b/app/Models/WorkOrder.php @@ -198,4 +198,11 @@ class WorkOrder extends Model ->whereIn('event_type', ['contact_failed', 'support_unavailable', 'site_contact_unavailable']) ->count(); } + + public function technicianLevel(): array + { + $level = $this->scopeSnapshotValue('required_technician_level') ?? $this->jobPost?->required_technician_level ?? 1; + + return config('technician-levels')[(int) $level] ?? config('technician-levels')[1]; + } } diff --git a/config/technician-levels.php b/config/technician-levels.php new file mode 100644 index 0000000..bcdb11b --- /dev/null +++ b/config/technician-levels.php @@ -0,0 +1,34 @@ + [ + 'name' => 'Level 1 - Smart hands', + 'short_name' => 'Smart hands', + 'description' => 'Follows clear remote direction, performs basic onsite tasks, takes photos, swaps labeled equipment, and reports what they see. Scope must be tightly defined and should not require independent diagnosis.', + 'scope_rule' => 'Use for simple, clearly documented tasks with step-by-step instructions and available support.', + ], + 2 => [ + 'name' => 'Level 2 - Installer', + 'short_name' => 'Installer', + 'description' => 'Installs or replaces equipment independently, follows a defined plan, verifies operation, and completes normal closeout. Scope should identify the equipment, expected result, and test path.', + 'scope_rule' => 'Use for planned installs, swaps, turn-ups, and closeouts where the desired end state is known.', + ], + 3 => [ + 'name' => 'Level 3 - Troubleshooter', + 'short_name' => 'Troubleshooter', + 'description' => 'Works semi-independently when the exact fault is unknown, gathers evidence, isolates likely causes, and attempts practical remediation within an agreed boundary.', + 'scope_rule' => 'Use when the problem is not fully known, but the buyer can define systems involved, support path, and escalation limits.', + ], + 4 => [ + 'name' => 'Level 4 - Specialist', + 'short_name' => 'Specialist', + 'description' => 'Handles advanced or vendor-specific systems, complex integrations, certification-heavy tasks, or work where mistakes carry higher operational risk.', + 'scope_rule' => 'Use when specialty knowledge, verified credentials, or deeper system ownership is required.', + ], + 5 => [ + 'name' => 'Level 5 - Project lead', + 'short_name' => 'Project lead', + 'description' => 'Coordinates multi-tech, multi-site, or high-ambiguity projects, manages onsite decisions, documents changes, and handles buyer/provider coordination.', + 'scope_rule' => 'Use for leadership, field coordination, cutovers, escalations, or projects where onsite judgment is central.', + ], +]; diff --git a/database/migrations/2026_07_10_232000_create_taxonomy_and_marketplace_filters.php b/database/migrations/2026_07_10_232000_create_taxonomy_and_marketplace_filters.php new file mode 100644 index 0000000..701f8b8 --- /dev/null +++ b/database/migrations/2026_07_10_232000_create_taxonomy_and_marketplace_filters.php @@ -0,0 +1,65 @@ +id(); + $table->foreignId('parent_id')->nullable()->constrained('taxonomy_terms')->cascadeOnDelete(); + $table->string('type'); + $table->string('name'); + $table->string('slug'); + $table->text('description')->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['type', 'slug']); + $table->index(['type', 'is_active', 'sort_order']); + }); + + Schema::create('provider_profile_taxonomy_term', function (Blueprint $table) { + $table->id(); + $table->foreignId('provider_profile_id')->constrained()->cascadeOnDelete(); + $table->foreignId('taxonomy_term_id')->constrained()->cascadeOnDelete(); + $table->string('evidence_source')->default('self_declared'); + $table->timestamps(); + + $table->unique(['provider_profile_id', 'taxonomy_term_id']); + }); + + Schema::table('provider_profiles', function (Blueprint $table) { + $table->unsignedTinyInteger('max_technician_level')->default(1)->after('service_area'); + }); + + Schema::table('job_posts', function (Blueprint $table) { + $table->foreignId('work_category_id')->nullable()->after('service_category')->constrained('taxonomy_terms')->nullOnDelete(); + $table->foreignId('work_specialty_id')->nullable()->after('work_category_id')->constrained('taxonomy_terms')->nullOnDelete(); + $table->unsignedTinyInteger('required_technician_level')->default(1)->after('work_specialty_id'); + $table->string('work_mode')->default('onsite')->after('required_technician_level'); + $table->string('pay_type')->nullable()->after('work_mode'); + $table->string('posted_terms_summary')->nullable()->after('pay_type'); + }); + } + + public function down(): void + { + Schema::table('job_posts', function (Blueprint $table) { + $table->dropConstrainedForeignId('work_category_id'); + $table->dropConstrainedForeignId('work_specialty_id'); + $table->dropColumn(['required_technician_level', 'work_mode', 'pay_type', 'posted_terms_summary']); + }); + + Schema::table('provider_profiles', function (Blueprint $table) { + $table->dropColumn('max_technician_level'); + }); + + Schema::dropIfExists('provider_profile_taxonomy_term'); + Schema::dropIfExists('taxonomy_terms'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 7e4d809..e18e331 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -4,6 +4,7 @@ namespace Database\Seeders; use App\Models\User; use App\Models\JobPost; +use App\Models\TaxonomyTerm; use App\Models\WorkOrder; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -19,6 +20,8 @@ class DatabaseSeeder extends Seeder */ public function run(): void { + $taxonomy = $this->seedTaxonomy(); + $roles = ['admin', 'provider', 'buyer']; foreach ($roles as $role) { @@ -60,6 +63,7 @@ class DatabaseSeeder extends Seeder 'headline' => 'POS, networking, cabling, and onsite IT support', 'bio' => 'Demo provider profile for the technician-owned exchange.', 'service_area' => 'Central US with regional travel', + 'max_technician_level' => 3, 'skills' => "POS\nNetworking\nStructured cabling\nPrinter support", 'services' => [ ['name' => 'Smart hands dispatch', 'level' => 'experienced'], @@ -92,6 +96,15 @@ class DatabaseSeeder extends Seeder ] ); + $providerProfile->taxonomyTerms()->syncWithoutDetaching([ + $taxonomy['categories']['point-of-sale']->id => ['evidence_source' => 'self_declared'], + $taxonomy['categories']['server-networking']->id => ['evidence_source' => 'self_declared'], + $taxonomy['specialties']['pos']->id => ['evidence_source' => 'self_declared'], + $taxonomy['specialties']['wireless-networking']->id => ['evidence_source' => 'self_declared'], + $taxonomy['skills']['well-tooled-mobile']->id => ['evidence_source' => 'self_declared'], + $taxonomy['tools']['cable-tester']->id => ['evidence_source' => 'self_declared'], + ]); + $providerProfile->externalImports()->updateOrCreate( ['platform' => 'Field Nation', 'external_id' => '172-630'], [ @@ -140,14 +153,27 @@ class DatabaseSeeder extends Seeder 'buyer_id' => $buyer->id, 'status' => 'assigned', 'service_category' => 'POS', + 'work_category_id' => $taxonomy['categories']['point-of-sale']->id, + 'work_specialty_id' => $taxonomy['specialties']['pos']->id, + 'required_technician_level' => 3, + 'work_mode' => 'onsite', + 'pay_type' => 'fixed', + 'posted_terms_summary' => 'Two-hour minimum plus travel if needed', 'location' => 'Tulsa, OK', 'time_window' => 'Morning', 'scope' => 'Troubleshoot intermittent POS terminal connectivity.', + 'primary_objective' => 'Restore intermittent POS terminal connectivity.', + 'included_work' => 'Inspect lane cabling, network path, and terminal connectivity.', + 'excluded_work' => 'No electrical work or ceiling cabling unless accepted as a change request.', + 'closeout_conditions' => 'Buyer validates that the terminal remains online.', 'required_skills' => 'POS and basic networking', 'required_tools' => 'Laptop, patch cables, toner/probe', 'deliverables' => "Check in\nPhotos\nResolution notes", 'payment_terms' => 'Direct buyer/provider payment terms.', 'vendor_onboarding' => 'Buyer handles direct onboarding.', + 'contact_certified' => true, + 'scope_clarity_status' => 'clear', + 'risk_flags' => [], 'visibility' => 'public', ] ); @@ -174,6 +200,10 @@ class DatabaseSeeder extends Seeder 'deliverables_checklist' => $job->deliverables, 'scheduled_at' => $job->starts_at, 'appointment_window' => $job->time_window, + 'scope_snapshot' => $job->scopeSnapshot(), + 'contact_snapshot' => $job->contactSnapshot(), + 'scope_clarity_status' => $job->scope_clarity_status, + 'risk_flags' => $job->risk_flags, 'checklist_items' => ['Check in', 'Photos', 'Resolution notes'], 'required_evidence' => $job->deliverables, 'evidence_rules' => ['Arrival photo', 'Completion photo'], @@ -196,4 +226,111 @@ class DatabaseSeeder extends Seeder ] ); } + + private function seedTaxonomy(): array + { + $categories = [ + 'access-alarms' => 'Access & Alarms', + 'av-digital-signage' => 'A/V & Digital Signage', + 'cameras' => 'Cameras', + 'ev-equipment' => 'EV Equipment', + 'fiber-cabling' => 'Fiber Cabling', + 'kiosk-atm' => 'Kiosk / ATM', + 'low-voltage-cabling' => 'Low Voltage Cabling', + 'office-equipment' => 'Office Equipment', + 'point-of-sale' => 'Point of Sale', + 'retail-services' => 'Retail Services', + 'server-networking' => 'Server & Networking', + 'telecom' => 'Telecom', + 'other-trades' => 'Other Trades', + ]; + + $specialties = [ + 'pos' => ['Point of Sale', 'point-of-sale'], + 'self-checkout' => ['Self-checkout', 'point-of-sale'], + 'networking' => ['Networking', 'server-networking'], + 'wireless-networking' => ['Wireless networking', 'server-networking'], + 'server-storage' => ['Server/storage', 'server-networking'], + 'low-voltage-runs' => ['Low voltage runs', 'low-voltage-cabling'], + 'low-voltage-testing' => ['Low voltage testing', 'low-voltage-cabling'], + 'fiber-testing' => ['Fiber testing', 'fiber-cabling'], + 'pots' => ['POTS', 'telecom'], + 'voip-sip' => ['VoIP/SIP', 'telecom'], + 'digital-signage' => ['Digital signage', 'av-digital-signage'], + 'ip-camera' => ['IP camera', 'cameras'], + 'printer' => ['Printer', 'office-equipment'], + ]; + + $skills = [ + 'well-tooled-mobile' => 'Well-tooled mobile', + 'well-prepared' => 'Well-prepared', + 'well-experienced' => 'Well-experienced', + 'smart-hands-ready' => 'Smart-hands ready', + 'independent-installer' => 'Independent installer', + 'field-troubleshooter' => 'Field troubleshooter', + ]; + + $tools = [ + 'laptop' => 'Laptop', + 'cable-tester' => 'Cable tester', + 'toner-probe' => 'Toner/probe', + 'labeler' => 'Labeler', + 'basic-hand-tools' => 'Basic hand tools', + ]; + + $certifications = [ + 'low-voltage-license' => 'Low-voltage license', + 'comptia-a-plus' => 'CompTIA A+', + 'network-plus' => 'Network+', + 'manufacturer-certification' => 'Manufacturer certification', + ]; + + $categoryRecords = []; + foreach ($categories as $slug => $name) { + $categoryRecords[$slug] = TaxonomyTerm::updateOrCreate( + ['type' => 'work_category', 'slug' => $slug], + ['name' => $name, 'sort_order' => count($categoryRecords) + 1, 'is_active' => true] + ); + } + + $specialtyRecords = []; + foreach ($specialties as $slug => [$name, $parentSlug]) { + $specialtyRecords[$slug] = TaxonomyTerm::updateOrCreate( + ['type' => 'work_specialty', 'slug' => $slug], + ['name' => $name, 'parent_id' => $categoryRecords[$parentSlug]->id ?? null, 'sort_order' => count($specialtyRecords) + 1, 'is_active' => true] + ); + } + + $skillRecords = []; + foreach ($skills as $slug => $name) { + $skillRecords[$slug] = TaxonomyTerm::updateOrCreate( + ['type' => 'skill', 'slug' => $slug], + ['name' => $name, 'sort_order' => count($skillRecords) + 1, 'is_active' => true] + ); + } + + $toolRecords = []; + foreach ($tools as $slug => $name) { + $toolRecords[$slug] = TaxonomyTerm::updateOrCreate( + ['type' => 'tool', 'slug' => $slug], + ['name' => $name, 'sort_order' => count($toolRecords) + 1, 'is_active' => true] + ); + } + + $certificationRecords = []; + foreach ($certifications as $slug => $name) { + $certificationRecords[$slug] = TaxonomyTerm::updateOrCreate( + ['type' => 'certification', 'slug' => $slug], + ['name' => $name, 'sort_order' => count($certificationRecords) + 1, 'is_active' => true] + ); + } + + return [ + 'categories' => $categoryRecords, + 'specialties' => $specialtyRecords, + 'skills' => $skillRecords, + 'tools' => $toolRecords, + 'certifications' => $certificationRecords, + ]; + } } diff --git a/docs/FORWARD_PHASE_PLAN.md b/docs/FORWARD_PHASE_PLAN.md index 0164580..3e01d41 100644 --- a/docs/FORWARD_PHASE_PLAN.md +++ b/docs/FORWARD_PHASE_PLAN.md @@ -32,7 +32,7 @@ The goal is not to clone Field Nation. The goal is to keep the useful operationa - Job posting now has structured scope and support certification fields, but the creation UI should be refined into reusable templates and stronger validation before real marketplace use. - Provider and buyer directories use text-based filters instead of normalized category, skill, coverage, remote, rating, and reliability filters. - Work-order lists are card-based and lack the dense assigned/available-work table that worked well in Field Nation. -- Work-order detail now supports contact/support failure logging and first-class change requests, but schedule update requests, running-late records, and general report-problem records still need a dedicated workflow. +- Work-order detail now supports contact/support failure logging and first-class change requests, but schedule update requests, running-late records, post-work tag verification, and general report-problem records still need a dedicated workflow. - Disputes and votes now have reason codes, but quorum rules, visibility rules, and deeper evidence timelines still need a future pass. - Reviews exist, but there is no review response, report flow, edit window, moderation queue, category definitions page, or imported/native reputation distinction in the UI. - Universal ratings are flexible, but category names are arbitrary strings and not governed by a taxonomy. @@ -310,6 +310,14 @@ Provider-protection rules: - Jobs should not rank purely by buyer preference or low provider rate. - Risk flags should be visible before a provider opens the work order. +Implementation status: + +- Completed first implementation pass alongside Phase 9. +- Replaced the jobs list with a denser available-work board. +- Added filters for category, technician level, scope clarity, support certification, remote eligibility, and hide risky jobs. +- Added columns for buyer, location, category, specialty, technician level, schedule, work mode, pay type, terms summary, quote count, scope clarity, support certification, and risk badges. +- Verified with Windows `php artisan test`. + ## Phase 9 - Taxonomy, Competency Tags, And Profile Evidence Purpose: @@ -397,6 +405,23 @@ Provider-protection rules: - Buyers should not be able to demand advanced work while labeling it smart hands. - Certification proof should be controlled by the provider and privacy-aware. +Implementation status: + +- Completed first implementation pass alongside Phase 8. +- Added `taxonomy_terms` for categories, specialties, skills, tools, and certifications. +- Added provider/tag pivot records with evidence sources. +- Added starter seed taxonomy for field-service categories and common traits/tools/certifications. +- Added explicit technician level definitions: + - Level 1 smart hands, + - Level 2 installer, + - Level 3 troubleshooter, + - Level 4 specialist, + - Level 5 project lead. +- Added provider maximum technician level and self-declared tags in profile editing. +- Added buyer job technician-level selection and scope mismatch risk detection for smart-hands jobs that include troubleshooting or certification requirements. +- Added provider directory filters for technician level and taxonomy tag. +- Deferred buyer post-work tag endorsement/disagreement to the next verification pass. + ## Phase 10 - Import Tooling And Marketplace History Purpose: @@ -682,11 +707,11 @@ Provider-protection rules: ## Recommended Immediate Sequence -1. Phase 8A: dense available-work board with compact filters. -2. Phase 8B: saved searches, risk filter chips, and provider default filters. -3. Phase 9A: taxonomy tables and starter work-category seeds. -4. Phase 10A: guided imported-history wizard. -5. Phase 11A: mobile-safe API endpoints for onsite work-order actions. +1. Post-work provider tag verification: buyer endorsement/disagreement with self-declared level and tags after completed work orders. +2. Phase 10A: guided imported-history wizard. +3. Phase 11A: mobile-safe API endpoints for onsite work-order actions. +4. Phase 12A: notification preference UI and event-channel controls. +5. Phase 13A: audit logs and moderation/operations queues. ## Key Design Commitments diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b2fe570..a6c7827 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -16,6 +16,9 @@ Provider Exchange is a Laravel application for a provider-centered field-service - Structured job scope, scope clarity status, risk badges, and anti-catch-all supplemental instruction guardrails. - Buyer contact/support certification, work-order contact snapshots, and provider contact/support failure logging. - First-class work-order change requests with reason codes, impact fields, status lifecycle, and print-packet visibility. +- Dense available-work board with filters for category, technician level, scope clarity, support certification, remote eligibility, and risk suppression. +- Normalized starter taxonomy for work categories, specialties, skills, tools, certifications, and provider tag evidence sources. +- Explicit technician level ladder from smart hands through project lead. - Configurable attachment storage policy with upload root, disk, size cap, MIME allowlist, image previews, file metadata, and deletion. - Mutual reviews with category metrics. - Peer-review disputes with comments, evidence, structured recommendations, and public vote breakdowns. @@ -46,18 +49,18 @@ Provider Exchange is a Laravel application for a provider-centered field-service 7. Contact accountability and support availability: certified contacts, support windows, event logs for failed support/contact attempts, and contact reliability evidence. 8. API/mobile prep: token-scoped endpoints, versioned API resources, geolocation check-in groundwork, and mobile-safe work-order actions. 9. Import tooling: guided manual imports for Field Nation, WorkMarket, and similar profile/review history. -10. Competency tags and levels: provider/buyer tag taxonomies, smart-hands entry lane, specialty tags such as network, POTS, POS, AV, cabling, installer, and troubleshooter, plus earned level badges based on completed work and reputation signals. +10. Post-work verification: buyer endorsement or disagreement with provider level/tags after completed work orders, earned evidence upgrades, and transparent tag history. 11. Admin operations: audit logs, queue health, system status, content moderation, and backup/export workflows. 12. Deployment hardening: production environment docs, queue worker setup, scheduler setup, storage policy, and server provisioning notes. 13. License and runtime audit: generate and review PHP and JavaScript dependency license reports before adding external theme kits, map any risky package to a replacement, and keep required local/runtime extensions such as `pdo_sqlite` documented for testability. ## Phase Checkpoint -Phases 1-7 have been implemented locally. Phases 1-5 have been shipped through the local -> GitHub -> ChristIT update workflow. Phase 6/7 is ready for the next ship/update pass. The next active planning block is: +Phases 1-9 have been implemented locally through the combined Phase 8/9 marketplace matching pass. The next active planning block is: 8. API/mobile prep: token-scoped endpoints, versioned API resources, geolocation check-in groundwork, contact/support failure logging, and mobile-safe work-order actions. 9. Import tooling: guided manual imports for Field Nation, WorkMarket, and similar profile/review history. -10. Competency tags and levels: provider/buyer tag taxonomies, smart-hands entry lane, specialty tags such as network, POTS, POS, AV, cabling, installer, and troubleshooter, plus earned level badges based on completed work and reputation signals. +10. Post-work verification: buyer endorsement or disagreement with provider level/tags after completed work orders, earned evidence upgrades, and transparent tag history. Work-order safeguards to carry into the next implementation passes: @@ -95,6 +98,22 @@ Phase 6/7 implementation completed: - Work-order print packets include scope safeguards, contact/support coverage, change requests, and contact/support events. - Disputes and peer votes now support reason codes such as scope expansion, unreachable contact, support unavailable, payment issue, and insufficient evidence. +Phase 8/9 implementation completed: + +- The available jobs page is now a dense marketplace board with compact filters for work category, technician level, scope clarity, support certification, remote eligibility, and hide-risky mode. +- Job rows expose buyer, location, category, specialty, requested technician level, schedule, pay type, posted terms summary, quote count, scope clarity, support certification, and risk badges. +- Technician levels are defined in `config/technician-levels.php`: + - Level 1 smart hands, + - Level 2 installer, + - Level 3 troubleshooter, + - Level 4 specialist, + - Level 5 project lead. +- Buyer job creation now requires a technician level and work mode, and can select normalized category/specialty plus pay type and posted terms summary. +- The app now has normalized taxonomy terms for work categories, specialties, skills, tools, and certifications. +- Provider profiles can declare a maximum technician level and self-declared taxonomy tags. +- Provider directory filters can match technician level and taxonomy tags. +- Taxonomy tag pivots track an evidence source, currently `self_declared`, with room for buyer endorsed, completed work, certification verified, and admin verified evidence. + Available-work safeguards to carry into directory and job-list phases: - Available jobs should expose pay type, provider terms, schedule type, work type, rough location, buyer reliability, support/contact certification, scope clarity status, and request/quote count. diff --git a/resources/views/jobs/create.blade.php b/resources/views/jobs/create.blade.php index 396255a..a4412b0 100644 --- a/resources/views/jobs/create.blade.php +++ b/resources/views/jobs/create.blade.php @@ -6,6 +6,41 @@
+
+ + +
+
+ + +
+
+ + +

The posted scope should match this level. Smart-hands work needs clear instructions and active support.

+
+
+ + +
@@ -21,6 +56,16 @@ Remote eligible +
+ + +
+
diff --git a/resources/views/jobs/index.blade.php b/resources/views/jobs/index.blade.php index ce79a88..899c45a 100644 --- a/resources/views/jobs/index.blade.php +++ b/resources/views/jobs/index.blade.php @@ -1,40 +1,104 @@

Jobs

-
- - - Search + +
+ + + + + +
+
+ + + + Filter + Reset +
@auth @role('buyer')Post job@endrole @endauth - @foreach ($jobs as $job) - -
-
-

{{ $job->title }}

-

{{ $job->location }} | {{ $job->service_category }} | {{ $job->status }}

-
-
- Scope {{ str_replace('_', ' ', $job->scope_clarity_status) }} - {{ $job->contact_certified ? 'Support certified' : 'Support not certified' }} -
-
- @if (is_array($job->risk_flags) && $job->risk_flags) -
- @foreach ($job->risk_flags as $flag) - {{ str_replace('_', ' ', $flag) }} - @endforeach -
- @endif -

{{ $job->scope }}

- -
- @endforeach +
+ + + + + + + + + + + + + @forelse ($jobs as $job) + + + + + + + + + @empty + + @endforelse + +
JobCategoryLevelScheduleTermsSignals
+ {{ $job->title }} +

{{ $job->buyer->buyerProfile?->company_name ?? $job->buyer->name }} | {{ $job->location }}

+

{{ $job->primary_objective ?: $job->scope }}

+
+

{{ $job->workCategory?->name ?? $job->service_category ?? 'Uncategorized' }}

+

{{ $job->workSpecialty?->name }}

+
+ {{ $job->technicianLevel()['short_name'] }} + +

{{ $job->starts_at?->format('M j g:i A') ?? 'No start' }}

+

{{ $job->time_window ?: 'No window' }} | {{ str_replace('_', ' ', $job->work_mode ?: 'onsite') }}

+
+

{{ str_replace('_', ' ', $job->pay_type ?: 'not listed') }}

+

{{ $job->posted_terms_summary ?: $job->payment_terms }}

+

{{ $job->quotes_count }} quote{{ $job->quotes_count === 1 ? '' : 's' }}

+
+
+ {{ str_replace('_', ' ', $job->scope_clarity_status) }} + {{ $job->contact_certified ? 'Support certified' : 'No support cert' }} + @foreach (array_slice(is_array($job->risk_flags) ? $job->risk_flags : [], 0, 3) as $flag) + {{ str_replace('_', ' ', $flag) }} + @endforeach +
+
No jobs matched these filters.
+
{{ $jobs->links() }}
diff --git a/resources/views/jobs/show.blade.php b/resources/views/jobs/show.blade.php index a065b1b..1aa55db 100644 --- a/resources/views/jobs/show.blade.php +++ b/resources/views/jobs/show.blade.php @@ -7,6 +7,10 @@

{{ $job->location }} | {{ $job->service_category }} | {{ $job->status }}

Scope {{ str_replace('_', ' ', $job->scope_clarity_status) }} + {{ $job->technicianLevel()['name'] }} + @if ($job->workCategory) + {{ $job->workCategory->name }} + @endif @if ($job->contact_certified) Support certified @else @@ -20,6 +24,7 @@

{{ $job->starts_at?->format('M j, Y g:i A') ?? 'No start set' }}

{{ $job->time_window ?: 'No window set' }} | {{ str_replace('_', ' ', $job->schedule_type ?: 'schedule not set') }}

+

{{ str_replace('_', ' ', $job->work_mode ?: 'onsite') }} | {{ str_replace('_', ' ', $job->pay_type ?: 'pay not listed') }}

@@ -41,6 +46,8 @@
Primary objective
{{ $job->primary_objective ?: $job->scope }}
+
Technician level
{{ $job->technicianLevel()['description'] }}
+
Level scope rule
{{ $job->technicianLevel()['scope_rule'] }}
Expected duration
{{ $job->expected_duration ?: 'Not specified' }}
Included work
{{ $job->included_work ?: 'Not specified' }}
Excluded work
{{ $job->excluded_work ?: 'Not specified' }}
diff --git a/resources/views/providers/edit.blade.php b/resources/views/providers/edit.blade.php index ddfc930..335426c 100644 --- a/resources/views/providers/edit.blade.php +++ b/resources/views/providers/edit.blade.php @@ -4,6 +4,7 @@ $certsText = collect($profile?->certification_records ?? [])->map(fn ($row) => trim(($row['name'] ?? '').' | '.($row['issuer'] ?? ''), ' |'))->implode("\n"); $visibility = $profile?->profile_visibility ?? []; $visible = fn (string $field): bool => (bool) ($visibility[$field] ?? true); + $selectedTaxonomy = $profile?->taxonomyTerms?->pluck('id')->all() ?? []; @endphp @@ -22,7 +23,28 @@ +
+ + +

This is self-declared until native work history, buyer endorsements, certifications, or admin verification support it.

+
+
+

Self-declared tags and evidence

+

Choose categories, specialties, skills, tools, and certifications that describe your work. Later passes can let buyers endorse or challenge these after completed work orders.

+
+ @foreach ($taxonomyTerms as $term) + + @endforeach +
+
diff --git a/resources/views/providers/index.blade.php b/resources/views/providers/index.blade.php index 8335b95..ac8720b 100644 --- a/resources/views/providers/index.blade.php +++ b/resources/views/providers/index.blade.php @@ -27,6 +27,24 @@ +
+ + +
+
+ + +
@@ -65,6 +83,9 @@ @if (request()->boolean('public_contact')) Public contact @endif + @if (! empty($filters['technician_level'])) + Level: {{ $technicianLevels[(int) $filters['technician_level']]['short_name'] ?? $filters['technician_level'] }} + @endif
@@ -90,6 +111,7 @@ @if ($profile->service_area) {{ $profile->service_area }} @endif + {{ $profile->technicianLevel()['short_name'] }} @if ($profile->public_contact) Public contact @endif @@ -107,6 +129,13 @@ @if ($profile->skills)

{{ \Illuminate\Support\Str::limit($profile->skills, 220) }}

@endif + @if ($profile->taxonomyTerms->isNotEmpty()) +
+ @foreach ($profile->taxonomyTerms->take(8) as $term) + {{ $term->name }} + @endforeach +
+ @endif @empty
diff --git a/resources/views/providers/show.blade.php b/resources/views/providers/show.blade.php index 56189f8..c0bcfc5 100644 --- a/resources/views/providers/show.blade.php +++ b/resources/views/providers/show.blade.php @@ -18,6 +18,12 @@ @if ($profile->insurance_status) {{ $profile->insurance_status }} @endif + {{ $profile->technicianLevel()['name'] }} +
+
+

{{ $profile->technicianLevel()['short_name'] }}

+

{{ $profile->technicianLevel()['description'] }}

+

Level and tags are self-declared unless supported by buyer endorsements, completed TSE work, certification proof, or admin verification.

@if ($profile->visible('bio')) @@ -25,6 +31,16 @@ @endif
+
+
Tags and evidence
+
+ @forelse ($profile->taxonomyTerms as $term) + {{ $term->name }}{{ $term->pivot?->evidence_source ? ' | '.str_replace('_', ' ', $term->pivot->evidence_source) : '' }} + @empty + No taxonomy tags yet. + @endforelse +
+
@if ($profile->visible('services'))
Services
diff --git a/resources/views/work-orders/print.blade.php b/resources/views/work-orders/print.blade.php index d7be547..e34fb4c 100644 --- a/resources/views/work-orders/print.blade.php +++ b/resources/views/work-orders/print.blade.php @@ -42,6 +42,7 @@

Scope Safeguards

Scope clarity: {{ str_replace('_', ' ', $workOrder->scope_clarity_status) }}

+

Technician level: {{ $workOrder->technicianLevel()['name'] }}

@if (is_array($workOrder->risk_flags) && $workOrder->risk_flags)

Risk flags: {{ collect($workOrder->risk_flags)->map(fn ($flag) => str_replace('_', ' ', $flag))->implode(', ') }}

@endif diff --git a/resources/views/work-orders/show.blade.php b/resources/views/work-orders/show.blade.php index d96cdd3..2cf3e4e 100644 --- a/resources/views/work-orders/show.blade.php +++ b/resources/views/work-orders/show.blade.php @@ -68,6 +68,7 @@
Scope {{ str_replace('_', ' ', $workOrder->scope_clarity_status) }} + {{ $workOrder->technicianLevel()['name'] }} @if ($workOrder->contactSnapshotValue('contact_certified')) Support certified @else diff --git a/tests/Feature/ExchangeWorkflowTest.php b/tests/Feature/ExchangeWorkflowTest.php index 0a30e3c..71a7397 100644 --- a/tests/Feature/ExchangeWorkflowTest.php +++ b/tests/Feature/ExchangeWorkflowTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature; use App\Models\JobPost; use App\Models\ProviderProfile; use App\Models\SocialPost; +use App\Models\TaxonomyTerm; use App\Models\User; use App\Models\WorkOrder; use App\Notifications\ExchangeEventNotification; @@ -26,6 +27,7 @@ class ExchangeWorkflowTest extends TestCase $this->actingAs($provider)->put('/provider-profile', [ 'business_name' => 'Provider LLC', 'service_area' => 'Oklahoma', + 'max_technician_level' => 2, ])->assertRedirect('/provider-profile'); $this->actingAs($buyer)->put('/buyer-profile', [ @@ -235,6 +237,8 @@ class ExchangeWorkflowTest extends TestCase $this->actingAs($buyer)->post('/jobs', [ 'title' => 'Replace POS lane router', 'service_category' => 'Point of Sale', + 'required_technician_level' => 3, + 'work_mode' => 'onsite', 'location' => 'Tulsa, OK', 'starts_at' => now()->addDays(3)->format('Y-m-d H:i:s'), 'time_window' => '9 AM - 12 PM', @@ -399,6 +403,7 @@ class ExchangeWorkflowTest extends TestCase $this->actingAs($provider)->put('/provider-profile', [ 'business_name' => 'Structured Provider', 'bio' => 'Public bio.', + 'max_technician_level' => 3, 'services_text' => "Smart hands | entry\nNetwork troubleshooting | advanced", 'tool_inventory_text' => "Cable tester | network\nTone probe | cabling", 'certification_records_text' => "Low voltage license | Oklahoma\nA+ | CompTIA", @@ -427,6 +432,48 @@ class ExchangeWorkflowTest extends TestCase ->assertDontSee('Internal only'); } + public function test_provider_taxonomy_tags_and_technician_level_can_filter_directory(): void + { + $provider = $this->userWithRole('provider'); + $networkTag = TaxonomyTerm::create([ + 'type' => 'skill', + 'name' => 'Field troubleshooter', + 'slug' => 'field-troubleshooter', + ]); + + $this->actingAs($provider)->put('/provider-profile', [ + 'business_name' => 'Tagged Provider', + 'service_area' => 'Tulsa', + 'max_technician_level' => 3, + 'skills' => 'Network and POS troubleshooting', + 'taxonomy_terms' => [$networkTag->id], + 'profile_visibility' => [ + 'bio' => '1', + 'services' => '1', + 'tools' => '1', + 'certifications' => '1', + 'rate_card' => '1', + 'availability' => '1', + 'imports' => '1', + ], + ])->assertRedirect('/provider-profile'); + + $profile = $provider->providerProfile()->firstOrFail(); + + $this->assertSame(3, $profile->max_technician_level); + $this->assertDatabaseHas('provider_profile_taxonomy_term', [ + 'provider_profile_id' => $profile->id, + 'taxonomy_term_id' => $networkTag->id, + 'evidence_source' => 'self_declared', + ]); + + $this->get('/providers?technician_level=3&taxonomy_term_id='.$networkTag->id) + ->assertOk() + ->assertSee('Tagged Provider') + ->assertSee('Troubleshooter') + ->assertSee('Field troubleshooter'); + } + public function test_buyer_directory_can_filter_and_sort_profiles(): void { $retailBuyer = $this->userWithRole('buyer'); @@ -497,6 +544,57 @@ class ExchangeWorkflowTest extends TestCase ->assertDontSee('Buyer internal note'); } + public function test_available_work_board_filters_by_category_level_and_safety_signals(): void + { + $buyer = $this->userWithRole('buyer'); + $category = TaxonomyTerm::create([ + 'type' => 'work_category', + 'name' => 'Point of Sale', + 'slug' => 'point-of-sale', + ]); + + JobPost::create([ + 'buyer_id' => $buyer->id, + 'title' => 'Clear POS troubleshooting', + 'status' => 'open', + 'service_category' => 'POS', + 'work_category_id' => $category->id, + 'required_technician_level' => 3, + 'work_mode' => 'onsite', + 'location' => 'Tulsa, OK', + 'scope' => 'Troubleshoot POS issue.', + 'primary_objective' => 'Find POS connectivity fault.', + 'included_work' => 'Test cabling and terminal connectivity.', + 'excluded_work' => 'No electrical work.', + 'deliverables' => 'Photos and notes', + 'closeout_conditions' => 'Buyer validates terminal online.', + 'contact_certified' => true, + 'scope_clarity_status' => 'clear', + 'risk_flags' => [], + 'visibility' => 'public', + ]); + + JobPost::create([ + 'buyer_id' => $buyer->id, + 'title' => 'Risky smart hands dump', + 'status' => 'open', + 'service_category' => 'Other', + 'required_technician_level' => 1, + 'work_mode' => 'onsite', + 'location' => 'OKC, OK', + 'scope' => 'Do everything onsite.', + 'scope_clarity_status' => 'needs_review', + 'risk_flags' => ['missing_scope_boundaries'], + 'visibility' => 'public', + ]); + + $this->get('/jobs?work_category_id='.$category->id.'&technician_level=3&scope_clarity=clear&support_certified=1&hide_risky=1') + ->assertOk() + ->assertSee('Clear POS troubleshooting') + ->assertSee('Troubleshooter') + ->assertDontSee('Risky smart hands dump'); + } + public function test_invalid_work_order_transition_is_rejected(): void { [, $provider, $workOrder] = $this->workOrderFixture();