--- /dev/null
+<?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();
+ }
+}
--- /dev/null
+<?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'));
+ }
+}