From 24fd8c0c5ccfbdac16e9f664e29bf8ae26b34ea6 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 13:24:07 -0500 Subject: [PATCH] Phase 4 work order profile depth --- CHANGELOG.md | 1 + .../Controllers/BuyerProfileController.php | 28 +++ .../Controllers/ProviderProfileController.php | 30 +++ app/Http/Controllers/QuoteController.php | 8 + app/Http/Controllers/WorkOrderController.php | 101 ++++++++ app/Models/BuyerProfile.php | 22 +- app/Models/ProviderProfile.php | 23 +- app/Models/WorkOrder.php | 44 +++- ..._add_depth_fields_to_work_orders_table.php | 36 +++ ...0_192000_add_structured_profile_fields.php | 48 ++++ database/seeders/DatabaseSeeder.php | 42 ++++ docs/ROADMAP.md | 4 +- resources/views/buyers/edit.blade.php | 49 +++- resources/views/buyers/show.blade.php | 80 +++++-- resources/views/providers/edit.blade.php | 61 ++++- resources/views/providers/show.blade.php | 108 +++++++-- resources/views/work-orders/index.blade.php | 45 +++- resources/views/work-orders/print.blade.php | 81 +++++++ resources/views/work-orders/show.blade.php | 218 ++++++++++++++---- routes/web.php | 3 + tests/Feature/ExchangeWorkflowTest.php | 109 +++++++++ 21 files changed, 1031 insertions(+), 110 deletions(-) create mode 100644 database/migrations/2026_07_10_191500_add_depth_fields_to_work_orders_table.php create mode 100644 database/migrations/2026_07_10_192000_add_structured_profile_fields.php create mode 100644 resources/views/work-orders/print.blade.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 09f831f..bc7b144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re - Documented the preferred local-to-GitHub-to-remote-update workflow and the ChristIT conversion from rsync test deploy to Git checkout. - Added Phase 2 directory quality improvements with advanced provider/buyer filters, sort controls, profile badges, rating-aware cards, result counts, and empty states. - 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. - 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. diff --git a/app/Http/Controllers/BuyerProfileController.php b/app/Http/Controllers/BuyerProfileController.php index cfff1dd..f001873 100644 --- a/app/Http/Controllers/BuyerProfileController.php +++ b/app/Http/Controllers/BuyerProfileController.php @@ -71,14 +71,25 @@ class BuyerProfileController extends Controller 'description' => ['nullable', 'string'], 'service_categories' => ['nullable', 'string'], 'hiring_regions' => ['nullable', 'string'], + 'hiring_policies_text' => ['nullable', 'string'], + 'locations_text' => ['nullable', 'string'], 'vendor_onboarding' => ['nullable', 'string'], 'payment_terms' => ['nullable', 'string'], 'website_url' => ['nullable', 'url', 'max:255'], 'contact_email' => ['nullable', 'email', 'max:255'], 'public_contact' => ['nullable', 'boolean'], + 'profile_visibility' => ['nullable', 'array'], + 'profile_visibility.*' => ['nullable', 'boolean'], + 'private_notes' => ['nullable', 'string'], ]); $data['public_contact'] = $request->boolean('public_contact'); + $data['hiring_policies'] = $this->linesToRecords($data['hiring_policies_text'] ?? '', ['name', 'summary']); + $data['locations'] = $this->linesToRecords($data['locations_text'] ?? '', ['name', 'region']); + $data['profile_visibility'] = $this->normalizeVisibility($request, [ + 'description', 'categories', 'locations', 'policies', 'payment_terms', 'onboarding', + ]); + unset($data['hiring_policies_text'], $data['locations_text']); $request->user()->buyerProfile()->updateOrCreate( ['user_id' => $request->user()->id], @@ -94,4 +105,21 @@ class BuyerProfileController extends Controller 'profile' => $buyer->load('user', 'attachments', 'ratings.user'), ]); } + + private function linesToRecords(string $text, array $keys): array + { + return collect(preg_split('/\r\n|\r|\n/', $text)) + ->map(fn (string $line): array => array_pad(array_map('trim', explode('|', $line, count($keys))), count($keys), null)) + ->filter(fn (array $parts): bool => (bool) ($parts[0] ?? null)) + ->map(fn (array $parts): array => collect($keys)->mapWithKeys(fn (string $key, int $index): array => [$key => $parts[$index] ?? null])->all()) + ->values() + ->all(); + } + + private function normalizeVisibility(Request $request, array $fields): array + { + return collect($fields) + ->mapWithKeys(fn (string $field): array => [$field => $request->boolean("profile_visibility.$field")]) + ->all(); + } } diff --git a/app/Http/Controllers/ProviderProfileController.php b/app/Http/Controllers/ProviderProfileController.php index 36e6407..f122a05 100644 --- a/app/Http/Controllers/ProviderProfileController.php +++ b/app/Http/Controllers/ProviderProfileController.php @@ -71,8 +71,11 @@ class ProviderProfileController extends Controller 'bio' => ['nullable', 'string'], 'service_area' => ['nullable', 'string', 'max:255'], 'skills' => ['nullable', 'string'], + 'services_text' => ['nullable', 'string'], 'tools' => ['nullable', 'string'], + 'tool_inventory_text' => ['nullable', 'string'], 'certifications' => ['nullable', 'string'], + 'certification_records_text' => ['nullable', 'string'], 'insurance_status' => ['nullable', 'string', 'max:255'], 'rate_card' => ['nullable', 'string'], 'travel_policy' => ['nullable', 'string'], @@ -80,9 +83,19 @@ class ProviderProfileController extends Controller 'website_url' => ['nullable', 'url', 'max:255'], 'phone' => ['nullable', 'string', 'max:255'], 'public_contact' => ['nullable', 'boolean'], + 'profile_visibility' => ['nullable', 'array'], + 'profile_visibility.*' => ['nullable', 'boolean'], + 'private_notes' => ['nullable', 'string'], ]); $data['public_contact'] = $request->boolean('public_contact'); + $data['services'] = $this->linesToRecords($data['services_text'] ?? '', ['name', 'level']); + $data['tool_inventory'] = $this->linesToRecords($data['tool_inventory_text'] ?? '', ['name', 'category']); + $data['certification_records'] = $this->linesToRecords($data['certification_records_text'] ?? '', ['name', 'issuer']); + $data['profile_visibility'] = $this->normalizeVisibility($request, [ + 'bio', 'services', 'tools', 'certifications', 'rate_card', 'availability', 'imports', + ]); + unset($data['services_text'], $data['tool_inventory_text'], $data['certification_records_text']); $request->user()->providerProfile()->updateOrCreate( ['user_id' => $request->user()->id], @@ -98,4 +111,21 @@ class ProviderProfileController extends Controller 'profile' => $provider->load('user', 'externalImports.attachments', 'attachments', 'ratings.user'), ]); } + + private function linesToRecords(string $text, array $keys): array + { + return collect(preg_split('/\r\n|\r|\n/', $text)) + ->map(fn (string $line): array => array_pad(array_map('trim', explode('|', $line, count($keys))), count($keys), null)) + ->filter(fn (array $parts): bool => (bool) ($parts[0] ?? null)) + ->map(fn (array $parts): array => collect($keys)->mapWithKeys(fn (string $key, int $index): array => [$key => $parts[$index] ?? null])->all()) + ->values() + ->all(); + } + + private function normalizeVisibility(Request $request, array $fields): array + { + return collect($fields) + ->mapWithKeys(fn (string $field): array => [$field => $request->boolean("profile_visibility.$field")]) + ->all(); + } } diff --git a/app/Http/Controllers/QuoteController.php b/app/Http/Controllers/QuoteController.php index d53712f..8fb1e86 100644 --- a/app/Http/Controllers/QuoteController.php +++ b/app/Http/Controllers/QuoteController.php @@ -121,6 +121,14 @@ class QuoteController extends Controller 'status' => 'assigned', 'agreed_terms' => trim(($quote->rate_summary ?? '')."\n\n".($quote->terms ?? '')), 'deliverables_checklist' => $quote->jobPost->deliverables, + 'scheduled_at' => $quote->jobPost->starts_at, + 'appointment_window' => $quote->jobPost->time_window, + '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() + ->values() + ->all(), + 'required_evidence' => $quote->jobPost->deliverables, 'status_history' => [[ 'status' => 'assigned', 'user_id' => $request->user()->id, diff --git a/app/Http/Controllers/WorkOrderController.php b/app/Http/Controllers/WorkOrderController.php index 321899c..473cdaa 100644 --- a/app/Http/Controllers/WorkOrderController.php +++ b/app/Http/Controllers/WorkOrderController.php @@ -6,6 +6,7 @@ use App\Models\WorkOrder; use App\Notifications\ExchangeEventNotification; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\View\View; class WorkOrderController extends Controller @@ -43,6 +44,21 @@ class WorkOrderController extends Controller ]); } + public function print(Request $request, WorkOrder $workOrder): View + { + $this->authorizeParticipant($request, $workOrder); + + return view('work-orders.print', [ + 'workOrder' => $workOrder->load( + 'jobPost', + 'buyer.buyerProfile', + 'provider.providerProfile', + 'acceptedQuote', + 'attachments', + ), + ]); + } + public function transition(Request $request, WorkOrder $workOrder): RedirectResponse { $this->authorizeParticipant($request, $workOrder); @@ -50,6 +66,8 @@ class WorkOrderController extends Controller $data = $request->validate([ 'status' => ['required', 'string', 'in:'.implode(',', WorkOrder::STATUSES)], 'completion_notes' => ['nullable', 'string'], + 'checklist_completed' => ['nullable', 'array'], + 'checklist_completed.*' => ['nullable', 'boolean'], ]); $status = $data['status']; @@ -76,6 +94,10 @@ class WorkOrderController extends Controller 'status' => $status, 'status_history' => $history, 'completion_notes' => $data['completion_notes'] ?? $workOrder->completion_notes, + 'checklist_completed' => $this->normalizeChecklistCompletion( + $workOrder, + $data['checklist_completed'] ?? $workOrder->checklistCompleted() + ), ]; if (isset($timestamps[$status]) && ! $workOrder->{$timestamps[$status]}) { @@ -98,6 +120,76 @@ class WorkOrderController extends Controller return redirect()->route('work-orders.show', $workOrder)->with('status', 'Work order updated.'); } + public function updateDetails(Request $request, WorkOrder $workOrder): RedirectResponse + { + $this->authorizeParticipant($request, $workOrder); + abort_unless($request->user()->id === $workOrder->buyer_id || $request->user()->hasRole('admin'), 403); + + $data = $request->validate([ + 'scheduled_at' => ['nullable', 'date'], + 'appointment_window' => ['nullable', 'string', 'max:255'], + 'agreed_terms' => ['nullable', 'string'], + 'deliverables_checklist' => ['nullable', 'string'], + 'required_evidence' => ['nullable', 'string'], + 'evidence_rules' => ['nullable', 'array'], + 'evidence_rules.*' => ['nullable', 'string', 'max:255'], + ]); + + $checklistItems = collect(preg_split('/\r\n|\r|\n/', (string) ($data['deliverables_checklist'] ?? ''))) + ->map(fn (string $item): string => trim($item, " \t\n\r\0\x0B-[]")) + ->filter() + ->values() + ->all(); + + $workOrder->update([ + 'scheduled_at' => $data['scheduled_at'] ?? null, + 'appointment_window' => $data['appointment_window'] ?? null, + 'agreed_terms' => $data['agreed_terms'] ?? null, + 'deliverables_checklist' => $data['deliverables_checklist'] ?? null, + 'checklist_items' => $checklistItems, + 'checklist_completed' => $this->normalizeChecklistCompletion($workOrder, $workOrder->checklistCompleted(), $checklistItems), + 'required_evidence' => $data['required_evidence'] ?? null, + 'evidence_rules' => array_values(array_filter($data['evidence_rules'] ?? [])), + ]); + + return redirect()->route('work-orders.show', $workOrder)->with('status', 'Work order details updated.'); + } + + public function requestChange(Request $request, WorkOrder $workOrder): RedirectResponse + { + $this->authorizeParticipant($request, $workOrder); + + $data = $request->validate([ + 'summary' => ['required', 'string', 'max:255'], + 'details' => ['nullable', 'string'], + ]); + + $requests = $workOrder->changeRequests(); + $requests[] = [ + 'summary' => $data['summary'], + 'details' => $data['details'] ?? null, + 'requested_by_id' => $request->user()->id, + 'requested_by_name' => $request->user()->name, + 'status' => 'open', + 'requested_at' => now()->toIso8601String(), + ]; + + $workOrder->update(['change_requests' => $requests]); + + $recipient = $request->user()->id === $workOrder->buyer_id + ? $workOrder->provider + : $workOrder->buyer; + + $recipient->notify(new ExchangeEventNotification( + 'Work order change requested', + $request->user()->name.' requested a change on '.$workOrder->jobPost->title.'.', + route('work-orders.show', $workOrder), + 'work_order_change_request' + )); + + return redirect()->route('work-orders.show', $workOrder)->with('status', 'Change request recorded.'); + } + private function authorizeParticipant(Request $request, WorkOrder $workOrder): void { abort_unless( @@ -123,4 +215,13 @@ class WorkOrderController extends Controller abort_unless(in_array($userId, [$workOrder->buyer_id, $workOrder->provider_id], true) || $request->user()->hasRole('admin'), 403); } } + + private function normalizeChecklistCompletion(WorkOrder $workOrder, array $completed, ?array $items = null): array + { + $items ??= $workOrder->checklistItems(); + + return collect($items) + ->mapWithKeys(fn (string $item): array => [$item => (bool) Arr::get($completed, $item, false)]) + ->all(); + } } diff --git a/app/Models/BuyerProfile.php b/app/Models/BuyerProfile.php index c26167d..fb0f80f 100644 --- a/app/Models/BuyerProfile.php +++ b/app/Models/BuyerProfile.php @@ -9,11 +9,22 @@ use Illuminate\Database\Eloquent\Model; #[Fillable([ 'user_id', 'company_name', 'headline', 'description', 'service_categories', - 'hiring_regions', 'vendor_onboarding', 'payment_terms', 'website_url', - 'contact_email', 'public_contact', + 'hiring_regions', 'hiring_policies', 'locations', 'vendor_onboarding', + 'payment_terms', 'website_url', 'contact_email', 'public_contact', + 'profile_visibility', 'private_notes', ])] class BuyerProfile extends Model { + protected function casts(): array + { + return [ + 'hiring_policies' => 'array', + 'locations' => 'array', + 'profile_visibility' => 'array', + 'public_contact' => 'boolean', + ]; + } + public function user(): BelongsTo { return $this->belongsTo(User::class); @@ -28,4 +39,11 @@ class BuyerProfile extends Model { return $this->morphMany(Rating::class, 'rateable'); } + + public function visible(string $field): bool + { + $visibility = $this->profile_visibility ?? []; + + return (bool) ($visibility[$field] ?? true); + } } diff --git a/app/Models/ProviderProfile.php b/app/Models/ProviderProfile.php index e028358..00b7ea6 100644 --- a/app/Models/ProviderProfile.php +++ b/app/Models/ProviderProfile.php @@ -10,11 +10,23 @@ use Illuminate\Database\Eloquent\Model; #[Fillable([ 'user_id', 'business_name', 'headline', 'bio', 'service_area', 'skills', - 'tools', 'certifications', 'insurance_status', 'rate_card', 'travel_policy', - 'availability_notes', 'website_url', 'phone', 'public_contact', + 'services', 'tools', 'tool_inventory', 'certifications', 'certification_records', + 'insurance_status', 'rate_card', 'travel_policy', 'availability_notes', + 'website_url', 'phone', 'public_contact', 'profile_visibility', 'private_notes', ])] class ProviderProfile extends Model { + protected function casts(): array + { + return [ + 'services' => 'array', + 'tool_inventory' => 'array', + 'certification_records' => 'array', + 'profile_visibility' => 'array', + 'public_contact' => 'boolean', + ]; + } + public function user(): BelongsTo { return $this->belongsTo(User::class); @@ -34,4 +46,11 @@ class ProviderProfile extends Model { return $this->morphMany(Rating::class, 'rateable'); } + + public function visible(string $field): bool + { + $visibility = $this->profile_visibility ?? []; + + return (bool) ($visibility[$field] ?? true); + } } diff --git a/app/Models/WorkOrder.php b/app/Models/WorkOrder.php index 1f14ba6..d26c8ea 100644 --- a/app/Models/WorkOrder.php +++ b/app/Models/WorkOrder.php @@ -10,7 +10,9 @@ use Illuminate\Database\Eloquent\Model; #[Fillable([ 'job_post_id', 'buyer_id', 'provider_id', 'accepted_quote_id', 'status', - 'status_history', 'agreed_terms', 'deliverables_checklist', 'completion_notes', + 'status_history', 'agreed_terms', 'deliverables_checklist', 'scheduled_at', + 'appointment_window', 'checklist_items', 'checklist_completed', 'required_evidence', + 'evidence_rules', 'change_requests', 'completion_notes', 'en_route_at', 'on_site_at', 'started_at', 'completed_at', 'approved_at', 'closed_at', ])] class WorkOrder extends Model @@ -36,6 +38,11 @@ class WorkOrder extends Model { return [ 'status_history' => 'array', + 'scheduled_at' => 'datetime', + 'checklist_items' => 'array', + 'checklist_completed' => 'array', + 'evidence_rules' => 'array', + 'change_requests' => 'array', 'en_route_at' => 'datetime', 'on_site_at' => 'datetime', 'started_at' => 'datetime', @@ -94,4 +101,39 @@ class WorkOrder extends Model { return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true); } + + public function checklistItems(): array + { + if (is_array($this->checklist_items) && $this->checklist_items !== []) { + return array_values(array_filter($this->checklist_items)); + } + + return collect(preg_split('/\r\n|\r|\n/', (string) $this->deliverables_checklist)) + ->map(fn (string $item): string => trim($item, " \t\n\r\0\x0B-[]")) + ->filter() + ->values() + ->all(); + } + + public function checklistCompleted(): array + { + return is_array($this->checklist_completed) ? $this->checklist_completed : []; + } + + public function checklistProgress(): array + { + $items = $this->checklistItems(); + $completed = $this->checklistCompleted(); + $done = collect($items)->filter(fn (string $item): bool => (bool) ($completed[$item] ?? false))->count(); + + return [ + 'done' => $done, + 'total' => count($items), + ]; + } + + public function changeRequests(): array + { + return is_array($this->change_requests) ? array_values($this->change_requests) : []; + } } diff --git a/database/migrations/2026_07_10_191500_add_depth_fields_to_work_orders_table.php b/database/migrations/2026_07_10_191500_add_depth_fields_to_work_orders_table.php new file mode 100644 index 0000000..f266352 --- /dev/null +++ b/database/migrations/2026_07_10_191500_add_depth_fields_to_work_orders_table.php @@ -0,0 +1,36 @@ +timestamp('scheduled_at')->nullable()->after('deliverables_checklist'); + $table->string('appointment_window')->nullable()->after('scheduled_at'); + $table->json('checklist_items')->nullable()->after('appointment_window'); + $table->json('checklist_completed')->nullable()->after('checklist_items'); + $table->text('required_evidence')->nullable()->after('checklist_completed'); + $table->json('evidence_rules')->nullable()->after('required_evidence'); + $table->json('change_requests')->nullable()->after('evidence_rules'); + }); + } + + public function down(): void + { + Schema::table('work_orders', function (Blueprint $table) { + $table->dropColumn([ + 'scheduled_at', + 'appointment_window', + 'checklist_items', + 'checklist_completed', + 'required_evidence', + 'evidence_rules', + 'change_requests', + ]); + }); + } +}; diff --git a/database/migrations/2026_07_10_192000_add_structured_profile_fields.php b/database/migrations/2026_07_10_192000_add_structured_profile_fields.php new file mode 100644 index 0000000..a2c4386 --- /dev/null +++ b/database/migrations/2026_07_10_192000_add_structured_profile_fields.php @@ -0,0 +1,48 @@ +json('services')->nullable()->after('skills'); + $table->json('tool_inventory')->nullable()->after('tools'); + $table->json('certification_records')->nullable()->after('certifications'); + $table->json('profile_visibility')->nullable()->after('public_contact'); + $table->text('private_notes')->nullable()->after('profile_visibility'); + }); + + Schema::table('buyer_profiles', function (Blueprint $table) { + $table->json('hiring_policies')->nullable()->after('hiring_regions'); + $table->json('locations')->nullable()->after('hiring_policies'); + $table->json('profile_visibility')->nullable()->after('public_contact'); + $table->text('private_notes')->nullable()->after('profile_visibility'); + }); + } + + public function down(): void + { + Schema::table('provider_profiles', function (Blueprint $table) { + $table->dropColumn([ + 'services', + 'tool_inventory', + 'certification_records', + 'profile_visibility', + 'private_notes', + ]); + }); + + Schema::table('buyer_profiles', function (Blueprint $table) { + $table->dropColumn([ + 'hiring_policies', + 'locations', + 'profile_visibility', + 'private_notes', + ]); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 86ffa07..7e4d809 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -61,12 +61,34 @@ class DatabaseSeeder extends Seeder 'bio' => 'Demo provider profile for the technician-owned exchange.', 'service_area' => 'Central US with regional travel', 'skills' => "POS\nNetworking\nStructured cabling\nPrinter support", + 'services' => [ + ['name' => 'Smart hands dispatch', 'level' => 'experienced'], + ['name' => 'Network troubleshooting', 'level' => 'advanced'], + ['name' => 'POS installation', 'level' => 'experienced'], + ], 'tools' => "Laptop\nToner/probe\nCable tester\nBasic hand tools", + 'tool_inventory' => [ + ['name' => 'Laptop', 'category' => 'diagnostics'], + ['name' => 'Toner/probe', 'category' => 'cabling'], + ['name' => 'Cable tester', 'category' => 'network'], + ], 'certifications' => 'Low-voltage and platform-specific certifications can be listed here.', + 'certification_records' => [ + ['name' => 'Low-voltage certification placeholder', 'issuer' => 'State or authority'], + ], 'insurance_status' => 'Provider supplied', 'rate_card' => "Two-hour minimum\nStandard onsite hourly rate set by provider\nTravel negotiated per job", 'travel_policy' => 'Travel terms are negotiated directly with buyers.', 'availability_notes' => 'Weekdays and scheduled after-hours work.', + 'profile_visibility' => [ + 'bio' => true, + 'services' => true, + 'tools' => true, + 'certifications' => true, + 'rate_card' => true, + 'availability' => true, + 'imports' => true, + ], ] ); @@ -87,8 +109,23 @@ class DatabaseSeeder extends Seeder 'description' => 'Demo buyer profile showing onboarding and payment terms.', 'service_categories' => "Networking\nPOS\nPrinter support", 'hiring_regions' => 'Nationwide', + 'hiring_policies' => [ + ['name' => 'Direct vendor onboarding', 'summary' => 'Buyer handles vendor setup outside the platform.'], + ['name' => 'Clear scope required', 'summary' => 'Work orders should include deliverables and evidence requirements.'], + ], + 'locations' => [ + ['name' => 'National dispatch', 'region' => 'United States'], + ], 'vendor_onboarding' => 'Direct vendor onboarding outside the platform.', 'payment_terms' => 'Direct payment between buyer and provider.', + 'profile_visibility' => [ + 'description' => true, + 'categories' => true, + 'locations' => true, + 'policies' => true, + 'payment_terms' => true, + 'onboarding' => true, + ], ] ); @@ -135,6 +172,11 @@ class DatabaseSeeder extends Seeder 'status' => 'assigned', 'agreed_terms' => $quote->terms, 'deliverables_checklist' => $job->deliverables, + 'scheduled_at' => $job->starts_at, + 'appointment_window' => $job->time_window, + 'checklist_items' => ['Check in', 'Photos', 'Resolution notes'], + 'required_evidence' => $job->deliverables, + 'evidence_rules' => ['Arrival photo', 'Completion photo'], 'status_history' => [['status' => 'assigned', 'user_id' => $buyer->id, 'at' => now()->toIso8601String()]], ] ); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 95ec5dc..12fba9c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,7 +34,7 @@ Provider Exchange is a Laravel application for a provider-centered field-service ## Next Build Phases -1. Profile depth: richer galleries, equipment lists, certifications, insurance fields, service coverage maps, and imported review summaries. +1. Profile depth: richer provider galleries for vans, tools, job photos, certifications, insurance, and past work; buyer galleries for company identity, logos, headers, locations, and example work; equipment/tool pickers with reusable tags; certification upload/proof records with starter certification lists; service coverage maps; and imported review summaries. 2. Directory quality: saved searches, better filtering, profile badges, availability signals, and stronger buyer/provider discovery pages. 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. @@ -70,3 +70,5 @@ Provider Exchange is a Laravel application for a provider-centered field-service - Marketplace-wide dispute binding beyond voluntary community reputation. - Automated scraping of third-party platforms where authentication, terms, or privacy boundaries are unclear. - Automated competency promotion rules beyond transparent, reviewable tag and level signals. +- Work-order template libraries for buyers and providers, including reusable sample work orders that can seed future jobs. +- Broader licensed trade profiles, such as electricians, where state certification proof and trade-specific scopes can be added without hard-coding the platform around only IT technicians. diff --git a/resources/views/buyers/edit.blade.php b/resources/views/buyers/edit.blade.php index 7f91785..94d63c4 100644 --- a/resources/views/buyers/edit.blade.php +++ b/resources/views/buyers/edit.blade.php @@ -1,23 +1,58 @@ +@php + $policiesText = collect($profile?->hiring_policies ?? [])->map(fn ($row) => trim(($row['name'] ?? '').' | '.($row['summary'] ?? ''), ' |'))->implode("\n"); + $locationsText = collect($profile?->locations ?? [])->map(fn ($row) => trim(($row['name'] ?? '').' | '.($row['region'] ?? ''), ' |'))->implode("\n"); + $visibility = $profile?->profile_visibility ?? []; + $visible = fn (string $field): bool => (bool) ($visibility[$field] ?? true); +@endphp + -

