Skip to content

T3642 FIX clean and merge the duplicated contacts - #2196

Merged
ecino merged 5 commits into
18.0from
T3642-hide-migrated-contact-aliases
Nov 2, 2026
Merged

ecino merged 5 commits into
18.0from
T3642-hide-migrated-contact-aliases

Conversation

@ecino

@ecino ecino commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

T3642

⚠️ Slow migration, plan a downtime. Upgrading crm_request took approx. 3.5 minutes on a copy of swiss18 (-u crm_request,interaction_resume,partner_compassion,wordpress_connector), plan for up to 8 minutes on production. The merges themselves are queued as jobs (about 30 jobs, 4 minutes once the queue runs), so queue_job must be running after the upgrade.

Contacts attached with partner_contact_in_several_companies in v14 were migrated as children of type other and now show up as duplicates. Other duplicates exist too.

New partner type email_alias (defined in crm_request, it was in partner_compassion in v14). The aliases of a contact get this type, so that real "Other address" children are neither treated as aliases nor hidden. Without crm_request the type does not exist and interaction_resume simply finds no alias.

Migration 18.0.1.0.1, post-migration (aliases of the v14 migration)

  • Only the children of type other that look like contacts attached in v14 are cleaned: blank, copied or same name as their parent (same words, any order), or sharing a word with their parent when it is a person. Any other child, like a contact person under a company, is left untouched.
  • Moves every relation (communications, claims, messages, invoices...) to the main contact, and the other children of the alias.
  • Archives the ones without data of their own (e-mail, address, phone numbers, birthdate, CC flag). The ones with an e-mail of their own stay active as email_alias. The ones with only an address or a phone number get the type other (Odoo synchronizes the address of the type contact with the main contact).
  • Aliases with a user or a sponsorship stay of type other, with an activity on their main contact.
  • A partner of type email_alias without parent becomes a normal contact.

Migration 18.0.1.0.1, end-migration (other duplicates)

  • Persons with the same name and e-mail, same name and zip, same name and birthdate, or same name, zip and street. Contacts with different birthdates are never merged nor converted.
  • The main contact is the only one with a user or an active sponsorship, otherwise the one with the most invoices, then the oldest.
  • Certain duplicates are merged, in jobs (res.partner._merge_duplicates): same name and e-mail, same name and zip with an e-mail missing but not different, or a birthdate / address match with the same e-mail.
  • The other matching ones become aliases of the main contact, with their relations moved to it (type other if they only have an address or a phone number).
  • Groups with several contacts having a user or an active sponsorship are never touched: an activity (Check Duplicates) is scheduled on the main contact.
  • Activities go to partner_auto_match.match_validation_responsible. The legacy key cms_form_compassion.match_validation_responsible is renamed to it first.
  • Relations are moved in bulk, with the foreign keys the merge wizard looks for. Nothing is deleted: a row that would be a duplicate of one of the main contact stays on the alias, and is logged.
  • The stored fields of the moved contacts (complete name...) are recomputed, and the migrations use SQL instead of the ORM for thousands of contacts.

Code

  • crm_request: an incoming request from an alias address belongs to the main contact, email_from keeps the alias so replies still go to it. The alias created from a request uses the normalized address and is attached to the main contact. A request set on an alias is put on its main contact, and an archived alias is not created again.
  • interaction_resume: the resume of a contact includes its aliases (computed once per fetch).

The "Main contacts" filter hiding the aliases by default is in CompassionCH/compassion-switzerland#1884.

Tested on a copy of swiss18 (upgrade 3 min 30, then 31 merge jobs, 4 min)

  • Aliases: 4600 children of type other: 4539 cleaned (938 archived, 3271 email_alias with an e-mail, 330 kept as other with an address or a phone number), 51 left untouched (real children), 10 flagged (user or sponsorship).
  • Other duplicates: 83 groups, 31 merged, 19 turned into aliases, 30 groups flagged (60 contacts, several with a user or an active sponsorship). 3 same name and zip pairs with different birthdates are left apart.
  • No claim, communication job or invoice left on a cleaned alias, nothing deleted when moving the relations.
  • A request from an alias address goes to the main contact, the reply goes to the alias, and the resume of the main contact shows it.

Contacts attached with partner_contact_in_several_companies were migrated
as children of type other and now appear as duplicates.

- crm_request: migration moving every relation of these aliases to their
  main contact, archiving the empty ones; incoming requests from an alias
  address belong to the main contact while email_from keeps the alias
- interaction_resume: the resume of a contact includes its aliases

Assisted-by: claude-sonnet-5-5, medium, claude-code
- end-migration: find duplicated persons (same name and e-mail / zip,
  same name and birthdate, same address), merge the certain ones in jobs,
  turn the others into children of type other, flag the rest with an
  activity (Check Duplicates, partner_auto_match responsible)
- post-migration: also schedule an activity for the aliases left untouched
- rename the legacy cms_form_compassion.match_validation_responsible key
- merge job: res.partner._merge_duplicates, shared helpers in tools

