]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
feat(ERP-292): AutoMarkService — оркестратор авторазметки
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Fri, 17 Apr 2026 13:49:41 +0000 (16:49 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Fri, 17 Apr 2026 13:49:41 +0000 (16:49 +0300)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
erp24/services/AutoMarkService.php [new file with mode: 0644]
erp24/tests/unit/services/AutoMarkServiceTest.php [new file with mode: 0644]

diff --git a/erp24/services/AutoMarkService.php b/erp24/services/AutoMarkService.php
new file mode 100644 (file)
index 0000000..d1f9e50
--- /dev/null
@@ -0,0 +1,158 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services;
+
+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();
+    }
+
+    /**
+     * Предсказать и сохранить разметку для одного товара.
+     * Если pending предсказание уже существует — вернуть его.
+     */
+    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
+    {
+        $ruleResult = $this->ruleParser->parse($name);
+        if ($ruleResult !== null && $ruleResult->confidence >= self::RULE_THRESHOLD) {
+            return $ruleResult;
+        }
+
+        $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();
+    }
+}
diff --git a/erp24/tests/unit/services/AutoMarkServiceTest.php b/erp24/tests/unit/services/AutoMarkServiceTest.php
new file mode 100644 (file)
index 0000000..fe7381e
--- /dev/null
@@ -0,0 +1,39 @@
+<?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'));
+    }
+}