Files
hub/app/Http/Controllers/Seller/ProductController.php
kelly b33ebac9bf fix: make product search case-insensitive and add defensive hashid checks
- 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
2025-12-08 17:06:25 -07:00

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'));
}
}