- 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
2284 lines
91 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|