Public release from ruodoo-project: 19.0 - 2026-05-10 21:19:01 UTC

This commit is contained in:
CI Publish Bot
2026-05-10 21:19:11 +00:00
commit cbf9e6e6d6
1213 changed files with 183945 additions and 0 deletions

110
dadata_connector/README.rst Normal file
View File

@ -0,0 +1,110 @@
================
DaData Connector
================
.. |badge1| image:: https://img.shields.io/badge/maturity-Production-green.png
:target: https://odoo-community.org/page/development-status
|badge1|
Модуль интеграции с сервисом `DaData <https://dadata.ru>`_ для автоматического
заполнения реквизитов контрагентов по ИНН или ОГРН.
**Возможности:**
- Поиск юридических лиц и ИП по ИНН через API DaData
- Автоматическое заполнение реквизитов партнёра: название, адрес, КПП, ОКПО, ОКВЭД, ОГРН, организационно-правовая форма
- Создание контактного лица (руководителя) при наличии данных об управлении
- Виджет поиска ``dadata_search`` на полях ``vat`` и ``ogrn`` в форме партнёра
- Настройка токена DaData через Настройки → Общие настройки → Интеграции
**Таблица содержания**
.. contents::
:local:
Использование
=============
1. Перейдите в **Настройки → Общие настройки → Интеграции** и укажите токен DaData.
2. Откройте форму контрагента.
3. Введите ИНН в поле **Tax ID** (или ОГРН в поле **ОГРН**).
4. Нажмите кнопку с лупой рядом с полем.
5. В открывшемся диалоге проверьте найденные данные и нажмите **Да** для применения.
Конфигурация
============
Токен DaData задаётся через системный параметр ``dadata_connector.dadata_token``
или через интерфейс настроек.
Получить токен можно в личном кабинете `dadata.ru <https://dadata.ru>`_.
Известные ограничения
=====================
- Поиск работает только для российских юридических лиц и ИП.
- Требуется установленная Python-библиотека ``dadata==21.10.1``.
- Модуль зависит от ``l10n_ru_doc`` для отображения российских реквизитов.
Тесты
=====
Запуск::
python odoo-bin -d <db> --test-tags dadata_connector
**common.py** — базовый класс ``DadataConnectorCommon`` и фикстуры:
- ``DADATA_LEGAL_RESPONSE`` — эталонный ответ API для юридического лица (ПАО)
- ``DADATA_INDIVIDUAL_RESPONSE`` — эталонный ответ API для индивидуального предпринимателя
- ``DadataConnectorCommon.setUpClass`` — создаёт тестового партнёра и прописывает тестовый токен в системные параметры
**test_res_partner.py** — тесты модели ``res.partner``:
- ``test_get_dadata_token_returns_token`` — токен возвращается, если системный параметр задан
- ``test_get_dadata_token_raises_when_missing````ValidationError`` при отсутствии токена
- ``test_parse_legal_entity_basic_fields`` — парсинг ИНН, ОГРН, КПП, ОКПО, ОКВЭД, названия, города, индекса и улицы для юрлица
- ``test_parse_legal_entity_company_form`` — код ОПФ ``12300`` маппится в ``plc``
- ``test_parse_legal_entity_wizard_data`` — данные для wizard: статус, тип организации, название, адрес
- ``test_parse_legal_entity_management`` — извлечение имени и должности руководителя
- ``test_parse_legal_entity_fts_registration`` — серия и номер свидетельства ФНС склеиваются через пробел
- ``test_parse_legal_entity_country_and_state`` — страна резолвится по ISO-коду из ``res.country``
- ``test_parse_individual_name`` — ФИО ИП собирается из частей ``fio``
- ``test_parse_individual_no_kpp`` — КПП не попадает в результат для ИП
- ``test_parse_individual_no_management`` — ключ ``management`` отсутствует, если данных нет
- ``test_parse_individual_no_fts_registration````sp_register_number`` отсутствует при ``fts_registration: null``
- ``test_get_legal_entity_data_returns_action`` — при ``widget=True`` возвращается ``ir.actions.act_window`` с wizard (DaData замокан)
- ``test_get_legal_entity_data_returns_dict_when_no_widget`` — при ``widget=False`` возвращается словарь с реквизитами
- ``test_get_legal_entity_data_raises_when_empty````ValidationError`` при пустом ответе DaData
- ``test_get_legal_entity_data_raises_on_http_error````ValidationError`` при ``HTTPStatusError`` от DaData
- ``test_get_view_sets_dadata_search_widget_on_vat````_get_view`` проставляет ``widget="dadata_search"`` на поле ``vat``
**test_wizard.py** — тесты ``res.partner.auto_data.wizard``:
- ``test_wizard_creation`` — wizard создаётся с корректными значениями полей
- ``test_button_yes_returns_close_action````button_yes`` возвращает ``act_window_close`` с флагом ``update: True``
- ``test_wizard_status_selection_values`` — все пять значений статуса принимаются без ошибок
- ``test_wizard_organization_type_individual`` — тип ``individual`` сохраняется корректно
Авторы
======
* MK.lab
Разработчики
============
* MK.lab
Changelog
=========
19.0.2026.04.10
~~~~~~~~~~~~~~~
* Портирование на Odoo 19
* Замена ``_lt`` на ``_t`` (убран в Odoo 18+)
* Виджет переименован в ``dadata_search`` во избежание конфликтов
* Переопределение ``_get_view`` для перекрытия ``partner_autocomplete``
* Удалено поле ``psrn_sp`` из логики парсинга

