- 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
399 lines
15 KiB
PHP
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),
|
|
]);
|
|
}
|
|
}
|