Phase 6 and 7 scope contact safeguards
This commit is contained in:
@@ -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'],
|
||||
]);
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
+13
-1
@@ -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 [
|
||||
|
||||
@@ -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
|
||||
|
||||
+123
-2
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'work_order_id',
|
||||
'requester_id',
|
||||
'responder_id',
|
||||
'reason_code',
|
||||
'summary',
|
||||
'details',
|
||||
'scope_impact',
|
||||
'schedule_impact',
|
||||
'terms_impact',
|
||||
'status',
|
||||
'resolution_notes',
|
||||
'responded_at',
|
||||
])]
|
||||
class WorkOrderChangeRequest extends Model
|
||||
{
|
||||
public const REASON_CODES = [
|
||||
'scope_expansion' => '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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'work_order_id',
|
||||
'user_id',
|
||||
'event_type',
|
||||
'attempted_channel',
|
||||
'attempted_at',
|
||||
'result',
|
||||
'notes',
|
||||
])]
|
||||
class WorkOrderContactEvent extends Model
|
||||
{
|
||||
public const EVENT_TYPES = [
|
||||
'contact_failed' => '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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user