Buyer Profile

-
-
- @csrf @method('PUT') + + + + +
+ + @csrf + @method('PUT') + + - + +
+

Public sections

+

Unchecked sections remain private to the buyer profile owner.

+
+ @foreach (['description' => 'Description', 'categories' => 'Categories', 'locations' => 'Locations', 'policies' => 'Hiring policies', 'payment_terms' => 'Payment terms', 'onboarding' => 'Onboarding'] as $key => $label) + + @endforeach +
+
+ + + Save buyer profile @if ($profile) -
-

Company photos and files

+
+

Company identity, logos, location photos, and sample files

+

Use captions to distinguish logo, header, office, site, or example work-order files.

diff --git a/resources/views/buyers/show.blade.php b/resources/views/buyers/show.blade.php index c802067..d8dcf83 100644 --- a/resources/views/buyers/show.blade.php +++ b/resources/views/buyers/show.blade.php @@ -1,16 +1,70 @@ -

{{ $profile->company_name }}

-
-

{{ $profile->headline }}

-

{{ $profile->description }}

-
-
Service categories
{{ $profile->service_categories }}
-
Hiring regions
{{ $profile->hiring_regions }}
-
Vendor onboarding
{{ $profile->vendor_onboarding }}
-
Payment terms
{{ $profile->payment_terms }}
-
- - - + + + + +
+
+
+ @if ($profile->public_contact) + Public contact + @endif + @if ($profile->hiring_regions) + {{ $profile->hiring_regions }} + @endif +
+ + @if ($profile->visible('description')) +

