--- /dev/null
+# ERP-292: ML-движок авторазметки товаров из 1С — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Автоматически присваивать атрибуты номенклатуры (category, species, sort, type, size, color) новым товарам из 1С на основе уже размеченных данных — без ручного ввода.
+
+**Architecture:** Гибридный 3-слойный движок: (1) Rule-based парсинг имени по словарям/regex — покрывает ~70% кейсов с confidence ≥ 0.9; (2) TF-IDF similarity matching по корпусу размеченных товаров — для неочевидных случаев; (3) предсказания хранятся в отдельной таблице `products_1c_automark_predictions` с confidence score и статусом валидации. Оркестратор (`AutoMarkService`) выбирает слой по порогу уверенности.
+
+**Tech Stack:** PHP 8.1, Yii2 2.0.45, PostgreSQL, Codeception (unit tests), RabbitMQ Queue
+
+---
+
+## Схема данных
+
+### Входные данные (обучающая выборка)
+```
+products_1c products_1c_nomenclature
+───────────────────────── ────────────────────────────────────
+id (GUID) ←─────────────────── id (GUID)
+name ← feature для ML category, subcategory, species
+code sort, type, size, color
+articule (уже размеченные атрибуты)
+```
+
+### Выходные данные (предсказания)
+```
+products_1c_automark_predictions
+─────────────────────────────────────────
+id, product_id → products_1c.id
+category, subcategory, species
+sort, type, size, color
+confidence (0.0..1.0)
+method ('rule' | 'similarity')
+status (0=pending, 1=approved, 2=rejected)
+approved_by, created_at, updated_at
+```
+
+---
+
+## File Structure
+
+```
+erp24/
+├── migrations/
+│ └── m260417_000001_create_products_1c_automark_predictions.php [NEW]
+├── records/
+│ └── Products1cAutomarkPrediction.php [NEW]
+├── services/
+│ ├── automark/
+│ │ ├── ParseResult.php [NEW] — Value object результата разметки
+│ │ ├── RuleBasedParser.php [NEW] — Словари + regex парсинг name
+│ │ └── SimilarityMatcher.php [NEW] — TF-IDF cosine similarity
+│ └── AutoMarkService.php [NEW] — Оркестратор
+├── commands/
+│ └── AutoMarkController.php [NEW] — CLI: php yii auto-mark/run
+├── jobs/
+│ └── AutoMarkPredictionJob.php [NEW] — Queue job для async разметки
+├── controllers/
+│ └── AutoMarkController.php [NEW] — Web UI валидации
+└── views/
+ └── auto-mark/
+ ├── index.php [NEW] — Список pending предсказаний
+ └── review.php [NEW] — Карточка товара + форма валидации
+
+tests/unit/
+├── services/
+│ ├── automark/
+│ │ ├── RuleBasedParserTest.php [NEW]
+│ │ └── SimilarityMatcherTest.php [NEW]
+│ └── AutoMarkServiceTest.php [NEW]
+└── records/
+ └── Products1cAutomarkPredictionTest.php [NEW]
+```
+
+---
+
+## Task 1: Миграция — таблица predictions
+
+**Files:**
+- Create: `erp24/migrations/m260417_000001_create_products_1c_automark_predictions.php`
+
+- [ ] **Step 1: Создать файл миграции**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+use yii\db\Migration;
+
+class m260417_000001_create_products_1c_automark_predictions extends Migration
+{
+ public function safeUp(): void
+ {
+ $this->createTable('products_1c_automark_predictions', [
+ 'id' => $this->primaryKey(),
+ 'product_id' => $this->string(36)->notNull(),
+ 'category' => $this->string(255)->null(),
+ 'subcategory' => $this->string(255)->null(),
+ 'species' => $this->string(255)->null(),
+ 'sort' => $this->string(255)->null(),
+ 'type' => $this->string(255)->null(),
+ 'size' => $this->integer()->null(),
+ 'color' => $this->string(255)->null(),
+ 'confidence' => $this->float()->notNull(),
+ 'method' => $this->string(50)->notNull(),
+ 'status' => $this->smallInteger()->notNull()->defaultValue(0),
+ 'approved_by' => $this->integer()->null(),
+ 'created_at' => $this->timestamp()->notNull()->defaultExpression('NOW()'),
+ 'updated_at' => $this->timestamp()->null(),
+ ]);
+
+ $this->createIndex('idx_automark_product_id', 'products_1c_automark_predictions', 'product_id');
+ $this->createIndex('idx_automark_status', 'products_1c_automark_predictions', 'status');
+ $this->addForeignKey(
+ 'fk_automark_product',
+ 'products_1c_automark_predictions', 'product_id',
+ 'products_1c', 'id',
+ 'CASCADE', 'CASCADE'
+ );
+ }
+
+ public function safeDown(): void
+ {
+ $this->dropTable('products_1c_automark_predictions');
+ }
+}
+```
+
+- [ ] **Step 2: Применить миграцию**
+
+```bash
+cd /Users/vladfo/development/yii-erp24/erp24
+php yii migrate --migrationPath=@app/migrations 1
+```
+
+Expected: `Applied 1 migration.`
+
+- [ ] **Step 3: Убедиться что таблица создана**
+
+```bash
+php yii migrate/history 5
+```
+
+Expected: вывод включает `m260417_000001_create_products_1c_automark_predictions`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add erp24/migrations/m260417_000001_create_products_1c_automark_predictions.php
+git commit -m "feat(ERP-292): миграция таблицы products_1c_automark_predictions"
+```
+
+---
+
+## Task 2: Model — Products1cAutomarkPrediction
+
+**Files:**
+- Create: `erp24/records/Products1cAutomarkPrediction.php`
+- Create: `erp24/tests/unit/records/Products1cAutomarkPredictionTest.php`
+
+- [ ] **Step 1: Написать failing тест**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\records;
+
+use Codeception\Test\Unit;
+use yii_app\records\Products1cAutomarkPrediction;
+
+/**
+ * @covers \yii_app\records\Products1cAutomarkPrediction
+ */
+class Products1cAutomarkPredictionTest extends Unit
+{
+ public function testTableName(): void
+ {
+ $this->assertSame('products_1c_automark_predictions', Products1cAutomarkPrediction::tableName());
+ }
+
+ public function testStatusConstants(): void
+ {
+ $this->assertSame(0, Products1cAutomarkPrediction::STATUS_PENDING);
+ $this->assertSame(1, Products1cAutomarkPrediction::STATUS_APPROVED);
+ $this->assertSame(2, Products1cAutomarkPrediction::STATUS_REJECTED);
+ }
+
+ public function testMethodConstants(): void
+ {
+ $this->assertSame('rule', Products1cAutomarkPrediction::METHOD_RULE);
+ $this->assertSame('similarity', Products1cAutomarkPrediction::METHOD_SIMILARITY);
+ }
+
+ public function testIsPendingMethod(): void
+ {
+ $model = new Products1cAutomarkPrediction();
+ $model->status = Products1cAutomarkPrediction::STATUS_PENDING;
+ $this->assertTrue($model->isPending());
+ }
+
+ public function testIsApprovedMethod(): void
+ {
+ $model = new Products1cAutomarkPrediction();
+ $model->status = Products1cAutomarkPrediction::STATUS_APPROVED;
+ $this->assertTrue($model->isApproved());
+ }
+}
+```
+
+- [ ] **Step 2: Запустить тест — убедиться что FAIL**
+
+```bash
+cd /Users/vladfo/development/yii-erp24/erp24
+vendor/bin/codecept run unit tests/unit/records/Products1cAutomarkPredictionTest.php -v
+```
+
+Expected: FAIL — `Class "yii_app\records\Products1cAutomarkPrediction" not found`
+
+- [ ] **Step 3: Создать модель**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use yii\db\ActiveQuery;
+
+/**
+ * Предсказание авторазметки товара из 1С.
+ *
+ * @property int $id
+ * @property string $product_id GUID товара из products_1c.id
+ * @property string|null $category
+ * @property string|null $subcategory
+ * @property string|null $species
+ * @property string|null $sort
+ * @property string|null $type
+ * @property int|null $size
+ * @property string|null $color
+ * @property float $confidence 0.0–1.0
+ * @property string $method 'rule' | 'similarity'
+ * @property int $status 0=pending, 1=approved, 2=rejected
+ * @property int|null $approved_by
+ * @property string $created_at
+ * @property string|null $updated_at
+ */
+class Products1cAutomarkPrediction extends \yii\db\ActiveRecord
+{
+ public const STATUS_PENDING = 0;
+ public const STATUS_APPROVED = 1;
+ public const STATUS_REJECTED = 2;
+
+ public const METHOD_RULE = 'rule';
+ public const METHOD_SIMILARITY = 'similarity';
+
+ public static function tableName(): string
+ {
+ return 'products_1c_automark_predictions';
+ }
+
+ public function rules(): array
+ {
+ return [
+ [['product_id', 'confidence', 'method', 'status'], 'required'],
+ [['product_id', 'category', 'subcategory', 'species', 'sort', 'type', 'color', 'method'], 'string', 'max' => 255],
+ [['size', 'status', 'approved_by'], 'integer'],
+ [['confidence'], 'number', 'min' => 0, 'max' => 1],
+ [['method'], 'in', 'range' => [self::METHOD_RULE, self::METHOD_SIMILARITY]],
+ [['status'], 'in', 'range' => [self::STATUS_PENDING, self::STATUS_APPROVED, self::STATUS_REJECTED]],
+ [['created_at', 'updated_at'], 'safe'],
+ [['category', 'subcategory', 'species', 'sort', 'type', 'color', 'approved_by', 'updated_at', 'size'], 'default', 'value' => null],
+ ];
+ }
+
+ public function isPending(): bool
+ {
+ return $this->status === self::STATUS_PENDING;
+ }
+
+ public function isApproved(): bool
+ {
+ return $this->status === self::STATUS_APPROVED;
+ }
+
+ public function getProduct(): ActiveQuery
+ {
+ return $this->hasOne(Products1c::class, ['id' => 'product_id']);
+ }
+}
+```
+
+- [ ] **Step 4: Запустить тест — убедиться что PASS**
+
+```bash
+vendor/bin/codecept run unit tests/unit/records/Products1cAutomarkPredictionTest.php -v
+```
+
+Expected: `OK (5 tests, 5 assertions)`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add erp24/records/Products1cAutomarkPrediction.php erp24/tests/unit/records/Products1cAutomarkPredictionTest.php
+git commit -m "feat(ERP-292): модель Products1cAutomarkPrediction"
+```
+
+---
+
+## Task 3: Value Object — ParseResult
+
+**Files:**
+- Create: `erp24/services/automark/ParseResult.php`
+
+Этот DTO передаётся между всеми слоями движка. Нет смысла тестировать отдельно — покроется через RuleBasedParserTest.
+
+- [ ] **Step 1: Создать ParseResult**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+final class ParseResult
+{
+ public function __construct(
+ public readonly ?string $category,
+ public readonly ?string $subcategory,
+ public readonly ?string $species,
+ public readonly ?string $sort,
+ public readonly ?string $type,
+ public readonly ?int $size,
+ public readonly ?string $color,
+ public readonly float $confidence,
+ public readonly string $method,
+ ) {}
+
+ public function isConfident(float $threshold = 0.7): bool
+ {
+ return $this->confidence >= $threshold;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'category' => $this->category,
+ 'subcategory' => $this->subcategory,
+ 'species' => $this->species,
+ 'sort' => $this->sort,
+ 'type' => $this->type,
+ 'size' => $this->size,
+ 'color' => $this->color,
+ 'confidence' => $this->confidence,
+ 'method' => $this->method,
+ ];
+ }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add erp24/services/automark/ParseResult.php
+git commit -m "feat(ERP-292): value object ParseResult"
+```
+
+---
+
+## Task 4: RuleBasedParser — парсинг имени товара
+
+**Files:**
+- Create: `erp24/services/automark/RuleBasedParser.php`
+- Create: `erp24/tests/unit/services/automark/RuleBasedParserTest.php`
+
+Логика: словари ключевых слов → атрибутов + regex для size. Если найдено species → category автоматически = Срезы (если не горшечное).
+
+- [ ] **Step 1: Написать failing тест**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services\automark;
+
+use Codeception\Test\Unit;
+use yii_app\services\automark\RuleBasedParser;
+use yii_app\services\automark\ParseResult;
+
+/**
+ * @covers \yii_app\services\automark\RuleBasedParser
+ */
+class RuleBasedParserTest extends Unit
+{
+ private RuleBasedParser $parser;
+
+ protected function setUp(): void
+ {
+ $this->parser = new RuleBasedParser();
+ }
+
+ public function testParsesRoseWithSizeAndColor(): void
+ {
+ $result = $this->parser->parse('Роза Premium 50см Красная');
+
+ $this->assertInstanceOf(ParseResult::class, $result);
+ $this->assertSame('Срезы', $result->category);
+ $this->assertSame('Роза', $result->species);
+ $this->assertSame('Premium', $result->sort);
+ $this->assertSame(50, $result->size);
+ $this->assertSame('Красная', $result->color);
+ $this->assertGreaterThanOrEqual(0.9, $result->confidence);
+ $this->assertSame('rule', $result->method);
+ }
+
+ public function testParsesPottedPlant(): void
+ {
+ $result = $this->parser->parse('Орхидея Фаленопсис горшок 12');
+
+ $this->assertSame('Горшечные', $result->category);
+ $this->assertGreaterThanOrEqual(0.8, $result->confidence);
+ }
+
+ public function testParsesPackaging(): void
+ {
+ $result = $this->parser->parse('Пакет целлофановый 60х80');
+
+ $this->assertSame('Упаковка', $result->category);
+ $this->assertGreaterThanOrEqual(0.85, $result->confidence);
+ }
+
+ public function testExtractsSizeInCm(): void
+ {
+ $result = $this->parser->parse('Хризантема Экстра 70 см Белая');
+
+ $this->assertSame(70, $result->size);
+ $this->assertSame('Белая', $result->color);
+ $this->assertSame('Хризантема', $result->species);
+ }
+
+ public function testReturnsNullForUnknownName(): void
+ {
+ $result = $this->parser->parse('XYZ-неизвестный-товар-123');
+
+ $this->assertNull($result);
+ }
+
+ public function testChrysanthemumIsSrezy(): void
+ {
+ $result = $this->parser->parse('Хризантема Белая 60см');
+
+ $this->assertSame('Срезы', $result->category);
+ $this->assertSame('Хризантема', $result->species);
+ }
+}
+```
+
+- [ ] **Step 2: Запустить — убедиться что FAIL**
+
+```bash
+vendor/bin/codecept run unit tests/unit/services/automark/RuleBasedParserTest.php -v
+```
+
+Expected: FAIL — `Class "yii_app\services\automark\RuleBasedParser" not found`
+
+- [ ] **Step 3: Создать RuleBasedParser**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+class RuleBasedParser
+{
+ private const SPECIES_SREZY = [
+ 'роза' => 'Роза', 'хризантема' => 'Хризантема', 'гербера' => 'Гербера',
+ 'тюльпан' => 'Тюльпан', 'лилия' => 'Лилия', 'пион' => 'Пион',
+ 'эустома' => 'Эустома', 'альстромерия' => 'Альстромерия',
+ 'гипсофила' => 'Гипсофила', 'ирис' => 'Ирис', 'гвоздика' => 'Гвоздика',
+ 'нарцисс' => 'Нарцисс', 'фрезия' => 'Фрезия', 'лизиантус' => 'Лизиантус',
+ ];
+
+ private const SORT_KEYWORDS = [
+ 'premium' => 'Premium', 'экстра' => 'Экстра', 'extra' => 'Extra',
+ 'select' => 'Select', 'стандарт' => 'Стандарт', 'премиум' => 'Премиум',
+ 'супер' => 'Супер', 'элит' => 'Элит',
+ ];
+
+ private const COLOR_KEYWORDS = [
+ 'красная' => 'Красная', 'красный' => 'Красный',
+ 'белая' => 'Белая', 'белый' => 'Белый',
+ 'розовая' => 'Розовая', 'розовый' => 'Розовый',
+ 'желтая' => 'Желтая', 'желтый' => 'Желтый',
+ 'оранжевая' => 'Оранжевая', 'оранжевый' => 'Оранжевый',
+ 'фиолетовая' => 'Фиолетовая', 'фиолетовый' => 'Фиолетовый',
+ 'синяя' => 'Синяя', 'синий' => 'Синий',
+ 'кремовая' => 'Кремовая', 'кремовый' => 'Кремовый',
+ 'бордовая' => 'Бордовая', 'бордовый' => 'Бордовый',
+ 'микс' => 'Микс', 'коралловая' => 'Коралловая',
+ ];
+
+ private const POTTED_KEYWORDS = ['горшок', 'горшечн', 'кашпо', 'вазон'];
+
+ private const PACKAGING_KEYWORDS = ['пакет', 'коробк', 'упаков', 'лента', 'бумага', 'сетка', 'сизаль'];
+
+ public function parse(string $name): ?ParseResult
+ {
+ $nameLower = mb_strtolower($name);
+
+ $category = $this->detectCategory($nameLower);
+ $species = $this->detectSpecies($nameLower);
+ $sort = $this->detectSort($nameLower);
+ $color = $this->detectColor($nameLower);
+ $size = $this->detectSize($name);
+
+ // Ничего не нашли — не возвращаем пустой результат
+ if ($category === null && $species === null) {
+ return null;
+ }
+
+ // Если нашли species среза, но category не определена — ставим Срезы
+ if ($species !== null && $category === null) {
+ $category = 'Срезы';
+ }
+
+ $confidence = $this->calcConfidence($category, $species, $sort, $color, $size);
+
+ return new ParseResult(
+ category: $category,
+ subcategory: null,
+ species: $species,
+ sort: $sort,
+ type: null,
+ size: $size,
+ color: $color,
+ confidence: $confidence,
+ method: 'rule',
+ );
+ }
+
+ private function detectCategory(string $nameLower): ?string
+ {
+ foreach (self::POTTED_KEYWORDS as $kw) {
+ if (str_contains($nameLower, $kw)) {
+ return 'Горшечные';
+ }
+ }
+ foreach (self::PACKAGING_KEYWORDS as $kw) {
+ if (str_contains($nameLower, $kw)) {
+ return 'Упаковка';
+ }
+ }
+ return null;
+ }
+
+ private function detectSpecies(string $nameLower): ?string
+ {
+ foreach (self::SPECIES_SREZY as $keyword => $label) {
+ if (str_contains($nameLower, $keyword)) {
+ return $label;
+ }
+ }
+ return null;
+ }
+
+ private function detectSort(string $nameLower): ?string
+ {
+ foreach (self::SORT_KEYWORDS as $keyword => $label) {
+ if (str_contains($nameLower, $keyword)) {
+ return $label;
+ }
+ }
+ return null;
+ }
+
+ private function detectColor(string $nameLower): ?string
+ {
+ foreach (self::COLOR_KEYWORDS as $keyword => $label) {
+ if (str_contains($nameLower, $keyword)) {
+ return $label;
+ }
+ }
+ return null;
+ }
+
+ private function detectSize(string $name): ?int
+ {
+ if (preg_match('/(\d+)\s*(?:см|cm|СМ|CM)/iu', $name, $m)) {
+ return (int) $m[1];
+ }
+ return null;
+ }
+
+ private function calcConfidence(?string $category, ?string $species, ?string $sort, ?string $color, ?int $size): float
+ {
+ $score = 0.5; // base
+ if ($category !== null) $score += 0.2;
+ if ($species !== null) $score += 0.15;
+ if ($color !== null) $score += 0.1;
+ if ($size !== null) $score += 0.05;
+ if ($sort !== null) $score += 0.05;
+ return min(1.0, $score);
+ }
+}
+```
+
+- [ ] **Step 4: Запустить тест — убедиться что PASS**
+
+```bash
+vendor/bin/codecept run unit tests/unit/services/automark/RuleBasedParserTest.php -v
+```
+
+Expected: `OK (6 tests, 14 assertions)`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add erp24/services/automark/RuleBasedParser.php erp24/tests/unit/services/automark/RuleBasedParserTest.php
+git commit -m "feat(ERP-292): RuleBasedParser — словарный парсинг имени товара"
+```
+
+---
+
+## Task 5: SimilarityMatcher — TF-IDF cosine similarity
+
+**Files:**
+- Create: `erp24/services/automark/SimilarityMatcher.php`
+- Create: `erp24/tests/unit/services/automark/SimilarityMatcherTest.php`
+
+Логика: токенизация → TF вектор → cosine similarity с каждой записью корпуса → возвращает ParseResult из лучшего совпадения.
+
+- [ ] **Step 1: Написать failing тест**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services\automark;
+
+use Codeception\Test\Unit;
+use yii_app\services\automark\SimilarityMatcher;
+use yii_app\services\automark\ParseResult;
+
+/**
+ * @covers \yii_app\services\automark\SimilarityMatcher
+ */
+class SimilarityMatcherTest extends Unit
+{
+ private SimilarityMatcher $matcher;
+
+ protected function setUp(): void
+ {
+ $this->matcher = new SimilarityMatcher();
+ }
+
+ private function makeCorpus(): array
+ {
+ return [
+ ['name' => 'Роза красная 50см Premium', 'category' => 'Срезы', 'subcategory' => null, 'species' => 'Роза', 'sort' => 'Premium', 'type' => null, 'size' => 50, 'color' => 'Красная'],
+ ['name' => 'Роза белая 60см Экстра', 'category' => 'Срезы', 'subcategory' => null, 'species' => 'Роза', 'sort' => 'Экстра', 'type' => null, 'size' => 60, 'color' => 'Белая'],
+ ['name' => 'Хризантема белая 70 см', 'category' => 'Срезы', 'subcategory' => null, 'species' => 'Хризантема', 'sort' => null, 'type' => null, 'size' => 70, 'color' => 'Белая'],
+ ];
+ }
+
+ public function testFindsBestMatchForSimilarName(): void
+ {
+ $result = $this->matcher->findBestMatch('Роза красная 50 Premium', $this->makeCorpus());
+
+ $this->assertInstanceOf(ParseResult::class, $result);
+ $this->assertSame('Срезы', $result->category);
+ $this->assertSame('Роза', $result->species);
+ $this->assertSame('similarity', $result->method);
+ $this->assertGreaterThan(0.0, $result->confidence);
+ }
+
+ public function testReturnsNullForEmptyCorpus(): void
+ {
+ $result = $this->matcher->findBestMatch('Роза красная', []);
+
+ $this->assertNull($result);
+ }
+
+ public function testConfidenceIsHigherForCloseMatch(): void
+ {
+ $corpus = $this->makeCorpus();
+ $closeMatch = $this->matcher->findBestMatch('Роза красная 50 Premium', $corpus);
+ $farMatch = $this->matcher->findBestMatch('Нечто совсем другое', $corpus);
+
+ $this->assertGreaterThan($farMatch?->confidence ?? 0.0, $closeMatch->confidence);
+ }
+
+ public function testTokenizePublicMethod(): void
+ {
+ $tokens = SimilarityMatcher::tokenize('Роза Premium 50см');
+
+ $this->assertContains('роза', $tokens);
+ $this->assertContains('premium', $tokens);
+ // числа и "см" не входят в токены
+ $this->assertNotContains('50см', $tokens);
+ }
+}
+```
+
+- [ ] **Step 2: Запустить — убедиться что FAIL**
+
+```bash
+vendor/bin/codecept run unit tests/unit/services/automark/SimilarityMatcherTest.php -v
+```
+
+Expected: FAIL — `Class "yii_app\services\automark\SimilarityMatcher" not found`
+
+- [ ] **Step 3: Создать SimilarityMatcher**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+class SimilarityMatcher
+{
+ // Стоп-слова: не несут смысловой нагрузки для сравнения
+ private const STOP_WORDS = ['и', 'в', 'на', 'с', 'по', 'для', 'из', 'от', 'до', 'за', 'при', 'под'];
+
+ /**
+ * Найти лучшее совпадение из размеченного корпуса.
+ *
+ * @param string $name Имя нового товара
+ * @param array $corpus Массив размеченных товаров: [name, category, species, ...]
+ */
+ public function findBestMatch(string $name, array $corpus): ?ParseResult
+ {
+ if (empty($corpus)) {
+ return null;
+ }
+
+ $queryTokens = self::tokenize($name);
+ if (empty($queryTokens)) {
+ return null;
+ }
+
+ $bestScore = -1.0;
+ $bestItem = null;
+
+ foreach ($corpus as $item) {
+ $corpusTokens = self::tokenize($item['name'] ?? '');
+ $score = $this->cosineSimilarity($queryTokens, $corpusTokens);
+ if ($score > $bestScore) {
+ $bestScore = $score;
+ $bestItem = $item;
+ }
+ }
+
+ if ($bestItem === null || $bestScore <= 0.0) {
+ return null;
+ }
+
+ return new ParseResult(
+ category: $bestItem['category'] ?? null,
+ subcategory: $bestItem['subcategory'] ?? null,
+ species: $bestItem['species'] ?? null,
+ sort: $bestItem['sort'] ?? null,
+ type: $bestItem['type'] ?? null,
+ size: isset($bestItem['size']) ? (int) $bestItem['size'] : null,
+ color: $bestItem['color'] ?? null,
+ confidence: round($bestScore, 4),
+ method: 'similarity',
+ );
+ }
+
+ /**
+ * Токенизация: lowercase, только буквы/цифры, без стоп-слов, без коротких токенов.
+ */
+ public static function tokenize(string $text): array
+ {
+ $text = mb_strtolower($text);
+ // Убрать числа со смешанными словами (50см → '')
+ $text = preg_replace('/\d+\s*(?:см|cm|СМ|CM)/iu', '', $text);
+ // Только кириллица и латиница
+ preg_match_all('/[а-яёa-z]{2,}/u', $text, $matches);
+ $tokens = $matches[0] ?? [];
+ return array_values(array_filter($tokens, fn($t) => !in_array($t, self::STOP_WORDS, true)));
+ }
+
+ private function cosineSimilarity(array $a, array $b): float
+ {
+ $vecA = array_count_values($a);
+ $vecB = array_count_values($b);
+
+ $allKeys = array_unique(array_merge(array_keys($vecA), array_keys($vecB)));
+ $dotProduct = 0.0;
+ $normA = 0.0;
+ $normB = 0.0;
+
+ foreach ($allKeys as $key) {
+ $va = $vecA[$key] ?? 0;
+ $vb = $vecB[$key] ?? 0;
+ $dotProduct += $va * $vb;
+ $normA += $va * $va;
+ $normB += $vb * $vb;
+ }
+
+ if ($normA === 0.0 || $normB === 0.0) {
+ return 0.0;
+ }
+
+ return $dotProduct / (sqrt($normA) * sqrt($normB));
+ }
+}
+```
+
+- [ ] **Step 4: Запустить тест — убедиться что PASS**
+
+```bash
+vendor/bin/codecept run unit tests/unit/services/automark/SimilarityMatcherTest.php -v
+```
+
+Expected: `OK (4 tests, 7 assertions)`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add erp24/services/automark/SimilarityMatcher.php erp24/tests/unit/services/automark/SimilarityMatcherTest.php
+git commit -m "feat(ERP-292): SimilarityMatcher — TF-IDF cosine similarity"
+```
+
+---
+
+## Task 6: AutoMarkService — оркестратор
+
+**Files:**
+- Create: `erp24/services/AutoMarkService.php`
+- Create: `erp24/tests/unit/services/AutoMarkServiceTest.php`
+
+Логика: Rule → confidence ≥ 0.9 → сохранить. Иначе Similarity → confidence ≥ 0.7 → сохранить. Иначе сохранить с низким confidence для ручной проверки.
+
+- [ ] **Step 1: Написать failing тест**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services;
+
+use Codeception\Test\Unit;
+use yii_app\services\AutoMarkService;
+
+/**
+ * @covers \yii_app\services\AutoMarkService
+ */
+class AutoMarkServiceTest extends Unit
+{
+ public function testRuleThresholdConstant(): void
+ {
+ $this->assertSame(0.9, AutoMarkService::RULE_THRESHOLD);
+ }
+
+ public function testSimilarityThresholdConstant(): void
+ {
+ $this->assertSame(0.7, AutoMarkService::SIMILARITY_THRESHOLD);
+ }
+
+ public function testPredictForProductMethodExists(): void
+ {
+ $this->assertTrue(method_exists(AutoMarkService::class, 'predictForProduct'));
+ }
+
+ public function testApplyApprovedPredictionMethodExists(): void
+ {
+ $this->assertTrue(method_exists(AutoMarkService::class, 'applyApprovedPrediction'));
+ }
+
+ public function testBatchPredictMethodExists(): void
+ {
+ $this->assertTrue(method_exists(AutoMarkService::class, 'batchPredict'));
+ }
+}
+```
+
+- [ ] **Step 2: Запустить — убедиться что FAIL**
+
+```bash
+vendor/bin/codecept run unit tests/unit/services/AutoMarkServiceTest.php -v
+```
+
+Expected: FAIL — `Class "yii_app\services\AutoMarkService" not found`
+
+- [ ] **Step 3: Создать AutoMarkService**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services;
+
+use Yii;
+use yii_app\records\Products1c;
+use yii_app\records\Products1cAutomarkPrediction;
+use yii_app\records\Products1cNomenclature;
+use yii_app\services\automark\ParseResult;
+use yii_app\services\automark\RuleBasedParser;
+use yii_app\services\automark\SimilarityMatcher;
+
+class AutoMarkService
+{
+ public const RULE_THRESHOLD = 0.9;
+ public const SIMILARITY_THRESHOLD = 0.7;
+
+ private RuleBasedParser $ruleParser;
+ private SimilarityMatcher $matcher;
+
+ public function __construct()
+ {
+ $this->ruleParser = new RuleBasedParser();
+ $this->matcher = new SimilarityMatcher();
+ }
+
+ /**
+ * Предсказать и сохранить разметку для одного товара.
+ * Если предсказание уже существует — пропустить.
+ */
+ public function predictForProduct(string $productId): ?Products1cAutomarkPrediction
+ {
+ $existing = Products1cAutomarkPrediction::find()
+ ->where(['product_id' => $productId, 'status' => Products1cAutomarkPrediction::STATUS_PENDING])
+ ->one();
+
+ if ($existing !== null) {
+ return $existing;
+ }
+
+ $product = Products1c::findOne($productId);
+ if ($product === null) {
+ return null;
+ }
+
+ $result = $this->runPipeline($product->name);
+ if ($result === null) {
+ return null;
+ }
+
+ return $this->savePrediction($productId, $result);
+ }
+
+ /**
+ * Применить одобренное предсказание → записать в products_1c_nomenclature.
+ */
+ public function applyApprovedPrediction(int $predictionId): bool
+ {
+ $prediction = Products1cAutomarkPrediction::findOne($predictionId);
+ if ($prediction === null || !$prediction->isApproved()) {
+ return false;
+ }
+
+ $nomenclature = Products1cNomenclature::findOne($prediction->product_id);
+ if ($nomenclature === null) {
+ $nomenclature = new Products1cNomenclature();
+ $nomenclature->id = $prediction->product_id;
+ $product = Products1c::findOne($prediction->product_id);
+ $nomenclature->name = $product?->name ?? '';
+ $nomenclature->location = '';
+ $nomenclature->type_num = '';
+ }
+
+ $nomenclature->category = $prediction->category ?? $nomenclature->category;
+ $nomenclature->subcategory = $prediction->subcategory ?? $nomenclature->subcategory;
+ $nomenclature->species = $prediction->species ?? $nomenclature->species;
+ $nomenclature->sort = $prediction->sort ?? $nomenclature->sort;
+ $nomenclature->type = $prediction->type ?? $nomenclature->type;
+ $nomenclature->size = $prediction->size ?? $nomenclature->size;
+ $nomenclature->color = $prediction->color ?? $nomenclature->color;
+
+ return $nomenclature->save();
+ }
+
+ /**
+ * Пакетная разметка товаров без номенклатуры.
+ *
+ * @param string[] $productIds Если пусто — обработать все без номенклатуры
+ */
+ public function batchPredict(array $productIds = []): int
+ {
+ if (empty($productIds)) {
+ $productIds = $this->getUnmarkedProductIds();
+ }
+
+ $count = 0;
+ foreach ($productIds as $id) {
+ if ($this->predictForProduct($id) !== null) {
+ $count++;
+ }
+ }
+ return $count;
+ }
+
+ private function runPipeline(string $name): ?ParseResult
+ {
+ // Слой 1: Rule-based
+ $ruleResult = $this->ruleParser->parse($name);
+ if ($ruleResult !== null && $ruleResult->confidence >= self::RULE_THRESHOLD) {
+ return $ruleResult;
+ }
+
+ // Слой 2: Similarity
+ $corpus = $this->loadCorpus();
+ $simResult = $this->matcher->findBestMatch($name, $corpus);
+ if ($simResult !== null && $simResult->confidence >= self::SIMILARITY_THRESHOLD) {
+ return $simResult;
+ }
+
+ // Вернуть лучшее из доступного с низкой уверенностью для ручной проверки
+ return $ruleResult ?? $simResult;
+ }
+
+ private function savePrediction(string $productId, ParseResult $result): ?Products1cAutomarkPrediction
+ {
+ $model = new Products1cAutomarkPrediction();
+ $model->product_id = $productId;
+ $model->category = $result->category;
+ $model->subcategory = $result->subcategory;
+ $model->species = $result->species;
+ $model->sort = $result->sort;
+ $model->type = $result->type;
+ $model->size = $result->size;
+ $model->color = $result->color;
+ $model->confidence = $result->confidence;
+ $model->method = $result->method;
+ $model->status = Products1cAutomarkPrediction::STATUS_PENDING;
+
+ return $model->save() ? $model : null;
+ }
+
+ private function loadCorpus(): array
+ {
+ return Products1cNomenclature::find()
+ ->select(['id', 'name', 'category', 'subcategory', 'species', 'sort', 'type', 'size', 'color'])
+ ->where(['not', ['category' => null]])
+ ->asArray()
+ ->all();
+ }
+
+ private function getUnmarkedProductIds(): array
+ {
+ return Products1c::find()
+ ->select('products_1c.id')
+ ->leftJoin('products_1c_nomenclature', 'products_1c.id = products_1c_nomenclature.id')
+ ->where(['products_1c_nomenclature.id' => null])
+ ->andWhere(['products_1c.tip' => Products1c::TYPE_PRODUCTS])
+ ->column();
+ }
+}
+```
+
+- [ ] **Step 4: Запустить тест — убедиться что PASS**
+
+```bash
+vendor/bin/codecept run unit tests/unit/services/AutoMarkServiceTest.php -v
+```
+
+Expected: `OK (5 tests, 5 assertions)`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add erp24/services/AutoMarkService.php erp24/tests/unit/services/AutoMarkServiceTest.php
+git commit -m "feat(ERP-292): AutoMarkService — оркестратор разметки"
+```
+
+---
+
+## Task 7: Console-команда AutoMarkController
+
+**Files:**
+- Create: `erp24/commands/AutoMarkController.php`
+
+- [ ] **Step 1: Создать консольный контроллер**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\commands;
+
+use Yii;
+use yii\console\Controller;
+use yii\console\ExitCode;
+use yii_app\services\AutoMarkService;
+
+/**
+ * Консольный контроллер авторазметки товаров из 1С (ERP-292).
+ *
+ * Использование:
+ * php yii auto-mark/run-new — разметить все товары без номенклатуры
+ * php yii auto-mark/run-product GUID — разметить конкретный товар
+ * php yii auto-mark/apply PRED_ID — применить одобренное предсказание
+ */
+class AutoMarkController extends Controller
+{
+ public string $defaultAction = 'run-new';
+
+ /**
+ * Разметить все товары без номенклатуры.
+ */
+ public function actionRunNew(): int
+ {
+ $this->stdout("Запуск авторазметки новых товаров...\n");
+
+ $service = new AutoMarkService();
+ $count = $service->batchPredict();
+
+ $this->stdout("Создано предсказаний: {$count}\n");
+ return ExitCode::OK;
+ }
+
+ /**
+ * Разметить конкретный товар по GUID.
+ */
+ public function actionRunProduct(string $guid): int
+ {
+ $service = new AutoMarkService();
+ $result = $service->predictForProduct($guid);
+
+ if ($result === null) {
+ $this->stderr("Не удалось создать предсказание для {$guid}\n");
+ return ExitCode::UNSPECIFIED_ERROR;
+ }
+
+ $this->stdout("Предсказание создано (ID={$result->id}, confidence={$result->confidence}, method={$result->method})\n");
+ return ExitCode::OK;
+ }
+
+ /**
+ * Применить одобренное предсказание к products_1c_nomenclature.
+ */
+ public function actionApply(int $predictionId): int
+ {
+ $service = new AutoMarkService();
+ $success = $service->applyApprovedPrediction($predictionId);
+
+ if (!$success) {
+ $this->stderr("Не удалось применить предсказание ID={$predictionId}\n");
+ return ExitCode::UNSPECIFIED_ERROR;
+ }
+
+ $this->stdout("Предсказание ID={$predictionId} применено.\n");
+ return ExitCode::OK;
+ }
+}
+```
+
+- [ ] **Step 2: Зарегистрировать контроллер в console-конфиге**
+
+Открыть `erp24/config/console.php`, найти массив `controllerMap` или `controllerNamespace` и убедиться что `yii_app\commands` уже включён. Если используется автодискавери — ничего делать не нужно.
+
+```bash
+grep -n "AutoMark\|controllerMap\|commands" erp24/config/console.php | head -20
+```
+
+- [ ] **Step 3: Проверить что команда доступна**
+
+```bash
+php yii help auto-mark
+```
+
+Expected: вывод с описанием `run-new`, `run-product`, `apply`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add erp24/commands/AutoMarkController.php
+git commit -m "feat(ERP-292): консольная команда auto-mark"
+```
+
+---
+
+## Task 8: Queue Job — асинхронная разметка
+
+**Files:**
+- Create: `erp24/jobs/AutoMarkPredictionJob.php`
+
+Job запускается при поступлении нового товара из 1С, чтобы не блокировать sync-процесс.
+
+- [ ] **Step 1: Создать Job**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\jobs;
+
+use yii\base\BaseObject;
+use yii\queue\JobInterface;
+use yii_app\services\AutoMarkService;
+
+class AutoMarkPredictionJob extends BaseObject implements JobInterface
+{
+ public string $productId;
+
+ public function execute($queue): void
+ {
+ (new AutoMarkService())->predictForProduct($this->productId);
+ }
+}
+```
+
+- [ ] **Step 2: Добавить вызов Job в sync-процесс**
+
+Найти место где происходит запись нового товара из 1С в `products_1c` (обычно в API2 или команде синхронизации):
+
+```bash
+grep -rn "products_1c.*insert\|Products1c.*save\|upsertProducts" erp24/ --include="*.php" | grep -v test | head -10
+```
+
+После найденного места добавить:
+
+```php
+\Yii::$app->queue->push(new \yii_app\jobs\AutoMarkPredictionJob([
+ 'productId' => $product->id,
+]));
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add erp24/jobs/AutoMarkPredictionJob.php
+git commit -m "feat(ERP-292): queue job AutoMarkPredictionJob"
+```
+
+---
+
+## Task 9: Web-контроллер UI валидации
+
+**Files:**
+- Create: `erp24/controllers/AutoMarkController.php`
+- Create: `erp24/views/auto-mark/index.php`
+- Create: `erp24/views/auto-mark/review.php`
+
+- [ ] **Step 1: Создать Web-контроллер**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\controllers;
+
+use Yii;
+use yii\web\Controller;
+use yii\web\NotFoundHttpException;
+use yii\data\ActiveDataProvider;
+use yii_app\records\Products1cAutomarkPrediction;
+use yii_app\services\AutoMarkService;
+
+class AutoMarkController extends Controller
+{
+ /**
+ * Список pending предсказаний с фильтрацией.
+ */
+ public function actionIndex(): string
+ {
+ $query = Products1cAutomarkPrediction::find()
+ ->with('product')
+ ->orderBy(['confidence' => SORT_DESC, 'created_at' => SORT_DESC]);
+
+ $status = Yii::$app->request->get('status');
+ if ($status !== null) {
+ $query->andWhere(['status' => (int) $status]);
+ } else {
+ $query->andWhere(['status' => Products1cAutomarkPrediction::STATUS_PENDING]);
+ }
+
+ $dataProvider = new ActiveDataProvider([
+ 'query' => $query,
+ 'pagination' => ['pageSize' => 50],
+ ]);
+
+ return $this->render('index', ['dataProvider' => $dataProvider]);
+ }
+
+ /**
+ * Карточка товара + форма валидации предсказания.
+ */
+ public function actionReview(int $id): string
+ {
+ $prediction = $this->findPrediction($id);
+
+ if (Yii::$app->request->isPost) {
+ $action = Yii::$app->request->post('action');
+ $service = new AutoMarkService();
+
+ if ($action === 'approve') {
+ $prediction->status = Products1cAutomarkPrediction::STATUS_APPROVED;
+ $prediction->approved_by = Yii::$app->user->id;
+ $prediction->updated_at = date('Y-m-d H:i:s');
+ $prediction->save();
+ $service->applyApprovedPrediction($prediction->id);
+ Yii::$app->session->setFlash('success', 'Разметка применена.');
+ } elseif ($action === 'reject') {
+ $prediction->status = Products1cAutomarkPrediction::STATUS_REJECTED;
+ $prediction->updated_at = date('Y-m-d H:i:s');
+ $prediction->save();
+ Yii::$app->session->setFlash('info', 'Предсказание отклонено.');
+ }
+
+ return $this->redirect(['index']);
+ }
+
+ return $this->render('review', ['prediction' => $prediction]);
+ }
+
+ private function findPrediction(int $id): Products1cAutomarkPrediction
+ {
+ $model = Products1cAutomarkPrediction::find()->with('product')->where(['id' => $id])->one();
+ if ($model === null) {
+ throw new NotFoundHttpException("Предсказание #{$id} не найдено.");
+ }
+ return $model;
+ }
+}
+```
+
+- [ ] **Step 2: Создать view/auto-mark/index.php**
+
+```php
+<?php
+/** @var \yii\data\ActiveDataProvider $dataProvider */
+use yii\grid\GridView;
+use yii\helpers\Html;
+use yii_app\records\Products1cAutomarkPrediction;
+
+$this->title = 'Авторазметка товаров';
+?>
+<div class="container-fluid">
+ <h2><?= Html::encode($this->title) ?></h2>
+
+ <div class="mb-3">
+ <?= Html::a('Pending', ['index', 'status' => Products1cAutomarkPrediction::STATUS_PENDING], ['class' => 'btn btn-warning']) ?>
+ <?= Html::a('Одобренные', ['index', 'status' => Products1cAutomarkPrediction::STATUS_APPROVED], ['class' => 'btn btn-success']) ?>
+ <?= Html::a('Отклонённые', ['index', 'status' => Products1cAutomarkPrediction::STATUS_REJECTED], ['class' => 'btn btn-secondary']) ?>
+ </div>
+
+ <?= GridView::widget([
+ 'dataProvider' => $dataProvider,
+ 'columns' => [
+ 'id',
+ [
+ 'label' => 'Товар',
+ 'value' => fn($m) => $m->product?->name ?? $m->product_id,
+ ],
+ 'category',
+ 'species',
+ 'sort',
+ 'color',
+ 'size',
+ [
+ 'label' => 'Confidence',
+ 'value' => fn($m) => number_format($m->confidence * 100, 1) . '%',
+ ],
+ 'method',
+ [
+ 'label' => 'Действия',
+ 'format' => 'raw',
+ 'value' => fn($m) => Html::a('Проверить', ['review', 'id' => $m->id], ['class' => 'btn btn-sm btn-primary']),
+ ],
+ ],
+ ]) ?>
+</div>
+```
+
+- [ ] **Step 3: Создать view/auto-mark/review.php**
+
+```php
+<?php
+/** @var \yii_app\records\Products1cAutomarkPrediction $prediction */
+use yii\helpers\Html;
+use yii_app\records\Products1cAutomarkPrediction;
+
+$this->title = 'Проверка разметки #' . $prediction->id;
+?>
+<div class="container">
+ <h2><?= Html::encode($this->title) ?></h2>
+
+ <div class="card mb-4">
+ <div class="card-header"><strong>Товар из 1С</strong></div>
+ <div class="card-body">
+ <p><strong>GUID:</strong> <?= Html::encode($prediction->product_id) ?></p>
+ <p><strong>Название:</strong> <?= Html::encode($prediction->product?->name ?? '—') ?></p>
+ <p><strong>Код:</strong> <?= Html::encode($prediction->product?->code ?? '—') ?></p>
+ </div>
+ </div>
+
+ <div class="card mb-4">
+ <div class="card-header">
+ <strong>Предсказание</strong>
+ — метод: <code><?= Html::encode($prediction->method) ?></code>,
+ уверенность: <strong><?= number_format($prediction->confidence * 100, 1) ?>%</strong>
+ </div>
+ <div class="card-body">
+ <table class="table table-bordered">
+ <tr><th>Категория</th><td><?= Html::encode($prediction->category ?? '—') ?></td></tr>
+ <tr><th>Подкатегория</th><td><?= Html::encode($prediction->subcategory ?? '—') ?></td></tr>
+ <tr><th>Вид</th><td><?= Html::encode($prediction->species ?? '—') ?></td></tr>
+ <tr><th>Сорт</th><td><?= Html::encode($prediction->sort ?? '—') ?></td></tr>
+ <tr><th>Тип</th><td><?= Html::encode($prediction->type ?? '—') ?></td></tr>
+ <tr><th>Размер</th><td><?= $prediction->size ?? '—' ?></td></tr>
+ <tr><th>Цвет</th><td><?= Html::encode($prediction->color ?? '—') ?></td></tr>
+ </table>
+ </div>
+ </div>
+
+ <?php if ($prediction->isPending()): ?>
+ <div class="d-flex gap-2">
+ <?= Html::beginForm(['review', 'id' => $prediction->id], 'post') ?>
+ <?= Html::hiddenInput('action', 'approve') ?>
+ <?= Html::submitButton('Применить разметку', ['class' => 'btn btn-success']) ?>
+ <?= Html::endForm() ?>
+
+ <?= Html::beginForm(['review', 'id' => $prediction->id], 'post') ?>
+ <?= Html::hiddenInput('action', 'reject') ?>
+ <?= Html::submitButton('Отклонить', ['class' => 'btn btn-danger']) ?>
+ <?= Html::endForm() ?>
+ </div>
+ <?php endif; ?>
+
+ <div class="mt-3">
+ <?= Html::a('← Назад к списку', ['index'], ['class' => 'btn btn-secondary']) ?>
+ </div>
+</div>
+```
+
+- [ ] **Step 4: Проверить что маршрут доступен**
+
+```bash
+php yii help auto-mark
+# если не консольный — проверить urlManager
+grep -n "auto-mark\|AutoMark" erp24/config/web.php | head -10
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add erp24/controllers/AutoMarkController.php erp24/views/auto-mark/
+git commit -m "feat(ERP-292): web-контроллер и views для валидации авторазметки"
+```
+
+---
+
+## Task 10: Финальная проверка
+
+- [ ] **Step 1: Запустить все тесты ERP-292**
+
+```bash
+cd /Users/vladfo/development/yii-erp24/erp24
+vendor/bin/codecept run unit tests/unit/records/Products1cAutomarkPredictionTest.php tests/unit/services/automark/ tests/unit/services/AutoMarkServiceTest.php -v
+```
+
+Expected: все тесты PASS, 0 failures.
+
+- [ ] **Step 2: Запустить regression suite**
+
+```bash
+vendor/bin/codecept run unit -v 2>&1 | tail -20
+```
+
+Expected: нет новых failures по сравнению с базовой веткой.
+
+- [ ] **Step 3: Smoke-тест консольной команды (dev-окружение)**
+
+```bash
+php yii auto-mark/run-new
+```
+
+Expected: вывод вида `Создано предсказаний: N`
+
+- [ ] **Step 4: Итоговый коммит**
+
+```bash
+git log --oneline feature_fomichev_ERP-292.. 2>/dev/null || git log --oneline -10
+git push origin HEAD
+```
+
+---
+
+## Self-Review
+
+### Spec coverage
+
+| Требование из ERP-292 | Задача |
+|-----------------------|--------|
+| Авторазметка category, species, sort, type, size, color | Task 4 (Rule), Task 5 (Similarity) |
+| Основа для автозаказа | Task 6 (applyApprovedPrediction → products_1c_nomenclature) |
+| Работа с уже размеченными данными | Task 6 (loadCorpus из products_1c_nomenclature) |
+| Запуск при поступлении товара | Task 8 (Queue Job) |
+| UI для ручной проверки | Task 9 |
+| Хранение предсказаний | Task 1-2 |
+
+### Type consistency check
+
+- `ParseResult` определён в Task 3, используется в Task 4, 5, 6 — ✅
+- `Products1cAutomarkPrediction` определён в Task 2, используется в Task 6, 7, 9 — ✅
+- `AutoMarkService` определён в Task 6, используется в Task 7, 8, 9 — ✅
+- Константы `STATUS_PENDING/APPROVED/REJECTED`, `METHOD_RULE/SIMILARITY` — используются консистентно — ✅
+
+### Placeholder scan
+
+Нет TBD, TODO, "implement later" — ✅
--- /dev/null
+# ERP-292: План ручного тестирования — ML-движок авторазметки
+
+**Ветка:** `feature_fomichev_ERP-292_automarkup`
+**Окружение:** dev (Docker)
+**Дата:** 2026-04-17
+
+---
+
+## Подготовка
+
+```bash
+# 1. Применить миграцию
+docker exec php-yii_erp24 php yii migrate 1
+
+# 2. Запустить unit-тесты
+docker exec php-yii_erp24 vendor/bin/codecept run unit \
+ tests/unit/records/Products1cAutomarkPredictionTest.php \
+ tests/unit/services/automark/ \
+ tests/unit/services/AutoMarkServiceTest.php
+
+# 3. Убедиться что таблица создана
+docker exec php-yii_erp24 php yii migrate/history 3
+```
+
+---
+
+## TC-01: Rule-based разметка — известный вид цветка
+
+**Предусловие:** В `products_1c` есть товар с именем типа "Роза Premium 50см Красная" без записи в `products_1c_nomenclature`.
+
+**Шаги:**
+1. Найти GUID: `SELECT id, name FROM products_1c WHERE tip='products' AND id NOT IN (SELECT id FROM products_1c_nomenclature) LIMIT 5;`
+2. Запустить: `docker exec php-yii_erp24 php yii auto-mark/run-product {GUID}`
+
+**Ожидаемый результат:**
+- Вывод: `Предсказание создано (ID=N, confidence=X.XX, method=rule)`
+- В `products_1c_automark_predictions`: запись с `method='rule'`, `confidence >= 0.9`, `status=0`
+- `category='Срезы'`, `species='Роза'`, `sort='Premium'`, `size=50`, `color='Красная'`
+
+---
+
+## TC-02: Similarity matching — нестандартное название
+
+**Предусловие:** Товар с названием без явных ключевых слов, но похожим на размеченные в `products_1c_nomenclature`.
+
+**Шаги:**
+1. Взять GUID товара с нестандартным названием
+2. `docker exec php-yii_erp24 php yii auto-mark/run-product {GUID}`
+
+**Ожидаемый результат:**
+- `method='similarity'`, `confidence` в диапазоне 0.7–0.89
+- Атрибуты взяты из наиболее похожего товара по TF-IDF
+
+---
+
+## TC-03: Неизвестный товар — предсказание не создаётся
+
+**Предусловие:** Товар с именем без ключевых слов и без аналогов в корпусе.
+
+**Шаги:**
+1. `docker exec php-yii_erp24 php yii auto-mark/run-product {GUID_неизвестного}`
+
+**Ожидаемый результат:**
+- Вывод: `Не удалось создать предсказание для {GUID}`
+- Запись в `products_1c_automark_predictions` НЕ создана
+
+---
+
+## TC-04: Пакетная разметка — без дублей
+
+**Шаги:**
+1. `docker exec php-yii_erp24 php yii auto-mark/run-new`
+2. Запустить повторно
+
+**Ожидаемый результат:**
+- Первый запуск: `Создано предсказаний: N` (N > 0)
+- Повторный запуск: `Создано предсказаний: 0` (дубли не создаются)
+
+---
+
+## TC-05: Web UI — список предсказаний
+
+**Шаги:**
+1. Открыть `http://localhost/auto-mark`
+2. Проверить фильтры: Pending / Одобренные / Отклонённые
+
+**Ожидаемый результат:**
+- GridView с колонками: ID, Товар, Категория, Вид, Сорт, Цвет, Размер, Confidence%, Метод, кнопка Проверить
+- Confidence отображается в % (например `95.0%`)
+- Фильтры переключают список
+
+---
+
+## TC-06: Web UI — одобрение предсказания
+
+**Предусловие:** Есть pending предсказание из TC-01/TC-04.
+
+**Шаги:**
+1. Открыть `/auto-mark/review?id={ID}`
+2. Убедиться что отображается: GUID товара, Название, таблица атрибутов, Confidence%
+3. Нажать "Применить разметку"
+
+**Ожидаемый результат:**
+- Редирект на список с flash "Разметка применена"
+- `products_1c_automark_predictions`: `status=1`, `approved_by` заполнен, `updated_at` заполнен
+- `products_1c_nomenclature`: новая запись с атрибутами из предсказания
+- При повторном открытии review — кнопки Применить/Отклонить не показываются
+
+---
+
+## TC-07: Web UI — отклонение предсказания
+
+**Предусловие:** Другое pending предсказание.
+
+**Шаги:**
+1. Открыть `/auto-mark/review?id={ID}`
+2. Нажать "Отклонить"
+
+**Ожидаемый результат:**
+- Редирект с flash "Предсказание отклонено"
+- `products_1c_automark_predictions`: `status=2`
+- `products_1c_nomenclature`: запись НЕ создана / не изменена
+
+---
+
+## TC-08: CLI apply — применение одобренного
+
+**Шаги:**
+```sql
+UPDATE products_1c_automark_predictions SET status=1 WHERE id={N};
+```
+2. `docker exec php-yii_erp24 php yii auto-mark/apply {N}`
+
+**Ожидаемый результат:**
+- Вывод: `Предсказание ID=N применено.`
+- В `products_1c_nomenclature` появилась/обновлена запись
+
+---
+
+## TC-09: Горшечные и упаковка
+
+**Шаги:**
+1. Найти товары с именами типа "Орхидея горшок 12" и "Пакет целлофановый 60х80"
+2. Запустить разметку для каждого
+
+**Ожидаемый результат:**
+- Орхидея: `category='Горшечные'`, `method='rule'`
+- Пакет: `category='Упаковка'`, `method='rule'`
+
+---
+
+## TC-10: XSS-защита (security smoke)
+
+**Шаги:**
+1. Добавить в `products_1c.name` строку вида `<script>alert(1)</script>` тестовой записи
+2. Запустить разметку, открыть список в UI
+
+**Ожидаемый результат:**
+- Текст отображается как `<script>alert(1)</script>` — без исполнения JS
+
+---
+
+## Чеклист
+
+| TC | Описание | Результат | Комментарий |
+|----|----------|-----------|-------------|
+| TC-01 | Rule-based: роза с size/color | ⬜ Pass / ⬜ Fail | |
+| TC-02 | Similarity: нестандартное имя | ⬜ Pass / ⬜ Fail | |
+| TC-03 | Null для неизвестного товара | ⬜ Pass / ⬜ Fail | |
+| TC-04 | Batch без дублей | ⬜ Pass / ⬜ Fail | |
+| TC-05 | Web UI список + фильтры | ⬜ Pass / ⬜ Fail | |
+| TC-06 | Одобрение → запись в номенклатуру | ⬜ Pass / ⬜ Fail | |
+| TC-07 | Отклонение → no changes | ⬜ Pass / ⬜ Fail | |
+| TC-08 | CLI apply одобренного | ⬜ Pass / ⬜ Fail | |
+| TC-09 | Горшечные и упаковка | ⬜ Pass / ⬜ Fail | |
+| TC-10 | XSS-защита | ⬜ Pass / ⬜ Fail | |
--- /dev/null
+# LLM Auto-Markup Verification Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Добавить третий путь верификации авторазметки товаров — LLM-верификатор, который пакетно проверяет pending-предсказания, обогащая контекст похожими товарами из nomenclature, и записывает вердикт в таблицу.
+
+**Architecture:** LLM-клиент (Guzzle, OpenAI-compatible API) вызывается из `LlmVerifier`, который разбивает pending-предсказания на батчи, обогащает каждый товар 5 похожими размеченными примерами через ILIKE-запрос, отправляет в LLM и записывает `llm_verdict` + `llm_verified_at` обратно в `products_1c_automark_predictions`. Запуск: автоматически в `actionRunNew` когда нет новых товаров, вручную через `actionVerifyLlm`.
+
+**Tech Stack:** PHP 8.1, Yii2 2.0.45, GuzzleHTTP 7.x, PostgreSQL 12+, Codeception 4.x
+
+---
+
+## File Map
+
+| Действие | Файл | Назначение |
+|---|---|---|
+| Create | `erp24/migrations/m260430_000001_add_llm_fields_to_automark_predictions.php` | Добавить llm_verdict, llm_verified_at, llm_comment |
+| Modify | `erp24/records/Products1cAutomarkPrediction.php` | Новые @property, константа METHOD_LLM, правила |
+| Create | `erp24/services/automark/LlmVerifyResult.php` | DTO: вердикт LLM для одного товара |
+| Create | `erp24/services/automark/LlmClient.php` | Guzzle HTTP-клиент к OpenAI-compatible LLM API |
+| Create | `erp24/services/automark/LlmVerifier.php` | Оркестратор: батчинг, обогащение, вызов клиента, сохранение |
+| Modify | `erp24/services/AutoMarkService.php` | Методы llmBatchVerify(), getPendingUnverifiedCount() |
+| Modify | `erp24/commands/AutoMarkController.php` | Новый actionVerifyLlm(), триггер в actionRunNew() |
+| Modify | `docker/db/dev.db.env` | Новые env-переменные LLM_* |
+| Create | `erp24/tests/unit/services/automark/LlmClientTest.php` | Юнит-тесты парсинга ответа |
+| Create | `erp24/tests/unit/services/automark/LlmVerifierTest.php` | Юнит-тесты построения промпта и парсинга вердиктов |
+
+---
+
+## Task 1: Migration — добавить LLM-поля в таблицу предсказаний
+
+**Files:**
+- Create: `erp24/migrations/m260430_000001_add_llm_fields_to_automark_predictions.php`
+
+- [ ] **Step 1: Создать файл миграции**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+use yii\db\Migration;
+
+class m260430_000001_add_llm_fields_to_automark_predictions extends Migration
+{
+ public function safeUp(): void
+ {
+ $this->addColumn(
+ 'products_1c_automark_predictions',
+ 'llm_verdict',
+ $this->string(20)->null()->defaultValue(null)->after('method')
+ );
+ $this->addColumn(
+ 'products_1c_automark_predictions',
+ 'llm_verified_at',
+ $this->timestamp()->null()->defaultValue(null)->after('llm_verdict')
+ );
+ $this->addColumn(
+ 'products_1c_automark_predictions',
+ 'llm_comment',
+ $this->string(500)->null()->defaultValue(null)->after('llm_verified_at')
+ );
+ $this->createIndex(
+ 'idx_automark_llm_verdict',
+ 'products_1c_automark_predictions',
+ 'llm_verdict'
+ );
+ }
+
+ public function safeDown(): void
+ {
+ $this->dropIndex('idx_automark_llm_verdict', 'products_1c_automark_predictions');
+ $this->dropColumn('products_1c_automark_predictions', 'llm_comment');
+ $this->dropColumn('products_1c_automark_predictions', 'llm_verified_at');
+ $this->dropColumn('products_1c_automark_predictions', 'llm_verdict');
+ }
+}
+```
+
+- [ ] **Step 2: Применить миграцию**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 php yii migrate --migrationPath=@app/migrations --interactive=0
+```
+
+Ожидаем: `Applied 1 migration.`
+
+- [ ] **Step 3: Проверить схему**
+
+```bash
+docker exec yii-erp24-db-pgsql-yii_erp24-1 psql -U postgres -d erp24 \
+ -c "\d erp24.products_1c_automark_predictions" 2>&1 | grep llm
+```
+
+Ожидаем три строки: `llm_verdict`, `llm_verified_at`, `llm_comment`.
+
+- [ ] **Step 4: Коммит**
+
+```bash
+git add erp24/migrations/m260430_000001_add_llm_fields_to_automark_predictions.php
+git commit -m "feat(ERP-292): миграция — llm_verdict, llm_verified_at, llm_comment"
+```
+
+---
+
+## Task 2: Обновить модель Products1cAutomarkPrediction
+
+**Files:**
+- Modify: `erp24/records/Products1cAutomarkPrediction.php`
+
+- [ ] **Step 1: Обновить файл модели**
+
+Заменить содержимое `erp24/records/Products1cAutomarkPrediction.php`:
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use yii\db\ActiveQuery;
+
+/**
+ * Предсказание авторазметки товара из 1С.
+ *
+ * @property int $id
+ * @property string $product_id
+ * @property string|null $category
+ * @property string|null $subcategory
+ * @property string|null $species
+ * @property string|null $sort
+ * @property string|null $type
+ * @property int|null $size
+ * @property string|null $color
+ * @property float $confidence
+ * @property string $method
+ * @property int $status
+ * @property int|null $approved_by
+ * @property string|null $llm_verdict
+ * @property string|null $llm_verified_at
+ * @property string|null $llm_comment
+ * @property string $created_at
+ * @property string|null $updated_at
+ */
+class Products1cAutomarkPrediction extends \yii\db\ActiveRecord
+{
+ public const STATUS_PENDING = 0;
+ public const STATUS_APPROVED = 1;
+ public const STATUS_REJECTED = 2;
+
+ public const METHOD_RULE = 'rule';
+ public const METHOD_SIMILARITY = 'similarity';
+ public const METHOD_LLM = 'llm';
+
+ public const LLM_VERDICT_APPROVED = 'approved';
+ public const LLM_VERDICT_REJECTED = 'rejected';
+
+ public static function tableName(): string
+ {
+ return 'products_1c_automark_predictions';
+ }
+
+ public function rules(): array
+ {
+ return [
+ [['product_id', 'confidence', 'method', 'status'], 'required'],
+ [['product_id', 'category', 'subcategory', 'species', 'sort', 'type', 'color', 'method'], 'string', 'max' => 255],
+ [['llm_verdict', 'llm_comment'], 'string', 'max' => 500],
+ [['size', 'status', 'approved_by'], 'integer'],
+ [['confidence'], 'number', 'min' => 0, 'max' => 1],
+ [['method'], 'in', 'range' => [self::METHOD_RULE, self::METHOD_SIMILARITY, self::METHOD_LLM]],
+ [['status'], 'in', 'range' => [self::STATUS_PENDING, self::STATUS_APPROVED, self::STATUS_REJECTED]],
+ [['llm_verdict'], 'in', 'range' => [self::LLM_VERDICT_APPROVED, self::LLM_VERDICT_REJECTED], 'skipOnEmpty' => true],
+ [['created_at', 'updated_at', 'llm_verified_at'], 'safe'],
+ [['category', 'subcategory', 'species', 'sort', 'type', 'color', 'approved_by', 'updated_at', 'size',
+ 'llm_verdict', 'llm_verified_at', 'llm_comment'], 'default', 'value' => null],
+ ];
+ }
+
+ public function isPending(): bool
+ {
+ return $this->status === self::STATUS_PENDING;
+ }
+
+ public function isApproved(): bool
+ {
+ return $this->status === self::STATUS_APPROVED;
+ }
+
+ public function isRejected(): bool
+ {
+ return $this->status === self::STATUS_REJECTED;
+ }
+
+ public function isLlmVerified(): bool
+ {
+ return $this->llm_verdict !== null;
+ }
+
+ public function getProduct(): ActiveQuery
+ {
+ return $this->hasOne(Products1c::class, ['id' => 'product_id']);
+ }
+}
+```
+
+- [ ] **Step 2: Прогнать существующие тесты модели**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 \
+ php vendor/bin/codecept run unit "records/Products1cAutomarkPredictionTest.php" --no-colors
+```
+
+Ожидаем: `OK (5 tests, 8 assertions)`
+
+- [ ] **Step 3: Коммит**
+
+```bash
+git add erp24/records/Products1cAutomarkPrediction.php
+git commit -m "feat(ERP-292): модель — METHOD_LLM, llm_verdict поля и валидация"
+```
+
+---
+
+## Task 3: LlmVerifyResult — DTO для вердикта
+
+**Files:**
+- Create: `erp24/services/automark/LlmVerifyResult.php`
+
+- [ ] **Step 1: Создать value object**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+final class LlmVerifyResult
+{
+ public function __construct(
+ public readonly int $predictionId,
+ public readonly string $verdict, // 'approved' | 'rejected'
+ public readonly ?string $comment,
+ ) {}
+
+ public function isApproved(): bool
+ {
+ return $this->verdict === 'approved';
+ }
+}
+```
+
+- [ ] **Step 2: Коммит**
+
+```bash
+git add erp24/services/automark/LlmVerifyResult.php
+git commit -m "feat(ERP-292): LlmVerifyResult DTO"
+```
+
+---
+
+## Task 4: LlmClient — HTTP-клиент к LLM API
+
+**Files:**
+- Create: `erp24/services/automark/LlmClient.php`
+- Create: `erp24/tests/unit/services/automark/LlmClientTest.php`
+
+Клиент работает с OpenAI-compatible endpoint (Ollama, vLLM, LM Studio и др.).
+Конфигурация читается из env: `LLM_ENDPOINT`, `LLM_API_KEY`, `LLM_MODEL`.
+
+- [ ] **Step 1: Написать failing-тест на parseResponse()**
+
+Создать `erp24/tests/unit/services/automark/LlmClientTest.php`:
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services\automark;
+
+use Codeception\Test\Unit;
+use yii_app\services\automark\LlmClient;
+
+/**
+ * @covers \yii_app\services\automark\LlmClient
+ */
+class LlmClientTest extends Unit
+{
+ public function testExtractsJsonFromCleanResponse(): void
+ {
+ $raw = '[{"id":1,"verdict":"approved","comment":null}]';
+ $this->assertSame(
+ [['id' => 1, 'verdict' => 'approved', 'comment' => null]],
+ LlmClient::parseResponse($raw)
+ );
+ }
+
+ public function testExtractsJsonEmbeddedInText(): void
+ {
+ $raw = "Вот результат:\n[{\"id\":2,\"verdict\":\"rejected\",\"comment\":\"Неверная категория\"}]\nГотово.";
+ $result = LlmClient::parseResponse($raw);
+ $this->assertSame('rejected', $result[0]['verdict']);
+ $this->assertSame(2, $result[0]['id']);
+ }
+
+ public function testThrowsOnUnparsableResponse(): void
+ {
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('LLM вернул невалидный JSON');
+ LlmClient::parseResponse('Я не знаю что ответить.');
+ }
+
+ public function testThrowsOnNonArrayJson(): void
+ {
+ $this->expectException(\RuntimeException::class);
+ LlmClient::parseResponse('{"error":"bad request"}');
+ }
+}
+```
+
+- [ ] **Step 2: Запустить — убедиться, что FAIL**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 \
+ php vendor/bin/codecept run unit "services/automark/LlmClientTest" --no-colors
+```
+
+Ожидаем: `FAIL` — класс не существует.
+
+- [ ] **Step 3: Создать LlmClient**
+
+Создать `erp24/services/automark/LlmClient.php`:
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
+
+class LlmClient
+{
+ private Client $http;
+ private string $endpoint;
+ private string $apiKey;
+ private string $model;
+
+ public function __construct()
+ {
+ $this->endpoint = (string)(getenv('LLM_ENDPOINT') ?: '');
+ $this->apiKey = (string)(getenv('LLM_API_KEY') ?: '');
+ $this->model = (string)(getenv('LLM_MODEL') ?: 'llama3.2');
+ $this->http = new Client(['timeout' => 120]);
+ }
+
+ /**
+ * Отправить запрос к LLM и вернуть распарсенный массив вердиктов.
+ *
+ * @throws \RuntimeException при сетевой ошибке или невалидном JSON
+ */
+ public function chat(string $systemPrompt, string $userMessage): array
+ {
+ if ($this->endpoint === '') {
+ throw new \RuntimeException('LLM_ENDPOINT не задан в env');
+ }
+
+ try {
+ $response = $this->http->post($this->endpoint, [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $this->apiKey,
+ 'Content-Type' => 'application/json',
+ ],
+ 'json' => [
+ 'model' => $this->model,
+ 'temperature' => 0.1,
+ 'max_tokens' => 2000,
+ 'messages' => [
+ ['role' => 'system', 'content' => $systemPrompt],
+ ['role' => 'user', 'content' => $userMessage],
+ ],
+ ],
+ ]);
+ } catch (GuzzleException $e) {
+ throw new \RuntimeException('LLM HTTP ошибка: ' . $e->getMessage(), 0, $e);
+ }
+
+ $body = (string) $response->getBody();
+ $decoded = json_decode($body, true);
+ $content = $decoded['choices'][0]['message']['content'] ?? '';
+
+ return self::parseResponse($content);
+ }
+
+ /**
+ * Извлечь JSON-массив из текстового ответа LLM.
+ * Модель иногда оборачивает JSON в пояснительный текст.
+ */
+ public static function parseResponse(string $raw): array
+ {
+ // Попробовать распарсить напрямую
+ $direct = json_decode(trim($raw), true);
+ if (is_array($direct) && array_is_list($direct)) {
+ return $direct;
+ }
+
+ // Извлечь первый JSON-массив из текста
+ if (preg_match('/\[.*?\]/su', $raw, $m)) {
+ $extracted = json_decode($m[0], true);
+ if (is_array($extracted) && array_is_list($extracted)) {
+ return $extracted;
+ }
+ }
+
+ throw new \RuntimeException('LLM вернул невалидный JSON: ' . mb_substr($raw, 0, 200));
+ }
+}
+```
+
+- [ ] **Step 4: Запустить тесты — убедиться, что PASS**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 \
+ php vendor/bin/codecept run unit "services/automark/LlmClientTest" --no-colors
+```
+
+Ожидаем: `OK (4 tests, ...)`
+
+- [ ] **Step 5: Коммит**
+
+```bash
+git add erp24/services/automark/LlmClient.php \
+ erp24/tests/unit/services/automark/LlmClientTest.php
+git commit -m "feat(ERP-292): LlmClient — HTTP-клиент и парсинг ответа"
+```
+
+---
+
+## Task 5: LlmVerifier — оркестратор пакетной верификации
+
+**Files:**
+- Create: `erp24/services/automark/LlmVerifier.php`
+- Create: `erp24/tests/unit/services/automark/LlmVerifierTest.php`
+
+- [ ] **Step 1: Написать failing-тесты**
+
+Создать `erp24/tests/unit/services/automark/LlmVerifierTest.php`:
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services\automark;
+
+use Codeception\Test\Unit;
+use yii_app\services\automark\LlmVerifier;
+
+/**
+ * @covers \yii_app\services\automark\LlmVerifier
+ */
+class LlmVerifierTest extends Unit
+{
+ public function testSystemPromptContainsTaxonomy(): void
+ {
+ $prompt = LlmVerifier::buildSystemPrompt();
+
+ $this->assertStringContainsString('Срезка', $prompt);
+ $this->assertStringContainsString('Розы', $prompt);
+ $this->assertStringContainsString('Горшечные_растения', $prompt);
+ $this->assertStringContainsString('approved', $prompt);
+ $this->assertStringContainsString('rejected', $prompt);
+ }
+
+ public function testBuildBatchPayloadContainsNameAndPrediction(): void
+ {
+ $items = [
+ [
+ 'id' => 42,
+ 'name' => 'Роза Эквадор 80 алая',
+ 'category' => 'Срезка',
+ 'subcategory' => 'Розы',
+ 'species' => 'Роза',
+ 'size' => 80,
+ 'color' => 'Алая',
+ 'examples' => [],
+ ],
+ ];
+
+ $payload = LlmVerifier::buildBatchPayload($items);
+ $decoded = json_decode($payload, true);
+
+ $this->assertIsArray($decoded);
+ $this->assertSame(42, $decoded[0]['id']);
+ $this->assertSame('Роза Эквадор 80 алая', $decoded[0]['name']);
+ $this->assertArrayHasKey('prediction', $decoded[0]);
+ $this->assertSame('Срезка', $decoded[0]['prediction']['category']);
+ }
+
+ public function testParseVerifyResultsReturnsLlmVerifyResultObjects(): void
+ {
+ $raw = [
+ ['id' => 10, 'verdict' => 'approved', 'comment' => null],
+ ['id' => 11, 'verdict' => 'rejected', 'comment' => 'Неверный цвет'],
+ ];
+
+ $results = LlmVerifier::parseVerifyResults($raw);
+
+ $this->assertCount(2, $results);
+ $this->assertTrue($results[0]->isApproved());
+ $this->assertSame(10, $results[0]->predictionId);
+ $this->assertFalse($results[1]->isApproved());
+ $this->assertSame('Неверный цвет', $results[1]->comment);
+ }
+
+ public function testParseVerifyResultsSkipsItemsWithoutRequiredFields(): void
+ {
+ $raw = [
+ ['id' => 10, 'verdict' => 'approved', 'comment' => null],
+ ['id' => 11], // нет verdict
+ ['verdict' => 'approved'], // нет id
+ ];
+
+ $results = LlmVerifier::parseVerifyResults($raw);
+ $this->assertCount(1, $results);
+ }
+}
+```
+
+- [ ] **Step 2: Запустить — убедиться, что FAIL**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 \
+ php vendor/bin/codecept run unit "services/automark/LlmVerifierTest" --no-colors
+```
+
+Ожидаем: FAIL — класс не существует.
+
+- [ ] **Step 3: Создать LlmVerifier**
+
+Создать `erp24/services/automark/LlmVerifier.php`:
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+use yii_app\records\Products1cAutomarkPrediction;
+use yii_app\records\Products1cNomenclature;
+
+class LlmVerifier
+{
+ private const SYSTEM_PROMPT_TAXONOMY = <<<TAXONOMY
+ ТАКСОНОМИЯ КАТЕГОРИЙ (используй точные названия):
+ - Срезка → Розы | Цветущие | Зелень | Экзотика | Прочее
+ - Горшечные_растения → Цветущие | Лиственные | Прочее
+ - Сухоцветы → Поштучно | Пучек | Прочее
+ - Упаковка → Пленка | Подарок | Прочее
+ - Сопутствующие_товары → Уход | Праздник | Игрушки | Пиротехника | Упаковка | Прочее
+ TAXONOMY;
+
+ private LlmClient $client;
+ private int $batchSize;
+
+ public function __construct(int $batchSize = 20)
+ {
+ $this->client = new LlmClient();
+ $this->batchSize = $batchSize;
+ }
+
+ /**
+ * Верифицировать все pending-предсказания без LLM-вердикта.
+ *
+ * @return int Количество верифицированных предсказаний
+ */
+ public function verifyPending(): int
+ {
+ $predictions = Products1cAutomarkPrediction::find()
+ ->with('product')
+ ->where(['status' => Products1cAutomarkPrediction::STATUS_PENDING])
+ ->andWhere(['llm_verdict' => null])
+ ->all();
+
+ if (empty($predictions)) {
+ return 0;
+ }
+
+ $batches = array_chunk($predictions, $this->batchSize);
+ $verified = 0;
+
+ foreach ($batches as $batch) {
+ $items = $this->enrichBatch($batch);
+ $payload = self::buildBatchPayload($items);
+
+ try {
+ $raw = $this->client->chat(self::buildSystemPrompt(), $payload);
+ $results = self::parseVerifyResults($raw);
+ $verified += $this->saveResults($results);
+ } catch (\RuntimeException) {
+ // Один батч упал — продолжаем следующий
+ continue;
+ }
+ }
+
+ return $verified;
+ }
+
+ /**
+ * Обогатить батч похожими примерами из nomenclature.
+ */
+ private function enrichBatch(array $predictions): array
+ {
+ $items = [];
+ foreach ($predictions as $prediction) {
+ $items[] = [
+ 'id' => $prediction->id,
+ 'name' => $prediction->product->name ?? '',
+ 'category' => $prediction->category,
+ 'subcategory' => $prediction->subcategory,
+ 'species' => $prediction->species,
+ 'size' => $prediction->size,
+ 'color' => $prediction->color,
+ 'examples' => $this->getSimilarExamples($prediction->product->name ?? ''),
+ ];
+ }
+ return $items;
+ }
+
+ /**
+ * Получить до 5 похожих размеченных товаров из nomenclature по ключевому слову.
+ */
+ private function getSimilarExamples(string $productName): array
+ {
+ preg_match('/[а-яёА-ЯЁa-zA-Z]{4,}/u', $productName, $m);
+ $keyword = $m[0] ?? '';
+ if ($keyword === '') {
+ return [];
+ }
+
+ return Products1cNomenclature::find()
+ ->select(['name', 'category', 'subcategory', 'species', 'size', 'color'])
+ ->where(['not', ['category' => null]])
+ ->andWhere(['ilike', 'name', $keyword])
+ ->limit(5)
+ ->asArray()
+ ->all();
+ }
+
+ /**
+ * Сохранить вердикты в БД.
+ *
+ * @param LlmVerifyResult[] $results
+ */
+ private function saveResults(array $results): int
+ {
+ $count = 0;
+ $now = date('Y-m-d H:i:s');
+
+ foreach ($results as $result) {
+ $rows = Products1cAutomarkPrediction::updateAll(
+ [
+ 'llm_verdict' => $result->verdict,
+ 'llm_verified_at' => $now,
+ 'llm_comment' => $result->comment,
+ ],
+ ['id' => $result->predictionId]
+ );
+ $count += $rows;
+ }
+
+ return $count;
+ }
+
+ public static function buildSystemPrompt(): string
+ {
+ return <<<PROMPT
+ Ты — система верификации авторазметки товаров цветочного магазина.
+
+ {$this->SYSTEM_PROMPT_TAXONOMY}
+
+ ЗАДАЧА: Для каждого товара в массиве:
+ 1. Проверь соответствие разметки (category, subcategory, species, size, color) названию товара.
+ 2. Используй similar_examples как эталон правильной разметки.
+ 3. Вынеси вердикт: approved (разметка верна) или rejected (есть ошибки).
+
+ ОТВЕТ — только JSON-массив, без пояснений вне него:
+ [{"id":<int>,"verdict":"approved"|"rejected","comment":"<причина если rejected, иначе null>"}]
+ PROMPT;
+ }
+
+ public static function buildBatchPayload(array $items): string
+ {
+ $payload = [];
+ foreach ($items as $item) {
+ $payload[] = [
+ 'id' => $item['id'],
+ 'name' => $item['name'],
+ 'prediction' => [
+ 'category' => $item['category'],
+ 'subcategory' => $item['subcategory'],
+ 'species' => $item['species'],
+ 'size' => $item['size'],
+ 'color' => $item['color'],
+ ],
+ 'similar_examples' => $item['examples'],
+ ];
+ }
+ return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+ }
+
+ /**
+ * @return LlmVerifyResult[]
+ */
+ public static function parseVerifyResults(array $raw): array
+ {
+ $results = [];
+ foreach ($raw as $item) {
+ if (!isset($item['id'], $item['verdict'])) {
+ continue;
+ }
+ $results[] = new LlmVerifyResult(
+ predictionId: (int) $item['id'],
+ verdict: (string) $item['verdict'],
+ comment: isset($item['comment']) ? (string) $item['comment'] : null,
+ );
+ }
+ return $results;
+ }
+}
+```
+
+- [ ] **Step 4: Исправить `buildSystemPrompt` — SYSTEM_PROMPT_TAXONOMY не может быть `$this->` в static методе**
+
+В `LlmVerifier.php` заменить `buildSystemPrompt()`:
+
+```php
+ public static function buildSystemPrompt(): string
+ {
+ $taxonomy = self::SYSTEM_PROMPT_TAXONOMY;
+ return <<<PROMPT
+ Ты — система верификации авторазметки товаров цветочного магазина.
+
+ {$taxonomy}
+
+ ЗАДАЧА: Для каждого товара в массиве:
+ 1. Проверь соответствие разметки (category, subcategory, species, size, color) названию товара.
+ 2. Используй similar_examples как эталон правильной разметки.
+ 3. Вынеси вердикт: approved (разметка верна) или rejected (есть ошибки).
+
+ ОТВЕТ — только JSON-массив, без пояснений вне него:
+ [{"id":<int>,"verdict":"approved"|"rejected","comment":"<причина если rejected, иначе null>"}]
+ PROMPT;
+ }
+```
+
+- [ ] **Step 5: Запустить тесты — убедиться, что PASS**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 \
+ php vendor/bin/codecept run unit "services/automark/LlmVerifierTest" --no-colors
+```
+
+Ожидаем: `OK (4 tests, ...)`
+
+- [ ] **Step 6: Прогнать все automark-тесты**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 \
+ php vendor/bin/codecept run unit "services/automark" --no-colors
+```
+
+Ожидаем: все зелёные.
+
+- [ ] **Step 7: Коммит**
+
+```bash
+git add erp24/services/automark/LlmVerifier.php \
+ erp24/services/automark/LlmVerifyResult.php \
+ erp24/tests/unit/services/automark/LlmVerifierTest.php
+git commit -m "feat(ERP-292): LlmVerifier — батчинг, обогащение примерами, парсинг вердиктов"
+```
+
+---
+
+## Task 6: AutoMarkService — добавить llmBatchVerify и getPendingUnverifiedCount
+
+**Files:**
+- Modify: `erp24/services/AutoMarkService.php`
+
+- [ ] **Step 1: Добавить два публичных метода в AutoMarkService**
+
+В конец `erp24/services/AutoMarkService.php` перед закрывающей `}` добавить:
+
+```php
+ /**
+ * Верифицировать pending-предсказания через LLM.
+ *
+ * @return int Количество верифицированных предсказаний
+ */
+ public function llmBatchVerify(): int
+ {
+ $batchSize = (int)(getenv('LLM_BATCH_SIZE') ?: 20);
+ $verifier = new \yii_app\services\automark\LlmVerifier($batchSize);
+ return $verifier->verifyPending();
+ }
+
+ /**
+ * Количество pending-предсказаний, ещё не прошедших LLM-верификацию.
+ */
+ public function getPendingUnverifiedCount(): int
+ {
+ return (int) Products1cAutomarkPrediction::find()
+ ->where(['status' => Products1cAutomarkPrediction::STATUS_PENDING])
+ ->andWhere(['llm_verdict' => null])
+ ->count();
+ }
+```
+
+- [ ] **Step 2: Добавить use-импорт LlmVerifier**
+
+В блок `use` в `AutoMarkService.php` добавить:
+
+```php
+use yii_app\services\automark\LlmVerifier;
+```
+
+И заменить `new \yii_app\services\automark\LlmVerifier(...)` на `new LlmVerifier(...)`.
+
+- [ ] **Step 3: Проверить синтаксис**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 php -l /www/services/AutoMarkService.php
+```
+
+Ожидаем: `No syntax errors detected`
+
+- [ ] **Step 4: Коммит**
+
+```bash
+git add erp24/services/AutoMarkService.php
+git commit -m "feat(ERP-292): AutoMarkService — llmBatchVerify, getPendingUnverifiedCount"
+```
+
+---
+
+## Task 7: Console command — триггер и новая команда verify-llm
+
+**Files:**
+- Modify: `erp24/commands/AutoMarkController.php`
+
+- [ ] **Step 1: Обновить AutoMarkController**
+
+Заменить содержимое `erp24/commands/AutoMarkController.php`:
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\commands;
+
+use yii\console\Controller;
+use yii\console\ExitCode;
+use yii_app\services\AutoMarkService;
+
+/**
+ * Авторазметка товаров из 1С (ERP-292).
+ *
+ * Использование:
+ * php yii auto-mark/run-new — разметить новые товары; если нет новых — запустить LLM-верификацию
+ * php yii auto-mark/run-product GUID — разметить конкретный товар
+ * php yii auto-mark/apply PRED_ID — применить одобренное предсказание
+ * php yii auto-mark/verify-llm — запустить LLM-верификацию принудительно
+ */
+class AutoMarkController extends Controller
+{
+ public $defaultAction = 'run-new';
+
+ public function actionRunNew(): int
+ {
+ $service = new AutoMarkService();
+
+ $this->stdout("Запуск авторазметки новых товаров...\n");
+ $newCount = $service->batchPredict();
+ $this->stdout("Создано предсказаний: {$newCount}\n");
+
+ if ($newCount === 0) {
+ $pendingCount = $service->getPendingUnverifiedCount();
+ if ($pendingCount > 0) {
+ $this->stdout("Новых товаров нет. Запуск LLM-верификации ({$pendingCount} ожидают)...\n");
+ return $this->runLlmVerify($service);
+ }
+ $this->stdout("Нет новых товаров и нет ожидающих верификации.\n");
+ }
+
+ return ExitCode::OK;
+ }
+
+ public function actionRunProduct(string $guid): int
+ {
+ $service = new AutoMarkService();
+ $result = $service->predictForProduct($guid);
+
+ if ($result === null) {
+ $this->stderr("Не удалось создать предсказание для {$guid}\n");
+ return ExitCode::UNSPECIFIED_ERROR;
+ }
+
+ $this->stdout("Предсказание создано (ID={$result->id}, confidence={$result->confidence}, method={$result->method})\n");
+ return ExitCode::OK;
+ }
+
+ public function actionApply(int $predictionId): int
+ {
+ $service = new AutoMarkService();
+ $success = $service->applyApprovedPrediction($predictionId);
+
+ if (!$success) {
+ $this->stderr("Не удалось применить предсказание ID={$predictionId}\n");
+ return ExitCode::UNSPECIFIED_ERROR;
+ }
+
+ $this->stdout("Предсказание ID={$predictionId} применено.\n");
+ return ExitCode::OK;
+ }
+
+ public function actionVerifyLlm(): int
+ {
+ $service = new AutoMarkService();
+ $pendingCount = $service->getPendingUnverifiedCount();
+
+ if ($pendingCount === 0) {
+ $this->stdout("Нет предсказаний для LLM-верификации.\n");
+ return ExitCode::OK;
+ }
+
+ $this->stdout("Запуск LLM-верификации ({$pendingCount} предсказаний)...\n");
+ return $this->runLlmVerify($service);
+ }
+
+ private function runLlmVerify(AutoMarkService $service): int
+ {
+ try {
+ $verified = $service->llmBatchVerify();
+ $this->stdout("LLM верифицировано: {$verified}\n");
+ return ExitCode::OK;
+ } catch (\RuntimeException $e) {
+ $this->stderr("Ошибка LLM-верификации: {$e->getMessage()}\n");
+ return ExitCode::UNSPECIFIED_ERROR;
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Проверить синтаксис**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 php -l /www/commands/AutoMarkController.php
+```
+
+Ожидаем: `No syntax errors detected`
+
+- [ ] **Step 3: Убедиться, что команды регистрируются**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 php yii help auto-mark 2>&1
+```
+
+Ожидаем вывод с `run-new`, `run-product`, `apply`, `verify-llm`.
+
+- [ ] **Step 4: Прогнать все automark-тесты**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 \
+ php vendor/bin/codecept run unit "services/automark" \
+ unit "records/Products1cAutomarkPredictionTest.php" --no-colors
+```
+
+Ожидаем: все зелёные.
+
+- [ ] **Step 5: Коммит**
+
+```bash
+git add erp24/commands/AutoMarkController.php
+git commit -m "feat(ERP-292): консольная команда verify-llm, триггер в run-new"
+```
+
+---
+
+## Task 8: Переменные окружения
+
+**Files:**
+- Modify: `docker/db/dev.db.env`
+
+- [ ] **Step 1: Добавить LLM env-переменные**
+
+В конец `docker/db/dev.db.env` добавить:
+
+```ini
+# LLM Verification (ERP-292)
+LLM_ENDPOINT=http://your-llm-server:11434/v1/chat/completions
+LLM_API_KEY=your-api-key-here
+LLM_MODEL=llama3.2
+LLM_BATCH_SIZE=20
+```
+
+Значения `LLM_ENDPOINT` и `LLM_API_KEY` запросить у команды / взять из инфраструктуры.
+
+- [ ] **Step 2: Проверить, что сервис читает переменные**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 php -r "echo getenv('LLM_ENDPOINT') ?: 'NOT SET';"
+```
+
+Ожидаем: URL вашего LLM-сервера (не `NOT SET`).
+
+- [ ] **Step 3: Коммит**
+
+```bash
+git add docker/db/dev.db.env
+git commit -m "feat(ERP-292): env — LLM_ENDPOINT, LLM_API_KEY, LLM_MODEL, LLM_BATCH_SIZE"
+```
+
+---
+
+## Self-Review
+
+### Spec coverage
+
+| Требование | Задача |
+|---|---|
+| LLM-клиент с endpoint + API key из env | Task 4, Task 8 |
+| Батчинг (не по одному) | Task 5 — `array_chunk` по `LLM_BATCH_SIZE` |
+| Триггер: нет новых → верифицировать pending | Task 7 — `actionRunNew()` |
+| Принудительный запуск | Task 7 — `actionVerifyLlm()` |
+| Системный промпт с таксономией | Task 5 — `buildSystemPrompt()` |
+| Обогащение контекста похожими примерами (ILIKE) | Task 5 — `getSimilarExamples()` |
+| Запись вердикта в таблицу (llm_verdict) | Task 1 (миграция), Task 5 (`saveResults()`) |
+| Маркировка "прошло LLM-верификацию" | `llm_verdict` + `llm_verified_at` поля |
+| METHOD_LLM константа | Task 2 |
+
+### Placeholder scan
+
+Найдено: нет TBD/TODO/placeholder.
+
+### Type consistency
+
+- `LlmVerifyResult` создаётся в Task 3 и используется в Task 5 — сигнатуры совпадают.
+- `llmBatchVerify()` / `getPendingUnverifiedCount()` добавляются в Task 6, вызываются в Task 7 — совпадают.
+- `LlmVerifier::verifyPending()` возвращает `int` — совпадает с вызовом в `AutoMarkService`.
# Token for Salebot Google Sheets import endpoint
SALEBOT_IMPORT_TOKEN=
+# === LLM (ERP-292 Automarkup Verification) ===
+# OpenAI-compatible endpoint (e.g. http://ollama:11434/v1, https://api.openai.com/v1)
+LLM_ENDPOINT=http://localhost:11434/v1
+# API key (required for OpenAI/cloud; can be arbitrary string for local Ollama)
+LLM_API_KEY=
+# Model name to use for verification
+LLM_MODEL=llama3.2
+# Batch size for LLM verification (default: 20)
+LLM_BATCH_SIZE=20
+
# === SITE API ===
# URL for external site API (SiteService)
SITE_API_URL=
use Yii;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
+use yii\web\Response;
use yii\data\ActiveDataProvider;
use yii_app\records\Products1cAutomarkPrediction;
use yii_app\services\AutoMarkService;
return $this->render('index', ['dataProvider' => $dataProvider]);
}
- public function actionReview(int $id): string
+ public function actionReview(int $id): string|Response
{
$prediction = $this->findPrediction($id);
}
$transaction->commit();
Yii::$app->session->setFlash('success', 'Разметка применена.');
- } catch (\Exception $e) {
+ } catch (\Exception) {
$transaction->rollBack();
Yii::$app->session->setFlash('error', 'Ошибка при применении разметки.');
}
--- /dev/null
+<!DOCTYPE html>
+<html lang="ru">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>ERP-292 Авторазметка товаров</title>
+<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
+<style>
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.6; }
+
+ .header { background: linear-gradient(135deg, #1a1f2e 0%, #16213e 100%); border-bottom: 1px solid #2d3748; padding: 32px 48px; }
+ .header h1 { font-size: 28px; font-weight: 700; color: #f7fafc; }
+ .header .tag { display: inline-block; background: #2b6cb0; color: #bee3f8; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; margin-left: 12px; vertical-align: middle; }
+ .header p { color: #a0aec0; margin-top: 8px; font-size: 15px; }
+
+ .nav { display: flex; background: #1a1f2e; border-bottom: 1px solid #2d3748; padding: 0 48px; overflow-x: auto; }
+ .nav button { background: none; border: none; color: #a0aec0; padding: 14px 20px; cursor: pointer; font-size: 14px; font-weight: 500; border-bottom: 2px solid transparent; transition: all .2s; white-space: nowrap; }
+ .nav button:hover { color: #e2e8f0; }
+ .nav button.active { color: #63b3ed; border-bottom-color: #63b3ed; }
+
+ .content { max-width: 1200px; margin: 0 auto; padding: 40px 48px; }
+ .section { display: none; }
+ .section.active { display: block; }
+
+ h2 { font-size: 22px; font-weight: 700; color: #f7fafc; margin-bottom: 8px; }
+ h3 { font-size: 16px; font-weight: 600; color: #90cdf4; margin: 28px 0 12px; }
+ p { color: #cbd5e0; margin-bottom: 12px; }
+
+ .card { background: #1a1f2e; border: 1px solid #2d3748; border-radius: 10px; padding: 24px; margin-bottom: 20px; }
+ .card-title { font-size: 14px; font-weight: 700; color: #90cdf4; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 16px; }
+
+ .diagram-wrap { background: #111827; border: 1px solid #2d3748; border-radius: 10px; padding: 32px; margin: 20px 0; overflow-x: auto; }
+ .diagram-wrap .mermaid { display: flex; justify-content: center; }
+
+ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
+ .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
+ @media (max-width: 900px) { .grid-2, .grid-3 { grid-template-columns: 1fr; } .content { padding: 24px 20px; } }
+
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
+ th { background: #2d3748; color: #90cdf4; padding: 10px 14px; text-align: left; font-weight: 600; }
+ td { padding: 9px 14px; border-bottom: 1px solid #2d3748; color: #cbd5e0; }
+ tr:hover td { background: #1e2a3a; }
+
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 600; }
+ .badge-yellow { background: #744210; color: #fbd38d; }
+ .badge-green { background: #1c4532; color: #9ae6b4; }
+ .badge-red { background: #63171b; color: #feb2b2; }
+ .badge-blue { background: #1a365d; color: #90cdf4; }
+ .badge-purple { background: #44337a; color: #d6bcfa; }
+
+ code { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 13px; }
+ pre { background: #0d1117; border: 1px solid #2d3748; border-radius: 8px; padding: 20px; overflow-x: auto; margin: 12px 0; }
+ pre code { color: #d1fae5; }
+
+ .pipeline-step { display: flex; align-items: flex-start; gap: 16px; padding: 16px 0; border-bottom: 1px solid #2d3748; }
+ .pipeline-step:last-child { border-bottom: none; }
+ .step-num { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px; flex-shrink: 0; }
+ .step-body { flex: 1; }
+ .step-title { font-weight: 600; color: #e2e8f0; margin-bottom: 4px; }
+ .step-desc { font-size: 14px; color: #a0aec0; }
+
+ .threshold-bar { height: 8px; background: #2d3748; border-radius: 4px; margin-top: 6px; }
+ .threshold-fill { height: 100%; border-radius: 4px; }
+
+ .file-list { list-style: none; }
+ .file-list li { padding: 8px 0; border-bottom: 1px solid #2d3748; font-size: 14px; display: flex; align-items: center; gap: 10px; }
+ .file-list li:last-child { border-bottom: none; }
+ .file-icon { font-size: 16px; }
+ .file-path { color: #a0aec0; font-family: monospace; font-size: 13px; }
+ .file-desc { color: #cbd5e0; }
+
+ .note { background: #1a2744; border-left: 3px solid #63b3ed; padding: 12px 16px; border-radius: 0 6px 6px 0; margin: 12px 0; font-size: 14px; color: #a0aec0; }
+</style>
+</head>
+<body>
+
+<div class="header">
+ <h1>Авторазметка товаров 1С <span class="tag">ERP-292</span></h1>
+ <p>Автоматическая классификация товаров по категории, виду, цвету, размеру и сорту</p>
+</div>
+
+<div class="nav">
+ <button class="active" onclick="show('overview', this)">Обзор</button>
+ <button onclick="show('pipeline', this)">Пайплайн предсказания</button>
+ <button onclick="show('integration', this)">Интеграция в систему</button>
+ <button onclick="show('database', this)">База данных</button>
+ <button onclick="show('files', this)">Файлы</button>
+ <button onclick="show('workflow', this)">Workflow разметки</button>
+</div>
+
+<div class="content">
+
+<!-- ═══════════════════════════════════════════════════════ OVERVIEW -->
+<div id="overview" class="section active">
+ <h2>Архитектура авторазметки</h2>
+ <p>Система автоматически анализирует названия товаров из 1С и предсказывает их атрибуты. Результаты проходят ревью менеджером перед сохранением в номенклатуру.</p>
+
+ <div class="diagram-wrap">
+ <div class="mermaid">
+graph TB
+ subgraph INPUT["Источники запуска"]
+ CLI["🖥️ CLI<br/>php yii auto-mark/run-new"]
+ QUEUE["📨 RabbitMQ Queue<br/>AutoMarkPredictionJob"]
+ WEB_RUN["🌐 Web (ручной запуск)"]
+ end
+
+ subgraph CORE["AutoMarkService — ядро"]
+ SERVICE["AutoMarkService"]
+ RULE["RuleBasedParser<br/>🔍 поиск по ключевым словам"]
+ SIM["SimilarityMatcher<br/>📐 косинусное сходство"]
+ DTO["ParseResult DTO"]
+ end
+
+ subgraph STORAGE["Хранилище"]
+ DB_PRED["🗄️ products_1c_automark_predictions<br/>pending / approved / rejected"]
+ DB_PRODUCTS["🗄️ products_1c<br/>товары из 1С"]
+ DB_NOM["🗄️ products_1c_nomenclature<br/>размеченная номенклатура"]
+ end
+
+ subgraph REVIEW["Ревью (Web UI)"]
+ LIST["📋 Список предсказаний<br/>/auto-mark/index"]
+ DETAIL["🔎 Форма проверки<br/>/auto-mark/review"]
+ end
+
+ CLI --> SERVICE
+ QUEUE --> SERVICE
+ WEB_RUN --> SERVICE
+ DB_PRODUCTS --> SERVICE
+ SERVICE --> RULE
+ SERVICE --> SIM
+ SIM -- "загружает корпус" --> DB_NOM
+ RULE --> DTO
+ SIM --> DTO
+ DTO --> DB_PRED
+ DB_PRED --> LIST
+ LIST --> DETAIL
+ DETAIL -- "approve" --> DB_NOM
+ DETAIL -- "reject" --> DB_PRED
+
+ style INPUT fill:#1a2744,stroke:#2b6cb0
+ style CORE fill:#1c2f1c,stroke:#2f855a
+ style STORAGE fill:#2d1515,stroke:#c53030
+ style REVIEW fill:#2d2215,stroke:#c05621
+ </div>
+ </div>
+
+ <div class="grid-2">
+ <div class="card">
+ <div class="card-title">Что делает система</div>
+ <div class="pipeline-step">
+ <div class="step-num" style="background:#2b6cb0;color:#bee3f8">1</div>
+ <div class="step-body">
+ <div class="step-title">Находит размеченные товары</div>
+ <div class="step-desc">Ищет в products_1c товары без записи в products_1c_nomenclature</div>
+ </div>
+ </div>
+ <div class="pipeline-step">
+ <div class="step-num" style="background:#276749;color:#9ae6b4">2</div>
+ <div class="step-body">
+ <div class="step-title">Анализирует название</div>
+ <div class="step-desc">Прогоняет через 2 алгоритма: правила и схожесть</div>
+ </div>
+ </div>
+ <div class="pipeline-step">
+ <div class="step-num" style="background:#6b21a8;color:#d6bcfa">3</div>
+ <div class="step-body">
+ <div class="step-title">Сохраняет предсказание</div>
+ <div class="step-desc">Создаёт запись в products_1c_automark_predictions со статусом pending</div>
+ </div>
+ </div>
+ <div class="pipeline-step">
+ <div class="step-num" style="background:#9c4221;color:#fbd38d">4</div>
+ <div class="step-body">
+ <div class="step-title">Менеджер проверяет</div>
+ <div class="step-desc">В Web UI: approve → запись попадает в номенклатуру</div>
+ </div>
+ </div>
+ </div>
+
+ <div class="card">
+ <div class="card-title">Предсказываемые атрибуты</div>
+ <table>
+ <tr><th>Атрибут</th><th>Пример</th></tr>
+ <tr><td>category</td><td>Срезы / Горшечные / Упаковка</td></tr>
+ <tr><td>subcategory</td><td>Розы голландские</td></tr>
+ <tr><td>species</td><td>Роза / Хризантема / Пион</td></tr>
+ <tr><td>sort</td><td>Premium / Экстра / Select</td></tr>
+ <tr><td>color</td><td>Красная / Белая / Микс</td></tr>
+ <tr><td>size</td><td>50 (в см)</td></tr>
+ <tr><td>type</td><td>— (зарезервировано)</td></tr>
+ <tr><td>confidence</td><td>0.0 – 1.0</td></tr>
+ </table>
+ </div>
+ </div>
+</div>
+
+<!-- ═══════════════════════════════════════════════════════ PIPELINE -->
+<div id="pipeline" class="section">
+ <h2>Пайплайн предсказания</h2>
+ <p>Два алгоритма работают последовательно. Приоритет — у правил (выше точность), fallback — у схожести.</p>
+
+ <div class="diagram-wrap">
+ <div class="mermaid">
+flowchart TD
+ START([Название товара]) --> RULE_PARSE
+
+ subgraph RULES["1. RuleBasedParser"]
+ RULE_PARSE["Привести к нижнему регистру"]
+ RULE_CAT["Определить категорию<br/>горшок/кашпо → Горшечные<br/>пакет/коробка → Упаковка"]
+ RULE_SPEC["Определить вид<br/>роза/пион/лилия/..."]
+ RULE_COLOR["Определить цвет<br/>красная/белая/микс/..."]
+ RULE_SIZE["Извлечь размер<br/>regex: /(\d+)\s*см/"]
+ RULE_SORT["Определить сорт<br/>premium/экстра/select/..."]
+ RULE_CONF["Вычислить confidence<br/>base=0.5, cat+0.2, species+0.15,<br/>color+0.1, size+0.05, sort+0.05"]
+ RULE_PARSE --> RULE_CAT --> RULE_SPEC --> RULE_COLOR --> RULE_SIZE --> RULE_SORT --> RULE_CONF
+ end
+
+ RULE_CONF --> CHECK1{"rule confidence<br/>≥ 0.9?"}
+ CHECK1 -- "✅ Да" --> SAVE
+ CHECK1 -- "❌ Нет" --> SIM_LOAD
+
+ subgraph SIMILARITY["2. SimilarityMatcher"]
+ SIM_LOAD["Загрузить корпус<br/>products_1c_nomenclature<br/>(только размеченные)"]
+ SIM_TOK["Токенизация запроса<br/>lowercase + стоп-слова убрать<br/>числа+см убрать"]
+ SIM_LOOP["Для каждого элемента корпуса:<br/>токенизировать → cosine similarity"]
+ SIM_BEST["Взять лучший результат"]
+ SIM_LOAD --> SIM_TOK --> SIM_LOOP --> SIM_BEST
+ end
+
+ SIM_BEST --> CHECK2{"sim confidence<br/>≥ 0.7?"}
+ CHECK2 -- "✅ Да" --> SAVE
+ CHECK2 -- "❌ Нет" --> FALLBACK{"Есть хоть<br/>какой-то результат?"}
+ FALLBACK -- "Да" --> SAVE
+ FALLBACK -- "Нет" --> NULL([null — товар пропущен])
+
+ SAVE["💾 savePrediction()<br/>→ STATUS_PENDING"]
+
+ style RULES fill:#1a2744,stroke:#2b6cb0
+ style SIMILARITY fill:#1c2f1c,stroke:#2f855a
+ </div>
+ </div>
+
+ <div class="grid-2">
+ <div class="card">
+ <div class="card-title">RuleBasedParser — ключевые слова</div>
+ <h3>Виды (14 позиций)</h3>
+ <p style="font-size:13px; color:#a0aec0">роза, хризантема, гербера, тюльпан, лилия, пион, эустома, альстромерия, гипсофила, ирис, гвоздика, нарцисс, фрезия, лизиантус</p>
+ <h3>Цвета (15 позиций)</h3>
+ <p style="font-size:13px; color:#a0aec0">красная, белая, розовая, желтая, оранжевая, фиолетовая, синяя, кремовая, бордовая, микс, коралловая...</p>
+ <h3>Сорта (8 позиций)</h3>
+ <p style="font-size:13px; color:#a0aec0">premium, экстра, extra, select, стандарт, премиум, супер, элит</p>
+ <div class="note">Если обнаружен вид, но нет категории → категория автоматически = "Срезы"</div>
+ </div>
+
+ <div class="card">
+ <div class="card-title">Расчёт confidence (правила)</div>
+ <table>
+ <tr><th>Признак</th><th>+к score</th></tr>
+ <tr><td>base</td><td>0.50</td></tr>
+ <tr><td>category найдена</td><td>+0.20</td></tr>
+ <tr><td>species найден</td><td>+0.15</td></tr>
+ <tr><td>color найден</td><td>+0.10</td></tr>
+ <tr><td>size найден</td><td>+0.05</td></tr>
+ <tr><td>sort найден</td><td>+0.05</td></tr>
+ <tr><th>Максимум</th><th>1.00</th></tr>
+ </table>
+ <div class="note" style="margin-top:12px">Порог для авто-прохода правил: <strong>≥ 0.9</strong> (нужно знать ≥4 признака)</div>
+
+ <h3>Пример: "Роза Premium 50см Красная"</h3>
+ <table>
+ <tr><td>base</td><td>0.50</td></tr>
+ <tr><td>species: Роза</td><td>+0.15</td></tr>
+ <tr><td>category: Срезы</td><td>+0.20</td></tr>
+ <tr><td>color: Красная</td><td>+0.10</td></tr>
+ <tr><td>size: 50</td><td>+0.05</td></tr>
+ <tr><td>sort: Premium</td><td>+0.05</td></tr>
+ <tr><th>Итого</th><th>1.00 ✅</th></tr>
+ </table>
+ </div>
+ </div>
+
+ <div class="card">
+ <div class="card-title">SimilarityMatcher — косинусное сходство</div>
+ <div class="grid-2">
+ <div>
+ <h3>Алгоритм</h3>
+ <pre><code>// 1. Токенизация
+"Роза красная 50см" → ["роза", "красная"]
+// числа+см удаляются, стоп-слова убираются
+
+// 2. TF-вектор (bag of words)
+{"роза": 1, "красная": 1}
+
+// 3. Cosine similarity с каждым элементом корпуса:
+cos(A,B) = dot(A,B) / (|A| × |B|)
+
+// 4. Взять максимальный score → взять атрибуты этого элемента</code></pre>
+ </div>
+ <div>
+ <h3>Источник корпуса</h3>
+ <p>Все записи из <code>products_1c_nomenclature</code>, у которых поле <code>category IS NOT NULL</code></p>
+ <p>Это уже размеченные вручную или через предыдущие одобренные предсказания товары.</p>
+ <div class="note">Чем больше одобренных разметок — тем точнее работает SimilarityMatcher.</div>
+ <h3>Порог</h3>
+ <p>Минимальный confidence для принятия: <strong>0.7</strong></p>
+ <p>Ниже порога — результат всё равно сохраняется как pending, но с низким confidence для ревью.</p>
+ </div>
+ </div>
+ </div>
+</div>
+
+<!-- ═══════════════════════════════════════════════════════ INTEGRATION -->
+<div id="integration" class="section">
+ <h2>Интеграция в общий пайплайн</h2>
+ <p>Авторазметка — это отдельный слой обработки, который работает параллельно с основным потоком товаров из 1С.</p>
+
+ <div class="diagram-wrap">
+ <div class="mermaid">
+sequenceDiagram
+ participant 1C as 1С (синхронизация)
+ participant DB as products_1c
+ participant CLI as CLI / Queue
+ participant SVC as AutoMarkService
+ participant PRED as products_1c_automark_predictions
+ participant NOM as products_1c_nomenclature
+ participant MGR as Менеджер (Web UI)
+
+ 1C->>DB: Синхронизация новых товаров
+ Note over DB: Новые товары без номенклатуры
+
+ CLI->>SVC: auto-mark/run-new
+ SVC->>DB: SELECT products без номенклатуры
+ DB-->>SVC: [productId, ...]
+
+ loop Для каждого товара
+ SVC->>PRED: Проверить existing pending
+ alt Нет pending
+ SVC->>SVC: runPipeline(product.name)
+ SVC->>PRED: INSERT prediction (status=0)
+ end
+ end
+
+ MGR->>PRED: GET /auto-mark/index (status=pending)
+ PRED-->>MGR: Список предсказаний
+
+ MGR->>MGR: Проверяет каждое предсказание
+ alt Одобрить
+ MGR->>PRED: POST action=approve → status=1
+ MGR->>SVC: applyApprovedPrediction(id)
+ SVC->>NOM: INSERT/UPDATE номенклатура
+ else Отклонить
+ MGR->>PRED: POST action=reject → status=2
+ end
+
+ Note over NOM: Товар теперь размечен<br/>и виден в SimilarityMatcher
+ </div>
+ </div>
+
+ <div class="grid-2">
+ <div class="card">
+ <div class="card-title">Запуск через Queue (Async)</div>
+ <pre><code>// Добавить задачу в очередь:
+Yii::$app->queue->push(
+ new AutoMarkPredictionJob([
+ 'productId' => $guid,
+ ])
+);
+
+// Job выполняет:
+public function execute($queue): void
+{
+ (new AutoMarkService())
+ ->predictForProduct($this->productId);
+}</code></pre>
+ <div class="note">Queue используется для обработки по одному товару асинхронно, например при webhook от 1С.</div>
+ </div>
+
+ <div class="card">
+ <div class="card-title">Запуск через CLI (Batch)</div>
+ <pre><code># Разметить все товары без номенклатуры:
+php yii auto-mark/run-new
+
+# Разметить конкретный товар по GUID:
+php yii auto-mark/run-product {GUID}
+
+# Применить одобренное предсказание:
+php yii auto-mark/apply {PRED_ID}</code></pre>
+ <div class="note">CLI удобен для первоначальной массовой разметки или крон-задачи.</div>
+ </div>
+ </div>
+
+ <div class="card">
+ <div class="card-title">Место в системе — диаграмма компонентов</div>
+ <div class="diagram-wrap" style="background:transparent;border:none;padding:0;">
+ <div class="mermaid">
+C4Component
+ title Авторазметка в контексте ERP24
+
+ Container_Boundary(erp, "ERP24 Backend (Yii2)") {
+ Component(sync, "1С Синхронизация", "Yii2 Service", "Загружает товары из 1С в products_1c")
+ Component(automark_svc, "AutoMarkService", "PHP Service", "Пайплайн предсказания атрибутов")
+ Component(automark_ctrl, "AutoMarkController (Web)", "Yii2 Controller", "UI для ревью предсказаний")
+ Component(automark_cmd, "AutoMarkController (CLI)", "Yii2 Console", "Пакетная разметка / apply")
+ Component(automark_job, "AutoMarkPredictionJob", "Yii Queue Job", "Async разметка одного товара")
+ Component(nom_svc, "Сервисы номенклатуры", "PHP Services", "Работа с products_1c_nomenclature")
+ }
+
+ ContainerDb(db, "PostgreSQL / MySQL", "Database", "products_1c, products_1c_nomenclature, products_1c_automark_predictions")
+ Container(queue, "RabbitMQ", "Message Queue", "Очередь задач")
+
+ Rel(sync, db, "Пишет товары")
+ Rel(automark_cmd, automark_svc, "Вызывает")
+ Rel(automark_job, automark_svc, "Вызывает")
+ Rel(automark_ctrl, automark_svc, "Вызывает applyApprovedPrediction")
+ Rel(automark_svc, db, "Читает товары, пишет предсказания, обновляет номенклатуру")
+ Rel(queue, automark_job, "Публикует задачи")
+ Rel(nom_svc, db, "Читает/пишет номенклатуру")
+ </div>
+ </div>
+ </div>
+</div>
+
+<!-- ═══════════════════════════════════════════════════════ DATABASE -->
+<div id="database" class="section">
+ <h2>Схема базы данных</h2>
+
+ <div class="diagram-wrap">
+ <div class="mermaid">
+erDiagram
+ products_1c {
+ string id PK "GUID (36 символов)"
+ string name "Название товара"
+ string code "Код товара"
+ int tip "Тип (1=продукт)"
+ timestamp created_at
+ }
+
+ products_1c_nomenclature {
+ string id PK "FK → products_1c.id"
+ string name "Название"
+ string category "Категория"
+ string subcategory "Подкатегория"
+ string species "Вид"
+ string sort "Сорт"
+ string type "Тип"
+ int size "Размер в см"
+ string color "Цвет"
+ string location "Локация"
+ string type_num "Номер типа"
+ }
+
+ products_1c_automark_predictions {
+ int id PK "AUTO_INCREMENT"
+ string product_id "FK → products_1c.id"
+ string category "Предсказанная категория"
+ string subcategory "Предсказанная подкатегория"
+ string species "Предсказанный вид"
+ string sort "Предсказанный сорт"
+ string type "Предсказанный тип"
+ int size "Предсказанный размер"
+ string color "Предсказанный цвет"
+ float confidence "Уверенность 0.0-1.0"
+ string method "rule | similarity"
+ smallint status "0=pending 1=approved 2=rejected"
+ int approved_by "ID пользователя"
+ timestamp created_at
+ timestamp updated_at
+ }
+
+ products_1c ||--o{ products_1c_automark_predictions : "имеет предсказания"
+ products_1c ||--o| products_1c_nomenclature : "имеет номенклатуру"
+ </div>
+ </div>
+
+ <div class="grid-2">
+ <div class="card">
+ <div class="card-title">Индексы таблицы предсказаний</div>
+ <table>
+ <tr><th>Имя индекса</th><th>Колонка</th><th>Цель</th></tr>
+ <tr><td>PRIMARY KEY</td><td>id</td><td>Идентификация записи</td></tr>
+ <tr><td>idx_automark_product_id</td><td>product_id</td><td>Поиск по товару</td></tr>
+ <tr><td>idx_automark_status</td><td>status</td><td>Фильтрация pending/approved</td></tr>
+ <tr><td>fk_automark_product</td><td>product_id → products_1c.id</td><td>CASCADE delete</td></tr>
+ </table>
+ </div>
+
+ <div class="card">
+ <div class="card-title">Статусы предсказания</div>
+ <table>
+ <tr><th>Код</th><th>Константа</th><th>Значение</th></tr>
+ <tr><td><span class="badge badge-yellow">0</span></td><td>STATUS_PENDING</td><td>Ожидает ревью менеджера</td></tr>
+ <tr><td><span class="badge badge-green">1</span></td><td>STATUS_APPROVED</td><td>Одобрено, данные в номенклатуре</td></tr>
+ <tr><td><span class="badge badge-red">2</span></td><td>STATUS_REJECTED</td><td>Отклонено, данные НЕ применены</td></tr>
+ </table>
+ <div class="note" style="margin-top:12px">Один товар может иметь несколько предсказаний, но только одно pending (проверяется в predictForProduct).</div>
+ </div>
+ </div>
+
+ <div class="card">
+ <div class="card-title">Поток данных: apply approved prediction</div>
+ <div class="diagram-wrap" style="background:transparent;border:none;padding:0">
+ <div class="mermaid">
+flowchart LR
+ A["products_1c_automark_predictions<br/>status = APPROVED"] --> B["AutoMarkService<br/>applyApprovedPrediction(id)"]
+ B --> C{"Есть запись в<br/>products_1c_nomenclature?"}
+ C -- "Нет" --> D["CREATE новую запись<br/>nomenclature с данными товара"]
+ C -- "Да" --> E["UPDATE существующую запись<br/>только те поля, что есть в предсказании"]
+ D --> F["Транзакция COMMIT"]
+ E --> F
+ F --> G["✅ Товар размечен"]
+ </div>
+ </div>
+ </div>
+</div>
+
+<!-- ═══════════════════════════════════════════════════════ FILES -->
+<div id="files" class="section">
+ <h2>Файлы ветки ERP-292</h2>
+ <p>Все новые файлы, добавленные в рамках задачи авторазметки.</p>
+
+ <div class="grid-2">
+ <div class="card">
+ <div class="card-title">Сервисный слой</div>
+ <ul class="file-list">
+ <li>
+ <span class="file-icon">⚙️</span>
+ <div>
+ <div class="file-desc">Основной сервис — оркестрация пайплайна</div>
+ <div class="file-path">erp24/services/AutoMarkService.php</div>
+ </div>
+ </li>
+ <li>
+ <span class="file-icon">🔍</span>
+ <div>
+ <div class="file-desc">Поиск по ключевым словам</div>
+ <div class="file-path">erp24/services/automark/RuleBasedParser.php</div>
+ </div>
+ </li>
+ <li>
+ <span class="file-icon">📐</span>
+ <div>
+ <div class="file-desc">Косинусное сходство с корпусом</div>
+ <div class="file-path">erp24/services/automark/SimilarityMatcher.php</div>
+ </div>
+ </li>
+ <li>
+ <span class="file-icon">📦</span>
+ <div>
+ <div class="file-desc">DTO результата предсказания</div>
+ <div class="file-path">erp24/services/automark/ParseResult.php</div>
+ </div>
+ </li>
+ </ul>
+ </div>
+
+ <div class="card">
+ <div class="card-title">Точки входа</div>
+ <ul class="file-list">
+ <li>
+ <span class="file-icon">🖥️</span>
+ <div>
+ <div class="file-desc">CLI команды: run-new / run-product / apply</div>
+ <div class="file-path">erp24/commands/AutoMarkController.php</div>
+ </div>
+ </li>
+ <li>
+ <span class="file-icon">📨</span>
+ <div>
+ <div class="file-desc">Queue Job для async разметки</div>
+ <div class="file-path">erp24/jobs/AutoMarkPredictionJob.php</div>
+ </div>
+ </li>
+ <li>
+ <span class="file-icon">🌐</span>
+ <div>
+ <div class="file-desc">Web контроллер (index + review)</div>
+ <div class="file-path">erp24/controllers/AutoMarkController.php</div>
+ </div>
+ </li>
+ </ul>
+ </div>
+
+ <div class="card">
+ <div class="card-title">Модели и Views</div>
+ <ul class="file-list">
+ <li>
+ <span class="file-icon">🗄️</span>
+ <div>
+ <div class="file-desc">ActiveRecord модель предсказаний</div>
+ <div class="file-path">erp24/records/Products1cAutomarkPrediction.php</div>
+ </div>
+ </li>
+ <li>
+ <span class="file-icon">📋</span>
+ <div>
+ <div class="file-desc">Список предсказаний с фильтрацией по статусу</div>
+ <div class="file-path">erp24/views/auto-mark/index.php</div>
+ </div>
+ </li>
+ <li>
+ <span class="file-icon">🔎</span>
+ <div>
+ <div class="file-desc">Форма ревью + кнопки approve/reject</div>
+ <div class="file-path">erp24/views/auto-mark/review.php</div>
+ </div>
+ </li>
+ <li>
+ <span class="file-icon">🗃️</span>
+ <div>
+ <div class="file-desc">Миграция: создание таблицы предсказаний</div>
+ <div class="file-path">erp24/migrations/m260417_000001_create_products_1c_automark_predictions.php</div>
+ </div>
+ </li>
+ </ul>
+ </div>
+
+ <div class="card">
+ <div class="card-title">Тесты</div>
+ <ul class="file-list">
+ <li>
+ <span class="file-icon">🧪</span>
+ <div>
+ <div class="file-desc">Unit тесты сервиса</div>
+ <div class="file-path">erp24/tests/unit/services/AutoMarkServiceTest.php</div>
+ </div>
+ </li>
+ <li>
+ <span class="file-icon">🧪</span>
+ <div>
+ <div class="file-desc">Тесты парсера правил</div>
+ <div class="file-path">erp24/tests/unit/services/automark/RuleBasedParserTest.php</div>
+ </div>
+ </li>
+ <li>
+ <span class="file-icon">🧪</span>
+ <div>
+ <div class="file-desc">Тесты матчера схожести</div>
+ <div class="file-path">erp24/tests/unit/services/automark/SimilarityMatcherTest.php</div>
+ </div>
+ </li>
+ <li>
+ <span class="file-icon">🧪</span>
+ <div>
+ <div class="file-desc">Тесты ActiveRecord модели</div>
+ <div class="file-path">erp24/tests/unit/records/Products1cAutomarkPredictionTest.php</div>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+</div>
+
+<!-- ═══════════════════════════════════════════════════════ WORKFLOW -->
+<div id="workflow" class="section">
+ <h2>Workflow разметки — жизненный цикл предсказания</h2>
+
+ <div class="diagram-wrap">
+ <div class="mermaid">
+stateDiagram-v2
+ [*] --> Pending : predictForProduct()
+
+ Pending --> Approved : Менеджер нажимает "Применить"
+ Pending --> Rejected : Менеджер нажимает "Отклонить"
+
+ Approved --> NomenclatureUpdated : applyApprovedPrediction()
+ NomenclatureUpdated --> [*] : Товар размечен
+
+ Rejected --> [*] : Предсказание архивируется
+
+ note right of Pending
+ Видно в Web UI на вкладке "Pending"
+ Sorted by confidence DESC
+ end note
+
+ note right of Approved
+ approved_by = user.id
+ Транзакция: UPDATE products_1c_nomenclature
+ end note
+
+ note right of Rejected
+ Остаётся в таблице
+ Не влияет на номенклатуру
+ end note
+ </div>
+ </div>
+
+ <div class="grid-2">
+ <div class="card">
+ <div class="card-title">Web UI — список предсказаний (/auto-mark/index)</div>
+ <p>Страница отображает предсказания из <code>products_1c_automark_predictions</code>, по умолчанию только <span class="badge badge-yellow">pending</span>.</p>
+ <h3>Фильтры по статусу</h3>
+ <table>
+ <tr>
+ <td><span class="badge badge-yellow">Pending</span></td>
+ <td>status=0, ожидают ревью</td>
+ </tr>
+ <tr>
+ <td><span class="badge badge-green">Одобренные</span></td>
+ <td>status=1, применены</td>
+ </tr>
+ <tr>
+ <td><span class="badge badge-red">Отклонённые</span></td>
+ <td>status=2, пропущены</td>
+ </tr>
+ </table>
+ <h3>Сортировка</h3>
+ <p>Сначала показывает самые уверенные предсказания (confidence DESC), потом по дате.</p>
+ </div>
+
+ <div class="card">
+ <div class="card-title">Web UI — форма ревью (/auto-mark/review)</div>
+ <p>Показывает детали товара из 1С и все предсказанные атрибуты.</p>
+ <h3>Метод предсказания</h3>
+ <table>
+ <tr>
+ <td><span class="badge badge-blue">rule</span></td>
+ <td>Распознано по ключевым словам — высокая точность</td>
+ </tr>
+ <tr>
+ <td><span class="badge badge-purple">similarity</span></td>
+ <td>Найдено похожее в корпусе — средняя точность</td>
+ </tr>
+ </table>
+ <h3>Действия (только для pending)</h3>
+ <table>
+ <tr><td>Применить разметку</td><td>approve → запись в номенклатуру</td></tr>
+ <tr><td>Отклонить</td><td>reject → запись пропускается</td></tr>
+ </table>
+ </div>
+ </div>
+
+ <div class="card">
+ <div class="card-title">Защита от дублирования</div>
+ <p>Перед созданием нового предсказания <code>predictForProduct()</code> проверяет наличие существующего <span class="badge badge-yellow">pending</span> для этого товара — и если оно есть, возвращает его, не создавая дубликат.</p>
+ <pre><code>$existing = Products1cAutomarkPrediction::find()
+ ->where(['product_id' => $productId, 'status' => STATUS_PENDING])
+ ->one();
+
+if ($existing !== null) {
+ return $existing; // Не создаём дубликат
+}</code></pre>
+ </div>
+
+ <div class="card">
+ <div class="card-title">Транзакционная безопасность</div>
+ <p>Операция approve выполняется в транзакции как в Web контроллере, так и в сервисе:</p>
+ <div class="grid-2">
+ <div>
+ <h3>В контроллере</h3>
+ <pre><code>$tx = Yii::$app->db->beginTransaction();
+try {
+ $prediction->status = STATUS_APPROVED;
+ $prediction->save();
+ $service->applyApprovedPrediction($id);
+ $tx->commit();
+} catch (Exception $e) {
+ $tx->rollBack();
+}</code></pre>
+ </div>
+ <div>
+ <h3>В сервисе (applyApprovedPrediction)</h3>
+ <pre><code>$tx = Yii::$app->db->beginTransaction();
+try {
+ // INSERT или UPDATE номенклатуры
+ $nomenclature->save();
+ $tx->commit();
+ return true;
+} catch (Exception $e) {
+ $tx->rollBack();
+ throw $e;
+}</code></pre>
+ </div>
+ </div>
+ <div class="note">Если apply не удался — статус предсказания откатывается обратно в pending.</div>
+ </div>
+</div>
+
+</div><!-- /content -->
+
+<script>
+ mermaid.initialize({
+ startOnLoad: true,
+ theme: 'dark',
+ themeVariables: {
+ primaryColor: '#1a2744',
+ primaryBorderColor: '#2b6cb0',
+ primaryTextColor: '#e2e8f0',
+ secondaryColor: '#1c2f1c',
+ tertiaryColor: '#2d1515',
+ background: '#0f1117',
+ mainBkg: '#1a1f2e',
+ nodeBorder: '#2d3748',
+ lineColor: '#4a5568',
+ textColor: '#e2e8f0',
+ edgeLabelBackground: '#1a1f2e',
+ },
+ flowchart: { htmlLabels: true, curve: 'basis' },
+ sequence: { actorMargin: 50, messageMargin: 35 },
+ });
+
+ function show(id, btn) {
+ document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
+ document.querySelectorAll('.nav button').forEach(b => b.classList.remove('active'));
+ document.getElementById(id).classList.add('active');
+ btn.classList.add('active');
+ }
+</script>
+</body>
+</html>
private const COLOR_KEYWORDS = [
'красная' => 'Красная', 'красный' => 'Красный',
+ 'алая' => 'Алая', 'алый' => 'Алый',
'белая' => 'Белая', 'белый' => 'Белый',
'розовая' => 'Розовая', 'розовый' => 'Розовый',
'желтая' => 'Желтая', 'желтый' => 'Желтый',
'синяя' => 'Синяя', 'синий' => 'Синий',
'кремовая' => 'Кремовая', 'кремовый' => 'Кремовый',
'бордовая' => 'Бордовая', 'бордовый' => 'Бордовый',
- 'микс' => 'Микс', 'коралловая' => 'Коралловая',
+ 'малиновая' => 'Малиновая', 'малиновый' => 'Малиновый',
+ 'сиреневая' => 'Сиреневая', 'сиреневый' => 'Сиреневый',
+ 'лиловая' => 'Лиловая', 'лиловый' => 'Лиловый',
+ 'персиковая' => 'Персиковая', 'персиковый' => 'Персиковый',
+ 'салатовая' => 'Салатовая', 'салатовый' => 'Салатовый',
+ 'терракот' => 'Терракот', 'терракотовая' => 'Терракот',
+ 'шампань' => 'Шампань', 'шампанское' => 'Шампань',
+ 'коралловая' => 'Коралловая', 'коралловый' => 'Коралловый',
+ 'мультиколор' => 'Микс', 'микс' => 'Микс',
+ ];
+
+ private const SPECIES_SUBCATEGORY = [
+ 'Роза' => 'Розы',
+ 'Гипсофила' => 'Зелень',
+ 'Хризантема' => 'Цветущие',
+ 'Гербера' => 'Цветущие',
+ 'Тюльпан' => 'Цветущие',
+ 'Лилия' => 'Цветущие',
+ 'Пион' => 'Цветущие',
+ 'Эустома' => 'Цветущие',
+ 'Альстромерия' => 'Цветущие',
+ 'Ирис' => 'Цветущие',
+ 'Гвоздика' => 'Цветущие',
+ 'Нарцисс' => 'Цветущие',
+ 'Фрезия' => 'Цветущие',
+ 'Лизиантус' => 'Цветущие',
];
private const POTTED_KEYWORDS = ['горшок', 'горшечн', 'кашпо', 'вазон'];
}
if ($species !== null && $category === null) {
- $category = 'Срезы';
+ $category = 'Срезка';
}
+ $subcategory = $species !== null ? (self::SPECIES_SUBCATEGORY[$species] ?? null) : null;
+
$confidence = $this->calcConfidence($category, $species, $sort, $color, $size);
return new ParseResult(
category: $category,
- subcategory: null,
+ subcategory: $subcategory,
species: $species,
sort: $sort,
type: null,
{
foreach (self::POTTED_KEYWORDS as $kw) {
if (str_contains($nameLower, $kw)) {
- return 'Горшечные';
+ return 'Горшечные_растения';
}
}
foreach (self::PACKAGING_KEYWORDS as $kw) {
if (preg_match('/(\d+)\s*(?:см|cm|СМ|CM)/iu', $name, $m)) {
return (int) $m[1];
}
+ // Bare number in [20, 200] surrounded by spaces — stem height without unit
+ if (preg_match('/(?:^|\s)(\d{2,3})(?:\s|$)/', $name, $m)) {
+ $n = (int) $m[1];
+ if ($n >= 20 && $n <= 200) {
+ return $n;
+ }
+ }
return null;
}
public function testIsPendingMethod(): void
{
- $model = new Products1cAutomarkPrediction();
- $model->status = Products1cAutomarkPrediction::STATUS_PENDING;
+ $model = $this->makeModel(['status' => Products1cAutomarkPrediction::STATUS_PENDING]);
$this->assertTrue($model->isPending());
}
public function testIsApprovedMethod(): void
{
- $model = new Products1cAutomarkPrediction();
- $model->status = Products1cAutomarkPrediction::STATUS_APPROVED;
+ $model = $this->makeModel(['status' => Products1cAutomarkPrediction::STATUS_APPROVED]);
$this->assertTrue($model->isApproved());
}
+
+ private function makeModel(array $attributes): Products1cAutomarkPrediction
+ {
+ $model = new Products1cAutomarkPrediction();
+ $ref = new \ReflectionProperty(\yii\db\BaseActiveRecord::class, '_attributes');
+ $ref->setValue($model, $attributes);
+ return $model;
+ }
}
$result = $this->parser->parse('Роза Premium 50см Красная');
$this->assertInstanceOf(ParseResult::class, $result);
- $this->assertSame('Срезы', $result->category);
+ $this->assertSame('Срезка', $result->category);
+ $this->assertSame('Розы', $result->subcategory);
$this->assertSame('Роза', $result->species);
$this->assertSame('Premium', $result->sort);
$this->assertSame(50, $result->size);
{
$result = $this->parser->parse('Орхидея Фаленопсис горшок 12');
- $this->assertSame('Горшечные', $result->category);
+ $this->assertSame('Горшечные_растения', $result->category);
$this->assertGreaterThanOrEqual(0.7, $result->confidence);
}
{
$result = $this->parser->parse('Хризантема Белая 60см');
- $this->assertSame('Срезы', $result->category);
+ $this->assertSame('Срезка', $result->category);
+ $this->assertSame('Цветущие', $result->subcategory);
$this->assertSame('Хризантема', $result->species);
}
+
+ public function testDetectsBareNumberAsStemHeight(): void
+ {
+ $result = $this->parser->parse('Роза Эквадор 80 алая');
+
+ $this->assertSame(80, $result->size);
+ $this->assertSame('Алая', $result->color);
+ $this->assertSame('Роза', $result->species);
+ $this->assertSame('Розы', $result->subcategory);
+ }
+
+ public function testDoesNotConfuseDimensionStringWithSize(): void
+ {
+ $result = $this->parser->parse('Пакет целлофановый 60х80');
+
+ $this->assertSame('Упаковка', $result->category);
+ $this->assertNull($result->size);
+ }
}
--- /dev/null
+# LLM Integration Guide
+
+> Для новых текстовых интеграций используйте `POST /v1/llm-analyze`.
+> `POST /v1/vlm-analyze` оставлен как legacy alias для совместимости.
+
+## Назначение
+
+Coordinator принимает текстовый запрос от внешней системы, проверяет `X-API-Key`, проксирует запрос на GPU worker и возвращает ответ модели.
+
+Подходит для:
+- ERP: классификация товара, разбор названий, выделение атрибутов, нормализация в JSON
+- Transcribe: анализ смен, анализ диалогов, факт продажи, качество обслуживания, структура разговора
+
+## Endpoint
+
+```http
+POST /v1/llm-analyze
+X-API-Key: <service-key>
+Content-Type: application/json
+```
+
+## Auth
+
+Используются отдельные входящие ключи:
+
+- `TRANSCRIBE_VLM_API_KEY` для запросов из Transcribe
+- `ERP_VLM_API_KEY` для запросов из ERP
+
+Внутренний вызов Coordinator -> Worker защищается Bearer token:
+
+- `CV_WORKER_INTERNAL_TOKEN` на координаторе
+- `WORKER_INTERNAL_TOKEN` на воркере
+
+Рекомендуется дополнительно ограничить доступ к Coordinator по IP / WireGuard / reverse proxy allowlist.
+
+## Request Contract
+
+```json
+{
+ "store_id": "shop01",
+ "system_prompt": "Return valid JSON only.",
+ "prompt": "Analyze the transcript and determine whether a sale happened.",
+ "structured_output": true
+}
+```
+
+Поля:
+
+| Поле | Обязательное | Тип | Описание |
+|------|--------------|-----|----------|
+| `prompt` | да | string | Основной пользовательский prompt |
+| `system_prompt` | нет | string | Системные инструкции для модели |
+| `store_id` | нет | string | Нужен, если логика запроса привязана к магазину |
+| `structured_output` | нет | bool | Сейчас информационное поле; формат ответа фактически задаётся prompt-ом |
+
+## Response Contract
+
+```json
+{
+ "ok": true,
+ "store_id": "shop01",
+ "response_text": "{\"has_sales\": true, \"confidence\": 0.91}",
+ "response_json": {
+ "has_sales": true,
+ "confidence": 0.91
+ },
+ "vlm_model": "google/gemma-3-12b-it",
+ "latency_ms": 842
+}
+```
+
+Поля:
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `ok` | bool | Статус обработки |
+| `store_id` | string \| null | Возвращается как есть из запроса |
+| `response_text` | string | Сырой текст модели |
+| `response_json` | object \| null | Распарсенный JSON, если модель его вернула |
+| `vlm_model` | string \| null | Модель, которая ответила |
+| `latency_ms` | int \| null | Время ответа модели |
+
+## ERP Integration
+
+### Когда вызывать
+
+Вызывать после получения или изменения названия товара, если нужно нормализовать карточку товара или выделить атрибуты.
+
+### Пример запроса
+
+```http
+POST /v1/llm-analyze
+X-API-Key: <ERP_VLM_API_KEY>
+Content-Type: application/json
+```
+
+```json
+{
+ "system_prompt": "You are a retail taxonomy classifier. Return valid JSON only.",
+ "prompt": "Classify flower item into JSON with keys: category, subcategory, flower_type, color, stem_length_cm, quantity, confidence. Input item: 'Роза Эквадор микс 70 см 25 шт'.",
+ "structured_output": true
+}
+```
+
+### Пример ответа
+
+```json
+{
+ "ok": true,
+ "store_id": null,
+ "response_text": "{\"category\":\"flowers\",\"subcategory\":\"rose\",\"flower_type\":\"ecuador_rose\",\"color\":\"mix\",\"stem_length_cm\":70,\"quantity\":25,\"confidence\":0.94}",
+ "response_json": {
+ "category": "flowers",
+ "subcategory": "rose",
+ "flower_type": "ecuador_rose",
+ "color": "mix",
+ "stem_length_cm": 70,
+ "quantity": 25,
+ "confidence": 0.94
+ },
+ "vlm_model": "google/gemma-3-12b-it",
+ "latency_ms": 615
+}
+```
+
+### Рекомендации для ERP
+
+- Формулируйте JSON schema прямо в prompt.
+- Требуйте `Return valid JSON only`.
+- Не отправляйте один giant prompt на тысячи товаров; лучше 1 товар = 1 запрос.
+- На своей стороне валидируйте `response_json` по ожидаемой схеме.
+- Если пришёл только `response_text`, пытайтесь fallback-парсить JSON локально и логируйте отклонения.
+
+## Transcribe Integration
+
+### Когда вызывать
+
+Вызывать после подготовки структурированного текста по смене или по отдельному диалогу.
+
+### Пример запроса для анализа смены
+
+```http
+POST /v1/llm-analyze
+X-API-Key: <TRANSCRIBE_VLM_API_KEY>
+Content-Type: application/json
+```
+
+```json
+{
+ "store_id": "shop01",
+ "system_prompt": "You analyze flower shop sales dialogs. Return valid JSON only.",
+ "prompt": "Analyze shift conversations. Return JSON with keys: has_sales, sales_count, dialogs_with_sales, objections, sentiment, manager_score, summary. Data: [{\"dialog_id\":\"d1\",\"timestamps\":[[0,12],[13,40]],\"transcript\":\"...\"},{\"dialog_id\":\"d2\",\"timestamps\":[[0,20]],\"transcript\":\"...\"}]",
+ "structured_output": true
+}
+```
+
+### Пример ответа
+
+```json
+{
+ "ok": true,
+ "store_id": "shop01",
+ "response_text": "{\"has_sales\":true,\"sales_count\":3,\"dialogs_with_sales\":[\"d1\",\"d4\",\"d7\"],\"objections\":[\"price\"],\"sentiment\":\"positive\",\"manager_score\":0.82,\"summary\":\"Three sales conversations identified during the shift.\"}",
+ "response_json": {
+ "has_sales": true,
+ "sales_count": 3,
+ "dialogs_with_sales": ["d1", "d4", "d7"],
+ "objections": ["price"],
+ "sentiment": "positive",
+ "manager_score": 0.82,
+ "summary": "Three sales conversations identified during the shift."
+ },
+ "vlm_model": "google/gemma-3-12b-it",
+ "latency_ms": 1094
+}
+```
+
+### Рекомендации для Transcribe
+
+- Передавайте уже очищенный и структурированный текст, а не сырой ASR-мусор.
+- Если payload большой, режьте по диалогам или по сменным блокам.
+- Всегда включайте список ключей, которые модель обязана вернуть.
+- Для аналитики смен лучше добавлять `dialog_id`, `speaker`, `timestamp`, чтобы потом связывать ответ с исходными данными.
+
+## Errors
+
+Типовые ответы:
+
+```json
+{
+ "detail": {
+ "type": "https://example.com/errors/invalid-api-key",
+ "title": "Invalid API key",
+ "status": 401
+ }
+}
+```
+
+```json
+{
+ "detail": {
+ "type": "https://example.com/errors/worker-unreachable",
+ "title": "Worker unreachable",
+ "status": 502,
+ "detail": "..."
+ }
+}
+```
+
+```json
+{
+ "ok": false,
+ "error": "All VLM backends exhausted. Last error: ...",
+ "code": "VLM_UNAVAILABLE"
+}
+```
+
+## Rollout Checklist
+
+1. Сгенерировать и записать ключи `TRANSCRIBE_VLM_API_KEY`, `ERP_VLM_API_KEY`.
+2. Настроить `CV_WORKER_INTERNAL_TOKEN` и `WORKER_INTERNAL_TOKEN`.
+3. Перезапустить coordinator и worker.
+4. Проверить smoke request в ERP.
+5. Проверить smoke request в Transcribe.
+6. Включить логирование входящих ошибок на стороне интегрируемых систем.
+
+## Smoke Examples
+
+### ERP smoke test
+
+```bash
+curl -X POST http://<coordinator-host>:8000/v1/llm-analyze \
+ -H 'Content-Type: application/json' \
+ -H 'X-API-Key: <ERP_VLM_API_KEY>' \
+ -d '{
+ "system_prompt": "Return valid JSON only.",
+ "prompt": "Classify item into JSON: category, subcategory. Item: Роза красная 60 см"
+ }'
+```
+
+### Transcribe smoke test
+
+```bash
+curl -X POST http://<coordinator-host>:8000/v1/llm-analyze \
+ -H 'Content-Type: application/json' \
+ -H 'X-API-Key: <TRANSCRIBE_VLM_API_KEY>' \
+ -d '{
+ "store_id": "shop01",
+ "system_prompt": "Return valid JSON only.",
+ "prompt": "Analyze transcript and return JSON with has_sales and summary."
+ }'
+```