From 71434c1ac48e4d09d2ab3364e4ed6869e03653a9 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 17:55:52 -0500 Subject: [PATCH] Add provider tag verification --- CHANGELOG.md | 2 + .../Controllers/ProviderProfileController.php | 10 +- .../ProviderTagVerificationController.php | 86 +++++++++++ app/Http/Controllers/WorkOrderController.php | 3 +- app/Models/ProviderProfile.php | 5 + app/Models/ProviderTagVerification.php | 72 ++++++++++ app/Models/WorkOrder.php | 6 + ...reate_provider_tag_verifications_table.php | 35 +++++ docs/FORWARD_PHASE_PLAN.md | 12 +- docs/ROADMAP.md | 15 +- resources/views/providers/show.blade.php | 47 +++++++ resources/views/work-orders/show.blade.php | 133 ++++++++++++++++++ routes/web.php | 2 + tests/Feature/ExchangeWorkflowTest.php | 87 ++++++++++++ 14 files changed, 504 insertions(+), 11 deletions(-) create mode 100644 app/Http/Controllers/ProviderTagVerificationController.php create mode 100644 app/Models/ProviderTagVerification.php create mode 100644 database/migrations/2026_07_10_234000_create_provider_tag_verifications_table.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ecf833..243f148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re - 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. - Added the guided imported-history wizard with structured work categories, imported endorsements, operational metrics, selected review excerpts, public visibility controls, proof attachments, and admin verification status. +- Added post-work provider tag verification so buyers can confirm/dispute observed technician level and declared provider tags after completed work orders, with buyer-endorsed tag evidence shown on provider profiles. - 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. @@ -39,6 +40,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re - `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. - `php artisan test` passed under Windows PHP with 49 tests and 239 assertions after the guided imported-history wizard. +- `php artisan test` passed under Windows PHP with 51 tests and 256 assertions after post-work provider tag verification. - `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/ProviderProfileController.php b/app/Http/Controllers/ProviderProfileController.php index 830e486..fee2807 100644 --- a/app/Http/Controllers/ProviderProfileController.php +++ b/app/Http/Controllers/ProviderProfileController.php @@ -139,7 +139,15 @@ class ProviderProfileController extends Controller public function show(ProviderProfile $provider): View { return view('providers.show', [ - 'profile' => $provider->load('user', 'externalImports.attachments', 'taxonomyTerms', 'attachments', 'ratings.user'), + 'profile' => $provider->load( + 'user', + 'externalImports.attachments', + 'taxonomyTerms', + 'attachments', + 'ratings.user', + 'tagVerifications.workOrder.jobPost', + 'tagVerifications.buyer', + ), ]); } diff --git a/app/Http/Controllers/ProviderTagVerificationController.php b/app/Http/Controllers/ProviderTagVerificationController.php new file mode 100644 index 0000000..f2f26dc --- /dev/null +++ b/app/Http/Controllers/ProviderTagVerificationController.php @@ -0,0 +1,86 @@ +user()->id === $workOrder->buyer_id || $request->user()->hasRole('admin'), 403); + abort_unless(in_array($workOrder->status, ['completed', 'buyer_approved', 'closed'], true), 422, 'Provider tag verification is available after completed work.'); + + $profile = $workOrder->provider?->providerProfile; + abort_unless($profile, 422, 'The assigned provider does not have a provider profile yet.'); + + $data = $request->validate([ + 'level_verdict' => ['required', 'string', 'in:'.implode(',', array_keys(ProviderTagVerification::LEVEL_VERDICTS))], + 'confirmed_level' => ['nullable', 'integer', 'min:1', 'max:5'], + 'confirmed_term_ids' => ['nullable', 'array'], + 'confirmed_term_ids.*' => ['integer', 'exists:taxonomy_terms,id'], + 'disputed_term_ids' => ['nullable', 'array'], + 'disputed_term_ids.*' => ['integer', 'exists:taxonomy_terms,id'], + 'suggested_tags_text' => ['nullable', 'string'], + 'notes' => ['nullable', 'string', 'max:4000'], + ]); + + $confirmed = $this->knownProfileTermIds($profile->id, $data['confirmed_term_ids'] ?? []); + $disputed = $this->knownProfileTermIds($profile->id, $data['disputed_term_ids'] ?? []); + $suggestedTags = collect(preg_split('/\r\n|\r|\n/', (string) ($data['suggested_tags_text'] ?? ''))) + ->map(fn (string $tag): string => trim($tag)) + ->filter() + ->unique() + ->values() + ->all(); + + $verification = $workOrder->providerTagVerification()->updateOrCreate( + ['work_order_id' => $workOrder->id], + [ + 'provider_profile_id' => $profile->id, + 'buyer_id' => $workOrder->buyer_id, + 'provider_id' => $workOrder->provider_id, + 'declared_level' => $profile->max_technician_level, + 'confirmed_level' => $data['confirmed_level'] ?? null, + 'level_verdict' => $data['level_verdict'], + 'confirmed_term_ids' => $confirmed, + 'disputed_term_ids' => $disputed, + 'suggested_tags' => $suggestedTags, + 'notes' => $data['notes'] ?? null, + ] + ); + + if ($confirmed !== []) { + $profile->taxonomyTerms()->syncWithoutDetaching( + collect($confirmed)->mapWithKeys(fn (int $termId): array => [$termId => ['evidence_source' => 'buyer_endorsed']])->all() + ); + } + + $workOrder->provider->notify(new ExchangeEventNotification( + 'Provider tags reviewed', + $request->user()->name.' added post-work competency evidence for '.$workOrder->jobPost->title.'.', + route('work-orders.show', $workOrder), + 'provider_tag_verification' + )); + + return redirect()->route('work-orders.show', $workOrder)->with('status', 'Provider tag verification saved.'); + } + + private function knownProfileTermIds(int $profileId, array $termIds): array + { + if ($termIds === []) { + return []; + } + + return TaxonomyTerm::whereIn('id', $termIds) + ->whereHas('providerProfiles', fn ($query) => $query->whereKey($profileId)) + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->all(); + } +} diff --git a/app/Http/Controllers/WorkOrderController.php b/app/Http/Controllers/WorkOrderController.php index 9be0136..3fabda0 100644 --- a/app/Http/Controllers/WorkOrderController.php +++ b/app/Http/Controllers/WorkOrderController.php @@ -34,10 +34,11 @@ class WorkOrderController extends Controller 'workOrder' => $workOrder->load( 'jobPost', 'buyer.buyerProfile', - 'provider.providerProfile', + 'provider.providerProfile.taxonomyTerms', 'acceptedQuote', 'structuredChangeRequests.requester', 'contactEvents.user', + 'providerTagVerification.buyer', 'reviews.reviewer', 'reviews.reviewee', 'disputes.openedBy', diff --git a/app/Models/ProviderProfile.php b/app/Models/ProviderProfile.php index fe9ad41..58736a1 100644 --- a/app/Models/ProviderProfile.php +++ b/app/Models/ProviderProfile.php @@ -56,6 +56,11 @@ class ProviderProfile extends Model ->withTimestamps(); } + public function tagVerifications(): HasMany + { + return $this->hasMany(ProviderTagVerification::class); + } + public function technicianLevel(): array { return config('technician-levels')[(int) $this->max_technician_level] ?? config('technician-levels')[1]; diff --git a/app/Models/ProviderTagVerification.php b/app/Models/ProviderTagVerification.php new file mode 100644 index 0000000..bfc975f --- /dev/null +++ b/app/Models/ProviderTagVerification.php @@ -0,0 +1,72 @@ + 'Level matched the work', + 'overstated' => 'Provider level seemed overstated', + 'understated' => 'Provider could handle more advanced work', + 'not_observed' => 'Not enough evidence from this job', + ]; + + protected function casts(): array + { + return [ + 'declared_level' => 'integer', + 'confirmed_level' => 'integer', + 'confirmed_term_ids' => 'array', + 'disputed_term_ids' => 'array', + 'suggested_tags' => 'array', + ]; + } + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + public function providerProfile(): BelongsTo + { + return $this->belongsTo(ProviderProfile::class); + } + + public function buyer(): BelongsTo + { + return $this->belongsTo(User::class, 'buyer_id'); + } + + public function provider(): BelongsTo + { + return $this->belongsTo(User::class, 'provider_id'); + } + + public function confirmedTerms() + { + return TaxonomyTerm::whereIn('id', $this->confirmed_term_ids ?? [])->get(); + } + + public function disputedTerms() + { + return TaxonomyTerm::whereIn('id', $this->disputed_term_ids ?? [])->get(); + } + + public function confirmedLevelDefinition(): ?array + { + if (! $this->confirmed_level) { + return null; + } + + return config('technician-levels')[$this->confirmed_level] ?? null; + } +} diff --git a/app/Models/WorkOrder.php b/app/Models/WorkOrder.php index 26e871f..f818074 100644 --- a/app/Models/WorkOrder.php +++ b/app/Models/WorkOrder.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Model; @@ -111,6 +112,11 @@ class WorkOrder extends Model return $this->hasMany(WorkOrderContactEvent::class); } + public function providerTagVerification(): HasOne + { + return $this->hasOne(ProviderTagVerification::class); + } + public function canTransitionTo(string $status): bool { return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true); diff --git a/database/migrations/2026_07_10_234000_create_provider_tag_verifications_table.php b/database/migrations/2026_07_10_234000_create_provider_tag_verifications_table.php new file mode 100644 index 0000000..6e5fdd5 --- /dev/null +++ b/database/migrations/2026_07_10_234000_create_provider_tag_verifications_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('work_order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('provider_profile_id')->constrained()->cascadeOnDelete(); + $table->foreignId('buyer_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('provider_id')->constrained('users')->cascadeOnDelete(); + $table->unsignedTinyInteger('declared_level')->nullable(); + $table->unsignedTinyInteger('confirmed_level')->nullable(); + $table->string('level_verdict')->default('not_observed'); + $table->json('confirmed_term_ids')->nullable(); + $table->json('disputed_term_ids')->nullable(); + $table->json('suggested_tags')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->unique('work_order_id'); + $table->index(['provider_profile_id', 'level_verdict']); + }); + } + + public function down(): void + { + Schema::dropIfExists('provider_tag_verifications'); + } +}; diff --git a/docs/FORWARD_PHASE_PLAN.md b/docs/FORWARD_PHASE_PLAN.md index 9d20f98..2ce4e03 100644 --- a/docs/FORWARD_PHASE_PLAN.md +++ b/docs/FORWARD_PHASE_PLAN.md @@ -33,6 +33,7 @@ The goal is not to clone Field Nation. The goal is to keep the useful operationa - 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, post-work tag verification, and general report-problem records still need a dedicated workflow. +- Work-order detail now supports contact/support failure logging, first-class change requests, and post-work provider tag verification; schedule update requests, running-late records, and general report-problem records still need dedicated workflows. - 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. @@ -420,7 +421,7 @@ Implementation status: - 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. +- Added buyer post-work tag endorsement/disagreement with buyer-endorsed evidence updates on provider profiles. ## Phase 10 - Import Tooling And Marketplace History @@ -717,11 +718,10 @@ Provider-protection rules: ## Recommended Immediate Sequence -1. Post-work provider tag verification: buyer endorsement/disagreement with self-declared level and tags after completed work orders. -2. Phase 11A: mobile-safe API endpoints for onsite work-order actions. -3. Phase 12A: notification preference UI and event-channel controls. -4. Phase 13A: audit logs and moderation/operations queues. -5. Phase 14A: deployment/scaling hardening. +1. Phase 11A: mobile-safe API endpoints for onsite work-order actions. +2. Phase 12A: notification preference UI and event-channel controls. +3. Phase 13A: audit logs and moderation/operations queues. +4. Phase 14A: deployment/scaling hardening. ## Key Design Commitments diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a0c8d2c..773a71b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,6 +20,7 @@ Provider Exchange is a Laravel application for a provider-centered field-service - 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. - Guided imported-history records for Field Nation, WorkMarket, and similar platforms with privacy controls, selected review excerpts, metrics, proof attachments, and admin verification status. +- Post-work provider tag verification for completed work orders, including buyer-confirmed level evidence, confirmed/disputed declared tags, suggested tags, and buyer-endorsed profile tag evidence. - 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. @@ -57,11 +58,11 @@ Provider Exchange is a Laravel application for a provider-centered field-service ## Phase Checkpoint -Phases 1-10A have been implemented locally through the guided imported-history pass. The next active planning block is: +Phases 1-10B have been implemented locally through post-work provider tag verification. 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. Post-work verification: buyer endorsement or disagreement with provider level/tags after completed work orders, earned evidence upgrades, and transparent tag history. -10. Notification preference UI and event-channel controls. +9. Notification preference UI and event-channel controls. +10. Admin operations, audit logs, and deployment hardening. Work-order safeguards to carry into the next implementation passes: @@ -125,6 +126,14 @@ Guided imported-history implementation completed: - Admin dashboard includes an imported-history verification queue. - Imported history remains separate from native TSE reputation. +Post-work provider tag verification completed: + +- Buyers and admins can verify provider level/tag evidence after a work order is completed, buyer approved, or closed. +- Verification records store the provider's declared level, the observed level, a level verdict, confirmed tags, disputed tags, buyer-suggested tags, and notes. +- Confirmed declared tags upgrade their provider-profile evidence source to `buyer_endorsed`. +- Provider profiles now show recent completed-work competency evidence separately from imported history and five-star reviews. +- Providers cannot self-verify their tags from a work order. + 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/providers/show.blade.php b/resources/views/providers/show.blade.php index 4c5c2f7..a703254 100644 --- a/resources/views/providers/show.blade.php +++ b/resources/views/providers/show.blade.php @@ -91,6 +91,53 @@ +
+
+
+

Completed-work competency evidence

+

Buyer confirmations from completed TSE work orders. These support profile tags without replacing provider-controlled profile claims.

+
+ {{ $profile->tagVerifications->count() }} records +
+ + @forelse ($profile->tagVerifications->sortByDesc('created_at')->take(6) as $verification) + @php + $confirmedTerms = $profile->taxonomyTerms->whereIn('id', $verification->confirmed_term_ids ?? []); + $confirmedLevel = $verification->confirmedLevelDefinition(); + @endphp +
+
+
+

{{ $verification->workOrder->jobPost->title ?? 'Completed work order' }}

+

{{ $verification->buyer->name }} | {{ str_replace('_', ' ', $verification->level_verdict) }} | {{ $verification->created_at->format('M j, Y') }}

+
+ @if ($confirmedLevel) + {{ $confirmedLevel['short_name'] }} + @endif +
+ @if ($confirmedTerms->isNotEmpty()) +
+ @foreach ($confirmedTerms as $term) + {{ $term->name }} + @endforeach +
+ @endif + @if ($verification->suggested_tags) +
+ @foreach ($verification->suggested_tags as $tag) + {{ $tag }} + @endforeach +
+ @endif + @if ($verification->notes) +

{{ $verification->notes }}

+ @endif +
+ @empty +