{{ $profile->description }}

+ @endif + +
+ @if ($profile->visible('categories')) +
Service categories
{{ $profile->service_categories }}
+ @endif + @if ($profile->visible('locations')) +
+
Locations
+
+ @forelse ($profile->locations ?? [] as $location) + {{ $location['name'] }}{{ ! empty($location['region']) ? ' | '.$location['region'] : '' }} + @empty + {{ $profile->hiring_regions }} + @endforelse +
+
+ @endif + @if ($profile->visible('policies')) +
+
Hiring policies
+
+ @forelse ($profile->hiring_policies ?? [] as $policy) +
+

{{ $policy['name'] }}

+ @if (! empty($policy['summary'])) +

{{ $policy['summary'] }}

+ @endif +
+ @empty + No structured policies listed. + @endforelse +
+
+ @endif + @if ($profile->visible('onboarding')) +
Vendor onboarding
{{ $profile->vendor_onboarding }}
+ @endif + @if ($profile->visible('payment_terms')) +
Payment terms
{{ $profile->payment_terms }}
+ @endif +
+ + + + +
diff --git a/resources/views/providers/edit.blade.php b/resources/views/providers/edit.blade.php index 430470d..ddfc930 100644 --- a/resources/views/providers/edit.blade.php +++ b/resources/views/providers/edit.blade.php @@ -1,33 +1,70 @@ +@php + $servicesText = collect($profile?->services ?? [])->map(fn ($row) => trim(($row['name'] ?? '').' | '.($row['level'] ?? ''), ' |'))->implode("\n"); + $toolsText = collect($profile?->tool_inventory ?? [])->map(fn ($row) => trim(($row['name'] ?? '').' | '.($row['category'] ?? ''), ' |'))->implode("\n"); + $certsText = collect($profile?->certification_records ?? [])->map(fn ($row) => trim(($row['name'] ?? '').' | '.($row['issuer'] ?? ''), ' |'))->implode("\n"); + $visibility = $profile?->profile_visibility ?? []; + $visible = fn (string $field): bool => (bool) ($visibility[$field] ?? true); +@endphp + -

