Files
hub/app/Console/Commands/ImportBrandFromMySQL.php
kelly aa788e9fe2 feat: Complete product and inventory management system
## Major Features Added
- Sale pricing support throughout checkout flow
- Stock notification system for out-of-stock products
- Backorder request system with job queue processing
- Inventory management dashboard and tracking
- Product observer for automatic inventory alerts

## Sale Pricing Fixes
- Fixed checkout to respect sale_price when available
- Updated cart calculations to use sale pricing
- Added sale badges and strikethrough pricing to:
  - Product preview pages
  - Cart display
  - Checkout summary
  - Order details (buyer/seller)
  - Invoice displays

## UI/UX Improvements
- Moved backorder quantity controls below button (better vertical flow)
- Added case quantity display with unit counts
- Fixed invoice total calculation (was showing subtotal as total)
- Added payment terms surcharge visibility in invoice breakdown

## Database Changes
- Created stock_notifications table for back-in-stock alerts
- Created backorders table for customer backorder requests
- Enhanced inventory_items and inventory_movements tables
- Added missing marketplace fields to products and brands

## Bug Fixes
- Fixed unit_price calculation to respect sale_price
- Fixed invoice JavaScript calculateTotal() to include surcharges
- Fixed CartController subtotal to use sale pricing
- Removed inline styles, migrated to Tailwind/DaisyUI classes

## Architecture Improvements
- Added BackorderService and StockNotificationService
- Created ProcessBackorderRequest job for async processing
- Implemented ProductObserver for inventory management
- Enhanced OrderModificationService

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 23:14:52 -07:00

