Skip to content

Category Reorganization: Avito Hierarchy Import — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace flat 2-level part categories with the full Avito hierarchy from docs/addons/avito_zapchasti_categories.json, preserving existing child categories and product references.

Architecture: A Symfony Console command reads the Avito JSON, inserts the new hierarchy, maps old parent IDs to new Avito node IDs by name, updates all references (product_categories, products.primary_category_id, children's parent_id), and deletes old parents. A separate Doctrine migration records the schema-level change tracking. The SeedReferenceDataCommand is updated to match.

Tech Stack: PHP 8.3, Doctrine ORM, Symfony Console, PostgreSQL

Important context:

  • Prod DB has 0 categories and 0 products — migration only matters on dev
  • Dev DB accessible via API at https://dev.partizap.ru/api/ (Basic Auth: $PARTIZAP_BASIC_AUTH_USER:$PARTIZAP_BASIC_AUTH_PASS)
  • Dev DB has 12 parent + 82 child part categories, ~50 products with category references
  • FK constraints exist on: product_categories.category_id, products.primary_category_id, search_logs.category_id, categories.parent_id
  • categories.slug has a UNIQUE constraint — slugs must not collide
  • Entity: app/Domain/Entity/Category.php, Enum: app/Domain/Enum/CategoryType.php
  • Seed: app/Infrastructure/Command/SeedReferenceDataCommand.php

Task 1: Create the MigrateCategoriesCommand Console Command

Files:

  • Create: app/Infrastructure/Command/MigrateCategoriesCommand.php
  • Reference: docs/addons/avito_zapchasti_categories.json
  • Reference: app/Infrastructure/Command/SeedReferenceDataCommand.php (for slugify method and patterns)

Step 1: Create the command file

Create app/Infrastructure/Command/MigrateCategoriesCommand.php with the full implementation:

php
<?php

declare(strict_types=1);

namespace App\Infrastructure\Command;

use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

final class MigrateCategoriesCommand extends Command
{
    /** @var array<string, int> old parent name → old ID */
    private array $oldParentMap = [];

    /** @var array<string, int> old parent name → new Avito ID */
    private array $nameToNewId = [];

    /** @var array<int, int> old ID → new ID (for parents that got replaced) */
    private array $idMapping = [];

    /** @var array<string, bool> track used slugs to avoid collisions */
    private array $usedSlugs = [];

    public function __construct(
        private readonly Connection $connection,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->setName('app:migrate-categories');
        $this->setDescription('Migrate part categories to Avito hierarchy structure');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $io->title('Migrating Categories to Avito Hierarchy');

        // 1. Load Avito JSON
        $jsonPath = dirname(__DIR__, 3) . '/docs/addons/avito_zapchasti_categories.json';
        if (!file_exists($jsonPath)) {
            $io->error("Avito JSON not found at: {$jsonPath}");
            return Command::FAILURE;
        }

        $avito = json_decode((string) file_get_contents($jsonPath), true, 512, JSON_THROW_ON_ERROR);
        $io->text('Loaded Avito JSON with ' . count($avito) . ' top-level categories');

        // 2. Load existing parent categories (part type, parent_id IS NULL)
        $oldParents = $this->connection->fetchAllAssociative(
            "SELECT id, name, slug, icon FROM categories WHERE category_type = 'part' AND parent_id IS NULL ORDER BY id"
        );

        if (count($oldParents) === 0) {
            $io->text('No existing parent categories found — fresh import');
        } else {
            $io->text('Found ' . count($oldParents) . ' existing parent categories');
            foreach ($oldParents as $p) {
                $this->oldParentMap[$p['name']] = (int) $p['id'];
            }
        }

        // 3. Load existing slugs (all categories) to avoid collisions
        $existingSlugs = $this->connection->fetchFirstColumn(
            'SELECT slug FROM categories'
        );
        foreach ($existingSlugs as $slug) {
            $this->usedSlugs[$slug] = true;
        }

        // 4. Begin transaction
        $this->connection->beginTransaction();

        try {
            // 5. Insert Avito hierarchy recursively
            $sortOrder = 0;
            $insertedCount = 0;
            foreach ($avito as $name => $children) {
                $insertedCount += $this->insertCategory(
                    $io,
                    (string) $name,
                    $children,
                    null,
                    $sortOrder++
                );
            }
            $io->text("Inserted {$insertedCount} new Avito categories");

            // 6. If there were old parents, remap children and products
            if (count($this->oldParentMap) > 0) {
                $this->remapChildren($io);
                $this->remapProducts($io);
                $this->deleteOldParents($io);
            }

            $this->connection->commit();
            $io->success('Category migration completed successfully');

            // 7. Print mapping for reference
            if (count($this->idMapping) > 0) {
                $io->section('ID Mapping (old → new)');
                foreach ($this->idMapping as $oldId => $newId) {
                    $name = array_search($oldId, $this->oldParentMap, true);
                    $io->text("  {$oldId} → {$newId} ({$name})");
                }
            }

            return Command::SUCCESS;
        } catch (\Throwable $e) {
            $this->connection->rollBack();
            $io->error('Migration failed: ' . $e->getMessage());
            return Command::FAILURE;
        }
    }

    /**
     * Recursively insert a category and its children from the Avito JSON.
     *
     * @param array<string, mixed>|list<mixed> $children
     * @return int number of categories inserted
     */
    private function insertCategory(
        SymfonyStyle $io,
        string $name,
        array $children,
        ?int $parentId,
        int $sortOrder,
    ): int {
        $slug = $this->generateUniqueSlug($name, $parentId);
        $count = 0;

        // Check if this name matches an old parent (only for "Для автомобилей" children)
        $isMatchedParent = isset($this->oldParentMap[$name]);

        $this->connection->insert('categories', [
            'name' => $name,
            'slug' => $slug,
            'icon' => null,
            'parent_id' => $parentId,
            'category_type' => 'part',
            'sort_order' => $sortOrder,
            'is_active' => true,
            'products_count' => 0,
        ]);

        $newId = (int) $this->connection->lastInsertId();
        $this->usedSlugs[$slug] = true;
        $count++;

        // If this matches an old parent, record the mapping
        if ($isMatchedParent) {
            $oldId = $this->oldParentMap[$name];
            $this->idMapping[$oldId] = $newId;
            $this->nameToNewId[$name] = $newId;
            $io->text("  Matched: '{$name}' old_id={$oldId} → new_id={$newId}");
        }

        // Insert children recursively
        if (is_array($children) && $this->isAssociativeArray($children)) {
            $childSortOrder = 0;
            foreach ($children as $childName => $grandChildren) {
                $count += $this->insertCategory(
                    $io,
                    (string) $childName,
                    is_array($grandChildren) ? $grandChildren : [],
                    $newId,
                    $childSortOrder++
                );
            }
        }

        return $count;
    }

    /**
     * Update children of old parents to point to new Avito parent IDs.
     */
    private function remapChildren(SymfonyStyle $io): void
    {
        $io->section('Remapping child categories');

        foreach ($this->idMapping as $oldId => $newId) {
            $affected = $this->connection->executeStatement(
                'UPDATE categories SET parent_id = ? WHERE parent_id = ?',
                [$newId, $oldId]
            );
            $name = array_search($oldId, $this->oldParentMap, true);
            $io->text("  '{$name}': {$affected} children moved from parent {$oldId} → {$newId}");
        }
    }

    /**
     * Update product_categories and products.primary_category_id.
     * Map references to old parent IDs → new Avito IDs.
     */
    private function remapProducts(SymfonyStyle $io): void
    {
        $io->section('Remapping product references');

        foreach ($this->idMapping as $oldId => $newId) {
            // product_categories
            $pcAffected = $this->connection->executeStatement(
                'UPDATE product_categories SET category_id = ? WHERE category_id = ?',
                [$newId, $oldId]
            );

            // products.primary_category_id
            $pAffected = $this->connection->executeStatement(
                'UPDATE products SET primary_category_id = ? WHERE primary_category_id = ?',
                [$newId, $oldId]
            );

            // search_logs.category_id
            $slAffected = $this->connection->executeStatement(
                'UPDATE search_logs SET category_id = ? WHERE category_id = ?',
                [$newId, $oldId]
            );

            $name = array_search($oldId, $this->oldParentMap, true);
            if ($pcAffected > 0 || $pAffected > 0 || $slAffected > 0) {
                $io->text("  '{$name}' ({$oldId}→{$newId}): {$pcAffected} product_categories, {$pAffected} products, {$slAffected} search_logs");
            }
        }
    }

    /**
     * Delete old parent categories that have been replaced by Avito nodes.
     */
    private function deleteOldParents(SymfonyStyle $io): void
    {
        $io->section('Deleting old parent categories');

        foreach ($this->idMapping as $oldId => $newId) {
            $this->connection->executeStatement(
                'DELETE FROM categories WHERE id = ?',
                [$oldId]
            );
            $name = array_search($oldId, $this->oldParentMap, true);
            $io->text("  Deleted: '{$name}' (id={$oldId})");
        }
    }

    private function generateUniqueSlug(string $name, ?int $parentId): string
    {
        $base = $this->slugify($name);

        // If parent exists, prefix with parent slug for uniqueness
        if ($parentId !== null) {
            $parentSlug = $this->connection->fetchOne(
                'SELECT slug FROM categories WHERE id = ?',
                [$parentId]
            );
            if ($parentSlug !== false) {
                $candidate = $parentSlug . '-' . $base;
                if (!isset($this->usedSlugs[$candidate])) {
                    return $candidate;
                }
            }
        }

        // Plain slug if unique
        if (!isset($this->usedSlugs[$base])) {
            return $base;
        }

        // Append numeric suffix
        $i = 2;
        while (isset($this->usedSlugs[$base . '-' . $i])) {
            $i++;
        }

        return $base . '-' . $i;
    }

    private function slugify(string $text): string
    {
        $translitMap = [
            'а' => 'a', 'б' => 'b', 'в' => 'v', 'г' => 'g', 'д' => 'd',
            'е' => 'e', 'ё' => 'yo', 'ж' => 'zh', 'з' => 'z', 'и' => 'i',
            'й' => 'j', 'к' => 'k', 'л' => 'l', 'м' => 'm', 'н' => 'n',
            'о' => 'o', 'п' => 'p', 'р' => 'r', 'с' => 's', 'т' => 't',
            'у' => 'u', 'ф' => 'f', 'х' => 'kh', 'ц' => 'ts', 'ч' => 'ch',
            'ш' => 'sh', 'щ' => 'shch', 'ъ' => '', 'ы' => 'y', 'ь' => '',
            'э' => 'e', 'ю' => 'yu', 'я' => 'ya',
            'А' => 'a', 'Б' => 'b', 'В' => 'v', 'Г' => 'g', 'Д' => 'd',
            'Е' => 'e', 'Ё' => 'yo', 'Ж' => 'zh', 'З' => 'z', 'И' => 'i',
            'Й' => 'j', 'К' => 'k', 'Л' => 'l', 'М' => 'm', 'Н' => 'n',
            'О' => 'o', 'П' => 'p', 'Р' => 'r', 'С' => 's', 'Т' => 't',
            'У' => 'u', 'Ф' => 'f', 'Х' => 'kh', 'Ц' => 'ts', 'Ч' => 'ch',
            'Ш' => 'sh', 'Щ' => 'shch', 'Ъ' => '', 'Ы' => 'y', 'Ь' => '',
            'Э' => 'e', 'Ю' => 'yu', 'Я' => 'ya',
        ];

        $text = strtr($text, $translitMap);
        $text = strtolower($text);
        $text = (string) preg_replace('/[^a-z0-9]+/', '-', $text);

        return trim($text, '-');
    }

    /**
     * @param array<mixed> $arr
     */
    private function isAssociativeArray(array $arr): bool
    {
        if ($arr === []) {
            return false;
        }

        return array_keys($arr) !== range(0, count($arr) - 1);
    }
}

Step 2: Register the command in DI container

Check config/container.php for how SeedReferenceDataCommand is registered. Register MigrateCategoriesCommand the same way, injecting Connection (from Doctrine DBAL).

Step 3: Verify the command is discoverable

Run: php bin/console list | grep migrate-categories Expected: app:migrate-categories appears in the list

Step 4: Commit

bash
git add app/Infrastructure/Command/MigrateCategoriesCommand.php config/container.php
git commit -m "feat: add MigrateCategoriesCommand for Avito hierarchy import"

Task 2: Test the Command on Dev

Step 1: Run the command in dry-run mode first (check via SQL)

First, check current state:

bash
curl -s --user "$PARTIZAP_BASIC_AUTH_USER:$PARTIZAP_BASIC_AUTH_PASS" "https://dev.partizap.ru/api/store/categories" | python3 -c "
import json,sys; d=json.load(sys.stdin)['data']
parts=[c for c in d if c['category_type']=='part']
parents=[c for c in parts if c['parent_id'] is None]
children=[c for c in parts if c['parent_id'] is not None]
print(f'Before: {len(parents)} parents, {len(children)} children')
"

Expected: Before: 12 parents, 82 children

Step 2: Deploy and run the command on dev

bash
# Push branch, deploy to dev, then run:
ssh dev 'cd /var/www/partizap/development && php bin/console app:migrate-categories'

Expected output should show:

  • 12 matched parents (old→new mapping)
  • ~70+ new Avito categories inserted
  • Children remapped
  • Product references updated
  • Old parents deleted

Step 3: Verify results

bash
curl -s --user "$PARTIZAP_BASIC_AUTH_USER:$PARTIZAP_BASIC_AUTH_PASS" "https://dev.partizap.ru/api/store/categories" | python3 -c "
import json,sys; d=json.load(sys.stdin)['data']
parts=[c for c in d if c['category_type']=='part']
parents=[c for c in parts if c['parent_id'] is None]
print(f'After: {len(parts)} total part categories')
print(f'Top-level: {len(parents)}')
for p in sorted(parents, key=lambda x: x['sort_order']):
    print(f'  {p[\"name\"]}')
"

Expected: 11 top-level categories (Запчасти, Аксессуары, GPS-навигаторы, Масла и автохимия, Аудио- и видеотехника, Багажники и фаркопы, Инструменты, Прицепы, Противоугонные устройства, Шины/диски/колёса, Экипировка)

Also verify products still have valid categories:

bash
curl -s --user "$PARTIZAP_BASIC_AUTH_USER:$PARTIZAP_BASIC_AUTH_PASS" "https://dev.partizap.ru/api/store/products?per_page=5" | python3 -c "
import json,sys; d=json.load(sys.stdin)['data']
for p in d[:5]:
    print(f'Product {p[\"id\"]}: primary_category_id={p.get(\"primary_category_id\")}')
"

Task 3: Update SeedReferenceDataCommand

Files:

  • Modify: app/Infrastructure/Command/SeedReferenceDataCommand.php (method seedCategories at line ~697)

Step 1: Replace the seedCategories method

Replace the hardcoded $categoryTree array and the seeding logic in seedCategories() with JSON-based import:

php
private function seedCategories(SymfonyStyle $io): void
{
    $io->section('Categories');

    // Load Avito hierarchy
    $jsonPath = dirname(__DIR__, 3) . '/docs/addons/avito_zapchasti_categories.json';
    $avito = json_decode((string) file_get_contents($jsonPath), true, 512, JSON_THROW_ON_ERROR);

    $sortOrder = 0;
    $totalCount = 0;

    foreach ($avito as $name => $children) {
        $totalCount += $this->seedCategoryRecursive(
            (string) $name,
            $children,
            null,
            $sortOrder++
        );
    }

    // Condition categories
    $conditions = ['Новое', 'Б/У', 'Восстановленное'];
    foreach ($conditions as $name) {
        $cat = new Category($name, $this->slugify($name), CategoryType::Condition);
        $cat->setSortOrder($sortOrder++);
        $this->em->persist($cat);
    }

    // Attribute categories
    $attributes = [
        'Оригинал' => 'original',
        'Неоригинал' => 'non-original',
        'Затрудняюсь ответить' => 'zatrudnyayus_otvetit',
    ];
    foreach ($attributes as $name => $slug) {
        $cat = new Category($name, $slug, CategoryType::Attribute);
        $cat->setSortOrder($sortOrder++);
        $this->em->persist($cat);
    }

    $this->em->flush();
    $io->text("Categories: {$totalCount} part categories, 3 conditions, 3 attributes");
}

/**
 * @param array<string, mixed>|list<mixed> $children
 * @return int number of categories created
 */
private function seedCategoryRecursive(
    string $name,
    array $children,
    ?Category $parent,
    int $sortOrder,
): int {
    $parentSlug = $parent?->getSlug();
    $slug = $parentSlug !== null
        ? $parentSlug . '-' . $this->slugify($name)
        : $this->slugify($name);

    $category = new Category($name, $slug, CategoryType::Part);
    $category->setParent($parent);
    $category->setSortOrder($sortOrder);
    $this->em->persist($category);

    $count = 1;

    if (is_array($children) && $children !== [] && array_keys($children) !== range(0, count($children) - 1)) {
        $childSortOrder = 0;
        foreach ($children as $childName => $grandChildren) {
            $count += $this->seedCategoryRecursive(
                (string) $childName,
                is_array($grandChildren) ? $grandChildren : [],
                $category,
                $childSortOrder++
            );
        }
    }

    return $count;
}

Step 2: Verify compilation

Run: composer analyse Expected: No new errors

Step 3: Commit

bash
git add app/Infrastructure/Command/SeedReferenceDataCommand.php
git commit -m "refactor: update SeedReferenceDataCommand to use Avito category hierarchy"

Task 4: Update frontend-api-reference.md

Files:

  • Modify: docs/frontend-api-reference.md

Step 1: Update the categories section

Find the categories documentation section and update it to reflect:

  • Part categories now have up to 4 levels of nesting
  • Top-level categories are: Запчасти, Аксессуары, GPS-навигаторы, etc.
  • The "Запчасти > Для автомобилей" branch contains the familiar subcategories (Двигатель, Подвеска, etc.)
  • Custom child categories (Блок цилиндров, МКПП, etc.) are at level 4

Step 2: Commit

bash
git add docs/frontend-api-reference.md
git commit -m "docs: update category hierarchy documentation for Avito structure"