diff --git a/ChangeLog b/ChangeLog
index 2149b7515af..2b2fd430e4a 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -189,6 +189,7 @@ NEW: API get country: load region and load states now an option (#37715)
NEW: Add MAIN_FORCE_SYSTEM_MESSAGE Renamed hook "info_admin" into "messageOfTheDay"
NEW: Category - API - Add type list (#38207)
NEW: Add method calculateVATNumberFromProperties()
+NEW: Add pdf_writeLogoOrCompanyName() helper in pdf.lib.php to mutualize PDF header logo rendering across document generators
NEW: Add method dolOutputDates()
FIX: Security permission on payment instead of create to create payment with API (credit Santosh Kumar Puppala) in commit ad8cd7c0e1800636302517e42fcdcafa393524b3
diff --git a/dev/tools/phan/baseline.txt b/dev/tools/phan/baseline.txt
index d99e3618646..7a6c6658013 100644
--- a/dev/tools/phan/baseline.txt
+++ b/dev/tools/phan/baseline.txt
@@ -47,6 +47,7 @@ return [
'htdocs/compta/tva/class/paymentvat.class.php' => ['PhanUndeclaredProperty'],
'htdocs/compta/tva/clients.php' => ['PhanTypeArraySuspiciousNull', 'PhanTypeInvalidDimOffset'],
'htdocs/core/actions_addupdatedelete.inc.php' => ['PhanUndeclaredProperty'],
+ 'htdocs/core/actions_linkedfiles.inc.php' => ['PhanUndeclaredProperty'],
'htdocs/core/actions_massactions.inc.php' => ['PhanUndeclaredProperty'],
'htdocs/core/actions_sendmails.inc.php' => ['PhanUndeclaredProperty'],
'htdocs/core/ajax/ajaxdirtree.php' => ['PhanTypeMismatchArgument'],
diff --git a/htdocs/accountancy/admin/fiscalyear_card.php b/htdocs/accountancy/admin/fiscalyear_card.php
index 6de0f000241..8c587b143c1 100644
--- a/htdocs/accountancy/admin/fiscalyear_card.php
+++ b/htdocs/accountancy/admin/fiscalyear_card.php
@@ -321,7 +321,7 @@ if (($id || $ref) && $action == 'edit') {
if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'create'))) {
$head = fiscalyear_prepare_head($object);
- print dol_get_fiche_head($head, 'card', $langs->trans("Fiscalyear"), -1, $object->picto, 0, '', '', 0, '', 1);
+ print dol_get_fiche_head($head, 'card', $langs->trans("Fiscalyear"), -1, $object->picto);
$morehtmlref = '';
//$morehtmlref .= '
';
diff --git a/htdocs/accountancy/admin/fiscalyear_info.php b/htdocs/accountancy/admin/fiscalyear_info.php
index 54217511698..2ebd074e995 100644
--- a/htdocs/accountancy/admin/fiscalyear_info.php
+++ b/htdocs/accountancy/admin/fiscalyear_info.php
@@ -78,7 +78,7 @@ if ($id) {
$head = fiscalyear_prepare_head($object);
- print dol_get_fiche_head($head, 'info', $langs->trans("Fiscalyear"), -1, $object->picto, 0, '', '', 0, '', 1);
+ print dol_get_fiche_head($head, 'info', $langs->trans("Fiscalyear"), -1, $object->picto);
$linkback = ''.$langs->trans("BackToList").'';
diff --git a/htdocs/accountancy/admin/template/card.php b/htdocs/accountancy/admin/template/card.php
index dfc26e15bd5..74d64c5c7db 100644
--- a/htdocs/accountancy/admin/template/card.php
+++ b/htdocs/accountancy/admin/template/card.php
@@ -397,7 +397,7 @@ if (($id || $code) && $action == 'edit') {
if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'create'))) {
$head = accountingTransactionTemplatePrepareHead($object);
- print dol_get_fiche_head($head, 'card', $langs->trans("BookkeepingTemplate"), -1, $object->picto, 0, '', '', 0, '', 1);
+ print dol_get_fiche_head($head, 'card', $langs->trans("BookkeepingTemplate"), -1, $object->picto);
$formconfirm = '';
diff --git a/htdocs/asset/card.php b/htdocs/asset/card.php
index fc6405f5453..8fcf3591850 100644
--- a/htdocs/asset/card.php
+++ b/htdocs/asset/card.php
@@ -263,7 +263,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea
$res = $object->fetch_optionals();
$head = assetPrepareHead($object);
- print dol_get_fiche_head($head, 'card', $langs->trans("Asset"), -1, $object->picto);
+ print dol_get_fiche_head($head, 'card', $langs->trans("Asset"), -1, $object->picto, 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/htdocs/bookcal/booking_list.php b/htdocs/bookcal/booking_list.php
index f1ccc9ea740..8dd2d2b5980 100644
--- a/htdocs/bookcal/booking_list.php
+++ b/htdocs/bookcal/booking_list.php
@@ -126,7 +126,7 @@ llxHeader('', $title, $helpurl, '', 0, 0, '', '', '', 'mod-bookcal page-list');
if ($object->id > 0) {
$head = calendarPrepareHead($object);
- print dol_get_fiche_head($head, 'booking', $langs->trans("Calendar"), -1, $object->picto, 0, '', '', 0, '', 1);
+ print dol_get_fiche_head($head, 'booking', $langs->trans("Calendar"), -1, $object->picto);
$formconfirm = '';
diff --git a/htdocs/bookcal/calendar_card.php b/htdocs/bookcal/calendar_card.php
index e320b75c18f..2344fd983db 100644
--- a/htdocs/bookcal/calendar_card.php
+++ b/htdocs/bookcal/calendar_card.php
@@ -287,7 +287,7 @@ if (($id || $ref) && $action == 'edit') {
if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'create'))) {
$head = calendarPrepareHead($object);
- print dol_get_fiche_head($head, 'card', $langs->trans("Calendar"), -1, $object->picto, 0, '', '', 0, '', 1);
+ print dol_get_fiche_head($head, 'card', $langs->trans("Calendar"), -1, $object->picto);
$formconfirm = '';
diff --git a/htdocs/comm/action/card.php b/htdocs/comm/action/card.php
index f891f4c38d9..954cbd10d3c 100644
--- a/htdocs/comm/action/card.php
+++ b/htdocs/comm/action/card.php
@@ -2659,7 +2659,7 @@ if ($id > 0 && $action != 'create') {
print '';
} else {
- print dol_get_fiche_head($head, 'card', $langs->trans("Action"), -1, 'action');
+ print dol_get_fiche_head($head, 'card', $langs->trans("Action"), -1, 'action', 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/htdocs/comm/action/class/actioncomm.class.php b/htdocs/comm/action/class/actioncomm.class.php
index a8ce446b104..8f8facb734a 100644
--- a/htdocs/comm/action/class/actioncomm.class.php
+++ b/htdocs/comm/action/class/actioncomm.class.php
@@ -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.
*
diff --git a/htdocs/commande/class/commande.class.php b/htdocs/commande/class/commande.class.php
index fd39f1a1783..e8c6163da81 100644
--- a/htdocs/commande/class/commande.class.php
+++ b/htdocs/commande/class/commande.class.php
@@ -11,7 +11,7 @@
* Copyright (C) 2014-2015 Marcos García
* Copyright (C) 2018 Nicolas ZABOURI
* Copyright (C) 2016-2022 Ferran Marcet
- * Copyright (C) 2021-2025 Frédéric France
+ * Copyright (C) 2021-2026 Frédéric France
* Copyright (C) 2022 Gauthier VERDOL
* Copyright (C) 2024-2026 MDW
* Copyright (C) 2024 William Mead
diff --git a/htdocs/compta/bank/various_payment/card.php b/htdocs/compta/bank/various_payment/card.php
index ebdd2d23f8f..bb4aa5f009f 100644
--- a/htdocs/compta/bank/various_payment/card.php
+++ b/htdocs/compta/bank/various_payment/card.php
@@ -639,7 +639,7 @@ if ($id) {
print $form->formconfirm(dolBuildUrl($_SERVER["PHP_SELF"], ['id' => $object->id]), $langs->trans('DeleteVariousPayment'), $text, 'confirm_delete', '', '', 2);
}
- print dol_get_fiche_head($head, 'card', $langs->trans("VariousPayment"), -1, $object->picto);
+ print dol_get_fiche_head($head, 'card', $langs->trans("VariousPayment"), -1, $object->picto, 0, '', '', 0, '', 1);
$morehtmlref = '
';
// Project
diff --git a/htdocs/compta/paiement/card.php b/htdocs/compta/paiement/card.php
index d72b08f95d2..2d0dfb10320 100644
--- a/htdocs/compta/paiement/card.php
+++ b/htdocs/compta/paiement/card.php
@@ -291,7 +291,7 @@ $form = new Form($db);
$head = payment_prepare_head($object);
-print dol_get_fiche_head($head, 'payment', $langs->trans("PaymentCustomerInvoice"), -1, 'payment');
+print dol_get_fiche_head($head, 'payment', $langs->trans("PaymentCustomerInvoice"), -1, 'payment', 0, '', '', 0, '', 1);
// Confirmation of payment delete
if ($action == 'delete') {
diff --git a/htdocs/compta/sociales/card.php b/htdocs/compta/sociales/card.php
index 141a3e85cef..5aeb7a4a758 100644
--- a/htdocs/compta/sociales/card.php
+++ b/htdocs/compta/sociales/card.php
@@ -513,7 +513,7 @@ if ($id > 0) {
}
- print dol_get_fiche_head($head, 'card', $langs->trans("SocialContribution"), -1, $object->picto, 0, '', '', 0, '', 1);
+ print dol_get_fiche_head($head, 'card', $langs->trans("SocialContribution"), -1, $object->picto, 0, '', '', 0, '', ($action == 'edit' ? 0 : 1));
// Print form confirm
print $formconfirm;
diff --git a/htdocs/compta/tva/card.php b/htdocs/compta/tva/card.php
index bade10f8669..ae9450594c1 100644
--- a/htdocs/compta/tva/card.php
+++ b/htdocs/compta/tva/card.php
@@ -583,7 +583,7 @@ if ($id > 0) {
$formconfirm = $hookmanager->resPrint;
}
- print dol_get_fiche_head($head, 'card', $langs->trans("VATPayment"), -1, 'payment', 0, '', '', 0, '', 1);
+ print dol_get_fiche_head($head, 'card', $langs->trans("VATPayment"), -1, 'payment', 0, '', '', 0, '', ($action == 'edit' ? 0 : 1));
// Print form confirm
print $formconfirm;
diff --git a/htdocs/contact/card.php b/htdocs/contact/card.php
index 1b2b9f56916..8a2e7ac1633 100644
--- a/htdocs/contact/card.php
+++ b/htdocs/contact/card.php
@@ -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
*/
@@ -1303,7 +1355,7 @@ if (is_object($objcanvas) && $objcanvas->displayCanvasExists($action)) {
// Show errors
dol_htmloutput_errors(is_numeric($error) ? '' : $error, $errors);
- print dol_get_fiche_head($head, 'card', $title, -1, 'contact');
+ print dol_get_fiche_head($head, 'card', $title, -1, 'contact', 0, '', '', 0, '', 1);
if ($action == 'create_user') {
// Full firstname and lastname separated with a dot : firstname.lastname
@@ -1568,6 +1620,11 @@ if (is_object($objcanvas) && $objcanvas->displayCanvasExists($action)) {
print ''.$langs->trans("DisableUser").'';
}
+ // 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'));
diff --git a/htdocs/contact/class/contact.class.php b/htdocs/contact/class/contact.class.php
index 557bc7ca714..5e3dcf3637c 100644
--- a/htdocs/contact/class/contact.class.php
+++ b/htdocs/contact/class/contact.class.php
@@ -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.
diff --git a/htdocs/core/actions_linkedfiles.inc.php b/htdocs/core/actions_linkedfiles.inc.php
index 4e3963bf165..d265f2a280d 100644
--- a/htdocs/core/actions_linkedfiles.inc.php
+++ b/htdocs/core/actions_linkedfiles.inc.php
@@ -37,6 +37,7 @@
* @var string $forceFullTextIndexation
*/
'
+@phan-var-force CommonObject $object
@phan-var-force string $upload_dir
@phan-var-force string $upload_dirold
@phan-var-force string $confirm
@@ -224,7 +225,7 @@ if ($action == 'confirm_deletefile' && $confirm == 'yes' && !empty($permissionto
$link = new Link($db);
$f = $link->fetch(GETPOSTINT('linkid'));
- if ($f) {
+ if ($f > 0) {
$link->url = GETPOST('link', 'alpha');
if (substr($link->url, 0, 7) != 'http://'
&& substr($link->url, 0, 8) != 'https://'
diff --git a/htdocs/core/ajax/fileupload.php b/htdocs/core/ajax/fileupload.php
index 353bab4111c..c8e64b51501 100644
--- a/htdocs/core/ajax/fileupload.php
+++ b/htdocs/core/ajax/fileupload.php
@@ -56,6 +56,17 @@ $elementupload = $element;
// Load object according to $id and $element
$object = fetchObjectByElement($id, $element);
+// fetchObjectByElement() returns an object even when the record was not found, and it returns a
+// GenericObject with no module when the element is unknown. In both cases restrictedArea() is then called
+// with an empty feature, and it grants the access without checking any permission, so we must stop here.
+// Note: fetchObjectByElement() may also return an int instead of an object when the module is disabled.
+// We answer the same http code and the same message than a refusal by restrictedArea() below, so that a
+// user can't tell an object that exists but is not allowed from an object that does not exist.
+if (!is_object($object) || empty($object->id) || empty($object->module)) {
+ dol_syslog("fileupload.php object ".$element." with id ".$id." was not found or its element is not supported", LOG_WARNING);
+ httponly_accessforbidden('Not allowed');
+}
+
$module = $object->module;
$element = $object->element;
@@ -69,14 +80,21 @@ if ($usesublevelpermission && !$user->hasRight($module, $element)) { // There is
// Security check
if (!empty($user->socid)) {
$socid = $user->socid;
+ // socid is not declared on CommonObject, which is the type fetchObjectByElement() answers, and an object
+ // that has no third party does not own the property at all.
if (property_exists($object, 'socid') && !empty($object->socid) && $socid != $object->socid) { // @phan-suppress-current-line PhanUndeclaredProperty
- httponly_accessforbidden("Access on object not allowed for this external user."); // This includes the exit.
+ // Same message than every other refusal of this page: a distinct one tells an external user that the
+ // object exists but belongs to another third party, which lets him enumerate the records of the others.
+ dol_syslog("fileupload.php object ".$element." with id ".$id." belongs to another third party than the external user", LOG_WARNING);
+ httponly_accessforbidden('Not allowed'); // This includes the exit.
}
}
$result = restrictedArea($user, $object->module, $object, $object->table_element, $usesublevelpermission, 'fk_soc', 'rowid', 0, 1); // Call with mode return
if (!$result) {
- httponly_accessforbidden('Not allowed by restrictArea (module='.$object->module.' table_element='.$object->table_element.')');
+ // The module and the table are reported into the log only, they must not be disclosed to the caller
+ dol_syslog("fileupload.php not allowed by restrictedArea (module=".$object->module." table_element=".$object->table_element.")", LOG_WARNING);
+ httponly_accessforbidden('Not allowed');
}
@@ -103,7 +121,24 @@ switch ($_SERVER['REQUEST_METHOD']) {
break;
*/
case 'POST':
- $upload_handler = new FileUpload(null, $id, $elementupload);
+ // The constructor throws an exception when the element does not support file uploading, or when the
+ // object was not found. Answer with the same json contract than post() so the caller can show the
+ // error, instead of letting a fatal error return an http code 500 with no usable content.
+ try {
+ $upload_handler = new FileUpload(null, $id, $elementupload);
+ } catch (Exception $e) {
+ // Same http code 200 and same content type negotiation than post(), the error is reported into the
+ // json content as the caller expects. Forcing 'application/json' would make jQuery parse the content
+ // by itself, and the JSON.parse() of the caller would then fail on an already parsed array.
+ if (isset($_SERVER['HTTP_ACCEPT']) && (strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false)) {
+ header('Content-type: application/json');
+ } else {
+ header('Content-type: text/plain');
+ }
+ echo json_encode(array(array('name' => '', 'error' => $e->getMessage())));
+ $db->close();
+ exit;
+ }
/*if (isset($_REQUEST['_method']) && $_REQUEST['_method'] === 'DELETE') {
$file = GETPOST('file');
diff --git a/htdocs/core/class/commonobject.class.php b/htdocs/core/class/commonobject.class.php
index bead69eec3c..25c6b691d88 100644
--- a/htdocs/core/class/commonobject.class.php
+++ b/htdocs/core/class/commonobject.class.php
@@ -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,
diff --git a/htdocs/core/class/fileupload.class.php b/htdocs/core/class/fileupload.class.php
index 7e2b2f7f387..938fdf7ab42 100644
--- a/htdocs/core/class/fileupload.class.php
+++ b/htdocs/core/class/fileupload.class.php
@@ -56,10 +56,11 @@ class FileUpload
* @param ?array{script_url?:string,upload_dir?:string,upload_url?:string,param_name?:string,delete_type?:string,max_file_size?:?int,min_file_size?:int,accept_file_types?:string,max_number_of_files?:?int,max_width?:?int,max_height?:?int,min_width?:int,min_height?:int,discard_aborted_uploads?:bool,image_versions?:array} $options Options array
* @param int $fk_element ID of element
* @param string $element Code of element
+ * @throws Exception If the object was not found, if the element does not support file
+ * uploading, or if the upload directory is missing or not writable
*/
public function __construct($options = null, $fk_element = null, $element = null)
{
- global $db;
global $hookmanager;
$hookmanager->initHooks(array('fileupload'));
@@ -77,11 +78,6 @@ class FileUpload
//print 'fileupload.class.php: element='.$element.' pathname='.$pathname.' filename='.$filename.' dir_output='.$dir_output."\n";
- if (empty($dir_output)) {
- setEventMessage('The element '.$element.' is not supported for uploading file. dir_output is unknown.', 'errors');
- throw new Exception('The element '.$element.' is not supported for uploading file. dir_output is unknown.');
- }
-
$object_ref = 'UndefinedReference';
$object = null;
// If pathname and filename are null then we can still upload files if we have specified upload_dir on $options
@@ -89,36 +85,62 @@ class FileUpload
// Get object from its id and type
$object = fetchObjectByElement($fk_element, $element);
- $object_ref = dol_sanitizeFileName($object->ref);
+ // fetchObjectByElement() also returns an object when the record was not found (fetch() returning 0),
+ // so we must check the object was really loaded. Without this, files would be stored at the root of
+ // the module directory, out of any object and out of any permission check on the object.
+ if (!is_object($object) || empty($object->id)) {
+ dol_syslog(get_class($this)."::__construct object ".$element." with id ".((int) $fk_element)." was not found", LOG_WARNING);
+ throw new Exception('objectnotfound');
+ }
+
+ // Directory of the module, including the sub directory used by some elements (/sending for a shipment,
+ // /commande for a supplier order, / for a task, ...). We must use the same directory than
+ // the one used by the "Attached files" tab of the object, otherwise the uploaded file is stored but
+ // never shown to the user.
+ // Note: getMultidirOutput() only knows the elements of its own switch, that is a minority of them. For
+ // all the others it does not return an empty string but the string
+ // 'error-diroutput-not-defined-for-this-object=x', and keeping the directory of getElementProperties()
+ // is then the nominal case, not a degraded one. So we only accept an absolute path: that string is a
+ // relative path, and writing into it would create the files under the web root.
+ $tmpdir = getMultidirOutput($object, $element);
+ if (!empty($tmpdir) && preg_match('/^([a-z]:)?[\\\\\/]/i', $tmpdir)) {
+ $dir_output = dol_sanitizePathName($tmpdir);
+ }
// Add object reference as file name prefix if const MAIN_DISABLE_SUGGEST_REF_AS_PREFIX is not enabled
if (!getDolGlobalInt('MAIN_DISABLE_SUGGEST_REF_AS_PREFIX')) {
- $savingDocMask = $object_ref . '-__file__';
+ $savingDocMask = dol_sanitizeFileName($object->ref).'-__file__';
}
- // Special cases to forge $object_ref used to forge $upload_dir
- if ($element == 'invoice_supplier') {
- $object_ref = get_exdir($object->id, 2, 0, 0, $object, 'invoice_supplier').$object_ref;
- } elseif ($element == 'project_task') {
- $parentForeignKey = 'fk_project';
- $parentClass = 'Project';
- $parentElement = 'projet';
- $parentObject = 'project';
+ // get_exdir() forges the directory of an object the way the "Attached files" tabs do: it always
+ // uses the id for a thirdparty (a thirdparty ref is a company name, so it is not unique), and it
+ // falls back on the id when the ref is empty. Using anything else here would store the file into
+ // a directory the tab never reads.
+ // Note that a few tabs sanitize the ref themselves instead of calling this function, so they have
+ // no fallback: on an object whose ref is empty in database, which the interface does not produce
+ // but old records may hold, they read the root of the directory of the module while we store
+ // under the id. Storing at the root would mix the files of every object of the module, so the
+ // fallback is kept and those tabs are the ones that should be fixed.
+ $object_ref = get_exdir(0, 0, 0, 1, $object, $element);
- dol_include_once('/'.$parentElement.'/class/'.$parentObject.'.class.php');
- $parent = new $parentClass($db);
- if ($object->$parentForeignKey !== null) {
- $parent->fetch((int) $object->$parentForeignKey);
- if (!empty($parent->socid)) {
- $parent->fetch_thirdparty();
- }
- $object->$parentObject = clone $parent;
- }
-
- $object_ref = dol_sanitizeFileName($object->project->ref).'/'.$object_ref;
+ // For the modules storing their documents on several levels, get_exdir() returned the level
+ // directories only, so we must append the directory of the object itself.
+ if (in_array($element, array('invoice_supplier', 'supplier_invoice'))) {
+ $object_ref .= '/'.dol_sanitizeFileName($object->ref);
}
}
+ // Tested after the call to getMultidirOutput(), because some elements have no 'dir_output' returned by
+ // getElementProperties() while getMultidirOutput() is still able to resolve their output directory.
+ if (empty($dir_output)) {
+ dol_syslog(get_class($this)."::__construct element ".$element." is not supported for uploading file, dir_output is unknown", LOG_WARNING);
+ throw new Exception('elementnotsupported');
+ }
+
+ // Note: 'upload_url' is not always the url of the file stored into 'upload_dir', because document.php
+ // forges the path of the file with its own rules for each value of modulepart. It is currently not a
+ // problem because the only caller of this class (the drag and drop of a file on a card) does not use
+ // the url returned into the json.
$this->options = array(
'script_url' => $_SERVER['PHP_SELF'],
'upload_dir' => $dir_output.'/'.$object_ref.'/',
@@ -458,6 +480,20 @@ class FileUpload
$file->name = $fileNameWithPrefix;
}
+ // trimFileName() checked the name is not already used, but it did it before the reference of the
+ // object was added as a prefix above, so it compared a name that is not the one we store. We must
+ // check it again on the final name, otherwise uploading twice the same file silently overwrites
+ // the first one, because dol_move_uploaded_file() is called below with $allowoverwrite = 1.
+ // The .noexe suffix is appended by dol_move_uploaded_file() on an executable file, so we must also
+ // look for the suffixed name, otherwise such a file is never seen as already existing and it is
+ // overwritten at each upload.
+ if ($this->options['discard_aborted_uploads']) {
+ $tmppath = dol_sanitizePathName($this->options['upload_dir']);
+ while (dol_is_file($tmppath.dol_sanitizeFileName($file->name)) || dol_is_file($tmppath.dol_sanitizeFileName($file->name).'.noexe')) {
+ $file->name = $this->upcountName($file->name);
+ }
+ }
+
$file_path = dol_sanitizePathName($this->options['upload_dir']).dol_sanitizeFileName($file->name);
$append_file = !$this->options['discard_aborted_uploads'] && dol_is_file($file_path) && $file->size > dol_filesize($file_path);
@@ -470,6 +506,14 @@ class FileUpload
} else {
// TODO Replace this with a call of dol_add_file_process(... $mode=1)
$result = dol_move_uploaded_file($uploaded_file, $file_path, 1, 0, 0, 0, 'userfile');
+
+ // A return of 2 means the file was stored with a .noexe suffix appended on its name.
+ // We must follow that renaming, otherwise the size check below is done on a file that
+ // does not exist, and we report an error on a file that was correctly stored.
+ if ($result == 2) {
+ $file->name .= '.noexe';
+ $file_path .= '.noexe';
+ }
}
} else {
// Non-multipart uploads (PUT method support)
diff --git a/htdocs/core/class/html.form.class.php b/htdocs/core/class/html.form.class.php
index 218598f4c73..4ff7ead419c 100644
--- a/htdocs/core/class/html.form.class.php
+++ b/htdocs/core/class/html.form.class.php
@@ -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;
diff --git a/htdocs/core/class/link.class.php b/htdocs/core/class/link.class.php
index 37b10cf41a2..8683ace8978 100644
--- a/htdocs/core/class/link.class.php
+++ b/htdocs/core/class/link.class.php
@@ -1,7 +1,7 @@
* Copyright (C) 2024-2026 MDW
- * Copyright (C) 2024 Frédéric France
+ * Copyright (C) 2024-2026 Frédéric France
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -40,6 +40,11 @@ class Link extends CommonObject
*/
public $table_element = 'links';
+ /**
+ * @var int<0,1> Does object support extrafields ? 0=No, 1=Yes
+ */
+ public $isextrafieldmanaged = 1;
+
/**
* @var int Entity
*/
@@ -102,7 +107,7 @@ class Link extends CommonObject
global $langs, $conf;
$error = 0;
- $langs->load("errors");
+ $langs->loadLangs(array("errors", "admin"));
// Clean parameters
if (empty($this->label)) {
$this->label = trim(basename($this->url));
@@ -116,7 +121,7 @@ class Link extends CommonObject
// Check parameters
if (empty($this->url)) {
- $this->error = $langs->trans("NoURL");
+ $this->error = $langs->trans("ErrorFieldRequired", $langs->transnoentitiesnoconv("URL"));
return -1;
}
@@ -137,12 +142,20 @@ class Link extends CommonObject
$this->id = $this->db->last_insert_id($this->db->prefix()."links");
if ($this->id > 0) {
- // Call trigger
- $result = $this->call_trigger('LINK_CREATE', $user);
+ // Actions on extra fields
+ $result = $this->insertExtraFields();
if ($result < 0) {
$error++;
}
- // End call triggers
+
+ if (!$error) {
+ // Call trigger
+ $result = $this->call_trigger('LINK_CREATE', $user);
+ if ($result < 0) {
+ $error++;
+ }
+ // End call triggers
+ }
} else {
$error++;
}
@@ -158,7 +171,7 @@ class Link extends CommonObject
}
} else {
if ($this->db->errno() == 'DB_ERROR_RECORD_ALREADY_EXISTS') {
- $this->error = $langs->trans("ErrorCompanyNameAlreadyExists", (string) $this->name);
+ $this->error = $langs->trans("ErrorDuplicateField");
$result = -1;
} else {
$this->error = $this->db->lasterror();
@@ -170,7 +183,7 @@ class Link extends CommonObject
}
/**
- * Update parameters of third party
+ * Update parameters of link
*
* @param User $user User executing update
* @param int $call_trigger 0=no, 1=yes
@@ -181,14 +194,14 @@ class Link extends CommonObject
global $langs, $conf;
require_once DOL_DOCUMENT_ROOT.'/core/lib/functions2.lib.php';
- $langs->load("errors");
+ $langs->loadLangs(array("errors", "admin"));
$error = 0;
dol_syslog(get_class($this)."::Update id = ".$this->id." call_trigger = ".$call_trigger);
// Check parameters
if (empty($this->url)) {
- $this->error = $langs->trans("NoURL");
+ $this->error = $langs->trans("ErrorFieldRequired", $langs->transnoentitiesnoconv("URL"));
return -1;
}
@@ -216,7 +229,13 @@ class Link extends CommonObject
dol_syslog(get_class($this)."::update sql = ".$sql);
$resql = $this->db->query($sql);
if ($resql) {
- if ($call_trigger) {
+ // Actions on extra fields
+ $result = $this->insertExtraFields();
+ if ($result < 0) {
+ $error++;
+ }
+
+ if (!$error && $call_trigger) {
// Call trigger
$result = $this->call_trigger('LINK_MODIFY', $user);
if ($result < 0) {
@@ -291,6 +310,10 @@ class Link extends CommonObject
$link->objectid = $obj->objectid;
$link->share = $obj->share;
$link->share_pass = $obj->share_pass;
+
+ // Retrieve all extrafields for link
+ $link->fetch_optionals();
+
$links[] = $link;
}
return 1;
@@ -345,6 +368,11 @@ class Link extends CommonObject
$rowid = $this->id;
}
+ if (empty($rowid) && empty($hashforshare)) {
+ $this->error = 'ErrorBadParameters';
+ return -1;
+ }
+
$sqlwhere = [];
$sql = "SELECT rowid, entity, datea, url, label, objecttype, objectid, share, share_pass FROM ".$this->db->prefix()."links";
@@ -377,6 +405,10 @@ class Link extends CommonObject
$this->objectid = $obj->objectid;
$this->share = $obj->share;
$this->share_pass = $obj->share_pass;
+
+ // Retrieve all extrafields for link
+ $this->fetch_optionals();
+
return 1;
} else {
return 0;
@@ -390,23 +422,26 @@ class Link extends CommonObject
/**
* Delete a link from database
*
- * @param User $user Object suer
+ * @param User $user Object user
+ * @param int<0,1> $notrigger 1=Does not execute triggers, 0=Execute triggers
* @return int Return integer <0 if KO, 0 if nothing done, >0 if OK
*/
- public function delete($user)
+ public function delete(User $user, $notrigger = 0)
{
dol_syslog(get_class($this)."::delete", LOG_DEBUG);
$error = 0;
$this->db->begin();
- // Call trigger
- $result = $this->call_trigger('LINK_DELETE', $user);
- if ($result < 0) {
- $this->db->rollback();
- return -1;
+ if (!$notrigger) {
+ // Call trigger
+ $result = $this->call_trigger('LINK_DELETE', $user);
+ if ($result < 0) {
+ $this->db->rollback();
+ return -1;
+ }
+ // End call triggers
}
- // End call triggers
// Remove link
$sql = "DELETE FROM ".$this->db->prefix()."links";
@@ -418,6 +453,14 @@ class Link extends CommonObject
$this->error = $this->db->lasterror();
}
+ // Removed extrafields
+ if (!$error) {
+ $result = $this->deleteExtraFields();
+ if ($result < 0) {
+ $error++;
+ }
+ }
+
if (!$error) {
$this->db->commit();
diff --git a/htdocs/core/lib/files.lib.php b/htdocs/core/lib/files.lib.php
index 42c914411d7..5be5993d72c 100644
--- a/htdocs/core/lib/files.lib.php
+++ b/htdocs/core/lib/files.lib.php
@@ -4048,6 +4048,14 @@ function dragAndDropFileUpload($htmlname)
{
global $object, $langs;
+ // Every generated javascript string that carries an interpolated value is delimited by a single quote, so
+ // dol_escape_js() is called with the mode 1 everywhere below: it escapes a single quote and leaves a double
+ // quote alone. The default mode would rewrite a double quote into an escaped single quote, which is safe
+ // inside a '...' string but silently alters the value.
+ // dol_escape_js() escapes the quotes but not '', and PHP_SELF holds the path info of the request on a
+ // server that accepts it, so a request could close the script tag below and open one of its own.
+ $pageurl = str_replace('', '<\\/', dol_escape_js($_SERVER["PHP_SELF"], 1));
+
$out = "";
$out .= '
';
$out .= "\n\n";
@@ -4056,7 +4064,7 @@ function dragAndDropFileUpload($htmlname)
jQuery(document).ready(function() {
var enterTargetDragDrop = null;
- $("#'.$htmlname.'").addClass("cssDragDropArea");
+ $(\'#'.$htmlname.'\').addClass(\'cssDragDropArea\');
$(".cssDragDropArea").on("dragenter", function(ev, ui) {
var dataTransfer = ev.originalEvent.dataTransfer;
@@ -4074,7 +4082,7 @@ function dragAndDropFileUpload($htmlname)
console.log("dragAndDropFileUpload: We add class highlightDragDropArea")
enterTargetDragDrop = ev.target;
$(this).addClass("highlightDragDropArea");
- $("#'.$htmlname.'Message").removeClass("hidden");
+ $(\'#'.$htmlname.'Message\').removeClass(\'hidden\');
ev.preventDefault();
});
@@ -4082,7 +4090,7 @@ function dragAndDropFileUpload($htmlname)
// Going out of drop area. Remove Highlight
if (enterTargetDragDrop == ev.target){
console.log("dragAndDropFileUpload: We remove class highlightDragDropArea")
- $("#'.$htmlname.'Message").addClass("hidden");
+ $(\'#'.$htmlname.'Message\').addClass(\'hidden\');
$(this).removeClass("highlightDragDropArea");
}
});
@@ -4093,12 +4101,12 @@ function dragAndDropFileUpload($htmlname)
});
$(".cssDragDropArea").on("drop", function(e) {
- console.log("Trigger event file dropped. fk_element='.dol_escape_js((string) $object->id).' element='.dol_escape_js($object->element).'");
+ console.log(\'Trigger event file dropped. fk_element='.dol_escape_js((string) $object->id, 1).' element='.dol_escape_js($object->element, 1).'\');
e.preventDefault();
fd = new FormData();
- fd.append("fk_element", "'.dol_escape_js((string) $object->id).'");
- fd.append("element", "'.dol_escape_js($object->element).'");
- fd.append("token", "'.currentToken().'");
+ fd.append(\'fk_element\', \''.dol_escape_js((string) $object->id, 1).'\');
+ fd.append(\'element\', \''.dol_escape_js($object->element, 1).'\');
+ fd.append(\'token\', \''.currentToken().'\');
fd.append("action", "linkit");
var dataTransfer = e.originalEvent.dataTransfer;
@@ -4112,7 +4120,7 @@ function dragAndDropFileUpload($htmlname)
$(".cssDragDropArea").removeClass("highlightDragDropArea");
counterdragdrop = 0;
$.ajax({
- url: "'.DOL_URL_ROOT.'/core/ajax/fileupload.php",
+ url: \''.DOL_URL_ROOT.'/core/ajax/fileupload.php\',
type: "POST",
processData: false,
contentType: false,
@@ -4121,7 +4129,16 @@ function dragAndDropFileUpload($htmlname)
console.log("Uploaded.", arguments);
/* arguments[0] is the json string of files */
/* arguments[1] is the value for variable "success", can be 0 or 1 */
- let listoffiles = JSON.parse(arguments[0]);
+ let listoffiles = [];
+ /* The answer is not the expected json when php stopped before answering, for example when
+ post_max_size was reached. Without this, the exception of JSON.parse() would leave the
+ user on a page with no message at all, thinking the file was added. */
+ try {
+ listoffiles = JSON.parse(arguments[0]);
+ } catch (e) {
+ window.location.href = \''.$pageurl.'?id='.dol_escape_js((string) $object->id, 1).'&seteventmessages=ErrorUploadFileDragDrop:errors\';
+ return;
+ }
console.log(listoffiles);
let nboferror = 0;
for (let i = 0; i < listoffiles.length; i++) {
@@ -4131,18 +4148,23 @@ function dragAndDropFileUpload($htmlname)
}
}
console.log(nboferror);
- if (nboferror > 0) {
- window.location.href = "'.$_SERVER["PHP_SELF"].'?id='.dol_escape_js((string) $object->id).'&seteventmessages=ErrorOnAtLeastOneFileUpload:warnings";
+ /* An empty list means no file was stored at all, so it is an error and not a success:
+ php empties $_FILES when post_max_size is reached. */
+ if (listoffiles.length == 0) {
+ window.location.href = \''.$pageurl.'?id='.dol_escape_js((string) $object->id, 1).'&seteventmessages=ErrorUploadFileDragDrop:errors\';
+ } else if (nboferror > 0) {
+ window.location.href = \''.$pageurl.'?id='.dol_escape_js((string) $object->id, 1).'&seteventmessages=ErrorOnAtLeastOneFileUpload:warnings\';
} else {
- window.location.href = "'.$_SERVER["PHP_SELF"].'?id='.dol_escape_js((string) $object->id).'&seteventmessages=UploadFileDragDropSuccess:mesgs";
+ window.location.href = \''.$pageurl.'?id='.dol_escape_js((string) $object->id, 1).'&seteventmessages=UploadFileDragDropSuccess:mesgs\';
}
},
- error:function() {
+ error:function(jqXHR) {
console.log("Error Uploading.", arguments)
- if (arguments[0].status == 403) {
- window.location.href = "'.$_SERVER["PHP_SELF"].'?id='.dol_escape_js((string) $object->id).'&seteventmessages=ErrorUploadPermissionDenied:errors";
+ if (jqXHR.status == 403) {
+ window.location.href = \''.$pageurl.'?id='.dol_escape_js((string) $object->id, 1).'&seteventmessages=ErrorUploadFileDragDropPermissionDenied:errors\';
+ } else {
+ window.location.href = \''.$pageurl.'?id='.dol_escape_js((string) $object->id, 1).'&seteventmessages=ErrorUploadFileDragDrop:errors\';
}
- window.location.href = "'.$_SERVER["PHP_SELF"].'?id='.dol_escape_js((string) $object->id).'&seteventmessages=ErrorUploadFileDragDropPermissionDenied:errors";
},
})
});
diff --git a/htdocs/core/lib/functions.lib.php b/htdocs/core/lib/functions.lib.php
index 5c24dc83716..2f01a66f036 100644
--- a/htdocs/core/lib/functions.lib.php
+++ b/htdocs/core/lib/functions.lib.php
@@ -187,6 +187,12 @@ function getMultidirOutput($object, $module = '', $forobject = 0, $mode = 'outpu
$module = 'knowledgemanagement';
$subdirectory = '/knowledgerecord';
break;
+ case 'partnership':
+ $subdirectory = '/partnership';
+ break;
+ case 'stocktransfer':
+ $subdirectory = '/stocktransfer';
+ break;
case 'commande_fournisseur':
$module = 'fournisseur';
$subdirectory = '/commande';
@@ -207,10 +213,20 @@ function getMultidirOutput($object, $module = '', $forobject = 0, $mode = 'outpu
case 'project_task':
$module = 'projet';
- // Fetch the project to build the correct path
- $object->fetchProject();
+ // Fetch the project to build the correct path. The signature of this function accepts an object
+ // that is not a CommonObject, and even a null when a module is given, so we must not call a method
+ // that only a CommonObject owns without testing it exists.
+ if (is_object($object) && method_exists($object, 'fetchProject')) {
+ $object->fetchProject();
+ }
- $subdirectory = '/'.$object->project->ref;
+ // The ref must be sanitized with dol_sanitizeFileName() and not only with dol_sanitizePathName()
+ // done at the end of this function, because a project ref is a user input that may contain a '/',
+ // a ':' or an accented char. dol_sanitizePathName() keeps them, so we would not return the
+ // directory used by projet/tasks/document.php, that sanitizes the ref with dol_sanitizeFileName().
+ if (!empty($object->project->ref)) {
+ $subdirectory = '/'.dol_sanitizeFileName($object->project->ref);
+ }
break;
case 'action':
case 'actioncomm':
@@ -226,7 +242,13 @@ function getMultidirOutput($object, $module = '', $forobject = 0, $mode = 'outpu
if (isset($conf->$module) && property_exists($conf->$module, 'multidir_output')) {
$s = '';
if ($mode != 'outputrel') {
- $s = $conf->$module->multidir_output[(empty($object->entity) ? $conf->entity : $object->entity)] . $subdirectory;
+ // An entity with no directory declared used to return an undefined index, so a relative path
+ // that made the caller read or write under the web root. Answer the error instead.
+ $entity = (int) (empty($object->entity) ? $conf->entity : $object->entity);
+ if (!isset($conf->$module->multidir_output[$entity])) {
+ return 'error-diroutput-not-defined-for-this-object='.$module;
+ }
+ $s = $conf->$module->multidir_output[$entity].$subdirectory;
}
if ($forobject && $object->id > 0) {
$s .= ($mode != 'outputrel' ? '/' : '') . get_exdir(0, 0, 0, 0, $object);
@@ -246,7 +268,12 @@ function getMultidirOutput($object, $module = '', $forobject = 0, $mode = 'outpu
}
} elseif ($mode == 'temp') {
if (isset($conf->$module) && property_exists($conf->$module, 'multidir_temp')) {
- return dol_sanitizePathName($conf->$module->multidir_temp[(empty($object->entity) ? $conf->entity : $object->entity)]);
+ // Same guard as the 'output' mode above, see the comment there
+ $entity = (int) (empty($object->entity) ? $conf->entity : $object->entity);
+ if (!isset($conf->$module->multidir_temp[$entity])) {
+ return 'error-dirtemp-not-defined-for-this-object='.$module;
+ }
+ return dol_sanitizePathName($conf->$module->multidir_temp[$entity]);
} elseif (isset($conf->$module) && property_exists($conf->$module, 'dir_temp')) {
return dol_sanitizePathName($conf->$module->dir_temp);
} else {
@@ -15243,6 +15270,7 @@ function getElementProperties($elementType)
$module = 'societe';
$subelement = 'contact';
$table_element = 'socpeople';
+ $subdir = '/contact';
} elseif ($elementType == 'inventory') {
$module = 'product';
$classpath = 'product/inventory/class';
@@ -15458,6 +15486,45 @@ function getElementProperties($elementType)
$classfile = 'paymentsalary';
$classname = 'PaymentSalary';
$module = 'salaries';
+ } elseif ($elementType == 'payment') {
+ $classpath = 'compta/paiement/class';
+ $classfile = 'paiement';
+ $classname = 'Paiement';
+ $module = 'facture'; // A customer payment belongs to the invoice module, there is no 'compta' module
+ $element = 'payment';
+ $subelement = 'payment';
+ $table_element = 'paiement';
+ } elseif ($elementType == 'payment_supplier') {
+ $classpath = 'fourn/class';
+ $classfile = 'paiementfourn';
+ $classname = 'PaiementFourn';
+ $module = 'fournisseur';
+ $element = 'payment_supplier';
+ $subelement = 'payment_supplier';
+ $table_element = 'paiementfourn';
+ } elseif ($elementType == 'payment_various') {
+ $classpath = 'compta/bank/class';
+ $classfile = 'paymentvarious';
+ $classname = 'PaymentVarious';
+ $module = 'bank'; // We need $conf->bank->dir_output and not $conf->banque->dir_output
+ $element = 'payment_various';
+ $subelement = 'payment_various';
+ $table_element = 'payment_various';
+ } elseif ($elementType == 'stocktransfer') {
+ $classpath = 'product/stock/stocktransfer/class';
+ $classfile = 'stocktransfer';
+ $classname = 'StockTransfer'; // Not the ucfirst() of the element, so it must be set explicitly
+ $module = 'stocktransfer';
+ $subelement = 'stocktransfer';
+ $table_element = 'stocktransfer_stocktransfer';
+ } elseif ($elementType == 'job' || $elementType == 'position' || $elementType == 'skill' || $elementType == 'evaluation') {
+ $classpath = 'hrm/class';
+ $classfile = $elementType;
+ $classname = ucfirst($elementType);
+ $module = 'hrm';
+ $subelement = $elementType;
+ $table_element = ($elementType == 'position' ? 'hrm_job_user' : 'hrm_'.$elementType);
+ $subdir = '/'.$elementType;
} elseif ($elementType == 'productlot') {
$module = 'productbatch';
$classpath = 'product/stock/class';
@@ -15508,6 +15575,7 @@ function getElementProperties($elementType)
$classfile = 'conferenceorbooth';
$classname = 'ConferenceOrBooth';
$module = 'eventorganization';
+ $subdir = '/conferenceorbooth';
} elseif ($elementType == 'ccountry') {
$module = '';
$classpath = 'core/class';
@@ -15597,9 +15665,24 @@ function getElementProperties($elementType)
} elseif ($element == 'invoice_supplier' && isModEnabled('fournisseur')) {
$dir_output = $conf->fournisseur->facture->dir_output;
$dir_temp = $conf->fournisseur->facture->dir_temp;
+ } elseif ($elementType == 'payment' && isModEnabled('invoice') && isset($conf->compta->payment)) {
+ // A customer payment is stored into a sub object of $conf, not handled by the generic case.
+ // Note: we must test $elementType and not $element, because the 'myobject_mysubobject' rule above
+ // rewrites $element to 'payment' for the element 'payment_salary' too, which is stored elsewhere.
+ $dir_output = $conf->compta->payment->dir_output;
+ $dir_temp = $conf->compta->payment->dir_temp;
+ } elseif ($elementType == 'payment_supplier' && isModEnabled('fournisseur') && isset($conf->fournisseur->payment)) {
+ $dir_output = $conf->fournisseur->payment->dir_output;
+ $dir_temp = $conf->fournisseur->payment->dir_temp;
+ }
+ // The sub directory must not be appended when the module is disabled, because $dir_output is then empty
+ // and we would return a path at the root of the file system instead of an empty string.
+ if (!empty($dir_output)) {
+ $dir_output .= $subdir;
+ }
+ if (!empty($dir_temp)) {
+ $dir_temp .= $subdir;
}
- $dir_output .= $subdir;
- $dir_temp .= $subdir;
$elementProperties = array(
'module' => $module,
diff --git a/htdocs/core/lib/pdf.lib.php b/htdocs/core/lib/pdf.lib.php
index 95dd345b642..6fe9e56e264 100644
--- a/htdocs/core/lib/pdf.lib.php
+++ b/htdocs/core/lib/pdf.lib.php
@@ -330,6 +330,47 @@ function pdf_getHeightForLogo($logo, $url = false)
return $height;
}
+/**
+ * Output company logo on top-left of a PDF page header, or the company name as fallback text if no logo is
+ * set, or an error message if the logo file is missing/unreadable. Shared by the page headers of the various
+ * document generators (invoices, orders, proposals, ...).
+ *
+ * @param TCPDF $pdf PDF object
+ * @param Translate $outputlangs Object lang for output
+ * @param Societe $emetteur Emitting company (the PDF generator's $this->emetteur)
+ * @param string $logodir Directory containing the logos subfolder (already resolved by the caller)
+ * @param float $posx X position to place the logo image
+ * @param float $posy Y position to place the logo image
+ * @param float $w Cell width used for the fallback company name / error message text
+ * @param float $default_font_size Default font size (used to size the error message font)
+ * @param string $align Alignment ('L', 'R', or 'J') for the fallback company name text
+ * @return void
+ */
+function pdf_writeLogoOrCompanyName($pdf, $outputlangs, $emetteur, $logodir, $posx, $posy, $w, $default_font_size, $align)
+{
+ if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
+ if ($emetteur->logo) {
+ if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) {
+ $logo = $logodir.'/logos/thumbs/'.$emetteur->logo_small;
+ } else {
+ $logo = $logodir.'/logos/'.$emetteur->logo;
+ }
+ if (is_readable($logo)) {
+ $height = pdf_getHeightForLogo($logo);
+ $pdf->Image($logo, $posx, $posy, 0, $height); // width=0 (auto)
+ } else {
+ $pdf->SetTextColor(200, 0, 0);
+ $pdf->SetFont('', 'B', $default_font_size - 2);
+ $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
+ $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
+ }
+ } else {
+ $text = (string) $emetteur->name;
+ $pdf->MultiCell($w, 4, $outputlangs->convToOutputCharset($text), 0, $align);
+ }
+ }
+}
+
/**
* Function to try to calculate height of a HTML Content.
* WARNING: Do not use this function inside a TCPDF transaction.
diff --git a/htdocs/core/lib/security.lib.php b/htdocs/core/lib/security.lib.php
index 803beaba888..4de3acb9e5f 100644
--- a/htdocs/core/lib/security.lib.php
+++ b/htdocs/core/lib/security.lib.php
@@ -227,6 +227,24 @@ function restrictedArea(User $user, $features, $object = 0, $tableandshare = '',
$parentfortableentity = 'fk_website@website';
} elseif ($features == 'project') {
$features = 'projet';
+ } elseif ($features == 'eventorganization' && is_object($object) && $object->element == 'conferenceorbooth') {
+ // The module of an event organization declares no permission of its own, on purpose, so a check on
+ // 'eventorganization' is refused to everyone, an administrator included. Check the parent project
+ // instead, which is what the card of the object does itself.
+ // The card refuses an external user before that check, and fk_project is nullable, so we must refuse
+ // both cases here too: with no parent project there is nothing left to check the access on, and
+ // granting it would be an access with no check at all.
+ if (!empty($user->socid) || empty($object->fk_project)) {
+ if ($mode) {
+ return 0;
+ } else {
+ accessforbidden();
+ }
+ }
+ $features = 'projet';
+ $tableandshare = 'projet&project';
+ $objectid = (int) $object->fk_project;
+ $object = $objectid;
} elseif ($features == 'product') {
$features = 'produit';
} elseif ($features == 'productbatch') {
@@ -235,6 +253,10 @@ function restrictedArea(User $user, $features, $object = 0, $tableandshare = '',
$feature2 = 'charges';
} elseif ($features == 'workstation') {
$feature2 = 'workstation';
+ } elseif ($features == 'hrm' && is_object($object) && in_array($object->element, array('job', 'position', 'skill'))) {
+ $feature2 = 'all'; // These 3 objects have no permission of their own, they share the level "all"
+ } elseif ($features == 'stocktransfer' && is_object($object) && $object->element == 'stocktransfer') {
+ $feature2 = 'stocktransfer'; // This module declares no permission at its first level, only this one
} elseif ($features == 'fournisseur') { // When vendor invoice and purchase order are into module 'fournisseur'
if (is_object($object) && $object->element == 'invoice_supplier') {
$feature2 = 'facture';
@@ -783,6 +805,30 @@ function checkUserAccessToObject($user, array $featuresarray, $object = 0, $tabl
$sharedelement = (!empty($params[1]) ? $params[1] : $dbtablename); // We change dbtablename, so we set sharedelement too.
}
+ // The default rule reads the columns entity and $dbt_keyfield of the table, but some tables own neither of
+ // them. The sql was then built on columns that do not exist, so it always failed and the access was refused
+ // to the users that this rule applies to.
+ // The rule is selected on the table and not on the element of the object, because $object is an id and not
+ // an object for most of the callers, the card of an asset and the card of a workstation included.
+ if (!empty($objectid) && in_array($dbtablename, array('asset', 'paiement', 'paiementfourn', 'workstation_workstation', 'hrm_job', 'hrm_job_user', 'hrm_skill'))) {
+ // None of these objects is linked to a third party, so an external user can own none of them. The
+ // default rule refused him through a link that does not exist, we must refuse him explicitly instead,
+ // otherwise the rules below, which do not look at the third party of the user at all, would grant it.
+ if (!empty($user->socid)) {
+ return false;
+ }
+ if (in_array($dbtablename, array('hrm_job', 'hrm_job_user', 'hrm_skill'))) {
+ // These 3 tables have no entity column either, so no rule that reads the table can be run on them.
+ // The permission is still checked by restrictedArea(), and the $checkhierarchy rule below still runs.
+ // Note that these 3 objects are therefore not partitioned between entities at all, in the database
+ // itself: their cards already answer to a user of another entity, and their lists already show the
+ // records of all of them. This rule does not widen that, it aligns with it.
+ $nocheck[] = $feature;
+ } else {
+ $check[] = $feature; // Test on the entity only, there is no third party to restrict on
+ }
+ }
+
// $objectid was already sanitized at begin of this method (can be an int or a list of int separated by comma).
// To avoid an access forbidden with a numeric ref
if ($dbt_select != 'rowid' && $dbt_select != 'id') {
diff --git a/htdocs/core/modules/asset/doc/pdf_standard_asset.modules.php b/htdocs/core/modules/asset/doc/pdf_standard_asset.modules.php
index a4b8433db2f..c34f36d7981 100644
--- a/htdocs/core/modules/asset/doc/pdf_standard_asset.modules.php
+++ b/htdocs/core/modules/asset/doc/pdf_standard_asset.modules.php
@@ -818,31 +818,11 @@ class pdf_standard_asset extends ModelePDFAsset
$pdf->SetXY($this->marge_gauche, $posy);
// Logo
- if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
- if ($this->emetteur->logo) {
- $logodir = $conf->mycompany->dir_output;
- if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
- $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
- }
- if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) {
- $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;
- } else {
- $logo = $logodir.'/logos/'.$this->emetteur->logo;
- }
- if (is_readable($logo)) {
- $height = pdf_getHeightForLogo($logo);
- $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto)
- } else {
- $pdf->SetTextColor(200, 0, 0);
- $pdf->SetFont('', 'B', $default_font_size - 2);
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
- }
- } else {
- $text = (string) $this->emetteur->name;
- $pdf->MultiCell($w, 4, $outputlangs->convToOutputCharset($text), 0, 'L');
- }
+ $logodir = $conf->mycompany->dir_output;
+ if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
+ $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
}
+ pdf_writeLogoOrCompanyName($pdf, $outputlangs, $this->emetteur, $logodir, $this->marge_gauche, $posy, $w, $default_font_size, 'L');
$pdf->SetFont('', 'B', $default_font_size + 3);
$pdf->SetXY($posx, $posy);
diff --git a/htdocs/core/modules/commande/doc/pdf_einstein.modules.php b/htdocs/core/modules/commande/doc/pdf_einstein.modules.php
index 98545c3cfc6..c41440c1128 100644
--- a/htdocs/core/modules/commande/doc/pdf_einstein.modules.php
+++ b/htdocs/core/modules/commande/doc/pdf_einstein.modules.php
@@ -1374,31 +1374,11 @@ class pdf_einstein extends ModelePDFCommandes
$pdf->SetXY($this->marge_gauche, $posy);
// Logo
- if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
- if ($this->emetteur->logo) {
- $logodir = $conf->mycompany->dir_output;
- if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
- $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
- }
- if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) {
- $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;
- } else {
- $logo = $logodir.'/logos/'.$this->emetteur->logo;
- }
- if (is_readable($logo)) {
- $height = pdf_getHeightForLogo($logo);
- $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto)
- } else {
- $pdf->SetTextColor(200, 0, 0);
- $pdf->SetFont('', 'B', $default_font_size - 2);
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
- }
- } else {
- $text = $this->emetteur->name;
- $pdf->MultiCell($w, 4, $outputlangs->convToOutputCharset($text), 0, $ltrdirection);
- }
+ $logodir = $conf->mycompany->dir_output;
+ if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
+ $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
}
+ pdf_writeLogoOrCompanyName($pdf, $outputlangs, $this->emetteur, $logodir, $this->marge_gauche, $posy, $w, $default_font_size, $ltrdirection);
$pdf->SetFont('', 'B', $default_font_size + 3);
$pdf->SetXY($posx, $posy);
diff --git a/htdocs/core/modules/commande/doc/pdf_eratosthene.modules.php b/htdocs/core/modules/commande/doc/pdf_eratosthene.modules.php
index b627bd220ad..a07a1dbfb21 100644
--- a/htdocs/core/modules/commande/doc/pdf_eratosthene.modules.php
+++ b/htdocs/core/modules/commande/doc/pdf_eratosthene.modules.php
@@ -1641,31 +1641,12 @@ class pdf_eratosthene extends ModelePDFCommandes
$pdf->SetXY($this->marge_gauche, $posy);
// Logo
- if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
- if ($this->emetteur->logo) {
- $logodir = $conf->mycompany->dir_output;
- if (!empty(getMultidirOutput($mysoc, 'mycompany'))) {
- $logodir = getMultidirOutput($mysoc, 'mycompany');
- }
- if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) {
- $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;
- } else {
- $logo = $logodir.'/logos/'.$this->emetteur->logo;
- }
- if (is_readable($logo)) {
- $height = pdf_getHeightForLogo($logo);
- $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto)
- } else {
- $pdf->SetTextColor(200, 0, 0);
- $pdf->SetFont('', 'B', $default_font_size - 2);
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
- }
- } else {
- $text = $this->emetteur->name;
- $pdf->MultiCell($w, 4, $outputlangs->convToOutputCharset($text), 0, 'L');
- }
+ $logodir = $conf->mycompany->dir_output;
+ $multidiroutput = getMultidirOutput($mysoc, 'mycompany');
+ if (!empty($multidiroutput)) {
+ $logodir = $multidiroutput;
}
+ pdf_writeLogoOrCompanyName($pdf, $outputlangs, $this->emetteur, $logodir, $this->marge_gauche, $posy, $w, $default_font_size, 'L');
$pdf->SetFont('', 'B', $default_font_size + 3);
$pdf->SetXY($posx, $posy);
diff --git a/htdocs/core/modules/contract/doc/pdf_strato.modules.php b/htdocs/core/modules/contract/doc/pdf_strato.modules.php
index 32f93edfea8..41ef69d9393 100644
--- a/htdocs/core/modules/contract/doc/pdf_strato.modules.php
+++ b/htdocs/core/modules/contract/doc/pdf_strato.modules.php
@@ -674,31 +674,12 @@ class pdf_strato extends ModelePDFContract
$pdf->SetXY($this->marge_gauche, $posy);
// Logo
- if (!getDolGlobalString('PDF_DISABLE_MYCOMPANY_LOGO')) {
- if ($this->emetteur->logo) {
- $logodir = $conf->mycompany->dir_output;
- if (getMultidirOutput($object, 'mycompany')) {
- $logodir = getMultidirOutput($object, 'mycompany');
- }
- if (!getDolGlobalString('MAIN_PDF_USE_LARGE_LOGO')) {
- $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;
- } else {
- $logo = $logodir.'/logos/'.$this->emetteur->logo;
- }
- if (is_readable($logo)) {
- $height = pdf_getHeightForLogo($logo);
- $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto)
- } else {
- $pdf->SetTextColor(200, 0, 0);
- $pdf->SetFont('', 'B', $default_font_size - 2);
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
- }
- } else {
- $text = $this->emetteur->name;
- $pdf->MultiCell($w, 4, $outputlangs->convToOutputCharset($text), 0, $ltrdirection);
- }
+ $logodir = $conf->mycompany->dir_output;
+ $multidiroutput = getMultidirOutput($object, 'mycompany');
+ if (!empty($multidiroutput)) {
+ $logodir = $multidiroutput;
}
+ pdf_writeLogoOrCompanyName($pdf, $outputlangs, $this->emetteur, $logodir, $this->marge_gauche, $posy, $w, $default_font_size, $ltrdirection);
$pdf->SetFont('', 'B', $default_font_size + 3);
$pdf->SetXY($posx, $posy);
diff --git a/htdocs/core/modules/facture/doc/pdf_octopus.modules.php b/htdocs/core/modules/facture/doc/pdf_octopus.modules.php
index a3dce874819..1f265f1b259 100644
--- a/htdocs/core/modules/facture/doc/pdf_octopus.modules.php
+++ b/htdocs/core/modules/facture/doc/pdf_octopus.modules.php
@@ -2026,31 +2026,11 @@ class pdf_octopus extends ModelePDFFactures
$pdf->SetXY($this->marge_gauche, $posy);
// Logo
- if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
- if ($this->emetteur->logo) {
- $logodir = $conf->mycompany->dir_output;
- if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
- $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
- }
- if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) {
- $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;
- } else {
- $logo = $logodir.'/logos/'.$this->emetteur->logo;
- }
- if (is_readable($logo)) {
- $height = pdf_getHeightForLogo($logo);
- $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto)
- } else {
- $pdf->SetTextColor(200, 0, 0);
- $pdf->SetFont('', 'B', $default_font_size - 2);
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
- }
- } else {
- $text = $this->emetteur->name;
- $pdf->MultiCell($w, 4, $outputlangs->convToOutputCharset($text), 0, $ltrdirection);
- }
+ $logodir = $conf->mycompany->dir_output;
+ if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
+ $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
}
+ pdf_writeLogoOrCompanyName($pdf, $outputlangs, $this->emetteur, $logodir, $this->marge_gauche, $posy, $w, $default_font_size, $ltrdirection);
$pdf->SetFont('', 'B', $default_font_size + 3);
$pdf->SetXY($posx, $posy);
diff --git a/htdocs/core/modules/facture/doc/pdf_sponge.modules.php b/htdocs/core/modules/facture/doc/pdf_sponge.modules.php
index e3df6b24535..51e4dfb92c5 100644
--- a/htdocs/core/modules/facture/doc/pdf_sponge.modules.php
+++ b/htdocs/core/modules/facture/doc/pdf_sponge.modules.php
@@ -2231,31 +2231,11 @@ class pdf_sponge extends ModelePDFFactures
$pdf->SetXY($this->marge_gauche, $posy);
// Logo
- if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
- if ($this->emetteur->logo) {
- $logodir = $conf->mycompany->dir_output;
- if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
- $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
- }
- if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) {
- $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;
- } else {
- $logo = $logodir.'/logos/'.$this->emetteur->logo;
- }
- if (is_readable($logo)) {
- $height = pdf_getHeightForLogo($logo);
- $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto)
- } else {
- $pdf->SetTextColor(200, 0, 0);
- $pdf->SetFont('', 'B', $default_font_size - 2);
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
- }
- } else {
- $text = $this->emetteur->name;
- $pdf->MultiCell($w, 4, $outputlangs->convToOutputCharset($text), 0, $ltrdirection);
- }
+ $logodir = $conf->mycompany->dir_output;
+ if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
+ $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
}
+ pdf_writeLogoOrCompanyName($pdf, $outputlangs, $this->emetteur, $logodir, $this->marge_gauche, $posy, $w, $default_font_size, $ltrdirection);
$pdf->SetFont('', 'B', $default_font_size + 3);
$pdf->SetXY($posx, $posy);
diff --git a/htdocs/core/modules/product/doc/pdf_standard.modules.php b/htdocs/core/modules/product/doc/pdf_standard.modules.php
index 42f9bbb4464..02326bba12c 100644
--- a/htdocs/core/modules/product/doc/pdf_standard.modules.php
+++ b/htdocs/core/modules/product/doc/pdf_standard.modules.php
@@ -756,31 +756,11 @@ class pdf_standard extends ModelePDFProduct
$pdf->SetXY($this->marge_gauche, $posy);
// Logo
- if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
- if ($this->emetteur->logo) {
- $logodir = $conf->mycompany->dir_output;
- if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
- $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
- }
- if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) {
- $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;
- } else {
- $logo = $logodir.'/logos/'.$this->emetteur->logo;
- }
- if (is_readable($logo)) {
- $height = pdf_getHeightForLogo($logo);
- $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto)
- } else {
- $pdf->SetTextColor(200, 0, 0);
- $pdf->SetFont('', 'B', $default_font_size - 2);
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
- }
- } else {
- $text = $this->emetteur->name;
- $pdf->MultiCell($w, 4, $outputlangs->convToOutputCharset($text), 0, $ltrdirection);
- }
+ $logodir = $conf->mycompany->dir_output;
+ if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
+ $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
}
+ pdf_writeLogoOrCompanyName($pdf, $outputlangs, $this->emetteur, $logodir, $this->marge_gauche, $posy, $w, $default_font_size, $ltrdirection);
$pdf->SetFont('', 'B', $default_font_size + 3);
diff --git a/htdocs/core/modules/propale/doc/pdf_azur.modules.php b/htdocs/core/modules/propale/doc/pdf_azur.modules.php
index 6ff7527d2d8..9b4022cd9dd 100644
--- a/htdocs/core/modules/propale/doc/pdf_azur.modules.php
+++ b/htdocs/core/modules/propale/doc/pdf_azur.modules.php
@@ -1582,31 +1582,11 @@ class pdf_azur extends ModelePDFPropales
$pdf->SetXY($this->marge_gauche, $posy);
// Logo
- if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
- if ($this->emetteur->logo) {
- $logodir = $conf->mycompany->dir_output;
- if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
- $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
- }
- if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) {
- $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;
- } else {
- $logo = $logodir.'/logos/'.$this->emetteur->logo;
- }
- if (is_readable($logo)) {
- $height = pdf_getHeightForLogo($logo);
- $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto)
- } else {
- $pdf->SetTextColor(200, 0, 0);
- $pdf->SetFont('', 'B', $default_font_size - 2);
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
- }
- } else {
- $text = $this->emetteur->name;
- $pdf->MultiCell($w, 4, $outputlangs->convToOutputCharset($text), 0, $ltrdirection);
- }
+ $logodir = $conf->mycompany->dir_output;
+ if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
+ $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
}
+ pdf_writeLogoOrCompanyName($pdf, $outputlangs, $this->emetteur, $logodir, $this->marge_gauche, $posy, $w, $default_font_size, $ltrdirection);
$pdf->SetFont('', 'B', $default_font_size + 3);
$pdf->SetXY($posx, $posy);
diff --git a/htdocs/core/modules/propale/doc/pdf_cyan.modules.php b/htdocs/core/modules/propale/doc/pdf_cyan.modules.php
index b9a1a39bd53..79d572c224a 100644
--- a/htdocs/core/modules/propale/doc/pdf_cyan.modules.php
+++ b/htdocs/core/modules/propale/doc/pdf_cyan.modules.php
@@ -1726,31 +1726,11 @@ class pdf_cyan extends ModelePDFPropales
$pdf->SetXY($this->marge_gauche, $posy);
// Logo
- if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
- if ($this->emetteur->logo) {
- $logodir = $conf->mycompany->dir_output;
- if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
- $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
- }
- if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) {
- $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;
- } else {
- $logo = $logodir.'/logos/'.$this->emetteur->logo;
- }
- if (is_readable($logo)) {
- $height = pdf_getHeightForLogo($logo);
- $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto)
- } else {
- $pdf->SetTextColor(200, 0, 0);
- $pdf->SetFont('', 'B', $default_font_size - 2);
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
- }
- } else {
- $text = $this->emetteur->name;
- $pdf->MultiCell($w, 4, $outputlangs->convToOutputCharset($text), 0, $ltrdirection);
- }
+ $logodir = $conf->mycompany->dir_output;
+ if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
+ $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
}
+ pdf_writeLogoOrCompanyName($pdf, $outputlangs, $this->emetteur, $logodir, $this->marge_gauche, $posy, $w, $default_font_size, $ltrdirection);
$pdf->SetFont('', 'B', $default_font_size + 3);
$pdf->SetXY($posx, $posy);
diff --git a/htdocs/core/modules/stocktransfer/doc/pdf_eagle_proforma.modules.php b/htdocs/core/modules/stocktransfer/doc/pdf_eagle_proforma.modules.php
index 2fdbe416247..738c4b9af11 100644
--- a/htdocs/core/modules/stocktransfer/doc/pdf_eagle_proforma.modules.php
+++ b/htdocs/core/modules/stocktransfer/doc/pdf_eagle_proforma.modules.php
@@ -1225,31 +1225,11 @@ class pdf_eagle_proforma extends ModelePDFStockTransfer
$pdf->SetXY($this->marge_gauche, $posy);
// Logo
- if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
- if ($this->emetteur->logo) {
- $logodir = $conf->mycompany->dir_output;
- if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
- $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
- }
- if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) {
- $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;
- } else {
- $logo = $logodir.'/logos/'.$this->emetteur->logo;
- }
- if (is_readable($logo)) {
- $height = pdf_getHeightForLogo($logo);
- $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto)
- } else {
- $pdf->SetTextColor(200, 0, 0);
- $pdf->SetFont('', 'B', $default_font_size - 2);
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
- }
- } else {
- $text = $this->emetteur->name;
- $pdf->MultiCell($w, 4, $outputlangs->convToOutputCharset($text), 0, 'L');
- }
+ $logodir = $conf->mycompany->dir_output;
+ if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
+ $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
}
+ pdf_writeLogoOrCompanyName($pdf, $outputlangs, $this->emetteur, $logodir, $this->marge_gauche, $posy, $w, $default_font_size, 'L');
$pdf->SetDrawColor(128, 128, 128);
diff --git a/htdocs/core/modules/supplier_order/doc/pdf_cornas.modules.php b/htdocs/core/modules/supplier_order/doc/pdf_cornas.modules.php
index a0dc7cd48d6..a994fc20764 100644
--- a/htdocs/core/modules/supplier_order/doc/pdf_cornas.modules.php
+++ b/htdocs/core/modules/supplier_order/doc/pdf_cornas.modules.php
@@ -1265,31 +1265,11 @@ class pdf_cornas extends ModelePDFSuppliersOrders
$pdf->SetXY($this->marge_gauche, $posy);
// Logo
- if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
- if ($this->emetteur->logo) {
- $logodir = $conf->mycompany->dir_output;
- if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
- $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
- }
- if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) {
- $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;
- } else {
- $logo = $logodir.'/logos/'.$this->emetteur->logo;
- }
- if (is_readable($logo)) {
- $height = pdf_getHeightForLogo($logo);
- $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto)
- } else {
- $pdf->SetTextColor(200, 0, 0);
- $pdf->SetFont('', 'B', $default_font_size - 2);
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
- }
- } else {
- $text = $this->emetteur->name;
- $pdf->MultiCell($w, 4, $outputlangs->convToOutputCharset($text), 0, $ltrdirection);
- }
+ $logodir = $conf->mycompany->dir_output;
+ if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
+ $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
}
+ pdf_writeLogoOrCompanyName($pdf, $outputlangs, $this->emetteur, $logodir, $this->marge_gauche, $posy, $w, $default_font_size, $ltrdirection);
$pdf->SetFont('', 'B', $default_font_size + 3);
$pdf->SetXY($posx, $posy);
diff --git a/htdocs/core/modules/supplier_order/doc/pdf_muscadet.modules.php b/htdocs/core/modules/supplier_order/doc/pdf_muscadet.modules.php
index cfb50edbd39..8f7d0976149 100644
--- a/htdocs/core/modules/supplier_order/doc/pdf_muscadet.modules.php
+++ b/htdocs/core/modules/supplier_order/doc/pdf_muscadet.modules.php
@@ -1133,31 +1133,11 @@ class pdf_muscadet extends ModelePDFSuppliersOrders
$pdf->SetXY($this->marge_gauche, $posy);
// Logo
- if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
- if ($this->emetteur->logo) {
- $logodir = $conf->mycompany->dir_output;
- if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
- $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
- }
- if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) {
- $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;
- } else {
- $logo = $logodir.'/logos/'.$this->emetteur->logo;
- }
- if (is_readable($logo)) {
- $height = pdf_getHeightForLogo($logo);
- $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto)
- } else {
- $pdf->SetTextColor(200, 0, 0);
- $pdf->SetFont('', 'B', $default_font_size - 2);
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
- $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
- }
- } else {
- $text = $this->emetteur->name;
- $pdf->MultiCell($w, 4, $outputlangs->convToOutputCharset($text), 0, $ltrdirection);
- }
+ $logodir = $conf->mycompany->dir_output;
+ if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
+ $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
}
+ pdf_writeLogoOrCompanyName($pdf, $outputlangs, $this->emetteur, $logodir, $this->marge_gauche, $posy, $w, $default_font_size, $ltrdirection);
$pdf->SetFont('', 'B', $default_font_size + 3);
$pdf->SetXY($posx, $posy);
diff --git a/htdocs/core/modules/supplier_proposal/doc/pdf_aurore.modules.php b/htdocs/core/modules/supplier_proposal/doc/pdf_aurore.modules.php
index 55a5b5b5da5..442e82a2dcb 100644
--- a/htdocs/core/modules/supplier_proposal/doc/pdf_aurore.modules.php
+++ b/htdocs/core/modules/supplier_proposal/doc/pdf_aurore.modules.php
@@ -1228,31 +1228,11 @@ class pdf_aurore extends ModelePDFSupplierProposal
$pdf->SetXY($this->marge_gauche, $posy);
// Logo
- if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
- if ($this->emetteur->logo) {
- $logodir = $conf->mycompany->dir_output;
- if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
- $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
- }
- if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) {
- $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;
- } else {
- $logo = $logodir.'/logos/'.$this->emetteur->logo;
- }
- if (is_readable($logo)) {
- $height = pdf_getHeightForLogo($logo);
- $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto)
- } else {
- $pdf->SetTextColor(200, 0, 0);
- $pdf->SetFont('', 'B', $default_font_size - 2);
- $pdf->MultiCell(100, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
- $pdf->MultiCell(100, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
- }
- } else {
- $text = $this->emetteur->name;
- $pdf->MultiCell(100, 4, $outputlangs->convToOutputCharset($text), 0);
- }
+ $logodir = $conf->mycompany->dir_output;
+ if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
+ $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
}
+ pdf_writeLogoOrCompanyName($pdf, $outputlangs, $this->emetteur, $logodir, $this->marge_gauche, $posy, 100, $default_font_size, 'J');
$pdf->SetFont('', 'B', $default_font_size + 3);
$pdf->SetXY($posx, $posy);
diff --git a/htdocs/core/modules/supplier_proposal/doc/pdf_zenith.modules.php b/htdocs/core/modules/supplier_proposal/doc/pdf_zenith.modules.php
index a9ec360bdd3..1c5c1063785 100644
--- a/htdocs/core/modules/supplier_proposal/doc/pdf_zenith.modules.php
+++ b/htdocs/core/modules/supplier_proposal/doc/pdf_zenith.modules.php
@@ -1244,31 +1244,11 @@ class pdf_zenith extends ModelePDFSupplierProposal
$pdf->SetXY($this->marge_gauche, $posy);
// Logo
- if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
- if ($this->emetteur->logo) {
- $logodir = $conf->mycompany->dir_output;
- if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
- $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
- }
- if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) {
- $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;
- } else {
- $logo = $logodir.'/logos/'.$this->emetteur->logo;
- }
- if (is_readable($logo)) {
- $height = pdf_getHeightForLogo($logo);
- $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto)
- } else {
- $pdf->SetTextColor(200, 0, 0);
- $pdf->SetFont('', 'B', $default_font_size - 2);
- $pdf->MultiCell(100, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L');
- $pdf->MultiCell(100, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L');
- }
- } else {
- $text = $this->emetteur->name;
- $pdf->MultiCell(100, 4, $outputlangs->convToOutputCharset($text), 0, $ltrdirection);
- }
+ $logodir = $conf->mycompany->dir_output;
+ if (!empty($conf->mycompany->multidir_output[$object->entity ?? $conf->entity])) {
+ $logodir = $conf->mycompany->multidir_output[$object->entity ?? $conf->entity];
}
+ pdf_writeLogoOrCompanyName($pdf, $outputlangs, $this->emetteur, $logodir, $this->marge_gauche, $posy, 100, $default_font_size, $ltrdirection);
$pdf->SetFont('', 'B', $default_font_size + 3);
$pdf->SetXY($posx, $posy);
diff --git a/htdocs/core/triggers/interface_50_modAgenda_ActionsAuto.class.php b/htdocs/core/triggers/interface_50_modAgenda_ActionsAuto.class.php
index f4a7e4116df..1bcf18196f7 100644
--- a/htdocs/core/triggers/interface_50_modAgenda_ActionsAuto.class.php
+++ b/htdocs/core/triggers/interface_50_modAgenda_ActionsAuto.class.php
@@ -186,18 +186,24 @@ class InterfaceActionsAuto extends DolibarrTriggers
$object->sendtoid = array($object->id => $object->id);
// $object->socid = $object->socid;
} elseif ($action == 'CONTACT_MODIFY' && $object instanceof Contact) {
+ '@phan-var-force Contact $object';
// Load translation files required by the page
$langs->loadLangs(array("agenda", "other", "companies"));
if (empty($object->actionmsg2)) {
if (empty($object->context['actionmsg2'])) {
- $object->actionmsg2 = $langs->transnoentities("CONTACT_MODIFYInDolibarr", (string) $object->name);
+ $object->actionmsg2 = $langs->transnoentities("CONTACT_MODIFYInDolibarr", $object->getFullName($langs));
} else {
$object->actionmsg2 = $object->context['actionmsg2'];
}
}
if (empty($object->actionmsg)) {
- $object->actionmsg = $langs->transnoentities("CONTACT_MODIFYInDolibarr", (string) $object->name);
+ $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);
diff --git a/htdocs/don/card.php b/htdocs/don/card.php
index 61c80893a9c..9ad1ed259d8 100644
--- a/htdocs/don/card.php
+++ b/htdocs/don/card.php
@@ -701,7 +701,7 @@ if (!empty($id) && $action != 'edit') {
$hselected = 'card';
$head = donation_prepare_head($object);
- print dol_get_fiche_head($head, $hselected, $langs->trans("Donation"), -1, 'donation');
+ print dol_get_fiche_head($head, $hselected, $langs->trans("Donation"), -1, 'donation', 0, '', '', 0, '', 1);
// Print form confirm
print $formconfirm;
diff --git a/htdocs/eventorganization/conferenceorbooth_card.php b/htdocs/eventorganization/conferenceorbooth_card.php
index a1a6adbe8bf..e80d786139b 100644
--- a/htdocs/eventorganization/conferenceorbooth_card.php
+++ b/htdocs/eventorganization/conferenceorbooth_card.php
@@ -534,7 +534,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea
$head = conferenceorboothPrepareHead($object, $withproject);
- print dol_get_fiche_head($head, 'card', $langs->trans("ConferenceOrBooth"), -1, $object->picto);
+ print dol_get_fiche_head($head, 'card', $langs->trans("ConferenceOrBooth"), -1, $object->picto, 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/htdocs/expedition/card.php b/htdocs/expedition/card.php
index 56bbe562258..c4931b3a7b3 100644
--- a/htdocs/expedition/card.php
+++ b/htdocs/expedition/card.php
@@ -2687,7 +2687,7 @@ if ($action == 'create' && $usercancreate) {
$res = $object->fetch_optionals();
$head = shipping_prepare_head($object);
- print dol_get_fiche_head($head, 'shipping', $langs->trans("Shipment"), -1, $object->picto);
+ print dol_get_fiche_head($head, 'shipping', $langs->trans("Shipment"), -1, $object->picto, 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/htdocs/expensereport/card.php b/htdocs/expensereport/card.php
index 91916cadd9a..1d18ec5d37a 100644
--- a/htdocs/expensereport/card.php
+++ b/htdocs/expensereport/card.php
@@ -1621,7 +1621,7 @@ if ($action == 'create') {
print '';
print '';
- print dol_get_fiche_head($head, 'card', $langs->trans("ExpenseReport"), 0, 'trip', 0, '', '', 0, '', 1);
+ print dol_get_fiche_head($head, 'card', $langs->trans("ExpenseReport"), 0, 'trip', 0, '', '', 0, '', 0); // No drag and drop on the edit form, dropping a file reloads the page and discards it
if ($object->status == ExpenseReport::STATUS_REFUSED) {
print '';
diff --git a/htdocs/fourn/paiement/card.php b/htdocs/fourn/paiement/card.php
index e21d842c69f..fb19a1d9d91 100644
--- a/htdocs/fourn/paiement/card.php
+++ b/htdocs/fourn/paiement/card.php
@@ -174,7 +174,9 @@ $formfile = new FormFile($db);
$head = payment_supplier_prepare_head($object);
-print dol_get_fiche_head($head, 'payment', $langs->trans('SupplierPayment'), -1, 'payment');
+// The tabs are printed before the result of the fetch is checked below, so we must not offer a drop area
+// when the object was not loaded: the upload could only fail.
+print dol_get_fiche_head($head, 'payment', $langs->trans('SupplierPayment'), -1, 'payment', 0, '', '', 0, '', ($result > 0 ? 1 : 0));
if ($result > 0) {
/*
diff --git a/htdocs/holiday/card.php b/htdocs/holiday/card.php
index 7bbdeaa4307..d3feb4c19c2 100644
--- a/htdocs/holiday/card.php
+++ b/htdocs/holiday/card.php
@@ -1334,7 +1334,9 @@ if ((empty($id) && empty($ref)) || $action == 'create' || $action == 'add') {
if ($canread) {
$head = holiday_prepare_head($object);
- if (($action == 'edit' && $object->status == Holiday::STATUS_DRAFT) || ($action == 'editvalidator')) {
+ $editmode = (($action == 'edit' && $object->status == Holiday::STATUS_DRAFT) || ($action == 'editvalidator'));
+
+ if ($editmode) {
if ($action == 'edit' && $object->status == Holiday::STATUS_DRAFT) {
$edit = true;
}
@@ -1345,7 +1347,9 @@ if ((empty($id) && empty($ref)) || $action == 'create' || $action == 'add') {
print ''."\n";
}
- print dol_get_fiche_head($head, 'card', $langs->trans("CPTitreMenu"), -1, 'holiday');
+ // No drop area in edit mode: these tabs are printed inside the edit form, and dropping a file
+ // reloads the page, which would discard what the user is typing.
+ print dol_get_fiche_head($head, 'card', $langs->trans("CPTitreMenu"), -1, 'holiday', 0, '', '', 0, '', ($editmode ? 0 : 1));
$linkback = ''.$langs->trans("BackToList").'';
diff --git a/htdocs/hrm/evaluation_card.php b/htdocs/hrm/evaluation_card.php
index 52c7193c17f..7450064fa74 100644
--- a/htdocs/hrm/evaluation_card.php
+++ b/htdocs/hrm/evaluation_card.php
@@ -358,7 +358,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea
$res = $object->fetch_optionals();
$head = evaluationPrepareHead($object);
- print dol_get_fiche_head($head, 'card', $langs->trans("Workstation"), -1, $object->picto);
+ print dol_get_fiche_head($head, 'card', $langs->trans("Workstation"), -1, $object->picto, 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/htdocs/hrm/job_card.php b/htdocs/hrm/job_card.php
index 59fc1aa5c20..785a06744b4 100644
--- a/htdocs/hrm/job_card.php
+++ b/htdocs/hrm/job_card.php
@@ -314,7 +314,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea
$head = jobPrepareHead($object);
$picto = 'company.png';
- print dol_get_fiche_head($head, 'job_card', $langs->trans("Workstation"), -1, $object->picto);
+ print dol_get_fiche_head($head, 'job_card', $langs->trans("Workstation"), -1, $object->picto, 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/htdocs/hrm/position_card.php b/htdocs/hrm/position_card.php
index a171fa9a8a9..1285a99db31 100644
--- a/htdocs/hrm/position_card.php
+++ b/htdocs/hrm/position_card.php
@@ -271,7 +271,7 @@ function displayPositionCard(&$object)
$head = positionCardPrepareHead($object);
- print dol_get_fiche_head($head, 'position', $langs->trans("Workstation"), -1, $object->picto);
+ print dol_get_fiche_head($head, 'position', $langs->trans("Workstation"), -1, $object->picto, 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/htdocs/hrm/skill_card.php b/htdocs/hrm/skill_card.php
index 106b1e6072f..3b5c6dad78a 100644
--- a/htdocs/hrm/skill_card.php
+++ b/htdocs/hrm/skill_card.php
@@ -430,7 +430,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea
$res = $object->fetch_optionals();
$head = skillPrepareHead($object);
- print dol_get_fiche_head($head, 'card', $langs->trans("Workstation"), -1, $object->picto);
+ print dol_get_fiche_head($head, 'card', $langs->trans("Workstation"), -1, $object->picto, 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/htdocs/install/mysql/migration/24.0.0-25.0.0.sql b/htdocs/install/mysql/migration/24.0.0-25.0.0.sql
index 364e8c177e4..bb9ab59b4ed 100644
--- a/htdocs/install/mysql/migration/24.0.0-25.0.0.sql
+++ b/htdocs/install/mysql/migration/24.0.0-25.0.0.sql
@@ -78,4 +78,9 @@ UPDATE llx_commande_fournisseurdet SET subprice_ttc = 0 WHERE subprice_ttc <> 0
UPDATE llx_facture_fourn_det SET pu_ttc = 0 WHERE pu_ttc <> 0 AND EXISTS (SELECT c.rowid FROM llx_const as c WHERE c.name = 'MAIN_VERSION_LAST_UPGRADE' AND c.value < '25.0.0');
UPDATE llx_supplier_proposaldet SET subprice_ttc = 0 WHERE subprice_ttc <> 0 AND EXISTS (SELECT c.rowid FROM llx_const as c WHERE c.name = 'MAIN_VERSION_LAST_UPGRADE' AND c.value < '25.0.0');
+-- Explicit contact address mode flag. NULL keeps the legacy resolution for existing records,
+-- so an existing alternative contact address stays independent from its thirdparty address.
+-- VMYSQL4.1 ALTER TABLE llx_socpeople ADD COLUMN use_thirdparty_address smallint DEFAULT NULL AFTER fk_soc;
+-- VPGSQL8.2 ALTER TABLE llx_socpeople ADD COLUMN use_thirdparty_address smallint DEFAULT NULL;
+
-- end of migration
diff --git a/htdocs/install/mysql/tables/llx_socpeople.sql b/htdocs/install/mysql/tables/llx_socpeople.sql
index ce980d864c4..14e7423cf80 100644
--- a/htdocs/install/mysql/tables/llx_socpeople.sql
+++ b/htdocs/install/mysql/tables/llx_socpeople.sql
@@ -25,6 +25,7 @@ create table llx_socpeople
datec datetime,
tms timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
fk_soc integer, -- lien vers la societe
+ use_thirdparty_address smallint DEFAULT NULL, -- 1=use linked thirdparty address, 0=use contact address, null=legacy resolution
entity integer DEFAULT 1 NOT NULL, -- multi company id
ref_ext varchar(255), -- reference into an external system (not used by dolibarr)
name_alias varchar(255),
diff --git a/htdocs/intracommreport/card.php b/htdocs/intracommreport/card.php
index c6288c30ab1..748325c23a2 100644
--- a/htdocs/intracommreport/card.php
+++ b/htdocs/intracommreport/card.php
@@ -284,7 +284,7 @@ if (($id || $ref) && $action == 'edit') {
if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'create'))) {
$head = intracommreportPrepareHead($object);
- print dol_get_fiche_head($head, 'card', $langs->trans("IntraCommReport"), -1, $object->picto, 0, '', '', 0, '', 1);
+ print dol_get_fiche_head($head, 'card', $langs->trans("IntraCommReport"), -1, $object->picto);
$formconfirm = '';
diff --git a/htdocs/knowledgemanagement/knowledgerecord_card.php b/htdocs/knowledgemanagement/knowledgerecord_card.php
index 6fbb511b928..3f10add8e04 100644
--- a/htdocs/knowledgemanagement/knowledgerecord_card.php
+++ b/htdocs/knowledgemanagement/knowledgerecord_card.php
@@ -300,7 +300,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea
$res = $object->fetch_optionals();
$head = knowledgerecordPrepareHead($object);
- print dol_get_fiche_head($head, 'card', $langs->trans("KnowledgeRecord"), -1, $object->picto);
+ print dol_get_fiche_head($head, 'card', $langs->trans("KnowledgeRecord"), -1, $object->picto, 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/htdocs/langs/en_US/companies.lang b/htdocs/langs/en_US/companies.lang
index 5f1071ad2eb..bf08c1fc92d 100644
--- a/htdocs/langs/en_US/companies.lang
+++ b/htdocs/langs/en_US/companies.lang
@@ -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
diff --git a/htdocs/langs/en_US/errors.lang b/htdocs/langs/en_US/errors.lang
index b5ac29a3140..412d290fc3a 100644
--- a/htdocs/langs/en_US/errors.lang
+++ b/htdocs/langs/en_US/errors.lang
@@ -334,6 +334,7 @@ ErrorFieldValue=Value for %s is incorrect
ErrorCoherenceMenu=%s is required when %s is 'left'
ErrorUploadFileDragDrop=There was an error while the file(s) upload
ErrorUploadFileDragDropPermissionDenied=There was an error while the file(s) upload : Permission denied
+ErrorOnAtLeastOneFileUpload=There was an error while uploading at least one file
ErrorFixThisHere=Fix this here
ErrorTheUrlOfYourDolInstanceDoesNotMatchURLIntoOAuthSetup=Error: The URL of you current instance (%s) does not match the URL defined into your OAuth2 login setup (%s). Doing OAuth2 login in such a configuration is not allowed.
ErrorMenuExistValue=A Menu already exist with this Title or URL
diff --git a/htdocs/loan/card.php b/htdocs/loan/card.php
index 70e81f7bb42..6a5c407e574 100644
--- a/htdocs/loan/card.php
+++ b/htdocs/loan/card.php
@@ -442,7 +442,7 @@ if ($id > 0) {
print '';
}
- print dol_get_fiche_head($head, 'card', $langs->trans("Loan"), -1, 'money-bill-alt', 0, '', '', 0, '', 1);
+ print dol_get_fiche_head($head, 'card', $langs->trans("Loan"), -1, 'money-bill-alt', 0, '', '', 0, '', ($action == 'edit' ? 0 : 1));
// Loan card
$linkback = ''.$langs->trans("BackToList").'';
diff --git a/htdocs/mrp/mo_card.php b/htdocs/mrp/mo_card.php
index 76b012b242e..04791cb8a68 100644
--- a/htdocs/mrp/mo_card.php
+++ b/htdocs/mrp/mo_card.php
@@ -584,7 +584,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea
$head = moPrepareHead($object);
- print dol_get_fiche_head($head, 'card', $langs->trans("ManufacturingOrder"), -1, $object->picto);
+ print dol_get_fiche_head($head, 'card', $langs->trans("ManufacturingOrder"), -1, $object->picto, 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/htdocs/partnership/partnership_card.php b/htdocs/partnership/partnership_card.php
index 9bb895dba7a..926ff24fff4 100644
--- a/htdocs/partnership/partnership_card.php
+++ b/htdocs/partnership/partnership_card.php
@@ -357,7 +357,7 @@ if (($id || $ref) && $action == 'edit') {
if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'create'))) {
$head = partnershipPrepareHead($object);
- print dol_get_fiche_head($head, 'card', $langs->trans("Partnership"), -1, $object->picto);
+ print dol_get_fiche_head($head, 'card', $langs->trans("Partnership"), -1, $object->picto, 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/htdocs/product/card.php b/htdocs/product/card.php
index 407d70528bc..393c9278d17 100644
--- a/htdocs/product/card.php
+++ b/htdocs/product/card.php
@@ -2075,7 +2075,7 @@ if (is_object($objcanvas) && $objcanvas->displayCanvasExists($canvasdisplayactio
$head = product_prepare_head($object);
$titre = $langs->trans("CardProduct".$object->type);
$picto = ($object->type == Product::TYPE_SERVICE ? 'service' : 'product');
- print dol_get_fiche_head($head, 'card', $titre, 0, $picto, 0, '', '', 0, '', 1);
+ print dol_get_fiche_head($head, 'card', $titre, 0, $picto, 0, '', '', 0, '', 0); // No drag and drop on the edit form, dropping a file reloads the page and discards it
// Call Hook tabContentEditProduct
$parameters = array();
diff --git a/htdocs/product/stock/productlot_card.php b/htdocs/product/stock/productlot_card.php
index 29b3238dadc..14426d4fe91 100644
--- a/htdocs/product/stock/productlot_card.php
+++ b/htdocs/product/stock/productlot_card.php
@@ -344,7 +344,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea
$res = $object->fetch_optionals();
$head = productlot_prepare_head($object);
- print dol_get_fiche_head($head, 'card', $langs->trans("Batch"), -1, $object->picto);
+ print dol_get_fiche_head($head, 'card', $langs->trans("Batch"), -1, $object->picto, 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/htdocs/product/stock/stocktransfer/stocktransfer_card.php b/htdocs/product/stock/stocktransfer/stocktransfer_card.php
index 744d2a75bc1..a68cdf027ff 100644
--- a/htdocs/product/stock/stocktransfer/stocktransfer_card.php
+++ b/htdocs/product/stock/stocktransfer/stocktransfer_card.php
@@ -548,7 +548,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea
$head = stocktransferPrepareHead($object);
- print dol_get_fiche_head($head, 'card', $langs->trans("StockTransfer"), -1, $object->picto);
+ print dol_get_fiche_head($head, 'card', $langs->trans("StockTransfer"), -1, $object->picto, 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/htdocs/projet/tasks/task.php b/htdocs/projet/tasks/task.php
index 3be6271041c..2bc963842bf 100644
--- a/htdocs/projet/tasks/task.php
+++ b/htdocs/projet/tasks/task.php
@@ -598,7 +598,7 @@ if ($id > 0 || !empty($ref)) {
$param = ($withproject ? '&withproject=1' : '');
$linkback = $withproject ? ''.$langs->trans("BackToList").'' : '';
- print dol_get_fiche_head($head, 'task_task', $langs->trans("Task"), -1, 'projecttask', 0, '', 'reposition');
+ print dol_get_fiche_head($head, 'task_task', $langs->trans("Task"), -1, 'projecttask', 0, '', 'reposition', 0, '', 1);
if ($action == 'clone') {
$formquestion = array(
diff --git a/htdocs/reception/card.php b/htdocs/reception/card.php
index a989e9ec06c..c3b4ccc3c24 100644
--- a/htdocs/reception/card.php
+++ b/htdocs/reception/card.php
@@ -2011,7 +2011,7 @@ if ($action == 'create' && $permissiontoadd) {
$res = $object->fetch_optionals();
$head = reception_prepare_head($object);
- print dol_get_fiche_head($head, 'reception', $langs->trans("Reception"), -1, 'dollyrevert');
+ print dol_get_fiche_head($head, 'reception', $langs->trans("Reception"), -1, 'dollyrevert', 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/htdocs/resource/card.php b/htdocs/resource/card.php
index 949ecc18a24..e202b36a827 100644
--- a/htdocs/resource/card.php
+++ b/htdocs/resource/card.php
@@ -250,7 +250,7 @@ if ($action == 'create' || $object->fetch($id, $ref) > 0) {
print dol_get_fiche_head();
} else {
$head = resource_prepare_head($object);
- print dol_get_fiche_head($head, 'resource', $title, -1, 'resource');
+ print dol_get_fiche_head($head, 'resource', $title, -1, 'resource', 0, '', '', 0, '', ($action == 'edit' ? 0 : 1));
}
if ($action == 'create' || $action == 'edit') {
diff --git a/htdocs/salaries/card.php b/htdocs/salaries/card.php
index 6f09059997b..0a3a8443e5e 100644
--- a/htdocs/salaries/card.php
+++ b/htdocs/salaries/card.php
@@ -896,7 +896,7 @@ if ($id > 0) {
print $formconfirm;
- print dol_get_fiche_head($head, 'card', $langs->trans("SalaryPayment"), -1, 'salary', 0, '', '', 0, '', 1);
+ print dol_get_fiche_head($head, 'card', $langs->trans("SalaryPayment"), -1, 'salary', 0, '', '', 0, '', ($action == 'edit' ? 0 : 1));
$linkback = ''.$langs->trans("BackToList").'';
diff --git a/htdocs/user/class/user.class.php b/htdocs/user/class/user.class.php
index 2912f18881c..7758dd9a9ee 100644
--- a/htdocs/user/class/user.class.php
+++ b/htdocs/user/class/user.class.php
@@ -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
diff --git a/htdocs/webhook/triggerhistory_card.php b/htdocs/webhook/triggerhistory_card.php
index 4cc30361323..74de6d17ebd 100644
--- a/htdocs/webhook/triggerhistory_card.php
+++ b/htdocs/webhook/triggerhistory_card.php
@@ -263,7 +263,7 @@ if (($id || $ref) && $action == 'edit') {
if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'create'))) {
$head = triggerhistoryPrepareHead($object);
- print dol_get_fiche_head($head, 'card', $langs->trans(""), -1, $object->picto, 0, '', '', 0, '', 1);
+ print dol_get_fiche_head($head, 'card', $langs->trans(""), -1, $object->picto);
$formconfirm = '';
diff --git a/htdocs/workstation/workstation_card.php b/htdocs/workstation/workstation_card.php
index f57a7dafe1d..c4e31df147e 100644
--- a/htdocs/workstation/workstation_card.php
+++ b/htdocs/workstation/workstation_card.php
@@ -312,7 +312,7 @@ if (($id || $ref) && $action == 'edit') {
if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'create'))) {
$head = workstationPrepareHead($object);
- print dol_get_fiche_head($head, 'card', $langs->trans("Workstation"), -1, $object->picto);
+ print dol_get_fiche_head($head, 'card', $langs->trans("Workstation"), -1, $object->picto, 0, '', '', 0, '', 1);
$formconfirm = '';
diff --git a/test/phpunit/AllTests.php b/test/phpunit/AllTests.php
index 370d49a0c46..424706d7cd9 100644
--- a/test/phpunit/AllTests.php
+++ b/test/phpunit/AllTests.php
@@ -206,6 +206,9 @@ class AllTests
require_once dirname(__FILE__).'/BOMTest.php';
$suite->addTestSuite('BOMTest');
+ require_once dirname(__FILE__).'/MoTest.php';
+ $suite->addTestSuite('MoTest');
+
require_once dirname(__FILE__).'/DolresourceTest.php';
$suite->addTestSuite('DolresourceTest');
@@ -239,6 +242,8 @@ class AllTests
$suite->addTestSuite('CommonInvoiceTest');
require_once dirname(__FILE__).'/FactureTest.php';
$suite->addTestSuite('FactureTest');
+ require_once dirname(__FILE__).'/PropalCommandeFactureWorkflowTest.php';
+ $suite->addTestSuite('PropalCommandeFactureWorkflowTest');
require_once dirname(__FILE__).'/FactureRecTest.php';
$suite->addTestSuite('FactureRecTest');
require_once dirname(__FILE__).'/FactureTestRounding.php';
@@ -288,6 +293,9 @@ class AllTests
require_once dirname(__FILE__).'/CategorieTest.php';
$suite->addTestSuite('CategorieTest');
+ require_once dirname(__FILE__).'/LinkTest.php';
+ $suite->addTestSuite('LinkTest');
+
require_once dirname(__FILE__).'/ProjectTest.php';
$suite->addTestSuite('ProjectTest');
require_once dirname(__FILE__).'/CommentTest.php';
diff --git a/test/phpunit/CommandeTest.php b/test/phpunit/CommandeTest.php
index c27a614fa90..3d200fde4e6 100644
--- a/test/phpunit/CommandeTest.php
+++ b/test/phpunit/CommandeTest.php
@@ -1,7 +1,7 @@
* Copyright (C) 2023 Alexandre Janniaux
- * Copyright (C) 2024-2025 Frédéric France
+ * Copyright (C) 2024-2026 Frédéric France
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -119,6 +119,12 @@ class CommandeTest extends CommonClassTest
$this->assertLessThan($result, 0);
print __METHOD__." id=".$id." result=".$result."\n";
+
+ // Specimen lines are built from real products picked at random (see Commande::initAsSpecimen), so the
+ // exact line count is not stable (a kit/BOM product can expand into extra lines) - only check totals coherence.
+ $this->assertNotEmpty($localobject->lines);
+ $this->assertLineTotalsMatchHeader($localobject, 'after fetch');
+
return $localobject;
}
@@ -147,13 +153,116 @@ class CommandeTest extends CommonClassTest
return $localobject;
}
+ /**
+ * testCommandeAddLine
+ *
+ * @param Commande $localobject Commande
+ * @return array{0:Commande,1:int} Commande and id of the line added
+ *
+ * @depends testCommandeUpdate
+ * The depends says test is run only if previous is ok
+ */
+ public function testCommandeAddLine($localobject)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $localobject->fetch_thirdparty();
+ $beforelinecount = count($localobject->lines);
+ $beforetotalht = (float) $localobject->total_ht;
+
+ $lineid = $localobject->addline('PHPUnit addline test', 100, 2, 20); // 2 x 100 HT at 20% VAT = 200 HT / 40 VAT / 240 TTC
+
+ print __METHOD__." id=".$localobject->id." lineid=".$lineid."\n";
+ $this->assertGreaterThan(0, $lineid, $localobject->errorsToString());
+
+ $localobject->fetch($localobject->id);
+ $this->assertCount($beforelinecount + 1, $localobject->lines);
+ $this->assertEqualsWithDelta($beforetotalht + 200, (float) $localobject->total_ht, 0.01, 'total_ht not updated after addline');
+ $this->assertLineTotalsMatchHeader($localobject, 'after addline');
+
+ return array($localobject, $lineid);
+ }
+
+ /**
+ * testCommandeUpdateLine
+ *
+ * @param array{0:Commande,1:int} $params Commande and id of the line to update
+ * @return array{0:Commande,1:int} Commande and id of the line updated
+ *
+ * @depends testCommandeAddLine
+ * The depends says test is run only if previous is ok
+ */
+ public function testCommandeUpdateLine($params)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ list($localobject, $lineid) = $params;
+ $beforelinecount = count($localobject->lines);
+ $beforetotalht = (float) $localobject->total_ht;
+
+ $result = $localobject->updateline($lineid, 'PHPUnit addline test', 100, 3, 0, 20); // qty 2 -> 3, so +100 HT / +20 VAT / +120 TTC
+
+ print __METHOD__." id=".$localobject->id." lineid=".$lineid." result=".$result."\n";
+ $this->assertGreaterThan(0, $result, $localobject->errorsToString());
+
+ $localobject->fetch($localobject->id);
+ $this->assertCount($beforelinecount, $localobject->lines);
+ $this->assertEqualsWithDelta($beforetotalht + 100, (float) $localobject->total_ht, 0.01, 'total_ht not updated after updateline');
+ $this->assertLineTotalsMatchHeader($localobject, 'after updateline');
+
+ return array($localobject, $lineid);
+ }
+
+ /**
+ * testCommandeDeleteLine
+ *
+ * @param array{0:Commande,1:int} $params Commande and id of the line to delete
+ * @return Commande
+ *
+ * @depends testCommandeUpdateLine
+ * The depends says test is run only if previous is ok
+ */
+ public function testCommandeDeleteLine($params)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ list($localobject, $lineid) = $params;
+ $beforelinecount = count($localobject->lines);
+ $beforetotalht = (float) $localobject->total_ht;
+
+ $result = $localobject->deleteLine($user, $lineid);
+
+ print __METHOD__." id=".$localobject->id." lineid=".$lineid." result=".$result."\n";
+ $this->assertGreaterThan(0, $result, $localobject->errorsToString());
+
+ $localobject->fetch($localobject->id);
+ // Back to the original specimen lines, with the same totals
+ $this->assertCount($beforelinecount - 1, $localobject->lines);
+ $this->assertEqualsWithDelta($beforetotalht - 300, (float) $localobject->total_ht, 0.01, 'total_ht not updated after deleteLine');
+ $this->assertLineTotalsMatchHeader($localobject, 'after deleteLine');
+
+ return $localobject;
+ }
+
/**
* testCommandeValid
*
* @param Commande $localobject Order
* @return Commande
*
- * @depends testCommandeUpdate
+ * @depends testCommandeDeleteLine
* The depends says test is run only if previous is ok
*/
public function testCommandeValid($localobject)
@@ -168,6 +277,30 @@ class CommandeTest extends CommonClassTest
print __METHOD__." id=".$localobject->id." result=".$result."\n";
$this->assertLessThan($result, 0);
+
+ // Test everything is still the same as a freshly built specimen with the same mutation applied
+ // (catches unwanted field changes introduced by update()/valid())
+ $this->assertMatchesFreshSpecimen(
+ $localobject,
+ function ($specimen) {
+ $specimen->note_private = 'New note private after update';
+ },
+ array(
+ 'newref', 'oldcopy', 'oldref', 'id', 'lines', 'line', 'client', 'thirdparty', 'brouillon', 'specimen',
+ 'fk_user_author', 'user_author_id', 'user_creation_id', 'user_modification_id',
+ 'date', 'date_commande', 'date_creation', 'date_validation', 'date_lim_reglement', 'datem', 'date_modification',
+ 'ref', 'statut', 'status', 'socid', 'billed', 'fk_incoterms', 'actiontypecode', 'actionmsg2', 'actionmsg',
+ 'mode_reglement', 'cond_reglement', 'mode_reglement_code', 'cond_reglement_code', 'availability_code', 'demand_reason_code',
+ 'cond_reglement_doc', 'modelpdf',
+ // Totals are ignored here: specimen lines reference random real products, and a kit/BOM product can
+ // expand into extra lines with a different amount - total correctness is checked by assertLineTotalsMatchHeader() instead.
+ 'total_ht', 'total_tva', 'total_ttc', 'total_localtax1', 'total_localtax2',
+ 'multicurrency_total_ht', 'multicurrency_total_tva', 'multicurrency_total_ttc', 'fk_multicurrency', 'multicurrency_code', 'multicurrency_tx',
+ 'trackid', 'user_creat', 'user_valid', 'note',
+ ),
+ array('tosell' => 1)
+ );
+
return $localobject;
}
@@ -212,11 +345,6 @@ class CommandeTest extends CommonClassTest
$langs = $this->savlangs;
$db = $this->savdb;
- /*$result=$localobject->setstatus(0);
- print __METHOD__." id=".$localobject->id." result=".$result."\n";
- $this->assertLessThan($result, 0);
- */
-
$localobject->info($localobject->id);
print __METHOD__." localobject->date_creation=".$localobject->date_creation."\n";
$this->assertNotEquals($localobject->date_creation, '');
diff --git a/test/phpunit/CommonClassTest.class.php b/test/phpunit/CommonClassTest.class.php
index 86978923406..39c8ba85a4e 100644
--- a/test/phpunit/CommonClassTest.class.php
+++ b/test/phpunit/CommonClassTest.class.php
@@ -2,7 +2,7 @@
/* Copyright (C) 2018 Laurent Destailleur
* Copyright (C) 2023 Alexandre Janniaux
* Copyright (C) 2024-2026 MDW
- * Copyright (C) 2024-2025 Frédéric France
+ * Copyright (C) 2024-2026 Frédéric France
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -382,6 +382,56 @@ abstract class CommonClassTest extends TestCase
return $retAr;
}
+ /**
+ * Assert that the sum of the persisted line totals matches the object header totals.
+ * Catches bugs where update_price() forgets a line, or a total is not recalculated after a line change.
+ *
+ * @param CommonObject $localobject Object with a ->lines array of line objects having total_ht/total_tva/total_ttc
+ * @param string $message Extra message to show on failure
+ * @return void
+ */
+ protected function assertLineTotalsMatchHeader($localobject, $message = '')
+ {
+ $sumht = 0.0;
+ $sumtva = 0.0;
+ $sumttc = 0.0;
+ foreach ($localobject->lines as $line) {
+ $sumht += (float) $line->total_ht;
+ $sumtva += (float) $line->total_tva;
+ $sumttc += (float) $line->total_ttc;
+ }
+
+ $this->assertEqualsWithDelta($sumht, (float) $localobject->total_ht, 0.01, 'total_ht does not match sum of lines. '.$message);
+ $this->assertEqualsWithDelta($sumtva, (float) $localobject->total_tva, 0.01, 'total_tva does not match sum of lines. '.$message);
+ $this->assertEqualsWithDelta($sumttc, (float) $localobject->total_ttc, 0.01, 'total_ttc does not match sum of lines. '.$message);
+ }
+
+ /**
+ * Compare $localobject against a freshly built specimen of the same class (with the same mutation applied)
+ * to detect fields unexpectedly changed by a lifecycle action such as update() or valid().
+ *
+ * @param object $localobject Object to check, already gone through create()/update()/valid()...
+ * @param callable $mutate Callback(object $specimen): void applying the same mutation that was applied to $localobject
+ * @param array $fieldstoignorearray Fields to ignore in the comparison (passed to objCompare)
+ * @param array $specimenparam Param array passed to initAsSpecimen()
+ * @return void
+ */
+ protected function assertMatchesFreshSpecimen($localobject, callable $mutate, array $fieldstoignorearray, array $specimenparam = array())
+ {
+ global $db;
+
+ $class = get_class($localobject);
+ $newlocalobject = new $class($db);
+ $newlocalobject->initAsSpecimen($specimenparam);
+ $mutate($newlocalobject);
+
+ $clonedobject = clone $localobject;
+ unset($clonedobject->array_options);
+
+ $arraywithdiff = $this->objCompare($clonedobject, $newlocalobject, true, $fieldstoignorearray);
+ $this->assertEquals(array(), $arraywithdiff, 'Found differences '.var_export($arraywithdiff, true));
+ }
+
/**
* Map deprecated module names to new module names
*/
diff --git a/test/phpunit/ContactTest.php b/test/phpunit/ContactTest.php
index e4e604b442d..833c69221cf 100644
--- a/test/phpunit/ContactTest.php
+++ b/test/phpunit/ContactTest.php
@@ -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 $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');
+ }
}
diff --git a/test/phpunit/FactureTest.php b/test/phpunit/FactureTest.php
index 6c8ed113892..96e0b225ab9 100644
--- a/test/phpunit/FactureTest.php
+++ b/test/phpunit/FactureTest.php
@@ -1,6 +1,6 @@
- * Copyright (C) 2018-2025 Frédéric France
+ * Copyright (C) 2018-2026 Frédéric France
* Copyright (C) 2023 Alexandre Janniaux
* Copyright (C) 2024 MDW
*
@@ -112,6 +112,12 @@ class FactureTest extends CommonClassTest
$this->assertLessThan($result, 0);
print __METHOD__." id=".$id." result=".$result."\n";
+
+ // Specimen lines are built from real products picked at random (see Facture::initAsSpecimen), so the
+ // exact line count is not stable (a kit/BOM product can expand into extra lines) - only check totals coherence.
+ $this->assertNotEmpty($localobject->lines);
+ $this->assertLineTotalsMatchHeader($localobject, 'after fetch');
+
return $localobject;
}
@@ -140,13 +146,116 @@ class FactureTest extends CommonClassTest
return $localobject;
}
+ /**
+ * testFactureAddLine
+ *
+ * @param Facture $localobject Invoice
+ * @return array{0:Facture,1:int} Invoice and id of the line added
+ *
+ * @depends testFactureUpdate
+ * The depends says test is run only if previous is ok
+ */
+ public function testFactureAddLine($localobject)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $localobject->fetch_thirdparty();
+ $beforelinecount = count($localobject->lines);
+ $beforetotalht = (float) $localobject->total_ht;
+
+ $lineid = $localobject->addline('PHPUnit addline test', 100, 2, 20); // 2 x 100 HT at 20% VAT = 200 HT / 40 VAT / 240 TTC
+
+ print __METHOD__." id=".$localobject->id." lineid=".$lineid."\n";
+ $this->assertGreaterThan(0, $lineid, $localobject->errorsToString());
+
+ $localobject->fetch($localobject->id);
+ $this->assertCount($beforelinecount + 1, $localobject->lines);
+ $this->assertEqualsWithDelta($beforetotalht + 200, (float) $localobject->total_ht, 0.01, 'total_ht not updated after addline');
+ $this->assertLineTotalsMatchHeader($localobject, 'after addline');
+
+ return array($localobject, $lineid);
+ }
+
+ /**
+ * testFactureUpdateLine
+ *
+ * @param array{0:Facture,1:int} $params Invoice and id of the line to update
+ * @return array{0:Facture,1:int} Invoice and id of the line updated
+ *
+ * @depends testFactureAddLine
+ * The depends says test is run only if previous is ok
+ */
+ public function testFactureUpdateLine($params)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ list($localobject, $lineid) = $params;
+ $beforelinecount = count($localobject->lines);
+ $beforetotalht = (float) $localobject->total_ht;
+
+ $result = $localobject->updateline($lineid, 'PHPUnit addline test', 100, 3, 0, '', '', 20); // qty 2 -> 3, so +100 HT / +20 VAT / +120 TTC
+
+ print __METHOD__." id=".$localobject->id." lineid=".$lineid." result=".$result."\n";
+ $this->assertGreaterThan(0, $result, $localobject->errorsToString());
+
+ $localobject->fetch($localobject->id);
+ $this->assertCount($beforelinecount, $localobject->lines);
+ $this->assertEqualsWithDelta($beforetotalht + 100, (float) $localobject->total_ht, 0.01, 'total_ht not updated after updateline');
+ $this->assertLineTotalsMatchHeader($localobject, 'after updateline');
+
+ return array($localobject, $lineid);
+ }
+
+ /**
+ * testFactureDeleteLine
+ *
+ * @param array{0:Facture,1:int} $params Invoice and id of the line to delete
+ * @return Facture
+ *
+ * @depends testFactureUpdateLine
+ * The depends says test is run only if previous is ok
+ */
+ public function testFactureDeleteLine($params)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ list($localobject, $lineid) = $params;
+ $beforelinecount = count($localobject->lines);
+ $beforetotalht = (float) $localobject->total_ht;
+
+ $result = $localobject->deleteLine($lineid);
+
+ print __METHOD__." id=".$localobject->id." lineid=".$lineid." result=".$result."\n";
+ $this->assertGreaterThan(0, $result, $localobject->errorsToString());
+
+ $localobject->fetch($localobject->id);
+ // Back to the original specimen lines, with the same totals
+ $this->assertCount($beforelinecount - 1, $localobject->lines);
+ $this->assertEqualsWithDelta($beforetotalht - 300, (float) $localobject->total_ht, 0.01, 'total_ht not updated after deleteLine');
+ $this->assertLineTotalsMatchHeader($localobject, 'after deleteLine');
+
+ return $localobject;
+ }
+
/**
* testFactureValid
*
* @param Facture $localobject Invoice
- * @return void
+ * @return Facture
*
- * @depends testFactureUpdate
+ * @depends testFactureDeleteLine
* The depends says test is run only if previous is ok
*/
public function testFactureValid($localobject)
@@ -166,33 +275,26 @@ class FactureTest extends CommonClassTest
$this->assertLessThan($result, 0);
- // Test everything is still the same as specimen
- $newlocalobject = new Facture($db);
- $newlocalobject->initAsSpecimen();
- $this->changeProperties($newlocalobject);
-
- // Hack to avoid test to be wrong when some modules are one
- unset($localobject->array_options);
- //unset($localobject->array_options['options_reseller']);
- //unset($localobject->array_options['options_reseller']);
-
- $arraywithdiff = $this->objCompare(
+ // Test everything is still the same as a freshly built specimen with the same mutation applied
+ // (catches unwanted field changes introduced by update()/validate())
+ $this->assertMatchesFreshSpecimen(
$localobject,
- $newlocalobject,
- true,
- // Not comparing:
+ function ($specimen) {
+ $this->changeProperties($specimen);
+ },
array(
- 'newref','oldcopy','oldref','id','lines','client','thirdparty','brouillon', 'fk_user_author', 'user_modification_id', 'date_creation','date_validation','datem','date_modification',
- 'ref','statut','status','paye','ref','actiontypecode','actionmsg2','actionmsg','mode_reglement','cond_reglement',
+ 'newref', 'oldcopy', 'oldref', 'id', 'lines', 'line', 'client', 'thirdparty', 'brouillon', 'fk_user_author', 'fk_user_modif', 'user_modification_id', 'date_creation', 'date_validation', 'datem', 'date_modification',
+ 'ref', 'statut', 'status', 'paye', 'ref', 'actiontypecode', 'actionmsg2', 'actionmsg', 'mode_reglement', 'cond_reglement',
'cond_reglement_doc', 'modelpdf',
- 'multicurrency_total_ht','multicurrency_total_tva', 'multicurrency_total_ttc','fk_multicurrency','multicurrency_code','multicurrency_tx',
- 'retained_warranty' ,'retained_warranty_date_limit', 'retained_warranty_fk_cond_reglement', 'specimen', 'situation_cycle_ref', 'situation_counter', 'situation_final',
- 'trackid','user_creat','user_valid', 'note'
+ // Totals are ignored here: specimen lines reference random real products, and a kit/BOM product can
+ // expand into extra lines with a different amount - total correctness is checked by assertLineTotalsMatchHeader() instead.
+ 'total_ht', 'total_tva', 'total_ttc',
+ 'multicurrency_total_ht', 'multicurrency_total_tva', 'multicurrency_total_ttc', 'fk_multicurrency', 'multicurrency_code', 'multicurrency_tx',
+ 'retained_warranty', 'retained_warranty_date_limit', 'retained_warranty_fk_cond_reglement', 'specimen', 'situation_cycle_ref', 'situation_counter', 'situation_final',
+ 'trackid', 'user_creat', 'user_valid', 'note'
)
);
- $this->assertEquals($arraywithdiff, array(), 'Found differences '.var_export($arraywithdiff, true)); // Actual, Expected. If it differs, do a var_dump($arraywithdiff) to see what differs
-
return $localobject;
}
@@ -213,11 +315,6 @@ class FactureTest extends CommonClassTest
$langs = $this->savlangs;
$db = $this->savdb;
- /*$result=$localobject->setstatus(0);
- print __METHOD__." id=".$localobject->id." result=".$result."\n";
- $this->assertLessThan($result, 0);
- */
-
$localobject->info($localobject->id);
print __METHOD__." localobject->date_creation=".$localobject->date_creation."\n";
$this->assertNotEquals($localobject->date_creation, '');
diff --git a/test/phpunit/FileUploadTest.php b/test/phpunit/FileUploadTest.php
new file mode 100644
index 00000000000..c52f4116f60
--- /dev/null
+++ b/test/phpunit/FileUploadTest.php
@@ -0,0 +1,516 @@
+.
+ * or see https://www.gnu.org/
+ */
+
+/**
+ * \file test/phpunit/FileUploadTest.php
+ * \ingroup test
+ * \brief PHPUnit test of the class FileUpload, used by the drag and drop of a file on a card.
+ * \remarks To run this script as CLI: phpunit FileUploadTest.php < /dev/null
+ * The redirection of stdin is only needed on an interactive terminal, because the tested
+ * code reads php://input when the file is not a real http upload.
+ */
+
+global $conf,$user,$langs,$db;
+require_once dirname(__FILE__).'/../../htdocs/master.inc.php';
+require_once dirname(__FILE__).'/../../htdocs/core/class/fileupload.class.php';
+require_once dirname(__FILE__).'/../../htdocs/core/lib/files.lib.php';
+require_once dirname(__FILE__).'/../../htdocs/product/class/product.class.php';
+require_once dirname(__FILE__).'/../../htdocs/societe/class/societe.class.php';
+require_once dirname(__FILE__).'/../../htdocs/contact/class/contact.class.php';
+require_once dirname(__FILE__).'/../../htdocs/projet/class/project.class.php';
+require_once dirname(__FILE__).'/../../htdocs/projet/class/task.class.php';
+require_once dirname(__FILE__).'/CommonClassTest.class.php';
+
+if (!defined('NOREQUIREUSER')) {
+ define('NOREQUIREUSER', '1');
+}
+if (!defined('NOREQUIREDB')) {
+ define('NOREQUIREDB', '1');
+}
+if (!defined('NOREQUIRESOC')) {
+ define('NOREQUIRESOC', '1');
+}
+if (!defined('NOREQUIRETRAN')) {
+ define('NOREQUIRETRAN', '1');
+}
+if (!defined('NOCSRFCHECK')) {
+ define('NOCSRFCHECK', '1');
+}
+if (!defined('NOTOKENRENEWAL')) {
+ define('NOTOKENRENEWAL', '1');
+}
+if (!defined('NOREQUIREMENU')) {
+ define('NOREQUIREMENU', '1');
+}
+if (!defined('NOREQUIREHTML')) {
+ define('NOREQUIREHTML', '1');
+}
+if (!defined('NOREQUIREAJAX')) {
+ define('NOREQUIREAJAX', '1');
+}
+if (!defined("NOLOGIN")) {
+ define("NOLOGIN", '1');
+}
+
+/**
+ * Class for PHPUnit tests of FileUpload
+ *
+ * @backupGlobals disabled
+ * @backupStaticAttributes enabled
+ * @remarks backupGlobals must be disabled to have db,conf,user and lang not erased.
+ */
+class FileUploadTest extends CommonClassTest
+{
+ /**
+ * Directories created by the tests, removed at the end
+ * @var string[]
+ */
+ protected static $dirstoclean = array();
+
+ /**
+ * Temporary files created by the tests, removed at the end
+ * @var string[]
+ */
+ protected static $filestoclean = array();
+
+ /**
+ * Objects of the fixture, removed by the rollback of the transaction
+ * @var array
+ */
+ protected static $objects = array();
+
+
+ /**
+ * setUpBeforeClass
+ *
+ * @return void
+ */
+ public static function setUpBeforeClass(): void
+ {
+ global $conf, $db, $user;
+
+ parent::setUpBeforeClass();
+
+ $conf->global->MAIN_DISABLE_SUGGEST_REF_AS_PREFIX = 0;
+
+ // A thirdparty
+ $thirdparty = new Societe($db);
+ $thirdparty->initAsSpecimen();
+ $thirdparty->name = 'Test FileUpload';
+ $thirdparty->country_id = 1;
+ $id = $thirdparty->create($user);
+ if ($id <= 0) {
+ die("Failed to create the thirdparty: ".$thirdparty->errorsToString()."\n");
+ }
+ $thirdparty->fetch($id);
+ self::$objects['societe'] = $thirdparty;
+
+ // A contact of this thirdparty
+ $contact = new Contact($db);
+ $contact->lastname = 'Doe';
+ $contact->firstname = 'John';
+ $contact->socid = $thirdparty->id;
+ $contact->country_id = 1;
+ $id = $contact->create($user);
+ if ($id <= 0) {
+ die("Failed to create the contact: ".$contact->errorsToString()."\n");
+ }
+ $contact->fetch($id);
+ self::$objects['contact'] = $contact;
+
+ // A product
+ $product = new Product($db);
+ $product->ref = 'PRODUCT-FILEUPLOAD-TEST';
+ $product->label = 'Test FileUpload';
+ $product->type = Product::TYPE_PRODUCT;
+ $id = $product->create($user);
+ if ($id <= 0) {
+ die("Failed to create the product: ".$product->errorsToString()."\n");
+ }
+ $product->fetch($id);
+ self::$objects['product'] = $product;
+
+ // A project whose ref holds the chars a path must not keep, and a task of this project
+ $project = new Project($db);
+ $project->ref = 'PJ/UP:é 2026';
+ $project->title = 'Test FileUpload';
+ $project->socid = $thirdparty->id;
+ $id = $project->create($user);
+ if ($id <= 0) {
+ die("Failed to create the project: ".$project->errorsToString()."\n");
+ }
+ $project->fetch($id);
+ self::$objects['project'] = $project;
+
+ $task = new Task($db);
+ $task->ref = 'TASK-FILEUPLOAD-TEST';
+ $task->label = 'Test FileUpload';
+ $task->fk_project = $project->id;
+ $id = $task->create($user);
+ if ($id <= 0) {
+ die("Failed to create the task: ".$task->errorsToString()."\n");
+ }
+ $task->fetch($id);
+ self::$objects['project_task'] = $task;
+ }
+
+ /**
+ * tearDownAfterClass
+ *
+ * @return void
+ */
+ public static function tearDownAfterClass(): void
+ {
+ foreach (self::$dirstoclean as $dir) {
+ if (is_dir($dir)) {
+ dol_delete_dir_recursive($dir);
+ }
+ }
+ foreach (self::$filestoclean as $file) {
+ @unlink($file);
+ }
+
+ // The objects created by setUpBeforeClass() are removed by the rollback of the parent, which
+ // closes the transaction opened by its own setUpBeforeClass().
+ parent::tearDownAfterClass();
+ }
+
+ /**
+ * Build a FileUpload for an object and return its upload directory.
+ *
+ * @param string $element Element code
+ * @param int $id Id of the object
+ * @return string Upload directory
+ */
+ protected function uploadDirOf($element, $id)
+ {
+ $upload = new FileUpload(null, $id, $element);
+ $this->assertIsArray($upload->options);
+ $this->assertArrayHasKey('upload_dir', $upload->options);
+ return $upload->options['upload_dir'];
+ }
+
+
+ //
+ // Resolution of the directory where the file is stored
+ //
+
+ /**
+ * The directory of a product must be the one read by product/document.php, otherwise the file is
+ * stored but never shown into the "Attached files" tab.
+ *
+ * @return void
+ */
+ public function testUploadDirOfAProduct()
+ {
+ global $conf;
+
+ $object = self::$objects['product'];
+
+ // The formula of product/document.php, written here on purpose so the test does not use the
+ // code it checks to build its expectation.
+ $expected = $conf->product->multidir_output[$object->entity].'/'.dol_sanitizeFileName($object->ref).'/';
+
+ $this->assertSame($expected, $this->uploadDirOf('product', $object->id));
+ }
+
+ /**
+ * A thirdparty stores its documents into a directory named after its id, not its ref.
+ *
+ * @return void
+ */
+ public function testUploadDirOfAThirdpartyUsesTheId()
+ {
+ global $conf;
+
+ $object = self::$objects['societe'];
+
+ // The formula of societe/document.php
+ $expected = $conf->societe->multidir_output[$object->entity].'/'.$object->id.'/';
+
+ $this->assertSame($expected, $this->uploadDirOf('societe', $object->id));
+ $this->assertStringNotContainsString(dol_sanitizeFileName($object->name), $this->uploadDirOf('societe', $object->id), 'The name of a thirdparty must never appear into its directory');
+ }
+
+ /**
+ * A contact is stored into a sub directory of the thirdparty module. getMultidirOutput() does not know
+ * this element and answers its sentinel, so this asserts the fallback on getElementProperties().
+ *
+ * @return void
+ */
+ public function testUploadDirOfAContactFallsBackOnGetElementProperties()
+ {
+ global $conf;
+
+ $object = self::$objects['contact'];
+
+ $this->assertStringStartsWith('error-diroutput-not-defined-for-this-object=', (string) getMultidirOutput($object, 'contact'), 'The fixture requires getMultidirOutput to fail on a contact');
+
+ // The formula of contact/document.php
+ $expected = $conf->societe->multidir_output[$object->entity].'/contact/'.dol_sanitizeFileName($object->ref).'/';
+
+ $this->assertSame($expected, $this->uploadDirOf('contact', $object->id));
+ }
+
+ /**
+ * A task is stored into a sub directory named after the ref of its project. That ref is a user input,
+ * so it must be sanitized the same way projet/tasks/document.php does it.
+ *
+ * @return void
+ */
+ public function testUploadDirOfATaskSanitizesTheProjectRef()
+ {
+ global $conf;
+
+ $task = self::$objects['project_task'];
+ $project = self::$objects['project'];
+
+ $this->assertStringContainsString('/', $project->ref, 'The fixture requires a project ref holding a slash');
+
+ // The formula of projet/tasks/document.php
+ $expected = $conf->project->multidir_output[$project->entity].'/'.dol_sanitizeFileName($project->ref).'/'.dol_sanitizeFileName($task->ref).'/';
+
+ $dir = $this->uploadDirOf('project_task', $task->id);
+
+ $this->assertSame($expected, $dir);
+ $this->assertStringNotContainsString('PJ/UP', $dir, 'The slash of the project ref must not create a sub directory');
+ $this->assertStringNotContainsString(':', $dir, 'A colon of the project ref must not be kept');
+ }
+
+ /**
+ * Whatever the element, the directory is absolute, inside DOL_DATA_ROOT, and never holds the sentinel
+ * string returned by getMultidirOutput() when it fails.
+ *
+ * @return void
+ */
+ public function testUploadDirIsNeverTheSentinelOfGetMultidirOutput()
+ {
+ foreach (array('product', 'societe', 'contact', 'project_task') as $element) {
+ $dir = $this->uploadDirOf($element, self::$objects[$element]->id);
+
+ $this->assertStringNotContainsString('error-diroutput-not-defined-for-this-object', $dir, 'The sentinel leaked into the directory of the element '.$element);
+ $this->assertStringStartsWith(DOL_DATA_ROOT, $dir, 'The directory of the element '.$element.' must be inside DOL_DATA_ROOT');
+ $this->assertStringNotContainsString('//', $dir, 'The directory of the element '.$element.' must not hold a double slash');
+ $this->assertStringEndsWith('/', $dir, 'The directory must end with a slash, the file name is concatenated to it');
+ }
+ }
+
+ /**
+ * The file name is prefixed by the ref of the object, and that ref is sanitized because it is a user
+ * input that may hold a slash.
+ *
+ * @return void
+ */
+ public function testSavingDocMaskIsSanitized()
+ {
+ $project = self::$objects['project'];
+
+ $upload = new FileUpload(null, $project->id, 'project');
+
+ $this->assertSame(dol_sanitizeFileName($project->ref).'-__file__', $upload->options['saving_doc_mask']);
+ $this->assertStringNotContainsString('/', $upload->options['saving_doc_mask'], 'A file name mask must never hold a slash');
+ }
+
+
+ //
+ // Refusals
+ //
+
+ /**
+ * An object that does not exist must be refused, otherwise the file is stored at the root of the
+ * directory of the module, out of any object and out of any permission check.
+ *
+ * @return void
+ */
+ public function testConstructRefusesAnObjectThatDoesNotExist()
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('objectnotfound');
+
+ new FileUpload(null, 99999999, 'product');
+ }
+
+ /**
+ * An id of 0 must be refused too, it would answer the directory of every unsaved object.
+ *
+ * @return void
+ */
+ public function testConstructRefusesAnEmptyId()
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('objectnotfound');
+
+ new FileUpload(null, 0, 'product');
+ }
+
+ /**
+ * An element with no directory at all must be refused instead of writing anywhere.
+ *
+ * @return void
+ */
+ public function testConstructRefusesAnUnknownElement()
+ {
+ $this->expectException(Exception::class);
+
+ new FileUpload(null, 1, 'anelementthatdoesnotexist');
+ }
+
+
+ //
+ // Deduplication of the name of the uploaded file
+ //
+
+
+ /**
+ * Build a FileUpload for an object of the fixture and empty its upload directory, so the tests below
+ * start from a known set of already used names.
+ *
+ * @param string $element Element code of the fixture
+ * @return FileUpload Instance ready to use
+ */
+ protected function prepareUploadDir($element)
+ {
+ $object = self::$objects[$element];
+ $upload = new FileUpload(null, $object->id, $element);
+ $dir = $upload->options['upload_dir'];
+
+ if (is_dir($dir)) {
+ dol_delete_dir_recursive($dir);
+ }
+ dol_mkdir($dir);
+ self::$dirstoclean[$dir] = $dir;
+
+ return $upload;
+ }
+
+ /**
+ * Create the temporary file playing the role of the file uploaded by the browser.
+ *
+ * @return string Path of the file
+ */
+ protected function makeTmpFile()
+ {
+ $tmpfile = DOL_DATA_ROOT.'/admin/temp/fileuploadtest-'.getmypid().'.txt';
+ dol_mkdir(dirname($tmpfile));
+ file_put_contents($tmpfile, 'content');
+ self::$filestoclean[$tmpfile] = $tmpfile;
+
+ return $tmpfile;
+ }
+
+ /**
+ * Call the protected handleFileUpload() of FileUpload.
+ *
+ * @param FileUpload $upload Instance
+ * @param string $tmpfile Path of the file to upload
+ * @param string $name Name sent by the browser
+ * @return stdClass The file object answered by handleFileUpload()
+ */
+ protected function callHandleFileUpload($upload, $tmpfile, $name)
+ {
+ $method = new ReflectionMethod('FileUpload', 'handleFileUpload');
+ $method->setAccessible(true);
+
+ // validate() reads CONTENT_LENGTH when the file is not a real http upload
+ $_SERVER['CONTENT_LENGTH'] = 10;
+
+ return $method->invoke($upload, $tmpfile, $name, 0, 'text/plain', 0, 0);
+ }
+
+ /**
+ * A name that is free must be kept as it is, only prefixed by the ref of the object.
+ *
+ * @return void
+ */
+ public function testUploadOfAFreeNameIsKept()
+ {
+ $upload = $this->prepareUploadDir('product');
+ $prefix = dol_sanitizeFileName(self::$objects['product']->ref).'-';
+
+ $file = $this->callHandleFileUpload($upload, $this->makeTmpFile(), 'afreename.txt');
+
+ $this->assertSame($prefix.'afreename.txt', $file->name, 'A free name must be kept, only the prefix of the ref is added');
+ }
+
+ /**
+ * Uploading twice the same file must not overwrite the first one: dol_move_uploaded_file() is called
+ * with $allowoverwrite = 1, so the name must be made unique before.
+ * trimFileName() already does that check, but on the name before the prefix of the ref is added, so
+ * it compares a name that is not the one stored.
+ *
+ * @return void
+ */
+ public function testUploadOfAnAlreadyExistingNameIsRenamed()
+ {
+ $upload = $this->prepareUploadDir('product');
+ $dir = $upload->options['upload_dir'];
+ $prefix = dol_sanitizeFileName(self::$objects['product']->ref).'-';
+
+ // The name is already used, with the prefix that trimFileName() does not know about
+ file_put_contents($dir.$prefix.'mydoc.txt', 'first');
+
+ $file = $this->callHandleFileUpload($upload, $this->makeTmpFile(), 'mydoc.txt');
+
+ $this->assertSame($prefix.'mydoc (1).txt', $file->name, 'The name must be made unique, the first file must not be overwritten');
+ $this->assertSame('first', file_get_contents($dir.$prefix.'mydoc.txt'), 'The first file must be untouched');
+ $this->assertFileExists($dir.$file->name, 'The new file must be stored under its new name');
+ }
+
+ /**
+ * The renaming must be repeated as long as the name is used, so a third upload gets a third name.
+ *
+ * @return void
+ */
+ public function testUploadOfAnAlreadyExistingNameIsRenamedAgain()
+ {
+ $upload = $this->prepareUploadDir('product');
+ $dir = $upload->options['upload_dir'];
+ $prefix = dol_sanitizeFileName(self::$objects['product']->ref).'-';
+
+ file_put_contents($dir.$prefix.'mydoc.txt', 'first');
+ file_put_contents($dir.$prefix.'mydoc (1).txt', 'second');
+
+ $file = $this->callHandleFileUpload($upload, $this->makeTmpFile(), 'mydoc.txt');
+
+ $this->assertSame($prefix.'mydoc (2).txt', $file->name);
+ $this->assertSame('first', file_get_contents($dir.$prefix.'mydoc.txt'));
+ $this->assertSame('second', file_get_contents($dir.$prefix.'mydoc (1).txt'));
+ }
+
+ /**
+ * A file whose name is executable is stored by dol_move_uploaded_file() with a '.noexe' suffix added.
+ * The check on an already used name must look for that suffixed name too, otherwise such a file is
+ * silently overwritten at each upload.
+ *
+ * @return void
+ */
+ public function testUploadOfAnAlreadyExistingNoexeNameIsRenamed()
+ {
+ $upload = $this->prepareUploadDir('product');
+ $dir = $upload->options['upload_dir'];
+ $prefix = dol_sanitizeFileName(self::$objects['product']->ref).'-';
+
+ // A previous upload of the same file was renamed with the .noexe suffix
+ file_put_contents($dir.$prefix.'myscript.php.noexe', 'first');
+
+ $file = $this->callHandleFileUpload($upload, $this->makeTmpFile(), 'myscript.php');
+
+ $this->assertStringStartsWith($prefix.'myscript (1).php', $file->name, 'A name already used with the .noexe suffix must be made unique too');
+ $this->assertSame('first', file_get_contents($dir.$prefix.'myscript.php.noexe'), 'The first file must be untouched');
+ }
+}
diff --git a/test/phpunit/FunctionsLibDragDropTest.php b/test/phpunit/FunctionsLibDragDropTest.php
new file mode 100644
index 00000000000..33dae26d9f2
--- /dev/null
+++ b/test/phpunit/FunctionsLibDragDropTest.php
@@ -0,0 +1,482 @@
+.
+ * or see https://www.gnu.org/
+ */
+
+/**
+ * \file test/phpunit/FunctionsLibDragDropTest.php
+ * \ingroup test
+ * \brief PHPUnit test of the directory resolution used by the drag and drop of a file on a card.
+ * \remarks To run this script as CLI: phpunit FunctionsLibDragDropTest.php
+ */
+
+global $conf,$user,$langs,$db;
+require_once dirname(__FILE__).'/../../htdocs/master.inc.php';
+require_once dirname(__FILE__).'/../../htdocs/product/class/product.class.php';
+require_once dirname(__FILE__).'/../../htdocs/societe/class/societe.class.php';
+require_once dirname(__FILE__).'/../../htdocs/contact/class/contact.class.php';
+require_once dirname(__FILE__).'/CommonClassTest.class.php';
+
+if (!defined('NOREQUIREUSER')) {
+ define('NOREQUIREUSER', '1');
+}
+if (!defined('NOREQUIREDB')) {
+ define('NOREQUIREDB', '1');
+}
+if (!defined('NOREQUIRESOC')) {
+ define('NOREQUIRESOC', '1');
+}
+if (!defined('NOREQUIRETRAN')) {
+ define('NOREQUIRETRAN', '1');
+}
+if (!defined('NOCSRFCHECK')) {
+ define('NOCSRFCHECK', '1');
+}
+if (!defined('NOTOKENRENEWAL')) {
+ define('NOTOKENRENEWAL', '1');
+}
+if (!defined('NOREQUIREMENU')) {
+ define('NOREQUIREMENU', '1');
+}
+if (!defined('NOREQUIREHTML')) {
+ define('NOREQUIREHTML', '1');
+}
+if (!defined('NOREQUIREAJAX')) {
+ define('NOREQUIREAJAX', '1');
+}
+if (!defined("NOLOGIN")) {
+ define("NOLOGIN", '1');
+}
+
+/**
+ * Class for PHPUnit tests of the directory resolution used by the drag and drop of a file on a card
+ *
+ * @backupGlobals disabled
+ * @backupStaticAttributes enabled
+ * @remarks backupGlobals must be disabled to have db,conf,user and lang not erased.
+ */
+class FunctionsLibDragDropTest extends CommonClassTest
+{
+ /**
+ * The string returned by getMultidirOutput() when it does not know the module of the object.
+ * It is a relative path, so writing into it creates files under the web root.
+ */
+ const SENTINEL = 'error-diroutput-not-defined-for-this-object=';
+
+ /**
+ * The elements equipped with the drag and drop of a file on their card by this work.
+ * Their 'dir_output' must always be usable to forge an absolute path.
+ *
+ * @return array>
+ */
+ public static function providerEquippedElements()
+ {
+ $elements = array(
+ 'contact', 'product', 'societe', 'action', 'expedition', 'reception', 'don', 'expensereport',
+ 'holiday', 'mo', 'partnership', 'stocktransfer', 'productlot', 'resource', 'workstation',
+ 'job', 'position', 'skill', 'evaluation', 'knowledgerecord', 'conferenceorbooth', 'asset',
+ 'payment', 'payment_supplier', 'payment_various', 'salary', 'chargesociales', 'project_task',
+ );
+ $out = array();
+ foreach ($elements as $element) {
+ $out[$element] = array($element);
+ }
+ return $out;
+ }
+
+
+ //
+ // getMultidirOutput()
+ //
+
+ /**
+ * getMultidirOutput() answers a sentinel string, not an empty string, when it does not know the module.
+ * Every caller must reject it, so the contract is asserted here once.
+ *
+ * @return void
+ */
+ public function testGetMultidirOutputReturnsARelativeSentinelOnFailure()
+ {
+ $object = new stdClass();
+ $object->element = 'anelementthatdoesnotexist';
+ $object->id = 1;
+ $object->entity = 1;
+
+ $dir = getMultidirOutput($object, 'anelementthatdoesnotexist');
+
+ $this->assertSame(self::SENTINEL.'anelementthatdoesnotexist', $dir, 'The failure of getMultidirOutput must be reported by the sentinel string');
+ $this->assertStringStartsNotWith('/', $dir, 'The sentinel is a relative path, so it must never be used to forge a path');
+
+ // The 'temp' mode has its own sentinel
+ $this->assertSame('error-dirtemp-not-defined-for-this-object=anelementthatdoesnotexist', getMultidirTemp($object, 'anelementthatdoesnotexist'));
+
+ // And a bad mode has a third one
+ $this->assertSame('error-bad-value-for-mode', getMultidirOutput($object, 'anelementthatdoesnotexist', 0, 'notamode'));
+ }
+
+ /**
+ * The sentinel is the answer for a majority of the elements equipped with the drag and drop, so any
+ * caller that forges a path from getMultidirOutput() alone is broken. This test documents the list.
+ *
+ * @return void
+ */
+ public function testGetMultidirOutputIsUnableToResolveMostEquippedElements()
+ {
+ $nbresolved = 0;
+ $nbsentinel = 0;
+ foreach (self::providerEquippedElements() as $row) {
+ $element = $row[0];
+ if ($element == 'project_task') {
+ continue; // Needs a real object, it calls fetchProject()
+ }
+ $object = new stdClass();
+ $object->element = $element;
+ $object->id = 1;
+ $object->entity = 1;
+ $dir = getMultidirOutput($object, $element);
+ if (strpos((string) $dir, self::SENTINEL) === 0) {
+ $nbsentinel++;
+ } else {
+ $nbresolved++;
+ $this->assertStringStartsWith(DOL_DATA_ROOT, (string) $dir, 'A resolved directory must be inside DOL_DATA_ROOT for the element '.$element);
+ }
+ }
+ $this->assertGreaterThan(0, $nbsentinel, 'getMultidirOutput is expected to fail on some equipped elements, the fallback on getElementProperties is required');
+ $this->assertGreaterThan(0, $nbresolved, 'getMultidirOutput is expected to resolve some equipped elements');
+ }
+
+ /**
+ * A module with a directory for the current entity only must not answer an undefined index (so a path
+ * relative to the web root) when the object belongs to another entity.
+ *
+ * @return void
+ */
+ public function testGetMultidirOutputFallsBackOnTheCurrentEntity()
+ {
+ global $conf;
+
+ $object = new Product($conf->db);
+ $object->id = 1;
+ $object->ref = 'AREF';
+ $object->entity = 99; // No directory is declared for this entity
+
+ $this->assertArrayNotHasKey(99, $conf->product->multidir_output, 'The fixture requires no directory declared for the entity 99');
+
+ $dir = getMultidirOutput($object, 'product');
+
+ $this->assertSame($conf->product->multidir_output[$conf->entity], $dir, 'The directory of the current entity must be used as a fallback');
+ $this->assertStringStartsWith(DOL_DATA_ROOT, $dir, 'The fallback must not answer a path relative to the web root');
+
+ // Same fallback for the temporary directory
+ $dirtemp = getMultidirTemp($object, 'product');
+ $this->assertStringStartsWith(DOL_DATA_ROOT, $dirtemp, 'The temporary directory must not be relative either');
+ }
+
+ /**
+ * The subdirectory of a partnership and of a stock transfer must be appended, because it is the one
+ * read by their "Attached files" tab (see partnership_document.php and stocktransfer_document.php).
+ *
+ * @return void
+ */
+ public function testGetMultidirOutputAppendsTheSubDirectoryOfTheTab()
+ {
+ global $conf;
+
+ $expected = array(
+ 'partnership' => '/partnership/partnership',
+ 'stocktransfer' => '/stocktransfer/stocktransfer',
+ 'knowledgerecord' => '/knowledgemanagement/knowledgerecord',
+ 'expedition' => '/expedition/sending',
+ );
+ $asserted = 0;
+ foreach ($expected as $element => $suffix) {
+ if (!isset($conf->{explode('/', ltrim($suffix, '/'))[0]})) {
+ continue;
+ }
+ $object = new stdClass();
+ $object->element = $element;
+ $object->id = 1;
+ $object->entity = $conf->entity;
+ $dir = getMultidirOutput($object, $element);
+ if (strpos((string) $dir, self::SENTINEL) === 0) {
+ continue; // Module not enabled on this instance
+ }
+ $this->assertSame(DOL_DATA_ROOT.$suffix, $dir, 'Wrong directory for the element '.$element);
+ $asserted++;
+ }
+
+ if (!$asserted) {
+ // None of the modules of the elements above is enabled, so the test asserted nothing. Say it
+ // instead of reporting a green test that checked nothing.
+ $this->markTestSkipped('None of the modules partnership, stocktransfer, knowledgemanagement and expedition is enabled');
+ }
+ }
+
+
+ //
+ // get_exdir()
+ //
+
+ /**
+ * get_exdir() is the reference implementation used both by the "Attached files" tabs and by FileUpload
+ * to forge the directory of an object. Its fallbacks must be asserted, they are load bearing.
+ *
+ * @return void
+ */
+ public function testGetExdirFallbacks()
+ {
+ global $db;
+
+ $object = new Product($db);
+ $object->id = 42;
+ $object->ref = 'MYREF';
+
+ // Nominal case: the ref is used
+ $this->assertSame('MYREF', get_exdir(0, 0, 0, 1, $object, 'product'));
+
+ // The trailing slash is added when $withoutslash is 0. FileUpload appends its own '/', so it must
+ // call get_exdir() with $withoutslash = 1 to avoid a double slash into the path.
+ $this->assertSame('MYREF/', get_exdir(0, 0, 0, 0, $object, 'product'));
+
+ // The id is used as a fallback when the ref is empty (a draft object with no numbering yet)
+ $object->ref = '';
+ $this->assertSame('42', get_exdir(0, 0, 0, 1, $object, 'product'), 'The id must be used when the ref is empty');
+ $object->ref = null;
+ $this->assertSame('42', get_exdir(0, 0, 0, 1, $object, 'product'), 'The id must be used when the ref is null');
+ $object->ref = '0';
+ $this->assertSame('42', get_exdir(0, 0, 0, 1, $object, 'product'), 'A ref "0" is empty for php, the id is used');
+
+ // An object with neither a ref nor an id gives the directory '0', because the id is cast to an int
+ // and then to a string. This is a shared directory for every unsaved object, so a caller must never
+ // forge a path from an object it did not load: FileUpload throws 'objectnotfound' before this point.
+ $empty = new Product($db);
+ $empty->id = 0;
+ $empty->ref = '';
+ $this->assertSame('0', get_exdir(0, 0, 0, 1, $empty, 'product'), 'An object with no id and no ref falls back on the directory "0"');
+
+ // The ref is a user input, it must be sanitized: no directory traversal, no separator
+ $object->ref = '../../etc';
+ $this->assertStringNotContainsString('..', get_exdir(0, 0, 0, 1, $object, 'product'), 'A ref must never allow a directory traversal');
+ $object->ref = 'A/B';
+ $this->assertStringNotContainsString('/', get_exdir(0, 0, 0, 1, $object, 'product'), 'A ref must never introduce a sub directory');
+
+ // The modulepart is read from the object when it is not given
+ $object->ref = 'MYREF';
+ $this->assertSame('MYREF', get_exdir(0, 0, 0, 1, $object), 'The modulepart must be deduced from the object');
+ }
+
+ /**
+ * A thirdparty stores its documents into a directory named after its id, because its ref is a company
+ * name and two thirdparties may share the same name.
+ *
+ * @return void
+ */
+ public function testGetExdirUsesTheIdForAThirdparty()
+ {
+ global $db;
+
+ $thirdparty = new Societe($db);
+ $thirdparty->id = 7;
+ $thirdparty->ref = 'My company';
+
+ $this->assertSame('7', get_exdir(0, 0, 0, 1, $thirdparty, 'societe'), 'The id must be used for a thirdparty');
+ $this->assertSame('7', get_exdir(0, 0, 0, 1, $thirdparty, 'thirdparty'), 'The id must be used for a thirdparty');
+
+ // The rule is on the class, not only on the modulepart: a contact of the module 'societe' keeps its ref
+ $contact = new Contact($db);
+ $contact->id = 8;
+ $contact->ref = 'DOE';
+ $this->assertSame('DOE', get_exdir(0, 0, 0, 1, $contact, 'contact'), 'A contact is not a thirdparty, its ref is used');
+ }
+
+ /**
+ * A module storing its documents on 2 levels answers the level directories only, so the caller must
+ * append the directory of the object itself.
+ *
+ * @return void
+ */
+ public function testGetExdirForATwoLevelsModule()
+ {
+ global $db;
+
+ $object = new Product($db); // Any object, only the id and the modulepart matter here
+ $object->id = 42;
+ $object->ref = 'MYREF';
+
+ $this->assertSame('2/4', get_exdir(0, 0, 0, 1, $object, 'invoice_supplier'), 'Two levels of directories are expected');
+ $this->assertSame('2/4', get_exdir(0, 0, 0, 1, $object, 'supplier_invoice'), 'The two aliases must answer the same directory');
+ $this->assertSame('2/4/', get_exdir(0, 0, 0, 0, $object, 'invoice_supplier'));
+
+ // The levels are built from the id, not from the ref
+ $object->id = 1234;
+ $this->assertSame('4/3', get_exdir(0, 0, 0, 1, $object, 'invoice_supplier'));
+ }
+
+
+ //
+ // getElementProperties()
+ //
+
+ /**
+ * The elements added or fixed by this work must answer the class that is really able to load them.
+ *
+ * @return void
+ */
+ public function testGetElementPropertiesOfTheNewElements()
+ {
+ $expected = array(
+ // element => array(module, classname, classpath, classfile, table_element)
+ 'payment' => array('facture', 'Paiement', 'compta/paiement/class', 'paiement', 'paiement'),
+ 'payment_supplier' => array('fournisseur', 'PaiementFourn', 'fourn/class', 'paiementfourn', 'paiementfourn'),
+ 'payment_various' => array('bank', 'PaymentVarious', 'compta/bank/class', 'paymentvarious', 'payment_various'),
+ 'stocktransfer' => array('stocktransfer', 'StockTransfer', 'product/stock/stocktransfer/class', 'stocktransfer', 'stocktransfer_stocktransfer'),
+ 'job' => array('hrm', 'Job', 'hrm/class', 'job', 'hrm_job'),
+ 'position' => array('hrm', 'Position', 'hrm/class', 'position', 'hrm_job_user'),
+ 'skill' => array('hrm', 'Skill', 'hrm/class', 'skill', 'hrm_skill'),
+ 'evaluation' => array('hrm', 'Evaluation', 'hrm/class', 'evaluation', 'hrm_evaluation'),
+ );
+
+ foreach ($expected as $element => $values) {
+ $prop = getElementProperties($element);
+
+ $this->assertSame($element, $prop['element'], 'The element '.$element.' must not be rewritten by the myobject_mysubobject rule');
+ $this->assertSame($values[0], $prop['module'], 'Wrong module for the element '.$element);
+ $this->assertSame($values[1], $prop['classname'], 'Wrong classname for the element '.$element);
+ $this->assertSame($values[2], $prop['classpath'], 'Wrong classpath for the element '.$element);
+ $this->assertSame($values[3], $prop['classfile'], 'Wrong classfile for the element '.$element);
+ $this->assertSame($values[4], $prop['table_element'], 'Wrong table for the element '.$element);
+
+ // The class must really exist, otherwise fetchObjectByElement() ends on a fatal error
+ $file = DOL_DOCUMENT_ROOT.'/'.$prop['classpath'].'/'.$prop['classfile'].'.class.php';
+ $this->assertFileExists($file, 'The class file of the element '.$element.' does not exist');
+ require_once $file;
+ $this->assertTrue(class_exists($prop['classname']), 'The class '.$prop['classname'].' of the element '.$element.' does not exist');
+
+ // And the table must exist too, restrictedArea() builds its sql on it.
+ // The table of an optional module is only created when the module is enabled, so the check is
+ // skipped otherwise: the mapping asserted above does not depend on the module being enabled.
+ if (isModEnabled($values[0])) {
+ $this->assertGreaterThan(0, $this->countTable($prop['table_element']), 'The table '.$prop['table_element'].' of the element '.$element.' is not readable');
+ }
+ }
+ }
+
+ /**
+ * Count the rows of a table, only to assert the table exists.
+ *
+ * @param string $table Table name without the prefix
+ * @return int -1 if the table does not exist, 1 otherwise
+ */
+ protected function countTable($table)
+ {
+ global $db;
+
+ $sql = "SELECT COUNT(*) as nb FROM ".$db->prefix().$db->escape($table);
+ $resql = $db->query($sql);
+ if (!$resql) {
+ return -1;
+ }
+ $db->free($resql);
+ return 1;
+ }
+
+ /**
+ * The 'dir_output' of an element must be the directory read by its "Attached files" tab, otherwise a
+ * file uploaded by drag and drop is stored but never shown to the user.
+ *
+ * @return void
+ */
+ public function testGetElementPropertiesDirOutputSubDirectory()
+ {
+ global $conf;
+
+ $expected = array(
+ 'contact' => array('societe', '/societe/contact', '/societe/temp/contact'),
+ 'job' => array('hrm', '/hrm/job', '/hrm/temp/job'),
+ 'position' => array('hrm', '/hrm/position', '/hrm/temp/position'),
+ 'skill' => array('hrm', '/hrm/skill', '/hrm/temp/skill'),
+ 'evaluation' => array('hrm', '/hrm/evaluation', '/hrm/temp/evaluation'),
+ 'conferenceorbooth' => array('eventorganization', '/eventorganization/conferenceorbooth', '/eventorganization/temp/conferenceorbooth'),
+ );
+ foreach ($expected as $element => $values) {
+ if (!isset($conf->{$values[0]})) {
+ $this->markTestSkipped('The module '.$values[0].' is not enabled, the fixture is not usable');
+ }
+ $prop = getElementProperties($element);
+ $this->assertSame(DOL_DATA_ROOT.$values[1], $prop['dir_output'], 'Wrong dir_output for the element '.$element);
+ $this->assertSame(DOL_DATA_ROOT.$values[2], $prop['dir_temp'], 'Wrong dir_temp for the element '.$element.', the sub directory applies to it too');
+ }
+ }
+
+ /**
+ * When the module of an element is disabled, its directory must be an empty string and NOT the sub
+ * directory alone: a caller would then read or write into '/contact', at the root of the file system.
+ * This is the regression the guard on $dir_output prevents, and it can only be seen with a module off.
+ *
+ * @return void
+ */
+ public function testGetElementPropertiesDirOutputWhenTheModuleIsDisabled()
+ {
+ global $conf;
+
+ $elements = array('contact' => 'societe', 'job' => 'hrm', 'position' => 'hrm', 'skill' => 'hrm',
+ 'evaluation' => 'hrm', 'conferenceorbooth' => 'eventorganization');
+
+ foreach ($elements as $element => $module) {
+ $savconfmodule = isset($conf->$module) ? $conf->$module : null;
+ $savmodules = $conf->modules;
+
+ // Simulate the module being disabled: no entry into $conf and no entry into $conf->modules
+ unset($conf->$module);
+ unset($conf->modules[$module]);
+
+ try {
+ $prop = getElementProperties($element);
+
+ $this->assertSame('', $prop['dir_output'], 'The dir_output of the element '.$element.' must be empty when the module '.$module.' is disabled, not the sub directory alone');
+ $this->assertSame('', $prop['dir_temp'], 'The dir_temp of the element '.$element.' must be empty when the module '.$module.' is disabled');
+ } finally {
+ if ($savconfmodule !== null) {
+ $conf->$module = $savconfmodule;
+ }
+ $conf->modules = $savmodules;
+ }
+ }
+ }
+
+ /**
+ * The directory of any equipped element is either empty or inside DOL_DATA_ROOT. It is never a path
+ * at the root of the file system, and never the sentinel of getMultidirOutput().
+ *
+ * @dataProvider providerEquippedElements
+ *
+ * @param string $element Element to check
+ * @return void
+ */
+ public function testGetElementPropertiesDirOutputIsAlwaysSafe($element)
+ {
+ $prop = getElementProperties($element);
+
+ foreach (array('dir_output', 'dir_temp') as $key) {
+ $dir = (string) $prop[$key];
+ $this->assertStringNotContainsString(self::SENTINEL, $dir, 'The '.$key.' of the element '.$element.' must never be the sentinel');
+ $this->assertTrue(
+ $dir === '' || strpos($dir, DOL_DATA_ROOT) === 0,
+ 'The '.$key.' of the element '.$element.' must be empty or inside DOL_DATA_ROOT, got "'.$dir.'"'
+ );
+ }
+ }
+}
diff --git a/test/phpunit/FunctionsLibTest.php b/test/phpunit/FunctionsLibTest.php
index c586d238936..c76ce2e67f5 100644
--- a/test/phpunit/FunctionsLibTest.php
+++ b/test/phpunit/FunctionsLibTest.php
@@ -33,6 +33,8 @@ global $conf,$user,$langs,$db,$mysoc;
require_once dirname(__FILE__).'/../../htdocs/master.inc.php';
require_once dirname(__FILE__).'/../../htdocs/core/lib/date.lib.php';
require_once dirname(__FILE__).'/../../htdocs/product/class/product.class.php';
+require_once dirname(__FILE__).'/../../htdocs/societe/class/societe.class.php';
+require_once dirname(__FILE__).'/../../htdocs/contact/class/contact.class.php';
require_once dirname(__FILE__).'/CommonClassTest.class.php';
if (! defined('NOREQUIREUSER')) {
@@ -109,6 +111,212 @@ class FunctionsLibTest extends CommonClassTest
}
+ /**
+ * testGetExdirForObject
+ *
+ * get_exdir() with $level = 0 and $withoutslash = 1 is the reference implementation used to forge the
+ * directory where the documents of an object are stored. FileUpload (the drag and drop of a file on a
+ * card) relies on it to store the file into the directory read by the "Attached files" tab.
+ *
+ * @return void
+ */
+ public function testGetExdirForObject()
+ {
+ global $conf, $db;
+
+ // PRODUCT_USE_OLD_PATH_FOR_PHOTO makes get_exdir() answer a two level path built on the id for a
+ // product, instead of the ref. The assertions below are on the default path, so the option is forced
+ // off here: it is a global, another test of the suite may have left it on, and the whole suite runs in
+ // a single process. The option gets its own coverage at the end of this test.
+ $savoldpath = getDolGlobalInt('PRODUCT_USE_OLD_PATH_FOR_PHOTO');
+ $conf->global->PRODUCT_USE_OLD_PATH_FOR_PHOTO = 0;
+
+ // The ref is used when it is defined
+ $object = new Product($db);
+ $object->id = 42;
+ $object->ref = 'MYREF';
+ $this->assertSame('MYREF', get_exdir(0, 0, 0, 1, $object, 'product'), 'The ref must be used when it is defined');
+
+ // The trailing slash is added when $withoutslash is 0. FileUpload appends its own '/' to the
+ // result, so it must call get_exdir() with $withoutslash = 1 to avoid a double slash in the path.
+ $this->assertSame('MYREF/', get_exdir(0, 0, 0, 0, $object, 'product'), 'A trailing slash is expected when $withoutslash is 0');
+
+ // The modulepart is deduced from the object when it is not given
+ $this->assertSame('MYREF', get_exdir(0, 0, 0, 1, $object), 'The modulepart must be deduced from the object');
+
+ // The ref is a user input: it must never introduce a directory traversal nor a sub directory
+ $object->ref = '../../etc';
+ $this->assertStringNotContainsString('..', get_exdir(0, 0, 0, 1, $object, 'product'), 'A ref must never allow a directory traversal');
+ $object->ref = 'A/B';
+ $this->assertStringNotContainsString('/', get_exdir(0, 0, 0, 1, $object, 'product'), 'A ref must never introduce a sub directory');
+
+ // The id is used as a fallback when the ref is empty
+ $object->ref = '';
+ $this->assertSame('42', get_exdir(0, 0, 0, 1, $object, 'product'), 'The id must be used when the ref is empty');
+ $object->ref = null;
+ $this->assertSame('42', get_exdir(0, 0, 0, 1, $object, 'product'), 'The id must be used when the ref is null');
+ $object->ref = '0';
+ $this->assertSame('42', get_exdir(0, 0, 0, 1, $object, 'product'), 'A ref "0" is empty for php, so the id is used');
+
+ // An object with neither a ref nor an id falls back on the directory '0', shared by every unsaved
+ // object. A caller must never forge a path from an object it did not load.
+ $empty = new Product($db);
+ $empty->id = 0;
+ $empty->ref = '';
+ $this->assertSame('0', get_exdir(0, 0, 0, 1, $empty, 'product'), 'An object with no id and no ref falls back on the directory "0"');
+
+ // The id is always used for a thirdparty, because its ref is a company name, so it is not unique
+ $thirdparty = new Societe($db);
+ $thirdparty->id = 7;
+ $thirdparty->ref = 'My company';
+ $this->assertSame('7', get_exdir(0, 0, 0, 1, $thirdparty, 'societe'), 'The id must be used for a thirdparty');
+ $this->assertSame('7', get_exdir(0, 0, 0, 1, $thirdparty, 'thirdparty'), 'The id must be used for a thirdparty');
+
+ // The rule is on the class, not only on the modulepart: a contact belongs to the module 'societe'
+ // but it is not a Societe, so it keeps its ref
+ $contact = new Contact($db);
+ $contact->id = 8;
+ $contact->ref = 'DOE';
+ $this->assertSame('DOE', get_exdir(0, 0, 0, 1, $contact, 'contact'), 'A contact is not a thirdparty, its ref is used');
+
+ // A module storing its documents on several levels returns the level directories only, not the object
+ $object->ref = 'MYREF';
+ $this->assertSame('2/4', get_exdir(0, 0, 0, 1, $object, 'invoice_supplier'), 'Two levels of directories are expected');
+ $this->assertSame('2/4', get_exdir(0, 0, 0, 1, $object, 'supplier_invoice'), 'The two aliases must answer the same directory');
+ $this->assertSame('2/4/', get_exdir(0, 0, 0, 0, $object, 'invoice_supplier'), 'A trailing slash is expected when $withoutslash is 0');
+
+ // The levels are built from the id, not from the ref
+ $object->id = 1234;
+ $this->assertSame('4/3', get_exdir(0, 0, 0, 1, $object, 'invoice_supplier'), 'The levels must be built from the id');
+
+ // With PRODUCT_USE_OLD_PATH_FOR_PHOTO a product joins the modules storing on two levels, so its
+ // directory is built on the id and the ref is ignored. This is the path the "Attached files" tab of a
+ // product reads on such an instance, so FileUpload must forge the same one.
+ $conf->global->PRODUCT_USE_OLD_PATH_FOR_PHOTO = 1;
+ $object->id = 42;
+ $object->ref = 'MYREF';
+ $this->assertSame('2/4', get_exdir(0, 0, 0, 1, $object, 'product'), 'With the old path option a product uses two levels built on its id');
+
+ $conf->global->PRODUCT_USE_OLD_PATH_FOR_PHOTO = $savoldpath;
+ }
+
+ /**
+ * testGetElementPropertiesDirOutput
+ *
+ * The 'dir_output' returned for an element must be the directory read by the "Attached files" tab of
+ * this element, otherwise a file uploaded by drag and drop is stored but never shown to the user.
+ *
+ * @return void
+ */
+ public function testGetElementPropertiesDirOutput()
+ {
+ global $conf, $db;
+
+ // A contact is stored into a sub directory of the thirdparty module, see contact/document.php.
+ // The exact value is asserted and not only the suffix: an assertion on the suffix alone would also
+ // pass on the value '/contact' returned when the module is disabled, which is the bug guarded here.
+ if (!isModEnabled('societe')) {
+ $this->markTestSkipped('The module societe must be enabled to check the directory of a contact');
+ }
+ $prop = getElementProperties('contact');
+ $this->assertSame('societe', $prop['module']);
+ $this->assertSame($conf->societe->multidir_output[$conf->entity].'/contact', $prop['dir_output'], 'A contact is stored into a /contact sub directory');
+ $this->assertSame($conf->societe->multidir_temp[$conf->entity].'/contact', $prop['dir_temp'], 'The sub directory applies to the temporary directory too');
+
+ // The elements added or fixed here must answer the class that is really able to load them, and the
+ // table restrictedArea() builds its sql on.
+ $expected = array(
+ // element => array(module, classname, classpath, classfile, table_element)
+ 'payment' => array('facture', 'Paiement', 'compta/paiement/class', 'paiement', 'paiement'),
+ 'payment_supplier' => array('fournisseur', 'PaiementFourn', 'fourn/class', 'paiementfourn', 'paiementfourn'),
+ 'payment_various' => array('bank', 'PaymentVarious', 'compta/bank/class', 'paymentvarious', 'payment_various'),
+ 'stocktransfer' => array('stocktransfer', 'StockTransfer', 'product/stock/stocktransfer/class', 'stocktransfer', 'stocktransfer_stocktransfer'),
+ 'job' => array('hrm', 'Job', 'hrm/class', 'job', 'hrm_job'),
+ 'position' => array('hrm', 'Position', 'hrm/class', 'position', 'hrm_job_user'),
+ 'skill' => array('hrm', 'Skill', 'hrm/class', 'skill', 'hrm_skill'),
+ 'evaluation' => array('hrm', 'Evaluation', 'hrm/class', 'evaluation', 'hrm_evaluation'),
+ );
+ foreach ($expected as $element => $values) {
+ $prop = getElementProperties($element);
+
+ $this->assertSame($element, $prop['element'], 'The element '.$element.' must not be truncated by the myobject_mysubobject rule');
+ $this->assertSame($values[0], $prop['module'], 'Wrong module for the element '.$element);
+ $this->assertSame($values[1], $prop['classname'], 'Wrong classname for the element '.$element);
+ $this->assertSame($values[2], $prop['classpath'], 'Wrong classpath for the element '.$element);
+ $this->assertSame($values[3], $prop['classfile'], 'Wrong classfile for the element '.$element);
+ $this->assertSame($values[4], $prop['table_element'], 'Wrong table for the element '.$element);
+
+ // The class must really exist, otherwise fetchObjectByElement() ends on a fatal error
+ $file = DOL_DOCUMENT_ROOT.'/'.$prop['classpath'].'/'.$prop['classfile'].'.class.php';
+ $this->assertFileExists($file, 'The class file of the element '.$element.' does not exist');
+ require_once $file;
+ $this->assertTrue(class_exists($prop['classname']), 'The class '.$prop['classname'].' of the element '.$element.' does not exist');
+
+ // And the table must exist too, restrictedArea() builds its sql on it.
+ // The table of an optional module is only created when the module is enabled, so the check is
+ // skipped otherwise: the mapping asserted above does not depend on the module being enabled.
+ if (isModEnabled($values[0])) {
+ $sql = "SELECT COUNT(*) as nb FROM ".$db->prefix().$db->escape($values[4]);
+ $resql = $db->query($sql);
+ $this->assertNotFalse($resql, 'The table '.$values[4].' of the element '.$element.' is not readable');
+ if ($resql) {
+ $db->free($resql);
+ }
+ }
+ }
+
+ // The elements of the hrm module are all stored into a sub directory named after the element
+ if (isModEnabled('hrm')) {
+ foreach (array('job', 'position', 'skill', 'evaluation') as $element) {
+ $prop = getElementProperties($element);
+ $this->assertSame($conf->hrm->dir_output.'/'.$element, $prop['dir_output'], 'The element '.$element.' is stored into a /'.$element.' sub directory');
+ $this->assertSame($conf->hrm->dir_temp.'/'.$element, $prop['dir_temp'], 'The sub directory applies to the temporary directory of '.$element.' too');
+ }
+ }
+
+ // The sub directory must not be appended when the module is disabled, otherwise we would return a
+ // path at the root of the file system (for example '/contact') instead of an empty string. This is
+ // only observable with the module really off, so it is simulated here.
+ $elements = array('contact' => 'societe', 'job' => 'hrm', 'position' => 'hrm', 'skill' => 'hrm',
+ 'evaluation' => 'hrm', 'conferenceorbooth' => 'eventorganization');
+ foreach ($elements as $element => $module) {
+ $savconfmodule = isset($conf->$module) ? $conf->$module : null;
+ $savmodules = $conf->modules;
+
+ unset($conf->$module);
+ unset($conf->modules[$module]);
+
+ try {
+ $prop = getElementProperties($element);
+ $this->assertSame('', $prop['dir_output'], 'The dir_output of the element '.$element.' must be empty when the module '.$module.' is disabled, not the sub directory alone');
+ $this->assertSame('', $prop['dir_temp'], 'The dir_temp of the element '.$element.' must be empty when the module '.$module.' is disabled');
+ } finally {
+ if ($savconfmodule !== null) {
+ $conf->$module = $savconfmodule;
+ }
+ $conf->modules = $savmodules;
+ }
+ }
+
+ // So the directory of an element is either empty, or a directory of the data directory of Dolibarr,
+ // and never the sentinel string returned by getMultidirOutput() when it fails.
+ $allelements = array('contact', 'job', 'position', 'skill', 'evaluation', 'conferenceorbooth',
+ 'partnership', 'stocktransfer', 'payment', 'payment_supplier', 'payment_various', 'product',
+ 'societe', 'action', 'expedition', 'reception', 'don', 'expensereport', 'holiday', 'mo',
+ 'productlot', 'resource', 'workstation', 'knowledgerecord', 'asset', 'salary', 'chargesociales');
+ foreach ($allelements as $element) {
+ $prop = getElementProperties($element);
+ foreach (array('dir_output', 'dir_temp') as $key) {
+ $dir = (string) $prop[$key];
+ $this->assertStringNotContainsString('error-diroutput-not-defined-for-this-object', $dir, 'The '.$key.' of the element '.$element.' must never be the sentinel of getMultidirOutput');
+ $this->assertTrue(
+ $dir === '' || strpos($dir, DOL_DATA_ROOT) === 0,
+ 'The '.$key.' of the element '.$element.' must be empty or inside DOL_DATA_ROOT, got "'.$dir.'"'
+ );
+ }
+ }
+ }
+
/**
* testDolCheckFilters
*
diff --git a/test/phpunit/LinkTest.php b/test/phpunit/LinkTest.php
new file mode 100644
index 00000000000..5ddb9327067
--- /dev/null
+++ b/test/phpunit/LinkTest.php
@@ -0,0 +1,267 @@
+
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ * or see https://www.gnu.org/
+ */
+
+/**
+ * \file test/phpunit/LinkTest.php
+ * \ingroup test
+ * \brief PHPUnit test
+ * \remarks To run this script as CLI: phpunit filename.php
+ */
+
+global $conf,$user,$langs,$db;
+//define('TEST_DB_FORCE_TYPE','mysql'); // This is to force using mysql driver
+//require_once 'PHPUnit/Autoload.php';
+require_once dirname(__FILE__).'/../../htdocs/master.inc.php';
+require_once dirname(__FILE__).'/../../htdocs/core/class/link.class.php';
+require_once dirname(__FILE__).'/CommonClassTest.class.php';
+
+if (empty($user->id)) {
+ print "Load permissions for admin user nb 1\n";
+ $user->fetch(1);
+ $user->loadRights();
+}
+$conf->global->MAIN_DISABLE_ALL_MAILS = 1;
+
+
+/**
+ * Class for PHPUnit tests
+ *
+ * @backupGlobals disabled
+ * @backupStaticAttributes enabled
+ * @remarks backupGlobals must be disabled to have db,conf,user and lang not erased.
+ */
+class LinkTest extends CommonClassTest
+{
+ /**
+ * testLinkCreate
+ *
+ * @return int
+ */
+ public function testLinkCreate()
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $localobject = new Link($db);
+ $localobject->url = 'https://www.dolibarr.org';
+ $localobject->label = 'Specimen link';
+ $localobject->objecttype = 'societe';
+ $localobject->objectid = 1;
+
+ $result = $localobject->create($user);
+ print __METHOD__." result=".$result."\n";
+ $this->assertGreaterThan(0, $result);
+
+ return $result;
+ }
+
+ /**
+ * testLinkCreateWithoutUrl
+ *
+ * A link with no url must be rejected.
+ *
+ * @return void
+ *
+ * @depends testLinkCreate
+ * The depends says test is run only if previous is ok
+ */
+ public function testLinkCreateWithoutUrl()
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $localobject = new Link($db);
+ $localobject->objecttype = 'societe';
+ $localobject->objectid = 1;
+
+ $result = $localobject->create($user);
+ print __METHOD__." result=".$result."\n";
+ $this->assertLessThan(0, $result);
+ }
+
+ /**
+ * testLinkFetch
+ *
+ * @param int $id Id of link
+ * @return Link
+ *
+ * @depends testLinkCreate
+ * The depends says test is run only if previous is ok
+ */
+ public function testLinkFetch($id)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $localobject = new Link($db);
+ $result = $localobject->fetch($id);
+
+ print __METHOD__." id=".$id." result=".$result."\n";
+ $this->assertGreaterThan(0, $result);
+ $this->assertEquals('https://www.dolibarr.org', $localobject->url);
+ $this->assertEquals('Specimen link', $localobject->label);
+
+ return $localobject;
+ }
+
+ /**
+ * testLinkFetchWithoutParameters
+ *
+ * A fetch() with neither rowid nor hashforshare must fail instead of
+ * returning an arbitrary record.
+ *
+ * @return void
+ *
+ * @depends testLinkCreate
+ * The depends says test is run only if previous is ok
+ */
+ public function testLinkFetchWithoutParameters()
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $localobject = new Link($db);
+ $result = $localobject->fetch(0);
+
+ print __METHOD__." result=".$result."\n";
+ $this->assertLessThan(0, $result);
+ }
+
+ /**
+ * testLinkUpdate
+ *
+ * @param Link $localobject Link
+ * @return Link
+ *
+ * @depends testLinkFetch
+ * The depends says test is run only if previous is ok
+ */
+ public function testLinkUpdate($localobject)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $localobject->label = 'Specimen link updated';
+ $result = $localobject->update($user);
+
+ print __METHOD__." id=".$localobject->id." result=".$result."\n";
+ $this->assertGreaterThan(0, $result);
+
+ $localobject->fetch($localobject->id);
+ $this->assertEquals('Specimen link updated', $localobject->label);
+
+ return $localobject;
+ }
+
+ /**
+ * testLinkFetchAll
+ *
+ * @param Link $localobject Link
+ * @return Link
+ *
+ * @depends testLinkUpdate
+ * The depends says test is run only if previous is ok
+ */
+ public function testLinkFetchAll($localobject)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $links = array();
+ $tmpobject = new Link($db);
+ $result = $tmpobject->fetchAll($links, $localobject->objecttype, $localobject->objectid);
+
+ print __METHOD__." result=".$result."\n";
+ $this->assertGreaterThan(0, $result);
+ $this->assertGreaterThan(0, count($links));
+
+ return $localobject;
+ }
+
+ /**
+ * testLinkCount
+ *
+ * @param Link $localobject Link
+ * @return Link
+ *
+ * @depends testLinkFetchAll
+ * The depends says test is run only if previous is ok
+ */
+ public function testLinkCount($localobject)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $nb = Link::count($db, $localobject->objecttype, $localobject->objectid);
+
+ print __METHOD__." nb=".$nb."\n";
+ $this->assertGreaterThan(0, $nb);
+
+ return $localobject;
+ }
+
+ /**
+ * testLinkDelete
+ *
+ * @param Link $localobject Link
+ * @return int
+ *
+ * @depends testLinkCount
+ * The depends says test is run only if previous is ok
+ */
+ public function testLinkDelete($localobject)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $result = $localobject->delete($user);
+
+ print __METHOD__." id=".$localobject->id." result=".$result."\n";
+ $this->assertGreaterThan(0, $result);
+
+ $resultFetch = $localobject->fetch($localobject->id);
+ print __METHOD__." resultFetch=".$resultFetch."\n";
+ $this->assertEquals(0, $resultFetch);
+
+ return $result;
+ }
+}
diff --git a/test/phpunit/MoTest.php b/test/phpunit/MoTest.php
new file mode 100644
index 00000000000..a2b335bc7f4
--- /dev/null
+++ b/test/phpunit/MoTest.php
@@ -0,0 +1,267 @@
+
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ * or see https://www.gnu.org/
+ */
+
+/**
+ * \file test/phpunit/MoTest.php
+ * \ingroup test
+ * \brief PHPUnit test
+ * \remarks To run this script as CLI: phpunit filename.php
+ */
+
+global $conf,$user,$langs,$db;
+require_once dirname(__FILE__).'/../../htdocs/master.inc.php';
+require_once dirname(__FILE__).'/../../htdocs/mrp/class/mo.class.php';
+require_once dirname(__FILE__).'/../../htdocs/product/class/product.class.php';
+require_once dirname(__FILE__).'/CommonClassTest.class.php';
+
+if (empty($user->id)) {
+ print "Load permissions for admin user nb 1\n";
+ $user->fetch(1);
+ $user->loadRights();
+}
+$conf->global->MAIN_DISABLE_ALL_MAILS = 1;
+
+
+/**
+ * Class for PHPUnit tests
+ *
+ * @backupGlobals disabled
+ * @backupStaticAttributes enabled
+ * @remarks backupGlobals must be disabled to have db,conf,user and lang not erased.
+ */
+class MoTest extends CommonClassTest
+{
+ /**
+ * testMoCreate
+ *
+ * Mo::create() needs a real product (fk_product): a random real catalog product cannot be used
+ * here, a kit/BOM product would be rejected by create() unless ALLOW_USE_KITS_INTO_BOM_AND_MO is
+ * set (see Mo::create()) - use a freshly created, plain (non-kit) specimen product instead.
+ *
+ * @return int
+ */
+ public function testMoCreate()
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $product = new Product($db);
+ $product->initAsSpecimen();
+ $productid = $product->create($user);
+ $this->assertGreaterThan(0, $productid, $product->errorsToString());
+
+ $localobject = new Mo($db);
+ $localobject->initAsSpecimen();
+ // initAsSpecimen() sets a fixed ref ('ABCD1234'), but a real Mo is created with the '(PROV)'
+ // placeholder so createCommon() assigns it a real provisional ref ("(PROVid)") - use that here
+ // so the ref-renumbering on validate() tested below reflects real usage.
+ $localobject->ref = '(PROV)';
+ $localobject->fk_product = $productid;
+ $localobject->qty = 5;
+ $result = $localobject->create($user);
+
+ $this->assertGreaterThan(0, $result, $localobject->errorsToString());
+ print __METHOD__." result=".$result." fk_product=".$productid."\n";
+
+ // create() must have auto-created the "to produce" line for the finished product itself
+ // (no BOM is set here, so there is nothing to consume)
+ $localobject->fetch($result);
+ $toproduce = $localobject->fetchLinesLinked('toproduce');
+ $this->assertCount(1, $toproduce);
+ $this->assertEquals($productid, $toproduce[0]['fk_product']);
+ $this->assertEqualsWithDelta(5.0, (float) $toproduce[0]['qty'], 0.00001);
+ $this->assertCount(0, $localobject->fetchLinesLinked('toconsume'));
+
+ return $result;
+ }
+
+ /**
+ * testMoFetch
+ *
+ * @param int $id Id of object
+ * @return Mo
+ *
+ * @depends testMoCreate
+ * The depends says test is run only if previous is ok
+ */
+ public function testMoFetch($id)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $localobject = new Mo($db);
+ $result = $localobject->fetch($id);
+
+ $this->assertGreaterThan(0, $result, $localobject->errorsToString());
+ print __METHOD__." id=".$id." result=".$result."\n";
+ $this->assertEqualsWithDelta(5.0, (float) $localobject->qty, 0.00001);
+ $this->assertEquals(Mo::STATUS_DRAFT, $localobject->status);
+ $this->assertMatchesRegularExpression('/^\(?PROV/i', (string) $localobject->ref, 'A not yet validated Mo must have a provisional ref');
+
+ return $localobject;
+ }
+
+ /**
+ * testMoUpdate
+ *
+ * @param Mo $localobject Mo
+ * @return Mo
+ *
+ * @depends testMoFetch
+ * The depends says test is run only if previous is ok
+ */
+ public function testMoUpdate($localobject)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $localobject->label = 'Updated label after update';
+ $localobject->note_private = 'New note private after update';
+ $result = $localobject->update($user);
+
+ $this->assertGreaterThan(0, $result, $localobject->errorsToString());
+ print __METHOD__." id=".$localobject->id." result=".$result."\n";
+
+ $localobject->fetch($localobject->id);
+ $this->assertSame('Updated label after update', $localobject->label);
+ $this->assertSame('New note private after update', $localobject->note_private);
+
+ return $localobject;
+ }
+
+ /**
+ * testMoValidate
+ *
+ * @param Mo $localobject Mo
+ * @return Mo
+ *
+ * @depends testMoUpdate
+ * The depends says test is run only if previous is ok
+ */
+ public function testMoValidate($localobject)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $oldref = $localobject->ref;
+ $result = $localobject->validate($user);
+
+ $this->assertEquals(1, $result, $localobject->errorsToString());
+ print __METHOD__." id=".$localobject->id." result=".$result." ref=".$localobject->ref."\n";
+
+ $this->assertEquals(Mo::STATUS_VALIDATED, $localobject->status);
+ $this->assertNotEquals($oldref, $localobject->ref, 'validate() must replace the provisional ref with a definitive one');
+ $this->assertNotRegExp('/^\(?PROV/i', $localobject->ref);
+
+ return $localobject;
+ }
+
+ /**
+ * testMoCancel
+ *
+ * @param Mo $localobject Mo
+ * @return Mo
+ *
+ * @depends testMoValidate
+ * The depends says test is run only if previous is ok
+ */
+ public function testMoCancel($localobject)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $result = $localobject->cancel($user);
+
+ $this->assertGreaterThan(0, $result, $localobject->errorsToString());
+ print __METHOD__." id=".$localobject->id." result=".$result."\n";
+
+ $localobject->fetch($localobject->id);
+ $this->assertEquals(Mo::STATUS_CANCELED, $localobject->status);
+
+ return $localobject;
+ }
+
+ /**
+ * testMoReopen
+ *
+ * @param Mo $localobject Mo
+ * @return int
+ *
+ * @depends testMoCancel
+ * The depends says test is run only if previous is ok
+ */
+ public function testMoReopen($localobject)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $result = $localobject->reopen($user);
+
+ $this->assertGreaterThan(0, $result, $localobject->errorsToString());
+ print __METHOD__." id=".$localobject->id." result=".$result."\n";
+
+ $localobject->fetch($localobject->id);
+ $this->assertEquals(Mo::STATUS_VALIDATED, $localobject->status);
+
+ return $localobject->id;
+ }
+
+ /**
+ * testMoDelete
+ *
+ * @param int $id Id of object
+ * @return int
+ *
+ * @depends testMoReopen
+ * The depends says test is run only if previous is ok
+ */
+ public function testMoDelete($id)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $localobject = new Mo($db);
+ $result = $localobject->fetch($id);
+ $result = $localobject->delete($user);
+
+ $this->assertGreaterThan(0, $result, $localobject->errorsToString());
+ print __METHOD__." id=".$id." result=".$result."\n";
+ return $result;
+ }
+}
diff --git a/test/phpunit/PropalCommandeFactureWorkflowTest.php b/test/phpunit/PropalCommandeFactureWorkflowTest.php
new file mode 100644
index 00000000000..6844d8a48cb
--- /dev/null
+++ b/test/phpunit/PropalCommandeFactureWorkflowTest.php
@@ -0,0 +1,222 @@
+
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ * or see https://www.gnu.org/
+ */
+
+/**
+ * \file test/phpunit/PropalCommandeFactureWorkflowTest.php
+ * \ingroup test
+ * \brief PHPUnit test of the Propal -> Commande -> Facture business workflow
+ * \remarks To run this script as CLI: phpunit filename.php
+ */
+
+global $conf,$user,$langs,$db;
+require_once dirname(__FILE__).'/../../htdocs/master.inc.php';
+require_once dirname(__FILE__).'/../../htdocs/comm/propal/class/propal.class.php';
+require_once dirname(__FILE__).'/../../htdocs/commande/class/commande.class.php';
+require_once dirname(__FILE__).'/../../htdocs/compta/facture/class/facture.class.php';
+require_once dirname(__FILE__).'/CommonClassTest.class.php';
+
+if (empty($user->id)) {
+ print "Load permissions for admin user nb 1\n";
+ $user->fetch(1);
+ $user->loadRights();
+}
+$conf->global->MAIN_DISABLE_ALL_MAILS = 1;
+
+
+/**
+ * Tests that a customer proposal, once converted into an order and then into an invoice,
+ * carries its thirdparty, lines, totals and notes correctly through the chain, and that
+ * each object is linked back to the one it was created from.
+ *
+ * @backupGlobals disabled
+ * @backupStaticAttributes enabled
+ * @remarks backupGlobals must be disabled to have db,conf,user and lang not erased.
+ */
+class PropalCommandeFactureWorkflowTest extends CommonClassTest
+{
+ /**
+ * setUpBeforeClass
+ *
+ * @return void
+ */
+ public static function setUpBeforeClass(): void
+ {
+ self::assertTrue(isModEnabled('propal'), 'module commercial proposal must be enabled');
+ self::assertTrue(isModEnabled('order'), 'module customer order must be enabled');
+ self::assertTrue(isModEnabled('invoice'), 'module customer invoice must be enabled');
+ parent::setUpBeforeClass();
+ }
+
+ /**
+ * testWorkflowCreateAndValidatePropal
+ *
+ * Entry point of the chain: create a thirdparty and a proposal for it, then validate the proposal.
+ *
+ * @return Propal
+ */
+ public function testWorkflowCreateAndValidatePropal()
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $soc = new Societe($db);
+ $soc->name = 'PropalCommandeFactureWorkflowTest Unittest';
+ $socid = $soc->create($user);
+ $this->assertGreaterThan(0, $socid, $soc->errorsToString());
+
+ // Use initAsSpecimen() only for realistic header defaults (payment terms, availability, ...).
+ // Its lines reference random real products, which can be kits/BOMs that silently expand into extra
+ // lines on each create()/createFrom...() down the chain: replace them with controlled, product-less
+ // lines so the totals asserted at each step of the chain are deterministic.
+ $propal = new Propal($db);
+ $propal->initAsSpecimen(array('tosell' => 1));
+ $propal->lines = array();
+ $propal->total_ht = 0;
+ $propal->total_tva = 0;
+ $propal->total_ttc = 0;
+ $propal->socid = $socid;
+ $propal->note_public = 'PHPUnit workflow test note (public)';
+ $propal->note_private = 'PHPUnit workflow test note (private)';
+ $result = $propal->create($user);
+ $this->assertGreaterThan(0, $result, $propal->errorsToString());
+
+ $lineid1 = $propal->addline('Workflow line A', 100, 2, 20); // 200 HT / 40 VAT / 240 TTC
+ $this->assertGreaterThan(0, $lineid1, $propal->errorsToString());
+ $lineid2 = $propal->addline('Workflow line B', 50, 3, 10); // 150 HT / 15 VAT / 165 TTC
+ $this->assertGreaterThan(0, $lineid2, $propal->errorsToString());
+
+ $propal->fetch($propal->id);
+ $this->assertEqualsWithDelta(350.0, (float) $propal->total_ht, 0.01, 'total_ht of the 2 controlled lines');
+
+ $result = $propal->valid($user);
+ $this->assertGreaterThan(0, $result, $propal->errorsToString());
+
+ $propal->fetch($propal->id);
+ $this->assertEquals(Propal::STATUS_VALIDATED, $propal->status);
+ $this->assertLineTotalsMatchHeader($propal, 'on validated proposal');
+
+ return $propal;
+ }
+
+ /**
+ * testWorkflowCreateOrderFromPropal
+ *
+ * @param Propal $propal Proposal validated by the previous test
+ * @return Commande
+ *
+ * @depends testWorkflowCreateAndValidatePropal
+ * The depends says test is run only if previous is ok
+ */
+ public function testWorkflowCreateOrderFromPropal($propal)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $commande = new Commande($db);
+ $result = $commande->createFromProposal($propal, $user);
+
+ print __METHOD__." propalid=".$propal->id." result=".$result."\n";
+ $this->assertGreaterThan(0, $result, $commande->errorsToString());
+
+ $commande->fetch($result);
+
+ $this->assertEquals($propal->socid, $commande->socid, 'Order thirdparty must match proposal thirdparty');
+ $this->assertEquals($propal->note_public, $commande->note_public, 'Order public note must be propagated from proposal');
+ $this->assertEquals($propal->note_private, $commande->note_private, 'Order private note must be propagated from proposal');
+ $this->assertCount(count($propal->lines), $commande->lines, 'Order must have the same number of lines as the proposal');
+ $this->assertEqualsWithDelta((float) $propal->total_ht, (float) $commande->total_ht, 0.01, 'Order total_ht must match proposal total_ht');
+ $this->assertEqualsWithDelta((float) $propal->total_tva, (float) $commande->total_tva, 0.01, 'Order total_tva must match proposal total_tva');
+ $this->assertEqualsWithDelta((float) $propal->total_ttc, (float) $commande->total_ttc, 0.01, 'Order total_ttc must match proposal total_ttc');
+ $this->assertLineTotalsMatchHeader($commande, 'on order created from proposal');
+
+ // The order must be linked back to its originating proposal (llx_element_element)
+ $commande->fetchObjectLinked();
+ $this->assertArrayHasKey('propal', $commande->linkedObjectsIds, 'Order must be linked to its originating proposal');
+ $this->assertContains($propal->id, $commande->linkedObjectsIds['propal']);
+
+ $result = $commande->valid($user);
+ print __METHOD__." id=".$commande->id." valid result=".$result."\n";
+ $this->assertGreaterThan(0, $result, $commande->errorsToString());
+
+ $commande->fetch($commande->id);
+ $this->assertEquals(Commande::STATUS_VALIDATED, $commande->status);
+
+ return $commande;
+ }
+
+ /**
+ * testWorkflowCreateInvoiceFromOrder
+ *
+ * @param Commande $commande Order validated by the previous test
+ * @return Facture
+ *
+ * @depends testWorkflowCreateOrderFromPropal
+ * The depends says test is run only if previous is ok
+ */
+ public function testWorkflowCreateInvoiceFromOrder($commande)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ $facture = new Facture($db);
+ $result = $facture->createFromOrder($commande, $user);
+
+ // createFromOrder() returns a status flag (1/-1), not the new invoice id (unlike
+ // Commande::createFromProposal()) - the created invoice's id must be read from $facture->id.
+ print __METHOD__." commandeid=".$commande->id." result=".$result."\n";
+ $this->assertGreaterThan(0, $result, $facture->errorsToString());
+
+ $facture->fetch($facture->id);
+
+ $this->assertEquals($commande->socid, $facture->socid, 'Invoice thirdparty must match order thirdparty');
+ $this->assertEquals($commande->note_public, $facture->note_public, 'Invoice public note must be propagated from order');
+ $this->assertEquals($commande->note_private, $facture->note_private, 'Invoice private note must be propagated from order');
+ $this->assertCount(count($commande->lines), $facture->lines, 'Invoice must have the same number of lines as the order');
+ $this->assertEqualsWithDelta((float) $commande->total_ht, (float) $facture->total_ht, 0.01, 'Invoice total_ht must match order total_ht');
+ $this->assertEqualsWithDelta((float) $commande->total_tva, (float) $facture->total_tva, 0.01, 'Invoice total_tva must match order total_tva');
+ $this->assertEqualsWithDelta((float) $commande->total_ttc, (float) $facture->total_ttc, 0.01, 'Invoice total_ttc must match order total_ttc');
+ $this->assertLineTotalsMatchHeader($facture, 'on invoice created from order');
+
+ // The invoice must be linked back to its originating order (llx_element_element)
+ $facture->fetchObjectLinked();
+ $this->assertArrayHasKey('commande', $facture->linkedObjectsIds, 'Invoice must be linked to its originating order');
+ $this->assertContains($commande->id, $facture->linkedObjectsIds['commande']);
+
+ // Force to default setup, same as FactureTest::testFactureValid
+ $conf->global->FAC_FORCE_DATE_VALIDATION = 0;
+ $conf->global->INVOICE_CHECK_POSTERIOR_DATE = 0;
+
+ $result = $facture->validate($user);
+ print __METHOD__." id=".$facture->id." validate result=".$result."\n";
+ $this->assertGreaterThan(0, $result, $facture->errorsToString());
+
+ $facture->fetch($facture->id);
+ $this->assertEquals(Facture::STATUS_VALIDATED, $facture->status);
+
+ return $facture;
+ }
+}
diff --git a/test/phpunit/PropalTest.php b/test/phpunit/PropalTest.php
index 5eac9bb1060..1b04f0e1022 100644
--- a/test/phpunit/PropalTest.php
+++ b/test/phpunit/PropalTest.php
@@ -1,7 +1,7 @@
* Copyright (C) 2023 Alexandre Janniaux
- * Copyright (C) 2024 Frédéric France
+ * Copyright (C) 2024-2026 Frédéric France
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -94,6 +94,12 @@ class PropalTest extends CommonClassTest
$this->assertLessThan($result, 0);
print __METHOD__." id=".$id." result=".$result."\n";
+
+ // Specimen lines are built from real products picked at random (see Propal::initAsSpecimen), so the
+ // exact line count is not stable (a kit/BOM product can expand into extra lines) - only check totals coherence.
+ $this->assertNotEmpty($localobject->lines);
+ $this->assertLineTotalsMatchHeader($localobject, 'after fetch');
+
return $localobject;
}
@@ -125,8 +131,8 @@ class PropalTest extends CommonClassTest
/**
* testPropalAddLine
*
- * @param Propal $localobject Proposal
- * @return Propal
+ * @param Propal $localobject Proposal
+ * @return array{0:Propal,1:int} Proposal and id of the line added
*
* @depends testPropalUpdate
* The depends says test is run only if previous is ok
@@ -140,10 +146,88 @@ class PropalTest extends CommonClassTest
$db = $this->savdb;
$localobject->fetch_thirdparty();
- $result = $localobject->addline('Added line', 10, 2, 19.6);
+ $beforelinecount = count($localobject->lines);
+ $beforetotalht = (float) $localobject->total_ht;
+
+ $lineid = $localobject->addline('PHPUnit addline test', 100, 2, 20); // 2 x 100 HT at 20% VAT = 200 HT / 40 VAT / 240 TTC
+
+ print __METHOD__." id=".$localobject->id." lineid=".$lineid."\n";
+ $this->assertGreaterThan(0, $lineid, $localobject->errorsToString());
+
+ $localobject->fetch($localobject->id);
+ $this->assertCount($beforelinecount + 1, $localobject->lines);
+ $this->assertEqualsWithDelta($beforetotalht + 200, (float) $localobject->total_ht, 0.01, 'total_ht not updated after addline');
+ $this->assertLineTotalsMatchHeader($localobject, 'after addline');
+
+ return array($localobject, $lineid);
+ }
+
+ /**
+ * testPropalUpdateLine
+ *
+ * @param array{0:Propal,1:int} $params Proposal and id of the line to update
+ * @return array{0:Propal,1:int} Proposal and id of the line updated
+ *
+ * @depends testPropalAddLine
+ * The depends says test is run only if previous is ok
+ */
+ public function testPropalUpdateLine($params)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ list($localobject, $lineid) = $params;
+ $beforelinecount = count($localobject->lines);
+ $beforetotalht = (float) $localobject->total_ht;
+
+ $result = $localobject->updateline($lineid, 100, 3, 0, 20, 0, 0, 'PHPUnit addline test'); // qty 2 -> 3, so +100 HT / +20 VAT / +120 TTC
+
+ print __METHOD__." id=".$localobject->id." lineid=".$lineid." result=".$result."\n";
+ $this->assertGreaterThan(0, $result, $localobject->errorsToString());
+
+ $localobject->fetch($localobject->id);
+ $this->assertCount($beforelinecount, $localobject->lines);
+ $this->assertEqualsWithDelta($beforetotalht + 100, (float) $localobject->total_ht, 0.01, 'total_ht not updated after updateline');
+ $this->assertLineTotalsMatchHeader($localobject, 'after updateline');
+
+ return array($localobject, $lineid);
+ }
+
+ /**
+ * testPropalDeleteLine
+ *
+ * @param array{0:Propal,1:int} $params Proposal and id of the line to delete
+ * @return Propal
+ *
+ * @depends testPropalUpdateLine
+ * The depends says test is run only if previous is ok
+ */
+ public function testPropalDeleteLine($params)
+ {
+ global $conf,$user,$langs,$db;
+ $conf = $this->savconf;
+ $user = $this->savuser;
+ $langs = $this->savlangs;
+ $db = $this->savdb;
+
+ list($localobject, $lineid) = $params;
+ $beforelinecount = count($localobject->lines);
+ $beforetotalht = (float) $localobject->total_ht;
+
+ $result = $localobject->deleteLine($lineid);
+
+ print __METHOD__." id=".$localobject->id." lineid=".$lineid." result=".$result."\n";
+ $this->assertGreaterThan(0, $result, $localobject->errorsToString());
+
+ $localobject->fetch($localobject->id);
+ // Back to the original specimen lines, with the same totals
+ $this->assertCount($beforelinecount - 1, $localobject->lines);
+ $this->assertEqualsWithDelta($beforetotalht - 300, (float) $localobject->total_ht, 0.01, 'total_ht not updated after deleteLine');
+ $this->assertLineTotalsMatchHeader($localobject, 'after deleteLine');
- $this->assertLessThan($result, 0);
- print __METHOD__." id=".$localobject->id." result=".$result."\n";
return $localobject;
}
@@ -153,7 +237,7 @@ class PropalTest extends CommonClassTest
* @param Propal $localobject Proposal
* @return Propal
*
- * @depends testPropalAddLine
+ * @depends testPropalDeleteLine
* The depends says test is run only if previous is ok
*/
public function testPropalValid($localobject)
@@ -168,6 +252,29 @@ class PropalTest extends CommonClassTest
print __METHOD__." id=".$localobject->id." result=".$result."\n";
$this->assertLessThan($result, 0);
+
+ // Test everything is still the same as a freshly built specimen with the same mutation applied
+ // (catches unwanted field changes introduced by update()/valid())
+ $this->assertMatchesFreshSpecimen(
+ $localobject,
+ function ($specimen) {
+ $specimen->note_private = 'New note private after update';
+ },
+ array(
+ 'newref', 'oldcopy', 'oldref', 'id', 'entity', 'lines', 'line', 'client', 'thirdparty', 'brouillon', 'specimen',
+ 'fk_user_author', 'user_author_id', 'user_creation_id', 'user_modification_id', 'user_validation_id',
+ 'date', 'datec', 'datev', 'datep', 'date_creation', 'date_validation', 'date_lim_reglement', 'fin_validite', 'datem', 'date_modification',
+ 'ref', 'statut', 'status', 'socid', 'billed', 'fk_incoterms', 'actiontypecode', 'actionmsg2', 'actionmsg',
+ 'mode_reglement', 'cond_reglement', 'mode_reglement_code', 'cond_reglement_code', 'availability', 'availability_code', 'demand_reason', 'demand_reason_code',
+ 'cond_reglement_doc', 'modelpdf', 'total',
+ // Totals are ignored here: specimen lines reference random real products, and a kit/BOM product can
+ // expand into extra lines with a different amount - total correctness is checked by assertLineTotalsMatchHeader() instead.
+ 'total_ht', 'total_tva', 'total_ttc', 'total_localtax1', 'total_localtax2',
+ 'multicurrency_total_ht', 'multicurrency_total_tva', 'multicurrency_total_ttc', 'fk_multicurrency', 'multicurrency_code', 'multicurrency_tx',
+ 'trackid', 'user_creat', 'user_valid', 'note',
+ )
+ );
+
return $localobject;
}
@@ -188,11 +295,6 @@ class PropalTest extends CommonClassTest
$langs = $this->savlangs;
$db = $this->savdb;
- /*$result=$localobject->setstatus(0);
- print __METHOD__." id=".$localobject->id." result=".$result."\n";
- $this->assertLessThan($result, 0);
- */
-
$localobject->info($localobject->id);
print __METHOD__." localobject->date_creation=".$localobject->date_creation."\n";
$this->assertNotEquals($localobject->date_creation, '');