📄 ImportChandraPharmaProductsCommand.php
📁 Path: /home/skhata/new.chandrapharma.com/app/Console/Commands/ImportChandraPharmaProductsCommand.php
📊 Size: 20.09 KB
🔒 Perm: 0600
📝 MIME: text/x-php
<?php
namespace App\Console\Commands;
use App\Modules\Catalog\Models\Category;
use App\Modules\Catalog\Models\Product;
use App\Modules\Catalog\Models\ProductImage;
use App\Modules\User\Models\User;
use App\Modules\Vendor\Models\Vendor;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Imports the Chandra Pharma WooCommerce product export (CSV) into this
* project's catalog.
*
* Source: a WooCommerce "export all columns" dump from chandrapharma.com
* (1428 rows, mostly junk plugin metadata columns which are ignored here).
* Decisions below were agreed with the client before writing this:
*
* - Vendor: every imported product is attached to a new, dedicated
* "Chandra Pharma" vendor — not the generic Test Store.
* - Scope: ALL rows are imported. WooCommerce "Published"=1 rows
* become status=active (live on the storefront);
* everything else (drafts/pending in WooCommerce — that
* was ~96% of the file) becomes status=draft: present in
* the DB and visible in admin, but not live until
* reviewed and published.
* - Categories: imported exactly as given in the "Categories" column
* ("Parent > Child" hierarchy, comma-separated for
* multiple). Note: ~95% of rows carry only the single
* category "Digestive Care" in the source data — that
* reads as a default/fallback tag on the WooCommerce
* side, not real per-product categorization. This
* command does not guess a better category; re-tagging
* is an expected follow-up admin task.
* - Currency: source prices are INR. NPR has been fixed-pegged to
* INR at 1:1.6 since 1993, so price/compare_price/mrp
* are each multiplied by 1.6 and stored as NPR.
* - Brand: the CSV's dedicated "Brands" column is 100% empty, and
* the one attribute that looks brand-like ("brands",
* present on ~1.5% of rows) contains placeholder tech
* company names (Apple, Samsung, HTC...) on medicine
* products — bad source data, not usable. brand_id is
* left null for every imported product.
* - Images: product_images rows are created with disk='url'
* (hotlinked to chandrapharma.com), matching this
* project's existing convention — see
* DownloadProductImagesCommand, which is the intended
* next step to pull them into local storage.
* - Rx flag: every product is imported as drug_schedule=OTC,
* is_prescription_required=false. The export has no
* reliable signal for this, and several imported
* products ARE prescription medicines (e.g. diabetes,
* antibiotic, asthma drugs seen in the sample rows) —
* a pharmacist MUST review and correct drug_schedule /
* is_prescription_required before this catalog goes
* live. This command will not guess at medical/legal
* classifications.
* - Variable products: the 2 rows with Type=variable have no
* accompanying Type=variation rows in this export (no
* variant data at all), so they're skipped and reported
* rather than silently imported with guessed pricing.
*
* Usage:
* php artisan import:chandra-products
* php artisan import:chandra-products storage/app/imports/chandrapharma-products.csv
* php artisan import:chandra-products --descriptions-only
*
* Safe to re-run: products are keyed by SKU (or a synthesized "CP-{id}"
* SKU when the source row had none), so re-running updates existing rows
* instead of duplicating them.
*
* --descriptions-only: re-cleans short_description/description on already
* -imported products WITHOUT touching vendor/price/stock/categories/images.
* Use this after a cleanHtml() fix (like the shortcode/literal-"\n" strip
* added below) instead of a full re-run — a full re-run would also delete
* and recreate every product_images row as disk='url' again, undoing
* products:download-images and forcing a full re-download of all 1428
* images for no reason.
*/
class ImportChandraPharmaProductsCommand extends Command
{
protected $signature = 'import:chandra-products {csv? : Path to the WooCommerce export CSV} {--descriptions-only : Only re-clean description/short_description text on already-imported products}';
protected $description = 'Import the Chandra Pharma WooCommerce product export into the catalog';
private const INR_TO_NPR = 1.6;
/** @var array<string, Category> keyed by the built "Parent > Child" path */
private array $categoryCache = [];
public function handle(): int
{
// A console command is one long-lived PHP process for all 1428
// rows, and this box's default 128M memory_limit isn't enough for
// that — anything that accumulates per-row (query logs, Telescope
// watchers if installed, activity-log snapshots) adds up across
// the whole run and only blows up partway through. Give this
// command its own generous ceiling rather than requiring a
// permanent php.ini change.
ini_set('memory_limit', '1024M');
// Telescope (present in this project — see the
// create_telescope_entries_table migration) records every query
// with full bindings/backtraces in memory for the entire request;
// for a console command that's the whole import run, and that's
// almost certainly what actually exhausted memory at ~1000 rows.
// Stop it recording for the duration of this command.
if (class_exists(\Laravel\Telescope\Telescope::class)) {
\Laravel\Telescope\Telescope::stopRecording();
}
// Also make sure Eloquent's own query log isn't accumulating
// (enabled by some local debug setups) for the same reason.
DB::connection()->disableQueryLog();
$path = $this->argument('csv') ?? storage_path('app/imports/chandrapharma-products.csv');
if (! is_file($path)) {
$this->error("CSV not found at: {$path}");
return self::FAILURE;
}
$handle = fopen($path, 'r');
if ($handle === false) {
$this->error('Could not open the CSV file.');
return self::FAILURE;
}
$header = fgetcsv($handle);
if ($header === false) {
$this->error('CSV appears to be empty.');
fclose($handle);
return self::FAILURE;
}
$header = array_map('trim', $header);
// WooCommerce's export is saved with a UTF-8 BOM, which PHP's
// fgetcsv() doesn't strip — it ends up glued to the very first
// header name ("ID" becomes "\xEF\xBB\xBFID"), so every row's
// $data['ID'] lookup silently came back empty and every row got
// skipped. Strip it before it's used as an array key.
if (isset($header[0])) {
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', $header[0]);
}
$columnCount = count($header);
$descriptionsOnly = (bool) $this->option('descriptions-only');
$vendor = $descriptionsOnly ? null : $this->resolveVendor();
$created = 0;
$updated = 0;
$skipped = 0;
$imagesCreated = 0;
$variableSkipped = [];
$this->info('Importing products from ' . $path . ' ...');
$bar = $this->output->createProgressBar();
$bar->start();
while (($row = fgetcsv($handle)) !== false) {
$bar->advance();
if (count($row) < $columnCount) {
$row = array_pad($row, $columnCount, '');
}
$data = array_combine($header, array_slice($row, 0, $columnCount));
$type = trim($data['Type'] ?? '');
$name = trim($data['Name'] ?? '');
$wcId = trim($data['ID'] ?? '');
if ($name === '' || $wcId === '') {
$skipped++;
continue;
}
if ($type === 'variable') {
$variableSkipped[] = "#{$wcId} {$name}";
$skipped++;
continue;
}
$result = $this->importRow($data, $wcId, $vendor, $descriptionsOnly);
match ($result) {
'created' => $created++,
'updated' => $updated++,
default => $skipped++,
};
$imagesCreated += $this->lastImageCount;
unset($data, $row);
// Belt-and-suspenders against whatever's accumulating in
// memory across the run (see the memory_limit note above) —
// force PHP to actually reclaim cyclic references (Eloquent
// models hold circular refs via relations/events) every so
// often instead of waiting for it to happen on its own.
if (($created + $updated + $skipped) % 100 === 0) {
gc_collect_cycles();
}
}
fclose($handle);
$bar->finish();
$this->newLine(2);
$this->info("Products created: {$created} | updated: {$updated} | skipped: {$skipped} | images: {$imagesCreated}");
if (! empty($variableSkipped)) {
$this->warn('Skipped ' . count($variableSkipped) . ' "variable" product(s) with no variation data in the export — needs manual entry:');
foreach ($variableSkipped as $line) {
$this->line(" - {$line}");
}
}
if ($descriptionsOnly) {
return self::SUCCESS;
}
// Pull the hotlinked images into local storage right away, so the
// import produces working images end-to-end without a manual
// second step. Safe to re-run on its own later (e.g. with
// --force) if some downloads fail here and need a retry.
if ($imagesCreated > 0) {
$this->newLine();
$this->info('Downloading product images into local storage...');
$this->call('products:download-images');
}
$this->newLine();
$this->warn('Still to do:');
$this->line(' 1. Have a pharmacist review drug_schedule / is_prescription_required — everything imported as OTC by default.');
$this->line(' 2. Re-tag products out of the single "Digestive Care" bucket where the source categorization was a fallback, not real.');
$this->line(' 3. Run php artisan scout:import "App\\Modules\\Catalog\\Models\\Product" if you use search indexing — imports run with search sync suppressed.');
$this->line(' 4. If any images failed to download (reported above), re-run: php artisan products:download-images');
return self::SUCCESS;
}
private int $lastImageCount = 0;
private function importRow(array $data, string $wcId, ?Vendor $vendor, bool $descriptionsOnly = false): string
{
$sku = trim($data['SKU'] ?? '');
if ($sku === '') {
$sku = 'CP-' . $wcId;
}
$this->lastImageCount = 0;
if ($descriptionsOnly) {
$product = Product::where('sku', $sku)->first();
if (! $product) {
return 'skipped';
}
$shortDescription = $this->cleanHtml($data['Short description'] ?? '');
$description = $this->cleanHtml($data['Description'] ?? '');
Product::withoutSyncingToSearch(function () use ($product, $shortDescription, $description) {
$product->update([
'short_description' => $shortDescription !== '' ? ['en' => $shortDescription] : null,
'description' => $description !== '' ? ['en' => $description] : null,
]);
});
return 'updated';
}
$name = trim($data['Name'] ?? '');
$published = trim($data['Published'] ?? '') === '1';
$regularPrice = $this->toFloat($data['Regular price'] ?? null);
$salePrice = $this->toFloat($data['Sale price'] ?? null);
$priceNpr = $regularPrice !== null ? round(($salePrice ?? $regularPrice) * self::INR_TO_NPR, 2) : null;
$comparePriceNpr = ($salePrice !== null && $regularPrice !== null) ? round($regularPrice * self::INR_TO_NPR, 2) : null;
$mrpNpr = $regularPrice !== null ? round($regularPrice * self::INR_TO_NPR, 2) : null;
$stockRaw = trim($data['Stock'] ?? '');
$inStock = trim($data['In stock?'] ?? '') === '1';
$stockQuantity = $stockRaw !== '' ? (int) $stockRaw : ($inStock ? 100 : 0);
$shortDescription = $this->cleanHtml($data['Short description'] ?? '');
$description = $this->cleanHtml($data['Description'] ?? '');
$slug = Str::slug($name) . '-' . $wcId;
/** @var Product $product */
$product = Product::withoutSyncingToSearch(function () use (
$sku, $vendor, $name, $slug, $shortDescription, $description,
$priceNpr, $comparePriceNpr, $mrpNpr, $stockQuantity, $published,
) {
return Product::updateOrCreate(
['sku' => $sku],
[
'vendor_id' => $vendor->id,
'name' => ['en' => $name],
'slug' => $slug,
'short_description' => $shortDescription !== '' ? ['en' => $shortDescription] : null,
'description' => $description !== '' ? ['en' => $description] : null,
'product_type' => 'simple',
'price' => $priceNpr,
'compare_price' => $comparePriceNpr,
'mrp' => $mrpNpr,
'currency' => 'NPR',
'track_inventory' => true,
'stock_quantity' => $stockQuantity,
'status' => $published ? 'active' : 'draft',
'published_at' => $published ? now() : null,
'drug_schedule' => 'OTC',
'is_prescription_required' => false,
'requires_shipping' => true,
]
);
});
$wasCreated = $product->wasRecentlyCreated;
// ── Categories ───────────────────────────────────────────────────
$categoryIds = [];
foreach (explode(',', $data['Categories'] ?? '') as $path) {
$path = trim($path);
if ($path === '') {
continue;
}
$category = $this->resolveCategoryPath($path);
if ($category) {
$categoryIds[] = $category->id;
}
}
if (! empty($categoryIds)) {
$product->categories()->sync($categoryIds);
$product->forceFill(['category_id' => $categoryIds[0]])->save();
}
// ── Images (hotlinked — disk='url', matches
// DownloadProductImagesCommand's expected input format) ────────
$urls = array_values(array_filter(array_map('trim', explode(',', $data['Images'] ?? ''))));
$this->lastImageCount = 0;
if (! empty($urls)) {
$product->images()->delete();
foreach ($urls as $i => $url) {
ProductImage::create([
'product_id' => $product->id,
'url' => $url,
'url_thumbnail' => $url,
'url_medium' => $url,
'url_large' => $url,
'disk' => 'url',
'is_primary' => $i === 0,
'sort_order' => $i,
]);
$this->lastImageCount++;
}
}
return $wasCreated ? 'created' : 'updated';
}
private function resolveVendor(): Vendor
{
$owner = User::firstOrCreate(
['email' => 'chandrapharma@stpecommerce.com'],
[
'name' => 'Chandra Pharma',
'password' => Str::random(32),
'email_verified_at' => now(),
]
);
if (method_exists($owner, 'hasRole') && ! $owner->hasRole('vendor')) {
$owner->assignRole('vendor');
}
$vendor = Vendor::firstOrCreate(
['email' => 'chandrapharma@stpecommerce.com'],
[
'owner_id' => $owner->id,
'name' => 'Chandra Pharma',
'slug' => 'chandra-pharma',
'legal_name' => 'Chandra Pharma',
'country' => 'NP',
'commission_rate' => 0.00,
'status' => 'approved',
'approved_at' => now(),
'is_active' => true,
]
);
if (! $owner->vendor_id) {
$owner->update(['vendor_id' => $vendor->id]);
}
return $vendor;
}
/**
* "Health Care > Pain & Fever Relief" resolves/creates "Health Care"
* as a root category, then "Pain & Fever Relief" under it. A bare
* "Digestive Care" resolves/creates a root category with no parent.
*/
private function resolveCategoryPath(string $path): ?Category
{
if (isset($this->categoryCache[$path])) {
return $this->categoryCache[$path];
}
$segments = array_values(array_filter(array_map('trim', explode('>', $path))));
$parent = null;
$built = '';
foreach ($segments as $depth => $segment) {
$built = $built === '' ? $segment : $built . ' > ' . $segment;
if (isset($this->categoryCache[$built])) {
$parent = $this->categoryCache[$built];
continue;
}
$slug = Str::slug($segment) . ($parent ? '-' . $parent->id : '');
$category = Category::firstOrCreate(
['slug' => $slug],
[
'parent_id' => $parent?->id,
'name' => ['en' => $segment],
'depth' => $depth,
'is_active' => true,
'show_in_menu' => true,
]
);
$this->categoryCache[$built] = $category;
$parent = $category;
}
return $parent;
}
private function toFloat(?string $value): ?float
{
$value = trim((string) $value);
return $value === '' ? null : (float) $value;
}
/**
* The source export's short/long descriptions carry cruft from the
* old WordPress site's page builder (WPBakery/Visual Composer-style
* shortcodes like "[vc_row][vc_column][vc_column_text css=""]...
* [/vc_column_text]") plus literal two-character "\n" escape
* sequences baked into the text as-is (not real newlines — an
* artifact of how the exporter serialized multi-line content).
* Neither is touched by strip_tags() (shortcodes use square brackets,
* not HTML tags) or by the whitespace-collapsing regex below (a
* literal backslash+n isn't whitespace), so both need stripping
* explicitly before the text is safe to store/display as plain text.
*/
private function cleanHtml(string $html): string
{
// Page-builder shortcodes: [tag], [tag attr="value"], [/tag].
$text = preg_replace('/\[\/?[a-zA-Z_][a-zA-Z0-9_-]*(?:\s+[^\]]*)?\]/', '', $html) ?? $html;
$text = html_entity_decode(strip_tags($text));
// Literal "\n" / "\r\n" / "\r" escape sequences left in the text
// as two-plus literal characters, not actual line breaks.
$text = str_replace(['\\r\\n', '\\n', '\\r'], ' ', $text);
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
return trim($text);
}
}