Files
hub/app/Http/Controllers/Seller/BatchController.php
kelly 722904d487 fix: Crystal issues batch 2 - products, batches, images
- 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
2025-12-10 21:37:24 -07:00

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