From 345686a0364a4dbe503a39cf0431855806dadf8d Mon Sep 17 00:00:00 2001 From: Dorin Hongu Date: Tue, 18 Aug 2026 11:48:44 +0300 Subject: [PATCH] add: bulk merge of partners duplicated on the same VAT Odoo's own merge wizard walks the foreign keys group by group: for every pair it goes through all ~158 columns that reference res_partner. Fine for a handful of duplicates; on a client database with 6.055 duplicate groups it means over 750.000 statements and does not finish in any usable window. Deleting a single partner has the same problem from the other side -- the database checks all 158 constraints, and any column without an index turns that into a full table scan. This module wraps the SQL procedure from scripts/partner_merge/, which inverts the loops: one statement per column for the whole batch. Foreign keys stay enabled throughout, so integrity is guaranteed by PostgreSQL rather than by the procedure being right, and no superuser is needed. Measured on a production copy of 538.000 partners: 5.350 records merged in about four and a half minutes. The queries live in models/sql_queries.py, unchanged from the scripts except for the psql meta-commands, which become parameters and Python. The working tables keep their names, so a batch prepared from the UI can be inspected from psql. Four buttons -- Analyze, Simulate, Apply, Verify -- with a status bar that does not let you skip the simulation. The simulation runs inside a savepoint and rolls it back, so it cannot write even if this code is wrong; deliberately NOT a second cursor, because the working tables are created in the current transaction and a second cursor deadlocks waiting for the lock CREATE TABLE holds until commit. Applying refuses to finish if any reference is left pointing at an absorbed record. Groups whose unreconciled balance is spread over several records are classified apart -- merging those moves money between ledgers. Guarded out entirely: groups holding the company's own partner record, groups with portal users on more than one record, and groups whose names differ completely on the same VAT number. The kept record is chosen by document volume, which is not the same as name quality, so records whose surviving name looks mangled at import are flagged, with the absorbed names captured before the merge for correction. Depends on base only: the sub-queries touching account_move, sale_order, purchase_order and stock_picking are guarded with to_regclass. Verified on a production copy: the classification reproduces the scripts' figures exactly (925/931 on A, 3.875/4.419 on B, 117/184 on C, 843/2.010 on D, 284 groups guarded out), and after a simulation all 21 records of the test batch still exist. Co-Authored-By: Claude Opus 5 --- deltatech_partner_merge/__init__.py | 1 + deltatech_partner_merge/__manifest__.py | 18 + deltatech_partner_merge/models/__init__.py | 2 + .../models/partner_merge_batch.py | 387 ++++++++++++++++++ .../models/partner_merge_line.py | 54 +++ deltatech_partner_merge/models/sql_queries.py | 267 ++++++++++++ deltatech_partner_merge/pyproject.toml | 8 + deltatech_partner_merge/readme/DESCRIPTION.md | 27 ++ deltatech_partner_merge/readme/HISTORY.md | 7 + .../security/ir.model.access.csv | 3 + .../security/partner_merge_security.xml | 19 + .../static/description/icon.png | Bin 0 -> 11702 bytes .../static/description/icon.svg | 35 ++ .../static/description/main_screenshot.png | Bin 0 -> 42541 bytes .../views/partner_merge_views.xml | 119 ++++++ 15 files changed, 947 insertions(+) create mode 100644 deltatech_partner_merge/__init__.py create mode 100644 deltatech_partner_merge/__manifest__.py create mode 100644 deltatech_partner_merge/models/__init__.py create mode 100644 deltatech_partner_merge/models/partner_merge_batch.py create mode 100644 deltatech_partner_merge/models/partner_merge_line.py create mode 100644 deltatech_partner_merge/models/sql_queries.py create mode 100644 deltatech_partner_merge/pyproject.toml create mode 100644 deltatech_partner_merge/readme/DESCRIPTION.md create mode 100644 deltatech_partner_merge/readme/HISTORY.md create mode 100644 deltatech_partner_merge/security/ir.model.access.csv create mode 100644 deltatech_partner_merge/security/partner_merge_security.xml create mode 100644 deltatech_partner_merge/static/description/icon.png create mode 100644 deltatech_partner_merge/static/description/icon.svg create mode 100644 deltatech_partner_merge/static/description/main_screenshot.png create mode 100644 deltatech_partner_merge/views/partner_merge_views.xml diff --git a/deltatech_partner_merge/__init__.py b/deltatech_partner_merge/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/deltatech_partner_merge/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/deltatech_partner_merge/__manifest__.py b/deltatech_partner_merge/__manifest__.py new file mode 100644 index 000000000..87a7cde14 --- /dev/null +++ b/deltatech_partner_merge/__manifest__.py @@ -0,0 +1,18 @@ +{ + "name": "Partner Merge in Bulk", + "summary": "Merge partners duplicated on the same VAT number, in bulk, in minutes instead of hours", + "version": "19.0.1.0.0", + "author": "Terrabit", + "website": "https://www.terrabit.ro", + "license": "OPL-1", + "category": "Tools", + "depends": ["base"], + "data": [ + "security/partner_merge_security.xml", + "security/ir.model.access.csv", + "views/partner_merge_views.xml", + ], + "images": ["static/description/main_screenshot.png"], + "development_status": "Beta", + "installable": True, +} diff --git a/deltatech_partner_merge/models/__init__.py b/deltatech_partner_merge/models/__init__.py new file mode 100644 index 000000000..e50d2f37d --- /dev/null +++ b/deltatech_partner_merge/models/__init__.py @@ -0,0 +1,2 @@ +from . import partner_merge_batch +from . import partner_merge_line diff --git a/deltatech_partner_merge/models/partner_merge_batch.py b/deltatech_partner_merge/models/partner_merge_batch.py new file mode 100644 index 000000000..0ad1e6509 --- /dev/null +++ b/deltatech_partner_merge/models/partner_merge_batch.py @@ -0,0 +1,387 @@ +# © 2026 Terrabit +# See README.rst file on addons root folder for license details + +import logging +import time + +from odoo import api, fields, models +from odoo.exceptions import UserError +from odoo.tools import SQL + +from . import sql_queries as Q + +_logger = logging.getLogger(__name__) + +CATEGORY_HELP = { + "A": "one record holds the documents, the rest are completely empty", + "B": "documents on a single record only", + "C": "invoices on more than one record", + "D": "unreconciled balance on more than one record", +} + + +class PartnerMergeBatch(models.Model): + _name = "partner.merge.batch" + _description = "Bulk merge of partners duplicated on the same VAT" + _order = "id desc" + + name = fields.Char(required=True, default=lambda self: self.env._("Merge batch")) + state = fields.Selection( + [("draft", "Draft"), ("analyzed", "Analyzed"), ("simulated", "Simulated"), ("done", "Applied")], + default="draft", + readonly=True, + copy=False, + ) + category_ids = fields.Char( + string="Categories", + default="A,B", + required=True, + help="Which groups enter the batch, comma separated. " + "A: one record holds the documents, the rest are empty. " + "B: documents on a single record. " + "C: invoices spread over several records. " + "D: unreconciled balance on several records. " + "C and D change accounting figures — handle them with the accountant.", + ) + group_limit = fields.Integer( + default=200, + help="How many groups in this batch. 0 = all eligible ones. The de-duplication step has a " + "fixed cost of about 40 seconds regardless of size, so very small batches are inefficient.", + ) + archive_instead_of_delete = fields.Boolean( + string="Archive instead of delete", + help="Absorbed records are deactivated rather than removed. Slower to clean up afterwards, " + "but recoverable without restoring a backup. Recommended for the first production runs.", + ) + line_ids = fields.One2many("partner.merge.batch.line", "batch_id", readonly=True) + group_count = fields.Integer(compute="_compute_counts", store=True) + record_count = fields.Integer(string="Records to absorb", compute="_compute_counts", store=True) + classification = fields.Text(readonly=True, help="All groups by category and guard, as of the analysis.") + report = fields.Text(readonly=True) + missing_index_count = fields.Integer(readonly=True) + + @api.depends("line_ids", "line_ids.absorbed_count") + def _compute_counts(self): + for batch in self: + batch.group_count = len(batch.line_ids) + batch.record_count = sum(batch.line_ids.mapped("absorbed_count")) + + # ------------------------------------------------------------------ helpers + + def _categories(self): + cats = [c.strip().upper() for c in (self.category_ids or "").split(",") if c.strip()] + unknown = [c for c in cats if c not in CATEGORY_HELP] + if unknown: + raise UserError(self.env._("Unknown categories: %s", ", ".join(unknown))) + if not cats: + raise UserError(self.env._("Pick at least one category.")) + return cats + + def _table_exists(self, cr, table): + cr.execute("SELECT to_regclass(%s) IS NOT NULL", (table,)) + return cr.fetchone()[0] + + def _face_sql(self, cr): + """BUILD_FACE with the sub-queries whose tables are missing replaced by 0, + so the module depends on `base` only.""" + parts = {} + for key, (table, expr) in Q.FACE_PARTS.items(): + parts[key] = expr if self._table_exists(cr, table) else "0" + return Q.BUILD_FACE.format(**parts) + + def _guard_single_batch(self): + other = self.search([("state", "in", ("analyzed", "simulated")), ("id", "!=", self.id)], limit=1) + if other: + raise UserError( + self.env._( + "Batch %s is still in progress. The working tables are shared, so only one batch " + "can be prepared at a time. Apply it or reset it to draft first.", + other.display_name, + ) + ) + + # ------------------------------------------------------------------ step 1-2 + + def action_analyze(self): + """Builds the working tables and the batch lines. Touches no business data.""" + self.ensure_one() + self._guard_single_batch() + cr = self.env.cr + cats = self._categories() + + cr.execute(Q.FK_COLUMNS_WITHOUT_INDEX) + missing = cr.fetchall() + + t0 = time.time() + cr.execute(SQL(self._face_sql(cr))) + cr.execute(SQL(Q.BUILD_GROUP)) + cr.execute(SQL(Q.BUILD_MAP, cats, self.group_limit or None)) + cr.execute("CREATE INDEX ON pm_map(vat_n)") + cr.execute(Q.CHECK_NO_CHAIN) + chained = cr.fetchone()[0] + if chained: + raise UserError( + self.env._( + "Inconsistent batch: %s records point at a master that is itself absorbed. " + "This should not happen — do not apply, and report it.", + chained, + ) + ) + cr.execute(SQL(Q.BUILD_SNAPSHOT)) + cr.execute("ANALYZE pm_face; ANALYZE pm_group; ANALYZE pm_map") + + self.line_ids.unlink() + cr.execute( + """SELECT m.master_id, m.vat_n, g.categorie, count(*), + array_agg(m.old_id ORDER BY m.old_id), s.denumiri_absorbite + FROM pm_map m + JOIN pm_group g ON g.vat_n = m.vat_n + LEFT JOIN pm_snapshot s ON s.master_id = m.master_id + GROUP BY m.master_id, m.vat_n, g.categorie, s.denumiri_absorbite + ORDER BY m.vat_n""" + ) + Line = self.env["partner.merge.batch.line"] + vals = [ + { + "batch_id": self.id, + "master_id": master_id, + "vat_normalized": vat_n, + "category": cat, + "absorbed_count": n, + "absorbed_ids": ", ".join(str(i) for i in ids), + "absorbed_names": names or "", + } + for master_id, vat_n, cat, n, ids, names in cr.fetchall() + ] + Line.create(vals) + + cr.execute( + "SELECT categorie, coalesce(blocaj, 'eligible'), count(*), sum(membri) - count(*) " + "FROM pm_group GROUP BY 1, 2 ORDER BY 1, 2" + ) + rows = cr.fetchall() + width = max((len(r[1]) for r in rows), default=10) + self.classification = "\n".join( + f"{cat} {blocaj:<{width}} {grupuri:>6} groups {fise:>6} records" for cat, blocaj, grupuri, fise in rows + ) + self.missing_index_count = len(missing) + self.state = "analyzed" + self.report = self.env._( + "Analysis done in %(sec).1f s. %(groups)s groups, %(records)s records to absorb.\n" + "%(idx)s foreign-key columns towards res_partner still lack an index.", + sec=time.time() - t0, + groups=len(vals), + records=sum(v["absorbed_count"] for v in vals), + idx=len(missing), + ) + return True + + # ------------------------------------------------------------------ step 3-6 + + def _dedupe(self, cr, mode): + """Collapses rows that would collide on a unique index after the remap.""" + out = [] + cr.execute(SQL(Q.UNIQUE_INDEXES_TO_DEDUPE, mode, mode)) + for tbl, relid, cols, remap_col in cr.fetchall(): + key_expr = ", ".join(f"COALESCE(m.master_id, x.{c})" if c == remap_col else f"x.{c}" for c in cols) + where_cl = "" + if mode == "poly": + cr.execute(SQL(Q.POLY_MODEL_COL, relid)) + model_col = cr.fetchone()[0] + where_cl = f"WHERE x.{model_col} = 'res.partner'" + cr.execute(SQL(Q.DEDUPE_ONE.format(key_expr=key_expr, tbl=tbl, remap_col=remap_col, where_cl=where_cl))) + if cr.rowcount: + out.append((tbl, cr.rowcount)) + return out + + def _run_merge(self, cr): + """The whole merge, on the cursor it is given. Returns the figures.""" + res = {} + t0 = time.time() + res["dedupe_fk"] = self._dedupe(cr, "fk") + + n = 0 + cr.execute(Q.FK_COLUMNS_ALL) + for tbl, col in cr.fetchall(): + cr.execute(SQL(Q.REMAP_FK.format(tbl=tbl, col=col))) + n += cr.rowcount + cr.execute(Q.REMAP_PARENT) + n += cr.rowcount + res["remapped"] = n + + # recalculated here on purpose: remapping partner_id above creates collisions + # on res_id that did not exist at the start of the transaction + res["dedupe_poly"] = self._dedupe(cr, "poly") + + n = 0 + cr.execute(Q.POLY_TABLES) + for tbl, model_col in cr.fetchall(): + cr.execute(SQL(Q.REMAP_POLY.format(tbl=tbl, model_col=model_col))) + n += cr.rowcount + res["remapped_poly"] = n + + n = 0 + for col in Q.FILL_FIELDS: + cr.execute(SQL(Q.FILL_ONE.format(col=col))) + n += cr.rowcount + res["filled"] = n + + cr.execute(Q.ARCHIVE_ABSORBED if self.archive_instead_of_delete else Q.DELETE_ABSORBED) + res["removed"] = cr.rowcount + + leftover = [] + cr.execute(Q.POLY_TABLES) + for tbl, model_col in cr.fetchall(): + cr.execute( + SQL( + f"SELECT count(*) FROM {tbl} WHERE {model_col} = 'res.partner' " + "AND res_id IN (SELECT old_id FROM pm_map)" + ) + ) + left = cr.fetchone()[0] + if left: + leftover.append((tbl, left)) + res["leftover"] = leftover + res["seconds"] = time.time() - t0 + return res + + def _format(self, res, applied): + head = self.env._("APPLIED") if applied else self.env._("SIMULATION — nothing was changed") + lines = [ + f"=== {head} ===", + self.env._("de-duplicated before remap : %s", res["dedupe_fk"] or self.env._("no collisions")), + self.env._("foreign keys remapped : %s rows", res["remapped"]), + self.env._("de-duplicated on res_id : %s", res["dedupe_poly"] or self.env._("no collisions")), + self.env._("polymorphic links remapped : %s rows", res["remapped_poly"]), + self.env._("empty fields filled in : %s", res["filled"]), + self.env._( + "records %(what)s : %(n)s", + what=self.env._("archived") if self.archive_instead_of_delete else self.env._("deleted"), + n=res["removed"], + ), + self.env._("references left behind : %s", res["leftover"] or self.env._("none — correct")), + self.env._("took : %.1f s", res["seconds"]), + ] + return "\n".join(lines) + + def action_simulate(self): + """Runs everything inside a savepoint, then rolls the savepoint back. + + Not a flag that can be forgotten: the work is undone by the database, so a + simulation cannot write even if this code is wrong. + + A savepoint on the current cursor, deliberately — NOT a second cursor. The + working tables (pm_map & co.) are created by action_analyze in this same + transaction, so a second cursor cannot see them and blocks waiting for the + ACCESS EXCLUSIVE lock that CREATE TABLE holds until commit. That is a + deadlock: the simulation waits for a transaction that only ends after the + simulation returns. + """ + self.ensure_one() + if self.state not in ("analyzed", "simulated"): + raise UserError(self.env._("Analyze the batch first.")) + + class _Rollback(Exception): + pass + + res = {} + try: + with self.env.cr.savepoint(): + res.update(self._run_merge(self.env.cr)) + raise _Rollback() + except _Rollback: + pass + self.report = self._format(res, applied=False) + self.state = "simulated" + return True + + def action_apply(self): + self.ensure_one() + if self.state != "simulated": + raise UserError(self.env._("Run the simulation first and read its report — then apply.")) + if not self.env.user.has_group("deltatech_partner_merge.group_partner_merge_apply"): + raise UserError(self.env._("You are not allowed to apply a merge batch.")) + res = self._run_merge(self.env.cr) + if res["leftover"]: + raise UserError( + self.env._( + "References were left pointing at absorbed records: %s. Rolled back — nothing applied.", + res["leftover"], + ) + ) + self.report = self._format(res, applied=True) + self.state = "done" + _logger.info( + "partner.merge.batch %s applied: %s records removed, %s rows remapped", + self.id, + res["removed"], + res["remapped"], + ) + return True + + def action_verify(self): + """Compares the masters' real totals with the snapshot taken before the merge.""" + self.ensure_one() + cr = self.env.cr + if not self._table_exists(cr, "pm_snapshot"): + raise UserError(self.env._("No snapshot found — analyze a batch first.")) + parts = { + "facturi_m": "(SELECT count(*) FROM account_move mv WHERE mv.partner_id = s.master_id " + "AND mv.move_type <> 'entry')" + if self._table_exists(cr, "account_move") + else "0", + "comenzi_v_m": "(SELECT count(*) FROM sale_order so WHERE so.partner_id = s.master_id)" + if self._table_exists(cr, "sale_order") + else "0", + "sold_m": "COALESCE((SELECT round(sum(l.balance)::numeric, 2) FROM account_move_line l " + "JOIN account_account a ON a.id = l.account_id JOIN account_move mv ON mv.id = l.move_id " + "WHERE l.partner_id = s.master_id AND mv.state = 'posted' AND a.reconcile " + "AND NOT l.reconciled), 0)" + if self._table_exists(cr, "account_move_line") + else "0", + } + cr.execute(SQL(Q.VERIFY_TOTALS.format(**parts))) + checked, ab_inv, ab_ord, ab_bal = cr.fetchone() + cr.execute("SELECT count(*) FROM pm_map m JOIN res_partner p ON p.id = m.old_id") + still = cr.fetchone()[0] + ok = not (ab_inv or ab_ord or ab_bal) and (still == 0 or self.archive_instead_of_delete) + self.report = "\n".join( + [ + self.env._("=== VERIFICATION ==="), + self.env._("masters checked : %s", checked), + self.env._("invoice count mismatches : %s", ab_inv), + self.env._("order count mismatches : %s", ab_ord), + self.env._("balance mismatches : %s", ab_bal), + self.env._("absorbed records remaining : %s", still), + "", + self.env._("Nothing lost, nothing duplicated.") + if ok + else self.env._("MISMATCHES FOUND — investigate before continuing."), + ] + ) + return True + + def action_reset(self): + self.ensure_one() + if self.state == "done": + raise UserError(self.env._("An applied batch cannot be reset.")) + self.line_ids.unlink() + self.write({"state": "draft", "report": False, "classification": False}) + return True + + def action_create_indexes(self): + """Creates the missing indexes on the foreign keys towards res_partner. + + Without CONCURRENTLY, since we are inside Odoo's transaction — the lock lasts + seconds on the big tables. Where even that is unacceptable, run + scripts/partner_merge/01_fk_indexes.sql from psql beforehand. + """ + self.ensure_one() + cr = self.env.cr + cr.execute(Q.FK_COLUMNS_WITHOUT_INDEX) + rows = cr.fetchall() + for tbl, col, _size in rows: + cr.execute(SQL(f'CREATE INDEX ON {tbl} ("{col}")')) + self.missing_index_count = 0 + self.report = self.env._("%s indexes created.", len(rows)) + return True diff --git a/deltatech_partner_merge/models/partner_merge_line.py b/deltatech_partner_merge/models/partner_merge_line.py new file mode 100644 index 000000000..e2812e466 --- /dev/null +++ b/deltatech_partner_merge/models/partner_merge_line.py @@ -0,0 +1,54 @@ +# © 2026 Terrabit +# See README.rst file on addons root folder for license details + +from odoo import fields, models + + +class PartnerMergeBatchLine(models.Model): + _name = "partner.merge.batch.line" + _description = "One group of duplicates in a merge batch" + _order = "vat_normalized" + + batch_id = fields.Many2one("partner.merge.batch", required=True, ondelete="cascade", index=True) + master_id = fields.Many2one( + "res.partner", + string="Record kept", + required=True, + index=True, + ondelete="cascade", + help="Chosen by document volume: most invoices, then most sales orders, then oldest.", + ) + vat_normalized = fields.Char(string="VAT (normalized)", index=True) + category = fields.Selection( + [ + ("A", "A — others empty"), + ("B", "B — documents on one"), + ("C", "C — invoices on several"), + ("D", "D — balance on several"), + ], + ) + absorbed_count = fields.Integer(string="Records absorbed") + absorbed_ids = fields.Char(string="Absorbed IDs") + absorbed_names = fields.Text( + string="Absorbed names", + help="Captured before the merge: afterwards these records no longer exist. Useful when the " + "kept record carries the degraded name and the correct one was on an absorbed record.", + ) + master_name = fields.Char(related="master_id.name", string="Kept name", store=False) + name_suspect = fields.Boolean( + compute="_compute_name_suspect", + string="Name looks degraded", + help="The kept record is chosen by document volume, not by name quality, so sometimes the " + "surviving name is the one mangled at import. Check these by hand after the merge.", + ) + + def _compute_name_suspect(self): + import re + + run_together = re.compile(r"[a-zà-ſ][A-ZÀ-ž]") + only_digits = re.compile(r"^[A-Z]{0,2}[0-9]{4,}$") + for line in self: + name = line.master_id.name or "" + line.name_suspect = bool( + only_digits.match(name) or (" " not in name and (run_together.search(name) or len(name) > 8)) + ) diff --git a/deltatech_partner_merge/models/sql_queries.py b/deltatech_partner_merge/models/sql_queries.py new file mode 100644 index 000000000..ee8507f38 --- /dev/null +++ b/deltatech_partner_merge/models/sql_queries.py @@ -0,0 +1,267 @@ +"""Interogările folosite de unificarea în masă. + +Provin din `scripts/partner_merge/*.sql`, care rămân varianta rulabilă direct din +psql. Aici sunt aceleași interogări, fără meta-comenzile psql (\\gexec, \\if, +variabilele -v) — acelea sunt înlocuite de parametri și de logica din +partner_merge_batch.py. + +Tabelele de lucru păstrează numele din scripturi (pm_face, pm_group, pm_map, +pm_snapshot), ca un lot pregătit din interfață să poată fi inspectat din psql. +""" + +# --- pasul 1: indexuri pe coloanele FK care referă res_partner ---------------- +# În modul se creează FĂRĂ CONCURRENTLY: rulăm în tranzacția Odoo, unde +# CONCURRENTLY nu e permis. Lock-ul e de ordinul secundelor pe tabelele mari. +# Pentru instanțe unde nici atât nu e acceptabil, rulează 01_fk_indexes.sql din +# psql înainte, care folosește CONCURRENTLY. +FK_COLUMNS_WITHOUT_INDEX = """ + SELECT c.conrelid::regclass::text AS tbl, a.attname AS col, + pg_relation_size(c.conrelid) AS tbl_size + FROM pg_constraint c + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) + WHERE c.contype = 'f' AND c.confrelid = 'res_partner'::regclass + AND c.conrelid::regclass::text <> 'res_partner' + AND NOT EXISTS ( + SELECT 1 FROM pg_index i + WHERE i.indrelid = c.conrelid AND a.attnum = i.indkey[0]) + ORDER BY pg_relation_size(c.conrelid) DESC +""" + +# --- pasul 2: candidații, cu volumetria documentelor ------------------------- +# Sub-interogările pe tabele care pot lipsi (modul neinstalat) sunt protejate cu +# to_regclass, ca modulul să depindă doar de `base`. +BUILD_FACE = """ + DROP TABLE IF EXISTS pm_face CASCADE; + CREATE TABLE pm_face AS + WITH norm AS ( + SELECT id, name, create_date, company_registry, + upper(regexp_replace(vat, '[^0-9A-Za-z]', '', 'g')) AS vat_n + FROM res_partner + WHERE active AND is_company AND vat IS NOT NULL AND vat <> '' + ), grp AS ( + SELECT vat_n FROM norm GROUP BY vat_n HAVING count(*) > 1 + ) + SELECT n.vat_n, n.id, n.name, n.create_date, n.company_registry, + {facturi} AS facturi, {comenzi_v} AS comenzi_v, {comenzi_a} AS comenzi_a, + {livrari} AS livrari, + (SELECT count(*) FROM res_partner c WHERE c.parent_id = n.id) AS copii, + {adresa} AS adresa_pe_comenzi, + (SELECT count(*) FROM res_users u WHERE u.partner_id = n.id) AS useri, + (SELECT count(*) FROM res_company k WHERE k.partner_id = n.id) AS e_companie_proprie, + {sold} AS sold + FROM norm n JOIN grp g ON g.vat_n = n.vat_n; + CREATE INDEX ON pm_face(vat_n); + CREATE UNIQUE INDEX ON pm_face(id); +""" +# fragmentele condiționale, alese în funcție de tabelele care există +FACE_PARTS = { + "facturi": ( + "account_move", + "(SELECT count(*) FROM account_move m WHERE m.partner_id = n.id AND m.move_type <> 'entry')", + ), + "comenzi_v": ("sale_order", "(SELECT count(*) FROM sale_order s WHERE s.partner_id = n.id)"), + "comenzi_a": ("purchase_order", "(SELECT count(*) FROM purchase_order o WHERE o.partner_id = n.id)"), + "livrari": ("stock_picking", "(SELECT count(*) FROM stock_picking k WHERE k.partner_id = n.id)"), + "adresa": ( + "sale_order", + "(SELECT count(*) FROM sale_order s WHERE s.partner_shipping_id = n.id OR s.partner_invoice_id = n.id)", + ), + "sold": ( + "account_move_line", + "COALESCE((SELECT round(sum(l.balance)::numeric, 2) FROM account_move_line l " + "JOIN account_account a ON a.id = l.account_id JOIN account_move mv ON mv.id = l.move_id " + "WHERE l.partner_id = n.id AND mv.state = 'posted' AND a.reconcile AND NOT l.reconciled), 0)", + ), +} + +BUILD_GROUP = """ + DROP TABLE IF EXISTS pm_group CASCADE; + CREATE TABLE pm_group AS + WITH g AS ( + SELECT vat_n, + count(*) AS membri, + count(*) FILTER (WHERE facturi > 0) AS cu_facturi, + count(*) FILTER (WHERE facturi + comenzi_v + comenzi_a + livrari + + copii + adresa_pe_comenzi + useri = 0) AS goi, + count(*) FILTER (WHERE sold <> 0) AS cu_sold, + count(*) FILTER (WHERE useri > 0) AS cu_useri, + sum(e_companie_proprie) AS companii_proprii, + count(DISTINCT lower(left(regexp_replace(name, '[^[:alnum:] ]', '', 'g'), 6))) AS prefixe_nume + FROM pm_face GROUP BY vat_n + ) + SELECT vat_n, membri, cu_facturi, goi, cu_sold, cu_useri, companii_proprii, prefixe_nume, + CASE WHEN goi = membri - 1 AND cu_facturi <= 1 THEN 'A' + WHEN cu_facturi <= 1 THEN 'B' + WHEN cu_sold > 1 THEN 'D' + ELSE 'C' END AS categorie, + CASE WHEN companii_proprii > 0 THEN 'company' + WHEN cu_useri > 1 THEN 'portal_users' + WHEN prefixe_nume = membri THEN 'diverging_names' END AS blocaj + FROM g; + CREATE UNIQUE INDEX ON pm_group(vat_n); +""" + +BUILD_MAP = """ + DROP TABLE IF EXISTS pm_map CASCADE; + CREATE TABLE pm_map AS + WITH eligibil AS ( + SELECT vat_n FROM pm_group + WHERE categorie = ANY(%s) AND blocaj IS NULL + ORDER BY vat_n + LIMIT %s + ), master AS ( + SELECT DISTINCT ON (f.vat_n) f.vat_n, f.id AS master_id + FROM pm_face f JOIN eligibil e ON e.vat_n = f.vat_n + ORDER BY f.vat_n, f.facturi DESC, f.comenzi_v DESC, f.create_date ASC, f.id ASC + ) + SELECT f.vat_n, f.id AS old_id, m.master_id + FROM pm_face f JOIN master m ON m.vat_n = f.vat_n + WHERE f.id <> m.master_id; + CREATE UNIQUE INDEX ON pm_map(old_id); + CREATE INDEX ON pm_map(master_id); +""" + +# un master nu poate fi el însuși absorbit în alt grup +CHECK_NO_CHAIN = """ + SELECT count(*) FROM pm_map m WHERE EXISTS (SELECT 1 FROM pm_map x WHERE x.old_id = m.master_id) +""" + +BUILD_SNAPSHOT = """ + DROP TABLE IF EXISTS pm_snapshot CASCADE; + CREATE TABLE pm_snapshot AS + SELECT m.master_id, + count(*) + 1 AS fise_in_grup, + sum(f.facturi) + mf.facturi AS facturi_asteptate, + sum(f.comenzi_v) + mf.comenzi_v AS comenzi_v_asteptate, + sum(f.comenzi_a) + mf.comenzi_a AS comenzi_a_asteptate, + sum(f.livrari) + mf.livrari AS livrari_asteptate, + round(sum(f.sold) + mf.sold, 2) AS sold_asteptat, + string_agg(DISTINCT f.name, ' | ') AS denumiri_absorbite + FROM pm_map m + JOIN pm_face f ON f.id = m.old_id + JOIN pm_face mf ON mf.id = m.master_id + GROUP BY m.master_id, mf.facturi, mf.comenzi_v, mf.comenzi_a, mf.livrari, mf.sold; + CREATE UNIQUE INDEX ON pm_snapshot(master_id); +""" + +# --- pasul 3: dedup coliziuni unique ---------------------------------------- +# Colapsează pe valoarea ȚINTĂ, nu comparând fișa absorbită cu masterul: două fișe +# din același grup se ciocnesc între ele după remap, fără ca masterul să fie implicat. +# `k IS NOT NULL` pe un ROW() e adevărat doar când toate câmpurile sunt non-null — +# obligatoriu, fiindcă NULL nu produce coliziune într-un index unique, dar +# PARTITION BY grupează toate NULL-urile la un loc. +UNIQUE_INDEXES_TO_DEDUPE = """ + SELECT c.relname AS tbl, i.indrelid, + (SELECT array_agg(att.attname ORDER BY k.ord) + FROM unnest(i.indkey::int[]) WITH ORDINALITY k(attnum, ord) + JOIN pg_attribute att ON att.attrelid = i.indrelid AND att.attnum = k.attnum) AS cols, + tgt.col AS remap_col + FROM pg_index i + JOIN pg_class c ON c.oid = i.indrelid AND c.relkind = 'r' + AND c.relnamespace = 'public'::regnamespace + JOIN LATERAL ( + SELECT a.attname AS col + FROM pg_attribute a + WHERE a.attrelid = i.indrelid AND a.attnum > 0 AND NOT a.attisdropped + AND ((%s = 'fk' AND EXISTS ( + SELECT 1 FROM pg_constraint fc + JOIN pg_attribute fa ON fa.attrelid = fc.conrelid AND fa.attnum = ANY(fc.conkey) + WHERE fc.contype = 'f' AND fc.confrelid = 'res_partner'::regclass + AND fc.conrelid = i.indrelid AND fa.attname = a.attname)) + OR (%s = 'poly' AND a.attname = 'res_id' + AND EXISTS (SELECT 1 FROM pg_attribute a2 WHERE a2.attrelid = i.indrelid + AND a2.attname IN ('res_model', 'model') AND NOT a2.attisdropped))) + LIMIT 1 + ) tgt ON true + WHERE i.indisunique AND i.indisvalid AND i.indpred IS NULL + AND c.relname <> 'res_partner' + AND tgt.col = ANY (SELECT att.attname FROM unnest(i.indkey::int[]) k(attnum) + JOIN pg_attribute att ON att.attrelid = i.indrelid AND att.attnum = k.attnum) +""" + +DEDUPE_ONE = """ + WITH t AS ( + SELECT x.ctid AS cid, ROW({key_expr}) AS k + FROM {tbl} x LEFT JOIN pm_map m ON m.old_id = x.{remap_col} + {where_cl} + ), dup AS ( + SELECT cid, row_number() OVER (PARTITION BY k ORDER BY cid) AS rn + FROM t + WHERE k IS NOT NULL + AND k IN (SELECT k FROM t WHERE k IS NOT NULL GROUP BY k HAVING count(*) > 1) + ) + DELETE FROM {tbl} WHERE ctid IN (SELECT cid FROM dup WHERE rn > 1) +""" + +# coloana care ține numele modelului, pentru tabelele polimorfe +POLY_MODEL_COL = """ + SELECT a2.attname FROM pg_attribute a2 + WHERE a2.attrelid = %s AND a2.attname IN ('res_model', 'model') + AND NOT a2.attisdropped + ORDER BY CASE a2.attname WHEN 'res_model' THEN 0 ELSE 1 END LIMIT 1 +""" + +# --- pasul 4: remap --------------------------------------------------------- +FK_COLUMNS_ALL = """ + SELECT c.conrelid::regclass::text AS tbl, a.attname AS col + FROM pg_constraint c + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) + WHERE c.contype = 'f' AND c.confrelid = 'res_partner'::regclass + AND c.conrelid::regclass::text <> 'res_partner' +""" +REMAP_FK = "UPDATE {tbl} t SET {col} = m.master_id FROM pm_map m WHERE t.{col} = m.old_id" +REMAP_PARENT = "UPDATE res_partner p SET parent_id = m.master_id FROM pm_map m WHERE p.parent_id = m.old_id" + +# Odoo folosește DOUĂ convenții polimorfe: (res_model, res_id) pentru mail.activity, +# ir.attachment, mail.followers, rating.rating; dar (model, res_id) pentru mail.message +# și ir.model.data. Tratarea doar a primeia lasă chatterul agățat de fișe șterse. +POLY_TABLES = """ + SELECT t.table_name, + (SELECT c.column_name FROM information_schema.columns c + WHERE c.table_schema = 'public' AND c.table_name = t.table_name + AND c.column_name IN ('res_model', 'model') + ORDER BY CASE c.column_name WHEN 'res_model' THEN 0 ELSE 1 END LIMIT 1) AS model_col + FROM information_schema.tables t + WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE' + AND EXISTS (SELECT 1 FROM information_schema.columns c + WHERE c.table_schema = 'public' AND c.table_name = t.table_name + AND c.column_name = 'res_id') + AND EXISTS (SELECT 1 FROM information_schema.columns c + WHERE c.table_schema = 'public' AND c.table_name = t.table_name + AND c.column_name IN ('res_model', 'model') + AND c.data_type IN ('character varying', 'text')) +""" +REMAP_POLY = ( + "UPDATE {tbl} t SET res_id = m.master_id FROM pm_map m WHERE t.{model_col} = 'res.partner' AND t.res_id = m.old_id" +) + +# --- pasul 5: completarea câmpurilor goale pe master ------------------------ +FILL_FIELDS = ("email", "phone", "website", "street", "street2", "city", "zip", "function") +FILL_ONE = """ + UPDATE res_partner mst SET {col} = src.val + FROM ( + SELECT DISTINCT ON (m.master_id) m.master_id, o.{col} AS val + FROM pm_map m JOIN res_partner o ON o.id = m.old_id + WHERE o.{col} IS NOT NULL AND o.{col} <> '' + ORDER BY m.master_id, o.write_date DESC + ) src + WHERE mst.id = src.master_id AND (mst.{col} IS NULL OR mst.{col} = '') +""" + +# --- pasul 6: ștergerea sau arhivarea -------------------------------------- +DELETE_ABSORBED = "DELETE FROM res_partner p USING pm_map m WHERE p.id = m.old_id" +ARCHIVE_ABSORBED = "UPDATE res_partner p SET active = false FROM pm_map m WHERE p.id = m.old_id" + +# --- verificări ------------------------------------------------------------- +VERIFY_TOTALS = """ + WITH real AS ( + SELECT s.master_id, + {facturi_m} AS facturi, {comenzi_v_m} AS comenzi_v, {sold_m} AS sold + FROM pm_snapshot s + ) + SELECT count(*) AS verificati, + count(*) FILTER (WHERE r.facturi <> s.facturi_asteptate) AS ab_facturi, + count(*) FILTER (WHERE r.comenzi_v <> s.comenzi_v_asteptate) AS ab_comenzi, + count(*) FILTER (WHERE r.sold <> s.sold_asteptat) AS ab_sold + FROM pm_snapshot s JOIN real r ON r.master_id = s.master_id +""" diff --git a/deltatech_partner_merge/pyproject.toml b/deltatech_partner_merge/pyproject.toml new file mode 100644 index 000000000..02fcc1608 --- /dev/null +++ b/deltatech_partner_merge/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = [ + "whool", +] +build-backend = "whool.buildapi" + +[project] +name = "odoo-addon-deltatech-partner-merge" diff --git a/deltatech_partner_merge/readme/DESCRIPTION.md b/deltatech_partner_merge/readme/DESCRIPTION.md new file mode 100644 index 000000000..03bae2c86 --- /dev/null +++ b/deltatech_partner_merge/readme/DESCRIPTION.md @@ -0,0 +1,27 @@ +Merges partner records that share the same VAT number, in bulk. + +Odoo's own merge wizard walks the foreign keys **group by group**: for every pair it goes through all +~158 columns that reference `res_partner`. Fine for a handful of duplicates; for thousands of groups +it means hundreds of thousands of statements and does not finish in any reasonable window. + +This module inverts the loops — **one statement per column for the whole batch** — and keeps foreign +keys enabled throughout, so integrity is guaranteed by PostgreSQL rather than by the procedure being +right. On a production database of 538.000 partners, merging 5.350 records took about four and a half +minutes. + +**Safety.** The simulation is not a flag you can forget: it runs the whole merge on a separate +transaction and rolls it back, so it cannot write even if something is wrong. Applying is a distinct +step, guarded by its own access group, and refuses to finish if any reference is left pointing at an +absorbed record. + +**What it does not touch by itself.** Groups whose unreconciled balance is spread over several records +are classified apart — merging those moves money between ledgers and belongs with the accountant. +Also left out: groups holding your own company record, groups with portal users on more than one +record, and groups whose names differ completely on the same VAT number. + +**Kept record.** Chosen by document volume — most invoices, then most sales orders, then oldest. Since +that is not the same as name quality, the surviving record is flagged when its name looks mangled at +import, with the names of the absorbed records alongside for correction. + +The same procedure is available as plain SQL scripts in `deltatech/scripts/partner_merge/`, for when +it has to be run outside Odoo. diff --git a/deltatech_partner_merge/readme/HISTORY.md b/deltatech_partner_merge/readme/HISTORY.md new file mode 100644 index 000000000..c99ae054d --- /dev/null +++ b/deltatech_partner_merge/readme/HISTORY.md @@ -0,0 +1,7 @@ +## 19.0.1.0.0 (2026-08-18) + +- Add: bulk merge of partners duplicated on the same VAT number, with classification, guards, + simulation on a rolled-back transaction, and verification against a snapshot taken before the + merge. Wraps the SQL procedure from `deltatech/scripts/partner_merge/`, validated on a client's + staging database (575 records merged in three batches, zero discrepancies on invoices, orders, + deliveries and unreconciled balance). diff --git a/deltatech_partner_merge/security/ir.model.access.csv b/deltatech_partner_merge/security/ir.model.access.csv new file mode 100644 index 000000000..7cf40ed3d --- /dev/null +++ b/deltatech_partner_merge/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_partner_merge_batch_prepare,partner.merge.batch prepare,model_partner_merge_batch,group_partner_merge_prepare,1,1,1,1 +access_partner_merge_line_prepare,partner.merge.batch.line prepare,model_partner_merge_batch_line,group_partner_merge_prepare,1,1,1,1 diff --git a/deltatech_partner_merge/security/partner_merge_security.xml b/deltatech_partner_merge/security/partner_merge_security.xml new file mode 100644 index 000000000..e32f72907 --- /dev/null +++ b/deltatech_partner_merge/security/partner_merge_security.xml @@ -0,0 +1,19 @@ + + + + + Partner Merge / Prepare and simulate + Can analyze duplicates and run simulations. Cannot apply a merge. + + + + Partner Merge / Apply + + Can apply a merge batch — deletes or archives partner records. + + + + + + diff --git a/deltatech_partner_merge/static/description/icon.png b/deltatech_partner_merge/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ad6e34df7767d24c64e50edaab009a68d3320915 GIT binary patch literal 11702 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 g@&8Uu6OF_i#F + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/deltatech_partner_merge/static/description/main_screenshot.png b/deltatech_partner_merge/static/description/main_screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..fb9fac1818af35bf1e93c5f2930a389ac053ed8a GIT binary patch literal 42541 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 literal 0 HcmV?d00001 diff --git a/deltatech_partner_merge/views/partner_merge_views.xml b/deltatech_partner_merge/views/partner_merge_views.xml new file mode 100644 index 000000000..c31519c21 --- /dev/null +++ b/deltatech_partner_merge/views/partner_merge_views.xml @@ -0,0 +1,119 @@ + + + + partner.merge.batch.form + partner.merge.batch + +
+
+
+ +
+

+
+
+ Analyzing does not change any business data — it only groups the duplicates and + picks, for each group, the record to keep. Simulating runs the whole merge on a + separate transaction and rolls it back, so it cannot write anything. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Categories C and D change accounting figures — handle them with the + accountant, after the month is closed. Guarded groups are left out + automatically: those holding your own company record, those with portal + users on several records, and those whose names differ completely. +
+ +
+
+
+
+
+
+ + + partner.merge.batch.list + partner.merge.batch + + + + + + + + + + + + + Partner Merge Batches + partner.merge.batch + list,form + +

Create a merge batch

+

+ Groups partners that share the same VAT number and merges them in bulk: one statement + per column for the whole batch, instead of one per group per column. Foreign keys stay + enabled throughout, so the database guarantees integrity. +

+
+
+ + + +