290 lines
11 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Brand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
class ImportBrandFromMySQL extends Command
{
protected $signature = 'brand:import-from-mysql {remoteName? : Remote brand name} {localName? : Local brand name (if different)}';
protected $description = 'Import brand data and images from remote MySQL database';
public function handle()
{
$remoteBrandName = $this->argument('remoteName') ?? 'Canna';
$localBrandName = $this->argument('localName') ?? $remoteBrandName;
$this->info('Connecting to remote MySQL database...');
try {
// Connect to remote MySQL with latin1 charset (Windows-1252)
$pdo = new \PDO(
'mysql:host=sql1.creationshop.net;dbname=hub_cannabrands;charset=latin1',
'claude',
'claude'
);
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$this->info('Connected successfully!');
// Fetch brand data from MySQL
$stmt = $pdo->prepare('
SELECT brand_id, name, tagline, short_desc, `desc`, url,
image, banner, address, unit_number, city, state, zip, phone,
public, fb, insta, twitter, youtube
FROM brands
WHERE name = :name
');
$stmt->execute(['name' => $remoteBrandName]);
$remoteBrand = $stmt->fetch(\PDO::FETCH_ASSOC);
if (! $remoteBrand) {
$this->error("Brand '{$remoteBrandName}' not found in remote database");
return 1;
}
$this->info("Found remote brand: {$remoteBrand['name']}");
// Find local brand by name
$localBrand = Brand::where('name', $localBrandName)->first();
if (! $localBrand) {
$this->error("Brand '{$localBrandName}' not found in local database");
$this->info('Available brands: '.Brand::pluck('name')->implode(', '));
return 1;
}
$this->info("Found local brand: {$localBrand->name} (ID: {$localBrand->id})");
// Create brands directory if it doesn't exist
if (! Storage::disk('public')->exists('brands')) {
Storage::disk('public')->makeDirectory('brands');
$this->info('Created brands directory');
}
// Initialize Intervention Image
$manager = new ImageManager(new Driver);
// Process logo image with thumbnails (save as PNG for transparency support)
if ($remoteBrand['image']) {
$logoPath = "brands/{$localBrand->slug}-logo.png";
// Read and process the original image
$originalImage = $manager->read($remoteBrand['image']);
// Try to remove white background by making white pixels transparent
// Sample corners to detect if background is white
$width = $originalImage->width();
$height = $originalImage->height();
// Use GD to manipulate pixels
$gdImage = imagecreatefromstring($remoteBrand['image']);
if ($gdImage !== false) {
// Enable alpha blending
imagealphablending($gdImage, false);
imagesavealpha($gdImage, true);
// Make white and near-white pixels transparent
for ($x = 0; $x < imagesx($gdImage); $x++) {
for ($y = 0; $y < imagesy($gdImage); $y++) {
$rgb = imagecolorat($gdImage, $x, $y);
$colors = imagecolorsforindex($gdImage, $rgb);
// If pixel is white or very close to white (RGB > 245)
if ($colors['red'] > 245 && $colors['green'] > 245 && $colors['blue'] > 245) {
$transparent = imagecolorallocatealpha($gdImage, 255, 255, 255, 127);
imagesetpixel($gdImage, $x, $y, $transparent);
}
}
}
// Save as PNG
ob_start();
imagepng($gdImage);
$processedData = ob_get_clean();
imagedestroy($gdImage);
Storage::disk('public')->put($logoPath, $processedData);
$originalImage = $manager->read($processedData);
} else {
// Fallback: save original as PNG
Storage::disk('public')->put($logoPath, $originalImage->toPng());
}
// Generate thumbnails optimized for retina displays (PNG for transparency)
// Thumbnail (160x160) for list views (2x retina at 80px)
$thumbRetina = clone $originalImage;
$thumbRetina->scale(width: 160);
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-thumb.png", $thumbRetina->toPng());
// Medium (600x600) for product cards (2x retina at 300px)
$mediumRetina = clone $originalImage;
$mediumRetina->scale(width: 600);
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-medium.png", $mediumRetina->toPng());
// Large (1600x1600) for detail views
$largeRetina = clone $originalImage;
$largeRetina->scale(width: 1600);
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-large.png", $largeRetina->toPng());
$localBrand->logo_path = $logoPath;
$this->info("✓ Saved logo + thumbnails: {$logoPath} (".strlen($remoteBrand['image']).' bytes)');
}
// Process banner image with thumbnails
if ($remoteBrand['banner']) {
$bannerPath = "brands/{$localBrand->slug}-banner.jpg";
// Save original
Storage::disk('public')->put($bannerPath, $remoteBrand['banner']);
// Generate banner thumbnails if banner is large enough
if (strlen($remoteBrand['banner']) > 1000) {
$image = $manager->read($remoteBrand['banner']);
// Medium banner (1344px wide) for retina displays at 672px
$mediumBanner = clone $image;
$mediumBanner->scale(width: 1344);
Storage::disk('public')->put("brands/{$localBrand->slug}-banner-medium.jpg", $mediumBanner->toJpeg(quality: 92));
// Large banner (2560px wide) for full-width hero sections
$largeBanner = clone $image;
$largeBanner->scale(width: 2560);
Storage::disk('public')->put("brands/{$localBrand->slug}-banner-large.jpg", $largeBanner->toJpeg(quality: 92));
}
$localBrand->banner_path = $bannerPath;
$this->info("✓ Saved banner + thumbnails: {$bannerPath} (".strlen($remoteBrand['banner']).' bytes)');
}
// Helper function to sanitize text (convert Windows-1252 to UTF-8)
$sanitize = function ($text) {
if (! $text) {
return $text;
}
// First, convert from Windows-1252/ISO-8859-1 to UTF-8
$text = mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
// Replace common Windows-1252 special characters with standard equivalents
$replacements = [
"\xE2\x80\x98" => "'", // Left single quote
"\xE2\x80\x99" => "'", // Right single quote (apostrophe)
"\xE2\x80\x9C" => '"', // Left double quote
"\xE2\x80\x9D" => '"', // Right double quote
"\xE2\x80\x93" => '-', // En dash
"\xE2\x80\x94" => '-', // Em dash
"\xE2\x80\x26" => '...', // Ellipsis
];
$text = str_replace(array_keys($replacements), array_values($replacements), $text);
return trim($text);
};
// Update other brand fields
$updates = [];
if ($remoteBrand['tagline']) {
$localBrand->tagline = $sanitize($remoteBrand['tagline']);
$updates[] = 'tagline';
}
if ($remoteBrand['short_desc']) {
$localBrand->description = $sanitize($remoteBrand['short_desc']);
$updates[] = 'description';
}
if ($remoteBrand['desc']) {
$localBrand->long_description = $sanitize($remoteBrand['desc']);
$updates[] = 'long_description';
}
if ($remoteBrand['url']) {
$localBrand->website_url = $remoteBrand['url'];
$updates[] = 'website_url';
}
// Address fields
if ($remoteBrand['address']) {
$localBrand->address = $remoteBrand['address'];
$updates[] = 'address';
}
if ($remoteBrand['unit_number']) {
$localBrand->unit_number = $remoteBrand['unit_number'];
$updates[] = 'unit_number';
}
if ($remoteBrand['city']) {
$localBrand->city = $remoteBrand['city'];
$updates[] = 'city';
}
if ($remoteBrand['state']) {
$localBrand->state = $remoteBrand['state'];
$updates[] = 'state';
}
if ($remoteBrand['zip']) {
$localBrand->zip_code = $remoteBrand['zip'];
$updates[] = 'zip_code';
}
if ($remoteBrand['phone']) {
$localBrand->phone = $remoteBrand['phone'];
$updates[] = 'phone';
}
// Social media
if ($remoteBrand['fb']) {
$localBrand->facebook_url = 'https://facebook.com/'.$remoteBrand['fb'];
$updates[] = 'facebook_url';
}
if ($remoteBrand['insta']) {
$localBrand->instagram_handle = $remoteBrand['insta'];
$updates[] = 'instagram_handle';
}
if ($remoteBrand['twitter']) {
$localBrand->twitter_handle = $remoteBrand['twitter'];
$updates[] = 'twitter_handle';
}
if ($remoteBrand['youtube']) {
$localBrand->youtube_url = $remoteBrand['youtube'];
$updates[] = 'youtube_url';
}
// Visibility
$localBrand->is_public = (bool) $remoteBrand['public'];
$updates[] = 'is_public';
// Save the brand
$localBrand->save();
$this->info("\n✓ Successfully imported brand data!");
$this->info('Updated fields: '.implode(', ', $updates));
$this->newLine();
$this->info('View the brand at:');
$this->line("http://localhost/s/cannabrands/brands/{$localBrand->hashid}/edit");
} catch (\Exception $e) {
$this->error('Error: '.$e->getMessage());
return 1;
}
return 0;
}
}