- 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)
1679 lines
67 KiB
PHP
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!');
|
|
}
|
|
}
|