CLOSE #29244 Feature to merge contacts (#39512)

Add the ability to merge two contacts, the same way third parties and members
can already be merged. The merged contact is absorbed by the current one, all
its satellite data is moved, then it is deleted.

Contact::mergeContact() orchestrates everything in a single transaction, using
the new CommonObject::commonReplaceContact() helper and the replaceContact()
static methods of Contact, ActionComm and User. A replaceContact hook lets the
external modules move their own data. No schema change, no migration.

Three specific traps of this table are handled:
- llx_element_contact.fk_socpeople references llx_socpeople only when
  c_type_contact.source is 'external', and llx_user when it is 'internal', so
  both queries filter on it. Without that filter the internal assignments of
  the users would silently be moved to the merged contact.
- Contact::updateRoles(), called by update(), deletes then reinserts every
  societe_contacts row of the contact from $this->roles. It is neutralised by
  setting roles to null, and update() is called before the links are moved.
- Every table having a unique index on the contact id is deduplicated before
  its update, so the update cannot violate it. $ignoreerrors is never used:
  the FIXME of commonReplaceThirdparty() documents the links it loses.

The files are moved after the commit and not before, unlike mergeCompany() and
mergeMembers(): a failure after the move leaves them with the database rolled
back but the files already gone. A name collision renames the file instead of
losing it, and a failure is reported to the user instead of being ignored.

Access guards live in the class and not in the page, so the REST API, the
scheduled jobs and the modules benefit from them too. They cover both contacts,
fetch() by rowid applying neither an entity nor a permission filter: entity,
private contact of another user, external user, sales representative perimeter,
absorbing a shared contact into a private one, and a contact linked to a user
account (moving llx_user.fk_socpeople would let the email of that user be
rewritten from the contact card, so it requires the permission to create users).

Form::select_contact() gets a one line fix on the way: it accepts a $filter
parameter, documents it, forwards it to the ajax branch, but dropped it when
building the combo, so the merge popup could not exclude the current contact.

Known limitations, out of the scope of this pull request: merging contacts of
different entities is refused, Contact::update() neither writes url, no_email,
fk_parent nor import_key while its $nosyncuser parameter is declared but never
read, and with CONTACT_USE_SEARCH_TO_SELECT set the current contact is still
offered in the popup, contact/ajax/contact.php overwriting the filter it gets.
This commit is contained in:
VIAL-GOUTEYRON Quentin 2026-08-19 02:26:34 +02:00 committed by GitHub
parent a0b0e44278
commit 2d44fdf49c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1597 additions and 1 deletions

View file

