- Fix #190: Product image upload now uses MinIO (default disk) with proper path structure: businesses/{slug}/brands/{slug}/products/{sku}/images/ - Fix #176: Products index now uses effective_price accessor instead of just wholesale_price, so sale prices display correctly - Fix #163: Batch create page was referencing non-existent 'component' relationship - changed to 'product' which is the actual relationship
583 lines
22 KiB
PHP
583 lines
22 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Seller;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Jobs\GenerateBatchQrCode;
|
|
use App\Jobs\ParseCoaDocument;
|
|
use App\Models\Batch;
|
|
use App\Models\Business;
|
|
use App\Models\Product;
|
|
use App\Services\QrCodeService;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class BatchController extends Controller
|
|
{
|
|
/**
|
|
* Display a listing of batches for the business
|
|
*/
|
|
public function index(Request $request, Business $business)
|
|
{
|
|
// Build query for batches
|
|
$query = Batch::where('business_id', $business->id)
|
|
->with(['product.brand', 'coaFiles'])
|
|
->orderBy('production_date', 'desc');
|
|
|
|
// Product filter (for product-scoped batch views)
|
|
$filteredProduct = null;
|
|
if ($request->filled('product')) {
|
|
// Resolve product by hashid
|
|
$product = Product::where('hashid', $request->product)
|
|
->whereHas('brand', function ($q) use ($business) {
|
|
$q->where('business_id', $business->id);
|
|
})
|
|
->first();
|
|
|
|
if ($product) {
|
|
$query->where('product_id', $product->id);
|
|
$filteredProduct = $product;
|
|
}
|
|
}
|
|
|
|
// Search filter
|
|
if ($request->filled('search')) {
|
|
$search = $request->search;
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('batch_number', 'LIKE', "%{$search}%")
|
|
->orWhere('test_id', 'LIKE', "%{$search}%")
|
|
->orWhere('lot_number', 'LIKE', "%{$search}%")
|
|
->orWhereHas('product', function ($productQuery) use ($search) {
|
|
$productQuery->where('name', 'LIKE', "%{$search}%");
|
|
});
|
|
});
|
|
}
|
|
|
|
$batches = $query->paginate(20)->withQueryString();
|
|
|
|
// Separate active and inactive batches
|
|
$activeBatches = $batches->filter(fn ($batch) => $batch->is_active);
|
|
$inactiveBatches = $batches->filter(fn ($batch) => ! $batch->is_active);
|
|
|
|
return view('seller.batches.index', compact('business', 'batches', 'activeBatches', 'inactiveBatches', 'filteredProduct'));
|
|
}
|
|
|
|
/**
|
|
* Show the form for creating a new batch
|
|
*/
|
|
public function create(Request $request, Business $business)
|
|
{
|
|
// Get products owned by this business (eager load brand for view)
|
|
$products = Product::with('brand')->whereHas('brand', function ($query) use ($business) {
|
|
$query->where('business_id', $business->id);
|
|
})->orderBy('name', 'asc')->get();
|
|
|
|
// For the new architecture, components are products (the view expects $components)
|
|
$components = $products;
|
|
|
|
// Get existing component batches that can be used as sources for homogenized batches
|
|
$componentBatches = Batch::where('business_id', $business->id)
|
|
->where('quantity_available', '>', 0)
|
|
->where('is_active', true)
|
|
->where('is_quarantined', false)
|
|
->with('product')
|
|
->orderBy('batch_number')
|
|
->get()
|
|
->map(function ($batch) {
|
|
// Map quantity_available to quantity_remaining for view compatibility
|
|
$batch->quantity_remaining = $batch->quantity_available;
|
|
|
|
return $batch;
|
|
});
|
|
|
|
return view('seller.batches.create', compact('business', 'products', 'components', 'componentBatches'));
|
|
}
|
|
|
|
/**
|
|
* Store a newly created batch
|
|
*/
|
|
public function store(Request $request, Business $business)
|
|
{
|
|
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
|
|
$maxValue = ($request->cannabinoid_unit ?? '%') === '%' ? 100 : 1000;
|
|
|
|
$validated = $request->validate([
|
|
// Accept either product_id or component_id (form sends component_id)
|
|
'product_id' => 'required_without:component_id|exists:products,id',
|
|
'component_id' => 'required_without:product_id|exists:products,id',
|
|
'batch_type' => 'nullable|string|in:component,homogenized',
|
|
'cannabinoid_unit' => 'nullable|string|in:%,MG/ML,MG/G,MG/UNIT',
|
|
'batch_number' => 'required|string|max:100|unique:batches,batch_number',
|
|
'internal_code' => 'nullable|string|max:100',
|
|
// Accept either quantity_produced or quantity_total (form sends quantity_total)
|
|
'quantity_produced' => 'nullable|numeric|min:0',
|
|
'quantity_total' => 'nullable|numeric|min:0',
|
|
'quantity_remaining' => 'nullable|numeric|min:0',
|
|
'quantity_unit' => 'nullable|string|max:50',
|
|
'quantity_allocated' => 'nullable|integer|min:0',
|
|
'expiration_date' => 'nullable|date',
|
|
'is_active' => 'nullable',
|
|
'production_date' => 'nullable|date',
|
|
'harvest_date' => 'nullable|date',
|
|
'package_date' => 'nullable|date',
|
|
'test_date' => 'nullable|date',
|
|
'test_id' => 'nullable|string|max:100',
|
|
'lot_number' => 'nullable|string|max:100',
|
|
'license_number' => 'nullable|string|max:255',
|
|
'lab_name' => 'nullable|string|max:255',
|
|
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'notes' => 'nullable|string',
|
|
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
|
|
]);
|
|
|
|
// Map component_id to product_id if provided
|
|
$productId = $validated['product_id'] ?? $validated['component_id'];
|
|
|
|
// Verify product belongs to this business
|
|
$product = Product::whereHas('brand', function ($query) use ($business) {
|
|
$query->where('business_id', $business->id);
|
|
})->findOrFail($productId);
|
|
|
|
// Map form fields to model fields
|
|
$validated['product_id'] = $productId;
|
|
$validated['quantity_produced'] = $validated['quantity_total'] ?? $validated['quantity_produced'] ?? 0;
|
|
$validated['quantity_available'] = $validated['quantity_remaining'] ?? $validated['quantity_produced'];
|
|
|
|
// Set business_id and defaults
|
|
$validated['business_id'] = $business->id;
|
|
$validated['cannabinoid_unit'] = $validated['cannabinoid_unit'] ?? '%';
|
|
$validated['is_active'] = $validated['is_active'] ?? true; // New batches are active by default
|
|
|
|
// Create batch (calculations happen in model boot method)
|
|
$batch = Batch::create($validated);
|
|
|
|
// Handle COA file uploads (stored on MinIO via default disk)
|
|
if ($request->hasFile('coa_files')) {
|
|
foreach ($request->file('coa_files') as $index => $file) {
|
|
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
|
|
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
|
|
$filePath = $file->storeAs($storagePath, $fileName); // Uses default disk (MinIO)
|
|
|
|
$batch->coaFiles()->create([
|
|
'file_name' => $file->getClientOriginalName(),
|
|
'file_path' => $filePath,
|
|
'file_type' => $file->getClientOriginalExtension(),
|
|
'file_size' => $file->getSize(),
|
|
'is_primary' => $index === 0,
|
|
'display_order' => $index,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// Dispatch job to generate QR code (runs via Horizon for reliability)
|
|
GenerateBatchQrCode::dispatch($batch, true);
|
|
|
|
// Return JSON for AJAX requests
|
|
if ($request->wantsJson()) {
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Batch created successfully.',
|
|
'batch' => [
|
|
'id' => $batch->id,
|
|
'batch_number' => $batch->batch_number,
|
|
'quantity_produced' => $batch->quantity_produced,
|
|
'quantity_allocated' => $batch->quantity_allocated,
|
|
'production_date' => $batch->production_date?->format('Y-m-d'),
|
|
'expiration_date' => $batch->expiration_date?->format('Y-m-d'),
|
|
'thc_percentage' => $batch->thc_percentage,
|
|
'is_active' => $batch->is_active,
|
|
],
|
|
]);
|
|
}
|
|
|
|
return redirect()
|
|
->route('seller.business.batches.index', $business->slug)
|
|
->with('success', 'Batch created successfully.');
|
|
}
|
|
|
|
/**
|
|
* Show the form for editing the specified batch
|
|
*/
|
|
public function edit(Request $request, Business $business, Batch $batch)
|
|
{
|
|
// Verify batch belongs to this business
|
|
if ($batch->business_id !== $business->id) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
// Get products owned by this business (eager load brand for view)
|
|
$products = Product::with('brand')->whereHas('brand', function ($query) use ($business) {
|
|
$query->where('business_id', $business->id);
|
|
})->orderBy('name', 'asc')->get();
|
|
|
|
$batch->load(['coaFiles', 'product.brand']);
|
|
|
|
return view('seller.batches.edit', compact('business', 'batch', 'products'));
|
|
}
|
|
|
|
/**
|
|
* Update the specified batch
|
|
*/
|
|
public function update(Request $request, Business $business, Batch $batch)
|
|
{
|
|
// Verify batch belongs to this business
|
|
if ($batch->business_id !== $business->id) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
|
|
$maxValue = $request->cannabinoid_unit === '%' ? 100 : 1000;
|
|
|
|
$validated = $request->validate([
|
|
'product_id' => 'required|exists:products,id',
|
|
'cannabinoid_unit' => 'required|string|in:%,MG/ML,MG/G,MG/UNIT',
|
|
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number,'.$batch->id,
|
|
'production_date' => 'nullable|date',
|
|
'test_date' => 'nullable|date',
|
|
'test_id' => 'nullable|string|max:100',
|
|
'lot_number' => 'nullable|string|max:100',
|
|
'lab_name' => 'nullable|string|max:255',
|
|
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
|
'notes' => 'nullable|string',
|
|
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
|
|
]);
|
|
|
|
// Verify product belongs to this business
|
|
$product = Product::whereHas('brand', function ($query) use ($business) {
|
|
$query->where('business_id', $business->id);
|
|
})->findOrFail($validated['product_id']);
|
|
|
|
// Update batch (calculations happen in model boot method)
|
|
$batch->update($validated);
|
|
|
|
// Handle new COA file uploads (stored on MinIO via default disk)
|
|
if ($request->hasFile('coa_files')) {
|
|
$existingFilesCount = $batch->coaFiles()->count();
|
|
foreach ($request->file('coa_files') as $index => $file) {
|
|
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
|
|
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
|
|
$filePath = $file->storeAs($storagePath, $fileName); // Uses default disk (MinIO)
|
|
|
|
$batch->coaFiles()->create([
|
|
'file_name' => $file->getClientOriginalName(),
|
|
'file_path' => $filePath,
|
|
'file_type' => $file->getClientOriginalExtension(),
|
|
'file_size' => $file->getSize(),
|
|
'is_primary' => $existingFilesCount === 0 && $index === 0,
|
|
'display_order' => $existingFilesCount + $index,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// Return JSON for Precognition requests
|
|
if ($request->wantsJson()) {
|
|
return response()->json([
|
|
'message' => 'Batch updated successfully.',
|
|
]);
|
|
}
|
|
|
|
return redirect()
|
|
->route('seller.business.batches.index', $business->slug)
|
|
->with('success', 'Batch updated successfully.');
|
|
}
|
|
|
|
/**
|
|
* Remove the specified batch
|
|
*/
|
|
public function destroy(Request $request, Business $business, Batch $batch)
|
|
{
|
|
// Verify batch belongs to this business
|
|
if ($batch->business_id !== $business->id) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
// Delete associated COA files from storage
|
|
foreach ($batch->coaFiles as $coaFile) {
|
|
if (Storage::exists($coaFile->file_path)) {
|
|
Storage::delete($coaFile->file_path);
|
|
}
|
|
}
|
|
|
|
$batch->delete();
|
|
|
|
return redirect()
|
|
->route('seller.business.batches.index', $business->slug)
|
|
->with('success', 'Batch deleted successfully.');
|
|
}
|
|
|
|
/**
|
|
* Activate a batch
|
|
*/
|
|
public function activate(Request $request, Business $business, Batch $batch)
|
|
{
|
|
// Verify batch belongs to this business
|
|
if ($batch->business_id !== $business->id) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
$batch->update(['is_active' => true]);
|
|
|
|
if ($request->wantsJson()) {
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Batch activated successfully.',
|
|
]);
|
|
}
|
|
|
|
return redirect()
|
|
->route('seller.business.batches.index', $business->slug)
|
|
->with('success', 'Batch activated successfully.');
|
|
}
|
|
|
|
/**
|
|
* Deactivate a batch
|
|
*/
|
|
public function deactivate(Request $request, Business $business, Batch $batch)
|
|
{
|
|
// Verify batch belongs to this business
|
|
if ($batch->business_id !== $business->id) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
$batch->update(['is_active' => false]);
|
|
|
|
if ($request->wantsJson()) {
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Batch deactivated successfully.',
|
|
]);
|
|
}
|
|
|
|
return redirect()
|
|
->route('seller.business.batches.index', $business->slug)
|
|
->with('success', 'Batch deactivated successfully.');
|
|
}
|
|
|
|
/**
|
|
* Generate QR code for a batch
|
|
*/
|
|
public function generateQrCode(Request $request, Business $business, Batch $batch)
|
|
{
|
|
// Verify batch belongs to this business
|
|
if ($batch->business_id !== $business->id) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
$qrService = app(QrCodeService::class);
|
|
$result = $qrService->generateWithLogo($batch);
|
|
|
|
// Refresh batch to get updated qr_code_path
|
|
$batch->refresh();
|
|
|
|
return response()->json([
|
|
'success' => $result['success'],
|
|
'message' => $result['message'],
|
|
'qr_code_url' => $batch->qr_code_path ? Storage::url($batch->qr_code_path) : null,
|
|
'download_url' => $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Download QR code for a batch
|
|
*/
|
|
public function downloadQrCode(Request $request, Business $business, Batch $batch)
|
|
{
|
|
// Verify batch belongs to this business
|
|
if ($batch->business_id !== $business->id) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
$qrService = app(QrCodeService::class);
|
|
$download = $qrService->download($batch);
|
|
|
|
if (! $download) {
|
|
return back()->with('error', 'QR code not found');
|
|
}
|
|
|
|
return $download;
|
|
}
|
|
|
|
/**
|
|
* Regenerate QR code for a batch
|
|
*/
|
|
public function regenerateQrCode(Request $request, Business $business, Batch $batch)
|
|
{
|
|
// Verify batch belongs to this business
|
|
if ($batch->business_id !== $business->id) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
$qrService = app(QrCodeService::class);
|
|
$result = $qrService->regenerate($batch);
|
|
|
|
// Refresh batch to get updated qr_code_path
|
|
$batch->refresh();
|
|
|
|
return response()->json([
|
|
'success' => $result['success'],
|
|
'message' => $result['message'],
|
|
'qr_code_url' => $batch->qr_code_path ? Storage::url($batch->qr_code_path) : null,
|
|
'download_url' => $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Delete QR code for a batch
|
|
*/
|
|
public function deleteQrCode(Request $request, Business $business, Batch $batch)
|
|
{
|
|
// Verify batch belongs to this business
|
|
if ($batch->business_id !== $business->id) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
$qrService = app(QrCodeService::class);
|
|
$result = $qrService->delete($batch);
|
|
|
|
return response()->json([
|
|
'success' => $result['success'],
|
|
'message' => $result['message'],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Bulk generate QR codes for multiple batches
|
|
*/
|
|
public function bulkGenerateQrCodes(Request $request, Business $business)
|
|
{
|
|
$validated = $request->validate([
|
|
'batch_ids' => 'required|array',
|
|
'batch_ids.*' => 'exists:batches,id',
|
|
]);
|
|
|
|
// Verify all batches belong to this business
|
|
$batches = Batch::whereIn('id', $validated['batch_ids'])
|
|
->where('business_id', $business->id)
|
|
->get();
|
|
|
|
if ($batches->count() !== count($validated['batch_ids'])) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Some batches do not belong to this business',
|
|
], 403);
|
|
}
|
|
|
|
$qrService = app(QrCodeService::class);
|
|
$result = $qrService->bulkGenerate($validated['batch_ids']);
|
|
|
|
return response()->json($result);
|
|
}
|
|
|
|
/**
|
|
* Scan a COA PDF and extract lab data using AI (async via Horizon)
|
|
*
|
|
* This endpoint accepts a PDF file upload, stores it temporarily,
|
|
* and dispatches a job to extract data. Returns a scan_id for polling.
|
|
*/
|
|
public function scanCoa(Request $request, Business $business)
|
|
{
|
|
$request->validate([
|
|
'coa_file' => 'required|file|mimes:pdf|max:10240', // 10MB max
|
|
'batch_id' => 'nullable|exists:batches,id',
|
|
]);
|
|
|
|
$file = $request->file('coa_file');
|
|
$batchId = $request->input('batch_id');
|
|
|
|
// Generate unique scan ID for tracking
|
|
$scanId = uniqid('coa_scan_', true);
|
|
|
|
// Store file temporarily for job processing
|
|
$storagePath = "temp/coa-scans/{$scanId}.pdf";
|
|
Storage::put($storagePath, file_get_contents($file->getRealPath()));
|
|
|
|
// Initialize scan status in cache
|
|
cache()->put("coa_scan_status_{$scanId}", [
|
|
'status' => 'processing',
|
|
'message' => 'COA is being analyzed...',
|
|
'data' => null,
|
|
'error' => null,
|
|
], now()->addHours(1));
|
|
|
|
// Dispatch job to parse COA
|
|
ParseCoaDocument::dispatch(
|
|
$storagePath,
|
|
$file->getClientOriginalName(),
|
|
$batchId,
|
|
$business->id
|
|
)->afterCommit();
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'scan_id' => $scanId,
|
|
'message' => 'COA scan started. Poll /scan-status/{scan_id} for results.',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Check status of a COA scan job.
|
|
*/
|
|
public function scanCoaStatus(Request $request, Business $business, string $scanId)
|
|
{
|
|
$status = cache()->get("coa_scan_status_{$scanId}");
|
|
|
|
if (! $status) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => 'Scan not found or expired',
|
|
], 404);
|
|
}
|
|
|
|
return response()->json($status);
|
|
}
|
|
|
|
/**
|
|
* Extend a batch by adding more inventory
|
|
*
|
|
* Used when receiving additional units of the same batch
|
|
* (e.g., second shipment from same production run)
|
|
*/
|
|
public function extend(Request $request, Business $business, Batch $batch)
|
|
{
|
|
// Verify batch belongs to this business
|
|
if ($batch->business_id !== $business->id) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'quantity' => 'required|integer|min:1',
|
|
'notes' => 'nullable|string|max:500',
|
|
]);
|
|
|
|
$result = $batch->extend($validated['quantity'], $validated['notes'] ?? null);
|
|
|
|
if ($request->wantsJson()) {
|
|
return response()->json($result);
|
|
}
|
|
|
|
if ($result['success']) {
|
|
return redirect()
|
|
->back()
|
|
->with('success', $result['message']);
|
|
}
|
|
|
|
return redirect()
|
|
->back()
|
|
->with('error', $result['message']);
|
|
}
|
|
}
|