View File

@ -0,0 +1,2 @@
from . import models
from . import wizard

View File

@ -0,0 +1,22 @@
{
"name": "DaData Connector",
"summary": """Obtaining data on legal entities from the DaData service""",
"author": "MK.lab",
"website": "#",
"category": "Marketing",
"version": "19.0.2025.12.03",
"depends": ["base", "web", "contacts", "account", "l10n_ru_doc"],
"external_dependencies": {"python": ["dadata==21.10.1"]},
"data": [
"security/ir.model.access.csv",
"views/res_partner_views.xml",
"wizard/res_partner_auto_data_wizard_views.xml",
"views/res_config_settings_view.xml",
],
"assets": {
"web.assets_backend": [
"dadata_connector/static/src/views/fields/search/*",
],
},
"installable": True,
}

225
dadata_connector/i18n/ru.po Normal file
View File

@ -0,0 +1,225 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * dadata_connector
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-07-18 05:07+0000\n"
"PO-Revision-Date: 2024-07-18 05:07+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: dadata_connector
#: model:ir.model.fields.selection,name:dadata_connector.selection__res_partner_auto_data_wizard__status__active
msgid "Active"
msgstr "Действующая"
#. module: dadata_connector
#: model:ir.model.fields.selection,name:dadata_connector.selection__res_partner_auto_data_wizard__status__bankrupt
msgid "Bankrupt"
msgstr "Банкротство"
#. module: dadata_connector
#: model:ir.model,name:dadata_connector.model_res_config_settings
msgid "Config Settings"
msgstr "Конфигурационные настройки"
#. module: dadata_connector
#: model:ir.model,name:dadata_connector.model_res_partner
msgid "Contact"
msgstr "Контакт"
#. module: dadata_connector
#: model:ir.model.fields,field_description:dadata_connector.field_res_partner_auto_data_wizard__create_uid
msgid "Created by"
msgstr ""
#. module: dadata_connector
#: model:ir.model.fields,field_description:dadata_connector.field_res_partner_auto_data_wizard__create_date
msgid "Created on"
msgstr ""
#. module: dadata_connector
#: model:ir.model.fields,field_description:dadata_connector.field_res_config_settings__dadata_token
msgid "DaData token"
msgstr ""
#. module: dadata_connector
#. odoo-javascript
#: code:addons/dadata_connector/static/src/views/fields/search/search_field.js:0
#: code:addons/local_addons/dadata_connector/static/src/views/fields/search/search_field.js:0
#, python-format
msgid "Data updated."
msgstr "Данные обновлены."
#. module: dadata_connector
#: model:ir.model.fields,field_description:dadata_connector.field_res_partner_auto_data_wizard__display_name
msgid "Display Name"
msgstr ""
#. module: dadata_connector
#. odoo-python
#: code:addons/dadata_connector/models/res_partner.py:0
#: code:addons/local_addons/dadata_connector/models/res_partner.py:0
#, python-format
msgid ""
"Failed to connect to DaData server. The token in the settings may be "
"incorrect."
msgstr ""
"Не удалось подключиться к серверу DaData. Возможно, токен в настройках "
"указан не верно."
#. module: dadata_connector
#: model:ir.model.fields,field_description:dadata_connector.field_res_partner_auto_data_wizard__full_address
msgid "Full legal address"
msgstr "Юридический адрес"
#. module: dadata_connector
#: model:ir.model.fields,field_description:dadata_connector.field_res_partner_auto_data_wizard__name
msgid "Full name"
msgstr "Наименование"
#. module: dadata_connector
#: model:ir.model.fields,field_description:dadata_connector.field_res_partner_auto_data_wizard__id
msgid "ID"
msgstr ""
#. module: dadata_connector
#: model:ir.model.fields,field_description:dadata_connector.field_res_partner_auto_data_wizard__vat
msgid "Identification Number"
msgstr "ИНН"
#. module: dadata_connector
#: model:ir.model.fields,help:dadata_connector.field_res_partner_auto_data_wizard__vat
msgid "Identification Number for selected type"
msgstr ""
#. module: dadata_connector
#: model:ir.model.fields.selection,name:dadata_connector.selection__res_partner_auto_data_wizard__organization_type__individual
msgid "Individual entrepreneur"
msgstr "Индивидуальный предприниматель"
#. module: dadata_connector
#: model:ir.model.fields,field_description:dadata_connector.field_res_partner_auto_data_wizard____last_update
msgid "Last Modified on"
msgstr ""
#. module: dadata_connector
#: model:ir.model.fields,field_description:dadata_connector.field_res_partner_auto_data_wizard__write_uid
msgid "Last Updated by"
msgstr ""
#. module: dadata_connector
#: model:ir.model.fields,field_description:dadata_connector.field_res_partner_auto_data_wizard__write_date
msgid "Last Updated on"
msgstr ""
#. module: dadata_connector
#: model:ir.model.fields.selection,name:dadata_connector.selection__res_partner_auto_data_wizard__organization_type__legal
msgid "Legal entity"
msgstr "Юридическое лицо"
#. module: dadata_connector
#: model:ir.model.fields,help:dadata_connector.field_res_partner_auto_data_wizard__organization_type
msgid "Legal entity or individual entrepreneur"
msgstr "Юридическое лицо или индивидуальный предприниматель"
#. module: dadata_connector
#: model:ir.model.fields.selection,name:dadata_connector.selection__res_partner_auto_data_wizard__status__liquidated
msgid "Liquidated"
msgstr "Ликвидирована"
#. module: dadata_connector
#: model:ir.model.fields.selection,name:dadata_connector.selection__res_partner_auto_data_wizard__status__liquidating
msgid "Liquidating"
msgstr "Ликвидируется"
#. module: dadata_connector
#: model_terms:ir.ui.view,arch_db:dadata_connector.res_partner_auto_data_wizard_view_form
msgid "No"
msgstr "Нет"
#. module: dadata_connector
#. odoo-python
#: code:addons/dadata_connector/models/res_partner.py:0
#: code:addons/local_addons/dadata_connector/models/res_partner.py:0
#, python-format
msgid "No data found for the organization"
msgstr "Не найдены данные для организации"
#. module: dadata_connector
#: model:ir.model.fields,field_description:dadata_connector.field_res_partner_auto_data_wizard__partner_id
msgid "Partner"
msgstr "Контакт"
#. module: dadata_connector
#: model:ir.model.fields.selection,name:dadata_connector.selection__res_partner_auto_data_wizard__status__reorganizing
msgid "Reorganizing"
msgstr ""
"В процессе присоединения к другому юр. лицу, с последующей ликвидацией"
#. module: dadata_connector
#. odoo-javascript
#: code:addons/dadata_connector/static/src/views/fields/search/search_field.js:0
#: code:addons/dadata_connector/static/src/views/fields/search/search_field.xml:0
#: code:addons/dadata_connector/static/src/views/fields/search/search_field.xml:0
#: code:addons/local_addons/dadata_connector/static/src/views/fields/search/search_field.js:0
#: code:addons/local_addons/dadata_connector/static/src/views/fields/search/search_field.xml:0
#: code:addons/local_addons/dadata_connector/static/src/views/fields/search/search_field.xml:0
#, python-format
msgid "Search"
msgstr "Поиск"
#. module: dadata_connector
#. odoo-python
#: code:addons/dadata_connector/models/res_partner.py:0
#: code:addons/local_addons/dadata_connector/models/res_partner.py:0
#, python-format
msgid "Set these details for the current contact?"
msgstr "Установить данные реквизиты для текущего контакта?"
#. module: dadata_connector
#: model:ir.model.fields,field_description:dadata_connector.field_res_partner_auto_data_wizard__status
msgid "Status"
msgstr "Статус"
#. module: dadata_connector
#. odoo-python
#: code:addons/dadata_connector/models/res_partner.py:0
#: code:addons/local_addons/dadata_connector/models/res_partner.py:0
#, python-format
msgid ""
"The token for DaData is not specified in the settings. (Settings - General "
"settings - Integrations - DaData token)"
msgstr ""
"В настройках не указан токен для DaData. (Настройки - Общие настройки - "
"Интеграции - DaData token)"
#. module: dadata_connector
#: model:ir.model.fields,field_description:dadata_connector.field_res_partner_auto_data_wizard__organization_type
msgid "Type of organization"
msgstr "Тип организации"
#. module: dadata_connector
#. odoo-python
#: code:addons/dadata_connector/models/res_partner.py:0
#: code:addons/local_addons/dadata_connector/models/res_partner.py:0
#, python-format
msgid "Unknown organization type"
msgstr "Неизвестный тип организации"
#. module: dadata_connector
#: model:ir.model,name:dadata_connector.model_res_partner_auto_data_wizard
msgid "Wizard for autofilling partner"
msgstr "Визард для автозаполнения контактов"
#. module: dadata_connector
#: model_terms:ir.ui.view,arch_db:dadata_connector.res_partner_auto_data_wizard_view_form
msgid "Yes"
msgstr "Да"

View File

@ -0,0 +1,2 @@
from . import res_config_settings
from . import res_partner

View File

@ -0,0 +1,10 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
dadata_token = fields.Char(
string="DaData token",
config_parameter="dadata_connector.dadata_token",
)

View File

@ -0,0 +1,165 @@
from dadata import Dadata
from httpx import HTTPStatusError
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
okopf = {
"50102": "sp",
"11000": "pshp",
"11051": "pshp",
"11064": "pshp",
"20700": "pshp",
"20701": "pshp",
"20716": "pshp",
"30006": "pshp",
# "": "coop", # todo Cooperative
"12300": "plc",
"12200": "jsc",
"12247": "pc",
"12267": "сjsc",
# "": "ga", # todo Government agency
}
class ResPartner(models.Model):
_inherit = "res.partner"
@api.model
def _get_view(self, view_id=None, view_type="form", **options):
arch, view = super()._get_view(view_id, view_type, **options)
if view_type == "form":
for node in arch.xpath("//field[@name='vat']"):
node.set("widget", "dadata_search")
return arch, view
def get_legal_entity_data(self, vat, widget=True):
token = self.get_dadata_token()
dadata = Dadata(token)
try:
result = dadata.find_by_id("party", vat, branch_type="MAIN")
except HTTPStatusError:
raise ValidationError(
_(
"Failed to connect to DaData server. The token in the settings may be incorrect."
)
)
if result:
wizard_data, new_data = self._parse_dadata_response(result)
if widget:
wizard = self.env["res.partner.auto_data.wizard"].create(wizard_data)
return {
"type": "ir.actions.act_window",
"target": "new",
"name": _("Set these details for the current contact?"),
"views": [(False, "form")],
"view_mode": "form",
"res_model": wizard._name,
"res_id": wizard.id,
"context": new_data,
}
else:
return new_data
else:
raise ValidationError(_("No data found for the organization"))
@api.model
def get_dadata_token(self):
token = (
self.env["ir.config_parameter"]
.sudo()
.get_param("dadata_connector.dadata_token")
)
if token:
return token
else:
raise ValidationError(
_(
"The token for DaData is not specified in the settings. (Settings - General settings - Integrations - DaData token)"
)
)
def _parse_dadata_response(self, data):
result = {}
wizard_data = {}
data = data[0]["data"]
# Data for widget
organization_type = data["type"].lower()
wizard_data["partner_id"] = self.id
wizard_data["status"] = data["state"]["status"].lower()
wizard_data["organization_type"] = organization_type
wizard_data["full_address"] = data["address"]["unrestricted_value"]
# Data for partner
result["vat"] = data["inn"]
result["okpo"] = data["okpo"]
result["arceat"] = data["okved"]
result["company_form"] = okopf.get(data["opf"]["code"])
result["ogrn"] = data["ogrn"]
if data["documents"] and data["documents"]["fts_registration"]:
result[
"sp_register_number"
] = f'{data["documents"]["fts_registration"]["series"]} {data["documents"]["fts_registration"]["number"]}'
result["sp_register_date"] = data["documents"]["fts_registration"][
"issue_date"
]
if organization_type == "legal":
result["kpp"] = data["kpp"]
# Name
if organization_type == "legal":
result["name"] = data["name"]["short_with_opf"]
wizard_data["name"] = data["name"]["short_with_opf"]
elif organization_type == "individual":
result[
"name"
] = f'{data["fio"]["surname"]} {data["fio"]["name"]} {data["fio"]["patronymic"]}'
wizard_data[
"name"
] = f'{data["fio"]["surname"]} {data["fio"]["name"]} {data["fio"]["patronymic"]}'
else:
raise ValidationError(_("Unknown organization type"))
# Address
address = data["address"]["data"]
country = self.env["res.country"].search(
[("code", "=", address["country_iso_code"])]
)
if country:
result["country_id"] = country.id
region = self.env["res.country.state"].search(
[
("code", "=", address["region_iso_code"].split("-")[-1]),
("country_id", "=", country.id),
]
)
if region:
result["state_id"] = region.id
result["city"] = address["city"]
street = []
for el in [
address["street_with_type"],
address["house_type_full"],
address["house"],
address["flat_type_full"],
address["flat"],
]:
if el:
street.append(el)
result["street"] = ", ".join(street)
result["zip"] = address["postal_code"]
if data.get("management"):
result["management"] = {
"manager_name": data["management"]["name"],
"manager_position": data["management"]["post"],
}
return wizard_data, result

View File

@ -0,0 +1,2 @@
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
"access_res_partner_auto_data_wizard","res_partner_auto_data_wizard user","model_res_partner_auto_data_wizard",,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_res_partner_auto_data_wizard res_partner_auto_data_wizard user model_res_partner_auto_data_wizard 1 1 1 1

View File

@ -0,0 +1,105 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _t } from "@web/core/l10n/translation";
import { useInputField } from "@web/views/fields/input_field_hook";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { CharField } from "@web/views/fields/char/char_field";
import { Component } from "@odoo/owl";
export class SearchField extends Component {
static template = "dadata_connector.SearchField";
static props = {
...standardFieldProps,
placeholder: { type: String, optional: true },
};
setup() {
useInputField({ getValue: () => this.props.record.data[this.props.name] || "" });
this.action = null;
}
async search() {
const record = this.props.record;
this.action = await this.env.services.orm.call(
"res.partner",
"get_legal_entity_data",
[record.resId],
{
vat: record.data[this.props.name],
}
);
await this.env.services.action.doAction(this.action, {
onClose: async (closeInfo) => {
if (closeInfo && closeInfo.update) {
const { management, ...rawData } = this.action.context;
// Only update fields that exist in the current record's field definitions.
// Many2one fields must be passed as [id, display_name] tuples for OWL record.update().
const newRecordData = {};
for (const [key, value] of Object.entries(rawData)) {
const field = record.fields[key];
if (!field) continue;
if (field.type === "many2one" && typeof value === "number") {
newRecordData[key] = [value, ""];
} else {
newRecordData[key] = value;
}
}
await record.update({
...newRecordData,
company_type: "company",
});
await record.save();
const recordChildren = record.data.child_ids.records;
if (management && !this._checkManagerExists(recordChildren, management)) {
await this._createManager(management);
}
await record.load();
this.env.services.notification.add(_t("Data updated."), {
type: "info",
});
}
},
});
}
_checkManagerExists(recordChildren, management) {
const managerName = management.manager_name;
const managerFunction = management.manager_position;
for (let rec of recordChildren) {
if (
rec.data.name.toUpperCase() === managerName.toUpperCase() &&
rec.data.function.toUpperCase() === managerFunction.toUpperCase()
)
return true;
}
return null;
}
async _createManager(management) {
const record = this.props.record;
await this.env.services.orm.call("res.partner", "create", [
{
name: management.manager_name,
function: management.manager_position,
parent_id: record.resId,
type: "contact",
},
]);
}
}
export const searchField = {
component: SearchField,
displayName: _t("DaData Search"),
supportedTypes: ["char"],
extractProps: ({ attrs, placeholder }) => ({
placeholder: attrs.placeholder || placeholder,
}),
};
registry.category("fields").add("dadata_search", searchField);

