π₯οΈ Server: LiteSpeed
π» System: Linux s20058.bom1.stableserver.net 5.14.0-611.55.1.el9_7.x86_64 #1 SMP PREEMPT_DYNAMIC Tue May 19 15:19:29 EDT 2026 x86_64
π€ User: skhata (1324)
π PHP: 8.4.20
π« Disabled: β¨ NONE
π OrganizeCategoriesCommand.php
π Path: /home/skhata/new.chandrapharma.com/app/Console/Commands/OrganizeCategoriesCommand.php
π Size: 8.91 KB
π Perm: 0600
π MIME: text/x-php
<?php
namespace App\Console\Commands;
use App\Modules\Catalog\Models\Category;
use Illuminate\Console\Command;
/**
* Re-parents the flat list of categories the WooCommerce product import
* created (29 root-level categories, one per unique "Categories" column
* value in the source CSV) under the site's original 7 top-level
* categories β the ones the storefront nav bar actually has icons and
* dedicated slugs for (see layouts/app.blade.php's $navCatIcons map).
*
* Why this was needed: the nav bar renders every category with
* parent_id=null (CategoryService::tree()). The import created each CSV
* category as its own root row, so the bar ballooned from 7 items to ~36.
* Nesting them under the original 7 removes them from that root-only
* query without touching which products belong to which category β a
* product's category assignment (product_category pivot) is unaffected
* by changing its category's *parent*.
*
* This does NOT fix the fact that ~95% of imported products only ever
* carried the single "Digestive Care" tag in the source data β that's a
* product-level re-tagging problem, not a category-tree problem, and is
* out of scope here on purpose.
*
* Usage:
* php artisan categories:organize (applies the changes)
* php artisan categories:organize --dry-run (preview only, no writes)
*
* Safe to re-run β matching is by category name, so already-reparented
* categories are simply found again and re-assigned to the same parent
* (a no-op in effect).
*/
class OrganizeCategoriesCommand extends Command
{
protected $signature = 'categories:organize {--dry-run : Preview changes without saving}';
protected $description = 'Nest the flat imported categories under the site\'s original top-level categories';
/**
* child category name (as it exists in the DB today) => target parent slug.
* Parent slugs match layouts/app.blade.php's $navCatIcons keys exactly.
*/
private const PLAN = [
// Medicines & OTC
'Digestive Care' => 'medicines-otc',
'First Aid & Wound Care' => 'medicines-otc',
'Respiratory & Allergy Care' => 'medicines-otc',
'Pain & Fever Relief' => 'medicines-otc', // currently nested under the throwaway "Health Care" root
'Sleep & Stress Relief' => 'medicines-otc',
'Bone & Joint Health' => 'medicines-otc',
'Eye & Ear Care' => 'medicines-otc',
// Wellness & Nutrition
'Vitamins & Minerals' => 'wellness-nutrition',
'Protein & Fitness' => 'wellness-nutrition',
'Weight Management' => 'wellness-nutrition',
'Immunity Boosters' => 'wellness-nutrition',
// Personal Care
'Oral Care' => 'personal-care',
'Bath & Body' => 'personal-care',
'Skin Care' => 'personal-care',
'Hair Care' => 'personal-care',
'Foot Care' => 'personal-care',
'Nails & Hand Care' => 'personal-care',
'Men Grooming' => 'personal-care',
'Makeup Essentials' => 'personal-care',
// Baby Care
'Baby Bath & Hygiene' => 'baby-care',
'Baby Nutrition' => 'baby-care',
'Mother Care Essentials' => 'baby-care',
// Ayurvedic & Herbal
'Ayurvedic Supplements' => 'ayurvedic-herbal',
// Medical Supplies
'Monitoring Devices' => 'medical-supplies',
'Mobility Support' => 'medical-supplies',
'Personal Safety Devices' => 'medical-supplies',
'Step counter' => 'medical-supplies',
// Women's Health
'Sexual Wellness' => 'womens-health',
];
/** The 7 canonical top-level categories β created if a fresh DB doesn't have them yet. */
private const CANONICAL_PARENTS = [
'medicines-otc' => 'Medicines & OTC',
'ayurvedic-herbal' => 'Ayurvedic & Herbal',
'medical-supplies' => 'Medical Supplies',
'personal-care' => 'Personal Care',
'baby-care' => 'Baby Care',
'wellness-nutrition' => 'Wellness & Nutrition',
'womens-health' => "Women's Health",
];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('--dry-run: previewing only, nothing will be saved.');
}
$parents = $this->resolveCanonicalParents($dryRun);
// Load every category once, keyed by normalized name, so we can
// find each PLAN entry regardless of its current parent/slug β
// child slugs from the import include a numeric parent-id suffix
// we can't predict ahead of time, so matching by name is the
// reliable option here.
$byName = Category::all()->keyBy(fn (Category $c) => $this->normalize($c->name));
$moved = 0;
$missing = [];
foreach (self::PLAN as $childName => $parentSlug) {
$category = $byName->get($this->normalize($childName));
$parent = $parents[$parentSlug] ?? null;
if (! $category || ! $parent) {
$missing[] = $childName;
continue;
}
if ((int) $category->parent_id === (int) $parent->id) {
continue; // already correctly parented β nothing to do
}
$this->line(" {$category->name} β {$parent->name}");
if (! $dryRun) {
$category->forceFill([
'parent_id' => $parent->id,
'depth' => 1,
'path' => (string) $parent->id,
])->save();
}
$moved++;
}
if (! empty($missing)) {
$this->warn('Not found in the database (skipped β check spelling/whether these were imported at all):');
foreach ($missing as $name) {
$this->line(" - {$name}");
}
}
// Clean up: the CSV's one hierarchical entry ("Health Care > Pain &
// Fever Relief") created a "Health Care" root purely to hold that
// one child. Now that the child has been moved to Medicines & OTC
// above, delete "Health Care" if it's been left with nothing in it.
$healthCare = $byName->get($this->normalize('Health Care'));
if ($healthCare && $healthCare->children()->count() === 0 && $healthCare->products()->count() === 0) {
$this->line(" Removing now-empty \"Health Care\" placeholder category");
if (! $dryRun) {
$healthCare->delete();
}
}
// Anything still sitting at root that isn't one of the 7 canonical
// categories and wasn't in PLAN is a gap in this command's mapping
// (e.g. the CSV categories changed since this was written) β flag
// it instead of silently leaving nav clutter unexplained.
$stillFlat = Category::whereNull('parent_id')
->whereNotIn('slug', array_keys(self::CANONICAL_PARENTS))
->pluck('name');
if ($stillFlat->isNotEmpty()) {
$this->warn('Still sitting at root level (not covered by this command\'s plan):');
foreach ($stillFlat as $name) {
$this->line(" - {$name}");
}
}
$this->newLine();
$this->info(($dryRun ? 'Would reparent' : 'Reparented') . " {$moved} categories.");
return self::SUCCESS;
}
/** @return array<string, Category> parent slug => Category */
private function resolveCanonicalParents(bool $dryRun): array
{
$parents = [];
foreach (self::CANONICAL_PARENTS as $slug => $name) {
$category = Category::where('slug', $slug)->first();
if (! $category) {
if ($dryRun) {
$this->warn("Canonical parent \"{$name}\" (slug: {$slug}) doesn't exist yet β would be created.");
continue;
}
$category = Category::create([
'slug' => $slug,
'name' => ['en' => $name],
'depth' => 0,
'is_active' => true,
'show_in_menu' => true,
]);
$this->info("Created missing canonical parent: {$name}");
}
$parents[$slug] = $category;
}
return $parents;
}
private function normalize(string $name): string
{
// Lowercase, strip anything that isn't a letter/number β makes
// "Women's Health" and "Womenβs Health" (straight vs curly
// apostrophe, which the WooCommerce export used inconsistently)
// compare equal instead of silently matching nothing.
return preg_replace('/[^a-z0-9]/', '', mb_strtolower($name)) ?? '';
}
}