]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
feat(ERP-292): RuleBasedParser — словарный парсинг имени товара
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Fri, 17 Apr 2026 13:37:54 +0000 (16:37 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Fri, 17 Apr 2026 13:37:54 +0000 (16:37 +0300)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
erp24/services/automark/RuleBasedParser.php [new file with mode: 0644]
erp24/tests/unit/services/automark/RuleBasedParserTest.php [new file with mode: 0644]

diff --git a/erp24/services/automark/RuleBasedParser.php b/erp24/services/automark/RuleBasedParser.php
new file mode 100644 (file)
index 0000000..2b4d08f
--- /dev/null
@@ -0,0 +1,141 @@
+<?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;
+        }
+
+        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;
+        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);
+    }
+}
diff --git a/erp24/tests/unit/services/automark/RuleBasedParserTest.php b/erp24/tests/unit/services/automark/RuleBasedParserTest.php
new file mode 100644 (file)
index 0000000..a4ca346
--- /dev/null
@@ -0,0 +1,76 @@
+<?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.7, $result->confidence);
+    }
+
+    public function testParsesPackaging(): void
+    {
+        $result = $this->parser->parse('Пакет целлофановый 60х80');
+
+        $this->assertSame('Упаковка', $result->category);
+        $this->assertGreaterThanOrEqual(0.7, $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);
+    }
+}