From: fomichev Date: Fri, 17 Apr 2026 13:49:41 +0000 (+0300) Subject: feat(ERP-292): AutoMarkService — оркестратор авторазметки X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=93024a73f8e90160a1c891baf679deed9260155d;p=erp24_rep%2Fyii-erp24%2F.git feat(ERP-292): AutoMarkService — оркестратор авторазметки Co-Authored-By: Claude Sonnet 4.6 --- diff --git a/erp24/services/AutoMarkService.php b/erp24/services/AutoMarkService.php new file mode 100644 index 00000000..d1f9e502 --- /dev/null +++ b/erp24/services/AutoMarkService.php @@ -0,0 +1,158 @@ +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 index 00000000..fe7381ed --- /dev/null +++ b/erp24/tests/unit/services/AutoMarkServiceTest.php @@ -0,0 +1,39 @@ +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')); + } +}