- Use ILIKE instead of LIKE for PostgreSQL case-insensitive search - Add hashid fallback in Brand::getLogoUrl() and getBannerUrl() - Prevents route generation errors when hashid is missing from eager load - Reduce eager loading in index() to only needed columns - Add pagination to listings() method - Filter hashid at DB level instead of PHP collection
1045 lines
41 KiB
PHP
1045 lines
41 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Seller;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Batch;
|
|
use App\Models\BomTemplate;
|
|
use App\Models\Brand;
|
|
use App\Models\Business;
|
|
use App\Models\Component;
|
|
use App\Models\InventoryItem;
|
|
use App\Models\Menu;
|
|
use App\Models\Product;
|
|
use App\Models\ProductLine;
|
|
use App\Models\ProductPackaging;
|
|
use App\Models\Promotion;
|
|
use App\Models\Strain;
|
|
use App\Models\Unit;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Str;
|
|
|
|
class ProductController extends Controller
|
|
{
|
|
/**
|
|
* Display a listing of products for the business
|
|
*/
|
|
public function index(Request $request, Business $business)
|
|
{
|
|
// Get brand IDs to filter by (respects brand context switcher)
|
|
$brandIds = BrandSwitcherController::getFilteredBrandIds();
|
|
|
|
// Calculate missing BOM count for health alert
|
|
$missingBomCount = Product::whereIn('brand_id', $brandIds)
|
|
->where('is_assembly', true)
|
|
->doesntHave('components')
|
|
->count();
|
|
|
|
// Build query - show only parent products (varieties nested under parents)
|
|
// Optimize eager loading: only load what's needed for the list view
|
|
$query = Product::whereIn('brand_id', $brandIds)
|
|
->whereNull('parent_product_id')
|
|
->with([
|
|
'brand:id,hashid,name,logo_path,slug',
|
|
'varieties' => fn ($q) => $q->select('id', 'hashid', 'parent_product_id', 'name', 'sku', 'wholesale_price', 'is_active', 'image_path', 'brand_id', 'inventory_mode', 'quantity_on_hand'),
|
|
'varieties.brand:id,hashid,logo_path,slug',
|
|
])
|
|
->select('id', 'hashid', 'parent_product_id', 'brand_id', 'name', 'sku', 'wholesale_price', 'is_active', 'is_featured', 'is_assembly', 'description', 'image_path', 'inventory_mode', 'quantity_on_hand');
|
|
|
|
// Search filter (case-insensitive for PostgreSQL)
|
|
if ($request->filled('search')) {
|
|
$search = $request->search;
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('name', 'ILIKE', "%{$search}%")
|
|
->orWhere('sku', 'ILIKE', "%{$search}%")
|
|
->orWhere('description', 'ILIKE', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
// Type filter
|
|
if ($request->filled('type')) {
|
|
$query->where('type', $request->type);
|
|
}
|
|
|
|
// Category filter
|
|
if ($request->filled('category')) {
|
|
$query->where('category', $request->category);
|
|
}
|
|
|
|
// Brand filter (when viewing "All Brands")
|
|
if ($request->filled('brand_id')) {
|
|
$query->where('brand_id', $request->brand_id);
|
|
}
|
|
|
|
// Active status filter
|
|
if ($request->filled('status')) {
|
|
$query->where('is_active', $request->status === 'active');
|
|
}
|
|
|
|
// Low stock filter - now uses inventory_items table
|
|
// TODO: Implement low stock filter via inventory_items relationship
|
|
// if ($request->filled('stock') && $request->stock === 'low') { ... }
|
|
|
|
// Sort
|
|
$sortBy = $request->get('sort_by', 'name');
|
|
$sortDir = $request->get('sort_dir', 'asc');
|
|
$query->orderBy($sortBy, $sortDir);
|
|
|
|
// Paginate for performance (925 products = 400KB JSON payload)
|
|
$perPage = $request->get('per_page', 50);
|
|
$paginator = $query->paginate($perPage);
|
|
|
|
// Get paginated products and format as arrays for the view
|
|
$products = $paginator->getCollection()
|
|
->filter(fn ($product) => ! empty($product->hashid)) // Skip products without hashid (pre-migration)
|
|
->map(function ($product) use ($business) {
|
|
// TODO: Replace mock metrics with real data from analytics/order tracking
|
|
// Image URL uses product image or falls back to brand logo
|
|
$imageUrl = $product->getImageUrl('thumb');
|
|
|
|
// Map varieties for nested display (avoid N+1 queries)
|
|
$varieties = $product->varieties
|
|
->filter(fn ($variety) => ! empty($variety->hashid)) // Skip varieties without hashid
|
|
->map(function ($variety) use ($business) {
|
|
return [
|
|
'id' => $variety->id,
|
|
'hashid' => $variety->hashid,
|
|
'name' => $variety->name,
|
|
'sku' => $variety->sku ?? 'N/A',
|
|
'price' => $variety->wholesale_price ?? 0,
|
|
'status' => $variety->is_active ? 'active' : 'inactive',
|
|
'image_url' => $variety->getImageUrl('thumb'),
|
|
'edit_url' => route('seller.business.products.edit', [$business->slug, $variety->hashid]),
|
|
'units_sold' => 0, // TODO: Eager load with withSum() for performance
|
|
'stock' => $variety->available_quantity,
|
|
'is_unlimited' => $variety->isUnlimited(),
|
|
];
|
|
})->values()->toArray();
|
|
|
|
return [
|
|
'id' => $product->id,
|
|
'hashid' => $product->hashid,
|
|
'product' => $product->name,
|
|
'sku' => $product->sku ?? 'N/A',
|
|
'brand' => $product->brand->name ?? 'N/A',
|
|
'channel' => 'Marketplace', // TODO: Add channel field to products
|
|
'price' => $product->wholesale_price ?? 0,
|
|
'views' => rand(500, 3000), // TODO: Replace with real view tracking
|
|
'orders' => rand(10, 200), // TODO: Replace with real order count
|
|
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation
|
|
'status' => $product->is_active ? 'active' : 'inactive',
|
|
'visibility' => $product->is_featured ? 'featured' : ($product->is_active ? 'public' : 'private'),
|
|
'health' => $product->healthStatus(),
|
|
'issues' => $product->issues(),
|
|
'edit_url' => route('seller.business.products.edit', [$business->slug, $product->hashid]),
|
|
'image_url' => $imageUrl,
|
|
'hasImage' => ! empty($imageUrl),
|
|
'hasDescription' => ! empty($product->description),
|
|
'varieties' => $varieties,
|
|
'hasVarieties' => count($varieties) > 0,
|
|
];
|
|
});
|
|
|
|
// Pass pagination info to the view
|
|
$pagination = [
|
|
'current_page' => $paginator->currentPage(),
|
|
'last_page' => $paginator->lastPage(),
|
|
'per_page' => $paginator->perPage(),
|
|
'total' => $paginator->total(),
|
|
'from' => $paginator->firstItem(),
|
|
'to' => $paginator->lastItem(),
|
|
];
|
|
|
|
return view('seller.products.index', compact('business', 'products', 'missingBomCount', 'paginator', 'pagination'));
|
|
}
|
|
|
|
/**
|
|
* Show the form for creating a new product
|
|
*/
|
|
public function create(Business $business, Request $request)
|
|
{
|
|
$brands = $business->brands()->orderBy('name')->get();
|
|
|
|
if ($brands->isEmpty()) {
|
|
return back()->with('error', 'Please create at least one brand before adding products.');
|
|
}
|
|
|
|
// Pre-select brand from query parameter, session flash, or context switcher
|
|
$selectedBrand = null;
|
|
|
|
if ($request->has('brand')) {
|
|
$selectedBrand = $brands->firstWhere('hashid', $request->get('brand'));
|
|
} elseif (session()->has('brand')) {
|
|
$selectedBrand = $brands->firstWhere('hashid', session('brand'));
|
|
} else {
|
|
$selectedBrand = BrandSwitcherController::getSelectedBrand();
|
|
}
|
|
|
|
// Create a new Product instance with sensible defaults
|
|
$product = new Product([
|
|
'is_assembly' => false,
|
|
'has_varieties' => false,
|
|
'is_active' => false, // Start as draft
|
|
'is_featured' => false,
|
|
'sell_multiples' => false,
|
|
'fractional_quantities' => false,
|
|
'allow_sample' => false,
|
|
'is_sellable' => true,
|
|
'inventory_mode' => Product::INVENTORY_SIMPLE,
|
|
'brand_id' => $selectedBrand?->id,
|
|
]);
|
|
|
|
// Set the brand relationship if a brand was selected
|
|
if ($selectedBrand) {
|
|
$product->setRelation('brand', $selectedBrand);
|
|
}
|
|
|
|
// Business-scoped taxonomies
|
|
$categories = \App\Models\ProductCategory::where('business_id', $business->id)
|
|
->where('is_active', true)
|
|
->whereNull('parent_id') // Only top-level categories
|
|
->orderBy('sort_order')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
$subcategories = \App\Models\ProductCategory::where('business_id', $business->id)
|
|
->where('is_active', true)
|
|
->whereNotNull('parent_id') // Only subcategories
|
|
->orderBy('sort_order')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
$productLines = ProductLine::where('business_id', $business->id)->orderBy('name')->get();
|
|
|
|
// Global taxonomies (active only)
|
|
$strains = Strain::where('is_active', true)->orderBy('name')->get();
|
|
$packagings = ProductPackaging::where('is_active', true)->orderBy('name')->get();
|
|
$units = Unit::where('is_active', true)->orderBy('name')->get();
|
|
|
|
// Business-scoped components for BOM
|
|
$components = Component::where('business_id', $business->id)
|
|
->where('is_active', true)
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
// Product type options (for category dropdown)
|
|
$productTypes = [
|
|
'flower' => 'Flower',
|
|
'preroll' => 'Pre-Roll',
|
|
'vape' => 'Vape',
|
|
'concentrate' => 'Concentrate',
|
|
'edible' => 'Edible',
|
|
'topical' => 'Topical',
|
|
'tincture' => 'Tincture',
|
|
'other' => 'Other',
|
|
];
|
|
|
|
// Status options
|
|
$statusOptions = [
|
|
'active' => 'Active',
|
|
'inactive' => 'Inactive',
|
|
'discontinued' => 'Discontinued',
|
|
];
|
|
|
|
// Empty data for new product
|
|
$audits = collect();
|
|
$brandProducts = collect();
|
|
$metrics = [
|
|
'units_sold' => 0,
|
|
'current_stock' => 0,
|
|
'is_low_stock' => false,
|
|
'best_buyers' => collect(),
|
|
'last_purchase' => null,
|
|
];
|
|
|
|
$initialTab = 'overview';
|
|
|
|
// Brand field is not locked in general create
|
|
$brandLocked = false;
|
|
|
|
// Empty tab data for new product
|
|
$inventoryItems = collect();
|
|
$batches = collect();
|
|
$archivedVariants = collect();
|
|
$promotions = collect();
|
|
$productMenus = collect();
|
|
$availableMenus = collect();
|
|
$bomTemplates = collect();
|
|
$bomComponentCount = 0;
|
|
$variants = collect();
|
|
|
|
// Use the same edit view
|
|
return view('seller.products.edit', compact(
|
|
'business',
|
|
'product',
|
|
'brands',
|
|
'categories',
|
|
'subcategories',
|
|
'strains',
|
|
'packagings',
|
|
'units',
|
|
'productLines',
|
|
'components',
|
|
'productTypes',
|
|
'statusOptions',
|
|
'audits',
|
|
'initialTab',
|
|
'brandProducts',
|
|
'metrics',
|
|
'brandLocked',
|
|
// Tab data
|
|
'inventoryItems',
|
|
'batches',
|
|
'archivedVariants',
|
|
'promotions',
|
|
'productMenus',
|
|
'availableMenus',
|
|
'bomTemplates',
|
|
'bomComponentCount',
|
|
'variants'
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Show the form for creating a new product under a specific brand
|
|
*/
|
|
public function createForBrand(Business $business, Brand $brand)
|
|
{
|
|
// Verify brand belongs to this business
|
|
if ($brand->business_id !== $business->id) {
|
|
abort(403, 'This brand does not belong to your business');
|
|
}
|
|
|
|
// Create a new Product instance with brand pre-selected
|
|
$product = new Product([
|
|
'is_assembly' => false,
|
|
'has_varieties' => false,
|
|
'is_active' => false, // Start as draft
|
|
'is_featured' => false,
|
|
'sell_multiples' => false,
|
|
'fractional_quantities' => false,
|
|
'allow_sample' => false,
|
|
'is_sellable' => true,
|
|
'inventory_mode' => Product::INVENTORY_SIMPLE,
|
|
'brand_id' => $brand->id, // Pre-select this brand
|
|
]);
|
|
|
|
// Set the brand relationship so the view can access $product->brand
|
|
$product->setRelation('brand', $brand);
|
|
|
|
// Get brands list (only this business's brands)
|
|
$brands = $business->brands()->orderBy('name')->get();
|
|
|
|
// Business-scoped taxonomies
|
|
$categories = \App\Models\ProductCategory::where('business_id', $business->id)
|
|
->where('is_active', true)
|
|
->whereNull('parent_id')
|
|
->orderBy('sort_order')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
$subcategories = \App\Models\ProductCategory::where('business_id', $business->id)
|
|
->where('is_active', true)
|
|
->whereNotNull('parent_id')
|
|
->orderBy('sort_order')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
$productLines = ProductLine::where('business_id', $business->id)->orderBy('name')->get();
|
|
|
|
// Global taxonomies (active only)
|
|
$strains = Strain::where('is_active', true)->orderBy('name')->get();
|
|
$packagings = ProductPackaging::where('is_active', true)->orderBy('name')->get();
|
|
$units = Unit::where('is_active', true)->orderBy('name')->get();
|
|
|
|
// Business-scoped components for BOM
|
|
$components = Component::where('business_id', $business->id)
|
|
->where('is_active', true)
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
// Product type options
|
|
$productTypes = [
|
|
'flower' => 'Flower',
|
|
'preroll' => 'Pre-Roll',
|
|
'vape' => 'Vape',
|
|
'concentrate' => 'Concentrate',
|
|
'edible' => 'Edible',
|
|
'topical' => 'Topical',
|
|
'tincture' => 'Tincture',
|
|
'other' => 'Other',
|
|
];
|
|
|
|
// Status options
|
|
$statusOptions = [
|
|
'active' => 'Active',
|
|
'inactive' => 'Inactive',
|
|
'discontinued' => 'Discontinued',
|
|
];
|
|
|
|
// Empty data for new product
|
|
$audits = collect();
|
|
$brandProducts = collect();
|
|
$metrics = [
|
|
'units_sold' => 0,
|
|
'current_stock' => 0,
|
|
'is_low_stock' => false,
|
|
'best_buyers' => collect(),
|
|
'last_purchase' => null,
|
|
];
|
|
|
|
$initialTab = 'overview';
|
|
|
|
// Flag to lock the brand field
|
|
$brandLocked = true;
|
|
|
|
// Empty tab data for new product
|
|
$inventoryItems = collect();
|
|
$batches = collect();
|
|
$archivedVariants = collect();
|
|
$promotions = collect();
|
|
$productMenus = collect();
|
|
$availableMenus = collect();
|
|
$bomTemplates = collect();
|
|
$bomComponentCount = 0;
|
|
$variants = collect();
|
|
|
|
// Use the same edit view
|
|
return view('seller.products.edit', compact(
|
|
'business',
|
|
'product',
|
|
'brands',
|
|
'categories',
|
|
'subcategories',
|
|
'strains',
|
|
'packagings',
|
|
'units',
|
|
'productLines',
|
|
'components',
|
|
'productTypes',
|
|
'statusOptions',
|
|
'audits',
|
|
'initialTab',
|
|
'brandProducts',
|
|
'metrics',
|
|
'brandLocked',
|
|
// Tab data
|
|
'inventoryItems',
|
|
'batches',
|
|
'archivedVariants',
|
|
'promotions',
|
|
'productMenus',
|
|
'availableMenus',
|
|
'bomTemplates',
|
|
'bomComponentCount',
|
|
'variants'
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Store a newly created product in storage
|
|
*/
|
|
public function store(Request $request, Business $business)
|
|
{
|
|
$validated = $request->validate([
|
|
'brand_id' => 'required|exists:brands,id',
|
|
'name' => 'required|string|max:255',
|
|
'sku' => 'required|string|max:100|unique:products,sku',
|
|
'description' => ['nullable', 'string', 'max:255'],
|
|
'tagline' => ['nullable', 'string', 'max:100'],
|
|
'category_id' => 'required|exists:product_categories,id',
|
|
'subcategory_id' => 'nullable|exists:product_categories,id',
|
|
'type' => 'nullable|string|in:flower,pre-roll,concentrate,edible,beverage,topical,tincture,vaporizer,other',
|
|
'wholesale_price' => 'required|numeric|min:0',
|
|
'price_unit' => 'required|string|in:each,gram,oz,lb,kg,ml,l',
|
|
'net_weight' => 'nullable|numeric|min:0',
|
|
'weight_unit' => 'nullable|string|in:g,oz,lb,kg,ml,l',
|
|
'units_per_case' => 'nullable|integer|min:1',
|
|
'thc_percentage' => 'nullable|numeric|min:0|max:100',
|
|
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
|
|
'is_active' => 'boolean',
|
|
'is_featured' => 'boolean',
|
|
]);
|
|
|
|
// Verify brand belongs to this business
|
|
$brand = Brand::forBusiness($business)
|
|
->findOrFail($validated['brand_id']);
|
|
|
|
// Generate slug
|
|
$validated['slug'] = Str::slug($validated['name']);
|
|
|
|
// Handle checkbox fields - set to false if not present in request
|
|
$validated['is_active'] = $request->has('is_active');
|
|
$validated['is_featured'] = $request->has('is_featured');
|
|
|
|
// Create product
|
|
$product = Product::create($validated);
|
|
|
|
// Handle image uploads if present
|
|
if ($request->hasFile('images')) {
|
|
foreach ($request->file('images') as $index => $image) {
|
|
$path = $image->store('products', 'public');
|
|
$product->images()->create([
|
|
'path' => $path,
|
|
'type' => 'product',
|
|
'is_primary' => $index === 0,
|
|
]);
|
|
}
|
|
}
|
|
|
|
return redirect()
|
|
->route('seller.business.products.index', $business->slug)
|
|
->with('success', "Product '{$product->name}' created successfully!");
|
|
}
|
|
|
|
/**
|
|
* Show the form for editing the specified product
|
|
*/
|
|
public function edit(Request $request, Business $business, Product $product)
|
|
{
|
|
// Get initial tab from query string (for tab persistence after save)
|
|
$initialTab = $request->query('tab', 'overview');
|
|
// CRITICAL BUSINESS ISOLATION: Scope by business_id BEFORE finding by ID
|
|
$product = Product::whereHas('brand', function ($query) use ($business) {
|
|
$query->where('business_id', $business->id);
|
|
})
|
|
->with(['brand', 'unit', 'strain', 'packaging', 'varieties', 'images', 'parent.varieties'])
|
|
->findOrFail($product->id);
|
|
|
|
// Prepare dropdown data
|
|
$brands = Brand::where('business_id', $business->id)->get();
|
|
|
|
// Business-scoped taxonomies
|
|
$categories = \App\Models\ProductCategory::where('business_id', $business->id)
|
|
->where('is_active', true)
|
|
->whereNull('parent_id') // Only top-level categories
|
|
->orderBy('sort_order')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
$subcategories = \App\Models\ProductCategory::where('business_id', $business->id)
|
|
->where('is_active', true)
|
|
->whereNotNull('parent_id') // Only subcategories
|
|
->orderBy('sort_order')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
$productLines = ProductLine::where('business_id', $business->id)->orderBy('name')->get();
|
|
|
|
// Global taxonomies (active only)
|
|
$strains = Strain::where('is_active', true)->orderBy('name')->get();
|
|
$packagings = ProductPackaging::where('is_active', true)->orderBy('name')->get();
|
|
$units = Unit::where('is_active', true)->orderBy('name')->get();
|
|
|
|
// Business-scoped components for BOM
|
|
$components = Component::where('business_id', $business->id)
|
|
->where('is_active', true)
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
// Product type options (for category dropdown)
|
|
$productTypes = [
|
|
'flower' => 'Flower',
|
|
'preroll' => 'Pre-Roll',
|
|
'vape' => 'Vape',
|
|
'concentrate' => 'Concentrate',
|
|
'edible' => 'Edible',
|
|
'topical' => 'Topical',
|
|
'tincture' => 'Tincture',
|
|
'other' => 'Other',
|
|
];
|
|
|
|
// Status options
|
|
$statusOptions = [
|
|
'active' => 'Active',
|
|
'inactive' => 'Inactive',
|
|
'discontinued' => 'Discontinued',
|
|
];
|
|
|
|
// Get audit history for this product
|
|
$audits = $product->audits()->with('user')->latest()->paginate(10);
|
|
|
|
// Get other products for the same brand (for dropdown)
|
|
$brandProducts = Product::where('brand_id', $product->brand_id)
|
|
->where('id', '!=', $product->id)
|
|
->orderBy('name')
|
|
->get(['id', 'hashid', 'name', 'sku']);
|
|
|
|
// Calculate product metrics
|
|
$metrics = [
|
|
'units_sold' => \App\Models\OrderItem::where('product_id', $product->id)
|
|
->sum('quantity'),
|
|
'current_stock' => $product->quantity_on_hand ?? 0,
|
|
'is_low_stock' => false, // TODO: Implement low stock threshold
|
|
'best_buyers' => \App\Models\OrderItem::where('product_id', $product->id)
|
|
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
|
->join('businesses', 'orders.business_id', '=', 'businesses.id')
|
|
->selectRaw('businesses.name, SUM(order_items.quantity) as total_quantity')
|
|
->groupBy('businesses.id', 'businesses.name')
|
|
->orderByDesc('total_quantity')
|
|
->limit(3)
|
|
->get(),
|
|
'last_purchase' => \App\Models\OrderItem::where('product_id', $product->id)
|
|
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
|
->join('businesses', 'orders.business_id', '=', 'businesses.id')
|
|
->orderBy('orders.created_at', 'desc')
|
|
->first(['orders.created_at as purchased_at', 'businesses.name as buyer_name']),
|
|
];
|
|
|
|
// Check if another product in this brand is already featured
|
|
$existingFeaturedProduct = Product::where('brand_id', $product->brand_id)
|
|
->where('is_featured', true)
|
|
->where('id', '!=', $product->id)
|
|
->first(['id', 'name', 'sku']);
|
|
|
|
// ===== TAB DATA =====
|
|
|
|
// Inventory Tab: Linked inventory items (if inventory module enabled)
|
|
$inventoryItems = $business->has_inventory
|
|
? InventoryItem::where('business_id', $business->id)
|
|
->where('product_id', $product->id)
|
|
->with('location')
|
|
->limit(10)
|
|
->get()
|
|
: collect();
|
|
|
|
// Batch Management Tab: Batches for this product
|
|
$batches = Batch::where('business_id', $business->id)
|
|
->where('product_id', $product->id)
|
|
->with(['lab', 'coaFiles'])
|
|
->orderByDesc('created_at')
|
|
->get();
|
|
|
|
// Archived Tab: Archived/deleted variants
|
|
$archivedVariants = $product->has_varieties
|
|
? Product::where('parent_product_id', $product->id)
|
|
->where('is_active', false)
|
|
->withTrashed()
|
|
->get()
|
|
: collect();
|
|
|
|
// Promotions Tab: Promotions including this product
|
|
$promotions = $business->has_marketing
|
|
? Promotion::where('business_id', $business->id)
|
|
->whereHas('products', function ($q) use ($product) {
|
|
$q->where('products.id', $product->id);
|
|
})
|
|
->orderByDesc('created_at')
|
|
->get()
|
|
: collect();
|
|
|
|
// Listings Tab & Custom Menus Tab: Menus this product is on
|
|
$productMenus = Menu::where('business_id', $business->id)
|
|
->whereHas('products', function ($q) use ($product) {
|
|
$q->where('products.id', $product->id);
|
|
})
|
|
->get();
|
|
|
|
// Custom Menus Tab: Available menus to add product to
|
|
$availableMenus = Menu::where('business_id', $business->id)
|
|
->whereDoesntHave('products', function ($q) use ($product) {
|
|
$q->where('products.id', $product->id);
|
|
})
|
|
->where('status', 'active')
|
|
->get();
|
|
|
|
// Templates Tab: Available BOM templates
|
|
$bomTemplates = BomTemplate::where('business_id', $business->id)
|
|
->where('is_active', true)
|
|
->where(function ($q) use ($product) {
|
|
$q->whereNull('brand_id')
|
|
->orWhere('brand_id', $product->brand_id);
|
|
})
|
|
->with('items.component')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
// Templates Tab: Current BOM component count
|
|
$bomComponentCount = $product->components()->count();
|
|
|
|
// Bulk Add/Edit Tab: Active variants
|
|
$variants = Product::where('parent_product_id', $product->id)
|
|
->where('is_active', true)
|
|
->get();
|
|
|
|
return view('seller.products.edit', compact(
|
|
'business',
|
|
'product',
|
|
'brands',
|
|
'categories',
|
|
'subcategories',
|
|
'strains',
|
|
'packagings',
|
|
'units',
|
|
'productLines',
|
|
'components',
|
|
'productTypes',
|
|
'statusOptions',
|
|
'audits',
|
|
'initialTab',
|
|
'brandProducts',
|
|
'metrics',
|
|
'existingFeaturedProduct',
|
|
// Tab data
|
|
'inventoryItems',
|
|
'batches',
|
|
'archivedVariants',
|
|
'promotions',
|
|
'productMenus',
|
|
'availableMenus',
|
|
'bomTemplates',
|
|
'bomComponentCount',
|
|
'variants'
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Show the form for editing the specified product (edit1 - top header layout)
|
|
*/
|
|
public function edit1(Business $business, Product $product)
|
|
{
|
|
// CRITICAL BUSINESS ISOLATION: Scope by business_id BEFORE finding by ID
|
|
$product = Product::whereHas('brand', function ($query) use ($business) {
|
|
$query->where('business_id', $business->id);
|
|
})
|
|
->with(['brand', 'unit', 'strain', 'packaging', 'varieties', 'images'])
|
|
->findOrFail($product->id);
|
|
|
|
// Prepare dropdown data
|
|
$brands = Brand::where('business_id', $business->id)->get();
|
|
|
|
// Business-scoped taxonomies
|
|
$categories = \App\Models\ProductCategory::where('business_id', $business->id)
|
|
->where('is_active', true)
|
|
->whereNull('parent_id') // Only top-level categories
|
|
->orderBy('sort_order')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
$subcategories = \App\Models\ProductCategory::where('business_id', $business->id)
|
|
->where('is_active', true)
|
|
->whereNotNull('parent_id') // Only subcategories
|
|
->orderBy('sort_order')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
$productLines = ProductLine::where('business_id', $business->id)->orderBy('name')->get();
|
|
|
|
// Global taxonomies (active only)
|
|
$strains = Strain::where('is_active', true)->orderBy('name')->get();
|
|
$packagings = ProductPackaging::where('is_active', true)->orderBy('name')->get();
|
|
$units = Unit::where('is_active', true)->orderBy('name')->get();
|
|
|
|
// Product type options (for category dropdown)
|
|
$productTypes = [
|
|
'flower' => 'Flower',
|
|
'preroll' => 'Pre-Roll',
|
|
'vape' => 'Vape',
|
|
'concentrate' => 'Concentrate',
|
|
'edible' => 'Edible',
|
|
'topical' => 'Topical',
|
|
'tincture' => 'Tincture',
|
|
'other' => 'Other',
|
|
];
|
|
|
|
// Status options
|
|
$statusOptions = [
|
|
'active' => 'Active',
|
|
'inactive' => 'Inactive',
|
|
'discontinued' => 'Discontinued',
|
|
];
|
|
|
|
// Get audit history for this product
|
|
$audits = $product->audits()->with('user')->latest()->paginate(10);
|
|
|
|
return view('seller.products.edit1', compact(
|
|
'business',
|
|
'product',
|
|
'brands',
|
|
'categories',
|
|
'subcategories',
|
|
'strains',
|
|
'packagings',
|
|
'units',
|
|
'productLines',
|
|
'productTypes',
|
|
'statusOptions',
|
|
'audits'
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Get validation rules organized by tab
|
|
*/
|
|
private function getTabValidationRules(): array
|
|
{
|
|
return [
|
|
'overview' => [
|
|
'brand_id' => 'required|exists:brands,id',
|
|
'name' => 'required|string|max:255',
|
|
'sku' => 'required|string|max:100',
|
|
'barcode' => 'nullable|string|max:100',
|
|
'category_id' => 'required|exists:product_categories,id',
|
|
'subcategory_id' => 'nullable|exists:product_categories,id',
|
|
'type' => 'nullable|string',
|
|
'product_line_id' => 'nullable|exists:product_lines,id',
|
|
'unit_id' => 'required|exists:units,id',
|
|
'sell_multiples' => 'nullable|boolean',
|
|
'fractional_quantities' => 'nullable|boolean',
|
|
'allow_sample' => 'nullable|boolean',
|
|
'is_active' => 'nullable|boolean',
|
|
'is_featured' => 'nullable|boolean',
|
|
'has_varieties' => 'nullable|boolean',
|
|
],
|
|
'pricing' => [
|
|
'cost_per_unit' => 'nullable|numeric|min:0',
|
|
'wholesale_price' => 'nullable|numeric|min:0',
|
|
'msrp' => 'nullable|numeric|min:0',
|
|
'net_weight' => 'nullable|numeric|min:0',
|
|
'weight_unit' => 'nullable|string|max:20',
|
|
'units_per_case' => 'nullable|integer|min:0',
|
|
'cased_qty' => 'nullable|integer|min:0',
|
|
'boxed_qty' => 'nullable|integer|min:0',
|
|
'min_order_qty' => 'nullable|integer|min:0',
|
|
'max_order_qty' => 'nullable|integer|min:0',
|
|
'is_case' => 'nullable|boolean',
|
|
'is_box' => 'nullable|boolean',
|
|
],
|
|
'inventory' => [
|
|
'status' => 'required|string',
|
|
'launch_date' => 'nullable|date',
|
|
// Sales Suite v1 inventory mode: fixed, batch, unlimited
|
|
'inventory_mode' => 'nullable|string|in:fixed,batch,unlimited',
|
|
'total_available_inventory' => 'nullable|integer|min:0',
|
|
'quantity_on_hand' => 'nullable|integer|min:0',
|
|
'quantity_allocated' => 'nullable|integer|min:0',
|
|
// Order quantity limits
|
|
'min_order_qty' => 'nullable|integer|min:1',
|
|
'max_order_qty' => 'nullable|integer|min:1',
|
|
// Low stock alerts
|
|
'low_stock_alert_enabled' => 'nullable|boolean',
|
|
'low_stock_threshold' => 'nullable|integer|min:0',
|
|
'sync_bamboo' => 'nullable|boolean',
|
|
'is_assembly' => 'nullable|boolean',
|
|
'show_inventory_to_buyers' => 'nullable|boolean',
|
|
'packaging_id' => 'nullable|exists:product_packagings,id',
|
|
'has_varieties' => 'nullable|boolean',
|
|
],
|
|
'cannabis' => [
|
|
'thc_percentage' => 'nullable|numeric|min:0|max:100',
|
|
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
|
|
'strain_id' => 'nullable|exists:strains,id',
|
|
'thc_content_mg' => 'nullable|numeric|min:0',
|
|
'cbd_content_mg' => 'nullable|numeric|min:0',
|
|
'strain_value' => 'nullable|numeric|min:0',
|
|
'ingredients' => 'nullable|string',
|
|
'effects' => 'nullable|string',
|
|
'dosage_guidelines' => 'nullable|string',
|
|
'arz_total_weight' => 'nullable|numeric|min:0',
|
|
'arz_usable_mmj' => 'nullable|numeric|min:0',
|
|
'metrc_id' => 'nullable|string|max:255',
|
|
'license_number' => 'nullable|string|max:255',
|
|
'harvest_date' => 'nullable|date',
|
|
'package_date' => 'nullable|date',
|
|
'test_date' => 'nullable|date',
|
|
],
|
|
'content' => [
|
|
'description' => ['nullable', 'string', 'max:255'],
|
|
'tagline' => ['nullable', 'string', 'max:100'],
|
|
'long_description' => ['nullable', 'string', 'max:500'],
|
|
'consumer_long_description' => ['nullable', 'string', 'max:500'],
|
|
'buyer_long_description' => ['nullable', 'string', 'max:500'],
|
|
'product_link' => 'nullable|url|max:255',
|
|
'creatives_json' => 'nullable|json',
|
|
'seo_title' => ['nullable', 'string', 'max:70'],
|
|
'seo_description' => ['nullable', 'string', 'max:160'],
|
|
],
|
|
'advanced' => [
|
|
'is_sellable' => 'nullable|boolean',
|
|
'is_fpr' => 'nullable|boolean',
|
|
'is_raw_material' => 'nullable|boolean',
|
|
'brand_display_order' => 'nullable|integer|min:0',
|
|
'parent_product_id' => 'nullable|exists:products,id',
|
|
'category' => 'nullable|string|max:100',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Update the specified product in storage
|
|
*/
|
|
public function update(Request $request, Business $business, Product $product)
|
|
{
|
|
// Get all validation rules organized by tab
|
|
$tabRules = $this->getTabValidationRules();
|
|
|
|
// Check if this is a per-tab save
|
|
$saveTab = $request->input('_tab');
|
|
|
|
if ($saveTab && isset($tabRules[$saveTab])) {
|
|
// Per-tab save: only validate fields for this tab
|
|
$rules = $tabRules[$saveTab];
|
|
} else {
|
|
// Full save: merge all tab rules
|
|
$rules = array_merge(...array_values($tabRules));
|
|
}
|
|
|
|
$validated = $request->validate($rules);
|
|
|
|
// Define checkbox fields per tab
|
|
$checkboxesByTab = [
|
|
'overview' => ['is_active', 'is_featured', 'sell_multiples', 'fractional_quantities', 'allow_sample', 'has_varieties'],
|
|
'pricing' => ['is_case', 'is_box'],
|
|
'inventory' => ['sync_bamboo', 'low_stock_alert_enabled', 'is_assembly', 'show_inventory_to_buyers', 'has_varieties'],
|
|
'advanced' => ['is_sellable', 'is_fpr', 'is_raw_material'],
|
|
];
|
|
|
|
// Convert checkboxes to boolean - only for fields in current validation scope
|
|
$checkboxesToProcess = $saveTab && isset($checkboxesByTab[$saveTab])
|
|
? $checkboxesByTab[$saveTab]
|
|
: array_merge(...array_values($checkboxesByTab));
|
|
|
|
foreach ($checkboxesToProcess as $checkbox) {
|
|
if (array_key_exists($checkbox, $rules)) {
|
|
// Use boolean() for fields that send actual values (hidden inputs with 0/1)
|
|
// Use has() for traditional checkboxes that are absent when unchecked
|
|
$useBoolean = in_array($checkbox, ['is_assembly', 'is_raw_material', 'is_active', 'is_featured', 'low_stock_alert_enabled', 'has_varieties']);
|
|
$validated[$checkbox] = $useBoolean
|
|
? $request->boolean($checkbox)
|
|
: $request->has($checkbox);
|
|
}
|
|
}
|
|
|
|
// Store creatives JSON (content tab)
|
|
if (isset($validated['creatives_json'])) {
|
|
$validated['creatives'] = $validated['creatives_json'];
|
|
unset($validated['creatives_json']);
|
|
}
|
|
|
|
// Assembly products: cost is derived from BOM, ignore manual cost_per_unit
|
|
if (isset($validated['is_assembly']) && $validated['is_assembly']) {
|
|
unset($validated['cost_per_unit']);
|
|
}
|
|
|
|
// CRITICAL BUSINESS ISOLATION: Ensure the product belongs to this business through brand relationship
|
|
$product = Product::whereHas('brand', function ($query) use ($business) {
|
|
$query->where('business_id', $business->id);
|
|
})
|
|
->findOrFail($product->id);
|
|
|
|
// Verify brand belongs to business (only when brand_id is being updated)
|
|
if (isset($validated['brand_id'])) {
|
|
Brand::where('id', $validated['brand_id'])
|
|
->where('business_id', $business->id)
|
|
->firstOrFail();
|
|
}
|
|
|
|
// BUSINESS RULE: Only one featured product per brand at a time
|
|
if ($request->has('is_featured') && $request->boolean('is_featured')) {
|
|
$brandId = $validated['brand_id'] ?? $product->brand_id;
|
|
$existingFeaturedProduct = Product::where('brand_id', $brandId)
|
|
->where('is_featured', true)
|
|
->where('id', '!=', $product->id)
|
|
->first();
|
|
|
|
if ($existingFeaturedProduct) {
|
|
if ($request->has('force_feature') && $request->boolean('force_feature')) {
|
|
$existingFeaturedProduct->update(['is_featured' => false]);
|
|
} else {
|
|
return redirect()
|
|
->back()
|
|
->withInput()
|
|
->with('existing_featured_product', $existingFeaturedProduct)
|
|
->withErrors(['is_featured' => "Only one product can be featured per brand at a time. '{$existingFeaturedProduct->name}' (SKU: {$existingFeaturedProduct->sku}) is currently featured."]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update product
|
|
$product->update($validated);
|
|
|
|
// Return JSON for Precognition requests
|
|
if ($request->wantsJson()) {
|
|
return response()->json([
|
|
'message' => 'Product updated successfully!',
|
|
]);
|
|
}
|
|
|
|
// Redirect back to edit page with preserved tab and success message
|
|
$tab = $request->input('_tab', $request->input('tab', 'overview'));
|
|
|
|
return redirect()
|
|
->route('seller.business.products.edit', [
|
|
'business' => $business->slug,
|
|
'product' => $product->hashid,
|
|
'tab' => $tab,
|
|
])
|
|
->with('success', 'Product updated successfully!');
|
|
}
|
|
|
|
/**
|
|
* Remove the specified product from storage
|
|
*/
|
|
public function destroy(Business $business, Product $product)
|
|
{
|
|
// Verify product belongs to this business
|
|
if (! $product->belongsToBusiness($business)) {
|
|
abort(403, 'This product does not belong to your business');
|
|
}
|
|
|
|
// Prevent deletion of products with sales or inventory history
|
|
if ($product->isLocked()) {
|
|
return redirect()
|
|
->back()
|
|
->with('error', "Cannot delete product: {$product->locked_reason}. Products with sales or inventory history cannot be deleted.");
|
|
}
|
|
|
|
$name = $product->name;
|
|
$product->delete();
|
|
|
|
return redirect()
|
|
->route('seller.business.products.index', $business->slug)
|
|
->with('success', "Product '{$name}' deleted successfully!");
|
|
}
|
|
|
|
/**
|
|
* Display the product listings dashboard focused on conversion and performance
|
|
*/
|
|
public function listings(Request $request, Business $business)
|
|
{
|
|
// Get brand IDs to filter by (respects brand context switcher)
|
|
$brandIds = BrandSwitcherController::getFilteredBrandIds();
|
|
|
|
// Build query for active listings with pagination
|
|
$perPage = $request->get('per_page', 50);
|
|
$paginator = Product::whereIn('brand_id', $brandIds)
|
|
->whereNotNull('hashid') // Filter at DB level instead of PHP
|
|
->with(['brand:id,name'])
|
|
->select('id', 'hashid', 'brand_id', 'name', 'sku', 'wholesale_price', 'is_active', 'is_featured')
|
|
->orderBy('name')
|
|
->paginate($perPage);
|
|
|
|
$products = $paginator->getCollection()
|
|
->map(function ($product) use ($business) {
|
|
// TODO: Replace mock metrics with real data from analytics/order tracking
|
|
return [
|
|
'id' => $product->id,
|
|
'hashid' => $product->hashid,
|
|
'product' => $product->name,
|
|
'sku' => $product->sku ?? 'N/A',
|
|
'brand' => $product->brand->name ?? 'N/A',
|
|
'channel' => 'Marketplace', // TODO: Add channel field to products
|
|
'price' => $product->wholesale_price ?? 0,
|
|
'views' => rand(500, 3000), // TODO: Replace with real view tracking
|
|
'orders' => rand(10, 200), // TODO: Replace with real order count
|
|
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation
|
|
'status' => $product->is_active ? 'active' : 'inactive',
|
|
'visibility' => $product->is_featured ? 'featured' : ($product->is_active ? 'public' : 'private'),
|
|
'edit_url' => route('seller.business.products.edit', [$business->slug, $product->hashid]),
|
|
];
|
|
});
|
|
|
|
return view('seller.products.listings', compact('business', 'products', 'paginator'));
|
|
}
|
|
}
|