From 34ee7a4740e3920985781a78df68b4617195af66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Fri, 21 Aug 2026 17:00:34 +0300 Subject: [PATCH 01/23] [ADD] ai_sale_product_matcher: find best-matched PIM products from requirement docs/images using AI --- .ruff.toml | 4 + ai_sale_product_matcher/README.rst | 90 + ai_sale_product_matcher/__init__.py | 3 + ai_sale_product_matcher/__manifest__.py | 38 + .../data/catalog_schema.json | 2070 +++++++++++++++++ ai_sale_product_matcher/models/__init__.py | 2 + .../models/ai_product_match.py | 80 + ai_sale_product_matcher/models/sale_order.py | 303 +++ ai_sale_product_matcher/pyproject.toml | 3 + ai_sale_product_matcher/readme/CONTEXT.md | 1 + .../readme/CONTRIBUTORS.md | 2 + ai_sale_product_matcher/readme/DESCRIPTION.md | 5 + ai_sale_product_matcher/readme/USAGE.md | 5 + .../security/ir.model.access.csv | 5 + ai_sale_product_matcher/services/__init__.py | 4 + .../services/ai_openai_compatible_client.py | 70 + .../services/catalog_schema.py | 78 + .../services/image_preprocessor.py | 89 + .../services/product_matcher.py | 219 ++ .../services/requirement_extractor.py | 170 ++ .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/description/index.html | 2 + .../static/src/sale_order_ai_matcher.js | 28 + ai_sale_product_matcher/tests/__init__.py | 2 + .../tests/test_product_matcher.py | 53 + .../tests/test_requirement_extractor.py | 35 + .../views/ai_product_match_views.xml | 51 + .../views/sale_order_views.xml | 30 + ai_sale_product_matcher/wizards/__init__.py | 1 + .../wizards/requirement_wizard.py | 109 + .../wizards/requirement_wizard_views.xml | 47 + 31 files changed, 3599 insertions(+) create mode 100644 ai_sale_product_matcher/README.rst create mode 100644 ai_sale_product_matcher/__init__.py create mode 100644 ai_sale_product_matcher/__manifest__.py create mode 100644 ai_sale_product_matcher/data/catalog_schema.json create mode 100644 ai_sale_product_matcher/models/__init__.py create mode 100644 ai_sale_product_matcher/models/ai_product_match.py create mode 100644 ai_sale_product_matcher/models/sale_order.py create mode 100644 ai_sale_product_matcher/pyproject.toml create mode 100644 ai_sale_product_matcher/readme/CONTEXT.md create mode 100644 ai_sale_product_matcher/readme/CONTRIBUTORS.md create mode 100644 ai_sale_product_matcher/readme/DESCRIPTION.md create mode 100644 ai_sale_product_matcher/readme/USAGE.md create mode 100644 ai_sale_product_matcher/security/ir.model.access.csv create mode 100644 ai_sale_product_matcher/services/__init__.py create mode 100644 ai_sale_product_matcher/services/ai_openai_compatible_client.py create mode 100644 ai_sale_product_matcher/services/catalog_schema.py create mode 100644 ai_sale_product_matcher/services/image_preprocessor.py create mode 100644 ai_sale_product_matcher/services/product_matcher.py create mode 100644 ai_sale_product_matcher/services/requirement_extractor.py create mode 100644 ai_sale_product_matcher/static/description/icon.png create mode 100644 ai_sale_product_matcher/static/description/index.html create mode 100644 ai_sale_product_matcher/static/src/sale_order_ai_matcher.js create mode 100644 ai_sale_product_matcher/tests/__init__.py create mode 100644 ai_sale_product_matcher/tests/test_product_matcher.py create mode 100644 ai_sale_product_matcher/tests/test_requirement_extractor.py create mode 100644 ai_sale_product_matcher/views/ai_product_match_views.xml create mode 100644 ai_sale_product_matcher/views/sale_order_views.xml create mode 100644 ai_sale_product_matcher/wizards/__init__.py create mode 100644 ai_sale_product_matcher/wizards/requirement_wizard.py create mode 100644 ai_sale_product_matcher/wizards/requirement_wizard_views.xml diff --git a/.ruff.toml b/.ruff.toml index 5e631282..5f9941c8 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -19,6 +19,10 @@ exclude = ["setup/*"] [lint.per-file-ignores] "__init__.py" = ["F401", "I001"] # ignore unused and unsorted imports in __init__.py "__manifest__.py" = ["B018"] # useless expression +"ai_sale_product_matcher/services/requirement_extractor.py" = ["C901", "E501"] +"ai_sale_product_matcher/services/product_matcher.py" = ["E501"] +"ai_sale_product_matcher/models/sale_order.py" = ["E501"] +"ai_sale_product_matcher/tests/*.py" = ["E501"] [lint.isort] section-order = ["future", "standard-library", "third-party", "odoo", "odoo-addons", "first-party", "local-folder"] diff --git a/ai_sale_product_matcher/README.rst b/ai_sale_product_matcher/README.rst new file mode 100644 index 00000000..151310e1 --- /dev/null +++ b/ai_sale_product_matcher/README.rst @@ -0,0 +1,90 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +======================== +AI Sale Product Matcher +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:placeholder + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fai-lightgray.png?logo=github + :target: https://github.com/OCA/ai/tree/19.0/ai_sale_product_matcher + :alt: OCA/ai +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/ai-19-0/ai-19-0-ai_sale_product_matcher + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Find best-matched PIM products from requirement documents/images using a vision LLM. See readme/DESCRIPTION.md for details. + +**Table of contents** + +.. contents:: + :local: + +Context +======= + +.. include:: readme/CONTEXT.md + +Description +=========== + +.. include:: readme/DESCRIPTION.md + +Usage +===== + +.. include:: readme/USAGE.md + +Changelog +========= + +19.0.1.0.0 (2026-08-21) +~~~~~~~~~~~~~~~~~~~~~~~ + +* [ADD] Initial implementation. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. + +Credits +======= + +Authors +------- + +* VSL + +Contributors +------------ + +* Volkan Taşcı + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org diff --git a/ai_sale_product_matcher/__init__.py b/ai_sale_product_matcher/__init__.py new file mode 100644 index 00000000..90315440 --- /dev/null +++ b/ai_sale_product_matcher/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import services +from . import wizards diff --git a/ai_sale_product_matcher/__manifest__.py b/ai_sale_product_matcher/__manifest__.py new file mode 100644 index 00000000..7ec14ff6 --- /dev/null +++ b/ai_sale_product_matcher/__manifest__.py @@ -0,0 +1,38 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "AI Sale Product Matcher", + "summary": "Find best-matched PIM products from requirement docs/images using AI", + "version": "19.0.1.0.0", + "category": "Sales/Sales", + "website": "https://github.com/OCA/ai", + "author": "VSL, Odoo Community Association (OCA)", + "license": "AGPL-3", + "development_status": "Alpha", + "application": False, + "installable": True, + "depends": [ + "sale", + "product", + "bus", + "queue_job", + "ai_connection", + "ai_tool", + ], + "external_dependencies": { + "python": ["Pillow", "pdf2image", "rapidfuzz", "requests"], + }, + "data": [ + "security/ir.model.access.csv", + "wizards/requirement_wizard_views.xml", + "views/sale_order_views.xml", + "views/ai_product_match_views.xml", + ], + "assets": { + "web.assets_backend": [ + "ai_sale_product_matcher/static/src/sale_order_ai_matcher.js", + ], + }, + "demo": [], +} diff --git a/ai_sale_product_matcher/data/catalog_schema.json b/ai_sale_product_matcher/data/catalog_schema.json new file mode 100644 index 00000000..b689e709 --- /dev/null +++ b/ai_sale_product_matcher/data/catalog_schema.json @@ -0,0 +1,2070 @@ +{ + "meta": { + "schema": "katalog-sablonu/1.0", + "kaynaklar": [ + "nilfisk-products.json", + "nilfisk-supurger.json", + "rottest-products.json", + "viper-products.json" + ], + "urun_sayisi": 163, + "ozellik_sayisi": 232, + "not": "Kanonik anahtarlar tüm dosyalarda aynı kavram için tektir. Set hiyerarşisi: common -> marka -> kategori; ürünler kendi kategori setine bağlıdır, üst setler miras alınır. Her özellik kendisini kullanan tüm ürünleri kapsayan EN ALT sete atanır (LCA): üç katalogda olanlar + iki markada birden kullanılanlar common'a, tek markanın birden çok kategorisinde olanlar marka setine, diğerleri kategori setine. Rottest Özellik matrisi feature_* boolean'larına dönüştürüldü (model adı eşleşmesinde VEYA mantığı)." + }, + "groups": [ + { + "key": "genel", + "label_tr": "Genel", + "sequence": 10 + }, + { + "key": "teknik", + "label_tr": "Teknik Değerler", + "sequence": 20 + }, + { + "key": "donanim", + "label_tr": "Donanım Özellikleri", + "sequence": 30 + }, + { + "key": "aksesuar", + "label_tr": "Standart Aksesuarlar", + "sequence": 40 + } + ], + "sets": [ + { + "key": "common", + "label_tr": "Ortak Özellikler (Tüm Kataloglar)", + "parent": null, + "brand": null + }, + { + "key": "brand_nilfisk", + "label_tr": "Nilfisk Ortak", + "parent": "common", + "brand": "Nilfisk" + }, + { + "key": "brand_viper", + "label_tr": "Viper Ortak", + "parent": "common", + "brand": "Viper" + }, + { + "key": "brand_rottest", + "label_tr": "Rottest Ortak", + "parent": "common", + "brand": "Rottest" + }, + { + "key": "grp_nilfisk_basinc", + "label_tr": "Basınçlı Yıkama (Soğuk/Sıcak Sulu)", + "parent": "brand_nilfisk", + "brand": "Nilfisk" + }, + { + "key": "grp_nilfisk_basincli_yikama_makineleri", + "label_tr": "Basınçlı Yıkama Makineleri", + "parent": "brand_nilfisk", + "brand": "Nilfisk" + }, + { + "key": "grp_nilfisk_cila_ve_parlatma_makineleri", + "label_tr": "Cila ve Parlatma Makineleri", + "parent": "brand_nilfisk", + "brand": "Nilfisk" + }, + { + "key": "grp_nilfisk_elektrikli_supurgeler", + "label_tr": "Elektrikli Süpürgeler", + "parent": "brand_nilfisk", + "brand": "Nilfisk" + }, + { + "key": "grp_nilfisk_endustriyel_vakum_makineleri", + "label_tr": "Endüstriyel Vakum Makineleri", + "parent": "brand_nilfisk", + "brand": "Nilfisk" + }, + { + "key": "grp_nilfisk_hali_temizleme_makineleri", + "label_tr": "Halı Temizleme Makineleri", + "parent": "brand_nilfisk", + "brand": "Nilfisk" + }, + { + "key": "grp_nilfisk_islak_kuru_tip_elektrikli_supurgeler", + "label_tr": "Islak Kuru Tip Elektrikli Süpürgeler", + "parent": "brand_nilfisk", + "brand": "Nilfisk" + }, + { + "key": "grp_nilfisk_islak_ve_kuru_supurgeler", + "label_tr": "Islak ve Kuru Süpürgeler", + "parent": "brand_nilfisk", + "brand": "Nilfisk" + }, + { + "key": "grp_nilfisk_kablosuz_supurgeler", + "label_tr": "Kablosuz Süpürgeler", + "parent": "brand_nilfisk", + "brand": "Nilfisk" + }, + { + "key": "grp_nilfisk_kuru_tip_elektrikli_supurgeler", + "label_tr": "Kuru Tip Elektrikli Süpürgeler", + "parent": "brand_nilfisk", + "brand": "Nilfisk" + }, + { + "key": "grp_nilfisk_profesyonel_supurgeler", + "label_tr": "Profesyonel Süpürgeler", + "parent": "brand_nilfisk", + "brand": "Nilfisk" + }, + { + "key": "grp_nilfisk_supuruculer", + "label_tr": "Süpürücüler", + "parent": "brand_nilfisk", + "brand": "Nilfisk" + }, + { + "key": "grp_nilfisk_zemin_otomatlari", + "label_tr": "Zemin Otomatları", + "parent": "brand_nilfisk", + "brand": "Nilfisk" + }, + { + "key": "grp_nilfisk_zemin_yikayicilar", + "label_tr": "Zemin Yıkayıcılar", + "parent": "brand_nilfisk", + "brand": "Nilfisk" + }, + { + "key": "grp_rottest_yikama", + "label_tr": "Yüksek Basınçlı Yıkama Makineleri", + "parent": "brand_rottest", + "brand": "Rottest" + }, + { + "key": "grp_viper_binicili_zemin_temizleme_makineleri", + "label_tr": "Binicili zemin temizleme makineleri", + "parent": "brand_viper", + "brand": "Viper" + }, + { + "key": "grp_viper_cila_parlatma_makinesi", + "label_tr": "Cila parlatma makinesi", + "parent": "brand_viper", + "brand": "Viper" + }, + { + "key": "grp_viper_hali_temizleyiciler", + "label_tr": "Halı temizleyiciler", + "parent": "brand_viper", + "brand": "Viper" + }, + { + "key": "grp_viper_i_ticili_zemin_temizleme_makineleri", + "label_tr": "İticili zemin temizleme makineleri", + "parent": "brand_viper", + "brand": "Viper" + }, + { + "key": "grp_viper_islak_kuru_tip_elektrikli_supurgeler", + "label_tr": "Islak kuru tip elektrikli süpürgeler", + "parent": "brand_viper", + "brand": "Viper" + }, + { + "key": "grp_viper_kuru_tip_elektrikli_supurgeler", + "label_tr": "Kuru tip elektrikli süpürgeler", + "parent": "brand_viper", + "brand": "Viper" + }, + { + "key": "grp_viper_kurutucular", + "label_tr": "Kurutucular", + "parent": "brand_viper", + "brand": "Viper" + }, + { + "key": "grp_viper_supuruculer", + "label_tr": "Süpürücüler", + "parent": "brand_viper", + "brand": "Viper" + }, + { + "key": "grp_viper_tek_diskli_makineler", + "label_tr": "Tek diskli makineler", + "parent": "brand_viper", + "brand": "Viper" + } + ], + "attributes": [ + { + "key": "acc_accessory_bag", + "label_tr": "Aks.: Aksesuar Çantası", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_acc_accessory_bag" + }, + { + "key": "acc_angled_nozzle", + "label_tr": "Aks.: Eğimli Başlık", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_islak_ve_kuru_supurgeler", + "field_name": "x_acc_angled_nozzle" + }, + { + "key": "acc_beacon", + "label_tr": "Aks.: Tepe Lambası", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_binicili_zemin_temizleme_makineleri", + "field_name": "x_acc_beacon" + }, + { + "key": "acc_brush", + "label_tr": "Aks.: Fırça", + "type": "char", + "group": "aksesuar", + "set": "brand_viper", + "field_name": "x_acc_brush" + }, + { + "key": "acc_brush_20inch", + "label_tr": "Aks.: 20\" Fırça", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_binicili_zemin_temizleme_makineleri", + "field_name": "x_acc_brush_20inch" + }, + { + "key": "acc_cable", + "label_tr": "Aks.: Kablo", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_acc_cable" + }, + { + "key": "acc_carpet_brush", + "label_tr": "Aks.: Halı Fırçası", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_tek_diskli_makineler", + "field_name": "x_acc_carpet_brush" + }, + { + "key": "acc_carpet_tool", + "label_tr": "Aks.: Halı Yıkama Aparatı", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_acc_carpet_tool" + }, + { + "key": "acc_combi_nozzle", + "label_tr": "Aks.: Combi Nozzle", + "type": "char", + "group": "aksesuar", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_acc_combi_nozzle" + }, + { + "key": "acc_combination_floor_brush", + "label_tr": "Aks.: Kombine Yer Fırçası", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_islak_ve_kuru_supurgeler", + "field_name": "x_acc_combination_floor_brush" + }, + { + "key": "acc_corner_tool", + "label_tr": "Aks.: Köşe Aparatı", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_kuru_tip_elektrikli_supurgeler", + "field_name": "x_acc_corner_tool" + }, + { + "key": "acc_crevice_tool", + "label_tr": "Aks.: Dar Ağızlı Uç", + "type": "char", + "group": "aksesuar", + "set": "brand_viper", + "field_name": "x_acc_crevice_tool" + }, + { + "key": "acc_debris_strainer", + "label_tr": "Aks.: Çöp Süzgeci", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_binicili_zemin_temizleme_makineleri", + "field_name": "x_acc_debris_strainer" + }, + { + "key": "acc_disc_brush", + "label_tr": "Aks.: Disk Fırça", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_i_ticili_zemin_temizleme_makineleri", + "field_name": "x_acc_disc_brush" + }, + { + "key": "acc_dry_nozzle", + "label_tr": "Aks.: Kuru Nozul", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_islak_kuru_tip_elektrikli_supurgeler", + "field_name": "x_acc_dry_nozzle" + }, + { + "key": "acc_dust_filter", + "label_tr": "Aks.: Toz Filtresi", + "type": "char", + "group": "aksesuar", + "set": "brand_viper", + "field_name": "x_acc_dust_filter" + }, + { + "key": "acc_filter", + "label_tr": "Aks.: Filtre", + "type": "char", + "group": "aksesuar", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_acc_filter" + }, + { + "key": "acc_filter_kit", + "label_tr": "Aks.: Filtre Kiti", + "type": "integer", + "group": "aksesuar", + "set": "grp_viper_supuruculer", + "field_name": "x_acc_filter_kit" + }, + { + "key": "acc_floor_brush", + "label_tr": "Aks.: Yer Fırçası", + "type": "integer", + "group": "aksesuar", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_acc_floor_brush" + }, + { + "key": "acc_floor_tool", + "label_tr": "Aks.: Döşeme Aparatı", + "type": "char", + "group": "aksesuar", + "set": "brand_viper", + "field_name": "x_acc_floor_tool" + }, + { + "key": "acc_front_squeegee", + "label_tr": "Aks.: Ön Silecek", + "type": "char", + "group": "aksesuar", + "set": "brand_viper", + "field_name": "x_acc_front_squeegee" + }, + { + "key": "acc_hand_tool_kit", + "label_tr": "Aks.: El Aleti Kiti", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_acc_hand_tool_kit" + }, + { + "key": "acc_hepa_filter", + "label_tr": "Aks.: HEPA Filtre", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_kuru_tip_elektrikli_supurgeler", + "field_name": "x_acc_hepa_filter" + }, + { + "key": "acc_hose_kit", + "label_tr": "Aks.: Hortum Kiti", + "type": "char", + "group": "aksesuar", + "set": "brand_viper", + "field_name": "x_acc_hose_kit" + }, + { + "key": "acc_large_turbo_brush", + "label_tr": "Aks.: Büyük Turbo Fırça", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_acc_large_turbo_brush" + }, + { + "key": "acc_main_brush", + "label_tr": "Aks.: Ana Fırça", + "type": "char", + "group": "aksesuar", + "set": "brand_viper", + "field_name": "x_acc_main_brush" + }, + { + "key": "acc_mini_turbo_brush", + "label_tr": "Aks.: Mini Turbo Fırça", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_acc_mini_turbo_brush" + }, + { + "key": "acc_mini_turbo_nozzle", + "label_tr": "Aks.: Mini Turbo Nozzle", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_acc_mini_turbo_nozzle" + }, + { + "key": "acc_pad_holder", + "label_tr": "Aks.: Ped Tutucu", + "type": "char", + "group": "aksesuar", + "set": "brand_viper", + "field_name": "x_acc_pad_holder" + }, + { + "key": "acc_parquet_nozzle", + "label_tr": "Aks.: Parke/Sert Zemin Nozzle", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_acc_parquet_nozzle" + }, + { + "key": "acc_rear_squeegee", + "label_tr": "Aks.: Arka Silecek", + "type": "char", + "group": "aksesuar", + "set": "brand_viper", + "field_name": "x_acc_rear_squeegee" + }, + { + "key": "acc_round_brush_nozzle", + "label_tr": "Aks.: Yuvarlak Fırçalı Nozul", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_islak_kuru_tip_elektrikli_supurgeler", + "field_name": "x_acc_round_brush_nozzle" + }, + { + "key": "acc_side_brush", + "label_tr": "Aks.: Yan Fırça", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_supuruculer", + "field_name": "x_acc_side_brush" + }, + { + "key": "acc_side_squeegees", + "label_tr": "Aks.: Yan Silecekler", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_binicili_zemin_temizleme_makineleri", + "field_name": "x_acc_side_squeegees" + }, + { + "key": "acc_splash_guard", + "label_tr": "Aks.: Su Sıçratma Aparatı", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_i_ticili_zemin_temizleme_makineleri", + "field_name": "x_acc_splash_guard" + }, + { + "key": "acc_squeegee_set", + "label_tr": "Aks.: Silecek Seti", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_supuruculer", + "field_name": "x_acc_squeegee_set" + }, + { + "key": "acc_suction_hose", + "label_tr": "Aks.: Emiş Hortumu", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_kuru_tip_elektrikli_supurgeler", + "field_name": "x_acc_suction_hose" + }, + { + "key": "acc_tank_kit", + "label_tr": "Aks.: Tank Kiti", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_tek_diskli_makineler", + "field_name": "x_acc_tank_kit" + }, + { + "key": "acc_tube_kit", + "label_tr": "Aks.: Boru Kiti", + "type": "char", + "group": "aksesuar", + "set": "brand_viper", + "field_name": "x_acc_tube_kit" + }, + { + "key": "acc_upholstery_brush", + "label_tr": "Aks.: Kenar/Mobilya Fırçası", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_acc_upholstery_brush" + }, + { + "key": "acc_vacuum_nozzle", + "label_tr": "Aks.: Vakum Ağzı", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_acc_vacuum_nozzle" + }, + { + "key": "acc_warning_light_kit", + "label_tr": "Aks.: İkaz Lambası Kiti", + "type": "integer", + "group": "aksesuar", + "set": "grp_viper_supuruculer", + "field_name": "x_acc_warning_light_kit" + }, + { + "key": "acc_water_spray_kit", + "label_tr": "Aks.: Su Püskürtme Kiti", + "type": "integer", + "group": "aksesuar", + "set": "grp_viper_supuruculer", + "field_name": "x_acc_water_spray_kit" + }, + { + "key": "acc_wet_nozzle", + "label_tr": "Aks.: Islak Nozul", + "type": "char", + "group": "aksesuar", + "set": "grp_viper_islak_kuru_tip_elektrikli_supurgeler", + "field_name": "x_acc_wet_nozzle" + }, + { + "key": "accessory_park_area", + "label_tr": "Aksesuar Park Alanı", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_islak_ve_kuru_supurgeler", + "field_name": "x_accessory_park_area" + }, + { + "key": "adjustable_brush_pressure", + "label_tr": "Ayarlanabilir Fırça Basıncı", + "type": "boolean", + "group": "donanim", + "set": "brand_viper", + "field_name": "x_adjustable_brush_pressure" + }, + { + "key": "adjustable_handle", + "label_tr": "Ayarlanabilir Sap", + "type": "boolean", + "group": "donanim", + "set": "brand_viper", + "field_name": "x_adjustable_handle" + }, + { + "key": "air_flow_l_min", + "label_tr": "Hava Debisi (L/dk)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_air_flow_l_min" + }, + { + "key": "air_flow_l_s", + "label_tr": "Hava Debisi (L/sn)", + "type": "char", + "group": "teknik", + "set": "brand_nilfisk", + "field_name": "x_air_flow_l_s" + }, + { + "key": "air_flow_max", + "label_tr": "Hava Debisi (maks)", + "type": "char", + "group": "teknik", + "set": "grp_viper_kurutucular", + "field_name": "x_air_flow_max" + }, + { + "key": "auto_start_stop", + "label_tr": "Otomatik Başlatma/Durdurma", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basincli_yikama_makineleri", + "field_name": "x_auto_start_stop" + }, + { + "key": "automatic_brush_change", + "label_tr": "Otomatik Fırça Tak-Çıkar", + "type": "boolean", + "group": "donanim", + "set": "brand_viper", + "field_name": "x_automatic_brush_change" + }, + { + "key": "battery_charger", + "label_tr": "Akü Şarj Aleti", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_binicili_zemin_temizleme_makineleri", + "field_name": "x_battery_charger" + }, + { + "key": "battery_compartment_mm", + "label_tr": "Akü Bölmesi (mm)", + "type": "char", + "group": "teknik", + "set": "brand_viper", + "field_name": "x_battery_compartment_mm" + }, + { + "key": "battery_type", + "label_tr": "Akü Tipi", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_battery_type" + }, + { + "key": "battery_version", + "label_tr": "Akülü Versiyon", + "type": "boolean", + "group": "donanim", + "set": "brand_viper", + "field_name": "x_battery_version" + }, + { + "key": "bendable_drain_hose", + "label_tr": "Bükülebilir Tahliye Hortumu", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_i_ticili_zemin_temizleme_makineleri", + "field_name": "x_bendable_drain_hose" + }, + { + "key": "blowing_function", + "label_tr": "Üfleme Özelliği", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_islak_ve_kuru_supurgeler", + "field_name": "x_blowing_function" + }, + { + "key": "body_material", + "label_tr": "Gövde Malzemesi", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_body_material" + }, + { + "key": "brush_diameter_mm", + "label_tr": "Fırça Çapı (mm)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_brush_diameter_mm" + }, + { + "key": "brush_motor_power_w", + "label_tr": "Fırça Motoru (W)", + "type": "char", + "group": "teknik", + "set": "brand_viper", + "field_name": "x_brush_motor_power_w" + }, + { + "key": "brush_pad_pressure_g_cm2", + "label_tr": "Fırça/Ped Basıncı (g/cm²)", + "type": "integer", + "group": "teknik", + "set": "grp_nilfisk_cila_ve_parlatma_makineleri", + "field_name": "x_brush_pad_pressure_g_cm2" + }, + { + "key": "brush_pressure_kg", + "label_tr": "Fırça Basıncı (kg)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_brush_pressure_kg" + }, + { + "key": "brush_pressure_rpm_kg", + "label_tr": "Fırça Basıncı (rpm/kg)", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_zemin_otomatlari", + "field_name": "x_brush_pressure_rpm_kg" + }, + { + "key": "brush_speed_rpm", + "label_tr": "Fırça Devri (rpm)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_brush_speed_rpm" + }, + { + "key": "brush_torque_nm", + "label_tr": "Fırça Torku (Nm)", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_cila_ve_parlatma_makineleri", + "field_name": "x_brush_torque_nm" + }, + { + "key": "built_in_accessories", + "label_tr": "Dahili Aksesuarlar", + "type": "char", + "group": "genel", + "set": "grp_nilfisk_kablosuz_supurgeler", + "field_name": "x_built_in_accessories" + }, + { + "key": "built_in_accessory_count", + "label_tr": "Dahili Aksesuar Sayısı", + "type": "integer", + "group": "teknik", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_built_in_accessory_count" + }, + { + "key": "built_in_cable", + "label_tr": "Dahili Kablo", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_built_in_cable" + }, + { + "key": "cable_length_m", + "label_tr": "Kablo Uzunluğu (m)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_cable_length_m" + }, + { + "key": "cable_rewind", + "label_tr": "Kablo Sarıcı", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_cable_rewind" + }, + { + "key": "cable_winder", + "label_tr": "Kablo Sarmalı", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_kurutucular", + "field_name": "x_cable_winder" + }, + { + "key": "car_wash_kit", + "label_tr": "Araç Yıkama Kiti", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basincli_yikama_makineleri", + "field_name": "x_car_wash_kit" + }, + { + "key": "carpet_performance", + "label_tr": "Halı Performansı", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_carpet_performance" + }, + { + "key": "carry_handle", + "label_tr": "Taşıma Kolu", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_kurutucular", + "field_name": "x_carry_handle" + }, + { + "key": "ceramic_coated_piston", + "label_tr": "Seramik Kaplama Piston", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basinc", + "field_name": "x_ceramic_coated_piston" + }, + { + "key": "certificates", + "label_tr": "Sertifikalar", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_certificates" + }, + { + "key": "charge_indicator", + "label_tr": "Şarj Göstergesi", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_kablosuz_supurgeler", + "field_name": "x_charge_indicator" + }, + { + "key": "clean_dirty_tanks_l", + "label_tr": "Temiz/Kirli Su Tankları (L)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_clean_dirty_tanks_l" + }, + { + "key": "clean_water_filter", + "label_tr": "Temiz Su Filtresi", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_clean_water_filter" + }, + { + "key": "clean_water_pump_pressure_bar", + "label_tr": "Temiz Su Pompa Basıncı (bar)", + "type": "integer", + "group": "teknik", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_clean_water_pump_pressure_bar" + }, + { + "key": "clean_water_tank_l", + "label_tr": "Temiz Su Tankı (L)", + "type": "float", + "group": "teknik", + "set": "common", + "field_name": "x_clean_water_tank_l" + }, + { + "key": "cleaning_area_per_charge_m2", + "label_tr": "Şarj Başına Alan (m²)", + "type": "integer", + "group": "teknik", + "set": "grp_nilfisk_zemin_yikayicilar", + "field_name": "x_cleaning_area_per_charge_m2" + }, + { + "key": "cleaning_width_2_brushes_mm", + "label_tr": "Temizleme Genişliği, 2 Fırça (mm)", + "type": "integer", + "group": "teknik", + "set": "common", + "field_name": "x_cleaning_width_2_brushes_mm" + }, + { + "key": "cleaning_width_mm", + "label_tr": "Temizleme Genişliği (mm)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_cleaning_width_mm" + }, + { + "key": "cleaning_width_side_brush_mm", + "label_tr": "Temizleme Genişliği, Yan Fırça (mm)", + "type": "integer", + "group": "teknik", + "set": "common", + "field_name": "x_cleaning_width_side_brush_mm" + }, + { + "key": "color", + "label_tr": "Renk", + "type": "char", + "group": "genel", + "set": "common", + "field_name": "x_color" + }, + { + "key": "compact_design", + "label_tr": "Kompakt Tasarım", + "type": "boolean", + "group": "donanim", + "set": "brand_viper", + "field_name": "x_compact_design" + }, + { + "key": "connection_type", + "label_tr": "Bağlantı Tipi", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_basincli_yikama_makineleri", + "field_name": "x_connection_type" + }, + { + "key": "container_capacity_kg", + "label_tr": "Konteyner Kapasitesi (kg)", + "type": "float", + "group": "teknik", + "set": "grp_nilfisk_profesyonel_supurgeler", + "field_name": "x_container_capacity_kg" + }, + { + "key": "corded_version", + "label_tr": "Kablolu Versiyon", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_i_ticili_zemin_temizleme_makineleri", + "field_name": "x_corded_version" + }, + { + "key": "cyclonic_filter", + "label_tr": "Siklonik Filtre", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_cyclonic_filter" + }, + { + "key": "debris_strainer", + "label_tr": "Çöp Süzgeci", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_binicili_zemin_temizleme_makineleri", + "field_name": "x_debris_strainer" + }, + { + "key": "depth_cm", + "label_tr": "Derinlik (cm)", + "type": "integer", + "group": "teknik", + "set": "grp_rottest_yikama", + "field_name": "x_depth_cm" + }, + { + "key": "description", + "label_tr": "Açıklama", + "type": "char", + "group": "genel", + "set": "common", + "field_name": "x_description" + }, + { + "key": "design", + "label_tr": "Tasarım", + "type": "char", + "group": "teknik", + "set": "brand_nilfisk", + "field_name": "x_design" + }, + { + "key": "detachable_tanks", + "label_tr": "Ayrılabilir Hazneler", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_detachable_tanks" + }, + { + "key": "dimensions_cm", + "label_tr": "Boyutlar (cm)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_dimensions_cm" + }, + { + "key": "dimensions_mm", + "label_tr": "Boyutlar (mm)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_dimensions_mm" + }, + { + "key": "dirty_water_drain_hoses", + "label_tr": "Kirli Su Tahliye Hortumları", + "type": "boolean", + "group": "donanim", + "set": "brand_viper", + "field_name": "x_dirty_water_drain_hoses" + }, + { + "key": "dirty_water_tank_l", + "label_tr": "Kirli Su Tankı (L)", + "type": "float", + "group": "teknik", + "set": "common", + "field_name": "x_dirty_water_tank_l" + }, + { + "key": "disc_brush_model", + "label_tr": "Disk Fırçalı Model", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_binicili_zemin_temizleme_makineleri", + "field_name": "x_disc_brush_model" + }, + { + "key": "drain_hose", + "label_tr": "Tahliye Hortumu", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_drain_hose" + }, + { + "key": "dual_disc_brush_platform", + "label_tr": "Disk Fırça Platformu (2 Fırça)", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_binicili_zemin_temizleme_makineleri", + "field_name": "x_dual_disc_brush_platform" + }, + { + "key": "dust_bag_capacity_l", + "label_tr": "Toz Torbası Kapasitesi (L)", + "type": "char", + "group": "teknik", + "set": "brand_nilfisk", + "field_name": "x_dust_bag_capacity_l" + }, + { + "key": "dust_bin_capacity_l", + "label_tr": "Toz Haznesi Kapasitesi (L)", + "type": "integer", + "group": "teknik", + "set": "grp_nilfisk_zemin_otomatlari", + "field_name": "x_dust_bin_capacity_l" + }, + { + "key": "dust_class", + "label_tr": "Toz Sınıfı", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_profesyonel_supurgeler", + "field_name": "x_dust_class" + }, + { + "key": "ean", + "label_tr": "EAN Numarası", + "type": "integer", + "group": "genel", + "set": "common", + "field_name": "x_ean" + }, + { + "key": "easy_carry_handle", + "label_tr": "Kolay Taşıma Sapı", + "type": "boolean", + "group": "donanim", + "set": "brand_viper", + "field_name": "x_easy_carry_handle" + }, + { + "key": "easy_control_panel", + "label_tr": "Kolay Kontrol Paneli", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_easy_control_panel" + }, + { + "key": "easy_empty_dust_bin", + "label_tr": "Kolay Boşaltılan Toz Haznesi", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_easy_empty_dust_bin" + }, + { + "key": "easy_maintenance", + "label_tr": "Kolay Bakım", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_zemin_yikayicilar", + "field_name": "x_easy_maintenance" + }, + { + "key": "easy_motor_access", + "label_tr": "Motor Alanına Kolay Ulaşım", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basinc", + "field_name": "x_easy_motor_access" + }, + { + "key": "easy_transport_wheel", + "label_tr": "Kolay Taşıma Tekerleği", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_kablosuz_supurgeler", + "field_name": "x_easy_transport_wheel" + }, + { + "key": "eco_mode", + "label_tr": "Eko Mod", + "type": "boolean", + "group": "donanim", + "set": "brand_viper", + "field_name": "x_eco_mode" + }, + { + "key": "electric_filter_shaker", + "label_tr": "Elektrikli Filtre Temizleyici", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_supuruculer", + "field_name": "x_electric_filter_shaker" + }, + { + "key": "electricity_consumption_kwh", + "label_tr": "Elektrik Sarfiyatı (kW/sa)", + "type": "char", + "group": "teknik", + "set": "grp_rottest_yikama", + "field_name": "x_electricity_consumption_kwh" + }, + { + "key": "energy_class", + "label_tr": "Enerji Sınıfı", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_energy_class" + }, + { + "key": "equipment_features", + "label_tr": "Donanım Özellikleri", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_zemin_yikayicilar", + "field_name": "x_equipment_features" + }, + { + "key": "ergonomic_handle", + "label_tr": "Ergonomik Sap", + "type": "boolean", + "group": "donanim", + "set": "common", + "field_name": "x_ergonomic_handle" + }, + { + "key": "extra_features", + "label_tr": "Ekstra Özellikler", + "type": "char", + "group": "genel", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_extra_features" + }, + { + "key": "filter_advantage", + "label_tr": "Filtre Avantajı", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_filter_advantage" + }, + { + "key": "filter_area", + "label_tr": "Filtre Alanı", + "type": "char", + "group": "teknik", + "set": "brand_nilfisk", + "field_name": "x_filter_area" + }, + { + "key": "filter_brush_maintenance", + "label_tr": "Filtre/Fırça Bakımı", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_zemin_yikayicilar", + "field_name": "x_filter_brush_maintenance" + }, + { + "key": "filter_detail", + "label_tr": "Filtre Detayı", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_filter_detail" + }, + { + "key": "filter_indicator", + "label_tr": "Filtre Göstergesi", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_islak_ve_kuru_supurgeler", + "field_name": "x_filter_indicator" + }, + { + "key": "filter_technology", + "label_tr": "Filtre Teknolojisi", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_filter_technology" + }, + { + "key": "floor_detection", + "label_tr": "Zemin Tanıma Özelliği", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_floor_detection" + }, + { + "key": "flow_activated_fa", + "label_tr": "Su Akışı Aktivasyonlu FA", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basinc", + "field_name": "x_flow_activated_fa" + }, + { + "key": "flow_rate_l_h", + "label_tr": "Debi (L/saat)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_flow_rate_l_h" + }, + { + "key": "frequency_hz", + "label_tr": "Frekans (Hz)", + "type": "char", + "group": "teknik", + "set": "brand_viper", + "field_name": "x_frequency_hz" + }, + { + "key": "front_side_rear_squeegees", + "label_tr": "Ön/Yan/Arka Silecek", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_supuruculer", + "field_name": "x_front_side_rear_squeegees" + }, + { + "key": "fuel_consumption_kg_h", + "label_tr": "Yakıt Sarfiyatı (kg/sa)", + "type": "float", + "group": "teknik", + "set": "grp_rottest_yikama", + "field_name": "x_fuel_consumption_kg_h" + }, + { + "key": "fuel_tank_l", + "label_tr": "Yakıt Tankı (L)", + "type": "integer", + "group": "teknik", + "set": "common", + "field_name": "x_fuel_tank_l" + }, + { + "key": "full_bag_filter_indicator", + "label_tr": "Torba/Filtre Dolu Göstergesi", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_full_bag_filter_indicator" + }, + { + "key": "gross_weight_kg", + "label_tr": "Brüt Ağırlık (kg)", + "type": "float", + "group": "teknik", + "set": "grp_viper_i_ticili_zemin_temizleme_makineleri", + "field_name": "x_gross_weight_kg" + }, + { + "key": "handle_control", + "label_tr": "Koldan Kumanda", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_handle_control" + }, + { + "key": "handle_vibration_m_s2", + "label_tr": "Sap Titreşimi (m/s²)", + "type": "char", + "group": "teknik", + "set": "brand_viper", + "field_name": "x_handle_vibration_m_s2" + }, + { + "key": "hard_floor_drying_time", + "label_tr": "Sert Zemin Kuruma Süresi", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_zemin_yikayicilar", + "field_name": "x_hard_floor_drying_time" + }, + { + "key": "has_10m_steel_hose", + "label_tr": "10 m Basınç Hortumu (Çelik Telli)", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basinc", + "field_name": "x_has_10m_steel_hose" + }, + { + "key": "has_detergent_tank", + "label_tr": "Deterjan Bidonu", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basinc", + "field_name": "x_has_detergent_tank" + }, + { + "key": "heating_stages", + "label_tr": "Isıtma Kademesi", + "type": "integer", + "group": "teknik", + "set": "grp_rottest_yikama", + "field_name": "x_heating_stages" + }, + { + "key": "height_cm", + "label_tr": "Yükseklik (cm)", + "type": "integer", + "group": "teknik", + "set": "grp_rottest_yikama", + "field_name": "x_height_cm" + }, + { + "key": "hidden_accessory_area", + "label_tr": "Gizli Aksesuar Alanı", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_kablosuz_supurgeler", + "field_name": "x_hidden_accessory_area" + }, + { + "key": "hidden_accessory_compartment", + "label_tr": "Gizli Aksesuar Bölmesi", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_hidden_accessory_compartment" + }, + { + "key": "hose_length_m", + "label_tr": "Hortum Uzunluğu (m)", + "type": "integer", + "group": "teknik", + "set": "grp_nilfisk_basincli_yikama_makineleri", + "field_name": "x_hose_length_m" + }, + { + "key": "hose_rewind", + "label_tr": "Hortum Sarma Aparatı", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_hose_rewind" + }, + { + "key": "hour_meter", + "label_tr": "Saat Sayacı", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_i_ticili_zemin_temizleme_makineleri", + "field_name": "x_hour_meter" + }, + { + "key": "is_new", + "label_tr": "Yeni Ürün", + "type": "boolean", + "group": "genel", + "set": "common", + "field_name": "x_is_new" + }, + { + "key": "lcd_display", + "label_tr": "LCD Ekran", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_binicili_zemin_temizleme_makineleri", + "field_name": "x_lcd_display" + }, + { + "key": "led_lighting", + "label_tr": "LED Aydınlatma", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_kablosuz_supurgeler", + "field_name": "x_led_lighting" + }, + { + "key": "low_noise_operation", + "label_tr": "Düşük Çalışma Sesi", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_kuru_tip_elektrikli_supurgeler", + "field_name": "x_low_noise_operation" + }, + { + "key": "machine_weight_kg", + "label_tr": "Makine Ağırlığı (kg)", + "type": "char", + "group": "teknik", + "set": "grp_viper_i_ticili_zemin_temizleme_makineleri", + "field_name": "x_machine_weight_kg" + }, + { + "key": "main_roller_brush", + "label_tr": "Ana Rulo Fırça", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_supuruculer", + "field_name": "x_main_roller_brush" + }, + { + "key": "max_climb_percent", + "label_tr": "Maks. Tırmanma Eğimi (%)", + "type": "char", + "group": "teknik", + "set": "brand_viper", + "field_name": "x_max_climb_percent" + }, + { + "key": "max_dump_height_cm", + "label_tr": "Maks. Boşaltma Yüksekliği (cm)", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_supuruculer", + "field_name": "x_max_dump_height_cm" + }, + { + "key": "max_load_kg", + "label_tr": "Maksimum Yük (kg)", + "type": "char", + "group": "teknik", + "set": "brand_viper", + "field_name": "x_max_load_kg" + }, + { + "key": "measured_current_a", + "label_tr": "Ölçülen Akım (A)", + "type": "char", + "group": "teknik", + "set": "grp_viper_kurutucular", + "field_name": "x_measured_current_a" + }, + { + "key": "metal_pump", + "label_tr": "Metal Pompa", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basincli_yikama_makineleri", + "field_name": "x_metal_pump" + }, + { + "key": "min_turning_diameter", + "label_tr": "Minimum Dönüş Çapı", + "type": "integer", + "group": "teknik", + "set": "brand_viper", + "field_name": "x_min_turning_diameter" + }, + { + "key": "model", + "label_tr": "Model", + "type": "char", + "group": "genel", + "set": "common", + "field_name": "x_model" + }, + { + "key": "motor_power_hp", + "label_tr": "Motor Gücü (HP)", + "type": "float", + "group": "teknik", + "set": "grp_rottest_yikama", + "field_name": "x_motor_power_hp" + }, + { + "key": "motor_power_kw", + "label_tr": "Motor Gücü (kW)", + "type": "char", + "group": "teknik", + "set": "brand_nilfisk", + "field_name": "x_motor_power_kw" + }, + { + "key": "motor_power_w", + "label_tr": "Motor Gücü (W)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_motor_power_w" + }, + { + "key": "motor_rpm", + "label_tr": "Motor (rpm)", + "type": "integer", + "group": "teknik", + "set": "grp_nilfisk_basinc", + "field_name": "x_motor_rpm" + }, + { + "key": "net_weight_kg", + "label_tr": "Net Ağırlık (kg)", + "type": "char", + "group": "teknik", + "set": "brand_viper", + "field_name": "x_net_weight_kg" + }, + { + "key": "net_weight_without_batteries_kg", + "label_tr": "Net Ağırlık, Aküler Hariç (kg)", + "type": "integer", + "group": "teknik", + "set": "grp_viper_supuruculer", + "field_name": "x_net_weight_without_batteries_kg" + }, + { + "key": "noise_db", + "label_tr": "Ses Seviyesi (dB)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_noise_db" + }, + { + "key": "noise_db_1_5m", + "label_tr": "1,5 m'de Gürültü (dBA)", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_cila_ve_parlatma_makineleri", + "field_name": "x_noise_db_1_5m" + }, + { + "key": "on_off_button", + "label_tr": "Açma/Kapatma Düğmesi", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_on_off_button" + }, + { + "key": "one_touch_button", + "label_tr": "One-Touch Tuşu", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_i_ticili_zemin_temizleme_makineleri", + "field_name": "x_one_touch_button" + }, + { + "key": "operating_temperature_c", + "label_tr": "Çalışma Sıcaklığı (°C)", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_zemin_yikayicilar", + "field_name": "x_operating_temperature_c" + }, + { + "key": "package_dimensions_mm", + "label_tr": "Paket Boyutları (mm)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_package_dimensions_mm" + }, + { + "key": "pad_holder_included", + "label_tr": "Ped Tutucusu Dahil", + "type": "boolean", + "group": "donanim", + "set": "brand_viper", + "field_name": "x_pad_holder_included" + }, + { + "key": "park_position", + "label_tr": "Park Pozisyonu", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_park_position" + }, + { + "key": "phase", + "label_tr": "Faz", + "type": "select", + "group": "teknik", + "set": "grp_nilfisk_basinc", + "field_name": "x_phase" + }, + { + "key": "polyester_dust_bag", + "label_tr": "Polyester Toz Torbası", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_islak_ve_kuru_supurgeler", + "field_name": "x_polyester_dust_bag" + }, + { + "key": "power_socket_input", + "label_tr": "Priz Girişi", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_islak_ve_kuru_supurgeler", + "field_name": "x_power_socket_input" + }, + { + "key": "power_source", + "label_tr": "Güç Kaynağı", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_supuruculer", + "field_name": "x_power_source" + }, + { + "key": "practical_carry_handle", + "label_tr": "Pratik Taşıma Sapı", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_practical_carry_handle" + }, + { + "key": "premium_car_wash_kit", + "label_tr": "Premium Araç Yıkama Kiti", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basincli_yikama_makineleri", + "field_name": "x_premium_car_wash_kit" + }, + { + "key": "pressure_activated_pa", + "label_tr": "Basınç Aktivasyonlu PA", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basinc", + "field_name": "x_pressure_activated_pa" + }, + { + "key": "pressure_bar", + "label_tr": "Basınç (bar)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_pressure_bar" + }, + { + "key": "pressure_bar_psi", + "label_tr": "Basınç (bar/psi)", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_hali_temizleme_makineleri", + "field_name": "x_pressure_bar_psi" + }, + { + "key": "product_code", + "label_tr": "Ürün Kodu", + "type": "integer", + "group": "genel", + "set": "common", + "field_name": "x_product_code" + }, + { + "key": "productivity_m2_h", + "label_tr": "Verimlilik (m²/sa)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_productivity_m2_h" + }, + { + "key": "protection_class", + "label_tr": "Koruma Sınıfı", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_protection_class" + }, + { + "key": "pump", + "label_tr": "Pompa", + "type": "char", + "group": "teknik", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_pump" + }, + { + "key": "rear_wheel_drive", + "label_tr": "Arkadan Çekişli", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_binicili_zemin_temizleme_makineleri", + "field_name": "x_rear_wheel_drive" + }, + { + "key": "recommended_area_m2", + "label_tr": "Önerilen Alan (m²)", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_recommended_area_m2" + }, + { + "key": "runtime", + "label_tr": "Kullanım Süresi", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_kablosuz_supurgeler", + "field_name": "x_runtime" + }, + { + "key": "second_park_position", + "label_tr": "İkinci Park Alanı", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_second_park_position" + }, + { + "key": "shipping_weight_kg", + "label_tr": "Nakliye Ağırlığı (kg)", + "type": "char", + "group": "teknik", + "set": "brand_nilfisk", + "field_name": "x_shipping_weight_kg" + }, + { + "key": "side_brush", + "label_tr": "Yan Fırça", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_supuruculer", + "field_name": "x_side_brush" + }, + { + "key": "silent_mode", + "label_tr": "Sessiz Mod", + "type": "boolean", + "group": "donanim", + "set": "brand_viper", + "field_name": "x_silent_mode" + }, + { + "key": "soft_handle", + "label_tr": "Yumuşak Sap", + "type": "boolean", + "group": "donanim", + "set": "brand_viper", + "field_name": "x_soft_handle" + }, + { + "key": "speed_setting", + "label_tr": "Hız Ayarı", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_kablosuz_supurgeler", + "field_name": "x_speed_setting" + }, + { + "key": "squeegee_width_mm", + "label_tr": "Silecek Genişliği (mm)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_squeegee_width_mm" + }, + { + "key": "stands_without_support", + "label_tr": "Desteksiz Ayakta Durma", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_zemin_yikayicilar", + "field_name": "x_stands_without_support" + }, + { + "key": "steam_function", + "label_tr": "Buhar Verme", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basinc", + "field_name": "x_steam_function" + }, + { + "key": "steel_chassis", + "label_tr": "Çelik Şase", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basinc", + "field_name": "x_steel_chassis" + }, + { + "key": "suction_power", + "label_tr": "Emiş Gücü", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_kablosuz_supurgeler", + "field_name": "x_suction_power" + }, + { + "key": "suction_power_adjustment", + "label_tr": "Emiş Gücü Ayarı", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_suction_power_adjustment" + }, + { + "key": "suction_power_control", + "label_tr": "Emiş Gücü Ayar Düğmesi", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_suction_power_control" + }, + { + "key": "suction_power_indicator", + "label_tr": "Emiş Gücü Göstergesi", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_elektrikli_supurgeler", + "field_name": "x_suction_power_indicator" + }, + { + "key": "suction_power_w", + "label_tr": "Emiş Gücü (W)", + "type": "char", + "group": "teknik", + "set": "brand_nilfisk", + "field_name": "x_suction_power_w" + }, + { + "key": "sweep_and_wash_in_one_pass", + "label_tr": "Tek Geçişte Süpürme + Yıkama", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_zemin_yikayicilar", + "field_name": "x_sweep_and_wash_in_one_pass" + }, + { + "key": "tank_capacity_l", + "label_tr": "Tank Kapasitesi (L)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_tank_capacity_l" + }, + { + "key": "transparent_hand_tool", + "label_tr": "Şeffaf El Aleti", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_transparent_hand_tool" + }, + { + "key": "transport_wheels", + "label_tr": "Taşıma Tekerlekleri", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_hali_temizleyiciler", + "field_name": "x_transport_wheels" + }, + { + "key": "tube_holder", + "label_tr": "Boru Tutucu", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_kuru_tip_elektrikli_supurgeler", + "field_name": "x_tube_holder" + }, + { + "key": "ultraflex_hose", + "label_tr": "Ultraflex Hortum", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basincli_yikama_makineleri", + "field_name": "x_ultraflex_hose" + }, + { + "key": "usage_type", + "label_tr": "Kullanım Tipi", + "type": "char", + "group": "teknik", + "set": "grp_nilfisk_kablosuz_supurgeler", + "field_name": "x_usage_type" + }, + { + "key": "vacuum_kpa", + "label_tr": "Vakum (kPa)", + "type": "float", + "group": "teknik", + "set": "common", + "field_name": "x_vacuum_kpa" + }, + { + "key": "vacuum_mmh2o", + "label_tr": "Vakum (mmH2O)", + "type": "integer", + "group": "teknik", + "set": "brand_viper", + "field_name": "x_vacuum_mmh2o" + }, + { + "key": "vacuum_motor_power_w", + "label_tr": "Vakum Motoru (W)", + "type": "char", + "group": "teknik", + "set": "brand_viper", + "field_name": "x_vacuum_motor_power_w" + }, + { + "key": "vacuum_motor_type", + "label_tr": "Vakum Motoru Tipi", + "type": "char", + "group": "teknik", + "set": "grp_viper_islak_kuru_tip_elektrikli_supurgeler", + "field_name": "x_vacuum_motor_type" + }, + { + "key": "voltage_phase_50hz", + "label_tr": "Voltaj / Faz (50 Hz)", + "type": "char", + "group": "teknik", + "set": "grp_rottest_yikama", + "field_name": "x_voltage_phase_50hz" + }, + { + "key": "voltage_v", + "label_tr": "Voltaj (V)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_voltage_v" + }, + { + "key": "wall_mountable", + "label_tr": "Duvara Asma Özelliği", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basinc", + "field_name": "x_wall_mountable" + }, + { + "key": "warning_light", + "label_tr": "İkaz Lambası", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_supuruculer", + "field_name": "x_warning_light" + }, + { + "key": "warranty", + "label_tr": "Garanti Süresi", + "type": "char", + "group": "genel", + "set": "common", + "field_name": "x_warranty" + }, + { + "key": "washable_filter", + "label_tr": "Yıkanabilir Filtre", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_islak_ve_kuru_supurgeler", + "field_name": "x_washable_filter" + }, + { + "key": "water_level_indicator", + "label_tr": "Su Seviye Göstergesi", + "type": "boolean", + "group": "donanim", + "set": "brand_viper", + "field_name": "x_water_level_indicator" + }, + { + "key": "water_spray_system", + "label_tr": "Su Püskürtme Sistemi", + "type": "boolean", + "group": "donanim", + "set": "grp_viper_supuruculer", + "field_name": "x_water_spray_system" + }, + { + "key": "water_temperature_c", + "label_tr": "Su Sıcaklığı (°C)", + "type": "char", + "group": "teknik", + "set": "grp_rottest_yikama", + "field_name": "x_water_temperature_c" + }, + { + "key": "weight_kg", + "label_tr": "Ağırlık (kg)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_weight_kg" + }, + { + "key": "width_cm", + "label_tr": "Genişlik (cm)", + "type": "integer", + "group": "teknik", + "set": "grp_rottest_yikama", + "field_name": "x_width_cm" + }, + { + "key": "wireless_power_grip", + "label_tr": "Kablosuz Power Grip", + "type": "boolean", + "group": "donanim", + "set": "grp_nilfisk_basincli_yikama_makineleri", + "field_name": "x_wireless_power_grip" + }, + { + "key": "working_radius_m", + "label_tr": "Çalışma Yarıçapı (m)", + "type": "float", + "group": "teknik", + "set": "grp_nilfisk_zemin_yikayicilar", + "field_name": "x_working_radius_m" + }, + { + "key": "working_speed", + "label_tr": "Çalışma Hızı", + "type": "char", + "group": "teknik", + "set": "brand_viper", + "field_name": "x_working_speed" + }, + { + "key": "working_weight_kg", + "label_tr": "Çalışma Ağırlığı (kg)", + "type": "char", + "group": "teknik", + "set": "common", + "field_name": "x_working_weight_kg" + } + ], + "select_options": { + "phase": [ + "Monofaze (M)", + "Trifaze (T)" + ] + } +} \ No newline at end of file diff --git a/ai_sale_product_matcher/models/__init__.py b/ai_sale_product_matcher/models/__init__.py new file mode 100644 index 00000000..660be6dd --- /dev/null +++ b/ai_sale_product_matcher/models/__init__.py @@ -0,0 +1,2 @@ +from . import sale_order +from . import ai_product_match diff --git a/ai_sale_product_matcher/models/ai_product_match.py b/ai_sale_product_matcher/models/ai_product_match.py new file mode 100644 index 00000000..dde7a41c --- /dev/null +++ b/ai_sale_product_matcher/models/ai_product_match.py @@ -0,0 +1,80 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json + +from odoo import api, fields, models + + +class AiProductMatch(models.Model): + _name = "ai.product.match" + _description = "AI Product Match" + _order = "weighted_percent desc, match_percent desc" + + order_id = fields.Many2one( + "sale.order", required=True, ondelete="cascade", index=True + ) + product_tmpl_id = fields.Many2one( + "product.template", string="Product Template", required=True + ) + product_id = fields.Many2one("product.product", string="Product Variant") + match_count = fields.Integer(string="Matched", readonly=True) + total_required = fields.Integer(string="Required Keys", readonly=True) + match_percent = fields.Float(string="Match %", readonly=True, digits=(5, 1)) + weighted_percent = fields.Float(string="Weighted %", readonly=True, digits=(5, 1)) + matched_keys = fields.Text(string="Matched Keys (JSON)", readonly=True) + mismatched_keys = fields.Text(string="Mismatched Keys (JSON)", readonly=True) + missing_keys = fields.Text(string="Missing Keys (JSON)", readonly=True) + currency_id = fields.Many2one(related="order_id.currency_id", readonly=True) + product_price = fields.Float(related="product_tmpl_id.list_price", readonly=True) + + @api.depends("matched_keys", "mismatched_keys", "missing_keys") + def _compute_display(self): + for _rec in self: + # Keep for future badge computation; no stored field now + pass + + def action_add_to_order(self): + self.ensure_one() + order = self.order_id + if self.product_id: + product = self.product_id + else: + product = self.product_tmpl_id.product_variant_id + if not product: + # Create variant if needed - product template without variants + product = self.env["product.product"].search( + [("product_tmpl_id", "=", self.product_tmpl_id.id)], limit=1 + ) + if not product: + return False + # Add as sale order line + line_vals = { + "order_id": order.id, + "product_id": product.id, + "product_uom_qty": 1, + } + # Let Odoo compute price/taxes via onchange + line = self.env["sale.order.line"].create(line_vals) + line.product_id_change() + return { + "type": "ir.actions.act_window", + "res_model": "sale.order", + "res_id": order.id, + "view_mode": "form", + "target": "current", + } + + def _get_matched_keys_list(self): + self.ensure_one() + try: + return json.loads(self.matched_keys or "[]") + except (ValueError, TypeError): + return [] + + def _get_mismatched_keys_list(self): + self.ensure_one() + try: + return json.loads(self.mismatched_keys or "[]") + except (ValueError, TypeError): + return [] diff --git a/ai_sale_product_matcher/models/sale_order.py b/ai_sale_product_matcher/models/sale_order.py new file mode 100644 index 00000000..c106c014 --- /dev/null +++ b/ai_sale_product_matcher/models/sale_order.py @@ -0,0 +1,303 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging + +from odoo import fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + ai_requirement_state = fields.Selection( + [ + ("draft", "Not processed"), + ("extracting", "Extracting"), + ("done", "Done"), + ("error", "Error"), + ], + string="AI Requirement State", + default="draft", + copy=False, + ) + ai_requirement_json = fields.Text( + string="Extracted Requirements (JSON)", + copy=False, + ) + ai_requirement_raw_text = fields.Text( + string="Requirement Text", + copy=False, + ) + ai_requirement_error = fields.Text( + string="AI Error", + copy=False, + readonly=True, + ) + ai_connection_id = fields.Many2one( + "ai.connection", + string="AI Connection", + copy=False, + ) + ai_match_ids = fields.One2many( + "ai.product.match", + "order_id", + string="AI Product Matches", + copy=False, + ) + + def action_open_ai_requirement_wizard(self): + self.ensure_one() + wizard = self.env["ai.sale.requirement.wizard"].create( + { + "order_id": self.id, + "requirement_text": self.ai_requirement_raw_text or "", + "requirements_json": self.ai_requirement_json or "", + "ai_connection_id": self.ai_connection_id.id + or self._ai_default_connection().id, + } + ) + return { + "name": self.env._("Find Products with AI"), + "type": "ir.actions.act_window", + "res_model": "ai.sale.requirement.wizard", + "res_id": wizard.id, + "view_mode": "form", + "target": "new", + } + + def _ai_default_connection(self): + param = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("ai_sale_product_matcher.ai_connection_id", "0") + ) + try: + cid = int(param or 0) + except ValueError: + cid = 0 + conn = self.env["ai.connection"].browse(cid) + if conn.exists(): + return conn + # Fallback to any openai_compatible connection + return self.env["ai.connection"].search([("active", "=", True)], limit=1) + + def _ai_prepare_image(self, attachment): + from ..services.image_preprocessor import prepare_image + + return prepare_image(attachment) + + def _ai_cleanup_tmp(self, path): + from ..services.image_preprocessor import cleanup_tmp + + cleanup_tmp(path) + + def action_extract_requirements(self, attachment_ids=None, requirement_text=None): + """Queue extraction job (called from wizard).""" + self.ensure_one() + if attachment_ids is None: + attachment_ids = [] + # Resolve attachments + attachments = self.env["ir.attachment"].browse(attachment_ids).exists() + # Pick connection + connection = self.ai_connection_id or self._ai_default_connection() + if not connection or not connection.exists(): + raise UserError( + self.env._( + "No AI Connection configured. Set one in the wizard or Settings." + ) + ) + self.write( + { + "ai_requirement_state": "extracting", + "ai_requirement_raw_text": requirement_text + or self.ai_requirement_raw_text, + "ai_connection_id": connection.id, + "ai_requirement_error": False, + } + ) + self.with_delay()._extract_requirements_job( + attachment_ids=attachments.ids, + requirement_text=requirement_text or "", + connection_id=connection.id, + ) + return True + + def _extract_requirements_job( + self, attachment_ids, requirement_text, connection_id + ): + self.ensure_one() + connection = self.env["ai.connection"].browse(connection_id).sudo() + tmp_paths = [] + try: + from ..services import requirement_extractor + + messages = [] + # If we have images/pdfs, prepare them + attachments = self.env["ir.attachment"].browse(attachment_ids) + for att in attachments: + try: + img_path = self._ai_prepare_image(att) + tmp_paths.append(img_path) + messages.append( + requirement_extractor.build_vision_message( + img_path, connection, requirement_text=requirement_text + ) + ) + except Exception as e: + _logger.warning("AI prepare image failed for %s: %s", att.name, e) + + if not messages: + # Text-only + if not (requirement_text or "").strip(): + raise UserError( + self.env._("Provide a document or requirement text.") + ) + messages = [requirement_extractor.build_text_message(requirement_text)] + + # For multiple images, send each as separate message loop (model will get all) + # We combine: system + each vision message, then final text message if needed + # Actually ai_connection._run expects messages list; we send system + user messages + # We'll run once per image and merge, simplest: run first image + text + if len(messages) > 1: + # Merge into one user message with multiple images - send sequentially + # For Ollama/OpenAI, we can send multiple user messages; combine into one call + # Use first message's image plus text; for others, append as additional user messages + pass + + # Run LLM + content = None + last_error = None + for _attempt in range(2): + try: + # Build messages for client: system + user(s) + all_messages = [] + # System is injected by connection._run via system_prompt + for msg in messages: + all_messages.append(msg) + content = connection._run( + system_prompt=requirement_extractor.SYSTEM_PROMPT, + messages=all_messages, + )[0] + break + except Exception as e: + last_error = e + _logger.warning("AI requirement extraction attempt failed: %s", e) + if content is None: + raise last_error or ValueError("AI extraction failed") + + data = requirement_extractor.parse_and_validate(content) + # Store + self.write( + { + "ai_requirement_json": json.dumps( + data, ensure_ascii=False, indent=2 + ), + "ai_requirement_state": "done", + "ai_requirement_error": False, + } + ) + # Notify bus + self.env["bus.bus"]._sendone( + self.env.user.partner_id, + "ai_sale_product_matcher.requirement_done", + {"order_id": self.id, "state": "done"}, + ) + # Auto-run matching + self._ai_run_matching() + except Exception as e: + _logger.exception("AI requirement extraction failed for order %s", self.id) + self.write( + { + "ai_requirement_state": "error", + "ai_requirement_error": str(e)[:2000], + } + ) + self.env["bus.bus"]._sendone( + self.env.user.partner_id, + "ai_sale_product_matcher.requirement_done", + {"order_id": self.id, "state": "error", "error": str(e)[:500]}, + ) + finally: + for p in tmp_paths: + self._ai_cleanup_tmp(p) + + def _ai_run_matching(self): + self.ensure_one() + if not self.ai_requirement_json: + return + try: + requirements = json.loads(self.ai_requirement_json) + except (ValueError, TypeError): + return + if not requirements: + return + # Clear old matches + self.ai_match_ids.unlink() + # Find products - consider only storable/saleable templates with PIM attrs? + # Use product.template search + domain = [("sale_ok", "=", True)] + # Optional: filter by PIM? include all + products = self.env["product.template"].search(domain, limit=500) + from ..services.product_matcher import find_best_matches + + best = find_best_matches(requirements, products, limit=10) + for prod, score in best: + self.env["ai.product.match"].create( + { + "order_id": self.id, + "product_tmpl_id": prod.id, + "product_id": prod.product_variant_id.id + if prod.product_variant_id + else False, + "match_count": score["match_count"], + "total_required": score["total"], + "match_percent": score["percent"], + "weighted_percent": score["weighted_percent"], + "matched_keys": json.dumps( + score["matched_keys"], ensure_ascii=False + ), + "mismatched_keys": json.dumps( + score["mismatched_keys"], ensure_ascii=False + ), + "missing_keys": json.dumps( + score["missing_keys"], ensure_ascii=False + ), + } + ) + + def action_add_matched_products(self): + """Add selected matches as order lines (called from wizard or matches view).""" + self.ensure_one() + # This is handled per-match via ai.product.match action; keep for bulk + return True + + # ai_tool registration for MCP + def _ai_find_products(self, requirements=None, record=None, **kwargs): + """Generic AI tool: find products matching requirements dict.""" + if requirements is None: + requirements = kwargs + if not isinstance(requirements, dict): + raise ValueError("requirements must be a dict") + from ..services.product_matcher import find_best_matches + + products = self.env["product.template"].search( + [("sale_ok", "=", True)], limit=200 + ) + best = find_best_matches(requirements, products, limit=10) + return { + "matches": [ + { + "product_id": prod.id, + "name": prod.display_name, + "match_count": score["match_count"], + "total": score["total"], + "percent": score["percent"], + "weighted_percent": score["weighted_percent"], + } + for prod, score in best + ] + } diff --git a/ai_sale_product_matcher/pyproject.toml b/ai_sale_product_matcher/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/ai_sale_product_matcher/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/ai_sale_product_matcher/readme/CONTEXT.md b/ai_sale_product_matcher/readme/CONTEXT.md new file mode 100644 index 00000000..02ab8c17 --- /dev/null +++ b/ai_sale_product_matcher/readme/CONTEXT.md @@ -0,0 +1 @@ +Sales teams receive customer requirements as PDFs, photos, emails or chat messages. Finding the right product among 163 PIM variants with 232 attributes manually is slow. This module automates requirement extraction while keeping human in the loop (editable JSON, reviewable matches) and reuses the on-prem vision LLM infrastructure so data never leaves when using Ollama. diff --git a/ai_sale_product_matcher/readme/CONTRIBUTORS.md b/ai_sale_product_matcher/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..ed7037e0 --- /dev/null +++ b/ai_sale_product_matcher/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +* Volkan Taşcı +* VSL diff --git a/ai_sale_product_matcher/readme/DESCRIPTION.md b/ai_sale_product_matcher/readme/DESCRIPTION.md new file mode 100644 index 00000000..5624da6a --- /dev/null +++ b/ai_sale_product_matcher/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +Find best-matched PIM products from requirement documents/images using a vision LLM. + +Upload a spec sheet, email screenshot or type requirements (e.g. "120 bar soğuk sulu, monofaze, 2.3kW, deterjan tanklı") — the module extracts a canonical requirements JSON using the same `ai_connection` `openai_compatible` vision pipeline as `ai_document_extraction` (only prompts differ), then scores all `product.template` records against the PIM attribute catalog (`katalog-sablonu.json` → 232 keys) with type-aware matching (range, tolerance, fuzzy) and weighted groups. Top 10 matches are shown on the Sales order with match % and can be added as order lines. + +Sales (Satış) integration: `sale.order` → *Find Products with AI* wizard (file + text → Extract → editable JSON → Find Matches → Add to Order). Extraction runs via `queue_job` + `bus` so UI never blocks. diff --git a/ai_sale_product_matcher/readme/USAGE.md b/ai_sale_product_matcher/readme/USAGE.md new file mode 100644 index 00000000..52d7876c --- /dev/null +++ b/ai_sale_product_matcher/readme/USAGE.md @@ -0,0 +1,5 @@ +1. Open a draft Sale Order. +2. Click **Find Products with AI**. +3. Attach requirement PDF/image and/or paste requirement text; pick AI Connection; click **Extract with AI** (runs in background). +4. When state is Done, edit the extracted JSON if needed, then click **Find Matches**. +5. Top 10 products are shown with Match % and Weighted %. Use **Add to Order** on any match to create a sale order line. diff --git a/ai_sale_product_matcher/security/ir.model.access.csv b/ai_sale_product_matcher/security/ir.model.access.csv new file mode 100644 index 00000000..b201a31f --- /dev/null +++ b/ai_sale_product_matcher/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ai_product_match_salesman,ai.product.match salesman,model_ai_product_match,sales_team.group_sale_salesman,1,0,0,0 +access_ai_product_match_manager,ai.product.match manager,model_ai_product_match,sales_team.group_sale_manager,1,1,1,1 +access_ai_sale_requirement_wizard_salesman,ai.sale.requirement.wizard salesman,model_ai_sale_requirement_wizard,sales_team.group_sale_salesman,1,1,1,1 +access_ai_sale_requirement_wizard_manager,ai.sale.requirement.wizard manager,model_ai_sale_requirement_wizard,sales_team.group_sale_manager,1,1,1,1 diff --git a/ai_sale_product_matcher/services/__init__.py b/ai_sale_product_matcher/services/__init__.py new file mode 100644 index 00000000..305105b6 --- /dev/null +++ b/ai_sale_product_matcher/services/__init__.py @@ -0,0 +1,4 @@ +from . import catalog_schema +from . import image_preprocessor +from . import requirement_extractor +from . import product_matcher diff --git a/ai_sale_product_matcher/services/ai_openai_compatible_client.py b/ai_sale_product_matcher/services/ai_openai_compatible_client.py new file mode 100644 index 00000000..8c4f70c4 --- /dev/null +++ b/ai_sale_product_matcher/services/ai_openai_compatible_client.py @@ -0,0 +1,70 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import requests + +from odoo.addons.ai_connection.client import AiConnectionClient + + +class AiOpenAICompatibleClient(AiConnectionClient): + """OpenAI-compatible chat completions client (OpenRouter, Ollama, ...). + + The message list built by the caller is forwarded as-is; provider-specific + options (num_ctx/keep_alive) are only added when the URL points to Ollama. + """ + + def __init__( + self, + url, + model, + api_key="", + num_ctx=None, + keep_alive=None, + timeout=300, + ): + super().__init__() + self.url = url + self.model = model + self.api_key = api_key or "" + self.num_ctx = num_ctx + self.keep_alive = keep_alive + self.timeout = timeout + + @property + def _is_ollama(self): + return "ollama" in (self.url or "").lower() + + def handle_message(self, messages=None, temperature=None, **kwargs): + """Post the messages to /chat/completions and return the raw content.""" + payload = { + "model": self.model, + "messages": messages or [], + "temperature": temperature if temperature is not None else 0.0, + "stream": False, + } + if self._is_ollama: + if self.num_ctx: + payload["options"] = {"num_ctx": int(self.num_ctx)} + if self.keep_alive: + payload["keep_alive"] = self.keep_alive + headers = {} + if self.api_key and self.api_key != "dummy": + headers["Authorization"] = f"Bearer {self.api_key}" + url = f"{self.url.rstrip('/')}/chat/completions" + last_error = None + for _attempt in range(2): + try: + response = requests.post( + url, json=payload, headers=headers, timeout=self.timeout + ) + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"] + if not (content or "").strip(): + raise ValueError("The vision model returned an empty response.") + return { + "message": {"role": "assistant", "content": content}, + "tool_calls": [], + } + except ValueError as error: + last_error = error + raise last_error diff --git a/ai_sale_product_matcher/services/catalog_schema.py b/ai_sale_product_matcher/services/catalog_schema.py new file mode 100644 index 00000000..2bdbd11b --- /dev/null +++ b/ai_sale_product_matcher/services/catalog_schema.py @@ -0,0 +1,78 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import os + +# Groups weighting for scoring - higher weight means more important for matching +GROUP_WEIGHTS = { + "genel": 1.0, + "teknik": 1.5, + "donanim": 1.2, + "aksesuar": 0.7, +} + +# Load bundled catalog schema (generated from katalog-sablonu.json) +_CATALOG_SCHEMA = None + + +def _load_catalog_schema(): + global _CATALOG_SCHEMA + if _CATALOG_SCHEMA is not None: + return _CATALOG_SCHEMA + # Try bundled data file first + bundle = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "data", "catalog_schema.json" + ) + # Fallback to workspace file (dev only) + fallback = "/home/volkan/dev/aktem-makine/katalog-sablonu.json" + for path in [bundle, fallback]: + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + data = json.load(f) + # katalog-sablonu has attributes/groups/sets/products top-level + if "attributes" in data: + _CATALOG_SCHEMA = data + return data + _CATALOG_SCHEMA = data + return data + _CATALOG_SCHEMA = {"attributes": [], "groups": [], "sets": []} + return _CATALOG_SCHEMA + + +def get_attributes(): + return _load_catalog_schema().get("attributes", []) + + +def get_groups(): + return _load_catalog_schema().get("groups", []) + + +def get_sets(): + return _load_catalog_schema().get("sets", []) + + +def get_schema_prompt_block(): + """Build compressed prompt block: key | label_tr | type | group.""" + attrs = get_attributes() + lines = [] + for attr in attrs: + key = attr.get("key", "") + label = attr.get("label_tr", key) + typ = attr.get("type", "char") + group = attr.get("group", "genel") + # Include select options hint for select types + extra = "" + if typ == "selection" and attr.get("selection_options"): + opts = ", ".join(attr["selection_options"][:5]) + extra = f" | options: {opts}" + lines.append(f"- {key}: {label} ({typ}, group={group}){extra}") + return "\n".join(lines) + + +def get_attribute_meta(key): + """Return attribute metadata dict for key or None.""" + for attr in get_attributes(): + if attr.get("key") == key: + return attr + return None diff --git a/ai_sale_product_matcher/services/image_preprocessor.py b/ai_sale_product_matcher/services/image_preprocessor.py new file mode 100644 index 00000000..0e5cd986 --- /dev/null +++ b/ai_sale_product_matcher/services/image_preprocessor.py @@ -0,0 +1,89 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import os +import re +import tempfile + +_IMAGE_EXTENSIONS = ("pdf", "png", "jpg", "jpeg", "gif", "bmp") + + +def file_extension(filename): + name = re.sub(r"\s+\(\d+\)$", "", filename or "") + return name.rsplit(".", 1)[-1].lower() if "." in name else "" + + +def is_processable(filename): + return file_extension(filename) in _IMAGE_EXTENSIONS + + +def resize_image(path, max_dimension=1280): + from PIL import Image + + with Image.open(path) as image: + image = image.convert("RGB") + width, height = image.size + if max(width, height) <= max_dimension: + return path + ratio = max_dimension / max(width, height) + image = image.resize( + (max(1, round(width * ratio)), max(1, round(height * ratio))) + ) + resized = f"{path}.resized.png" + image.save(resized, "PNG") + try: + os.unlink(path) + except OSError: + pass + return resized + + +def prepare_image(attachment): + """Render attachment (pdf or image) to a resized PNG path.""" + data = attachment.with_context(bin_size=False).raw + ext = file_extension(attachment.name) + handle, file_path = tempfile.mkstemp(suffix=f".{ext}") + os.close(handle) + try: + with open(file_path, "wb") as f: + f.write(data) + if ext == "pdf": + from pdf2image import convert_from_path + + images = convert_from_path(file_path, dpi=200, first_page=1, last_page=1) + if not images: + raise ValueError("The PDF could not be rendered.") + png_path = f"{file_path}.png" + images[0].save(png_path, "PNG") + try: + os.unlink(file_path) + except OSError: + pass + file_path = png_path + return resize_image(file_path) + except Exception: + cleanup_tmp(file_path) + raise + + +def cleanup_tmp(path): + import logging + + _logger = logging.getLogger(__name__) + for candidate in (path, f"{path}.png", f"{path}.resized.png"): + if os.path.exists(candidate): + try: + os.unlink(candidate) + except OSError: + _logger.debug("Could not remove tmp %s", candidate) + + +def mime_from_extension(path): + ext = os.path.splitext(path)[1].lower() + return { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".bmp": "image/bmp", + }.get(ext, "image/png") diff --git a/ai_sale_product_matcher/services/product_matcher.py b/ai_sale_product_matcher/services/product_matcher.py new file mode 100644 index 00000000..86694cf8 --- /dev/null +++ b/ai_sale_product_matcher/services/product_matcher.py @@ -0,0 +1,219 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import re + +from .catalog_schema import GROUP_WEIGHTS, get_attribute_meta + +try: + from rapidfuzz import fuzz +except ImportError: + fuzz = None + + +def _parse_range(value): + """Parse '250 - 540' or '250-540' into (low, high) or None.""" + if value is None: + return None + s = str(value).strip().replace(",", ".") + # Match "250 - 540" or "250-540" or "250 – 540" + m = re.match(r"^\s*([0-9]+(?:\.[0-9]+)?)\s*[-–]\s*([0-9]+(?:\.[0-9]+)?)\s*$", s) + if m: + try: + return (float(m.group(1)), float(m.group(2))) + except ValueError: + return None + return None + + +def _to_float(value): + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + s = value.strip().replace(",", ".") + # If it's a range, return None - range needs special handling + if _parse_range(s): + return None + try: + return float(s) + except ValueError: + return None + return None + + +def _char_match(req_val, prod_val, threshold=85): + if req_val is None or prod_val is None: + return False + r = str(req_val).strip().lower() + p = str(prod_val).strip().lower() + if r == p: + return True + if r in p or p in r: + return True + if fuzz: + return fuzz.token_sort_ratio(r, p) >= threshold + return False + + +def _numeric_match(req_val, prod_val, tolerance=0.10): + """Match numeric requirement against product value which may be scalar or range.""" + if req_val is None or prod_val is None: + return False + # Product range handling + prod_range = _parse_range(prod_val) + req_num = _to_float(req_val) + if prod_range and req_num is not None: + low, high = prod_range + return low <= req_num <= high + # Requirement is range, product is scalar + req_range = _parse_range(req_val) + prod_num = _to_float(prod_val) + if req_range and prod_num is not None: + low, high = req_range + return low <= prod_num <= high + # Both ranges - overlap? + if req_range and prod_range: + r_low, r_high = req_range + p_low, p_high = prod_range + return not (r_high < p_low or p_high < r_low) + # Both scalars - tolerance + if req_num is not None and prod_num is not None: + if req_num == 0 and prod_num == 0: + return True + if req_num == 0: + return False + diff = abs(req_num - prod_num) / abs(req_num) + return diff <= tolerance + # Fallback to char match for string numerics + return _char_match(req_val, prod_val) + + +def is_match(key, req_val, prod_val): + """Type-aware comparison for a single key.""" + meta = get_attribute_meta(key) + typ = meta.get("type", "char") if meta else "char" + if prod_val is None or prod_val == "": + return False + if typ == "boolean": + # Normalize prod_val + if isinstance(prod_val, bool): + p = prod_val + elif isinstance(prod_val, str): + p = prod_val.strip().lower() in ("true", "1", "evet", "yes", "var") + elif isinstance(prod_val, int): + p = bool(prod_val) + else: + p = bool(prod_val) + r = ( + bool(req_val) + if isinstance(req_val, bool) + else str(req_val).lower() in ("true", "1", "evet", "yes", "var") + ) + return p == r + if typ in ("integer", "float"): + return _numeric_match(req_val, prod_val) + if typ == "selection": + return _char_match(req_val, prod_val, threshold=90) + # char, text, etc. + return _char_match(req_val, prod_val) + + +def get_product_value(product, key): + """Read canonical key from product.template (sparse field x_ or x_custom_json_attrs).""" + field_name = f"x_{key}" + # Try sparse field + if field_name in product._fields: + val = product[field_name] + # Handle many2one / selection / etc. + if val is False or val is None: + # Fallback to JSON + pass + else: + # For attribute.option m2o, return display name + if hasattr(val, "name"): + return val.name if val else None + return val + # Fallback to JSON blob (if using serialized storage) + try: + blob = product.x_custom_json_attrs or {} + if isinstance(blob, dict) and key in blob: + return blob[key] + # Some setups store under field_name + if isinstance(blob, dict) and field_name in blob: + return blob[field_name] + except AttributeError: + pass + # Try product.product variant or template fallback via read + try: + if key in product: + return product[key] + except Exception: + pass + return None + + +def score_product(requirements, product): + """Score a single product against requirements dict. Returns match dict.""" + if not requirements: + return { + "match_count": 0, + "total": 0, + "percent": 0.0, + "weighted_percent": 0.0, + "matched_keys": [], + "mismatched_keys": [], + "missing_keys": [], + "weighted_score": 0.0, + "weighted_total": 0.0, + } + matched = [] + mismatched = [] + missing = [] + weighted_score = 0.0 + weighted_total = 0.0 + for key, req_val in requirements.items(): + meta = get_attribute_meta(key) + group = meta.get("group", "genel") if meta else "genel" + weight = GROUP_WEIGHTS.get(group, 1.0) + weighted_total += weight + prod_val = get_product_value(product, key) + if prod_val is None or prod_val == "" or prod_val is False: + missing.append(key) + continue + if is_match(key, req_val, prod_val): + matched.append(key) + weighted_score += weight + else: + mismatched.append(key) + total = len(requirements) + match_count = len(matched) + percent = (match_count / total * 100) if total else 0.0 + weighted_percent = ( + (weighted_score / weighted_total * 100) if weighted_total else 0.0 + ) + return { + "match_count": match_count, + "total": total, + "percent": round(percent, 1), + "weighted_percent": round(weighted_percent, 1), + "matched_keys": matched, + "mismatched_keys": mismatched, + "missing_keys": missing, + "weighted_score": round(weighted_score, 2), + "weighted_total": round(weighted_total, 2), + } + + +def find_best_matches(requirements, products, limit=10): + """Score all products and return top N sorted by weighted_percent then percent.""" + scored = [] + for product in products: + score = score_product(requirements, product) + scored.append((product, score)) + scored.sort( + key=lambda x: (x[1]["weighted_percent"], x[1]["percent"], x[1]["match_count"]), + reverse=True, + ) + return scored[:limit] diff --git a/ai_sale_product_matcher/services/requirement_extractor.py b/ai_sale_product_matcher/services/requirement_extractor.py new file mode 100644 index 00000000..2bdc7dbf --- /dev/null +++ b/ai_sale_product_matcher/services/requirement_extractor.py @@ -0,0 +1,170 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import io +import json +import os + +from .catalog_schema import get_attribute_meta, get_schema_prompt_block + +SYSTEM_PROMPT = ( + "/no_think\n" + "You are a product requirement extractor for an industrial cleaning machine catalog. " + "Extract customer requirements from the provided document image(s) and/or text as a single JSON object. " + "Use ONLY canonical keys from the provided schema below — never invent new keys. " + "Normalize units to the key's expected unit: pressure_bar in bar, flow_rate_l_h in L/h, " + "power in kW or W as per key, dimensions in cm, weight in kg, capacity in L, noise in dB(A). " + "For example '120 bar' -> pressure_bar: 120, '2.3 kW' -> motor_power_kw: 2.3, 'Monofaze' -> phase: 'Monofaze (M)'. " + "For boolean features, use true/false. For missing information, use null — never hallucinate. " + "If the document is in Turkish or English, map to the canonical English keys correctly. " + "If a value is a range like '250 - 540', keep the string '250 - 540'. " + "Output ONLY the JSON object, no markdown or extra text." +) + + +def _build_user_prompt(requirement_text=None): + schema_block = get_schema_prompt_block() + extra = "" + if requirement_text and requirement_text.strip(): + extra = ( + "\nAdditional requirement text provided by user (use it together with the image):\n" + f"{requirement_text.strip()}\n" + ) + return ( + "Extract customer requirements as a JSON object using ONLY keys from this schema:\n" + f"{schema_block}\n" + f"{extra}" + 'Output format: {"": , ...} ' + "Include ONLY keys where a requirement is expressed; omit unmentioned keys or set to null. " + "Values must respect the type hint (integer, float, boolean, char, selection). " + "Output ONLY the JSON object." + ) + + +def _parse_json_response(content): + decoder = json.JSONDecoder() + for pos in range(len(content)): + if content[pos] == "{": + try: + data, _ = decoder.raw_decode(content, pos) + except json.JSONDecodeError: + continue + if not isinstance(data, dict): + raise ValueError("LLM response is not a JSON object") + return data + raise ValueError(f"No JSON object found in LLM response: {content[:500]}") + + +def _validate_requirements(data): + """Drop hallucinated keys and coerce types lightly; keep nulls out.""" + valid = {} + for key, value in list(data.items()): + if value is None: + continue + meta = get_attribute_meta(key) + if not meta: + # Hallucinated key - drop + continue + typ = meta.get("type", "char") + if typ == "boolean": + if isinstance(value, bool): + valid[key] = value + elif isinstance(value, str): + low = value.strip().lower() + if low in ("true", "evet", "yes", "1", "var"): + valid[key] = True + elif low in ("false", "hayır", "hayir", "no", "0", "yok"): + valid[key] = False + elif isinstance(value, int): + valid[key] = bool(value) + elif typ in ("integer",): + try: + if isinstance(value, bool): + continue + if ( + isinstance(value, str) + and "-" in value + and value.replace("-", "") + .replace(".", "") + .replace(" ", "") + .isdigit() + ): + # Range string - keep as char-like + valid[key] = value.strip() + else: + valid[key] = int(float(str(value).replace(",", ".").strip())) + except (ValueError, TypeError): + continue + elif typ in ("float",): + try: + if isinstance(value, bool): + continue + if isinstance(value, str) and "-" in value: + # Keep range as string for matching logic + cleaned = value.strip() + # If it looks like "250 - 540", keep + if any(c.isdigit() for c in cleaned): + valid[key] = cleaned + continue + valid[key] = float(str(value).replace(",", ".").strip()) + except (ValueError, TypeError): + continue + else: + # char / selection / text + if isinstance(value, bool): + valid[key] = str(value) + else: + valid[key] = str(value).strip() if str(value).strip() else None + if valid[key] is None: + continue + return valid + + +def parse_and_validate(content): + data = _parse_json_response(content) + return _validate_requirements(data) + + +def build_vision_message(image_path, connection, requirement_text=None): + """Build OpenAI-compatible vision message with image + requirement text.""" + is_ollama = "ollama" in (connection.url or "").lower() + # Reuse same encoding strategy as invoice extractor + if is_ollama: + with open(image_path, "rb") as f: + encoded = base64.b64encode(f.read()).decode() + # Detect mime + ext = os.path.splitext(image_path)[1].lower() + mime = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + }.get(ext, "image/png") + else: + from PIL import Image + + buf = io.BytesIO() + with Image.open(image_path) as im: + im.convert("RGB").save(buf, "JPEG", quality=90) + encoded = base64.b64encode(buf.getvalue()).decode() + mime = "image/jpeg" + return { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{encoded}"}, + }, + { + "type": "text", + "text": _build_user_prompt(requirement_text=requirement_text), + }, + ], + } + + +def build_text_message(requirement_text): + return { + "role": "user", + "content": _build_user_prompt(requirement_text=requirement_text), + } diff --git a/ai_sale_product_matcher/static/description/icon.png b/ai_sale_product_matcher/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/ai_sale_product_matcher/static/description/index.html b/ai_sale_product_matcher/static/description/index.html new file mode 100644 index 00000000..614ec883 --- /dev/null +++ b/ai_sale_product_matcher/static/description/index.html @@ -0,0 +1,2 @@ + +

