Files
hub/app/Http/Controllers/Seller/BrandController.php
kelly 11a07692ad feat: add CannaiQ product mapping
- Add product_cannaiq_mappings pivot table (many-to-many)
- Add ProductCannaiqMapping model
- Add cannaiqMappings relationship to Product model
- Add mapping page at /s/{business}/brands/{brand}/cannaiq
- Add map/unmap API endpoints
- Update brand settings CannaiQ section with searchable brand dropdown
- Search CannaiQ products and map to Hub products
2025-12-17 18:58:08 -07:00

2284 lines
91 KiB
PHP

<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Concerns\HandlesPrecognition;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreBrandRequest;
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;
class BrandController extends Controller
{
use HandlesPrecognition;
/**
* Display a listing of brands for the business
*/
public function index(Request $request, Business $business)
{
$this->authorize('viewAny', [Brand::class, $business]);
// Get brands for this business and parent company (if division)
// Eager load product count to prevent N+1 queries
$brands = Brand::where(function ($query) use ($business) {
$query->where('business_id', $business->id);
if ($business->parent_id) {
$query->orWhere('business_id', $business->parent_id);
}
})
->withCount('products')
->orderBy('name')
->get();
// 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,
'is_cannaiq_connected' => $brand->isCannaiqConnected(),
'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]),
'profile_url' => route('seller.business.brands.profile', [$business->slug, $brand]),
'stats_url' => route('seller.business.brands.stats', [$business->slug, $brand]),
'edit_url' => route('seller.business.brands.edit', [$business->slug, $brand]),
'stores_url' => route('seller.business.brands.stores.index', [$business->slug, $brand]),
'orders_url' => route('seller.business.brands.orders', [$business->slug, $brand]),
'isNewBrand' => $brand->created_at && $brand->created_at->diffInDays(now()) <= 30,
];
})->values();
return view('seller.brands.index', compact('business', 'brands', 'brandsJson'));
}
/**
* Show the form for creating a new brand
*/
public function create(Business $business)
{
$this->authorize('create', [Brand::class, $business]);
return view('seller.brands.create', compact('business'));
}
/**
* Show the Nexus version of the brand create form (demo/test)
*/
public function createNexus(Business $business)
{
return view('seller.brands.create-nexus', compact('business'));
}
/**
* Show the Nexus version of the brand edit form (demo/test)
*/
public function editNexus(Business $business, Brand $brand)
{
return view('seller.brands.edit-nexus', compact('business', 'brand'));
}
/**
* Store a newly created brand in storage
*/
public function store(StoreBrandRequest $request, Business $business)
{
// Authorization is handled by StoreBrandRequest
$validated = $request->validated();
// Clean and normalize website URL - strip any protocol user entered, then add https://
if ($request->filled('website_url')) {
$url = $validated['website_url'];
// Strip http:// or https:// if user entered it
$url = preg_replace('#^https?://#i', '', $url);
// Strip any leading/trailing whitespace
$url = trim($url);
// Validate that we have a valid domain format
if (! empty($url) && ! filter_var('https://'.$url, FILTER_VALIDATE_URL)) {
return redirect()
->back()
->withInput()
->withErrors(['website_url' => 'Please enter a valid website URL (e.g., example.com)']);
}
// Add https:// prefix
$validated['website_url'] = ! empty($url) ? 'https://'.$url : null;
}
// Generate slug from name
$validated['slug'] = Str::slug($validated['name']);
// Handle logo upload
if ($request->hasFile('logo')) {
$validated['logo_path'] = $request->file('logo')->store('brands/logos', 'public');
}
// Handle banner upload
if ($request->hasFile('banner')) {
$validated['banner_path'] = $request->file('banner')->store('brands/banners', 'public');
}
// Set boolean defaults
$validated['is_public'] = $request->boolean('is_public');
$validated['is_featured'] = $request->boolean('is_featured');
$validated['is_active'] = $request->boolean('is_active');
// Create brand
$brand = $business->brands()->create($validated);
return redirect()
->route('seller.business.brands.index', $business->slug)
->with('success', 'Brand created successfully!');
}
/**
* Display the specified brand (read-only view)
*/
public function show(Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
// Redirect to brand dashboard - seller brand pages should use dashboard, not show view
return redirect()->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid]);
}
/**
* Show the brand dashboard (seller admin view)
*/
public function dashboard(Request $request, Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
// 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']);
// Load all brands for the brand selector dropdown (lightweight, always needed)
$brands = $business->brands()
->where('is_active', true)
->withCount('products')
->orderBy('name')
->get();
// 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) {
$product->setRelation('brand', $brand);
return [
'id' => $product->id,
'hashid' => $product->hashid,
'name' => $product->name,
'sku' => $product->sku ?? 'N/A',
'type' => $product->type ?? 'N/A',
'price' => $product->wholesale_price ?? 0,
'stock' => $product->quantity_on_hand ?? 0,
'status' => $product->is_active ? 'Active' : 'Draft',
'image_url' => $product->getImageUrl('thumb'),
'edit_url' => route('seller.business.products.edit', [$business->slug, $product->hashid]),
'preview_url' => route('seller.business.products.preview', [$business->slug, $product->hashid]),
];
})
->values();
return [
'products' => $products,
'productsPagination' => [
'current_page' => $productsPaginator->currentPage(),
'last_page' => $productsPaginator->lastPage(),
'per_page' => $productsPaginator->perPage(),
'total' => $productsPaginator->total(),
'from' => $productsPaginator->firstItem(),
'to' => $productsPaginator->lastItem(),
],
'productsPaginator' => $productsPaginator,
];
}
/**
* 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);
}
/**
* Preview the brand as it would appear to buyers
*/
public function preview(Request $request, Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
// Load brand with business relationship
$brand->load('business');
// Paginate products (50 per page) instead of loading all
$perPage = $request->get('per_page', 50);
$productsPaginator = $brand->products()
->where('is_active', true)
->whereNull('parent_product_id') // Only parent products
->with([
'images',
'strain',
'unit',
'productLine',
'varieties' => function ($q) {
$q->where('is_active', true)
->with(['images', 'strain', 'unit'])
->orderBy('name');
},
])
->orderBy('name')
->paginate($perPage);
// Get other brands from the same business
$otherBrands = Brand::where('business_id', $brand->business_id)
->where('id', '!=', $brand->id)
->orderBy('name')
->get();
// Group paginated products by product line
$productsByLine = $productsPaginator->getCollection()->groupBy(function ($product) {
return $product->productLine->name ?? 'Uncategorized';
});
// Allow viewing as buyer with ?as=buyer query parameter (for testing)
$isSeller = request()->query('as') !== 'buyer';
return view('seller.brands.preview', compact('business', 'brand', 'otherBrands', 'productsByLine', 'productsPaginator', 'isSeller'));
}
/**
* Show the form for editing the specified brand
*/
public function edit(Business $business, Brand $brand)
{
$this->authorize('update', [$brand, $business]);
// 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'));
}
/**
* Update the specified brand in storage
*/
public function update(UpdateBrandRequest $request, Business $business, Brand $brand)
{
// Handle Laravel Precognition validation-only requests
if ($this->isPrecognitive($request)) {
// Validation happens automatically via UpdateBrandRequest
return;
}
// Authorization is handled by UpdateBrandRequest
$validated = $request->validated();
// Clean and normalize website URL - strip any protocol user entered, then add https://
if ($request->filled('website_url')) {
$url = $validated['website_url'];
// Strip http:// or https:// if user entered it
$url = preg_replace('#^https?://#i', '', $url);
// Strip any leading/trailing whitespace
$url = trim($url);
// Validate that we have a valid domain format
if (! empty($url) && ! filter_var('https://'.$url, FILTER_VALIDATE_URL)) {
return redirect()
->back()
->withInput()
->withErrors(['website_url' => 'Please enter a valid website URL (e.g., example.com)']);
}
// Add https:// prefix
$validated['website_url'] = ! empty($url) ? 'https://'.$url : null;
} else {
$validated['website_url'] = null;
}
// Update slug if name changed
if ($validated['name'] !== $brand->name) {
$validated['slug'] = Str::slug($validated['name']);
}
// Handle logo removal
if ($request->boolean('remove_logo') && $brand->logo_path) {
Storage::delete($brand->logo_path);
$validated['logo_path'] = null;
}
// Handle logo upload
if ($request->hasFile('logo')) {
// Delete old logo
if ($brand->logo_path) {
Storage::delete($brand->logo_path);
}
$validated['logo_path'] = $request->file('logo')->store('brands/logos');
}
// Handle banner removal
if ($request->boolean('remove_banner') && $brand->banner_path) {
Storage::delete($brand->banner_path);
$validated['banner_path'] = null;
}
// Handle banner upload
if ($request->hasFile('banner')) {
// Delete old banner
if ($brand->banner_path) {
Storage::delete($brand->banner_path);
}
$validated['banner_path'] = $request->file('banner')->store('brands/banners');
}
// Set boolean defaults
$validated['is_public'] = $request->boolean('is_public');
$validated['is_featured'] = $request->boolean('is_featured');
$validated['is_active'] = $request->boolean('is_active');
// Set social media preview toggles
$validated['show_website_in_preview'] = $request->boolean('show_website_in_preview');
$validated['show_instagram_in_preview'] = $request->boolean('show_instagram_in_preview');
$validated['show_facebook_in_preview'] = $request->boolean('show_facebook_in_preview');
$validated['show_twitter_in_preview'] = $request->boolean('show_twitter_in_preview');
$validated['show_youtube_in_preview'] = $request->boolean('show_youtube_in_preview');
$validated['show_tiktok_in_preview'] = $request->boolean('show_tiktok_in_preview');
$validated['show_cannagrams_in_preview'] = $request->boolean('show_cannagrams_in_preview');
// Remove form-only fields
unset($validated['remove_logo'], $validated['remove_banner']);
// Brand Messaging Settings
$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);
// Return JSON for Precognition requests
if ($request->wantsJson()) {
return response()->json([
'message' => 'Brand updated successfully!',
]);
}
// Determine redirect destination based on source
// If 'tab' param is present, user came from dashboard (tabbed view)
// Otherwise, user came from simple edit view
if ($request->has('tab')) {
$tab = $request->input('tab', 'settings');
return redirect()
->route('seller.business.brands.dashboard', [
'business' => $business->slug,
'brand' => $brand->hashid,
'tab' => $tab,
])
->with('success', 'Brand updated successfully!');
}
// Redirect back to simple edit page
return redirect()
->route('seller.business.brands.edit', [$business->slug, $brand->hashid])
->with('success', 'Brand updated successfully!');
}
/**
* Show brand performance statistics
*/
public function stats(Request $request, Business $business, Brand $brand)
{
Gate::authorize('view', [$brand, $business]);
// Determine date range from request
$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;
default: // all_time
// Query from earliest order for this brand, or default to 3 years ago if no orders
$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() : now()->subYears(3)->startOfDay();
$endDate = now()->endOfDay();
}
// Create cache key for this stats request
$cacheKey = sprintf(
'brand_stats:%d:%s:%s:%s',
$brand->id,
$preset,
$startDate->format('Y-m-d'),
$endDate->format('Y-m-d')
);
// Cache for 5 minutes (stats don't need real-time updates)
$stats = \Illuminate\Support\Facades\Cache::remember($cacheKey, 300, function () use ($brand, $startDate, $endDate) {
return $this->calculateBrandStats($brand, $startDate, $endDate);
});
return view('seller.brands.stats', array_merge($stats, [
'business' => $business,
'brand' => $brand,
'preset' => $preset,
'startDate' => $startDate,
'endDate' => $endDate,
]));
}
/**
* Display the Brand Intelligence Hub (comprehensive brand overview).
*
* Brand Managers have VIEW ONLY access.
* Sellers/Admins can see edit buttons.
*/
public function profile(Request $request, Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
$user = $request->user();
// Check if user is a brand manager (view only)
$isBrandManager = $user->brands()
->where('brands.id', $brand->id)
->wherePivot('role', 'manager')
->exists();
// Brand managers can ONLY see brands they're assigned to
if ($isBrandManager) {
$userBrandIds = $user->brands()->pluck('brands.id')->toArray();
if (! in_array($brand->id, $userBrandIds)) {
abort(403, 'You do not have access to this brand.');
}
}
// Can edit if NOT a brand manager (sellers/admins can edit)
$canEdit = ! $isBrandManager;
// Load brand with relationships
$brand->load(['business', 'products.department']);
// Date ranges
$ninetyDaysAgo = now()->subDays(90);
$thirtyDaysAgo = now()->subDays(30);
$sixtyDaysAgo = now()->subDays(60);
// ═══════════════════════════════════════════════════════════════
// CORE SALES STATS (90 days)
// ═══════════════════════════════════════════════════════════════
$salesStats = $this->calculateBrandStats($brand, $ninetyDaysAgo, now());
// ═══════════════════════════════════════════════════════════════
// STORE INTELLIGENCE (90 days)
// ═══════════════════════════════════════════════════════════════
$storeStats = $this->calculateStoreStats($brand, 90);
// ═══════════════════════════════════════════════════════════════
// PRODUCT VELOCITY DATA
// ═══════════════════════════════════════════════════════════════
$productVelocity = $this->calculateProductVelocity($brand, $thirtyDaysAgo, $ninetyDaysAgo);
// Product categories breakdown
$productCategories = $brand->products
->groupBy('type')
->map(fn ($products) => $products->count())
->sortDesc();
// Recent product changes (last 30 days)
$recentlyAdded = $brand->products()
->where('created_at', '>=', $thirtyDaysAgo)
->orderByDesc('created_at')
->limit(10)
->get();
$recentlyUpdated = $brand->products()
->where('updated_at', '>=', $thirtyDaysAgo)
->where('created_at', '<', $thirtyDaysAgo)
->orderByDesc('updated_at')
->limit(10)
->get();
// Out of stock products
$outOfStock = $brand->products()
->where(function ($q) use ($brand) {
$q->where('brand_id', $brand->id)
->where(function ($sub) {
$sub->where('status', 'unavailable')
->orWhere(function ($inner) {
$inner->where('inventory_mode', '!=', 'unlimited')
->where('quantity_on_hand', '<=', 0);
});
});
})
->limit(10)
->get();
// Discontinued/archived products
$discontinued = $brand->products()
->where('brand_id', $brand->id)
->where(function ($q) {
$q->whereIn('status', ['archived', 'discontinued'])
->orWhere('is_active', false);
})
->limit(10)
->get();
// ═══════════════════════════════════════════════════════════════
// BUYER INTELLIGENCE
// ═══════════════════════════════════════════════════════════════
$buyerIntelligence = $this->getBuyerIntelligence($brand, $business, $ninetyDaysAgo, $thirtyDaysAgo, $sixtyDaysAgo);
// ═══════════════════════════════════════════════════════════════
// PROMOTIONS & RECOMMENDATIONS
// ═══════════════════════════════════════════════════════════════
$promotions = Promotion::where('brand_id', $brand->id)
->withCount('products')
->orderByDesc('created_at')
->get();
// Get promo recommendations for this brand
$promoRecommendations = PromoRecommendation::where('brand_id', $brand->id)
->where('status', 'pending')
->whereNull('dismissed_at')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->with('product')
->orderByDesc('priority')
->orderByDesc('confidence')
->limit(10)
->get();
// Get promo insights from helper if available
$promoInsights = $this->getPromoInsights($brand);
// ═══════════════════════════════════════════════════════════════
// ORCHESTRATOR INSIGHTS
// ═══════════════════════════════════════════════════════════════
$orchestratorInsights = $this->getOrchestratorInsights($brand, $business, $ninetyDaysAgo, $thirtyDaysAgo);
// ═══════════════════════════════════════════════════════════════
// TIMELINE / ACTIVITY (Recent significant events)
// ═══════════════════════════════════════════════════════════════
$timeline = $this->getBrandTimeline($brand, $business, $ninetyDaysAgo);
// ═══════════════════════════════════════════════════════════════
// ALERTS & RISKS
// ═══════════════════════════════════════════════════════════════
$alerts = $this->generateBrandAlerts($brand, $salesStats, $buyerIntelligence, $productVelocity, $outOfStock);
// ═══════════════════════════════════════════════════════════════
// PERFORMANCE SCORE & SUB-SCORES
// ═══════════════════════════════════════════════════════════════
$performanceScore = $this->calculateBrandPerformanceScore($brand, $salesStats, $buyerIntelligence['activeBuyers']->count());
$subScores = $this->calculateSubScores($brand, $salesStats, $buyerIntelligence, $productVelocity);
// ═══════════════════════════════════════════════════════════════
// INSIGHTS (Executive summary cards)
// ═══════════════════════════════════════════════════════════════
$insights = $this->generateBrandInsights($brand, $salesStats, $buyerIntelligence, $productVelocity);
return view('seller.brands.profile', [
'business' => $business,
'brand' => $brand,
'canEdit' => $canEdit,
'isBrandManager' => $isBrandManager,
// Core stats
'salesStats' => $salesStats,
'storeStats' => $storeStats,
'productCategories' => $productCategories,
'productVelocity' => $productVelocity,
// Product states
'recentlyAdded' => $recentlyAdded,
'recentlyUpdated' => $recentlyUpdated,
'outOfStock' => $outOfStock,
'discontinued' => $discontinued,
// Buyer intelligence
'buyerIntelligence' => $buyerIntelligence,
'activeBuyers' => $buyerIntelligence['activeBuyers'],
// Promotions
'promotions' => $promotions,
'promoRecommendations' => $promoRecommendations,
'promoInsights' => $promoInsights,
// Orchestrator
'orchestratorInsights' => $orchestratorInsights,
// Timeline & alerts
'timeline' => $timeline,
'alerts' => $alerts,
// Scores
'performanceScore' => $performanceScore,
'subScores' => $subScores,
// Insights
'insights' => $insights,
]);
}
/**
* Calculate product velocity metrics for the brand.
*/
private function calculateProductVelocity(Brand $brand, $thirtyDaysAgo, $ninetyDaysAgo): array
{
$products = $brand->products()->where('is_active', true)->get();
// Get order items for velocity calculation
$orderItems30d = \App\Models\OrderItem::whereHas('product', fn ($q) => $q->where('brand_id', $brand->id))
->whereHas('order', fn ($q) => $q->where('created_at', '>=', $thirtyDaysAgo))
->selectRaw('product_id, SUM(quantity) as units, SUM(line_total) as revenue')
->groupBy('product_id')
->get()
->keyBy('product_id');
$orderItems90d = \App\Models\OrderItem::whereHas('product', fn ($q) => $q->where('brand_id', $brand->id))
->whereHas('order', fn ($q) => $q->where('created_at', '>=', $ninetyDaysAgo))
->selectRaw('product_id, SUM(quantity) as units, SUM(line_total) as revenue')
->groupBy('product_id')
->get()
->keyBy('product_id');
$velocityData = $products->map(function ($product) use ($orderItems30d, $orderItems90d) {
$data30d = $orderItems30d->get($product->id);
$data90d = $orderItems90d->get($product->id);
$units30d = $data30d?->units ?? 0;
$units90d = $data90d?->units ?? 0;
$revenue30d = $data30d?->revenue ?? 0;
$revenue90d = $data90d?->revenue ?? 0;
// Velocity = units per day
$velocity30d = $units30d / 30;
$velocity90d = $units90d / 90;
// Categorize velocity
$velocityStatus = 'slow';
if ($velocity30d >= 0.5) {
$velocityStatus = 'top';
} elseif ($velocity30d >= 0.1) {
$velocityStatus = 'moderate';
} elseif ($velocity30d >= 0.03) {
$velocityStatus = 'slow';
} else {
$velocityStatus = 'stale';
}
return [
'product' => $product,
'units_30d' => (int) $units30d,
'units_90d' => (int) $units90d,
'revenue_30d' => $revenue30d,
'revenue_90d' => $revenue90d,
'velocity_30d' => round($velocity30d, 3),
'velocity_90d' => round($velocity90d, 3),
'velocity_status' => $velocityStatus,
];
})->sortByDesc('revenue_90d');
// Summarize
$topSellers = $velocityData->where('velocity_status', 'top')->count();
$slowMovers = $velocityData->whereIn('velocity_status', ['slow', 'stale'])->count();
return [
'products' => $velocityData,
'summary' => [
'top_sellers' => $topSellers,
'moderate' => $velocityData->where('velocity_status', 'moderate')->count(),
'slow_movers' => $slowMovers,
'stale' => $velocityData->where('velocity_status', 'stale')->count(),
],
];
}
/**
* Get buyer intelligence data for the brand.
*/
private function getBuyerIntelligence(Brand $brand, Business $business, $ninetyDaysAgo, $thirtyDaysAgo, $sixtyDaysAgo): array
{
// Active buyers (ordered in last 90 days)
$activeBuyers = \App\Models\Business::whereHas('orders', function ($q) use ($brand, $ninetyDaysAgo) {
$q->where('created_at', '>=', $ninetyDaysAgo)
->whereHas('items.product', fn ($pq) => $pq->where('brand_id', $brand->id));
})
->withCount(['orders' => function ($q) use ($brand, $ninetyDaysAgo) {
$q->where('created_at', '>=', $ninetyDaysAgo)
->whereHas('items.product', fn ($pq) => $pq->where('brand_id', $brand->id));
}])
->orderByDesc('orders_count')
->limit(20)
->get();
// New buyers (first order from this brand in last 30 days)
$newBuyers30d = \App\Models\Business::whereHas('orders', function ($q) use ($brand, $thirtyDaysAgo) {
$q->where('created_at', '>=', $thirtyDaysAgo)
->whereHas('items.product', fn ($pq) => $pq->where('brand_id', $brand->id));
})
->whereDoesntHave('orders', function ($q) use ($brand, $thirtyDaysAgo) {
$q->where('created_at', '<', $thirtyDaysAgo)
->whereHas('items.product', fn ($pq) => $pq->where('brand_id', $brand->id));
})
->count();
// Repeat buyers (2+ orders in 90 days)
$repeatBuyers = \App\Models\Business::whereHas('orders', function ($q) use ($brand, $ninetyDaysAgo) {
$q->where('created_at', '>=', $ninetyDaysAgo)
->whereHas('items.product', fn ($pq) => $pq->where('brand_id', $brand->id));
}, '>=', 2)->count();
// At-risk buyers (ordered 45-90 days ago, nothing since)
$atRiskBuyers = \App\Models\Business::whereHas('orders', function ($q) use ($brand, $sixtyDaysAgo, $ninetyDaysAgo) {
$q->whereBetween('created_at', [$ninetyDaysAgo, $sixtyDaysAgo])
->whereHas('items.product', fn ($pq) => $pq->where('brand_id', $brand->id));
})
->whereDoesntHave('orders', function ($q) use ($brand, $sixtyDaysAgo) {
$q->where('created_at', '>', $sixtyDaysAgo)
->whereHas('items.product', fn ($pq) => $pq->where('brand_id', $brand->id));
})
->count();
// Total unique buyers in 90 days
$totalBuyers90d = $activeBuyers->count();
// Repeat buyer percentage
$repeatPercent = $totalBuyers90d > 0 ? round(($repeatBuyers / $totalBuyers90d) * 100, 1) : 0;
return [
'activeBuyers' => $activeBuyers,
'newBuyers30d' => $newBuyers30d,
'repeatBuyers' => $repeatBuyers,
'atRiskBuyers' => $atRiskBuyers,
'totalBuyers90d' => $totalBuyers90d,
'repeatPercent' => $repeatPercent,
'segments' => [
['name' => 'New Buyers (30d)', 'count' => $newBuyers30d, 'color' => 'success'],
['name' => 'Repeat Buyers', 'count' => $repeatBuyers, 'color' => 'primary'],
['name' => 'At-Risk (45-90d)', 'count' => $atRiskBuyers, 'color' => 'warning'],
],
];
}
/**
* Get promo insights from InBrandPromoHelper if available.
*/
private function getPromoInsights(Brand $brand): array
{
try {
$helper = app(InBrandPromoHelper::class);
return $helper->getSuggestionsForBrand($brand, 30);
} catch (\Exception $e) {
return [
'top_sellers' => [],
'slow_movers' => [],
'new_launches' => [],
'aging_inventory' => [],
'summary' => null,
];
}
}
/**
* Get orchestrator insights for this brand.
*/
private function getOrchestratorInsights(Brand $brand, Business $business, $ninetyDaysAgo, $thirtyDaysAgo): array
{
// Get orchestrator profile for this brand
$profile = BrandOrchestratorProfile::where('brand_id', $brand->id)->first();
// Tasks for this brand
$tasks30d = OrchestratorTask::where('brand_id', $brand->id)
->where('created_at', '>=', $thirtyDaysAgo)
->get();
$tasks90d = OrchestratorTask::where('brand_id', $brand->id)
->where('created_at', '>=', $ninetyDaysAgo)
->get();
// Task stats
$tasksCreated30d = $tasks30d->count();
$tasksCreated90d = $tasks90d->count();
$tasksCompleted30d = $tasks30d->whereNotNull('completed_at')->count();
$tasksCompleted90d = $tasks90d->whereNotNull('completed_at')->count();
// Completion rate
$completionRate30d = $tasksCreated30d > 0 ? round(($tasksCompleted30d / $tasksCreated30d) * 100, 1) : 0;
$completionRate90d = $tasksCreated90d > 0 ? round(($tasksCompleted90d / $tasksCreated90d) * 100, 1) : 0;
// Tasks that resulted in orders
$resultedInOrder30d = $tasks30d->where('resulted_in_order', true)->count();
$conversionRate = $tasksCompleted30d > 0 ? round(($resultedInOrder30d / $tasksCompleted30d) * 100, 1) : 0;
// Tasks by type
$tasksByType = $tasks90d->groupBy('type')->map->count()->sortDesc();
// Pending tasks
$pendingTasks = OrchestratorTask::where('brand_id', $brand->id)
->whereNull('completed_at')
->whereNull('ignored_at')
->where('approval_state', 'approved')
->count();
return [
'profile' => $profile,
'tasksCreated30d' => $tasksCreated30d,
'tasksCreated90d' => $tasksCreated90d,
'tasksCompleted30d' => $tasksCompleted30d,
'tasksCompleted90d' => $tasksCompleted90d,
'completionRate30d' => $completionRate30d,
'completionRate90d' => $completionRate90d,
'resultedInOrder30d' => $resultedInOrder30d,
'conversionRate' => $conversionRate,
'tasksByType' => $tasksByType,
'pendingTasks' => $pendingTasks,
];
}
/**
* Get brand timeline / activity feed.
*/
private function getBrandTimeline(Brand $brand, Business $business, $ninetyDaysAgo): array
{
$timeline = collect();
// Recent product additions
$brand->products()
->where('created_at', '>=', $ninetyDaysAgo)
->orderByDesc('created_at')
->limit(5)
->get()
->each(function ($product) use ($timeline) {
$timeline->push([
'date' => $product->created_at,
'type' => 'product_added',
'icon' => 'plus-circle',
'color' => 'success',
'title' => 'Product Added',
'description' => $product->name,
]);
});
// Promotions started
Promotion::where('brand_id', $brand->id)
->where('starts_at', '>=', $ninetyDaysAgo)
->orderByDesc('starts_at')
->limit(5)
->get()
->each(function ($promo) use ($timeline) {
$timeline->push([
'date' => $promo->starts_at,
'type' => 'promo_started',
'icon' => 'tag',
'color' => 'warning',
'title' => 'Promotion Started',
'description' => $promo->name,
]);
});
// Promotions ended
Promotion::where('brand_id', $brand->id)
->where('ends_at', '>=', $ninetyDaysAgo)
->where('ends_at', '<=', now())
->orderByDesc('ends_at')
->limit(5)
->get()
->each(function ($promo) use ($timeline) {
$timeline->push([
'date' => $promo->ends_at,
'type' => 'promo_ended',
'icon' => 'tag',
'color' => 'base-content/50',
'title' => 'Promotion Ended',
'description' => $promo->name,
]);
});
// Sort by date descending and limit
return $timeline->sortByDesc('date')->take(15)->values()->toArray();
}
/**
* Generate alerts and risk indicators for the brand.
*/
private function generateBrandAlerts(Brand $brand, array $salesStats, array $buyerIntelligence, array $productVelocity, $outOfStock): array
{
$alerts = [];
// Out of stock alert
if ($outOfStock->count() > 0) {
$alerts[] = [
'type' => 'error',
'icon' => 'exclamation-triangle',
'title' => $outOfStock->count().' products out of stock',
'tab' => 'products',
];
}
// Slow movers alert
$slowMovers = $productVelocity['summary']['slow_movers'] ?? 0;
if ($slowMovers >= 3) {
$alerts[] = [
'type' => 'warning',
'icon' => 'clock',
'title' => $slowMovers.' slow-moving SKUs need attention',
'tab' => 'products',
];
}
// At-risk buyers alert
$atRisk = $buyerIntelligence['atRiskBuyers'] ?? 0;
if ($atRisk > 0) {
$alerts[] = [
'type' => 'warning',
'icon' => 'users',
'title' => $atRisk.' at-risk buyers (no order in 45+ days)',
'tab' => 'buyers',
];
}
// Revenue decline alert
if (($salesStats['revenueChange'] ?? 0) < -20) {
$alerts[] = [
'type' => 'error',
'icon' => 'trending-down',
'title' => 'Revenue down '.abs(round($salesStats['revenueChange'])).'% vs prior period',
'tab' => 'sales',
];
}
// No recent promotions
$activePromos = Promotion::where('brand_id', $brand->id)
->where('status', 'active')
->count();
if ($activePromos === 0 && $slowMovers > 0) {
$alerts[] = [
'type' => 'info',
'icon' => 'tag',
'title' => 'No active promotions running for slow movers',
'tab' => 'promotions',
];
}
return $alerts;
}
/**
* Calculate performance sub-scores.
*/
private function calculateSubScores(Brand $brand, array $salesStats, array $buyerIntelligence, array $productVelocity): array
{
// Sales Momentum (based on revenue change and order volume)
$salesMomentum = 50;
if (($salesStats['revenueChange'] ?? 0) > 20) {
$salesMomentum = 90;
} elseif (($salesStats['revenueChange'] ?? 0) > 0) {
$salesMomentum = 70;
} elseif (($salesStats['revenueChange'] ?? 0) > -10) {
$salesMomentum = 50;
} else {
$salesMomentum = 30;
}
// Buyer Engagement
$buyerEngagement = 50;
$repeatPercent = $buyerIntelligence['repeatPercent'] ?? 0;
if ($repeatPercent >= 40) {
$buyerEngagement = 90;
} elseif ($repeatPercent >= 25) {
$buyerEngagement = 70;
} elseif ($repeatPercent >= 10) {
$buyerEngagement = 50;
} else {
$buyerEngagement = 30;
}
// Product Health
$totalProducts = $brand->products->count();
$activeProducts = $brand->products->where('is_active', true)->count();
$topSellers = $productVelocity['summary']['top_sellers'] ?? 0;
$productHealth = $totalProducts > 0 ? round(($activeProducts / $totalProducts) * 100) : 0;
if ($topSellers >= 3) {
$productHealth = min(100, $productHealth + 20);
}
// Promo Effectiveness (placeholder - would need promo ROI data)
$promoEffectiveness = 60; // Default
return [
['name' => 'Sales Momentum', 'score' => $salesMomentum, 'color' => $salesMomentum >= 60 ? 'success' : ($salesMomentum >= 40 ? 'warning' : 'error')],
['name' => 'Buyer Engagement', 'score' => $buyerEngagement, 'color' => $buyerEngagement >= 60 ? 'success' : ($buyerEngagement >= 40 ? 'warning' : 'error')],
['name' => 'Product Health', 'score' => $productHealth, 'color' => $productHealth >= 60 ? 'success' : ($productHealth >= 40 ? 'warning' : 'error')],
['name' => 'Promo Effectiveness', 'score' => $promoEffectiveness, 'color' => $promoEffectiveness >= 60 ? 'success' : ($promoEffectiveness >= 40 ? 'warning' : 'error')],
];
}
/**
* Generate insight cards for executive summary.
*/
private function generateBrandInsights(Brand $brand, array $salesStats, array $buyerIntelligence, array $productVelocity): array
{
$insights = [];
// Top SKU insight
if ($salesStats['bestSellingSku'] ?? null) {
$topRevenue = $salesStats['bestSellingSku']['revenue'] ?? 0;
$totalRevenue = $salesStats['totalRevenue'] ?? 1;
$percent = $totalRevenue > 0 ? round(($topRevenue / $totalRevenue) * 100) : 0;
if ($percent >= 30) {
$insights[] = [
'icon' => 'star',
'color' => 'warning',
'text' => "Your flagship SKU accounts for {$percent}% of brand revenue.",
];
}
}
// Repeat buyer growth
if (($buyerIntelligence['repeatPercent'] ?? 0) >= 30) {
$insights[] = [
'icon' => 'users',
'color' => 'success',
'text' => "{$buyerIntelligence['repeatPercent']}% of buyers are repeat customers.",
];
}
// Slow movers insight
$slowMovers = $productVelocity['summary']['slow_movers'] ?? 0;
if ($slowMovers >= 3) {
$insights[] = [
'icon' => 'clock',
'color' => 'warning',
'text' => "{$slowMovers} SKUs are slow-moving. See Products tab for promo suggestions.",
];
}
// New buyers insight
if (($buyerIntelligence['newBuyers30d'] ?? 0) > 0) {
$insights[] = [
'icon' => 'user-plus',
'color' => 'success',
'text' => "{$buyerIntelligence['newBuyers30d']} new buyers acquired in the last 30 days.",
];
}
// Revenue trend insight
$revenueChange = $salesStats['revenueChange'] ?? 0;
if (abs($revenueChange) >= 10) {
$direction = $revenueChange > 0 ? 'up' : 'down';
$color = $revenueChange > 0 ? 'success' : 'error';
$insights[] = [
'icon' => $revenueChange > 0 ? 'trending-up' : 'trending-down',
'color' => $color,
'text' => 'Revenue is '.$direction.' '.abs(round($revenueChange)).'% vs prior period.',
];
}
return array_slice($insights, 0, 4);
}
/**
* Calculate a brand performance score (0-100) based on various metrics.
*/
private function calculateBrandPerformanceScore(Brand $brand, array $salesStats, int $activeBuyerCount): int
{
$score = 0;
// Product catalog health (20 points max)
$activeProducts = $brand->products->where('is_active', true)->count();
$totalProducts = $brand->products->count();
if ($totalProducts > 0) {
$catalogHealth = ($activeProducts / $totalProducts) * 20;
$score += min(20, $catalogHealth);
}
// Revenue activity (30 points max)
if ($salesStats['totalRevenue'] > 0) {
$score += 15; // Base points for having revenue
if ($salesStats['totalRevenue'] >= 10000) {
$score += 15; // Bonus for $10k+ revenue
} elseif ($salesStats['totalRevenue'] >= 1000) {
$score += 10; // Moderate bonus for $1k+ revenue
} else {
$score += 5; // Small bonus for any revenue
}
}
// Order volume (20 points max)
if ($salesStats['totalOrders'] >= 50) {
$score += 20;
} elseif ($salesStats['totalOrders'] >= 20) {
$score += 15;
} elseif ($salesStats['totalOrders'] >= 5) {
$score += 10;
} elseif ($salesStats['totalOrders'] > 0) {
$score += 5;
}
// Active buyer count (20 points max)
if ($activeBuyerCount >= 20) {
$score += 20;
} elseif ($activeBuyerCount >= 10) {
$score += 15;
} elseif ($activeBuyerCount >= 5) {
$score += 10;
} elseif ($activeBuyerCount > 0) {
$score += 5;
}
// Brand completeness (10 points max)
$completeness = 0;
if ($brand->logo_path) {
$completeness += 2;
}
if ($brand->description) {
$completeness += 2;
}
if ($brand->tagline) {
$completeness += 2;
}
if ($brand->key_selling_points && count($brand->key_selling_points) > 0) {
$completeness += 2;
}
if ($brand->website_url || $brand->instagram_handle) {
$completeness += 2;
}
$score += min(10, $completeness);
return min(100, max(0, (int) $score));
}
/**
* Calculate brand statistics for the given date range
*/
private function calculateBrandStats(Brand $brand, $startDate, $endDate): array
{
// Calculate product counts with efficient queries (not loading all products)
$productCounts = $brand->products()
->selectRaw('COUNT(*) as total, SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active')
->first();
$totalProducts = $productCounts->total ?? 0;
$activeProducts = $productCounts->active ?? 0;
// Get product IDs for this brand (for use in subqueries)
$brandProductIds = $brand->products()->pluck('id');
// Calculate current period metrics with single efficient query
$currentStats = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
->whereHas('order', function ($query) use ($startDate, $endDate) {
$query->whereBetween('created_at', [$startDate, $endDate]);
})
->selectRaw('
COUNT(DISTINCT order_id) as total_orders,
COALESCE(SUM(line_total), 0) as total_revenue,
COALESCE(SUM(quantity), 0) as total_units
')
->first();
$totalOrders = $currentStats->total_orders ?? 0;
$totalRevenue = $currentStats->total_revenue ?? 0;
$totalUnits = $currentStats->total_units ?? 0;
// Previous period comparison (same duration before start date)
$daysDiff = $startDate->diffInDays($endDate);
$previousStartDate = $startDate->copy()->subDays($daysDiff + 1);
$previousEndDate = $startDate->copy()->subDay();
$previousStats = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
->whereHas('order', function ($query) use ($previousStartDate, $previousEndDate) {
$query->whereBetween('created_at', [$previousStartDate, $previousEndDate]);
})
->selectRaw('
COUNT(DISTINCT order_id) as total_orders,
COALESCE(SUM(line_total), 0) as total_revenue
')
->first();
$previousRevenue = $previousStats->total_revenue ?? 0;
$previousOrders = $previousStats->total_orders ?? 0;
// Calculate percent changes
$revenueChange = $previousRevenue > 0 ? (($totalRevenue - $previousRevenue) / $previousRevenue) * 100 : 0;
$ordersChange = $previousOrders > 0 ? (($totalOrders - $previousOrders) / $previousOrders) * 100 : 0;
// Average order value
$avgOrderValue = $totalOrders > 0 ? $totalRevenue / $totalOrders : 0;
// Revenue by day - using database aggregation
$revenueByDay = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
->join('orders', 'order_items.order_id', '=', 'orders.id')
->whereBetween('orders.created_at', [$startDate, $endDate])
->selectRaw('DATE(orders.created_at) as date, SUM(order_items.line_total) as revenue')
->groupBy('date')
->orderBy('date')
->pluck('revenue', 'date');
// Top products by revenue - using database aggregation (limit to top 20)
$topProductsData = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
->whereHas('order', function ($query) use ($startDate, $endDate) {
$query->whereBetween('created_at', [$startDate, $endDate]);
})
->selectRaw('
product_id,
SUM(line_total) as revenue,
SUM(quantity) as units,
COUNT(DISTINCT order_id) as orders
')
->groupBy('product_id')
->orderByDesc('revenue')
->limit(20)
->get()
->keyBy('product_id');
// Load only the products we need for display
$topProductIds = $topProductsData->keys();
$products = \App\Models\Product::whereIn('id', $topProductIds)
->whereNull('parent_product_id')
->with(['varieties' => function ($q) use ($topProductsData) {
$q->whereIn('id', $topProductsData->keys());
}])
->get()
->keyBy('id');
// Build product stats with preloaded data
$productStats = $topProductsData
->filter(function ($data) use ($products) {
return $products->has($data->product_id);
})
->map(function ($data) use ($products, $topProductsData) {
$product = $products->get($data->product_id);
$varietyStats = collect();
if ($product && $product->has_varieties) {
$varietyStats = $product->varieties->map(function ($variety) use ($topProductsData) {
$varietyData = $topProductsData->get($variety->id);
return [
'product' => $variety,
'revenue' => $varietyData->revenue ?? 0,
'units' => $varietyData->units ?? 0,
'orders' => $varietyData->orders ?? 0,
];
})->sortByDesc('revenue');
}
return [
'product' => $product,
'revenue' => $data->revenue,
'units' => $data->units,
'orders' => $data->orders,
'varieties' => $varietyStats,
];
})
->sortByDesc('revenue');
// Get best selling SKU
$bestSellingSku = $productStats->first();
// Top buyers by revenue - using database aggregation
$topBuyersData = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
->join('orders', 'order_items.order_id', '=', 'orders.id')
->whereBetween('orders.created_at', [$startDate, $endDate])
->selectRaw('
orders.business_id,
SUM(order_items.line_total) as revenue,
COUNT(DISTINCT orders.id) as orders,
SUM(order_items.quantity) as units
')
->groupBy('orders.business_id')
->orderByDesc('revenue')
->limit(5)
->get();
// Load buyer businesses in single query
$buyerBusinesses = \App\Models\Business::whereIn('id', $topBuyersData->pluck('business_id'))
->select('id', 'name')
->get()
->keyBy('id');
$topBuyers = $topBuyersData->map(function ($data) use ($buyerBusinesses) {
return [
'business' => $buyerBusinesses->get($data->business_id),
'revenue' => $data->revenue,
'orders' => $data->orders,
'units' => $data->units,
];
});
return [
'totalProducts' => $totalProducts,
'activeProducts' => $activeProducts,
'totalOrders' => $totalOrders,
'totalRevenue' => $totalRevenue,
'totalUnits' => $totalUnits,
'avgOrderValue' => $avgOrderValue,
'revenueChange' => $revenueChange,
'ordersChange' => $ordersChange,
'revenueByDay' => $revenueByDay,
'productStats' => $productStats,
'bestSellingSku' => $bestSellingSku,
'topBuyers' => $topBuyers,
];
}
/**
* Generate and download PDF report
*/
public function exportPdf(Request $request, Business $business, Brand $brand)
{
Gate::authorize('view', [$brand, $business]);
// Get the same data as stats view
$statsData = $this->getStatsData($request, $business, $brand);
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('seller.brands.stats-pdf', $statsData);
return $pdf->download($brand->slug.'-stats-'.$statsData['startDate']->format('Y-m-d').'-to-'.$statsData['endDate']->format('Y-m-d').'.pdf');
}
/**
* Email PDF report to user
*/
public function emailPdf(Request $request, Business $business, Brand $brand)
{
Gate::authorize('view', [$brand, $business]);
// Validate email addresses (comma-separated)
$validated = $request->validate([
'emails' => 'required|string',
]);
// Parse and validate each email address
$emailList = array_map('trim', explode(',', $validated['emails']));
$validEmails = [];
foreach ($emailList as $email) {
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
$validEmails[] = $email;
}
}
if (empty($validEmails)) {
return redirect()
->back()
->withInput()
->withErrors(['emails' => 'Please provide at least one valid email address.']);
}
// Get the same data as stats view
$statsData = $this->getStatsData($request, $business, $brand);
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('seller.brands.stats-pdf', $statsData);
// Send email with PDF attachment to all recipients
\Illuminate\Support\Facades\Mail::send('emails.stats-report', [
'brand' => $brand,
'business' => $business,
'startDate' => $statsData['startDate'],
'endDate' => $statsData['endDate'],
], function ($message) use ($validEmails, $brand, $pdf, $statsData) {
$message->to($validEmails)
->subject('Brand Statistics Report: '.$brand->name)
->attachData($pdf->output(), $brand->slug.'-stats-'.$statsData['startDate']->format('Y-m-d').'-to-'.$statsData['endDate']->format('Y-m-d').'.pdf');
});
$recipientCount = count($validEmails);
$recipientList = $recipientCount === 1 ? $validEmails[0] : $recipientCount.' recipients';
return redirect()
->route('seller.business.brands.stats', [$business->slug, $brand->hashid, 'preset' => $statsData['preset']])
->with('success', 'Report emailed to '.$recipientList);
}
/**
* Extract stats data logic into reusable method
*/
private function getStatsData(Request $request, Business $business, Brand $brand): array
{
// Determine date range from request
$preset = $request->input('preset', 'last_30_days');
$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 'next_week':
$startDate = now()->addWeek()->startOfWeek();
$endDate = now()->addWeek()->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()->subDays(30);
$endDate = $request->input('end_date') ? \Carbon\Carbon::parse($request->input('end_date'))->endOfDay() : now();
break;
default: // last_30_days
$startDate = now()->subDays(30);
$endDate = now();
}
// Load brand with products
$brand->load(['products' => function ($query) {
$query->with(['orderItems.order']);
}]);
// Calculate overall brand metrics
$totalProducts = $brand->products->count();
$activeProducts = $brand->products->where('is_active', true)->count();
// Get all order items for this brand's products in the selected date range
$orderItems = \App\Models\OrderItem::whereHas('product', function ($query) use ($brand) {
$query->where('brand_id', $brand->id);
})
->whereHas('order', function ($query) use ($startDate, $endDate) {
$query->whereBetween('created_at', [$startDate, $endDate]);
})
->with('order', 'product')
->get();
// Calculate metrics
$totalOrders = $orderItems->pluck('order_id')->unique()->count();
$totalRevenue = $orderItems->sum('line_total');
$totalUnits = $orderItems->sum('quantity');
// Previous period comparison (same duration before start date)
$daysDiff = $startDate->diffInDays($endDate);
$previousStartDate = $startDate->copy()->subDays($daysDiff + 1);
$previousEndDate = $startDate->copy()->subDay();
$previousOrderItems = \App\Models\OrderItem::whereHas('product', function ($query) use ($brand) {
$query->where('brand_id', $brand->id);
})
->whereHas('order', function ($query) use ($previousStartDate, $previousEndDate) {
$query->whereBetween('created_at', [$previousStartDate, $previousEndDate]);
})
->get();
$previousRevenue = $previousOrderItems->sum('line_total');
$previousOrders = $previousOrderItems->pluck('order_id')->unique()->count();
// Calculate percent changes
$revenueChange = $previousRevenue > 0 ? (($totalRevenue - $previousRevenue) / $previousRevenue) * 100 : 0;
$ordersChange = $previousOrders > 0 ? (($totalOrders - $previousOrders) / $previousOrders) * 100 : 0;
// Average order value
$avgOrderValue = $totalOrders > 0 ? $totalRevenue / $totalOrders : 0;
// Revenue by day
$revenueByDay = $orderItems->groupBy(function ($item) {
return $item->order->created_at->format('Y-m-d');
})->map(function ($items) {
return $items->sum('line_total');
})->sortKeys();
// Top products by revenue (with varieties nested under parents)
// Filter to only show parent products (exclude varieties from top level)
$productStats = $brand->products
->filter(function ($product) {
return is_null($product->parent_product_id); // Only parent products
})
->map(function ($product) use ($startDate, $endDate) {
$items = $product->orderItems()
->whereHas('order', function ($query) use ($startDate, $endDate) {
$query->whereBetween('created_at', [$startDate, $endDate]);
})
->with('order')
->get();
$revenue = $items->sum('line_total');
$units = $items->sum('quantity');
$orders = $items->pluck('order_id')->unique()->count();
// Always get variety breakdown if product has varieties
$varietyStats = [];
if ($product->has_varieties) {
$varietyStats = $product->varieties->map(function ($variety) use ($startDate, $endDate) {
$varietyItems = $variety->orderItems()
->whereHas('order', function ($query) use ($startDate, $endDate) {
$query->whereBetween('created_at', [$startDate, $endDate]);
})
->with('order')
->get();
return [
'product' => $variety,
'revenue' => $varietyItems->sum('line_total'),
'units' => $varietyItems->sum('quantity'),
'orders' => $varietyItems->pluck('order_id')->unique()->count(),
];
})->sortByDesc('revenue');
}
return [
'product' => $product,
'revenue' => $revenue,
'units' => $units,
'orders' => $orders,
'varieties' => $varietyStats,
];
})->sortByDesc('revenue');
// Get best selling SKU
$bestSellingSku = $productStats->first();
return compact(
'business',
'brand',
'totalProducts',
'activeProducts',
'totalOrders',
'totalRevenue',
'totalUnits',
'avgOrderValue',
'revenueChange',
'ordersChange',
'revenueByDay',
'productStats',
'bestSellingSku',
'preset',
'startDate',
'endDate'
);
}
/**
* Remove the specified brand from storage
*/
public function destroy(Business $business, Brand $brand)
{
$this->authorize('delete', [$brand, $business]);
// Check if brand has any products with sales/orders
$hasProductsWithSales = $brand->products()
->whereHas('orderItems')
->exists();
if ($hasProductsWithSales) {
return redirect()
->route('seller.business.brands.index', $business->slug)
->with('error', 'Cannot delete brand - it has products with sales activity.');
}
// Delete logo and banner files
if ($brand->logo_path) {
Storage::delete($brand->logo_path);
}
if ($brand->banner_path) {
Storage::delete($brand->banner_path);
}
$brand->delete();
return redirect()
->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,
]);
}
/**
* Connect brand to CannaiQ API.
*
* Normalizes the brand name and stores as cannaiq_brand_key.
*/
public function cannaiqConnect(Request $request, Business $business, Brand $brand)
{
$this->authorize('update', [$brand, $business]);
$validated = $request->validate([
'brand_name' => 'required|string|max:255',
]);
$brand->connectToCannaiq($validated['brand_name']);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => 'Brand connected to CannaiQ',
'cannaiq_brand_key' => $brand->cannaiq_brand_key,
]);
}
return redirect()
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
->with('success', 'Brand connected to CannaiQ successfully!');
}
/**
* Disconnect brand from CannaiQ API.
*/
public function cannaiqDisconnect(Request $request, Business $business, Brand $brand)
{
$this->authorize('update', [$brand, $business]);
$brand->disconnectFromCannaiq();
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => 'Brand disconnected from CannaiQ',
]);
}
return redirect()
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
->with('success', 'Brand disconnected from CannaiQ.');
}
/**
* CannaiQ product mapping page.
*
* Shows Hub products for this brand and allows mapping to CannaiQ products.
*/
public function cannaiqMapping(Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
if (! $brand->isCannaiqConnected()) {
return redirect()
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
->with('error', 'Please connect this brand to CannaiQ first.');
}
$products = $brand->products()
->with('cannaiqMappings')
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.brands.cannaiq-mapping', [
'business' => $business,
'brand' => $brand,
'products' => $products,
]);
}
/**
* Map a Hub product to a CannaiQ product.
*/
public function cannaiqMapProduct(Request $request, Business $business, Brand $brand)
{
$this->authorize('update', [$brand, $business]);
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'cannaiq_product_id' => 'required|integer',
'cannaiq_product_name' => 'required|string|max:255',
'cannaiq_store_id' => 'nullable|string|max:255',
'cannaiq_store_name' => 'nullable|string|max:255',
]);
// Verify product belongs to this brand
$product = $brand->products()->findOrFail($validated['product_id']);
// Create mapping (ignore if already exists)
$mapping = $product->cannaiqMappings()->firstOrCreate(
['cannaiq_product_id' => $validated['cannaiq_product_id']],
[
'cannaiq_product_name' => $validated['cannaiq_product_name'],
'cannaiq_store_id' => $validated['cannaiq_store_id'] ?? null,
'cannaiq_store_name' => $validated['cannaiq_store_name'] ?? null,
]
);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'mapping' => $mapping,
]);
}
return redirect()
->route('seller.business.brands.cannaiq.mapping', [$business->slug, $brand->hashid])
->with('success', 'Product mapped successfully.');
}
/**
* Remove a product mapping.
*/
public function cannaiqUnmapProduct(Request $request, Business $business, Brand $brand, \App\Models\ProductCannaiqMapping $mapping)
{
$this->authorize('update', [$brand, $business]);
// Verify mapping belongs to a product of this brand
if ($mapping->product->brand_id !== $brand->id) {
abort(403);
}
$mapping->delete();
if ($request->wantsJson()) {
return response()->json(['success' => true]);
}
return redirect()
->route('seller.business.brands.cannaiq.mapping', [$business->slug, $brand->hashid])
->with('success', 'Mapping removed.');
}
/**
* Calculate store/distribution metrics for the brand.
*
* Returns metrics about store penetration, SKU stock rate, and average SKUs per store.
*/
private function calculateStoreStats(Brand $brand, int $days = 90): array
{
// Count unique buyer businesses (stores) that ordered this brand in current period
$currentStores = \App\Models\Order::whereHas('items.product', fn ($q) => $q->where('brand_id', $brand->id))
->where('created_at', '>=', now()->subDays($days))
->distinct('business_id')
->count('business_id');
// Previous period for comparison
$previousStores = \App\Models\Order::whereHas('items.product', fn ($q) => $q->where('brand_id', $brand->id))
->whereBetween('created_at', [now()->subDays($days * 2), now()->subDays($days)])
->distinct('business_id')
->count('business_id');
// SKU stock rate: % of brand's active SKUs that have been ordered
$activeSkus = $brand->products()->where('is_active', true)->count();
$orderedSkus = \App\Models\OrderItem::whereHas('product', fn ($q) => $q->where('brand_id', $brand->id))
->whereHas('order', fn ($q) => $q->where('created_at', '>=', now()->subDays($days)))
->distinct('product_id')
->count('product_id');
$stockRate = $activeSkus > 0 ? round(($orderedSkus / $activeSkus) * 100, 1) : 0;
// Avg SKUs per store
$avgSkusPerStore = $currentStores > 0 ? round($orderedSkus / $currentStores, 1) : 0;
return [
'currentStores' => $currentStores,
'storeChange' => $currentStores - $previousStores,
'stockRate' => $stockRate,
'avgSkusPerStore' => $avgSkusPerStore,
'orderedSkus' => $orderedSkus,
'activeSkus' => $activeSkus,
];
}
}