diff --git a/base_import_match/README.rst b/base_import_match/README.rst index 340f2fab2..6a398ea10 100644 --- a/base_import_match/README.rst +++ b/base_import_match/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ================= Base Import Match ================= @@ -17,7 +13,7 @@ Base Import Match .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-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%2Fserver--backend-lightgray.png?logo=github @@ -35,8 +31,8 @@ Base Import Match By default, when importing data (like CSV import) with the ``base_import`` module, Odoo follows this rule: -- If you import the XMLID of a record, make an **update**. -- If you do not, **create** a new record. +- If you import the XMLID of a record, make an **update**. +- If you do not, **create** a new record. This module allows you to set additional rules to match if a given import is an update or a new record. @@ -45,41 +41,65 @@ This is useful when you need to sync heterogeneous databases, and the field you use to match records in those databases with Odoo's is not the XMLID but the name, VAT, email, etc. -After installing this module, the import logic will be changed to: +After installing this module, the import preview UI adds a **Match** +column. Checking the box on a field means: + +- Use it to **find existing records** (match key). +- **Do not write** its value during the actual import. + +This lets you update records by matching on fields like name, email or +VAT without needing an XMLID in your file. + +Pre-configured **Import Match** rules act as templates: their fields are +pre-checked in the import preview. You can toggle any field on or off +before importing, regardless of whether a rule exists. + +When match-only fields are selected in the UI, matching uses all +selected fields together to search for a single existing record. If any +row fails to match exactly one record (zero or multiple matches), the +entire import is blocked and errors are shown for the affected rows. +This prevents accidental creation of duplicate records. -- If you import the XMLID of a record, make an **update**. -- If you do not: +Note that conditional rules (fields with a required imported value) are +only applied during programmatic imports; the UI-driven matching uses +the selected fields unconditionally. - - If there are import match rules for the model you are importing: +For programmatic imports (without UI), the configured rules are used +instead. The import logic in that case is: - - Discard the rules that require fields you are not importing. - - Traverse the remaining rules one by one in order to find a match - in the database. +- If you import the XMLID of a record, make an **update**. +- If you do not: - - Skip the rule if it requires a special condition that is not - satisfied. - - If one match is found: + - If there are import match rules for the model you are importing: - - Stop traversing the rest of valid rules. - - **Update** that record. + - Discard the rules that require fields you are not importing. + - Traverse the remaining rules one by one in order to find a + match in the database. - - If zero or multiple matches are found: + - Skip the rule if it requires a special condition that is not + satisfied. + - If one match is found: - - Continue with the next rule. + - Stop traversing the rest of valid rules. + - **Update** that record. - - If all rules are exhausted and no single match is found: + - If zero or multiple matches are found: - - **Create** a new record. + - Continue with the next rule. - - If there are no match rules for your model: + - If all rules are exhausted and no single match is found: - - **Create** a new record. + - **Create** a new record. + + - If there are no match rules for your model: + + - **Create** a new record. By default 2 rules are installed for production instances: -- One rule that will allow you to update companies based on their VAT, - when ``is_company`` is ``True``. -- One rule that will allow you to update users based on their login. +- One rule that will allow you to update companies based on their VAT, + when ``is_company`` is ``True``. +- One rule that will allow you to update users based on their login. In demo instances there are more examples. @@ -91,7 +111,8 @@ In demo instances there are more examples. Configuration ============= -To configure this module, you need to: +Import Match rules are optional templates that pre-check the **Match** +column in the import preview. To configure them: 1. Go to *Settings > Technical > Database Structure > Import Match*. 2. *Create*. @@ -114,15 +135,28 @@ Usage To use this module, you need to: -1. Follow steps in **Configuration** section above. -2. Go to any list view. -3. Press *Import* and follow the import procedure as usual. +1. Go to any list or kanban view. +2. Go to *Action > Import records* and upload your file. +3. In the import preview, check **Match** on the fields you want to use + as matching keys (e.g. name, email, VAT). These fields will be used + to find existing records but will not be written. +4. Proceed with the import as usual. If any row cannot be matched to + exactly one existing record, the import will be blocked and error + messages will indicate which rows had zero or multiple matches. + +If Import Match rules are configured for the model, their fields will be +pre-checked automatically. Known issues / Roadmap ====================== -- Add a setting to throw an error when multiple matches are found, - instead of falling back to creation of new record. +- Add a setting to throw an error when multiple matches are found + during programmatic imports, instead of falling back to creation of + new record. (UI-driven imports already block on zero or multiple + matches.) +- Support matching on child record fields (one2many subfields) in the + import preview. Currently, matching only works for direct fields of + the imported model. Bug Tracker =========== @@ -145,11 +179,15 @@ Authors Contributors ------------ -- `Tecnativa `__: +- `Tecnativa `__: + + - Jairo Llopis + - Vicent Cubells + - Ernesto Tejeda + +- `Quartile `__: - - Jairo Llopis - - Vicent Cubells - - Ernesto Tejeda + - Yoshi Tashiro Maintainers ----------- diff --git a/base_import_match/__manifest__.py b/base_import_match/__manifest__.py index ed76dd23f..5e051b5fd 100644 --- a/base_import_match/__manifest__.py +++ b/base_import_match/__manifest__.py @@ -18,4 +18,10 @@ "views/base_import_match_view.xml", ], "demo": ["demo/base_import_match.xml"], + "assets": { + "web.assets_backend": [ + "base_import_match/static/src/**/*.js", + "base_import_match/static/src/**/*.xml", + ], + }, } diff --git a/base_import_match/models/base.py b/base_import_match/models/base.py index 6c2ccc138..fdc2b4a67 100644 --- a/base_import_match/models/base.py +++ b/base_import_match/models/base.py @@ -1,11 +1,56 @@ # Copyright 2017 Jairo Llopis +# Copyright 2026 Quartile (https://www.quartile.co) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import api, models +from odoo import _, api, models class Base(models.AbstractModel): _inherit = "base" + @api.model + def _match_by_fields(self, match_fields, converted_row, imported_row): + """Find existing records matching on the given fields. + + Return a ``(recordset, count)`` tuple. When exactly one record matches, + *recordset* is that record; otherwise it is an empty record. + """ + domain = [] + for fname in match_fields & converted_row.keys(): + value = converted_row[fname] + # For x2many fields + if isinstance(value, list) and value and isinstance(value[0], tuple): + for ref in imported_row.get(fname, "").split(","): + ref = ref.strip() + if ref: + domain.append((fname, "=", ref)) + else: + domain.append((fname, "=", value)) + if not domain: + return self, 0 + match = self.search(domain, limit=2) + if len(match) == 1: + return match, 1 + return self, len(match) + + @api.model + def _match_error(self, match_fields, row, record_index, count): + """Build an import error dict for a failed match attempt.""" + criteria = ", ".join(f"{f}={row.get(f, '')}" for f in sorted(match_fields)) + if count == 0: + msg = _("No matching record found for: %(criteria)s", criteria=criteria) + else: + msg = _( + "Multiple matching records found (expected 1) for: %(criteria)s", + criteria=criteria, + ) + return { + "type": "error", + "message": msg, + "rows": {"from": record_index, "to": record_index}, + "record": record_index, + "field": False, + } + @api.model def load(self, fields, data): """Try to identify rows by other pseudo-unique keys. @@ -15,9 +60,16 @@ def load(self, fields, data): XMLID in place, Odoo will understand that it must *update* the record instead of *creating* a new one. """ - # We only need to patch this call if there are usable rules for it - if self.env["base_import.match"]._usable_rules(self._name, fields): + # UI-selected match fields prevail; configured rules are only used when the + # context key is absent (e.g. programmatic imports). + ctx_match_only = self.env.context.get("import_match_only_fields") + match_only_fields = set(ctx_match_only or []) & set(fields) + has_rules = ctx_match_only is None and bool( + self.env["base_import.match"]._usable_rules(self._name, fields) + ) + if match_only_fields or has_rules: newdata = list() + match_errors = [] # Change .id (dbid) by id (xmlid) if ".id" in fields: column = fields.index(".id") @@ -48,6 +100,15 @@ def load(self, fields, data): elif dbid: # Find the xmlid for this dbid match = self.browse(dbid) + elif match_only_fields: + # Match using user-selected fields from the UI + match, count = self._match_by_fields(match_only_fields, record, row) + if count != 1: + match_errors.append( + self._match_error( + match_only_fields, row, info["record"], count + ) + ) else: # Store records that match a combination match = self.env["base_import.match"]._match_find(self, record, row) @@ -58,7 +119,15 @@ def load(self, fields, data): row["id"] = ext_id[match.id] if match else row.get("id", "") # Store the modified row, in the same order as fields newdata.append(tuple(row[f] for f in clean_fields)) + if match_errors: + return {"ids": False, "messages": match_errors, "nextrow": False} # We will import the patched data to get updates on matches data = newdata + # Rebuild fields/data without match-only columns. + if match_only_fields: + drop_set = {fields.index(f) for f in match_only_fields} + keep_indexes = [i for i in range(len(fields)) if i not in drop_set] + fields[:] = [fields[i] for i in keep_indexes] + data = [tuple(row[i] for i in keep_indexes) for row in data] # Normal method handles the rest of the job return super().load(fields, data) diff --git a/base_import_match/models/base_import.py b/base_import_match/models/base_import.py index f36d72f37..93bd64cf1 100644 --- a/base_import_match/models/base_import.py +++ b/base_import_match/models/base_import.py @@ -1,5 +1,6 @@ # Copyright 2016 Grupo ESOC Ingeniería de Servicios, S.L.U. - Jairo Llopis # Copyright 2016 Tecnativa - Vicent Cubells +# Copyright 2026 Quartile (https://www.quartile.co) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import logging @@ -177,3 +178,22 @@ def _compute_display_name(self): def _onchange_match_id_name(self): """Update match name.""" self.mapped("match_id")._compute_name() + + +class BaseImportImport(models.TransientModel): + _inherit = "base_import.import" + + def parse_preview(self, options, count=10): + res = super().parse_preview(options, count=count) + if not res.get("error"): + rules = self.env["base_import.match"].search( + [("model_name", "=", self.res_model)] + ) + res["match_fields"] = list(set(rules.mapped("field_ids.name"))) + return res + + def execute_import(self, fields, columns, options, dryrun=False): + match_only = options.pop("import_match_only_fields", None) + if match_only is not None: + self = self.with_context(import_match_only_fields=match_only) + return super().execute_import(fields, columns, options, dryrun=dryrun) diff --git a/base_import_match/readme/CONFIGURE.md b/base_import_match/readme/CONFIGURE.md index 764c9d70f..985cb420f 100644 --- a/base_import_match/readme/CONFIGURE.md +++ b/base_import_match/readme/CONFIGURE.md @@ -1,4 +1,5 @@ -To configure this module, you need to: +Import Match rules are optional templates that pre-check the **Match** column in the +import preview. To configure them: 1. Go to *Settings \> Technical \> Database Structure \> Import Match*. 2. *Create*. diff --git a/base_import_match/readme/CONTRIBUTORS.md b/base_import_match/readme/CONTRIBUTORS.md index 40925a0e9..26b17a6c0 100644 --- a/base_import_match/readme/CONTRIBUTORS.md +++ b/base_import_match/readme/CONTRIBUTORS.md @@ -2,3 +2,5 @@ - Jairo Llopis - Vicent Cubells - Ernesto Tejeda +- [Quartile](https://www.quartile.co): + - Yoshi Tashiro diff --git a/base_import_match/readme/DESCRIPTION.md b/base_import_match/readme/DESCRIPTION.md index 3ece5107c..1c12305a4 100644 --- a/base_import_match/readme/DESCRIPTION.md +++ b/base_import_match/readme/DESCRIPTION.md @@ -11,7 +11,30 @@ This is useful when you need to sync heterogeneous databases, and the field you use to match records in those databases with Odoo's is not the XMLID but the name, VAT, email, etc. -After installing this module, the import logic will be changed to: +After installing this module, the import preview UI adds a **Match** column. Checking +the box on a field means: + +- Use it to **find existing records** (match key). +- **Do not write** its value during the actual import. + +This lets you update records by matching on fields like name, email or VAT without +needing an XMLID in your file. + +Pre-configured **Import Match** rules act as templates: their fields are pre-checked in +the import preview. You can toggle any field on or off before importing, regardless of +whether a rule exists. + +When match-only fields are selected in the UI, matching uses all selected fields +together to search for a single existing record. If any row fails to match exactly one +record (zero or multiple matches), the entire import is blocked and errors are shown for +the affected rows. This prevents accidental creation of duplicate records. + +Note that conditional rules (fields with a required imported value) are only applied +during programmatic imports; the UI-driven matching uses the selected fields +unconditionally. + +For programmatic imports (without UI), the configured rules are used instead. The import +logic in that case is: - If you import the XMLID of a record, make an **update**. - If you do not: diff --git a/base_import_match/readme/ROADMAP.md b/base_import_match/readme/ROADMAP.md index 8abb6f256..0a7f01232 100644 --- a/base_import_match/readme/ROADMAP.md +++ b/base_import_match/readme/ROADMAP.md @@ -1,2 +1,5 @@ -- Add a setting to throw an error when multiple matches are found, - instead of falling back to creation of new record. +- Add a setting to throw an error when multiple matches are found during + programmatic imports, instead of falling back to creation of new record. + (UI-driven imports already block on zero or multiple matches.) +- Support matching on child record fields (one2many subfields) in the import preview. + Currently, matching only works for direct fields of the imported model. diff --git a/base_import_match/readme/USAGE.md b/base_import_match/readme/USAGE.md index 3bee1b759..a5c2233cb 100644 --- a/base_import_match/readme/USAGE.md +++ b/base_import_match/readme/USAGE.md @@ -1,5 +1,13 @@ To use this module, you need to: -1. Follow steps in **Configuration** section above. -2. Go to any list view. -3. Press *Import* and follow the import procedure as usual. +1. Go to any list or kanban view. +2. Go to *Action \> Import records* and upload your file. +3. In the import preview, check **Match** on the fields you want to use as matching + keys (e.g. name, email, VAT). These fields will be used to find existing records + but will not be written. +4. Proceed with the import as usual. If any row cannot be matched to exactly one + existing record, the import will be blocked and error messages will indicate + which rows had zero or multiple matches. + +If Import Match rules are configured for the model, their fields will be pre-checked +automatically. diff --git a/base_import_match/static/description/index.html b/base_import_match/static/description/index.html index 17fc66b6a..5a990951c 100644 --- a/base_import_match/static/description/index.html +++ b/base_import_match/static/description/index.html @@ -3,16 +3,15 @@ -README.rst +Base Import Match -
+
+