No completed-work competency confirmations have been recorded yet.

+ @endforelse +
+ @if ($profile->visible('imports'))

Imported profile and review history

diff --git a/resources/views/work-orders/show.blade.php b/resources/views/work-orders/show.blade.php index 2cf3e4e..6cd03af 100644 --- a/resources/views/work-orders/show.blade.php +++ b/resources/views/work-orders/show.blade.php @@ -6,6 +6,13 @@ $changeReasonCodes = \App\Models\WorkOrderChangeRequest::REASON_CODES; $contactEventTypes = \App\Models\WorkOrderContactEvent::EVENT_TYPES; $disputeReasonCodes = \App\Models\Dispute::REASON_CODES; + $providerProfile = $workOrder->provider->providerProfile; + $providerTerms = $providerProfile?->taxonomyTerms ?? collect(); + $tagVerification = $workOrder->providerTagVerification; + $verificationCompleteStatuses = ['completed', 'buyer_approved', 'closed']; + $canVerifyProviderTags = (auth()->id() === $workOrder->buyer_id || auth()->user()->hasRole('admin')) + && in_array($workOrder->status, $verificationCompleteStatuses, true) + && $providerProfile; @endphp @@ -416,6 +423,132 @@ @endforeach
+
+
+
+

Provider tag verification

