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:
kelly
2025-12-11 05:10:15 +00:00
7 changed files with 252 additions and 15 deletions

View File

@@ -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) {

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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.";
}

View File

@@ -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
}
};

View File

@@ -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>