Merge pull request 'fix: Crystal issues batch 2 - products, batches, images' (#192) from fix/crystal-issues-batch-2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/192
This commit is contained in:
@@ -80,7 +80,7 @@ class BatchController extends Controller
|
||||
->where('quantity_available', '>', 0)
|
||||
->where('is_active', true)
|
||||
->where('is_quarantined', false)
|
||||
->with('component')
|
||||
->with('product')
|
||||
->orderBy('batch_number')
|
||||
->get()
|
||||
->map(function ($batch) {
|
||||
|
||||
@@ -106,7 +106,7 @@ class ProductController extends Controller
|
||||
'hashid' => $variety->hashid,
|
||||
'name' => $variety->name,
|
||||
'sku' => $variety->sku ?? 'N/A',
|
||||
'price' => $variety->wholesale_price ?? 0,
|
||||
'price' => $variety->effective_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]),
|
||||
@@ -123,7 +123,7 @@ class ProductController extends Controller
|
||||
'sku' => $product->sku ?? 'N/A',
|
||||
'brand' => $product->brand->name ?? 'N/A',
|
||||
'channel' => 'Marketplace', // TODO: Add channel field to products
|
||||
'price' => $product->wholesale_price ?? 0,
|
||||
'price' => $product->effective_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
|
||||
@@ -479,9 +479,14 @@ class ProductController extends Controller
|
||||
$product = Product::create($validated);
|
||||
|
||||
// Handle image uploads if present
|
||||
// Note: Uses default disk (minio) per CLAUDE.md rules - never use 'public' disk for media
|
||||
if ($request->hasFile('images')) {
|
||||
$brand = $product->brand;
|
||||
$basePath = "businesses/{$business->slug}/brands/{$brand->slug}/products/{$product->sku}/images";
|
||||
|
||||
foreach ($request->file('images') as $index => $image) {
|
||||
$path = $image->store('products', 'public');
|
||||
$filename = $image->hashName();
|
||||
$path = $image->storeAs($basePath, $filename);
|
||||
$product->images()->create([
|
||||
'path' => $path,
|
||||
'type' => 'product',
|
||||
|
||||
@@ -29,9 +29,9 @@ class StoreBrandRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'tagline' => ['nullable', 'string', 'min:30', 'max:45'],
|
||||
'description' => ['nullable', 'string', 'min:100', 'max:150'],
|
||||
'long_description' => ['nullable', 'string', 'max:500'],
|
||||
'tagline' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'long_description' => ['nullable', 'string', 'max:5000'],
|
||||
'brand_announcement' => ['nullable', 'string', 'max:500'],
|
||||
'website_url' => 'nullable|string|max:255',
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ class UpdateBrandRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'tagline' => ['nullable', 'string', 'min:30', 'max:45'],
|
||||
'description' => ['nullable', 'string', 'min:100', 'max:150'],
|
||||
'long_description' => ['nullable', 'string', 'max:500'],
|
||||
'tagline' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'long_description' => ['nullable', 'string', 'max:5000'],
|
||||
'brand_announcement' => ['nullable', 'string', 'max:500'],
|
||||
'website_url' => 'nullable|string|max:255',
|
||||
|
||||
|
||||
@@ -260,10 +260,10 @@ class BrandVoicePrompt
|
||||
* AI generation MUST respect these limits.
|
||||
*/
|
||||
public const CHARACTER_LIMITS = [
|
||||
'tagline' => ['min' => 30, 'max' => 45, 'label' => 'Tagline'],
|
||||
'short_description' => ['min' => 100, 'max' => 150, 'label' => 'Short Description'],
|
||||
'description' => ['min' => 100, 'max' => 150, 'label' => 'Short Description'], // Alias
|
||||
'long_description' => ['min' => 400, 'max' => 500, 'label' => 'Long Description'],
|
||||
'tagline' => ['min' => null, 'max' => 255, 'label' => 'Tagline'],
|
||||
'short_description' => ['min' => null, 'max' => 1000, 'label' => 'Short Description'],
|
||||
'description' => ['min' => null, 'max' => 1000, 'label' => 'Short Description'], // Alias
|
||||
'long_description' => ['min' => null, 'max' => 5000, 'label' => 'Long Description'],
|
||||
'brand_announcement' => ['min' => 400, 'max' => 500, 'label' => 'Brand Announcement'],
|
||||
'seo_title' => ['min' => 60, 'max' => 70, 'label' => 'SEO Title'],
|
||||
'seo_description' => ['min' => 150, 'max' => 160, 'label' => 'SEO Description'],
|
||||
@@ -803,6 +803,11 @@ class BrandVoicePrompt
|
||||
return '';
|
||||
}
|
||||
|
||||
// If no min is set, only enforce max
|
||||
if ($limits['min'] === null) {
|
||||
return "CHARACTER LIMIT: Output should not exceed {$limits['max']} characters.";
|
||||
}
|
||||
|
||||
return "STRICT CHARACTER LIMIT: Output MUST be between {$limits['min']}-{$limits['max']} characters. Do NOT output fewer than {$limits['min']} or more than {$limits['max']} characters.";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Backfill descriptions from MySQL hub_cannabrands database.
|
||||
*
|
||||
* This migration:
|
||||
* 1. Updates existing products with long_description from product_extras
|
||||
* 2. Creates missing Nuvata products (NU-* SKUs)
|
||||
* 3. Updates existing brands with tagline, description, long_description
|
||||
*
|
||||
* Idempotent: Only updates null fields, won't overwrite existing data.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
protected string $mysqlConnection = 'mysql_import';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
// Check if mysql_import connection is configured
|
||||
if (! config('database.connections.mysql_import')) {
|
||||
echo "Skipping: mysql_import connection not configured.\n";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::connection($this->mysqlConnection)->getPdo();
|
||||
} catch (\Exception $e) {
|
||||
echo "Skipping: Cannot connect to mysql_import database.\n";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->backfillProductDescriptions();
|
||||
$this->importNuvataProducts();
|
||||
$this->backfillBrandDescriptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing products with long_description from product_extras.
|
||||
*/
|
||||
protected function backfillProductDescriptions(): void
|
||||
{
|
||||
echo "Backfilling product long_descriptions...\n";
|
||||
|
||||
// Get all products with long_description from MySQL
|
||||
$mysqlProducts = DB::connection($this->mysqlConnection)
|
||||
->table('products')
|
||||
->join('product_extras', 'products.id', '=', 'product_extras.product_id')
|
||||
->whereNotNull('product_extras.long_description')
|
||||
->where('product_extras.long_description', '!=', '')
|
||||
->select('products.code as sku', 'product_extras.long_description')
|
||||
->get();
|
||||
|
||||
echo "Found {$mysqlProducts->count()} products with long_description in MySQL.\n";
|
||||
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($mysqlProducts as $mysqlProduct) {
|
||||
// Find matching product in PostgreSQL by SKU
|
||||
$pgProduct = Product::where('sku', $mysqlProduct->sku)->first();
|
||||
|
||||
if (! $pgProduct) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only update if long_description is null/empty
|
||||
if (empty($pgProduct->long_description)) {
|
||||
$pgProduct->update([
|
||||
'long_description' => $mysqlProduct->long_description,
|
||||
]);
|
||||
$updated++;
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "Updated {$updated} products, skipped {$skipped} (not found or already has data).\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Import missing Nuvata products (NU-* SKUs).
|
||||
*/
|
||||
protected function importNuvataProducts(): void
|
||||
{
|
||||
echo "Importing missing Nuvata products...\n";
|
||||
|
||||
// Find Nuvata brand in PostgreSQL
|
||||
$nuvataBrand = Brand::where('slug', 'nuvata')->first();
|
||||
|
||||
if (! $nuvataBrand) {
|
||||
echo "Nuvata brand not found in PostgreSQL. Skipping Nuvata product import.\n";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get Nuvata products from MySQL
|
||||
$nuvataProducts = DB::connection($this->mysqlConnection)
|
||||
->table('products')
|
||||
->leftJoin('product_extras', 'products.id', '=', 'product_extras.product_id')
|
||||
->where('products.code', 'like', 'NU-%')
|
||||
->select(
|
||||
'products.code as sku',
|
||||
'products.name',
|
||||
'products.description',
|
||||
'products.wholesale_price',
|
||||
'products.active',
|
||||
'products.created_at',
|
||||
'products.updated_at',
|
||||
'product_extras.long_description'
|
||||
)
|
||||
->get();
|
||||
|
||||
echo "Found {$nuvataProducts->count()} Nuvata products in MySQL.\n";
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($nuvataProducts as $mysqlProduct) {
|
||||
// Check if product already exists
|
||||
if (Product::where('sku', $mysqlProduct->sku)->exists()) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate unique hashid
|
||||
$hashid = Str::random(8);
|
||||
while (Product::where('hashid', $hashid)->exists()) {
|
||||
$hashid = Str::random(8);
|
||||
}
|
||||
|
||||
Product::create([
|
||||
'brand_id' => $nuvataBrand->id,
|
||||
'hashid' => $hashid,
|
||||
'sku' => $mysqlProduct->sku,
|
||||
'name' => $mysqlProduct->name,
|
||||
'description' => $mysqlProduct->description,
|
||||
'long_description' => $mysqlProduct->long_description,
|
||||
'wholesale_price' => $mysqlProduct->wholesale_price,
|
||||
'is_active' => (bool) $mysqlProduct->active,
|
||||
'created_at' => $mysqlProduct->created_at,
|
||||
'updated_at' => $mysqlProduct->updated_at,
|
||||
]);
|
||||
|
||||
$created++;
|
||||
}
|
||||
|
||||
echo "Created {$created} Nuvata products, skipped {$skipped} existing.\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Update brands with tagline, description, and long_description.
|
||||
*/
|
||||
protected function backfillBrandDescriptions(): void
|
||||
{
|
||||
echo "Backfilling brand descriptions...\n";
|
||||
|
||||
// Get brands from MySQL with descriptions
|
||||
$mysqlBrands = DB::connection($this->mysqlConnection)
|
||||
->table('brands')
|
||||
->select('name', 'tagline', 'short_desc', 'desc')
|
||||
->get();
|
||||
|
||||
echo "Found {$mysqlBrands->count()} brands in MySQL.\n";
|
||||
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($mysqlBrands as $mysqlBrand) {
|
||||
$name = trim($mysqlBrand->name ?? '');
|
||||
if (! $name) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find matching brand in PostgreSQL by name (case-insensitive)
|
||||
$pgBrand = Brand::whereRaw('LOWER(name) = ?', [strtolower($name)])->first();
|
||||
|
||||
if (! $pgBrand) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
|
||||
// Only update null/empty fields
|
||||
if (empty($pgBrand->tagline) && ! empty($mysqlBrand->tagline)) {
|
||||
$updates['tagline'] = $mysqlBrand->tagline;
|
||||
}
|
||||
|
||||
if (empty($pgBrand->description) && ! empty($mysqlBrand->short_desc)) {
|
||||
$updates['description'] = $mysqlBrand->short_desc;
|
||||
}
|
||||
|
||||
if (empty($pgBrand->long_description) && ! empty($mysqlBrand->desc)) {
|
||||
$updates['long_description'] = $mysqlBrand->desc;
|
||||
}
|
||||
|
||||
if (! empty($updates)) {
|
||||
$pgBrand->update($updates);
|
||||
$updated++;
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "Updated {$updated} brands, skipped {$skipped} (not found or already has data).\n";
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// This is a data backfill migration - no rollback needed
|
||||
// Data was only added to null fields, original data preserved
|
||||
}
|
||||
};
|
||||
@@ -333,7 +333,7 @@
|
||||
<option value="">Select source batch...</option>
|
||||
@foreach($componentBatches as $batch)
|
||||
<option value="{{ $batch->id }}">
|
||||
{{ $batch->batch_number }} - {{ $batch->component->name ?? 'Unknown' }} ({{ $batch->quantity_remaining }} {{ $batch->quantity_unit }} remaining)
|
||||
{{ $batch->batch_number }} - {{ $batch->product->name ?? 'Unknown' }} ({{ $batch->quantity_remaining }} {{ $batch->quantity_unit }} remaining)
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user