Provider Profile

-
-
- @csrf @method('PUT') + + + + +
+ + @csrf + @method('PUT') - - - + + + + + + - + +
+

Public sections

+

Unchecked sections remain available to you but do not display on the public provider profile.

+
+ @foreach (['bio' => 'Bio', 'services' => 'Services', 'tools' => 'Tools', 'certifications' => 'Certifications', 'rate_card' => 'Rate card', 'availability' => 'Availability', 'imports' => 'Imported history'] as $key => $label) + + @endforeach +
+
+ + + Save provider profile @if ($profile) -
-

Profile photos and files

+
+

Profile photos, certification proof, tools, and job files

+

Use captions to distinguish van photos, tool photos, certification proof, job photos, and insurance documents.

-
+ @csrf -

External profile snapshot

+

External profile snapshot

diff --git a/resources/views/providers/show.blade.php b/resources/views/providers/show.blade.php index e374c7d..4efde5d 100644 --- a/resources/views/providers/show.blade.php +++ b/resources/views/providers/show.blade.php @@ -1,32 +1,94 @@ -

{{ $profile->business_name }}

-
-
-

{{ $profile->headline }}

-

{{ $profile->bio }}

+ + + + +
+
+
+ @if ($profile->service_area) + {{ $profile->service_area }} + @endif + @if ($profile->public_contact) + Public contact + @endif + @if ($profile->insurance_status) + {{ $profile->insurance_status }} + @endif +
+ + @if ($profile->visible('bio')) +

