Add provider tag verification

This commit is contained in:
Codex
2026-07-10 17:55:52 -05:00
parent 3b856b5e46
commit 71434c1ac4
14 changed files with 504 additions and 11 deletions
+2
View File
@@ -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 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 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 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. - 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 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. - 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 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 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 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. - `npm run build` passed with a production Vite build.
- `https://christit.com/tse/login` loads publicly. - `https://christit.com/tse/login` loads publicly.
- Seeded admin login succeeds on `https://christit.com/tse` and reaches `/tse/dashboard`. - Seeded admin login succeeds on `https://christit.com/tse` and reaches `/tse/dashboard`.
@@ -139,7 +139,15 @@ class ProviderProfileController extends Controller
public function show(ProviderProfile $provider): View public function show(ProviderProfile $provider): View
{ {
return view('providers.show', [ 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',
),
]); ]);
} }
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers;
use App\Models\ProviderTagVerification;
use App\Models\TaxonomyTerm;
use App\Models\WorkOrder;
use App\Notifications\ExchangeEventNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class ProviderTagVerificationController extends Controller
{
public function store(Request $request, WorkOrder $workOrder): RedirectResponse
{
abort_unless($request->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();
}
}
+2 -1
View File
@@ -34,10 +34,11 @@ class WorkOrderController extends Controller
'workOrder' => $workOrder->load( 'workOrder' => $workOrder->load(
'jobPost', 'jobPost',
'buyer.buyerProfile', 'buyer.buyerProfile',
'provider.providerProfile', 'provider.providerProfile.taxonomyTerms',
'acceptedQuote', 'acceptedQuote',
'structuredChangeRequests.requester', 'structuredChangeRequests.requester',
'contactEvents.user', 'contactEvents.user',
'providerTagVerification.buyer',
'reviews.reviewer', 'reviews.reviewer',
'reviews.reviewee', 'reviews.reviewee',
'disputes.openedBy', 'disputes.openedBy',
+5
View File
@@ -56,6 +56,11 @@ class ProviderProfile extends Model
->withTimestamps(); ->withTimestamps();
} }
public function tagVerifications(): HasMany
{
return $this->hasMany(ProviderTagVerification::class);
}
public function technicianLevel(): array public function technicianLevel(): array
{ {
return config('technician-levels')[(int) $this->max_technician_level] ?? config('technician-levels')[1]; return config('technician-levels')[(int) $this->max_technician_level] ?? config('technician-levels')[1];
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'work_order_id', 'provider_profile_id', 'buyer_id', 'provider_id',
'declared_level', 'confirmed_level', 'level_verdict',
'confirmed_term_ids', 'disputed_term_ids', 'suggested_tags', 'notes',
])]
class ProviderTagVerification extends Model
{
public const LEVEL_VERDICTS = [
'confirmed' => '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;
}
}
+6
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -111,6 +112,11 @@ class WorkOrder extends Model
return $this->hasMany(WorkOrderContactEvent::class); return $this->hasMany(WorkOrderContactEvent::class);
} }
public function providerTagVerification(): HasOne
{
return $this->hasOne(ProviderTagVerification::class);
}
public function canTransitionTo(string $status): bool public function canTransitionTo(string $status): bool
{ {
return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true); return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true);
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('provider_tag_verifications', function (Blueprint $table) {
$table->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');
}
};
+6 -6
View File
@@ -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. - 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 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 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. - 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. - 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. - 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 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 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. - 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 ## Phase 10 - Import Tooling And Marketplace History
@@ -717,11 +718,10 @@ Provider-protection rules:
## Recommended Immediate Sequence ## Recommended Immediate Sequence
1. Post-work provider tag verification: buyer endorsement/disagreement with self-declared level and tags after completed work orders. 1. Phase 11A: mobile-safe API endpoints for onsite work-order actions.
2. Phase 11A: mobile-safe API endpoints for onsite work-order actions. 2. Phase 12A: notification preference UI and event-channel controls.
3. Phase 12A: notification preference UI and event-channel controls. 3. Phase 13A: audit logs and moderation/operations queues.
4. Phase 13A: audit logs and moderation/operations queues. 4. Phase 14A: deployment/scaling hardening.
5. Phase 14A: deployment/scaling hardening.
## Key Design Commitments ## Key Design Commitments
+12 -3
View File
@@ -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. - 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. - 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. - 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. - Configurable attachment storage policy with upload root, disk, size cap, MIME allowlist, image previews, file metadata, and deletion.
- Mutual reviews with category metrics. - Mutual reviews with category metrics.
- Peer-review disputes with comments, evidence, structured recommendations, and public vote breakdowns. - 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 ## 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. 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. 9. Notification preference UI and event-channel controls.
10. 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: 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. - Admin dashboard includes an imported-history verification queue.
- Imported history remains separate from native TSE reputation. - 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-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. - 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.
+47
View File
@@ -91,6 +91,53 @@
<x-rating-form type="provider_profile" :id="$profile->id" category="provider_overall" /> <x-rating-form type="provider_profile" :id="$profile->id" category="provider_overall" />
</section> </section>
<section class="tse-panel p-6">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="font-semibold text-slate-950 dark:text-white">Completed-work competency evidence</h3>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">Buyer confirmations from completed TSE work orders. These support profile tags without replacing provider-controlled profile claims.</p>
</div>
<x-badge tone="sky">{{ $profile->tagVerifications->count() }} records</x-badge>
</div>
@forelse ($profile->tagVerifications->sortByDesc('created_at')->take(6) as $verification)
@php
$confirmedTerms = $profile->taxonomyTerms->whereIn('id', $verification->confirmed_term_ids ?? []);
$confirmedLevel = $verification->confirmedLevelDefinition();
@endphp
<div class="mt-4 rounded-md border border-slate-200 p-4 text-sm dark:border-slate-800">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="font-semibold text-slate-950 dark:text-white">{{ $verification->workOrder->jobPost->title ?? 'Completed work order' }}</p>
<p class="mt-1 text-slate-600 dark:text-slate-400">{{ $verification->buyer->name }} | {{ str_replace('_', ' ', $verification->level_verdict) }} | {{ $verification->created_at->format('M j, Y') }}</p>
</div>
@if ($confirmedLevel)
<x-badge tone="sky">{{ $confirmedLevel['short_name'] }}</x-badge>
@endif
</div>
@if ($confirmedTerms->isNotEmpty())
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($confirmedTerms as $term)
<x-badge tone="emerald">{{ $term->name }}</x-badge>
@endforeach
</div>
@endif
@if ($verification->suggested_tags)
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($verification->suggested_tags as $tag)
<x-badge tone="slate">{{ $tag }}</x-badge>
@endforeach
</div>
@endif
@if ($verification->notes)
<p class="mt-3 whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $verification->notes }}</p>
@endif
</div>
@empty
<p class="mt-3 text-sm text-slate-600 dark:text-slate-400">No completed-work competency confirmations have been recorded yet.</p>
@endforelse
</section>
@if ($profile->visible('imports')) @if ($profile->visible('imports'))
<section class="tse-panel p-6"> <section class="tse-panel p-6">
<h3 class="font-semibold text-slate-950 dark:text-white">Imported profile and review history</h3> <h3 class="font-semibold text-slate-950 dark:text-white">Imported profile and review history</h3>
+133
View File
@@ -6,6 +6,13 @@
$changeReasonCodes = \App\Models\WorkOrderChangeRequest::REASON_CODES; $changeReasonCodes = \App\Models\WorkOrderChangeRequest::REASON_CODES;
$contactEventTypes = \App\Models\WorkOrderContactEvent::EVENT_TYPES; $contactEventTypes = \App\Models\WorkOrderContactEvent::EVENT_TYPES;
$disputeReasonCodes = \App\Models\Dispute::REASON_CODES; $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 @endphp
<x-app-layout> <x-app-layout>
@@ -416,6 +423,132 @@
@endforeach @endforeach
</section> </section>
<section class="tse-panel p-6">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="font-semibold text-slate-950 dark:text-white">Provider tag verification</h3>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">After completed work, buyers can confirm whether the provider level and self-declared tags matched what was observed on this job.</p>
</div>
@if ($tagVerification)
<x-badge tone="emerald">Verification recorded</x-badge>
@else
<x-badge tone="slate">No verification yet</x-badge>
@endif
</div>
@if ($tagVerification)
@php
$confirmedTerms = $providerTerms->whereIn('id', $tagVerification->confirmed_term_ids ?? []);
$disputedTerms = $providerTerms->whereIn('id', $tagVerification->disputed_term_ids ?? []);
$confirmedLevel = $tagVerification->confirmedLevelDefinition();
@endphp
<div class="mt-4 rounded-md border border-slate-200 p-4 text-sm dark:border-slate-800">
<p class="font-semibold text-slate-950 dark:text-white">
{{ $tagVerification->buyer->name }} marked level evidence as {{ str_replace('_', ' ', $tagVerification->level_verdict) }}.
</p>
<p class="mt-1 text-slate-600 dark:text-slate-400">
Declared: {{ config('technician-levels')[$tagVerification->declared_level]['short_name'] ?? 'Not recorded' }}
@if ($confirmedLevel)
| Observed: {{ $confirmedLevel['short_name'] }}
@endif
</p>
@if ($confirmedTerms->isNotEmpty())
<div class="mt-3">
<p class="text-xs font-semibold uppercase text-slate-500 dark:text-slate-400">Confirmed tags</p>
<div class="mt-2 flex flex-wrap gap-2">
@foreach ($confirmedTerms as $term)
<x-badge tone="emerald">{{ $term->name }}</x-badge>
@endforeach
</div>
</div>
@endif
@if ($disputedTerms->isNotEmpty())
<div class="mt-3">
<p class="text-xs font-semibold uppercase text-slate-500 dark:text-slate-400">Disputed tags</p>
<div class="mt-2 flex flex-wrap gap-2">
@foreach ($disputedTerms as $term)
<x-badge tone="amber">{{ $term->name }}</x-badge>
@endforeach
</div>
</div>
@endif
@if ($tagVerification->suggested_tags)
<div class="mt-3">
<p class="text-xs font-semibold uppercase text-slate-500 dark:text-slate-400">Buyer suggested tags</p>
<div class="mt-2 flex flex-wrap gap-2">
@foreach ($tagVerification->suggested_tags as $tag)
<x-badge tone="sky">{{ $tag }}</x-badge>
@endforeach
</div>
</div>
@endif
@if ($tagVerification->notes)
<p class="mt-3 whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $tagVerification->notes }}</p>
@endif
</div>
@endif
@if ($canVerifyProviderTags)
<form method="POST" action="{{ route('provider-tag-verifications.store', $workOrder) }}" class="mt-4 space-y-4 rounded-md border border-slate-200 p-4 dark:border-slate-800">
@csrf
<div class="grid gap-4 md:grid-cols-2">
<div>
<label for="level_verdict" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Level verdict</label>
<select id="level_verdict" name="level_verdict" class="mt-1 block w-full rounded-md border-slate-300 bg-white text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
@foreach (\App\Models\ProviderTagVerification::LEVEL_VERDICTS as $code => $label)
<option value="{{ $code }}" @selected(($tagVerification?->level_verdict ?? 'not_observed') === $code)>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label for="confirmed_level" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Observed technician level</label>
<select id="confirmed_level" name="confirmed_level" class="mt-1 block w-full rounded-md border-slate-300 bg-white text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
<option value="">Not observed</option>
@foreach (config('technician-levels') as $level => $definition)
<option value="{{ $level }}" @selected((int) ($tagVerification?->confirmed_level ?? 0) === $level)>{{ $definition['name'] }}</option>
@endforeach
</select>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">Confirm observed tags</p>
<div class="mt-2 space-y-2">
@forelse ($providerTerms as $term)
<label class="flex items-start gap-2 rounded-md border border-slate-200 p-2 text-sm dark:border-slate-800">
<input type="checkbox" name="confirmed_term_ids[]" value="{{ $term->id }}" @checked(in_array($term->id, $tagVerification?->confirmed_term_ids ?? [], true)) class="mt-1 rounded border-slate-300 text-sky-600 shadow-sm focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950">
<span>{{ $term->name }} <span class="text-xs text-slate-500">({{ str_replace('_', ' ', $term->type) }})</span></span>
</label>
@empty
<p class="text-sm text-slate-600 dark:text-slate-400">The provider has not declared tags yet.</p>
@endforelse
</div>
</div>
<div>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">Dispute tags that did not match this job</p>
<div class="mt-2 space-y-2">
@forelse ($providerTerms as $term)
<label class="flex items-start gap-2 rounded-md border border-slate-200 p-2 text-sm dark:border-slate-800">
<input type="checkbox" name="disputed_term_ids[]" value="{{ $term->id }}" @checked(in_array($term->id, $tagVerification?->disputed_term_ids ?? [], true)) class="mt-1 rounded border-slate-300 text-amber-600 shadow-sm focus:ring-amber-500 dark:border-slate-700 dark:bg-slate-950">
<span>{{ $term->name }} <span class="text-xs text-slate-500">({{ str_replace('_', ' ', $term->type) }})</span></span>
</label>
@empty
<p class="text-sm text-slate-600 dark:text-slate-400">No tags are available to dispute.</p>
@endforelse
</div>
</div>
</div>
<x-field name="suggested_tags_text" label="Suggested tags, one per line" :value="implode(PHP_EOL, $tagVerification?->suggested_tags ?? [])" textarea />
<x-field name="notes" label="Notes about observed preparation, tools, or fit" :value="$tagVerification?->notes" textarea />
<x-primary-button>Save provider tag verification</x-primary-button>
</form>
@elseif (! in_array($workOrder->status, $verificationCompleteStatuses, true))
<p class="mt-3 text-sm text-slate-600 dark:text-slate-400">Provider tag verification opens after the provider marks the work completed.</p>
@endif
</section>
<section class="tse-panel p-6"> <section class="tse-panel p-6">
<h3 class="font-semibold text-slate-950 dark:text-white">Community rating</h3> <h3 class="font-semibold text-slate-950 dark:text-white">Community rating</h3>
<x-rating-summary :ratings="$workOrder->ratings" /> <x-rating-summary :ratings="$workOrder->ratings" />
+2
View File
@@ -11,6 +11,7 @@ use App\Http\Controllers\ExternalProfileImportController;
use App\Http\Controllers\JobPostController; use App\Http\Controllers\JobPostController;
use App\Http\Controllers\NotificationController; use App\Http\Controllers\NotificationController;
use App\Http\Controllers\ProviderProfileController; use App\Http\Controllers\ProviderProfileController;
use App\Http\Controllers\ProviderTagVerificationController;
use App\Http\Controllers\QuoteController; use App\Http\Controllers\QuoteController;
use App\Http\Controllers\RatingController; use App\Http\Controllers\RatingController;
use App\Http\Controllers\ReviewController; 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::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}/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}/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}/response', [ReviewController::class, 'respond'])->name('reviews.respond');
Route::post('/reviews/{review}/report', [ReviewController::class, 'report'])->name('reviews.report'); Route::post('/reviews/{review}/report', [ReviewController::class, 'report'])->name('reviews.report');
Route::patch('/reviews/{review}/moderation', [ReviewController::class, 'moderate'])->name('reviews.moderate'); Route::patch('/reviews/{review}/moderation', [ReviewController::class, 'moderate'])->name('reviews.moderate');
+87
View File
@@ -525,6 +525,93 @@ class ExchangeWorkflowTest extends TestCase
->assertSee('Field troubleshooter'); ->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 public function test_buyer_directory_can_filter_and_sort_profiles(): void
{ {
$retailBuyer = $this->userWithRole('buyer'); $retailBuyer = $this->userWithRole('buyer');