Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 134 |
|
0.00% |
0 / 1 |
CRAP | |
0.00% |
0 / 1 |
| ImportProductsCsv | |
0.00% |
0 / 134 |
|
0.00% |
0 / 1 |
702 | |
0.00% |
0 / 1 |
| handle | |
0.00% |
0 / 134 |
|
0.00% |
0 / 1 |
702 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Console\Commands; |
| 4 | |
| 5 | use App\Models\admin\Product; |
| 6 | use App\Models\Attribute; |
| 7 | use App\Models\AttributeValue; |
| 8 | use App\Models\Brand; |
| 9 | use App\Models\Category; |
| 10 | use App\Models\ProductVariation; |
| 11 | use App\Models\User; |
| 12 | use App\Service\StockService; |
| 13 | use Illuminate\Console\Command; |
| 14 | use Illuminate\Support\Facades\DB; |
| 15 | use Illuminate\Support\Facades\File; |
| 16 | use Illuminate\Support\Str; |
| 17 | |
| 18 | class ImportProductsCsv extends Command |
| 19 | { |
| 20 | protected $signature = 'import:products {file}'; |
| 21 | |
| 22 | protected $description = 'Import products from a CSV file and wipe old data'; |
| 23 | |
| 24 | public function handle() |
| 25 | { |
| 26 | $file = $this->argument('file'); |
| 27 | |
| 28 | if (! file_exists($file)) { |
| 29 | $this->error("File not found: {$file}"); |
| 30 | |
| 31 | return Command::FAILURE; |
| 32 | } |
| 33 | |
| 34 | $this->info('Wiping existing product data...'); |
| 35 | $this->warn("Note: this also wipes the `stocks` table (POS inventory) and re-syncs it from the fresh import below. Any past order still referencing an old stock row will lose that line item's product/color/size lookup on the Order Details page — this command has always wiped products/categories/brands the same way, so this isn't a new risk, just flagging it."); |
| 36 | |
| 37 | DB::statement('SET FOREIGN_KEY_CHECKS=0;'); |
| 38 | DB::table('product_variation_attribute_value')->truncate(); |
| 39 | DB::table('product_variations')->truncate(); |
| 40 | DB::table('attribute_values')->truncate(); |
| 41 | DB::table('attributes')->truncate(); |
| 42 | DB::table('category_product')->truncate(); |
| 43 | // BUG FIX: this command wipes `products` (and everything under it) and |
| 44 | // reimports fresh rows with brand new ids, but used to leave `stocks` |
| 45 | // completely untouched — every Stock row from before the wipe was left |
| 46 | // pointing at a product_id that no longer existed, which is exactly why |
| 47 | // some products showed up in the POS as "Unnamed product". Since this |
| 48 | // command's whole job is "wipe old data" and stocks are re-synced per |
| 49 | // product further down (via StockService::syncStockForProduct), it's |
| 50 | // safe and correct to wipe them here too. |
| 51 | DB::table('stocks')->truncate(); |
| 52 | DB::table('products')->truncate(); |
| 53 | DB::table('categories')->truncate(); |
| 54 | DB::table('brands')->truncate(); |
| 55 | DB::statement('SET FOREIGN_KEY_CHECKS=1;'); |
| 56 | |
| 57 | $this->info('Data wiped successfully.'); |
| 58 | |
| 59 | $user = User::first(); |
| 60 | if (! $user) { |
| 61 | $this->error('No user found to assign created_by.'); |
| 62 | |
| 63 | return Command::FAILURE; |
| 64 | } |
| 65 | |
| 66 | auth()->login($user); |
| 67 | |
| 68 | // Create uploads directory if doesn't exist |
| 69 | $uploadPath = public_path('storage/products'); |
| 70 | if (! File::exists($uploadPath)) { |
| 71 | File::makeDirectory($uploadPath, 0777, true); |
| 72 | } |
| 73 | |
| 74 | $this->info('Parsing CSV...'); |
| 75 | |
| 76 | // Read CSV properly handling newlines inside quoted fields |
| 77 | $csvContent = file_get_contents($file); |
| 78 | $rows = []; |
| 79 | $resource = fopen('php://memory', 'r+'); |
| 80 | fwrite($resource, $csvContent); |
| 81 | rewind($resource); |
| 82 | $header = null; |
| 83 | while (($row = fgetcsv($resource, 0, ',', '"')) !== false) { |
| 84 | if ($header === null) { |
| 85 | $header = array_map('trim', $row); |
| 86 | } else { |
| 87 | if (count($header) === count($row)) { |
| 88 | $rows[] = array_combine($header, $row); |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | fclose($resource); |
| 93 | |
| 94 | $this->info('Total rows to process: ' . count($rows)); |
| 95 | |
| 96 | $lastProductId = null; // Track the most recently created parent product |
| 97 | |
| 98 | foreach ($rows as $index => $data) { |
| 99 | $importType = strtolower(trim($data['Import Type'] ?? '')); |
| 100 | |
| 101 | if ($importType === 'product' || empty($importType)) { |
| 102 | // ── PARENT PRODUCT ── |
| 103 | $sku = trim($data['SKU']); |
| 104 | $name = trim($data['Name']); |
| 105 | |
| 106 | if (empty($name)) { |
| 107 | continue; |
| 108 | } |
| 109 | |
| 110 | // Category |
| 111 | $categoryName = trim($data['Categories'] ?? 'Uncategorized') ?: 'Uncategorized'; |
| 112 | $firstCat = trim(explode(',', $categoryName)[0]); |
| 113 | $category = Category::firstOrCreate( |
| 114 | ['name' => $firstCat], |
| 115 | ['slug' => Str::slug($firstCat), 'isActive' => 1, 'created_by' => $user->id] |
| 116 | ); |
| 117 | |
| 118 | // Brand |
| 119 | $brandName = trim($data['Brand'] ?? ''); |
| 120 | $brandId = null; |
| 121 | if (! empty($brandName)) { |
| 122 | $brand = Brand::firstOrCreate( |
| 123 | ['name' => $brandName], |
| 124 | ['slug' => Str::slug($brandName), 'status' => 1, 'created_by' => $user->id] |
| 125 | ); |
| 126 | $brandId = $brand->id; |
| 127 | } |
| 128 | |
| 129 | // Prices |
| 130 | $price = ! empty($data['Price']) ? (float) $data['Price'] : 0; |
| 131 | $sellingPrice = ! empty($data['Sale Price']) ? (float) $data['Sale Price'] : $price; |
| 132 | |
| 133 | // Image download |
| 134 | $imageUrl = trim($data['Image'] ?? ''); |
| 135 | $localImagePath = null; |
| 136 | if (! empty($imageUrl)) { |
| 137 | try { |
| 138 | $contents = @file_get_contents($imageUrl); |
| 139 | if ($contents) { |
| 140 | $filename = uniqid() . '-' . basename(parse_url($imageUrl, PHP_URL_PATH)); |
| 141 | file_put_contents($uploadPath . '/' . $filename, $contents); |
| 142 | $localImagePath = 'storage/products/' . $filename; |
| 143 | } else { |
| 144 | $localImagePath = $imageUrl; |
| 145 | } |
| 146 | } catch (\Exception $e) { |
| 147 | $localImagePath = $imageUrl; |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | $product = Product::create([ |
| 152 | 'name' => $name, |
| 153 | 'sku_no' => $sku, |
| 154 | 'product_code' => $sku, |
| 155 | 'slug' => Str::slug($name) . '-' . uniqid(), |
| 156 | 'category_id' => $category->id, |
| 157 | 'brand_id' => $brandId, |
| 158 | 'details' => $data['Content'] ?? ($data['Description'] ?? null), |
| 159 | 'short_details' => $data['Description'] ?? null, |
| 160 | 'price' => $price, |
| 161 | 'selling_price' => $sellingPrice, |
| 162 | 'thumbnail' => $localImagePath, |
| 163 | 'image' => $localImagePath, |
| 164 | 'stock' => (int) ($data['Quantity'] ?? 100), |
| 165 | 'created_by' => $user->id, |
| 166 | ]); |
| 167 | |
| 168 | $product->categories()->syncWithoutDetaching([$category->id]); |
| 169 | |
| 170 | // ✅ Track this as the current parent |
| 171 | $lastProductId = $product->id; |
| 172 | |
| 173 | $this->info("Created Product: {$name} (SKU: {$sku})"); |
| 174 | |
| 175 | } elseif ($importType === 'variation') { |
| 176 | // ── VARIATION ── link to the LAST created parent product (sequential) |
| 177 | if (! $lastProductId) { |
| 178 | $this->warn("Row {$index}: Variation found but no parent product created yet. Skipping."); |
| 179 | |
| 180 | continue; |
| 181 | } |
| 182 | |
| 183 | $price = ! empty($data['Sale Price']) |
| 184 | ? (float) $data['Sale Price'] |
| 185 | : (! empty($data['Price']) ? (float) $data['Price'] : null); |
| 186 | |
| 187 | $variation = ProductVariation::create([ |
| 188 | 'product_id' => $lastProductId, |
| 189 | 'sku' => trim($data['SKU']) . '-' . Str::random(5), |
| 190 | 'price' => $price, |
| 191 | 'stock_quantity' => (int) ($data['Quantity'] ?? 10), |
| 192 | 'stock_status' => $data['Stock Status'] ?? 'in_stock', |
| 193 | ]); |
| 194 | |
| 195 | // Parse Attributes e.g. "Size:39,Color:Black" |
| 196 | $attributesStr = trim($data['Product Attributes'] ?? ''); |
| 197 | if (! empty($attributesStr)) { |
| 198 | foreach (explode(',', $attributesStr) as $pair) { |
| 199 | $parts = explode(':', trim($pair)); |
| 200 | if (count($parts) === 2) { |
| 201 | $attrName = trim($parts[0]); |
| 202 | $attrValue = trim($parts[1]); |
| 203 | |
| 204 | $attribute = Attribute::firstOrCreate(['name' => $attrName]); |
| 205 | $valueObj = AttributeValue::firstOrCreate([ |
| 206 | 'attribute_id' => $attribute->id, |
| 207 | 'value' => $attrValue, |
| 208 | ]); |
| 209 | |
| 210 | DB::table('product_variation_attribute_value')->insert([ |
| 211 | 'product_variation_id' => $variation->id, |
| 212 | 'attribute_value_id' => $valueObj->id, |
| 213 | 'created_at' => now(), |
| 214 | 'updated_at' => now(), |
| 215 | ]); |
| 216 | } |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | $attrDisplay = $attributesStr ?: 'no attributes'; |
| 221 | $this->info(" └─ Variation: {$attrDisplay}"); |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | // BUG FIX: without this, none of the just-imported products (or their |
| 226 | // variations) ever showed up in the POS — see the note on the `stocks` |
| 227 | // truncate above. This creates the matching Stock row(s) for every |
| 228 | // product now in the database. |
| 229 | $this->info("\nSyncing POS stock table..."); |
| 230 | Product::with('variations')->get()->each(function ($product) { |
| 231 | StockService::syncStockForProduct($product); |
| 232 | }); |
| 233 | |
| 234 | $this->info("\nImport completed successfully!"); |
| 235 | |
| 236 | return Command::SUCCESS; |
| 237 | } |
| 238 | } |