Assisted-by: claude-sonnet-5-5, medium, claude-code
@ecino ecino changed the title T3642 FIX clean contacts surfaced by the v14 migration T3642 FIX clean and merge the duplicated contacts Nov 2, 2026
The aliases of a contact get the type email_alias instead of other, so
that the real other addresses are not mistaken for aliases and not hidden.
Both migrations set it on the contacts they clean.

Assisted-by: claude-sonnet-5-5, medium, claude-code
@ecino
ecino marked this pull request as ready for review November 2, 2026 12:22

@ecino ecino left a comment •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review is AI-generated (Claude Code) and has not been verified line by line.

Updated after the second review pass. The findings of the first pass were reported as fixed and are removed from this comment; only what remains or is new is listed.

To fix before merging

  1. models/res_partner.py:6: the import chain res_partner.py → tools/partner_cleanup.py → openupgradelib runs on every registry load, but the manifest only declares pandas. Add openupgradelib to external_dependencies (or keep those helpers out of the model import path).
  2. migrations/18.0.1.0.1/end-migration.py:219: "same name + same zip, e-mail missing on one side" still returns merge without looking at the street, and the birthdate guard only applies when both birthdates are filled. Two different people in the same village can be merged (the source partner is deleted). A differing street, or an e-mail on one side only, should downgrade to child/review.
  3. tools/partner_cleanup.py:152: converted duplicates with their own address/phone become children but keep type contact, so Odoo's _children_sync overwrites their address on the next address edit of the main contact. Set them to other.

Smaller points

  • post-migration.py:48: set(aliases.ids) is rebuilt for every alias inside the comprehension (O(N²)); compute it once.
  • end-migration.py:160: _add_usage reads recurring_contract.correspondent_id unguarded, while post-migration uses column_exists.
  • partner_cleanup.py:107: the "no data of its own" test ignores name, ref, lang, comment, city and country, and the shared-token heuristic can classify "Marie Dupont" under "Jean Dupont" as an alias that is then archived. Compare names (equal, blank or a copy) before archiving.
  • partner_cleanup.py:29: COPY_NAME misses the Italian (copia).
  • partner_cleanup.py:216: move_relations re-implements FK/reference re-pointing that the merge wizard already provides (_update_foreign_keys_generic, _update_reference_fields_generic); the fallback loops per alias with one savepoint each.
  • partner_cleanup.py:180: _rename_like_main does one UPDATE per alias and is the only place stored computed fields (complete_name, ...) are refreshed, so re-parented but not renamed aliases keep stale values.
  • models/request.py:288: an alias is resolved to its parent only for the field sync; partner_id and message author_id keep the alias, and the duplicate-alias check ignores archived aliases.

ecino added 2 commits November 2, 2026 14:41
- only clean the children of type other that look like contacts attached
  in v14, leave the real other addresses untouched
- never merge or convert contacts with different birthdates
- drop rows only in link tables on a unique violation, and log them
- guard the sponsorship tables, merge the visitors of a partner before a
  merge, normalize the e-mail of the alias created from a request
- keep the data an alias carries (phone, birthdate), re-parent the children
  of a converted contact, use SQL instead of the ORM in the migrations
- compute the aliases once per fetch of the interaction resume
- turn an email_alias without parent into a contact

Assisted-by: claude-sonnet-5-5, medium, claude-code
- no openupgradelib in the code imported by the models
- contacts kept for their own address or phone get the type other, as
  Odoo synchronizes the address of the type contact with the main contact
- move the relations with the foreign keys of the merge wizard, never
  delete a row, leave a conflicting row on the alias
- recompute the stored fields of the moved contacts, rename in one query
- a request on an alias goes to the main contact, archived aliases are
  not created again
- (copia) is a copy, compute the set of aliases once

Assisted-by: claude-sonnet-5-5, medium, claude-code
@ecino

ecino commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

This reply is AI-generated (Claude Code).

Thanks for the second pass. Fixed in 44181c4: points 1 and 3, and the smaller points on set(aliases.ids), (copia), move_relations, _rename_like_main and the request write(). The remaining points are fine as they are, and we won't change them:

  • 2 (same name + zip, e-mail missing on one side): this is the rule we asked for. The birthdate guard applies when both birthdates are known. The merge keeps the data (relations are moved to the main contact) and leaves a note on it, so a homonym in the same village without any e-mail is an accepted, rare risk.
  • Smaller, _add_usage reads correspondent_id unguarded: the end-migration runs after all modules are loaded. recurring_contract and correspondent_id come from the sponsorship modules, which are always installed together on our databases.
  • Smaller, "no data of its own" ignores name, ref, lang, comment, city, country: the test targets what identifies the person (e-mail, address, phones, birthdate). An alias is archived, not deleted, and its relations are moved, so nothing is lost and it can be restored. The "Marie under Jean Dupont" case is a known, limited false positive.

@ecino
ecino merged commit d36b8f0 into 18.0 Nov 2, 2026
1 check passed
@ecino
ecino deleted the T3642-hide-migrated-contact-aliases branch November 2, 2026 15:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant