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..b752a6b9 --- /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.esm.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..87780b2f --- /dev/null +++ b/ai_sale_product_matcher/data/catalog_schema.json @@ -0,0 +1,2067 @@ +{ + "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)"] + } +} diff --git a/ai_sale_product_matcher/i18n/ai_sale_product_matcher.pot b/ai_sale_product_matcher/i18n/ai_sale_product_matcher.pot new file mode 100644 index 00000000..2e5a9ccc --- /dev/null +++ b/ai_sale_product_matcher/i18n/ai_sale_product_matcher.pot @@ -0,0 +1,397 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * ai_sale_product_matcher +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0-20260817\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-30 23:24+0000\n" +"PO-Revision-Date: 2026-08-30 23:24+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__ai_connection_id +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_connection_id +msgid "AI Connection" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_requirement_error +msgid "AI Error" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_filter_brand_id +msgid "AI Filter Brand" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_filter_categ_id +msgid "AI Filter Category" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_order_form_ai_matcher +msgid "AI Matches" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model,name:ai_sale_product_matcher.model_ai_product_match +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_product_match_form +msgid "AI Product Match" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.actions.act_window,name:ai_sale_product_matcher.action_ai_product_match +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_match_ids +#: model:ir.ui.menu,name:ai_sale_product_matcher.menu_ai_product_match +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_product_match_list +msgid "AI Product Matches" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_requirement_state +msgid "AI Requirement State" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model,name:ai_sale_product_matcher.model_ai_sale_requirement_wizard +msgid "AI Requirement Wizard" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_product_match_form +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_product_match_list +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_order_form_ai_matcher +msgid "Add to Order" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "All brands" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "All categories" +msgstr "" + +#. module: ai_sale_product_matcher +#. odoo-python +#: code:addons/ai_sale_product_matcher/wizards/requirement_wizard.py:0 +msgid "Attach a file or enter requirement text." +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__brand_id +msgid "Brand" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__categ_id +msgid "Category" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Close" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__create_uid +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__create_date +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__currency_id +msgid "Currency" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__display_name +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__display_name +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__display_name +msgid "Display Name" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields.selection,name:ai_sale_product_matcher.selection__ai_sale_requirement_wizard__state__done +#: model:ir.model.fields.selection,name:ai_sale_product_matcher.selection__sale_order__ai_requirement_state__done +msgid "Done" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields.selection,name:ai_sale_product_matcher.selection__ai_sale_requirement_wizard__state__draft +msgid "Draft" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,help:ai_sale_product_matcher.field_ai_sale_requirement_wizard__requirements_json +msgid "Edit before matching" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields.selection,name:ai_sale_product_matcher.selection__sale_order__ai_requirement_state__error +msgid "Error" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Extract with AI" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Extracted JSON (editable)" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__requirements_json +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_requirement_json +msgid "Extracted Requirements (JSON)" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields.selection,name:ai_sale_product_matcher.selection__ai_sale_requirement_wizard__state__extracting +#: model:ir.model.fields.selection,name:ai_sale_product_matcher.selection__sale_order__ai_requirement_state__extracting +msgid "Extracting" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Filters (optional)" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Find Matches" +msgstr "" + +#. module: ai_sale_product_matcher +#. odoo-python +#: code:addons/ai_sale_product_matcher/models/sale_order.py:0 +#: model:ir.actions.act_window,name:ai_sale_product_matcher.action_ai_sale_requirement_wizard +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_order_form_ai_matcher +msgid "Find Products with AI" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__id +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__id +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__id +msgid "ID" +msgstr "" + +#. module: ai_sale_product_matcher +#. odoo-python +#: code:addons/ai_sale_product_matcher/wizards/requirement_wizard.py:0 +msgid "Invalid JSON: %s" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_product_match_form +msgid "Key breakdown" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__write_uid +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__write_date +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__match_percent +msgid "Match %" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__match_count +msgid "Matched" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__matched_keys +msgid "Matched Keys (JSON)" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__match_ids +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Matches" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__mismatched_keys +msgid "Mismatched Keys (JSON)" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__missing_keys +msgid "Missing Keys (JSON)" +msgstr "" + +#. module: ai_sale_product_matcher +#. odoo-python +#: code:addons/ai_sale_product_matcher/wizards/requirement_wizard.py:0 +msgid "No AI Connection configured. Set one in Settings." +msgstr "" + +#. module: ai_sale_product_matcher +#. odoo-python +#: code:addons/ai_sale_product_matcher/models/sale_order.py:0 +msgid "No AI Connection configured. Set one in the wizard or Settings." +msgstr "" + +#. module: ai_sale_product_matcher +#. odoo-python +#: code:addons/ai_sale_product_matcher/wizards/requirement_wizard.py:0 +msgid "No matches found. Run Find Matches first." +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields.selection,name:ai_sale_product_matcher.selection__sale_order__ai_requirement_state__draft +msgid "Not processed" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,help:ai_sale_product_matcher.field_sale_order__ai_filter_brand_id +msgid "Optional brand filter for AI matching (top-level category)" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,help:ai_sale_product_matcher.field_sale_order__ai_filter_categ_id +msgid "Optional category filter for AI matching" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,help:ai_sale_product_matcher.field_ai_sale_requirement_wizard__brand_id +msgid "" +"Optional: limit matching to this brand (top-level category like " +"Nilfisk/Viper/Rottest)" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,help:ai_sale_product_matcher.field_ai_sale_requirement_wizard__categ_id +msgid "Optional: limit matching to this category" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__order_id +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__order_id +msgid "Order" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,help:ai_sale_product_matcher.field_ai_sale_requirement_wizard__requirement_text +msgid "Paste email, spec sheet text, etc." +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "" +"Paste requirement text, e.g. '120 bar soğuk sulu, 2.3kW monofaze, deterjan " +"tanklı'" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,help:ai_sale_product_matcher.field_ai_product_match__product_price +msgid "Price at which the product is sold to customers." +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__product_tmpl_id +msgid "Product Template" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__product_id +msgid "Product Variant" +msgstr "" + +#. module: ai_sale_product_matcher +#. odoo-python +#: code:addons/ai_sale_product_matcher/models/sale_order.py:0 +msgid "Provide a document or requirement text." +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Refresh" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__total_required +msgid "Required Keys" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__attachment_ids +msgid "Requirement Files" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Requirement Files (PDF/Image)" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__requirement_text +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_requirement_raw_text +msgid "Requirement Text" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Requirements" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model,name:ai_sale_product_matcher.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__product_price +msgid "Sales Price" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__state +msgid "State" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_order_form_ai_matcher +msgid "Top Matches" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__weighted_percent +msgid "Weighted %" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "{\"pressure_bar\": 120, \"phase\": \"Monofaze (M)\"}" +msgstr "" diff --git a/ai_sale_product_matcher/i18n/tr.po b/ai_sale_product_matcher/i18n/tr.po new file mode 100644 index 00000000..26a746e2 --- /dev/null +++ b/ai_sale_product_matcher/i18n/tr.po @@ -0,0 +1,400 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * ai_sale_product_matcher +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-30 23:24+0000\n" +"PO-Revision-Date: 2026-08-21 19:00:00+0000\n" +"Last-Translator: Volkan Taşcı \n" +"Language-Team: Turkish\n" +"Language: tr_TR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__ai_connection_id +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_connection_id +msgid "AI Connection" +msgstr "AI Bağlantısı" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_requirement_error +msgid "AI Error" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_filter_brand_id +msgid "AI Filter Brand" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_filter_categ_id +msgid "AI Filter Category" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_order_form_ai_matcher +msgid "AI Matches" +msgstr "AI Eşleşmeleri" + +#. module: ai_sale_product_matcher +#: model:ir.model,name:ai_sale_product_matcher.model_ai_product_match +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_product_match_form +msgid "AI Product Match" +msgstr "AI Ürün Eşleşmesi" + +#. module: ai_sale_product_matcher +#: model:ir.actions.act_window,name:ai_sale_product_matcher.action_ai_product_match +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_match_ids +#: model:ir.ui.menu,name:ai_sale_product_matcher.menu_ai_product_match +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_product_match_list +msgid "AI Product Matches" +msgstr "AI Ürün Eşleşmeleri" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_requirement_state +msgid "AI Requirement State" +msgstr "AI Gereksinim Durumu" + +#. module: ai_sale_product_matcher +#: model:ir.model,name:ai_sale_product_matcher.model_ai_sale_requirement_wizard +msgid "AI Requirement Wizard" +msgstr "AI Gereksinim Sihirbazı" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_product_match_form +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_product_match_list +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_order_form_ai_matcher +msgid "Add to Order" +msgstr "Siparişe Ekle" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "All brands" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "All categories" +msgstr "" + +#. module: ai_sale_product_matcher +#. odoo-python +#: code:addons/ai_sale_product_matcher/wizards/requirement_wizard.py:0 +msgid "Attach a file or enter requirement text." +msgstr "Bir dosya ekleyin veya gereksinim metni girin." + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__brand_id +msgid "Brand" +msgstr "Marka" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__categ_id +msgid "Category" +msgstr "Kategori" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Close" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__create_uid +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__create_date +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__currency_id +msgid "Currency" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__display_name +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__display_name +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__display_name +msgid "Display Name" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields.selection,name:ai_sale_product_matcher.selection__ai_sale_requirement_wizard__state__done +#: model:ir.model.fields.selection,name:ai_sale_product_matcher.selection__sale_order__ai_requirement_state__done +msgid "Done" +msgstr "Tamamlandı" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields.selection,name:ai_sale_product_matcher.selection__ai_sale_requirement_wizard__state__draft +msgid "Draft" +msgstr "Taslak" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,help:ai_sale_product_matcher.field_ai_sale_requirement_wizard__requirements_json +msgid "Edit before matching" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields.selection,name:ai_sale_product_matcher.selection__sale_order__ai_requirement_state__error +msgid "Error" +msgstr "Hata" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Extract with AI" +msgstr "AI ile Çıkar" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Extracted JSON (editable)" +msgstr "Çıkarılan JSON (düzenlenebilir)" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__requirements_json +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_requirement_json +msgid "Extracted Requirements (JSON)" +msgstr "Çıkarılan Gereksinimler (JSON)" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields.selection,name:ai_sale_product_matcher.selection__ai_sale_requirement_wizard__state__extracting +#: model:ir.model.fields.selection,name:ai_sale_product_matcher.selection__sale_order__ai_requirement_state__extracting +msgid "Extracting" +msgstr "Çıkarılıyor" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Filters (optional)" +msgstr "Filtreler (opsiyonel)" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Find Matches" +msgstr "Eşleşmeleri Bul" + +#. module: ai_sale_product_matcher +#. odoo-python +#: code:addons/ai_sale_product_matcher/models/sale_order.py:0 +#: model:ir.actions.act_window,name:ai_sale_product_matcher.action_ai_sale_requirement_wizard +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_order_form_ai_matcher +msgid "Find Products with AI" +msgstr "AI ile Ürün Bul" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__id +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__id +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__id +msgid "ID" +msgstr "" + +#. module: ai_sale_product_matcher +#. odoo-python +#: code:addons/ai_sale_product_matcher/wizards/requirement_wizard.py:0 +msgid "Invalid JSON: %s" +msgstr "Geçersiz JSON: %s" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_product_match_form +msgid "Key breakdown" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__write_uid +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__write_date +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__match_percent +msgid "Match %" +msgstr "Eşleşme %" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__match_count +msgid "Matched" +msgstr "Eşleşen" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__matched_keys +msgid "Matched Keys (JSON)" +msgstr "Eşleşen Anahtarlar (JSON)" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__match_ids +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Matches" +msgstr "Eşleşmeler" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__mismatched_keys +msgid "Mismatched Keys (JSON)" +msgstr "Uyuşmayan Anahtarlar (JSON)" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__missing_keys +msgid "Missing Keys (JSON)" +msgstr "Eksik Anahtarlar (JSON)" + +#. module: ai_sale_product_matcher +#. odoo-python +#: code:addons/ai_sale_product_matcher/wizards/requirement_wizard.py:0 +msgid "No AI Connection configured. Set one in Settings." +msgstr "" + +#. module: ai_sale_product_matcher +#. odoo-python +#: code:addons/ai_sale_product_matcher/models/sale_order.py:0 +msgid "No AI Connection configured. Set one in the wizard or Settings." +msgstr "" +"AI Bağlantısı yapılandırılmadı. Sihirbazda veya Ayarlar’da bir tane " +"ayarlayın." + +#. module: ai_sale_product_matcher +#. odoo-python +#: code:addons/ai_sale_product_matcher/wizards/requirement_wizard.py:0 +msgid "No matches found. Run Find Matches first." +msgstr "Hiç eşleşme bulunamadı. Önce Eşleşmeleri Bul’u çalıştırın." + +#. module: ai_sale_product_matcher +#: model:ir.model.fields.selection,name:ai_sale_product_matcher.selection__sale_order__ai_requirement_state__draft +msgid "Not processed" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,help:ai_sale_product_matcher.field_sale_order__ai_filter_brand_id +msgid "Optional brand filter for AI matching (top-level category)" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,help:ai_sale_product_matcher.field_sale_order__ai_filter_categ_id +msgid "Optional category filter for AI matching" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,help:ai_sale_product_matcher.field_ai_sale_requirement_wizard__brand_id +msgid "" +"Optional: limit matching to this brand (top-level category like " +"Nilfisk/Viper/Rottest)" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,help:ai_sale_product_matcher.field_ai_sale_requirement_wizard__categ_id +msgid "Optional: limit matching to this category" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__order_id +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__order_id +msgid "Order" +msgstr "Sipariş" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,help:ai_sale_product_matcher.field_ai_sale_requirement_wizard__requirement_text +msgid "Paste email, spec sheet text, etc." +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "" +"Paste requirement text, e.g. '120 bar soğuk sulu, 2.3kW monofaze, deterjan " +"tanklı'" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,help:ai_sale_product_matcher.field_ai_product_match__product_price +msgid "Price at which the product is sold to customers." +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__product_tmpl_id +msgid "Product Template" +msgstr "Ürün Şablonu" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__product_id +msgid "Product Variant" +msgstr "" + +#. module: ai_sale_product_matcher +#. odoo-python +#: code:addons/ai_sale_product_matcher/models/sale_order.py:0 +msgid "Provide a document or requirement text." +msgstr "Bir doküman veya gereksinim metni girin." + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Refresh" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__total_required +msgid "Required Keys" +msgstr "Gereken Anahtarlar" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__attachment_ids +msgid "Requirement Files" +msgstr "" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Requirement Files (PDF/Image)" +msgstr "Gereksinim Dosyaları (PDF/Görsel)" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__requirement_text +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_sale_order__ai_requirement_raw_text +msgid "Requirement Text" +msgstr "Gereksinim Metni" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "Requirements" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model,name:ai_sale_product_matcher.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__product_price +msgid "Sales Price" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_sale_requirement_wizard__state +msgid "State" +msgstr "Durum" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_order_form_ai_matcher +msgid "Top Matches" +msgstr "" + +#. module: ai_sale_product_matcher +#: model:ir.model.fields,field_description:ai_sale_product_matcher.field_ai_product_match__weighted_percent +msgid "Weighted %" +msgstr "Ağırlıklı %" + +#. module: ai_sale_product_matcher +#: model_terms:ir.ui.view,arch_db:ai_sale_product_matcher.view_ai_sale_requirement_wizard_form +msgid "{\"pressure_bar\": 120, \"phase\": \"Monofaze (M)\"}" +msgstr "" 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..74608ddd --- /dev/null +++ b/ai_sale_product_matcher/models/ai_product_match.py @@ -0,0 +1,102 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +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 (Odoo 19 uses _onchange_product_id) + line = self.env["sale.order.line"].create(line_vals) + if hasattr(line, "_onchange_product_id"): + try: + line._onchange_product_id() + except Exception: + _logger.debug( + "onchange failed while adding product %s to order %s", + self.product_id.id or self.product_tmpl_id.id, + order.id, + exc_info=True, + ) + elif hasattr(line, "product_id_change"): + try: + line.product_id_change() + except Exception: + _logger.debug( + "product_id_change failed while adding product %s to order %s", + self.product_id.id or self.product_tmpl_id.id, + order.id, + exc_info=True, + ) + 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..17553bcf --- /dev/null +++ b/ai_sale_product_matcher/models/sale_order.py @@ -0,0 +1,344 @@ +# 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, + ) + ai_filter_brand_id = fields.Many2one( + "product.category", + string="AI Filter Brand", + help="Optional brand filter for AI matching (top-level category)", + copy=False, + domain="[('parent_id', '=', False)]", + ) + ai_filter_categ_id = fields.Many2one( + "product.category", + string="AI Filter Category", + help="Optional category filter for AI matching", + copy=False, + domain="[('parent_id', '!=', 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, + "brand_id": self.ai_filter_brand_id.id or False, + "categ_id": self.ai_filter_categ_id.id or False, + } + ) + 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_images(self, attachment): + from ..services.image_preprocessor import prepare_images + + return prepare_images(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 + from ..services.image_preprocessor import extract_text_from_pdf + + messages = [] + all_image_paths = [] + extracted_texts = [] + attachments = self.env["ir.attachment"].browse(attachment_ids) + for att in attachments: + try: + # Multi-page support: prepare all pages + img_paths = self._ai_prepare_images(att) + for p in img_paths: + all_image_paths.append(p) + tmp_paths.append(p) + # Also extract text for PDFs as fallback/augmentation + txt = extract_text_from_pdf(att) + if txt: + extracted_texts.append(txt[:4000]) + except Exception as e: + _logger.warning("AI prepare image failed for %s: %s", att.name, e) + + # Combine extracted PDF text with user requirement_text + combined_text = "" + if extracted_texts: + combined_text += "\n\nPDF extracted text:\n" + "\n---\n".join( + extracted_texts + ) + if requirement_text and requirement_text.strip(): + combined_text += ( + "\n\nUser requirement text:\n" + requirement_text.strip() + ) + combined_text = combined_text.strip() + + if all_image_paths: + # Single vision message with all pages + combined text + # (sector-independent, always English prompt) + messages.append( + requirement_extractor.build_vision_message_multi( + all_image_paths, + connection, + requirement_text=combined_text, + env=self.env, + ) + ) + elif combined_text: + messages = [ + requirement_extractor.build_text_message( + combined_text, env=self.env + ) + ] + else: + raise UserError(self.env._("Provide a document or requirement text.")) + + # 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, env=self.env) + # Store and clear previous matches (user must click Find Matches + # for correct ranking) + self.write( + { + "ai_requirement_json": json.dumps( + data, ensure_ascii=False, indent=2 + ), + "ai_requirement_state": "done", + "ai_requirement_error": False, + } + ) + self.ai_match_ids.unlink() + # Notify bus - matching is now manual via Find Matches button + # (prevents wrong auto-matches) + self.env["bus.bus"]._sendone( + self.env.user.partner_id, + "ai_sale_product_matcher.requirement_done", + {"order_id": self.id, "state": "done"}, + ) + 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() + domain = [("sale_ok", "=", True)] + # Optional brand/category filters (sector-independent) + if self.ai_filter_brand_id: + # Brand is top-level category (Nilfisk/Viper/Rottest) — include its children + brand_children = self.env["product.category"].search( + [("parent_id", "=", self.ai_filter_brand_id.id)] + ).ids + [self.ai_filter_brand_id.id] + domain.append(("categ_id", "in", brand_children)) + if self.ai_filter_categ_id: + domain.append(("categ_id", "=", self.ai_filter_categ_id.id)) + 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, env=self.env) + 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, env=self.env) + 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..d4148156 --- /dev/null +++ b/ai_sale_product_matcher/readme/CONTEXT.md @@ -0,0 +1,5 @@ +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..7f1965bd --- /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..0d10f828 --- /dev/null +++ b/ai_sale_product_matcher/readme/DESCRIPTION.md @@ -0,0 +1,13 @@ +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..65ee1caf --- /dev/null +++ b/ai_sale_product_matcher/readme/USAGE.md @@ -0,0 +1,7 @@ +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..a13b2b81 --- /dev/null +++ b/ai_sale_product_matcher/services/catalog_schema.py @@ -0,0 +1,132 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging +import os + +_logger = logging.getLogger(__name__) + +# 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, +} + +# Fallback static schema (for tests / sector-independent demo with cleaning machines) +_CATALOG_SCHEMA = None + + +def _load_catalog_schema(): + global _CATALOG_SCHEMA + if _CATALOG_SCHEMA is not None: + return _CATALOG_SCHEMA + bundle = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "data", "catalog_schema.json" + ) + if os.path.exists(bundle): + with open(bundle, encoding="utf-8") as f: + _CATALOG_SCHEMA = json.load(f) + return _CATALOG_SCHEMA + _CATALOG_SCHEMA = {"attributes": [], "groups": [], "sets": []} + return _CATALOG_SCHEMA + + +def get_attributes(env=None): + """Return catalog attributes; merge live PIM attributes when env is given. + + The bundled JSON schema is the primary source so labels stay stable; + live PIM keys are merged in when they add new entries. + """ + file_attrs = _load_catalog_schema().get("attributes", []) + if env is None: + return file_attrs + try: + live_attrs = env["attribute.attribute"].search([]) + if not live_attrs: + return file_attrs + live_keys = set() + for attr in live_attrs: + key = ( + attr.field_id.name[2:] + if attr.field_id and attr.field_id.name.startswith("x_") + else attr.name + ) + live_keys.add(key) + file_keys = set(a.get("key") for a in file_attrs) + if live_keys == file_keys: + return file_attrs + merged = {a["key"]: a for a in file_attrs} + for attr in live_attrs: + key = ( + attr.field_id.name[2:] + if attr.field_id and attr.field_id.name.startswith("x_") + else attr.name + ) + if key not in merged: + merged[key] = { + "key": key, + "label_tr": attr.field_id.field_description + if attr.field_id + else attr.name, + "label_en": attr.name, + "type": attr.attribute_type, + "group": "genel", + } + return list(merged.values()) + except Exception: + return file_attrs + + +def get_groups(env=None): + if env is not None: + try: + groups = env["attribute.group"].search([]) + if groups: + return [{"name": g.name} for g in groups] + except Exception: + _logger.debug("Could not read live attribute groups", exc_info=True) + return _load_catalog_schema().get("groups", []) + + +def get_sets(env=None): + if env is not None: + try: + sets = env["attribute.set"].search([]) + if sets: + return [{"name": s.name} for s in sets] + except Exception: + _logger.debug("Could not read live attribute sets", exc_info=True) + return _load_catalog_schema().get("sets", []) + + +def get_schema_prompt_block(env=None): + """Build compressed prompt block: key | label | type | group. + + Always English prompt, sector-independent: keys are PIM attribute keys. + Labels are Turkish (from PIM) to help LLM map Turkish specs to English keys. + """ + attrs = get_attributes(env=env) + lines = [] + for attr in attrs: + key = attr.get("key", "") + # Prefer Turkish label for Turkish specs, even though prompt is English + label = attr.get("label_tr") or attr.get("label_en") or key + typ = attr.get("type", "char") + group = attr.get("group", "genel") + 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, env=None): + """Return attribute metadata dict for key or None.""" + for attr in get_attributes(env=env): + 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..fbc9b8ac --- /dev/null +++ b/ai_sale_product_matcher/services/image_preprocessor.py @@ -0,0 +1,154 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +import os +import re +import tempfile + +_logger = logging.getLogger(__name__) + +_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: + _logger.debug("Could not remove tmp file %s", path, exc_info=True) + return resized + + +def prepare_image(attachment): + """Backward compat: return first page only.""" + paths = prepare_images(attachment, max_pages=1) + return paths[0] if paths else None + + +def prepare_images(attachment, max_pages=4): + """Render attachment to list of resized PNG paths (multi-page PDF support).""" + data = attachment.with_context(bin_size=False).raw + ext = file_extension(attachment.name) + handle, file_path = tempfile.mkstemp(suffix=f".{ext}") + os.close(handle) + tmp_paths = [] + try: + with open(file_path, "wb") as f: + f.write(data) + if ext == "pdf": + from pdf2image import convert_from_path + + try: + # Get page count to limit + from pdf2image import pdfinfo_from_path + + info = pdfinfo_from_path(file_path) + total_pages = int(info.get("Pages", 1)) + except Exception: + total_pages = max_pages + pages_to_render = min(total_pages, max_pages) + images = convert_from_path( + file_path, dpi=200, first_page=1, last_page=pages_to_render + ) + if not images: + raise ValueError("The PDF could not be rendered.") + try: + os.unlink(file_path) + except OSError: + _logger.debug("Could not remove tmp file %s", file_path, exc_info=True) + for idx, image in enumerate(images): + png_path = f"{file_path}.page{idx}.png" + image.save(png_path, "PNG") + resized = resize_image(png_path) + tmp_paths.append(resized) + return tmp_paths + else: + resized = resize_image(file_path) + return [resized] + except Exception: + cleanup_tmp(file_path) + for p in tmp_paths: + cleanup_tmp(p) + raise + + +def extract_text_from_pdf(attachment): + """Extract text from PDF via pdfminer as fallback (for spec sheets).""" + if file_extension(attachment.name) != "pdf": + return "" + data = attachment.with_context(bin_size=False).raw + handle, file_path = tempfile.mkstemp(suffix=".pdf") + os.close(handle) + try: + with open(file_path, "wb") as f: + f.write(data) + try: + from pdfminer.high_level import extract_text + + text = extract_text(file_path) or "" + return text.strip() + except ImportError: + return "" + except Exception: + return "" + finally: + try: + os.unlink(file_path) + except OSError: + _logger.debug("Could not remove tmp file %s", file_path, exc_info=True) + + +def cleanup_tmp(path): + 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) + # Also try page variants + for i in range(10): + cand = f"{path}.page{i}.png" + if os.path.exists(cand): + try: + os.unlink(cand) + except OSError: + _logger.debug("Could not remove tmp %s", cand, exc_info=True) + cand2 = f"{cand}.resized.png" + if os.path.exists(cand2): + try: + os.unlink(cand2) + except OSError: + _logger.debug("Could not remove tmp %s", cand2, exc_info=True) + + +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..93d108c8 --- /dev/null +++ b/ai_sale_product_matcher/services/product_matcher.py @@ -0,0 +1,290 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +import re + +from .catalog_schema import GROUP_WEIGHTS, get_attribute_meta + +_logger = logging.getLogger(__name__) + +try: + from rapidfuzz import fuzz +except ImportError: + fuzz = None + +# Keys where larger is better (en az) +AT_LEAST_KEYS = { + "clean_water_tank_l", + "dirty_water_tank_l", + "tank_capacity_l", + "brush_motor_power_w", + "vacuum_motor_power_w", + "motor_power_w", + "brush_diameter_mm", + "brush_pressure_kg", + "brush_speed_rpm", + "productivity_m2_h", + "suction_power_w", + "suction_power", + "cleaning_area_per_charge_m2", +} + +# Keys where smaller is better (en fazla / fazla olmamalı) +AT_MOST_KEYS = { + "gross_weight_kg", + "weight_kg", + "noise_db", + "noise_level_db", + "fuel_consumption_kg_h", + "electricity_consumption_kwh", +} + + +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(",", ".") + 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 _extract_number(value): + """Extract first numeric value from string like '400 Watt' or '67 Desibel'.""" + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + s = value.strip().replace(",", ".") + # Handle range first + if _parse_range(s): + return None + m = re.search(r"([0-9]+(?:\.[0-9]+)?)", s) + if m: + try: + return float(m.group(1)) + except ValueError: + return None + try: + return float(s) + except ValueError: + return None + return None + + +def _to_float(value): + return _extract_number(value) + + +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, key=None): + """Match numeric requirement against product value which may be scalar or range. + + For AT_LEAST_KEYS (en az), prod >= req is considered match even if diff > tolerance. + For AT_MOST_KEYS (en fazla), prod <= req is considered match. + """ + if req_val is None or prod_val is None: + return False + # Handle string with units -> extract number + 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 + 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 + 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) + 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 + # Directional logic for spec sheets (en az / en fazla) + if key in AT_LEAST_KEYS: + # Product should be >= requirement (allow small tolerance below) + if prod_num >= req_num * 0.95: + return True + if key in AT_MOST_KEYS: + if prod_num <= req_num * 1.05: + return True + diff = abs(req_num - prod_num) / abs(req_num) + return diff <= tolerance + return _char_match(req_val, prod_val) + + +def is_match(key, req_val, prod_val, env=None): + """Type-aware comparison for a single key. Sector-independent via env.""" + meta = get_attribute_meta(key, env=env) + typ = meta.get("type", "char") if meta else "char" + if prod_val is None or prod_val == "" or prod_val is False: + return False + # Treat 0 / 0.0 as missing for tank/weight etc. (import artifact) + if isinstance(prod_val, (int, float)) and prod_val == 0: + # Check if key is numeric tank/weight - 0 means missing + if key in AT_LEAST_KEYS or key in AT_MOST_KEYS or typ in ("integer", "float"): + return False + if typ == "boolean": + 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, key=key) + if typ == "selection": + return _char_match(req_val, prod_val, threshold=90) + # char, text, etc. - try numeric directional first if both look numeric + if _extract_number(req_val) is not None and _extract_number(prod_val) is not None: + if key in AT_LEAST_KEYS or key in AT_MOST_KEYS: + return _numeric_match(req_val, prod_val, key=key) + return _char_match(req_val, prod_val) + + +def get_product_value(product, key): + """Read canonical key from product.template (sparse x_ or JSON attrs).""" + field_name = f"x_{key}" + if field_name in product._fields: + val = product[field_name] + if val is False or val is None: + pass + else: + if hasattr(val, "name"): + return val.name if val else None + # Treat 0 as missing for numeric fields where 0 is not realistic + if isinstance(val, (int, float)) and val == 0: + # Keep 0 for now, but is_match will treat as missing + return val + return val + try: + blob = product.x_custom_json_attrs or {} + if isinstance(blob, dict) and key in blob: + return blob[key] + if isinstance(blob, dict) and field_name in blob: + return blob[field_name] + except AttributeError: + _logger.debug( + "No custom JSON attributes on product %s", product.id, exc_info=True + ) + try: + if key in product: + return product[key] + except Exception: + _logger.debug( + "Cannot read key %s on product %s", key, product.id, exc_info=True + ) + return None + + +def score_product(requirements, product, env=None): + """Score a single product against requirements dict. Returns match dict. + + Sector-independent: uses live PIM meta when env given. + Missing keys (prod_val None/0) are neutral — they don't penalize percent. + """ + 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, env=env) + group = meta.get("group", "genel") if meta else "genel" + weight = GROUP_WEIGHTS.get(group, 1.0) + prod_val = get_product_value(product, key) + is_missing = prod_val is None or prod_val == "" or prod_val is False + if not is_missing and isinstance(prod_val, (int, float)) and prod_val == 0: + if key in AT_LEAST_KEYS or key in AT_MOST_KEYS: + is_missing = True + if is_missing: + missing.append(key) + continue + weighted_total += weight + if is_match(key, req_val, prod_val, env=env): + matched.append(key) + weighted_score += weight + else: + mismatched.append(key) + total_present = len(matched) + len(mismatched) + total = len(requirements) + # Percent over present keys; if all missing, percent 0 + percent = (len(matched) / total_present * 100) if total_present else 0.0 + weighted_percent = ( + (weighted_score / weighted_total * 100) if weighted_total else 0.0 + ) + return { + "match_count": len(matched), + "total": total, + "present_total": total_present, + "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, env=None): + """Score all products and return top N. + + Sector-independent, always English prompt. Ranking prioritizes absolute + matched count first (ideal product must be top), then weighted percent. + This prevents a product with 5/5 (100%) outranking 8/9 (88%) with more evidence. + """ + scored = [] + for product in products: + score = score_product(requirements, product, env=env) + scored.append((product, score)) + scored.sort( + key=lambda x: (x[1]["match_count"], x[1]["weighted_percent"], x[1]["percent"]), + 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..7fd74265 --- /dev/null +++ b/ai_sale_product_matcher/services/requirement_extractor.py @@ -0,0 +1,231 @@ +# 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, OMIT the key " + "— never output null and 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'. " + "CRITICAL: Output ONLY the JSON object with ONLY the keys that have a clear " + "value in the document; DO NOT include keys with null; keep output under 20 " + "keys; no markdown or extra text." +) + + +def _build_user_prompt(requirement_text=None, env=None): + # Sector-independent, always English: fetch live PIM keys when env is available + schema_block = get_schema_prompt_block(env=env) + 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 clearly expressed with a " + "value; DO NOT include keys with null, unknown or missing values; " + "DO NOT invent keys; skip irrelevant keys. " + "Values must respect the type hint (integer, float, boolean, char, " + "selection). " + "Output ONLY the JSON object, no markdown, max 20 keys." + ) + + +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]}") + + +_DROP = object() +_TRUE_WORDS = ("true", "evet", "yes", "1", "var") +_FALSE_WORDS = ("false", "hayır", "hayir", "no", "0", "yok") + + +def _is_range_string(value): + return ( + isinstance(value, str) + and "-" in value + and value.replace("-", "").replace(".", "").replace(" ", "").isdigit() + ) + + +def _coerce_boolean(value): + if isinstance(value, bool): + return value + if isinstance(value, str): + low = value.strip().lower() + if low in _TRUE_WORDS: + return True + if low in _FALSE_WORDS: + return False + return _DROP + if isinstance(value, int): + return bool(value) + return _DROP + + +def _coerce_integer(value): + if isinstance(value, bool): + return _DROP + try: + if _is_range_string(value): + # Range string - keep as char-like + return value.strip() + return int(float(str(value).replace(",", ".").strip())) + except (ValueError, TypeError): + return _DROP + + +def _coerce_float(value): + if isinstance(value, bool): + return _DROP + if isinstance(value, str) and "-" in value: + # Keep range like "250 - 540" as string for matching logic + cleaned = value.strip() + if any(c.isdigit() for c in cleaned): + return cleaned + try: + return float(str(value).replace(",", ".").strip()) + except (ValueError, TypeError): + return _DROP + + +def _coerce_char(value): + if isinstance(value, bool): + return str(value) + # Note: an empty string yields None here, matching the historical behavior + return str(value).strip() if str(value).strip() else None + + +_COERCERS = { + "boolean": _coerce_boolean, + "integer": _coerce_integer, + "float": _coerce_float, +} + + +def _validate_requirements(data, env=None): + """Drop hallucinated keys and coerce types lightly; keep nulls out.""" + valid = {} + for key, value in data.items(): + if value is None: + continue + meta = get_attribute_meta(key, env=env) + if not meta: + # Hallucinated key - drop + continue + coerce = _COERCERS.get(meta.get("type", "char"), _coerce_char) + result = coerce(value) + if result is not _DROP: + valid[key] = result + return valid + + +def parse_and_validate(content, env=None): + data = _parse_json_response(content) + return _validate_requirements(data, env=env) + + +def _encode_image(image_path, connection): + is_ollama = "ollama" in (connection.url or "").lower() + if is_ollama: + with open(image_path, "rb") as f: + encoded = base64.b64encode(f.read()).decode() + 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 mime, encoded + + +def build_vision_message(image_path, connection, requirement_text=None, env=None): + """Build OpenAI-compatible vision message with image + requirement text.""" + mime, encoded = _encode_image(image_path, connection) + 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, env=env), + }, + ], + } + + +def build_vision_message_multi( + image_paths, connection, requirement_text=None, env=None +): + """Build a single user message carrying multiple page images (multi-page PDFs).""" + content = [] + for path in image_paths: + mime, encoded = _encode_image(path, connection) + content.append( + { + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{encoded}"}, + } + ) + content.append( + { + "type": "text", + "text": _build_user_prompt(requirement_text=requirement_text, env=env), + } + ) + return {"role": "user", "content": content} + + +def build_text_message(requirement_text, env=None): + return { + "role": "user", + "content": _build_user_prompt(requirement_text=requirement_text, env=env), + } 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 00000000..3a0328b5 Binary files /dev/null and b/ai_sale_product_matcher/static/description/icon.png differ 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.esm.js b/ai_sale_product_matcher/static/src/sale_order_ai_matcher.esm.js new file mode 100644 index 00000000..4abeea3d --- /dev/null +++ b/ai_sale_product_matcher/static/src/sale_order_ai_matcher.esm.js @@ -0,0 +1,24 @@ +/** @odoo-module **/ + +import {registry} from "@web/core/registry"; + +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 gereksinimleri çıkarıldı — sayfayı yenileyin veya wizard'da Refresh yapın.", + {type: "success"} + ); + } 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..3a17e5c0 --- /dev/null +++ b/ai_sale_product_matcher/tests/test_product_matcher.py @@ -0,0 +1,54 @@ +# 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 - 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..520f005f --- /dev/null +++ b/ai_sale_product_matcher/tests/test_requirement_extractor.py @@ -0,0 +1,39 @@ +# 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 that + # 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..e234302c --- /dev/null +++ b/ai_sale_product_matcher/views/ai_product_match_views.xml @@ -0,0 +1,83 @@ + + + + ai.product.match.list + ai.product.match + + + + + + + + +