Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 134
0.00% covered (danger)
0.00%
0 / 1
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImportProductsCsv
0.00% covered (danger)
0.00%
0 / 134
0.00% covered (danger)
0.00%
0 / 1
702
0.00% covered (danger)
0.00%
0 / 1
 handle
0.00% covered (danger)
0.00%
0 / 134
0.00% covered (danger)
0.00%
0 / 1
702
1<?php
2
3namespace App\Console\Commands;
4
5use App\Models\admin\Product;
6use App\Models\Attribute;
7use App\Models\AttributeValue;
8use App\Models\Brand;
9use App\Models\Category;
10use App\Models\ProductVariation;
11use App\Models\User;
12use App\Service\StockService;
13use Illuminate\Console\Command;
14use Illuminate\Support\Facades\DB;
15use Illuminate\Support\Facades\File;
16use Illuminate\Support\Str;
17
18class 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}