View File

@ -0,0 +1,4 @@
.o_search_content small {
overflow-wrap: normal;
word-break: normal;
}

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="dadata_connector.SearchField" owl="1">
<div class="o_search_content d-inline-flex w-100">
<t t-if="props.readonly">
<span t-esc="props.record.data[props.name]"/>
</t>
<t t-else="">
<input class="o_input flex-grow-1"
t-att-id="props.id"
type="text"
autocomplete="off"
t-att-placeholder="props.placeholder"
t-ref="input" />
<button
t-if="props.record.data[props.name]"
class="btn btn-secondary fa fa-search ms-1"
data-tooltip="Search in DaData"
aria-label="Search in DaData"
t-on-click="() => this.search()"/>
</t>
</div>
</t>
</templates>

View File

@ -0,0 +1,2 @@
from . import test_res_partner
from . import test_wizard

View File

@ -0,0 +1,99 @@
from odoo.tests.common import TransactionCase
# Minimal DaData API response fixture for a legal entity (ООО)
DADATA_LEGAL_RESPONSE = [
{
"data": {
"type": "LEGAL",
"inn": "7707083893",
"ogrn": "1027700132195",
"kpp": "773601001",
"okpo": "00032537",
"okved": "64.19",
"opf": {"code": "12300"},
"name": {
"short_with_opf": "ПАО Сбербанк",
"full_with_opf": "Публичное акционерное общество «Сбербанк России»",
},
"state": {"status": "ACTIVE"},
"address": {
"unrestricted_value": "117997, г Москва, ул Вавилова, д 19",
"data": {
"country_iso_code": "RU",
"region_iso_code": "RU-MOW",
"city": "Москва",
"street_with_type": "ул Вавилова",
"house_type_full": "дом",
"house": "19",
"flat_type_full": None,
"flat": None,
"postal_code": "117997",
},
},
"documents": {
"fts_registration": {
"series": "77",
"number": "004599035",
"issue_date": "2002-08-16",
}
},
"management": {
"name": "ГРЕФ ГЕРМАН ОСКАРОВИЧ",
"post": "ПРЕЗИДЕНТ, ПРЕДСЕДАТЕЛЬ ПРАВЛЕНИЯ",
},
}
}
]
# Minimal DaData API response fixture for an individual entrepreneur (ИП)
DADATA_INDIVIDUAL_RESPONSE = [
{
"data": {
"type": "INDIVIDUAL",
"inn": "500100732259",
"ogrn": "304500116000157",
"kpp": None,
"okpo": "0107544",
"okved": "47.91",
"opf": {"code": "50102"},
"fio": {
"surname": "ИВАНОВ",
"name": "ИВАН",
"patronymic": "ИВАНОВИЧ",
},
"state": {"status": "ACTIVE"},
"address": {
"unrestricted_value": "141001, Московская обл, г Мытищи",
"data": {
"country_iso_code": "RU",
"region_iso_code": "RU-MOS",
"city": "Мытищи",
"street_with_type": None,
"house_type_full": None,
"house": None,
"flat_type_full": None,
"flat": None,
"postal_code": "141001",
},
},
"documents": {"fts_registration": None},
"management": None,
}
}
]
class DadataConnectorCommon(TransactionCase):
"""Base class for dadata_connector tests."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env["res.partner"].create({
"name": "Test Partner",
"is_company": True,
})
cls.env["ir.config_parameter"].sudo().set_param(
"dadata_connector.dadata_token", "test_token_123"
)

View File

@ -0,0 +1,182 @@
from unittest.mock import MagicMock, patch
from odoo.exceptions import ValidationError
from odoo.tests.common import tagged
from .common import (
DADATA_INDIVIDUAL_RESPONSE,
DADATA_LEGAL_RESPONSE,
DadataConnectorCommon,
)
@tagged("post_install", "-at_install")
class TestResPartnerDadata(DadataConnectorCommon):
"""Tests for res.partner DaData integration methods."""
# ------------------------------------------------------------------
# get_dadata_token
# ------------------------------------------------------------------
def test_get_dadata_token_returns_token(self):
"""Token is returned when the system parameter is set."""
token = self.partner.get_dadata_token()
self.assertEqual(token, "test_token_123")
def test_get_dadata_token_raises_when_missing(self):
"""ValidationError is raised when token is not configured."""
self.env["ir.config_parameter"].sudo().set_param(
"dadata_connector.dadata_token", ""
)
with self.assertRaises(ValidationError):
self.partner.get_dadata_token()
# ------------------------------------------------------------------
# _parse_dadata_response — legal entity
# ------------------------------------------------------------------
def test_parse_legal_entity_basic_fields(self):
"""Parsing a legal entity response fills basic partner fields."""
wizard_data, result = self.partner._parse_dadata_response(DADATA_LEGAL_RESPONSE)
self.assertEqual(result["vat"], "7707083893")
self.assertEqual(result["ogrn"], "1027700132195")
self.assertEqual(result["kpp"], "773601001")
self.assertEqual(result["okpo"], "00032537")
self.assertEqual(result["arceat"], "64.19")
self.assertEqual(result["name"], "ПАО Сбербанк")
self.assertEqual(result["city"], "Москва")
self.assertEqual(result["zip"], "117997")
self.assertEqual(result["street"], "ул Вавилова, дом, 19")
def test_parse_legal_entity_company_form(self):
"""OPF code 12300 maps to 'plc' company form."""
_, result = self.partner._parse_dadata_response(DADATA_LEGAL_RESPONSE)
self.assertEqual(result["company_form"], "plc")
def test_parse_legal_entity_wizard_data(self):
"""Wizard data contains status, organization_type, name and address."""
wizard_data, _ = self.partner._parse_dadata_response(DADATA_LEGAL_RESPONSE)
self.assertEqual(wizard_data["status"], "active")
self.assertEqual(wizard_data["organization_type"], "legal")
self.assertEqual(wizard_data["name"], "ПАО Сбербанк")
self.assertIn("Вавилова", wizard_data["full_address"])
def test_parse_legal_entity_management(self):
"""Management data is extracted when present."""
_, result = self.partner._parse_dadata_response(DADATA_LEGAL_RESPONSE)
self.assertIn("management", result)
self.assertEqual(result["management"]["manager_name"], "ГРЕФ ГЕРМАН ОСКАРОВИЧ")
self.assertEqual(
result["management"]["manager_position"],
"ПРЕЗИДЕНТ, ПРЕДСЕДАТЕЛЬ ПРАВЛЕНИЯ",
)
def test_parse_legal_entity_fts_registration(self):
"""FTS registration series and number are concatenated correctly."""
_, result = self.partner._parse_dadata_response(DADATA_LEGAL_RESPONSE)
self.assertEqual(result["sp_register_number"], "77 004599035")
self.assertEqual(result["sp_register_date"], "2002-08-16")
def test_parse_legal_entity_country_and_state(self):
"""Country and state are resolved from ISO codes when they exist in DB."""
russia = self.env["res.country"].search([("code", "=", "RU")], limit=1)
if not russia:
self.skipTest("Russia not found in res.country — l10n data not loaded")
_, result = self.partner._parse_dadata_response(DADATA_LEGAL_RESPONSE)
self.assertEqual(result["country_id"][0], russia.id)
# ------------------------------------------------------------------
# _parse_dadata_response — individual entrepreneur
# ------------------------------------------------------------------
def test_parse_individual_name(self):
"""Individual entrepreneur name is built from fio parts."""
_, result = self.partner._parse_dadata_response(DADATA_INDIVIDUAL_RESPONSE)
self.assertEqual(result["name"], "ИВАНОВ ИВАН ИВАНОВИЧ")
def test_parse_individual_no_kpp(self):
"""Individual entrepreneur response does not set kpp."""
_, result = self.partner._parse_dadata_response(DADATA_INDIVIDUAL_RESPONSE)
self.assertNotIn("kpp", result)
def test_parse_individual_no_management(self):
"""No management key in result when management is absent."""
_, result = self.partner._parse_dadata_response(DADATA_INDIVIDUAL_RESPONSE)
self.assertNotIn("management", result)
def test_parse_individual_no_fts_registration(self):
"""No sp_register_number when fts_registration is None."""
_, result = self.partner._parse_dadata_response(DADATA_INDIVIDUAL_RESPONSE)
self.assertNotIn("sp_register_number", result)
# ------------------------------------------------------------------
# get_legal_entity_data
# ------------------------------------------------------------------
@patch("myaddons.dob.dadata_connector.models.res_partner.Dadata")
def test_get_legal_entity_data_returns_action(self, MockDadata):
"""get_legal_entity_data returns an act_window action when widget=True."""
mock_instance = MagicMock()
mock_instance.find_by_id.return_value = DADATA_LEGAL_RESPONSE
MockDadata.return_value = mock_instance
action = self.partner.get_legal_entity_data(vat="7707083893", widget=True)
self.assertEqual(action["type"], "ir.actions.act_window")
self.assertEqual(action["res_model"], "res.partner.auto_data.wizard")
self.assertEqual(action["target"], "new")
@patch("myaddons.dob.dadata_connector.models.res_partner.Dadata")
def test_get_legal_entity_data_returns_dict_when_no_widget(self, MockDadata):
"""get_legal_entity_data returns raw data dict when widget=False."""
mock_instance = MagicMock()
mock_instance.find_by_id.return_value = DADATA_LEGAL_RESPONSE
MockDadata.return_value = mock_instance
result = self.partner.get_legal_entity_data(vat="7707083893", widget=False)
self.assertIsInstance(result, dict)
self.assertEqual(result["vat"], "7707083893")
@patch("myaddons.dob.dadata_connector.models.res_partner.Dadata")
def test_get_legal_entity_data_raises_when_empty(self, MockDadata):
"""ValidationError is raised when DaData returns no results."""
mock_instance = MagicMock()
mock_instance.find_by_id.return_value = []
MockDadata.return_value = mock_instance
with self.assertRaises(ValidationError):
self.partner.get_legal_entity_data(vat="0000000000", widget=False)
@patch("myaddons.dob.dadata_connector.models.res_partner.Dadata")
def test_get_legal_entity_data_raises_on_http_error(self, MockDadata):
"""ValidationError is raised on HTTPStatusError from DaData."""
from httpx import HTTPStatusError, Request, Response
mock_instance = MagicMock()
mock_instance.find_by_id.side_effect = HTTPStatusError(
"401", request=MagicMock(spec=Request), response=MagicMock(spec=Response)
)
MockDadata.return_value = mock_instance
with self.assertRaises(ValidationError):
self.partner.get_legal_entity_data(vat="7707083893", widget=False)
# ------------------------------------------------------------------
# _get_view
# ------------------------------------------------------------------
def test_get_view_sets_dadata_search_widget_on_vat(self):
"""_get_view replaces widget on vat field with dadata_search."""
arch, _view = self.env["res.partner"]._get_view(view_type="form")
vat_nodes = arch.xpath("//field[@name='vat']")
for node in vat_nodes:
self.assertEqual(
node.get("widget"),
"dadata_search",
"vat field must use dadata_search widget",
)

View File

@ -0,0 +1,47 @@
from odoo.tests.common import tagged
from .common import DadataConnectorCommon
@tagged("post_install", "-at_install")
class TestResPartnerAutoDataWizard(DadataConnectorCommon):
"""Tests for res.partner.auto_data.wizard."""
def _create_wizard(self, **kwargs):
vals = {
"partner_id": self.partner.id,
"name": "ПАО Тест",
"status": "active",
"organization_type": "legal",
"full_address": "117997, г Москва, ул Вавилова, д 19",
}
vals.update(kwargs)
return self.env["res.partner.auto_data.wizard"].create(vals)
def test_wizard_creation(self):
"""Wizard record is created with expected field values."""
wizard = self._create_wizard()
self.assertEqual(wizard.name, "ПАО Тест")
self.assertEqual(wizard.status, "active")
self.assertEqual(wizard.organization_type, "legal")
self.assertEqual(wizard.partner_id, self.partner)
def test_button_yes_returns_close_action(self):
"""button_yes returns act_window_close with update flag."""
wizard = self._create_wizard()
result = wizard.button_yes()
self.assertEqual(result["type"], "ir.actions.act_window_close")
self.assertTrue(result["infos"]["update"])
def test_wizard_status_selection_values(self):
"""All expected status values are accepted."""
statuses = ["active", "liquidating", "liquidated", "bankrupt", "reorganizing"]
for status in statuses:
wizard = self._create_wizard(status=status)
self.assertEqual(wizard.status, status)
def test_wizard_organization_type_individual(self):
"""Wizard accepts individual organization type."""
wizard = self._create_wizard(organization_type="individual")
self.assertEqual(wizard.organization_type, "individual")

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="dadata_res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.dadata</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="15" />
<field name="inherit_id" ref="base.res_config_settings_view_form" />
<field name="arch" type="xml">
<xpath expr="//setting[@id='recaptcha']" position="after">
<setting id="dadata_token" help="Dadata token value">
<field name="dadata_token" />
</setting>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,28 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<record id="partner_with_auto_data_view_form" model="ir.ui.view">
<field name="name">Partner with auto data view form</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="l10n_ru_doc.view_partner_ru_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='ogrn']" position="attributes">
<attribute name="widget">dadata_search</attribute>
</xpath>
</field>
</record>
<record id="partner_with_auto_data_view_form2" model="ir.ui.view">
<field name="name">Partner with auto data view form 2</field>
<field name="model">res.partner</field>
<field name="priority">2</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<!-- vat widget is set programmatically via _get_view to override partner_autocomplete -->
<xpath expr="//field[@name='vat']" position="attributes">
<attribute name="widget">dadata_search</attribute>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1 @@
from . import res_partner_auto_data_wizard

View File

@ -0,0 +1,40 @@
from odoo import fields, models
class ResPartnerAutoDataWizard(models.TransientModel):
_name = "res.partner.auto_data.wizard"
_description = "Wizard for autofilling partner"
partner_id = fields.Many2one(
string="Partner",
comodel_name="res.partner",
)
vat = fields.Char(
string="Identification Number", help="Identification Number for selected type"
)
status = fields.Selection(
string="Status",
selection=[
("active", "Active"),
("liquidating", "Liquidating"),
("liquidated", "Liquidated"),
("bankrupt", "Bankrupt"),
("reorganizing", "Reorganizing"),
],
)
organization_type = fields.Selection(
string="Type of organization",
selection=[
("legal", "Legal entity"),
("individual", "Individual entrepreneur"),
],
help="Legal entity or individual entrepreneur",
)
name = fields.Char(string="Full name")
full_address = fields.Text(string="Full legal address")
def button_yes(self):
return {"type": "ir.actions.act_window_close", "infos": {"update": True}}

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_partner_auto_data_wizard_view_form" model="ir.ui.view">
<field name="name">Partner with auto data wizard view form</field>
<field name="model">res.partner.auto_data.wizard</field>
<field name="arch" type="xml">
<form create="0" delete="0" edit="0">
<group>
<field name="status"
decoration-success="status == 'active'"
decoration-danger="status != 'active'"/>
<field name="organization_type"/>
<field name="name"/>
<field name="full_address"/>
</group>
<footer>
<button string="Yes" class="oe_highlight" name="button_yes" type="object"/>
<button string="No" class="btn btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>