+

After completed work, buyers can confirm whether the provider level and self-declared tags matched what was observed on this job.

+
+ @if ($tagVerification) + Verification recorded + @else + No verification yet + @endif +
+ + @if ($tagVerification) + @php + $confirmedTerms = $providerTerms->whereIn('id', $tagVerification->confirmed_term_ids ?? []); + $disputedTerms = $providerTerms->whereIn('id', $tagVerification->disputed_term_ids ?? []); + $confirmedLevel = $tagVerification->confirmedLevelDefinition(); + @endphp +
+

+ {{ $tagVerification->buyer->name }} marked level evidence as {{ str_replace('_', ' ', $tagVerification->level_verdict) }}. +

+

+ Declared: {{ config('technician-levels')[$tagVerification->declared_level]['short_name'] ?? 'Not recorded' }} + @if ($confirmedLevel) + | Observed: {{ $confirmedLevel['short_name'] }} + @endif +

+ @if ($confirmedTerms->isNotEmpty()) +
+

Confirmed tags

+
+ @foreach ($confirmedTerms as $term) + {{ $term->name }} + @endforeach +
+
+ @endif + @if ($disputedTerms->isNotEmpty()) +
+

Disputed tags

+
+ @foreach ($disputedTerms as $term) + {{ $term->name }} + @endforeach +
+
+ @endif + @if ($tagVerification->suggested_tags) +
+