Base Import Match

- - -Odoo Community Association - -
-

Base Import Match

-

Beta License: AGPL-3 OCA/server-backend Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/server-backend Translate me on Weblate Try me on Runboat

By default, when importing data (like CSV import) with the base_import module, Odoo follows this rule:

    @@ -386,14 +380,34 @@

    Base Import Match

    This is useful when you need to sync heterogeneous databases, and the field you use to match records in those databases with Odoo’s is not the XMLID but the name, VAT, email, etc.

    -

    After installing this module, the import logic will be changed to:

    +

    After installing this module, the import preview UI adds a Match +column. Checking the box on a field means:

    +
      +
    • Use it to find existing records (match key).
    • +
    • Do not write its value during the actual import.
    • +
    +

    This lets you update records by matching on fields like name, email or +VAT without needing an XMLID in your file.

    +

    Pre-configured Import Match rules act as templates: their fields are +pre-checked in the import preview. You can toggle any field on or off +before importing, regardless of whether a rule exists.

    +

    When match-only fields are selected in the UI, matching uses all +selected fields together to search for a single existing record. If any +row fails to match exactly one record (zero or multiple matches), the +entire import is blocked and errors are shown for the affected rows. +This prevents accidental creation of duplicate records.

    +

    Note that conditional rules (fields with a required imported value) are +only applied during programmatic imports; the UI-driven matching uses +the selected fields unconditionally.

    +

    For programmatic imports (without UI), the configured rules are used +instead. The import logic in that case is:

    • If you import the XMLID of a record, make an update.
    • If you do not:
      • If there are import match rules for the model you are importing:
        • Discard the rules that require fields you are not importing.
        • -
        • Traverse the remaining rules one by one in order to find a match -in the database.
            +
          • Traverse the remaining rules one by one in order to find a +match in the database.
            • Skip the rule if it requires a special condition that is not satisfied.
            • If one match is found:
                @@ -443,8 +457,9 @@

                Base Import Match

