Summary of completed work: - Complete buyer portal (browse, cart, checkout, orders, invoices) - Complete seller portal (orders, manifests, fleet, picking) - Business onboarding wizards (buyer 4-step, seller 5-step) - Email verification and registration flows - Notification system for buyers and sellers - Payment term surcharges and pickup/delivery workflows - Filament admin resources (Business, Brand, Product, Order, Invoice, User) - 51 migrations executed successfully Renamed Company -> Business throughout codebase for consistency. Unified authentication flows with password reset. Added audit trail and telescope for debugging. Next: Week 4 Data Migration (Days 22-28) - Product migration (883 products from cannabrands_crm) - Company migration (81 buyer companies) - User migration (preserve password hashes) - Order history migration
348 lines
9.2 KiB
PHP
348 lines
9.2 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use DateTime;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
|
|
class Contact extends Model
|
|
{
|
|
use HasFactory, SoftDeletes;
|
|
|
|
// Contact Types for Cannabis Business (LeafLink-aligned)
|
|
public const CONTACT_TYPES = [
|
|
'primary' => 'Primary Contact',
|
|
'owner' => 'Owner/Executive',
|
|
'manager' => 'General Manager',
|
|
'buyer' => 'Buyer/Purchasing Manager',
|
|
'accounts_payable' => 'Accounts Payable',
|
|
'accounts_receivable' => 'Accounts Receivable',
|
|
'receiving' => 'Receiving/Delivery Manager',
|
|
'compliance' => 'Compliance Officer',
|
|
'operations' => 'Operations Manager',
|
|
'sales' => 'Sales Representative',
|
|
'marketing' => 'Marketing Contact',
|
|
'it' => 'IT/Technical Contact',
|
|
'legal' => 'Legal Contact',
|
|
'staff' => 'Staff Member',
|
|
];
|
|
|
|
// Communication Preferences
|
|
public const COMMUNICATION_METHODS = [
|
|
'email' => 'Email',
|
|
'phone' => 'Phone',
|
|
'sms' => 'SMS/Text',
|
|
'portal' => 'Online Portal',
|
|
'fax' => 'Fax',
|
|
];
|
|
|
|
protected $fillable = [
|
|
// Ownership
|
|
'company_id',
|
|
'location_id', // Optional - can be company-wide or location-specific
|
|
'user_id', // Optional - linked user account
|
|
|
|
// Personal Information
|
|
'first_name',
|
|
'last_name',
|
|
'title', // Mr., Ms., Dr., etc.
|
|
'position', // Job title
|
|
'department',
|
|
|
|
// Contact Information
|
|
'email',
|
|
'phone',
|
|
'mobile',
|
|
'fax',
|
|
'extension',
|
|
|
|
// Business Details
|
|
'contact_type',
|
|
'responsibilities', // JSON: array of responsibilities
|
|
'permissions', // JSON: what they can approve/access
|
|
|
|
// Communication Preferences
|
|
'preferred_contact_method',
|
|
'communication_preferences', // JSON: when/how to contact
|
|
'language_preference',
|
|
'timezone',
|
|
|
|
// Schedule & Availability
|
|
'work_hours', // JSON: schedule
|
|
'availability_notes',
|
|
'emergency_contact',
|
|
|
|
// Status & Settings
|
|
'is_primary', // Primary contact for business/location
|
|
'is_active',
|
|
'is_emergency_contact',
|
|
'can_approve_orders',
|
|
'can_receive_invoices',
|
|
'can_place_orders',
|
|
'receive_notifications',
|
|
'receive_marketing',
|
|
|
|
// Internal Notes
|
|
'notes',
|
|
'last_contact_date',
|
|
'next_followup_date',
|
|
'relationship_notes',
|
|
|
|
// Account Management
|
|
'archived_at',
|
|
'archived_reason',
|
|
'created_by',
|
|
'updated_by',
|
|
];
|
|
|
|
protected $casts = [
|
|
'responsibilities' => 'array',
|
|
'permissions' => 'array',
|
|
'communication_preferences' => 'array',
|
|
'work_hours' => 'array',
|
|
'is_primary' => 'boolean',
|
|
'is_active' => 'boolean',
|
|
'is_emergency_contact' => 'boolean',
|
|
'can_approve_orders' => 'boolean',
|
|
'can_receive_invoices' => 'boolean',
|
|
'can_place_orders' => 'boolean',
|
|
'receive_notifications' => 'boolean',
|
|
'receive_marketing' => 'boolean',
|
|
'last_contact_date' => 'datetime',
|
|
'next_followup_date' => 'datetime',
|
|
'archived_at' => 'datetime',
|
|
];
|
|
|
|
// Relationships
|
|
public function business(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Business::class);
|
|
}
|
|
|
|
public function location(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Location::class);
|
|
}
|
|
|
|
public function user(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class);
|
|
}
|
|
|
|
public function addresses(): MorphMany
|
|
{
|
|
return $this->morphMany(Address::class, 'addressable');
|
|
}
|
|
|
|
public function createdBy(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'created_by');
|
|
}
|
|
|
|
public function updatedBy(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'updated_by');
|
|
}
|
|
|
|
// Scopes
|
|
public function scopeActive($query)
|
|
{
|
|
return $query->where('is_active', true);
|
|
}
|
|
|
|
public function scopePrimary($query)
|
|
{
|
|
return $query->where('is_primary', true);
|
|
}
|
|
|
|
public function scopeByType($query, string $type)
|
|
{
|
|
return $query->where('contact_type', $type);
|
|
}
|
|
|
|
public function scopeCanApproveOrders($query)
|
|
{
|
|
return $query->where('can_approve_orders', true);
|
|
}
|
|
|
|
public function scopeCanPlaceOrders($query)
|
|
{
|
|
return $query->where('can_place_orders', true);
|
|
}
|
|
|
|
public function scopeReceivesInvoices($query)
|
|
{
|
|
return $query->where('can_receive_invoices', true);
|
|
}
|
|
|
|
public function scopeEmergencyContacts($query)
|
|
{
|
|
return $query->where('is_emergency_contact', true);
|
|
}
|
|
|
|
public function scopeForCompany($query, int $companyId)
|
|
{
|
|
return $query->where('company_id', $companyId);
|
|
}
|
|
|
|
public function scopeForLocation($query, int $locationId)
|
|
{
|
|
return $query->where('location_id', $locationId);
|
|
}
|
|
|
|
// Helper Methods
|
|
public function getFullName(): string
|
|
{
|
|
return trim("{$this->first_name} {$this->last_name}");
|
|
}
|
|
|
|
public function getDisplayName(): string
|
|
{
|
|
$name = $this->getFullName();
|
|
|
|
if ($this->position) {
|
|
$name .= " ({$this->position})";
|
|
}
|
|
|
|
return $name;
|
|
}
|
|
|
|
public function getFormattedTitle(): string
|
|
{
|
|
$parts = array_filter([
|
|
$this->title,
|
|
$this->getFullName(),
|
|
$this->position,
|
|
]);
|
|
|
|
return implode(' ', $parts);
|
|
}
|
|
|
|
public function getContactInfo(): array
|
|
{
|
|
return array_filter([
|
|
'email' => $this->email,
|
|
'phone' => $this->phone,
|
|
'mobile' => $this->mobile,
|
|
'fax' => $this->fax,
|
|
]);
|
|
}
|
|
|
|
public function getPreferredContactMethod(): string
|
|
{
|
|
return $this->preferred_contact_method ?? 'email';
|
|
}
|
|
|
|
public function canBeContactedAt(DateTime $datetime = null): bool
|
|
{
|
|
$datetime = $datetime ?? now();
|
|
|
|
if (!$this->work_hours) {
|
|
return true; // No restrictions
|
|
}
|
|
|
|
$dayOfWeek = strtolower($datetime->format('l'));
|
|
$timeOfDay = $datetime->format('H:i');
|
|
|
|
$schedule = $this->work_hours[$dayOfWeek] ?? null;
|
|
|
|
if (!$schedule || !isset($schedule['start'], $schedule['end'])) {
|
|
return false; // No schedule for this day
|
|
}
|
|
|
|
return $timeOfDay >= $schedule['start'] && $timeOfDay <= $schedule['end'];
|
|
}
|
|
|
|
public function isBuyer(): bool
|
|
{
|
|
return $this->contact_type === 'buyer';
|
|
}
|
|
|
|
public function isAccountsPayable(): bool
|
|
{
|
|
return $this->contact_type === 'accounts_payable';
|
|
}
|
|
|
|
public function isReceiving(): bool
|
|
{
|
|
return $this->contact_type === 'receiving';
|
|
}
|
|
|
|
public function isPrimary(): bool
|
|
{
|
|
return $this->is_primary;
|
|
}
|
|
|
|
public function isArchived(): bool
|
|
{
|
|
return !is_null($this->archived_at);
|
|
}
|
|
|
|
public function hasPermission(string $permission): bool
|
|
{
|
|
return in_array($permission, $this->permissions ?? []);
|
|
}
|
|
|
|
public function hasResponsibility(string $responsibility): bool
|
|
{
|
|
return in_array($responsibility, $this->responsibilities ?? []);
|
|
}
|
|
|
|
// Status Management
|
|
public function archive(string $reason = null, User $archivedBy = null)
|
|
{
|
|
$this->update([
|
|
'is_active' => false,
|
|
'is_primary' => false, // Can't be primary if archived
|
|
'archived_at' => now(),
|
|
'archived_reason' => $reason,
|
|
'updated_by' => $archivedBy?->id,
|
|
]);
|
|
}
|
|
|
|
public function restore(User $restoredBy = null)
|
|
{
|
|
$this->update([
|
|
'is_active' => true,
|
|
'archived_at' => null,
|
|
'archived_reason' => null,
|
|
'updated_by' => $restoredBy?->id,
|
|
]);
|
|
}
|
|
|
|
public function makePrimary()
|
|
{
|
|
// Remove primary status from other contacts in same scope
|
|
if ($this->location_id) {
|
|
// Location-specific primary
|
|
static::where('location_id', $this->location_id)
|
|
->where('id', '!=', $this->id)
|
|
->update(['is_primary' => false]);
|
|
} else {
|
|
// Company-wide primary
|
|
static::where('company_id', $this->company_id)
|
|
->whereNull('location_id')
|
|
->where('id', '!=', $this->id)
|
|
->update(['is_primary' => false]);
|
|
}
|
|
|
|
$this->update(['is_primary' => true]);
|
|
}
|
|
|
|
public function updateLastContact()
|
|
{
|
|
$this->update(['last_contact_date' => now()]);
|
|
}
|
|
|
|
public function setNextFollowup(DateTime $date, string $notes = null)
|
|
{
|
|
$this->update([
|
|
'next_followup_date' => $date,
|
|
'relationship_notes' => $this->relationship_notes . "\n\nFollowup scheduled for {$date->format('Y-m-d')}: {$notes}",
|
|
]);
|
|
}
|
|
} |