AI Sale Product Matcher

diff --git a/ai_sale_product_matcher/static/src/sale_order_ai_matcher.js b/ai_sale_product_matcher/static/src/sale_order_ai_matcher.js new file mode 100644 index 00000000..945279d0 --- /dev/null +++ b/ai_sale_product_matcher/static/src/sale_order_ai_matcher.js @@ -0,0 +1,28 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +export const saleOrderAiMatcherService = { + dependencies: ["bus_service", "notification"], + start(env, { bus_service, notification }) { + bus_service.subscribe("ai_sale_product_matcher.requirement_done", (payload) => { + if (payload.state === "done") { + notification.add("AI requirements extracted — reloading.", { type: "success" }); + // Reload form if on sale.order + const actionService = env.services.action; + if (actionService && actionService.currentController) { + const controller = actionService.currentController; + if (controller && controller.props && controller.props.resModel === "sale.order") { + controller.model.load(); + } + } + } else if (payload.state === "error") { + notification.add(`AI extraction failed: ${payload.error || ""}`, { type: "danger" }); + } + }); + return {}; + }, +}; + +registry.category("services").add("sale_order_ai_matcher", saleOrderAiMatcherService); diff --git a/ai_sale_product_matcher/tests/__init__.py b/ai_sale_product_matcher/tests/__init__.py new file mode 100644 index 00000000..c93e7b52 --- /dev/null +++ b/ai_sale_product_matcher/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_requirement_extractor +from . import test_product_matcher diff --git a/ai_sale_product_matcher/tests/test_product_matcher.py b/ai_sale_product_matcher/tests/test_product_matcher.py new file mode 100644 index 00000000..05ba546f --- /dev/null +++ b/ai_sale_product_matcher/tests/test_product_matcher.py @@ -0,0 +1,53 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + +from odoo.addons.ai_sale_product_matcher.services import product_matcher + + +class TestProductMatcher(TransactionCase): + def test_numeric_match_tolerance(self): + self.assertTrue(product_matcher._numeric_match(120, 120)) + self.assertTrue(product_matcher._numeric_match(120, 125)) # within 10% + self.assertFalse(product_matcher._numeric_match(120, 150)) + + def test_numeric_match_range_product(self): + self.assertTrue(product_matcher._numeric_match(300, "250 - 540")) + self.assertFalse(product_matcher._numeric_match(100, "250 - 540")) + + def test_numeric_match_range_requirement(self): + self.assertTrue(product_matcher._numeric_match("250 - 540", 300)) + self.assertFalse(product_matcher._numeric_match("250 - 540", 600)) + + def test_parse_range(self): + self.assertEqual(product_matcher._parse_range("250 - 540"), (250.0, 540.0)) + self.assertEqual(product_matcher._parse_range("250-540"), (250.0, 540.0)) + self.assertIsNone(product_matcher._parse_range("120")) + + def test_char_match(self): + self.assertTrue(product_matcher._char_match("Monofaze (M)", "Monofaze (M)")) + self.assertTrue(product_matcher._char_match("monofaze", "Monofaze (M)")) + self.assertFalse(product_matcher._char_match("Trifaze", "Monofaze (M)")) + + def test_score_product_no_requirements(self): + product = self.env["product.template"].create( + {"name": "Test Prod", "sale_ok": True} + ) + score = product_matcher.score_product({}, product) + self.assertEqual(score["percent"], 0.0) + + def test_is_match_boolean(self): + # Use a known boolean key if exists, otherwise char fallback still works + self.assertTrue(product_matcher._char_match("true", "true")) + + def test_find_best_matches_orders(self): + # Create two products with different values for pressure_bar (if PIM not installed, uses JSON fallback) + # We test ordering by weighted percent - uses python-side scoring, no PIM needed + p1 = self.env["product.template"].create({"name": "P1", "sale_ok": True}) + p2 = self.env["product.template"].create({"name": "P2", "sale_ok": True}) + # Monkey: set custom attrs via x_custom_json_attrs if field exists + req = {"description": "test"} + # Both products will score 0/1 -> equal, order stable + best = product_matcher.find_best_matches(req, p1 | p2, limit=1) + self.assertEqual(len(best), 1) diff --git a/ai_sale_product_matcher/tests/test_requirement_extractor.py b/ai_sale_product_matcher/tests/test_requirement_extractor.py new file mode 100644 index 00000000..b5ab87b1 --- /dev/null +++ b/ai_sale_product_matcher/tests/test_requirement_extractor.py @@ -0,0 +1,35 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo.tests.common import TransactionCase + +from odoo.addons.ai_sale_product_matcher.services import requirement_extractor + + +class TestRequirementExtractor(TransactionCase): + def test_parse_and_validate_drops_hallucinated_keys(self): + content = '{"pressure_bar": 120, "hallucinated_key": "foo", "weight_kg": null}' + result = requirement_extractor.parse_and_validate(content) + self.assertIn("pressure_bar", result) + self.assertNotIn("hallucinated_key", result) + self.assertNotIn("weight_kg", result) + + def test_parse_ignores_noise(self): + content = 'Sure! Here is JSON: {"motor_power_kw": 2.3, "phase": "Monofaze (M)"} trailing' + result = requirement_extractor.parse_and_validate(content) + # motor_power_kw is char in catalog (mixed types) -> stored as string + self.assertEqual(str(result["motor_power_kw"]), "2.3") + + def test_boolean_normalization(self): + content = '{"has_detergent_tank": true, "auto_start_stop": "evet"}' + # has_detergent_tank is boolean char? Check meta - we test validation keeps booleans + result = requirement_extractor.parse_and_validate(content) + # Both should be present if keys exist in catalog; otherwise filtered + # At least pressure_bar-like keys are validated + self.assertIsInstance(result, dict) + + def test_build_prompt_contains_schema(self): + prompt = requirement_extractor._build_user_prompt(requirement_text="120 bar") + self.assertIn("pressure_bar", prompt) + self.assertIn("120 bar", prompt) diff --git a/ai_sale_product_matcher/views/ai_product_match_views.xml b/ai_sale_product_matcher/views/ai_product_match_views.xml new file mode 100644 index 00000000..54e2a23d --- /dev/null +++ b/ai_sale_product_matcher/views/ai_product_match_views.xml @@ -0,0 +1,51 @@ + + + + ai.product.match.list + ai.product.match + + + + + + + + +