@ -2644,6 +2644,61 @@ class ActionComm extends CommonObject
return CommonObject::commonReplaceThirdparty($dbs, $origin_id, $dest_id, $tables);
}
/**
* Function used to replace a contact id with another one when merging two contacts.
* llx_actioncomm_resources with element_type = 'socpeople' is where the contacts assigned to an
* event are really stored, llx_actioncomm.fk_contact being deprecated but still filled.
*
* @param DoliDB $dbs Database handler
* @param int $origin_id Old contact id (the contact to delete)
* @param int $dest_id New contact id (the contact that will receive elements of the other)
* @return bool True if success, False if error
*/
public static function replaceContact(DoliDB $dbs, $origin_id, $dest_id)
{
// llx_actioncomm_resources: UNIQUE(fk_actioncomm, element_type, fk_element)
$sql = 'DELETE FROM '.$dbs->prefix().'actioncomm_resources WHERE rowid IN (';
$sql .= ' SELECT x.rowid FROM (';
$sql .= ' SELECT origin.rowid FROM '.$dbs->prefix().'actioncomm_resources as origin';
$sql .= ' INNER JOIN '.$dbs->prefix().'actioncomm_resources as dest';
$sql .= ' ON dest.fk_actioncomm = origin.fk_actioncomm AND dest.element_type = origin.element_type';
$sql .= " WHERE origin.element_type = 'socpeople'";
$sql .= ' AND origin.fk_element = '.((int) $origin_id).' AND dest.fk_element = '.((int) $dest_id);
$sql .= ' ) as x)';
if (!$dbs->query($sql)) {
return false;
}
$sql = 'UPDATE '.$dbs->prefix().'actioncomm_resources SET fk_element = '.((int) $dest_id);
$sql .= " WHERE element_type = 'socpeople' AND fk_element = ".((int) $origin_id);
if (!$dbs->query($sql)) {
return false;
}
// llx_actioncomm_reminder: UNIQUE(fk_actioncomm, fk_user, fk_soc, fk_contact, typeremind, offsetvalue, offsetunit)
$sql = 'DELETE FROM '.$dbs->prefix().'actioncomm_reminder WHERE rowid IN (';
$sql .= ' SELECT x.rowid FROM (';
$sql .= ' SELECT origin.rowid FROM '.$dbs->prefix().'actioncomm_reminder as origin';
$sql .= ' INNER JOIN '.$dbs->prefix().'actioncomm_reminder as dest';
$sql .= ' ON dest.fk_actioncomm = origin.fk_actioncomm AND dest.typeremind = origin.typeremind';
$sql .= ' AND dest.offsetvalue = origin.offsetvalue AND dest.offsetunit = origin.offsetunit';
// fk_user and fk_soc are part of the unique key and are nullable, hence the NULL safe
// comparisons: the MySQL <=> operator is not portable to PostgreSQL
$sql .= ' AND (dest.fk_user = origin.fk_user OR (dest.fk_user IS NULL AND origin.fk_user IS NULL))';
$sql .= ' AND (dest.fk_soc = origin.fk_soc OR (dest.fk_soc IS NULL AND origin.fk_soc IS NULL))';
$sql .= ' WHERE origin.fk_contact = '.((int) $origin_id).' AND dest.fk_contact = '.((int) $dest_id);
$sql .= ' ) as x)';
if (!$dbs->query($sql)) {
return false;
}
$tables = array(
'actioncomm', 'actioncomm_reminder'
);
return CommonObject::commonReplaceContact($dbs, $origin_id, $dest_id, $tables, 'fk_contact');
}
/**
* Function used to replace a product id with another one.
*

View file

@ -353,6 +353,39 @@ if (empty($reshook)) {
}
}
// Merge a contact into the current one. As on the third party card, the confirmation popup submits
// with a GET, the request being protected by the CSRF token of main.inc.php. All the permission and
// perimeter checks on the two contacts are done by Contact::mergeContact() itself.
if ($action == 'confirm_merge' && $confirm == 'yes' && $permissiontoadd && $user->hasRight('societe', 'contact', 'supprimer')) {
$contact_origin_id = GETPOSTINT('contact_origin');
if ($contact_origin_id <= 0) {
$langs->load('errors');
setEventMessages($langs->trans('ErrorFieldRequired', $langs->transnoentitiesnoconv('MergeOriginContact')), null, 'errors');
} else {
// fetch() returns the id when found, 2 when several records were found, 0 when not found
// and -1 on error: a plain "<= 0" test would report an empty error on the not found case
$result = $object->fetch($id);
if ($result == 0) {
$langs->load('errors');
setEventMessages($langs->trans('ErrorRecordNotFound'), null, 'errors');
} elseif ($result != $id) {
$langs->load('errors');
setEventMessages($object->error ? $object->error : $langs->trans('ErrorBadParameters'), $object->errors, 'errors');
} elseif ($object->mergeContact($contact_origin_id) < 0) {
setEventMessages($object->error, $object->errors, 'errors');
} else {
setEventMessages($langs->trans('ContactsMergeSuccess'), null, 'mesgs');
// The merge is committed, but the files are moved afterwards and may have failed
if (!empty($object->warnings)) {
setEventMessages(null, $object->warnings, 'warnings');
}
header("Location: ".$_SERVER['PHP_SELF'].'?id='.$object->id);
exit;
}
}
}
if ($action == 'update' && empty($cancel) && $permissiontoadd) {
if (!GETPOST("lastname", 'alpha')) {
$error++;
@ -602,6 +635,25 @@ if (is_object($objcanvas) && $objcanvas->displayCanvasExists($action)) {
}
}
// Confirm merging contact
if ($action == 'merge' && $permissiontoadd && $user->hasRight('societe', 'contact', 'supprimer')) {
// The current contact is excluded with the $filter parameter and not with $exclude, the latter
// being applied by selectcontacts() only when it receives an array while the ajax branch gives
// it a string. With CONTACT_USE_SEARCH_TO_SELECT the exclusion is lost anyway, contact/ajax
// /contact.php overwriting the filter it receives: merging a contact into itself is then refused
// by mergeContact() instead. The third party is shown to tell homonyms apart.
$formquestion = array(
array(
'name' => 'contact_origin',
'label' => $langs->trans("MergeOriginContact"),
'type' => 'other',
'value' => $form->select_contact(0, '', 'contact_origin', 1, '', '', 1, 'minwidth200', false, 1, 0, array(), '', '', '', '(sp.rowid:!=:'.((int) $id).')')
)
);
print $form->formconfirm($_SERVER["PHP_SELF"]."?id=".$id, $langs->trans("MergeContacts"), $langs->trans("ConfirmMergeContacts"), "confirm_merge", $formquestion, 'no', 1, 300);
}
/*
* Onglets
*/
@ -1568,6 +1620,11 @@ if (is_object($objcanvas) && $objcanvas->displayCanvasExists($action)) {
print '<a class="butActionDelete" href="'.$_SERVER['PHP_SELF'].'?action=disable&id='.$object->id.'&token='.newToken().'">'.$langs->trans("DisableUser").'</a>';
}
// Merge
if ($permissiontoadd && $user->hasRight('societe', 'contact', 'supprimer')) {
print dolGetButtonAction($langs->trans("MergeContacts"), $langs->trans("Merge"), 'danger', $_SERVER["PHP_SELF"].'?id='.$object->id.'&action=merge&token='.newToken(), '', $user->hasRight('societe', 'contact', 'supprimer'));
}
// Delete
if ($user->hasRight('societe', 'contact', 'supprimer')) {
print dolGetButtonAction($langs->trans("Delete"), '', 'delete', $_SERVER["PHP_SELF"].'?id='.$object->id.'&action=delete&token='.newToken().($backtopage ? '&backtopage='.urlencode($backtopage) : ''), 'delete', $user->hasRight('societe', 'contact', 'supprimer'));

View file

@ -52,6 +52,31 @@ class Contact extends CommonObject
*/
public $TRIGGER_PREFIX = 'CONTACT';
/**
* @var string[] Properties copied from the merged contact when they are empty on the target one.
* Only properties actually loaded by fetch() can be listed here: url, no_email,
* fk_parent, ip and the geo columns are not, and no_email is deprecated in favour
* of the llx_mailing_unsubscribe table. photo is excluded on purpose: the file is
* moved once the transaction is committed and may be renamed on a name collision.
*/
public const MERGE_FIELDS_FILL_IF_EMPTY = array(
'civility_code', 'lastname', 'firstname', 'name_alias', 'address', 'zip', 'town',
'state_id', 'country_id', 'poste', 'phone_pro', 'phone_perso', 'phone_mobile', 'fax',
'email', 'socialnetworks', 'birthday', 'default_lang', 'ref_ext',
'fk_prospectlevel', 'stcomm_id', 'socid'
);
/**
* @var string[] Properties concatenated when merging two contacts.
*/
public const MERGE_FIELDS_CONCAT = array('note_public', 'note_private');
/**
* @var int Maximum depth walked when looking for the ancestors of a contact, to avoid an
* infinite loop should the parent hierarchy already contain a cycle.
*/
public const MERGE_MAX_PARENT_DEPTH = 100;
/**
* @var string ID to identify managed object
*/
@ -1867,6 +1892,668 @@ class Contact extends CommonObject
return CommonObject::commonReplaceThirdparty($dbs, $origin_id, $dest_id, $tables);
}
/**
* Function used to replace a contact id with another one when merging two contacts.
* Every table having a unique index on the contact id is deduplicated before its update, so the
* update cannot violate it.
* llx_categorie_contact is not handled here (done by setCategories) and llx_socpeople_extrafields
* is not either (values are merged into the target contact before its update).
*
* @param DoliDB $dbs Database handler
* @param int $origin_id Old contact id (the contact to delete)
* @param int $dest_id New contact id (the contact that will receive elements of the other)
* @return bool True if success, False if error
*/
public static function replaceContact(DoliDB $dbs, $origin_id, $dest_id)
{
// llx_societe_contacts: UNIQUE(entity, fk_soc, fk_c_type_contact, fk_socpeople).
// Delete the roles the target contact already has, then move the remaining ones.
$sql = 'DELETE FROM '.$dbs->prefix().'societe_contacts WHERE rowid IN (';
$sql .= ' SELECT x.rowid FROM (';
$sql .= ' SELECT origin.rowid FROM '.$dbs->prefix().'societe_contacts as origin';
$sql .= ' INNER JOIN '.$dbs->prefix().'societe_contacts as dest ON dest.entity = origin.entity';
$sql .= ' AND dest.fk_soc = origin.fk_soc AND dest.fk_c_type_contact = origin.fk_c_type_contact';
$sql .= ' WHERE origin.fk_socpeople = '.((int) $origin_id).' AND dest.fk_socpeople = '.((int) $dest_id);
$sql .= ' ) as x)';
if (!$dbs->query($sql)) {
return false;
}
if (!CommonObject::commonReplaceContact($dbs, $origin_id, $dest_id, array('societe_contacts'))) {
return false;
}
// llx_element_contact.fk_socpeople points to llx_socpeople only when c_type_contact.source is
// 'external'. It points to llx_user when source is 'internal', so both queries below MUST filter
// on it, otherwise internal (user) assignments would be moved to the merged contact.
// llx_element_contact: UNIQUE(element_id, fk_c_type_contact, fk_socpeople)
$sql = 'DELETE FROM '.$dbs->prefix().'element_contact WHERE rowid IN (';
$sql .= ' SELECT x.rowid FROM (';
$sql .= ' SELECT origin.rowid FROM '.$dbs->prefix().'element_contact as origin';
$sql .= ' INNER JOIN '.$dbs->prefix().'element_contact as dest ON dest.element_id = origin.element_id';
$sql .= ' AND dest.fk_c_type_contact = origin.fk_c_type_contact';
$sql .= ' WHERE origin.fk_socpeople = '.((int) $origin_id).' AND dest.fk_socpeople = '.((int) $dest_id);
$sql .= " AND origin.fk_c_type_contact IN (SELECT rowid FROM ".$dbs->prefix()."c_type_contact WHERE source = 'external')";
$sql .= ' ) as x)';
if (!$dbs->query($sql)) {
return false;
}
$sql = 'UPDATE '.$dbs->prefix().'element_contact SET fk_socpeople = '.((int) $dest_id);
$sql .= ' WHERE fk_socpeople = '.((int) $origin_id);
$sql .= " AND fk_c_type_contact IN (SELECT rowid FROM ".$dbs->prefix()."c_type_contact WHERE source = 'external')";
if (!$dbs->query($sql)) {
return false;
}
// References to a contact stored as a (type, id) couple. All the names below are literals, so
// they are safe to concatenate. 'unique' lists the other columns of the unique index of the
// table, if any, so the rows the target contact already has can be dropped before the update.
$polymorphic = array(
array('table' => 'object_lang', 'id' => 'fk_object', 'type' => 'type_object',
'values' => array('contact', 'socpeople'), 'unique' => array('property', 'lang')),
array('table' => 'links', 'id' => 'objectid', 'type' => 'objecttype',
'values' => array('contact'), 'unique' => array('label')),
array('table' => 'element_element', 'id' => 'fk_source', 'type' => 'sourcetype',
'values' => array('contact'), 'unique' => array('fk_target', 'targettype')),
array('table' => 'element_element', 'id' => 'fk_target', 'type' => 'targettype',
'values' => array('contact'), 'unique' => array('fk_source', 'sourcetype')),
// An event can be linked to a contact as its related object
array('table' => 'actioncomm', 'id' => 'fk_element', 'type' => 'elementtype',
'values' => array('contact'), 'unique' => array()),
// dol_move() updates the path of the indexed files but never their source object, so the
// index rows have to be moved here or they would point to the deleted contact. The unique
// index of the table is on (filepath, filename, entity), which is left untouched.
array('table' => 'ecm_files', 'id' => 'src_object_id', 'type' => 'src_object_type',
'values' => array('contact', 'socpeople'), 'unique' => array()),
array('table' => 'quickmemo_memo', 'id' => 'fk_element', 'type' => 'element_type',
'values' => array('contact'), 'unique' => array()),
array('table' => 'comment', 'id' => 'fk_element', 'type' => 'element_type',
'values' => array('contact'), 'unique' => array()),
);
foreach ($polymorphic as $ref) {
// Some of these tables are provided by modules that may not be installed
$sanitizedtable = $dbs->sanitize($ref['table']);
$sanitizedidcol = $dbs->sanitize($ref['id']);
$sanitizedtypecol = $dbs->sanitize($ref['type']);
if (!$dbs->DDLListTables((string) $dbs->database_name, $dbs->prefix().$sanitizedtable)) {
continue;
}
// Each value is escaped on its own: sanitize() removes the quotes inside the string it is
// given, so sanitizing an already assembled list would collapse it into a single value
$quotedvalues = array();
foreach ($ref['values'] as $refvalue) {
$quotedvalues[] = "'".$dbs->escape($refvalue)."'";
}
$sanitizedvalues = implode(', ', $quotedvalues); // @phan-suppress-current-line SqlInjection
$sanitizedtypefilter = $sanitizedtypecol." IN (".$sanitizedvalues.")";
if (!empty($ref['unique'])) {
$sql = "DELETE FROM ".$dbs->prefix().$sanitizedtable." WHERE rowid IN (";
$sql .= " SELECT x.rowid FROM (";
$sql .= " SELECT origin.rowid FROM ".$dbs->prefix().$sanitizedtable." as origin";
$sql .= " INNER JOIN ".$dbs->prefix().$sanitizedtable." as dest";
$sql .= " ON dest.".$sanitizedtypecol." = origin.".$sanitizedtypecol;
foreach ($ref['unique'] as $uniquecol) {
$sanitizeduniquecol = $dbs->sanitize($uniquecol);
$sql .= " AND dest.".$sanitizeduniquecol." = origin.".$sanitizeduniquecol;
}
$sql .= " WHERE origin.".$sanitizedidcol." = ".((int) $origin_id);
$sql .= " AND dest.".$sanitizedidcol." = ".((int) $dest_id);
$sql .= " AND origin.".$sanitizedtypefilter;
$sql .= " ) as x)";
if (!$dbs->query($sql)) {
return false;
}
}
$sql = "UPDATE ".$dbs->prefix().$sanitizedtable." SET ".$sanitizedidcol." = ".((int) $dest_id);
$sql .= " WHERE ".$sanitizedtypefilter;
$sql .= " AND ".$sanitizedidcol." = ".((int) $origin_id);
if (!$dbs->query($sql)) {
return false;
}
}
// A link between the two contacts became a link of the target contact to itself, which the
// linked objects box would then display. There is no unique index violation, so nothing failed.
$sql = "DELETE FROM ".$dbs->prefix()."element_element WHERE fk_source = fk_target";
$sql .= " AND sourcetype = targettype AND fk_source = ".((int) $dest_id);
$sql .= " AND sourcetype = 'contact'";
if (!$dbs->query($sql)) {
return false;
}
return true;
}
/**
* Merge a contact with the current one, deleting the given contact $contact_origin_id.
* All satellite data of the merged contact are moved to the current contact.
* Access guards are implemented here and not into the calling page, and cover the two contacts, so
* the REST API, the scheduled jobs and the external modules also benefit from them.
* Must not be called inside an already open transaction: DoliDB::rollback() only decrements the
* nesting counter, so the caller would commit a partially merged contact.
*
* @param int $contact_origin_id Contact to merge the data from (will be deleted)
* @return int Return integer -1 if error, >=0 if OK
*/
public function mergeContact($contact_origin_id)
{
global $langs, $hookmanager, $user, $action;
$error = 0;
$langs->loadLangs(array('errors', 'companies'));
// The target contact must have been loaded: update() would silently update no row and the
// satellite data would then be moved to the contact id 0.
if (!($this->id > 0) || empty($this->entity)) {
$this->error = $langs->trans('ErrorBadParameters');
dol_syslog(__METHOD__.' Called on a contact that was not loaded', LOG_ERR);
return -1;
}
if ($contact_origin_id <= 0 || $contact_origin_id == $this->id) {
$this->error = $langs->trans('ErrorBadParameters');
return -1;
}
// A merge deletes a contact, so it requires the permission to delete one, whatever the caller
if (!$user->hasRight('societe', 'contact', 'creer') || !$user->hasRight('societe', 'contact', 'supprimer')) {
$this->error = $langs->trans('ErrorForbidden');
return -1;
}
// An external user never merges anything, as on the third party card
if ($user->socid > 0) {
$this->error = $langs->trans('ErrorForbidden');
return -1;
}
$contact_origin = new Contact($this->db); // The contact that we will delete
$resultfetch = $contact_origin->fetch($contact_origin_id);
// fetch() returns the id when found, 2 when several records were found, 0 when not found and -1 on error
if ($resultfetch != $contact_origin_id) {
$this->error = $langs->trans('ErrorRecordNotFound');
dol_syslog(__METHOD__.' Cannot fetch contact id='.$contact_origin_id.', result='.$resultfetch, LOG_ERR);
return -1;
}
// Access guards. fetch() by rowid applies neither an entity filter nor a permission filter, so
// the two contacts are revalidated here, including the current one: an id coming from a POST is
// not to be trusted, and the checks must also protect the callers that are not the contact card.
$entities = explode(',', getEntity('contact'));
foreach (array($this, $contact_origin) as $tmpcontact) {
if (!in_array($tmpcontact->entity, $entities) || $tmpcontact->entity != $this->entity) {
$this->error = $langs->trans('ErrorContactsMergeDifferentEntity');
return -1;
}
if (!empty($tmpcontact->priv) && $tmpcontact->user_creation_id != $user->id) {
$this->error = $langs->trans('ErrorContactsMergePrivate');
return -1;
}
// A contact without a third party is shared, so the perimeter of the sales representatives
// does not apply to it, as in restrictedArea()
if ($tmpcontact->socid > 0 && !$user->hasRight('societe', 'client', 'voir')
&& !$this->isSalesRepresentativeOf($tmpcontact->socid)) {
$this->error = $langs->trans('ErrorForbidden');
return -1;
}
}
// Absorbing a shared contact into a private one would hide its data from everybody else,
// including the administrators, and the merged contact is deleted so it is not reversible
if (!empty($this->priv) && empty($contact_origin->priv)) {
$this->error = $langs->trans('ErrorContactsMergeIntoPrivate');
return -1;
}
$originlinked = $this->isLinkedToUser($contact_origin->id);
$destlinked = $this->isLinkedToUser($this->id);
if ($originlinked < 0 || $destlinked < 0) {
// The guard below is a security one, so it must refuse and not let the merge through
$this->error = $langs->trans('ErrorContactsMerge');
return -1;
}
// Moving llx_user.fk_socpeople would give the contact of a user account to another contact,
// and Contact::update() then propagates the email of that contact to the user, which is a way
// to take over the account. Changing the contact of a user requires the permission to do so.
if (($originlinked > 0 || $destlinked > 0) && !$user->hasRight('user', 'user', 'creer')) {
$this->error = $langs->trans('ErrorContactsMergeLinkedToUser');
return -1;
}
// llx_user.fk_socpeople has a unique key: refuse rather than silently break a user link
if ($originlinked > 0 && $destlinked > 0) {
$this->error = $langs->trans('ErrorContactsMergeBothLinkedToUser');
return -1;
}
dol_syslog(__METHOD__.' merge contact id='.$contact_origin->id.' (will be deleted) into the contact id='.$this->id);
$this->db->begin();
// Recopy some data
foreach (self::MERGE_FIELDS_FILL_IF_EMPTY as $property) {
if (empty($this->$property) && !empty($contact_origin->$property)) {
$this->$property = $contact_origin->$property;
}
}
// Concat some data, with a dated mention so a targeted erasure stays possible later
$mention = '['.$langs->transnoentitiesnoconv('MergedFromContact', dol_print_date(dol_now(), 'day'), (string) $contact_origin->id).']';
foreach (self::MERGE_FIELDS_CONCAT as $property) {
if (!empty($contact_origin->$property)) {
$this->$property = dol_concatdesc($this->$property, $mention."\n".$contact_origin->$property);
}
}
// A merge must never make the data of a private contact visible to everybody
if (!empty($contact_origin->priv)) {
$this->priv = 1;
}
// If alias name is not defined on target contact, we can store in it the old name of the contact
if (empty($this->name_alias) && $this->getFullName($langs) != $contact_origin->getFullName($langs)) {
$this->name_alias = $contact_origin->getFullName($langs);
}
// Merge extrafields. They are saved by the update() below.
if (is_array($contact_origin->array_options)) {
foreach ($contact_origin->array_options as $key => $val) {
if (empty($this->array_options[$key])) {
$this->array_options[$key] = $val;
}
}
}
// updateRoles(), called by update(), deletes then reinserts every societe_contacts row of the
// contact from $this->roles, which would wipe the roles we are about to move. It is a no-op
// when roles is not set. Set it to null instead of using unset(): roles is a declared property
// and unset() would make any later access emit an "Undefined property" warning.
$this->roles = null;
// Update. The trigger is called once at the end of the merge, hence $notrigger = 1.
if ($this->update($this->id, $user, 1) <= 0) {
$error++;
dol_syslog(__METHOD__.' Failed to update the target contact: '.$this->errorsToString(), LOG_ERR);
}
// Merge categories, before the deletion below: llx_categorie_contact has a foreign key on
// llx_socpeople without ON DELETE.
if (!$error) {
include_once DOL_DOCUMENT_ROOT.'/categories/class/categorie.class.php';
$static_cat = new Categorie($this->db);
$cats_origin = $static_cat->containing($contact_origin->id, 'contact', 'id');
$cats_dest = $static_cat->containing($this->id, 'contact', 'id');
// containing() returns the int -1 on SQL error. Reading it as an empty list would replace
// the categories of the target contact by the ones of the merged contact only.
if (!is_array($cats_origin) || !is_array($cats_dest)) {
$this->error = $static_cat->error;
dol_syslog(__METHOD__.' Cannot read the categories of the contacts: '.$this->error, LOG_ERR);
$error++;
} else {
// array_merge() must be used here: the + operator on arrays is a union on keys, it
// would silently drop categories.
$cats = array_merge($cats_origin, $cats_dest);
if ($this->setCategories(array_values(array_unique($cats))) < 0) {
$error++;
}
}
}
// Children contacts
if (!$error) {
$error += $this->mergeContactChildren($contact_origin);
}
// Move links
if (!$error) {
$objects = array(
'ActionComm' => '/comm/action/class/actioncomm.class.php',
'Contact' => '/contact/class/contact.class.php',
'User' => '/user/class/user.class.php',
);
foreach ($objects as $object_name => $object_file) {
require_once DOL_DOCUMENT_ROOT.$object_file;
if (!$object_name::replaceContact($this->db, $contact_origin->id, $this->id)) {
$error++;
$this->error = $this->db->lasterror();
dol_syslog(__METHOD__.' '.$object_name.'::replaceContact failed: '.$this->error, LOG_ERR);
break;
}
}
}
// Tables of the optional modules
if (!$error) {
$error += $this->mergeContactOptionalTables($contact_origin);
}
// External modules should update their ones too
if (!$error) {
$parameters = array('contact_origin' => $contact_origin->id, 'contact_dest' => $this->id);
$reshook = $hookmanager->executeHooks('replaceContact', $parameters, $this, $action);
if ($reshook < 0) {
$this->error = $hookmanager->error;
$this->errors = $hookmanager->errors;
$error++;
}
}
if (!$error) {
$this->context = array(
'merge' => 1,
'mergefromid' => $contact_origin->id,
'mergefromname' => $contact_origin->getFullName($langs)
);
// Call trigger
$result = $this->call_trigger('CONTACT_MODIFY', $user);
if ($result < 0) {
$error++;
}
// End call triggers
}
if (!$error) {
// We finally remove the old contact
if ($contact_origin->delete($user) < 1) {
$this->error = $contact_origin->error;
$this->errors = $contact_origin->errors;
$error++;
}
}
if ($error) {
$this->error = $langs->trans('ErrorContactsMerge').' '.$this->error;
$this->db->rollback();
// The object still holds the merged values in memory, reload it so the caller does not
// display data that was rolled back
$this->fetch($this->id);
return -1;
}
$this->db->commit();
// Files are moved once the transaction is committed: dol_move() is not transactional, and
// Contact::delete() does not remove the directory of the contact, so the files are still there.
$this->mergeContactFiles($contact_origin->id);
return 0;
}
/**
* Tell whether a user account is linked to the given contact.
* llx_user.fk_socpeople has a unique key, so a merge cannot move that link when the target
* contact is already linked to another user.
*
* @param int $contactid Id of the contact to check
* @return int 1 if a user is linked to this contact, 0 if none, -1 on error
*/
private function isLinkedToUser($contactid)
{
$sql = "SELECT rowid FROM ".$this->db->prefix()."user WHERE fk_socpeople = ".((int) $contactid);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
dol_syslog(__METHOD__.' '.$this->error, LOG_ERR);
return -1;
}
$found = ($this->db->num_rows($resql) > 0 ? 1 : 0);
$this->db->free($resql);
return $found;
}
/**
* Tell whether the current user is a sales representative of the given third party.
* Used to keep a user restricted to his own portfolio from merging a contact he cannot see.
*
* @param int $socid Id of the third party of the contact to merge
* @return bool True if allowed
*/
private function isSalesRepresentativeOf($socid)
{
global $user;
if (empty($socid)) {
return false; // A shared contact with no third party is out of any portfolio
}
$sql = "SELECT fk_soc FROM ".$this->db->prefix()."societe_commerciaux";
$sql .= " WHERE fk_soc = ".((int) $socid)." AND fk_user = ".((int) $user->id);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
dol_syslog(__METHOD__.' '.$this->error, LOG_ERR);
return false;
}
$found = ($this->db->num_rows($resql) > 0);
$this->db->free($resql);
return $found;
}
/**
* Move the children of the merged contact to the target contact.
* llx_socpeople.fk_parent has neither a foreign key nor an index, and it is not written by
* update(), so the hierarchy must be fixed with dedicated queries. Two corruptions have to be
* avoided: a dangling pointer when the target contact is a child of the merged one, and a cycle
* when a child of the merged contact is an ancestor of the target one.
*
* @param Contact $contact_origin Contact being merged into the current one
* @return int Number of errors
*/
private function mergeContactChildren($contact_origin)
{
$error = 0;
// fk_parent is not loaded by fetch()
$parentofdest = $this->getParentId($this->id);
// The target contact is a child of the merged one: its parent is about to be deleted
if ($parentofdest == $contact_origin->id) {
$newparent = $this->getParentId($contact_origin->id);
// A dedicated query is used rather than setValueFrom(): the 'int' format of the latter
// casts null to 0, while fk_parent is nullable, and its trigger key would fetch the
// record again and overwrite the values merged into memory.
$sql = 'UPDATE '.$this->db->prefix().'socpeople';
$sql .= ' SET fk_parent = '.($newparent > 0 ? ((int) $newparent) : 'NULL');
$sql .= ' WHERE rowid = '.((int) $this->id);
if (!$this->db->query($sql)) {
$this->error = $this->db->lasterror();
dol_syslog(__METHOD__.' '.$this->error, LOG_ERR);
return 1;
}
}
// Collect the ancestors of the target contact, they must not become its children
$ancestors = array();
$currentid = $this->getParentId($this->id);
$depth = 0;
while ($currentid > 0 && $depth < self::MERGE_MAX_PARENT_DEPTH) {
if (in_array($currentid, $ancestors)) {
break; // The hierarchy already contains a cycle, stop walking it
}
$ancestors[] = (int) $currentid;
$currentid = $this->getParentId($currentid);
$depth++;
}
$sql = 'UPDATE '.$this->db->prefix().'socpeople SET fk_parent = '.((int) $this->id);
$sql .= ' WHERE fk_parent = '.((int) $contact_origin->id);
$sql .= ' AND rowid <> '.((int) $this->id);
if (!empty($ancestors)) {
// $ancestors only contains ids already cast to int
$sanitizedancestors = implode(',', $ancestors); // @phan-suppress-current-line SqlInjection
$sql .= " AND rowid NOT IN (".$sanitizedancestors.")";
}
if (!$this->db->query($sql)) {
$this->error = $this->db->lasterror();
dol_syslog(__METHOD__.' '.$this->error, LOG_ERR);
$error++;
}
// The children excluded above, being ancestors of the target contact, still point to the
// contact about to be deleted. Detach them rather than leave a dangling parent.
if (!$error) {
$sql = 'UPDATE '.$this->db->prefix().'socpeople SET fk_parent = NULL';
$sql .= ' WHERE fk_parent = '.((int) $contact_origin->id);
if (!$this->db->query($sql)) {
$this->error = $this->db->lasterror();
dol_syslog(__METHOD__.' '.$this->error, LOG_ERR);
$error++;
}
}
return $error;
}
/**
* Return the id of the parent contact of a contact, 0 if none.
* fk_parent is not among the columns loaded by fetch().
*
* @param int $contactid Id of the contact
* @return int Id of the parent contact, 0 if none or on error
*/
private function getParentId($contactid)
{
$sql = "SELECT fk_parent FROM ".$this->db->prefix()."socpeople WHERE rowid = ".((int) $contactid);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
dol_syslog(__METHOD__.' '.$this->error, LOG_ERR);
return 0;
}
$parentid = 0;
if ($obj = $this->db->fetch_object($resql)) {
$parentid = (empty($obj->fk_parent) ? 0 : (int) $obj->fk_parent);
}
$this->db->free($resql);
return $parentid;
}
/**
* Move the data stored by the optional modules and by the notification system.
* These tables are handled here instead of in a replaceContact() of their own class, to keep the
* number of modified files low, the same way Adherent::mergeMembers() does.
*
* @param Contact $contact_origin Contact being merged into the current one
* @return int Number of errors
*/
private function mergeContactOptionalTables($contact_origin)
{
$error = 0;
// Notifications. llx_notify_def has no unique key, but a duplicated row means the same
// notification sent twice, so it must be deduplicated as well.
$sql = 'DELETE FROM '.$this->db->prefix().'notify_def WHERE rowid IN (';
$sql .= ' SELECT x.rowid FROM (';
$sql .= ' SELECT origin.rowid FROM '.$this->db->prefix().'notify_def as origin';
$sql .= ' INNER JOIN '.$this->db->prefix().'notify_def as dest ON dest.fk_action = origin.fk_action';
// A row is a duplicate only if the whole definition matches, recipient included: fk_soc,
// entity, type, threshold, context, fk_user and email are nullable, hence the NULL safe
// comparisons, the MySQL <=> operator not being portable to PostgreSQL.
$sql .= ' AND (dest.fk_soc = origin.fk_soc OR (dest.fk_soc IS NULL AND origin.fk_soc IS NULL))';
$sql .= ' AND (dest.entity = origin.entity OR (dest.entity IS NULL AND origin.entity IS NULL))';
$sql .= ' AND (dest.type = origin.type OR (dest.type IS NULL AND origin.type IS NULL))';
$sql .= ' AND (dest.threshold = origin.threshold OR (dest.threshold IS NULL AND origin.threshold IS NULL))';
$sql .= ' AND (dest.context = origin.context OR (dest.context IS NULL AND origin.context IS NULL))';
$sql .= ' AND (dest.fk_user = origin.fk_user OR (dest.fk_user IS NULL AND origin.fk_user IS NULL))';
$sql .= ' AND (dest.email = origin.email OR (dest.email IS NULL AND origin.email IS NULL))';
$sql .= ' WHERE origin.fk_contact = '.((int) $contact_origin->id).' AND dest.fk_contact = '.((int) $this->id);
$sql .= ' ) as x)';
if (!$this->db->query($sql)) {
$this->error = $this->db->lasterror();
dol_syslog(__METHOD__.' '.$this->error, LOG_ERR);
return 1;
}
if (!CommonObject::commonReplaceContact($this->db, $contact_origin->id, $this->id, array('notify', 'notify_def'), 'fk_contact')) {
dol_syslog(__METHOD__.' Failed to move the notifications: '.$this->db->lasterror(), LOG_ERR);
return 1;
}
// Mass emailing targets
if ($this->db->DDLListTables((string) $this->db->database_name, $this->db->prefix().'mailing_cibles')) {
// No deduplication here: uk_mailing_cibles is (fk_mailing, email), it does not contain
// fk_contact, so moving fk_contact cannot violate it, and the rows hold the send history.
if (!CommonObject::commonReplaceContact($this->db, $contact_origin->id, $this->id, array('mailing_cibles'), 'fk_contact')) {
dol_syslog(__METHOD__.' Failed to move the mass emailing targets: '.$this->db->lasterror(), LOG_ERR);
return 1;
}
// The source of a target is also stored as a (source_type, source_id) couple
$sql = 'UPDATE '.$this->db->prefix().'mailing_cibles SET source_id = '.((int) $this->id);
$sql .= " WHERE source_type = 'contact' AND source_id = ".((int) $contact_origin->id);
if (!$this->db->query($sql)) {
$this->error = $this->db->lasterror();
dol_syslog(__METHOD__.' '.$this->error, LOG_ERR);
return 1;
}
}
return $error;
}
/**
* Move the files of the merged contact into the directory of the target contact.
* Called once the transaction is committed, because dol_move() is not transactional. A failure
* cannot be rolled back, so it is reported to the user instead of being silently logged.
*
* @param int $contact_origin_id Id of the merged contact
* @return void
*/
private function mergeContactFiles($contact_origin_id)
{
global $conf, $langs;
if (empty($conf->societe->multidir_output[$this->entity])) {
return;
}
// files.lib.php is not loaded when the merge is called outside of a web context
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
// The id is used and not $this->ref, which is null unless the contact was loaded by fetch()
$srcdir = $conf->societe->multidir_output[$this->entity].'/contact/'.((int) $contact_origin_id);
$destdir = $conf->societe->multidir_output[$this->entity].'/contact/'.((int) $this->id);
if (!dol_is_dir($srcdir)) {
return;
}
$failed = array();
$dirlist = dol_dir_list($srcdir, 'files', 1);
foreach ($dirlist as $filetomove) {
$destfile = $destdir.'/'.$filetomove['relativename'];
// dol_move() is called below with $overwriteifexists = 0, so a file already existing on
// the target contact is renamed rather than lost
if (dol_is_file($destfile)) {
$info = pathinfo($filetomove['relativename']);
$suffix = (empty($info['extension']) ? '' : '.'.$info['extension']);
$destfile = $destdir.'/'.(empty($info['dirname']) || $info['dirname'] == '.' ? '' : $info['dirname'].'/');
$destfile .= $info['filename'].'-'.((int) $contact_origin_id).$suffix;
}
// dol_move() does not create the target directory, and the target contact usually has
// none yet, so it has to be created for every level of the source tree
dol_mkdir(dirname($destfile));
if (!dol_move($filetomove['fullname'], $destfile, '0', 0, 0, 1)) {
$failed[] = $filetomove['relativename'];
}
}
if (!empty($failed)) {
dol_syslog(__METHOD__.' Failed to move '.count($failed).' file(s) from '.$srcdir, LOG_ERR);
// The merge itself is committed, so this is reported as a warning and not as a failure
$this->warnings[] = $langs->trans('WarningContactsMergeFilesNotMoved', implode(', ', $failed));
}
}
/**
* Fetch roles (default contact of some companies) for the current contact.
* This load the array ->roles.

View file

@ -10137,6 +10137,68 @@ abstract class CommonObject
return true;
}
/**
* Function used to replace a contact id with another one.
* This function is meant to be called from replaceContact with the appropriate tables.
* The column storing the contact id is 'fk_socpeople' on some tables and 'fk_contact' on others,
* hence the $fieldname parameter.
*
* @param DoliDB $dbs Database handler
* @param int $origin_id Old contact id (the contact to delete)
* @param int $dest_id New contact id (the contact that will receive elements of the other)
* @param string[] $tables Tables that need to be changed
* @param string $fieldname Name of the column storing the contact id ('fk_socpeople' or 'fk_contact')
* @param int<0,1> $ignoreerrors Ignore errors. Return true even if errors.
* @return bool True if success, False if error
*/
public static function commonReplaceContact(DoliDB $dbs, $origin_id, $dest_id, array $tables, $fieldname = 'fk_socpeople', $ignoreerrors = 0)
{
global $hookmanager;
// Table and column names are concatenated into the SQL, so they are validated as a defence in
// depth: this method is public and static, hence callable from any module.
if (!preg_match('/^[a-z0-9_]+$/', $fieldname)) {
dol_syslog(__METHOD__.' Refused an invalid column name: '.$fieldname, LOG_ERR);
return false;
}
$parameters = array(
'origin_id' => $origin_id,
'dest_id' => $dest_id,
'tables' => $tables,
'fieldname' => $fieldname,
);
$reshook = $hookmanager->executeHooks('commonReplaceContact', $parameters);
if ($reshook > 0) {
return true; // replacement code
} elseif ($reshook < 0) {
return $ignoreerrors === 1; // failure
} // reshook = 0 => execute normal code
foreach ($tables as $table) {
if (!preg_match('/^[a-z0-9_]+$/', $table)) {
dol_syslog(__METHOD__.' Refused an invalid table name: '.$table, LOG_ERR);
return false;
}
$sanitizedtable = $dbs->sanitize($table);
$sanitizedfieldname = $dbs->sanitize($fieldname);
$sql = "UPDATE ".$dbs->prefix().$sanitizedtable;
$sql .= " SET ".$sanitizedfieldname." = ".((int) $dest_id);
$sql .= " WHERE ".$sanitizedfieldname." = ".((int) $origin_id);
if (!$dbs->query($sql)) {
if ($ignoreerrors) {
return true;
}
return false;
}
}
return true;
}
/**
* Get buy price to use for margin calculation. This function is called when buy price is unknown.
* Set buy price = sell price if ForceBuyingPriceIfNull configured,

View file

@ -1975,7 +1975,7 @@ class Form
$options_only = 0;
$limitto = '';
$out .= $this->selectcontacts($socid, $selected, $htmlname, $showempty, $exclude, $limitto, $showfunction, $morecss, $options_only, $showsoc, $forcecombo, $events, $moreparam, $htmlid, $multiple, $disableifempty);
$out .= $this->selectcontacts($socid, $selected, $htmlname, $showempty, $exclude, $limitto, $showfunction, $morecss, $options_only, $showsoc, $forcecombo, $events, $moreparam, $htmlid, $multiple, $disableifempty, $filter);
}
$conf->global->CONTACT_USE_SEARCH_TO_SELECT = $sav;

View file

@ -201,6 +201,11 @@ class InterfaceActionsAuto extends DolibarrTriggers
$object->actionmsg = $langs->transnoentities("CONTACT_MODIFYInDolibarr", $object->getFullName($langs));
}
// For merge event, we add a mention
if (!empty($object->context['mergefromname'])) {
$object->actionmsg = dol_concatdesc($object->actionmsg, $langs->transnoentities("DataFromWasMerged", $object->context['mergefromname'].' (id='.$object->context['mergefromid'].')'));
}
$object->sendtoid = array($object->id => $object->id);
// $object->socid = $object->socid;
} elseif ($action == 'CONTRACT_VALIDATE' && $object instanceof Contrat) {

View file

@ -446,10 +446,22 @@ MergeOriginThirdparty=Duplicated third party (the third party you want to delete
MergeThirdparties=Merge third parties
ConfirmMergeThirdparties=Are you sure you want to merge the chosen third party with the current one? All linked objects (invoices, orders, ...) will be moved to the current third party, then the chosen third party will be deleted.
ThirdpartiesMergeSuccess=Third parties have been merged
MergeOriginContact=Duplicated contact (the contact you want to delete)
MergeContacts=Merge contacts
ConfirmMergeContacts=Are you sure you want to merge the chosen contact with the current one? All linked objects (events, proposals, orders, ...) will be moved to the current contact, then the chosen contact will be deleted. Note that the chosen contact may belong to another third party.
ContactsMergeSuccess=Contacts have been merged
MergedFromContact=Merged on %s from contact #%s
SaleRepresentativeLogin=Login of sales representative
SaleRepresentativeFirstname=First name of sales representative
SaleRepresentativeLastname=Last name of sales representative
ErrorThirdpartiesMerge=There was an error when deleting the third parties. Please check the log. Changes have been reverted.
ErrorContactsMerge=There was an error when merging the contacts. Please check the log. Changes have been reverted.
ErrorContactsMergeDifferentEntity=Contacts belonging to different entities cannot be merged
ErrorContactsMergePrivate=A private contact of another user cannot be merged
ErrorContactsMergeIntoPrivate=A shared contact cannot be merged into a private contact, its data would no longer be visible to the other users
ErrorContactsMergeLinkedToUser=One of the contacts is linked to a user account. Merging them requires the permission to create users.
ErrorContactsMergeBothLinkedToUser=Both contacts are linked to a user account. Unlink one of them before merging.
WarningContactsMergeFilesNotMoved=Contacts have been merged but some files could not be moved: %s
NewCustomerSupplierCodeProposed=Customer or Vendor code already used, a new code is suggested
KeepEmptyIfGenericAddress=Keep this field empty if this address is a generic address
#Imports

View file

@ -4196,6 +4196,25 @@ class User extends CommonObject
return CommonObject::commonReplaceThirdparty($dbs, $origin_id, $dest_id, $tables);
}
/**
* Function used to replace a contact id with another one when merging two contacts.
* llx_user.fk_socpeople has a unique key, so the case where both contacts are linked to a user
* is refused by Contact::mergeContact() before this method is called.
*
* @param DoliDB $dbs Database handler
* @param int $origin_id Old contact id (the contact to delete)
* @param int $dest_id New contact id (the contact that will receive elements of the other)
* @return bool True if success, False if error
*/
public static function replaceContact(DoliDB $dbs, $origin_id, $dest_id)
{
if (!CommonObject::commonReplaceContact($dbs, $origin_id, $dest_id, array('user'))) {
return false;
}
return CommonObject::commonReplaceContact($dbs, $origin_id, $dest_id, array('user_alert'), 'fk_contact');
}
/**
* Load metrics this->nb for dashboard

View file

@ -30,6 +30,7 @@ global $conf,$user,$langs,$db;
//require_once 'PHPUnit/Autoload.php';
require_once dirname(__FILE__).'/../../htdocs/master.inc.php';
require_once dirname(__FILE__).'/../../htdocs/contact/class/contact.class.php';
require_once dirname(__FILE__).'/../../htdocs/societe/class/societe.class.php';
require_once dirname(__FILE__).'/CommonClassTest.class.php';
$langs->load("dict");
@ -319,4 +320,702 @@ class ContactTest extends CommonClassTest
return $localobjectadd->id;
}
/**
* Create a contact to be used as a fixture by the merge tests.
*
* @param string $lastname Last name of the contact
* @param array<string,mixed> $moreprops Additional properties to set before the creation
* @return Contact Created contact
*/
private function createContactForMerge($lastname, $moreprops = array())
{
global $user, $db;
$contact = new Contact($db);
$contact->lastname = $lastname;
$contact->firstname = 'Phpunit';
foreach ($moreprops as $key => $val) {
$contact->$key = $val;
}
// The triggers are disabled on the creation of the fixtures only, to keep it independent from
// the modules installed on the instance running the tests. mergeContact() itself does fire
// CONTACT_MODIFY, as mergeCompany() fires COMPANY_MODIFY.
// No cleanup is needed: CommonClassTest opens a transaction that is rolled back after the
// class, and DoliDB::rollback() is nesting aware, so the commit of mergeContact() is included.
$id = $contact->create($user, 1);
$this->assertGreaterThan(0, $id, 'Failed to create the fixture contact: '.$contact->errorsToString());
return $contact;
}
/**
* testContactMerge
*
* Check that two contacts can be merged: the empty fields of the target contact are filled from
* the merged one, the notes are concatenated and the merged contact is deleted.
*
* @return void
*/
public function testContactMerge()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$dest = $this->createContactForMerge('MergeDest');
$origin = $this->createContactForMerge('MergeOrigin', array(
'email' => 'merge.origin@example.com',
'phone_pro' => '0102030405',
'note_public' => 'Note from the merged contact'
));
$result = $dest->mergeContact($origin->id);
print __METHOD__." result=".$result."\n";
$this->assertEquals(0, $result, 'mergeContact failed: '.$dest->error);
$check = new Contact($db);
$check->fetch($dest->id);
$this->assertEquals('merge.origin@example.com', $check->email, 'The empty email must be filled from the merged contact');
$this->assertEquals('0102030405', $check->phone_pro, 'The empty phone must be filled from the merged contact');
$this->assertStringContainsString('Note from the merged contact', (string) $check->note_public);
$deleted = new Contact($db);
$this->assertEquals(0, $deleted->fetch($origin->id), 'The merged contact must have been deleted');
}
/**
* testContactMergeKeepsInternalElementContact
*
* llx_element_contact.fk_socpeople references llx_socpeople when c_type_contact.source is
* 'external' but llx_user when it is 'internal'. Check that merging contacts never touches the
* internal (user) assignments, which would silently corrupt them.
*
* @return void
*/
public function testContactMergeKeepsInternalElementContact()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$dest = $this->createContactForMerge('MergeDestInternal');
$origin = $this->createContactForMerge('MergeOriginInternal');
// An internal type of contact, ie one referencing llx_user and not llx_socpeople
$sql = "SELECT rowid FROM ".$db->prefix()."c_type_contact WHERE source = 'internal' AND active = 1";
$resql = $db->query($sql);
$this->assertNotFalse($resql, 'Cannot read the types of contact');
$objtype = $db->fetch_object($resql);
$db->free($resql);
$this->assertIsObject($objtype, 'No active internal type of contact found');
// Simulate a user assigned to an element, storing a user id into fk_socpeople.
// The id of the merged contact is used on purpose: it is the value a buggy UPDATE would move.
$sql = "INSERT INTO ".$db->prefix()."element_contact(datecreate, statut, element_id, fk_c_type_contact, fk_socpeople)";
$sql .= " VALUES ('".$db->idate(dol_now())."', 4, 999999, ".((int) $objtype->rowid).", ".((int) $origin->id).")";
$this->assertNotFalse($db->query($sql), 'Cannot create the internal link fixture');
$result = $dest->mergeContact($origin->id);
print __METHOD__." result=".$result."\n";
$this->assertEquals(0, $result, 'mergeContact failed: '.$dest->error);
$sql = "SELECT fk_socpeople FROM ".$db->prefix()."element_contact WHERE element_id = 999999";
$sql .= " AND fk_c_type_contact = ".((int) $objtype->rowid);
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
$db->free($resql);
$this->assertIsObject($obj, 'The internal link must still exist');
$this->assertEquals($origin->id, $obj->fk_socpeople, 'An internal link must NOT be moved by a contact merge');
}
/**
* testContactMergeDoesNotWipeRoles
*
* updateRoles(), called by update(), deletes then reinserts every societe_contacts row of the
* contact from $this->roles. Check that merging does not lose the roles of the target contact.
*
* @return void
*/
public function testContactMergeDoesNotWipeRoles()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$company = new Societe($db);
$company->name = 'PhpunitMergeRoles';
$socid = $company->create($user, 1);
$this->assertGreaterThan(0, $socid, 'Failed to create the fixture third party: '.$company->errorsToString());
$dest = $this->createContactForMerge('MergeDestRoles', array('socid' => $socid));
$origin = $this->createContactForMerge('MergeOriginRoles', array('socid' => $socid));
$sql = "SELECT rowid FROM ".$db->prefix()."c_type_contact WHERE source = 'external' AND active = 1";
$resql = $db->query($sql);
$objtype = $db->fetch_object($resql);
$db->free($resql);
$this->assertIsObject($objtype, 'No active external type of contact found');
$sql = "INSERT INTO ".$db->prefix()."societe_contacts(entity, date_creation, fk_soc, fk_c_type_contact, fk_socpeople)";
$sql .= " VALUES (".((int) $conf->entity).", '".$db->idate(dol_now())."', ".((int) $socid).", ".((int) $objtype->rowid).", ".((int) $dest->id).")";
$this->assertNotFalse($db->query($sql), 'Cannot create the role fixture');
$result = $dest->mergeContact($origin->id);
print __METHOD__." result=".$result."\n";
$this->assertEquals(0, $result, 'mergeContact failed: '.$dest->error);
$sql = "SELECT COUNT(rowid) as nb FROM ".$db->prefix()."societe_contacts WHERE fk_socpeople = ".((int) $dest->id);
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
$db->free($resql);
$this->assertEquals(1, $obj->nb, 'The role of the target contact must be kept by the merge');
}
/**
* testContactMergeDeduplicatesElementContact
*
* llx_element_contact has a unique key on (element_id, fk_c_type_contact, fk_socpeople). Check
* that merging two contacts sharing the same role on the same object does not fail.
*
* @return void
*/
public function testContactMergeDeduplicatesElementContact()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$dest = $this->createContactForMerge('MergeDestDedup');
$origin = $this->createContactForMerge('MergeOriginDedup');
$sql = "SELECT rowid FROM ".$db->prefix()."c_type_contact WHERE source = 'external' AND active = 1";
$resql = $db->query($sql);
$objtype = $db->fetch_object($resql);
$db->free($resql);
$this->assertIsObject($objtype, 'No active external type of contact found');
foreach (array($dest->id, $origin->id) as $contactid) {
$sql = "INSERT INTO ".$db->prefix()."element_contact(datecreate, statut, element_id, fk_c_type_contact, fk_socpeople)";
$sql .= " VALUES ('".$db->idate(dol_now())."', 4, 999998, ".((int) $objtype->rowid).", ".((int) $contactid).")";
$this->assertNotFalse($db->query($sql), 'Cannot create the duplicated link fixture');
}
$result = $dest->mergeContact($origin->id);
print __METHOD__." result=".$result."\n";
$this->assertEquals(0, $result, 'mergeContact must succeed despite the duplicated link: '.$dest->error);
$sql = "SELECT COUNT(rowid) as nb FROM ".$db->prefix()."element_contact WHERE element_id = 999998";
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
$db->free($resql);
$this->assertEquals(1, $obj->nb, 'Only one link must remain after the merge');
}
/**
* testContactMergeMovesActioncommResources
*
* The contacts assigned to an event are stored into llx_actioncomm_resources with an element_type
* of 'socpeople', llx_actioncomm.fk_contact being deprecated.
*
* @return void
*/
public function testContactMergeMovesActioncommResources()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$dest = $this->createContactForMerge('MergeDestEvent');
$origin = $this->createContactForMerge('MergeOriginEvent');
$sql = "INSERT INTO ".$db->prefix()."actioncomm_resources(fk_actioncomm, element_type, fk_element, mandatory, answer_status, transparency)";
$sql .= " VALUES (999997, 'socpeople', ".((int) $origin->id).", 0, 0, 0)";
$this->assertNotFalse($db->query($sql), 'Cannot create the event resource fixture');
$result = $dest->mergeContact($origin->id);
print __METHOD__." result=".$result."\n";
$this->assertEquals(0, $result, 'mergeContact failed: '.$dest->error);
$sql = "SELECT fk_element FROM ".$db->prefix()."actioncomm_resources WHERE fk_actioncomm = 999997";
$sql .= " AND element_type = 'socpeople'";
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
$db->free($resql);
$this->assertIsObject($obj, 'The event assignment must still exist');
$this->assertEquals($dest->id, $obj->fk_element, 'The event assignment must be moved to the target contact');
}
/**
* testContactMergeRemapsChildren
*
* llx_socpeople.fk_parent has neither a foreign key nor an index. Check that the children of the
* merged contact are moved, and that no dangling pointer is left when the target contact is a
* child of the merged one.
*
* @return void
*/
public function testContactMergeRemapsChildren()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$dest = $this->createContactForMerge('MergeDestParent');
$origin = $this->createContactForMerge('MergeOriginParent');
$child = $this->createContactForMerge('MergeChild');
// The child belongs to the merged contact, and the target contact is itself a child of it
foreach (array($child->id, $dest->id) as $contactid) {
$sql = "UPDATE ".$db->prefix()."socpeople SET fk_parent = ".((int) $origin->id)." WHERE rowid = ".((int) $contactid);
$this->assertNotFalse($db->query($sql), 'Cannot create the hierarchy fixture');
}
$result = $dest->mergeContact($origin->id);
print __METHOD__." result=".$result."\n";
$this->assertEquals(0, $result, 'mergeContact failed: '.$dest->error);
$sql = "SELECT rowid, fk_parent FROM ".$db->prefix()."socpeople WHERE rowid IN (".((int) $child->id).", ".((int) $dest->id).")";
$resql = $db->query($sql);
$this->assertEquals(2, $db->num_rows($resql), 'Both the child and the target contact must still exist');
while ($obj = $db->fetch_object($resql)) {
if ($obj->rowid == $child->id) {
$this->assertEquals($dest->id, $obj->fk_parent, 'The child must be moved to the target contact');
} else {
$this->assertNotEquals($origin->id, $obj->fk_parent, 'The target contact must not point to the deleted contact');
$this->assertNotEquals($dest->id, $obj->fk_parent, 'The target contact must not be its own parent');
}
}
$db->free($resql);
}
/**
* testContactMergeUnionOfCategories
*
* @return void
*/
public function testContactMergeUnionOfCategories()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
require_once dirname(__FILE__).'/../../htdocs/categories/class/categorie.class.php';
$dest = $this->createContactForMerge('MergeDestCateg');
$origin = $this->createContactForMerge('MergeOriginCateg');
$catids = array();
foreach (array('PhpunitMergeCatA', 'PhpunitMergeCatB') as $label) {
$categ = new Categorie($db);
$categ->label = $label;
$categ->type = Categorie::TYPE_CONTACT;
$catid = $categ->create($user);
$this->assertGreaterThan(0, $catid, 'Failed to create the fixture category: '.$categ->errorsToString());
$catids[] = $catid;
}
$dest->setCategories(array($catids[0]));
$origin->setCategories(array($catids[1]));
$result = $dest->mergeContact($origin->id);
print __METHOD__." result=".$result."\n";
$this->assertEquals(0, $result, 'mergeContact failed: '.$dest->error);
$sql = "SELECT COUNT(fk_categorie) as nb FROM ".$db->prefix()."categorie_contact WHERE fk_socpeople = ".((int) $dest->id);
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
$db->free($resql);
$this->assertEquals(2, $obj->nb, 'The target contact must hold the union of both categories');
}
/**
* testContactMergeRejectsInvalidInput
*
* fetch() returns the id when found, 2 when several records were found, 0 when not found and -1
* on error, so the return value must be compared to the requested id.
*
* @return void
*/
public function testContactMergeRejectsInvalidInput()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$dest = $this->createContactForMerge('MergeDestInvalid');
$this->assertEquals(-1, $dest->mergeContact(0), 'Merging an empty id must be refused');
$this->assertEquals(-1, $dest->mergeContact($dest->id), 'Merging a contact into itself must be refused');
$this->assertEquals(-1, $dest->mergeContact(999996), 'Merging an unknown contact must be refused');
$check = new Contact($db);
$this->assertEquals($dest->id, $check->fetch($dest->id), 'The target contact must be untouched');
}
/**
* testContactMergeRefusesUnloadedTarget
*
* A contact that was not loaded has an id of 0, and update() would silently update no row while
* the satellite data would be moved to the contact id 0. This must be refused.
*
* @return void
*/
public function testContactMergeRefusesUnloadedTarget()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$origin = $this->createContactForMerge('MergeOriginUnloaded');
$notloaded = new Contact($db);
$this->assertEquals(-1, $notloaded->mergeContact($origin->id), 'Merging into an unloaded contact must be refused');
$check = new Contact($db);
$this->assertEquals($origin->id, $check->fetch($origin->id), 'The contact to merge must still exist');
}
/**
* testContactMergeDeduplicatesPolymorphicRefs
*
* llx_links has a unique index on (objectid, objecttype, label), so moving the links of the merged
* contact must not violate it when both contacts share a link of the same label.
*
* @return void
*/
public function testContactMergeDeduplicatesPolymorphicRefs()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$dest = $this->createContactForMerge('MergeDestLink');
$origin = $this->createContactForMerge('MergeOriginLink');
foreach (array($dest->id, $origin->id) as $contactid) {
$sql = "INSERT INTO ".$db->prefix()."links(entity, datea, url, label, objecttype, objectid)";
$sql .= " VALUES (".((int) $conf->entity).", '".$db->idate(dol_now())."', 'https://example.com',";
$sql .= " 'PhpunitSameLabel', 'contact', ".((int) $contactid).")";
$this->assertNotFalse($db->query($sql), 'Cannot create the link fixture');
}
$result = $dest->mergeContact($origin->id);
print __METHOD__." result=".$result."\n";
$this->assertEquals(0, $result, 'mergeContact must succeed despite the duplicated link: '.$dest->error);
$sql = "SELECT COUNT(rowid) as nb FROM ".$db->prefix()."links WHERE objecttype = 'contact'";
$sql .= " AND objectid = ".((int) $dest->id)." AND label = 'PhpunitSameLabel'";
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
$db->free($resql);
$this->assertEquals(1, $obj->nb, 'Only one link must remain after the merge');
}
/**
* testContactMergeMovesPolymorphicSocpeopleRefs
*
* llx_ecm_files.src_object_type holds either the element name of the contact ('contact') or its
* table name ('socpeople') depending on the writer, so both flavours must be moved. A list of
* values must not be given to DoliDB::sanitize() as a whole: it removes the quotes it contains and
* would collapse the list into a single value matching nothing.
*
* @return void
*/
public function testContactMergeMovesPolymorphicSocpeopleRefs()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$dest = $this->createContactForMerge('MergeDestEcm');
$origin = $this->createContactForMerge('MergeOriginEcm');
foreach (array('contact', 'socpeople') as $i => $objecttype) {
$sql = "INSERT INTO ".$db->prefix()."ecm_files(entity, ref, label, filename, filepath, src_object_type, src_object_id, date_c)";
$sql .= " VALUES (".((int) $conf->entity).", 'phpunitmerge".((int) $i).((int) $origin->id)."', 'phpunitmergelabel',";
$sql .= " 'phpunitmerge".((int) $i).".txt', 'contact/".((int) $origin->id)."', '".$db->escape($objecttype)."',";
$sql .= " ".((int) $origin->id).", '".$db->idate(dol_now())."')";
$this->assertNotFalse($db->query($sql), 'Cannot create the indexed file fixture');
}
$result = $dest->mergeContact($origin->id);
print __METHOD__." result=".$result."\n";
$this->assertEquals(0, $result, 'mergeContact failed: '.$dest->error);
$sql = "SELECT COUNT(rowid) as nb FROM ".$db->prefix()."ecm_files WHERE label = 'phpunitmergelabel'";
$sql .= " AND src_object_id = ".((int) $dest->id);
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
$db->free($resql);
$this->assertEquals(2, $obj->nb, 'Both flavours of src_object_type must be moved to the target contact');
}
/**
* testContactMergeMovesUserLink
*
* llx_user.fk_socpeople and llx_user_alert.fk_contact are moved by User::replaceContact().
*
* @return void
*/
public function testContactMergeMovesUserLink()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$dest = $this->createContactForMerge('MergeDestUser');
$origin = $this->createContactForMerge('MergeOriginUser');
// The contact of a user account is on the merged contact only, the target one is free
$sql = "UPDATE ".$db->prefix()."user SET fk_socpeople = ".((int) $origin->id)." WHERE rowid = ".((int) $user->id);
$this->assertNotFalse($db->query($sql), 'Cannot create the user link fixture');
$sql = "INSERT INTO ".$db->prefix()."user_alert(type, fk_user, fk_contact) VALUES (1, ".((int) $user->id).", ".((int) $origin->id).")";
$this->assertNotFalse($db->query($sql), 'Cannot create the user alert fixture');
$result = $dest->mergeContact($origin->id);
print __METHOD__." result=".$result."\n";
$this->assertEquals(0, $result, 'mergeContact failed: '.$dest->error);
$sql = "SELECT fk_socpeople FROM ".$db->prefix()."user WHERE rowid = ".((int) $user->id);
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
$db->free($resql);
$this->assertIsObject($obj, 'The user must still exist');
$this->assertEquals($dest->id, $obj->fk_socpeople, 'The contact of the user account must be moved to the target contact');
$sql = "SELECT COUNT(rowid) as nb FROM ".$db->prefix()."user_alert WHERE fk_contact = ".((int) $dest->id);
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
$db->free($resql);
$this->assertEquals(1, $obj->nb, 'The alert of the user must be moved to the target contact');
}
/**
* testContactMergeDeduplicatesSocieteContacts
*
* llx_societe_contacts has a unique key on (entity, fk_soc, fk_c_type_contact, fk_socpeople), so
* two contacts holding the same role on the same third party must not make the merge fail.
*
* @return void
*/
public function testContactMergeDeduplicatesSocieteContacts()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$company = new Societe($db);
$company->name = 'PhpunitMergeSameRole';
$socid = $company->create($user, 1);
$this->assertGreaterThan(0, $socid, 'Failed to create the fixture third party: '.$company->errorsToString());
$dest = $this->createContactForMerge('MergeDestSameRole', array('socid' => $socid));
$origin = $this->createContactForMerge('MergeOriginSameRole', array('socid' => $socid));
$sql = "SELECT rowid FROM ".$db->prefix()."c_type_contact WHERE source = 'external' AND active = 1";
$resql = $db->query($sql);
$objtype = $db->fetch_object($resql);
$db->free($resql);
$this->assertIsObject($objtype, 'No active external type of contact found');
foreach (array($dest->id, $origin->id) as $contactid) {
$sql = "INSERT INTO ".$db->prefix()."societe_contacts(entity, date_creation, fk_soc, fk_c_type_contact, fk_socpeople)";
$sql .= " VALUES (".((int) $conf->entity).", '".$db->idate(dol_now())."', ".((int) $socid).", ".((int) $objtype->rowid).", ".((int) $contactid).")";
$this->assertNotFalse($db->query($sql), 'Cannot create the shared role fixture');
}
$result = $dest->mergeContact($origin->id);
print __METHOD__." result=".$result."\n";
$this->assertEquals(0, $result, 'mergeContact must succeed despite the shared role: '.$dest->error);
$sql = "SELECT COUNT(rowid) as nb FROM ".$db->prefix()."societe_contacts WHERE fk_soc = ".((int) $socid);
$sql .= " AND fk_c_type_contact = ".((int) $objtype->rowid);
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
$db->free($resql);
$this->assertEquals(1, $obj->nb, 'Only one role must remain after the merge');
}
/**
* testContactMergeRemovesSelfLink
*
* If the two contacts were linked to each other, moving both ends of the link in llx_element_element
* leaves a link of the target contact to itself, which no unique key forbids.
*
* @return void
*/
public function testContactMergeRemovesSelfLink()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$dest = $this->createContactForMerge('MergeDestSelfLink');
$origin = $this->createContactForMerge('MergeOriginSelfLink');
$sql = "INSERT INTO ".$db->prefix()."element_element(fk_source, sourcetype, fk_target, targettype)";
$sql .= " VALUES (".((int) $origin->id).", 'contact', ".((int) $dest->id).", 'contact')";
$this->assertNotFalse($db->query($sql), 'Cannot create the link fixture');
$result = $dest->mergeContact($origin->id);
print __METHOD__." result=".$result."\n";
$this->assertEquals(0, $result, 'mergeContact failed: '.$dest->error);
$sql = "SELECT COUNT(rowid) as nb FROM ".$db->prefix()."element_element WHERE sourcetype = 'contact'";
$sql .= " AND targettype = 'contact' AND fk_source = ".((int) $dest->id)." AND fk_target = ".((int) $dest->id);
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
$db->free($resql);
$this->assertEquals(0, $obj->nb, 'The target contact must not be linked to itself');
}
/**
* testContactMergeRefusesToHideDataIntoPrivate
*
* A private contact is visible to its creator only, administrators included, so absorbing a shared
* contact into a private one would hide its data from everybody and cannot be undone.
*
* @return void
*/
public function testContactMergeRefusesToHideDataIntoPrivate()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$dest = $this->createContactForMerge('MergeDestPrivate', array('priv' => 1));
$origin = $this->createContactForMerge('MergeOriginShared');
$this->assertEquals(-1, $dest->mergeContact($origin->id), 'Merging a shared contact into a private one must be refused');
$check = new Contact($db);
$this->assertEquals($origin->id, $check->fetch($origin->id), 'The shared contact must still exist');
}
/**
* testContactMergeMovesFiles
*
* The documents are moved once the transaction is committed, because dol_move() is not
* transactional. Check that the tree is preserved, that a name collision renames the moved file
* instead of overwriting the one of the target contact, and that nothing is left behind.
*
* @return void
*/
public function testContactMergeMovesFiles()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
if (empty($conf->societe->multidir_output[$conf->entity])) {
$this->markTestSkipped('No output directory configured for the third parties');
}
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
$dest = $this->createContactForMerge('MergeDestFiles');
$origin = $this->createContactForMerge('MergeOriginFiles');
$base = $conf->societe->multidir_output[$conf->entity].'/contact/';
$srcdir = $base.$origin->id;
$destdir = $base.$dest->id;
// One file at the root, one in a subdirectory, and one colliding with a file of the target
dol_mkdir($srcdir.'/sub');
dol_mkdir($destdir);
file_put_contents($srcdir.'/phpunitplain.txt', 'from the merged contact');
file_put_contents($srcdir.'/sub/phpunitnested.txt', 'nested');
file_put_contents($srcdir.'/phpunitclash.txt', 'version of the merged contact');
file_put_contents($destdir.'/phpunitclash.txt', 'version of the target contact');
$result = $dest->mergeContact($origin->id);
print __METHOD__." result=".$result."\n";
$this->assertEquals(0, $result, 'mergeContact failed: '.$dest->error);
$this->assertEmpty($dest->warnings, 'No file should have failed to move');
$this->assertTrue(dol_is_file($destdir.'/phpunitplain.txt'), 'The file must be moved to the target contact');
$this->assertTrue(dol_is_file($destdir.'/sub/phpunitnested.txt'), 'The subdirectories must be preserved');
$this->assertEquals('version of the target contact', file_get_contents($destdir.'/phpunitclash.txt'), 'The file of the target contact must not be overwritten');
$this->assertEquals('version of the merged contact', file_get_contents($destdir.'/phpunitclash-'.$origin->id.'.txt'), 'The colliding file must be renamed, not lost');
$this->assertCount(0, dol_dir_list($srcdir, 'files', 1), 'No file must be left on the merged contact');
// The class transaction rolls the database back but not the files
dol_delete_dir_recursive($srcdir);
dol_delete_dir_recursive($destdir);
}
/**
* testContactMergeThirdPartyOfTheTarget
*
* socid is among the fields filled when empty, so merging a contact of a third party into a
* contact that has none attaches the target to that third party, while a target that already has
* one keeps it. Both directions are checked because the second one is not reversible.
*
* @return void
*/
public function testContactMergeThirdPartyOfTheTarget()
{
global $conf,$user,$langs,$db;
$conf = $this->savconf;
$user = $this->savuser;
$langs = $this->savlangs;
$db = $this->savdb;
$socids = array();
foreach (array('PhpunitMergeSocKept', 'PhpunitMergeSocGiven') as $name) {
$company = new Societe($db);
$company->name = $name;
$socid = $company->create($user, 1);
$this->assertGreaterThan(0, $socid, 'Failed to create the fixture third party: '.$company->errorsToString());
$socids[] = $socid;
}
// A target that has no third party inherits the one of the merged contact
$orphan = $this->createContactForMerge('MergeDestNoSoc');
$attached = $this->createContactForMerge('MergeOriginSoc', array('socid' => $socids[1]));
$this->assertEquals(0, $orphan->mergeContact($attached->id), 'mergeContact failed: '.$orphan->error);
$check = new Contact($db);
$check->fetch($orphan->id);
$this->assertEquals($socids[1], $check->socid, 'A target without a third party must inherit the one of the merged contact');
// A target that already has one keeps it, whatever the third party of the merged contact
$kept = $this->createContactForMerge('MergeDestOwnSoc', array('socid' => $socids[0]));
$other = $this->createContactForMerge('MergeOriginOtherSoc', array('socid' => $socids[1]));
$this->assertEquals(0, $kept->mergeContact($other->id), 'mergeContact failed: '.$kept->error);
$check = new Contact($db);
$check->fetch($kept->id);
$this->assertEquals($socids[0], $check->socid, 'The third party of the target contact must never be replaced');
}
}