{{ $profile->bio }}

+ @endif +
-
Service area
{{ $profile->service_area }}
-
Insurance
{{ $profile->insurance_status }}
-
Skills
{{ $profile->skills }}
-
Tools
{{ $profile->tools }}
-
Rate card
{{ $profile->rate_card }}
-
Travel policy
{{ $profile->travel_policy }}
+ @if ($profile->visible('services')) +
+
Services
+
+ @forelse ($profile->services ?? [] as $service) + {{ $service['name'] }}{{ ! empty($service['level']) ? ' | '.$service['level'] : '' }} + @empty + {{ $profile->skills }} + @endforelse +
+
+ @endif + @if ($profile->visible('tools')) +
+
Tools
+
+ @forelse ($profile->tool_inventory ?? [] as $tool) + {{ $tool['name'] }}{{ ! empty($tool['category']) ? ' | '.$tool['category'] : '' }} + @empty + {{ $profile->tools }} + @endforelse +
+
+ @endif + @if ($profile->visible('certifications')) +
+
Certifications
+
+ @forelse ($profile->certification_records ?? [] as $certification) + {{ $certification['name'] }}{{ ! empty($certification['issuer']) ? ' | '.$certification['issuer'] : '' }} + @empty + {{ $profile->certifications }} + @endforelse +
+
+ @endif + @if ($profile->visible('rate_card')) +
Rate card
{{ $profile->rate_card }}
+ @endif + @if ($profile->visible('availability')) +
Travel policy
{{ $profile->travel_policy }}
+
Availability
{{ $profile->availability_notes }}
+ @endif
+
-
-