-

Configuration

-

To configure this module, you need to:

+

Configuration

+

Import Match rules are optional templates that pre-check the Match +column in the import preview. To configure them:

  1. Go to Settings > Technical > Database Structure > Import Match.
  2. Create.
  3. @@ -463,23 +478,35 @@

    Configuration

    In that list view, you can sort rules by drag and drop.

-

Usage

+

Usage

To use this module, you need to:

    -
  1. Follow steps in Configuration section above.
  2. -
  3. Go to any list view.
  4. -
  5. Press Import and follow the import procedure as usual.
  6. +
  7. Go to any list or kanban view.
  8. +
  9. Go to Action > Import records and upload your file.
  10. +
  11. In the import preview, check Match on the fields you want to use +as matching keys (e.g. name, email, VAT). These fields will be used +to find existing records but will not be written.
  12. +
  13. Proceed with the import as usual. If any row cannot be matched to +exactly one existing record, the import will be blocked and error +messages will indicate which rows had zero or multiple matches.
+

If Import Match rules are configured for the model, their fields will be +pre-checked automatically.

-

Known issues / Roadmap

+

Known issues / Roadmap

    -
  • Add a setting to throw an error when multiple matches are found, -instead of falling back to creation of new record.
  • +
  • Add a setting to throw an error when multiple matches are found +during programmatic imports, instead of falling back to creation of +new record. (UI-driven imports already block on zero or multiple +matches.)
  • +
  • Support matching on child record fields (one2many subfields) in the +import preview. Currently, matching only works for direct fields of +the imported model.