Buyer suggested tags

+
+ @foreach ($tagVerification->suggested_tags as $tag) + {{ $tag }} + @endforeach +
+
+ @endif + @if ($tagVerification->notes) +

{{ $tagVerification->notes }}

+ @endif +
+ @endif + + @if ($canVerifyProviderTags) +
+ @csrf +
+
+ + +
+
+ + +
+
+ +
+
+

Confirm observed tags

+
+ @forelse ($providerTerms as $term) + + @empty +

The provider has not declared tags yet.

+ @endforelse +
+
+
+

Dispute tags that did not match this job

+
+ @forelse ($providerTerms as $term) + + @empty +

No tags are available to dispute.

+ @endforelse +
+
+
+ + + + Save provider tag verification + + @elseif (! in_array($workOrder->status, $verificationCompleteStatuses, true)) +

Provider tag verification opens after the provider marks the work completed.

+ @endif +
+

Community rating

diff --git a/routes/web.php b/routes/web.php index fa7e775..f80ad78 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Http\Controllers\ExternalProfileImportController; use App\Http\Controllers\JobPostController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\ProviderProfileController; +use App\Http\Controllers\ProviderTagVerificationController; use App\Http\Controllers\QuoteController; use App\Http\Controllers\RatingController; use App\Http\Controllers\ReviewController; @@ -87,6 +88,7 @@ Route::middleware('auth')->group(function () { Route::patch('/work-orders/{workOrder}/transition', [WorkOrderController::class, 'transition'])->name('work-orders.transition'); Route::post('/work-orders/{workOrder}/messages', [WorkOrderMessageController::class, 'store'])->name('work-order-messages.store'); Route::post('/work-orders/{workOrder}/reviews', [ReviewController::class, 'store'])->name('reviews.store'); + Route::post('/work-orders/{workOrder}/provider-tag-verification', [ProviderTagVerificationController::class, 'store'])->name('provider-tag-verifications.store'); Route::post('/reviews/{review}/response', [ReviewController::class, 'respond'])->name('reviews.respond'); Route::post('/reviews/{review}/report', [ReviewController::class, 'report'])->name('reviews.report'); Route::patch('/reviews/{review}/moderation', [ReviewController::class, 'moderate'])->name('reviews.moderate'); diff --git a/tests/Feature/ExchangeWorkflowTest.php b/tests/Feature/ExchangeWorkflowTest.php index 00f3d8c..9e253a3 100644 --- a/tests/Feature/ExchangeWorkflowTest.php +++ b/tests/Feature/ExchangeWorkflowTest.php @@ -525,6 +525,93 @@ class ExchangeWorkflowTest extends TestCase ->assertSee('Field troubleshooter'); } + public function test_buyer_can_verify_provider_tags_after_completed_work(): void + { + [$buyer, $provider, $workOrder] = $this->workOrderFixture(); + $workOrder->update(['status' => 'completed']); + + $toolTag = TaxonomyTerm::create([ + 'type' => 'tool', + 'name' => 'Cable tester', + 'slug' => 'buyer-endorsed-cable-tester', + ]); + $skillTag = TaxonomyTerm::create([ + 'type' => 'skill', + 'name' => 'Field troubleshooter', + 'slug' => 'buyer-endorsed-field-troubleshooter', + ]); + $profile = $provider->providerProfile()->create([ + 'business_name' => 'Verified Provider', + 'max_technician_level' => 3, + 'profile_visibility' => ['imports' => true], + ]); + $profile->taxonomyTerms()->sync([ + $toolTag->id => ['evidence_source' => 'self_declared'], + $skillTag->id => ['evidence_source' => 'self_declared'], + ]); + + $this->actingAs($buyer)->post("/work-orders/{$workOrder->id}/provider-tag-verification", [ + 'level_verdict' => 'confirmed', + 'confirmed_level' => 3, + 'confirmed_term_ids' => [$toolTag->id], + 'disputed_term_ids' => [$skillTag->id], + 'suggested_tags_text' => "Well-prepared\nRetail POS", + 'notes' => 'Provider arrived with the right test gear and solved the issue.', + ])->assertRedirect("/work-orders/{$workOrder->id}"); + + $this->assertDatabaseHas('provider_tag_verifications', [ + 'work_order_id' => $workOrder->id, + 'provider_profile_id' => $profile->id, + 'level_verdict' => 'confirmed', + 'confirmed_level' => 3, + 'notes' => 'Provider arrived with the right test gear and solved the issue.', + ]); + $this->assertDatabaseHas('provider_profile_taxonomy_term', [ + 'provider_profile_id' => $profile->id, + 'taxonomy_term_id' => $toolTag->id, + 'evidence_source' => 'buyer_endorsed', + ]); + $this->assertDatabaseHas('provider_profile_taxonomy_term', [ + 'provider_profile_id' => $profile->id, + 'taxonomy_term_id' => $skillTag->id, + 'evidence_source' => 'self_declared', + ]); + + $this->actingAs($buyer)->get("/work-orders/{$workOrder->id}") + ->assertOk() + ->assertSee('Provider tag verification') + ->assertSee('Cable tester') + ->assertSee('Well-prepared'); + + $this->get("/providers/{$profile->id}") + ->assertOk() + ->assertSee('Completed-work competency evidence') + ->assertSee('Cable tester') + ->assertSee('Retail POS') + ->assertSee('buyer endorsed'); + } + + public function test_provider_tag_verification_rejects_provider_and_uncompleted_work(): void + { + [$buyer, $provider, $workOrder] = $this->workOrderFixture(); + $provider->providerProfile()->create([ + 'business_name' => 'Unready Provider', + 'max_technician_level' => 2, + ]); + + $this->actingAs($provider)->post("/work-orders/{$workOrder->id}/provider-tag-verification", [ + 'level_verdict' => 'confirmed', + 'confirmed_level' => 2, + ])->assertForbidden(); + + $this->actingAs($buyer)->post("/work-orders/{$workOrder->id}/provider-tag-verification", [ + 'level_verdict' => 'confirmed', + 'confirmed_level' => 2, + ])->assertStatus(422); + + $this->assertDatabaseCount('provider_tag_verifications', 0); + } + public function test_buyer_directory_can_filter_and_sort_profiles(): void { $retailBuyer = $this->userWithRole('buyer');