Compare commits
88 Commits
fix/produc
...
fix/gitea-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb8e2a89c4 | ||
|
|
8286aebf4e | ||
|
|
4cff4af841 | ||
|
|
8abcd3291e | ||
|
|
a7c3eb4183 | ||
|
|
1ed62fe0de | ||
|
|
160b312ca5 | ||
|
|
6d22a99259 | ||
|
|
febfd75016 | ||
|
|
fbb72f902b | ||
|
|
fd11ae0fe0 | ||
|
|
16c5c455fa | ||
|
|
df587fdda3 | ||
|
|
3fb5747aa2 | ||
|
|
33c9420b00 | ||
|
|
37204edfd7 | ||
|
|
8d9725b501 | ||
|
|
6cf8ad1854 | ||
|
|
58f787feb0 | ||
|
|
970ce05846 | ||
|
|
672b0d5f6b | ||
|
|
4415194b28 | ||
|
|
213b0ef8f2 | ||
|
|
13dbe046e1 | ||
|
|
592df4de44 | ||
|
|
ae581b4d5c | ||
|
|
8a8f83cc0c | ||
|
|
722904d487 | ||
|
|
ddc84f6730 | ||
|
|
2c510844f0 | ||
|
|
105a1e8ce0 | ||
|
|
7e06ff3488 | ||
|
|
aed1e62c65 | ||
|
|
f9f1b8dc46 | ||
|
|
89d3a54988 | ||
|
|
0c60e5c519 | ||
|
|
1ecc4a916b | ||
|
|
f9d7573cb4 | ||
|
|
e48e9c9b82 | ||
|
|
afbb1ba79c | ||
|
|
5f0042e483 | ||
|
|
08f5a3adac | ||
|
|
e62ea5c809 | ||
|
|
8d43953cad | ||
|
|
a628f2b207 | ||
|
|
367daadfe9 | ||
|
|
329c01523a | ||
|
|
5fb26f901d | ||
|
|
6baadf5744 | ||
|
|
a3508c57a2 | ||
|
|
38cba2cd72 | ||
|
|
735e09ab90 | ||
|
|
05ef21cd71 | ||
|
|
65c65bf9cc | ||
|
|
e33f0d0182 | ||
|
|
c8faf2f2d6 | ||
|
|
50bb3fce77 | ||
|
|
c7fdc67060 | ||
|
|
c7e2b0e4ac | ||
|
|
0cf83744db | ||
|
|
defeeffa07 | ||
|
|
0fbf99c005 | ||
|
|
67eb679c7e | ||
|
|
3b7f3acaa6 | ||
|
|
3d1f3b1057 | ||
|
|
7a2748e904 | ||
|
|
4f2061cd00 | ||
|
|
8bb9044f2d | ||
|
|
7da52677d5 | ||
|
|
a049db38a9 | ||
|
|
bb60a772f9 | ||
|
|
95d92f27d3 | ||
|
|
f08910bbf4 | ||
|
|
e043137269 | ||
|
|
de988d9abd | ||
|
|
72df0cfe88 | ||
|
|
65a752f4d8 | ||
|
|
7d0230be5f | ||
|
|
75305a01b0 | ||
|
|
f2ce0dfee3 | ||
|
|
1222610080 | ||
|
|
c1d0cdf477 | ||
|
|
a55ea906ac | ||
|
|
70e274415d | ||
|
|
fca89475cc | ||
|
|
a88eeb7981 | ||
|
|
eed4df0c4a | ||
|
|
915b0407cf |
@@ -7,17 +7,36 @@
|
||||
# - tags (2025.X) → cannabrands.app (versioned production releases)
|
||||
#
|
||||
# Pipeline Strategy:
|
||||
# - PRs: Run tests (lint, style, phpunit)
|
||||
# - PRs: Run tests (lint, style, phpunit) IN PARALLEL
|
||||
# - Push to develop/master: Skip tests (already passed on PR), build + deploy
|
||||
# - Tags: Build versioned release
|
||||
#
|
||||
# Optimization Notes:
|
||||
# - php-lint, code-style, and tests run in parallel after composer install
|
||||
# - Uses parallel-lint for faster PHP syntax checking
|
||||
# - PostgreSQL tuned for CI (fsync disabled)
|
||||
# - Cache rebuild only on merge builds
|
||||
|
||||
when:
|
||||
- branch: [develop, master]
|
||||
event: push
|
||||
- event: [pull_request, tag]
|
||||
|
||||
# Install dependencies first (needed for php-lint to resolve traits/classes)
|
||||
# Use explicit git clone plugin to fix auth issues
|
||||
# The default clone was failing with "could not read Username"
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
depth: 50
|
||||
lfs: false
|
||||
partial: false
|
||||
|
||||
steps:
|
||||
# ============================================
|
||||
# DEPENDENCY INSTALLATION (Sequential)
|
||||
# ============================================
|
||||
|
||||
# Restore Composer cache
|
||||
restore-composer-cache:
|
||||
image: meltwater/drone-cache:dev
|
||||
@@ -34,6 +53,8 @@ steps:
|
||||
# Install dependencies (uses pre-built Laravel image with all extensions)
|
||||
composer-install:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- restore-composer-cache
|
||||
commands:
|
||||
- echo "Creating minimal .env for package discovery..."
|
||||
- |
|
||||
@@ -63,9 +84,11 @@ steps:
|
||||
fi
|
||||
- echo "✅ Composer dependencies ready!"
|
||||
|
||||
# Rebuild Composer cache
|
||||
# Rebuild Composer cache (only on merge builds, not PRs)
|
||||
rebuild-composer-cache:
|
||||
image: meltwater/drone-cache:dev
|
||||
depends_on:
|
||||
- composer-install
|
||||
settings:
|
||||
backend: "filesystem"
|
||||
rebuild: true
|
||||
@@ -75,22 +98,31 @@ steps:
|
||||
- "vendor"
|
||||
volumes:
|
||||
- /tmp/woodpecker-cache:/tmp/cache
|
||||
when:
|
||||
branch: [develop, master]
|
||||
event: push
|
||||
|
||||
# PHP Syntax Check (PRs only - skipped on merge since tests already passed)
|
||||
# ============================================
|
||||
# PR CHECKS (Run in Parallel for Speed)
|
||||
# ============================================
|
||||
|
||||
# PHP Syntax Check - Uses parallel-lint for 5-10x speed improvement
|
||||
php-lint:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
commands:
|
||||
- echo "Checking PHP syntax..."
|
||||
- find app -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
|
||||
- find routes -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
|
||||
- find database -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
|
||||
- echo "Checking PHP syntax (parallel)..."
|
||||
- ./vendor/bin/parallel-lint app routes database config --colors --blame
|
||||
- echo "✅ PHP syntax check complete!"
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# Run Laravel Pint (PRs only - skipped on merge since tests already passed)
|
||||
# Run Laravel Pint (code style)
|
||||
code-style:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
commands:
|
||||
- echo "Checking code style with Laravel Pint..."
|
||||
- ./vendor/bin/pint --test
|
||||
@@ -98,11 +130,13 @@ steps:
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# Run PHPUnit Tests (PRs only - skipped on merge since tests already passed)
|
||||
# Run PHPUnit Tests
|
||||
# Note: Uses array cache/session for speed and isolation (Laravel convention)
|
||||
# Redis + Reverb services used for real-time broadcasting tests
|
||||
tests:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
when:
|
||||
event: pull_request
|
||||
environment:
|
||||
@@ -128,20 +162,35 @@ steps:
|
||||
- echo "Setting up Laravel environment..."
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- echo "Waiting for PostgreSQL to be ready..."
|
||||
- |
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if pg_isready -h postgres -p 5432 -U testing 2>/dev/null; then
|
||||
echo "✅ PostgreSQL is ready!"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for postgres... attempt $i/10"
|
||||
sleep 3
|
||||
done
|
||||
- echo "Starting Reverb server in background..."
|
||||
- php artisan reverb:start --host=0.0.0.0 --port=8080 > /dev/null 2>&1 &
|
||||
- sleep 2
|
||||
- echo "Running tests..."
|
||||
- echo "Running tests in parallel..."
|
||||
- php artisan test --parallel
|
||||
- echo "Tests complete!"
|
||||
- echo "✅ Tests complete!"
|
||||
|
||||
# Validate seeders that run in dev/staging environments
|
||||
# This prevents deployment failures caused by seeder errors (e.g., fake() crashes)
|
||||
# Uses APP_ENV=development to match K8s init container behavior
|
||||
validate-seeders:
|
||||
# ============================================
|
||||
# MERGE BUILD STEPS (Sequential, after PR passes)
|
||||
# ============================================
|
||||
|
||||
# Validate migrations before deployment
|
||||
# Only runs pending migrations - never fresh or seed
|
||||
validate-migrations:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
environment:
|
||||
APP_ENV: development
|
||||
APP_ENV: production
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
@@ -152,20 +201,21 @@ steps:
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
commands:
|
||||
- echo "Validating seeders (matches K8s init container)..."
|
||||
- echo "Validating migrations..."
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- echo "Running migrate:fresh --seed with APP_ENV=development..."
|
||||
- php artisan migrate:fresh --seed --force
|
||||
- echo "✅ Seeder validation complete!"
|
||||
- echo "Running pending migrations only..."
|
||||
- php artisan migrate --force
|
||||
- echo "✅ Migration validation complete!"
|
||||
when:
|
||||
branch: [develop, master]
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for DEV environment (develop branch)
|
||||
build-image-dev:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
depends_on:
|
||||
- validate-migrations
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
@@ -194,11 +244,12 @@ steps:
|
||||
when:
|
||||
branch: develop
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Auto-deploy to dev.cannabrands.app (develop branch only)
|
||||
deploy-dev:
|
||||
image: bitnami/kubectl:latest
|
||||
depends_on:
|
||||
- build-image-dev
|
||||
environment:
|
||||
KUBECONFIG_CONTENT:
|
||||
from_secret: kubeconfig_dev
|
||||
@@ -231,11 +282,12 @@ steps:
|
||||
when:
|
||||
branch: develop
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for PRODUCTION (master branch)
|
||||
build-image-production:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
depends_on:
|
||||
- validate-migrations
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
@@ -260,11 +312,12 @@ steps:
|
||||
when:
|
||||
branch: master
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Deploy to production (master branch)
|
||||
deploy-production:
|
||||
image: bitnami/kubectl:latest
|
||||
depends_on:
|
||||
- build-image-production
|
||||
environment:
|
||||
KUBECONFIG_CONTENT:
|
||||
from_secret: kubeconfig_prod
|
||||
@@ -288,11 +341,12 @@ steps:
|
||||
when:
|
||||
branch: master
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for tagged releases (optional versioned releases)
|
||||
build-image-release:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
depends_on:
|
||||
- composer-install
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
@@ -313,7 +367,6 @@ steps:
|
||||
provenance: false
|
||||
when:
|
||||
event: tag
|
||||
status: success
|
||||
|
||||
# Success notification
|
||||
success:
|
||||
@@ -384,7 +437,7 @@ steps:
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
fi
|
||||
|
||||
# Services for tests
|
||||
# Services for tests (optimized for CI speed)
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
@@ -392,6 +445,9 @@ services:
|
||||
POSTGRES_USER: testing
|
||||
POSTGRES_PASSWORD: testing
|
||||
POSTGRES_DB: testing
|
||||
# CI-optimized settings via environment (faster writes, safe for ephemeral test DB)
|
||||
POSTGRES_INITDB_ARGS: "--data-checksums"
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
|
||||
42
app/Console/Commands/DispatchScheduledCampaigns.php
Normal file
42
app/Console/Commands/DispatchScheduledCampaigns.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\SendMarketingCampaignJob;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* DispatchScheduledCampaigns - Dispatch scheduled marketing campaigns.
|
||||
*
|
||||
* Run via scheduler: Schedule::command('marketing:dispatch-scheduled-campaigns')->everyMinute();
|
||||
*/
|
||||
class DispatchScheduledCampaigns extends Command
|
||||
{
|
||||
protected $signature = 'marketing:dispatch-scheduled-campaigns';
|
||||
|
||||
protected $description = 'Dispatch scheduled marketing campaigns that are ready to send';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$campaigns = MarketingCampaign::readyToSend()->get();
|
||||
|
||||
if ($campaigns->isEmpty()) {
|
||||
$this->info('No scheduled campaigns ready to send.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$campaigns->count()} campaign(s) ready to send.");
|
||||
|
||||
foreach ($campaigns as $campaign) {
|
||||
$this->info("Dispatching campaign: {$campaign->name} (ID: {$campaign->id})");
|
||||
|
||||
SendMarketingCampaignJob::dispatch($campaign->id);
|
||||
}
|
||||
|
||||
$this->info('Done.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
108
app/Console/Commands/RunDueMarketingAutomations.php
Normal file
108
app/Console/Commands/RunDueMarketingAutomations.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\RunMarketingAutomationJob;
|
||||
use App\Models\Marketing\MarketingAutomation;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RunDueMarketingAutomations extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'marketing:run-due-automations
|
||||
{--business= : Only process automations for a specific business ID}
|
||||
{--dry-run : Show which automations would run without executing them}
|
||||
{--sync : Run synchronously instead of dispatching to queue}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Check and run all due marketing automations';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$businessId = $this->option('business');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$sync = $this->option('sync');
|
||||
|
||||
$this->info('Checking for due marketing automations...');
|
||||
|
||||
// Query active automations
|
||||
$query = MarketingAutomation::where('is_active', true)
|
||||
->whereIn('trigger_type', [
|
||||
MarketingAutomation::TRIGGER_SCHEDULED_CANNAIQ_CHECK,
|
||||
MarketingAutomation::TRIGGER_SCHEDULED_STORE_CHECK,
|
||||
]);
|
||||
|
||||
if ($businessId) {
|
||||
$query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
$automations = $query->get();
|
||||
|
||||
if ($automations->isEmpty()) {
|
||||
$this->info('No active automations found.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$automations->count()} active automation(s).");
|
||||
|
||||
$dueCount = 0;
|
||||
|
||||
foreach ($automations as $automation) {
|
||||
if (! $automation->isDue()) {
|
||||
$this->line(" - <comment>{$automation->name}</comment>: Not due yet");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$dueCount++;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" - <info>{$automation->name}</info>: Would run (dry-run mode)");
|
||||
$this->line(" Trigger: {$automation->trigger_type_label}");
|
||||
$this->line(" Frequency: {$automation->frequency_label}");
|
||||
$this->line(' Last run: '.($automation->last_run_at?->diffForHumans() ?? 'Never'));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(" - <info>{$automation->name}</info>: Dispatching...");
|
||||
|
||||
if ($sync) {
|
||||
// Run synchronously
|
||||
try {
|
||||
$job = new RunMarketingAutomationJob($automation->id);
|
||||
$job->handle(app(\App\Services\Marketing\AutomationRunner::class));
|
||||
$this->line(' <info>Completed</info>');
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" Failed: {$e->getMessage()}");
|
||||
}
|
||||
} else {
|
||||
// Dispatch to queue
|
||||
RunMarketingAutomationJob::dispatch($automation->id);
|
||||
$this->line(' <info>Dispatched to queue</info>');
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->newLine();
|
||||
$this->info("Dry run complete. {$dueCount} automation(s) would have been executed.");
|
||||
} else {
|
||||
$this->newLine();
|
||||
$this->info("Done. {$dueCount} automation(s) ".($sync ? 'executed' : 'dispatched').'.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
203
app/Filament/Pages/CannaiqSettings.php
Normal file
203
app/Filament/Pages/CannaiqSettings.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Services\Cannaiq\CannaiqClient;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class CannaiqSettings extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-chart-bar-square';
|
||||
|
||||
protected string $view = 'filament.pages.cannaiq-settings';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'Integrations';
|
||||
|
||||
protected static ?string $navigationLabel = 'CannaiQ';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected static ?string $title = 'CannaiQ Settings';
|
||||
|
||||
protected static ?string $slug = 'cannaiq-settings';
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth('admin')->check();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'base_url' => config('services.cannaiq.base_url'),
|
||||
'api_key' => '', // Never show the actual key
|
||||
'cache_ttl' => config('services.cannaiq.cache_ttl', 7200),
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
$apiKeyConfigured = ! empty(config('services.cannaiq.api_key'));
|
||||
$baseUrl = config('services.cannaiq.base_url');
|
||||
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('CannaiQ Integration')
|
||||
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
|
||||
->schema([
|
||||
Placeholder::make('status')
|
||||
->label('Connection Status')
|
||||
->content(function () use ($apiKeyConfigured, $baseUrl) {
|
||||
$statusHtml = '<div class="space-y-2">';
|
||||
|
||||
// API Key status
|
||||
if ($apiKeyConfigured) {
|
||||
$statusHtml .= '<div class="flex items-center gap-2 text-success-600 dark:text-success-400">'.
|
||||
'<span class="text-lg">✓</span>'.
|
||||
'<span>API Key configured</span>'.
|
||||
'</div>';
|
||||
} else {
|
||||
$statusHtml .= '<div class="flex items-center gap-2 text-warning-600 dark:text-warning-400">'.
|
||||
'<span class="text-lg">⚠</span>'.
|
||||
'<span>API Key not configured (using trusted origin auth)</span>'.
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Base URL
|
||||
$statusHtml .= '<div class="text-sm text-gray-500 dark:text-gray-400">'.
|
||||
'Base URL: <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">'.$baseUrl.'</code>'.
|
||||
'</div>';
|
||||
|
||||
$statusHtml .= '</div>';
|
||||
|
||||
return new HtmlString($statusHtml);
|
||||
}),
|
||||
|
||||
Placeholder::make('features')
|
||||
->label('Features Enabled')
|
||||
->content(new HtmlString(
|
||||
'<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 p-4">'.
|
||||
'<ul class="list-disc list-inside text-sm text-gray-600 dark:text-gray-400 space-y-1">'.
|
||||
'<li><strong>Brand Analysis</strong> - Market positioning, SKU velocity, shelf opportunities</li>'.
|
||||
'<li><strong>Marketing Intelligence</strong> - Competitive insights and recommendations</li>'.
|
||||
'<li><strong>Promo Recommendations</strong> - AI-powered promotional strategies</li>'.
|
||||
'<li><strong>Store Playbook</strong> - Actionable insights for retail partners</li>'.
|
||||
'</ul>'.
|
||||
'</div>'
|
||||
)),
|
||||
]),
|
||||
|
||||
Section::make('Configuration')
|
||||
->description('CannaiQ is configured via environment variables. Update your .env file to change these settings.')
|
||||
->schema([
|
||||
TextInput::make('base_url')
|
||||
->label('Base URL')
|
||||
->disabled()
|
||||
->helperText('Set via CANNAIQ_BASE_URL environment variable'),
|
||||
|
||||
TextInput::make('cache_ttl')
|
||||
->label('Cache TTL (seconds)')
|
||||
->disabled()
|
||||
->helperText('Set via CANNAIQ_CACHE_TTL environment variable. Default: 7200 (2 hours)'),
|
||||
|
||||
Placeholder::make('env_example')
|
||||
->label('Environment Variables')
|
||||
->content(new HtmlString(
|
||||
'<div class="rounded-lg bg-gray-900 text-gray-100 p-4 font-mono text-sm overflow-x-auto">'.
|
||||
'<div class="text-gray-400"># CannaiQ Configuration</div>'.
|
||||
'<div>CANNAIQ_BASE_URL=https://cannaiq.co/api/v1</div>'.
|
||||
'<div>CANNAIQ_API_KEY=your-api-key-here</div>'.
|
||||
'<div>CANNAIQ_CACHE_TTL=7200</div>'.
|
||||
'</div>'
|
||||
)),
|
||||
])
|
||||
->collapsed(),
|
||||
|
||||
Section::make('Business Access')
|
||||
->description('CannaiQ features must be enabled per-business in the Business settings.')
|
||||
->schema([
|
||||
Placeholder::make('business_info')
|
||||
->label('')
|
||||
->content(new HtmlString(
|
||||
'<div class="rounded-lg border border-info-200 bg-info-50 dark:border-info-800 dark:bg-info-950 p-4">'.
|
||||
'<div class="flex items-start gap-3">'.
|
||||
'<span class="text-info-600 dark:text-info-400 text-lg">ⓘ</span>'.
|
||||
'<div class="text-sm">'.
|
||||
'<p class="font-medium text-info-800 dark:text-info-200">How to enable CannaiQ for a business:</p>'.
|
||||
'<ol class="list-decimal list-inside mt-2 text-info-700 dark:text-info-300 space-y-1">'.
|
||||
'<li>Go to <strong>Users → Businesses</strong></li>'.
|
||||
'<li>Edit the business</li>'.
|
||||
'<li>Go to the <strong>Integrations</strong> tab</li>'.
|
||||
'<li>Toggle <strong>Enable CannaiQ</strong></li>'.
|
||||
'</ol>'.
|
||||
'</div>'.
|
||||
'</div>'.
|
||||
'</div>'
|
||||
)),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function testConnection(): void
|
||||
{
|
||||
try {
|
||||
$client = app(CannaiqClient::class);
|
||||
|
||||
// Try to fetch something from the API to verify connection
|
||||
// We'll use a simple health check or fetch minimal data
|
||||
$response = $client->getBrandAnalysis('test-brand', 'test-business');
|
||||
|
||||
// If we get here without exception, connection works
|
||||
// (even if the response is empty/error from CannaiQ side)
|
||||
Notification::make()
|
||||
->title('Connection Test')
|
||||
->body('Successfully connected to CannaiQ API')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->title('Connection Failed')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
// Clear all CannaiQ-related cache keys
|
||||
$patterns = [
|
||||
'cannaiq:*',
|
||||
'brand_analysis:*',
|
||||
];
|
||||
|
||||
$cleared = 0;
|
||||
foreach ($patterns as $pattern) {
|
||||
// Note: This is a simplified clear - in production you might want
|
||||
// to use Redis SCAN for pattern matching
|
||||
Cache::forget($pattern);
|
||||
$cleared++;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Cache Cleared')
|
||||
->body('CannaiQ cache has been cleared')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@@ -852,6 +852,40 @@ class BusinessResource extends Resource
|
||||
]),
|
||||
]),
|
||||
|
||||
// ===== INTEGRATIONS TAB =====
|
||||
// Third-party service integrations
|
||||
Tab::make('Integrations')
|
||||
->icon('heroicon-o-link')
|
||||
->schema([
|
||||
// ===== CANNAIQ SECTION =====
|
||||
Section::make('CannaiQ')
|
||||
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
|
||||
->schema([
|
||||
Toggle::make('cannaiq_enabled')
|
||||
->label('Enable CannaiQ')
|
||||
->helperText('When enabled, this business gets access to Brand Analysis, Intelligence, and Promos features.')
|
||||
->default(false),
|
||||
|
||||
Forms\Components\Placeholder::make('cannaiq_info')
|
||||
->label('')
|
||||
->content(new \Illuminate\Support\HtmlString(
|
||||
'<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 p-4 text-sm">'.
|
||||
'<div class="font-medium text-gray-700 dark:text-gray-300 mb-2">CannaiQ Features</div>'.
|
||||
'<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-1">'.
|
||||
'<li>Brand Analysis - Market positioning, SKU velocity, shelf opportunities</li>'.
|
||||
'<li>Marketing Intelligence - Competitive insights and recommendations</li>'.
|
||||
'<li>Promo Recommendations - AI-powered promotional strategies</li>'.
|
||||
'</ul>'.
|
||||
'<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">'.
|
||||
'<a href="https://cannaiq.co" target="_blank" class="text-primary-600 hover:text-primary-500 dark:text-primary-400 font-medium inline-flex items-center gap-1">'.
|
||||
'Visit CannaiQ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>'.
|
||||
'</a>'.
|
||||
'</div>'.
|
||||
'</div>'
|
||||
)),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ===== LEGACY MODULES TAB =====
|
||||
// These flags are kept for backward compatibility.
|
||||
// The recommended way to configure access is via Suites above.
|
||||
|
||||
@@ -28,6 +28,14 @@ class EditBusiness extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('view_marketing_portal')
|
||||
->label('Marketing Portal')
|
||||
->icon('heroicon-o-megaphone')
|
||||
->color('info')
|
||||
->url(fn () => route('portal.dashboard', $this->record->slug))
|
||||
->openUrlInNewTab()
|
||||
->visible(fn () => $this->record->status === 'approved' && $this->record->business_type === 'buyer'),
|
||||
|
||||
Actions\Action::make('approve_application')
|
||||
->label('Approve Application')
|
||||
->icon('heroicon-o-check-circle')
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Analytics\BuyerEngagementScore;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmDeal;
|
||||
use App\Models\Crm\CrmMeetingBooking;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use App\Services\Crm\CrmSlaService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
@@ -39,7 +45,7 @@ class DashboardController extends Controller
|
||||
* NOTE: All metrics are pre-calculated by CalculateDashboardMetrics job
|
||||
* and stored in Redis. This method only reads from Redis for instant response.
|
||||
*/
|
||||
public function overview(Business $business)
|
||||
public function overview(Request $request, Business $business)
|
||||
{
|
||||
// Read pre-calculated metrics from Redis
|
||||
$redisKey = "dashboard:{$business->id}:overview";
|
||||
@@ -104,6 +110,12 @@ class DashboardController extends Controller
|
||||
// Orchestrator Widget Data (if enabled)
|
||||
$orchestratorWidget = (new \App\Http\Controllers\Seller\OrchestratorController)->getWidgetData($business);
|
||||
|
||||
// Hub Tiles Data (CRM, Tasks, Calendar, etc.)
|
||||
$hubTiles = $this->getHubTilesData($business, $request->user());
|
||||
|
||||
// Sales Inbox - unified view of items needing attention
|
||||
$salesInbox = $this->getSalesInboxData($business, $request->user());
|
||||
|
||||
return view('seller.dashboard.overview', compact(
|
||||
'business',
|
||||
'revenueLast30',
|
||||
@@ -122,7 +134,9 @@ class DashboardController extends Controller
|
||||
'topBrands',
|
||||
'needsAttention',
|
||||
'recentActivity',
|
||||
'orchestratorWidget'
|
||||
'orchestratorWidget',
|
||||
'hubTiles',
|
||||
'salesInbox'
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1195,4 +1209,289 @@ class DashboardController extends Controller
|
||||
// Sort all activities by timestamp (most recent first) and apply limit
|
||||
return $activities->sortByDesc('timestamp')->take($limit)->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Sales Inbox data - unified view of items needing sales rep attention
|
||||
* Includes overdue invoices, deals needing follow-up, tasks, and messages
|
||||
*/
|
||||
protected function getSalesInboxData(Business $business, $user): array
|
||||
{
|
||||
$overdue = [];
|
||||
$upcoming = [];
|
||||
$messages = [];
|
||||
|
||||
// Get brand IDs for this business
|
||||
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
|
||||
|
||||
// Overdue invoices
|
||||
$overdueInvoices = \App\Models\Invoice::whereHas('order.items.product.brand', function ($query) use ($brandIds) {
|
||||
$query->whereIn('id', $brandIds);
|
||||
})
|
||||
->where('payment_status', 'pending')
|
||||
->where('due_date', '<', now())
|
||||
->with(['business:id,name', 'order:id,order_number'])
|
||||
->orderBy('due_date', 'asc')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
foreach ($overdueInvoices as $invoice) {
|
||||
$daysOverdue = now()->diffInDays($invoice->due_date, false);
|
||||
$overdue[] = [
|
||||
'type' => 'invoice',
|
||||
'label' => "Invoice {$invoice->invoice_number} for {$invoice->business->name}",
|
||||
'age' => $daysOverdue,
|
||||
'link' => route('seller.business.invoices.show', [$business->slug, $invoice->invoice_number]),
|
||||
];
|
||||
}
|
||||
|
||||
// Overdue tasks
|
||||
$overdueTasks = CrmTask::where('seller_business_id', $business->id)
|
||||
->whereNull('completed_at')
|
||||
->where('due_at', '<', now())
|
||||
->with(['contact:id,first_name,last_name,company_name'])
|
||||
->orderBy('due_at', 'asc')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
foreach ($overdueTasks as $task) {
|
||||
$daysOverdue = now()->diffInDays($task->due_at, false);
|
||||
$contactName = $task->contact
|
||||
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: $task->contact->company_name
|
||||
: 'Unknown';
|
||||
$overdue[] = [
|
||||
'type' => 'task',
|
||||
'label' => $task->title.($contactName ? " ({$contactName})" : ''),
|
||||
'age' => $daysOverdue,
|
||||
'link' => route('seller.business.crm.tasks.index', $business->slug),
|
||||
];
|
||||
}
|
||||
|
||||
// Deals needing follow-up (no activity in 7+ days)
|
||||
$staleDeals = CrmDeal::forBusiness($business->id)
|
||||
->where('status', 'open')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('last_activity_at')
|
||||
->orWhere('last_activity_at', '<', now()->subDays(7));
|
||||
})
|
||||
->with(['account:id,name'])
|
||||
->orderBy('last_activity_at', 'asc')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
foreach ($staleDeals as $deal) {
|
||||
$daysSinceActivity = $deal->last_activity_at
|
||||
? now()->diffInDays($deal->last_activity_at, false)
|
||||
: -30;
|
||||
$overdue[] = [
|
||||
'type' => 'deal',
|
||||
'label' => $deal->name.($deal->account ? " ({$deal->account->name})" : ''),
|
||||
'age' => $daysSinceActivity,
|
||||
'link' => route('seller.business.crm.deals.show', [$business->slug, $deal->hashid]),
|
||||
];
|
||||
}
|
||||
|
||||
// Upcoming tasks (due in next 7 days)
|
||||
$upcomingTasks = CrmTask::where('seller_business_id', $business->id)
|
||||
->whereNull('completed_at')
|
||||
->where('due_at', '>=', now())
|
||||
->where('due_at', '<=', now()->addDays(7))
|
||||
->with(['contact:id,first_name,last_name,company_name'])
|
||||
->orderBy('due_at', 'asc')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
foreach ($upcomingTasks as $task) {
|
||||
$daysUntilDue = now()->diffInDays($task->due_at, false);
|
||||
$contactName = $task->contact
|
||||
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: $task->contact->company_name
|
||||
: 'Unknown';
|
||||
$upcoming[] = [
|
||||
'type' => 'task',
|
||||
'label' => $task->title.($contactName ? " ({$contactName})" : ''),
|
||||
'days_until' => abs($daysUntilDue),
|
||||
'link' => route('seller.business.crm.tasks.index', $business->slug),
|
||||
];
|
||||
}
|
||||
|
||||
// Upcoming meetings (next 7 days)
|
||||
$upcomingMeetings = CrmMeetingBooking::where('business_id', $business->id)
|
||||
->where('status', 'scheduled')
|
||||
->where('start_at', '>=', now())
|
||||
->where('start_at', '<=', now()->addDays(7))
|
||||
->with(['contact:id,first_name,last_name,company_name'])
|
||||
->orderBy('start_at', 'asc')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
foreach ($upcomingMeetings as $meeting) {
|
||||
$daysUntil = now()->diffInDays($meeting->start_at, false);
|
||||
$contactName = $meeting->contact
|
||||
? trim($meeting->contact->first_name.' '.$meeting->contact->last_name) ?: $meeting->contact->company_name
|
||||
: 'Unknown';
|
||||
$upcoming[] = [
|
||||
'type' => 'meeting',
|
||||
'label' => ($meeting->title ?? 'Meeting')." with {$contactName}",
|
||||
'days_until' => abs($daysUntil),
|
||||
'link' => route('seller.business.crm.calendar.index', $business->slug),
|
||||
];
|
||||
}
|
||||
|
||||
// CRM Messages (unread threads)
|
||||
$unreadThreads = CrmThread::forBusiness($business->id)
|
||||
->where('status', 'open')
|
||||
->where('is_read', false)
|
||||
->with(['contact:id,name,email'])
|
||||
->orderBy('last_message_at', 'desc')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
foreach ($unreadThreads as $thread) {
|
||||
$messages[] = [
|
||||
'type' => 'message',
|
||||
'label' => $thread->contact->name ?? $thread->contact->email ?? 'Unknown contact',
|
||||
'preview' => $thread->last_message_preview ?? 'New message',
|
||||
'time' => $thread->last_message_at?->diffForHumans() ?? 'Recently',
|
||||
'link' => route('seller.business.crm.threads.show', [$business->slug, $thread->hashid]),
|
||||
];
|
||||
}
|
||||
|
||||
// Sort overdue by age (most overdue first)
|
||||
usort($overdue, fn ($a, $b) => $a['age'] <=> $b['age']);
|
||||
|
||||
// Sort upcoming by days until due
|
||||
usort($upcoming, fn ($a, $b) => $a['days_until'] <=> $b['days_until']);
|
||||
|
||||
return [
|
||||
'overdue' => $overdue,
|
||||
'upcoming' => $upcoming,
|
||||
'messages' => $messages,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary data for dashboard hub tiles
|
||||
* Each tile shows key metrics with a link to the detail page
|
||||
*/
|
||||
protected function getHubTilesData(Business $business, $user): array
|
||||
{
|
||||
$cacheKey = "dashboard_hub_tiles:{$business->id}:{$user->id}";
|
||||
|
||||
return Cache::remember($cacheKey, 60, function () use ($business) {
|
||||
// Inbox (CRM Threads)
|
||||
$inboxStats = CrmThread::forBusiness($business->id)
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_count,
|
||||
SUM(CASE WHEN status = 'open' AND is_read = false THEN 1 ELSE 0 END) as unread_count,
|
||||
SUM(CASE WHEN status = 'open' AND priority = 'urgent' THEN 1 ELSE 0 END) as urgent_count
|
||||
")
|
||||
->first();
|
||||
|
||||
// Deals
|
||||
$dealStats = CrmDeal::forBusiness($business->id)
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_count,
|
||||
SUM(CASE WHEN status = 'open' THEN value ELSE 0 END) as pipeline_value,
|
||||
SUM(CASE WHEN status = 'won' AND EXTRACT(MONTH FROM actual_close_date) = ? AND EXTRACT(YEAR FROM actual_close_date) = ? THEN value ELSE 0 END) as won_this_month
|
||||
", [now()->month, now()->year])
|
||||
->first();
|
||||
|
||||
// Tasks
|
||||
$taskStats = CrmTask::where('seller_business_id', $business->id)
|
||||
->selectRaw('
|
||||
SUM(CASE WHEN completed_at IS NULL THEN 1 ELSE 0 END) as pending_count,
|
||||
SUM(CASE WHEN completed_at IS NULL AND DATE(due_at) = CURRENT_DATE THEN 1 ELSE 0 END) as due_today,
|
||||
SUM(CASE WHEN completed_at IS NULL AND due_at < NOW() THEN 1 ELSE 0 END) as overdue_count
|
||||
')
|
||||
->first();
|
||||
|
||||
// Calendar/Meetings (upcoming in next 7 days)
|
||||
$upcomingMeetings = CrmMeetingBooking::where('business_id', $business->id)
|
||||
->where('status', 'scheduled')
|
||||
->where('start_at', '>=', now())
|
||||
->where('start_at', '<=', now()->addDays(7))
|
||||
->count();
|
||||
|
||||
$todayMeetings = CrmMeetingBooking::where('business_id', $business->id)
|
||||
->where('status', 'scheduled')
|
||||
->whereDate('start_at', today())
|
||||
->count();
|
||||
|
||||
// Buyer Intelligence
|
||||
$buyerStats = [
|
||||
'total' => 0,
|
||||
'at_risk' => 0,
|
||||
'high_value' => 0,
|
||||
];
|
||||
|
||||
if (class_exists(BuyerEngagementScore::class)) {
|
||||
$buyerStats = [
|
||||
'total' => BuyerEngagementScore::forBusiness($business->id)->count(),
|
||||
'at_risk' => BuyerEngagementScore::forBusiness($business->id)->where('engagement_level', 'cold')->count(),
|
||||
'high_value' => BuyerEngagementScore::forBusiness($business->id)->highValue()->count(),
|
||||
];
|
||||
}
|
||||
|
||||
// Team Performance (SLA)
|
||||
$slaMetrics = ['compliance_rate' => 100, 'avg_response_time' => 0];
|
||||
try {
|
||||
$slaService = app(CrmSlaService::class);
|
||||
$slaMetrics = $slaService->getMetrics($business->id, 30);
|
||||
} catch (\Exception $e) {
|
||||
// SLA service not available
|
||||
}
|
||||
|
||||
// Orchestrator tasks count
|
||||
$orchestratorTasks = 0;
|
||||
try {
|
||||
$orchestratorData = (new \App\Http\Controllers\Seller\OrchestratorController)->getWidgetData($business);
|
||||
$orchestratorTasks = $orchestratorData['total_count'] ?? 0;
|
||||
} catch (\Exception $e) {
|
||||
// Orchestrator not available
|
||||
}
|
||||
|
||||
return [
|
||||
'inbox' => [
|
||||
'open' => $inboxStats->open_count ?? 0,
|
||||
'unread' => $inboxStats->unread_count ?? 0,
|
||||
'urgent' => $inboxStats->urgent_count ?? 0,
|
||||
'route' => 'seller.business.crm.threads.index',
|
||||
],
|
||||
'deals' => [
|
||||
'open' => $dealStats->open_count ?? 0,
|
||||
'pipeline_value' => $dealStats->pipeline_value ?? 0,
|
||||
'won_this_month' => $dealStats->won_this_month ?? 0,
|
||||
'route' => 'seller.business.crm.deals.index',
|
||||
],
|
||||
'tasks' => [
|
||||
'pending' => $taskStats->pending_count ?? 0,
|
||||
'due_today' => $taskStats->due_today ?? 0,
|
||||
'overdue' => $taskStats->overdue_count ?? 0,
|
||||
'route' => 'seller.business.crm.tasks.index',
|
||||
],
|
||||
'calendar' => [
|
||||
'upcoming' => $upcomingMeetings,
|
||||
'today' => $todayMeetings,
|
||||
'route' => 'seller.business.crm.calendar.index',
|
||||
],
|
||||
'buyers' => [
|
||||
'total' => $buyerStats['total'],
|
||||
'at_risk' => $buyerStats['at_risk'],
|
||||
'high_value' => $buyerStats['high_value'],
|
||||
'route' => 'seller.business.buyer-intelligence.index',
|
||||
],
|
||||
'team' => [
|
||||
'sla_compliance' => $slaMetrics['compliance_rate'] ?? 100,
|
||||
'avg_response_time' => $slaMetrics['avg_response_time'] ?? 0,
|
||||
'route' => 'seller.business.crm.dashboard.team',
|
||||
],
|
||||
'orchestrator' => [
|
||||
'tasks' => $orchestratorTasks,
|
||||
'route' => 'seller.business.orchestrator.index',
|
||||
],
|
||||
'analytics' => [
|
||||
'route' => 'seller.business.dashboard.analytics',
|
||||
],
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,15 +73,27 @@ class OrderController extends Controller
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('order_number', 'like', "%{$search}%")
|
||||
$q->where('order_number', 'ILIKE', "%{$search}%")
|
||||
->orWhereHas('business', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%");
|
||||
$q->where('name', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$orders = $query->paginate(20)->withQueryString();
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $orders->map(fn ($o) => [
|
||||
'order_number' => $o->order_number,
|
||||
'name' => $o->order_number.' - '.$o->business->name,
|
||||
'customer' => $o->business->name,
|
||||
'status' => $o->status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.orders.index', compact('orders', 'business'));
|
||||
}
|
||||
|
||||
|
||||
249
app/Http/Controllers/Portal/CampaignController.php
Normal file
249
app/Http/Controllers/Portal/CampaignController.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\SendMarketingCampaignJob;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class CampaignController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$campaigns = MarketingCampaign::where('business_id', $business->id)
|
||||
->with('list')
|
||||
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
||||
->when($request->channel, fn ($q, $channel) => $q->where('channel', $channel))
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
$statuses = [
|
||||
'draft' => 'Draft',
|
||||
'scheduled' => 'Scheduled',
|
||||
'sending' => 'Sending',
|
||||
'sent' => 'Sent',
|
||||
'completed' => 'Completed',
|
||||
'cancelled' => 'Cancelled',
|
||||
'failed' => 'Failed',
|
||||
];
|
||||
|
||||
$channels = MarketingCampaign::CHANNELS;
|
||||
|
||||
return view('portal.campaigns.index', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'campaigns',
|
||||
'statuses',
|
||||
'channels'
|
||||
));
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
// Get lists for this business
|
||||
$lists = MarketingList::where('business_id', $business->id)
|
||||
->withCount('contacts')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Pre-populate from promo if provided
|
||||
$promo = null;
|
||||
if ($request->query('promo_id')) {
|
||||
$promo = MarketingPromo::where('business_id', $business->id)
|
||||
->find($request->query('promo_id'));
|
||||
}
|
||||
|
||||
// Pre-select channel if provided
|
||||
$preselectedChannel = $request->query('channel', 'email');
|
||||
|
||||
$channels = MarketingCampaign::CHANNELS;
|
||||
|
||||
return view('portal.campaigns.create', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'lists',
|
||||
'promo',
|
||||
'preselectedChannel',
|
||||
'channels'
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'channel' => 'required|in:email,sms',
|
||||
'list_id' => 'required|exists:marketing_lists,id',
|
||||
'subject' => 'required_if:channel,email|nullable|string|max:255',
|
||||
'body' => 'required|string',
|
||||
'send_at' => 'nullable|date|after:now',
|
||||
'promo_id' => 'nullable|exists:marketing_promos,id',
|
||||
]);
|
||||
|
||||
// Verify list belongs to this business
|
||||
$list = MarketingList::where('business_id', $business->id)
|
||||
->findOrFail($validated['list_id']);
|
||||
|
||||
// Build campaign data
|
||||
$campaignData = [
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'channel' => $validated['channel'],
|
||||
'list_id' => $list->id,
|
||||
'subject' => $validated['subject'] ?? null,
|
||||
'body' => $validated['body'],
|
||||
'status' => 'draft',
|
||||
'created_by' => Auth::id(),
|
||||
// Use branding defaults for from fields
|
||||
'from_name' => $branding->effective_from_name,
|
||||
'from_email' => $branding->effective_from_email,
|
||||
];
|
||||
|
||||
// Link to promo if provided
|
||||
if (! empty($validated['promo_id'])) {
|
||||
$promo = MarketingPromo::where('business_id', $business->id)
|
||||
->find($validated['promo_id']);
|
||||
|
||||
if ($promo) {
|
||||
$campaignData['source_type'] = 'promo';
|
||||
$campaignData['source_id'] = $promo->id;
|
||||
}
|
||||
}
|
||||
|
||||
// Set schedule if provided
|
||||
if (! empty($validated['send_at'])) {
|
||||
$campaignData['send_at'] = $validated['send_at'];
|
||||
$campaignData['status'] = 'scheduled';
|
||||
}
|
||||
|
||||
$campaign = MarketingCampaign::create($campaignData);
|
||||
|
||||
if ($campaign->status === 'scheduled') {
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', 'Campaign scheduled successfully.');
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', 'Campaign created as draft. Review and send when ready.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
$campaign->load(['list', 'logs']);
|
||||
|
||||
// Get stats
|
||||
$stats = [
|
||||
'total_recipients' => $campaign->total_recipients,
|
||||
'sent' => $campaign->total_sent,
|
||||
'delivered' => $campaign->total_delivered,
|
||||
'opened' => $campaign->total_opened,
|
||||
'clicked' => $campaign->total_clicked,
|
||||
'failed' => $campaign->total_failed,
|
||||
];
|
||||
|
||||
return view('portal.campaigns.show', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'campaign',
|
||||
'stats'
|
||||
));
|
||||
}
|
||||
|
||||
public function sendNow(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
||||
return back()->with('error', 'This campaign cannot be sent.');
|
||||
}
|
||||
|
||||
// Count recipients
|
||||
$recipientCount = $campaign->list?->contacts()->count() ?? 0;
|
||||
|
||||
if ($recipientCount === 0) {
|
||||
return back()->with('error', 'No recipients in the selected list.');
|
||||
}
|
||||
|
||||
// Update campaign
|
||||
$campaign->update([
|
||||
'status' => 'sending',
|
||||
'total_recipients' => $recipientCount,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
// Dispatch job
|
||||
SendMarketingCampaignJob::dispatch($campaign);
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', "Campaign is now sending to {$recipientCount} recipients.");
|
||||
}
|
||||
|
||||
public function schedule(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($campaign->status !== 'draft') {
|
||||
return back()->with('error', 'Only draft campaigns can be scheduled.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'send_at' => 'required|date|after:now',
|
||||
]);
|
||||
|
||||
$campaign->update([
|
||||
'status' => 'scheduled',
|
||||
'send_at' => $validated['send_at'],
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', 'Campaign scheduled for '.$campaign->send_at->format('M j, Y g:i A'));
|
||||
}
|
||||
|
||||
public function cancel(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
||||
return back()->with('error', 'This campaign cannot be cancelled.');
|
||||
}
|
||||
|
||||
$campaign->update([
|
||||
'status' => 'cancelled',
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.index', $business->slug)
|
||||
->with('success', 'Campaign cancelled.');
|
||||
}
|
||||
}
|
||||
80
app/Http/Controllers/Portal/DashboardController.php
Normal file
80
app/Http/Controllers/Portal/DashboardController.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Services\Marketing\PromoRecommendationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PromoRecommendationService $promoService
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
// Get recommended promos for this business
|
||||
$recommendedPromos = collect();
|
||||
try {
|
||||
// Get store external IDs for this business if available
|
||||
$storeExternalIds = $business->cannaiqStores()
|
||||
->pluck('external_id')
|
||||
->toArray();
|
||||
|
||||
if (! empty($storeExternalIds)) {
|
||||
$recommendations = $this->promoService->getRecommendations(
|
||||
$business,
|
||||
$storeExternalIds[0] ?? null,
|
||||
limit: 5
|
||||
);
|
||||
$recommendedPromos = collect($recommendations['recommendations'] ?? []);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// CannaiQ not configured or error - that's fine, show empty
|
||||
}
|
||||
|
||||
// Get recent campaigns for this business
|
||||
$recentCampaigns = MarketingCampaign::where('business_id', $business->id)
|
||||
->with('list')
|
||||
->latest()
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Get active promos
|
||||
$activePromos = MarketingPromo::forBusiness($business->id)
|
||||
->currentlyActive()
|
||||
->with('brand')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Get campaign stats
|
||||
$campaignStats = [
|
||||
'total' => MarketingCampaign::where('business_id', $business->id)->count(),
|
||||
'sent' => MarketingCampaign::where('business_id', $business->id)
|
||||
->whereIn('status', ['sent', 'completed'])
|
||||
->count(),
|
||||
'draft' => MarketingCampaign::where('business_id', $business->id)
|
||||
->where('status', 'draft')
|
||||
->count(),
|
||||
'scheduled' => MarketingCampaign::where('business_id', $business->id)
|
||||
->where('status', 'scheduled')
|
||||
->count(),
|
||||
];
|
||||
|
||||
return view('portal.dashboard', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'recommendedPromos',
|
||||
'recentCampaigns',
|
||||
'activePromos',
|
||||
'campaignStats'
|
||||
));
|
||||
}
|
||||
}
|
||||
83
app/Http/Controllers/Portal/ListController.php
Normal file
83
app/Http/Controllers/Portal/ListController.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ListController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$lists = MarketingList::where('business_id', $business->id)
|
||||
->withCount('contacts')
|
||||
->orderBy('name')
|
||||
->paginate(15);
|
||||
|
||||
return view('portal.lists.index', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'lists'
|
||||
));
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
$types = MarketingList::getTypes();
|
||||
|
||||
return view('portal.lists.create', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'types'
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|in:static,smart',
|
||||
]);
|
||||
|
||||
$list = MarketingList::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'],
|
||||
'type' => $validated['type'],
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('portal.lists.show', [$business->slug, $list])
|
||||
->with('success', 'List created successfully.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, MarketingList $list)
|
||||
{
|
||||
// Ensure list belongs to this business
|
||||
if ($list->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$contacts = $list->contacts()
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(25);
|
||||
|
||||
return view('portal.lists.show', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'list',
|
||||
'contacts'
|
||||
));
|
||||
}
|
||||
}
|
||||
75
app/Http/Controllers/Portal/PromoController.php
Normal file
75
app/Http/Controllers/Portal/PromoController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Services\Marketing\PromoRecommendationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PromoController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PromoRecommendationService $promoService
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
// Get recommended promos from CannaiQ
|
||||
$recommendedPromos = collect();
|
||||
try {
|
||||
$storeExternalIds = $business->cannaiqStores()
|
||||
->pluck('external_id')
|
||||
->toArray();
|
||||
|
||||
if (! empty($storeExternalIds)) {
|
||||
$recommendations = $this->promoService->getRecommendations(
|
||||
$business,
|
||||
$storeExternalIds[0] ?? null,
|
||||
limit: 20
|
||||
);
|
||||
$recommendedPromos = collect($recommendations['recommendations'] ?? []);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// CannaiQ not available
|
||||
}
|
||||
|
||||
// Get existing promos for this business
|
||||
$existingPromos = MarketingPromo::forBusiness($business->id)
|
||||
->with('brand')
|
||||
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
||||
->latest()
|
||||
->paginate(12);
|
||||
|
||||
$statuses = MarketingPromo::getStatuses();
|
||||
|
||||
return view('portal.promos.index', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'recommendedPromos',
|
||||
'existingPromos',
|
||||
'statuses'
|
||||
));
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, MarketingPromo $promo)
|
||||
{
|
||||
// Ensure promo belongs to this business
|
||||
if ($promo->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
$promo->load('brand');
|
||||
|
||||
return view('portal.promos.show', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'promo'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ class BatchController extends Controller
|
||||
->where('quantity_available', '>', 0)
|
||||
->where('is_active', true)
|
||||
->where('is_quarantined', false)
|
||||
->with('component')
|
||||
->with('product')
|
||||
->orderBy('batch_number')
|
||||
->get()
|
||||
->map(function ($batch) {
|
||||
@@ -102,17 +102,28 @@ class BatchController extends Controller
|
||||
$maxValue = ($request->cannabinoid_unit ?? '%') === '%' ? 100 : 1000;
|
||||
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
// Accept either product_id or component_id (form sends component_id)
|
||||
'product_id' => 'required_without:component_id|exists:products,id',
|
||||
'component_id' => 'required_without:product_id|exists:products,id',
|
||||
'batch_type' => 'nullable|string|in:component,homogenized',
|
||||
'cannabinoid_unit' => 'nullable|string|in:%,MG/ML,MG/G,MG/UNIT',
|
||||
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number',
|
||||
'quantity_produced' => 'nullable|integer|min:0',
|
||||
'batch_number' => 'required|string|max:100|unique:batches,batch_number',
|
||||
'internal_code' => 'nullable|string|max:100',
|
||||
// Accept either quantity_produced or quantity_total (form sends quantity_total)
|
||||
'quantity_produced' => 'nullable|numeric|min:0',
|
||||
'quantity_total' => 'nullable|numeric|min:0',
|
||||
'quantity_remaining' => 'nullable|numeric|min:0',
|
||||
'quantity_unit' => 'nullable|string|max:50',
|
||||
'quantity_allocated' => 'nullable|integer|min:0',
|
||||
'expiration_date' => 'nullable|date',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'is_active' => 'nullable',
|
||||
'production_date' => 'nullable|date',
|
||||
'harvest_date' => 'nullable|date',
|
||||
'package_date' => 'nullable|date',
|
||||
'test_date' => 'nullable|date',
|
||||
'test_id' => 'nullable|string|max:100',
|
||||
'lot_number' => 'nullable|string|max:100',
|
||||
'license_number' => 'nullable|string|max:255',
|
||||
'lab_name' => 'nullable|string|max:255',
|
||||
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
@@ -126,10 +137,18 @@ class BatchController extends Controller
|
||||
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
|
||||
]);
|
||||
|
||||
// Map component_id to product_id if provided
|
||||
$productId = $validated['product_id'] ?? $validated['component_id'];
|
||||
|
||||
// Verify product belongs to this business
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($validated['product_id']);
|
||||
})->findOrFail($productId);
|
||||
|
||||
// Map form fields to model fields
|
||||
$validated['product_id'] = $productId;
|
||||
$validated['quantity_produced'] = $validated['quantity_total'] ?? $validated['quantity_produced'] ?? 0;
|
||||
$validated['quantity_available'] = $validated['quantity_remaining'] ?? $validated['quantity_produced'];
|
||||
|
||||
// Set business_id and defaults
|
||||
$validated['business_id'] = $business->id;
|
||||
|
||||
@@ -9,12 +9,14 @@ use App\Http\Requests\UpdateBrandRequest;
|
||||
use App\Models\Brand;
|
||||
use App\Models\BrandOrchestratorProfile;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmChannel;
|
||||
use App\Models\Menu;
|
||||
use App\Models\OrchestratorTask;
|
||||
use App\Models\PromoRecommendation;
|
||||
use App\Models\Promotion;
|
||||
use App\Services\Promo\InBrandPromoHelper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -42,7 +44,29 @@ class BrandController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.brands.index', compact('business', 'brands'));
|
||||
// Pre-compute expensive operations for Alpine.js (prevents N+1 route() calls in Blade)
|
||||
$brandsJson = $brands->filter(fn ($brand) => $brand->hashid)->map(function ($brand) use ($business) {
|
||||
return [
|
||||
'id' => $brand->id,
|
||||
'hashid' => $brand->hashid,
|
||||
'name' => $brand->name,
|
||||
'tagline' => $brand->tagline,
|
||||
'logo_url' => $brand->hasLogo() ? $brand->getLogoUrl(160) : null,
|
||||
'is_active' => $brand->is_active,
|
||||
'is_public' => $brand->is_public,
|
||||
'is_featured' => $brand->is_featured,
|
||||
'products_count' => $brand->products_count ?? 0,
|
||||
'updated_at' => $brand->updated_at?->diffForHumans(),
|
||||
'website_url' => $brand->website_url,
|
||||
'preview_url' => route('seller.business.brands.preview', [$business->slug, $brand]),
|
||||
'dashboard_url' => route('seller.business.brands.dashboard', [$business->slug, $brand]),
|
||||
'stats_url' => route('seller.business.brands.stats', [$business->slug, $brand]),
|
||||
'edit_url' => route('seller.business.brands.edit', [$business->slug, $brand]),
|
||||
'isNewBrand' => $brand->created_at && $brand->created_at->diffInDays(now()) <= 30,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return view('seller.brands.index', compact('business', 'brands', 'brandsJson'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,121 +169,179 @@ class BrandController extends Controller
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// Load relationships
|
||||
// Determine active tab - only load data for that tab
|
||||
$activeTab = $request->input('tab', 'overview');
|
||||
|
||||
// Load minimal brand data with products for metrics display
|
||||
$brand->load(['business', 'products']);
|
||||
|
||||
// Get stats data for Analytics tab (default to this month)
|
||||
$preset = $request->input('preset', 'this_month');
|
||||
$startDate = null;
|
||||
$endDate = null;
|
||||
|
||||
switch ($preset) {
|
||||
case 'this_week':
|
||||
$startDate = now()->startOfWeek();
|
||||
$endDate = now()->endOfWeek();
|
||||
break;
|
||||
case 'last_week':
|
||||
$startDate = now()->subWeek()->startOfWeek();
|
||||
$endDate = now()->subWeek()->endOfWeek();
|
||||
break;
|
||||
case 'this_month':
|
||||
$startDate = now()->startOfMonth();
|
||||
$endDate = now()->endOfMonth();
|
||||
break;
|
||||
case 'last_month':
|
||||
$startDate = now()->subMonth()->startOfMonth();
|
||||
$endDate = now()->subMonth()->endOfMonth();
|
||||
break;
|
||||
case 'this_year':
|
||||
$startDate = now()->startOfYear();
|
||||
$endDate = now()->endOfYear();
|
||||
break;
|
||||
case 'custom':
|
||||
$startDate = $request->input('start_date') ? \Carbon\Carbon::parse($request->input('start_date'))->startOfDay() : now()->startOfMonth();
|
||||
$endDate = $request->input('end_date') ? \Carbon\Carbon::parse($request->input('end_date'))->endOfDay() : now();
|
||||
break;
|
||||
case 'all_time':
|
||||
default:
|
||||
// Query from earliest order for this brand, or default to brand creation date if no orders
|
||||
$earliestOrder = \App\Models\Order::whereHas('items.product', function ($query) use ($brand) {
|
||||
$query->where('brand_id', $brand->id);
|
||||
})->oldest('created_at')->first();
|
||||
|
||||
// If no orders, use the brand's creation date as the starting point
|
||||
$startDate = $earliestOrder
|
||||
? $earliestOrder->created_at->startOfDay()
|
||||
: ($brand->created_at ? $brand->created_at->startOfDay() : now()->subYears(3)->startOfDay());
|
||||
$endDate = now()->endOfDay();
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate stats for analytics tab
|
||||
$stats = $this->calculateBrandStats($brand, $startDate, $endDate);
|
||||
|
||||
// Load promotions filtered by brand
|
||||
$promotions = Promotion::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->withCount('products')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
// Load upcoming promotions (scheduled within next 7 days)
|
||||
$upcomingPromotions = Promotion::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->upcomingWithinDays(7)
|
||||
->withCount('products')
|
||||
->orderBy('starts_at', 'asc')
|
||||
->get();
|
||||
|
||||
// Load active promotions for quick display
|
||||
$activePromotions = Promotion::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->active()
|
||||
->withCount('products')
|
||||
->orderBy('ends_at', 'asc')
|
||||
->get();
|
||||
|
||||
// Load menus filtered by brand
|
||||
$menus = Menu::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->withCount('products')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
// Load promo recommendations for this brand
|
||||
$recommendations = PromoRecommendation::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->pending()
|
||||
->notExpired()
|
||||
->with(['product'])
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN priority = 'high' THEN 1
|
||||
WHEN priority = 'medium' THEN 2
|
||||
WHEN priority = 'low' THEN 3
|
||||
ELSE 4
|
||||
END
|
||||
")
|
||||
->orderByDesc('confidence')
|
||||
->get();
|
||||
|
||||
// Load all brands for the brand selector dropdown
|
||||
// Load all brands for the brand selector dropdown (lightweight, always needed)
|
||||
$brands = $business->brands()
|
||||
->where('is_active', true)
|
||||
->withCount('products')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Load products for this brand (newest first) with pagination
|
||||
// Get date range for stats (used by overview and analytics)
|
||||
$preset = $request->input('preset', 'this_month');
|
||||
[$startDate, $endDate] = $this->getDateRangeForPreset($preset, $request, $brand);
|
||||
|
||||
// Initialize empty data - will be populated based on active tab
|
||||
$viewData = [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
'brands' => $brands,
|
||||
'preset' => $preset,
|
||||
'startDate' => $startDate,
|
||||
'endDate' => $endDate,
|
||||
'activeTab' => $activeTab,
|
||||
// Empty defaults for all tab data
|
||||
'promotions' => collect(),
|
||||
'activePromotions' => collect(),
|
||||
'upcomingPromotions' => collect(),
|
||||
'recommendations' => collect(),
|
||||
'menus' => collect(),
|
||||
'products' => collect(),
|
||||
'productsPagination' => [],
|
||||
'productsPaginator' => null,
|
||||
'collections' => collect(),
|
||||
'brandInsights' => [],
|
||||
// Empty stats defaults
|
||||
'totalOrders' => 0,
|
||||
'totalRevenue' => 0,
|
||||
'totalUnits' => 0,
|
||||
'avgOrderValue' => 0,
|
||||
'totalProducts' => 0,
|
||||
'activeProducts' => 0,
|
||||
'revenueChange' => 0,
|
||||
'ordersChange' => 0,
|
||||
'revenueByDay' => collect(),
|
||||
'productStats' => collect(),
|
||||
'bestSellingSku' => null,
|
||||
'topBuyers' => collect(),
|
||||
];
|
||||
|
||||
// Load data based on active tab
|
||||
switch ($activeTab) {
|
||||
case 'overview':
|
||||
$viewData = array_merge($viewData, $this->loadOverviewTabData($brand, $business, $startDate, $endDate));
|
||||
break;
|
||||
case 'products':
|
||||
$viewData = array_merge($viewData, $this->loadProductsTabData($brand, $business, $request));
|
||||
break;
|
||||
case 'promotions':
|
||||
$viewData = array_merge($viewData, $this->loadPromotionsTabData($brand, $business));
|
||||
break;
|
||||
case 'menus':
|
||||
$viewData = array_merge($viewData, $this->loadMenusTabData($brand, $business));
|
||||
break;
|
||||
case 'analytics':
|
||||
$viewData = array_merge($viewData, $this->loadAnalyticsTabData($brand, $business, $startDate, $endDate, $preset));
|
||||
break;
|
||||
case 'settings':
|
||||
case 'storefront':
|
||||
case 'collections':
|
||||
// These tabs don't need additional data loading
|
||||
break;
|
||||
}
|
||||
|
||||
return view('seller.brands.dashboard', $viewData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date range based on preset selection.
|
||||
*/
|
||||
private function getDateRangeForPreset(string $preset, Request $request, Brand $brand): array
|
||||
{
|
||||
switch ($preset) {
|
||||
case 'this_week':
|
||||
return [now()->startOfWeek(), now()->endOfWeek()];
|
||||
case 'last_week':
|
||||
return [now()->subWeek()->startOfWeek(), now()->subWeek()->endOfWeek()];
|
||||
case 'this_month':
|
||||
return [now()->startOfMonth(), now()->endOfMonth()];
|
||||
case 'last_month':
|
||||
return [now()->subMonth()->startOfMonth(), now()->subMonth()->endOfMonth()];
|
||||
case 'this_year':
|
||||
return [now()->startOfYear(), now()->endOfYear()];
|
||||
case 'custom':
|
||||
$startDate = $request->input('start_date') ? \Carbon\Carbon::parse($request->input('start_date'))->startOfDay() : now()->startOfMonth();
|
||||
$endDate = $request->input('end_date') ? \Carbon\Carbon::parse($request->input('end_date'))->endOfDay() : now();
|
||||
|
||||
return [$startDate, $endDate];
|
||||
case 'all_time':
|
||||
default:
|
||||
$earliestOrder = \App\Models\Order::whereHas('items.product', function ($query) use ($brand) {
|
||||
$query->where('brand_id', $brand->id);
|
||||
})->oldest('created_at')->first();
|
||||
|
||||
$startDate = $earliestOrder
|
||||
? $earliestOrder->created_at->startOfDay()
|
||||
: ($brand->created_at ? $brand->created_at->startOfDay() : now()->subYears(3)->startOfDay());
|
||||
|
||||
return [$startDate, now()->endOfDay()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data for Overview tab (lightweight stats + insights).
|
||||
*/
|
||||
private function loadOverviewTabData(Brand $brand, Business $business, $startDate, $endDate): array
|
||||
{
|
||||
// Cache brand insights for 15 minutes
|
||||
$cacheKey = "brand:{$brand->id}:insights:{$startDate->format('Y-m-d')}:{$endDate->format('Y-m-d')}";
|
||||
$brandInsights = Cache::remember($cacheKey, 900, fn () => $this->calculateBrandInsights($brand, $business, $startDate, $endDate));
|
||||
|
||||
// Load active promotions for quick display (lightweight)
|
||||
$activePromotions = Promotion::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->active()
|
||||
->withCount('products')
|
||||
->orderBy('ends_at', 'asc')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Load recommendations (lightweight - limit to 5)
|
||||
$recommendations = PromoRecommendation::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->pending()
|
||||
->notExpired()
|
||||
->with(['product'])
|
||||
->orderByRaw("CASE WHEN priority = 'high' THEN 1 WHEN priority = 'medium' THEN 2 WHEN priority = 'low' THEN 3 ELSE 4 END")
|
||||
->orderByDesc('confidence')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Get basic counts (very fast single query)
|
||||
$productCounts = $brand->products()
|
||||
->selectRaw('COUNT(*) as total, SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'brandInsights' => $brandInsights,
|
||||
'activePromotions' => $activePromotions,
|
||||
'recommendations' => $recommendations,
|
||||
'totalProducts' => $productCounts->total ?? 0,
|
||||
'activeProducts' => $productCounts->active ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data for Products tab.
|
||||
*/
|
||||
private function loadProductsTabData(Brand $brand, Business $business, Request $request): array
|
||||
{
|
||||
$perPage = $request->get('per_page', 50);
|
||||
$productsPaginator = $brand->products()
|
||||
->whereNotNull('hashid')
|
||||
->where('hashid', '!=', '')
|
||||
->with('images')
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($perPage);
|
||||
|
||||
$products = $productsPaginator->getCollection()
|
||||
->filter(fn ($product) => ! empty($product->hashid))
|
||||
->map(function ($product) use ($business, $brand) {
|
||||
// Set brand relationship so getImageUrl() can fall back to brand logo
|
||||
$product->setRelation('brand', $brand);
|
||||
|
||||
return [
|
||||
@@ -275,35 +357,101 @@ class BrandController extends Controller
|
||||
'edit_url' => route('seller.business.products.edit', [$business->slug, $product->hashid]),
|
||||
'preview_url' => route('seller.business.products.preview', [$business->slug, $product->hashid]),
|
||||
];
|
||||
});
|
||||
})
|
||||
->values();
|
||||
|
||||
// Pagination info for the view
|
||||
$productsPagination = [
|
||||
'current_page' => $productsPaginator->currentPage(),
|
||||
'last_page' => $productsPaginator->lastPage(),
|
||||
'per_page' => $productsPaginator->perPage(),
|
||||
'total' => $productsPaginator->total(),
|
||||
'from' => $productsPaginator->firstItem(),
|
||||
'to' => $productsPaginator->lastItem(),
|
||||
];
|
||||
|
||||
return view('seller.brands.dashboard', array_merge($stats, [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
'brands' => $brands,
|
||||
'preset' => $preset,
|
||||
'startDate' => $startDate,
|
||||
'endDate' => $endDate,
|
||||
'promotions' => $promotions,
|
||||
'activePromotions' => $activePromotions,
|
||||
'upcomingPromotions' => $upcomingPromotions,
|
||||
'recommendations' => $recommendations,
|
||||
'menus' => $menus,
|
||||
return [
|
||||
'products' => $products,
|
||||
'productsPagination' => $productsPagination,
|
||||
'productsPagination' => [
|
||||
'current_page' => $productsPaginator->currentPage(),
|
||||
'last_page' => $productsPaginator->lastPage(),
|
||||
'per_page' => $productsPaginator->perPage(),
|
||||
'total' => $productsPaginator->total(),
|
||||
'from' => $productsPaginator->firstItem(),
|
||||
'to' => $productsPaginator->lastItem(),
|
||||
],
|
||||
'productsPaginator' => $productsPaginator,
|
||||
'collections' => collect(), // Placeholder for future collections feature
|
||||
]));
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data for Promotions tab.
|
||||
*/
|
||||
private function loadPromotionsTabData(Brand $brand, Business $business): array
|
||||
{
|
||||
$promotions = Promotion::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->withCount('products')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$upcomingPromotions = Promotion::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->upcomingWithinDays(7)
|
||||
->withCount('products')
|
||||
->orderBy('starts_at', 'asc')
|
||||
->get();
|
||||
|
||||
$activePromotions = Promotion::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->active()
|
||||
->withCount('products')
|
||||
->orderBy('ends_at', 'asc')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'promotions' => $promotions,
|
||||
'upcomingPromotions' => $upcomingPromotions,
|
||||
'activePromotions' => $activePromotions,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data for Menus tab.
|
||||
*/
|
||||
private function loadMenusTabData(Brand $brand, Business $business): array
|
||||
{
|
||||
$menus = Menu::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->withCount('products')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return ['menus' => $menus];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data for Analytics tab (cached for 15 minutes).
|
||||
*/
|
||||
private function loadAnalyticsTabData(Brand $brand, Business $business, $startDate, $endDate, string $preset): array
|
||||
{
|
||||
// Cache stats for 15 minutes (keyed by brand + date range)
|
||||
$cacheKey = "brand:{$brand->id}:stats:{$preset}:{$startDate->format('Y-m-d')}:{$endDate->format('Y-m-d')}";
|
||||
|
||||
return Cache::remember($cacheKey, 900, fn () => $this->calculateBrandStats($brand, $startDate, $endDate));
|
||||
}
|
||||
|
||||
/**
|
||||
* API endpoint for lazy-loading tab data via AJAX.
|
||||
*/
|
||||
public function tabData(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
$tab = $request->input('tab', 'overview');
|
||||
$preset = $request->input('preset', 'this_month');
|
||||
[$startDate, $endDate] = $this->getDateRangeForPreset($preset, $request, $brand);
|
||||
|
||||
$data = match ($tab) {
|
||||
'overview' => $this->loadOverviewTabData($brand, $business, $startDate, $endDate),
|
||||
'products' => $this->loadProductsTabData($brand, $business, $request),
|
||||
'promotions' => $this->loadPromotionsTabData($brand, $business),
|
||||
'menus' => $this->loadMenusTabData($brand, $business),
|
||||
'analytics' => $this->loadAnalyticsTabData($brand, $business, $startDate, $endDate, $preset),
|
||||
default => [],
|
||||
};
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,7 +507,14 @@ class BrandController extends Controller
|
||||
{
|
||||
$this->authorize('update', [$brand, $business]);
|
||||
|
||||
return view('seller.brands.edit', compact('business', 'brand'));
|
||||
// Get available email channels for CRM inbound routing
|
||||
$emailChannels = CrmChannel::forBusiness($business->id)
|
||||
->where('type', CrmChannel::TYPE_EMAIL)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.brands.edit', compact('business', 'brand', 'emailChannels'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -456,6 +611,19 @@ class BrandController extends Controller
|
||||
$brand->inbound_email = $request->input('inbound_email');
|
||||
$brand->sms_number = $request->input('sms_number');
|
||||
|
||||
// CRM Channel Assignment (validate channel belongs to this business)
|
||||
if ($request->has('inbound_email_channel_id')) {
|
||||
$channelId = $request->input('inbound_email_channel_id');
|
||||
if ($channelId) {
|
||||
$channel = CrmChannel::where('business_id', $business->id)
|
||||
->where('id', $channelId)
|
||||
->first();
|
||||
$validated['inbound_email_channel_id'] = $channel ? $channel->id : null;
|
||||
} else {
|
||||
$validated['inbound_email_channel_id'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update brand
|
||||
$brand->update($validated);
|
||||
|
||||
@@ -1711,4 +1879,214 @@ class BrandController extends Controller
|
||||
->route('seller.business.brands.index', $business->slug)
|
||||
->with('success', 'Brand deleted successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate lightweight brand insights for the dashboard
|
||||
*/
|
||||
private function calculateBrandInsights(Brand $brand, Business $business, $startDate, $endDate): array
|
||||
{
|
||||
// Eager load images to avoid N+1 and lazy loading errors
|
||||
$products = $brand->products()->with('images')->get();
|
||||
|
||||
// Top Performer - product with highest revenue in date range
|
||||
$topPerformer = null;
|
||||
$topPerformerData = \App\Models\Order::whereHas('items.product', function ($query) use ($brand) {
|
||||
$query->where('brand_id', $brand->id);
|
||||
})
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->whereIn('status', ['confirmed', 'completed', 'shipped', 'delivered'])
|
||||
->with(['items.product' => function ($query) use ($brand) {
|
||||
$query->where('brand_id', $brand->id);
|
||||
}])
|
||||
->get()
|
||||
->flatMap(function ($order) use ($brand) {
|
||||
return $order->items->filter(function ($item) use ($brand) {
|
||||
return $item->product && $item->product->brand_id === $brand->id;
|
||||
});
|
||||
})
|
||||
->groupBy('product_id')
|
||||
->map(function ($items) {
|
||||
$product = $items->first()->product;
|
||||
|
||||
return [
|
||||
'product' => $product,
|
||||
'revenue' => $items->sum(function ($item) {
|
||||
return $item->quantity * $item->price;
|
||||
}),
|
||||
'orders' => $items->count(),
|
||||
];
|
||||
})
|
||||
->sortByDesc('revenue')
|
||||
->first();
|
||||
|
||||
if ($topPerformerData) {
|
||||
$topPerformer = [
|
||||
'name' => $topPerformerData['product']->name,
|
||||
'hashid' => $topPerformerData['product']->hashid,
|
||||
'revenue' => $topPerformerData['revenue'],
|
||||
'orders' => $topPerformerData['orders'],
|
||||
];
|
||||
}
|
||||
|
||||
// Needs Attention - aggregate counts for quick issues
|
||||
$missingImages = $products->filter(fn ($p) => empty($p->image_path) && $p->images->isEmpty())->count();
|
||||
$hiddenProducts = $products->filter(fn ($p) => ! $p->is_active)->count();
|
||||
$draftProducts = $products->filter(fn ($p) => $p->status === 'draft')->count();
|
||||
// Note: Out of stock would require inventory data - hardcoded to 0 for now
|
||||
$outOfStock = 0;
|
||||
|
||||
$totalIssues = $missingImages + $hiddenProducts + $draftProducts + $outOfStock;
|
||||
|
||||
// Visibility Issues - hidden + draft count
|
||||
$visibilityIssues = $hiddenProducts + $draftProducts;
|
||||
|
||||
return [
|
||||
'topPerformer' => $topPerformer,
|
||||
'needsAttention' => [
|
||||
'total' => $totalIssues,
|
||||
'missingImages' => $missingImages,
|
||||
'hiddenProducts' => $hiddenProducts,
|
||||
'draftProducts' => $draftProducts,
|
||||
'outOfStock' => $outOfStock,
|
||||
],
|
||||
'visibilityIssues' => $visibilityIssues,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Display brand market analysis / intelligence page.
|
||||
*
|
||||
* v4 endpoint with optional store_id filtering for per-store projections.
|
||||
*/
|
||||
public function analysis(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// CannaiQ must be enabled to access Brand Analysis
|
||||
if (! $business->cannaiq_enabled) {
|
||||
return view('seller.brands.analysis-disabled', [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
]);
|
||||
}
|
||||
|
||||
// v4: Get optional store_id filter for shelf value projections
|
||||
$storeId = $request->query('store_id');
|
||||
|
||||
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
|
||||
$analysis = $analysisService->getAnalysis($brand, $business, $storeId);
|
||||
|
||||
// Load all brands for the brand selector
|
||||
$brands = $business->brands()
|
||||
->where('is_active', true)
|
||||
->withCount('products')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Build store list from placement data for store selector
|
||||
$storeList = [];
|
||||
if ((bool) $business->cannaiq_enabled) {
|
||||
$placementStores = $analysis->placement['stores'] ?? $analysis->placement ?? [];
|
||||
$whitespaceStores = $analysis->placement['whitespaceStores'] ?? [];
|
||||
|
||||
foreach ($placementStores as $store) {
|
||||
$storeList[] = [
|
||||
'id' => $store['storeId'] ?? '',
|
||||
'name' => $store['storeName'] ?? 'Unknown',
|
||||
'state' => $store['state'] ?? null,
|
||||
];
|
||||
}
|
||||
foreach ($whitespaceStores as $store) {
|
||||
$storeList[] = [
|
||||
'id' => $store['storeId'] ?? '',
|
||||
'name' => $store['storeName'] ?? 'Unknown',
|
||||
'state' => $store['state'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return view('seller.brands.analysis', [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
'brands' => $brands,
|
||||
'analysis' => $analysis,
|
||||
'cannaiqEnabled' => (bool) $business->cannaiq_enabled,
|
||||
'storeList' => $storeList,
|
||||
'selectedStoreId' => $storeId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh brand analysis data (clears cache and re-fetches).
|
||||
*/
|
||||
public function analysisRefresh(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// CannaiQ must be enabled to refresh analysis
|
||||
if (! $business->cannaiq_enabled) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'CannaiQ is not enabled for this business. Please contact support.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return back()->with('error', 'CannaiQ is not enabled for this business.');
|
||||
}
|
||||
|
||||
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
|
||||
$analysis = $analysisService->refreshAnalysis($brand, $business);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Analysis data refreshed',
|
||||
'data' => $analysis->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.analysis', [$business->slug, $brand->hashid])
|
||||
->with('success', 'Analysis data refreshed successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store-level playbook for a specific store.
|
||||
*
|
||||
* Returns targeted recommendations for a single retail account.
|
||||
*/
|
||||
public function storePlaybook(Request $request, Business $business, Brand $brand, string $storeId)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
if (! $business->cannaiq_enabled) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'CannaiQ is not enabled for this business',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return back()->with('error', 'CannaiQ is not enabled for this business');
|
||||
}
|
||||
|
||||
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
|
||||
$playbook = $analysisService->getStorePlaybook($brand, $business, $storeId);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $playbook,
|
||||
]);
|
||||
}
|
||||
|
||||
// For non-JSON requests, redirect to analysis page with store selected
|
||||
return redirect()
|
||||
->route('seller.business.brands.analysis', [
|
||||
$business->slug,
|
||||
$brand->hashid,
|
||||
'store_id' => $storeId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,22 @@ class BrandSwitcherController extends Controller
|
||||
{
|
||||
$brandId = $request->input('brand_id');
|
||||
$brandHashid = $request->input('brand_hashid');
|
||||
$redirectTo = $request->input('redirect_to');
|
||||
|
||||
// If both are empty, clear the session (show all brands)
|
||||
if (empty($brandId) && empty($brandHashid)) {
|
||||
// Clear cache for current user before removing session
|
||||
$user = auth()->user();
|
||||
$business = $user?->primaryBusiness();
|
||||
$oldBrandId = session('selected_brand_id');
|
||||
|
||||
if ($user && $business && $oldBrandId) {
|
||||
\Illuminate\Support\Facades\Cache::forget("selected_brand:{$user->id}:{$business->id}:{$oldBrandId}");
|
||||
}
|
||||
|
||||
session()->forget('selected_brand_id');
|
||||
|
||||
return back();
|
||||
return $redirectTo ? redirect($redirectTo) : back();
|
||||
}
|
||||
|
||||
// Verify the brand exists and belongs to user's business
|
||||
|
||||
@@ -14,12 +14,17 @@ class ContactController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of contacts (CRM Core)
|
||||
* Shows all contacts who have interacted with this seller business
|
||||
* Shows all contacts from buyer businesses (accounts)
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Get all contact IDs that have interacted with this business
|
||||
// through orders, conversations, or messages
|
||||
// Get all contacts from buyer businesses (accounts)
|
||||
// This gives a complete view of all contacts in the CRM
|
||||
$query = Contact::whereHas('business', function ($q) {
|
||||
$q->where('type', 'buyer');
|
||||
})->with(['business', 'user']);
|
||||
|
||||
// Also track which contacts have engaged for stats
|
||||
$orderContactIds = Order::whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})->whereNotNull('contact_id')->pluck('contact_id');
|
||||
@@ -28,11 +33,7 @@ class ContactController extends Controller
|
||||
->whereNotNull('primary_contact_id')
|
||||
->pluck('primary_contact_id');
|
||||
|
||||
$contactIds = $orderContactIds->merge($conversationContactIds)->unique();
|
||||
|
||||
// Build query
|
||||
$query = Contact::whereIn('id', $contactIds)
|
||||
->with(['business', 'user']);
|
||||
$engagedContactIds = $orderContactIds->merge($conversationContactIds)->unique();
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('search')) {
|
||||
@@ -60,6 +61,8 @@ class ContactController extends Controller
|
||||
$query->whereIn('id', $orderContactIds);
|
||||
} elseif ($request->activity === 'has_conversations') {
|
||||
$query->whereIn('id', $conversationContactIds);
|
||||
} elseif ($request->activity === 'engaged') {
|
||||
$query->whereIn('id', $engagedContactIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,12 +78,14 @@ class ContactController extends Controller
|
||||
|
||||
$contacts = $query->paginate(20)->withQueryString();
|
||||
|
||||
// Get stats
|
||||
// Get stats - count all buyer contacts and engaged contacts
|
||||
$allBuyerContactsQuery = Contact::whereHas('business', fn ($q) => $q->where('type', 'buyer'));
|
||||
$stats = [
|
||||
'total' => Contact::whereIn('id', $contactIds)->count(),
|
||||
'active' => Contact::whereIn('id', $contactIds)->where('is_active', true)->count(),
|
||||
'with_orders' => Contact::whereIn('id', $orderContactIds)->count(),
|
||||
'with_conversations' => Contact::whereIn('id', $conversationContactIds)->count(),
|
||||
'total' => (clone $allBuyerContactsQuery)->count(),
|
||||
'active' => (clone $allBuyerContactsQuery)->where('is_active', true)->count(),
|
||||
'with_orders' => $orderContactIds->count(),
|
||||
'with_conversations' => $conversationContactIds->count(),
|
||||
'engaged' => $engagedContactIds->count(),
|
||||
];
|
||||
|
||||
return view('seller.contacts.index', compact('business', 'contacts', 'stats'));
|
||||
|
||||
@@ -5,8 +5,11 @@ namespace App\Http\Controllers\Seller\Crm;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Crm\CrmEvent;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\SalesOpportunity;
|
||||
use App\Models\SendMenuLog;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -18,15 +21,154 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->with(['contacts'])
|
||||
->orderBy('name')
|
||||
->paginate(25);
|
||||
$query = Business::where('type', 'buyer')
|
||||
->with(['contacts']);
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('q')) {
|
||||
$search = $request->q;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('dba_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('business_email', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Status filter - default to approved, but allow viewing all
|
||||
if ($request->filled('status') && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
} else {
|
||||
$query->where('status', 'approved');
|
||||
}
|
||||
|
||||
$accounts = $query->orderBy('name')->paginate(25);
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $accounts->map(fn ($a) => [
|
||||
'slug' => $a->slug,
|
||||
'name' => $a->name,
|
||||
'email' => $a->business_email,
|
||||
'status' => $a->status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.crm.accounts.index', compact('business', 'accounts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create customer form
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
return view('seller.crm.accounts.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new customer (buyer business)
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'license_number' => 'nullable|string|max:100',
|
||||
'business_email' => 'nullable|email|max:255',
|
||||
'business_phone' => 'nullable|string|max:50',
|
||||
'physical_address' => 'nullable|string|max:255',
|
||||
'physical_city' => 'nullable|string|max:100',
|
||||
'physical_state' => 'nullable|string|max:50',
|
||||
'physical_zipcode' => 'nullable|string|max:20',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_title' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
// Create the buyer business
|
||||
$account = Business::create([
|
||||
'name' => $validated['name'],
|
||||
'dba_name' => $validated['dba_name'] ?? null,
|
||||
'license_number' => $validated['license_number'] ?? null,
|
||||
'business_email' => $validated['business_email'] ?? null,
|
||||
'business_phone' => $validated['business_phone'] ?? null,
|
||||
'physical_address' => $validated['physical_address'] ?? null,
|
||||
'physical_city' => $validated['physical_city'] ?? null,
|
||||
'physical_state' => $validated['physical_state'] ?? null,
|
||||
'physical_zipcode' => $validated['physical_zipcode'] ?? null,
|
||||
'type' => 'buyer',
|
||||
'status' => 'approved', // Auto-approve customers created by sellers
|
||||
]);
|
||||
|
||||
// Create contact if provided
|
||||
if (! empty($validated['contact_name'])) {
|
||||
$account->contacts()->create([
|
||||
'first_name' => explode(' ', $validated['contact_name'])[0],
|
||||
'last_name' => implode(' ', array_slice(explode(' ', $validated['contact_name']), 1)) ?: null,
|
||||
'email' => $validated['contact_email'] ?? null,
|
||||
'phone' => $validated['contact_phone'] ?? null,
|
||||
'title' => $validated['contact_title'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Log the creation event
|
||||
CrmEvent::log(
|
||||
sellerBusinessId: $business->id,
|
||||
eventType: 'account_created',
|
||||
summary: "Customer {$account->name} created",
|
||||
buyerBusinessId: $account->id,
|
||||
userId: auth()->id(),
|
||||
channel: 'system'
|
||||
);
|
||||
|
||||
// Return JSON for AJAX requests
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'id' => $account->id,
|
||||
'name' => $account->name,
|
||||
'slug' => $account->slug,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
|
||||
->with('success', 'Customer created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit customer form
|
||||
*/
|
||||
public function edit(Request $request, Business $business, Business $account)
|
||||
{
|
||||
return view('seller.crm.accounts.edit', compact('business', 'account'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a customer (buyer business)
|
||||
*/
|
||||
public function update(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'license_number' => 'nullable|string|max:100',
|
||||
'business_email' => 'nullable|email|max:255',
|
||||
'business_phone' => 'nullable|string|max:50',
|
||||
'physical_address' => 'nullable|string|max:255',
|
||||
'physical_city' => 'nullable|string|max:100',
|
||||
'physical_state' => 'nullable|string|max:50',
|
||||
'physical_zipcode' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
$account->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
|
||||
->with('success', 'Customer updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show account details
|
||||
*/
|
||||
@@ -34,17 +176,37 @@ class AccountController extends Controller
|
||||
{
|
||||
$account->load(['contacts']);
|
||||
|
||||
// Get orders for this account from this seller
|
||||
// Get orders for this account from this seller (with invoices)
|
||||
$orders = $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->with(['invoice'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Get quotes for this account
|
||||
$quotes = CrmQuote::where('business_id', $business->id)
|
||||
->where('account_id', $account->id)
|
||||
->with(['contact', 'items'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Get invoices for this account (via orders)
|
||||
$invoices = Invoice::whereHas('order', function ($q) use ($business, $account) {
|
||||
$q->where('business_id', $account->id)
|
||||
->whereHas('items.product.brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
});
|
||||
})
|
||||
->with(['order', 'payments'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Get opportunities for this account from this seller
|
||||
// SalesOpportunity uses business_id for the buyer
|
||||
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->with(['stage', 'brand'])
|
||||
@@ -52,7 +214,6 @@ class AccountController extends Controller
|
||||
->get();
|
||||
|
||||
// Get tasks related to this account
|
||||
// CrmTask uses business_id for the buyer
|
||||
$tasks = CrmTask::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->whereNull('completed_at')
|
||||
@@ -98,6 +259,31 @@ class AccountController extends Controller
|
||||
->selectRaw('COUNT(*) as open_count, COALESCE(SUM(value), 0) as pipeline_value')
|
||||
->first();
|
||||
|
||||
// Financial stats from invoices
|
||||
$financialStats = Invoice::whereHas('order', function ($q) use ($business, $account) {
|
||||
$q->where('business_id', $account->id)
|
||||
->whereHas('items.product.brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
});
|
||||
})
|
||||
->selectRaw('
|
||||
COALESCE(SUM(amount_due), 0) as outstanding_balance,
|
||||
COALESCE(SUM(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN amount_due ELSE 0 END), 0) as past_due_amount,
|
||||
COUNT(CASE WHEN amount_due > 0 THEN 1 END) as open_invoice_count,
|
||||
MIN(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN due_date END) as oldest_past_due_date
|
||||
')
|
||||
->first();
|
||||
|
||||
// Get last payment info
|
||||
$lastPayment = \App\Models\InvoicePayment::whereHas('invoice.order', function ($q) use ($business, $account) {
|
||||
$q->where('business_id', $account->id)
|
||||
->whereHas('items.product.brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
});
|
||||
})
|
||||
->latest('payment_date')
|
||||
->first();
|
||||
|
||||
$stats = [
|
||||
'total_orders' => $orderStats->total_orders ?? 0,
|
||||
'total_revenue' => $orderStats->total_revenue ?? 0,
|
||||
@@ -105,11 +291,25 @@ class AccountController extends Controller
|
||||
'pipeline_value' => $opportunityStats->pipeline_value ?? 0,
|
||||
];
|
||||
|
||||
$financials = [
|
||||
'outstanding_balance' => $financialStats->outstanding_balance ?? 0,
|
||||
'past_due_amount' => $financialStats->past_due_amount ?? 0,
|
||||
'open_invoice_count' => $financialStats->open_invoice_count ?? 0,
|
||||
'oldest_past_due_days' => $financialStats->oldest_past_due_date
|
||||
? now()->diffInDays($financialStats->oldest_past_due_date)
|
||||
: null,
|
||||
'last_payment_amount' => $lastPayment->amount ?? null,
|
||||
'last_payment_date' => $lastPayment->payment_date ?? null,
|
||||
];
|
||||
|
||||
return view('seller.crm.accounts.show', compact(
|
||||
'business',
|
||||
'account',
|
||||
'stats',
|
||||
'financials',
|
||||
'orders',
|
||||
'quotes',
|
||||
'invoices',
|
||||
'opportunities',
|
||||
'tasks',
|
||||
'conversationEvents',
|
||||
@@ -141,7 +341,15 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function orders(Request $request, Business $business, Business $account)
|
||||
{
|
||||
return view('seller.crm.accounts.orders', compact('business', 'account'));
|
||||
$orders = $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->with(['items.product.brand'])
|
||||
->latest()
|
||||
->paginate(25);
|
||||
|
||||
return view('seller.crm.accounts.orders', compact('business', 'account', 'orders'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,7 +357,13 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function activity(Request $request, Business $business, Business $account)
|
||||
{
|
||||
return view('seller.crm.accounts.activity', compact('business', 'account'));
|
||||
$activities = Activity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->with(['causer'])
|
||||
->latest()
|
||||
->paginate(50);
|
||||
|
||||
return view('seller.crm.accounts.activity', compact('business', 'account', 'activities'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,4 +396,95 @@ class AccountController extends Controller
|
||||
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
|
||||
->with('success', 'Note added successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new contact for an account
|
||||
*/
|
||||
public function storeContact(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'first_name' => 'required|string|max:100',
|
||||
'last_name' => 'nullable|string|max:100',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'title' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
$contact = $account->contacts()->create($validated);
|
||||
|
||||
// Return JSON for AJAX requests
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'id' => $contact->id,
|
||||
'first_name' => $contact->first_name,
|
||||
'last_name' => $contact->last_name,
|
||||
'email' => $contact->email,
|
||||
'phone' => $contact->phone,
|
||||
'title' => $contact->title,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
|
||||
->with('success', 'Contact added successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit contact form
|
||||
*/
|
||||
public function editContact(Request $request, Business $business, Business $account, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to this account
|
||||
if ($contact->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('seller.crm.accounts.contacts-edit', compact('business', 'account', 'contact'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a contact
|
||||
*/
|
||||
public function updateContact(Request $request, Business $business, Business $account, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to this account
|
||||
if ($contact->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'first_name' => 'required|string|max:100',
|
||||
'last_name' => 'nullable|string|max:100',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'title' => 'nullable|string|max:100',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
// Handle checkbox - if not sent, default to false
|
||||
$validated['is_active'] = $request->boolean('is_active');
|
||||
|
||||
$contact->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
|
||||
->with('success', 'Contact updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a contact
|
||||
*/
|
||||
public function destroyContact(Request $request, Business $business, Business $account, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to this account
|
||||
if ($contact->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$contact->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
|
||||
->with('success', 'Contact deleted successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
196
app/Http/Controllers/Seller/Crm/ChannelController.php
Normal file
196
app/Http/Controllers/Seller/Crm/ChannelController.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessEmailIdentity;
|
||||
use App\Models\Crm\CrmChannel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ChannelController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all CRM channels for a business.
|
||||
*/
|
||||
public function index(Business $business)
|
||||
{
|
||||
$channels = CrmChannel::forBusiness($business->id)
|
||||
->orderBy('type')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.channels.index', compact('business', 'channels'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the create channel form.
|
||||
*/
|
||||
public function create(Business $business)
|
||||
{
|
||||
// Get available email identities
|
||||
$emailIdentities = BusinessEmailIdentity::forBusiness($business->id)
|
||||
->active()
|
||||
->with('mailSettings')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.channels.create', [
|
||||
'business' => $business,
|
||||
'channel' => null,
|
||||
'emailIdentities' => $emailIdentities,
|
||||
'types' => [
|
||||
CrmChannel::TYPE_EMAIL => 'Email',
|
||||
CrmChannel::TYPE_SMS => 'SMS',
|
||||
],
|
||||
'departments' => CrmChannel::DEPARTMENTS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new channel.
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'type' => ['required', 'string', Rule::in([CrmChannel::TYPE_EMAIL, CrmChannel::TYPE_SMS])],
|
||||
'department' => ['required', 'string', Rule::in(array_keys(CrmChannel::DEPARTMENTS))],
|
||||
'is_active' => ['boolean'],
|
||||
// Email-specific
|
||||
'identity_id' => ['nullable', 'required_if:type,email', 'exists:business_email_identities,id'],
|
||||
// SMS-specific
|
||||
'phone_number' => ['nullable', 'required_if:type,sms', 'string', 'max:20'],
|
||||
]);
|
||||
|
||||
// Build config based on type
|
||||
$config = ['department' => $validated['department']];
|
||||
$identifier = null;
|
||||
|
||||
if ($validated['type'] === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
|
||||
$identity = BusinessEmailIdentity::where('business_id', $business->id)
|
||||
->findOrFail($validated['identity_id']);
|
||||
|
||||
$config['identity_id'] = $identity->id;
|
||||
$config['mail_settings_id'] = $identity->mail_settings_id;
|
||||
$identifier = $identity->email;
|
||||
}
|
||||
|
||||
if ($validated['type'] === CrmChannel::TYPE_SMS && ! empty($validated['phone_number'])) {
|
||||
$config['phone_number'] = $validated['phone_number'];
|
||||
$identifier = $validated['phone_number'];
|
||||
}
|
||||
|
||||
$channel = CrmChannel::create([
|
||||
'business_id' => $business->id,
|
||||
'type' => $validated['type'],
|
||||
'name' => $validated['name'],
|
||||
'department' => $validated['department'],
|
||||
'identifier' => $identifier,
|
||||
'config' => $config,
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
'can_send' => true,
|
||||
'can_receive' => true,
|
||||
]);
|
||||
|
||||
// Link the email identity to this channel
|
||||
if ($validated['type'] === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
|
||||
BusinessEmailIdentity::where('id', $validated['identity_id'])
|
||||
->update(['crm_channel_id' => $channel->id]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.channels.index', $business)
|
||||
->with('success', 'Channel created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the edit channel form.
|
||||
*/
|
||||
public function edit(Business $business, CrmChannel $channel)
|
||||
{
|
||||
// Security: ensure channel belongs to business
|
||||
if ($channel->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Get available email identities
|
||||
$emailIdentities = BusinessEmailIdentity::forBusiness($business->id)
|
||||
->active()
|
||||
->with('mailSettings')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.channels.edit', [
|
||||
'business' => $business,
|
||||
'channel' => $channel,
|
||||
'emailIdentities' => $emailIdentities,
|
||||
'types' => [
|
||||
CrmChannel::TYPE_EMAIL => 'Email',
|
||||
CrmChannel::TYPE_SMS => 'SMS',
|
||||
],
|
||||
'departments' => CrmChannel::DEPARTMENTS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing channel.
|
||||
*/
|
||||
public function update(Request $request, Business $business, CrmChannel $channel)
|
||||
{
|
||||
// Security: ensure channel belongs to business
|
||||
if ($channel->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'department' => ['required', 'string', Rule::in(array_keys(CrmChannel::DEPARTMENTS))],
|
||||
'is_active' => ['boolean'],
|
||||
// Email-specific
|
||||
'identity_id' => ['nullable', 'exists:business_email_identities,id'],
|
||||
// SMS-specific
|
||||
'phone_number' => ['nullable', 'string', 'max:20'],
|
||||
]);
|
||||
|
||||
// Build config based on type
|
||||
$config = $channel->config ?? [];
|
||||
$config['department'] = $validated['department'];
|
||||
$identifier = $channel->identifier;
|
||||
|
||||
if ($channel->type === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
|
||||
$identity = BusinessEmailIdentity::where('business_id', $business->id)
|
||||
->findOrFail($validated['identity_id']);
|
||||
|
||||
// Unlink old identity if different
|
||||
$oldIdentityId = $config['identity_id'] ?? null;
|
||||
if ($oldIdentityId && $oldIdentityId != $identity->id) {
|
||||
BusinessEmailIdentity::where('id', $oldIdentityId)
|
||||
->update(['crm_channel_id' => null]);
|
||||
}
|
||||
|
||||
$config['identity_id'] = $identity->id;
|
||||
$config['mail_settings_id'] = $identity->mail_settings_id;
|
||||
$identifier = $identity->email;
|
||||
|
||||
// Link new identity
|
||||
$identity->update(['crm_channel_id' => $channel->id]);
|
||||
}
|
||||
|
||||
if ($channel->type === CrmChannel::TYPE_SMS && ! empty($validated['phone_number'])) {
|
||||
$config['phone_number'] = $validated['phone_number'];
|
||||
$identifier = $validated['phone_number'];
|
||||
}
|
||||
|
||||
$channel->update([
|
||||
'name' => $validated['name'],
|
||||
'department' => $validated['department'],
|
||||
'identifier' => $identifier,
|
||||
'config' => $config,
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.channels.index', $business)
|
||||
->with('success', 'Channel updated successfully.');
|
||||
}
|
||||
}
|
||||
252
app/Http/Controllers/Seller/Crm/ContactController.php
Normal file
252
app/Http/Controllers/Seller/Crm/ContactController.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display all CRM contacts (contacts from buyer businesses).
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$query = Contact::query()
|
||||
->whereHas('business', function ($q) {
|
||||
$q->where('type', 'buyer');
|
||||
})
|
||||
->with(['business', 'location']);
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('q')) {
|
||||
$search = $request->q;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('first_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('last_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('email', 'ILIKE', "%{$search}%")
|
||||
->orWhere('phone', 'ILIKE', "%{$search}%")
|
||||
->orWhere('position', 'ILIKE', "%{$search}%")
|
||||
->orWhereHas('business', function ($q) use ($search) {
|
||||
$q->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('dba_name', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Account filter
|
||||
if ($request->filled('account')) {
|
||||
$query->where('business_id', $request->account);
|
||||
}
|
||||
|
||||
// Contact type filter
|
||||
if ($request->filled('type')) {
|
||||
$query->where('contact_type', $request->type);
|
||||
}
|
||||
|
||||
// Active filter - default to active
|
||||
if ($request->filled('status')) {
|
||||
if ($request->status === 'inactive') {
|
||||
$query->where('is_active', false);
|
||||
} elseif ($request->status === 'all') {
|
||||
// Show all
|
||||
} else {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
} else {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
$contacts = $query
|
||||
->orderBy('last_name')
|
||||
->orderBy('first_name')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $contacts->map(fn ($c) => [
|
||||
'hashid' => $c->hashid,
|
||||
'name' => $c->getFullName(),
|
||||
'email' => $c->email,
|
||||
'account' => $c->business?->name,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Get accounts for filter dropdown
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'dba_name']);
|
||||
|
||||
return view('seller.crm.contacts.index', compact('business', 'contacts', 'accounts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new contact.
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'dba_name']);
|
||||
|
||||
$selectedAccount = $request->filled('account')
|
||||
? Business::find($request->account)
|
||||
: null;
|
||||
|
||||
return view('seller.crm.contacts.create', compact('business', 'accounts', 'selectedAccount'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created contact.
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'business_id' => 'required|exists:businesses,id',
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'mobile' => 'nullable|string|max:50',
|
||||
'position' => 'nullable|string|max:255',
|
||||
'contact_type' => 'nullable|string|in:'.implode(',', array_keys(Contact::CONTACT_TYPES)),
|
||||
'preferred_contact_method' => 'nullable|string|in:'.implode(',', array_keys(Contact::COMMUNICATION_METHODS)),
|
||||
'is_primary' => 'nullable|boolean',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Verify the target business is a buyer
|
||||
$targetBusiness = Business::findOrFail($validated['business_id']);
|
||||
if ($targetBusiness->type !== 'buyer') {
|
||||
return redirect()->back()->with('error', 'Contacts can only be added to customer accounts.');
|
||||
}
|
||||
|
||||
// If setting as primary, remove primary from other contacts
|
||||
if ($request->boolean('is_primary')) {
|
||||
Contact::where('business_id', $validated['business_id'])->update(['is_primary' => false]);
|
||||
}
|
||||
|
||||
$contact = Contact::create([
|
||||
'business_id' => $validated['business_id'],
|
||||
'first_name' => $validated['first_name'],
|
||||
'last_name' => $validated['last_name'],
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'mobile' => $validated['mobile'] ?? null,
|
||||
'position' => $validated['position'] ?? null,
|
||||
'contact_type' => $validated['contact_type'] ?? 'general',
|
||||
'preferred_contact_method' => $validated['preferred_contact_method'] ?? 'email',
|
||||
'is_primary' => $request->boolean('is_primary', false),
|
||||
'is_active' => true,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.contacts.index', $business)
|
||||
->with('success', "Contact '{$contact->getFullName()}' created successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing a contact.
|
||||
*/
|
||||
public function edit(Business $business, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to a buyer business
|
||||
if ($contact->business->type !== 'buyer') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'dba_name']);
|
||||
|
||||
return view('seller.crm.contacts.edit', compact('business', 'contact', 'accounts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a contact.
|
||||
*/
|
||||
public function update(Request $request, Business $business, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to a buyer business
|
||||
if ($contact->business->type !== 'buyer') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'business_id' => 'required|exists:businesses,id',
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'mobile' => 'nullable|string|max:50',
|
||||
'position' => 'nullable|string|max:255',
|
||||
'contact_type' => 'nullable|string|in:'.implode(',', array_keys(Contact::CONTACT_TYPES)),
|
||||
'preferred_contact_method' => 'nullable|string|in:'.implode(',', array_keys(Contact::COMMUNICATION_METHODS)),
|
||||
'is_primary' => 'nullable|boolean',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Verify the target business is a buyer
|
||||
$targetBusiness = Business::findOrFail($validated['business_id']);
|
||||
if ($targetBusiness->type !== 'buyer') {
|
||||
return redirect()->back()->with('error', 'Contacts can only belong to customer accounts.');
|
||||
}
|
||||
|
||||
// If setting as primary, remove primary from other contacts
|
||||
if ($request->boolean('is_primary') && ! $contact->is_primary) {
|
||||
Contact::where('business_id', $validated['business_id'])
|
||||
->where('id', '!=', $contact->id)
|
||||
->update(['is_primary' => false]);
|
||||
}
|
||||
|
||||
$contact->update([
|
||||
'business_id' => $validated['business_id'],
|
||||
'first_name' => $validated['first_name'],
|
||||
'last_name' => $validated['last_name'],
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'mobile' => $validated['mobile'] ?? null,
|
||||
'position' => $validated['position'] ?? null,
|
||||
'contact_type' => $validated['contact_type'] ?? 'general',
|
||||
'preferred_contact_method' => $validated['preferred_contact_method'] ?? 'email',
|
||||
'is_primary' => $request->boolean('is_primary', false),
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.contacts.index', $business)
|
||||
->with('success', "Contact '{$contact->getFullName()}' updated successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive/delete a contact.
|
||||
*/
|
||||
public function destroy(Business $business, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to a buyer business
|
||||
if ($contact->business->type !== 'buyer') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$name = $contact->getFullName();
|
||||
|
||||
$contact->archive('Deleted via CRM', auth()->user());
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.contacts.index', $business)
|
||||
->with('success', "Contact '{$name}' has been archived.");
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,13 @@ namespace App\Http\Controllers\Seller\Crm;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\Crm\SyncCalendarJob;
|
||||
use App\Models\Business;
|
||||
use App\Models\CalendarEvent;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Crm\CrmCalendarConnection;
|
||||
use App\Models\Crm\CrmMeetingBooking;
|
||||
use App\Models\Crm\CrmSyncedEvent;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\User;
|
||||
use App\Services\Crm\CrmCalendarService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -17,7 +22,7 @@ class CrmCalendarController extends Controller
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Calendar view
|
||||
* Calendar view - unified activity calendar
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
@@ -28,52 +33,402 @@ class CrmCalendarController extends Controller
|
||||
->where('user_id', $user->id)
|
||||
->get();
|
||||
|
||||
// Get events for calendar view
|
||||
$startDate = $request->input('start', now()->startOfMonth());
|
||||
$endDate = $request->input('end', now()->endOfMonth());
|
||||
// Get team members for assignment dropdown
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->select('id', 'first_name', 'last_name', 'email')
|
||||
->get();
|
||||
|
||||
$events = CrmSyncedEvent::whereIn('calendar_connection_id', $connections->pluck('id'))
|
||||
->whereBetween('start_at', [$startDate, $endDate])
|
||||
// Get contacts for event creation
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
$contacts = Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->with('business:id,name')
|
||||
->orderBy('first_name')
|
||||
->limit(200)
|
||||
->get();
|
||||
|
||||
// Get event types and colors for legend/forms
|
||||
$eventTypes = CalendarEvent::TYPES;
|
||||
$eventColors = CalendarEvent::TYPE_COLORS;
|
||||
|
||||
// Pass $business to view for route generation (Premium CRM uses seller.business.crm.* routes)
|
||||
return view('seller.crm.calendar.index', compact(
|
||||
'business',
|
||||
'connections',
|
||||
'teamMembers',
|
||||
'contacts',
|
||||
'eventTypes',
|
||||
'eventColors'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Get all events for date range (unified: internal + synced + bookings + tasks)
|
||||
*/
|
||||
public function events(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'start' => 'required|date',
|
||||
'end' => 'required|date|after:start',
|
||||
]);
|
||||
|
||||
$startDate = $validated['start'];
|
||||
$endDate = $validated['end'];
|
||||
$allEvents = collect();
|
||||
|
||||
// 1. Internal CalendarEvents
|
||||
$internalEvents = CalendarEvent::forSellerBusiness($business->id)
|
||||
->inDateRange($startDate, $endDate)
|
||||
->with(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name'])
|
||||
->get()
|
||||
->map(fn ($e) => [
|
||||
'id' => $e->id,
|
||||
'id' => 'event_'.$e->id,
|
||||
'title' => $e->title,
|
||||
'start' => $e->start_at->toIso8601String(),
|
||||
'end' => $e->end_at?->toIso8601String(),
|
||||
'allDay' => $e->all_day,
|
||||
'color' => $e->connection->provider === 'google' ? '#4285f4' : '#0078d4',
|
||||
'color' => $e->getColor(),
|
||||
'classNames' => ['calendar-event-internal', 'event-type-'.$e->type],
|
||||
'extendedProps' => [
|
||||
'source' => 'internal',
|
||||
'event_id' => $e->id,
|
||||
'type' => $e->type,
|
||||
'type_label' => $e->getTypeLabel(),
|
||||
'status' => $e->status,
|
||||
'location' => $e->location,
|
||||
'description' => $e->description,
|
||||
'attendees' => $e->attendees,
|
||||
'contact_id' => $e->contact_id,
|
||||
'contact_name' => $e->contact ? $e->contact->first_name.' '.$e->contact->last_name : null,
|
||||
'assigned_to' => $e->assigned_to,
|
||||
'assignee_name' => $e->assignee ? $e->assignee->first_name.' '.$e->assignee->last_name : null,
|
||||
'editable' => true,
|
||||
],
|
||||
]);
|
||||
$allEvents = $allEvents->merge($internalEvents);
|
||||
|
||||
// Get meeting bookings
|
||||
$bookings = \App\Models\Crm\CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
|
||||
// 2. Synced external events (Google/Outlook)
|
||||
$connections = CrmCalendarConnection::where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('sync_enabled', true)
|
||||
->pluck('id');
|
||||
|
||||
if ($connections->isNotEmpty()) {
|
||||
$syncedEvents = CrmSyncedEvent::whereIn('calendar_connection_id', $connections)
|
||||
->whereBetween('start_at', [$startDate, $endDate])
|
||||
->with('connection:id,provider')
|
||||
->get()
|
||||
->map(fn ($e) => [
|
||||
'id' => 'synced_'.$e->id,
|
||||
'title' => $e->title,
|
||||
'start' => $e->start_at->toIso8601String(),
|
||||
'end' => $e->end_at?->toIso8601String(),
|
||||
'allDay' => $e->all_day,
|
||||
'color' => $e->connection->provider === 'google' ? '#4285f4' : '#0078d4',
|
||||
'classNames' => ['calendar-event-synced', 'provider-'.$e->connection->provider],
|
||||
'extendedProps' => [
|
||||
'source' => 'synced',
|
||||
'provider' => $e->connection->provider,
|
||||
'location' => $e->location,
|
||||
'description' => $e->description,
|
||||
'attendees' => $e->attendees,
|
||||
'external_link' => $e->external_link,
|
||||
'editable' => false,
|
||||
],
|
||||
]);
|
||||
$allEvents = $allEvents->merge($syncedEvents);
|
||||
}
|
||||
|
||||
// 3. Meeting bookings
|
||||
$bookings = CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
|
||||
$q->where('business_id', $business->id)
|
||||
->where('user_id', $user->id);
|
||||
})
|
||||
->whereBetween('start_at', [$startDate, $endDate])
|
||||
->with(['meetingLink', 'contact'])
|
||||
->where('status', '!=', 'cancelled')
|
||||
->with(['meetingLink:id,name', 'contact:id,first_name,last_name'])
|
||||
->get()
|
||||
->map(fn ($b) => [
|
||||
'id' => 'booking_'.$b->id,
|
||||
'title' => $b->meetingLink->name.' - '.$b->booker_name,
|
||||
'title' => ($b->meetingLink->name ?? 'Meeting').' - '.$b->booker_name,
|
||||
'start' => $b->start_at->toIso8601String(),
|
||||
'end' => $b->end_at->toIso8601String(),
|
||||
'color' => '#10b981',
|
||||
'classNames' => ['calendar-event-booking'],
|
||||
'extendedProps' => [
|
||||
'type' => 'booking',
|
||||
'contact_id' => $b->contact_id,
|
||||
'source' => 'booking',
|
||||
'booking_id' => $b->id,
|
||||
'status' => $b->status,
|
||||
'booker_name' => $b->booker_name,
|
||||
'booker_email' => $b->booker_email,
|
||||
'contact_id' => $b->contact_id,
|
||||
'contact_name' => $b->contact ? $b->contact->first_name.' '.$b->contact->last_name : null,
|
||||
'location' => $b->location,
|
||||
'editable' => false,
|
||||
],
|
||||
]);
|
||||
$allEvents = $allEvents->merge($bookings);
|
||||
|
||||
$allEvents = $events->merge($bookings);
|
||||
// 4. CRM Tasks with due dates (shown as all-day markers)
|
||||
$tasks = CrmTask::forSellerBusiness($business->id)
|
||||
->incomplete()
|
||||
->whereNotNull('due_at')
|
||||
->whereBetween('due_at', [$startDate, $endDate])
|
||||
->with(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name'])
|
||||
->get()
|
||||
->map(fn ($t) => [
|
||||
'id' => 'task_'.$t->id,
|
||||
'title' => '📋 '.$t->title,
|
||||
'start' => $t->due_at->toDateString(),
|
||||
'allDay' => true,
|
||||
'color' => $t->isOverdue() ? '#EF4444' : '#F59E0B',
|
||||
'classNames' => ['calendar-event-task', $t->isOverdue() ? 'task-overdue' : ''],
|
||||
'extendedProps' => [
|
||||
'source' => 'task',
|
||||
'task_id' => $t->id,
|
||||
'type' => $t->type,
|
||||
'priority' => $t->priority,
|
||||
'contact_id' => $t->contact_id,
|
||||
'contact_name' => $t->contact ? $t->contact->first_name.' '.$t->contact->last_name : null,
|
||||
'assigned_to' => $t->assigned_to,
|
||||
'assignee_name' => $t->assignee ? $t->assignee->first_name.' '.$t->assignee->last_name : null,
|
||||
'is_overdue' => $t->isOverdue(),
|
||||
'editable' => false,
|
||||
],
|
||||
]);
|
||||
$allEvents = $allEvents->merge($tasks);
|
||||
|
||||
// Pass $business to view for route generation (Premium CRM uses seller.business.crm.* routes)
|
||||
return view('seller.crm.calendar.index', compact('business', 'connections', 'allEvents'));
|
||||
return response()->json($allEvents->values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new calendar event
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:5000',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'start_at' => 'required|date',
|
||||
'end_at' => 'nullable|date|after:start_at',
|
||||
'all_day' => 'boolean',
|
||||
'type' => 'required|string|in:'.implode(',', array_keys(CalendarEvent::TYPES)),
|
||||
'contact_id' => 'nullable|exists:contacts,id',
|
||||
'assigned_to' => 'nullable|exists:users,id',
|
||||
'reminder_minutes' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
// Security: verify contact belongs to a customer business
|
||||
if (! empty($validated['contact_id'])) {
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->findOrFail($validated['contact_id']);
|
||||
}
|
||||
|
||||
// Security: verify assignee belongs to business
|
||||
if (! empty($validated['assigned_to'])) {
|
||||
User::where('id', $validated['assigned_to'])
|
||||
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
$event = CalendarEvent::create([
|
||||
'seller_business_id' => $business->id,
|
||||
'created_by' => $request->user()->id,
|
||||
'assigned_to' => $validated['assigned_to'] ?? $request->user()->id,
|
||||
'title' => $validated['title'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'location' => $validated['location'] ?? null,
|
||||
'start_at' => $validated['start_at'],
|
||||
'end_at' => $validated['end_at'] ?? null,
|
||||
'all_day' => $validated['all_day'] ?? false,
|
||||
'type' => $validated['type'],
|
||||
'status' => 'scheduled',
|
||||
'contact_id' => $validated['contact_id'] ?? null,
|
||||
'reminder_at' => isset($validated['reminder_minutes']) && $validated['reminder_minutes'] > 0
|
||||
? now()->parse($validated['start_at'])->subMinutes($validated['reminder_minutes'])
|
||||
: null,
|
||||
]);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'event' => $event->load(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name']),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a calendar event
|
||||
*/
|
||||
public function update(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|required|string|max:255',
|
||||
'description' => 'nullable|string|max:5000',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'start_at' => 'sometimes|required|date',
|
||||
'end_at' => 'nullable|date|after:start_at',
|
||||
'all_day' => 'boolean',
|
||||
'type' => 'sometimes|required|string|in:'.implode(',', array_keys(CalendarEvent::TYPES)),
|
||||
'status' => 'sometimes|required|string|in:scheduled,completed,cancelled',
|
||||
'contact_id' => 'nullable|exists:contacts,id',
|
||||
'assigned_to' => 'nullable|exists:users,id',
|
||||
'reminder_minutes' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
// Security checks for contact and assignee
|
||||
if (isset($validated['contact_id']) && $validated['contact_id']) {
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->findOrFail($validated['contact_id']);
|
||||
}
|
||||
|
||||
if (isset($validated['assigned_to']) && $validated['assigned_to']) {
|
||||
User::where('id', $validated['assigned_to'])
|
||||
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
// Handle reminder
|
||||
if (isset($validated['reminder_minutes'])) {
|
||||
$validated['reminder_at'] = $validated['reminder_minutes'] > 0
|
||||
? now()->parse($validated['start_at'] ?? $event->start_at)->subMinutes($validated['reminder_minutes'])
|
||||
: null;
|
||||
$validated['reminder_sent'] = false;
|
||||
unset($validated['reminder_minutes']);
|
||||
}
|
||||
|
||||
$event->update($validated);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'event' => $event->fresh()->load(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name']),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick reschedule via drag-and-drop
|
||||
*/
|
||||
public function reschedule(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'start_at' => 'required|date',
|
||||
'end_at' => 'nullable|date|after:start_at',
|
||||
'all_day' => 'boolean',
|
||||
]);
|
||||
|
||||
$event->reschedule(
|
||||
$validated['start_at'],
|
||||
$validated['end_at'] ?? null,
|
||||
$request->user()
|
||||
);
|
||||
|
||||
if (isset($validated['all_day'])) {
|
||||
$event->update(['all_day' => $validated['all_day']]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'event' => $event->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark event as complete
|
||||
*/
|
||||
public function complete(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$event->markComplete($request->user());
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'event' => $event->fresh()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event marked as complete.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an event
|
||||
*/
|
||||
public function cancel(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$event->cancel($request->user());
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'event' => $event->fresh()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event cancelled.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$event->delete();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single event details (for modal)
|
||||
*/
|
||||
public function show(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$event->load([
|
||||
'contact:id,first_name,last_name,email,phone',
|
||||
'business:id,name',
|
||||
'assignee:id,first_name,last_name,email',
|
||||
'creator:id,first_name,last_name',
|
||||
]);
|
||||
|
||||
return response()->json($event);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +459,7 @@ class CrmCalendarController extends Controller
|
||||
|
||||
$params = http_build_query([
|
||||
'client_id' => config('services.google.client_id'),
|
||||
'redirect_uri' => route('seller.crm.calendar.callback'),
|
||||
'redirect_uri' => route('seller.business.crm.calendar.callback', $business),
|
||||
'response_type' => 'code',
|
||||
'scope' => 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events',
|
||||
'access_type' => 'offline',
|
||||
@@ -128,7 +483,7 @@ class CrmCalendarController extends Controller
|
||||
|
||||
$params = http_build_query([
|
||||
'client_id' => config('services.microsoft.client_id'),
|
||||
'redirect_uri' => route('seller.crm.calendar.callback'),
|
||||
'redirect_uri' => route('seller.business.crm.calendar.callback', $business),
|
||||
'response_type' => 'code',
|
||||
'scope' => 'offline_access Calendars.ReadWrite',
|
||||
'state' => $state,
|
||||
@@ -140,17 +495,17 @@ class CrmCalendarController extends Controller
|
||||
/**
|
||||
* OAuth callback
|
||||
*/
|
||||
public function callback(Request $request)
|
||||
public function callback(Request $request, Business $business)
|
||||
{
|
||||
if ($request->has('error')) {
|
||||
return redirect()->route('seller.crm.calendar.connections')
|
||||
return redirect()->route('seller.business.crm.calendar.connections', $business)
|
||||
->withErrors(['error' => 'Authorization failed: '.$request->input('error_description')]);
|
||||
}
|
||||
|
||||
try {
|
||||
$state = decrypt($request->input('state'));
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->route('seller.crm.calendar.connections')
|
||||
return redirect()->route('seller.business.crm.calendar.connections', $business)
|
||||
->withErrors(['error' => 'Invalid state parameter.']);
|
||||
}
|
||||
|
||||
@@ -161,7 +516,7 @@ class CrmCalendarController extends Controller
|
||||
$tokens = $this->calendarService->exchangeCodeForTokens($provider, $code);
|
||||
|
||||
if (! $tokens) {
|
||||
return redirect()->route('seller.crm.calendar.connections')
|
||||
return redirect()->route('seller.business.crm.calendar.connections', $business)
|
||||
->withErrors(['error' => 'Failed to obtain access token.']);
|
||||
}
|
||||
|
||||
@@ -189,7 +544,7 @@ class CrmCalendarController extends Controller
|
||||
// Queue initial sync
|
||||
SyncCalendarJob::dispatch($state['user_id'], $provider);
|
||||
|
||||
return redirect()->route('seller.crm.calendar.connections')
|
||||
return redirect()->route('seller.business.crm.calendar.connections', $business)
|
||||
->with('success', ucfirst($provider).' Calendar connected successfully.');
|
||||
}
|
||||
|
||||
@@ -238,34 +593,4 @@ class CrmCalendarController extends Controller
|
||||
|
||||
return back()->with('success', 'Calendar sync started. Events will appear shortly.');
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Get events for date range (for calendar JS)
|
||||
*/
|
||||
public function events(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'start' => 'required|date',
|
||||
'end' => 'required|date|after:start',
|
||||
]);
|
||||
|
||||
$connections = CrmCalendarConnection::where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->pluck('id');
|
||||
|
||||
$events = CrmSyncedEvent::whereIn('calendar_connection_id', $connections)
|
||||
->whereBetween('start_at', [$validated['start'], $validated['end']])
|
||||
->get()
|
||||
->map(fn ($e) => [
|
||||
'id' => $e->id,
|
||||
'title' => $e->title,
|
||||
'start' => $e->start_at->toIso8601String(),
|
||||
'end' => $e->end_at?->toIso8601String(),
|
||||
'allDay' => $e->all_day,
|
||||
]);
|
||||
|
||||
return response()->json($events);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Seller\Crm;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmDeal;
|
||||
use App\Models\Crm\CrmPipeline;
|
||||
use App\Models\Crm\CrmRepMetric;
|
||||
use App\Models\Crm\CrmSlaTimer;
|
||||
use App\Models\Crm\CrmThread;
|
||||
@@ -43,13 +44,26 @@ class CrmDashboardController extends Controller
|
||||
*/
|
||||
public function sales(Request $request, Business $business)
|
||||
{
|
||||
// Get the default pipeline for stage name mapping
|
||||
$defaultPipeline = CrmPipeline::where('business_id', $business->id)
|
||||
->where('is_default', true)
|
||||
->first();
|
||||
|
||||
// Pipeline summary
|
||||
$stageMap = collect($defaultPipeline?->stages ?? [])->mapWithKeys(function ($stage, $index) {
|
||||
return [$index => $stage['name'] ?? "Stage {$index}"];
|
||||
})->all();
|
||||
|
||||
// Pipeline summary - group by stage_id (index into pipeline stages JSON array)
|
||||
$pipelineSummary = CrmDeal::forBusiness($business->id)
|
||||
->open()
|
||||
->selectRaw('stage, count(*) as count, sum(value) as total_value, sum(weighted_value) as weighted_value')
|
||||
->groupBy('stage')
|
||||
->get();
|
||||
->selectRaw('stage_id, count(*) as count, sum(value) as total_value, sum(weighted_value) as weighted_value')
|
||||
->groupBy('stage_id')
|
||||
->get()
|
||||
->map(function ($item) use ($stageMap) {
|
||||
$item->stage_name = $stageMap[$item->stage_id] ?? "Stage {$item->stage_id}";
|
||||
|
||||
return $item;
|
||||
});
|
||||
|
||||
// Won/Lost this month
|
||||
$monthlyStats = [
|
||||
|
||||
@@ -86,7 +86,7 @@ class CrmSettingsController extends Controller
|
||||
'is_default' => $validated['is_default'] ?? false,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.settings.channels')
|
||||
return redirect()->route('seller.business.crm.settings.channels', $business)
|
||||
->with('success', 'Channel created successfully.');
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ class CrmSettingsController extends Controller
|
||||
|
||||
$channel->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.settings.channels')
|
||||
return redirect()->route('seller.business.crm.settings.channels', $business)
|
||||
->with('success', 'Channel updated.');
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ class CrmSettingsController extends Controller
|
||||
'is_default' => $validated['is_default'] ?? false,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.settings.pipelines')
|
||||
return redirect()->route('seller.business.crm.settings.pipelines', $business)
|
||||
->with('success', 'Pipeline created.');
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ class CrmSettingsController extends Controller
|
||||
|
||||
$pipeline->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.settings.pipelines')
|
||||
return redirect()->route('seller.business.crm.settings.pipelines', $business)
|
||||
->with('success', 'Pipeline updated.');
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ class CrmSettingsController extends Controller
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.settings.sla')
|
||||
return redirect()->route('seller.business.crm.settings.sla', $business)
|
||||
->with('success', 'SLA policy created.');
|
||||
}
|
||||
|
||||
@@ -371,7 +371,7 @@ class CrmSettingsController extends Controller
|
||||
|
||||
$policy->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.settings.sla')
|
||||
return redirect()->route('seller.business.crm.settings.sla', $business)
|
||||
->with('success', 'SLA policy updated.');
|
||||
}
|
||||
|
||||
@@ -519,7 +519,7 @@ class CrmSettingsController extends Controller
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.settings.templates')
|
||||
return redirect()->route('seller.business.crm.settings.templates', $business)
|
||||
->with('success', 'Template created.');
|
||||
}
|
||||
|
||||
@@ -560,7 +560,7 @@ class CrmSettingsController extends Controller
|
||||
|
||||
$template->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.settings.templates')
|
||||
return redirect()->route('seller.business.crm.settings.templates', $business)
|
||||
->with('success', 'Template updated.');
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class DealController extends Controller
|
||||
// Build base query for deals
|
||||
$dealsQuery = CrmDeal::forBusiness($business->id)
|
||||
->where('pipeline_id', $pipeline->id)
|
||||
->with(['contact:id,first_name,last_name,email', 'account:id,name', 'owner:id,name,email']);
|
||||
->with(['contact:id,first_name,last_name,email', 'account:id,name', 'owner:id,first_name,last_name,email']);
|
||||
|
||||
// Filters
|
||||
if ($request->filled('owner_id')) {
|
||||
@@ -73,7 +73,7 @@ class DealController extends Controller
|
||||
|
||||
// Get team members (limited fields)
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->select('id', 'name', 'email')
|
||||
->select('id', 'first_name', 'last_name', 'email')
|
||||
->get();
|
||||
|
||||
// Calculate stats with single efficient query using selectRaw
|
||||
@@ -110,7 +110,7 @@ class DealController extends Controller
|
||||
|
||||
// Limit contacts for dropdown - most recent 100
|
||||
$contacts = Contact::where('business_id', $business->id)
|
||||
->select('id', 'first_name', 'last_name', 'email', 'company_name')
|
||||
->select('id', 'first_name', 'last_name', 'email')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(100)
|
||||
->get();
|
||||
@@ -125,7 +125,7 @@ class DealController extends Controller
|
||||
->get();
|
||||
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->select('id', 'name', 'email')
|
||||
->select('id', 'first_name', 'last_name', 'email')
|
||||
->get();
|
||||
|
||||
$brands = Brand::where('business_id', $business->id)
|
||||
@@ -200,7 +200,7 @@ class DealController extends Controller
|
||||
'status' => CrmDeal::STATUS_OPEN,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.deals.show', $deal)
|
||||
return redirect()->route('seller.business.crm.deals.show', [$business, $deal])
|
||||
->with('success', 'Deal created successfully.');
|
||||
}
|
||||
|
||||
@@ -373,7 +373,7 @@ class DealController extends Controller
|
||||
|
||||
$deal->delete();
|
||||
|
||||
return redirect()->route('seller.crm.deals.index')
|
||||
return redirect()->route('seller.business.crm.deals.index', $business)
|
||||
->with('success', 'Deal deleted.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ class InvoiceController extends Controller
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('invoice_number', 'like', "%{$request->search}%")
|
||||
->orWhere('title', 'like', "%{$request->search}%");
|
||||
$q->where('invoice_number', 'ILIKE', "%{$request->search}%")
|
||||
->orWhere('title', 'ILIKE', "%{$request->search}%");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ class InvoiceController extends Controller
|
||||
|
||||
$invoice->calculateTotals();
|
||||
|
||||
return redirect()->route('seller.crm.invoices.show', $invoice)
|
||||
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||
->with('success', 'Invoice created successfully.');
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ class InvoiceController extends Controller
|
||||
|
||||
$invoice->delete();
|
||||
|
||||
return redirect()->route('seller.crm.invoices.index')
|
||||
return redirect()->route('seller.business.crm.invoices.index', $business)
|
||||
->with('success', 'Invoice deleted.');
|
||||
}
|
||||
}
|
||||
|
||||
171
app/Http/Controllers/Seller/Crm/LeadController.php
Normal file
171
app/Http/Controllers/Seller/Crm/LeadController.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmLead;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LeadController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display leads listing
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$query = CrmLead::forSeller($business)
|
||||
->with('assignee')
|
||||
->notConverted();
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('q')) {
|
||||
$search = $request->q;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('company_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('contact_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('contact_email', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ($request->filled('status') && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$leads = $query->latest()->paginate(25);
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $leads->map(fn ($l) => [
|
||||
'hashid' => $l->hashid,
|
||||
'name' => $l->company_name,
|
||||
'contact' => $l->contact_name,
|
||||
'email' => $l->contact_email,
|
||||
'status' => $l->status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.crm.leads.index', compact('business', 'leads'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create lead form
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
return view('seller.crm.leads.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new lead
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'company_name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'license_number' => 'nullable|string|max:100',
|
||||
'contact_name' => 'required|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_title' => 'nullable|string|max:100',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'zip_code' => 'nullable|string|max:20',
|
||||
'source' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::SOURCES)),
|
||||
'notes' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
$validated['seller_business_id'] = $business->id;
|
||||
$validated['status'] = 'new';
|
||||
|
||||
$lead = CrmLead::create($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.leads.show', [$business->slug, $lead->hashid])
|
||||
->with('success', 'Lead created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show lead details
|
||||
*/
|
||||
public function show(Request $request, Business $business, CrmLead $lead)
|
||||
{
|
||||
// Ensure lead belongs to this seller
|
||||
if ($lead->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$lead->load('assignee');
|
||||
|
||||
return view('seller.crm.leads.show', compact('business', 'lead'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit lead form
|
||||
*/
|
||||
public function edit(Request $request, Business $business, CrmLead $lead)
|
||||
{
|
||||
// Ensure lead belongs to this seller
|
||||
if ($lead->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('seller.crm.leads.edit', compact('business', 'lead'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a lead
|
||||
*/
|
||||
public function update(Request $request, Business $business, CrmLead $lead)
|
||||
{
|
||||
// Ensure lead belongs to this seller
|
||||
if ($lead->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'company_name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'license_number' => 'nullable|string|max:100',
|
||||
'contact_name' => 'required|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_title' => 'nullable|string|max:100',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'zip_code' => 'nullable|string|max:20',
|
||||
'source' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::SOURCES)),
|
||||
'status' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::STATUSES)),
|
||||
'notes' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
$lead->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.leads.show', [$business->slug, $lead->hashid])
|
||||
->with('success', 'Lead updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a lead
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, CrmLead $lead)
|
||||
{
|
||||
// Ensure lead belongs to this seller
|
||||
if ($lead->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$lead->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.leads.index', $business->slug)
|
||||
->with('success', 'Lead deleted.');
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ class MeetingLinkController extends Controller
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.meetings.links.show', $meetingLink)
|
||||
return redirect()->route('seller.business.crm.meetings.links.show', [$business, $meetingLink])
|
||||
->with('success', 'Meeting link created. Share the booking URL with contacts.');
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ class MeetingLinkController extends Controller
|
||||
|
||||
$meetingLink->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.meetings.links.show', $meetingLink)
|
||||
return redirect()->route('seller.business.crm.meetings.links.show', [$business, $meetingLink])
|
||||
->with('success', 'Meeting link updated.');
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ class MeetingLinkController extends Controller
|
||||
|
||||
$meetingLink->delete();
|
||||
|
||||
return redirect()->route('seller.crm.meetings.links.index')
|
||||
return redirect()->route('seller.business.crm.meetings.links.index', $business)
|
||||
->with('success', 'Meeting link deleted.');
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,21 @@
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\QuoteMail;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Crm\CrmDeal;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use App\Models\Crm\CrmQuoteItem;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Services\Accounting\ArService;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class QuoteController extends Controller
|
||||
{
|
||||
@@ -37,6 +44,19 @@ class QuoteController extends Controller
|
||||
|
||||
$quotes = $query->orderByDesc('created_at')->paginate(25);
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $quotes->map(fn ($q) => [
|
||||
'id' => $q->id,
|
||||
'name' => $q->quote_number.' - '.($q->title ?? 'Untitled'),
|
||||
'contact' => $q->contact?->name ?? '-',
|
||||
'status' => $q->status,
|
||||
'total' => '$'.number_format($q->total, 2),
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.crm.quotes.index', compact('quotes', 'business'));
|
||||
}
|
||||
|
||||
@@ -45,21 +65,26 @@ class QuoteController extends Controller
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$contacts = Contact::where('business_id', $business->id)->get();
|
||||
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
|
||||
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
|
||||
})->get();
|
||||
$deals = CrmDeal::forBusiness($business->id)->open()->get();
|
||||
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->where('is_active', true)
|
||||
// Get all approved buyer businesses as potential customers
|
||||
// Contacts are loaded dynamically via /search/contacts?customer_id={account_id}
|
||||
// Include locations for delivery address selection
|
||||
// Note: We don't filter by whereHas('contacts') because newly created customers
|
||||
// may not have contacts yet - contacts can be added after selecting the account
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->with('locations:id,business_id,name,is_primary')
|
||||
->orderBy('name')
|
||||
->select(['id', 'name', 'slug'])
|
||||
->get();
|
||||
$deals = CrmDeal::forBusiness($business->id)->open()->get();
|
||||
// Products are loaded via AJAX search (/search/products) for better performance
|
||||
|
||||
// Pre-fill from deal if provided
|
||||
$deal = $request->filled('deal_id')
|
||||
? CrmDeal::forBusiness($business->id)->find($request->deal_id)
|
||||
: null;
|
||||
|
||||
return view('seller.crm.quotes.create', compact('contacts', 'accounts', 'deals', 'products', 'deal', 'business'));
|
||||
return view('seller.crm.quotes.create', compact('accounts', 'deals', 'deal', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,10 +112,13 @@ class QuoteController extends Controller
|
||||
'items.*.discount_percent' => 'nullable|numeric|min:0|max:100',
|
||||
]);
|
||||
|
||||
// SECURITY: Verify contact belongs to business
|
||||
Contact::where('id', $validated['contact_id'])
|
||||
->where('business_id', $business->id)
|
||||
->firstOrFail();
|
||||
// SECURITY: Verify contact belongs to the selected account (customer business)
|
||||
// Contacts are associated with buyer businesses, not the seller
|
||||
if (! empty($validated['account_id'])) {
|
||||
Contact::where('id', $validated['contact_id'])
|
||||
->where('business_id', $validated['account_id'])
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
// SECURITY: Verify deal belongs to business if provided
|
||||
if (! empty($validated['deal_id'])) {
|
||||
@@ -136,7 +164,7 @@ class QuoteController extends Controller
|
||||
|
||||
$quote->calculateTotals();
|
||||
|
||||
return redirect()->route('seller.crm.quotes.show', $quote)
|
||||
return redirect()->route('seller.business.crm.quotes.show', [$business, $quote])
|
||||
->with('success', 'Quote created successfully.');
|
||||
}
|
||||
|
||||
@@ -235,12 +263,12 @@ class QuoteController extends Controller
|
||||
|
||||
$quote->calculateTotals();
|
||||
|
||||
return redirect()->route('seller.crm.quotes.show', $quote)
|
||||
return redirect()->route('seller.business.crm.quotes.show', [$business, $quote])
|
||||
->with('success', 'Quote updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send quote to contact
|
||||
* Send quote via email
|
||||
*/
|
||||
public function send(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
@@ -248,17 +276,252 @@ class QuoteController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $quote->canBeSent()) {
|
||||
return back()->withErrors(['error' => 'This quote cannot be sent.']);
|
||||
$validated = $request->validate([
|
||||
'to' => 'required|email',
|
||||
'cc' => 'nullable|string',
|
||||
'message' => 'nullable|string|max:2000',
|
||||
'attach_pdf' => 'boolean',
|
||||
]);
|
||||
|
||||
// Generate PDF if needed
|
||||
$pdfPath = null;
|
||||
if ($validated['attach_pdf'] ?? true) {
|
||||
$pdfPath = $this->generateQuotePdf($quote, $business);
|
||||
}
|
||||
|
||||
$quote->send($request->user());
|
||||
// Send email
|
||||
$ccEmails = [];
|
||||
if (! empty($validated['cc'])) {
|
||||
$ccEmails = array_map('trim', explode(',', $validated['cc']));
|
||||
}
|
||||
|
||||
// TODO: Send email notification to contact
|
||||
Mail::to($validated['to'])
|
||||
->cc($ccEmails)
|
||||
->send(new QuoteMail($quote, $business, $validated['message'] ?? null, $pdfPath));
|
||||
|
||||
// Update quote status if draft
|
||||
if ($quote->status === CrmQuote::STATUS_DRAFT) {
|
||||
$quote->send($request->user());
|
||||
}
|
||||
|
||||
// Log activity
|
||||
Activity::log(
|
||||
sellerBusinessId: $business->id,
|
||||
subject: $quote,
|
||||
type: 'quote.emailed',
|
||||
description: "Quote {$quote->quote_number} emailed to {$validated['to']}",
|
||||
causer: $request->user(),
|
||||
contactId: $quote->contact_id,
|
||||
);
|
||||
|
||||
return back()->with('success', 'Quote sent successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update quote status (accept/decline/expire)
|
||||
*/
|
||||
public function updateStatus(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|in:accepted,rejected,expired',
|
||||
'note' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$oldStatus = $quote->status;
|
||||
|
||||
if ($validated['status'] === 'accepted') {
|
||||
$quote->accept();
|
||||
} elseif ($validated['status'] === 'rejected') {
|
||||
$quote->reject($validated['note'] ?? 'Declined by seller');
|
||||
} else {
|
||||
$quote->update([
|
||||
'status' => CrmQuote::STATUS_EXPIRED,
|
||||
]);
|
||||
}
|
||||
|
||||
Activity::log(
|
||||
sellerBusinessId: $business->id,
|
||||
subject: $quote,
|
||||
type: 'quote.status_changed',
|
||||
description: "Quote {$quote->quote_number} status changed from {$oldStatus} to {$validated['status']}",
|
||||
causer: $request->user(),
|
||||
contactId: $quote->contact_id,
|
||||
);
|
||||
|
||||
return back()->with('success', 'Quote status updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert quote to order
|
||||
*/
|
||||
public function convertToOrder(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($quote->order_id) {
|
||||
return back()->withErrors(['error' => 'This quote already has an order.']);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'also_create_invoice' => 'boolean',
|
||||
]);
|
||||
|
||||
// Create order from quote
|
||||
$orderNumber = 'ORD-'.strtoupper(uniqid());
|
||||
|
||||
$order = Order::create([
|
||||
'order_number' => $orderNumber,
|
||||
'business_id' => $quote->account_id, // Buyer business
|
||||
'seller_business_id' => $business->id,
|
||||
'contact_id' => $quote->contact_id,
|
||||
'user_id' => $request->user()->id,
|
||||
'subtotal' => $quote->subtotal,
|
||||
'surcharge' => 0,
|
||||
'tax' => $quote->tax_amount,
|
||||
'total' => $quote->total,
|
||||
'status' => 'new',
|
||||
'created_by' => 'seller',
|
||||
'payment_terms' => 'net_30',
|
||||
'notes' => $quote->notes,
|
||||
]);
|
||||
|
||||
// Copy line items
|
||||
foreach ($quote->items as $item) {
|
||||
OrderItem::create([
|
||||
'order_id' => $order->id,
|
||||
'product_id' => $item->product_id,
|
||||
'quantity' => $item->quantity,
|
||||
'unit_price' => $item->unit_price,
|
||||
'line_total' => $item->line_total,
|
||||
'product_name' => $item->product?->name ?? $item->description,
|
||||
'product_sku' => $item->product?->sku ?? '',
|
||||
'brand_name' => $item->product?->brand?->name ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
// Link quote to order and update status
|
||||
$quote->update([
|
||||
'order_id' => $order->id,
|
||||
'status' => CrmQuote::STATUS_ACCEPTED,
|
||||
'accepted_at' => now(),
|
||||
]);
|
||||
|
||||
// Log activity
|
||||
Activity::log(
|
||||
sellerBusinessId: $business->id,
|
||||
subject: $quote,
|
||||
type: 'quote.converted_to_order',
|
||||
description: "Quote {$quote->quote_number} converted to Order {$orderNumber}",
|
||||
causer: $request->user(),
|
||||
contactId: $quote->contact_id,
|
||||
);
|
||||
|
||||
// Optionally create invoice
|
||||
if ($validated['also_create_invoice'] ?? false) {
|
||||
$invoice = $quote->convertToInvoice();
|
||||
|
||||
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||
->with('success', 'Order and invoice created from quote.');
|
||||
}
|
||||
|
||||
return redirect()->route('seller.business.orders.show', [$business, $order])
|
||||
->with('success', 'Order created from quote.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate invoice from quote (or its order)
|
||||
*/
|
||||
public function generateInvoice(Request $request, Business $business, CrmQuote $quote, ArService $arService)
|
||||
{
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($quote->invoice) {
|
||||
return back()->withErrors(['error' => 'This quote already has an invoice.']);
|
||||
}
|
||||
|
||||
// Credit check if there's a buyer account
|
||||
if ($quote->account_id) {
|
||||
$buyerBusiness = Business::find($quote->account_id);
|
||||
|
||||
if ($buyerBusiness) {
|
||||
$creditCheck = $arService->checkCreditForAccount(
|
||||
$business,
|
||||
$buyerBusiness,
|
||||
(float) $quote->total
|
||||
);
|
||||
|
||||
if (! $creditCheck['can_extend']) {
|
||||
return back()->withErrors([
|
||||
'error' => 'Cannot create invoice: '.$creditCheck['reason'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$invoice = $quote->convertToInvoice();
|
||||
|
||||
Activity::log(
|
||||
sellerBusinessId: $business->id,
|
||||
subject: $quote,
|
||||
type: 'quote.invoice_generated',
|
||||
description: "Invoice {$invoice->invoice_number} generated from Quote {$quote->quote_number}",
|
||||
causer: $request->user(),
|
||||
contactId: $quote->contact_id,
|
||||
);
|
||||
|
||||
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||
->with('success', 'Invoice created from quote.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and store quote PDF
|
||||
*/
|
||||
protected function generateQuotePdf(CrmQuote $quote, Business $business): ?string
|
||||
{
|
||||
$quote->load(['contact', 'account', 'items.product.brand', 'business']);
|
||||
|
||||
$pdf = Pdf::loadView('pdfs.crm-quote', [
|
||||
'quote' => $quote,
|
||||
'business' => $business,
|
||||
'sellerBusiness' => $business,
|
||||
]);
|
||||
|
||||
$filename = "quotes/{$quote->quote_number}.pdf";
|
||||
Storage::put($filename, $pdf->output());
|
||||
|
||||
$quote->update(['pdf_path' => $filename]);
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* View quote PDF
|
||||
*/
|
||||
public function pdf(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$quote->load(['contact', 'account', 'items.product.brand', 'business']);
|
||||
|
||||
$pdf = Pdf::loadView('pdfs.crm-quote', [
|
||||
'quote' => $quote,
|
||||
'business' => $business,
|
||||
'sellerBusiness' => $business,
|
||||
]);
|
||||
|
||||
return $pdf->inline("{$quote->quote_number}.pdf");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert quote to invoice
|
||||
*/
|
||||
@@ -302,7 +565,7 @@ class QuoteController extends Controller
|
||||
|
||||
$invoice = $quote->convertToInvoice();
|
||||
|
||||
return redirect()->route('seller.crm.invoices.show', $invoice)
|
||||
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||
->with('success', 'Invoice created from quote.');
|
||||
}
|
||||
|
||||
@@ -330,7 +593,7 @@ class QuoteController extends Controller
|
||||
|
||||
$quote->delete();
|
||||
|
||||
return redirect()->route('seller.crm.quotes.index')
|
||||
return redirect()->route('seller.business.crm.quotes.index', $business)
|
||||
->with('success', 'Quote deleted.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,30 @@ class TaskController extends Controller
|
||||
$tasksQuery->where('type', $request->type);
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('q')) {
|
||||
$search = $request->q;
|
||||
$tasksQuery->where(function ($q) use ($search) {
|
||||
$q->where('title', 'ILIKE', "%{$search}%")
|
||||
->orWhere('details', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$tasks = $tasksQuery->paginate(25);
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $tasks->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'name' => $t->title,
|
||||
'type' => $t->type,
|
||||
'assignee' => $t->assignee?->name ?? 'Unassigned',
|
||||
'due_at' => $t->due_at?->format('M j, Y'),
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Get stats with single efficient query
|
||||
$statsQuery = CrmTask::where('seller_business_id', $business->id)
|
||||
->selectRaw('
|
||||
@@ -62,7 +84,12 @@ class TaskController extends Controller
|
||||
// Get team members for assignment filter
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
|
||||
return view('seller.crm.tasks.index', compact('business', 'tasks', 'counts', 'teamMembers'));
|
||||
// Get buyer businesses (accounts) for filtering
|
||||
$buyerBusinesses = Business::where('type', 'buyer')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
return view('seller.crm.tasks.index', compact('business', 'tasks', 'counts', 'teamMembers', 'buyerBusinesses'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,13 +22,113 @@ class ThreadController extends Controller
|
||||
protected CrmAiService $aiService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Show compose form for new thread
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
// Get customer business IDs (businesses that have ordered from this seller)
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
// Get contacts from customer businesses (accounts)
|
||||
$contacts = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->with('business:id,name')
|
||||
->orderBy('first_name')
|
||||
->limit(200)
|
||||
->get();
|
||||
|
||||
// Get available channels
|
||||
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||
|
||||
// Pre-select contact if provided
|
||||
$selectedContact = null;
|
||||
if ($request->filled('contact_id')) {
|
||||
$selectedContact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->find($request->contact_id);
|
||||
}
|
||||
|
||||
return view('seller.crm.threads.create', compact('business', 'contacts', 'channels', 'selectedContact'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new thread and send initial message
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'contact_id' => 'required|exists:contacts,id',
|
||||
'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app',
|
||||
'subject' => 'nullable|string|max:255',
|
||||
'body' => 'required|string|max:10000',
|
||||
'attachments.*' => 'nullable|file|max:10240',
|
||||
]);
|
||||
|
||||
// Get customer business IDs (businesses that have ordered from this seller)
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
// SECURITY: Verify contact belongs to a customer business
|
||||
$contact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->findOrFail($validated['contact_id']);
|
||||
|
||||
// Determine recipient address
|
||||
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
|
||||
? $contact->email
|
||||
: $contact->phone;
|
||||
|
||||
if (! $to) {
|
||||
return back()->withInput()->withErrors([
|
||||
'channel_type' => 'Contact does not have the required contact info for this channel.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Create thread first
|
||||
$thread = CrmThread::create([
|
||||
'business_id' => $business->id,
|
||||
'contact_id' => $contact->id,
|
||||
'account_id' => $contact->account_id,
|
||||
'subject' => $validated['subject'],
|
||||
'status' => 'open',
|
||||
'priority' => 'normal',
|
||||
'last_channel_type' => $validated['channel_type'],
|
||||
'assigned_to' => $request->user()->id,
|
||||
]);
|
||||
|
||||
// Send the message
|
||||
$success = $this->channelService->sendMessage(
|
||||
businessId: $business->id,
|
||||
channelType: $validated['channel_type'],
|
||||
to: $to,
|
||||
body: $validated['body'],
|
||||
subject: $validated['subject'] ?? null,
|
||||
threadId: $thread->id,
|
||||
contactId: $contact->id,
|
||||
userId: $request->user()->id,
|
||||
attachments: $request->file('attachments', [])
|
||||
);
|
||||
|
||||
if (! $success) {
|
||||
// Delete the thread if message failed
|
||||
$thread->delete();
|
||||
|
||||
return back()->withInput()->withErrors(['body' => 'Failed to send message.']);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.threads.show', [$business, $thread])
|
||||
->with('success', 'Conversation started successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display unified inbox
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$query = CrmThread::forBusiness($business->id)
|
||||
->with(['contact', 'assignee', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||
->with(['contact', 'assignee', 'brand', 'channel', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||
->withCount('messages');
|
||||
|
||||
// Filters
|
||||
@@ -52,6 +152,16 @@ class ThreadController extends Controller
|
||||
$query->withPriority($request->priority);
|
||||
}
|
||||
|
||||
// Department filter
|
||||
if ($request->filled('department')) {
|
||||
$query->forDepartment($request->department);
|
||||
}
|
||||
|
||||
// Brand filter
|
||||
if ($request->filled('brand_id')) {
|
||||
$query->forBrand($request->brand_id);
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('subject', 'like', "%{$request->search}%")
|
||||
@@ -70,7 +180,16 @@ class ThreadController extends Controller
|
||||
// Get available channels
|
||||
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||
|
||||
return view('seller.crm.threads.index', compact('business', 'threads', 'teamMembers', 'channels'));
|
||||
// Get brands for filter dropdown
|
||||
$brands = \App\Models\Brand::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get departments for filter dropdown
|
||||
$departments = CrmChannel::DEPARTMENTS;
|
||||
|
||||
return view('seller.crm.threads.index', compact('business', 'threads', 'teamMembers', 'channels', 'brands', 'departments'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +207,8 @@ class ThreadController extends Controller
|
||||
'contact',
|
||||
'account',
|
||||
'assignee',
|
||||
'brand',
|
||||
'channel',
|
||||
'messages.attachments',
|
||||
'messages.user',
|
||||
'deals',
|
||||
@@ -168,6 +289,12 @@ class ThreadController extends Controller
|
||||
return back()->withErrors(['body' => 'Failed to send message.']);
|
||||
}
|
||||
|
||||
// Auto-assign thread to sender if unassigned
|
||||
if ($thread->assigned_to === null) {
|
||||
$thread->assigned_to = $request->user()->id;
|
||||
$thread->save();
|
||||
}
|
||||
|
||||
// Handle SLA
|
||||
$this->slaService->handleOutboundMessage($thread);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class EmailSettingsController extends Controller
|
||||
'business' => $business,
|
||||
'settings' => $settings,
|
||||
'drivers' => BusinessMailSettings::DRIVERS,
|
||||
'providers' => BusinessMailSettings::PROVIDERS,
|
||||
'encryptions' => BusinessMailSettings::ENCRYPTIONS,
|
||||
'commonPorts' => BusinessMailSettings::COMMON_PORTS,
|
||||
]);
|
||||
@@ -34,6 +35,7 @@ class EmailSettingsController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'driver' => ['required', 'string', Rule::in(array_keys(BusinessMailSettings::DRIVERS))],
|
||||
'provider' => ['required', 'string', Rule::in(array_keys(BusinessMailSettings::PROVIDERS))],
|
||||
'host' => ['nullable', 'string', 'max:255'],
|
||||
'port' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||
'encryption' => ['nullable', 'string', Rule::in(['tls', 'ssl', ''])],
|
||||
@@ -43,6 +45,9 @@ class EmailSettingsController extends Controller
|
||||
'from_email' => ['nullable', 'email', 'max:255'],
|
||||
'reply_to_email' => ['nullable', 'email', 'max:255'],
|
||||
'is_active' => ['boolean'],
|
||||
// Postal-specific config fields
|
||||
'postal_server_url' => ['nullable', 'url', 'max:255'],
|
||||
'postal_webhook_secret' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
// Handle empty encryption value
|
||||
@@ -55,6 +60,21 @@ class EmailSettingsController extends Controller
|
||||
unset($validated['password']);
|
||||
}
|
||||
|
||||
// Build provider_config from provider-specific fields
|
||||
$providerConfig = [];
|
||||
if ($validated['provider'] === BusinessMailSettings::PROVIDER_POSTAL) {
|
||||
if (! empty($validated['postal_server_url'])) {
|
||||
$providerConfig['server_url'] = $validated['postal_server_url'];
|
||||
}
|
||||
if (! empty($validated['postal_webhook_secret'])) {
|
||||
$providerConfig['webhook_secret'] = $validated['postal_webhook_secret'];
|
||||
}
|
||||
}
|
||||
$validated['provider_config'] = ! empty($providerConfig) ? $providerConfig : null;
|
||||
|
||||
// Remove provider-specific fields from main validated array
|
||||
unset($validated['postal_server_url'], $validated['postal_webhook_secret']);
|
||||
|
||||
$settings = BusinessMailSettings::getOrCreate($business);
|
||||
$settings->update($validated);
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\Invoices\InvoiceSentMail;
|
||||
use App\Models\Business;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoicePayment;
|
||||
use App\Services\InvoiceService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -25,64 +28,7 @@ class InvoiceController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get all products from brands owned by this business with images, stock levels, and batches
|
||||
$products = \App\Models\Product::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->with(['brand', 'images', 'availableBatches.labs'])
|
||||
->select('id', 'brand_id', 'name', 'sku', 'description', 'wholesale_price', 'msrp_price',
|
||||
'quantity_on_hand', 'quantity_allocated', 'type', 'image_path')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function ($product) use ($business) {
|
||||
// Map batches with their COA data
|
||||
$batches = $product->availableBatches->map(function ($batch) {
|
||||
$latestLab = $batch->getLatestLab();
|
||||
|
||||
return [
|
||||
'id' => $batch->id,
|
||||
'batch_number' => $batch->batch_number,
|
||||
'quantity_available' => $batch->quantity_available,
|
||||
'production_date' => $batch->production_date?->format('M j, Y'),
|
||||
'expiration_date' => $batch->expiration_date?->format('M j, Y'),
|
||||
'is_expiring_soon' => $batch->isExpiringSoon(),
|
||||
'lab' => $latestLab ? [
|
||||
'total_thc' => $latestLab->total_thc,
|
||||
'total_cbd' => $latestLab->total_cbd,
|
||||
'test_date' => $latestLab->test_date->format('M j, Y'),
|
||||
'lab_name' => $latestLab->lab_name,
|
||||
'compliance_pass' => $latestLab->compliance_pass,
|
||||
'terpene_profile' => $latestLab->terpene_profile,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
|
||||
// Calculate inventory from InventoryItem model
|
||||
$totalOnHand = $product->inventoryItems()
|
||||
->where('business_id', $business->id)
|
||||
->sum('quantity_on_hand');
|
||||
$totalAllocated = $product->inventoryItems()
|
||||
->where('business_id', $business->id)
|
||||
->sum('quantity_allocated');
|
||||
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'sku' => $product->sku,
|
||||
'description' => $product->description,
|
||||
'brand_name' => $product->brand?->name,
|
||||
'wholesale_price' => $product->wholesale_price,
|
||||
'msrp_price' => $product->msrp_price,
|
||||
'quantity_on_hand' => $totalOnHand,
|
||||
'quantity_allocated' => $totalAllocated,
|
||||
'quantity_available' => max(0, $totalOnHand - $totalAllocated),
|
||||
'type' => $product->type,
|
||||
'image_url' => $product->images->first()?->path
|
||||
? \Storage::url($product->images->first()->path)
|
||||
: ($product->image_path ? \Storage::url($product->image_path) : null),
|
||||
'batches' => $batches,
|
||||
'has_batches' => $batches->count() > 0,
|
||||
];
|
||||
});
|
||||
// Products are loaded via API search (/search/invoice-products) for better performance
|
||||
|
||||
// Get recently invoiced products (last 30 days, top 10 most common)
|
||||
$recentProducts = \App\Models\Product::forBusiness($business)
|
||||
@@ -118,7 +64,7 @@ class InvoiceController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
return view('seller.invoices.create', compact('business', 'buyers', 'products', 'recentProducts'));
|
||||
return view('seller.invoices.create', compact('business', 'buyers', 'recentProducts'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,7 +118,7 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Display a listing of invoices for the business.
|
||||
*/
|
||||
public function index(Business $business)
|
||||
public function index(Business $business, Request $request)
|
||||
{
|
||||
// Get brand IDs for this business (single query, reused for filtering)
|
||||
$brandIds = $business->brands()->pluck('id');
|
||||
@@ -192,11 +138,47 @@ class InvoiceController extends Controller
|
||||
->where('due_date', '<', now())->count(),
|
||||
];
|
||||
|
||||
// Apply search filter - search by customer business name or invoice number
|
||||
$search = $request->input('search');
|
||||
if ($search) {
|
||||
$baseQuery->where(function ($query) use ($search) {
|
||||
$query->where('invoice_number', 'ilike', "%{$search}%")
|
||||
->orWhereHas('business', function ($q) use ($search) {
|
||||
$q->where('name', 'ilike', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
$status = $request->input('status');
|
||||
if ($status === 'unpaid') {
|
||||
$baseQuery->where('payment_status', 'unpaid');
|
||||
} elseif ($status === 'paid') {
|
||||
$baseQuery->where('payment_status', 'paid');
|
||||
} elseif ($status === 'overdue') {
|
||||
$baseQuery->where('payment_status', '!=', 'paid')
|
||||
->where('due_date', '<', now());
|
||||
}
|
||||
|
||||
// Paginate with only the relations needed for display
|
||||
$invoices = (clone $baseQuery)
|
||||
->with(['business:id,name,primary_contact_email,business_email', 'order:id,contact_id,user_id', 'order.contact:id,first_name,last_name,email', 'order.user:id,email'])
|
||||
->latest()
|
||||
->paginate(25);
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $invoices->map(fn ($i) => [
|
||||
'hashid' => $i->hashid,
|
||||
'name' => $i->invoice_number.' - '.$i->business->name,
|
||||
'invoice_number' => $i->invoice_number,
|
||||
'customer' => $i->business->name,
|
||||
'status' => $i->payment_status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.invoices.index', compact('business', 'invoices', 'stats'));
|
||||
}
|
||||
@@ -207,7 +189,13 @@ class InvoiceController extends Controller
|
||||
public function show(Business $business, Invoice $invoice)
|
||||
{
|
||||
// Verify invoice belongs to this business through order items
|
||||
$invoice->load(['order.items.product.brand', 'business']);
|
||||
$invoice->load([
|
||||
'order.items.product.brand',
|
||||
'order.contact',
|
||||
'order.user',
|
||||
'business',
|
||||
'payments.recordedByUser',
|
||||
]);
|
||||
|
||||
// Check if any of the order's items belong to brands owned by this business
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
@@ -297,4 +285,102 @@ class InvoiceController extends Controller
|
||||
'contacts' => $contacts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send invoice by email.
|
||||
*/
|
||||
public function send(Business $business, Invoice $invoice, Request $request, InvoiceService $invoiceService): Response
|
||||
{
|
||||
// Verify invoice belongs to this business through order items
|
||||
$invoice->load('order.items.product.brand');
|
||||
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
return $item->product && $item->product->belongsToBusiness($business);
|
||||
});
|
||||
|
||||
if (! $belongsToBusiness) {
|
||||
abort(403, 'This invoice does not belong to your business');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'to' => ['required', 'email'],
|
||||
'cc' => ['nullable', 'email'],
|
||||
'message' => ['nullable', 'string', 'max:2000'],
|
||||
'attach_pdf' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
// Generate PDF if requested
|
||||
$pdfContent = null;
|
||||
if ($validated['attach_pdf'] ?? false) {
|
||||
// Regenerate PDF if it doesn't exist
|
||||
if (! $invoice->pdf_path || ! Storage::disk('local')->exists($invoice->pdf_path)) {
|
||||
$invoiceService->regeneratePdf($invoice);
|
||||
$invoice->refresh();
|
||||
}
|
||||
|
||||
if ($invoice->pdf_path && Storage::disk('local')->exists($invoice->pdf_path)) {
|
||||
$pdfContent = Storage::disk('local')->get($invoice->pdf_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Send email
|
||||
$mail = Mail::to($validated['to']);
|
||||
|
||||
if (! empty($validated['cc'])) {
|
||||
$mail->cc($validated['cc']);
|
||||
}
|
||||
|
||||
$mail->send(new InvoiceSentMail(
|
||||
$invoice,
|
||||
$validated['message'] ?? null,
|
||||
$pdfContent
|
||||
));
|
||||
|
||||
return back()->with('success', 'Invoice sent successfully to '.$validated['to']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a payment for an invoice.
|
||||
*/
|
||||
public function recordPayment(Business $business, Invoice $invoice, Request $request): Response
|
||||
{
|
||||
// Verify invoice belongs to this business through order items
|
||||
$invoice->load('order.items.product.brand');
|
||||
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
return $item->product && $item->product->belongsToBusiness($business);
|
||||
});
|
||||
|
||||
if (! $belongsToBusiness) {
|
||||
abort(403, 'This invoice does not belong to your business');
|
||||
}
|
||||
|
||||
if ($invoice->payment_status === 'paid') {
|
||||
return back()->withErrors(['error' => 'This invoice is already fully paid.']);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'amount' => ['required', 'numeric', 'min:0.01', 'max:'.$invoice->amount_due],
|
||||
'payment_date' => ['required', 'date'],
|
||||
'payment_method' => ['required', 'string', 'in:cash,check,wire,ach,credit_card,bank_transfer,other'],
|
||||
'reference' => ['nullable', 'string', 'max:255'],
|
||||
'notes' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
InvoicePayment::create([
|
||||
'invoice_id' => $invoice->id,
|
||||
'amount' => $validated['amount'],
|
||||
'payment_date' => $validated['payment_date'],
|
||||
'payment_method' => $validated['payment_method'],
|
||||
'reference' => $validated['reference'],
|
||||
'notes' => $validated['notes'],
|
||||
'recorded_by' => $request->user()->id,
|
||||
]);
|
||||
|
||||
$statusMessage = $invoice->fresh()->payment_status === 'paid'
|
||||
? 'Payment recorded. Invoice is now fully paid.'
|
||||
: 'Payment recorded successfully.';
|
||||
|
||||
return back()->with('success', $statusMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Marketing\Campaign;
|
||||
use App\Models\Marketing\MarketingChannel;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Models\Marketing\MarketingTemplate;
|
||||
use App\Services\AI\TemplatePromptBuilder;
|
||||
use App\Services\Marketing\AIContentService;
|
||||
@@ -45,13 +46,21 @@ class CampaignController extends Controller
|
||||
$preselectedSegment = $request->query('segment');
|
||||
$preselectedBrand = $request->query('brand_id');
|
||||
|
||||
// Pre-populate from Promo if promo_id provided
|
||||
$promo = null;
|
||||
if ($request->query('promo_id')) {
|
||||
$promo = MarketingPromo::where('business_id', $business->id)
|
||||
->find($request->query('promo_id'));
|
||||
}
|
||||
|
||||
return view('seller.marketing.campaigns.create', compact(
|
||||
'business',
|
||||
'brands',
|
||||
'channels',
|
||||
'templates',
|
||||
'preselectedSegment',
|
||||
'preselectedBrand'
|
||||
'preselectedBrand',
|
||||
'promo'
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
148
app/Http/Controllers/Seller/Marketing/IntelligenceController.php
Normal file
148
app/Http/Controllers/Seller/Marketing/IntelligenceController.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Services\Marketing\MarketingIntelligenceService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Marketing Intelligence Controller
|
||||
*
|
||||
* Displays market intelligence data from CannaiQ including:
|
||||
* - Store-level metrics (pricing position, market share, trends)
|
||||
* - Product metrics (velocity, pricing history, competitor positioning)
|
||||
* - Competitor snapshots (out-of-stock, pricing, promotions)
|
||||
*/
|
||||
class IntelligenceController extends Controller
|
||||
{
|
||||
protected MarketingIntelligenceService $intelligence;
|
||||
|
||||
public function __construct(MarketingIntelligenceService $intelligence)
|
||||
{
|
||||
$this->intelligence = $intelligence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the marketing intelligence dashboard
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
// Get brands for filtering
|
||||
$brands = Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get store external ID from business settings or request
|
||||
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
|
||||
|
||||
// Fetch intelligence data if store is configured
|
||||
$storeMetrics = [];
|
||||
$productMetrics = [];
|
||||
$competitorSnapshot = [];
|
||||
$trends = [];
|
||||
|
||||
if ($storeExternalId) {
|
||||
$storeMetrics = $this->intelligence->getStoreIntelligence($business->id, $storeExternalId);
|
||||
$productMetrics = $this->intelligence->getProductIntelligence($business->id, $storeExternalId, 20);
|
||||
$competitorSnapshot = $this->intelligence->getCompetitorSnapshot($business->id, $storeExternalId);
|
||||
$trends = $this->intelligence->getMarketTrends($business->id, $storeExternalId);
|
||||
}
|
||||
|
||||
return view('seller.marketing.intelligence.index', compact(
|
||||
'business',
|
||||
'brands',
|
||||
'storeExternalId',
|
||||
'storeMetrics',
|
||||
'productMetrics',
|
||||
'competitorSnapshot',
|
||||
'trends'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display store-level intelligence details
|
||||
*/
|
||||
public function store(Request $request, $businessSlug, string $storeExternalId)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
$storeData = $this->intelligence->getStoreIntelligence($business->id, $storeExternalId);
|
||||
$productMetrics = $this->intelligence->getProductIntelligence($business->id, $storeExternalId, 50);
|
||||
$trends = $this->intelligence->getMarketTrends($business->id, $storeExternalId);
|
||||
|
||||
return view('seller.marketing.intelligence.store', compact(
|
||||
'business',
|
||||
'storeExternalId',
|
||||
'storeData',
|
||||
'productMetrics',
|
||||
'trends'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display product-level intelligence details
|
||||
*/
|
||||
public function product(Request $request, $businessSlug, string $productExternalId)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
// Get store context
|
||||
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
|
||||
|
||||
$productData = [];
|
||||
$priceHistory = [];
|
||||
$competitorPricing = [];
|
||||
|
||||
if ($storeExternalId) {
|
||||
// Get product data from cached metrics
|
||||
$allProducts = $this->intelligence->getProductIntelligence($business->id, $storeExternalId, 100);
|
||||
$products = $allProducts['products'] ?? [];
|
||||
|
||||
// Find the specific product
|
||||
$productData = collect($products)->firstWhere('product_id', $productExternalId) ?? [];
|
||||
|
||||
// Price history would come from historical snapshots
|
||||
// For now, placeholder
|
||||
$priceHistory = [];
|
||||
$competitorPricing = [];
|
||||
}
|
||||
|
||||
return view('seller.marketing.intelligence.product', compact(
|
||||
'business',
|
||||
'productExternalId',
|
||||
'productData',
|
||||
'priceHistory',
|
||||
'competitorPricing'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh intelligence data from CannaiQ
|
||||
*/
|
||||
public function refresh(Request $request, $businessSlug)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
|
||||
|
||||
if (! $storeExternalId) {
|
||||
return redirect()
|
||||
->route('seller.business.marketing.intelligence.index', $business->slug)
|
||||
->with('error', 'No store configured for intelligence data.');
|
||||
}
|
||||
|
||||
$results = $this->intelligence->refreshIntelligence($business->id, $storeExternalId);
|
||||
|
||||
$successCount = count(array_filter($results));
|
||||
$message = $successCount > 0
|
||||
? "Intelligence data refreshed ({$successCount}/3 data sources updated)."
|
||||
: 'Failed to refresh intelligence data. Please try again later.';
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.intelligence.index', $business->slug)
|
||||
->with($successCount > 0 ? 'success' : 'error', $message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\SendMarketingCampaignJob;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Services\Messaging\EmailSender;
|
||||
use App\Services\Messaging\SmsSender;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MarketingCampaignController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$query = MarketingCampaign::forBusiness($business->id)
|
||||
->with('list')
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
if ($request->filled('channel')) {
|
||||
$query->channel($request->channel);
|
||||
}
|
||||
|
||||
$campaigns = $query->paginate(25)->withQueryString();
|
||||
|
||||
return view('seller.marketing.campaigns.index', [
|
||||
'business' => $business,
|
||||
'campaigns' => $campaigns,
|
||||
'statuses' => MarketingCampaign::STATUSES,
|
||||
'channels' => MarketingCampaign::CHANNELS,
|
||||
'filters' => $request->only(['status', 'channel']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business): View
|
||||
{
|
||||
$lists = MarketingList::forBusiness($business->id)->get();
|
||||
|
||||
// Pre-fill from promo if source=promo
|
||||
$prefill = [];
|
||||
if ($request->source === 'promo' && $request->promo_id) {
|
||||
$promo = MarketingPromo::forBusiness($business->id)->find($request->promo_id);
|
||||
if ($promo) {
|
||||
$prefill = $this->prefillFromPromo($promo, $request->channel ?? 'email');
|
||||
}
|
||||
}
|
||||
|
||||
return view('seller.marketing.campaigns.create', [
|
||||
'business' => $business,
|
||||
'lists' => $lists,
|
||||
'channels' => MarketingCampaign::CHANNELS,
|
||||
'prefill' => $prefill,
|
||||
'source' => $request->source,
|
||||
'sourceId' => $request->promo_id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'channel' => 'required|in:email,sms,multi',
|
||||
'marketing_list_id' => 'nullable|exists:marketing_lists,id',
|
||||
'subject' => 'nullable|string|max:255',
|
||||
'email_preview_text' => 'nullable|string|max:255',
|
||||
'sms_body' => 'nullable|string|max:1600',
|
||||
'email_body_html' => 'nullable|string',
|
||||
'from_name' => 'nullable|string|max:255',
|
||||
'from_email' => 'nullable|email|max:255',
|
||||
'source_type' => 'nullable|string|in:manual,promo,automation',
|
||||
'source_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
// Verify list belongs to business
|
||||
if ($validated['marketing_list_id']) {
|
||||
$list = MarketingList::where('business_id', $business->id)
|
||||
->find($validated['marketing_list_id']);
|
||||
if (! $list) {
|
||||
return back()->withErrors(['marketing_list_id' => 'Invalid list selected.'])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
$campaign = MarketingCampaign::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'channel' => $validated['channel'],
|
||||
'status' => MarketingCampaign::STATUS_DRAFT,
|
||||
'marketing_list_id' => $validated['marketing_list_id'] ?? null,
|
||||
'subject' => $validated['subject'] ?? null,
|
||||
'email_preview_text' => $validated['email_preview_text'] ?? null,
|
||||
'sms_body' => $validated['sms_body'] ?? null,
|
||||
'email_body_html' => $validated['email_body_html'] ?? null,
|
||||
'from_name' => $validated['from_name'] ?? null,
|
||||
'from_email' => $validated['from_email'] ?? null,
|
||||
'source_type' => $validated['source_type'] ?? MarketingCampaign::SOURCE_MANUAL,
|
||||
'source_id' => $validated['source_id'] ?? null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
|
||||
->with('success', 'Campaign created successfully.');
|
||||
}
|
||||
|
||||
public function show(Business $business, MarketingCampaign $campaign): View
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
$campaign->load('list', 'messageLogs');
|
||||
|
||||
return view('seller.marketing.campaigns.show', [
|
||||
'business' => $business,
|
||||
'campaign' => $campaign,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Business $business, MarketingCampaign $campaign): View
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
if (! $campaign->canEdit()) {
|
||||
return redirect()
|
||||
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
|
||||
->with('error', 'Cannot edit a campaign that is sending or sent.');
|
||||
}
|
||||
|
||||
$lists = MarketingList::forBusiness($business->id)->get();
|
||||
|
||||
return view('seller.marketing.campaigns.edit', [
|
||||
'business' => $business,
|
||||
'campaign' => $campaign,
|
||||
'lists' => $lists,
|
||||
'channels' => MarketingCampaign::CHANNELS,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Business $business, MarketingCampaign $campaign): RedirectResponse
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
if (! $campaign->canEdit()) {
|
||||
return back()->with('error', 'Cannot edit a campaign that is sending or sent.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'channel' => 'required|in:email,sms,multi',
|
||||
'marketing_list_id' => 'nullable|exists:marketing_lists,id',
|
||||
'subject' => 'nullable|string|max:255',
|
||||
'email_preview_text' => 'nullable|string|max:255',
|
||||
'sms_body' => 'nullable|string|max:1600',
|
||||
'email_body_html' => 'nullable|string',
|
||||
'from_name' => 'nullable|string|max:255',
|
||||
'from_email' => 'nullable|email|max:255',
|
||||
]);
|
||||
|
||||
// Verify list belongs to business
|
||||
if ($validated['marketing_list_id']) {
|
||||
$list = MarketingList::where('business_id', $business->id)
|
||||
->find($validated['marketing_list_id']);
|
||||
if (! $list) {
|
||||
return back()->withErrors(['marketing_list_id' => 'Invalid list selected.'])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
$campaign->update([
|
||||
'name' => $validated['name'],
|
||||
'channel' => $validated['channel'],
|
||||
'marketing_list_id' => $validated['marketing_list_id'] ?? null,
|
||||
'subject' => $validated['subject'] ?? null,
|
||||
'email_preview_text' => $validated['email_preview_text'] ?? null,
|
||||
'sms_body' => $validated['sms_body'] ?? null,
|
||||
'email_body_html' => $validated['email_body_html'] ?? null,
|
||||
'from_name' => $validated['from_name'] ?? null,
|
||||
'from_email' => $validated['from_email'] ?? null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
|
||||
->with('success', 'Campaign updated successfully.');
|
||||
}
|
||||
|
||||
public function schedule(Request $request, Business $business, MarketingCampaign $campaign): RedirectResponse
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
if (! $campaign->canSchedule()) {
|
||||
return back()->with('error', 'Cannot schedule this campaign.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'send_at' => 'required|date|after:now',
|
||||
]);
|
||||
|
||||
$campaign->schedule(new \DateTime($validated['send_at']));
|
||||
|
||||
return back()->with('success', 'Campaign scheduled for '.date('M j, Y g:i A', strtotime($validated['send_at'])));
|
||||
}
|
||||
|
||||
public function sendNow(Business $business, MarketingCampaign $campaign): RedirectResponse
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
if (! $campaign->canSend()) {
|
||||
return back()->with('error', 'Cannot send this campaign. Make sure a list is selected and campaign is in draft status.');
|
||||
}
|
||||
|
||||
SendMarketingCampaignJob::dispatch($campaign->id);
|
||||
|
||||
return back()->with('success', 'Campaign is now being sent.');
|
||||
}
|
||||
|
||||
public function cancel(Business $business, MarketingCampaign $campaign): RedirectResponse
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
if (! $campaign->canCancel()) {
|
||||
return back()->with('error', 'Cannot cancel this campaign.');
|
||||
}
|
||||
|
||||
$campaign->cancel();
|
||||
|
||||
return back()->with('success', 'Campaign cancelled.');
|
||||
}
|
||||
|
||||
public function testEmail(Request $request, Business $business, MarketingCampaign $campaign, EmailSender $emailSender): JsonResponse
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => 'required|email',
|
||||
]);
|
||||
|
||||
if (! $campaign->hasEmailContent()) {
|
||||
return response()->json(['success' => false, 'message' => 'Campaign has no email content.']);
|
||||
}
|
||||
|
||||
$result = $emailSender->sendTestEmail($campaign, $validated['email']);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function testSms(Request $request, Business $business, MarketingCampaign $campaign, SmsSender $smsSender): JsonResponse
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
$validated = $request->validate([
|
||||
'phone' => 'required|string',
|
||||
]);
|
||||
|
||||
if (! $campaign->hasSmsContent()) {
|
||||
return response()->json(['success' => false, 'message' => 'Campaign has no SMS content.']);
|
||||
}
|
||||
|
||||
$result = $smsSender->sendTestSms($campaign, $validated['phone']);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function destroy(Business $business, MarketingCampaign $campaign): RedirectResponse
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
if ($campaign->status === MarketingCampaign::STATUS_SENDING) {
|
||||
return back()->with('error', 'Cannot delete a campaign that is currently sending.');
|
||||
}
|
||||
|
||||
$campaign->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.campaigns.index', $business)
|
||||
->with('success', 'Campaign deleted successfully.');
|
||||
}
|
||||
|
||||
protected function authorizeCampaign(Business $business, MarketingCampaign $campaign): void
|
||||
{
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
protected function prefillFromPromo(MarketingPromo $promo, string $channel): array
|
||||
{
|
||||
$name = $promo->name.' - '.($channel === 'sms' ? 'SMS' : 'Email').' Blast';
|
||||
$subject = $promo->name;
|
||||
|
||||
// Build simple description from promo
|
||||
$description = $promo->description ?? '';
|
||||
$dateRange = '';
|
||||
if ($promo->start_date && $promo->end_date) {
|
||||
$dateRange = 'Valid '.$promo->start_date->format('M j').' - '.$promo->end_date->format('M j');
|
||||
}
|
||||
|
||||
// Simple email template
|
||||
$emailHtml = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{$promo->name}</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #1a1a1a;">{$promo->name}</h1>
|
||||
<p>{$description}</p>
|
||||
<p style="font-weight: bold; color: #059669;">{$dateRange}</p>
|
||||
<p>Don't miss out on this limited time offer!</p>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
// Simple SMS
|
||||
$smsBody = $promo->name;
|
||||
if ($description) {
|
||||
$smsBody .= ' - '.substr($description, 0, 100);
|
||||
}
|
||||
if ($dateRange) {
|
||||
$smsBody .= '. '.$dateRange;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'subject' => $subject,
|
||||
'email_body_html' => $emailHtml,
|
||||
'sms_body' => $smsBody,
|
||||
'source_type' => MarketingCampaign::SOURCE_PROMO,
|
||||
'source_id' => $promo->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingContact;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MarketingContactController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$query = MarketingContact::forBusiness($business->id)
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if ($request->filled('type')) {
|
||||
$query->ofType($request->type);
|
||||
}
|
||||
|
||||
if ($request->filled('subscribed')) {
|
||||
if ($request->subscribed === 'email') {
|
||||
$query->subscribedEmail();
|
||||
} elseif ($request->subscribed === 'sms') {
|
||||
$query->subscribedSms();
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('email', 'ILIKE', "%{$search}%")
|
||||
->orWhere('phone', 'ILIKE', "%{$search}%")
|
||||
->orWhere('first_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('last_name', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$contacts = $query->paginate(25)->withQueryString();
|
||||
|
||||
$lists = MarketingList::forBusiness($business->id)->get();
|
||||
|
||||
return view('seller.marketing.contacts.index', [
|
||||
'business' => $business,
|
||||
'contacts' => $contacts,
|
||||
'lists' => $lists,
|
||||
'types' => MarketingContact::TYPES,
|
||||
'filters' => $request->only(['type', 'subscribed', 'search']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Business $business): View
|
||||
{
|
||||
return view('seller.marketing.contacts.create', [
|
||||
'business' => $business,
|
||||
'types' => MarketingContact::TYPES,
|
||||
'sources' => MarketingContact::SOURCES,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'type' => 'required|in:buyer,consumer,internal',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'first_name' => 'nullable|string|max:100',
|
||||
'last_name' => 'nullable|string|max:100',
|
||||
'tags' => 'nullable|array',
|
||||
'is_subscribed_email' => 'boolean',
|
||||
'is_subscribed_sms' => 'boolean',
|
||||
]);
|
||||
|
||||
if (empty($validated['email']) && empty($validated['phone'])) {
|
||||
return back()->withErrors(['email' => 'Either email or phone is required.'])->withInput();
|
||||
}
|
||||
|
||||
$contact = MarketingContact::create([
|
||||
'business_id' => $business->id,
|
||||
'type' => $validated['type'],
|
||||
'email' => $validated['email'] ?? null,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'first_name' => $validated['first_name'] ?? null,
|
||||
'last_name' => $validated['last_name'] ?? null,
|
||||
'tags' => $validated['tags'] ?? [],
|
||||
'source' => MarketingContact::SOURCE_MANUAL,
|
||||
'is_subscribed_email' => $validated['is_subscribed_email'] ?? true,
|
||||
'is_subscribed_sms' => $validated['is_subscribed_sms'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.contacts.index', $business)
|
||||
->with('success', 'Contact created successfully.');
|
||||
}
|
||||
|
||||
public function edit(Business $business, MarketingContact $contact): View
|
||||
{
|
||||
$this->authorizeContact($business, $contact);
|
||||
|
||||
return view('seller.marketing.contacts.edit', [
|
||||
'business' => $business,
|
||||
'contact' => $contact,
|
||||
'types' => MarketingContact::TYPES,
|
||||
'sources' => MarketingContact::SOURCES,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Business $business, MarketingContact $contact): RedirectResponse
|
||||
{
|
||||
$this->authorizeContact($business, $contact);
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => 'required|in:buyer,consumer,internal',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'first_name' => 'nullable|string|max:100',
|
||||
'last_name' => 'nullable|string|max:100',
|
||||
'tags' => 'nullable|array',
|
||||
'is_subscribed_email' => 'boolean',
|
||||
'is_subscribed_sms' => 'boolean',
|
||||
]);
|
||||
|
||||
if (empty($validated['email']) && empty($validated['phone'])) {
|
||||
return back()->withErrors(['email' => 'Either email or phone is required.'])->withInput();
|
||||
}
|
||||
|
||||
$contact->update([
|
||||
'type' => $validated['type'],
|
||||
'email' => $validated['email'] ?? null,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'first_name' => $validated['first_name'] ?? null,
|
||||
'last_name' => $validated['last_name'] ?? null,
|
||||
'tags' => $validated['tags'] ?? [],
|
||||
'is_subscribed_email' => $validated['is_subscribed_email'] ?? true,
|
||||
'is_subscribed_sms' => $validated['is_subscribed_sms'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.contacts.index', $business)
|
||||
->with('success', 'Contact updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, MarketingContact $contact): RedirectResponse
|
||||
{
|
||||
$this->authorizeContact($business, $contact);
|
||||
|
||||
$contact->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.contacts.index', $business)
|
||||
->with('success', 'Contact deleted successfully.');
|
||||
}
|
||||
|
||||
public function addToList(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'contact_ids' => 'required|array',
|
||||
'contact_ids.*' => 'integer|exists:marketing_contacts,id',
|
||||
'list_id' => 'required|integer|exists:marketing_lists,id',
|
||||
]);
|
||||
|
||||
$list = MarketingList::where('business_id', $business->id)
|
||||
->findOrFail($validated['list_id']);
|
||||
|
||||
$contacts = MarketingContact::forBusiness($business->id)
|
||||
->whereIn('id', $validated['contact_ids'])
|
||||
->pluck('id');
|
||||
|
||||
$list->addContacts($contacts->toArray());
|
||||
|
||||
return back()->with('success', count($contacts).' contact(s) added to list.');
|
||||
}
|
||||
|
||||
protected function authorizeContact(Business $business, MarketingContact $contact): void
|
||||
{
|
||||
if ($contact->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MarketingListController extends Controller
|
||||
{
|
||||
public function index(Business $business): View
|
||||
{
|
||||
$lists = MarketingList::forBusiness($business->id)
|
||||
->withCount('contacts')
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(25);
|
||||
|
||||
return view('seller.marketing.lists.index', [
|
||||
'business' => $business,
|
||||
'lists' => $lists,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Business $business): View
|
||||
{
|
||||
return view('seller.marketing.lists.create', [
|
||||
'business' => $business,
|
||||
'types' => MarketingList::TYPES,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'type' => 'required|in:static,smart',
|
||||
'filters' => 'nullable|array',
|
||||
]);
|
||||
|
||||
MarketingList::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'type' => $validated['type'],
|
||||
'filters' => $validated['filters'] ?? null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.lists.index', $business)
|
||||
->with('success', 'List created successfully.');
|
||||
}
|
||||
|
||||
public function show(Business $business, MarketingList $list): View
|
||||
{
|
||||
$this->authorizeList($business, $list);
|
||||
|
||||
$contacts = $list->getContacts()->paginate(25);
|
||||
|
||||
return view('seller.marketing.lists.show', [
|
||||
'business' => $business,
|
||||
'list' => $list,
|
||||
'contacts' => $contacts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Business $business, MarketingList $list): View
|
||||
{
|
||||
$this->authorizeList($business, $list);
|
||||
|
||||
return view('seller.marketing.lists.edit', [
|
||||
'business' => $business,
|
||||
'list' => $list,
|
||||
'types' => MarketingList::TYPES,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Business $business, MarketingList $list): RedirectResponse
|
||||
{
|
||||
$this->authorizeList($business, $list);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'filters' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$list->update([
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'filters' => $validated['filters'] ?? null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.lists.index', $business)
|
||||
->with('success', 'List updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, MarketingList $list): RedirectResponse
|
||||
{
|
||||
$this->authorizeList($business, $list);
|
||||
|
||||
$list->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.lists.index', $business)
|
||||
->with('success', 'List deleted successfully.');
|
||||
}
|
||||
|
||||
public function removeContact(Business $business, MarketingList $list, int $contactId): RedirectResponse
|
||||
{
|
||||
$this->authorizeList($business, $list);
|
||||
|
||||
$list->removeContacts([$contactId]);
|
||||
|
||||
return back()->with('success', 'Contact removed from list.');
|
||||
}
|
||||
|
||||
protected function authorizeList(Business $business, MarketingList $list): void
|
||||
{
|
||||
if ($list->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
382
app/Http/Controllers/Seller/Marketing/PromoController.php
Normal file
382
app/Http/Controllers/Seller/Marketing/PromoController.php
Normal file
@@ -0,0 +1,382 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Services\Marketing\PromoRecommendationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Promo Builder Controller
|
||||
*
|
||||
* Manages promotional offers including:
|
||||
* - Creating promos with AI recommendations
|
||||
* - Targeting stores, brands, or categories
|
||||
* - Estimating lift and margin impact
|
||||
* - Generating SMS/email copy
|
||||
*/
|
||||
class PromoController extends Controller
|
||||
{
|
||||
protected PromoRecommendationService $recommendations;
|
||||
|
||||
public function __construct(PromoRecommendationService $recommendations)
|
||||
{
|
||||
$this->recommendations = $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display list of all promos
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
$promos = MarketingPromo::forBusiness($business->id)
|
||||
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
||||
->when($request->type, fn ($q, $type) => $q->where('type', $type))
|
||||
->when($request->brand_id, fn ($q, $brandId) => $q->where('brand_id', $brandId))
|
||||
->with(['brand', 'creator'])
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
$brands = Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$promoTypes = MarketingPromo::getTypes();
|
||||
$statuses = MarketingPromo::getStatuses();
|
||||
|
||||
return view('seller.marketing.promos.index', compact(
|
||||
'business',
|
||||
'promos',
|
||||
'brands',
|
||||
'promoTypes',
|
||||
'statuses'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create promo form (Promo Builder wizard)
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
$brands = Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$promoTypes = MarketingPromo::getTypes();
|
||||
|
||||
// Get AI recommendations
|
||||
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
|
||||
$recommendations = $this->recommendations->getRecommendations($business->id, $storeExternalId);
|
||||
|
||||
return view('seller.marketing.promos.create', compact(
|
||||
'business',
|
||||
'brands',
|
||||
'promoTypes',
|
||||
'recommendations'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new promo
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|in:'.implode(',', array_keys(MarketingPromo::getTypes())),
|
||||
'brand_id' => 'nullable|exists:brands,id',
|
||||
'store_external_id' => 'nullable|string|max:100',
|
||||
'config' => 'required|array',
|
||||
'expected_lift' => 'nullable|numeric|min:0|max:100',
|
||||
'expected_margin_brand' => 'nullable|numeric',
|
||||
'expected_margin_store' => 'nullable|numeric',
|
||||
'starts_at' => 'nullable|date',
|
||||
'ends_at' => 'nullable|date|after_or_equal:starts_at',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'sms_copy' => 'nullable|string|max:160',
|
||||
'email_copy' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
// Verify brand belongs to business if provided
|
||||
if ($validated['brand_id']) {
|
||||
$brand = Brand::where('business_id', $business->id)
|
||||
->where('id', $validated['brand_id'])
|
||||
->first();
|
||||
|
||||
if (! $brand) {
|
||||
abort(404, 'Brand not found');
|
||||
}
|
||||
}
|
||||
|
||||
$promo = MarketingPromo::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'type' => $validated['type'],
|
||||
'brand_id' => $validated['brand_id'] ?? null,
|
||||
'store_external_id' => $validated['store_external_id'] ?? null,
|
||||
'config' => $validated['config'],
|
||||
'expected_lift' => $validated['expected_lift'] ?? null,
|
||||
'expected_margin_brand' => $validated['expected_margin_brand'] ?? null,
|
||||
'expected_margin_store' => $validated['expected_margin_store'] ?? null,
|
||||
'starts_at' => $validated['starts_at'] ?? null,
|
||||
'ends_at' => $validated['ends_at'] ?? null,
|
||||
'description' => $validated['description'] ?? null,
|
||||
'sms_copy' => $validated['sms_copy'] ?? null,
|
||||
'email_copy' => $validated['email_copy'] ?? null,
|
||||
'status' => MarketingPromo::STATUS_DRAFT,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
|
||||
->with('success', 'Promo created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a single promo
|
||||
*/
|
||||
public function show(Request $request, $businessSlug, MarketingPromo $promo)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$this->authorizePromo($promo, $business);
|
||||
|
||||
$promo->load(['brand', 'creator']);
|
||||
|
||||
return view('seller.marketing.promos.show', compact('business', 'promo'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit form for a promo
|
||||
*/
|
||||
public function edit(Request $request, $businessSlug, MarketingPromo $promo)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$this->authorizePromo($promo, $business);
|
||||
|
||||
$brands = Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$promoTypes = MarketingPromo::getTypes();
|
||||
|
||||
return view('seller.marketing.promos.edit', compact(
|
||||
'business',
|
||||
'promo',
|
||||
'brands',
|
||||
'promoTypes'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a promo
|
||||
*/
|
||||
public function update(Request $request, $businessSlug, MarketingPromo $promo)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$this->authorizePromo($promo, $business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'brand_id' => 'nullable|exists:brands,id',
|
||||
'store_external_id' => 'nullable|string|max:100',
|
||||
'config' => 'nullable|array',
|
||||
'expected_lift' => 'nullable|numeric|min:0|max:100',
|
||||
'expected_margin_brand' => 'nullable|numeric',
|
||||
'expected_margin_store' => 'nullable|numeric',
|
||||
'starts_at' => 'nullable|date',
|
||||
'ends_at' => 'nullable|date|after_or_equal:starts_at',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'sms_copy' => 'nullable|string|max:160',
|
||||
'email_copy' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
// Verify brand belongs to business if provided
|
||||
if ($validated['brand_id']) {
|
||||
$brand = Brand::where('business_id', $business->id)
|
||||
->where('id', $validated['brand_id'])
|
||||
->first();
|
||||
|
||||
if (! $brand) {
|
||||
abort(404, 'Brand not found');
|
||||
}
|
||||
}
|
||||
|
||||
$promo->update([
|
||||
'name' => $validated['name'],
|
||||
'brand_id' => $validated['brand_id'] ?? null,
|
||||
'store_external_id' => $validated['store_external_id'] ?? null,
|
||||
'config' => $validated['config'] ?? $promo->config,
|
||||
'expected_lift' => $validated['expected_lift'] ?? $promo->expected_lift,
|
||||
'expected_margin_brand' => $validated['expected_margin_brand'] ?? $promo->expected_margin_brand,
|
||||
'expected_margin_store' => $validated['expected_margin_store'] ?? $promo->expected_margin_store,
|
||||
'starts_at' => $validated['starts_at'] ?? $promo->starts_at,
|
||||
'ends_at' => $validated['ends_at'] ?? $promo->ends_at,
|
||||
'description' => $validated['description'] ?? $promo->description,
|
||||
'sms_copy' => $validated['sms_copy'] ?? $promo->sms_copy,
|
||||
'email_copy' => $validated['email_copy'] ?? $promo->email_copy,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
|
||||
->with('success', 'Promo updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a promo
|
||||
*/
|
||||
public function destroy(Request $request, $businessSlug, MarketingPromo $promo)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$this->authorizePromo($promo, $business);
|
||||
|
||||
$promo->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.promos.index', $business->slug)
|
||||
->with('success', 'Promo deleted successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a promo
|
||||
*/
|
||||
public function activate(Request $request, $businessSlug, MarketingPromo $promo)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$this->authorizePromo($promo, $business);
|
||||
|
||||
$promo->activate();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
|
||||
->with('success', 'Promo activated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a promo
|
||||
*/
|
||||
public function cancel(Request $request, $businessSlug, MarketingPromo $promo)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$this->authorizePromo($promo, $business);
|
||||
|
||||
$promo->cancel();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
|
||||
->with('success', 'Promo cancelled.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a promo
|
||||
*/
|
||||
public function duplicate(Request $request, $businessSlug, MarketingPromo $promo)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$this->authorizePromo($promo, $business);
|
||||
|
||||
$newPromo = $promo->replicate();
|
||||
$newPromo->name = $promo->name.' (Copy)';
|
||||
$newPromo->status = MarketingPromo::STATUS_DRAFT;
|
||||
$newPromo->created_by = auth()->id();
|
||||
$newPromo->save();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.promos.edit', [$business->slug, $newPromo])
|
||||
->with('success', 'Promo duplicated. Make your changes and save.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AI recommendations for promo
|
||||
*/
|
||||
public function recommend(Request $request, $businessSlug)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
|
||||
|
||||
$recommendations = $this->recommendations->getRecommendations($business->id, $storeExternalId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'recommendations' => $recommendations,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate promo impact
|
||||
*/
|
||||
public function estimate(Request $request, $businessSlug)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => 'required|in:'.implode(',', array_keys(MarketingPromo::getTypes())),
|
||||
'config' => 'required|array',
|
||||
'brand_id' => 'nullable|exists:brands,id',
|
||||
'store_external_id' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$estimate = $this->recommendations->estimateImpact(
|
||||
$validated,
|
||||
$business->id,
|
||||
$validated['store_external_id'] ?? $business->cannaiq_store_id ?? null
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'estimate' => $estimate,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SMS/email copy for promo
|
||||
*/
|
||||
public function generateCopy(Request $request, $businessSlug)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => 'required|in:'.implode(',', array_keys(MarketingPromo::getTypes())),
|
||||
'config' => 'required|array',
|
||||
'channel' => 'required|in:sms,email',
|
||||
'brand_id' => 'nullable|exists:brands,id',
|
||||
]);
|
||||
|
||||
// Get brand name for copy generation
|
||||
$brandName = null;
|
||||
if ($validated['brand_id']) {
|
||||
$brand = Brand::where('business_id', $business->id)
|
||||
->where('id', $validated['brand_id'])
|
||||
->first();
|
||||
$brandName = $brand?->name;
|
||||
}
|
||||
|
||||
$promoConfig = array_merge($validated, ['brand_name' => $brandName ?? 'our products']);
|
||||
|
||||
$copy = $validated['channel'] === 'sms'
|
||||
? $this->recommendations->generateSmsCopy($promoConfig)
|
||||
: $this->recommendations->generateEmailCopy($promoConfig);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'copy' => $copy,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize that the promo belongs to the business
|
||||
*/
|
||||
protected function authorizePromo(MarketingPromo $promo, $business): void
|
||||
{
|
||||
if ($promo->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
240
app/Http/Controllers/Seller/MarketingAutomationController.php
Normal file
240
app/Http/Controllers/Seller/MarketingAutomationController.php
Normal file
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\RunMarketingAutomationJob;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingAutomation;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use App\Models\Marketing\MarketingTemplate;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class MarketingAutomationController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
|
||||
$automations = MarketingAutomation::where('business_id', $business->id)
|
||||
->with('latestRun')
|
||||
->when($request->status === 'active', fn ($q) => $q->where('is_active', true))
|
||||
->when($request->status === 'inactive', fn ($q) => $q->where('is_active', false))
|
||||
->when($request->trigger_type, fn ($q, $type) => $q->where('trigger_type', $type))
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
return view('seller.marketing.automations.index', compact('business', 'automations'));
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
|
||||
$presets = MarketingAutomation::getTypePresets();
|
||||
$selectedPreset = $request->query('preset');
|
||||
|
||||
$lists = MarketingList::where('business_id', $business->id)
|
||||
->withCount('contacts')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$templates = MarketingTemplate::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.marketing.automations.create', compact(
|
||||
'business',
|
||||
'presets',
|
||||
'selectedPreset',
|
||||
'lists',
|
||||
'templates'
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'scope' => 'required|in:internal,portal',
|
||||
'trigger_type' => 'required|in:'.implode(',', array_keys(MarketingAutomation::TRIGGER_TYPES)),
|
||||
'trigger_config' => 'required|json',
|
||||
'condition_config' => 'required|json',
|
||||
'action_config' => 'required|json',
|
||||
]);
|
||||
|
||||
// Decode JSON configs from the form
|
||||
$triggerConfig = json_decode($validated['trigger_config'], true) ?? [];
|
||||
$conditionConfig = json_decode($validated['condition_config'], true) ?? [];
|
||||
$actionConfig = json_decode($validated['action_config'], true) ?? [];
|
||||
|
||||
// Normalize condition config - convert percentage values
|
||||
if (isset($conditionConfig['min_price_advantage']) && $conditionConfig['min_price_advantage'] > 1) {
|
||||
$conditionConfig['min_price_advantage'] = $conditionConfig['min_price_advantage'] / 100;
|
||||
}
|
||||
|
||||
// Map velocity_threshold to velocity_30d_threshold for slow mover clearance
|
||||
if (isset($conditionConfig['velocity_threshold'])) {
|
||||
$conditionConfig['velocity_30d_threshold'] = $conditionConfig['velocity_threshold'];
|
||||
unset($conditionConfig['velocity_threshold']);
|
||||
}
|
||||
|
||||
$automation = MarketingAutomation::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'],
|
||||
'is_active' => true,
|
||||
'scope' => $validated['scope'],
|
||||
'trigger_type' => $validated['trigger_type'],
|
||||
'trigger_config' => $triggerConfig,
|
||||
'condition_config' => $conditionConfig,
|
||||
'action_config' => $actionConfig,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.automations.index', $business)
|
||||
->with('success', "Automation \"{$automation->name}\" created successfully.");
|
||||
}
|
||||
|
||||
public function edit(Request $request, Business $business, MarketingAutomation $automation)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
$this->ensureAutomationBelongsToBusiness($automation, $business);
|
||||
|
||||
$presets = MarketingAutomation::getTypePresets();
|
||||
|
||||
$lists = MarketingList::where('business_id', $business->id)
|
||||
->withCount('contacts')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$templates = MarketingTemplate::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.marketing.automations.edit', compact(
|
||||
'business',
|
||||
'automation',
|
||||
'presets',
|
||||
'lists',
|
||||
'templates'
|
||||
));
|
||||
}
|
||||
|
||||
public function update(Request $request, Business $business, MarketingAutomation $automation)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
$this->ensureAutomationBelongsToBusiness($automation, $business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'scope' => 'required|in:internal,portal',
|
||||
'trigger_type' => 'required|in:'.implode(',', array_keys(MarketingAutomation::TRIGGER_TYPES)),
|
||||
'trigger_config' => 'required|json',
|
||||
'condition_config' => 'required|json',
|
||||
'action_config' => 'required|json',
|
||||
]);
|
||||
|
||||
// Decode JSON configs from the form
|
||||
$triggerConfig = json_decode($validated['trigger_config'], true) ?? [];
|
||||
$conditionConfig = json_decode($validated['condition_config'], true) ?? [];
|
||||
$actionConfig = json_decode($validated['action_config'], true) ?? [];
|
||||
|
||||
// Normalize condition config - convert percentage values
|
||||
if (isset($conditionConfig['min_price_advantage']) && $conditionConfig['min_price_advantage'] > 1) {
|
||||
$conditionConfig['min_price_advantage'] = $conditionConfig['min_price_advantage'] / 100;
|
||||
}
|
||||
|
||||
// Map velocity_threshold to velocity_30d_threshold for slow mover clearance
|
||||
if (isset($conditionConfig['velocity_threshold'])) {
|
||||
$conditionConfig['velocity_30d_threshold'] = $conditionConfig['velocity_threshold'];
|
||||
unset($conditionConfig['velocity_threshold']);
|
||||
}
|
||||
|
||||
$automation->update([
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'],
|
||||
'scope' => $validated['scope'],
|
||||
'trigger_type' => $validated['trigger_type'],
|
||||
'trigger_config' => $triggerConfig,
|
||||
'condition_config' => $conditionConfig,
|
||||
'action_config' => $actionConfig,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.automations.index', $business)
|
||||
->with('success', "Automation \"{$automation->name}\" updated successfully.");
|
||||
}
|
||||
|
||||
public function toggle(Request $request, Business $business, MarketingAutomation $automation)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
$this->ensureAutomationBelongsToBusiness($automation, $business);
|
||||
|
||||
$automation->update([
|
||||
'is_active' => ! $automation->is_active,
|
||||
]);
|
||||
|
||||
$status = $automation->is_active ? 'enabled' : 'disabled';
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->with('success', "Automation \"{$automation->name}\" has been {$status}.");
|
||||
}
|
||||
|
||||
public function runNow(Request $request, Business $business, MarketingAutomation $automation)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
$this->ensureAutomationBelongsToBusiness($automation, $business);
|
||||
|
||||
if (! $automation->is_active) {
|
||||
return redirect()
|
||||
->back()
|
||||
->with('error', 'Cannot run an inactive automation. Enable it first.');
|
||||
}
|
||||
|
||||
// Dispatch the job
|
||||
RunMarketingAutomationJob::dispatch($automation->id);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.automations.runs.index', [$business, $automation])
|
||||
->with('success', "Automation \"{$automation->name}\" has been queued to run.");
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Business $business, MarketingAutomation $automation)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
$this->ensureAutomationBelongsToBusiness($automation, $business);
|
||||
|
||||
$name = $automation->name;
|
||||
$automation->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.automations.index', $business)
|
||||
->with('success', "Automation \"{$name}\" has been deleted.");
|
||||
}
|
||||
|
||||
protected function authorizeForBusiness(Business $business): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Check user has access to this business
|
||||
if (! $user->businesses->contains($business->id) && ! $user->hasRole('Super Admin')) {
|
||||
abort(403, 'Unauthorized access to this business.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function ensureAutomationBelongsToBusiness(MarketingAutomation $automation, Business $business): void
|
||||
{
|
||||
if ($automation->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingAutomation;
|
||||
use App\Models\Marketing\MarketingAutomationRun;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class MarketingAutomationRunController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business, MarketingAutomation $automation)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
$this->ensureAutomationBelongsToBusiness($automation, $business);
|
||||
|
||||
$runs = MarketingAutomationRun::where('marketing_automation_id', $automation->id)
|
||||
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
||||
->orderBy('started_at', 'desc')
|
||||
->paginate(25);
|
||||
|
||||
return view('seller.marketing.automations.runs.index', compact(
|
||||
'business',
|
||||
'automation',
|
||||
'runs'
|
||||
));
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, MarketingAutomation $automation, MarketingAutomationRun $run)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
$this->ensureAutomationBelongsToBusiness($automation, $business);
|
||||
$this->ensureRunBelongsToAutomation($run, $automation);
|
||||
|
||||
return view('seller.marketing.automations.runs.show', compact(
|
||||
'business',
|
||||
'automation',
|
||||
'run'
|
||||
));
|
||||
}
|
||||
|
||||
protected function authorizeForBusiness(Business $business): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->businesses->contains($business->id) && ! $user->hasRole('Super Admin')) {
|
||||
abort(403, 'Unauthorized access to this business.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function ensureAutomationBelongsToBusiness(MarketingAutomation $automation, Business $business): void
|
||||
{
|
||||
if ($automation->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
protected function ensureRunBelongsToAutomation(MarketingAutomationRun $run, MarketingAutomation $automation): void
|
||||
{
|
||||
if ($run->marketing_automation_id !== $automation->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,11 @@ class ProductController extends Controller
|
||||
// Get brand IDs to filter by (respects brand context switcher)
|
||||
$brandIds = BrandSwitcherController::getFilteredBrandIds();
|
||||
|
||||
// Get all brands for the business for the filter dropdown
|
||||
$brands = \App\Models\Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
// Calculate missing BOM count for health alert
|
||||
$missingBomCount = Product::whereIn('brand_id', $brandIds)
|
||||
->where('is_assembly', true)
|
||||
@@ -106,7 +111,7 @@ class ProductController extends Controller
|
||||
'hashid' => $variety->hashid,
|
||||
'name' => $variety->name,
|
||||
'sku' => $variety->sku ?? 'N/A',
|
||||
'price' => $variety->wholesale_price ?? 0,
|
||||
'price' => $variety->effective_price ?? $variety->wholesale_price ?? 0,
|
||||
'status' => $variety->is_active ? 'active' : 'inactive',
|
||||
'image_url' => $variety->getImageUrl('thumb'),
|
||||
'edit_url' => route('seller.business.products.edit', [$business->slug, $variety->hashid]),
|
||||
@@ -123,7 +128,7 @@ class ProductController extends Controller
|
||||
'sku' => $product->sku ?? 'N/A',
|
||||
'brand' => $product->brand->name ?? 'N/A',
|
||||
'channel' => 'Marketplace', // TODO: Add channel field to products
|
||||
'price' => $product->wholesale_price ?? 0,
|
||||
'price' => $product->effective_price ?? $product->wholesale_price ?? 0,
|
||||
'views' => rand(500, 3000), // TODO: Replace with real view tracking
|
||||
'orders' => rand(10, 200), // TODO: Replace with real order count
|
||||
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation
|
||||
@@ -150,7 +155,20 @@ class ProductController extends Controller
|
||||
'to' => $paginator->lastItem(),
|
||||
];
|
||||
|
||||
return view('seller.products.index', compact('business', 'products', 'missingBomCount', 'paginator', 'pagination'));
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $products->map(fn ($p) => [
|
||||
'hashid' => $p['hashid'],
|
||||
'name' => $p['product'],
|
||||
'sku' => $p['sku'],
|
||||
'brand' => $p['brand'],
|
||||
])->values()->toArray(),
|
||||
'pagination' => $pagination,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.products.index', compact('business', 'brands', 'products', 'missingBomCount', 'paginator', 'pagination'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,8 +468,8 @@ class ProductController extends Controller
|
||||
'category_id' => 'required|exists:product_categories,id',
|
||||
'subcategory_id' => 'nullable|exists:product_categories,id',
|
||||
'type' => 'nullable|string|in:flower,pre-roll,concentrate,edible,beverage,topical,tincture,vaporizer,other',
|
||||
'wholesale_price' => 'required|numeric|min:0',
|
||||
'price_unit' => 'required|string|in:each,gram,oz,lb,kg,ml,l',
|
||||
'wholesale_price' => 'nullable|numeric|min:0',
|
||||
'price_unit' => 'nullable|string|in:each,gram,oz,lb,kg,ml,l',
|
||||
'net_weight' => 'nullable|numeric|min:0',
|
||||
'weight_unit' => 'nullable|string|in:g,oz,lb,kg,ml,l',
|
||||
'units_per_case' => 'nullable|integer|min:1',
|
||||
@@ -472,20 +490,37 @@ class ProductController extends Controller
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
$validated['is_featured'] = $request->has('is_featured');
|
||||
|
||||
// Create product
|
||||
$product = Product::create($validated);
|
||||
// Set default value for price_unit if not provided
|
||||
$validated['price_unit'] = $validated['price_unit'] ?? 'each';
|
||||
|
||||
// Handle image uploads if present
|
||||
if ($request->hasFile('images')) {
|
||||
foreach ($request->file('images') as $index => $image) {
|
||||
$path = $image->store('products', 'public');
|
||||
$product->images()->create([
|
||||
'path' => $path,
|
||||
'type' => 'product',
|
||||
'is_primary' => $index === 0,
|
||||
]);
|
||||
// Create product and handle images in a transaction
|
||||
$product = \DB::transaction(function () use ($validated, $request, $business) {
|
||||
$product = Product::create($validated);
|
||||
|
||||
// Handle image uploads if present
|
||||
// Note: Uses default disk (minio) per CLAUDE.md rules - never use 'public' disk for media
|
||||
if ($request->hasFile('images')) {
|
||||
$brand = $product->brand;
|
||||
$basePath = "businesses/{$business->slug}/brands/{$brand->slug}/products/{$product->sku}/images";
|
||||
|
||||
foreach ($request->file('images') as $index => $image) {
|
||||
$filename = $image->hashName();
|
||||
$path = $image->storeAs($basePath, $filename);
|
||||
|
||||
if ($path === false) {
|
||||
throw new \RuntimeException('Failed to upload image to storage');
|
||||
}
|
||||
|
||||
$product->images()->create([
|
||||
'path' => $path,
|
||||
'type' => 'product',
|
||||
'is_primary' => $index === 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $product;
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.products.index', $business->slug)
|
||||
@@ -958,7 +993,7 @@ class ProductController extends Controller
|
||||
// Update product
|
||||
$product->update($validated);
|
||||
|
||||
// Return JSON for Precognition requests
|
||||
// Return JSON for AJAX requests
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'message' => 'Product updated successfully!',
|
||||
@@ -1029,7 +1064,7 @@ class ProductController extends Controller
|
||||
'sku' => $product->sku ?? 'N/A',
|
||||
'brand' => $product->brand->name ?? 'N/A',
|
||||
'channel' => 'Marketplace', // TODO: Add channel field to products
|
||||
'price' => $product->wholesale_price ?? 0,
|
||||
'price' => (float) ($product->wholesale_price ?? 0),
|
||||
'views' => rand(500, 3000), // TODO: Replace with real view tracking
|
||||
'orders' => rand(10, 200), // TODO: Replace with real order count
|
||||
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation
|
||||
|
||||
@@ -50,15 +50,9 @@ class PromotionController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$products = \App\Models\Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
->with('brand')
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
// Products are loaded via API search (/search/products?brand_id=...) for better performance
|
||||
|
||||
return view('seller.promotions.create', compact('business', 'brands', 'products'));
|
||||
return view('seller.promotions.create', compact('business', 'brands'));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business)
|
||||
@@ -168,7 +162,13 @@ class PromotionController extends Controller
|
||||
->with('products')
|
||||
->findOrFail($id);
|
||||
|
||||
return view('seller.promotions.edit', compact('business', 'promotion'));
|
||||
$brands = \App\Models\Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$selectedProductIds = $promotion->products->pluck('id')->toArray();
|
||||
|
||||
return view('seller.promotions.edit', compact('business', 'promotion', 'brands', 'selectedProductIds'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Business $business, int $id)
|
||||
|
||||
266
app/Http/Controllers/Seller/SearchController.php
Normal file
266
app/Http/Controllers/Seller/SearchController.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Seller Search Controller
|
||||
*
|
||||
* Provides search endpoints for the search-select component.
|
||||
* All endpoints return JSON in format: [{value: id, label: name}, ...]
|
||||
*
|
||||
* These are AJAX endpoints used by the search-select Alpine component.
|
||||
*/
|
||||
class SearchController extends Controller
|
||||
{
|
||||
/**
|
||||
* Search customers (buyer businesses) for the current seller.
|
||||
*
|
||||
* GET /s/{business}/search/customers?q=...
|
||||
*/
|
||||
public function customers(Request $request, Business $business): JsonResponse
|
||||
{
|
||||
$query = $request->input('q', '');
|
||||
|
||||
// Search businesses that have placed orders with this seller
|
||||
$customers = Business::query()
|
||||
->whereHas('orders.items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->when($query, function ($q) use ($query) {
|
||||
$q->where(function ($q2) use ($query) {
|
||||
$q2->where('name', 'ILIKE', "%{$query}%")
|
||||
->orWhere('business_email', 'ILIKE', "%{$query}%");
|
||||
});
|
||||
})
|
||||
->orderBy('name')
|
||||
->limit(25)
|
||||
->get(['id', 'name', 'business_email']);
|
||||
|
||||
return response()->json(
|
||||
$customers->map(fn ($c) => [
|
||||
'value' => $c->id,
|
||||
'label' => $c->name,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search contacts for a specific customer or the seller's own contacts.
|
||||
*
|
||||
* GET /s/{business}/search/contacts?q=...&customer_id=...
|
||||
*/
|
||||
public function contacts(Request $request, Business $business): JsonResponse
|
||||
{
|
||||
$query = $request->input('q', '');
|
||||
$customerId = $request->input('customer_id');
|
||||
|
||||
$contactsQuery = Contact::query()
|
||||
->where('is_active', true);
|
||||
|
||||
// If customer_id is provided, search contacts for that customer
|
||||
if ($customerId) {
|
||||
$contactsQuery->where('business_id', $customerId);
|
||||
} else {
|
||||
// Otherwise, search contacts for the seller's business
|
||||
$contactsQuery->where('business_id', $business->id);
|
||||
}
|
||||
|
||||
$contacts = $contactsQuery
|
||||
->when($query, function ($q) use ($query) {
|
||||
$q->where(function ($q2) use ($query) {
|
||||
$q2->where('first_name', 'ILIKE', "%{$query}%")
|
||||
->orWhere('last_name', 'ILIKE', "%{$query}%")
|
||||
->orWhere('email', 'ILIKE', "%{$query}%")
|
||||
->orWhere('title', 'ILIKE', "%{$query}%");
|
||||
});
|
||||
})
|
||||
->orderBy('first_name')
|
||||
->orderBy('last_name')
|
||||
->limit(25)
|
||||
->get(['id', 'first_name', 'last_name', 'email', 'title']);
|
||||
|
||||
return response()->json(
|
||||
$contacts->map(fn ($c) => [
|
||||
'value' => $c->id,
|
||||
'label' => $c->getFullName().($c->email ? " ({$c->email})" : ''),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search accounts (CRM accounts / other businesses).
|
||||
*
|
||||
* GET /s/{business}/search/accounts?q=...
|
||||
*/
|
||||
public function accounts(Request $request, Business $business): JsonResponse
|
||||
{
|
||||
$query = $request->input('q', '');
|
||||
|
||||
// Search all businesses except the current one
|
||||
$accounts = Business::query()
|
||||
->where('id', '!=', $business->id)
|
||||
->when($query, function ($q) use ($query) {
|
||||
$q->where(function ($q2) use ($query) {
|
||||
$q2->where('name', 'ILIKE', "%{$query}%")
|
||||
->orWhere('business_email', 'ILIKE', "%{$query}%");
|
||||
});
|
||||
})
|
||||
->orderBy('name')
|
||||
->limit(25)
|
||||
->get(['id', 'name', 'business_email', 'city', 'state']);
|
||||
|
||||
return response()->json(
|
||||
$accounts->map(fn ($a) => [
|
||||
'value' => $a->id,
|
||||
'label' => $a->name.($a->city ? " ({$a->city}, {$a->state})" : ''),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search products for the current business.
|
||||
*
|
||||
* GET /s/{business}/search/products?q=...&brand_id=...
|
||||
*/
|
||||
public function products(Request $request, Business $business): JsonResponse
|
||||
{
|
||||
$query = $request->input('q', '');
|
||||
$brandId = $request->input('brand_id');
|
||||
|
||||
$products = \App\Models\Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->where('is_active', true)
|
||||
->with('brand:id,name')
|
||||
->when($brandId, fn ($q) => $q->where('brand_id', $brandId))
|
||||
->when($query, function ($q) use ($query) {
|
||||
$q->where(function ($q2) use ($query) {
|
||||
$q2->where('name', 'ILIKE', "%{$query}%")
|
||||
->orWhere('sku', 'ILIKE', "%{$query}%")
|
||||
->orWhereHas('brand', fn ($b) => $b->where('name', 'ILIKE', "%{$query}%"));
|
||||
});
|
||||
})
|
||||
->orderBy('name')
|
||||
->limit(100)
|
||||
->get(['id', 'brand_id', 'name', 'sku', 'wholesale_price']);
|
||||
|
||||
return response()->json(
|
||||
$products->map(fn ($p) => [
|
||||
'value' => $p->id,
|
||||
'label' => $p->name.' ('.$p->sku.')',
|
||||
'id' => $p->id,
|
||||
'brand_id' => $p->brand_id,
|
||||
'brand_name' => $p->brand?->name,
|
||||
'name' => $p->name,
|
||||
'sku' => $p->sku,
|
||||
'wholesale_price' => $p->wholesale_price ?? 0,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search products with full details for invoices (includes batches, stock, images).
|
||||
*
|
||||
* GET /s/{business}/search/invoice-products?q=...&type=...&in_stock=1
|
||||
*/
|
||||
public function invoiceProducts(Request $request, Business $business): JsonResponse
|
||||
{
|
||||
$query = $request->input('q', '');
|
||||
$type = $request->input('type', '');
|
||||
$inStockOnly = $request->boolean('in_stock', false);
|
||||
|
||||
$products = \App\Models\Product::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->with(['brand', 'availableBatches.labs'])
|
||||
->when($query, function ($q) use ($query) {
|
||||
$q->where(function ($q2) use ($query) {
|
||||
$q2->where('name', 'ILIKE', "%{$query}%")
|
||||
->orWhere('sku', 'ILIKE', "%{$query}%")
|
||||
->orWhere('description', 'ILIKE', "%{$query}%");
|
||||
});
|
||||
})
|
||||
->when($type, fn ($q) => $q->where('type', $type))
|
||||
->orderBy('name')
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(function ($product) use ($business) {
|
||||
// Calculate inventory
|
||||
$totalOnHand = $product->inventoryItems()
|
||||
->where('business_id', $business->id)
|
||||
->sum('quantity_on_hand');
|
||||
|
||||
// Map batches with COA data
|
||||
$batches = $product->availableBatches->map(function ($batch) {
|
||||
$latestLab = $batch->getLatestLab();
|
||||
|
||||
return [
|
||||
'id' => $batch->id,
|
||||
'batch_number' => $batch->batch_number,
|
||||
'quantity_available' => $batch->quantity_available,
|
||||
'expiration_date' => $batch->expiration_date?->format('Y-m-d'),
|
||||
'has_coa' => $latestLab !== null,
|
||||
'thc_total' => $latestLab?->thc_total,
|
||||
'cbd_total' => $latestLab?->cbd_total,
|
||||
];
|
||||
});
|
||||
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'sku' => $product->sku,
|
||||
'type' => $product->type,
|
||||
'description' => $product->description,
|
||||
'wholesale_price' => $product->wholesale_price ?? 0,
|
||||
'msrp_price' => $product->msrp_price,
|
||||
'brand_name' => $product->brand?->name,
|
||||
'image_url' => $product->getImageUrl('thumb'),
|
||||
'quantity_available' => $totalOnHand,
|
||||
'has_batches' => $batches->isNotEmpty(),
|
||||
'batches' => $batches,
|
||||
];
|
||||
});
|
||||
|
||||
// Filter by stock if requested
|
||||
if ($inStockOnly) {
|
||||
$products = $products->filter(fn ($p) => $p['quantity_available'] > 0)->values();
|
||||
}
|
||||
|
||||
return response()->json($products);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search users/team members for the current business.
|
||||
*
|
||||
* GET /s/{business}/search/users?q=...
|
||||
*/
|
||||
public function users(Request $request, Business $business): JsonResponse
|
||||
{
|
||||
$query = $request->input('q', '');
|
||||
|
||||
$users = $business->users()
|
||||
->when($query, function ($q) use ($query) {
|
||||
$q->where(function ($q2) use ($query) {
|
||||
$q2->where('first_name', 'ILIKE', "%{$query}%")
|
||||
->orWhere('last_name', 'ILIKE', "%{$query}%")
|
||||
->orWhere('email', 'ILIKE', "%{$query}%");
|
||||
});
|
||||
})
|
||||
->orderBy('first_name')
|
||||
->orderBy('last_name')
|
||||
->limit(25)
|
||||
->get(['users.id', 'first_name', 'last_name', 'email']);
|
||||
|
||||
return response()->json(
|
||||
$users->map(fn ($u) => [
|
||||
'value' => $u->id,
|
||||
'label' => trim("{$u->first_name} {$u->last_name}") ?: $u->email,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,46 +2,168 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Contact;
|
||||
use App\Services\ConversationService;
|
||||
use App\Services\Crm\CrmChannelService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* SMS Webhook Controller
|
||||
*
|
||||
* Handles inbound SMS from our SMS gateway.
|
||||
* Routes messages to the CRM unified inbox (crm_threads + crm_channel_messages).
|
||||
*
|
||||
* This controller normalizes gateway-specific payloads into a provider-agnostic
|
||||
* format before passing to CrmChannelService.
|
||||
*/
|
||||
class SmsController extends Controller
|
||||
{
|
||||
public function inbound(Request $request)
|
||||
public function __construct(
|
||||
protected CrmChannelService $crmChannelService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle inbound SMS from our gateway.
|
||||
*
|
||||
* Expected gateway payload fields (adjust to match your actual gateway):
|
||||
* - to: our phone number (identifies business/channel)
|
||||
* - from: sender phone number
|
||||
* - text: message text
|
||||
* - message_id: unique message ID from gateway
|
||||
* - timestamp: message timestamp (optional)
|
||||
* - media: array of media URLs (optional)
|
||||
*
|
||||
* The controller normalizes this to our standard format before
|
||||
* passing to the CRM service.
|
||||
*/
|
||||
public function inbound(Request $request): Response
|
||||
{
|
||||
$to = $request->input('To'); // brand SMS number
|
||||
$from = $request->input('From'); // buyer phone
|
||||
$body = $request->input('Body');
|
||||
Log::info('SMS gateway inbound received', [
|
||||
'to' => $request->input('to'),
|
||||
'from' => $request->input('from'),
|
||||
'body_preview' => substr($request->input('text', ''), 0, 50),
|
||||
]);
|
||||
|
||||
// find brand by sms_number
|
||||
$brand = Brand::where('sms_number', $to)->first();
|
||||
if (! $brand) {
|
||||
return;
|
||||
try {
|
||||
// Normalize gateway payload to our standard format
|
||||
$payload = [
|
||||
'provider' => 'gateway',
|
||||
'to_number' => $this->normalizePhoneNumber($request->input('to')),
|
||||
'from_number' => $this->normalizePhoneNumber($request->input('from')),
|
||||
'body' => $request->input('text'),
|
||||
'external_message_id' => $request->input('message_id'),
|
||||
'meta' => $request->all(),
|
||||
];
|
||||
|
||||
// Handle media attachments if present
|
||||
$media = $request->input('media', []);
|
||||
if (! empty($media)) {
|
||||
$payload['attachments'] = [];
|
||||
foreach ($media as $item) {
|
||||
$payload['attachments'][] = [
|
||||
'url' => $item['url'] ?? $item,
|
||||
'content_type' => $item['content_type'] ?? 'application/octet-stream',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$message = $this->crmChannelService->receiveInboundSms($payload);
|
||||
|
||||
if ($message) {
|
||||
Log::info("SMS gateway: Created CRM message {$message->id}");
|
||||
}
|
||||
|
||||
// Return simple OK response
|
||||
return response('OK', 200);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SMS gateway inbound error: '.$e->getMessage(), [
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
// Still return 200 to prevent gateway retries
|
||||
return response('OK', 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle delivery status callback from our gateway.
|
||||
*
|
||||
* Expected gateway payload:
|
||||
* - message_id: the external message ID
|
||||
* - status: delivery status (sent, delivered, failed, etc.)
|
||||
* - error_code: error code if failed (optional)
|
||||
* - error_message: error description (optional)
|
||||
*/
|
||||
public function status(Request $request): Response
|
||||
{
|
||||
Log::info('SMS gateway status callback', [
|
||||
'message_id' => $request->input('message_id'),
|
||||
'status' => $request->input('status'),
|
||||
]);
|
||||
|
||||
try {
|
||||
$externalMessageId = $request->input('message_id');
|
||||
$gatewayStatus = $request->input('status');
|
||||
$errorCode = $request->input('error_code');
|
||||
$errorMessage = $request->input('error_message');
|
||||
|
||||
if ($externalMessageId && $gatewayStatus) {
|
||||
// Normalize gateway status to our standard statuses
|
||||
$status = $this->normalizeStatus($gatewayStatus);
|
||||
|
||||
if ($status) {
|
||||
$error = $errorCode ? "{$errorCode}: {$errorMessage}" : $errorMessage;
|
||||
$this->crmChannelService->updateMessageStatus($externalMessageId, $status, $error);
|
||||
}
|
||||
}
|
||||
|
||||
return response('OK', 200);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SMS gateway status callback error: '.$e->getMessage());
|
||||
|
||||
return response('OK', 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize gateway-specific status to our standard statuses.
|
||||
*
|
||||
* Adjust this mapping based on your gateway's status values.
|
||||
*/
|
||||
protected function normalizeStatus(string $gatewayStatus): ?string
|
||||
{
|
||||
// Common status mappings - adjust for your specific gateway
|
||||
return match (strtolower($gatewayStatus)) {
|
||||
'sent', 'queued', 'accepted', 'pending' => 'sent',
|
||||
'delivered', 'delivery_success' => 'delivered',
|
||||
'read', 'seen' => 'read',
|
||||
'failed', 'undelivered', 'rejected', 'error', 'delivery_failed' => 'failed',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize phone number to E.164 format.
|
||||
*/
|
||||
protected function normalizePhoneNumber(?string $number): ?string
|
||||
{
|
||||
if (! $number) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// find contact by phone
|
||||
$contact = Contact::where('phone', $from)->first();
|
||||
if (! $contact) {
|
||||
return;
|
||||
// Remove all non-numeric characters except leading +
|
||||
$number = preg_replace('/[^0-9+]/', '', $number);
|
||||
|
||||
// Ensure it starts with +
|
||||
if (! str_starts_with($number, '+')) {
|
||||
// Assume US number if 10 digits
|
||||
if (strlen($number) === 10) {
|
||||
$number = '+1'.$number;
|
||||
} elseif (strlen($number) === 11 && str_starts_with($number, '1')) {
|
||||
$number = '+'.$number;
|
||||
}
|
||||
}
|
||||
|
||||
$service = app(ConversationService::class);
|
||||
|
||||
$conversation = $service->findOrCreateConversation($brand->id, $contact->id);
|
||||
|
||||
$service->storeMessage(
|
||||
$conversation,
|
||||
'sms',
|
||||
'inbound',
|
||||
$body,
|
||||
[
|
||||
'from_phone' => $from,
|
||||
'to_phone' => $to,
|
||||
]
|
||||
);
|
||||
|
||||
$conversation->update(['last_message_at' => now()]);
|
||||
return $number;
|
||||
}
|
||||
}
|
||||
|
||||
232
app/Http/Controllers/Webhook/PostalEmailWebhookController.php
Normal file
232
app/Http/Controllers/Webhook/PostalEmailWebhookController.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Crm\CrmChannelService;
|
||||
use App\Services\Email\InboundEmailService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Postal Email Webhook Controller
|
||||
*
|
||||
* Handles inbound emails via our self-hosted Postal mail server
|
||||
* and delivery event webhooks.
|
||||
*
|
||||
* Postal is our primary production email provider.
|
||||
*
|
||||
* @see https://docs.postalserver.io/developer/webhooks
|
||||
*/
|
||||
class PostalEmailWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected InboundEmailService $inboundEmailService,
|
||||
protected CrmChannelService $crmChannelService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle Postal webhook events.
|
||||
*
|
||||
* Postal sends JSON webhooks for various events:
|
||||
* - MessageReceived: inbound email
|
||||
* - MessageDelivered: delivery confirmation
|
||||
* - MessageBounced: bounce notification
|
||||
* - MessageHeld: held for moderation
|
||||
* - MessageDelayed: delayed delivery
|
||||
*
|
||||
* @see https://docs.postalserver.io/developer/webhooks#event-types
|
||||
*/
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
// TODO: Verify Postal webhook signature
|
||||
// $this->verifySignature($request);
|
||||
|
||||
$event = $request->input('event');
|
||||
$payload = $request->input('payload', []);
|
||||
|
||||
Log::info('Postal webhook received', [
|
||||
'event' => $event,
|
||||
'message_id' => $payload['message']['id'] ?? null,
|
||||
]);
|
||||
|
||||
try {
|
||||
return match ($event) {
|
||||
'MessageReceived' => $this->handleInboundEmail($payload),
|
||||
'MessageDelivered' => $this->handleDeliveryEvent($payload, 'delivered'),
|
||||
'MessageBounced' => $this->handleDeliveryEvent($payload, 'bounced'),
|
||||
'MessageHeld' => $this->handleDeliveryEvent($payload, 'held'),
|
||||
'MessageDelayed' => $this->handleDeliveryEvent($payload, 'delayed'),
|
||||
default => $this->handleUnknownEvent($event),
|
||||
};
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Postal webhook error: '.$e->getMessage(), [
|
||||
'event' => $event,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return response('Error logged', 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle inbound email from Postal.
|
||||
*
|
||||
* Postal MessageReceived payload structure:
|
||||
* {
|
||||
* "message": {
|
||||
* "id": 12345,
|
||||
* "token": "abc123",
|
||||
* "from": "sender@example.com",
|
||||
* "to": ["recipient@ourdomain.com"],
|
||||
* "subject": "Email subject",
|
||||
* "plain_body": "Plain text content",
|
||||
* "html_body": "<html>...</html>",
|
||||
* "attachments": [...],
|
||||
* "headers": {...},
|
||||
* "received_with_ssl": true
|
||||
* },
|
||||
* "base64_encoded": false
|
||||
* }
|
||||
*/
|
||||
protected function handleInboundEmail(array $payload): Response
|
||||
{
|
||||
$message = $payload['message'] ?? [];
|
||||
|
||||
if (empty($message)) {
|
||||
Log::warning('Postal: Empty message payload');
|
||||
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
Log::info('Postal inbound email received', [
|
||||
'from' => $message['from'] ?? null,
|
||||
'to' => $message['to'][0] ?? null,
|
||||
'subject' => $message['subject'] ?? null,
|
||||
]);
|
||||
|
||||
$normalized = $this->normalizePayload($message, $payload);
|
||||
|
||||
$crmMessage = $this->inboundEmailService->handleInbound($normalized);
|
||||
|
||||
if ($crmMessage) {
|
||||
Log::info("Postal: Created CRM message {$crmMessage->id}");
|
||||
}
|
||||
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle delivery event from Postal.
|
||||
*/
|
||||
protected function handleDeliveryEvent(array $payload, string $eventType): Response
|
||||
{
|
||||
$message = $payload['message'] ?? [];
|
||||
$messageId = $message['message_id'] ?? $message['id'] ?? null;
|
||||
|
||||
if (! $messageId) {
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
$status = match ($eventType) {
|
||||
'delivered' => 'delivered',
|
||||
'bounced' => 'bounced',
|
||||
'held' => 'held',
|
||||
'delayed' => 'pending',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($status) {
|
||||
$error = $message['bounce_message'] ?? $message['details'] ?? null;
|
||||
$this->crmChannelService->updateMessageStatus((string) $messageId, $status, $error);
|
||||
}
|
||||
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle unknown event type.
|
||||
*/
|
||||
protected function handleUnknownEvent(?string $event): Response
|
||||
{
|
||||
Log::info('Postal: Unhandled event type', ['event' => $event]);
|
||||
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Postal payload to our standard format.
|
||||
*/
|
||||
protected function normalizePayload(array $message, array $rawPayload): array
|
||||
{
|
||||
// Parse from address
|
||||
$from = $message['from'] ?? '';
|
||||
$fromEmail = $from;
|
||||
$fromName = null;
|
||||
|
||||
if (preg_match('/^(.+?)\s*<(.+?)>$/', $from, $matches)) {
|
||||
$fromName = trim($matches[1], '" ');
|
||||
$fromEmail = $matches[2];
|
||||
}
|
||||
|
||||
// Get first recipient
|
||||
$to = $message['to'][0] ?? '';
|
||||
$toEmail = $to;
|
||||
|
||||
if (preg_match('/<(.+?)>/', $to, $matches)) {
|
||||
$toEmail = $matches[1];
|
||||
}
|
||||
|
||||
// Parse headers - Postal provides them as an object
|
||||
$headers = $message['headers'] ?? [];
|
||||
$headersLower = [];
|
||||
foreach ($headers as $key => $value) {
|
||||
$headersLower[strtolower($key)] = $value;
|
||||
}
|
||||
|
||||
// Handle attachments
|
||||
$attachments = [];
|
||||
foreach ($message['attachments'] ?? [] as $att) {
|
||||
$attachments[] = [
|
||||
'filename' => $att['filename'] ?? 'attachment',
|
||||
'content_type' => $att['content_type'] ?? 'application/octet-stream',
|
||||
'size' => $att['size'] ?? null,
|
||||
'content_id' => $att['content_id'] ?? null,
|
||||
'data' => $att['data'] ?? null, // Base64 encoded if present
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'provider' => 'postal',
|
||||
'from_email' => strtolower(trim($fromEmail)),
|
||||
'from_name' => $fromName,
|
||||
'to_email' => strtolower(trim($toEmail)),
|
||||
'subject' => $message['subject'] ?? null,
|
||||
'text_body' => $message['plain_body'] ?? null,
|
||||
'html_body' => $message['html_body'] ?? null,
|
||||
'message_id' => $headersLower['message-id'] ?? $message['message_id'] ?? (string) ($message['id'] ?? ''),
|
||||
'in_reply_to' => $headersLower['in-reply-to'] ?? null,
|
||||
'references' => $headersLower['references'] ?? null,
|
||||
'headers' => $headersLower,
|
||||
'attachments' => $attachments,
|
||||
'postal_id' => $message['id'] ?? null,
|
||||
'postal_token' => $message['token'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Postal webhook signature.
|
||||
*
|
||||
* Postal signs webhooks using RSA. The signature is in the X-Postal-Signature header.
|
||||
*
|
||||
* @see https://docs.postalserver.io/developer/webhooks#verifying-webhook-signatures
|
||||
*/
|
||||
protected function verifySignature(Request $request): void
|
||||
{
|
||||
// TODO: Implement signature verification
|
||||
// $signature = $request->header('X-Postal-Signature');
|
||||
// $publicKey = config('services.postal.webhook_public_key');
|
||||
// Verify using openssl_verify()
|
||||
}
|
||||
}
|
||||
164
app/Http/Controllers/Webhook/PostmarkEmailWebhookController.php
Normal file
164
app/Http/Controllers/Webhook/PostmarkEmailWebhookController.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Crm\CrmChannelService;
|
||||
use App\Services\Email\InboundEmailService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Postmark Email Webhook Controller
|
||||
*
|
||||
* Handles inbound emails via Postmark Inbound
|
||||
* and delivery event webhooks.
|
||||
*
|
||||
* @see https://postmarkapp.com/developer/webhooks/inbound-webhook
|
||||
* @see https://postmarkapp.com/developer/webhooks/delivery-webhook
|
||||
*/
|
||||
class PostmarkEmailWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected InboundEmailService $inboundEmailService,
|
||||
protected CrmChannelService $crmChannelService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle Postmark Inbound webhook.
|
||||
*
|
||||
* Postmark sends JSON with these fields:
|
||||
* - FromFull: { Email, Name }
|
||||
* - ToFull: [{ Email, Name }]
|
||||
* - Subject: email subject
|
||||
* - TextBody: plain text body
|
||||
* - HtmlBody: HTML body
|
||||
* - MessageID: Postmark message ID
|
||||
* - Headers: [{ Name, Value }]
|
||||
* - Attachments: [{ Name, Content, ContentType, ContentLength }]
|
||||
*/
|
||||
public function inbound(Request $request): Response
|
||||
{
|
||||
Log::info('Postmark Inbound webhook received', [
|
||||
'to' => $request->input('ToFull.0.Email'),
|
||||
'from' => $request->input('FromFull.Email'),
|
||||
'subject' => $request->input('Subject'),
|
||||
]);
|
||||
|
||||
try {
|
||||
$payload = $this->normalizePayload($request);
|
||||
|
||||
$message = $this->inboundEmailService->handleInbound($payload);
|
||||
|
||||
if ($message) {
|
||||
Log::info("Postmark: Created CRM message {$message->id}");
|
||||
}
|
||||
|
||||
return response('OK', 200);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Postmark Inbound webhook error: '.$e->getMessage(), [
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return response('Error logged', 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Postmark delivery/bounce webhook.
|
||||
*/
|
||||
public function events(Request $request): Response
|
||||
{
|
||||
Log::info('Postmark Events webhook received');
|
||||
|
||||
try {
|
||||
$recordType = $request->input('RecordType');
|
||||
$messageId = $request->input('MessageID');
|
||||
|
||||
if ($recordType && $messageId) {
|
||||
$this->processEvent($request->all());
|
||||
}
|
||||
|
||||
return response('OK', 200);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Postmark Events webhook error: '.$e->getMessage());
|
||||
|
||||
return response('Error logged', 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Postmark payload to our standard format.
|
||||
*/
|
||||
protected function normalizePayload(Request $request): array
|
||||
{
|
||||
$fromFull = $request->input('FromFull', []);
|
||||
$toFull = $request->input('ToFull', []);
|
||||
$headers = $request->input('Headers', []);
|
||||
|
||||
// Convert headers array to associative
|
||||
$headersAssoc = [];
|
||||
foreach ($headers as $header) {
|
||||
$key = strtolower($header['Name'] ?? '');
|
||||
if ($key) {
|
||||
$headersAssoc[$key] = $header['Value'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle attachments
|
||||
$attachments = [];
|
||||
foreach ($request->input('Attachments', []) as $att) {
|
||||
$attachments[] = [
|
||||
'filename' => $att['Name'] ?? 'attachment',
|
||||
'content_type' => $att['ContentType'] ?? 'application/octet-stream',
|
||||
'size' => $att['ContentLength'] ?? null,
|
||||
'content_id' => $att['ContentID'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'provider' => 'postmark',
|
||||
'from_email' => strtolower(trim($fromFull['Email'] ?? $request->input('From', ''))),
|
||||
'from_name' => $fromFull['Name'] ?? null,
|
||||
'to_email' => strtolower(trim($toFull[0]['Email'] ?? $request->input('To', ''))),
|
||||
'subject' => $request->input('Subject'),
|
||||
'text_body' => $request->input('TextBody'),
|
||||
'html_body' => $request->input('HtmlBody'),
|
||||
'message_id' => $headersAssoc['message-id'] ?? $request->input('MessageID'),
|
||||
'in_reply_to' => $headersAssoc['in-reply-to'] ?? null,
|
||||
'references' => $headersAssoc['references'] ?? null,
|
||||
'headers' => $headersAssoc,
|
||||
'attachments' => $attachments,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a delivery event.
|
||||
*/
|
||||
protected function processEvent(array $event): void
|
||||
{
|
||||
$recordType = $event['RecordType'] ?? null;
|
||||
$messageId = $event['MessageID'] ?? null;
|
||||
|
||||
if (! $recordType || ! $messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = match ($recordType) {
|
||||
'Delivery' => 'delivered',
|
||||
'Open' => 'read',
|
||||
'Bounce', 'HardBounce', 'SoftBounce' => 'bounced',
|
||||
'SpamComplaint' => 'failed',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($status) {
|
||||
$this->crmChannelService->updateMessageStatus(
|
||||
$messageId,
|
||||
$status,
|
||||
$event['Description'] ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
252
app/Http/Controllers/Webhook/SendgridEmailWebhookController.php
Normal file
252
app/Http/Controllers/Webhook/SendgridEmailWebhookController.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Crm\CrmChannelService;
|
||||
use App\Services\Email\InboundEmailService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* SendGrid Email Webhook Controller
|
||||
*
|
||||
* Handles inbound emails via SendGrid Inbound Parse
|
||||
* and delivery event webhooks.
|
||||
*
|
||||
* @see https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook
|
||||
* @see https://docs.sendgrid.com/for-developers/tracking-events/event
|
||||
*/
|
||||
class SendgridEmailWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected InboundEmailService $inboundEmailService,
|
||||
protected CrmChannelService $crmChannelService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle SendGrid Inbound Parse webhook.
|
||||
*
|
||||
* SendGrid sends multipart/form-data with these fields:
|
||||
* - from: sender email (with name)
|
||||
* - to: recipient email
|
||||
* - subject: email subject
|
||||
* - text: plain text body
|
||||
* - html: HTML body
|
||||
* - headers: raw headers as text
|
||||
* - envelope: JSON with actual from/to
|
||||
* - attachments: number of attachments
|
||||
* - attachment-info: JSON with attachment metadata
|
||||
* - attachment1, attachment2, etc: actual attachment files
|
||||
*/
|
||||
public function inbound(Request $request): Response
|
||||
{
|
||||
Log::info('SendGrid Inbound Parse webhook received', [
|
||||
'to' => $request->input('to'),
|
||||
'from' => $request->input('from'),
|
||||
'subject' => $request->input('subject'),
|
||||
]);
|
||||
|
||||
// TODO: Verify SendGrid webhook signature
|
||||
// $this->verifySignature($request);
|
||||
|
||||
try {
|
||||
$payload = $this->normalizePayload($request);
|
||||
|
||||
$message = $this->inboundEmailService->handleInbound($payload);
|
||||
|
||||
if ($message) {
|
||||
Log::info("SendGrid: Created CRM message {$message->id}");
|
||||
}
|
||||
|
||||
return response('OK', 200);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SendGrid Inbound Parse error: '.$e->getMessage(), [
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
// Still return 200 to prevent retries for application errors
|
||||
return response('Error logged', 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle SendGrid delivery event webhook.
|
||||
*
|
||||
* Events include: delivered, bounce, dropped, deferred, etc.
|
||||
*/
|
||||
public function events(Request $request): Response
|
||||
{
|
||||
Log::info('SendGrid Events webhook received');
|
||||
|
||||
// TODO: Verify SendGrid event webhook signature
|
||||
|
||||
try {
|
||||
$events = $request->json()->all();
|
||||
|
||||
// SendGrid sends events as an array
|
||||
if (! is_array($events)) {
|
||||
$events = [$events];
|
||||
}
|
||||
|
||||
foreach ($events as $event) {
|
||||
$this->processEvent($event);
|
||||
}
|
||||
|
||||
return response('OK', 200);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SendGrid Events webhook error: '.$e->getMessage());
|
||||
|
||||
return response('Error logged', 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize SendGrid Inbound Parse payload to our standard format.
|
||||
*/
|
||||
protected function normalizePayload(Request $request): array
|
||||
{
|
||||
// Parse from email (format: "Name <email@example.com>" or just "email@example.com")
|
||||
$from = $request->input('from', '');
|
||||
$fromEmail = $from;
|
||||
$fromName = null;
|
||||
|
||||
if (preg_match('/^(.+?)\s*<(.+?)>$/', $from, $matches)) {
|
||||
$fromName = trim($matches[1], '" ');
|
||||
$fromEmail = $matches[2];
|
||||
}
|
||||
|
||||
// Parse to email (can be multiple, take first)
|
||||
$to = $request->input('to', '');
|
||||
$toEmail = $to;
|
||||
|
||||
if (preg_match('/<(.+?)>/', $to, $matches)) {
|
||||
$toEmail = $matches[1];
|
||||
} elseif (strpos($to, ',') !== false) {
|
||||
$toEmail = trim(explode(',', $to)[0]);
|
||||
}
|
||||
|
||||
// Parse headers
|
||||
$rawHeaders = $request->input('headers', '');
|
||||
$headers = $this->parseHeaders($rawHeaders);
|
||||
|
||||
// Get envelope data for more accurate addresses
|
||||
$envelope = json_decode($request->input('envelope', '{}'), true);
|
||||
|
||||
if (! empty($envelope['from'])) {
|
||||
$fromEmail = $envelope['from'];
|
||||
}
|
||||
if (! empty($envelope['to'][0])) {
|
||||
$toEmail = $envelope['to'][0];
|
||||
}
|
||||
|
||||
// Handle attachments
|
||||
$attachments = [];
|
||||
$attachmentInfo = json_decode($request->input('attachment-info', '{}'), true);
|
||||
|
||||
foreach ($attachmentInfo as $key => $info) {
|
||||
$attachments[] = [
|
||||
'filename' => $info['filename'] ?? $key,
|
||||
'content_type' => $info['type'] ?? 'application/octet-stream',
|
||||
'size' => $info['size'] ?? null,
|
||||
'content_id' => $info['content-id'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'provider' => 'sendgrid',
|
||||
'from_email' => strtolower(trim($fromEmail)),
|
||||
'from_name' => $fromName,
|
||||
'to_email' => strtolower(trim($toEmail)),
|
||||
'subject' => $request->input('subject'),
|
||||
'text_body' => $request->input('text'),
|
||||
'html_body' => $request->input('html'),
|
||||
'message_id' => $headers['message-id'] ?? null,
|
||||
'in_reply_to' => $headers['in-reply-to'] ?? null,
|
||||
'references' => $headers['references'] ?? null,
|
||||
'headers' => $headers,
|
||||
'attachments' => $attachments,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw email headers into associative array.
|
||||
*/
|
||||
protected function parseHeaders(string $rawHeaders): array
|
||||
{
|
||||
$headers = [];
|
||||
$lines = explode("\n", $rawHeaders);
|
||||
$currentKey = null;
|
||||
$currentValue = '';
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Continuation of previous header
|
||||
if (preg_match('/^\s+(.*)/', $line, $matches)) {
|
||||
$currentValue .= ' '.trim($matches[1]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Save previous header
|
||||
if ($currentKey !== null) {
|
||||
$headers[strtolower($currentKey)] = trim($currentValue);
|
||||
}
|
||||
|
||||
// Parse new header
|
||||
if (preg_match('/^([^:]+):\s*(.*)/', $line, $matches)) {
|
||||
$currentKey = $matches[1];
|
||||
$currentValue = $matches[2];
|
||||
}
|
||||
}
|
||||
|
||||
// Save last header
|
||||
if ($currentKey !== null) {
|
||||
$headers[strtolower($currentKey)] = trim($currentValue);
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a delivery event.
|
||||
*/
|
||||
protected function processEvent(array $event): void
|
||||
{
|
||||
$eventType = $event['event'] ?? null;
|
||||
$messageId = $event['sg_message_id'] ?? $event['smtp-id'] ?? null;
|
||||
|
||||
if (! $eventType || ! $messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = match ($eventType) {
|
||||
'delivered' => 'delivered',
|
||||
'open' => 'read',
|
||||
'bounce', 'dropped' => 'bounced',
|
||||
'deferred', 'spamreport' => 'failed',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($status) {
|
||||
$this->crmChannelService->updateMessageStatus(
|
||||
$messageId,
|
||||
$status,
|
||||
$event['reason'] ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify SendGrid webhook signature.
|
||||
*
|
||||
* @see https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features
|
||||
*/
|
||||
protected function verifySignature(Request $request): void
|
||||
{
|
||||
// TODO: Implement signature verification
|
||||
// $signature = $request->header('X-Twilio-Email-Event-Webhook-Signature');
|
||||
// $timestamp = $request->header('X-Twilio-Email-Event-Webhook-Timestamp');
|
||||
// Verify using public key
|
||||
}
|
||||
}
|
||||
237
app/Http/Controllers/Webhook/SesEmailWebhookController.php
Normal file
237
app/Http/Controllers/Webhook/SesEmailWebhookController.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Crm\CrmChannelService;
|
||||
use App\Services\Email\InboundEmailService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* AWS SES Email Webhook Controller
|
||||
*
|
||||
* Handles inbound emails via SES → SNS → HTTP
|
||||
* and delivery notification webhooks.
|
||||
*
|
||||
* @see https://docs.aws.amazon.com/ses/latest/dg/receiving-email-notifications-contents.html
|
||||
* @see https://docs.aws.amazon.com/ses/latest/dg/notification-contents.html
|
||||
*/
|
||||
class SesEmailWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected InboundEmailService $inboundEmailService,
|
||||
protected CrmChannelService $crmChannelService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle AWS SES webhook via SNS.
|
||||
*
|
||||
* SNS sends JSON with:
|
||||
* - Type: 'SubscriptionConfirmation' | 'Notification' | 'UnsubscribeConfirmation'
|
||||
* - Message: JSON string containing the actual SES notification
|
||||
* - SubscribeURL: URL to confirm subscription (for SubscriptionConfirmation)
|
||||
*/
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
// Handle SNS subscription confirmation
|
||||
if ($request->input('Type') === 'SubscriptionConfirmation') {
|
||||
return $this->handleSubscriptionConfirmation($request);
|
||||
}
|
||||
|
||||
// Handle unsubscribe confirmation
|
||||
if ($request->input('Type') === 'UnsubscribeConfirmation') {
|
||||
Log::info('SES SNS unsubscribe confirmation received');
|
||||
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
// Handle notifications
|
||||
if ($request->input('Type') === 'Notification') {
|
||||
return $this->handleNotification($request);
|
||||
}
|
||||
|
||||
Log::warning('SES webhook: Unknown Type', ['type' => $request->input('Type')]);
|
||||
|
||||
return response('Unknown type', 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle SNS subscription confirmation.
|
||||
*/
|
||||
protected function handleSubscriptionConfirmation(Request $request): Response
|
||||
{
|
||||
$subscribeUrl = $request->input('SubscribeURL');
|
||||
|
||||
if ($subscribeUrl) {
|
||||
Log::info('SES SNS subscription confirmation', ['url' => $subscribeUrl]);
|
||||
|
||||
// Auto-confirm subscription
|
||||
try {
|
||||
file_get_contents($subscribeUrl);
|
||||
Log::info('SES SNS subscription confirmed');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SES SNS subscription confirmation failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle SNS notification.
|
||||
*/
|
||||
protected function handleNotification(Request $request): Response
|
||||
{
|
||||
$messageJson = $request->input('Message');
|
||||
|
||||
if (! $messageJson) {
|
||||
Log::warning('SES webhook: No Message in notification');
|
||||
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
try {
|
||||
$message = json_decode($messageJson, true);
|
||||
|
||||
if (! $message) {
|
||||
Log::warning('SES webhook: Invalid JSON in Message');
|
||||
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
// Determine notification type
|
||||
$notificationType = $message['notificationType'] ?? $message['eventType'] ?? null;
|
||||
|
||||
if ($notificationType === 'Received') {
|
||||
return $this->handleInboundEmail($message);
|
||||
}
|
||||
|
||||
// Handle delivery notifications
|
||||
if (in_array($notificationType, ['Delivery', 'Bounce', 'Complaint', 'Open'])) {
|
||||
return $this->handleDeliveryEvent($message);
|
||||
}
|
||||
|
||||
Log::info('SES webhook: Unhandled notification type', ['type' => $notificationType]);
|
||||
|
||||
return response('OK', 200);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SES webhook error: '.$e->getMessage(), [
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return response('Error logged', 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle inbound email notification.
|
||||
*/
|
||||
protected function handleInboundEmail(array $message): Response
|
||||
{
|
||||
Log::info('SES Inbound email received');
|
||||
|
||||
$mail = $message['mail'] ?? [];
|
||||
$receipt = $message['receipt'] ?? [];
|
||||
$content = $message['content'] ?? null;
|
||||
|
||||
// For full email content, SES needs to store in S3 and we fetch it
|
||||
// For now, we work with the basic metadata
|
||||
$payload = $this->normalizePayload($mail, $receipt, $content);
|
||||
|
||||
$crmMessage = $this->inboundEmailService->handleInbound($payload);
|
||||
|
||||
if ($crmMessage) {
|
||||
Log::info("SES: Created CRM message {$crmMessage->id}");
|
||||
}
|
||||
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle delivery event notification.
|
||||
*/
|
||||
protected function handleDeliveryEvent(array $message): Response
|
||||
{
|
||||
$notificationType = $message['notificationType'] ?? $message['eventType'] ?? null;
|
||||
$mail = $message['mail'] ?? [];
|
||||
$messageId = $mail['messageId'] ?? null;
|
||||
|
||||
if (! $messageId) {
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
$status = match ($notificationType) {
|
||||
'Delivery' => 'delivered',
|
||||
'Open' => 'read',
|
||||
'Bounce' => 'bounced',
|
||||
'Complaint' => 'failed',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($status) {
|
||||
$bounce = $message['bounce'] ?? [];
|
||||
$complaint = $message['complaint'] ?? [];
|
||||
|
||||
$error = $bounce['bounceType'] ?? $complaint['complaintFeedbackType'] ?? null;
|
||||
|
||||
$this->crmChannelService->updateMessageStatus($messageId, $status, $error);
|
||||
}
|
||||
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize SES payload to our standard format.
|
||||
*/
|
||||
protected function normalizePayload(array $mail, array $receipt, ?string $content): array
|
||||
{
|
||||
$source = $mail['source'] ?? '';
|
||||
$destination = $mail['destination'][0] ?? $receipt['recipients'][0] ?? '';
|
||||
|
||||
// Parse headers
|
||||
$headers = [];
|
||||
foreach ($mail['headers'] ?? [] as $header) {
|
||||
$headers[strtolower($header['name'])] = $header['value'];
|
||||
}
|
||||
|
||||
// Parse from address
|
||||
$fromEmail = $source;
|
||||
$fromName = null;
|
||||
|
||||
if (preg_match('/^(.+?)\s*<(.+?)>$/', $source, $matches)) {
|
||||
$fromName = trim($matches[1], '" ');
|
||||
$fromEmail = $matches[2];
|
||||
}
|
||||
|
||||
// If we have raw content, parse it
|
||||
$textBody = null;
|
||||
$htmlBody = null;
|
||||
|
||||
if ($content) {
|
||||
// Basic email parsing (for production, use a proper email parser)
|
||||
if (preg_match('/Content-Type:\s*text\/plain.*?\r?\n\r?\n(.+?)(?=\r?\n--|\Z)/s', $content, $matches)) {
|
||||
$textBody = trim($matches[1]);
|
||||
}
|
||||
if (preg_match('/Content-Type:\s*text\/html.*?\r?\n\r?\n(.+?)(?=\r?\n--|\Z)/s', $content, $matches)) {
|
||||
$htmlBody = trim($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'provider' => 'ses',
|
||||
'from_email' => strtolower(trim($fromEmail)),
|
||||
'from_name' => $fromName,
|
||||
'to_email' => strtolower(trim($destination)),
|
||||
'subject' => $headers['subject'] ?? $mail['commonHeaders']['subject'] ?? null,
|
||||
'text_body' => $textBody,
|
||||
'html_body' => $htmlBody,
|
||||
'message_id' => $headers['message-id'] ?? $mail['messageId'] ?? null,
|
||||
'in_reply_to' => $headers['in-reply-to'] ?? null,
|
||||
'references' => $headers['references'] ?? null,
|
||||
'headers' => $headers,
|
||||
'attachments' => [], // Would need to fetch from S3 for attachments
|
||||
];
|
||||
}
|
||||
}
|
||||
79
app/Http/Middleware/EnsureMarketingPortalAccess.php
Normal file
79
app/Http/Middleware/EnsureMarketingPortalAccess.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Business;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Middleware to ensure the user has Marketing Portal access.
|
||||
*
|
||||
* Marketing Portal access is granted to users with:
|
||||
* - contact_type = 'marketing_portal' on business_user pivot
|
||||
* - OR users who are super admins (for testing/support)
|
||||
*
|
||||
* This middleware is applied to /portal/{business}/* routes.
|
||||
*/
|
||||
class EnsureMarketingPortalAccess
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Must be authenticated
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// Get business from route (could be slug or id)
|
||||
$businessParam = $request->route('business');
|
||||
$business = $this->resolveBusiness($businessParam);
|
||||
|
||||
if (! $business) {
|
||||
abort(404, 'Business not found.');
|
||||
}
|
||||
|
||||
// Store resolved business for later use
|
||||
$request->route()->setParameter('business', $business);
|
||||
|
||||
// Super admins can access any portal
|
||||
if ($user->isSuperAdmin()) {
|
||||
$request->attributes->set('is_portal_admin', true);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Check if user has marketing portal access for this business
|
||||
if (! $user->isMarketingPortalUser($business)) {
|
||||
abort(403, 'You do not have Marketing Portal access for this business.');
|
||||
}
|
||||
|
||||
$request->attributes->set('is_portal_admin', false);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve business from slug or ID
|
||||
*/
|
||||
protected function resolveBusiness($param): ?Business
|
||||
{
|
||||
if (! $param) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Already a Business model
|
||||
if ($param instanceof Business) {
|
||||
return $param;
|
||||
}
|
||||
|
||||
// Try by slug first, then by ID
|
||||
return Business::where('slug', $param)->first()
|
||||
?? Business::find($param);
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,9 @@ class StoreBrandRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'tagline' => ['nullable', 'string', 'min:30', 'max:45'],
|
||||
'description' => ['nullable', 'string', 'min:100', 'max:150'],
|
||||
'long_description' => ['nullable', 'string', 'max:500'],
|
||||
'tagline' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'long_description' => ['nullable', 'string', 'max:5000'],
|
||||
'brand_announcement' => ['nullable', 'string', 'max:500'],
|
||||
'website_url' => 'nullable|string|max:255',
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ class UpdateBrandRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'tagline' => ['nullable', 'string', 'min:30', 'max:45'],
|
||||
'description' => ['nullable', 'string', 'min:100', 'max:150'],
|
||||
'long_description' => ['nullable', 'string', 'max:500'],
|
||||
'tagline' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'long_description' => ['nullable', 'string', 'max:5000'],
|
||||
'brand_announcement' => ['nullable', 'string', 'max:500'],
|
||||
'website_url' => 'nullable|string|max:255',
|
||||
|
||||
@@ -75,6 +75,9 @@ class UpdateBrandRequest extends FormRequest
|
||||
'support_email' => 'nullable|email|max:255',
|
||||
'wholesale_email' => 'nullable|email|max:255',
|
||||
'pr_email' => 'nullable|email|max:255',
|
||||
|
||||
// CRM Channel Assignment
|
||||
'inbound_email_channel_id' => 'nullable|integer|exists:crm_channels,id',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
159
app/Jobs/CalculateBrandAnalysisMetrics.php
Normal file
159
app/Jobs/CalculateBrandAnalysisMetrics.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Services\Cannaiq\BrandAnalysisService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Background job to pre-calculate Brand Analysis metrics.
|
||||
*
|
||||
* This job runs in the background to compute expensive engagement and sentiment
|
||||
* metrics for brands, caching the results for 2 hours. This prevents N+1 queries
|
||||
* and expensive aggregations from running on page load.
|
||||
*
|
||||
* Schedule: Every 2 hours via Horizon
|
||||
* Queue: default (or 'analytics' if available)
|
||||
*
|
||||
* Key benefits:
|
||||
* - Aggregates CRM message counts, response rates, and quote/order metrics in batch
|
||||
* - Pre-computes buyer engagement scores
|
||||
* - For CannaiQ-enabled businesses, also pre-computes sentiment scores
|
||||
* - Uses existing BrandAnalysisService caching mechanism (2-hour TTL)
|
||||
*/
|
||||
class CalculateBrandAnalysisMetrics implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The business to calculate metrics for (null = all seller businesses)
|
||||
*/
|
||||
public ?int $businessId;
|
||||
|
||||
/**
|
||||
* The brand to calculate metrics for (null = all brands in business)
|
||||
*/
|
||||
public ?int $brandId;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(?int $businessId = null, ?int $brandId = null)
|
||||
{
|
||||
$this->businessId = $businessId;
|
||||
$this->brandId = $brandId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(BrandAnalysisService $service): void
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$processedCount = 0;
|
||||
|
||||
try {
|
||||
if ($this->businessId && $this->brandId) {
|
||||
// Single brand calculation
|
||||
$this->calculateForBrand($service, $this->businessId, $this->brandId);
|
||||
$processedCount = 1;
|
||||
} elseif ($this->businessId) {
|
||||
// All brands for a single business
|
||||
$processedCount = $this->calculateForBusiness($service, $this->businessId);
|
||||
} else {
|
||||
// All seller businesses with active brands
|
||||
$processedCount = $this->calculateForAllBusinesses($service);
|
||||
}
|
||||
|
||||
$duration = round(microtime(true) - $startTime, 2);
|
||||
Log::info('CalculateBrandAnalysisMetrics completed', [
|
||||
'business_id' => $this->businessId ?? 'all',
|
||||
'brand_id' => $this->brandId ?? 'all',
|
||||
'brands_processed' => $processedCount,
|
||||
'duration_seconds' => $duration,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CalculateBrandAnalysisMetrics failed', [
|
||||
'business_id' => $this->businessId,
|
||||
'brand_id' => $this->brandId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate metrics for all seller businesses
|
||||
*/
|
||||
private function calculateForAllBusinesses(BrandAnalysisService $service): int
|
||||
{
|
||||
$processedCount = 0;
|
||||
|
||||
Business::where('type', 'seller')
|
||||
->where('status', 'approved')
|
||||
->chunk(10, function ($businesses) use ($service, &$processedCount) {
|
||||
foreach ($businesses as $business) {
|
||||
$processedCount += $this->calculateForBusiness($service, $business->id);
|
||||
}
|
||||
});
|
||||
|
||||
return $processedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate metrics for all active brands in a business
|
||||
*/
|
||||
private function calculateForBusiness(BrandAnalysisService $service, int $businessId): int
|
||||
{
|
||||
$business = Business::find($businessId);
|
||||
if (! $business) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$brands = Brand::where('business_id', $businessId)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
$this->calculateForBrand($service, $businessId, $brand->id);
|
||||
}
|
||||
|
||||
return $brands->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate metrics for a single brand
|
||||
*/
|
||||
private function calculateForBrand(BrandAnalysisService $service, int $businessId, int $brandId): void
|
||||
{
|
||||
$business = Business::find($businessId);
|
||||
$brand = Brand::find($brandId);
|
||||
|
||||
if (! $business || ! $brand) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This triggers the full analysis calculation and caches it
|
||||
// The BrandAnalysisService handles caching internally with 2-hour TTL
|
||||
$service->refreshAnalysis($brand, $business);
|
||||
}
|
||||
|
||||
/**
|
||||
* The job failed to process.
|
||||
*/
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error('CalculateBrandAnalysisMetrics job failed', [
|
||||
'business_id' => $this->businessId,
|
||||
'brand_id' => $this->brandId,
|
||||
'exception' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
117
app/Jobs/RunMarketingAutomationJob.php
Normal file
117
app/Jobs/RunMarketingAutomationJob.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Marketing\MarketingAutomation;
|
||||
use App\Services\Marketing\AutomationRunner;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RunMarketingAutomationJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*/
|
||||
public int $backoff = 60;
|
||||
|
||||
/**
|
||||
* The automation ID to run.
|
||||
*/
|
||||
public int $automationId;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(int $automationId)
|
||||
{
|
||||
$this->automationId = $automationId;
|
||||
$this->onQueue('marketing');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(AutomationRunner $runner): void
|
||||
{
|
||||
$automation = MarketingAutomation::find($this->automationId);
|
||||
|
||||
if (! $automation) {
|
||||
Log::warning('RunMarketingAutomationJob: Automation not found', [
|
||||
'automation_id' => $this->automationId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $automation->is_active) {
|
||||
Log::info('RunMarketingAutomationJob: Automation is inactive, skipping', [
|
||||
'automation_id' => $this->automationId,
|
||||
'automation_name' => $automation->name,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('RunMarketingAutomationJob: Starting automation run', [
|
||||
'automation_id' => $automation->id,
|
||||
'automation_name' => $automation->name,
|
||||
'business_id' => $automation->business_id,
|
||||
]);
|
||||
|
||||
$run = $runner->runAutomation($automation);
|
||||
|
||||
Log::info('RunMarketingAutomationJob: Automation run completed', [
|
||||
'automation_id' => $automation->id,
|
||||
'run_id' => $run->id,
|
||||
'status' => $run->status,
|
||||
'summary' => $run->summary,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a job failure.
|
||||
*/
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error('RunMarketingAutomationJob: Job failed', [
|
||||
'automation_id' => $this->automationId,
|
||||
'error' => $exception->getMessage(),
|
||||
'trace' => $exception->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// Update automation status to error
|
||||
$automation = MarketingAutomation::find($this->automationId);
|
||||
if ($automation) {
|
||||
$automation->update([
|
||||
'last_run_at' => now(),
|
||||
'last_status' => 'error',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tags that should be assigned to the job.
|
||||
*/
|
||||
public function tags(): array
|
||||
{
|
||||
$automation = MarketingAutomation::find($this->automationId);
|
||||
|
||||
return [
|
||||
'marketing',
|
||||
'automation',
|
||||
'automation:'.$this->automationId,
|
||||
'business:'.($automation?->business_id ?? 'unknown'),
|
||||
];
|
||||
}
|
||||
}
|
||||
131
app/Jobs/SendMarketingCampaignJob.php
Normal file
131
app/Jobs/SendMarketingCampaignJob.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Services\Messaging\EmailSender;
|
||||
use App\Services\Messaging\SmsSender;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* SendMarketingCampaignJob - Process and send a marketing campaign.
|
||||
*
|
||||
* Handles email and/or SMS sending based on campaign channel.
|
||||
* Chunks through contacts for memory efficiency.
|
||||
*/
|
||||
class SendMarketingCampaignJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 3600; // 1 hour max
|
||||
|
||||
public function __construct(
|
||||
protected int $campaignId
|
||||
) {}
|
||||
|
||||
public function handle(EmailSender $emailSender, SmsSender $smsSender): void
|
||||
{
|
||||
$campaign = MarketingCampaign::find($this->campaignId);
|
||||
|
||||
if (! $campaign) {
|
||||
Log::channel('marketing')->error('Campaign not found', ['campaign_id' => $this->campaignId]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($campaign->status, [MarketingCampaign::STATUS_DRAFT, MarketingCampaign::STATUS_SCHEDULED])) {
|
||||
Log::channel('marketing')->warning('Campaign not in sendable state', [
|
||||
'campaign_id' => $campaign->id,
|
||||
'status' => $campaign->status,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $campaign->list) {
|
||||
Log::channel('marketing')->error('Campaign has no list', ['campaign_id' => $campaign->id]);
|
||||
$campaign->cancel();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Log::channel('marketing')->info('Starting campaign send', [
|
||||
'campaign_id' => $campaign->id,
|
||||
'campaign_name' => $campaign->name,
|
||||
'channel' => $campaign->channel,
|
||||
]);
|
||||
|
||||
$campaign->markSending();
|
||||
|
||||
$sendEmail = in_array($campaign->channel, [MarketingCampaign::CHANNEL_EMAIL, MarketingCampaign::CHANNEL_MULTI]);
|
||||
$sendSms = in_array($campaign->channel, [MarketingCampaign::CHANNEL_SMS, MarketingCampaign::CHANNEL_MULTI]);
|
||||
|
||||
$totalSent = 0;
|
||||
$totalFailed = 0;
|
||||
|
||||
// Process contacts in chunks
|
||||
$campaign->list->getContacts()
|
||||
->chunk(100, function ($contacts) use ($campaign, $emailSender, $smsSender, $sendEmail, $sendSms, &$totalSent, &$totalFailed) {
|
||||
foreach ($contacts as $contact) {
|
||||
if ($sendEmail && $contact->canReceiveEmail()) {
|
||||
$log = $emailSender->sendCampaignEmail($campaign, $contact);
|
||||
if ($log->status === 'sent') {
|
||||
$totalSent++;
|
||||
} else {
|
||||
$totalFailed++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($sendSms && $contact->canReceiveSms()) {
|
||||
$log = $smsSender->sendCampaignSms($campaign, $contact);
|
||||
if ($log->status === 'sent') {
|
||||
$totalSent++;
|
||||
} else {
|
||||
$totalFailed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to prevent rate limiting
|
||||
usleep(50000); // 50ms
|
||||
}
|
||||
});
|
||||
|
||||
$campaign->markSent();
|
||||
$campaign->updateMetrics();
|
||||
|
||||
Log::channel('marketing')->info('Campaign send completed', [
|
||||
'campaign_id' => $campaign->id,
|
||||
'total_sent' => $totalSent,
|
||||
'total_failed' => $totalFailed,
|
||||
]);
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::channel('marketing')->error('Campaign job failed', [
|
||||
'campaign_id' => $this->campaignId,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$campaign = MarketingCampaign::find($this->campaignId);
|
||||
if ($campaign && $campaign->status === MarketingCampaign::STATUS_SENDING) {
|
||||
// Don't cancel - leave as sending so admin can investigate
|
||||
$campaign->update([
|
||||
'metrics' => array_merge($campaign->metrics ?? [], [
|
||||
'error' => $exception->getMessage(),
|
||||
'failed_at' => now()->toIso8601String(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
app/Mail/Invoices/InvoiceSentMail.php
Normal file
50
app/Mail/Invoices/InvoiceSentMail.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail\Invoices;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Attachment;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class InvoiceSentMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Invoice $invoice,
|
||||
public ?string $message = null,
|
||||
public ?string $pdfContent = null
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: "Invoice {$this->invoice->invoice_number} from ".config('app.name'),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'emails.invoices.invoice-sent',
|
||||
);
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
if (! $this->pdfContent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
Attachment::fromData(
|
||||
fn () => $this->pdfContent,
|
||||
"{$this->invoice->invoice_number}.pdf"
|
||||
)->withMime('application/pdf'),
|
||||
];
|
||||
}
|
||||
}
|
||||
60
app/Mail/QuoteMail.php
Normal file
60
app/Mail/QuoteMail.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Attachment;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class QuoteMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public CrmQuote $quote,
|
||||
public Business $business,
|
||||
public ?string $customMessage = null,
|
||||
public ?string $pdfPath = null
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: "Quote {$this->quote->quote_number} from {$this->business->name}",
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.quote',
|
||||
with: [
|
||||
'quote' => $this->quote,
|
||||
'business' => $this->business,
|
||||
'customMessage' => $this->customMessage,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
if (! $this->pdfPath || ! Storage::exists($this->pdfPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
Attachment::fromStorage($this->pdfPath)
|
||||
->as("{$this->quote->quote_number}.pdf")
|
||||
->withMime('application/pdf'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessViaProduct;
|
||||
use App\Traits\HasHashid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -13,15 +14,17 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Batch extends Model
|
||||
{
|
||||
use BelongsToBusinessViaProduct, HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessViaProduct, HasFactory, HasHashid, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'hashid',
|
||||
'product_id',
|
||||
'lab_id',
|
||||
'parent_batch_id',
|
||||
'business_id',
|
||||
'cannabinoid_unit',
|
||||
'batch_number',
|
||||
'quantity_unit',
|
||||
'internal_code',
|
||||
'batch_type',
|
||||
'production_date',
|
||||
|
||||
@@ -126,6 +126,9 @@ class Brand extends Model implements Auditable
|
||||
'support_email',
|
||||
'wholesale_email',
|
||||
'pr_email',
|
||||
|
||||
// CRM Channel for inbound emails
|
||||
'inbound_email_channel_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -201,6 +204,14 @@ class Brand extends Model implements Auditable
|
||||
return $this->hasOne(BrandAiProfile::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The CRM channel used for inbound emails to this brand.
|
||||
*/
|
||||
public function inboundEmailChannel(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Crm\CrmChannel::class, 'inbound_email_channel_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this brand has an AI profile configured
|
||||
*/
|
||||
|
||||
169
app/Models/Branding/BusinessBrandingSetting.php
Normal file
169
app/Models/Branding/BusinessBrandingSetting.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Branding;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Business Branding Settings
|
||||
*
|
||||
* White-label branding configuration for the Marketing Portal.
|
||||
* Controls logo, colors, and messaging defaults for portal users.
|
||||
*/
|
||||
class BusinessBrandingSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'logo_path',
|
||||
'favicon_path',
|
||||
'primary_color',
|
||||
'secondary_color',
|
||||
'accent_color',
|
||||
'email_from_name',
|
||||
'email_from_email',
|
||||
'sms_from_label',
|
||||
'portal_title',
|
||||
'portal_welcome_message',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Relationships
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Accessors
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the logo URL for display
|
||||
*/
|
||||
public function getLogoUrlAttribute(): ?string
|
||||
{
|
||||
if (! $this->logo_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::url($this->logo_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the favicon URL for display
|
||||
*/
|
||||
public function getFaviconUrlAttribute(): ?string
|
||||
{
|
||||
if (! $this->favicon_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::url($this->favicon_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective email from name (falls back to business name)
|
||||
*/
|
||||
public function getEffectiveFromNameAttribute(): string
|
||||
{
|
||||
return $this->email_from_name ?? $this->business->name ?? 'Marketing Portal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective email from address
|
||||
*/
|
||||
public function getEffectiveFromEmailAttribute(): ?string
|
||||
{
|
||||
return $this->email_from_email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective SMS sender label
|
||||
*/
|
||||
public function getEffectiveSmsLabelAttribute(): string
|
||||
{
|
||||
// SMS sender IDs have strict requirements - max 11 alphanumeric chars
|
||||
$label = $this->sms_from_label ?? substr(preg_replace('/[^A-Za-z0-9]/', '', $this->business->name ?? 'Portal'), 0, 11);
|
||||
|
||||
return $label ?: 'Portal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get portal title (falls back to business name + " Portal")
|
||||
*/
|
||||
public function getEffectivePortalTitleAttribute(): string
|
||||
{
|
||||
return $this->portal_title ?? ($this->business->name ?? 'Marketing').' Portal';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CSS Helper Methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get CSS custom properties for theming
|
||||
*/
|
||||
public function getCssVariables(): array
|
||||
{
|
||||
$vars = [];
|
||||
|
||||
if ($this->primary_color) {
|
||||
$vars['--portal-primary'] = $this->primary_color;
|
||||
}
|
||||
|
||||
if ($this->secondary_color) {
|
||||
$vars['--portal-secondary'] = $this->secondary_color;
|
||||
}
|
||||
|
||||
if ($this->accent_color) {
|
||||
$vars['--portal-accent'] = $this->accent_color;
|
||||
}
|
||||
|
||||
return $vars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inline style string for CSS variables
|
||||
*/
|
||||
public function getCssVariableStyle(): string
|
||||
{
|
||||
$vars = $this->getCssVariables();
|
||||
|
||||
if (empty($vars)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return implode('; ', array_map(
|
||||
fn ($key, $value) => "{$key}: {$value}",
|
||||
array_keys($vars),
|
||||
array_values($vars)
|
||||
));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Static Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get or create branding settings for a business
|
||||
*/
|
||||
public static function forBusiness(Business $business): self
|
||||
{
|
||||
return self::firstOrCreate(
|
||||
['business_id' => $business->id],
|
||||
[
|
||||
'portal_title' => $business->name.' Portal',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -292,6 +292,7 @@ class Business extends Model implements AuditableContract
|
||||
'has_management_suite',
|
||||
'has_enterprise_suite',
|
||||
'use_suite_navigation',
|
||||
'cannaiq_enabled',
|
||||
|
||||
// Sales Suite Usage Limits
|
||||
'sales_suite_brand_limit',
|
||||
@@ -366,6 +367,7 @@ class Business extends Model implements AuditableContract
|
||||
'has_enterprise_suite' => 'boolean', // Legacy - use is_enterprise_plan instead
|
||||
'is_enterprise_plan' => 'boolean', // Plan limit override - when true, usage limits are not enforced
|
||||
'use_suite_navigation' => 'boolean',
|
||||
'cannaiq_enabled' => 'boolean',
|
||||
// Sales Suite Usage Limits
|
||||
'sales_suite_brand_limit' => 'integer',
|
||||
'sales_suite_sku_limit_per_brand' => 'integer',
|
||||
@@ -900,6 +902,44 @@ class Business extends Model implements AuditableContract
|
||||
return $this->hasSuite('dispensary');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if business has CannaiQ enabled.
|
||||
* CannaiQ provides AI-powered market intelligence, promo recommendations, and automation.
|
||||
*/
|
||||
public function hasCannaiqEnabled(): bool
|
||||
{
|
||||
return (bool) $this->cannaiq_enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if business has Messaging Suite access.
|
||||
* Messaging is included in Sales Suite or can be standalone.
|
||||
*/
|
||||
public function hasMessagingSuite(): bool
|
||||
{
|
||||
return $this->hasSuite('messaging') || $this->hasSalesSuite();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if business has CannaiQ Marketing Access.
|
||||
* Required for: Market Intelligence, Promo Builder (AI features)
|
||||
* Requires BOTH Sales Suite AND CannaiQ enabled.
|
||||
*/
|
||||
public function hasCannaiqMarketingAccess(): bool
|
||||
{
|
||||
return $this->hasSalesSuite() && $this->hasCannaiqEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if business has Automation Access.
|
||||
* Required for: Marketing Automations / Playbooks
|
||||
* Requires Sales Suite + CannaiQ + Messaging (automations send campaigns).
|
||||
*/
|
||||
public function hasAutomationAccess(): bool
|
||||
{
|
||||
return $this->hasCannaiqMarketingAccess() && $this->hasMessagingSuite();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if business has access to a specific feature via any assigned suite.
|
||||
*
|
||||
|
||||
206
app/Models/BusinessEmailIdentity.php
Normal file
206
app/Models/BusinessEmailIdentity.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Crm\CrmChannel;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* BusinessEmailIdentity - Maps inbound email addresses to businesses/channels
|
||||
*
|
||||
* This model represents an email identity that can receive inbound emails.
|
||||
* It maps the receiving email address to a business, CRM channel, and optionally
|
||||
* to the BusinessMailSettings for outbound replies.
|
||||
*
|
||||
* NOTE: This does NOT store SMTP credentials. Outbound email uses BusinessMailSettings.
|
||||
*/
|
||||
class BusinessEmailIdentity extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const PROVIDER_POSTAL = 'postal';
|
||||
|
||||
public const PROVIDER_SENDGRID = 'sendgrid';
|
||||
|
||||
public const PROVIDER_POSTMARK = 'postmark';
|
||||
|
||||
public const PROVIDER_SES = 'ses';
|
||||
|
||||
public const PROVIDERS = [
|
||||
self::PROVIDER_POSTAL => 'Postal',
|
||||
self::PROVIDER_SENDGRID => 'SendGrid',
|
||||
self::PROVIDER_POSTMARK => 'Postmark',
|
||||
self::PROVIDER_SES => 'Amazon SES',
|
||||
];
|
||||
|
||||
public const DEPARTMENT_SALES = 'sales';
|
||||
|
||||
public const DEPARTMENT_SUPPORT = 'support';
|
||||
|
||||
public const DEPARTMENT_ORDERS = 'orders';
|
||||
|
||||
public const DEPARTMENT_GENERAL = 'general';
|
||||
|
||||
public const DEPARTMENTS = [
|
||||
self::DEPARTMENT_SALES => 'Sales',
|
||||
self::DEPARTMENT_SUPPORT => 'Support',
|
||||
self::DEPARTMENT_ORDERS => 'Orders',
|
||||
self::DEPARTMENT_GENERAL => 'General',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'crm_channel_id',
|
||||
'mail_settings_id',
|
||||
'email',
|
||||
'provider',
|
||||
'department',
|
||||
'config',
|
||||
'is_active',
|
||||
'is_primary',
|
||||
'last_received_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'config' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'is_primary' => 'boolean',
|
||||
'last_received_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function crmChannel(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CrmChannel::class, 'crm_channel_id');
|
||||
}
|
||||
|
||||
public function mailSettings(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BusinessMailSettings::class, 'mail_settings_id');
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForEmail($query, string $email)
|
||||
{
|
||||
return $query->where('email', strtolower($email));
|
||||
}
|
||||
|
||||
public function scopeForProvider($query, string $provider)
|
||||
{
|
||||
return $query->where('provider', $provider);
|
||||
}
|
||||
|
||||
public function scopeForDepartment($query, string $department)
|
||||
{
|
||||
return $query->where('department', $department);
|
||||
}
|
||||
|
||||
// Static Helpers
|
||||
|
||||
/**
|
||||
* Find an email identity by email address.
|
||||
*/
|
||||
public static function findByEmail(string $email): ?self
|
||||
{
|
||||
return self::active()
|
||||
->forEmail(strtolower($email))
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a CRM channel for this identity.
|
||||
*/
|
||||
public function getOrCreateChannel(): CrmChannel
|
||||
{
|
||||
if ($this->crmChannel) {
|
||||
return $this->crmChannel;
|
||||
}
|
||||
|
||||
$channel = CrmChannel::updateOrCreate(
|
||||
[
|
||||
'business_id' => $this->business_id,
|
||||
'type' => CrmChannel::TYPE_EMAIL,
|
||||
'identifier' => $this->email,
|
||||
],
|
||||
[
|
||||
'name' => $this->department ? ucfirst($this->department).' Email' : 'Email',
|
||||
'is_active' => true,
|
||||
'can_send' => true,
|
||||
'can_receive' => true,
|
||||
'config' => [
|
||||
'identity_id' => $this->id,
|
||||
'provider' => $this->provider,
|
||||
'department' => $this->department,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->update(['crm_channel_id' => $channel->id]);
|
||||
|
||||
return $channel;
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
public function getProviderLabel(): string
|
||||
{
|
||||
return self::PROVIDERS[$this->provider] ?? ucfirst($this->provider);
|
||||
}
|
||||
|
||||
public function getDepartmentLabel(): string
|
||||
{
|
||||
return self::DEPARTMENTS[$this->department] ?? ucfirst($this->department ?? 'General');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this identity uses Postal mail server.
|
||||
*
|
||||
* Checks both the identity's own provider field and the linked mail settings.
|
||||
*/
|
||||
public function isPostal(): bool
|
||||
{
|
||||
// Check identity's own provider
|
||||
if ($this->provider === self::PROVIDER_POSTAL) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check linked mail settings provider
|
||||
if ($this->mailSettings && $this->mailSettings->isPostal()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function recordReceived(): void
|
||||
{
|
||||
$this->update(['last_received_at' => now()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the webhook secret for signature verification.
|
||||
*/
|
||||
public function getWebhookSecret(): ?string
|
||||
{
|
||||
return $this->config['webhook_secret'] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,16 @@ class BusinessMailSettings extends Model
|
||||
self::DRIVER_RESEND => 'Resend',
|
||||
];
|
||||
|
||||
// Mail server providers (distinct from driver/transport)
|
||||
public const PROVIDER_GENERIC_SMTP = 'generic_smtp';
|
||||
|
||||
public const PROVIDER_POSTAL = 'postal';
|
||||
|
||||
public const PROVIDERS = [
|
||||
self::PROVIDER_GENERIC_SMTP => 'Standard SMTP',
|
||||
self::PROVIDER_POSTAL => 'Postal Server',
|
||||
];
|
||||
|
||||
// Common encryption types
|
||||
public const ENCRYPTION_TLS = 'tls';
|
||||
|
||||
@@ -68,6 +78,8 @@ class BusinessMailSettings extends Model
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'driver',
|
||||
'provider',
|
||||
'provider_config',
|
||||
'host',
|
||||
'port',
|
||||
'encryption',
|
||||
@@ -87,6 +99,7 @@ class BusinessMailSettings extends Model
|
||||
'is_active' => 'boolean',
|
||||
'is_verified' => 'boolean',
|
||||
'last_tested_at' => 'datetime',
|
||||
'provider_config' => 'array',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -149,6 +162,7 @@ class BusinessMailSettings extends Model
|
||||
['business_id' => $business->id],
|
||||
[
|
||||
'driver' => self::DRIVER_SMTP,
|
||||
'provider' => self::PROVIDER_GENERIC_SMTP,
|
||||
'port' => self::PORT_SMTP_TLS,
|
||||
'encryption' => self::ENCRYPTION_TLS,
|
||||
'is_active' => false,
|
||||
@@ -168,6 +182,30 @@ class BusinessMailSettings extends Model
|
||||
return self::DRIVERS[$this->driver] ?? $this->driver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the provider label.
|
||||
*/
|
||||
public function getProviderLabel(): string
|
||||
{
|
||||
return self::PROVIDERS[$this->provider] ?? ucfirst($this->provider ?? 'SMTP');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this settings uses Postal mail server.
|
||||
*/
|
||||
public function isPostal(): bool
|
||||
{
|
||||
return $this->provider === self::PROVIDER_POSTAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a provider-specific config value.
|
||||
*/
|
||||
public function getProviderConfig(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->provider_config[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if settings are complete enough to attempt sending.
|
||||
*/
|
||||
|
||||
157
app/Models/Cannaiq/ProductMetric.php
Normal file
157
app/Models/Cannaiq/ProductMetric.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Cannaiq;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* CannaiQ Product Metrics Cache
|
||||
*
|
||||
* Stores cached snapshots of product-level intelligence from CannaiQ API.
|
||||
* Includes pricing, velocity, stock status, and competitive positioning.
|
||||
*/
|
||||
class ProductMetric extends Model
|
||||
{
|
||||
protected $table = 'cannaiq_product_metrics';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'store_external_id',
|
||||
'product_external_id',
|
||||
'snapshot_date',
|
||||
'raw_payload',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'snapshot_date' => 'date',
|
||||
'raw_payload' => 'array',
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Relationships
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scopes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForStore($query, string $storeExternalId)
|
||||
{
|
||||
return $query->where('store_external_id', $storeExternalId);
|
||||
}
|
||||
|
||||
public function scopeForProduct($query, string $productExternalId)
|
||||
{
|
||||
return $query->where('product_external_id', $productExternalId);
|
||||
}
|
||||
|
||||
public function scopeLatest($query)
|
||||
{
|
||||
return $query->orderByDesc('snapshot_date');
|
||||
}
|
||||
|
||||
public function scopeInStock($query)
|
||||
{
|
||||
return $query->whereJsonContains('raw_payload->in_stock', true);
|
||||
}
|
||||
|
||||
public function scopeOutOfStock($query)
|
||||
{
|
||||
return $query->whereJsonContains('raw_payload->in_stock', false);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Accessors
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get product name from payload
|
||||
*/
|
||||
public function getProductNameAttribute(): ?string
|
||||
{
|
||||
return $this->raw_payload['name'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brand name from payload
|
||||
*/
|
||||
public function getBrandNameAttribute(): ?string
|
||||
{
|
||||
return $this->raw_payload['brand_name'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current price from payload
|
||||
*/
|
||||
public function getCurrentPriceAttribute(): ?float
|
||||
{
|
||||
return $this->raw_payload['price'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get original/MSRP price from payload
|
||||
*/
|
||||
public function getOriginalPriceAttribute(): ?float
|
||||
{
|
||||
return $this->raw_payload['original_price'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if product is on sale
|
||||
*/
|
||||
public function getIsOnSaleAttribute(): bool
|
||||
{
|
||||
$current = $this->current_price;
|
||||
$original = $this->original_price;
|
||||
|
||||
return $current && $original && $current < $original;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get discount percentage
|
||||
*/
|
||||
public function getDiscountPercentAttribute(): ?float
|
||||
{
|
||||
if (! $this->is_on_sale) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round((1 - ($this->current_price / $this->original_price)) * 100, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if in stock
|
||||
*/
|
||||
public function getInStockAttribute(): bool
|
||||
{
|
||||
return $this->raw_payload['in_stock'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get THC percentage
|
||||
*/
|
||||
public function getThcPercentAttribute(): ?float
|
||||
{
|
||||
return $this->raw_payload['thc_percent'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category
|
||||
*/
|
||||
public function getCategoryAttribute(): ?string
|
||||
{
|
||||
return $this->raw_payload['category'] ?? null;
|
||||
}
|
||||
}
|
||||
94
app/Models/Cannaiq/StoreMetric.php
Normal file
94
app/Models/Cannaiq/StoreMetric.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Cannaiq;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* CannaiQ Store Metrics Cache
|
||||
*
|
||||
* Stores cached snapshots of store-level intelligence from CannaiQ API.
|
||||
* Data is refreshed periodically and used for dashboard displays.
|
||||
*/
|
||||
class StoreMetric extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'cannaiq_store_metrics';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'store_external_id',
|
||||
'snapshot_date',
|
||||
'raw_payload',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'snapshot_date' => 'date',
|
||||
'raw_payload' => 'array',
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Relationships
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scopes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForStore($query, string $storeExternalId)
|
||||
{
|
||||
return $query->where('store_external_id', $storeExternalId);
|
||||
}
|
||||
|
||||
public function scopeLatest($query)
|
||||
{
|
||||
return $query->orderByDesc('snapshot_date');
|
||||
}
|
||||
|
||||
public function scopeRecent($query, int $days = 7)
|
||||
{
|
||||
return $query->where('snapshot_date', '>=', now()->subDays($days));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Accessors
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get store name from payload
|
||||
*/
|
||||
public function getStoreNameAttribute(): ?string
|
||||
{
|
||||
return $this->raw_payload['name'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product count from payload
|
||||
*/
|
||||
public function getProductCountAttribute(): ?int
|
||||
{
|
||||
return $this->raw_payload['product_count'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average price from payload
|
||||
*/
|
||||
public function getAveragePriceAttribute(): ?float
|
||||
{
|
||||
return $this->raw_payload['average_price'] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,19 @@ class CrmChannel extends Model
|
||||
self::TYPE_MARKETPLACE => 'Marketplace',
|
||||
];
|
||||
|
||||
// Department constants
|
||||
public const DEPARTMENT_SALES = 'sales';
|
||||
|
||||
public const DEPARTMENT_SUPPORT = 'support';
|
||||
|
||||
public const DEPARTMENT_OTHER = 'other';
|
||||
|
||||
public const DEPARTMENTS = [
|
||||
self::DEPARTMENT_SALES => 'Sales',
|
||||
self::DEPARTMENT_SUPPORT => 'Support',
|
||||
self::DEPARTMENT_OTHER => 'Other',
|
||||
];
|
||||
|
||||
public const SYNC_STATUS_IDLE = 'idle';
|
||||
|
||||
public const SYNC_STATUS_SYNCING = 'syncing';
|
||||
@@ -56,6 +69,7 @@ class CrmChannel extends Model
|
||||
'business_id',
|
||||
'type',
|
||||
'name',
|
||||
'department',
|
||||
'identifier',
|
||||
'config',
|
||||
'access_token',
|
||||
@@ -192,6 +206,20 @@ class CrmChannel extends Model
|
||||
return self::TYPES[$this->type] ?? ucfirst($this->type);
|
||||
}
|
||||
|
||||
public function getDepartmentLabel(): string
|
||||
{
|
||||
return self::DEPARTMENTS[$this->department] ?? ucfirst($this->department ?? 'General');
|
||||
}
|
||||
|
||||
public function scopeForDepartment($query, string|array $department)
|
||||
{
|
||||
if (is_array($department)) {
|
||||
return $query->whereIn('department', $department);
|
||||
}
|
||||
|
||||
return $query->where('department', $department);
|
||||
}
|
||||
|
||||
public function getIcon(): string
|
||||
{
|
||||
return match ($this->type) {
|
||||
|
||||
@@ -94,7 +94,7 @@ class CrmDeal extends Model
|
||||
'value' => 'decimal:2',
|
||||
'weighted_value' => 'decimal:2',
|
||||
'probability' => 'integer',
|
||||
'ai_probability' => 'decimal:4',
|
||||
'ai_probability' => 'integer',
|
||||
'ai_probability_factors' => 'array',
|
||||
'products_interested' => 'array',
|
||||
'custom_fields' => 'array',
|
||||
@@ -229,7 +229,7 @@ class CrmDeal extends Model
|
||||
public function scopeAtRisk($query)
|
||||
{
|
||||
return $query->open()
|
||||
->where('ai_probability', '<', 0.3);
|
||||
->where('ai_probability', '<', 30);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
@@ -396,6 +396,6 @@ class CrmDeal extends Model
|
||||
|
||||
public function getAiProbabilityPercentage(): int
|
||||
{
|
||||
return (int) (($this->ai_probability ?? 0) * 100);
|
||||
return (int) ($this->ai_probability ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
125
app/Models/Crm/CrmLead.php
Normal file
125
app/Models/Crm/CrmLead.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Crm;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use App\Traits\HasHashid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class CrmLead extends Model
|
||||
{
|
||||
use HasFactory, HasHashid, SoftDeletes;
|
||||
|
||||
public const STATUSES = [
|
||||
'new' => 'New',
|
||||
'contacted' => 'Contacted',
|
||||
'qualified' => 'Qualified',
|
||||
'converted' => 'Converted',
|
||||
'lost' => 'Lost',
|
||||
];
|
||||
|
||||
public const SOURCES = [
|
||||
'website' => 'Website',
|
||||
'referral' => 'Referral',
|
||||
'trade_show' => 'Trade Show',
|
||||
'cold_call' => 'Cold Call',
|
||||
'social_media' => 'Social Media',
|
||||
'email_campaign' => 'Email Campaign',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'hashid',
|
||||
'seller_business_id',
|
||||
'company_name',
|
||||
'dba_name',
|
||||
'license_number',
|
||||
'contact_name',
|
||||
'contact_email',
|
||||
'contact_phone',
|
||||
'contact_title',
|
||||
'city',
|
||||
'state',
|
||||
'address',
|
||||
'zip_code',
|
||||
'source',
|
||||
'status',
|
||||
'notes',
|
||||
'assigned_to',
|
||||
'converted_to_business_id',
|
||||
'converted_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'converted_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function sellerBusiness(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'seller_business_id');
|
||||
}
|
||||
|
||||
public function assignee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assigned_to');
|
||||
}
|
||||
|
||||
public function convertedBusiness(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'converted_to_business_id');
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeForSeller($query, Business $business)
|
||||
{
|
||||
return $query->where('seller_business_id', $business->id);
|
||||
}
|
||||
|
||||
public function scopeStatus($query, string $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
public function scopeNotConverted($query)
|
||||
{
|
||||
return $query->whereNull('converted_at');
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
public function isConverted(): bool
|
||||
{
|
||||
return $this->converted_at !== null;
|
||||
}
|
||||
|
||||
public function getStatusBadgeClass(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
'new' => 'badge-info',
|
||||
'contacted' => 'badge-warning',
|
||||
'qualified' => 'badge-primary',
|
||||
'converted' => 'badge-success',
|
||||
'lost' => 'badge-ghost',
|
||||
default => 'badge-ghost',
|
||||
};
|
||||
}
|
||||
|
||||
public function getFullAddress(): ?string
|
||||
{
|
||||
$parts = array_filter([
|
||||
$this->address,
|
||||
$this->city,
|
||||
$this->state,
|
||||
$this->zip_code,
|
||||
]);
|
||||
|
||||
return $parts ? implode(', ', $parts) : null;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Models\Crm;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -69,6 +70,9 @@ class CrmQuote extends Model
|
||||
'signed_by_email',
|
||||
'signature_ip',
|
||||
'signature_data',
|
||||
'order_id',
|
||||
'notes_customer',
|
||||
'notes_internal',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -125,6 +129,11 @@ class CrmQuote extends Model
|
||||
return $this->hasOne(CrmInvoice::class, 'quote_id');
|
||||
}
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(CrmAccountFile::class, 'quote_id');
|
||||
@@ -261,6 +270,45 @@ class CrmQuote extends Model
|
||||
return $this->valid_until && $this->valid_until->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if quote is expired (accessor for Blade templates)
|
||||
*/
|
||||
public function getIsExpiredAttribute(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_EXPIRED || (
|
||||
$this->status === self::STATUS_SENT &&
|
||||
$this->valid_until && $this->valid_until->isPast()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get amount already invoiced from this quote
|
||||
*/
|
||||
public function getAmountInvoicedAttribute(): float
|
||||
{
|
||||
return $this->invoice ? (float) $this->invoice->total : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining amount not yet invoiced
|
||||
*/
|
||||
public function getAmountRemainingAttribute(): float
|
||||
{
|
||||
return (float) $this->total - $this->amount_invoiced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total units across all quote items
|
||||
*/
|
||||
public function getTotalUnitsAttribute(): int
|
||||
{
|
||||
return (int) $this->items->sum(function ($item) {
|
||||
$unitsPerCase = $item->product?->units_per_case ?? 1;
|
||||
|
||||
return $item->quantity * $unitsPerCase;
|
||||
});
|
||||
}
|
||||
|
||||
public function canBeEdited(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
|
||||
@@ -88,4 +88,69 @@ class CrmQuoteItem extends Model
|
||||
{
|
||||
return '$'.number_format($this->unit_price, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable quantity display string
|
||||
* e.g., "2 Cases (24 Units)" or "24 Units" if no case info
|
||||
*/
|
||||
public function getQuantityDisplayAttribute(): string
|
||||
{
|
||||
$quantity = (int) $this->quantity;
|
||||
$unitsPerCase = $this->product?->units_per_case;
|
||||
|
||||
if ($unitsPerCase && $unitsPerCase > 1) {
|
||||
$totalUnits = $quantity * $unitsPerCase;
|
||||
|
||||
return sprintf(
|
||||
'%d %s (%d Units)',
|
||||
$quantity,
|
||||
$quantity === 1 ? 'Case' : 'Cases',
|
||||
$totalUnits
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf('%d %s', $quantity, $quantity === 1 ? 'Unit' : 'Units');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total units for this item (accounting for units per case)
|
||||
*/
|
||||
public function getTotalUnitsAttribute(): int
|
||||
{
|
||||
$unitsPerCase = $this->product?->units_per_case ?? 1;
|
||||
|
||||
return (int) ($this->quantity * $unitsPerCase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product name from related product or description
|
||||
*/
|
||||
public function getProductNameAttribute(): string
|
||||
{
|
||||
return $this->product?->name ?? $this->description ?? 'Unknown Product';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brand name from related product
|
||||
*/
|
||||
public function getBrandNameAttribute(): ?string
|
||||
{
|
||||
return $this->product?->brand?->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SKU from related product
|
||||
*/
|
||||
public function getSkuAttribute(): ?string
|
||||
{
|
||||
return $this->product?->sku;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product image URL
|
||||
*/
|
||||
public function getProductImageUrlAttribute(): ?string
|
||||
{
|
||||
return $this->product?->getImageUrl('thumb');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models\Crm;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Conversation;
|
||||
@@ -50,6 +51,9 @@ class CrmThread extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'brand_id',
|
||||
'channel_id',
|
||||
'department',
|
||||
'contact_id',
|
||||
'account_id',
|
||||
'legacy_conversation_id',
|
||||
@@ -99,6 +103,16 @@ class CrmThread extends Model
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function brand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
public function channel(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CrmChannel::class, 'channel_id');
|
||||
}
|
||||
|
||||
public function contact(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Contact::class);
|
||||
@@ -206,6 +220,20 @@ class CrmThread extends Model
|
||||
return $query->where('priority', $priority);
|
||||
}
|
||||
|
||||
public function scopeForDepartment($query, string|array $department)
|
||||
{
|
||||
if (is_array($department)) {
|
||||
return $query->whereIn('department', $department);
|
||||
}
|
||||
|
||||
return $query->where('department', $department);
|
||||
}
|
||||
|
||||
public function scopeForBrand($query, int $brandId)
|
||||
{
|
||||
return $query->where('brand_id', $brandId);
|
||||
}
|
||||
|
||||
public function scopeNeedingAttention($query)
|
||||
{
|
||||
return $query->open()
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Invoice extends Model
|
||||
@@ -64,6 +65,14 @@ class Invoice extends Model
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payments for this invoice.
|
||||
*/
|
||||
public function payments(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvoicePayment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: Get unpaid invoices.
|
||||
*/
|
||||
@@ -107,6 +116,14 @@ class Invoice extends Model
|
||||
&& $this->due_date->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the is_overdue attribute.
|
||||
*/
|
||||
public function getIsOverdueAttribute(): bool
|
||||
{
|
||||
return $this->isOverdue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if invoice is paid.
|
||||
*/
|
||||
@@ -159,6 +176,24 @@ class Invoice extends Model
|
||||
return $this->markPaid($amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate amounts after payment changes.
|
||||
*/
|
||||
public function recalculateAmounts(): void
|
||||
{
|
||||
$totalPaid = $this->payments()->sum('amount');
|
||||
$this->amount_paid = $totalPaid;
|
||||
$this->amount_due = max(0, $this->total - $totalPaid);
|
||||
|
||||
if ($this->amount_due <= 0) {
|
||||
$this->payment_status = 'paid';
|
||||
} elseif ($this->amount_paid > 0) {
|
||||
$this->payment_status = 'partially_paid';
|
||||
}
|
||||
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the payment status badge color.
|
||||
*/
|
||||
@@ -172,4 +207,23 @@ class Invoice extends Model
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the payment terms label.
|
||||
*/
|
||||
public function getPaymentTermsLabelAttribute(): ?string
|
||||
{
|
||||
if (! $this->order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($this->order->payment_terms) {
|
||||
'cod' => 'Cash on Delivery',
|
||||
'net_15' => 'Net 15',
|
||||
'net_30' => 'Net 30',
|
||||
'net_60' => 'Net 60',
|
||||
'net_90' => 'Net 90',
|
||||
default => ucfirst(str_replace('_', ' ', $this->order->payment_terms ?? '')),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
91
app/Models/InvoicePayment.php
Normal file
91
app/Models/InvoicePayment.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Invoice Payment - Payment record for an invoice
|
||||
*
|
||||
* Tracks individual payments against invoices, supporting
|
||||
* partial payments and multiple payment methods.
|
||||
*/
|
||||
class InvoicePayment extends Model
|
||||
{
|
||||
public const METHOD_CASH = 'cash';
|
||||
|
||||
public const METHOD_CHECK = 'check';
|
||||
|
||||
public const METHOD_CREDIT_CARD = 'credit_card';
|
||||
|
||||
public const METHOD_BANK_TRANSFER = 'bank_transfer';
|
||||
|
||||
public const METHOD_ACH = 'ach';
|
||||
|
||||
public const METHOD_WIRE = 'wire';
|
||||
|
||||
public const METHOD_OTHER = 'other';
|
||||
|
||||
public const METHODS = [
|
||||
self::METHOD_CASH => 'Cash',
|
||||
self::METHOD_CHECK => 'Check',
|
||||
self::METHOD_CREDIT_CARD => 'Credit Card',
|
||||
self::METHOD_BANK_TRANSFER => 'Bank Transfer',
|
||||
self::METHOD_ACH => 'ACH',
|
||||
self::METHOD_WIRE => 'Wire Transfer',
|
||||
self::METHOD_OTHER => 'Other',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'invoice_id',
|
||||
'amount',
|
||||
'payment_method',
|
||||
'payment_date',
|
||||
'reference',
|
||||
'notes',
|
||||
'recorded_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'decimal:2',
|
||||
'payment_date' => 'date',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(function (InvoicePayment $payment) {
|
||||
$payment->invoice->recalculateAmounts();
|
||||
});
|
||||
|
||||
static::deleted(function (InvoicePayment $payment) {
|
||||
$payment->invoice->recalculateAmounts();
|
||||
});
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function invoice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Invoice::class);
|
||||
}
|
||||
|
||||
public function recordedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'recorded_by');
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
public function getMethodLabel(): string
|
||||
{
|
||||
return self::METHODS[$this->payment_method] ?? ucfirst(str_replace('_', ' ', $this->payment_method));
|
||||
}
|
||||
|
||||
public function getFormattedAmount(): string
|
||||
{
|
||||
return '$'.number_format((float) $this->amount, 2);
|
||||
}
|
||||
}
|
||||
312
app/Models/Marketing/MarketingAutomation.php
Normal file
312
app/Models/Marketing/MarketingAutomation.php
Normal file
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Business;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class MarketingAutomation extends Model
|
||||
{
|
||||
// Trigger types
|
||||
public const TRIGGER_SCHEDULED_CANNAIQ_CHECK = 'scheduled_cannaiq_check';
|
||||
|
||||
public const TRIGGER_SCHEDULED_STORE_CHECK = 'scheduled_store_check';
|
||||
|
||||
public const TRIGGER_MANUAL_TEST = 'manual_test';
|
||||
|
||||
public const TRIGGER_TYPES = [
|
||||
self::TRIGGER_SCHEDULED_CANNAIQ_CHECK => 'Scheduled CannaiQ Check',
|
||||
self::TRIGGER_SCHEDULED_STORE_CHECK => 'Scheduled Store Check',
|
||||
self::TRIGGER_MANUAL_TEST => 'Manual Test',
|
||||
];
|
||||
|
||||
// Condition types
|
||||
public const CONDITION_COMPETITOR_OUT_OF_STOCK = 'competitor_out_of_stock_and_we_have_inventory';
|
||||
|
||||
public const CONDITION_SLOW_MOVER_CLEARANCE = 'slow_mover_clearance';
|
||||
|
||||
public const CONDITION_NEW_STORE_LAUNCH = 'new_store_launch';
|
||||
|
||||
public const CONDITION_CATEGORY_MOMENTUM = 'category_momentum_shift';
|
||||
|
||||
public const CONDITION_BRAND_RANK_DROP = 'brand_rank_drop';
|
||||
|
||||
public const CONDITION_TYPES = [
|
||||
self::CONDITION_COMPETITOR_OUT_OF_STOCK => 'Competitor Out of Stock (We Have Inventory)',
|
||||
self::CONDITION_SLOW_MOVER_CLEARANCE => 'Slow Mover Clearance',
|
||||
self::CONDITION_NEW_STORE_LAUNCH => 'New Store Launch',
|
||||
self::CONDITION_CATEGORY_MOMENTUM => 'Category Momentum Shift',
|
||||
self::CONDITION_BRAND_RANK_DROP => 'Brand Rank Drop',
|
||||
];
|
||||
|
||||
// Scope types
|
||||
public const SCOPE_INTERNAL = 'internal';
|
||||
|
||||
public const SCOPE_PORTAL = 'portal';
|
||||
|
||||
public const SCOPES = [
|
||||
self::SCOPE_INTERNAL => 'Internal Only',
|
||||
self::SCOPE_PORTAL => 'Portal Visible',
|
||||
];
|
||||
|
||||
// Frequency options
|
||||
public const FREQUENCY_HOURLY = 'hourly';
|
||||
|
||||
public const FREQUENCY_DAILY = 'daily';
|
||||
|
||||
public const FREQUENCY_WEEKLY = 'weekly';
|
||||
|
||||
public const FREQUENCIES = [
|
||||
self::FREQUENCY_HOURLY => 'Hourly',
|
||||
self::FREQUENCY_DAILY => 'Daily',
|
||||
self::FREQUENCY_WEEKLY => 'Weekly',
|
||||
];
|
||||
|
||||
// Promo types for actions
|
||||
public const PROMO_TYPES = [
|
||||
'bogo' => 'BOGO (Buy One Get One)',
|
||||
'percent_off' => 'Percent Off',
|
||||
'set_price' => 'Set Price',
|
||||
'bundle' => 'Bundle Deal',
|
||||
'flash_sale' => 'Flash Sale',
|
||||
'clearance' => 'Clearance',
|
||||
'launch_special' => 'Launch Special',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'name',
|
||||
'description',
|
||||
'is_active',
|
||||
'scope',
|
||||
'trigger_type',
|
||||
'trigger_config',
|
||||
'condition_config',
|
||||
'action_config',
|
||||
'last_run_at',
|
||||
'last_status',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'trigger_config' => 'array',
|
||||
'condition_config' => 'array',
|
||||
'action_config' => 'array',
|
||||
'meta' => 'array',
|
||||
'last_run_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function runs(): HasMany
|
||||
{
|
||||
return $this->hasMany(MarketingAutomationRun::class);
|
||||
}
|
||||
|
||||
public function latestRun(): HasMany
|
||||
{
|
||||
return $this->runs()->latest('started_at')->limit(1);
|
||||
}
|
||||
|
||||
// Accessors
|
||||
|
||||
public function getTriggerTypeLabelAttribute(): string
|
||||
{
|
||||
return self::TRIGGER_TYPES[$this->trigger_type] ?? $this->trigger_type;
|
||||
}
|
||||
|
||||
public function getConditionTypeLabelAttribute(): string
|
||||
{
|
||||
$type = $this->condition_config['type'] ?? 'unknown';
|
||||
|
||||
return self::CONDITION_TYPES[$type] ?? $type;
|
||||
}
|
||||
|
||||
public function getScopeLabelAttribute(): string
|
||||
{
|
||||
return self::SCOPES[$this->scope] ?? $this->scope;
|
||||
}
|
||||
|
||||
public function getFrequencyAttribute(): string
|
||||
{
|
||||
return $this->trigger_config['frequency'] ?? self::FREQUENCY_DAILY;
|
||||
}
|
||||
|
||||
public function getFrequencyLabelAttribute(): string
|
||||
{
|
||||
$frequency = $this->frequency;
|
||||
|
||||
return self::FREQUENCIES[$frequency] ?? $frequency;
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->last_status) {
|
||||
'success' => 'success',
|
||||
'partial' => 'warning',
|
||||
'error', 'failed' => 'error',
|
||||
'skipped' => 'ghost',
|
||||
default => 'ghost',
|
||||
};
|
||||
}
|
||||
|
||||
// Methods
|
||||
|
||||
/**
|
||||
* Check if the automation is due to run based on its schedule.
|
||||
*/
|
||||
public function isDue(): bool
|
||||
{
|
||||
if (! $this->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->trigger_type === self::TRIGGER_MANUAL_TEST) {
|
||||
return false; // Manual automations are never auto-triggered
|
||||
}
|
||||
|
||||
$frequency = $this->trigger_config['frequency'] ?? self::FREQUENCY_DAILY;
|
||||
$timeOfDay = $this->trigger_config['time_of_day'] ?? '09:00';
|
||||
$minuteOffset = $this->trigger_config['minute_offset'] ?? 0;
|
||||
|
||||
$now = Carbon::now();
|
||||
|
||||
// If never run, it's due (assuming time matches for daily/weekly)
|
||||
if (! $this->last_run_at) {
|
||||
return $this->timeMatchesSchedule($now, $frequency, $timeOfDay, $minuteOffset);
|
||||
}
|
||||
|
||||
return match ($frequency) {
|
||||
self::FREQUENCY_HOURLY => $this->last_run_at->diffInMinutes($now) >= 60 - 5, // 5-min buffer
|
||||
self::FREQUENCY_DAILY => $this->last_run_at->diffInHours($now) >= 23 && $this->timeMatchesSchedule($now, $frequency, $timeOfDay, $minuteOffset),
|
||||
self::FREQUENCY_WEEKLY => $this->last_run_at->diffInDays($now) >= 6 && $this->timeMatchesSchedule($now, $frequency, $timeOfDay, $minuteOffset),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current time matches the scheduled time.
|
||||
*/
|
||||
protected function timeMatchesSchedule(Carbon $now, string $frequency, string $timeOfDay, int $minuteOffset): bool
|
||||
{
|
||||
if ($frequency === self::FREQUENCY_HOURLY) {
|
||||
return $now->minute >= $minuteOffset && $now->minute < $minuteOffset + 10;
|
||||
}
|
||||
|
||||
// Parse time of day (HH:MM format)
|
||||
[$hour, $minute] = array_map('intval', explode(':', $timeOfDay));
|
||||
|
||||
// Allow a 30-minute window after scheduled time
|
||||
$scheduledMinutes = $hour * 60 + $minute;
|
||||
$currentMinutes = $now->hour * 60 + $now->minute;
|
||||
|
||||
return $currentMinutes >= $scheduledMinutes && $currentMinutes < $scheduledMinutes + 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get automation type preset info for the UI.
|
||||
*/
|
||||
public static function getTypePresets(): array
|
||||
{
|
||||
return [
|
||||
'competitor_flash_sale' => [
|
||||
'name' => 'Competitor Out of Stock → Flash Sale',
|
||||
'description' => 'When competitors are out of stock in your category and you have inventory, automatically create a flash promo and SMS campaign.',
|
||||
'trigger_type' => self::TRIGGER_SCHEDULED_CANNAIQ_CHECK,
|
||||
'condition_type' => self::CONDITION_COMPETITOR_OUT_OF_STOCK,
|
||||
'default_trigger_config' => [
|
||||
'frequency' => self::FREQUENCY_HOURLY,
|
||||
'minute_offset' => 5,
|
||||
'store_scope' => 'all',
|
||||
],
|
||||
'default_condition_config' => [
|
||||
'type' => self::CONDITION_COMPETITOR_OUT_OF_STOCK,
|
||||
'category' => null,
|
||||
'min_inventory_units' => 30,
|
||||
'min_price_advantage' => 0.1,
|
||||
],
|
||||
'default_action_config' => [
|
||||
'create_promo' => [
|
||||
'promo_type' => 'flash_bogo',
|
||||
'duration_hours' => 24,
|
||||
],
|
||||
'create_campaign' => [
|
||||
'channels' => ['sms'],
|
||||
'list_type' => 'consumers',
|
||||
'send_mode' => 'immediate',
|
||||
'subject_template' => 'Flash Deal: {product_name} Today Only',
|
||||
'sms_body_template' => '🔥 Flash deal today at {store_name}: {promo_text}',
|
||||
],
|
||||
],
|
||||
],
|
||||
'slow_mover_clearance' => [
|
||||
'name' => 'Slow Movers → Clearance Email',
|
||||
'description' => 'For slow-moving inventory, automatically create clearance promos and email campaigns to deal-seekers.',
|
||||
'trigger_type' => self::TRIGGER_SCHEDULED_CANNAIQ_CHECK,
|
||||
'condition_type' => self::CONDITION_SLOW_MOVER_CLEARANCE,
|
||||
'default_trigger_config' => [
|
||||
'frequency' => self::FREQUENCY_DAILY,
|
||||
'time_of_day' => '09:00',
|
||||
],
|
||||
'default_condition_config' => [
|
||||
'type' => self::CONDITION_SLOW_MOVER_CLEARANCE,
|
||||
'velocity_30d_threshold' => 5,
|
||||
'min_inventory_units' => 50,
|
||||
'min_days_in_stock' => 30,
|
||||
],
|
||||
'default_action_config' => [
|
||||
'create_promo' => [
|
||||
'promo_type' => 'clearance',
|
||||
'duration_hours' => 168, // 7 days
|
||||
'discount_percent' => 20,
|
||||
],
|
||||
'create_campaign' => [
|
||||
'channels' => ['email'],
|
||||
'list_type' => 'consumers',
|
||||
'list_tags' => ['deal_seeker', 'loyal'],
|
||||
'send_mode' => 'immediate',
|
||||
'subject_template' => 'Clearance Alert: Save on {product_name}',
|
||||
'email_body_template' => 'Great deals on premium products. {promo_text}',
|
||||
],
|
||||
],
|
||||
],
|
||||
'new_store_launch' => [
|
||||
'name' => 'New Store Launch → Welcome Blast',
|
||||
'description' => 'When your brand appears at a new store in CannaiQ, automatically send a welcome campaign.',
|
||||
'trigger_type' => self::TRIGGER_SCHEDULED_STORE_CHECK,
|
||||
'condition_type' => self::CONDITION_NEW_STORE_LAUNCH,
|
||||
'default_trigger_config' => [
|
||||
'frequency' => self::FREQUENCY_DAILY,
|
||||
'time_of_day' => '10:00',
|
||||
],
|
||||
'default_condition_config' => [
|
||||
'type' => self::CONDITION_NEW_STORE_LAUNCH,
|
||||
'first_appearance_window_days' => 7,
|
||||
],
|
||||
'default_action_config' => [
|
||||
'create_promo' => [
|
||||
'promo_type' => 'launch_special',
|
||||
'duration_hours' => 168, // 7 days
|
||||
],
|
||||
'create_campaign' => [
|
||||
'channels' => ['email', 'sms'],
|
||||
'list_type' => 'consumers',
|
||||
'send_mode' => 'immediate',
|
||||
'subject_template' => 'Now Available: {brand_name} at {store_name}',
|
||||
'sms_body_template' => '🎉 {brand_name} is now at {store_name}! {promo_text}',
|
||||
'email_body_template' => 'Exciting news! {brand_name} products are now available at {store_name}.',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
169
app/Models/Marketing/MarketingAutomationRun.php
Normal file
169
app/Models/Marketing/MarketingAutomationRun.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MarketingAutomationRun extends Model
|
||||
{
|
||||
public const STATUS_RUNNING = 'running';
|
||||
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
|
||||
public const STATUS_PARTIAL = 'partial';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_RUNNING => 'Running',
|
||||
self::STATUS_SUCCESS => 'Success',
|
||||
self::STATUS_PARTIAL => 'Partial',
|
||||
self::STATUS_FAILED => 'Failed',
|
||||
self::STATUS_SKIPPED => 'Skipped',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'marketing_automation_id',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
'status',
|
||||
'summary',
|
||||
'details',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'details' => 'array',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function automation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MarketingAutomation::class, 'marketing_automation_id');
|
||||
}
|
||||
|
||||
// Accessors
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return self::STATUSES[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_RUNNING => 'info',
|
||||
self::STATUS_SUCCESS => 'success',
|
||||
self::STATUS_PARTIAL => 'warning',
|
||||
self::STATUS_FAILED => 'error',
|
||||
self::STATUS_SKIPPED => 'ghost',
|
||||
default => 'ghost',
|
||||
};
|
||||
}
|
||||
|
||||
public function getDurationAttribute(): ?string
|
||||
{
|
||||
if (! $this->started_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$end = $this->finished_at ?? now();
|
||||
$seconds = $this->started_at->diffInSeconds($end);
|
||||
|
||||
if ($seconds < 60) {
|
||||
return "{$seconds}s";
|
||||
}
|
||||
|
||||
$minutes = floor($seconds / 60);
|
||||
$remainingSeconds = $seconds % 60;
|
||||
|
||||
return "{$minutes}m {$remainingSeconds}s";
|
||||
}
|
||||
|
||||
// Methods
|
||||
|
||||
/**
|
||||
* Mark the run as started.
|
||||
*/
|
||||
public static function start(MarketingAutomation $automation): self
|
||||
{
|
||||
return self::create([
|
||||
'business_id' => $automation->business_id,
|
||||
'marketing_automation_id' => $automation->id,
|
||||
'started_at' => now(),
|
||||
'status' => self::STATUS_RUNNING,
|
||||
'details' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the run as finished with success.
|
||||
*/
|
||||
public function succeed(string $summary, array $details = []): self
|
||||
{
|
||||
return $this->finish(self::STATUS_SUCCESS, $summary, $details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the run as finished with partial success.
|
||||
*/
|
||||
public function partial(string $summary, array $details = []): self
|
||||
{
|
||||
return $this->finish(self::STATUS_PARTIAL, $summary, $details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the run as failed.
|
||||
*/
|
||||
public function fail(string $summary, array $details = []): self
|
||||
{
|
||||
return $this->finish(self::STATUS_FAILED, $summary, $details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the run as skipped (conditions not met).
|
||||
*/
|
||||
public function skip(string $summary, array $details = []): self
|
||||
{
|
||||
return $this->finish(self::STATUS_SKIPPED, $summary, $details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish the run with the given status.
|
||||
*/
|
||||
protected function finish(string $status, string $summary, array $details = []): self
|
||||
{
|
||||
$this->update([
|
||||
'finished_at' => now(),
|
||||
'status' => $status,
|
||||
'summary' => $summary,
|
||||
'details' => array_merge($this->details ?? [], $details),
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add details to the run.
|
||||
*/
|
||||
public function addDetails(array $details): self
|
||||
{
|
||||
$this->update([
|
||||
'details' => array_merge($this->details ?? [], $details),
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
340
app/Models/Marketing/MarketingCampaign.php
Normal file
340
app/Models/Marketing/MarketingCampaign.php
Normal file
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* MarketingCampaign - Email or SMS marketing campaign.
|
||||
*
|
||||
* Targets a MarketingList and tracks send status and metrics.
|
||||
*/
|
||||
class MarketingCampaign extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const CHANNEL_EMAIL = 'email';
|
||||
|
||||
public const CHANNEL_SMS = 'sms';
|
||||
|
||||
public const CHANNEL_MULTI = 'multi';
|
||||
|
||||
public const CHANNELS = [
|
||||
self::CHANNEL_EMAIL => 'Email',
|
||||
self::CHANNEL_SMS => 'SMS',
|
||||
self::CHANNEL_MULTI => 'Email & SMS',
|
||||
];
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_SCHEDULED = 'scheduled';
|
||||
|
||||
public const STATUS_SENDING = 'sending';
|
||||
|
||||
public const STATUS_SENT = 'sent';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_DRAFT => 'Draft',
|
||||
self::STATUS_SCHEDULED => 'Scheduled',
|
||||
self::STATUS_SENDING => 'Sending',
|
||||
self::STATUS_SENT => 'Sent',
|
||||
self::STATUS_CANCELLED => 'Cancelled',
|
||||
];
|
||||
|
||||
public const SOURCE_MANUAL = 'manual';
|
||||
|
||||
public const SOURCE_PROMO = 'promo';
|
||||
|
||||
public const SOURCE_AUTOMATION = 'automation';
|
||||
|
||||
public const SOURCES = [
|
||||
self::SOURCE_MANUAL => 'Manual',
|
||||
self::SOURCE_PROMO => 'Promo Builder',
|
||||
self::SOURCE_AUTOMATION => 'Automation',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'name',
|
||||
'channel',
|
||||
'status',
|
||||
'marketing_list_id',
|
||||
'marketing_template_id',
|
||||
'subject',
|
||||
'email_preview_text',
|
||||
'sms_body',
|
||||
'email_body_html',
|
||||
'from_name',
|
||||
'from_email',
|
||||
'send_at',
|
||||
'sent_at',
|
||||
'metrics',
|
||||
'source_type',
|
||||
'source_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'send_at' => 'datetime',
|
||||
'sent_at' => 'datetime',
|
||||
'metrics' => 'array',
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Relationships
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function list(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MarketingList::class, 'marketing_list_id');
|
||||
}
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MarketingTemplate::class, 'marketing_template_id');
|
||||
}
|
||||
|
||||
public function messageLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(MarketingMessageLog::class);
|
||||
}
|
||||
|
||||
public function promo(): ?BelongsTo
|
||||
{
|
||||
if ($this->source_type !== self::SOURCE_PROMO) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->belongsTo(MarketingPromo::class, 'source_id');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Scopes
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function scopeForBusiness(Builder $query, int $businessId): Builder
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeDraft(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_DRAFT);
|
||||
}
|
||||
|
||||
public function scopeScheduled(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_SCHEDULED);
|
||||
}
|
||||
|
||||
public function scopeSending(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_SENDING);
|
||||
}
|
||||
|
||||
public function scopeSent(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_SENT);
|
||||
}
|
||||
|
||||
public function scopeReadyToSend(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_SCHEDULED)
|
||||
->where('send_at', '<=', now());
|
||||
}
|
||||
|
||||
public function scopeChannel(Builder $query, string $channel): Builder
|
||||
{
|
||||
return $query->where('channel', $channel);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Accessors
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function getChannelLabel(): string
|
||||
{
|
||||
return self::CHANNELS[$this->channel] ?? $this->channel;
|
||||
}
|
||||
|
||||
public function getStatusLabel(): string
|
||||
{
|
||||
return self::STATUSES[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
public function getSourceLabel(): string
|
||||
{
|
||||
return self::SOURCES[$this->source_type] ?? $this->source_type;
|
||||
}
|
||||
|
||||
public function getRecipientCountAttribute(): int
|
||||
{
|
||||
if (! $this->list) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($this->channel === self::CHANNEL_EMAIL) {
|
||||
return $this->list->email_subscriber_count;
|
||||
}
|
||||
|
||||
if ($this->channel === self::CHANNEL_SMS) {
|
||||
return $this->list->sms_subscriber_count;
|
||||
}
|
||||
|
||||
return $this->list->contact_count;
|
||||
}
|
||||
|
||||
public function getSentCountAttribute(): int
|
||||
{
|
||||
return $this->messageLogs()->where('status', 'sent')->count();
|
||||
}
|
||||
|
||||
public function getFailedCountAttribute(): int
|
||||
{
|
||||
return $this->messageLogs()->where('status', 'failed')->count();
|
||||
}
|
||||
|
||||
public function getOpenedCountAttribute(): int
|
||||
{
|
||||
return $this->messageLogs()->whereNotNull('opened_at')->count();
|
||||
}
|
||||
|
||||
public function getClickedCountAttribute(): int
|
||||
{
|
||||
return $this->messageLogs()->whereNotNull('clicked_at')->count();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Status Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function isDraft(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
public function isScheduled(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_SCHEDULED;
|
||||
}
|
||||
|
||||
public function isSending(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_SENDING;
|
||||
}
|
||||
|
||||
public function isSent(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_SENT;
|
||||
}
|
||||
|
||||
public function isCancelled(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_CANCELLED;
|
||||
}
|
||||
|
||||
public function canEdit(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_SCHEDULED]);
|
||||
}
|
||||
|
||||
public function canSend(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT && $this->marketing_list_id;
|
||||
}
|
||||
|
||||
public function canSchedule(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT && $this->marketing_list_id;
|
||||
}
|
||||
|
||||
public function canCancel(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_SCHEDULED]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Actions
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function schedule(\DateTimeInterface $sendAt): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_SCHEDULED,
|
||||
'send_at' => $sendAt,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markSending(): void
|
||||
{
|
||||
$this->update(['status' => self::STATUS_SENDING]);
|
||||
}
|
||||
|
||||
public function markSent(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_SENT,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel(): void
|
||||
{
|
||||
$this->update(['status' => self::STATUS_CANCELLED]);
|
||||
}
|
||||
|
||||
public function updateMetrics(): void
|
||||
{
|
||||
$this->update([
|
||||
'metrics' => [
|
||||
'total' => $this->messageLogs()->count(),
|
||||
'sent' => $this->sent_count,
|
||||
'failed' => $this->failed_count,
|
||||
'opened' => $this->opened_count,
|
||||
'clicked' => $this->clicked_count,
|
||||
'updated_at' => now()->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Content Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function hasEmailContent(): bool
|
||||
{
|
||||
return ! empty($this->subject) && ! empty($this->email_body_html);
|
||||
}
|
||||
|
||||
public function hasSmsContent(): bool
|
||||
{
|
||||
return ! empty($this->sms_body);
|
||||
}
|
||||
|
||||
public function getSmsCharacterCount(): int
|
||||
{
|
||||
return strlen($this->sms_body ?? '');
|
||||
}
|
||||
|
||||
public function getSmsSegmentCount(): int
|
||||
{
|
||||
$length = $this->getSmsCharacterCount();
|
||||
|
||||
if ($length <= 160) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (int) ceil($length / 153);
|
||||
}
|
||||
}
|
||||
205
app/Models/Marketing/MarketingContact.php
Normal file
205
app/Models/Marketing/MarketingContact.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* MarketingContact - Marketing-specific contact for campaigns.
|
||||
*
|
||||
* Supports B2B (buyers), B2C (consumers), and internal contacts.
|
||||
* Can optionally link to existing Contact, Business, or CrmLead records.
|
||||
*/
|
||||
class MarketingContact extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
public const TYPE_BUYER = 'buyer';
|
||||
|
||||
public const TYPE_CONSUMER = 'consumer';
|
||||
|
||||
public const TYPE_INTERNAL = 'internal';
|
||||
|
||||
public const TYPES = [
|
||||
self::TYPE_BUYER => 'Buyer',
|
||||
self::TYPE_CONSUMER => 'Consumer',
|
||||
self::TYPE_INTERNAL => 'Internal',
|
||||
];
|
||||
|
||||
public const SOURCE_MANUAL = 'manual';
|
||||
|
||||
public const SOURCE_IMPORT = 'import';
|
||||
|
||||
public const SOURCE_SYNCED = 'synced';
|
||||
|
||||
public const SOURCE_API = 'api';
|
||||
|
||||
public const SOURCES = [
|
||||
self::SOURCE_MANUAL => 'Manual Entry',
|
||||
self::SOURCE_IMPORT => 'CSV Import',
|
||||
self::SOURCE_SYNCED => 'Synced from CRM',
|
||||
self::SOURCE_API => 'API',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'type',
|
||||
'email',
|
||||
'phone',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'tags',
|
||||
'source',
|
||||
'related_type',
|
||||
'related_id',
|
||||
'is_subscribed_email',
|
||||
'is_subscribed_sms',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tags' => 'array',
|
||||
'meta' => 'array',
|
||||
'is_subscribed_email' => 'boolean',
|
||||
'is_subscribed_sms' => 'boolean',
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Relationships
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function related(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function lists(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
MarketingList::class,
|
||||
'marketing_list_contact',
|
||||
'marketing_contact_id',
|
||||
'marketing_list_id'
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
public function messageLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(MarketingMessageLog::class);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Scopes
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function scopeForBusiness(Builder $query, int $businessId): Builder
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeOfType(Builder $query, string $type): Builder
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
|
||||
public function scopeBuyers(Builder $query): Builder
|
||||
{
|
||||
return $query->where('type', self::TYPE_BUYER);
|
||||
}
|
||||
|
||||
public function scopeConsumers(Builder $query): Builder
|
||||
{
|
||||
return $query->where('type', self::TYPE_CONSUMER);
|
||||
}
|
||||
|
||||
public function scopeSubscribedEmail(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_subscribed_email', true)->whereNotNull('email');
|
||||
}
|
||||
|
||||
public function scopeSubscribedSms(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_subscribed_sms', true)->whereNotNull('phone');
|
||||
}
|
||||
|
||||
public function scopeWithTag(Builder $query, string $tag): Builder
|
||||
{
|
||||
return $query->whereJsonContains('tags', $tag);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Accessors
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function getFullNameAttribute(): string
|
||||
{
|
||||
return trim("{$this->first_name} {$this->last_name}") ?: $this->email ?: $this->phone ?: 'Unknown';
|
||||
}
|
||||
|
||||
public function getDisplayNameAttribute(): string
|
||||
{
|
||||
if ($this->first_name || $this->last_name) {
|
||||
return $this->full_name;
|
||||
}
|
||||
|
||||
return $this->email ?: $this->phone ?: 'Contact #'.$this->id;
|
||||
}
|
||||
|
||||
public function getTypeLabel(): string
|
||||
{
|
||||
return self::TYPES[$this->type] ?? $this->type;
|
||||
}
|
||||
|
||||
public function getSourceLabel(): string
|
||||
{
|
||||
return self::SOURCES[$this->source] ?? $this->source;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function canReceiveEmail(): bool
|
||||
{
|
||||
return $this->is_subscribed_email && ! empty($this->email);
|
||||
}
|
||||
|
||||
public function canReceiveSms(): bool
|
||||
{
|
||||
return $this->is_subscribed_sms && ! empty($this->phone);
|
||||
}
|
||||
|
||||
public function addTag(string $tag): void
|
||||
{
|
||||
$tags = $this->tags ?? [];
|
||||
if (! in_array($tag, $tags)) {
|
||||
$tags[] = $tag;
|
||||
$this->update(['tags' => $tags]);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeTag(string $tag): void
|
||||
{
|
||||
$tags = $this->tags ?? [];
|
||||
$this->update(['tags' => array_values(array_diff($tags, [$tag]))]);
|
||||
}
|
||||
|
||||
public function hasTag(string $tag): bool
|
||||
{
|
||||
return in_array($tag, $this->tags ?? []);
|
||||
}
|
||||
}
|
||||
218
app/Models/Marketing/MarketingList.php
Normal file
218
app/Models/Marketing/MarketingList.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* MarketingList - Collection of marketing contacts for campaigns.
|
||||
*
|
||||
* Supports static lists (manually curated) and smart lists (filter-based).
|
||||
*/
|
||||
class MarketingList extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_STATIC = 'static';
|
||||
|
||||
public const TYPE_SMART = 'smart';
|
||||
|
||||
public const TYPES = [
|
||||
self::TYPE_STATIC => 'Static List',
|
||||
self::TYPE_SMART => 'Smart List',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'name',
|
||||
'description',
|
||||
'type',
|
||||
'filters',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'filters' => 'array',
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Relationships
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function contacts(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
MarketingContact::class,
|
||||
'marketing_list_contact',
|
||||
'marketing_list_id',
|
||||
'marketing_contact_id'
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
public function campaigns(): HasMany
|
||||
{
|
||||
return $this->hasMany(MarketingCampaign::class);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Scopes
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function scopeForBusiness(Builder $query, int $businessId): Builder
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeStatic(Builder $query): Builder
|
||||
{
|
||||
return $query->where('type', self::TYPE_STATIC);
|
||||
}
|
||||
|
||||
public function scopeSmart(Builder $query): Builder
|
||||
{
|
||||
return $query->where('type', self::TYPE_SMART);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Accessors
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function getTypeLabel(): string
|
||||
{
|
||||
return self::TYPES[$this->type] ?? $this->type;
|
||||
}
|
||||
|
||||
public function getContactCountAttribute(): int
|
||||
{
|
||||
if ($this->type === self::TYPE_SMART) {
|
||||
return $this->getSmartListContacts()->count();
|
||||
}
|
||||
|
||||
return $this->contacts()->count();
|
||||
}
|
||||
|
||||
public function getEmailSubscriberCountAttribute(): int
|
||||
{
|
||||
return $this->getEligibleContacts('email')->count();
|
||||
}
|
||||
|
||||
public function getSmsSubscriberCountAttribute(): int
|
||||
{
|
||||
return $this->getEligibleContacts('sms')->count();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// List Contacts
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get contacts for this list (handles both static and smart lists).
|
||||
*/
|
||||
public function getContacts(): Builder
|
||||
{
|
||||
if ($this->type === self::TYPE_SMART) {
|
||||
return $this->getSmartListContacts();
|
||||
}
|
||||
|
||||
return MarketingContact::whereHas('lists', fn ($q) => $q->where('marketing_lists.id', $this->id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contacts eligible for a specific channel.
|
||||
*/
|
||||
public function getEligibleContacts(string $channel): Builder
|
||||
{
|
||||
$query = $this->getContacts();
|
||||
|
||||
if ($channel === 'email') {
|
||||
return $query->subscribedEmail();
|
||||
}
|
||||
|
||||
if ($channel === 'sms') {
|
||||
return $query->subscribedSms();
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build smart list query from filters.
|
||||
*/
|
||||
protected function getSmartListContacts(): Builder
|
||||
{
|
||||
$query = MarketingContact::forBusiness($this->business_id);
|
||||
$filters = $this->filters ?? [];
|
||||
|
||||
if (! empty($filters['type'])) {
|
||||
$query->ofType($filters['type']);
|
||||
}
|
||||
|
||||
if (! empty($filters['tags'])) {
|
||||
foreach ((array) $filters['tags'] as $tag) {
|
||||
$query->withTag($tag);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($filters['subscribed_email'])) {
|
||||
$query->where('is_subscribed_email', $filters['subscribed_email']);
|
||||
}
|
||||
|
||||
if (isset($filters['subscribed_sms'])) {
|
||||
$query->where('is_subscribed_sms', $filters['subscribed_sms']);
|
||||
}
|
||||
|
||||
if (! empty($filters['source'])) {
|
||||
$query->where('source', $filters['source']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function isStatic(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_STATIC;
|
||||
}
|
||||
|
||||
public function isSmart(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_SMART;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add contacts to static list.
|
||||
*/
|
||||
public function addContacts(array $contactIds): void
|
||||
{
|
||||
if ($this->type !== self::TYPE_STATIC) {
|
||||
throw new \LogicException('Cannot manually add contacts to a smart list');
|
||||
}
|
||||
|
||||
$this->contacts()->syncWithoutDetaching($contactIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove contacts from static list.
|
||||
*/
|
||||
public function removeContacts(array $contactIds): void
|
||||
{
|
||||
if ($this->type !== self::TYPE_STATIC) {
|
||||
throw new \LogicException('Cannot manually remove contacts from a smart list');
|
||||
}
|
||||
|
||||
$this->contacts()->detach($contactIds);
|
||||
}
|
||||
}
|
||||
224
app/Models/Marketing/MarketingMessageLog.php
Normal file
224
app/Models/Marketing/MarketingMessageLog.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* MarketingMessageLog - Individual message send log.
|
||||
*
|
||||
* Tracks each email or SMS sent to a contact as part of a campaign.
|
||||
*/
|
||||
class MarketingMessageLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const CHANNEL_EMAIL = 'email';
|
||||
|
||||
public const CHANNEL_SMS = 'sms';
|
||||
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
|
||||
public const STATUS_SENT = 'sent';
|
||||
|
||||
public const STATUS_DELIVERED = 'delivered';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STATUS_BOUNCED = 'bounced';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_QUEUED => 'Queued',
|
||||
self::STATUS_SENT => 'Sent',
|
||||
self::STATUS_DELIVERED => 'Delivered',
|
||||
self::STATUS_FAILED => 'Failed',
|
||||
self::STATUS_BOUNCED => 'Bounced',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'marketing_campaign_id',
|
||||
'marketing_contact_id',
|
||||
'channel',
|
||||
'status',
|
||||
'to',
|
||||
'provider_message_id',
|
||||
'error_message',
|
||||
'sent_at',
|
||||
'delivered_at',
|
||||
'opened_at',
|
||||
'clicked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sent_at' => 'datetime',
|
||||
'delivered_at' => 'datetime',
|
||||
'opened_at' => 'datetime',
|
||||
'clicked_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Relationships
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function campaign(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MarketingCampaign::class, 'marketing_campaign_id');
|
||||
}
|
||||
|
||||
public function contact(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MarketingContact::class, 'marketing_contact_id');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Scopes
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function scopeForBusiness(Builder $query, int $businessId): Builder
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForCampaign(Builder $query, int $campaignId): Builder
|
||||
{
|
||||
return $query->where('marketing_campaign_id', $campaignId);
|
||||
}
|
||||
|
||||
public function scopeForContact(Builder $query, int $contactId): Builder
|
||||
{
|
||||
return $query->where('marketing_contact_id', $contactId);
|
||||
}
|
||||
|
||||
public function scopeEmail(Builder $query): Builder
|
||||
{
|
||||
return $query->where('channel', self::CHANNEL_EMAIL);
|
||||
}
|
||||
|
||||
public function scopeSms(Builder $query): Builder
|
||||
{
|
||||
return $query->where('channel', self::CHANNEL_SMS);
|
||||
}
|
||||
|
||||
public function scopeStatus(Builder $query, string $status): Builder
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
public function scopeSent(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_SENT);
|
||||
}
|
||||
|
||||
public function scopeFailed(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_FAILED);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Accessors
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function getStatusLabel(): string
|
||||
{
|
||||
return self::STATUSES[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
public function isEmail(): bool
|
||||
{
|
||||
return $this->channel === self::CHANNEL_EMAIL;
|
||||
}
|
||||
|
||||
public function isSms(): bool
|
||||
{
|
||||
return $this->channel === self::CHANNEL_SMS;
|
||||
}
|
||||
|
||||
public function wasOpened(): bool
|
||||
{
|
||||
return $this->opened_at !== null;
|
||||
}
|
||||
|
||||
public function wasClicked(): bool
|
||||
{
|
||||
return $this->clicked_at !== null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Status Updates
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function markSent(?string $providerMessageId = null): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_SENT,
|
||||
'sent_at' => now(),
|
||||
'provider_message_id' => $providerMessageId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markDelivered(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_DELIVERED,
|
||||
'delivered_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markFailed(string $errorMessage): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_FAILED,
|
||||
'error_message' => $errorMessage,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markBounced(?string $reason = null): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_BOUNCED,
|
||||
'error_message' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
public function recordOpen(): void
|
||||
{
|
||||
if (! $this->opened_at) {
|
||||
$this->update(['opened_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function recordClick(): void
|
||||
{
|
||||
$this->update(['clicked_at' => now()]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Factory Methods
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public static function createQueued(
|
||||
MarketingCampaign $campaign,
|
||||
MarketingContact $contact,
|
||||
string $channel,
|
||||
string $to
|
||||
): self {
|
||||
return self::create([
|
||||
'business_id' => $campaign->business_id,
|
||||
'marketing_campaign_id' => $campaign->id,
|
||||
'marketing_contact_id' => $contact->id,
|
||||
'channel' => $channel,
|
||||
'status' => self::STATUS_QUEUED,
|
||||
'to' => $to,
|
||||
]);
|
||||
}
|
||||
}
|
||||
270
app/Models/Marketing/MarketingPromo.php
Normal file
270
app/Models/Marketing/MarketingPromo.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* Marketing Promo
|
||||
*
|
||||
* Represents a promotional offer created through the Promo Builder.
|
||||
* Can be targeted to specific stores, brands, or categories.
|
||||
*/
|
||||
class MarketingPromo extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'marketing_promos';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'store_external_id',
|
||||
'brand_id',
|
||||
'name',
|
||||
'type',
|
||||
'config',
|
||||
'expected_lift',
|
||||
'expected_margin_brand',
|
||||
'expected_margin_store',
|
||||
'status',
|
||||
'starts_at',
|
||||
'ends_at',
|
||||
'description',
|
||||
'sms_copy',
|
||||
'email_copy',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'config' => 'array',
|
||||
'expected_lift' => 'decimal:2',
|
||||
'expected_margin_brand' => 'decimal:2',
|
||||
'expected_margin_store' => 'decimal:2',
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Promo types
|
||||
public const TYPE_BOGO = 'bogo';
|
||||
|
||||
public const TYPE_PERCENT_OFF = 'percent_off';
|
||||
|
||||
public const TYPE_BUNDLE = 'bundle';
|
||||
|
||||
public const TYPE_SET_PRICE = 'set_price';
|
||||
|
||||
public const TYPE_BUY_X_GET_Y = 'buy_x_get_y';
|
||||
|
||||
// Statuses
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Relationships
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function brand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scopes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForStore($query, string $storeExternalId)
|
||||
{
|
||||
return $query->where('store_external_id', $storeExternalId);
|
||||
}
|
||||
|
||||
public function scopeDraft($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_DRAFT);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
public function scopeExpired($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_EXPIRED);
|
||||
}
|
||||
|
||||
public function scopeCurrentlyActive($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_ACTIVE)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('starts_at')->orWhere('starts_at', '<=', now());
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('ends_at')->orWhere('ends_at', '>=', now());
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Accessors
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get human-readable promo type
|
||||
*/
|
||||
public function getTypeDisplayAttribute(): string
|
||||
{
|
||||
return match ($this->type) {
|
||||
self::TYPE_BOGO => 'Buy One Get One',
|
||||
self::TYPE_PERCENT_OFF => 'Percentage Off',
|
||||
self::TYPE_BUNDLE => 'Bundle Deal',
|
||||
self::TYPE_SET_PRICE => 'Set Price',
|
||||
self::TYPE_BUY_X_GET_Y => 'Buy X Get Y',
|
||||
default => ucfirst(str_replace('_', ' ', $this->type)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status badge color
|
||||
*/
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_DRAFT => 'warning',
|
||||
self::STATUS_ACTIVE => 'success',
|
||||
self::STATUS_EXPIRED => 'neutral',
|
||||
self::STATUS_CANCELLED => 'error',
|
||||
default => 'neutral',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if promo is currently running
|
||||
*/
|
||||
public function getIsRunningAttribute(): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
if ($this->starts_at && $this->starts_at > $now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->ends_at && $this->ends_at < $now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get discount value from config
|
||||
*/
|
||||
public function getDiscountValueAttribute(): ?float
|
||||
{
|
||||
return $this->config['discount_value'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target products from config
|
||||
*/
|
||||
public function getTargetProductsAttribute(): array
|
||||
{
|
||||
return $this->config['target_products'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get required quantity from config
|
||||
*/
|
||||
public function getRequiredQuantityAttribute(): ?int
|
||||
{
|
||||
return $this->config['required_quantity'] ?? null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Activate the promo
|
||||
*/
|
||||
public function activate(): bool
|
||||
{
|
||||
$this->status = self::STATUS_ACTIVE;
|
||||
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the promo
|
||||
*/
|
||||
public function cancel(): bool
|
||||
{
|
||||
$this->status = self::STATUS_CANCELLED;
|
||||
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark as expired
|
||||
*/
|
||||
public function expire(): bool
|
||||
{
|
||||
$this->status = self::STATUS_EXPIRED;
|
||||
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available promo types
|
||||
*/
|
||||
public static function getTypes(): array
|
||||
{
|
||||
return [
|
||||
self::TYPE_BOGO => 'Buy One Get One',
|
||||
self::TYPE_PERCENT_OFF => 'Percentage Off',
|
||||
self::TYPE_BUNDLE => 'Bundle Deal',
|
||||
self::TYPE_SET_PRICE => 'Set Price',
|
||||
self::TYPE_BUY_X_GET_Y => 'Buy X Get Y',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available statuses
|
||||
*/
|
||||
public static function getStatuses(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_DRAFT => 'Draft',
|
||||
self::STATUS_ACTIVE => 'Active',
|
||||
self::STATUS_EXPIRED => 'Expired',
|
||||
self::STATUS_CANCELLED => 'Cancelled',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -151,4 +151,45 @@ class OrderItem extends Model implements Auditable
|
||||
{
|
||||
return $this->pre_delivery_status !== 'rejected';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable quantity display string
|
||||
* e.g., "2 Cases (24 Units)" or "24 Units" if no case info
|
||||
*
|
||||
* @param int|null $qty Override quantity (useful for picked/delivered display)
|
||||
*/
|
||||
public function getQuantityDisplay(?int $qty = null): string
|
||||
{
|
||||
$quantity = $qty ?? $this->quantity;
|
||||
$unitsPerCase = $this->product?->units_per_case;
|
||||
|
||||
if ($unitsPerCase && $unitsPerCase > 1) {
|
||||
$totalUnits = $quantity * $unitsPerCase;
|
||||
|
||||
return sprintf(
|
||||
'%d %s (%d Units)',
|
||||
$quantity,
|
||||
$quantity === 1 ? 'Case' : 'Cases',
|
||||
$totalUnits
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf('%d %s', $quantity, $quantity === 1 ? 'Unit' : 'Units');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display quantity (picked if available, otherwise ordered)
|
||||
*/
|
||||
public function getEffectiveQuantity(): int
|
||||
{
|
||||
return $this->picked_qty > 0 ? $this->picked_qty : $this->quantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display for effective quantity (picked or ordered)
|
||||
*/
|
||||
public function getEffectiveQuantityDisplay(): string
|
||||
{
|
||||
return $this->getQuantityDisplay($this->getEffectiveQuantity());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,6 +490,11 @@ class Product extends Model implements Auditable
|
||||
return $query->where('brand_id', $brandId);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, Business $business)
|
||||
{
|
||||
return $query->whereHas('brand', fn ($q) => $q->where('business_id', $business->id));
|
||||
}
|
||||
|
||||
public function scopeForDepartment($query, Department $department)
|
||||
{
|
||||
return $query->where('department_id', $department->id);
|
||||
|
||||
@@ -1084,4 +1084,39 @@ class User extends Authenticatable implements FilamentUser
|
||||
{
|
||||
return $this->hasMany(Business::class, 'owner_user_id');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Marketing Portal Methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if user is a Marketing Portal user for a given business.
|
||||
*
|
||||
* Marketing Portal access is granted when:
|
||||
* - User belongs to the business with contact_type = 'marketing_portal'
|
||||
*/
|
||||
public function isMarketingPortalUser(Business $business): bool
|
||||
{
|
||||
// Super admins have access to all portals
|
||||
if ($this->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check contact_type on business_user pivot
|
||||
$pivot = $this->businesses()
|
||||
->where('business_id', $business->id)
|
||||
->first()?->pivot;
|
||||
|
||||
return $pivot && $pivot->contact_type === 'marketing_portal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get businesses where user has marketing portal access.
|
||||
*/
|
||||
public function getMarketingPortalBusinesses(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return $this->businesses()
|
||||
->wherePivot('contact_type', 'marketing_portal')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
149
app/Policies/MarketingCampaignPolicy.php
Normal file
149
app/Policies/MarketingCampaignPolicy.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\User;
|
||||
|
||||
class MarketingCampaignPolicy
|
||||
{
|
||||
/**
|
||||
* Determine if the user can view any campaigns for the business
|
||||
*/
|
||||
public function viewAny(User $user, ?Business $business = null): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can view a specific campaign
|
||||
*/
|
||||
public function view(User $user, MarketingCampaign $campaign, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Campaign must belong to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can create campaigns
|
||||
*/
|
||||
public function create(User $user, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can update the campaign
|
||||
*/
|
||||
public function update(User $user, MarketingCampaign $campaign, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Campaign must belong to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Can only update drafts and scheduled campaigns
|
||||
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can delete the campaign
|
||||
*/
|
||||
public function delete(User $user, MarketingCampaign $campaign, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Campaign must belong to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Can only delete drafts
|
||||
if ($campaign->status !== 'draft') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can send the campaign
|
||||
*/
|
||||
public function send(User $user, MarketingCampaign $campaign, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Campaign must belong to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Can only send drafts and scheduled campaigns
|
||||
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can cancel the campaign
|
||||
*/
|
||||
public function cancel(User $user, MarketingCampaign $campaign, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Campaign must belong to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Can only cancel drafts and scheduled campaigns
|
||||
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
}
|
||||
127
app/Policies/MarketingPromoPolicy.php
Normal file
127
app/Policies/MarketingPromoPolicy.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Models\User;
|
||||
|
||||
class MarketingPromoPolicy
|
||||
{
|
||||
/**
|
||||
* Determine if the user can view any promos for the business
|
||||
*/
|
||||
public function viewAny(User $user, ?Business $business = null): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can view a specific promo
|
||||
*/
|
||||
public function view(User $user, MarketingPromo $promo, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Promo must belong to this business
|
||||
if ($promo->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can create promos
|
||||
*
|
||||
* Portal users can only view - they can't create promos
|
||||
*/
|
||||
public function create(User $user, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Portal users are view-only for promos
|
||||
if ($user->isMarketingPortalUser($business) && ! $user->businesses->contains($business->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can update the promo
|
||||
*
|
||||
* Portal users can only view - they can't update promos
|
||||
*/
|
||||
public function update(User $user, MarketingPromo $promo, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Promo must belong to this business
|
||||
if ($promo->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Portal users are view-only for promos
|
||||
if ($user->isMarketingPortalUser($business) && ! $user->businesses->contains($business->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can delete the promo
|
||||
*
|
||||
* Portal users can only view - they can't delete promos
|
||||
*/
|
||||
public function delete(User $user, MarketingPromo $promo, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Promo must belong to this business
|
||||
if ($promo->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can launch a campaign from this promo
|
||||
*
|
||||
* Portal users CAN launch campaigns from promos
|
||||
*/
|
||||
public function launchCampaign(User $user, MarketingPromo $promo, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Promo must belong to this business
|
||||
if ($promo->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$versionData = cache()->remember('app.version_data', now()->addSeconds(5), function () {
|
||||
$version = 'dev';
|
||||
$commit = 'unknown';
|
||||
$buildDate = null;
|
||||
|
||||
// For Docker: read from version.env (injected at build time)
|
||||
$versionFile = base_path('version.env');
|
||||
@@ -117,6 +118,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$data = parse_ini_file($versionFile);
|
||||
$version = $data['VERSION'] ?? 'dev';
|
||||
$commit = $data['COMMIT'] ?? 'unknown';
|
||||
$buildDate = $data['BUILD_DATE'] ?? null;
|
||||
}
|
||||
// For local dev: read from git directly (but cached for 5 seconds)
|
||||
// Check for .git (directory for regular repos, file for worktrees)
|
||||
@@ -128,6 +130,13 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
// Only proceed if we successfully got a commit SHA
|
||||
if ($commit !== '' && $commit !== 'unknown') {
|
||||
// Get commit date for local dev
|
||||
$dateCommand = sprintf('cd %s && git log -1 --format=%%ci 2>/dev/null', escapeshellarg(base_path()));
|
||||
$commitDate = trim(shell_exec($dateCommand) ?: '');
|
||||
if ($commitDate) {
|
||||
$buildDate = date('M j, g:ia', strtotime($commitDate));
|
||||
}
|
||||
|
||||
// Check for uncommitted changes (dirty working directory)
|
||||
$diffCommand = sprintf('cd %s && git diff --quiet 2>/dev/null; echo $?', escapeshellarg(base_path()));
|
||||
$cachedCommand = sprintf('cd %s && git diff --cached --quiet 2>/dev/null; echo $?', escapeshellarg(base_path()));
|
||||
@@ -147,17 +156,19 @@ class AppServiceProvider extends ServiceProvider
|
||||
return [
|
||||
'version' => $version,
|
||||
'commit' => $commit,
|
||||
'buildDate' => $buildDate,
|
||||
];
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
// If cache fails (e.g., Redis not ready), calculate version without caching
|
||||
$versionData = ['version' => 'dev', 'commit' => 'unknown'];
|
||||
$versionData = ['version' => 'dev', 'commit' => 'unknown', 'buildDate' => null];
|
||||
|
||||
$versionFile = base_path('version.env');
|
||||
if (File::exists($versionFile)) {
|
||||
$data = parse_ini_file($versionFile);
|
||||
$versionData['version'] = $data['VERSION'] ?? 'dev';
|
||||
$versionData['commit'] = $data['COMMIT'] ?? 'unknown';
|
||||
$versionData['buildDate'] = $data['BUILD_DATE'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +176,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$view->with([
|
||||
'appVersion' => $versionData['version'],
|
||||
'appCommit' => $versionData['commit'],
|
||||
'appBuildDate' => $versionData['buildDate'],
|
||||
'appVersionFull' => "{$versionData['version']} (sha-{$versionData['commit']})",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Models\Product;
|
||||
use App\Policies\MarketingCampaignPolicy;
|
||||
use App\Policies\MarketingPromoPolicy;
|
||||
use App\Policies\ProductPolicy;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
|
||||
@@ -15,6 +19,8 @@ class AuthServiceProvider extends ServiceProvider
|
||||
*/
|
||||
protected $policies = [
|
||||
Product::class => ProductPolicy::class,
|
||||
MarketingCampaign::class => MarketingCampaignPolicy::class,
|
||||
MarketingPromo::class => MarketingPromoPolicy::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -260,10 +260,10 @@ class BrandVoicePrompt
|
||||
* AI generation MUST respect these limits.
|
||||
*/
|
||||
public const CHARACTER_LIMITS = [
|
||||
'tagline' => ['min' => 30, 'max' => 45, 'label' => 'Tagline'],
|
||||
'short_description' => ['min' => 100, 'max' => 150, 'label' => 'Short Description'],
|
||||
'description' => ['min' => 100, 'max' => 150, 'label' => 'Short Description'], // Alias
|
||||
'long_description' => ['min' => 400, 'max' => 500, 'label' => 'Long Description'],
|
||||
'tagline' => ['min' => null, 'max' => 255, 'label' => 'Tagline'],
|
||||
'short_description' => ['min' => null, 'max' => 1000, 'label' => 'Short Description'],
|
||||
'description' => ['min' => null, 'max' => 1000, 'label' => 'Short Description'], // Alias
|
||||
'long_description' => ['min' => null, 'max' => 5000, 'label' => 'Long Description'],
|
||||
'brand_announcement' => ['min' => 400, 'max' => 500, 'label' => 'Brand Announcement'],
|
||||
'seo_title' => ['min' => 60, 'max' => 70, 'label' => 'SEO Title'],
|
||||
'seo_description' => ['min' => 150, 'max' => 160, 'label' => 'SEO Description'],
|
||||
@@ -803,6 +803,11 @@ class BrandVoicePrompt
|
||||
return '';
|
||||
}
|
||||
|
||||
// If no min is set, only enforce max
|
||||
if ($limits['min'] === null) {
|
||||
return "CHARACTER LIMIT: Output should not exceed {$limits['max']} characters.";
|
||||
}
|
||||
|
||||
return "STRICT CHARACTER LIMIT: Output MUST be between {$limits['min']}-{$limits['max']} characters. Do NOT output fewer than {$limits['min']} or more than {$limits['max']} characters.";
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Accounting\ArCustomer;
|
||||
use App\Models\Accounting\ArInvoice;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* AR Service - Core AR operations and credit enforcement.
|
||||
@@ -316,40 +317,43 @@ class ArService
|
||||
public function getArSummary(Business $business, ?array $businessIds = null): array
|
||||
{
|
||||
$businessIds = $businessIds ?? $this->getBusinessIdsWithChildren($business);
|
||||
$cacheKey = 'ar_summary_'.implode('_', $businessIds);
|
||||
|
||||
$totalAr = ArInvoice::whereIn('business_id', $businessIds)
|
||||
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('balance_due', '>', 0)
|
||||
->sum('balance_due');
|
||||
return Cache::remember($cacheKey, now()->addHour(), function () use ($businessIds) {
|
||||
$totalAr = ArInvoice::whereIn('business_id', $businessIds)
|
||||
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('balance_due', '>', 0)
|
||||
->sum('balance_due');
|
||||
|
||||
$totalPastDue = ArInvoice::whereIn('business_id', $businessIds)
|
||||
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('balance_due', '>', 0)
|
||||
->where('due_date', '<', now())
|
||||
->sum('balance_due');
|
||||
$totalPastDue = ArInvoice::whereIn('business_id', $businessIds)
|
||||
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('balance_due', '>', 0)
|
||||
->where('due_date', '<', now())
|
||||
->sum('balance_due');
|
||||
|
||||
$atRiskCount = ArCustomer::whereIn('business_id', $businessIds)
|
||||
->where('is_active', true)
|
||||
->where(function ($query) {
|
||||
$query->where('on_credit_hold', true)
|
||||
->orWhereHas('invoices', function ($q) {
|
||||
$q->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('balance_due', '>', 0)
|
||||
->where('due_date', '<', now());
|
||||
});
|
||||
})
|
||||
->count();
|
||||
$atRiskCount = ArCustomer::whereIn('business_id', $businessIds)
|
||||
->where('is_active', true)
|
||||
->where(function ($query) {
|
||||
$query->where('on_credit_hold', true)
|
||||
->orWhereHas('invoices', function ($q) {
|
||||
$q->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('balance_due', '>', 0)
|
||||
->where('due_date', '<', now());
|
||||
});
|
||||
})
|
||||
->count();
|
||||
|
||||
$onHoldCount = ArCustomer::whereIn('business_id', $businessIds)
|
||||
->where('on_credit_hold', true)
|
||||
->count();
|
||||
$onHoldCount = ArCustomer::whereIn('business_id', $businessIds)
|
||||
->where('on_credit_hold', true)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total_ar' => (float) $totalAr,
|
||||
'total_past_due' => (float) $totalPastDue,
|
||||
'at_risk_count' => $atRiskCount,
|
||||
'on_hold_count' => $onHoldCount,
|
||||
];
|
||||
return [
|
||||
'total_ar' => (float) $totalAr,
|
||||
'total_past_due' => (float) $totalPastDue,
|
||||
'at_risk_count' => $atRiskCount,
|
||||
'on_hold_count' => $onHoldCount,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,39 +364,42 @@ class ArService
|
||||
public function getTopArAccounts(Business $business, int $limit = 5, ?array $businessIds = null): Collection
|
||||
{
|
||||
$businessIds = $businessIds ?? $this->getBusinessIdsWithChildren($business);
|
||||
$cacheKey = 'ar_top_accounts_'.implode('_', $businessIds).'_'.$limit;
|
||||
|
||||
return ArCustomer::whereIn('ar_customers.business_id', $businessIds)
|
||||
->where('is_active', true)
|
||||
->whereHas('invoices', function ($q) {
|
||||
$q->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('balance_due', '>', 0);
|
||||
})
|
||||
->with('business')
|
||||
->get()
|
||||
->map(function ($customer) {
|
||||
$balance = $customer->invoices()
|
||||
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('balance_due', '>', 0)
|
||||
->sum('balance_due');
|
||||
return Cache::remember($cacheKey, now()->addHour(), function () use ($businessIds, $limit) {
|
||||
return ArCustomer::whereIn('ar_customers.business_id', $businessIds)
|
||||
->where('is_active', true)
|
||||
->whereHas('invoices', function ($q) {
|
||||
$q->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('balance_due', '>', 0);
|
||||
})
|
||||
->with('business')
|
||||
->get()
|
||||
->map(function ($customer) {
|
||||
$balance = $customer->invoices()
|
||||
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('balance_due', '>', 0)
|
||||
->sum('balance_due');
|
||||
|
||||
$pastDue = $customer->invoices()
|
||||
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('balance_due', '>', 0)
|
||||
->where('due_date', '<', now())
|
||||
->sum('balance_due');
|
||||
$pastDue = $customer->invoices()
|
||||
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('balance_due', '>', 0)
|
||||
->where('due_date', '<', now())
|
||||
->sum('balance_due');
|
||||
|
||||
return [
|
||||
'customer' => $customer,
|
||||
'business' => $customer->business,
|
||||
'balance' => (float) $balance,
|
||||
'past_due' => (float) $pastDue,
|
||||
'credit_status' => $customer->credit_status ?? ArCustomer::CREDIT_STATUS_GOOD,
|
||||
'on_credit_hold' => $customer->on_credit_hold ?? false,
|
||||
];
|
||||
})
|
||||
->sortByDesc('balance')
|
||||
->take($limit)
|
||||
->values();
|
||||
return [
|
||||
'customer' => $customer,
|
||||
'business' => $customer->business,
|
||||
'balance' => (float) $balance,
|
||||
'past_due' => (float) $pastDue,
|
||||
'credit_status' => $customer->credit_status ?? ArCustomer::CREDIT_STATUS_GOOD,
|
||||
'on_credit_hold' => $customer->on_credit_hold ?? false,
|
||||
];
|
||||
})
|
||||
->sortByDesc('balance')
|
||||
->take($limit)
|
||||
->values();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\Business;
|
||||
use App\Models\Department;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BudgetService
|
||||
@@ -244,52 +245,56 @@ class BudgetService
|
||||
*/
|
||||
public function getBudgetSummary(Budget $budget): array
|
||||
{
|
||||
$lines = $budget->lines()->with(['department', 'glAccount'])->get();
|
||||
$cacheKey = 'budget_summary_'.$budget->id;
|
||||
|
||||
$totalBudget = $lines->sum('amount');
|
||||
return Cache::remember($cacheKey, now()->addHour(), function () use ($budget) {
|
||||
$lines = $budget->lines()->with(['department', 'glAccount'])->get();
|
||||
|
||||
// Get all actuals for the budget
|
||||
$actuals = $this->getActualsForBudget($budget);
|
||||
$totalActual = $actuals->sum('actual_amount');
|
||||
$totalBudget = $lines->sum('amount');
|
||||
|
||||
$varianceAmount = $totalBudget - $totalActual;
|
||||
$variancePercent = $totalBudget > 0
|
||||
? round(($varianceAmount / $totalBudget) * 100, 2)
|
||||
: 0;
|
||||
// Get all actuals for the budget
|
||||
$actuals = $this->getActualsForBudget($budget);
|
||||
$totalActual = $actuals->sum('actual_amount');
|
||||
|
||||
// Group by department for summary
|
||||
$byDepartment = $actuals->groupBy('department_id')
|
||||
->map(function ($items, $deptId) {
|
||||
return [
|
||||
'department_id' => $deptId,
|
||||
'department_name' => $items->first()['department_name'],
|
||||
'budget' => $items->sum('budget_amount'),
|
||||
'actual' => $items->sum('actual_amount'),
|
||||
];
|
||||
})->values();
|
||||
$varianceAmount = $totalBudget - $totalActual;
|
||||
$variancePercent = $totalBudget > 0
|
||||
? round(($varianceAmount / $totalBudget) * 100, 2)
|
||||
: 0;
|
||||
|
||||
// Group by account for summary
|
||||
$byAccount = $actuals->groupBy('gl_account_id')
|
||||
->map(function ($items, $accountId) {
|
||||
$first = $items->first();
|
||||
// Group by department for summary
|
||||
$byDepartment = $actuals->groupBy('department_id')
|
||||
->map(function ($items, $deptId) {
|
||||
return [
|
||||
'department_id' => $deptId,
|
||||
'department_name' => $items->first()['department_name'],
|
||||
'budget' => $items->sum('budget_amount'),
|
||||
'actual' => $items->sum('actual_amount'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return [
|
||||
'account_id' => $accountId,
|
||||
'account_name' => $first['account_number'].' - '.$first['account_name'],
|
||||
'budget' => $items->sum('budget_amount'),
|
||||
'actual' => $items->sum('actual_amount'),
|
||||
];
|
||||
})->values();
|
||||
// Group by account for summary
|
||||
$byAccount = $actuals->groupBy('gl_account_id')
|
||||
->map(function ($items, $accountId) {
|
||||
$first = $items->first();
|
||||
|
||||
return [
|
||||
'total_budget' => $totalBudget,
|
||||
'total_actual' => $totalActual,
|
||||
'variance_amount' => $varianceAmount,
|
||||
'variance_percent' => $variancePercent,
|
||||
'line_count' => $lines->count(),
|
||||
'by_department' => $byDepartment,
|
||||
'by_account' => $byAccount,
|
||||
];
|
||||
return [
|
||||
'account_id' => $accountId,
|
||||
'account_name' => $first['account_number'].' - '.$first['account_name'],
|
||||
'budget' => $items->sum('budget_amount'),
|
||||
'actual' => $items->sum('actual_amount'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return [
|
||||
'total_budget' => $totalBudget,
|
||||
'total_actual' => $totalActual,
|
||||
'variance_amount' => $varianceAmount,
|
||||
'variance_percent' => $variancePercent,
|
||||
'line_count' => $lines->count(),
|
||||
'by_department' => $byDepartment,
|
||||
'by_account' => $byAccount,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\Accounting\FinancialActivity;
|
||||
use App\Models\Business;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class CustomerFinancialService
|
||||
{
|
||||
@@ -26,53 +27,57 @@ class CustomerFinancialService
|
||||
? $business->divisions()->pluck('id')->push($business->id)->toArray()
|
||||
: [$business->id];
|
||||
|
||||
// Get all open invoices
|
||||
$openInvoices = ArInvoice::where('ar_customer_id', $customer->id)
|
||||
->whereIn('business_id', $businessIds)
|
||||
->whereIn('status', ['open', 'partial'])
|
||||
->get();
|
||||
$cacheKey = 'customer_financial_summary_'.$customer->id.'_'.implode('_', $businessIds);
|
||||
|
||||
$totalOpenAr = $openInvoices->sum('balance_due');
|
||||
$now = now();
|
||||
return Cache::remember($cacheKey, now()->addHour(), function () use ($customer, $businessIds) {
|
||||
// Get all open invoices
|
||||
$openInvoices = ArInvoice::where('ar_customer_id', $customer->id)
|
||||
->whereIn('business_id', $businessIds)
|
||||
->whereIn('status', ['open', 'partial'])
|
||||
->get();
|
||||
|
||||
// Calculate aging buckets
|
||||
$aging = $this->calculateAgingBuckets($openInvoices, $now);
|
||||
$totalOpenAr = $openInvoices->sum('balance_due');
|
||||
$now = now();
|
||||
|
||||
// Past due calculations
|
||||
$pastDueInvoices = $openInvoices->filter(fn ($inv) => $inv->due_date->lt($now));
|
||||
$pastDueTotal = $pastDueInvoices->sum('balance_due');
|
||||
// Calculate aging buckets
|
||||
$aging = $this->calculateAgingBuckets($openInvoices, $now);
|
||||
|
||||
// Credit status
|
||||
$creditLimit = $customer->credit_limit ?? 0;
|
||||
$creditUsed = $totalOpenAr;
|
||||
$overLimitAmount = max(0, $creditUsed - $creditLimit);
|
||||
$creditStatus = $this->determineCreditStatus($customer, $creditUsed, $pastDueTotal);
|
||||
// Past due calculations
|
||||
$pastDueInvoices = $openInvoices->filter(fn ($inv) => $inv->due_date->lt($now));
|
||||
$pastDueTotal = $pastDueInvoices->sum('balance_due');
|
||||
|
||||
// Last payment
|
||||
$lastPayment = ArPayment::whereHas('invoice', fn ($q) => $q->where('ar_customer_id', $customer->id))
|
||||
->whereIn('business_id', $businessIds)
|
||||
->orderByDesc('payment_date')
|
||||
->first();
|
||||
// Credit status
|
||||
$creditLimit = $customer->credit_limit ?? 0;
|
||||
$creditUsed = $totalOpenAr;
|
||||
$overLimitAmount = max(0, $creditUsed - $creditLimit);
|
||||
$creditStatus = $this->determineCreditStatus($customer, $creditUsed, $pastDueTotal);
|
||||
|
||||
return [
|
||||
'customer' => $customer,
|
||||
'total_open_ar' => $totalOpenAr,
|
||||
'past_due_total' => $pastDueTotal,
|
||||
'aging' => $aging,
|
||||
'credit_limit' => $creditLimit,
|
||||
'credit_used' => $creditUsed,
|
||||
'credit_available' => max(0, $creditLimit - $creditUsed),
|
||||
'over_limit_amount' => $overLimitAmount,
|
||||
'credit_status' => $creditStatus,
|
||||
'on_credit_hold' => $customer->on_credit_hold ?? false,
|
||||
'hold_reason' => $customer->hold_reason ?? null,
|
||||
'last_payment_date' => $lastPayment?->payment_date,
|
||||
'last_payment_amount' => $lastPayment?->amount ?? 0,
|
||||
'open_invoice_count' => $openInvoices->count(),
|
||||
'past_due_count' => $pastDueInvoices->count(),
|
||||
'highest_aging_bucket' => $this->getHighestAgingBucket($aging),
|
||||
'avg_days_to_pay' => $this->calculateAvgDaysToPay($customer, $businessIds),
|
||||
];
|
||||
// Last payment
|
||||
$lastPayment = ArPayment::whereHas('invoice', fn ($q) => $q->where('ar_customer_id', $customer->id))
|
||||
->whereIn('business_id', $businessIds)
|
||||
->orderByDesc('payment_date')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'customer' => $customer,
|
||||
'total_open_ar' => $totalOpenAr,
|
||||
'past_due_total' => $pastDueTotal,
|
||||
'aging' => $aging,
|
||||
'credit_limit' => $creditLimit,
|
||||
'credit_used' => $creditUsed,
|
||||
'credit_available' => max(0, $creditLimit - $creditUsed),
|
||||
'over_limit_amount' => $overLimitAmount,
|
||||
'credit_status' => $creditStatus,
|
||||
'on_credit_hold' => $customer->on_credit_hold ?? false,
|
||||
'hold_reason' => $customer->hold_reason ?? null,
|
||||
'last_payment_date' => $lastPayment?->payment_date,
|
||||
'last_payment_amount' => $lastPayment?->amount ?? 0,
|
||||
'open_invoice_count' => $openInvoices->count(),
|
||||
'past_due_count' => $pastDueInvoices->count(),
|
||||
'highest_aging_bucket' => $this->getHighestAgingBucket($aging),
|
||||
'avg_days_to_pay' => $this->calculateAvgDaysToPay($customer, $businessIds),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Accounting\ArCustomer;
|
||||
use App\Models\Accounting\ArInvoice;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Service for financial analytics and reporting.
|
||||
@@ -25,50 +26,54 @@ class FinanceAnalyticsService
|
||||
*/
|
||||
public function getAPAging(Business $business, ?array $businessIds = null): array
|
||||
{
|
||||
$today = now()->startOfDay();
|
||||
$businessIds = $businessIds ?? $this->getBusinessIdsWithChildren($business);
|
||||
$cacheKey = 'finance_ap_aging_'.implode('_', $businessIds);
|
||||
|
||||
$bills = ApBill::whereIn('business_id', $businessIds)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->with(['vendor', 'business'])
|
||||
->get();
|
||||
return Cache::remember($cacheKey, now()->addHour(), function () use ($businessIds) {
|
||||
$today = now()->startOfDay();
|
||||
|
||||
$buckets = [
|
||||
'current' => ['label' => '0-30 days', 'min' => 0, 'max' => 30, 'amount' => 0, 'count' => 0],
|
||||
'bucket_31_60' => ['label' => '31-60 days', 'min' => 31, 'max' => 60, 'amount' => 0, 'count' => 0],
|
||||
'bucket_61_90' => ['label' => '61-90 days', 'min' => 61, 'max' => 90, 'amount' => 0, 'count' => 0],
|
||||
'over_90' => ['label' => '90+ days', 'min' => 91, 'max' => 9999, 'amount' => 0, 'count' => 0],
|
||||
];
|
||||
$bills = ApBill::whereIn('business_id', $businessIds)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->with(['vendor', 'business'])
|
||||
->get();
|
||||
|
||||
$overdueBills = collect();
|
||||
$buckets = [
|
||||
'current' => ['label' => '0-30 days', 'min' => 0, 'max' => 30, 'amount' => 0, 'count' => 0],
|
||||
'bucket_31_60' => ['label' => '31-60 days', 'min' => 31, 'max' => 60, 'amount' => 0, 'count' => 0],
|
||||
'bucket_61_90' => ['label' => '61-90 days', 'min' => 61, 'max' => 90, 'amount' => 0, 'count' => 0],
|
||||
'over_90' => ['label' => '90+ days', 'min' => 91, 'max' => 9999, 'amount' => 0, 'count' => 0],
|
||||
];
|
||||
|
||||
foreach ($bills as $bill) {
|
||||
$daysOld = $bill->due_date ? $today->diffInDays($bill->due_date, false) * -1 : 0;
|
||||
$overdueBills = collect();
|
||||
|
||||
if ($daysOld <= 30) {
|
||||
$buckets['current']['amount'] += $bill->balance_due;
|
||||
$buckets['current']['count']++;
|
||||
} elseif ($daysOld <= 60) {
|
||||
$buckets['bucket_31_60']['amount'] += $bill->balance_due;
|
||||
$buckets['bucket_31_60']['count']++;
|
||||
} elseif ($daysOld <= 90) {
|
||||
$buckets['bucket_61_90']['amount'] += $bill->balance_due;
|
||||
$buckets['bucket_61_90']['count']++;
|
||||
} else {
|
||||
$buckets['over_90']['amount'] += $bill->balance_due;
|
||||
$buckets['over_90']['count']++;
|
||||
foreach ($bills as $bill) {
|
||||
$daysOld = $bill->due_date ? $today->diffInDays($bill->due_date, false) * -1 : 0;
|
||||
|
||||
if ($daysOld <= 30) {
|
||||
$buckets['current']['amount'] += $bill->balance_due;
|
||||
$buckets['current']['count']++;
|
||||
} elseif ($daysOld <= 60) {
|
||||
$buckets['bucket_31_60']['amount'] += $bill->balance_due;
|
||||
$buckets['bucket_31_60']['count']++;
|
||||
} elseif ($daysOld <= 90) {
|
||||
$buckets['bucket_61_90']['amount'] += $bill->balance_due;
|
||||
$buckets['bucket_61_90']['count']++;
|
||||
} else {
|
||||
$buckets['over_90']['amount'] += $bill->balance_due;
|
||||
$buckets['over_90']['count']++;
|
||||
}
|
||||
|
||||
if ($bill->isOverdue()) {
|
||||
$overdueBills->push($bill);
|
||||
}
|
||||
}
|
||||
|
||||
if ($bill->isOverdue()) {
|
||||
$overdueBills->push($bill);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'buckets' => $buckets,
|
||||
'total' => array_sum(array_column($buckets, 'amount')),
|
||||
'overdue_bills' => $overdueBills->sortBy('due_date'),
|
||||
];
|
||||
return [
|
||||
'buckets' => $buckets,
|
||||
'total' => array_sum(array_column($buckets, 'amount')),
|
||||
'overdue_bills' => $overdueBills->sortBy('due_date'),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,24 +83,28 @@ class FinanceAnalyticsService
|
||||
*/
|
||||
public function getAPBreakdownByDivision(Business $business, ?array $businessIds = null): Collection
|
||||
{
|
||||
$children = Business::where('parent_id', $business->id)
|
||||
->when($businessIds, fn ($q) => $q->whereIn('id', $businessIds))
|
||||
->get();
|
||||
$cacheKey = 'finance_ap_division_'.$business->id.'_'.($businessIds ? implode('_', $businessIds) : 'all');
|
||||
|
||||
return $children->map(function ($child) {
|
||||
$bills = ApBill::where('business_id', $child->id)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
return Cache::remember($cacheKey, now()->addHour(), function () use ($business, $businessIds) {
|
||||
$children = Business::where('parent_id', $business->id)
|
||||
->when($businessIds, fn ($q) => $q->whereIn('id', $businessIds))
|
||||
->get();
|
||||
|
||||
$overdue = $bills->filter(fn ($b) => $b->isOverdue());
|
||||
return $children->map(function ($child) {
|
||||
$bills = ApBill::where('business_id', $child->id)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->get();
|
||||
|
||||
return [
|
||||
'business' => $child,
|
||||
'total_outstanding' => $bills->sum('balance_due'),
|
||||
'overdue_amount' => $overdue->sum('balance_due'),
|
||||
'bill_count' => $bills->count(),
|
||||
'overdue_count' => $overdue->count(),
|
||||
];
|
||||
$overdue = $bills->filter(fn ($b) => $b->isOverdue());
|
||||
|
||||
return [
|
||||
'business' => $child,
|
||||
'total_outstanding' => $bills->sum('balance_due'),
|
||||
'overdue_amount' => $overdue->sum('balance_due'),
|
||||
'bill_count' => $bills->count(),
|
||||
'overdue_count' => $overdue->count(),
|
||||
];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,22 +116,25 @@ class FinanceAnalyticsService
|
||||
public function getAPBreakdownByVendor(Business $business, ?array $businessIds = null): Collection
|
||||
{
|
||||
$businessIds = $businessIds ?? $this->getBusinessIdsWithChildren($business);
|
||||
$cacheKey = 'finance_ap_vendor_'.implode('_', $businessIds);
|
||||
|
||||
return ApBill::whereIn('business_id', $businessIds)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->select('vendor_id')
|
||||
->selectRaw('SUM(balance_due) as total_outstanding')
|
||||
->selectRaw('COUNT(*) as bill_count')
|
||||
->selectRaw('SUM(CASE WHEN due_date < NOW() THEN balance_due ELSE 0 END) as overdue_amount')
|
||||
->groupBy('vendor_id')
|
||||
->orderByDesc('total_outstanding')
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'vendor' => ApVendor::find($row->vendor_id),
|
||||
'total_outstanding' => (float) $row->total_outstanding,
|
||||
'bill_count' => (int) $row->bill_count,
|
||||
'overdue_amount' => (float) $row->overdue_amount,
|
||||
]);
|
||||
return Cache::remember($cacheKey, now()->addHour(), function () use ($businessIds) {
|
||||
return ApBill::whereIn('business_id', $businessIds)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->select('vendor_id')
|
||||
->selectRaw('SUM(balance_due) as total_outstanding')
|
||||
->selectRaw('COUNT(*) as bill_count')
|
||||
->selectRaw('SUM(CASE WHEN due_date < NOW() THEN balance_due ELSE 0 END) as overdue_amount')
|
||||
->groupBy('vendor_id')
|
||||
->orderByDesc('total_outstanding')
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'vendor' => ApVendor::find($row->vendor_id),
|
||||
'total_outstanding' => (float) $row->total_outstanding,
|
||||
'bill_count' => (int) $row->bill_count,
|
||||
'overdue_amount' => (float) $row->overdue_amount,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,49 +145,53 @@ class FinanceAnalyticsService
|
||||
public function getCashForecast(Business $business, int $days = 30, ?array $businessIds = null): array
|
||||
{
|
||||
$businessIds = $businessIds ?? $this->getBusinessIdsWithChildren($business);
|
||||
$startDate = now()->startOfDay();
|
||||
$endDate = now()->addDays($days)->endOfDay();
|
||||
$cacheKey = 'finance_cash_forecast_'.implode('_', $businessIds).'_'.$days;
|
||||
|
||||
$bills = ApBill::whereIn('business_id', $businessIds)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->whereBetween('due_date', [$startDate, $endDate])
|
||||
->with(['vendor', 'business'])
|
||||
->orderBy('due_date')
|
||||
->get();
|
||||
return Cache::remember($cacheKey, now()->addHour(), function () use ($businessIds, $days) {
|
||||
$startDate = now()->startOfDay();
|
||||
$endDate = now()->addDays($days)->endOfDay();
|
||||
|
||||
$daily = [];
|
||||
for ($i = 0; $i <= $days; $i++) {
|
||||
$date = now()->addDays($i)->format('Y-m-d');
|
||||
$daily[$date] = ['date' => $date, 'label' => now()->addDays($i)->format('M d'), 'amount' => 0, 'count' => 0];
|
||||
}
|
||||
$bills = ApBill::whereIn('business_id', $businessIds)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->whereBetween('due_date', [$startDate, $endDate])
|
||||
->with(['vendor', 'business'])
|
||||
->orderBy('due_date')
|
||||
->get();
|
||||
|
||||
foreach ($bills as $bill) {
|
||||
$date = $bill->due_date->format('Y-m-d');
|
||||
if (isset($daily[$date])) {
|
||||
$daily[$date]['amount'] += $bill->balance_due;
|
||||
$daily[$date]['count']++;
|
||||
$daily = [];
|
||||
for ($i = 0; $i <= $days; $i++) {
|
||||
$date = now()->addDays($i)->format('Y-m-d');
|
||||
$daily[$date] = ['date' => $date, 'label' => now()->addDays($i)->format('M d'), 'amount' => 0, 'count' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
$byVendor = $bills->groupBy('vendor_id')->map(fn ($vendorBills) => [
|
||||
'vendor' => $vendorBills->first()->vendor,
|
||||
'total' => $vendorBills->sum('balance_due'),
|
||||
'count' => $vendorBills->count(),
|
||||
])->sortByDesc('total')->values();
|
||||
foreach ($bills as $bill) {
|
||||
$date = $bill->due_date->format('Y-m-d');
|
||||
if (isset($daily[$date])) {
|
||||
$daily[$date]['amount'] += $bill->balance_due;
|
||||
$daily[$date]['count']++;
|
||||
}
|
||||
}
|
||||
|
||||
$byDivision = $bills->groupBy('business_id')->map(fn ($divisionBills) => [
|
||||
'division' => $divisionBills->first()->business,
|
||||
'total' => $divisionBills->sum('balance_due'),
|
||||
'count' => $divisionBills->count(),
|
||||
])->sortByDesc('total')->values();
|
||||
$byVendor = $bills->groupBy('vendor_id')->map(fn ($vendorBills) => [
|
||||
'vendor' => $vendorBills->first()->vendor,
|
||||
'total' => $vendorBills->sum('balance_due'),
|
||||
'count' => $vendorBills->count(),
|
||||
])->sortByDesc('total')->values();
|
||||
|
||||
return [
|
||||
'daily' => array_values($daily),
|
||||
'by_vendor' => $byVendor,
|
||||
'by_division' => $byDivision,
|
||||
'total' => $bills->sum('balance_due'),
|
||||
'bill_count' => $bills->count(),
|
||||
];
|
||||
$byDivision = $bills->groupBy('business_id')->map(fn ($divisionBills) => [
|
||||
'division' => $divisionBills->first()->business,
|
||||
'total' => $divisionBills->sum('balance_due'),
|
||||
'count' => $divisionBills->count(),
|
||||
])->sortByDesc('total')->values();
|
||||
|
||||
return [
|
||||
'daily' => array_values($daily),
|
||||
'by_vendor' => $byVendor,
|
||||
'by_division' => $byDivision,
|
||||
'total' => $bills->sum('balance_due'),
|
||||
'bill_count' => $bills->count(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,68 +204,72 @@ class FinanceAnalyticsService
|
||||
return collect();
|
||||
}
|
||||
|
||||
$children = Business::where('parent_id', $business->id)->get();
|
||||
$yearStart = now()->startOfYear();
|
||||
$cacheKey = 'finance_division_rollup_'.$business->id;
|
||||
|
||||
return $children->map(function ($child) use ($yearStart) {
|
||||
// AP Metrics
|
||||
$apOutstanding = ApBill::where('business_id', $child->id)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->sum('balance_due');
|
||||
return Cache::remember($cacheKey, now()->addHour(), function () use ($business) {
|
||||
$children = Business::where('parent_id', $business->id)->get();
|
||||
$yearStart = now()->startOfYear();
|
||||
|
||||
$apOverdue = ApBill::where('business_id', $child->id)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->where('due_date', '<', now())
|
||||
->sum('balance_due');
|
||||
return $children->map(function ($child) use ($yearStart) {
|
||||
// AP Metrics
|
||||
$apOutstanding = ApBill::where('business_id', $child->id)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->sum('balance_due');
|
||||
|
||||
$ytdPayments = ApPayment::where('business_id', $child->id)
|
||||
->where('status', ApPayment::STATUS_COMPLETED)
|
||||
->where('payment_date', '>=', $yearStart)
|
||||
->sum('amount');
|
||||
$apOverdue = ApBill::where('business_id', $child->id)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->where('due_date', '<', now())
|
||||
->sum('balance_due');
|
||||
|
||||
$pendingApproval = ApBill::where('business_id', $child->id)
|
||||
->whereIn('status', [ApBill::STATUS_DRAFT, ApBill::STATUS_PENDING])
|
||||
->count();
|
||||
$ytdPayments = ApPayment::where('business_id', $child->id)
|
||||
->where('status', ApPayment::STATUS_COMPLETED)
|
||||
->where('payment_date', '>=', $yearStart)
|
||||
->sum('amount');
|
||||
|
||||
// AR Metrics
|
||||
$arTotal = ArInvoice::where('business_id', $child->id)
|
||||
->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE])
|
||||
->sum('balance_due');
|
||||
$pendingApproval = ApBill::where('business_id', $child->id)
|
||||
->whereIn('status', [ApBill::STATUS_DRAFT, ApBill::STATUS_PENDING])
|
||||
->count();
|
||||
|
||||
$arOverdue = ArInvoice::where('business_id', $child->id)
|
||||
->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE])
|
||||
->where('due_date', '<', now())
|
||||
->sum('balance_due');
|
||||
// AR Metrics
|
||||
$arTotal = ArInvoice::where('business_id', $child->id)
|
||||
->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE])
|
||||
->sum('balance_due');
|
||||
|
||||
// Count at-risk customers (overdue or on credit hold)
|
||||
$atRiskCustomers = ArCustomer::where('business_id', $child->id)
|
||||
->where(function ($query) {
|
||||
$query->where('on_credit_hold', true)
|
||||
->orWhereHas('invoices', function ($q) {
|
||||
$q->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE])
|
||||
->where('due_date', '<', now());
|
||||
});
|
||||
})
|
||||
->count();
|
||||
$arOverdue = ArInvoice::where('business_id', $child->id)
|
||||
->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE])
|
||||
->where('due_date', '<', now())
|
||||
->sum('balance_due');
|
||||
|
||||
$onHoldCustomers = ArCustomer::where('business_id', $child->id)
|
||||
->where('on_credit_hold', true)
|
||||
->count();
|
||||
// Count at-risk customers (overdue or on credit hold)
|
||||
$atRiskCustomers = ArCustomer::where('business_id', $child->id)
|
||||
->where(function ($query) {
|
||||
$query->where('on_credit_hold', true)
|
||||
->orWhereHas('invoices', function ($q) {
|
||||
$q->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE])
|
||||
->where('due_date', '<', now());
|
||||
});
|
||||
})
|
||||
->count();
|
||||
|
||||
return [
|
||||
'division' => $child,
|
||||
// AP
|
||||
'ap_outstanding' => (float) $apOutstanding,
|
||||
'ap_overdue' => (float) $apOverdue,
|
||||
'ytd_payments' => (float) $ytdPayments,
|
||||
'pending_approval' => $pendingApproval,
|
||||
// AR
|
||||
'ar_total' => (float) $arTotal,
|
||||
'ar_overdue' => (float) $arOverdue,
|
||||
'ar_at_risk' => $atRiskCustomers,
|
||||
'ar_on_hold' => $onHoldCustomers,
|
||||
];
|
||||
})->sortByDesc('ap_outstanding')->values();
|
||||
$onHoldCustomers = ArCustomer::where('business_id', $child->id)
|
||||
->where('on_credit_hold', true)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'division' => $child,
|
||||
// AP
|
||||
'ap_outstanding' => (float) $apOutstanding,
|
||||
'ap_overdue' => (float) $apOverdue,
|
||||
'ytd_payments' => (float) $ytdPayments,
|
||||
'pending_approval' => $pendingApproval,
|
||||
// AR
|
||||
'ar_total' => (float) $arTotal,
|
||||
'ar_overdue' => (float) $arOverdue,
|
||||
'ar_at_risk' => $atRiskCustomers,
|
||||
'ar_on_hold' => $onHoldCustomers,
|
||||
];
|
||||
})->sortByDesc('ap_outstanding')->values();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,46 +284,50 @@ class FinanceAnalyticsService
|
||||
$businessIds = $this->getBusinessIdsWithChildren($business);
|
||||
}
|
||||
|
||||
$yearStart = now()->startOfYear();
|
||||
$cacheKey = 'finance_vendor_spend_'.implode('_', $businessIds);
|
||||
|
||||
$ytdByVendor = ApPayment::whereIn('business_id', $businessIds)
|
||||
->where('status', ApPayment::STATUS_COMPLETED)
|
||||
->where('payment_date', '>=', $yearStart)
|
||||
->select('vendor_id')
|
||||
->selectRaw('SUM(amount) as total')
|
||||
->groupBy('vendor_id')
|
||||
->orderByDesc('total')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn ($row) => ['vendor' => ApVendor::find($row->vendor_id), 'total' => (float) $row->total]);
|
||||
return Cache::remember($cacheKey, now()->addHour(), function () use ($businessIds) {
|
||||
$yearStart = now()->startOfYear();
|
||||
|
||||
$monthlyTrend = [];
|
||||
for ($i = 11; $i >= 0; $i--) {
|
||||
$month = now()->subMonths($i);
|
||||
$total = ApPayment::whereIn('business_id', $businessIds)
|
||||
$ytdByVendor = ApPayment::whereIn('business_id', $businessIds)
|
||||
->where('status', ApPayment::STATUS_COMPLETED)
|
||||
->whereBetween('payment_date', [$month->copy()->startOfMonth(), $month->copy()->endOfMonth()])
|
||||
->where('payment_date', '>=', $yearStart)
|
||||
->select('vendor_id')
|
||||
->selectRaw('SUM(amount) as total')
|
||||
->groupBy('vendor_id')
|
||||
->orderByDesc('total')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn ($row) => ['vendor' => ApVendor::find($row->vendor_id), 'total' => (float) $row->total]);
|
||||
|
||||
$monthlyTrend = [];
|
||||
for ($i = 11; $i >= 0; $i--) {
|
||||
$month = now()->subMonths($i);
|
||||
$total = ApPayment::whereIn('business_id', $businessIds)
|
||||
->where('status', ApPayment::STATUS_COMPLETED)
|
||||
->whereBetween('payment_date', [$month->copy()->startOfMonth(), $month->copy()->endOfMonth()])
|
||||
->sum('amount');
|
||||
|
||||
$monthlyTrend[] = ['month' => $month->format('M Y'), 'short' => $month->format('M'), 'amount' => (float) $total];
|
||||
}
|
||||
|
||||
$mtdTotal = ApPayment::whereIn('business_id', $businessIds)
|
||||
->where('status', ApPayment::STATUS_COMPLETED)
|
||||
->where('payment_date', '>=', now()->startOfMonth())
|
||||
->sum('amount');
|
||||
|
||||
$monthlyTrend[] = ['month' => $month->format('M Y'), 'short' => $month->format('M'), 'amount' => (float) $total];
|
||||
}
|
||||
$ytdTotal = ApPayment::whereIn('business_id', $businessIds)
|
||||
->where('status', ApPayment::STATUS_COMPLETED)
|
||||
->where('payment_date', '>=', $yearStart)
|
||||
->sum('amount');
|
||||
|
||||
$mtdTotal = ApPayment::whereIn('business_id', $businessIds)
|
||||
->where('status', ApPayment::STATUS_COMPLETED)
|
||||
->where('payment_date', '>=', now()->startOfMonth())
|
||||
->sum('amount');
|
||||
|
||||
$ytdTotal = ApPayment::whereIn('business_id', $businessIds)
|
||||
->where('status', ApPayment::STATUS_COMPLETED)
|
||||
->where('payment_date', '>=', $yearStart)
|
||||
->sum('amount');
|
||||
|
||||
return [
|
||||
'top_vendors' => $ytdByVendor,
|
||||
'mtd_total' => (float) $mtdTotal,
|
||||
'ytd_total' => (float) $ytdTotal,
|
||||
'monthly_trend' => $monthlyTrend,
|
||||
];
|
||||
return [
|
||||
'top_vendors' => $ytdByVendor,
|
||||
'mtd_total' => (float) $mtdTotal,
|
||||
'ytd_total' => (float) $ytdTotal,
|
||||
'monthly_trend' => $monthlyTrend,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\Accounting\FinancialActivity;
|
||||
use App\Models\Business;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class VendorFinancialService
|
||||
{
|
||||
@@ -26,57 +27,61 @@ class VendorFinancialService
|
||||
? $business->divisions()->pluck('id')->push($business->id)->toArray()
|
||||
: [$business->id];
|
||||
|
||||
// Get all open bills
|
||||
$openBills = ApBill::where('vendor_id', $vendor->id)
|
||||
->whereIn('business_id', $businessIds)
|
||||
->whereIn('status', ['open', 'partial'])
|
||||
->get();
|
||||
$cacheKey = 'vendor_financial_summary_'.$vendor->id.'_'.implode('_', $businessIds);
|
||||
|
||||
$totalOpenAp = $openBills->sum('balance_due');
|
||||
$now = now();
|
||||
|
||||
// Calculate aging buckets
|
||||
$aging = $this->calculateAgingBuckets($openBills, $now);
|
||||
|
||||
// Past due calculations
|
||||
$pastDueBills = $openBills->filter(fn ($bill) => $bill->due_date->lt($now));
|
||||
$pastDueTotal = $pastDueBills->sum('balance_due');
|
||||
|
||||
// Last payment
|
||||
$lastPayment = ApPayment::whereHas('bill', fn ($q) => $q->where('vendor_id', $vendor->id))
|
||||
->whereIn('business_id', $businessIds)
|
||||
->orderByDesc('payment_date')
|
||||
->first();
|
||||
|
||||
// Child businesses using this vendor
|
||||
$childBusinessesUsing = [];
|
||||
if ($includeChildren && $business->hasChildBusinesses()) {
|
||||
$childBusinessesUsing = ApBill::where('vendor_id', $vendor->id)
|
||||
return Cache::remember($cacheKey, now()->addHour(), function () use ($vendor, $business, $businessIds, $includeChildren) {
|
||||
// Get all open bills
|
||||
$openBills = ApBill::where('vendor_id', $vendor->id)
|
||||
->whereIn('business_id', $businessIds)
|
||||
->with('business')
|
||||
->get()
|
||||
->pluck('business')
|
||||
->unique('id')
|
||||
->values();
|
||||
}
|
||||
->whereIn('status', ['open', 'partial'])
|
||||
->get();
|
||||
|
||||
return [
|
||||
'vendor' => $vendor,
|
||||
'total_open_ap' => $totalOpenAp,
|
||||
'past_due_total' => $pastDueTotal,
|
||||
'aging' => $aging,
|
||||
'open_bill_count' => $openBills->count(),
|
||||
'past_due_count' => $pastDueBills->count(),
|
||||
'last_payment_date' => $lastPayment?->payment_date,
|
||||
'last_payment_amount' => $lastPayment?->amount ?? 0,
|
||||
'highest_aging_bucket' => $this->getHighestAgingBucket($aging),
|
||||
'avg_payment_days' => $this->calculateAvgPaymentDays($vendor, $businessIds),
|
||||
'child_businesses_using' => $childBusinessesUsing,
|
||||
'ytd_paid' => $this->getYtdPaid($vendor, $businessIds),
|
||||
'total_bills_all_time' => ApBill::where('vendor_id', $vendor->id)
|
||||
$totalOpenAp = $openBills->sum('balance_due');
|
||||
$now = now();
|
||||
|
||||
// Calculate aging buckets
|
||||
$aging = $this->calculateAgingBuckets($openBills, $now);
|
||||
|
||||
// Past due calculations
|
||||
$pastDueBills = $openBills->filter(fn ($bill) => $bill->due_date->lt($now));
|
||||
$pastDueTotal = $pastDueBills->sum('balance_due');
|
||||
|
||||
// Last payment
|
||||
$lastPayment = ApPayment::whereHas('bill', fn ($q) => $q->where('vendor_id', $vendor->id))
|
||||
->whereIn('business_id', $businessIds)
|
||||
->count(),
|
||||
];
|
||||
->orderByDesc('payment_date')
|
||||
->first();
|
||||
|
||||
// Child businesses using this vendor
|
||||
$childBusinessesUsing = [];
|
||||
if ($includeChildren && $business->hasChildBusinesses()) {
|
||||
$childBusinessesUsing = ApBill::where('vendor_id', $vendor->id)
|
||||
->whereIn('business_id', $businessIds)
|
||||
->with('business')
|
||||
->get()
|
||||
->pluck('business')
|
||||
->unique('id')
|
||||
->values();
|
||||
}
|
||||
|
||||
return [
|
||||
'vendor' => $vendor,
|
||||
'total_open_ap' => $totalOpenAp,
|
||||
'past_due_total' => $pastDueTotal,
|
||||
'aging' => $aging,
|
||||
'open_bill_count' => $openBills->count(),
|
||||
'past_due_count' => $pastDueBills->count(),
|
||||
'last_payment_date' => $lastPayment?->payment_date,
|
||||
'last_payment_amount' => $lastPayment?->amount ?? 0,
|
||||
'highest_aging_bucket' => $this->getHighestAgingBucket($aging),
|
||||
'avg_payment_days' => $this->calculateAvgPaymentDays($vendor, $businessIds),
|
||||
'child_businesses_using' => $childBusinessesUsing,
|
||||
'ytd_paid' => $this->getYtdPaid($vendor, $businessIds),
|
||||
'total_bills_all_time' => ApBill::where('vendor_id', $vendor->id)
|
||||
->whereIn('business_id', $businessIds)
|
||||
->count(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
533
app/Services/Cannaiq/AdvancedV3IntelligenceDTO.php
Normal file
533
app/Services/Cannaiq/AdvancedV3IntelligenceDTO.php
Normal file
@@ -0,0 +1,533 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Cannaiq;
|
||||
|
||||
/**
|
||||
* Advanced v4 Intelligence Data Transfer Object
|
||||
*
|
||||
* Contains advanced brand intelligence analytics including:
|
||||
* - Brand positioning and differentiation scoring (v3)
|
||||
* - Trend lead/lag analysis (predictive vs laggy behavior) (v3)
|
||||
* - Cross-state market signals (v3)
|
||||
* - Shelf displacement opportunities (v3)
|
||||
* - Shelf value projections with capture scenarios (v4)
|
||||
*
|
||||
* v4.0 Additions:
|
||||
* - shelfValueProjections: Revenue projections by scope (store/state/multi_state)
|
||||
* - capture_scenarios: 10%, 25%, 50% market capture modeling
|
||||
* - opportunity_label: "Big prize, low effort" etc.
|
||||
* - consumerDemand: Consumer Demand Index + SKU lifecycle stages
|
||||
* - elasticity: Price elasticity metrics per SKU
|
||||
* - competitiveThreat: Competitive pressure scoring
|
||||
* - portfolioBalance: Category mix, redundancy clusters, gaps
|
||||
*
|
||||
* All data is derived from existing CannaiQ + internal data; no new scrapes.
|
||||
*/
|
||||
class AdvancedV3IntelligenceDTO
|
||||
{
|
||||
public function __construct(
|
||||
// v3.0 fields
|
||||
public readonly ?array $brandPositioning = null,
|
||||
public readonly ?array $trendLeadLag = null,
|
||||
public readonly array $marketSignals = [],
|
||||
public readonly array $shelfOpportunities = [],
|
||||
// v4.0: Shelf value projections with capture scenarios
|
||||
public readonly array $shelfValueProjections = [],
|
||||
// v4.0: Consumer Demand Index + SKU lifecycle
|
||||
public readonly ?array $consumerDemand = null,
|
||||
// v4.0: Price elasticity metrics
|
||||
public readonly ?array $elasticity = null,
|
||||
// v4.0: Competitive threat scoring
|
||||
public readonly ?array $competitiveThreat = null,
|
||||
// v4.0: Portfolio balance analysis
|
||||
public readonly ?array $portfolioBalance = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create empty DTO when data is unavailable
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self(
|
||||
brandPositioning: null,
|
||||
trendLeadLag: null,
|
||||
marketSignals: [],
|
||||
shelfOpportunities: [],
|
||||
shelfValueProjections: [],
|
||||
consumerDemand: null,
|
||||
elasticity: null,
|
||||
competitiveThreat: null,
|
||||
portfolioBalance: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty brand positioning structure
|
||||
*
|
||||
* Structure:
|
||||
* - differentiation_score: 0-100 (how unique vs competitors)
|
||||
* - positioning_label: 'more_of_the_same'|'value_disruptor'|'premium_standout'|'potency_leader'|'format_outlier'
|
||||
* - comparables: Array of similar brands with distance scores
|
||||
* - notes: Array of bullet explanations
|
||||
*/
|
||||
public static function emptyBrandPositioning(): array
|
||||
{
|
||||
return [
|
||||
'differentiation_score' => null,
|
||||
'positioning_label' => 'more_of_the_same',
|
||||
'comparables' => [],
|
||||
'notes' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty trend lead/lag structure
|
||||
*
|
||||
* Structure:
|
||||
* - lead_lag_index: -100 (laggy) to +100 (predictive)
|
||||
* - classification: 'strong_leader'|'emerging_leader'|'in_line'|'follower'|'laggy'
|
||||
* - supporting_signals: Array of category-level signals
|
||||
*/
|
||||
public static function emptyTrendLeadLag(): array
|
||||
{
|
||||
return [
|
||||
'lead_lag_index' => 0,
|
||||
'classification' => 'in_line',
|
||||
'supporting_signals' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty market signal structure
|
||||
*
|
||||
* Structure:
|
||||
* - scope: 'multi_state'|'state'|'category'
|
||||
* - state_code: optional state
|
||||
* - category: optional category
|
||||
* - description: human-readable summary
|
||||
* - trend_strength: 0-100
|
||||
* - relevant_to_brand: bool
|
||||
* - brand_fit: 'strong_fit'|'partial_fit'|'gap'
|
||||
* - example_brand: optional example
|
||||
*/
|
||||
public static function emptyMarketSignal(): array
|
||||
{
|
||||
return [
|
||||
'scope' => 'category',
|
||||
'state_code' => null,
|
||||
'category' => null,
|
||||
'description' => '',
|
||||
'trend_strength' => 0,
|
||||
'relevant_to_brand' => false,
|
||||
'brand_fit' => 'gap',
|
||||
'example_brand' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty shelf opportunity structure
|
||||
*
|
||||
* Structure:
|
||||
* - store_id: CannaiQ store external ID
|
||||
* - store_name: Store display name
|
||||
* - state_code: State abbreviation
|
||||
* - opportunity_type: 'whitespace'|'displacement'
|
||||
* - competitor_brand: null for whitespace
|
||||
* - competitor_product_name: null for whitespace
|
||||
* - our_best_sku_id: our matching product ID
|
||||
* - our_best_sku_name: our matching product name
|
||||
* - est_monthly_units_current: competitor's current volume
|
||||
* - est_monthly_units_if_we_win: projected volume if we win
|
||||
* - est_monthly_revenue_if_we_win: projected revenue
|
||||
* - quality_score_delta: -100 to +100 (positive = we're better)
|
||||
* - value_score_delta: -100 to +100 (positive = better value)
|
||||
* - displacement_difficulty: 'low'|'medium'|'high'
|
||||
* - difficulty_score: 0-100 (100 = hardest)
|
||||
* - rationale_tags: Array of reason strings
|
||||
*/
|
||||
public static function emptyShelfOpportunity(): array
|
||||
{
|
||||
return [
|
||||
'store_id' => null,
|
||||
'store_name' => 'Unknown',
|
||||
'state_code' => null,
|
||||
'opportunity_type' => 'whitespace',
|
||||
'competitor_brand' => null,
|
||||
'competitor_product_name' => null,
|
||||
'our_best_sku_id' => null,
|
||||
'our_best_sku_name' => null,
|
||||
'est_monthly_units_current' => 0,
|
||||
'est_monthly_units_if_we_win' => 0,
|
||||
'est_monthly_revenue_if_we_win' => 0,
|
||||
'quality_score_delta' => 0,
|
||||
'value_score_delta' => 0,
|
||||
'displacement_difficulty' => 'medium',
|
||||
'difficulty_score' => 50,
|
||||
'rationale_tags' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty shelf value projection structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - scope: 'store'|'state'|'multi_state' - geographic scope of projection
|
||||
* - store_id: CannaiQ store ID (when scope='store')
|
||||
* - store_name: Store display name (when scope='store')
|
||||
* - state_code: State abbreviation (when scope='store' or 'state')
|
||||
* - current_competitor_sales: Competitor revenue currently on shelf
|
||||
* - category_total_sales: Total category sales at location
|
||||
* - our_current_share: Our % of category sales (0.0-1.0)
|
||||
* - our_current_shelf_value: Our current monthly revenue at location
|
||||
* - avg_displacement_difficulty: 0-100 (aggregated from opportunities)
|
||||
* - opportunity_label: 'Big prize, low effort'|'Low-hanging fruit'|'High potential, high difficulty'|'Grind zone'
|
||||
* - capture_scenarios: Array of capture scenario projections
|
||||
*/
|
||||
public static function emptyShelfValueProjection(): array
|
||||
{
|
||||
return [
|
||||
'scope' => 'store',
|
||||
'store_id' => null,
|
||||
'store_name' => null,
|
||||
'state_code' => null,
|
||||
'current_competitor_sales' => 0,
|
||||
'category_total_sales' => 0,
|
||||
'our_current_share' => 0,
|
||||
'our_current_shelf_value' => 0,
|
||||
'avg_displacement_difficulty' => 50,
|
||||
'opportunity_label' => 'Grind zone',
|
||||
'capture_scenarios' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty capture scenario structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - capture_percent: 10|25|50 - % of competitor shelf to capture
|
||||
* - projected_monthly_revenue: Revenue if we achieve this capture
|
||||
* - projected_units: Units if we achieve this capture
|
||||
* - revenue_lift_from_current: Delta from our current revenue
|
||||
* - effort_level: 'low'|'medium'|'high' - based on difficulty + capture %
|
||||
*/
|
||||
public static function emptyCaptureScenario(): array
|
||||
{
|
||||
return [
|
||||
'capture_percent' => 10,
|
||||
'projected_monthly_revenue' => 0,
|
||||
'projected_units' => 0,
|
||||
'revenue_lift_from_current' => 0,
|
||||
'effort_level' => 'medium',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get opportunity label based on value and difficulty
|
||||
*
|
||||
* @param float $value Estimated monthly revenue opportunity
|
||||
* @param int $difficulty 0-100 difficulty score
|
||||
*/
|
||||
public static function getOpportunityLabel(float $value, int $difficulty): string
|
||||
{
|
||||
// High value threshold: $5,000/mo
|
||||
// Low difficulty threshold: 40
|
||||
$highValue = $value >= 5000;
|
||||
$lowDifficulty = $difficulty <= 40;
|
||||
|
||||
return match (true) {
|
||||
$highValue && $lowDifficulty => 'Big prize, low effort',
|
||||
! $highValue && $lowDifficulty => 'Low-hanging fruit',
|
||||
$highValue && ! $lowDifficulty => 'High potential, high difficulty',
|
||||
default => 'Grind zone',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for views
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'brandPositioning' => $this->brandPositioning,
|
||||
'trendLeadLag' => $this->trendLeadLag,
|
||||
'marketSignals' => $this->marketSignals,
|
||||
'shelfOpportunities' => $this->shelfOpportunities,
|
||||
'shelfValueProjections' => $this->shelfValueProjections,
|
||||
'consumerDemand' => $this->consumerDemand,
|
||||
'elasticity' => $this->elasticity,
|
||||
'competitiveThreat' => $this->competitiveThreat,
|
||||
'portfolioBalance' => $this->portfolioBalance,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any v3/v4 intelligence data is available
|
||||
*/
|
||||
public function hasData(): bool
|
||||
{
|
||||
return $this->brandPositioning !== null
|
||||
|| $this->trendLeadLag !== null
|
||||
|| ! empty($this->marketSignals)
|
||||
|| ! empty($this->shelfOpportunities)
|
||||
|| ! empty($this->shelfValueProjections)
|
||||
|| $this->consumerDemand !== null
|
||||
|| $this->elasticity !== null
|
||||
|| $this->competitiveThreat !== null
|
||||
|| $this->portfolioBalance !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty consumer demand structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - consumer_demand_index: 0-100 overall brand demand score
|
||||
* - sku_scores: Array of per-SKU demand metrics
|
||||
*/
|
||||
public static function emptyConsumerDemand(): array
|
||||
{
|
||||
return [
|
||||
'consumer_demand_index' => null,
|
||||
'sku_scores' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty SKU demand score structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - product_id: Internal product ID
|
||||
* - product_name: Display name
|
||||
* - demand_index: 0-100 demand score
|
||||
* - promo_independence: 0-100 (higher = sells well without promos)
|
||||
* - cross_store_consistency: 0-100 (higher = consistent across stores)
|
||||
* - stage: 'launch'|'growth'|'peak'|'decline'|'terminal'|null
|
||||
*/
|
||||
public static function emptySkuDemandScore(): array
|
||||
{
|
||||
return [
|
||||
'product_id' => null,
|
||||
'product_name' => 'Unknown',
|
||||
'demand_index' => null,
|
||||
'promo_independence' => null,
|
||||
'cross_store_consistency' => null,
|
||||
'stage' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty elasticity structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - sku_elasticity: Array of per-SKU price elasticity metrics
|
||||
*/
|
||||
public static function emptyElasticity(): array
|
||||
{
|
||||
return [
|
||||
'sku_elasticity' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty SKU elasticity structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - product_id: Internal product ID
|
||||
* - product_name: Display name
|
||||
* - current_price: Current average price
|
||||
* - elasticity: Numeric elasticity coefficient (negative = price sensitive)
|
||||
* - price_behavior: 'sensitive'|'stable'|'room_to_raise'|null
|
||||
* - note: Human-readable recommendation
|
||||
*/
|
||||
public static function emptySkuElasticity(): array
|
||||
{
|
||||
return [
|
||||
'product_id' => null,
|
||||
'product_name' => 'Unknown',
|
||||
'current_price' => null,
|
||||
'elasticity' => null,
|
||||
'price_behavior' => null,
|
||||
'note' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty competitive threat structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - overall_threat_score: 0-100 aggregate threat level
|
||||
* - threat_level: 'low'|'medium'|'high'
|
||||
* - threats: Array of competitor threat details
|
||||
*/
|
||||
public static function emptyCompetitiveThreat(): array
|
||||
{
|
||||
return [
|
||||
'overall_threat_score' => null,
|
||||
'threat_level' => null,
|
||||
'threats' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty competitor threat structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - brand_name: Competitor brand name
|
||||
* - threat_score: 0-100 individual threat score
|
||||
* - price_aggression: 0-100 (how aggressively they undercut)
|
||||
* - velocity_trend: -100 to +100 (their growth vs decline)
|
||||
* - overlap_score: 0-100 (category/store overlap)
|
||||
* - notes: Array of threat reasons
|
||||
*/
|
||||
public static function emptyThreatBrand(): array
|
||||
{
|
||||
return [
|
||||
'brand_name' => 'Unknown',
|
||||
'threat_score' => null,
|
||||
'price_aggression' => null,
|
||||
'velocity_trend' => null,
|
||||
'overlap_score' => null,
|
||||
'notes' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty portfolio balance structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - category_mix: Array of category distribution
|
||||
* - redundancy_clusters: Array of similar SKU groupings
|
||||
* - gaps: Array of identified portfolio gaps
|
||||
*/
|
||||
public static function emptyPortfolioBalance(): array
|
||||
{
|
||||
return [
|
||||
'category_mix' => [],
|
||||
'redundancy_clusters' => [],
|
||||
'gaps' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty category mix item structure (v4.0)
|
||||
*/
|
||||
public static function emptyCategoryMix(): array
|
||||
{
|
||||
return [
|
||||
'category' => 'Unknown',
|
||||
'sku_count' => 0,
|
||||
'revenue_share_percent' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty redundancy cluster structure (v4.0)
|
||||
*/
|
||||
public static function emptyRedundancyCluster(): array
|
||||
{
|
||||
return [
|
||||
'cluster_id' => null,
|
||||
'label' => 'Unknown',
|
||||
'product_ids' => [],
|
||||
'note' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty portfolio gap structure (v4.0)
|
||||
*/
|
||||
public static function emptyPortfolioGap(): array
|
||||
{
|
||||
return [
|
||||
'category' => 'Unknown',
|
||||
'description' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get threat level label from score
|
||||
*/
|
||||
public static function getThreatLevel(float $score): string
|
||||
{
|
||||
return match (true) {
|
||||
$score >= 70 => 'high',
|
||||
$score >= 40 => 'medium',
|
||||
default => 'low',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lifecycle stage from velocity metrics
|
||||
*
|
||||
* @param float $velocity Current daily velocity
|
||||
* @param float|null $velocityTrend % change vs prior period (-100 to +100)
|
||||
* @param float $categoryAvgVelocity Category average velocity
|
||||
*/
|
||||
public static function getLifecycleStage(float $velocity, ?float $velocityTrend, float $categoryAvgVelocity): string
|
||||
{
|
||||
$relativeVelocity = $categoryAvgVelocity > 0 ? $velocity / $categoryAvgVelocity : 0;
|
||||
|
||||
// Very low velocity with flat/declining trend = terminal
|
||||
if ($relativeVelocity < 0.2 && ($velocityTrend === null || $velocityTrend <= 0)) {
|
||||
return 'terminal';
|
||||
}
|
||||
|
||||
// Low velocity but growing = launch
|
||||
if ($relativeVelocity < 0.5 && $velocityTrend !== null && $velocityTrend > 20) {
|
||||
return 'launch';
|
||||
}
|
||||
|
||||
// Medium velocity with strong growth = growth
|
||||
if ($velocityTrend !== null && $velocityTrend > 10) {
|
||||
return 'growth';
|
||||
}
|
||||
|
||||
// High velocity, stable = peak
|
||||
if ($relativeVelocity >= 0.8 && ($velocityTrend === null || abs($velocityTrend) <= 10)) {
|
||||
return 'peak';
|
||||
}
|
||||
|
||||
// Declining = decline
|
||||
if ($velocityTrend !== null && $velocityTrend < -10) {
|
||||
return 'decline';
|
||||
}
|
||||
|
||||
// Default to growth for healthy products
|
||||
return 'growth';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get positioning label for display
|
||||
*/
|
||||
public function getPositioningLabelDisplay(): string
|
||||
{
|
||||
if (! $this->brandPositioning) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
return match ($this->brandPositioning['positioning_label'] ?? 'more_of_the_same') {
|
||||
'value_disruptor' => 'Value Disruptor',
|
||||
'premium_standout' => 'Premium Standout',
|
||||
'potency_leader' => 'Potency Leader',
|
||||
'format_outlier' => 'Format Outlier',
|
||||
default => 'More of the Same',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trend classification for display
|
||||
*/
|
||||
public function getTrendClassificationDisplay(): string
|
||||
{
|
||||
if (! $this->trendLeadLag) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
return match ($this->trendLeadLag['classification'] ?? 'in_line') {
|
||||
'strong_leader' => 'Predictive (Leads Market)',
|
||||
'emerging_leader' => 'Early Mover',
|
||||
'follower' => 'Follower',
|
||||
'laggy' => 'Laggy (Follows Late)',
|
||||
default => 'In Line with Market',
|
||||
};
|
||||
}
|
||||
}
|
||||
1895
app/Services/Cannaiq/AdvancedV3IntelligenceService.php
Normal file
1895
app/Services/Cannaiq/AdvancedV3IntelligenceService.php
Normal file
File diff suppressed because it is too large
Load Diff
337
app/Services/Cannaiq/BrandAnalysisDTO.php
Normal file
337
app/Services/Cannaiq/BrandAnalysisDTO.php
Normal file
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Cannaiq;
|
||||
|
||||
/**
|
||||
* Brand Analysis Data Transfer Object (v3.0)
|
||||
*
|
||||
* Contains all market intelligence data for a brand, structured for the Analysis page.
|
||||
* When CannaiQ is disabled, contains only internal sales data.
|
||||
* When CannaiQ is enabled, enriched with market intelligence.
|
||||
*
|
||||
* v2.0 Additions:
|
||||
* - engagement: Buyer outreach and response tracking (always available)
|
||||
* - sentiment: Store support and brand positioning (CannaiQ only)
|
||||
*
|
||||
* v3.0 Additions:
|
||||
* - advancedV3: Advanced intelligence analytics (CannaiQ only)
|
||||
* - brandPositioning: Differentiation score and positioning label
|
||||
* - trendLeadLag: Predictive vs laggy behavior analysis
|
||||
* - marketSignals: Cross-state market trends
|
||||
* - shelfOpportunities: Displacement opportunities with difficulty scores
|
||||
*
|
||||
* Structure Reference (v1.5):
|
||||
*
|
||||
* placement: [
|
||||
* 'stores' => [...], // List of stores carrying brand
|
||||
* 'whitespaceStores' => [...], // v1.5: Stores with competitors but not us
|
||||
* 'whitespaceCount' => int, // v1.5: Count of whitespace opportunities
|
||||
* 'penetrationByRegion' => [ // v1.5: Regional breakdown
|
||||
* ['region' => 'CA', 'storeCount' => 10, 'totalStores' => 50, 'penetrationPercent' => 20],
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* competitors: [
|
||||
* 'competitors' => [...], // List of competitor brands
|
||||
* 'pricePosition' => 'value'|'mid'|'premium', // v1.5: Our price position
|
||||
* 'headToHeadSkus' => [ // v1.5: Direct SKU comparisons
|
||||
* ['ourSku' => '...', 'competitorSku' => '...', 'ourVelocity' => 0.5, ...],
|
||||
* ],
|
||||
* 'marketShareTrend' => [ // v1.5: Time series market share
|
||||
* ['period' => '2025-01', 'ourShare' => 12.5, 'competitor1Share' => 15.2, ...],
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* promoPerformance: [
|
||||
* [
|
||||
* 'id' => ..., 'name' => ...,
|
||||
* 'baselineVelocity' => float, // v1.5: Non-promo velocity
|
||||
* 'promoVelocity' => float, // v1.5: During-promo velocity
|
||||
* 'velocityLift' => float, // v1.5: Percent lift
|
||||
* 'efficiencyScore' => float, // v1.5: Units gained per discount dollar
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* inventoryProjection: [
|
||||
* 'items' => [ // v1.5: Structured items array
|
||||
* ['sku' => '...', 'daysOfStock' => int, 'riskLevel' => 'low'|'medium'|'high', ...],
|
||||
* ],
|
||||
* 'overstockedItems' => [...], // v1.5: Items with >90 days supply
|
||||
* 'rollup' => [ // v1.5: Brand-level summary
|
||||
* 'criticalCount' => int,
|
||||
* 'warningCount' => int,
|
||||
* 'overstockedSkuCount' => int,
|
||||
* 'riskLevel' => 'healthy'|'moderate'|'elevated'|'critical',
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* slippage: [
|
||||
* 'alerts' => [...], // Basic alerts (existing)
|
||||
* 'summary' => [ // v1.5: Summary metrics
|
||||
* 'lostStores30dCount' => int,
|
||||
* 'lostStores60dCount' => int,
|
||||
* 'lostSkus30dCount' => int,
|
||||
* 'competitorTakeoverCount' => int,
|
||||
* ],
|
||||
* 'lostStores30d' => [...], // v1.5: List of lost stores
|
||||
* 'lostStores60d' => [...],
|
||||
* 'lostSkus30d' => [...], // v1.5: List of lost SKUs
|
||||
* 'competitorTakeovers' => [...], // v1.5: SKU replacement events
|
||||
* 'oosMetrics' => [ // v1.5: Out-of-stock metrics
|
||||
* 'avgOOSDuration' => float,
|
||||
* 'avgReorderLag' => float,
|
||||
* 'chronicOOSStores' => [...],
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* engagement: [ // v2.0: Buyer outreach & response (ALWAYS available)
|
||||
* 'reach' => [
|
||||
* 'storesContacted30d' => int, // Unique stores contacted
|
||||
* 'messagesSent30d' => int, // Total outbound messages
|
||||
* 'touchesPerStore' => float, // Avg touches per store
|
||||
* 'repActivityLeaders' => [...], // Top reps by activity
|
||||
* ],
|
||||
* 'response' => [
|
||||
* 'responseRate' => float, // 0..1 reply rate
|
||||
* 'avgResponseTimeHours' => float|null, // Median reply time
|
||||
* 'storesNotResponding' => int, // Silent accounts
|
||||
* 'mostEngagedStores' => [...], // Top responding stores
|
||||
* ],
|
||||
* 'actions' => [
|
||||
* 'quotesIssued30d' => int, // Quotes tied to brand
|
||||
* 'ordersPlaced30d' => int, // Orders with brand products
|
||||
* 'conversionRate' => float|null, // Quotes → Orders
|
||||
* 'reorderRate' => float|null, // Repeat buyers
|
||||
* 'atRiskAccounts' => [...], // Accounts needing attention
|
||||
* ],
|
||||
* 'quality' => [
|
||||
* 'touchTypeBreakdown' => [...], // By channel type
|
||||
* 'buyerEngagementScore' => float|null, // 0..100
|
||||
* 'buyerEngagementLabel' => string, // "Strong partner" / "Healthy" / "At risk" / "Needs action"
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* sentiment: [ // v2.0: Store support (CannaiQ ONLY - null when disabled)
|
||||
* 'storeSupport' => [
|
||||
* 'storesPromotingBrand30d' => int, // Stores with active promos
|
||||
* 'promoFrequencyPerStore' => float|null,// Promos per store
|
||||
* 'featuredPlacementCount' => int, // Featured/specials count
|
||||
* 'avgShelfShare' => float|null, // Category share
|
||||
* 'storeSentimentScore' => float|null, // 0..100
|
||||
* 'storeSentimentLabel' => string, // "Advocates" / "Supportive" / "Neutral" / "Unsupportive"
|
||||
* ],
|
||||
* 'pricingBehavior' => [
|
||||
* 'avgDiscountRate' => float|null, // Avg promo discount
|
||||
* 'priceRespectIndex' => float|null, // 0..100 (MSRP adherence)
|
||||
* 'competitorPricePressure' => float|null, // 0..100
|
||||
* ],
|
||||
* 'inventoryBehavior' => [
|
||||
* 'sellThroughAfterRestock' => float|null, // Units/day post-restock
|
||||
* 'restockUrgencyIndex' => float|null, // 0..100 (faster reorders = higher)
|
||||
* 'stockNeglectEvents' => int, // Extended OOS events
|
||||
* 'shelfCommitment' => [
|
||||
* 'singleSkuStores' => int, // Stores with 1 SKU
|
||||
* 'multiSkuStores' => int, // Stores with 3+ SKUs
|
||||
* 'avgSkusPerStore' => float|null, // Avg SKU depth
|
||||
* ],
|
||||
* ],
|
||||
* ]
|
||||
*/
|
||||
class BrandAnalysisDTO
|
||||
{
|
||||
public function __construct(
|
||||
// Core metadata
|
||||
public readonly int $brandId,
|
||||
public readonly string $brandName,
|
||||
public readonly bool $cannaiqEnabled,
|
||||
public readonly ?\DateTimeInterface $dataFreshness = null,
|
||||
|
||||
// Connection error message (when CannaiQ is enabled but API fails)
|
||||
public readonly ?string $connectionError = null,
|
||||
|
||||
// Store placement data (v1.5: enriched with whitespace + regional)
|
||||
public readonly array $placement = [],
|
||||
|
||||
// Competitor analysis (v1.5: enriched with head-to-head + trends)
|
||||
public readonly array $competitors = [],
|
||||
|
||||
// SKU performance data
|
||||
public readonly array $skuPerformance = [],
|
||||
|
||||
// Promo performance data (v1.5: enriched with lift + efficiency)
|
||||
public readonly array $promoPerformance = [],
|
||||
|
||||
// Inventory projections (v1.5: enriched with risk levels + rollup)
|
||||
public readonly array $inventoryProjection = [],
|
||||
|
||||
// Slippage/velocity warnings (v1.5: fully structured)
|
||||
public readonly array $slippage = [],
|
||||
|
||||
// Summary metrics (v1.5: enriched with whitespace count)
|
||||
public readonly array $summary = [],
|
||||
|
||||
// v2.0: Buyer engagement (internal CRM + orders - ALWAYS available)
|
||||
public readonly array $engagement = [],
|
||||
|
||||
// v2.0: Store sentiment (CannaiQ data - ONLY when cannaiq_enabled)
|
||||
public readonly ?array $sentiment = null,
|
||||
|
||||
// v3.0: Advanced intelligence (CannaiQ data - ONLY when cannaiq_enabled)
|
||||
public readonly ?AdvancedV3IntelligenceDTO $advancedV3 = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create empty DTO for when data is unavailable
|
||||
*/
|
||||
public static function empty(int $brandId, string $brandName, bool $cannaiqEnabled): self
|
||||
{
|
||||
return new self(
|
||||
brandId: $brandId,
|
||||
brandName: $brandName,
|
||||
cannaiqEnabled: $cannaiqEnabled,
|
||||
dataFreshness: null,
|
||||
placement: [
|
||||
'stores' => [],
|
||||
'whitespaceStores' => [],
|
||||
'whitespaceCount' => 0,
|
||||
'penetrationByRegion' => [],
|
||||
],
|
||||
competitors: [
|
||||
'competitors' => [],
|
||||
'pricePosition' => null,
|
||||
'headToHeadSkus' => [],
|
||||
'marketShareTrend' => [],
|
||||
],
|
||||
skuPerformance: [],
|
||||
promoPerformance: [],
|
||||
inventoryProjection: [
|
||||
'items' => [],
|
||||
'overstockedItems' => [],
|
||||
'rollup' => [
|
||||
'criticalCount' => 0,
|
||||
'warningCount' => 0,
|
||||
'overstockedSkuCount' => 0,
|
||||
'riskLevel' => 'healthy',
|
||||
],
|
||||
],
|
||||
slippage: [
|
||||
'alerts' => [],
|
||||
'summary' => [
|
||||
'lostStores30dCount' => 0,
|
||||
'lostStores60dCount' => 0,
|
||||
'lostSkus30dCount' => 0,
|
||||
'competitorTakeoverCount' => 0,
|
||||
],
|
||||
'lostStores30d' => [],
|
||||
'lostStores60d' => [],
|
||||
'lostSkus30d' => [],
|
||||
'competitorTakeovers' => [],
|
||||
'oosMetrics' => [
|
||||
'avgOOSDuration' => null,
|
||||
'avgReorderLag' => null,
|
||||
'chronicOOSStores' => [],
|
||||
],
|
||||
],
|
||||
summary: [
|
||||
'totalStores' => 0,
|
||||
'totalSkus' => 0,
|
||||
'avgPrice' => 0,
|
||||
'marketShare' => null,
|
||||
'pricePosition' => null,
|
||||
'whitespaceCount' => 0,
|
||||
],
|
||||
engagement: self::emptyEngagement(),
|
||||
sentiment: null,
|
||||
advancedV3: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get empty engagement structure
|
||||
*/
|
||||
public static function emptyEngagement(): array
|
||||
{
|
||||
return [
|
||||
'reach' => [
|
||||
'storesContacted30d' => 0,
|
||||
'messagesSent30d' => 0,
|
||||
'touchesPerStore' => 0,
|
||||
'repActivityLeaders' => [],
|
||||
],
|
||||
'response' => [
|
||||
'responseRate' => 0,
|
||||
'avgResponseTimeHours' => null,
|
||||
'storesNotResponding' => 0,
|
||||
'mostEngagedStores' => [],
|
||||
],
|
||||
'actions' => [
|
||||
'quotesIssued30d' => 0,
|
||||
'ordersPlaced30d' => 0,
|
||||
'conversionRate' => null,
|
||||
'reorderRate' => null,
|
||||
'atRiskAccounts' => [],
|
||||
],
|
||||
'quality' => [
|
||||
'touchTypeBreakdown' => [],
|
||||
'buyerEngagementScore' => null,
|
||||
'buyerEngagementLabel' => 'Needs action',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get empty sentiment structure
|
||||
*/
|
||||
public static function emptySentiment(): array
|
||||
{
|
||||
return [
|
||||
'storeSupport' => [
|
||||
'storesPromotingBrand30d' => 0,
|
||||
'promoFrequencyPerStore' => null,
|
||||
'featuredPlacementCount' => 0,
|
||||
'avgShelfShare' => null,
|
||||
'storeSentimentScore' => null,
|
||||
'storeSentimentLabel' => 'Neutral',
|
||||
],
|
||||
'pricingBehavior' => [
|
||||
'avgDiscountRate' => null,
|
||||
'priceRespectIndex' => null,
|
||||
'competitorPricePressure' => null,
|
||||
],
|
||||
'inventoryBehavior' => [
|
||||
'sellThroughAfterRestock' => null,
|
||||
'restockUrgencyIndex' => null,
|
||||
'stockNeglectEvents' => 0,
|
||||
'shelfCommitment' => [
|
||||
'singleSkuStores' => 0,
|
||||
'multiSkuStores' => 0,
|
||||
'avgSkusPerStore' => null,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for views
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'brandId' => $this->brandId,
|
||||
'brandName' => $this->brandName,
|
||||
'cannaiqEnabled' => $this->cannaiqEnabled,
|
||||
'connectionError' => $this->connectionError,
|
||||
'dataFreshness' => $this->dataFreshness?->format('Y-m-d H:i:s'),
|
||||
'placement' => $this->placement,
|
||||
'competitors' => $this->competitors,
|
||||
'skuPerformance' => $this->skuPerformance,
|
||||
'promoPerformance' => $this->promoPerformance,
|
||||
'inventoryProjection' => $this->inventoryProjection,
|
||||
'slippage' => $this->slippage,
|
||||
'summary' => $this->summary,
|
||||
'engagement' => $this->engagement,
|
||||
'sentiment' => $this->sentiment,
|
||||
'advancedV3' => $this->advancedV3?->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
1827
app/Services/Cannaiq/BrandAnalysisService.php
Normal file
1827
app/Services/Cannaiq/BrandAnalysisService.php
Normal file
File diff suppressed because it is too large
Load Diff
571
app/Services/Cannaiq/CannaiqClient.php
Normal file
571
app/Services/Cannaiq/CannaiqClient.php
Normal file
@@ -0,0 +1,571 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Cannaiq;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* CannaiQ API Client
|
||||
*
|
||||
* Connects to the CannaiQ Marketing Intelligence API to fetch:
|
||||
* - Store metrics (pricing position, market share, trends)
|
||||
* - Product metrics (velocity, pricing history, competitor positioning)
|
||||
* - Competitor snapshots (out-of-stock, pricing, promotions)
|
||||
*
|
||||
* API Base URL: https://cannaiq.co/api/v1
|
||||
* Authentication: X-API-Key header (trusted origins and localhost bypass auth)
|
||||
*/
|
||||
class CannaiqClient
|
||||
{
|
||||
protected PendingRequest $http;
|
||||
|
||||
protected string $baseUrl;
|
||||
|
||||
protected ?string $apiKey;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = config('services.cannaiq.base_url', 'https://cannaiq.co/api/v1');
|
||||
$this->apiKey = config('services.cannaiq.api_key');
|
||||
|
||||
$this->http = Http::baseUrl($this->baseUrl)
|
||||
->timeout(30)
|
||||
->retry(3, 100, function ($exception) {
|
||||
return $exception instanceof \Illuminate\Http\Client\ConnectionException;
|
||||
})
|
||||
->withHeaders($this->getHeaders());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get headers for API requests
|
||||
*/
|
||||
protected function getHeaders(): array
|
||||
{
|
||||
$headers = [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
|
||||
// Add API key if configured (not needed for trusted origins *.cannabrands.app)
|
||||
if ($this->apiKey) {
|
||||
$headers['X-API-Key'] = $this->apiKey;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all stores (paginated)
|
||||
*
|
||||
* @param int $limit Number of stores to return
|
||||
* @param int $offset Pagination offset
|
||||
*/
|
||||
public function listStores(int $limit = 50, int $offset = 0): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get('/stores', [
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to list stores', [
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to list stores', 'stores' => []];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception listing stores', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage(), 'stores' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store details
|
||||
*
|
||||
* @param string $storeId CannaiQ store ID
|
||||
*/
|
||||
public function getStore(string $storeId): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/stores/{$storeId}");
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch store', [
|
||||
'store_id' => $storeId,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch store'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching store', [
|
||||
'store_id' => $storeId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store performance metrics
|
||||
* Returns: product counts, brands, categories, price stats, stock health
|
||||
*
|
||||
* @param string $storeId CannaiQ store ID
|
||||
*/
|
||||
public function getStoreMetrics(string $storeId): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/stores/{$storeId}/metrics");
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch store metrics', [
|
||||
'store_id' => $storeId,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch store metrics'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching store metrics', [
|
||||
'store_id' => $storeId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store's product catalog
|
||||
*
|
||||
* @param string $storeId CannaiQ store ID
|
||||
* @param int $limit Number of products to return
|
||||
* @param int $offset Pagination offset
|
||||
*/
|
||||
public function getStoreProducts(string $storeId, int $limit = 100, int $offset = 0): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/stores/{$storeId}/products", [
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch store products', [
|
||||
'store_id' => $storeId,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch store products', 'products' => []];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching store products', [
|
||||
'store_id' => $storeId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage(), 'products' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product-level metrics with price changes since last crawl
|
||||
*
|
||||
* @param string $storeId CannaiQ store ID
|
||||
* @param int $limit Number of products to return
|
||||
* @param int $offset Pagination offset
|
||||
*/
|
||||
public function getStoreProductMetrics(string $storeId, int $limit = 100, int $offset = 0): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/stores/{$storeId}/product-metrics", [
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch product metrics', [
|
||||
'store_id' => $storeId,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch product metrics', 'products' => []];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching product metrics', [
|
||||
'store_id' => $storeId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage(), 'products' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get competitor snapshot for a store
|
||||
* Returns: nearby competitors, price comparisons, brand overlap
|
||||
*
|
||||
* @param string $storeId CannaiQ store ID
|
||||
*/
|
||||
public function getStoreCompetitorSnapshot(string $storeId): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/stores/{$storeId}/competitor-snapshot");
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch competitor snapshot', [
|
||||
'store_id' => $storeId,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch competitor snapshot'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching competitor snapshot', [
|
||||
'store_id' => $storeId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search products across all stores
|
||||
*
|
||||
* @param string $query Search query
|
||||
* @param array $filters Optional filters (category, brand, etc)
|
||||
*/
|
||||
public function searchProducts(string $query, array $filters = []): array
|
||||
{
|
||||
try {
|
||||
$params = array_merge(['q' => $query, 'limit' => 50], $filters);
|
||||
$response = $this->http->get('/products', $params);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
return ['error' => true, 'message' => 'Search failed', 'products' => []];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception searching products', [
|
||||
'query' => $query,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage(), 'products' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product details
|
||||
*
|
||||
* @param int|string $productId CannaiQ product ID
|
||||
*/
|
||||
public function getProduct(int|string $productId): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/products/{$productId}");
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch product'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching product', [
|
||||
'product_id' => $productId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product price/stock history
|
||||
*
|
||||
* @param int|string $productId CannaiQ product ID
|
||||
*/
|
||||
public function getProductHistory(int|string $productId): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/products/{$productId}/history");
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch product history', 'history' => []];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching product history', [
|
||||
'product_id' => $productId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage(), 'history' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all brands
|
||||
*/
|
||||
public function listBrands(): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get('/brands');
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to list brands', 'brands' => []];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception listing brands', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage(), 'brands' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brand details and products
|
||||
*
|
||||
* @param string $brandName Brand name/slug
|
||||
*/
|
||||
public function getBrand(string $brandName): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/brands/{$brandName}");
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch brand'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching brand', [
|
||||
'brand' => $brandName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all categories
|
||||
*/
|
||||
public function listCategories(): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get('/categories');
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to list categories', 'categories' => []];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception listing categories', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage(), 'categories' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check API health (full system health)
|
||||
*/
|
||||
public function healthCheck(): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get('/health/full');
|
||||
|
||||
return [
|
||||
'healthy' => $response->successful(),
|
||||
'status' => $response->status(),
|
||||
'data' => $response->json(),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'healthy' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check basic API health
|
||||
*/
|
||||
public function ping(): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get('/health');
|
||||
|
||||
return [
|
||||
'healthy' => $response->successful(),
|
||||
'data' => $response->json(),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'healthy' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Brand Analytics API Endpoints (v1.5)
|
||||
// These endpoints provide brand-level intelligence
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get brand-level metrics including whitespace and regional penetration
|
||||
*
|
||||
* @param string $brandName Brand name/slug
|
||||
*/
|
||||
public function getBrandMetrics(string $brandName): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/brands/{$brandName}/metrics");
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch brand metrics', [
|
||||
'brand' => $brandName,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch brand metrics'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching brand metrics', [
|
||||
'brand' => $brandName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get competitor analysis for a brand
|
||||
* Returns: head-to-head comparisons, market share trends, price position
|
||||
*
|
||||
* @param string $brandName Brand name/slug
|
||||
* @param array $options Optional parameters (top_n, etc)
|
||||
*/
|
||||
public function getBrandCompetitors(string $brandName, array $options = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/brands/{$brandName}/competitors", $options);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch brand competitors', [
|
||||
'brand' => $brandName,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch brand competitors'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching brand competitors', [
|
||||
'brand' => $brandName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get promotion performance metrics for a brand
|
||||
* Returns: velocity lift, baseline vs promo velocity, efficiency scores
|
||||
*
|
||||
* @param string $brandName Brand name/slug
|
||||
* @param array $options Optional parameters (from, to date range)
|
||||
*/
|
||||
public function getBrandPromoMetrics(string $brandName, array $options = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/brands/{$brandName}/promo-metrics", $options);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch brand promo metrics', [
|
||||
'brand' => $brandName,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch brand promo metrics'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching brand promo metrics', [
|
||||
'brand' => $brandName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slippage/churn metrics for a brand
|
||||
* Returns: lost stores, lost SKUs, competitor takeovers, OOS metrics
|
||||
*
|
||||
* @param string $brandName Brand name/slug
|
||||
* @param array $options Optional parameters (days_back, etc)
|
||||
*/
|
||||
public function getBrandSlippage(string $brandName, array $options = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/brands/{$brandName}/slippage", $options);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch brand slippage', [
|
||||
'brand' => $brandName,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch brand slippage'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching brand slippage', [
|
||||
'brand' => $brandName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Models\Crm\CrmMessageAttachment;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* CRM Channel Service - Omnichannel messaging
|
||||
@@ -131,6 +132,215 @@ class CrmChannelService
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive an inbound SMS message.
|
||||
*
|
||||
* This method resolves business/channel by the receiving phone number,
|
||||
* finds or creates a thread, and stores the message in the CRM system.
|
||||
*
|
||||
* Provider-agnostic: works with any SMS gateway that normalizes to this format.
|
||||
*
|
||||
* @param array $payload Expected keys:
|
||||
* - provider: SMS gateway identifier (e.g., 'gateway', 'twilio')
|
||||
* - to_number: our phone number (identifies business/channel)
|
||||
* - from_number: sender phone number
|
||||
* - body: message text
|
||||
* - external_message_id: unique message ID from gateway (optional)
|
||||
* - meta: raw webhook payload (optional)
|
||||
* - attachments: array of attachment metadata (optional)
|
||||
*/
|
||||
public function receiveInboundSms(array $payload): ?CrmChannelMessage
|
||||
{
|
||||
$provider = $payload['provider'] ?? 'gateway';
|
||||
$toNumber = $payload['to_number'] ?? null;
|
||||
$fromNumber = $payload['from_number'] ?? null;
|
||||
$body = $payload['body'] ?? '';
|
||||
$externalMessageId = $payload['external_message_id'] ?? null;
|
||||
|
||||
if (! $toNumber || ! $fromNumber) {
|
||||
Log::warning('receiveInboundSms: Missing to_number or from_number');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1) Find CRM channel by phone number
|
||||
$channel = $this->findChannelByPhoneNumber($toNumber);
|
||||
|
||||
if (! $channel) {
|
||||
Log::info("receiveInboundSms: No CRM channel found for {$toNumber}");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$businessId = $channel->business_id;
|
||||
|
||||
// 2) Find contact by phone number
|
||||
$contact = $this->findContactByAddress($businessId, $fromNumber, CrmChannel::TYPE_SMS);
|
||||
|
||||
// 3) Find or create thread
|
||||
$thread = $this->findOrCreateSmsThread($businessId, $channel, $contact, $fromNumber);
|
||||
|
||||
// 4) Check for duplicate using external_message_id
|
||||
if ($externalMessageId) {
|
||||
$existing = CrmChannelMessage::where('external_id', $externalMessageId)->first();
|
||||
if ($existing) {
|
||||
Log::info("receiveInboundSms: Duplicate message {$externalMessageId}");
|
||||
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Create message
|
||||
$message = CrmChannelMessage::create([
|
||||
'business_id' => $businessId,
|
||||
'thread_id' => $thread->id,
|
||||
'channel_id' => $channel->id,
|
||||
'contact_id' => $contact?->id,
|
||||
'channel_type' => CrmChannel::TYPE_SMS,
|
||||
'direction' => CrmChannelMessage::DIRECTION_INBOUND,
|
||||
'external_id' => $externalMessageId,
|
||||
'from_address' => $fromNumber,
|
||||
'to_address' => $toNumber,
|
||||
'body' => $body,
|
||||
'status' => CrmChannelMessage::STATUS_DELIVERED,
|
||||
'delivered_at' => now(),
|
||||
'metadata' => [
|
||||
'provider' => $provider,
|
||||
'attachments' => $payload['attachments'] ?? [],
|
||||
'raw' => $payload['meta'] ?? [],
|
||||
],
|
||||
]);
|
||||
|
||||
// 6) Update thread
|
||||
$thread->update([
|
||||
'last_message_at' => now(),
|
||||
'last_message_direction' => CrmChannelMessage::DIRECTION_INBOUND,
|
||||
'last_message_preview' => Str::limit($body, 100),
|
||||
'last_channel_type' => CrmChannel::TYPE_SMS,
|
||||
'is_read' => false,
|
||||
'read_at' => null,
|
||||
'read_by' => null,
|
||||
]);
|
||||
|
||||
// Reopen if closed
|
||||
if ($thread->status === CrmThread::STATUS_CLOSED) {
|
||||
$thread->update(['status' => CrmThread::STATUS_OPEN]);
|
||||
}
|
||||
|
||||
// 7) Trigger automations
|
||||
app(CrmAutomationService::class)->trigger('message_received', [
|
||||
'business_id' => $businessId,
|
||||
'message' => $message,
|
||||
'thread' => $thread,
|
||||
'contact' => $contact,
|
||||
'channel_type' => CrmChannel::TYPE_SMS,
|
||||
]);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find CRM channel by phone number.
|
||||
*
|
||||
* Checks the channel identifier and config for matching phone numbers.
|
||||
*/
|
||||
protected function findChannelByPhoneNumber(string $phoneNumber): ?CrmChannel
|
||||
{
|
||||
// Normalize phone number
|
||||
$normalized = preg_replace('/[^0-9]/', '', $phoneNumber);
|
||||
$withPlus = '+'.ltrim($phoneNumber, '+');
|
||||
|
||||
// First check identifier directly
|
||||
$channel = CrmChannel::where('type', CrmChannel::TYPE_SMS)
|
||||
->where('is_active', true)
|
||||
->where(function ($q) use ($phoneNumber, $normalized, $withPlus) {
|
||||
$q->where('identifier', $phoneNumber)
|
||||
->orWhere('identifier', $normalized)
|
||||
->orWhere('identifier', $withPlus);
|
||||
})
|
||||
->first();
|
||||
|
||||
if ($channel) {
|
||||
return $channel;
|
||||
}
|
||||
|
||||
// Check config JSON for phone_number
|
||||
// This is a fallback for channels that store phone in config
|
||||
$channels = CrmChannel::where('type', CrmChannel::TYPE_SMS)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
foreach ($channels as $ch) {
|
||||
$config = $ch->config ?? [];
|
||||
$configPhone = $config['phone_number'] ?? $config['from_number'] ?? null;
|
||||
|
||||
if ($configPhone) {
|
||||
$configNormalized = preg_replace('/[^0-9]/', '', $configPhone);
|
||||
if ($configNormalized === $normalized) {
|
||||
return $ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create a thread for SMS conversation.
|
||||
*/
|
||||
protected function findOrCreateSmsThread(
|
||||
int $businessId,
|
||||
CrmChannel $channel,
|
||||
?Contact $contact,
|
||||
string $fromNumber
|
||||
): CrmThread {
|
||||
// Try to find existing open thread with same contact or phone
|
||||
if ($contact) {
|
||||
$thread = CrmThread::forBusiness($businessId)
|
||||
->where('contact_id', $contact->id)
|
||||
->whereIn('status', [CrmThread::STATUS_OPEN, CrmThread::STATUS_SNOOZED])
|
||||
->where('last_channel_type', CrmChannel::TYPE_SMS)
|
||||
->orderBy('last_message_at', 'desc')
|
||||
->first();
|
||||
|
||||
if ($thread) {
|
||||
return $thread;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find by stored phone number in metadata
|
||||
$thread = CrmThread::forBusiness($businessId)
|
||||
->whereIn('status', [CrmThread::STATUS_OPEN, CrmThread::STATUS_SNOOZED])
|
||||
->where('last_channel_type', CrmChannel::TYPE_SMS)
|
||||
->whereHas('messages', function ($q) use ($fromNumber) {
|
||||
$q->where('from_address', $fromNumber)
|
||||
->where('direction', CrmChannelMessage::DIRECTION_INBOUND);
|
||||
})
|
||||
->orderBy('last_message_at', 'desc')
|
||||
->first();
|
||||
|
||||
if ($thread) {
|
||||
// Update contact_id if we now have a contact
|
||||
if ($contact && ! $thread->contact_id) {
|
||||
$thread->update(['contact_id' => $contact->id]);
|
||||
}
|
||||
|
||||
return $thread;
|
||||
}
|
||||
|
||||
// Create new thread
|
||||
return CrmThread::create([
|
||||
'business_id' => $businessId,
|
||||
'contact_id' => $contact?->id,
|
||||
'account_id' => $contact?->buyer_business_id,
|
||||
'status' => CrmThread::STATUS_OPEN,
|
||||
'priority' => CrmThread::PRIORITY_NORMAL,
|
||||
'last_channel_type' => CrmChannel::TYPE_SMS,
|
||||
'last_message_at' => now(),
|
||||
'last_message_direction' => CrmChannelMessage::DIRECTION_INBOUND,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually send a message through the provider
|
||||
*/
|
||||
|
||||
341
app/Services/Email/InboundEmailService.php
Normal file
341
app/Services/Email/InboundEmailService.php
Normal file
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Email;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessEmailIdentity;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Crm\CrmChannel;
|
||||
use App\Models\Crm\CrmChannelMessage;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use App\Services\Crm\CrmAutomationService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* InboundEmailService - Handles incoming emails for CRM
|
||||
*
|
||||
* This service receives normalized email payloads from provider-specific
|
||||
* webhook controllers and routes them to the appropriate CRM threads.
|
||||
*
|
||||
* It does NOT handle SMTP credentials or sending - that's EmailSender's job.
|
||||
*/
|
||||
class InboundEmailService
|
||||
{
|
||||
/**
|
||||
* Handle an inbound email.
|
||||
*
|
||||
* Expected payload keys:
|
||||
* - provider: 'sendgrid' | 'postmark' | 'ses'
|
||||
* - from_email: sender email address
|
||||
* - from_name: sender display name (optional)
|
||||
* - to_email: recipient email address (our identity)
|
||||
* - subject: email subject
|
||||
* - text_body: plain text body
|
||||
* - html_body: HTML body (optional)
|
||||
* - message_id: email Message-ID header
|
||||
* - in_reply_to: In-Reply-To header (optional)
|
||||
* - references: References header (optional)
|
||||
* - headers: all headers as associative array (optional)
|
||||
* - attachments: array of attachment metadata (optional)
|
||||
*/
|
||||
public function handleInbound(array $payload): ?CrmChannelMessage
|
||||
{
|
||||
$toEmail = strtolower(trim($payload['to_email'] ?? ''));
|
||||
|
||||
if (! $toEmail) {
|
||||
Log::warning('InboundEmailService: No to_email in payload');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1) Resolve business + email identity
|
||||
$identity = BusinessEmailIdentity::findByEmail($toEmail);
|
||||
|
||||
if (! $identity) {
|
||||
Log::info("InboundEmailService: No identity found for {$toEmail}");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$business = $identity->business;
|
||||
|
||||
if (! $business) {
|
||||
Log::warning("InboundEmailService: Identity {$identity->id} has no business");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2) Get or create CRM channel
|
||||
$channel = $identity->getOrCreateChannel();
|
||||
|
||||
// 3) Find contact by from_email
|
||||
$fromEmail = strtolower(trim($payload['from_email'] ?? ''));
|
||||
$contact = $this->findOrCreateContact($business, $fromEmail, $payload['from_name'] ?? null);
|
||||
|
||||
// 4) Find or create thread (using In-Reply-To / Message-ID / contact)
|
||||
$thread = $this->findOrCreateThread($business, $channel, $contact, $payload);
|
||||
|
||||
// 5) Store the inbound message
|
||||
$message = $this->storeInboundMessage($thread, $channel, $contact, $payload);
|
||||
|
||||
// 6) Update identity last received timestamp
|
||||
$identity->recordReceived();
|
||||
|
||||
// 7) Trigger automations
|
||||
app(CrmAutomationService::class)->trigger('message_received', [
|
||||
'business_id' => $business->id,
|
||||
'message' => $message,
|
||||
'thread' => $thread,
|
||||
'contact' => $contact,
|
||||
'channel_type' => CrmChannel::TYPE_EMAIL,
|
||||
]);
|
||||
|
||||
Log::info("InboundEmailService: Created message {$message->id} in thread {$thread->id} for {$toEmail}");
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create a contact from the sender's email.
|
||||
*/
|
||||
protected function findOrCreateContact(Business $business, string $email, ?string $name): ?Contact
|
||||
{
|
||||
if (! $email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// First try to find existing contact
|
||||
$contact = Contact::where('business_id', $business->id)
|
||||
->where('email', $email)
|
||||
->first();
|
||||
|
||||
if ($contact) {
|
||||
return $contact;
|
||||
}
|
||||
|
||||
// Parse name into first/last
|
||||
$firstName = null;
|
||||
$lastName = null;
|
||||
|
||||
if ($name) {
|
||||
$parts = explode(' ', trim($name), 2);
|
||||
$firstName = $parts[0] ?? null;
|
||||
$lastName = $parts[1] ?? null;
|
||||
}
|
||||
|
||||
// Create new contact
|
||||
return Contact::create([
|
||||
'business_id' => $business->id,
|
||||
'email' => $email,
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
'source' => 'email_inbound',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find existing thread or create a new one.
|
||||
*
|
||||
* Threading logic:
|
||||
* 1. Check In-Reply-To header → match to existing message's external_id
|
||||
* 2. Check References header → match to any message's external_id
|
||||
* 3. Check Message-ID → ensure we haven't already processed this email
|
||||
* 4. Find open thread for same contact on same channel
|
||||
* 5. Create new thread if no match
|
||||
*/
|
||||
protected function findOrCreateThread(
|
||||
Business $business,
|
||||
CrmChannel $channel,
|
||||
?Contact $contact,
|
||||
array $payload
|
||||
): CrmThread {
|
||||
$messageId = $payload['message_id'] ?? null;
|
||||
$inReplyTo = $payload['in_reply_to'] ?? null;
|
||||
$references = $payload['references'] ?? null;
|
||||
|
||||
// 1) Check In-Reply-To header
|
||||
if ($inReplyTo) {
|
||||
$thread = $this->findThreadByMessageId($business->id, $inReplyTo);
|
||||
if ($thread) {
|
||||
$this->ensureThreadHasChannelInfo($thread, $channel);
|
||||
|
||||
return $thread;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Check References header (can be multiple message IDs)
|
||||
if ($references) {
|
||||
$refIds = is_array($references) ? $references : preg_split('/\s+/', $references);
|
||||
foreach ($refIds as $refId) {
|
||||
$thread = $this->findThreadByMessageId($business->id, trim($refId));
|
||||
if ($thread) {
|
||||
$this->ensureThreadHasChannelInfo($thread, $channel);
|
||||
|
||||
return $thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Check if we already have this exact message (dedup)
|
||||
if ($messageId) {
|
||||
$existingMessage = CrmChannelMessage::where('business_id', $business->id)
|
||||
->where('external_id', $messageId)
|
||||
->first();
|
||||
|
||||
if ($existingMessage && $existingMessage->thread) {
|
||||
$this->ensureThreadHasChannelInfo($existingMessage->thread, $channel);
|
||||
|
||||
return $existingMessage->thread;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Find open thread for same contact on email channel
|
||||
if ($contact) {
|
||||
$thread = CrmThread::forBusiness($business->id)
|
||||
->where('contact_id', $contact->id)
|
||||
->where('last_channel_type', CrmChannel::TYPE_EMAIL)
|
||||
->whereIn('status', [CrmThread::STATUS_OPEN, CrmThread::STATUS_SNOOZED])
|
||||
->orderBy('last_message_at', 'desc')
|
||||
->first();
|
||||
|
||||
if ($thread) {
|
||||
$this->ensureThreadHasChannelInfo($thread, $channel);
|
||||
|
||||
return $thread;
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Find brand associated with this channel (if any)
|
||||
$brandId = $this->findBrandForChannel($business->id, $channel->id);
|
||||
|
||||
// 6) Create new thread
|
||||
return CrmThread::create([
|
||||
'business_id' => $business->id,
|
||||
'brand_id' => $brandId,
|
||||
'channel_id' => $channel->id,
|
||||
'department' => $channel->department,
|
||||
'contact_id' => $contact?->id,
|
||||
'account_id' => $contact?->buyer_business_id,
|
||||
'subject' => $payload['subject'] ?? null,
|
||||
'status' => CrmThread::STATUS_OPEN,
|
||||
'priority' => CrmThread::PRIORITY_NORMAL,
|
||||
'last_channel_type' => CrmChannel::TYPE_EMAIL,
|
||||
'last_message_at' => now(),
|
||||
'last_message_direction' => CrmChannelMessage::DIRECTION_INBOUND,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the brand associated with a channel (via inbound_email_channel_id).
|
||||
*/
|
||||
protected function findBrandForChannel(int $businessId, int $channelId): ?int
|
||||
{
|
||||
$brand = Brand::where('business_id', $businessId)
|
||||
->where('inbound_email_channel_id', $channelId)
|
||||
->first();
|
||||
|
||||
return $brand?->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an existing thread has channel info set (backfill for older threads).
|
||||
*/
|
||||
protected function ensureThreadHasChannelInfo(CrmThread $thread, CrmChannel $channel): void
|
||||
{
|
||||
$updates = [];
|
||||
|
||||
// Set channel_id if missing
|
||||
if (! $thread->channel_id) {
|
||||
$updates['channel_id'] = $channel->id;
|
||||
}
|
||||
|
||||
// Set department if missing
|
||||
if (! $thread->department && $channel->department) {
|
||||
$updates['department'] = $channel->department;
|
||||
}
|
||||
|
||||
// Set brand_id if missing and channel has associated brand
|
||||
if (! $thread->brand_id) {
|
||||
$brandId = $this->findBrandForChannel($thread->business_id, $channel->id);
|
||||
if ($brandId) {
|
||||
$updates['brand_id'] = $brandId;
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($updates)) {
|
||||
$thread->update($updates);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find thread by a message's external_id (Message-ID header).
|
||||
*/
|
||||
protected function findThreadByMessageId(int $businessId, string $messageId): ?CrmThread
|
||||
{
|
||||
$message = CrmChannelMessage::where('business_id', $businessId)
|
||||
->where(function ($q) use ($messageId) {
|
||||
$q->where('external_id', $messageId)
|
||||
->orWhereJsonContains('metadata->message_id', $messageId);
|
||||
})
|
||||
->first();
|
||||
|
||||
return $message?->thread;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the inbound email as a CRM message.
|
||||
*/
|
||||
protected function storeInboundMessage(
|
||||
CrmThread $thread,
|
||||
CrmChannel $channel,
|
||||
?Contact $contact,
|
||||
array $payload
|
||||
): CrmChannelMessage {
|
||||
$message = CrmChannelMessage::create([
|
||||
'business_id' => $thread->business_id,
|
||||
'thread_id' => $thread->id,
|
||||
'channel_id' => $channel->id,
|
||||
'contact_id' => $contact?->id,
|
||||
'channel_type' => CrmChannel::TYPE_EMAIL,
|
||||
'direction' => CrmChannelMessage::DIRECTION_INBOUND,
|
||||
'external_id' => $payload['message_id'] ?? null,
|
||||
'from_address' => $payload['from_email'] ?? null,
|
||||
'to_address' => $payload['to_email'] ?? null,
|
||||
'subject' => $payload['subject'] ?? null,
|
||||
'body' => $payload['text_body'] ?? strip_tags($payload['html_body'] ?? ''),
|
||||
'body_html' => $payload['html_body'] ?? null,
|
||||
'body_plain' => $payload['text_body'] ?? null,
|
||||
'status' => CrmChannelMessage::STATUS_DELIVERED,
|
||||
'delivered_at' => now(),
|
||||
'metadata' => [
|
||||
'provider' => $payload['provider'] ?? null,
|
||||
'message_id' => $payload['message_id'] ?? null,
|
||||
'in_reply_to' => $payload['in_reply_to'] ?? null,
|
||||
'references' => $payload['references'] ?? null,
|
||||
'headers' => $payload['headers'] ?? [],
|
||||
'attachments' => $payload['attachments'] ?? [],
|
||||
'from_name' => $payload['from_name'] ?? null,
|
||||
],
|
||||
]);
|
||||
|
||||
// Update thread
|
||||
$thread->update([
|
||||
'last_message_at' => now(),
|
||||
'last_message_direction' => CrmChannelMessage::DIRECTION_INBOUND,
|
||||
'last_message_preview' => Str::limit($message->body, 100),
|
||||
'last_channel_type' => CrmChannel::TYPE_EMAIL,
|
||||
'is_read' => false,
|
||||
'read_at' => null,
|
||||
'read_by' => null,
|
||||
]);
|
||||
|
||||
// Reopen thread if it was closed
|
||||
if ($thread->status === CrmThread::STATUS_CLOSED) {
|
||||
$thread->update(['status' => CrmThread::STATUS_OPEN]);
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
705
app/Services/Marketing/AutomationRunner.php
Normal file
705
app/Services/Marketing/AutomationRunner.php
Normal file
@@ -0,0 +1,705 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketing;
|
||||
|
||||
use App\Jobs\SendMarketingCampaignJob;
|
||||
use App\Models\Marketing\MarketingAutomation;
|
||||
use App\Models\Marketing\MarketingAutomationRun;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Services\Cannaiq\CannaiqClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* AutomationRunner - Evaluates and executes marketing automations.
|
||||
*
|
||||
* Handles condition evaluation based on CannaiQ data and local inventory,
|
||||
* then executes actions like creating promos and launching campaigns.
|
||||
*/
|
||||
class AutomationRunner
|
||||
{
|
||||
protected CannaiqClient $cannaiqClient;
|
||||
|
||||
protected array $runDetails = [];
|
||||
|
||||
public function __construct(CannaiqClient $cannaiqClient)
|
||||
{
|
||||
$this->cannaiqClient = $cannaiqClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an automation and return the run record.
|
||||
*/
|
||||
public function runAutomation(MarketingAutomation $automation): MarketingAutomationRun
|
||||
{
|
||||
$this->runDetails = [
|
||||
'automation_id' => $automation->id,
|
||||
'automation_name' => $automation->name,
|
||||
'trigger_type' => $automation->trigger_type,
|
||||
'condition_type' => $automation->condition_config['type'] ?? 'unknown',
|
||||
'started_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
// Start the run record
|
||||
$run = MarketingAutomationRun::start($automation);
|
||||
|
||||
try {
|
||||
// Evaluate conditions
|
||||
$evaluationResult = $this->evaluateConditions($automation);
|
||||
|
||||
if (! $evaluationResult['conditions_met']) {
|
||||
// Conditions not met - skip
|
||||
$run->skip(
|
||||
$evaluationResult['reason'] ?? 'Conditions not met',
|
||||
array_merge($this->runDetails, [
|
||||
'evaluation' => $evaluationResult,
|
||||
])
|
||||
);
|
||||
|
||||
$this->updateAutomationStatus($automation, 'skipped');
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
// Execute actions
|
||||
$actionResults = $this->executeActions($automation, $evaluationResult['context'] ?? []);
|
||||
|
||||
// Determine final status
|
||||
$hasErrors = collect($actionResults)->contains('success', false);
|
||||
$allFailed = collect($actionResults)->every('success', false);
|
||||
|
||||
if ($allFailed) {
|
||||
$run->fail(
|
||||
'All actions failed',
|
||||
array_merge($this->runDetails, [
|
||||
'evaluation' => $evaluationResult,
|
||||
'actions' => $actionResults,
|
||||
])
|
||||
);
|
||||
$this->updateAutomationStatus($automation, 'error');
|
||||
} elseif ($hasErrors) {
|
||||
$run->partial(
|
||||
$this->buildSummary($actionResults),
|
||||
array_merge($this->runDetails, [
|
||||
'evaluation' => $evaluationResult,
|
||||
'actions' => $actionResults,
|
||||
])
|
||||
);
|
||||
$this->updateAutomationStatus($automation, 'partial');
|
||||
} else {
|
||||
$run->succeed(
|
||||
$this->buildSummary($actionResults),
|
||||
array_merge($this->runDetails, [
|
||||
'evaluation' => $evaluationResult,
|
||||
'actions' => $actionResults,
|
||||
])
|
||||
);
|
||||
$this->updateAutomationStatus($automation, 'success');
|
||||
}
|
||||
|
||||
return $run;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('AutomationRunner: Exception during automation run', [
|
||||
'automation_id' => $automation->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$run->fail(
|
||||
'Exception: '.$e->getMessage(),
|
||||
array_merge($this->runDetails, [
|
||||
'error' => $e->getMessage(),
|
||||
'exception_class' => get_class($e),
|
||||
])
|
||||
);
|
||||
|
||||
$this->updateAutomationStatus($automation, 'error');
|
||||
|
||||
return $run;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate conditions based on condition_config type.
|
||||
*/
|
||||
protected function evaluateConditions(MarketingAutomation $automation): array
|
||||
{
|
||||
$conditionConfig = $automation->condition_config;
|
||||
$conditionType = $conditionConfig['type'] ?? 'unknown';
|
||||
|
||||
return match ($conditionType) {
|
||||
MarketingAutomation::CONDITION_COMPETITOR_OUT_OF_STOCK => $this->evaluateCompetitorOutOfStock($automation),
|
||||
MarketingAutomation::CONDITION_SLOW_MOVER_CLEARANCE => $this->evaluateSlowMoverClearance($automation),
|
||||
MarketingAutomation::CONDITION_NEW_STORE_LAUNCH => $this->evaluateNewStoreLaunch($automation),
|
||||
default => [
|
||||
'conditions_met' => false,
|
||||
'reason' => "Unknown condition type: {$conditionType}",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate: Competitor is out of stock and we have inventory.
|
||||
*/
|
||||
protected function evaluateCompetitorOutOfStock(MarketingAutomation $automation): array
|
||||
{
|
||||
$config = $automation->condition_config;
|
||||
$triggerConfig = $automation->trigger_config;
|
||||
|
||||
$category = $config['category'] ?? null;
|
||||
$minInventory = $config['min_inventory_units'] ?? 30;
|
||||
$minPriceAdvantage = $config['min_price_advantage'] ?? 0.1;
|
||||
|
||||
$storeScope = $triggerConfig['store_scope'] ?? 'all';
|
||||
$brandIds = $triggerConfig['brand_ids'] ?? [];
|
||||
|
||||
// Get store external IDs to check
|
||||
$storeIds = $this->getStoreExternalIds($automation->business_id, $storeScope);
|
||||
|
||||
if (empty($storeIds)) {
|
||||
return [
|
||||
'conditions_met' => false,
|
||||
'reason' => 'No stores found for this business',
|
||||
];
|
||||
}
|
||||
|
||||
$opportunities = [];
|
||||
|
||||
foreach ($storeIds as $storeId) {
|
||||
try {
|
||||
// Get competitor snapshot from CannaiQ
|
||||
$competitorData = $this->cannaiqClient->getStoreCompetitorSnapshot($storeId);
|
||||
|
||||
if (! empty($competitorData['error'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for out-of-stock competitors in our category
|
||||
$outOfStockCompetitors = $this->findOutOfStockCompetitors(
|
||||
$competitorData,
|
||||
$category,
|
||||
$minInventory,
|
||||
$minPriceAdvantage
|
||||
);
|
||||
|
||||
if (! empty($outOfStockCompetitors)) {
|
||||
$opportunities[] = [
|
||||
'store_id' => $storeId,
|
||||
'competitors' => $outOfStockCompetitors,
|
||||
'category' => $category,
|
||||
];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('AutomationRunner: Error checking store', [
|
||||
'store_id' => $storeId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($opportunities)) {
|
||||
return [
|
||||
'conditions_met' => false,
|
||||
'reason' => 'No competitor out-of-stock opportunities found',
|
||||
'stores_checked' => count($storeIds),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'conditions_met' => true,
|
||||
'reason' => count($opportunities).' store(s) have competitor out-of-stock opportunities',
|
||||
'context' => [
|
||||
'opportunities' => $opportunities,
|
||||
'category' => $category,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate: Slow-moving inventory that needs clearance.
|
||||
*/
|
||||
protected function evaluateSlowMoverClearance(MarketingAutomation $automation): array
|
||||
{
|
||||
$config = $automation->condition_config;
|
||||
|
||||
$velocityThreshold = $config['velocity_30d_threshold'] ?? 5;
|
||||
$minInventory = $config['min_inventory_units'] ?? 50;
|
||||
$minDaysInStock = $config['min_days_in_stock'] ?? 30;
|
||||
|
||||
// Get slow movers from CannaiQ or local inventory
|
||||
// For now, we'll stub this with CannaiQ product metrics
|
||||
$storeIds = $this->getStoreExternalIds($automation->business_id, 'all');
|
||||
|
||||
$slowMovers = [];
|
||||
|
||||
foreach ($storeIds as $storeId) {
|
||||
try {
|
||||
$productMetrics = $this->cannaiqClient->getStoreProductMetrics($storeId, 100, 0);
|
||||
|
||||
if (! empty($productMetrics['error'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$products = $productMetrics['products'] ?? [];
|
||||
|
||||
foreach ($products as $product) {
|
||||
$velocity = $product['velocity_30d'] ?? 0;
|
||||
$inventory = $product['inventory_units'] ?? 0;
|
||||
$daysInStock = $product['days_in_stock'] ?? 0;
|
||||
|
||||
if ($velocity < $velocityThreshold
|
||||
&& $inventory >= $minInventory
|
||||
&& $daysInStock >= $minDaysInStock) {
|
||||
$slowMovers[] = [
|
||||
'store_id' => $storeId,
|
||||
'product_id' => $product['id'] ?? null,
|
||||
'product_name' => $product['name'] ?? 'Unknown',
|
||||
'velocity_30d' => $velocity,
|
||||
'inventory' => $inventory,
|
||||
'days_in_stock' => $daysInStock,
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('AutomationRunner: Error checking slow movers', [
|
||||
'store_id' => $storeId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($slowMovers)) {
|
||||
return [
|
||||
'conditions_met' => false,
|
||||
'reason' => 'No slow-moving products found matching criteria',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'conditions_met' => true,
|
||||
'reason' => count($slowMovers).' slow-moving product(s) found',
|
||||
'context' => [
|
||||
'slow_movers' => $slowMovers,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate: New store launch (first appearance of brand at a store).
|
||||
*/
|
||||
protected function evaluateNewStoreLaunch(MarketingAutomation $automation): array
|
||||
{
|
||||
$config = $automation->condition_config;
|
||||
$windowDays = $config['first_appearance_window_days'] ?? 7;
|
||||
|
||||
// Check for new store appearances in CannaiQ
|
||||
// This would typically involve comparing current store list vs. cached/stored list
|
||||
// For now, stub this with a simplified check
|
||||
|
||||
$storeIds = $this->getStoreExternalIds($automation->business_id, 'all');
|
||||
|
||||
// Get previously known stores from meta or a tracking table
|
||||
$knownStores = $automation->meta['known_stores'] ?? [];
|
||||
|
||||
$newStores = array_diff($storeIds, $knownStores);
|
||||
|
||||
if (empty($newStores)) {
|
||||
return [
|
||||
'conditions_met' => false,
|
||||
'reason' => 'No new store appearances found',
|
||||
];
|
||||
}
|
||||
|
||||
// Update known stores in meta
|
||||
$automation->update([
|
||||
'meta' => array_merge($automation->meta ?? [], [
|
||||
'known_stores' => $storeIds,
|
||||
'last_store_check' => now()->toIso8601String(),
|
||||
]),
|
||||
]);
|
||||
|
||||
return [
|
||||
'conditions_met' => true,
|
||||
'reason' => count($newStores).' new store(s) detected',
|
||||
'context' => [
|
||||
'new_stores' => array_values($newStores),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute actions based on action_config.
|
||||
*/
|
||||
protected function executeActions(MarketingAutomation $automation, array $context): array
|
||||
{
|
||||
$actionConfig = $automation->action_config;
|
||||
$results = [];
|
||||
|
||||
// Create promo if configured
|
||||
if (! empty($actionConfig['create_promo'])) {
|
||||
$results['promo'] = $this->createPromo($automation, $actionConfig['create_promo'], $context);
|
||||
}
|
||||
|
||||
// Create campaign if configured
|
||||
if (! empty($actionConfig['create_campaign'])) {
|
||||
$promoId = $results['promo']['promo_id'] ?? null;
|
||||
$results['campaign'] = $this->createCampaign(
|
||||
$automation,
|
||||
$actionConfig['create_campaign'],
|
||||
$context,
|
||||
$promoId
|
||||
);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a promo based on action config.
|
||||
*/
|
||||
protected function createPromo(MarketingAutomation $automation, array $promoConfig, array $context): array
|
||||
{
|
||||
try {
|
||||
$promoType = $promoConfig['promo_type'] ?? 'flash_bogo';
|
||||
$durationHours = $promoConfig['duration_hours'] ?? 24;
|
||||
$discountPercent = $promoConfig['discount_percent'] ?? null;
|
||||
|
||||
// Map automation promo types to MarketingPromo types
|
||||
$typeMapping = [
|
||||
'flash_bogo' => MarketingPromo::TYPE_BOGO,
|
||||
'percent_off' => MarketingPromo::TYPE_PERCENT_OFF,
|
||||
'clearance' => MarketingPromo::TYPE_PERCENT_OFF,
|
||||
'launch_special' => MarketingPromo::TYPE_BOGO,
|
||||
];
|
||||
|
||||
$promoTypeDb = $typeMapping[$promoType] ?? MarketingPromo::TYPE_PERCENT_OFF;
|
||||
|
||||
// Build promo name from context
|
||||
$promoName = $this->buildPromoName($automation, $context);
|
||||
|
||||
// Build config
|
||||
$config = [];
|
||||
if ($discountPercent) {
|
||||
$config['discount_value'] = $discountPercent;
|
||||
}
|
||||
|
||||
// Determine store scope from context
|
||||
$storeExternalId = null;
|
||||
if (! empty($context['opportunities'][0]['store_id'])) {
|
||||
$storeExternalId = $context['opportunities'][0]['store_id'];
|
||||
}
|
||||
|
||||
$promo = MarketingPromo::create([
|
||||
'business_id' => $automation->business_id,
|
||||
'store_external_id' => $storeExternalId,
|
||||
'name' => $promoName,
|
||||
'type' => $promoTypeDb,
|
||||
'config' => $config,
|
||||
'status' => MarketingPromo::STATUS_ACTIVE,
|
||||
'starts_at' => now(),
|
||||
'ends_at' => now()->addHours($durationHours),
|
||||
'description' => "Auto-generated by automation: {$automation->name}",
|
||||
'sms_copy' => $this->buildSmsCopy($automation, $context),
|
||||
'email_copy' => $this->buildEmailCopy($automation, $context),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'promo_id' => $promo->id,
|
||||
'promo_name' => $promo->name,
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('AutomationRunner: Failed to create promo', [
|
||||
'automation_id' => $automation->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a campaign based on action config.
|
||||
*/
|
||||
protected function createCampaign(
|
||||
MarketingAutomation $automation,
|
||||
array $campaignConfig,
|
||||
array $context,
|
||||
?int $promoId = null
|
||||
): array {
|
||||
try {
|
||||
$channels = $campaignConfig['channels'] ?? ['email'];
|
||||
$listType = $campaignConfig['list_type'] ?? 'consumers';
|
||||
$sendMode = $campaignConfig['send_mode'] ?? 'immediate';
|
||||
$subjectTemplate = $campaignConfig['subject_template'] ?? 'Special Offer';
|
||||
$smsBodyTemplate = $campaignConfig['sms_body_template'] ?? '';
|
||||
$emailBodyTemplate = $campaignConfig['email_body_template'] ?? '';
|
||||
|
||||
// Get or create a marketing list for this business
|
||||
$list = $this->getOrCreateMarketingList($automation->business_id, $listType);
|
||||
|
||||
if (! $list) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'No marketing list found for this business',
|
||||
];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($channels as $channel) {
|
||||
$campaignName = "{$automation->name} - ".ucfirst($channel).' - '.now()->format('M j, Y');
|
||||
|
||||
$campaignData = [
|
||||
'business_id' => $automation->business_id,
|
||||
'name' => $campaignName,
|
||||
'channel' => $channel,
|
||||
'status' => MarketingCampaign::STATUS_DRAFT,
|
||||
'marketing_list_id' => $list->id,
|
||||
'source_type' => MarketingCampaign::SOURCE_AUTOMATION,
|
||||
'source_id' => $automation->id,
|
||||
];
|
||||
|
||||
// Fill content based on channel
|
||||
if ($channel === 'email') {
|
||||
$campaignData['subject'] = $this->replacePlaceholders($subjectTemplate, $context);
|
||||
$campaignData['email_body_html'] = $this->replacePlaceholders($emailBodyTemplate, $context);
|
||||
} elseif ($channel === 'sms') {
|
||||
$campaignData['sms_body'] = $this->replacePlaceholders($smsBodyTemplate, $context);
|
||||
}
|
||||
|
||||
// Link to promo if available
|
||||
if ($promoId) {
|
||||
$campaignData['source_type'] = MarketingCampaign::SOURCE_PROMO;
|
||||
$campaignData['source_id'] = $promoId;
|
||||
}
|
||||
|
||||
$campaign = MarketingCampaign::create($campaignData);
|
||||
|
||||
// Handle send mode
|
||||
if ($sendMode === 'immediate') {
|
||||
$campaign->update(['status' => MarketingCampaign::STATUS_SENDING]);
|
||||
SendMarketingCampaignJob::dispatch($campaign);
|
||||
$results[$channel] = [
|
||||
'success' => true,
|
||||
'campaign_id' => $campaign->id,
|
||||
'status' => 'sending',
|
||||
];
|
||||
} elseif ($sendMode === 'schedule') {
|
||||
$scheduleOffset = $campaignConfig['schedule_offset_hours'] ?? 1;
|
||||
$campaign->schedule(now()->addHours($scheduleOffset));
|
||||
$results[$channel] = [
|
||||
'success' => true,
|
||||
'campaign_id' => $campaign->id,
|
||||
'status' => 'scheduled',
|
||||
'send_at' => $campaign->send_at->toIso8601String(),
|
||||
];
|
||||
} else {
|
||||
$results[$channel] = [
|
||||
'success' => true,
|
||||
'campaign_id' => $campaign->id,
|
||||
'status' => 'draft',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'campaigns' => $results,
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('AutomationRunner: Failed to create campaign', [
|
||||
'automation_id' => $automation->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store external IDs for a business.
|
||||
*/
|
||||
protected function getStoreExternalIds(int $businessId, string $scope): array
|
||||
{
|
||||
// Get stores from cannaiq_store_metrics table or configured store list
|
||||
// For now, we'll get from the CannaiQ cached data
|
||||
|
||||
$stores = \DB::table('cannaiq_store_metrics')
|
||||
->where('business_id', $businessId)
|
||||
->pluck('store_external_id')
|
||||
->unique()
|
||||
->toArray();
|
||||
|
||||
return $stores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find out-of-stock competitors from CannaiQ data.
|
||||
*/
|
||||
protected function findOutOfStockCompetitors(
|
||||
array $competitorData,
|
||||
?string $category,
|
||||
int $minInventory,
|
||||
float $minPriceAdvantage
|
||||
): array {
|
||||
$outOfStock = [];
|
||||
|
||||
// Parse competitor snapshot data
|
||||
$competitors = $competitorData['competitors'] ?? [];
|
||||
|
||||
foreach ($competitors as $competitor) {
|
||||
$competitorProducts = $competitor['products'] ?? [];
|
||||
|
||||
foreach ($competitorProducts as $product) {
|
||||
$productCategory = $product['category'] ?? null;
|
||||
|
||||
// Skip if category filter specified and doesn't match
|
||||
if ($category && $productCategory !== $category) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$competitorStock = $product['competitor_inventory'] ?? 0;
|
||||
$ourStock = $product['our_inventory'] ?? 0;
|
||||
$priceAdvantage = $product['price_advantage'] ?? 0;
|
||||
|
||||
// Check if competitor is out of stock and we have inventory
|
||||
if ($competitorStock === 0 && $ourStock >= $minInventory && $priceAdvantage >= $minPriceAdvantage) {
|
||||
$outOfStock[] = [
|
||||
'competitor_name' => $competitor['name'] ?? 'Unknown',
|
||||
'product_name' => $product['name'] ?? 'Unknown',
|
||||
'our_inventory' => $ourStock,
|
||||
'price_advantage' => $priceAdvantage,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $outOfStock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a marketing list for campaigns.
|
||||
*/
|
||||
protected function getOrCreateMarketingList(int $businessId, string $listType): ?MarketingList
|
||||
{
|
||||
// Try to find existing list
|
||||
$list = MarketingList::where('business_id', $businessId)
|
||||
->where('type', 'static')
|
||||
->first();
|
||||
|
||||
if ($list) {
|
||||
return $list;
|
||||
}
|
||||
|
||||
// Create a default list if none exists
|
||||
return MarketingList::create([
|
||||
'business_id' => $businessId,
|
||||
'name' => 'All Contacts',
|
||||
'type' => 'static',
|
||||
'description' => 'Auto-created list for automation campaigns',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build promo name from automation and context.
|
||||
*/
|
||||
protected function buildPromoName(MarketingAutomation $automation, array $context): string
|
||||
{
|
||||
$conditionType = $automation->condition_config['type'] ?? 'unknown';
|
||||
|
||||
return match ($conditionType) {
|
||||
MarketingAutomation::CONDITION_COMPETITOR_OUT_OF_STOCK => 'Flash Sale - '.now()->format('M j'),
|
||||
MarketingAutomation::CONDITION_SLOW_MOVER_CLEARANCE => 'Clearance - '.now()->format('M j'),
|
||||
MarketingAutomation::CONDITION_NEW_STORE_LAUNCH => 'Welcome Special - '.now()->format('M j'),
|
||||
default => $automation->name.' - '.now()->format('M j'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SMS copy from context.
|
||||
*/
|
||||
protected function buildSmsCopy(MarketingAutomation $automation, array $context): string
|
||||
{
|
||||
$template = $automation->action_config['create_campaign']['sms_body_template']
|
||||
?? '🔥 Special deal today! Visit us for exclusive savings.';
|
||||
|
||||
return $this->replacePlaceholders($template, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build email copy from context.
|
||||
*/
|
||||
protected function buildEmailCopy(MarketingAutomation $automation, array $context): string
|
||||
{
|
||||
$template = $automation->action_config['create_campaign']['email_body_template']
|
||||
?? '<p>Check out our latest special offers!</p>';
|
||||
|
||||
return $this->replacePlaceholders($template, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace placeholders in templates.
|
||||
*/
|
||||
protected function replacePlaceholders(string $template, array $context): string
|
||||
{
|
||||
$replacements = [
|
||||
'{store_name}' => $context['opportunities'][0]['store_id'] ?? 'our store',
|
||||
'{product_name}' => $context['opportunities'][0]['competitors'][0]['product_name']
|
||||
?? $context['slow_movers'][0]['product_name']
|
||||
?? 'featured products',
|
||||
'{promo_text}' => 'Limited time offer!',
|
||||
'{brand_name}' => 'Our Brand',
|
||||
'{category}' => $context['category'] ?? 'cannabis',
|
||||
];
|
||||
|
||||
return str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a human-readable summary of action results.
|
||||
*/
|
||||
protected function buildSummary(array $actionResults): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (! empty($actionResults['promo']['success'])) {
|
||||
$parts[] = "Created promo: {$actionResults['promo']['promo_name']}";
|
||||
}
|
||||
|
||||
if (! empty($actionResults['campaign']['success'])) {
|
||||
$campaigns = $actionResults['campaign']['campaigns'] ?? [];
|
||||
$campaignCount = count($campaigns);
|
||||
$parts[] = "Created {$campaignCount} campaign(s)";
|
||||
|
||||
foreach ($campaigns as $channel => $result) {
|
||||
if ($result['success']) {
|
||||
$parts[] = ucfirst($channel).": {$result['status']}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return implode('. ', $parts) ?: 'No actions executed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update automation status after run.
|
||||
*/
|
||||
protected function updateAutomationStatus(MarketingAutomation $automation, string $status): void
|
||||
{
|
||||
$automation->update([
|
||||
'last_run_at' => now(),
|
||||
'last_status' => $status,
|
||||
]);
|
||||
}
|
||||
}
|
||||
376
app/Services/Marketing/MarketingIntelligenceService.php
Normal file
376
app/Services/Marketing/MarketingIntelligenceService.php
Normal file
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketing;
|
||||
|
||||
use App\Models\Cannaiq\ProductMetric;
|
||||
use App\Models\Cannaiq\StoreMetric;
|
||||
use App\Services\Cannaiq\CannaiqClient;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Marketing Intelligence Service
|
||||
*
|
||||
* Orchestrates market intelligence data from CannaiQ, handling:
|
||||
* - Data fetching and caching
|
||||
* - Store and product metric aggregation
|
||||
* - Competitor analysis
|
||||
* - Trend calculations
|
||||
*/
|
||||
class MarketingIntelligenceService
|
||||
{
|
||||
protected CannaiqClient $client;
|
||||
|
||||
protected int $cacheTtl;
|
||||
|
||||
public function __construct(CannaiqClient $client)
|
||||
{
|
||||
$this->client = $client;
|
||||
$this->cacheTtl = config('services.cannaiq.cache_ttl', 7200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store-level intelligence metrics
|
||||
*/
|
||||
public function getStoreIntelligence(int $businessId, string $storeExternalId): array
|
||||
{
|
||||
$cacheKey = "cannaiq:store:{$businessId}:{$storeExternalId}";
|
||||
|
||||
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($businessId, $storeExternalId) {
|
||||
// Check for cached snapshot in database
|
||||
$cached = StoreMetric::forBusiness($businessId)
|
||||
->forStore($storeExternalId)
|
||||
->latest()
|
||||
->recent(24) // Within last 24 hours
|
||||
->first();
|
||||
|
||||
if ($cached) {
|
||||
return $this->formatStoreMetrics($cached);
|
||||
}
|
||||
|
||||
// Fetch fresh data from CannaiQ (store metrics endpoint)
|
||||
$data = $this->client->getStoreMetrics($storeExternalId);
|
||||
|
||||
if (isset($data['error'])) {
|
||||
return [
|
||||
'error' => true,
|
||||
'message' => $data['message'] ?? 'Failed to fetch store metrics',
|
||||
'metrics' => $this->getDefaultStoreMetrics(),
|
||||
];
|
||||
}
|
||||
|
||||
// Cache to database
|
||||
$metric = $this->cacheStoreMetric($businessId, $storeExternalId, $data);
|
||||
|
||||
return $this->formatStoreMetrics($metric);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product-level intelligence metrics for a store
|
||||
*/
|
||||
public function getProductIntelligence(int $businessId, string $storeExternalId, int $limit = 50): array
|
||||
{
|
||||
$cacheKey = "cannaiq:products:{$businessId}:{$storeExternalId}:{$limit}";
|
||||
|
||||
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($businessId, $storeExternalId, $limit) {
|
||||
// Check for cached products in database
|
||||
$cached = ProductMetric::forBusiness($businessId)
|
||||
->forStore($storeExternalId)
|
||||
->recent(24)
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($cached->isNotEmpty()) {
|
||||
return [
|
||||
'products' => $cached->map(fn ($m) => $this->formatProductMetric($m))->toArray(),
|
||||
'cached_at' => $cached->first()->snapshot_date->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
// Fetch fresh data from CannaiQ
|
||||
$data = $this->client->getStoreProductMetrics($storeExternalId, $limit);
|
||||
|
||||
if (isset($data['error'])) {
|
||||
return [
|
||||
'error' => true,
|
||||
'message' => $data['message'] ?? 'Failed to fetch product metrics',
|
||||
'products' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// Cache products to database
|
||||
$products = $data['products'] ?? [];
|
||||
$metrics = [];
|
||||
|
||||
foreach ($products as $product) {
|
||||
$metric = $this->cacheProductMetric($businessId, $storeExternalId, $product);
|
||||
$metrics[] = $this->formatProductMetric($metric);
|
||||
}
|
||||
|
||||
return [
|
||||
'products' => $metrics,
|
||||
'total' => $data['meta']['total'] ?? count($metrics),
|
||||
'cached_at' => now()->toIso8601String(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get competitor snapshot for a store's market area
|
||||
*/
|
||||
public function getCompetitorSnapshot(int $businessId, string $storeExternalId): array
|
||||
{
|
||||
$cacheKey = "cannaiq:competitors:{$businessId}:{$storeExternalId}";
|
||||
|
||||
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($storeExternalId) {
|
||||
$data = $this->client->getStoreCompetitorSnapshot($storeExternalId);
|
||||
|
||||
if (isset($data['error'])) {
|
||||
return [
|
||||
'error' => true,
|
||||
'message' => $data['message'] ?? 'Failed to fetch competitor data',
|
||||
'competitors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'target_store' => $data['target_store'] ?? null,
|
||||
'competitors' => $this->processCompetitors($data['competitors'] ?? []),
|
||||
'snapshot_time' => $data['snapshot_time'] ?? now()->toIso8601String(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated market trends
|
||||
*/
|
||||
public function getMarketTrends(int $businessId, string $storeExternalId): array
|
||||
{
|
||||
// Get historical metrics from database
|
||||
$storeMetrics = StoreMetric::forBusiness($businessId)
|
||||
->forStore($storeExternalId)
|
||||
->recent(30) // Last 30 days
|
||||
->orderBy('snapshot_date')
|
||||
->get();
|
||||
|
||||
if ($storeMetrics->isEmpty()) {
|
||||
return [
|
||||
'trends' => [],
|
||||
'message' => 'Insufficient data for trend analysis',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'pricing_trend' => $this->calculatePricingTrend($storeMetrics),
|
||||
'inventory_trend' => $this->calculateInventoryTrend($storeMetrics),
|
||||
'competitor_activity' => $this->calculateCompetitorActivity($storeMetrics),
|
||||
'data_points' => $storeMetrics->count(),
|
||||
'date_range' => [
|
||||
'start' => $storeMetrics->first()->snapshot_date->toDateString(),
|
||||
'end' => $storeMetrics->last()->snapshot_date->toDateString(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh all intelligence data for a business
|
||||
*/
|
||||
public function refreshIntelligence(int $businessId, string $storeExternalId): array
|
||||
{
|
||||
// Clear caches
|
||||
Cache::forget("cannaiq:store:{$businessId}:{$storeExternalId}");
|
||||
Cache::forget("cannaiq:products:{$businessId}:{$storeExternalId}:50");
|
||||
Cache::forget("cannaiq:competitors:{$businessId}:{$storeExternalId}");
|
||||
|
||||
$results = [
|
||||
'store' => false,
|
||||
'products' => false,
|
||||
'competitors' => false,
|
||||
];
|
||||
|
||||
try {
|
||||
// Refresh store metrics
|
||||
$storeData = $this->client->getStoreMetrics($storeExternalId);
|
||||
if (! isset($storeData['error'])) {
|
||||
$this->cacheStoreMetric($businessId, $storeExternalId, $storeData);
|
||||
$results['store'] = true;
|
||||
}
|
||||
|
||||
// Refresh product metrics
|
||||
$productData = $this->client->getStoreProductMetrics($storeExternalId, 100);
|
||||
if (! isset($productData['error'])) {
|
||||
foreach ($productData['products'] ?? [] as $product) {
|
||||
$this->cacheProductMetric($businessId, $storeExternalId, $product);
|
||||
}
|
||||
$results['products'] = true;
|
||||
}
|
||||
|
||||
// Refresh competitor snapshot
|
||||
$competitorData = $this->client->getStoreCompetitorSnapshot($storeExternalId);
|
||||
if (! isset($competitorData['error'])) {
|
||||
$results['competitors'] = true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('MarketingIntelligence: Refresh failed', [
|
||||
'business_id' => $businessId,
|
||||
'store_id' => $storeExternalId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache store metric to database
|
||||
*/
|
||||
protected function cacheStoreMetric(int $businessId, string $storeExternalId, array $data): StoreMetric
|
||||
{
|
||||
return StoreMetric::create([
|
||||
'business_id' => $businessId,
|
||||
'store_external_id' => $storeExternalId,
|
||||
'snapshot_date' => now(),
|
||||
'raw_payload' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache product metric to database
|
||||
*/
|
||||
protected function cacheProductMetric(int $businessId, string $storeExternalId, array $data): ProductMetric
|
||||
{
|
||||
return ProductMetric::updateOrCreate(
|
||||
[
|
||||
'business_id' => $businessId,
|
||||
'store_external_id' => $storeExternalId,
|
||||
'product_external_id' => $data['id'] ?? $data['product_id'] ?? uniqid(),
|
||||
'snapshot_date' => now()->toDateString(),
|
||||
],
|
||||
[
|
||||
'raw_payload' => $data,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format store metric for API response
|
||||
*/
|
||||
protected function formatStoreMetrics(StoreMetric $metric): array
|
||||
{
|
||||
return [
|
||||
'store_name' => $metric->store_name,
|
||||
'product_count' => $metric->product_count,
|
||||
'average_price' => $metric->average_price,
|
||||
'pricing_position' => $metric->raw_payload['pricing_position'] ?? null,
|
||||
'market_share' => $metric->raw_payload['market_share'] ?? null,
|
||||
'snapshot_date' => $metric->snapshot_date->toIso8601String(),
|
||||
'raw' => $metric->raw_payload,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format product metric for API response
|
||||
*/
|
||||
protected function formatProductMetric(ProductMetric $metric): array
|
||||
{
|
||||
return [
|
||||
'product_id' => $metric->product_external_id,
|
||||
'name' => $metric->product_name,
|
||||
'brand' => $metric->brand_name,
|
||||
'category' => $metric->category,
|
||||
'current_price' => $metric->current_price,
|
||||
'original_price' => $metric->original_price,
|
||||
'is_on_sale' => $metric->is_on_sale,
|
||||
'discount_percent' => $metric->discount_percent,
|
||||
'in_stock' => $metric->in_stock,
|
||||
'thc_percent' => $metric->thc_percent,
|
||||
'snapshot_date' => $metric->snapshot_date->toDateString(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process competitor data
|
||||
*/
|
||||
protected function processCompetitors(array $competitors): array
|
||||
{
|
||||
return collect($competitors)->map(function ($competitor) {
|
||||
return [
|
||||
'id' => $competitor['id'] ?? null,
|
||||
'name' => $competitor['name'] ?? 'Unknown',
|
||||
'slug' => $competitor['slug'] ?? null,
|
||||
'distance' => $competitor['distance'] ?? null,
|
||||
'product_count' => $competitor['products_count'] ?? 0,
|
||||
'average_price' => $competitor['average_price'] ?? null,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate pricing trend from historical data
|
||||
*/
|
||||
protected function calculatePricingTrend($metrics): array
|
||||
{
|
||||
$prices = $metrics->pluck('raw_payload.average_price')->filter()->values();
|
||||
|
||||
if ($prices->count() < 2) {
|
||||
return ['direction' => 'stable', 'change' => 0];
|
||||
}
|
||||
|
||||
$first = $prices->first();
|
||||
$last = $prices->last();
|
||||
$change = $first > 0 ? (($last - $first) / $first) * 100 : 0;
|
||||
|
||||
return [
|
||||
'direction' => $change > 1 ? 'up' : ($change < -1 ? 'down' : 'stable'),
|
||||
'change' => round($change, 2),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate inventory trend from historical data
|
||||
*/
|
||||
protected function calculateInventoryTrend($metrics): array
|
||||
{
|
||||
$counts = $metrics->pluck('raw_payload.products_count')->filter()->values();
|
||||
|
||||
if ($counts->count() < 2) {
|
||||
return ['direction' => 'stable', 'change' => 0];
|
||||
}
|
||||
|
||||
$first = $counts->first();
|
||||
$last = $counts->last();
|
||||
$change = $first > 0 ? (($last - $first) / $first) * 100 : 0;
|
||||
|
||||
return [
|
||||
'direction' => $change > 5 ? 'up' : ($change < -5 ? 'down' : 'stable'),
|
||||
'change' => round($change, 2),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate competitor activity level
|
||||
*/
|
||||
protected function calculateCompetitorActivity($metrics): array
|
||||
{
|
||||
// Placeholder - would analyze competitor snapshots over time
|
||||
return [
|
||||
'level' => 'moderate',
|
||||
'promo_activity' => 'normal',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default store metrics when data is unavailable
|
||||
*/
|
||||
protected function getDefaultStoreMetrics(): array
|
||||
{
|
||||
return [
|
||||
'store_name' => null,
|
||||
'product_count' => 0,
|
||||
'average_price' => null,
|
||||
'pricing_position' => null,
|
||||
'market_share' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user