-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -487,15 +514,15 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Tecnativa
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

- -Odoo Community Association - +Odoo Community Association

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

@@ -519,6 +548,5 @@

Maintainers

-
diff --git a/base_import_match/static/src/import_data_content.esm.js b/base_import_match/static/src/import_data_content.esm.js new file mode 100644 index 000000000..fe2289986 --- /dev/null +++ b/base_import_match/static/src/import_data_content.esm.js @@ -0,0 +1,14 @@ +// Copyright 2026 Quartile (https://www.quartile.co) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import {ImportDataContent} from "@base_import/import_data_content/import_data_content"; +import {patch} from "@web/core/utils/patch"; + +patch(ImportDataContent.prototype, { + onMatchOnlyChanged: (column, ev) => (column.matchOnly = ev.target.checked), + get hasIdColumn() { + return this.props.columns.some( + (c) => c.fieldInfo && ["id", ".id"].includes(c.fieldInfo.fieldPath) + ); + }, +}); diff --git a/base_import_match/static/src/import_data_content.xml b/base_import_match/static/src/import_data_content.xml new file mode 100644 index 000000000..a585187e1 --- /dev/null +++ b/base_import_match/static/src/import_data_content.xml @@ -0,0 +1,29 @@ + + + + + Match + + + + + + + + diff --git a/base_import_match/static/src/import_model.esm.js b/base_import_match/static/src/import_model.esm.js new file mode 100644 index 000000000..0dd01ddfd --- /dev/null +++ b/base_import_match/static/src/import_model.esm.js @@ -0,0 +1,48 @@ +// Copyright 2026 Quartile (https://www.quartile.co) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import {BaseImportModel} from "@base_import/import_model"; +import {patch} from "@web/core/utils/patch"; + +patch(BaseImportModel.prototype, { + _onLoadSuccess(res) { + this.matchFieldDefaults = {}; + for (const name of res.match_fields || []) { + this.matchFieldDefaults[name] = true; + } + super._onLoadSuccess(res); + const hasId = this.columns.some( + (c) => c.fieldInfo && ["id", ".id"].includes(c.fieldInfo.fieldPath) + ); + for (const column of this.columns) { + const fieldName = column.fieldInfo && column.fieldInfo.name; + column.matchOnly = hasId + ? false + : Boolean(this.matchFieldDefaults[fieldName]); + } + }, + + setColumnField(column, fieldInfo) { + super.setColumnField(column, fieldInfo); + const fieldPath = fieldInfo && fieldInfo.fieldPath; + if (["id", ".id"].includes(fieldPath)) { + for (const col of this.columns) { + col.matchOnly = false; + } + } else if (fieldPath && fieldPath.includes("/")) { + column.matchOnly = false; + } else { + column.matchOnly = Boolean( + this.matchFieldDefaults[fieldInfo && fieldInfo.name] + ); + } + }, + + get formattedImportOptions() { + const options = super.formattedImportOptions; + options.import_match_only_fields = this.columns + .filter((col) => col.matchOnly && col.fieldInfo) + .map((col) => col.fieldInfo.fieldPath); + return options; + }, +}); diff --git a/base_import_match/tests/test_import.py b/base_import_match/tests/test_import.py index 5f798cd03..40bbf8323 100644 --- a/base_import_match/tests/test_import.py +++ b/base_import_match/tests/test_import.py @@ -15,17 +15,19 @@ class ImportCase(TransactionCase): - def _base_import_record(self, res_model, file_name): + def _base_import_record(self, res_model, file_name=None, data=None): """Create and return a ``base_import.import`` record.""" - with open(PATH % file_name) as demo_file: - return self.env["base_import.import"].create( - { - "res_model": res_model, - "file": demo_file.read(), - "file_name": f"{file_name}.csv", - "file_type": "csv", - } - ) + if file_name: + with open(PATH % file_name) as demo_file: + data = demo_file.read() + return self.env["base_import.import"].create( + { + "res_model": res_model, + "file": data, + "file_name": f"{file_name or 'test'}.csv", + "file_type": "csv", + } + ) def test_res_partner_external_id(self): """Change name based on External ID.""" @@ -57,15 +59,16 @@ def test_res_partner_vat(self): self.assertEqual(deco_addict.name, "Deco Addict Changed") def test_res_partner_invalid_combination_vat(self): - """Change name based on VAT.""" + """Invalid combination does not update the record.""" deco_addict = self.env.ref("base.res_partner_2") deco_addict.vat = "BE0477472701" + original_name = deco_addict.name record = self._base_import_record( "res.partner", "res_partner_invalid_combination_vat" ) record.execute_import(["name", "vat", "is_company"], [], OPTIONS) deco_addict.env.cache.invalidate() - self.assertEqual(deco_addict.name, deco_addict.name) + self.assertEqual(deco_addict.name, original_name) def test_res_partner_parent_name_is_company(self): """Change email based on parent_id, name and is_company.""" @@ -113,6 +116,102 @@ def test_res_partner_name_duplicated(self): self.env.ref("base.res_partner_2").function, "Function Changed" ) + def test_match_only_from_ui(self): + """Match by email via UI selection, update function, don't write email.""" + partner = self.env["res.partner"].create( + {"name": "Match Partner", "email": "match@example.com"} + ) + record = self._base_import_record( + "res.partner", data="match@example.com,New Function\n" + ) + options = dict(OPTIONS, import_match_only_fields=["email"]) + record.execute_import(["email", "function"], [], options) + partner.env.cache.invalidate() + self.assertEqual(partner.function, "New Function") + self.assertEqual(partner.email, "match@example.com") + + def test_match_only_no_match_blocks(self): + """When match-only field doesn't find a record, block the import.""" + record = self._base_import_record( + "res.partner", data="nonexistent@example.com,New Partner\n" + ) + options = dict(OPTIONS, import_match_only_fields=["email"]) + count_before = self.env["res.partner"].search_count([]) + result = record.execute_import(["email", "name"], [], options) + count_after = self.env["res.partner"].search_count([]) + self.assertEqual(count_after, count_before) + self.assertFalse(result["ids"]) + self.assertTrue(result["messages"]) + self.assertIn("No matching record found", result["messages"][0]["message"]) + + def test_match_only_multiple_match_blocks(self): + """When match-only field finds multiple records, block the import.""" + self.env["res.partner"].create({"name": "Dup 1", "email": "dup@example.com"}) + self.env["res.partner"].create({"name": "Dup 2", "email": "dup@example.com"}) + record = self._base_import_record( + "res.partner", data="dup@example.com,Updated Name\n" + ) + options = dict(OPTIONS, import_match_only_fields=["email"]) + result = record.execute_import(["email", "name"], [], options) + self.assertFalse(result["ids"]) + self.assertTrue(result["messages"]) + self.assertIn( + "Multiple matching records found", result["messages"][0]["message"] + ) + + def test_match_only_empty_value_used_as_criteria(self): + """Empty imported value is still used as a match criterion.""" + self.env["res.partner"].create( + {"name": "Test", "email": "test@example.com", "vat": "BE123"} + ) + record = self._base_import_record( + "res.partner", data="test@example.com,,New Function\n" + ) + options = dict(OPTIONS, import_match_only_fields=["email", "vat"]) + result = record.execute_import(["email", "vat", "function"], [], options) + # email matches but vat doesn't (empty vs "BE123"), so import is blocked + self.assertFalse(result["ids"]) + self.assertTrue(result["messages"]) + + def test_match_only_partial_match_blocks_all(self): + """One row matches, one doesn't: entire import blocked.""" + partner = self.env["res.partner"].create( + {"name": "Existing", "email": "exists@example.com"} + ) + original_name = partner.name + record = self._base_import_record( + "res.partner", + data="exists@example.com,Updated\nnope@example.com,New\n", + ) + options = dict(OPTIONS, import_match_only_fields=["email"]) + count_before = self.env["res.partner"].search_count([]) + result = record.execute_import(["email", "name"], [], options) + count_after = self.env["res.partner"].search_count([]) + # Entire import blocked — no new record, existing not updated + self.assertFalse(result["ids"]) + self.assertTrue(result["messages"]) + self.assertEqual(count_after, count_before) + self.assertEqual(partner.name, original_name) + + def test_match_only_empty_skips_rules(self): + """Empty match-only list from UI skips matching even if rules exist.""" + partner = self.env["res.partner"].create( + {"name": "VAT Partner", "vat": "BE0411905847", "is_company": True} + ) + original_name = partner.name + record = self._base_import_record( + "res.partner", data="Changed Name,BE0411905847,True\n" + ) + # Empty list = user unchecked everything in UI -> no matching + options = dict(OPTIONS, import_match_only_fields=[]) + count_before = self.env["res.partner"].search_count([]) + record.execute_import(["name", "vat", "is_company"], [], options) + count_after = self.env["res.partner"].search_count([]) + partner.env.cache.invalidate() + # Should create a new record, not update the existing one + self.assertEqual(count_after, count_before + 1) + self.assertEqual(partner.name, original_name) + def test_res_users_login(self): """Change name based on login.""" record = self._base_import_record("res.users", "res_users_login")