Files
hub/app/Http/Controllers/Seller/PromotionController.php
kelly 496ca61489 feat: dashboard redesign and sidebar consolidation
- Redesign dashboard as daily briefing format with action-first layout
- Consolidate sidebar menu structure (Dashboard as single link)
- Fix CRM form styling to use consistent UI patterns
- Add PWA icons and push notification groundwork
- Update SuiteMenuResolver for cleaner navigation
2025-12-14 03:41:31 -07:00

399 lines
15 KiB
PHP

<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Product;
use App\Models\PromoRecommendation;
use App\Models\Promotion;
use App\Services\Promo\PromoCalculator;
use Illuminate\Http\Request;
class PromotionController extends Controller
{
public function __construct(
protected PromoCalculator $promoCalculator
) {}
public function index(Request $request, Business $business)
{
// TODO: Future deprecation - This global promotions page will be replaced by brand-scoped promotions
// Once brand-scoped promotions are stable and rolled out, this route should redirect to:
// return redirect()->route('seller.business.brands.promotions.index', [$business, $defaultBrand]);
// Where $defaultBrand is determined by business context or user preference
// Get brands for filter dropdown
$brands = \App\Models\Brand::where('business_id', $business->id)
->orderBy('name')
->get(['id', 'name', 'hashid']);
$query = Promotion::where('business_id', $business->id)
->withCount('products');
// Filter by brand
if ($request->filled('brand')) {
$query->where('brand_id', $request->brand);
}
// Filter by status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$promotions = $query->orderBy('created_at', 'desc')->get();
// Load pending recommendations with product data
// Gracefully handle if promo_recommendations table doesn't exist yet
$recommendations = collect();
if (\Schema::hasTable('promo_recommendations')) {
$recommendations = PromoRecommendation::where('business_id', $business->id)
->pending()
->notExpired()
->with('product')
->orderByRaw("CASE WHEN priority = 'high' THEN 1 WHEN priority = 'medium' THEN 2 ELSE 3 END")
->orderBy('created_at', 'desc')
->get();
}
return view('seller.promotions.index', compact('business', 'promotions', 'recommendations', 'brands'));
}
public function create(Business $business)
{
$brands = \App\Models\Brand::where('business_id', $business->id)
->orderBy('name')
->get();
// Products are loaded via API search (/search/products?brand_id=...) for better performance
return view('seller.promotions.create', compact('business', 'brands'));
}
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'type' => 'required|in:percentage,fixed,bogo,price_override',
'discount_value' => 'nullable|numeric|min:0',
'starts_at' => 'required|date',
'ends_at' => 'required|date|after:starts_at',
'status' => 'required|in:active,scheduled,draft',
'brand_id' => 'required|exists:brands,id',
'product_ids' => 'nullable|array',
'product_ids.*' => 'integer|exists:products,id',
'recommendation_id' => 'nullable|integer|exists:promo_recommendations,id',
]);
// Validate margins if products are selected (skip for drafts without products)
$validationResult = null;
if (! empty($validated['product_ids']) && $validated['type'] !== 'bogo') {
$products = Product::whereIn('id', $validated['product_ids'])->get();
$validationResult = $this->promoCalculator->validate(
$validated['type'],
$products,
['discount_value' => $validated['discount_value'] ?? 0]
);
if (! $validationResult->approved) {
return back()
->withInput()
->withErrors(['discount_value' => $validationResult->reason]);
}
}
// Special validation for BOGO - check first product
if ($validated['type'] === 'bogo' && ! empty($validated['product_ids'])) {
$product = Product::find($validated['product_ids'][0]);
if ($product) {
// Default BOGO is Buy 1 Get 1 Free
$validationResult = $this->promoCalculator->checkBogo($product, 1, 1, 100);
if (! $validationResult->approved) {
return back()
->withInput()
->withErrors(['type' => $validationResult->reason]);
}
}
}
// Build promotion data with margin validation fields
$promotionData = [
'business_id' => $business->id,
'brand_id' => $validated['brand_id'],
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'type' => $validated['type'],
'discount_value' => $validated['discount_value'] ?? null,
'starts_at' => $validated['starts_at'],
'ends_at' => $validated['ends_at'],
'status' => $validated['status'],
];
// Add margin validation data if validation passed
if ($validationResult && $validationResult->approved) {
$promotionData['company_margin'] = round($validationResult->companyMargin * 100, 2);
$promotionData['dispensary_margin'] = round($validationResult->dispensaryMargin * 100, 2);
$promotionData['is_margin_safe'] = true;
$promotionData['margin_validated_at'] = now();
}
$promotion = Promotion::create($promotionData);
if (! empty($validated['product_ids'])) {
$promotion->products()->attach($validated['product_ids']);
}
// Mark recommendation as accepted if provided
if (! empty($validated['recommendation_id'])) {
$recommendation = \App\Models\PromoRecommendation::where('business_id', $business->id)
->find($validated['recommendation_id']);
if ($recommendation && $recommendation->isPending()) {
$recommendation->accept($promotion);
}
}
return redirect()->route('seller.business.promotions.index', $business->slug)
->with('success', 'Promotion created successfully');
}
public function show(Business $business, int $id)
{
$promotion = Promotion::where('business_id', $business->id)
->with('products')
->findOrFail($id);
return view('seller.promotions.show', compact('business', 'promotion'));
}
public function edit(Business $business, int $id)
{
$promotion = Promotion::where('business_id', $business->id)
->with('products')
->findOrFail($id);
$brands = \App\Models\Brand::where('business_id', $business->id)
->orderBy('name')
->get();
$selectedProductIds = $promotion->products->pluck('id')->toArray();
return view('seller.promotions.edit', compact('business', 'promotion', 'brands', 'selectedProductIds'));
}
public function update(Request $request, Business $business, int $id)
{
$promotion = Promotion::where('business_id', $business->id)->findOrFail($id);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'type' => 'required|in:percentage,fixed,bogo,price_override',
'discount_value' => 'nullable|numeric|min:0',
'starts_at' => 'required|date',
'ends_at' => 'required|date|after:starts_at',
'status' => 'required|in:active,scheduled,draft,expired',
'brand_id' => 'required|exists:brands,id',
'product_ids' => 'nullable|array',
'product_ids.*' => 'integer|exists:products,id',
]);
// Validate margins if products are selected
$productIds = $validated['product_ids'] ?? $promotion->products->pluck('id')->toArray();
$validationResult = null;
if (! empty($productIds) && $validated['type'] !== 'bogo') {
$products = Product::whereIn('id', $productIds)->get();
$validationResult = $this->promoCalculator->validate(
$validated['type'],
$products,
['discount_value' => $validated['discount_value'] ?? 0]
);
if (! $validationResult->approved) {
return back()
->withInput()
->withErrors(['discount_value' => $validationResult->reason]);
}
}
// Special validation for BOGO
if ($validated['type'] === 'bogo' && ! empty($productIds)) {
$product = Product::find($productIds[0]);
if ($product) {
$validationResult = $this->promoCalculator->checkBogo($product, 1, 1, 100);
if (! $validationResult->approved) {
return back()
->withInput()
->withErrors(['type' => $validationResult->reason]);
}
}
}
// Build update data with margin validation fields
$updateData = [
'brand_id' => $validated['brand_id'],
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'type' => $validated['type'],
'discount_value' => $validated['discount_value'] ?? null,
'starts_at' => $validated['starts_at'],
'ends_at' => $validated['ends_at'],
'status' => $validated['status'],
];
// Update margin validation data if validation passed
if ($validationResult && $validationResult->approved) {
$updateData['company_margin'] = round($validationResult->companyMargin * 100, 2);
$updateData['dispensary_margin'] = round($validationResult->dispensaryMargin * 100, 2);
$updateData['is_margin_safe'] = true;
$updateData['margin_validated_at'] = now();
}
$promotion->update($updateData);
if (isset($validated['product_ids'])) {
$promotion->products()->sync($validated['product_ids']);
}
return redirect()->route('seller.business.promotions.index', $business->slug)
->with('success', 'Promotion updated successfully');
}
public function destroy(Business $business, int $id)
{
$promotion = Promotion::where('business_id', $business->id)->findOrFail($id);
$promotion->delete();
return redirect()->route('seller.business.promotions.index', $business->slug)
->with('success', 'Promotion deleted successfully');
}
public function end(Request $request, Business $business, int $id)
{
$promotion = Promotion::where('business_id', $business->id)->findOrFail($id);
$promotion->update([
'ends_at' => now(),
'status' => 'expired',
]);
return redirect()->route('seller.business.promotions.index', $business->slug)
->with('success', 'Promotion ended successfully');
}
/**
* AJAX endpoint: Check margins for a promotion in real-time.
*/
public function checkMargins(Request $request, Business $business)
{
$validated = $request->validate([
'type' => 'required|in:percentage,fixed,bogo,price_override',
'discount_value' => 'nullable|numeric|min:0',
'product_ids' => 'nullable|array',
'product_ids.*' => 'integer|exists:products,id',
]);
// If no products selected, return empty state
if (empty($validated['product_ids'])) {
return response()->json([
'status' => 'pending',
'message' => 'Select products to check margins',
]);
}
$products = Product::whereIn('id', $validated['product_ids'])->get();
// Validate access - products must belong to this business
foreach ($products as $product) {
if ($product->brand?->business_id !== $business->id) {
return response()->json([
'status' => 'error',
'message' => 'Invalid product selection',
], 403);
}
}
// Get first product for helper calculations
$firstProduct = $products->first();
$type = $validated['type'];
$discountValue = (float) ($validated['discount_value'] ?? 0);
// Calculate current margins for the first product
$currentMargins = $this->promoCalculator->getCurrentMargins($firstProduct);
// Calculate max safe discount
$maxSafeDiscount = null;
$maxSafeGetQty = null;
if ($type === 'percentage') {
$maxSafeDiscount = $this->promoCalculator->maxSafePercentOff($firstProduct);
} elseif ($type === 'fixed') {
$maxSafeDiscount = $this->promoCalculator->maxSafeFixedOff($firstProduct);
} elseif ($type === 'bogo') {
$maxSafeGetQty = $this->promoCalculator->maxSafeBogoGetQty($firstProduct, 1, 100);
} elseif ($type === 'price_override') {
$maxSafeDiscount = $this->promoCalculator->minSafePriceOverride($firstProduct);
}
// Validate the current promotion settings
$validationResult = $this->promoCalculator->validate(
$type,
$products,
['discount_value' => $discountValue]
);
return response()->json([
'status' => $validationResult->approved ? 'approved' : 'rejected',
'approved' => $validationResult->approved,
'company_margin' => $validationResult->companyMarginPercent(),
'dispensary_margin' => $validationResult->dispensaryMarginPercent(),
'reason' => $validationResult->reason,
'max_safe_discount' => $maxSafeDiscount,
'max_safe_get_qty' => $maxSafeGetQty,
'current_margins' => $currentMargins,
'min_margin_required' => 50,
]);
}
/**
* AJAX endpoint: Get product pricing info for margin display.
*/
public function getProductPricing(Request $request, Business $business)
{
$validated = $request->validate([
'product_id' => 'required|integer|exists:products,id',
]);
$product = Product::find($validated['product_id']);
// Validate access
if ($product->brand?->business_id !== $business->id) {
return response()->json(['error' => 'Invalid product'], 403);
}
$margins = $this->promoCalculator->getCurrentMargins($product);
return response()->json([
'product_id' => $product->id,
'name' => $product->name,
'cost' => (float) $product->cost_per_unit,
'wholesale' => (float) $product->wholesale_price,
'msrp' => (float) $product->msrp,
'company_margin' => $margins['company_margin'],
'dispensary_margin' => $margins['dispensary_margin'],
'max_safe_percent_off' => $this->promoCalculator->maxSafePercentOff($product),
'max_safe_fixed_off' => $this->promoCalculator->maxSafeFixedOff($product),
'max_safe_bogo_get_qty' => $this->promoCalculator->maxSafeBogoGetQty($product, 1, 100),
'min_safe_price_override' => $this->promoCalculator->minSafePriceOverride($product),
]);
}
}