Appearance
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.slughas 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(methodseedCategoriesat 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"