External profile history

- @forelse ($profile->externalImports as $import) -
-

{{ $import->platform }} {{ $import->external_id ? '#'.$import->external_id : '' }}

-

Rating: {{ $import->rating ?? 'n/a' }} | Reviews: {{ $import->review_count ?? 'n/a' }} | Completed: {{ $import->completed_jobs ?? 'n/a' }}

-

{{ $import->notes }}

-
- @empty -

No external profile snapshots yet.

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

Imported profile and review history

+ @forelse ($profile->externalImports as $import) +
+

{{ $import->platform }} {{ $import->external_id ? '#'.$import->external_id : '' }}

+

Rating: {{ $import->rating ?? 'n/a' }} | Reviews: {{ $import->review_count ?? 'n/a' }} | Completed: {{ $import->completed_jobs ?? 'n/a' }}

+

{{ $import->notes }}

+ +
+ @empty +

No external profile snapshots yet.

+ @endforelse +
+ @endif
diff --git a/resources/views/work-orders/index.blade.php b/resources/views/work-orders/index.blade.php index 4388230..f21d7c9 100644 --- a/resources/views/work-orders/index.blade.php +++ b/resources/views/work-orders/index.blade.php @@ -1,13 +1,42 @@ -

Work Orders

-
- @foreach ($orders as $order) - -

{{ $order->jobPost->title }}

-

{{ $order->status }} | Buyer: {{ $order->buyer->name }} | Provider: {{ $order->provider->name }}

- + + + + +
diff --git a/resources/views/work-orders/print.blade.php b/resources/views/work-orders/print.blade.php new file mode 100644 index 0000000..84e6105 --- /dev/null +++ b/resources/views/work-orders/print.blade.php @@ -0,0 +1,81 @@ + + + + + + Work Order {{ $workOrder->id }} - {{ $workOrder->jobPost->title }} + + + + +

{{ $workOrder->jobPost->title }}

+

Work Order #{{ $workOrder->id }} | {{ str_replace('_', ' ', $workOrder->status) }}

+ +
+
+

Buyer

+

{{ $workOrder->buyer->buyerProfile?->company_name ?? $workOrder->buyer->name }}

