Files
hub/app/Http/Controllers/Seller/BrandController.php
kelly 11c67f491c feat: MySQL data import and parallel test fixes
- Import Cannabrands data from MySQL to PostgreSQL (strains, categories,
  companies, locations, contacts, products, images, invoices)
- Make migrations idempotent for parallel test execution
- Add ParallelTesting setup for separate test databases per process
- Update product type constraint for imported data
- Keep MysqlImport seeders for reference (data already in PG)
2025-12-04 19:26:38 -07:00

1679 lines
67 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\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\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();
return view('seller.brands.index', compact('business', 'brands'));
}
/**
* 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]);
// Load relationships
$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
$brands = $business->brands()
->where('is_active', true)
->withCount('products')
->orderBy('name')
->get();
// Load products for this brand (newest first) with pagination
$perPage = $request->get('per_page', 50);
$productsPaginator = $brand->products()
->with('images')
->orderBy('created_at', 'desc')
->paginate($perPage);
$products = $productsPaginator->getCollection()
->map(function ($product) use ($business, $brand) {
// Set brand relationship so getImageUrl() can fall back to brand logo
$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]),
];
});
// 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,
'products' => $products,
'productsPagination' => $productsPagination,
'productsPaginator' => $productsPaginator,
'collections' => collect(), // Placeholder for future collections feature
]));
}
/**
* 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]);
return view('seller.brands.edit', compact('business', 'brand'));
}
/**
* 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');
// 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());
// ═══════════════════════════════════════════════════════════════
// 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,
'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
{
// Eager load products with their varieties
$brand->load([
'products' => function ($query) {
$query->with('varieties');
},
]);
// 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
// WITH eager loading to prevent N+1 queries
$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.business', '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();
// Build a map of product_id => order items for efficient lookup
$productOrderItemsMap = $orderItems->groupBy('product_id');
// 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 ($productOrderItemsMap) {
// Get order items for this product from the map (no additional query!)
$items = $productOrderItemsMap->get($product->id, collect());
$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 ($productOrderItemsMap) {
// Get order items for this variety from the map (no additional query!)
$varietyItems = $productOrderItemsMap->get($variety->id, collect());
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();
// Top buyers by revenue
$topBuyers = $orderItems->groupBy(function ($item) {
return $item->order->business_id;
})->map(function ($items) {
$business = $items->first()->order->business;
return [
'business' => $business,
'revenue' => $items->sum('line_total'),
'orders' => $items->pluck('order_id')->unique()->count(),
'units' => $items->sum('quantity'),
];
})->sortByDesc('revenue')->take(5);
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!');
}
}