From 5009269907deb224ae7f0b8ab37176226cc9368f Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 14:14:23 -0500 Subject: [PATCH] Phase 6 and 7 scope contact safeguards --- CHANGELOG.md | 2 + app/Http/Controllers/DisputeController.php | 1 + .../Controllers/DisputeVoteController.php | 1 + app/Http/Controllers/JobPostController.php | 45 +++++- app/Http/Controllers/QuoteController.php | 4 + app/Http/Controllers/WorkOrderController.php | 74 ++++++++-- app/Models/Dispute.php | 14 +- app/Models/DisputeVote.php | 2 +- app/Models/JobPost.php | 125 ++++++++++++++++- app/Models/WorkOrder.php | 64 ++++++++- app/Models/WorkOrderChangeRequest.php | 58 ++++++++ app/Models/WorkOrderContactEvent.php | 43 ++++++ ...31000_add_scope_and_contact_safeguards.php | 119 ++++++++++++++++ ...reate_work_order_change_requests_table.php | 35 +++++ ...create_work_order_contact_events_table.php | 30 ++++ docs/DEPLOYMENT.md | 7 +- docs/FORWARD_PHASE_PLAN.md | 52 ++++--- docs/INSTALL.md | 2 +- docs/ROADMAP.md | 22 ++- resources/views/disputes/show.blade.php | 12 ++ resources/views/jobs/create.blade.php | 95 +++++++++++-- resources/views/jobs/index.blade.php | 19 ++- resources/views/jobs/show.blade.php | 72 +++++++++- resources/views/work-orders/print.blade.php | 71 ++++++++++ resources/views/work-orders/show.blade.php | 132 +++++++++++++++++- routes/web.php | 2 + tests/Feature/ExchangeWorkflowTest.php | 115 ++++++++++++++- 27 files changed, 1158 insertions(+), 60 deletions(-) create mode 100644 app/Models/WorkOrderChangeRequest.php create mode 100644 app/Models/WorkOrderContactEvent.php create mode 100644 database/migrations/2026_07_10_231000_add_scope_and_contact_safeguards.php create mode 100644 database/migrations/2026_07_10_231100_create_work_order_change_requests_table.php create mode 100644 database/migrations/2026_07_10_231200_create_work_order_contact_events_table.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a8ace87..b1e19e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re - Added Phase 3 asset and storage management with configurable attachment disk/root/size/MIME policy, image previews, file metadata display, attachment deletion, update-script storage linking, and upload policy tests. - 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. - 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. @@ -33,6 +34,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`. - `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/DisputeController.php b/app/Http/Controllers/DisputeController.php index 9949943..428a5a2 100644 --- a/app/Http/Controllers/DisputeController.php +++ b/app/Http/Controllers/DisputeController.php @@ -30,6 +30,7 @@ class DisputeController extends Controller $data = $request->validate([ 'summary' => ['required', 'string', 'max:255'], + 'reason_code' => ['nullable', 'string', 'in:'.implode(',', array_keys(Dispute::REASON_CODES))], 'claim' => ['required', 'string'], 'evidence_notes' => ['nullable', 'string'], ]); diff --git a/app/Http/Controllers/DisputeVoteController.php b/app/Http/Controllers/DisputeVoteController.php index 28db65b..3e3d190 100644 --- a/app/Http/Controllers/DisputeVoteController.php +++ b/app/Http/Controllers/DisputeVoteController.php @@ -14,6 +14,7 @@ class DisputeVoteController extends Controller $data = $request->validate([ 'recommendation' => ['required', 'string', 'in:provider,buyer,split,insufficient_evidence'], + 'reason_code' => ['nullable', 'string', 'in:'.implode(',', array_keys(Dispute::REASON_CODES))], 'reason' => ['nullable', 'string', 'max:2000'], ]); diff --git a/app/Http/Controllers/JobPostController.php b/app/Http/Controllers/JobPostController.php index a957429..d455eff 100644 --- a/app/Http/Controllers/JobPostController.php +++ b/app/Http/Controllers/JobPostController.php @@ -48,16 +48,59 @@ class JobPostController extends Controller 'location' => ['required', 'string', 'max:255'], 'starts_at' => ['nullable', 'date'], 'time_window' => ['nullable', 'string', 'max:255'], + 'schedule_type' => ['nullable', 'string', 'max:255'], + 'remote_eligible' => ['nullable', 'boolean'], 'scope' => ['required', 'string'], + 'primary_objective' => ['nullable', 'string'], + 'included_work' => ['nullable', 'string'], + 'excluded_work' => ['nullable', 'string'], + 'maximum_onsite_expectations' => ['nullable', 'string'], + 'expected_duration' => ['nullable', 'string', 'max:255'], 'required_skills' => ['nullable', 'string'], 'required_tools' => ['nullable', 'string'], + 'required_certifications' => ['nullable', 'string'], + 'required_safety_gear' => ['nullable', 'string'], 'deliverables' => ['nullable', 'string'], + 'closeout_conditions' => ['nullable', 'string'], + 'buyer_provided_equipment' => ['nullable', 'string'], + 'provider_provided_equipment' => ['nullable', 'string'], + 'return_shipment_expectations' => ['nullable', 'string'], + 'parking_access_notes' => ['nullable', 'string'], + 'onsite_restrictions' => ['nullable', 'string'], + 'supplemental_instructions' => ['nullable', 'string'], 'payment_terms' => ['nullable', 'string'], 'vendor_onboarding' => ['nullable', 'string'], + 'primary_contact_name' => ['nullable', 'string', 'max:255'], + 'primary_contact_phone' => ['nullable', 'string', 'max:255'], + 'primary_contact_email' => ['nullable', 'email', 'max:255'], + 'backup_contact_name' => ['nullable', 'string', 'max:255'], + 'backup_contact_phone' => ['nullable', 'string', 'max:255'], + 'backup_contact_email' => ['nullable', 'email', 'max:255'], + 'dispatch_contact_name' => ['nullable', 'string', 'max:255'], + 'dispatch_contact_phone' => ['nullable', 'string', 'max:255'], + 'dispatch_contact_email' => ['nullable', 'email', 'max:255'], + 'technical_bridge' => ['nullable', 'string', 'max:255'], + 'escalation_contact' => ['nullable', 'string', 'max:255'], + 'support_channel' => ['nullable', 'string', 'max:255'], + 'support_expected_response_time' => ['nullable', 'string', 'max:255'], + 'support_availability_window' => ['nullable', 'string', 'max:255'], + 'contact_certified' => ['nullable', 'accepted'], 'visibility' => ['required', 'string', 'in:public,members'], ]); - $job = $request->user()->jobPosts()->create($data); + $job = new JobPost($data); + $job->buyer_id = $request->user()->id; + $job->remote_eligible = $request->boolean('remote_eligible'); + $job->contact_certified = $request->boolean('contact_certified'); + + if ($job->contact_certified) { + $job->contact_certified_by_id = $request->user()->id; + $job->contact_certified_at = now(); + } + + $job->risk_flags = $job->computeRiskFlags(); + $job->scope_clarity_status = $job->computedScopeClarityStatus(); + $job->save(); return redirect()->route('jobs.show', $job)->with('status', 'Job posted.'); } diff --git a/app/Http/Controllers/QuoteController.php b/app/Http/Controllers/QuoteController.php index 8fb1e86..7d2fc36 100644 --- a/app/Http/Controllers/QuoteController.php +++ b/app/Http/Controllers/QuoteController.php @@ -123,6 +123,10 @@ class QuoteController extends Controller 'deliverables_checklist' => $quote->jobPost->deliverables, 'scheduled_at' => $quote->jobPost->starts_at, 'appointment_window' => $quote->jobPost->time_window, + 'scope_snapshot' => $quote->jobPost->scopeSnapshot(), + 'contact_snapshot' => $quote->jobPost->contactSnapshot(), + 'scope_clarity_status' => $quote->jobPost->scope_clarity_status, + 'risk_flags' => $quote->jobPost->risk_flags, 'checklist_items' => collect(preg_split('/\r\n|\r|\n/', (string) $quote->jobPost->deliverables)) ->map(fn (string $item): string => trim($item, " \t\n\r\0\x0B-[]")) ->filter() diff --git a/app/Http/Controllers/WorkOrderController.php b/app/Http/Controllers/WorkOrderController.php index 473cdaa..9be0136 100644 --- a/app/Http/Controllers/WorkOrderController.php +++ b/app/Http/Controllers/WorkOrderController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers; use App\Models\WorkOrder; +use App\Models\WorkOrderChangeRequest; +use App\Models\WorkOrderContactEvent; use App\Notifications\ExchangeEventNotification; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -34,6 +36,8 @@ class WorkOrderController extends Controller 'buyer.buyerProfile', 'provider.providerProfile', 'acceptedQuote', + 'structuredChangeRequests.requester', + 'contactEvents.user', 'reviews.reviewer', 'reviews.reviewee', 'disputes.openedBy', @@ -54,6 +58,8 @@ class WorkOrderController extends Controller 'buyer.buyerProfile', 'provider.providerProfile', 'acceptedQuote', + 'structuredChangeRequests.requester', + 'contactEvents.user', 'attachments', ), ]); @@ -162,19 +168,22 @@ class WorkOrderController extends Controller $data = $request->validate([ 'summary' => ['required', 'string', 'max:255'], 'details' => ['nullable', 'string'], + 'reason_code' => ['required', 'string', 'in:'.implode(',', array_keys(WorkOrderChangeRequest::REASON_CODES))], + 'scope_impact' => ['nullable', 'string'], + 'schedule_impact' => ['nullable', 'string'], + 'terms_impact' => ['nullable', 'string'], ]); - $requests = $workOrder->changeRequests(); - $requests[] = [ + $changeRequest = $workOrder->structuredChangeRequests()->create([ 'summary' => $data['summary'], 'details' => $data['details'] ?? null, - 'requested_by_id' => $request->user()->id, - 'requested_by_name' => $request->user()->name, + 'reason_code' => $data['reason_code'], + 'scope_impact' => $data['scope_impact'] ?? null, + 'schedule_impact' => $data['schedule_impact'] ?? null, + 'terms_impact' => $data['terms_impact'] ?? null, + 'requester_id' => $request->user()->id, 'status' => 'open', - 'requested_at' => now()->toIso8601String(), - ]; - - $workOrder->update(['change_requests' => $requests]); + ]); $recipient = $request->user()->id === $workOrder->buyer_id ? $workOrder->provider @@ -190,6 +199,55 @@ class WorkOrderController extends Controller return redirect()->route('work-orders.show', $workOrder)->with('status', 'Change request recorded.'); } + public function resolveChangeRequest(Request $request, WorkOrder $workOrder, WorkOrderChangeRequest $changeRequest): RedirectResponse + { + $this->authorizeParticipant($request, $workOrder); + abort_unless($changeRequest->work_order_id === $workOrder->id, 404); + abort_unless($request->user()->id !== $changeRequest->requester_id || $request->user()->hasRole('admin'), 403); + + $data = $request->validate([ + 'status' => ['required', 'string', 'in:accepted,declined,withdrawn'], + 'resolution_notes' => ['nullable', 'string'], + ]); + + $changeRequest->update([ + 'status' => $data['status'], + 'resolution_notes' => $data['resolution_notes'] ?? null, + 'responder_id' => $request->user()->id, + 'responded_at' => now(), + ]); + + return redirect()->route('work-orders.show', $workOrder)->with('status', 'Change request updated.'); + } + + public function logContactEvent(Request $request, WorkOrder $workOrder): RedirectResponse + { + $this->authorizeParticipant($request, $workOrder); + abort_unless($request->user()->id === $workOrder->provider_id || $request->user()->hasRole('admin'), 403); + + $data = $request->validate([ + 'event_type' => ['required', 'string', 'in:'.implode(',', array_keys(WorkOrderContactEvent::EVENT_TYPES))], + 'attempted_channel' => ['nullable', 'string', 'max:255'], + 'attempted_at' => ['nullable', 'date'], + 'result' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string'], + ]); + + $event = $workOrder->contactEvents()->create($data + [ + 'user_id' => $request->user()->id, + 'attempted_at' => $data['attempted_at'] ?? now(), + ]); + + $workOrder->buyer->notify(new ExchangeEventNotification( + WorkOrderContactEvent::EVENT_TYPES[$event->event_type] ?? 'Contact event logged', + $request->user()->name.' logged '.str_replace('_', ' ', $event->event_type).' on '.$workOrder->jobPost->title.'.', + route('work-orders.show', $workOrder), + 'contact_event' + )); + + return redirect()->route('work-orders.show', $workOrder)->with('status', 'Contact/support event logged.'); + } + private function authorizeParticipant(Request $request, WorkOrder $workOrder): void { abort_unless( diff --git a/app/Models/Dispute.php b/app/Models/Dispute.php index 1f19dbf..406a713 100644 --- a/app/Models/Dispute.php +++ b/app/Models/Dispute.php @@ -9,11 +9,23 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Model; #[Fillable([ - 'work_order_id', 'opened_by_id', 'status', 'summary', 'claim', 'response', + 'work_order_id', 'opened_by_id', 'status', 'summary', 'reason_code', 'claim', 'response', 'evidence_notes', 'recommended_resolution', 'peer_votes', 'resolved_at', ])] class Dispute extends Model { + public const REASON_CODES = [ + 'scope_expansion' => 'Scope expansion', + 'unreachable_contact' => 'Unreachable contact', + 'support_unavailable' => 'Support unavailable', + 'missing_deliverable' => 'Missing deliverable', + 'payment_issue' => 'Payment issue', + 'schedule_issue' => 'Schedule issue', + 'quality_issue' => 'Quality issue', + 'insufficient_evidence' => 'Insufficient evidence', + 'other' => 'Other', + ]; + protected function casts(): array { return [ diff --git a/app/Models/DisputeVote.php b/app/Models/DisputeVote.php index 9b91f89..34e1661 100644 --- a/app/Models/DisputeVote.php +++ b/app/Models/DisputeVote.php @@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Model; -#[Fillable(['dispute_id', 'user_id', 'recommendation', 'reason'])] +#[Fillable(['dispute_id', 'user_id', 'recommendation', 'reason_code', 'reason'])] class DisputeVote extends Model { public function dispute(): BelongsTo diff --git a/app/Models/JobPost.php b/app/Models/JobPost.php index 8b9958b..e837b24 100644 --- a/app/Models/JobPost.php +++ b/app/Models/JobPost.php @@ -11,15 +11,37 @@ use Illuminate\Database\Eloquent\Model; #[Fillable([ 'buyer_id', 'title', 'status', 'service_category', 'location', 'starts_at', - 'time_window', 'scope', 'required_skills', 'required_tools', 'deliverables', - 'payment_terms', 'vendor_onboarding', 'visibility', + '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', + 'provider_provided_equipment', 'return_shipment_expectations', 'parking_access_notes', + 'onsite_restrictions', 'supplemental_instructions', 'scope_clarity_status', + 'risk_flags', 'payment_terms', 'vendor_onboarding', 'primary_contact_name', + 'primary_contact_phone', 'primary_contact_email', 'backup_contact_name', + 'backup_contact_phone', 'backup_contact_email', 'dispatch_contact_name', + 'dispatch_contact_phone', 'dispatch_contact_email', 'technical_bridge', + 'escalation_contact', 'support_channel', 'support_expected_response_time', + 'support_availability_window', 'contact_certified', 'contact_certified_by_id', + 'contact_certified_at', 'visibility', ])] class JobPost extends Model { + public const SCOPE_CLARITY_STATUSES = [ + 'clear' => 'Clear', + 'needs_review' => 'Needs review', + 'broad_scope' => 'Broad scope', + 'missing_support' => 'Missing support', + ]; + protected function casts(): array { return [ 'starts_at' => 'datetime', + 'remote_eligible' => 'boolean', + 'risk_flags' => 'array', + 'contact_certified' => 'boolean', + 'contact_certified_at' => 'datetime', ]; } @@ -52,4 +74,103 @@ class JobPost extends Model { return $this->morphMany(Rating::class, 'rateable'); } + + public function scopeSnapshot(): array + { + return [ + 'primary_objective' => $this->primary_objective, + 'included_work' => $this->included_work, + 'excluded_work' => $this->excluded_work, + 'maximum_onsite_expectations' => $this->maximum_onsite_expectations, + 'expected_duration' => $this->expected_duration, + 'required_skills' => $this->required_skills, + 'required_tools' => $this->required_tools, + 'required_certifications' => $this->required_certifications, + 'required_safety_gear' => $this->required_safety_gear, + 'deliverables' => $this->deliverables, + 'closeout_conditions' => $this->closeout_conditions, + 'buyer_provided_equipment' => $this->buyer_provided_equipment, + 'provider_provided_equipment' => $this->provider_provided_equipment, + 'return_shipment_expectations' => $this->return_shipment_expectations, + 'parking_access_notes' => $this->parking_access_notes, + 'onsite_restrictions' => $this->onsite_restrictions, + 'supplemental_instructions' => $this->supplemental_instructions, + ]; + } + + public function contactSnapshot(): array + { + return [ + 'primary_contact_name' => $this->primary_contact_name, + 'primary_contact_phone' => $this->primary_contact_phone, + 'primary_contact_email' => $this->primary_contact_email, + 'backup_contact_name' => $this->backup_contact_name, + 'backup_contact_phone' => $this->backup_contact_phone, + 'backup_contact_email' => $this->backup_contact_email, + 'dispatch_contact_name' => $this->dispatch_contact_name, + 'dispatch_contact_phone' => $this->dispatch_contact_phone, + 'dispatch_contact_email' => $this->dispatch_contact_email, + 'technical_bridge' => $this->technical_bridge, + 'escalation_contact' => $this->escalation_contact, + 'support_channel' => $this->support_channel, + 'support_expected_response_time' => $this->support_expected_response_time, + 'support_availability_window' => $this->support_availability_window, + 'contact_certified' => $this->contact_certified, + 'contact_certified_at' => $this->contact_certified_at?->toIso8601String(), + ]; + } + + public function computeRiskFlags(): array + { + $flags = []; + + if (! $this->primary_objective || ! $this->included_work || ! $this->excluded_work) { + $flags[] = 'missing_scope_boundaries'; + } + + if (! $this->deliverables || ! $this->closeout_conditions) { + $flags[] = 'unclear_closeout'; + } + + if (! $this->backup_contact_phone && ! $this->backup_contact_email) { + $flags[] = 'missing_contact_backup'; + } + + if (! $this->contact_certified) { + $flags[] = 'support_not_certified'; + } + + if (strlen((string) $this->supplemental_instructions) > 2000) { + $flags[] = 'long_supplemental_instructions'; + } + + if ($this->return_shipment_expectations) { + $flags[] = 'return_shipment_required'; + } + + if ($this->starts_at && $this->starts_at->isBefore(now()->addDay())) { + $flags[] = 'compressed_schedule'; + } + + return array_values(array_unique($flags)); + } + + public function computedScopeClarityStatus(): string + { + $flags = $this->computeRiskFlags(); + + if (in_array('missing_scope_boundaries', $flags, true) || in_array('unclear_closeout', $flags, true)) { + return 'needs_review'; + } + + if (in_array('support_not_certified', $flags, true) || in_array('missing_contact_backup', $flags, true)) { + return 'missing_support'; + } + + if (in_array('long_supplemental_instructions', $flags, true)) { + return 'broad_scope'; + } + + return 'clear'; + } } diff --git a/app/Models/WorkOrder.php b/app/Models/WorkOrder.php index d26c8ea..c6dd8b6 100644 --- a/app/Models/WorkOrder.php +++ b/app/Models/WorkOrder.php @@ -12,7 +12,8 @@ use Illuminate\Database\Eloquent\Model; 'job_post_id', 'buyer_id', 'provider_id', 'accepted_quote_id', 'status', 'status_history', 'agreed_terms', 'deliverables_checklist', 'scheduled_at', 'appointment_window', 'checklist_items', 'checklist_completed', 'required_evidence', - 'evidence_rules', 'change_requests', 'completion_notes', + 'evidence_rules', 'scope_snapshot', 'contact_snapshot', 'scope_clarity_status', + 'risk_flags', 'change_requests', 'completion_notes', 'en_route_at', 'on_site_at', 'started_at', 'completed_at', 'approved_at', 'closed_at', ])] class WorkOrder extends Model @@ -42,6 +43,9 @@ class WorkOrder extends Model 'checklist_items' => 'array', 'checklist_completed' => 'array', 'evidence_rules' => 'array', + 'scope_snapshot' => 'array', + 'contact_snapshot' => 'array', + 'risk_flags' => 'array', 'change_requests' => 'array', 'en_route_at' => 'datetime', 'on_site_at' => 'datetime', @@ -97,6 +101,16 @@ class WorkOrder extends Model return $this->morphMany(Rating::class, 'rateable'); } + public function structuredChangeRequests(): HasMany + { + return $this->hasMany(WorkOrderChangeRequest::class); + } + + public function contactEvents(): HasMany + { + return $this->hasMany(WorkOrderContactEvent::class); + } + public function canTransitionTo(string $status): bool { return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true); @@ -134,6 +148,54 @@ class WorkOrder extends Model public function changeRequests(): array { + $structured = $this->relationLoaded('structuredChangeRequests') + ? $this->structuredChangeRequests + : $this->structuredChangeRequests()->latest()->get(); + + if ($structured->isNotEmpty()) { + return $structured + ->map(fn (WorkOrderChangeRequest $request): array => [ + 'id' => $request->id, + 'summary' => $request->summary, + 'details' => $request->details, + 'reason_code' => $request->reason_code, + 'reason_label' => WorkOrderChangeRequest::REASON_CODES[$request->reason_code] ?? $request->reason_code, + 'scope_impact' => $request->scope_impact, + 'schedule_impact' => $request->schedule_impact, + 'terms_impact' => $request->terms_impact, + 'requested_by_id' => $request->requester_id, + 'requested_by_name' => $request->requester?->name, + 'status' => $request->status, + 'requested_at' => $request->created_at?->toIso8601String(), + 'resolution_notes' => $request->resolution_notes, + ]) + ->values() + ->all(); + } + return is_array($this->change_requests) ? array_values($this->change_requests) : []; } + + public function scopeSnapshotValue(string $key): ?string + { + $snapshot = is_array($this->scope_snapshot) ? $this->scope_snapshot : []; + + return $snapshot[$key] ?? $this->jobPost?->{$key}; + } + + public function contactSnapshotValue(string $key): ?string + { + $snapshot = is_array($this->contact_snapshot) ? $this->contact_snapshot : []; + + return $snapshot[$key] ?? $this->jobPost?->{$key}; + } + + public function contactFailureCount(): int + { + $events = $this->relationLoaded('contactEvents') ? $this->contactEvents : $this->contactEvents()->get(); + + return $events + ->whereIn('event_type', ['contact_failed', 'support_unavailable', 'site_contact_unavailable']) + ->count(); + } } diff --git a/app/Models/WorkOrderChangeRequest.php b/app/Models/WorkOrderChangeRequest.php new file mode 100644 index 0000000..4fe0102 --- /dev/null +++ b/app/Models/WorkOrderChangeRequest.php @@ -0,0 +1,58 @@ + 'Scope expansion', + 'missing_scope' => 'Missing or ambiguous scope', + 'schedule_change' => 'Schedule change', + 'site_condition' => 'Unexpected site condition', + 'buyer_request' => 'Buyer-requested change', + 'provider_request' => 'Provider-requested change', + 'other' => 'Other', + ]; + + public const STATUSES = ['open', 'accepted', 'declined', 'withdrawn']; + + protected function casts(): array + { + return [ + 'responded_at' => 'datetime', + ]; + } + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + public function requester(): BelongsTo + { + return $this->belongsTo(User::class, 'requester_id'); + } + + public function responder(): BelongsTo + { + return $this->belongsTo(User::class, 'responder_id'); + } +} diff --git a/app/Models/WorkOrderContactEvent.php b/app/Models/WorkOrderContactEvent.php new file mode 100644 index 0000000..240b0e5 --- /dev/null +++ b/app/Models/WorkOrderContactEvent.php @@ -0,0 +1,43 @@ + 'Contact failed', + 'support_unavailable' => 'Support unavailable', + 'site_contact_unavailable' => 'Site contact unavailable', + 'contact_reached' => 'Contact reached', + ]; + + protected function casts(): array + { + return [ + 'attempted_at' => 'datetime', + ]; + } + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/database/migrations/2026_07_10_231000_add_scope_and_contact_safeguards.php b/database/migrations/2026_07_10_231000_add_scope_and_contact_safeguards.php new file mode 100644 index 0000000..cf69f87 --- /dev/null +++ b/database/migrations/2026_07_10_231000_add_scope_and_contact_safeguards.php @@ -0,0 +1,119 @@ +text('primary_objective')->nullable()->after('scope'); + $table->text('included_work')->nullable()->after('primary_objective'); + $table->text('excluded_work')->nullable()->after('included_work'); + $table->text('maximum_onsite_expectations')->nullable()->after('excluded_work'); + $table->string('expected_duration')->nullable()->after('maximum_onsite_expectations'); + $table->string('schedule_type')->nullable()->after('time_window'); + $table->boolean('remote_eligible')->default(false)->after('schedule_type'); + $table->text('required_certifications')->nullable()->after('required_tools'); + $table->text('required_safety_gear')->nullable()->after('required_certifications'); + $table->text('closeout_conditions')->nullable()->after('deliverables'); + $table->text('buyer_provided_equipment')->nullable()->after('closeout_conditions'); + $table->text('provider_provided_equipment')->nullable()->after('buyer_provided_equipment'); + $table->text('return_shipment_expectations')->nullable()->after('provider_provided_equipment'); + $table->text('parking_access_notes')->nullable()->after('return_shipment_expectations'); + $table->text('onsite_restrictions')->nullable()->after('parking_access_notes'); + $table->text('supplemental_instructions')->nullable()->after('onsite_restrictions'); + $table->string('scope_clarity_status')->default('needs_review')->after('supplemental_instructions'); + $table->json('risk_flags')->nullable()->after('scope_clarity_status'); + $table->string('primary_contact_name')->nullable()->after('vendor_onboarding'); + $table->string('primary_contact_phone')->nullable()->after('primary_contact_name'); + $table->string('primary_contact_email')->nullable()->after('primary_contact_phone'); + $table->string('backup_contact_name')->nullable()->after('primary_contact_email'); + $table->string('backup_contact_phone')->nullable()->after('backup_contact_name'); + $table->string('backup_contact_email')->nullable()->after('backup_contact_phone'); + $table->string('dispatch_contact_name')->nullable()->after('backup_contact_email'); + $table->string('dispatch_contact_phone')->nullable()->after('dispatch_contact_name'); + $table->string('dispatch_contact_email')->nullable()->after('dispatch_contact_phone'); + $table->string('technical_bridge')->nullable()->after('dispatch_contact_email'); + $table->string('escalation_contact')->nullable()->after('technical_bridge'); + $table->string('support_channel')->nullable()->after('escalation_contact'); + $table->string('support_expected_response_time')->nullable()->after('support_channel'); + $table->string('support_availability_window')->nullable()->after('support_expected_response_time'); + $table->boolean('contact_certified')->default(false)->after('support_availability_window'); + $table->foreignId('contact_certified_by_id')->nullable()->after('contact_certified')->constrained('users')->nullOnDelete(); + $table->timestamp('contact_certified_at')->nullable()->after('contact_certified_by_id'); + }); + + Schema::table('work_orders', function (Blueprint $table) { + $table->json('scope_snapshot')->nullable()->after('required_evidence'); + $table->json('contact_snapshot')->nullable()->after('scope_snapshot'); + $table->string('scope_clarity_status')->default('needs_review')->after('contact_snapshot'); + $table->json('risk_flags')->nullable()->after('scope_clarity_status'); + }); + + Schema::table('disputes', function (Blueprint $table) { + $table->string('reason_code')->nullable()->after('summary'); + }); + + Schema::table('dispute_votes', function (Blueprint $table) { + $table->string('reason_code')->nullable()->after('recommendation'); + }); + } + + public function down(): void + { + Schema::table('dispute_votes', function (Blueprint $table) { + $table->dropColumn('reason_code'); + }); + + Schema::table('disputes', function (Blueprint $table) { + $table->dropColumn('reason_code'); + }); + + Schema::table('work_orders', function (Blueprint $table) { + $table->dropColumn(['scope_snapshot', 'contact_snapshot', 'scope_clarity_status', 'risk_flags']); + }); + + Schema::table('job_posts', function (Blueprint $table) { + $table->dropConstrainedForeignId('contact_certified_by_id'); + $table->dropColumn([ + 'primary_objective', + 'included_work', + 'excluded_work', + 'maximum_onsite_expectations', + 'expected_duration', + 'schedule_type', + 'remote_eligible', + 'required_certifications', + 'required_safety_gear', + 'closeout_conditions', + 'buyer_provided_equipment', + 'provider_provided_equipment', + 'return_shipment_expectations', + 'parking_access_notes', + 'onsite_restrictions', + 'supplemental_instructions', + 'scope_clarity_status', + 'risk_flags', + 'primary_contact_name', + 'primary_contact_phone', + 'primary_contact_email', + 'backup_contact_name', + 'backup_contact_phone', + 'backup_contact_email', + 'dispatch_contact_name', + 'dispatch_contact_phone', + 'dispatch_contact_email', + 'technical_bridge', + 'escalation_contact', + 'support_channel', + 'support_expected_response_time', + 'support_availability_window', + 'contact_certified', + 'contact_certified_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_07_10_231100_create_work_order_change_requests_table.php b/database/migrations/2026_07_10_231100_create_work_order_change_requests_table.php new file mode 100644 index 0000000..f7ca2a0 --- /dev/null +++ b/database/migrations/2026_07_10_231100_create_work_order_change_requests_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('work_order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('requester_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('responder_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('reason_code')->default('scope_expansion'); + $table->string('summary'); + $table->text('details')->nullable(); + $table->text('scope_impact')->nullable(); + $table->text('schedule_impact')->nullable(); + $table->text('terms_impact')->nullable(); + $table->string('status')->default('open'); + $table->text('resolution_notes')->nullable(); + $table->timestamp('responded_at')->nullable(); + $table->timestamps(); + + $table->index(['work_order_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_order_change_requests'); + } +}; diff --git a/database/migrations/2026_07_10_231200_create_work_order_contact_events_table.php b/database/migrations/2026_07_10_231200_create_work_order_contact_events_table.php new file mode 100644 index 0000000..99e8ab1 --- /dev/null +++ b/database/migrations/2026_07_10_231200_create_work_order_contact_events_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('work_order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('event_type')->default('contact_failed'); + $table->string('attempted_channel')->nullable(); + $table->timestamp('attempted_at')->nullable(); + $table->string('result')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['work_order_id', 'event_type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_order_contact_events'); + } +}; diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index a5f1c81..2fce660 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -18,6 +18,8 @@ This document records the explicit runtime requirements and server changes for P - `openssl` - `pdo` - `pdo_sqlite` for SQLite test deployments and the default automated test suite + - `sqlite3` for SQLite-backed local tests + - `gd` for generated image upload fixtures and image handling - `session` - `tokenizer` - `xml` @@ -37,8 +39,9 @@ This document records the explicit runtime requirements and server changes for P ## Local Test Requirements - The default PHPUnit configuration uses an in-memory SQLite database, so whichever PHP runtime executes `php artisan test` must include `pdo_sqlite` and `sqlite3`. -- On Windows, `php -m` should list both `PDO` and `pdo_sqlite`; otherwise the feature tests fail before reaching app code with `could not find driver`. -- WSL Ubuntu is acceptable for local verification when Windows PHP is missing SQLite support, as long as WSL PHP has `pdo_sqlite` and `sqlite3`. +- Upload tests also require `fileinfo` for MIME detection and `gd` for generated image fixtures. +- On Windows, `php -m` should list `PDO`, `pdo_sqlite`, `sqlite3`, `fileinfo`, and `gd`; otherwise the feature tests fail before reaching app code or upload assertions. +- WSL Ubuntu is acceptable for local verification when Windows PHP is missing extension support, as long as WSL PHP has the same extensions. ## Recommended Production Runtime diff --git a/docs/FORWARD_PHASE_PLAN.md b/docs/FORWARD_PHASE_PLAN.md index b0474ca..0164580 100644 --- a/docs/FORWARD_PHASE_PLAN.md +++ b/docs/FORWARD_PHASE_PLAN.md @@ -1,6 +1,6 @@ # Forward Phase Plan -This plan starts from the current Provider Exchange codebase as of commit `926455b` and reconciles the previous Phase 5-10 plan with the Field Nation product research captured on July 10, 2026. +This plan starts from the current Provider Exchange codebase and reconciles the previous Phase 5-10 plan with the Field Nation product research captured on July 10, 2026. The goal is not to clone Field Nation. The goal is to keep the useful operational patterns while designing against the exact provider-hostile problems we observed: opaque scoring, catch-all scope language, unreachable buyer contacts, weak filtering safeguards, and work-order ambiguity. @@ -14,9 +14,9 @@ The goal is not to clone Field Nation. The goal is to keep the useful operationa - Buyer profiles with public/private section visibility, company description, service categories, hiring regions, structured hiring policies, locations, vendor onboarding, payment terms, website, contact email, public contact toggle, private notes, attachments, and ratings. - Public provider and buyer directories with basic keyword filters, text-field filters, public-contact filter, sort by newest/name/rating, result counts, filter badges, and card views. - Community feed posts and comments. -- Buyer job posts with title, status, service category, location, start time, time window, scope, required skills, required tools, deliverables, payment terms, vendor onboarding, visibility, comments, attachments, quotes, ratings, and accepted work-order creation. +- Buyer job posts with title, status, service category, location, start time, time window, scope, structured scope fields, requirements, closeout rules, support/contact certification, risk flags, payment terms, vendor onboarding, visibility, comments, attachments, quotes, ratings, and accepted work-order creation. - Provider quote submission, quote revision history, quote decline flow, and buyer quote acceptance. -- Work orders created from accepted quotes with buyer/provider participants, status transitions, timestamps, agreed terms, checklist items, checklist completion state, evidence requirements, evidence rules, change-request JSON records, completion notes, messages, attachments, print packet, reviews, disputes, and ratings. +- Work orders created from accepted quotes with buyer/provider participants, status transitions, timestamps, agreed terms, checklist items, checklist completion state, evidence requirements, evidence rules, scope/contact snapshots, first-class change requests, contact/support events, completion notes, messages, attachments, print packet, reviews, disputes, and ratings. - Work-order status flow: assigned, en route, onsite, in progress, completed, buyer approved, disputed, closed, cancelled. - Mutual work-order reviews with five-star overall rating and category ratings for communication, scope accuracy, payment reliability, workmanship, and timeliness. - Universal ratings for provider profiles, buyer profiles, job posts, work orders, and disputes. @@ -29,13 +29,11 @@ The goal is not to clone Field Nation. The goal is to keep the useful operationa ### Current Weak Spots In Code -- Job posting still centers on a single free-form `scope` field, which allows the exact catch-all work-order behavior we want to prevent. -- Job posts do not yet have explicit fields for primary objective, included work, excluded work, maximum onsite expectation, schedule type, support/contact certification, escalation path, risk flags, or scope-clarity status. +- 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 has good sections, but contact availability, support failure logging, schedule update requests, running-late records, and report-problem records are not first-class workflow events yet. -- Change requests exist only as JSON on the work order, with no status lifecycle, approval/denial path, price/time impact, or dispute linkage. -- Disputes have votes, but reason codes are free text and do not yet include structured categories such as scope expansion, unreachable contact, missing deliverable, payment issue, or buyer support failure. +- 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. +- 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. - External profile imports store summary numbers and notes, but not structured work-category history, imported endorsement categories, imported review snapshots with privacy controls, or imported-vs-native display rules. @@ -171,6 +169,18 @@ Provider-protection rules: - Undefined scope should not become provider fault. - Dispute reasons should distinguish poor provider performance from buyer-created ambiguity. +Implementation status: + +- Completed first implementation pass. +- Added structured job scope fields and support/contact certification fields. +- Added computed scope clarity and risk flags on jobs and work orders. +- Snapshotted scope and contact commitments when a quote becomes a work order. +- Converted change requests to first-class records with reason and impact fields while preserving legacy JSON fallback. +- Added contact/support event records for contact failed, support unavailable, site contact unavailable, and contact reached. +- Added print packet visibility for scope safeguards, support contacts, change requests, and contact/support events. +- Added reason codes to disputes and peer votes. +- Verified with Windows `php artisan test`. + ## Phase 7 - Contact Accountability And Support Availability Purpose: @@ -223,6 +233,17 @@ Provider-protection rules: - If contacts fail, that should be evidence, not a buried message. - Buyer reliability should include support availability, not only payment. +Implementation status: + +- Completed first implementation pass alongside Phase 6. +- Job creation now captures primary, backup, dispatch, bridge, escalation, support channel, response time, support window, and certification. +- Work orders preserve the accepted job contact snapshot. +- Providers can log contact/support events as evidence. +- Contact issue counts show on the work-order detail. +- Print packets include contact/support coverage and logged contact events. +- Dispute reason codes include unreachable contact and support unavailable. +- Verified with Windows `php artisan test`. + ## Phase 8 - Available Work Board And Filtering System Purpose: @@ -661,16 +682,11 @@ Provider-protection rules: ## Recommended Immediate Sequence -1. Phase 5A: review category definitions, review response, edit window, and report flow. -2. Phase 5B: moderation queue for reported reviews and rating governance docs in the UI. -3. Phase 6A: structured job scope fields and anti-catch-all guardrails. -4. Phase 6B: risk badges and scope clarity indicators on job list/detail. -5. Phase 7A: contact/support certification fields on job posts and work orders. -6. Phase 7B: provider contact-failure/support-unavailable event logging. -7. Phase 8A: dense available-work board with compact filters. -8. Phase 9A: taxonomy tables and starter work-category seeds. -9. Phase 10A: guided imported-history wizard. -10. Phase 11A: mobile-safe API endpoints for onsite work-order actions. +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. ## Key Design Commitments diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 25762f4..bd914ad 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -38,7 +38,7 @@ The preferred working loop for this project is: ## Important Notes - The installer is a first-pass Ubuntu/Nginx helper, not a universal hosting panel installer. -- Local and CI test runners need PHP SQLite support: `pdo_sqlite` and `sqlite3`. +- Local and CI test runners need PHP SQLite and upload-test support: `pdo_sqlite`, `sqlite3`, `fileinfo`, and `gd`. - Public attachment serving needs `php artisan storage:link`; the checked-in update script runs this automatically. - Tune upload policy with `TSE_ATTACHMENT_DISK`, `TSE_ATTACHMENT_ROOT`, `TSE_ATTACHMENT_MAX_KB`, and `TSE_ATTACHMENT_MIME_TYPES`. - Review `.env` before production use. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c159ecf..b2fe570 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -13,6 +13,9 @@ Provider Exchange is a Laravel application for a provider-centered field-service - Quote revisions, quote decline flow, and quote acceptance. - Work-order creation from accepted quotes. - Work-order status transitions, messages, attachments, deliverables, and completion notes. +- 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. - 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. @@ -39,8 +42,8 @@ Provider Exchange is a Laravel application for a provider-centered field-service 3. Asset and storage management: configurable upload roots, file policy, attachment previews, deletion, and update-path storage link handling. 4. Work-order depth: checklist templates, required evidence rules, appointment windows, onsite timestamps, change-request records, and print/PDF export for work-order packets. 5. Reputation refinement: rating category definitions, anti-abuse controls, edit windows, report flows, and moderation views. -6. Dispute workflow: better evidence timelines, reason-coded votes, quorum/visibility rules, and admin/community moderation tools. -7. Notifications: channel preferences, email templates, and event-specific subscription controls. +6. Scope clarity and work-order safeguards: structured job scope, risk badges, anti-catch-all rules, first-class change requests, and reason-coded dispute paths. +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. @@ -50,10 +53,8 @@ Provider Exchange is a Laravel application for a provider-centered field-service ## Phase Checkpoint -Phases 1-5 have been implemented locally. Phases 1-4 have been shipped through the local -> GitHub -> ChristIT update workflow; Phase 5 is ready for the next ship/update pass. The next active planning block is: +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: -6. Dispute workflow: better evidence timelines, reason-coded votes, scope-expansion and unreachable-contact reasons, quorum/visibility rules, and admin/community moderation tools. -7. Notifications: channel preferences, email templates, and event-specific subscription controls. 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. @@ -83,6 +84,17 @@ Phase 5 implementation completed: - Review edits are limited by a configurable edit window. - Imported marketplace history is labeled separately from native TSE reputation. +Phase 6/7 implementation completed: + +- Job posts now include structured scope fields for primary objective, included work, excluded work, maximum onsite expectations, duration, requirements, closeout conditions, equipment expectations, return shipment, access notes, restrictions, and supplemental instructions. +- Supplemental instructions are explicitly treated as reference material that cannot override structured scope boundaries. +- Job cards, job detail, and work orders show scope clarity, support certification, and computed risk badges. +- Accepted quotes snapshot the job scope and contact/support commitments into the work order. +- Change requests are first-class records with reason code, scope impact, schedule impact, terms impact, status, responder, and resolution notes. +- Providers can log contact failed, support unavailable, site contact unavailable, or contact reached events with channel, time, result, and notes. +- 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. + 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/disputes/show.blade.php b/resources/views/disputes/show.blade.php index eea288f..7718e24 100644 --- a/resources/views/disputes/show.blade.php +++ b/resources/views/disputes/show.blade.php @@ -3,6 +3,9 @@

Opened by {{ $dispute->openedBy->name }} | {{ $dispute->status }}

+ @if ($dispute->reason_code) +

Reason: {{ \App\Models\Dispute::REASON_CODES[$dispute->reason_code] ?? str_replace('_', ' ', $dispute->reason_code) }}

+ @endif

Claim

{{ $dispute->claim }}

Evidence notes

@@ -38,12 +41,21 @@ + + Save vote @foreach ($dispute->votes as $vote)

{{ $vote->user->name }} | {{ str_replace('_', ' ', $vote->recommendation) }}

+ @if ($vote->reason_code) +

{{ \App\Models\Dispute::REASON_CODES[$vote->reason_code] ?? str_replace('_', ' ', $vote->reason_code) }}

+ @endif

{{ $vote->reason }}

@endforeach diff --git a/resources/views/jobs/create.blade.php b/resources/views/jobs/create.blade.php index 1b298fb..396255a 100644 --- a/resources/views/jobs/create.blade.php +++ b/resources/views/jobs/create.blade.php @@ -1,19 +1,92 @@

Post Job

-
+ @csrf - - - - - - - - - +
+ + + + + +
+ + +
+ +
+ +
+
+

Structured scope

+

These fields define the job. Supplemental instructions cannot override these boundaries.

+
+ +
+ + + + +
+ + +
+ +
+

Requirements and closeout

+
+ + + + + + + + + + + + +
+
+ +
+
+

Contact and support availability

+

Certify real support coverage for the scheduled work window before dispatch.

+
+
+ + + + + + + + + +
+
+ + + + + + +
+
+ - Post job diff --git a/resources/views/jobs/index.blade.php b/resources/views/jobs/index.blade.php index df5114c..ce79a88 100644 --- a/resources/views/jobs/index.blade.php +++ b/resources/views/jobs/index.blade.php @@ -14,8 +14,23 @@ @auth @role('buyer')Post job@endrole @endauth @foreach ($jobs as $job) -

{{ $job->title }}

-

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

+
+
+

{{ $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 }}

diff --git a/resources/views/jobs/show.blade.php b/resources/views/jobs/show.blade.php index 9a328c6..a065b1b 100644 --- a/resources/views/jobs/show.blade.php +++ b/resources/views/jobs/show.blade.php @@ -1,15 +1,81 @@

{{ $job->title }}

-
-

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

-

{{ $job->scope }}

+
+
+
+

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

+
+ Scope {{ str_replace('_', ' ', $job->scope_clarity_status) }} + @if ($job->contact_certified) + Support certified + @else + Support not certified + @endif + @if ($job->remote_eligible) + Remote eligible + @endif +
+
+
+

{{ $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') }}

+
+
+ + @if (is_array($job->risk_flags) && $job->risk_flags) +
+

Risk flags

+
+ @foreach ($job->risk_flags as $flag) + {{ str_replace('_', ' ', $flag) }} + @endforeach +
+
+ @endif + +
+

Anti-catch-all rule

+

The structured scope fields define this job. Supplemental instructions are preserved for reference, but they do not override included work, excluded work, closeout conditions, or change-request requirements.

+
+
+
Primary objective
{{ $job->primary_objective ?: $job->scope }}
+
Expected duration
{{ $job->expected_duration ?: 'Not specified' }}
+
Included work
{{ $job->included_work ?: 'Not specified' }}
+
Excluded work
{{ $job->excluded_work ?: 'Not specified' }}
+
Maximum onsite expectations
{{ $job->maximum_onsite_expectations ?: 'Not specified' }}
+
Closeout conditions
{{ $job->closeout_conditions ?: 'Not specified' }}
Required skills
{{ $job->required_skills }}
Required tools
{{ $job->required_tools }}
+
Required certifications
{{ $job->required_certifications ?: 'None listed' }}
+
Safety gear
{{ $job->required_safety_gear ?: 'None listed' }}
Deliverables
{{ $job->deliverables }}
Payment terms
{{ $job->payment_terms }}
+
Buyer-provided equipment
{{ $job->buyer_provided_equipment ?: 'Not specified' }}
+
Provider-provided equipment
{{ $job->provider_provided_equipment ?: 'Not specified' }}
+
Return shipment
{{ $job->return_shipment_expectations ?: 'Not specified' }}
+
Parking/access
{{ $job->parking_access_notes ?: 'Not specified' }}
+
Onsite restrictions
{{ $job->onsite_restrictions ?: 'Not specified' }}
+
Vendor onboarding
{{ $job->vendor_onboarding ?: 'Not specified' }}
+ @if ($job->supplemental_instructions) +
+ Supplemental instructions +

{{ $job->supplemental_instructions }}

+
+ @endif +
+

Contact and support coverage

+
+
Primary contact
{{ $job->primary_contact_name ?: 'Not set' }}
{{ $job->primary_contact_phone }} {{ $job->primary_contact_email }}
+
Backup contact
{{ $job->backup_contact_name ?: 'Not set' }}
{{ $job->backup_contact_phone }} {{ $job->backup_contact_email }}
+
Dispatch contact
{{ $job->dispatch_contact_name ?: 'Not set' }}
{{ $job->dispatch_contact_phone }} {{ $job->dispatch_contact_email }}
+
Technical bridge
{{ $job->technical_bridge ?: 'Not set' }}
+
Escalation
{{ $job->escalation_contact ?: 'Not set' }}
+
Support availability
{{ $job->support_availability_window ?: 'Not set' }}
{{ $job->support_channel }} {{ $job->support_expected_response_time }}
+
+
diff --git a/resources/views/work-orders/print.blade.php b/resources/views/work-orders/print.blade.php index 84e6105..d7be547 100644 --- a/resources/views/work-orders/print.blade.php +++ b/resources/views/work-orders/print.blade.php @@ -39,6 +39,55 @@

{{ $workOrder->scheduled_at?->format('M j, Y g:i A') ?? 'Not set' }} | {{ $workOrder->appointment_window ?: 'No window set' }}

+
+

Scope Safeguards

+

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

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

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

+ @endif +
+
+

Primary Objective

+

{!! nl2br(e($workOrder->scopeSnapshotValue('primary_objective') ?: $workOrder->jobPost->scope)) !!}

+
+
+

Included Work

+

{!! nl2br(e($workOrder->scopeSnapshotValue('included_work') ?: 'Not specified')) !!}

+
+
+

Excluded Work

+

{!! nl2br(e($workOrder->scopeSnapshotValue('excluded_work') ?: 'Not specified')) !!}

+
+
+

Closeout Conditions

+

{!! nl2br(e($workOrder->scopeSnapshotValue('closeout_conditions') ?: 'Not specified')) !!}

+
+
+
+ +
+

Contact And Support

+

Certified: {{ $workOrder->contactSnapshotValue('contact_certified') ? 'Yes' : 'No' }}

+
+
+

Primary

+

{{ $workOrder->contactSnapshotValue('primary_contact_name') ?: 'Not set' }}
{{ $workOrder->contactSnapshotValue('primary_contact_phone') }} {{ $workOrder->contactSnapshotValue('primary_contact_email') }}

+
+
+

Backup

+

{{ $workOrder->contactSnapshotValue('backup_contact_name') ?: 'Not set' }}
{{ $workOrder->contactSnapshotValue('backup_contact_phone') }} {{ $workOrder->contactSnapshotValue('backup_contact_email') }}

+
+
+

Dispatch

+

{{ $workOrder->contactSnapshotValue('dispatch_contact_name') ?: 'Not set' }}
{{ $workOrder->contactSnapshotValue('dispatch_contact_phone') }} {{ $workOrder->contactSnapshotValue('dispatch_contact_email') }}

+
+
+

Escalation

+

{{ $workOrder->contactSnapshotValue('escalation_contact') ?: 'Not set' }}
{{ $workOrder->contactSnapshotValue('support_channel') }} {{ $workOrder->contactSnapshotValue('support_expected_response_time') }}

+
+
+
+

Agreed Terms

{!! nl2br(e($workOrder->agreed_terms ?: 'No agreed terms recorded.')) !!}

@@ -67,6 +116,28 @@ @endif
+
+

Change Requests

+
    + @forelse ($workOrder->changeRequests() as $changeRequest) +
  • {{ $changeRequest['summary'] }} | {{ $changeRequest['reason_label'] ?? 'Change' }} | {{ $changeRequest['status'] ?? 'open' }}
  • + @empty +
  • No change requests recorded.
  • + @endforelse +
+
+ +
+

Contact / Support Events

+
    + @forelse ($workOrder->contactEvents as $event) +
  • {{ \App\Models\WorkOrderContactEvent::EVENT_TYPES[$event->event_type] ?? str_replace('_', ' ', $event->event_type) }} | {{ $event->attempted_at?->format('M j, Y g:i A') ?? $event->created_at->format('M j, Y g:i A') }} | {{ $event->result ?: 'No result' }}
  • + @empty +
  • No contact or support failures recorded.
  • + @endforelse +
+
+

Attachments

    diff --git a/resources/views/work-orders/show.blade.php b/resources/views/work-orders/show.blade.php index 58ca9fa..d96cdd3 100644 --- a/resources/views/work-orders/show.blade.php +++ b/resources/views/work-orders/show.blade.php @@ -2,6 +2,10 @@ $progress = $workOrder->checklistProgress(); $completed = $workOrder->checklistCompleted(); $evidenceRules = is_array($workOrder->evidence_rules) ? $workOrder->evidence_rules : []; + $riskFlags = is_array($workOrder->risk_flags) ? $workOrder->risk_flags : []; + $changeReasonCodes = \App\Models\WorkOrderChangeRequest::REASON_CODES; + $contactEventTypes = \App\Models\WorkOrderContactEvent::EVENT_TYPES; + $disputeReasonCodes = \App\Models\Dispute::REASON_CODES; @endphp @@ -34,6 +38,7 @@ +
@@ -55,6 +60,49 @@
+
+
+
+

Scope and support safeguards

+

Structured scope governs the work order. Supplemental instructions cannot create undefined onsite obligations.

+
+
+ Scope {{ str_replace('_', ' ', $workOrder->scope_clarity_status) }} + @if ($workOrder->contactSnapshotValue('contact_certified')) + Support certified + @else + Support not certified + @endif +
+
+ @if ($riskFlags) +
+ @foreach ($riskFlags as $flag) + {{ str_replace('_', ' ', $flag) }} + @endforeach +
+ @endif +
+
Primary objective
{{ $workOrder->scopeSnapshotValue('primary_objective') ?: $workOrder->jobPost->scope }}
+
Expected duration
{{ $workOrder->scopeSnapshotValue('expected_duration') ?: 'Not specified' }}
+
Included work
{{ $workOrder->scopeSnapshotValue('included_work') ?: 'Not specified' }}
+
Excluded work
{{ $workOrder->scopeSnapshotValue('excluded_work') ?: 'Not specified' }}
+
Maximum onsite expectations
{{ $workOrder->scopeSnapshotValue('maximum_onsite_expectations') ?: 'Not specified' }}
+
Closeout conditions
{{ $workOrder->scopeSnapshotValue('closeout_conditions') ?: 'Not specified' }}
+
+
+

Certified contact path

+
+
Primary
{{ $workOrder->contactSnapshotValue('primary_contact_name') ?: 'Not set' }}
{{ $workOrder->contactSnapshotValue('primary_contact_phone') }} {{ $workOrder->contactSnapshotValue('primary_contact_email') }}
+
Backup
{{ $workOrder->contactSnapshotValue('backup_contact_name') ?: 'Not set' }}
{{ $workOrder->contactSnapshotValue('backup_contact_phone') }} {{ $workOrder->contactSnapshotValue('backup_contact_email') }}
+
Dispatch
{{ $workOrder->contactSnapshotValue('dispatch_contact_name') ?: 'Not set' }}
{{ $workOrder->contactSnapshotValue('dispatch_contact_phone') }} {{ $workOrder->contactSnapshotValue('dispatch_contact_email') }}
+
Technical bridge
{{ $workOrder->contactSnapshotValue('technical_bridge') ?: 'Not set' }}
+
Escalation
{{ $workOrder->contactSnapshotValue('escalation_contact') ?: 'Not set' }}
+
Support window
{{ $workOrder->contactSnapshotValue('support_availability_window') ?: 'Not set' }}
{{ $workOrder->contactSnapshotValue('support_channel') }} {{ $workOrder->contactSnapshotValue('support_expected_response_time') }}
+
+
+
+ @if (auth()->id() === $workOrder->buyer_id || auth()->user()->hasRole('admin'))

Work-order setup

@@ -129,18 +177,49 @@

Change requests

@csrf +
+ + +
+
+ + + +
Record change request
@forelse ($workOrder->changeRequests() as $requestRecord)

{{ $requestRecord['summary'] }}

-

{{ $requestRecord['requested_by_name'] ?? 'User' }} | {{ $requestRecord['status'] ?? 'open' }} | {{ $requestRecord['requested_at'] ?? '' }}

+

{{ $requestRecord['requested_by_name'] ?? 'User' }} | {{ $requestRecord['reason_label'] ?? 'Change' }} | {{ $requestRecord['status'] ?? 'open' }} | {{ $requestRecord['requested_at'] ?? '' }}

@if (! empty($requestRecord['details']))

{{ $requestRecord['details'] }}

@endif +
+

Scope: {{ $requestRecord['scope_impact'] ?? 'Not specified' }}

+

Schedule: {{ $requestRecord['schedule_impact'] ?? 'Not specified' }}

+

Terms: {{ $requestRecord['terms_impact'] ?? 'Not specified' }}

+
+ @if (! empty($requestRecord['id']) && ($requestRecord['status'] ?? 'open') === 'open' && (auth()->id() !== ($requestRecord['requested_by_id'] ?? null) || auth()->user()->hasRole('admin'))) +
+ @csrf + @method('PATCH') + + + Update +
+ @endif
@empty

No change requests yet.

@@ -148,6 +227,43 @@
+
+

Contact and support event log

+ @if (auth()->id() === $workOrder->provider_id || auth()->user()->hasRole('admin')) +
+ @csrf +
+
+ + +
+ + + +
+ + Log contact/support event + + @endif +
+ @forelse ($workOrder->contactEvents as $event) +
+

{{ $contactEventTypes[$event->event_type] ?? str_replace('_', ' ', $event->event_type) }}

+

{{ $event->user->name }} | {{ $event->attempted_at?->format('M j, Y g:i A') ?? $event->created_at->format('M j, Y g:i A') }} | {{ $event->attempted_channel ?: 'No channel' }} | {{ $event->result ?: 'No result' }}

+ @if ($event->notes) +

{{ $event->notes }}

+ @endif +
+ @empty +

No contact/support events have been logged.

+ @endforelse +
+
+

Messages

@@ -213,9 +329,11 @@
@foreach ($reviewFields as $field => $label) - @php($definitionKey = $fieldDefinitionKeys[$field] ?? null) + @php + $definitionKey = $fieldDefinitionKeys[$field] ?? null; + @endphp
- + @if ($definitionKey && ! empty($definitions[$definitionKey]))

{{ $definitions[$definitionKey] }}

@endif @@ -308,6 +426,14 @@ @csrf +
+ + +
Open dispute diff --git a/routes/web.php b/routes/web.php index d54cf72..a68b196 100644 --- a/routes/web.php +++ b/routes/web.php @@ -81,6 +81,8 @@ Route::middleware('auth')->group(function () { Route::get('/work-orders/{workOrder}/print', [WorkOrderController::class, 'print'])->name('work-orders.print'); Route::patch('/work-orders/{workOrder}/details', [WorkOrderController::class, 'updateDetails'])->name('work-orders.details'); Route::post('/work-orders/{workOrder}/change-requests', [WorkOrderController::class, 'requestChange'])->name('work-orders.change-requests'); + Route::patch('/work-orders/{workOrder}/change-requests/{changeRequest}', [WorkOrderController::class, 'resolveChangeRequest'])->name('work-orders.change-requests.resolve'); + Route::post('/work-orders/{workOrder}/contact-events', [WorkOrderController::class, 'logContactEvent'])->name('work-orders.contact-events.store'); 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'); diff --git a/tests/Feature/ExchangeWorkflowTest.php b/tests/Feature/ExchangeWorkflowTest.php index 2a2e737..0a30e3c 100644 --- a/tests/Feature/ExchangeWorkflowTest.php +++ b/tests/Feature/ExchangeWorkflowTest.php @@ -206,11 +206,19 @@ class ExchangeWorkflowTest extends TestCase $this->assertFalse($workOrder->checklist_completed['Cable test']); $this->actingAs($provider)->post("/work-orders/{$workOrder->id}/change-requests", [ + 'reason_code' => 'scope_expansion', 'summary' => 'Add second terminal', 'details' => 'Buyer requested another POS terminal onsite.', + 'scope_impact' => 'Install and test one additional terminal.', + 'schedule_impact' => 'Adds one hour.', + 'terms_impact' => 'Requires added labor approval.', ])->assertRedirect("/work-orders/{$workOrder->id}"); - $this->assertDatabaseHas('work_orders', ['id' => $workOrder->id]); + $this->assertDatabaseHas('work_order_change_requests', [ + 'work_order_id' => $workOrder->id, + 'reason_code' => 'scope_expansion', + 'summary' => 'Add second terminal', + ]); $this->assertSame('Add second terminal', $workOrder->fresh()->changeRequests()[0]['summary']); $this->actingAs($buyer)->get("/work-orders/{$workOrder->id}/print") @@ -219,6 +227,111 @@ class ExchangeWorkflowTest extends TestCase ->assertSee('Print'); } + public function test_scope_and_contact_safeguards_flow_into_work_orders(): void + { + $provider = $this->userWithRole('provider'); + $buyer = $this->userWithRole('buyer'); + + $this->actingAs($buyer)->post('/jobs', [ + 'title' => 'Replace POS lane router', + 'service_category' => 'Point of Sale', + 'location' => 'Tulsa, OK', + 'starts_at' => now()->addDays(3)->format('Y-m-d H:i:s'), + 'time_window' => '9 AM - 12 PM', + 'schedule_type' => 'flex_window', + 'scope' => 'Replace the router and confirm lane connectivity.', + 'primary_objective' => 'Restore POS lane network connectivity.', + 'included_work' => 'Replace router, connect cables, verify connectivity.', + 'excluded_work' => 'No ceiling cabling or electrical work.', + 'maximum_onsite_expectations' => 'One router replacement and basic test.', + 'expected_duration' => 'Two hours', + 'required_skills' => 'POS networking', + 'required_tools' => 'Laptop and cable tester', + 'deliverables' => "Arrival photo\nCompletion photo", + 'closeout_conditions' => 'Buyer validates lanes online before closeout.', + 'payment_terms' => 'Direct ACH', + 'primary_contact_name' => 'Store Manager', + 'primary_contact_phone' => '555-0100', + 'backup_contact_name' => 'Dispatch', + 'backup_contact_phone' => '555-0101', + 'support_channel' => 'Phone', + 'support_expected_response_time' => '15 minutes', + 'support_availability_window' => 'Full appointment window', + 'contact_certified' => '1', + 'visibility' => 'public', + ])->assertRedirect(); + + $job = JobPost::where('title', 'Replace POS lane router')->firstOrFail(); + + $this->assertSame('clear', $job->scope_clarity_status); + $this->assertTrue($job->contact_certified); + $this->assertSame([], $job->risk_flags); + + $this->actingAs($provider)->post("/jobs/{$job->id}/quotes", [ + 'requested_amount' => 250, + 'rate_summary' => 'Flat project rate', + 'terms' => 'Scope exactly as posted.', + ])->assertRedirect("/jobs/{$job->id}"); + + $quote = $job->quotes()->firstOrFail(); + + $this->actingAs($buyer)->post("/quotes/{$quote->id}/accept") + ->assertRedirect(); + + $workOrder = $job->workOrder()->firstOrFail(); + + $this->assertSame('Restore POS lane network connectivity.', $workOrder->scope_snapshot['primary_objective']); + $this->assertSame('Store Manager', $workOrder->contact_snapshot['primary_contact_name']); + $this->assertSame('clear', $workOrder->scope_clarity_status); + + $this->actingAs($provider)->post("/work-orders/{$workOrder->id}/contact-events", [ + 'event_type' => 'support_unavailable', + 'attempted_channel' => 'Phone', + 'result' => 'No answer after two calls', + 'notes' => 'Support bridge did not answer during the certified window.', + ])->assertRedirect("/work-orders/{$workOrder->id}"); + + $this->assertDatabaseHas('work_order_contact_events', [ + 'work_order_id' => $workOrder->id, + 'event_type' => 'support_unavailable', + 'result' => 'No answer after two calls', + ]); + + $this->actingAs($provider)->get("/work-orders/{$workOrder->id}") + ->assertOk() + ->assertSee('Support unavailable') + ->assertSee('Restore POS lane network connectivity.'); + } + + public function test_disputes_and_votes_accept_reason_codes(): void + { + [$buyer, $provider, $workOrder] = $this->workOrderFixture(); + + $this->actingAs($provider)->post("/work-orders/{$workOrder->id}/disputes", [ + 'summary' => 'Could not reach site contact', + 'reason_code' => 'unreachable_contact', + 'claim' => 'The listed contact did not answer.', + 'evidence_notes' => 'Two calls and one text attempted.', + ])->assertRedirect(); + + $dispute = $workOrder->disputes()->firstOrFail(); + + $this->actingAs($buyer)->post("/disputes/{$dispute->id}/votes", [ + 'recommendation' => 'provider', + 'reason_code' => 'unreachable_contact', + 'reason' => 'The provider documented the contact failure.', + ])->assertRedirect("/disputes/{$dispute->id}"); + + $this->assertDatabaseHas('disputes', [ + 'id' => $dispute->id, + 'reason_code' => 'unreachable_contact', + ]); + $this->assertDatabaseHas('dispute_votes', [ + 'dispute_id' => $dispute->id, + 'reason_code' => 'unreachable_contact', + ]); + } + public function test_external_profile_snapshot_can_be_saved(): void { $provider = $this->userWithRole('provider');