+
+
+

Provider

+

{{ $workOrder->provider->providerProfile?->business_name ?? $workOrder->provider->name }}

+
+
+ +
+

Schedule

+

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

+
+ +
+

Agreed Terms

+

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

+
+ +
+

Checklist

+
    + @forelse ($workOrder->checklistItems() as $item) +
  • {{ (bool) ($workOrder->checklistCompleted()[$item] ?? false) ? '[x]' : '[ ]' }} {{ $item }}
  • + @empty +
  • No checklist items recorded.
  • + @endforelse +
+
+ +
+

Required Evidence

+

{!! nl2br(e($workOrder->required_evidence ?: 'No required evidence recorded.')) !!}

+ @if (is_array($workOrder->evidence_rules) && $workOrder->evidence_rules) +
    + @foreach ($workOrder->evidence_rules as $rule) +
  • {{ $rule }}
  • + @endforeach +
+ @endif +
+ +
+

Attachments

+
    + @forelse ($workOrder->attachments as $attachment) +
  • {{ $attachment->original_name }} | {{ $attachment->kind }} | {{ $attachment->formattedSize() }}
  • + @empty +
  • No attachments recorded.
  • + @endforelse +
+
+ + diff --git a/resources/views/work-orders/show.blade.php b/resources/views/work-orders/show.blade.php index e17b3f5..885af93 100644 --- a/resources/views/work-orders/show.blade.php +++ b/resources/views/work-orders/show.blade.php @@ -1,64 +1,200 @@ +@php + $progress = $workOrder->checklistProgress(); + $completed = $workOrder->checklistCompleted(); + $evidenceRules = is_array($workOrder->evidence_rules) ? $workOrder->evidence_rules : []; +@endphp + -

Work Order: {{ $workOrder->jobPost->title }}

-
-
-

Status: {{ $workOrder->status }}

-

{{ $workOrder->agreed_terms }}

-

Deliverables

-

{{ $workOrder->deliverables_checklist }}

- - - - - - @csrf @method('PATCH') - - - + + + Print packet + + + +
+
+
+
+ {{ str_replace('_', ' ', $workOrder->status) }} +

+ Buyer: {{ $workOrder->buyer->buyerProfile?->company_name ?? $workOrder->buyer->name }} + | Provider: {{ $workOrder->provider->providerProfile?->business_name ?? $workOrder->provider->name }} +

+
+
+

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

+

Window: {{ $workOrder->appointment_window ?: 'Not set' }}

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

Agreed terms

+

{{ $workOrder->agreed_terms ?: 'No agreed terms recorded.' }}

+
+
+

Required evidence

+

{{ $workOrder->required_evidence ?: 'No specific evidence requirements recorded.' }}

+ @if ($evidenceRules) +
+ @foreach ($evidenceRules as $rule) + {{ $rule }} + @endforeach +
+ @endif +
+
+
+ + @if (auth()->id() === $workOrder->buyer_id || auth()->user()->hasRole('admin')) +
+

Work-order setup

+ + @csrf + @method('PATCH') +
+ + +
+ + + +
+ @foreach (['Arrival photo', 'Completion photo', 'Signed closeout'] as $rule) + + @endforeach +
+ Save setup + +
+ @endif + +
+

Checklist and status

+
+ @csrf + @method('PATCH') +
+
+ + +
+ +
+ +
+ @forelse ($workOrder->checklistItems() as $item) + + @empty +

No checklist items have been defined yet.

+ @endforelse +
+ Save status
-
-

Messages

+ +
+
+

Evidence and files

+

Attach photos, closeout forms, notes, screenshots, or other work evidence.

+
+ + +
+ +
+

Change requests

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

{{ $requestRecord['summary'] }}

+

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

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

{{ $requestRecord['details'] }}

+ @endif +
+ @empty +

No change requests yet.

+ @endforelse +
+
+ +
+

Messages

@csrf Send message @foreach ($workOrder->messages as $message) -
-

{{ $message->user->name }} | {{ $message->created_at->diffForHumans() }}

-

{{ $message->body }}

+
+

{{ $message->user->name }} | {{ $message->created_at->diffForHumans() }}

+

{{ $message->body }}

@endforeach
-
-

Reviews

+ +
+

Reviews

@csrf - - - - - - +
+ + + + + + +
Save review @foreach ($workOrder->reviews as $review) -
-

{{ $review->reviewer->name }} rated {{ $review->reviewee->name }} {{ $review->rating }}/5

-

Communication {{ $review->communication_rating ?? 'n/a' }} | Scope {{ $review->scope_accuracy_rating ?? 'n/a' }} | Payment {{ $review->payment_reliability_rating ?? 'n/a' }} | Workmanship {{ $review->workmanship_rating ?? 'n/a' }} | Timeliness {{ $review->timeliness_rating ?? 'n/a' }}

-

{{ $review->body }}

+
+

{{ $review->reviewer->name }} rated {{ $review->reviewee->name }} {{ $review->rating }}/5

+

Communication {{ $review->communication_rating ?? 'n/a' }} | Scope {{ $review->scope_accuracy_rating ?? 'n/a' }} | Payment {{ $review->payment_reliability_rating ?? 'n/a' }} | Workmanship {{ $review->workmanship_rating ?? 'n/a' }} | Timeliness {{ $review->timeliness_rating ?? 'n/a' }}

+

{{ $review->body }}

@endforeach
-
-

Disputes / Peer Review

+ +
+

Community rating

+ + +
+ +
+

Disputes / Peer Review

@csrf @@ -67,8 +203,8 @@ Open dispute @foreach ($workOrder->disputes as $dispute) - - {{ $dispute->summary }} | {{ $dispute->status }} + + {{ $dispute->summary }} | {{ $dispute->status }} @endforeach
diff --git a/routes/web.php b/routes/web.php index 32c261e..01f0205 100644 --- a/routes/web.php +++ b/routes/web.php @@ -78,6 +78,9 @@ Route::middleware('auth')->group(function () { Route::get('/work-orders', [WorkOrderController::class, 'index'])->name('work-orders.index'); Route::get('/work-orders/{workOrder}', [WorkOrderController::class, 'show'])->name('work-orders.show'); + 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}/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 4c1b332..3396ec9 100644 --- a/tests/Feature/ExchangeWorkflowTest.php +++ b/tests/Feature/ExchangeWorkflowTest.php @@ -96,6 +96,50 @@ class ExchangeWorkflowTest extends TestCase $this->assertDatabaseHas('disputes', ['work_order_id' => $workOrder->id, 'summary' => 'Scope changed']); } + public function test_work_order_details_checklist_change_requests_and_print_view_work(): void + { + [$buyer, $provider, $workOrder] = $this->workOrderFixture(); + + $this->actingAs($buyer)->patch("/work-orders/{$workOrder->id}/details", [ + 'scheduled_at' => '2026-07-12 09:00:00', + 'appointment_window' => '9 AM - 11 AM', + 'agreed_terms' => 'Two-hour minimum.', + 'deliverables_checklist' => "Arrival photo\nCable test\nCloseout notes", + 'required_evidence' => 'Photos and test results required.', + 'evidence_rules' => ['Arrival photo', 'Completion photo'], + ])->assertRedirect("/work-orders/{$workOrder->id}"); + + $workOrder->refresh(); + + $this->assertSame(['Arrival photo', 'Cable test', 'Closeout notes'], $workOrder->checklistItems()); + $this->assertSame(['Arrival photo', 'Completion photo'], $workOrder->evidence_rules); + + $this->actingAs($provider)->patch("/work-orders/{$workOrder->id}/transition", [ + 'status' => 'en_route', + 'checklist_completed' => [ + 'Arrival photo' => '1', + 'Cable test' => '0', + ], + ])->assertRedirect("/work-orders/{$workOrder->id}"); + + $workOrder->refresh(); + $this->assertTrue($workOrder->checklist_completed['Arrival photo']); + $this->assertFalse($workOrder->checklist_completed['Cable test']); + + $this->actingAs($provider)->post("/work-orders/{$workOrder->id}/change-requests", [ + 'summary' => 'Add second terminal', + 'details' => 'Buyer requested another POS terminal onsite.', + ])->assertRedirect("/work-orders/{$workOrder->id}"); + + $this->assertDatabaseHas('work_orders', ['id' => $workOrder->id]); + $this->assertSame('Add second terminal', $workOrder->fresh()->changeRequests()[0]['summary']); + + $this->actingAs($buyer)->get("/work-orders/{$workOrder->id}/print") + ->assertOk() + ->assertSee('Arrival photo') + ->assertSee('Print'); + } + public function test_external_profile_snapshot_can_be_saved(): void { $provider = $this->userWithRole('provider'); @@ -156,6 +200,41 @@ class ExchangeWorkflowTest extends TestCase ->assertDontSee('Beta Fiber Works'); } + public function test_provider_profile_saves_structured_services_tools_certifications_and_visibility(): void + { + $provider = $this->userWithRole('provider'); + + $this->actingAs($provider)->put('/provider-profile', [ + 'business_name' => 'Structured Provider', + 'bio' => 'Public bio.', + 'services_text' => "Smart hands | entry\nNetwork troubleshooting | advanced", + 'tool_inventory_text' => "Cable tester | network\nTone probe | cabling", + 'certification_records_text' => "Low voltage license | Oklahoma\nA+ | CompTIA", + 'profile_visibility' => [ + 'bio' => '1', + 'services' => '1', + 'tools' => '1', + 'certifications' => '1', + 'imports' => '1', + ], + 'private_notes' => 'Internal only.', + ])->assertRedirect('/provider-profile'); + + $profile = $provider->providerProfile()->firstOrFail(); + + $this->assertSame('Smart hands', $profile->services[0]['name']); + $this->assertSame('network', $profile->tool_inventory[0]['category']); + $this->assertSame('CompTIA', $profile->certification_records[1]['issuer']); + $this->assertFalse($profile->profile_visibility['rate_card']); + + $this->get("/providers/{$profile->id}") + ->assertOk() + ->assertSee('Smart hands') + ->assertSee('Cable tester') + ->assertSee('Low voltage license') + ->assertDontSee('Internal only'); + } + public function test_buyer_directory_can_filter_and_sort_profiles(): void { $retailBuyer = $this->userWithRole('buyer'); @@ -196,6 +275,36 @@ class ExchangeWorkflowTest extends TestCase ->assertDontSee('Beta Warehouse Group'); } + public function test_buyer_profile_saves_structured_hiring_policies_locations_and_visibility(): void + { + $buyer = $this->userWithRole('buyer'); + + $this->actingAs($buyer)->put('/buyer-profile', [ + 'company_name' => 'Structured Buyer', + 'description' => 'Buyer description.', + 'hiring_policies_text' => "Direct ACH | Net 15 after approval\nCOI required | Before dispatch", + 'locations_text' => "Tulsa office | Oklahoma\nDallas office | Texas", + 'profile_visibility' => [ + 'description' => '1', + 'locations' => '1', + 'policies' => '1', + ], + 'private_notes' => 'Buyer internal note.', + ])->assertRedirect('/buyer-profile'); + + $profile = $buyer->buyerProfile()->firstOrFail(); + + $this->assertSame('Direct ACH', $profile->hiring_policies[0]['name']); + $this->assertSame('Texas', $profile->locations[1]['region']); + $this->assertFalse($profile->profile_visibility['payment_terms']); + + $this->get("/buyers/{$profile->id}") + ->assertOk() + ->assertSee('Direct ACH') + ->assertSee('Tulsa office') + ->assertDontSee('Buyer internal note'); + } + public function test_invalid_work_order_transition_is_rejected(): void { [, $provider, $workOrder] = $this->workOrderFixture();